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

+58
View File
@@ -0,0 +1,58 @@
# Level editor – bug review (2026-11-07, agent "edbugs")
Scope: `editor.*`, `format.stpcmerge`, `levelbuild`, `mods.levels`, `mods.boottolevel`. Method: code review of
undo/history, project IO, build, play-test; property/fuzz tests; stress run of the editor on Xvfb.
## New tests
| Test | What it does |
|---|---|
| `editor/bugs/EditFuzzTest` | 12 seeds (env `LLAMAMOD_FUZZ_SEEDS`, 80 passed) × 150 random steps (move with/without merge key, place object, add brush, duplicate, delete, set random props, undo, redo) on edit/new projects of t1l1m001/t1l2m001 → save → load: same scene (every persistent prop, list order), save bytes identical, build identical and parseable (`Level.parse`); undo all → original WAD bytes; redo → same scene/project again |
| `editor/bugs/BrushFuzzTest` | 260 brushes of every kind with random/absurd parameters (as typed into the inspector): compile < 20 s, no exception, every generated model ≤ 8000 vertices, build OK or validator error |
| `editor/bugs/CorruptProjectTest` | every level.json value replaced by values of other types, truncated zips, flipped bytes: load gives IOException or a project that builds/validates and saves |
| `editor/bugs/CrossImportEdgeTest` | cross-level import into projects that already have own textures (used by a brush and a painted original face): indices shift correctly, save/load/build identical, edits after the import |
| `editor/bugs/EditorRegressionTest`, `editor/ui/DocumentRegressionTest`, `editor/tools/playtest/PlaytestSessionTest` | one regression test per fixed bug (all fail or run out of memory on the code before the fixes) |
| `editor/ui/EditorStress` (main, not a unit test) | on an X display: opens 9 damaged/missing project files, 400 rapid random edits with undo/redo/autosave/duplicate/delete, build + switch level / close level while building, undo all → original WAD, crash with autosave → recover. `java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR -Dllamamod.editor.cd=CD com.eliasmueller.llamamod.editor.ui.EditorStress OUT [corrupt,village,spam,switch,undoall,crash,recover,nocd]` |
## Fixed bugs
| # | Severity | Bug / repro | Fix | Test |
|---|---|---|---|---|
| B1 | high | A box/platform/ramp/stairs/pillar/cylinder/tree/quad touching x=0 or z=0 (e.g. box 0..1) failed the **whole build** ("geometry outside the 96x96 grid: cell -1,-1") and made **all brushes vanish** from the live preview: wall collision is also registered in neighbour cells (margin 0.3), which do not exist outside the grid | `LevelBuilder.geometry`: skip margin-only collision cells outside the grid (real out-of-grid geometry still errors; goldens unchanged) | `brushAtGridEdgeBuilds` |
| B2 | low | The out-of-grid build error did not say which brush | `BrushCompiler.culprits`: error names the brushes near that cell ("– move brush:n3 into the grid") | `outsideGeometryNamesTheBrush` |
| B3 | high (data loss) | Recovering an autosave of a project that had a file: the document was **not dirty** (`dirty()` ignored `fresh` when a file was set) → closing/opening another level did not ask and `close()` deleted the autosave: recovered work lost | `Document.dirty()`: `history.isDirty() \|\| fresh \|\| file == null && canUndo()` | `recoveredDocumentWithFileIsDirty`, EditorStress crash/recover (dirty=true) |
| B4 | medium | After a cross-level import (`Importer` clears the history) the document looked saved after one Ctrl+Z (`History.clear()` marked the cleared state as saved; undoing the "Import … (permanent)" marker emptied the stack) → close without prompt lost the import | `History.clear()` keeps the dirty state (sentinel "saved state unreachable") | `clearKeepsDirtyState`, `CrossImportEdgeTest` |
| B5 | low | After more than 500 steps (undo limit), undoing everything showed the level as saved although the dropped steps were still applied | `History.push`: dropping the bottom step makes an empty-stack saved state unreachable | `undoLimitKeepsDirtyState` |
| B6 | high | Absurd brush values from the inspector froze or killed the editor (live preview compiles on the EDT): step ≤ 0 → endless loop in `LevelBuilder.cuts`; step 1e-9 / postsEvery 0 / steps 10⁶ / size 10⁶ / coordinates 10¹² → OutOfMemoryError; NaN, `rotation`/`tint` of the wrong length → exceptions that dropped **all** brushes from preview and build | `BrushCompiler.paramProblem` + `extentProblem` (non-numbers, step 0.125..64, ≤ 200 000 pieces, post spacing ≥ 0.1, steps 1..256, sizes 0..256, array lengths, > 64 cells outside the grid, \|y\| > 4096): brush skipped with a warning; `Validator` reports them as errors with the brush id (extent margin 8); any other runtime error of one brush only skips that brush | `absurdBrushParametersAreRejected`, `BrushFuzzTest` |
| B7 | low | Texture `#-1` (negative index) crashed the whole brush compile (IndexOutOfBounds outside the per-brush handling) | `LevelBuilder.tex`: negative index → IllegalArgumentException (brush skipped, warning) | `absurdBrushParametersAreRejected` |
| B8 | medium | A damaged project (wrong value types, unknown brushKind, broken references) threw ClassCastException/NPE/IAE instead of IOException; a huge brush value in a file ran the loader out of memory (B6) | `ProjectIO.read`/`info` wrap runtime errors: "the project file is damaged: …" | `damagedProjectGivesIoException`, `CorruptProjectTest` |
| B9 | medium (data loss) | Play-test of an unsaved level (or of a project saved outside the levels folder) wrote `<data>/levels/<slug>.llamalevel` and **silently overwrote a different project** with that name | `PlayTest.installTarget`: reuse the file only if it is the document's own file or the same project (kind, base, name); else `<stem>-2…` (used by both Play implementations) | `playTestDoesNotOverwriteOtherProjects` |
| B10 | medium | Boot-to-level play-test: the restore of the user's mod selection runs in a daemon thread of the editor; closing the (standalone) editor while the game runs left the boot mod (quitOnEscape, no menus) applied | `PlaytestRunner`: pending restore written to `<data>/editor/playtest-session.json`; deleted after the restore; on the next editor start (`onOpen` hook) a left-over session is restored (after the game has exited) | `PlaytestSessionTest` |
| B11 | medium | A build result arriving after the level was closed/replaced (Close or Open while building) was shown for the new level (problems linked to the wrong nodes by id) and – for Play – went on to install and **launch the closed level** | `PlayTest.build`: result ignored when `w.document() != d` (status "Build of … discarded") | EditorStress "switch" (status stays "Not built") |
| B13 | high (game crash) | Player starts around 34–35 / 58 in Village 1 crashed the **game** while loading (page fault reading 0xFACF0002 at 0x405527 in FUN_004054c0). Cause (decompile `FUN_00403020`): when the first floor test finds no polygon for a player-type strat (flags 0x40200) it sets the floor record flags \|= 5 ("floor on a map tile") **without** writing tile/polygon index (+0xc/+0xe); FUN_004054c0 then reads the tile→model table with the uninitialised index. The floor test only walks the tiles listed in the start's **own grid cell** – cell 35,58 has a tile (model 44) but no floor polygon under 35.5/58.5, cell 36,58 has no tile. In game (boot-to-level start override, :8): crash at 35.5/7.06/58.5, 35.5/9/58.5, 36.5/8/58.5, 5/8/5; fine at 34.2/7.35/58.2, 37.5/7.3/58.5 and the original start – exactly the positions `FloorFinder.below` (own cell, ≤ 0.6 above) finds no floor for. A death floor (30) does not crash (record reset at 0x40353e) | `StartOverride.unsafe/unsafeStart`; boot-to-level refuses such a start ("…no collision floor at or below it in grid cell 35, 58 (the game crashes while loading the level)", verified with apptest) and its version is 2 (old cached results are rebuilt); `LevelProjectMod.apply` refuses a level whose built WAD has such a start; the existing GeometryChecks error for the player start now says the game crashes (it already blocks Build/Play in the editor); "Play from camera position" already snaps to a floor of the own cell (`FloorFinder.near`) | `StartSafetyTest` (in-game positions, override refused, moved start reported + mod refused, no false alarms for 8 originals, testwelt and all templates), `OriginalStartsTest` |
| B12 | low | Autosave wrote the file in place; a crash during the write destroyed the only recovery copy | write `.tmp` + move | – (review) |
## Verified, no bug found
* Undo/redo consistency over random sequences incl. merged drags, grouped batches, deferred deletes of
waypoint chains, duplicates; undo all → byte-identical original WAD (unit fuzz + in the running editor
after 400 UI-level edits).
* Project round trip: diff storage incl. order, removed/added/changed, runs, unknown fields; save after load
byte-identical; cross-import with shifted user textures.
* Renderer sync: no stale selection after delete/undo spam (`selection.prune` per flush); no exceptions on
stderr during the stress run; build runs on a snapshot (no model access off the EDT).
* Opening damaged/empty/missing files and a missing base level shows one error dialog and keeps the current level.
## Not fixed (reasons)
* Damaged-file dialogs show raw exception text ("ClassCastException …"); cosmetic, left to the UX review.
* After a failed open, the status bar keeps "Loading <file>" (`StatusBar.progressDone` keeps the last message) – cosmetic (UX).
* `GlRenderer.dispose()` does not delete `dynVao`/`dynVbo` (only at window close) – negligible.
* `SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes until the next level (small, bounded by edits).
* Tile-paint erase strokes are one undo step per tile (`DeferredRemove` has no merge) – UX, not a defect.
* `installTarget` treats a file with the same kind/base/level name as the previous play-test copy; two different
projects with the same level name and base still share one file (rare; renaming one avoids it).
* `LevelProjectSource`: when all TestLevel chapters 2..49 are taken, the 49th is used twice – practically unreachable.
* LlamaMod's own Play (outside the editor) does not finish a pending boot-to-level restore; it shows the
mod selection as "changes pending" (activeMods contains boot-to-level), so it is visible; fixing it would need a
framework change.
+91
View File
@@ -0,0 +1,91 @@
# Launcher and mod pipeline – bug review (2026-11-07, agent "lxbugs")
Scope: `Main` (CLI), `ui`, `platform` (Installer, IsoImage, GameSource, Integrity, GameLauncher, ExePatcher,
Config, FileOps, RegFile), `save`, `mods` (ModManager, ModRegistry, options, cache, world-9 fixup, ApplyLog),
`exe`, all `mods.*`. Method: code review, decompile checks (`re/groove_decomp.c`), programmatic combination
tests against the real groove.exe, ISO images built with genisoimage, in-game smoke tests with
`tools/apptest.py` (display :8).
## New tests
| Test | What it does |
|---|---|
| `mods/ExeModCombinationsTest` | all 31 combinations of the exe mods (intro-skip, cheats, practice, freecam, boot-to-level) × {defaults, every exe option on}: apply against the real groove.exe, re-parse the result (`ExeImage.parse`), sections sorted and non-overlapping (VA and raw), SizeOfImage covers the last section, `objdump -h` accepts the all-mods exe and lists `.llamac`/`.llamad`, restore gives the HKCU base exe, re-apply is 100 % cache hits and byte-identical (journal replay) |
| `platform/IsoVariantsTest` | ISOs built with genisoimage: Joliet (like the CD), Rock Ridge only, plain ISO 9660 → install + restore → lower-case `game/groove.exe` with the HKCU patch, `checkGameExe` OK, save game visible to the save editor, long names kept (Joliet/RR); truncated ISO, text file, all-zero file, ISO without game data → clear `IOException`s; setup again from a changed source / resume after cancel |
| `mods/ModManagerConcurrencyTest` | second instance applying/clearing the cache while the first applies → clean error, first finishes; clear cache refused while the game runs; exe written by another instance is not reported as "changed by antivirus" |
| `save/SaveStoreTest` | three saves within one second keep three backups; `(null)kingN.ksv` migration (empty slot, used slot, wrong size), corrupt slot, import of a wrong-size file, read-only slot file |
| `MainCliTest` | `--install` without/with two arguments, `--apply`/`--launch`/`--unlock` before setup, `--unlock` with missing/non-numeric/out-of-range slot, world, chapter (no file written), valid world-9 chapter 49 |
| `platform/GameLauncherPathsTest` | ANSI code page / length check of game paths; Wine data drive `L:` used for CDPath, exe and registry file |
| `platform/ConfigTest` | truncated config.json with an existing installation → still installed, copy kept as `config.json.broken`; wrong value types |
| `mods/levels/LevelProjectModTest.versionFollowsTheFileWhenItIsSavedAgain` | cache key changes when the project file is saved again |
## Fixed bugs
| # | Severity | Bug / repro | Fix | Test |
|---|---|---|---|---|
| L1 | high | **Intro skip + Boot to level could not be combined** (both default to "skip logos"): apply failed with "intro-skip (skip logo movies and images) (0x0041D56E, 3 bytes) overlaps "skip logo movies" of boot-to-level". Hit every editor play-test (it forces boot-to-level logos=true) for users with Intro skip enabled – 16 of 62 exe-mod combinations failed | `IntroSkipMod`: skip its logo patch when the identical patch is already there (boot-to-level runs first, order 90 < 100) | `ExeModCombinationsTest` |
| L2 | medium | ISO without Joliet (Rock Ridge only, e.g. `genisoimage -R`, or plain ISO 9660): names were the upper-case 8.3 names (`GROOVE.EXE`, `ARGOLOGO.DAT` instead of `argologompeg.dat`); on Linux `baseExe` did not find `original/hd/groove.exe` → no exe patch, exe mods failed ("NoSuchFile"), `game/groove.exe` missing → Play impossible; save games as `KING0.KSV` next to the editor's `king0.ksv` | `IsoImage`: Rock Ridge `NM` names when there is no Joliet; `Installer.setupGameDir`: all-upper-case HD names are copied lower-case; `ModManager.baseExe` finds the original exe case-insensitively | `IsoVariantsTest` |
| L3 | medium | **No lock between instances** (second window, CLI `--apply`, standalone editor play-test): both rebuild `cd-active.new`, `cache/<mod>/<key>.tmp` and `game/groove.exe` → deleted half-built folders, mixed results. The first window's config kept the old exe hash → after the other instance applied, Play showed "groove.exe was removed or changed – probably by antivirus software" | `ModManager.apply`/`clearCache` hold an exclusive `FileLock` on `<data>/.apply.lock` ("Another LlamaMod window or command is applying mods right now…"); `checkGameExe` accepts (and adopts) the exe hash stored on disk by another instance | `ModManagerConcurrencyTest` |
| L4 | medium (data loss) | Save backups are folders named by the second; a second save of the same slot within that second **overwrote the backup of the original state** (`REPLACE_EXISTING`) | `SaveStore.backupDir`: `<ts>-2`, `-3`, … when the file already exists | `SaveStoreTest` |
| L5 | medium | Level projects: `LevelProjectMod.version()` was the hash of the file **when the mod list was built**. Saving the project again (editor) while the launcher was open → Apply used the old cache key → the **old build** came back from the cache. The launcher also never rebuilt its mod list (`AppState.registry` was final): projects created in the editor appeared only after a restart | `LevelProjectMod.version()` re-hashes when size/mtime changed; `AppState.reloadMods()` on every `MainFrame.refreshAll()` (editor "mods changed", wizard, theme) | `versionFollowsTheFileWhenItIsSavedAgain` |
| L6 | medium | Setup again (Settings → Run setup again, `--install`) **skipped every file whose size matched** the old copy ("resume"), so a repaired or different ISO did not replace damaged same-size files; files not in the new source stayed in `original/` (and in cd-active); the mod cache (keyed by names + sizes) served outputs of the old data | `Installer`: resume only for an interrupted install of the same source (marker `original/.install-source`), otherwise copy everything, delete stale files, clear the cache | `IsoVariantsTest.setupAgainCopiesChangedDataAndDropsTheCache` |
| L7 | medium | Game paths: the game builds file names in a **260-byte buffer** (0x583748, FUN_00415690, no length check; the pointer at 0x58384C after it is passed to `free`) and uses ANSI file functions. A data folder path longer than ~230 characters crashes the game; characters outside the ANSI code page (Windows user "Łukasz" on a German system, Cyrillic names, …) become '?' → CD files not found. Linux: same through Wine (home folder with such characters) | Linux: the data folder is mapped as drive **L:** in our prefix (`wine/dosdevices/l:`), CDPath = `L:\cd-active\`, exe `L:\game\groove.exe` (short, ASCII); Windows (and the Linux fallback to Z:): `GameLauncher.checkPaths` refuses to start with a clear message (path > 200 chars or not encodable in `native.encoding`) | `GameLauncherPathsTest`; in game: all runs below use `L:` |
| L8 | low | CLI argument errors: `--install` without path → `NoSuchElementException` stack trace; `--unlock` without/with bad numbers → stack traces; `--unlock 4 1 1` wrote `king3.ksv`, `--unlock 0 …` wrote `king-1.ksv`, world 10 / chapter 7 of world 1 were written into the save (game crashes on the map); `--apply`/`--launch` before setup → "NoSuchFileException: …/original/cd"; `--install` while the game runs replaced game/groove.exe and then failed | `Main`: `UsageException` (exit 2, "Error: …"), range checks (slot 1–3, world 1–9, chapter 1–n), "not set up yet", running-game checks for `--install`/`--unlock`; `--editor-build/-import/-new` refused in a build without editor (`Edition`) | `MainCliTest` |
| L9 | low | "Clear cache" (Settings) while the game runs: on Linux with the symlink fallback the running game lost its CD files, on Windows the delete fails half-way on locked hard links, and the following re-apply is refused | `ModManager.clearCache` refuses while the game runs (and takes the apply lock) | `clearCacheIsRefusedWhileTheGameRuns` |
| L10 | low | Damaged `config.json` (cut off by a crash or power loss): defaults → "not installed" → setup wizard again (600 MB copy), and the next save overwrote the damaged file | `Config.load`: copy to `config.json.broken`; if `original/` and `game/groove.exe` exist, the installation stays usable (mod selection/options are lost) | `ConfigTest` |
| L11 | low | Apply wrote `game/groove.exe` **before** swapping `cd-active`; when the swap failed (Windows: cd-active open in Explorer or being scanned) the new exe (e.g. boot-to-level into world 9) ran against the old CD folder; `gameExeSha256` was set before the write, so a failed write produced a false "changed by antivirus" alarm | `ModManager.apply`: cd-active first, then the exe; the hash is recorded after a successful write | existing ModManager tests |
| L12 | low | Windows: replacing a read-only file (save game copied from a CD/old install) failed with AccessDenied | `FileOps.writeAtomic` clears the read-only flag of the target first | `SaveStoreTest` (no-op on Linux) |
## In-game smoke tests (apptest, display :8, session `lxbugs-mx`, all with CDPath `L:\cd-active\`)
Each run: apply via the CLI → (slot 3 unlocked at the level, menu → slot → map → level via `tolevel`) →
key sequence with screenshots → crash check (process, game.log, winedbg, window) → audio client present.
Exe mods were additionally checked in every combination by `ExeModCombinationsTest` (apply, re-parse,
objdump, replay, restore).
| Run | Mods (options) | Level | Result |
|---|---|---|---|
| orig | none | Village 1 | OK, walking |
| all-v | fog, randomizer, mirror, intro-skip, cheats, practice, freecam, debug-wireframe, testworld (defaults) | Village 1 | OK; wireframe + mirror visible, F7 free camera flies, F5/F8 |
| all-t | same | TestLevel 1 (Test World) | OK |
| introskip | intro-skip (story on) | Village 1 | OK |
| cheats | cheats (diagnostics on) | Village 1 | first run: harness timeout on the map (Enter not taken); re-runs `cheats-def` (defaults) and `cheats-diag2` (diagnostics) OK → harness flake |
| practice | practice (defaults, checkpoints) | Village 1 | OK, F5 / F8 |
| freecam | freecam | Village 1 | OK, F7 camera and F9 noclip |
| mrf-v / mrf-r | mirror, randomizer, fog | Village 1 / River 1 | OK / OK (raft level mirrored and randomized) |
| twdbg-t / twdbg-v | testworld, debug-wireframe | TestLevel 1 / Village 1 | OK / OK |
| boot | boot-to-level (world 1, chapter 2) | boots into Village 2 | OK |
| boot-exe | boot-to-level (world 3 ch 1) + intro-skip (story) + cheats + practice + freecam | boots into River 1 | OK (this combination failed to apply before L1); F7 works |
After rebasing onto the merged main tree (simp-all + launcher UX changes): orig, all-v, boot-exe, mrf-r
re-run with the final jar: all OK (CDPath `L:\cd-active\`).
![matrix](img/launcher-matrix.jpg)
Screens: all mods in Village (wireframe, mirrored) and the free camera; River with mirror+randomizer+fog;
Test World with all mods; boot-to-level + all exe mods (River loading card); practice restore.
## Not fixed / recommendations
* ~~**Cache key and app updates**~~ – fixed in 0.2.0: the chain root contains the app version, the
build id (hash of the compiled classes) and `ExePatcher.REVISION`; entries of other builds (and of
0.1.0) are deleted after an apply.
* **Cross-mod hotkeys**: practice (save/restore) and freecam (camera/noclip) offer the same key list
and do not know each other – the same key in both triggers both. Needs a check across options
(e.g. ModsPanel or a `Mod.hotkeys()` hook).
* **Launcher edition** (`edition.properties` `editor=false`): only hides the editor button and the
`--editor*` commands; built by `./gradlew packageAll` (`fatJarLauncher`, `packageLinuxLauncher`,
`packageWindowsLauncher`) since 0.2.0. The editor packages **must stay in
the jar**: `mods.boottolevel.StartOverride` (editor.io/model, tools.playtest.FloorFinder) and
`mods.levels.*` (editor.io) need them at apply time; `ModRegistry` would only skip level projects
(LinkageError is caught), but boot-to-level with a player start override would fail with
NoClassDefFoundError.
* **OneDrive / redirected Documents** (decided: no warning) as data folder: hard links inside a synced folder and
on-demand files are fragile; the wizard could warn when the chosen folder is under `%OneDrive%`.
* **Interrupted apply** (app killed between the two renames of `buildActive`): `cd-active` is
missing until the next apply; the GUI then restores the original (dropping the mods), CLI
`--launch` says "CD folder missing". `cd-active.new` is complete at that point and could be moved
in instead.
* A `config.json` saved by a stale window after another instance applied still overwrites
activeMods/gameExeSha256 (L3 only covers the check); a merge-on-save would be needed.
* Old Wine versions are not detected (no version check); missing Wine gives a clear message.
+221
View File
@@ -0,0 +1,221 @@
# Debug-Ansicht: Kollision & unsichtbare Objekte im Spiel (`tools/debugviz.py`)
Stand: 2026-11-06. Zeigt in **allen 34 Level-WADs** die Kollisionsgeometrie (auch unsichtbare
Wände/Böden, Todesebenen) und unsichtbare Logik-Objekte (Kameras, Trigger, Ausgänge,
Checkpoints, Kraft-/Box-Zonen, Umgebungszonen …) direkt im Spiel – als farbiges Drahtgitter,
unsichtbare Flächen zusätzlich halbtransparent gefüllt. Spielmechanik bleibt unverändert.
## Benutzung
```
lama-mod debug an # Debug-WADs zuschalten (baut das Overlay neu, aktiviert damit auch andere Mods)
lama-mod debug aus # wieder Original-Level (testlevel-Mod bleibt, wie sie war)
lama-mod debug # Status
```
Wirkt beim nächsten Laden eines Levels (am besten Spiel neu starten). Nicht umschalten,
während ein Level lädt. Mod-Ordner: `~/.wine-lama/mods/debug/wads/*.wad` (gleiche Namen wie
auf der CD, z. B. `t1l1m001.wad`); die Datei `~/.wine-lama/mods/debug/.aus` = abgeschaltet.
Reihenfolge im Overlay: alphabetisch, spätere gewinnen → `testlevel` (T1L9*) bleibt immer,
Debug-WADs ersetzen nur die Original-Level. `lama-mod an|aus|status` funktionieren wie
vorher (`an` überspringt Mod-Ordner mit `.aus`). Sicherung des alten Skripts:
`deb-build/lama-mod.bak-vor-debug`.
Neu bauen (z. B. nach Änderungen an Farben):
```
python3 tools/debugviz.py --all ~/.wine-lama/mods/debug/wads --report /tmp/dv.json
python3 tools/debugviz.py IN.wad OUT.wad [--no-fill] [--no-markers] [--no-coll] [--opaque]
[--width 0.007] [--offset 0.012]
```
Laufzeit ≈ 10–40 s je Level.
## Farblegende
Alle Farben sind RGB (0–255). **Linien** = Drahtgitter (deckend), **Füllung** = halbtransparent
(Alpha 96/255, Blendmodus 1 der Engine).
### Kollisionsflächen (Rahmen um jedes Kollisionspolygon der Welt-Kacheln)
Kantenfarbe nach Lage, solange das Material 0 (normal) ist:
| Farbe | RGB | Bedeutung |
|---|---|---|
| **Grün** (Linien) | 40,255,40 | begehbarer Boden (Normale steil nach oben, ny ≥ 0,7) |
| **Gelbgrün** (Linien) | 200,255,0 | Schräge (0,2 ≤ ny < 0,7) |
| **Blau** (Linien) | 0,150,255 | Wand (−0,2 < ny < 0,2) |
| **Orange** (Linien) | 255,130,0 | Decke / Überhang (ny ≤ −0,2) |
| **Rot, halbtransparent gefüllt** | 255,0,0 | **unsichtbare Kollision**: keine Render-Fläche innerhalb ±0,5 Einheiten entlang der Normale (= unsichtbare Wand/Boden/Decke). Die Kantenfarbe zeigt weiterhin Lage bzw. Material. |
Kollision mit Sondermaterial (Byte 1 des Kollisionspolygons ≠ 0): Kanten in Materialfarbe,
bei unsichtbaren Polygonen auch die Füllung in Materialfarbe (statt Rot):
| Material | Farbe | RGB | Bedeutung |
|---|---|---|---|
| 30 (0x1e) | **Magenta**, nur Linien (keine Füllung) | 255,0,255 | **kein Boden / Fall- und Todesebene**: Spieler fällt hindurch (FUN_004036d0 setzt Boden = keiner). Meist Modell 0 bei y ≈ −2,75 in jeder Rasterzelle unter den Inseln → magentafarbenes Gitter = Absturz-/Todeszone. |
| 16 | **Violett**, immer gefüllt | 190,60,255 | Ausgangs-Boden: LevelDoor löst nur aus, wenn der Spieler auf Material 16 in der Türzelle steht |
| 17 (0x11) | **Gelb** | 255,255,0 | selektive Kollision: wirkt nur für Objekte mit Laufzeit-Flag 0x8000000 (Strat+0xe8) |
| 18 (0x12) | **Hellorange/Pfirsich** | 255,200,120 | selektive Kollision: wirkt NICHT für Objekte mit Flag 0x8000000 |
| 1 | Türkis | 0,200,200 | Sondermaterial, Bedeutung unbekannt (Strat-Skripte werten es aus) |
| 2 | Weiß | 255,255,255 | dto. |
| 3 | Rosa | 255,120,170 | dto. (häufig in Welt 5 Berg) |
| 5 | Mint | 120,255,200 | dto. (Fluss, Achterbahn) |
| 6 | Lavendel | 160,120,255 | dto. |
| 7 | Hellorange | 255,170,80 | dto. |
| 8 | Dunkelgrün | 0,130,60 | dto. |
| 9 | Hellblau | 150,200,255 | dto. |
| 10 | Oliv | 150,150,0 | dto. |
| 11 | Braun | 150,90,40 | dto. |
| 13 | Lachs | 255,130,110 | dto. |
| 15 | Hellgelb | 255,255,150 | dto. |
| 20 | Petrol | 0,120,140 | dto. |
| 26 | Dunkelviolett | 110,40,160 | dto. |
| 27 | Silber | 190,190,190 | dto. (Verfolgungsjagd t1l2m003) |
| 28 | Pink | 255,0,140 | dto. |
| 33 | Dunkelrot | 150,0,0 | dto. |
| 255 | Fast schwarz | 20,20,20 | dto. |
| sonstige | Hellgrau | 230,230,230 | (kommt in den Originalen nicht vor) |
### Objekt-Marker (unsichtbare Strats, MAP-Strat-Einträge)
Markerwürfel (10 cm) auf einem dünnen Pfahl 0,35 über dem Objekt-Ursprung; kurzer waagrechter
Strich = Blickrichtung (Kameras, Checkpoints).
| Farbe | RGB | Kategorie | Typen |
|---|---|---|---|
| **Weiß**, Pfeil flach am Boden | 255,255,255 | Spielerstart (Richtung) | Llama, KuscoAndPachaTogetherOnATree (Fluss), RollerCoaster, LlamaChase |
| **Violett** + Drahtwürfel 1×1×1 um die Rasterzelle | 190,60,255 | Ausgang (Auslösezelle) | LevelDoor, VillageEndDoor, EndGameDoor, TeleportDoor |
| **Grüntürkis** | 0,255,140 | Checkpoint | CheckPointPost, CheckPointBox |
| **Cyan** | 0,255,255 | Kamera | Camera* (CameraSide, CameraLift, CameraForceInsideBox …), CutSceneCamera*, RiverWaterfallCamera |
| **Gelb** | 255,255,0 | Trigger | CutSceneTrigger*, *Trigger*, Say* (Sprüche), ChangeToBunny/Turtle, RiverStartSign |
| **Orange** | 255,90,0 | Kraft-/Sonderzonen | Force*, FallOffNoHurt, ObjectDontDropInBox, RiverCurrentBox, RiverRebound, WaterReboundPoints*, SuckingWPs, ObjectSuckPoints, RiverTrackChange, TrackSwitch, RiverWhirlpool, BackgroundRotationChanger |
| **Gold** | 255,200,0 | Geheimnis-Zone | Secret |
| **Grau** | 170,170,170 | sonstige unsichtbare Steuer-Objekte | CheatTeleport, Wompy*, Spawner, Helper, Coin-Linien/-Kreise, Rain, DrippingWater, WaterfallMist, MusicBox, FlockOfBirds, Bats, Scorpions*, AppearingPlatforms* … |
| **Dunkelgrau**, kleiner Punkt (6 cm) | 90,90,90 | alle übrigen Objekte (Ursprung) | Münzen, Gegner, Türen … – damit wirklich nichts fehlt |
| **Rosa** Drahtwürfel je Rasterzelle | 255,100,180 | Umgebungs-/Musik-/Nebelzone | MAP-Block 4 (90-Byte-Einträge, Zelle x/y/z) |
**Zonen-Boxen:** hat ein Marker-Objekt genau 2 Wegpunkte und liegt das Objekt im Rechteck
dazwischen, ist das ein Box-Bereich (Secret, CameraSide/Lift, ObjectDontDropInBox,
FallOffNoHurt, RiverCurrentBox …): Drahtquader in der Kategoriefarbe von min(y) bis max(y)+1,2
(die Höhe ist geschätzt – der Code prüft vermutlich nur x/z), bei Flächen ≤ 64 Einheiten² mit
halbtransparentem Boden (Alpha 70).
**Wegpunkt-Ketten** anderer Marker-Objekte: kleine Würfel (5 cm) in abgedunkelter
Kategoriefarbe (60 %), bei Kraftzonen mit Verbindungslinien (z. B. WaterReboundPoints-Pfade,
CutSceneTrigger-Kamerafahrt nur als Punkte).
## Wie es gebaut ist (Entscheidungen)
* **Kollisions-Overlay an die TRAK-Modelle angehängt** (nicht als eigene Kacheln): pro
Kollisionspolygon ein Rahmen aus 2·n Vertices (äußere Ecken + um die Linienbreite nach innen
versetzte Ecken, aus den Kantenebenen berechnet), 2·n Dreiecke; Füllung nutzt die inneren
Ecken mit. Alles 0,012 entlang der Flächennormale versetzt (kein Z-Fighting). Dadurch stimmen
Kachel-Transformation, Zonen-/Sichtbarkeit und Kachel-Wechsel (Alt-Modelle, WFPC 0x10000)
automatisch. Kollision wird aus dem Spielraum zurückgerechnet (geomlib.coll_decode/
coll_polygon, z gespiegelt).
* Face-Flags der Overlay-Dreiecke: 0x800 | 0x400 (doppelseitig) | 0x4000 (Vertexfarben
ignorieren) | 0x8 bei jedem Texturwechsel und spätestens alle 250 Faces (Render-Batch).
* **Farben** = angehängte Flachfarb-Texturen (TEXT-Tabelle, Seite −1): deckend Flags 0x105,
halbtransparent **0x1105** (Bits 12–14 = 1 → Tabelle 0x573eb8: Blendmodus 1 =
SRCALPHA/INVSRCALPHA, Alpha aus dem 4. Byte (v1) des Eintrags; im Spiel geprüft).
Halbtransparente Dreiecke laufen über den Sortierpuffer (FUN_0041ed90, 512 KB/Frame ≈ 5000
Dreiecke; ist er voll, fallen sie einfach weg – kein Absturz). Deshalb gibt es Füllungen nur
für unsichtbare Kollision, Material 16 und kleine Zonenböden.
* **Vertexfarben-Block** in MAP: je Kachel (und je Farbsatz/Frame, auch für Alt-Modelle) um
nV(Overlay) Einträge 0x00808080 verlängert (Byte 3 = 0, ändert die Frame-Anzahl nicht).
* **Unsichtbar-Erkennung**: Render-Dreiecke aller Kacheln in Weltkoordinaten, nach Zellen
gebuckelt; für Mittelpunkt + 4 Punkte (70 % Richtung Ecken) jedes Kollisionspolygons wird ein
Strahl entlang der Normale (±0,5) gegen Dreiecke mit ähnlicher Ausrichtung (|cos| > 0,4)
getestet; < 60 % Treffer = unsichtbar. Je Modell Mehrheitsentscheid über bis zu 6 Instanzen.
Strat-Modelle (STPC: Türen, Plattformen, Figuren) zählen NICHT als Render-Fläche – Kollision
hinter beweglichen Objekten kann daher rot erscheinen.
(Erste Version mit 0,06/0,15 Abstand markierte ganze Hauswände und Felswände rot, weil die
Kollisionshülle 0,2–0,4 vor der sichtbaren Wand liegt – zu viel Rauschen.)
* **Marker** als neue Kacheln ohne Kollision, je Rasterzelle ein Modell (Linien in ≤ 1-Stücke
zerteilt und in die Zelle ihres Mittelpunkts gelegt) → bekommen automatisch die Zone der
Zelle. Neue Kacheln werden hinten angehängt (Indizes der Original-Kacheln bleiben).
* **Spielmechanik unverändert**: keine Kollisionspolygone hinzugefügt/geändert, Strats,
Wegpunkte, Parameterpool, Zonen und Rest von MAP byte-gleich (wird nach dem Schreiben
geprüft: Strats/Knoten/Rest/Original-Kacheln identisch).
* Größe: WADs wachsen um 0,1–4 MB (max. 8,9 MB, t1l6m003). Level-Speicher (Arena 16 MB,
FUN_0041ef00) hält TRAK+STPC+MAP – reicht für alle (Ladetest).
## Grenzen / bekannt
* Kollision der **Objekt-Modelle** (STPC: bewegliche Plattformen, Türen, Kisten …) wird nicht
gezeigt – die bewegen sich; nur ihr Ursprung als dunkelgrauer Punkt.
* Zonen-Höhen der Box-Bereiche sind geschätzt; ob die Engine y prüft, ist offen.
* Bedeutung der meisten Sondermaterialien unbekannt (nur Farbe + Anzahl, s. Tabelle unten);
Material 30 = kein Boden, 16 = Ausgang, 17/18 = selektiv sicher aus dem Code.
* Umgebungszonen (MAP-Block 4): Zelle sicher, Bedeutung (Nebel/Musik/Kamera) nur mittel sicher;
Vorgabe-Eintrag (Position 1,1,1) wird nicht gezeichnet.
* Liegt die Kamera dicht an einem Marker/einer Linie, zerschneidet der Near-Plane-Clip der
Engine die Dreiecke manchmal zu großen Fetzen (nur kurz sichtbar).
* Leistung: unter Wine/llvmpipe (Xvfb) ohne spürbaren Einbruch; auf echter GPU unkritisch
(≤ 2000 zusätzliche Vertices je Kachelmodell, Ø ~1–2× so viele Dreiecke wie das Original).
* Testlevel T1L9 (Testwelt) hat keine Debug-Version (testlevel-Mod hat Vorrang).
## Statistik je Level (Stand Build 2026-11-06)
| Level | Welt | WAD alt→neu (KB) | Koll.-Polygone | davon unsichtbar | Sondermaterialien (Anzahl Polygone) | Marker |
|---|---|---|---|---|---|---|
| t1l1m001 | Dorf | 6158→8235 | 3474 | 215 | 2:138, 6:4, 8:35, 9:13, 16:8, 17:5, 18:84, 28:19, 30:1 | ausgang 1, checkpoint 6, geheimnis 2, kamera 6, kraft 6, objekt 95, sonst 4, start 1, trigger 1, umgebung 12 |
| t1l1m002 | Dorf | 5471→7573 | 3909 | 130 | 6:4, 8:14, 16:9, 18:65, 28:23, 30:1 | ausgang 1, checkpoint 5, geheimnis 1, kamera 8, kraft 5, objekt 89, sonst 9, start 1, trigger 1, umgebung 15 |
| t1l1m003 | Dorf | 5891→8348 | 3893 | 79 | 2:15, 6:4, 8:8, 16:5, 17:3, 18:458, 20:6, 28:23, 30:1 | ausgang 1, kamera 3, kraft 6, objekt 45, sonst 3, start 1, trigger 2, umgebung 4 |
| t1l1m004 | Dorf | 5866→7006 | 2169 | 60 | 2:15, 8:8, 16:16, 17:3, 18:90, 30:1 | ausgang 2, geheimnis 1, kamera 2, kraft 1, objekt 31, sonst 2, start 1, trigger 1, umgebung 5 |
| t1l2m001 | Dschungelnacht | 5094→7491 | 3933 | 460 | 1:26, 2:111, 6:156, 9:34, 16:22, 17:24, 18:284, 20:80, 28:28, 30:138, 33:1 | ausgang 1, checkpoint 2, geheimnis 1, kamera 3, kraft 6, objekt 39, sonst 8, start 1, trigger 1, umgebung 8 |
| t1l2m002 | Dschungelnacht | 5329→7561 | 3715 | 389 | 1:216, 2:57, 6:154, 9:24, 16:34, 17:23, 18:319, 28:28, 30:50 | ausgang 1, checkpoint 4, geheimnis 1, kamera 3, kraft 7, objekt 105, sonst 2, start 1, trigger 1, umgebung 18 |
| t1l2m003 | Dschungelnacht (Verfolgung) | 2837→5142 | 4844 | 630 | 5:3, 9:6, 10:6, 11:37, 16:26, 27:206, 30:46 | kamera 2, kraft 1, objekt 33, sonst 4, start 1, trigger 1 |
| t1l3m001 | Fluss | 4099→8220 | 8371 | 1000 | 5:26, 16:8, 18:23 | ausgang 2, geheimnis 1, kamera 13, kraft 4, objekt 140, sonst 13, start 1, trigger 5 |
| t1l3m002 | Fluss | 3799→8249 | 8824 | 1097 | 5:40, 16:5, 18:23, 30:1 | ausgang 2, geheimnis 1, kamera 19, kraft 44, objekt 115, sonst 39, start 1, trigger 6 |
| t1l3m003 | Fluss | 4497→8537 | 8387 | 868 | 5:56, 16:5 | ausgang 4, geheimnis 1, kamera 15, kraft 7, objekt 52, sonst 15, start 1, trigger 3 |
| t1l3m004 | Fluss | 2674→3754 | 2201 | 228 | 5:18, 16:5 | ausgang 2, kamera 5, kraft 4, objekt 31, sonst 7, start 1, trigger 2 |
| t1l4m001 | Dschungeltag | 5469→7380 | 3214 | 230 | 1:148, 6:105, 10:33, 16:18, 17:33, 18:158, 20:25, 28:24, 30:10 | ausgang 1, checkpoint 9, geheimnis 1, kamera 5, kraft 12, objekt 120, sonst 3, start 1, trigger 1, umgebung 10 |
| t1l4m002 | Dschungeltag | 5807→8045 | 3820 | 157 | 1:186, 6:123, 10:80, 16:33, 17:55, 18:309, 20:6, 28:98, 30:22 | ausgang 1, checkpoint 7, geheimnis 2, kamera 1, kraft 14, objekt 135, sonst 3, start 1, trigger 1, umgebung 14 |
| t1l4m003 | Dschungeltag | 6320→8886 | 4805 | 135 | 1:174, 2:52, 6:10, 10:33, 16:36, 17:110, 18:890, 20:186, 28:27, 30:33, 255:8 | ausgang 1, checkpoint 3, geheimnis 2, kamera 6, kraft 4, objekt 99, sonst 3, start 1, trigger 1, umgebung 16 |
| t1l5m001 | Berg | 6090→8613 | 4419 | 188 | 3:356, 8:96, 16:24, 17:35, 18:539, 20:66, 28:6, 30:87 | ausgang 1, checkpoint 3, geheimnis 2, kamera 5, kraft 2, objekt 83, sonst 7, start 1, trigger 1, umgebung 15 |
| t1l5m002 | Berg | 5949→8558 | 5242 | 220 | 2:20, 3:396, 8:23, 9:15, 16:19, 17:33, 18:342, 20:57, 30:120 | ausgang 1, checkpoint 3, geheimnis 4, kamera 10, kraft 3, objekt 67, sonst 6, start 1, trigger 1, umgebung 7 |
| t1l5m003 | Berg | 4989→6796 | 3399 | 98 | 3:232, 8:223, 16:17, 18:20, 20:6, 30:78 | ausgang 1, checkpoint 5, geheimnis 1, kamera 4, kraft 1, objekt 71, sonst 6, start 1, trigger 1, umgebung 2 |
| t1l6m001 | Stadt | 6210→8227 | 2857 | 169 | 6:29, 16:18, 17:140, 18:110, 30:38 | ausgang 1, checkpoint 4, geheimnis 1, kamera 5, kraft 3, objekt 83, sonst 3, start 1, trigger 5, umgebung 20 |
| t1l6m002 | Stadt | 5155→7060 | 1771 | 106 | 6:56, 16:9, 17:20, 18:39, 30:17 | ausgang 1, checkpoint 10, geheimnis 1, kamera 7, kraft 9, objekt 127, sonst 16, start 1, trigger 5, umgebung 46 |
| t1l6m003 | Stadt | 6693→8924 | 2462 | 148 | 1:39, 16:13, 17:80, 18:154, 28:3, 30:10 | ausgang 1, checkpoint 11, geheimnis 6, kamera 11, kraft 6, objekt 107, sonst 15, start 1, trigger 3, umgebung 59 |
| t1l7m001 | Katakomben (Achterbahn) | 3054→6705 | 4043 | 20 | 5:54, 11:18, 13:18, 16:9, 30:18 | kamera 1, objekt 2, sonst 36, start 1, trigger 1, umgebung 14 |
| t1l7m002 | Katakomben | 5280→7722 | 1795 | 111 | 2:1, 11:3, 16:44, 30:93 | ausgang 3, checkpoint 12, geheimnis 5, kamera 6, kraft 4, objekt 186, sonst 12, start 1, trigger 1, umgebung 23 |
| t1l7m003 | Katakomben (Achterbahn) | 2496→5140 | 1999 | 11 | 5:54, 11:90, 13:36, 15:18, 16:9, 30:9 | kamera 1, objekt 11, sonst 41, start 1, trigger 1 |
| t1l7m004 | Katakomben | 5299→6978 | 1892 | 61 | 2:7, 16:32, 30:70 | ausgang 1, checkpoint 3, geheimnis 3, kamera 4, kraft 7, objekt 93, sonst 10, start 1, trigger 2, umgebung 15 |
| t1l7m005 | Katakomben (Achterbahn) | 2390→4782 | 1885 | 9 | 5:54, 11:36, 13:36, 16:9, 30:9 | kamera 1, objekt 1, sonst 51, start 1, trigger 1, umgebung 9 |
| t1l8m001 | Labor | 6632→8824 | 3396 | 185 | 1:40, 8:9, 16:16, 17:8, 18:181, 30:56 | ausgang 1, checkpoint 2, geheimnis 2, kamera 8, kraft 2, objekt 99, sonst 3, start 1, trigger 2, umgebung 19 |
| t1l8m002 | Labor | 5427→7112 | 2204 | 76 | 16:1, 17:44, 18:496, 30:42 | ausgang 1, geheimnis 1, kamera 5, kraft 2, objekt 74, sonst 2, start 1, trigger 3, umgebung 1 |
| t1l8m003 | Labor | 4656→5546 | 1398 | 34 | 16:9, 17:10, 18:196, 30:21 | ausgang 1, checkpoint 2, geheimnis 1, kamera 2, kraft 2, objekt 36, sonst 2, start 1, trigger 3, umgebung 6 |
| t1l8m004 | Labor | 5817→8407 | 2993 | 124 | 9:10, 16:66, 17:1, 30:57 | ausgang 1, checkpoint 7, geheimnis 3, kamera 12, kraft 6, objekt 164, sonst 3, start 1, trigger 7, umgebung 46 |
| t1l8m005 | Labor | 5375→8660 | 4041 | 179 | 7:10, 16:78, 17:1, 30:82 | checkpoint 7, geheimnis 1, kamera 14, kraft 5, objekt 113, sonst 15, start 1, trigger 2, umgebung 47 |
| t1l8m006 | Labor | 5423→5948 | 738 | 44 | 2:13, 8:24, 16:3, 17:5, 18:27, 26:4 | ausgang 1, objekt 15, start 1, trigger 17 |
| t6l6m001 | Sonder (Optionen/Karte) | 1331→1482 | 333 | 0 | | kamera 1, objekt 3 |
| t6l6m002 | Sonder (Optionen/Karte) | 3605→3640 | 64 | 0 | | kamera 1, objekt 2 |
(Kollisionspolygone je TRAK-Modell gezählt, nicht je Kachel-Instanz.)
## Tests (2026-11-06)
**Offline (alle 33 Debug-WADs, `$JOB/tmp/dv/offcheck.py`):** TRAK parst und baut byte-gleich
zurück; ≤ 8000 Vertices je Modell (max. 2012, t1l4m003); Render-Batches ≤ 256 Faces; alle
Texturindizes < Tabellengröße; Vertexfarben-Block je Kachel = nV(Modell) (alle Frames);
MAP mit `mapchunk` vollständig geparst (Rest 0 Byte) und byte-gleich neu gebaut; Strats,
Wegpunkte, Parameterpool, Umgebungszonen, Zonentabelle/-raster, Alt-Block und Original-Kacheln
unverändert; Original-Modelle (Vertices, Faces, **Kollision**) byte-genau unverändert; neue
Marker-Modelle ohne Kollision; alle anderen Chunks (STPC, Sound, LGPC …) identisch;
Kacheln im Raster. TRAK+STPC+MAP+LGPC ≤ 7,6 MB (Level-Arena 16 MB).
**Im Spiel geladen** (lamatest, über Slot Welt 9 als T1L9M001.wad, ~40–90 s gespielt/gedreht,
kein Absturz, kein Hänger, keine Fehler im Wine-Log): t1l1m001 (mehrfach, Sichtprüfung),
t1l1m003, t1l1m004, t1l2m001, t1l2m002, t1l3m001–004 (Fluss läuft auch im Slot 9 normal:
Boot fährt), t1l4m001–003, t1l5m001–003, t1l6m001–003 = 19 Level.
Getestet wurde der Build direkt vor dem endgültigen: identische Geometrie, nur die Farbwerte
der Sondermaterialien wurden danach auf feste Farben je Material umgestellt (+8–16 Byte
Texturtabelle). Die Vorschaubilder stammen ebenfalls aus diesem Build – Sondermaterial-Farben
darin (z. B. Türkis-Füllung im Berg-Bild) entsprechen noch nicht ganz der Legende oben.
**Nur offline geprüft:** t1l1m002 und t1l2m003 (Ladeversuch scheiterte vorher an der
Menü-Erkennung von lamatest, nicht am Level), t1l7m001–005 (Katakomben/Achterbahn),
t1l8m001–006 (Labor), t6l6m001/002. Ein Test über den echten Spielstand-Slot wurde nicht gemacht.
lamatest-Änderung nebenbei: Spielstand-Auswahl mit mehr Versuchen und 0,3 s langen
Tastendrücken (unter CPU-Last gingen kurze Drücke verloren).
Bilder: `notes/img/debug/00_montage.jpg` (6 Welten), `01_…06_*.jpg` (1280×720).
+158
View File
@@ -0,0 +1,158 @@
# 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<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`), `"#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.
+153
View File
@@ -0,0 +1,153 @@
# 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](img/editor/xobj_all_worlds_tab.jpg)
## 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](img/editor/xobj_drag_drop_inspector.jpg)
![import world](img/editor/xobj_import_world_preview.jpg)
## 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).
+48
View File
@@ -0,0 +1,48 @@
# Llama Level Editor – design brief
Goal (from the project owner): a fully fledged, graphical **3D level editor** for "Ein Königreich für ein Lama"
(Emperor's New Groove PC) – "everything the Argonaut developers probably had available. Support everything that
is possible. Nicely organised, easy to use, well designed." Results are shared as LlamaMod mods.
## Scope
- Create new levels from scratch (primitive building blocks like levels/testwelt, tile painting, objects).
- Open and modify ORIGINAL levels (move/add/delete objects, tiles, waypoints, zones, lights …).
- Import own 3D models (OBJ, glTF) as level geometry incl. textures and collision.
- Import/edit textures.
- Everything the format allows: tiles (TRAK models on the MAP grid, height, rotation, alt models), strats/objects
with parameters and flags, waypoint chains / paths / camera nodes, environment+teleport/respawn zones (the 90-byte
records), fog per zone, lights (LGHT), visibility regions (region grid + masks), vertex colours / lighting bake,
collision polygons and materials (e.g. 16 = exit floor, 30 = death/no floor), level name, music, kill plane.
- Play-test button: build + launch the game straight into the level.
## Architecture
- Same Gradle project (`~/projects/llamamod`), packages under `com.eliasmueller.llamamod.editor`:
- `editor.model` – scene model (pure data, no UI): Scene, nodes for tiles/brushes/meshes, objects (strats),
waypoints, zones, lights, regions, materials, assets. Undoable commands (`editor.model.cmd`).
- `editor.io` – project file format, original-level import (WAD → Scene), build (Scene → WAD) on top of
`format` and `levelbuild`.
- `editor.render` – OpenGL viewport (LWJGL 3 via lwjgl3-awt `AWTGLCanvas` inside Swing), textured +
vertex-coloured rendering, wireframe/collision overlays, picking, gizmos.
- `editor.ui` – Swing shell (FlatLaf, same look as LlamaMod): menu/toolbar, viewport, outliner (tree),
inspector (properties), asset browser (models, object types, textures), status bar.
- Launch: from LlamaMod ("Level Editor" button/tab) and standalone `--editor [file]`.
- Natives: LWJGL natives for linux-x64 and windows-x64 must be packaged (both packages).
## Project format (`.llamalevel`)
- A zip: `level.json` (scene) + `assets/` (imported meshes, PNG textures) + `thumbnail.png`.
- Contains NO original game data. Original-level edits store only a reference (`base: "t1l1m001"`) plus edits
(diff); new levels reference a base level for its object code (STPC) and textures (default `t1l1m001`).
- Deterministic build: same project + same CD → byte-identical WAD.
- Versioned schema (`formatVersion`), forward-compatible loading (unknown fields kept).
## LlamaMod integration
- Every `.llamalevel` in `<data>/levels/` (plus "Import level…") appears as a mod in category `levels`.
- New levels occupy TestLevel (world 9) chapters 1..49; the user picks a free slot (default: next free).
Edited original levels replace that level's WAD.
- Conflicts (two mods writing the same WAD) are reported clearly.
## Conventions
- English everywhere, only necessary comments, no commits, `./gradlew spotlessApply build` green.
- Reuse `format`, `levelbuild`, `mods.debug` (collision/marker knowledge), `mods.mirror` (env-zone/light layouts).
- Test in game with `tools/apptest.py` (private silent PulseAudio, own Xvfb display; user watches :5).
- UI screenshots on Xvfb (llvmpipe OpenGL works there) – look at them, iterate on design.
+90
View File
@@ -0,0 +1,90 @@
# `.llamalevel` – level project format (version 1)
A zip file (deterministic: fixed entry times, `level.json` first, then `thumbnail.png`, then the
assets sorted by path). Code: `editor.io.ProjectIO`, `SceneDiff`, `NodeCodec`.
| Entry | Content |
|---|---|
| `level.json` | header + the changes against the reference scene (below) |
| `thumbnail.png` | optional preview (also used for the TestLevel loading picture) |
| `assets/<name>/…` | imported meshes with their MTL/textures/buffers (`MeshAsset.file` points here) |
| `assets/pages/<id>.png` | pixels of added texture pages (lossless: ABGR1555 ↔ RGBA with alpha 0/128/255) |
**No original game data.** A project is stored as the difference to a *reference scene* that is
rebuilt from the user's own CD when the project is loaded:
* `kind: "edit"` – reference = the original level `base` imported unchanged (`Templates.original`).
* `kind: "new"` – reference = `base` with all tiles, tile models, objects and waypoints removed and
visibility regions off (`Templates.newLevel`). The base contributes object code (STPC),
textures, zones (fog/music), lights and sound. Default base `t1l1m001` (village).
A newly created project (`Project.create`) additionally removes, as ordinary edits stored in the
diff, everything bound to the base's positions (`Templates.clearBaseLeftovers`, since 0.2.0):
all zones except the start zone (flag 8), all non-directional lights, waypoints, and sets every
region mask to "all". The reference stays unchanged, so projects saved before keep their zones
and lights. Textures stay (STPC object models index them).
Building the same project with the same CD gives a byte-identical WAD (`WadBuilder` is
deterministic; brushes use the stored `seed`).
## level.json
```json
{
"formatVersion": 1,
"app": "LlamaMod level editor",
"kind": "new", // "edit" | "new"
"base": "t1l1m001", // base level (CD wads/<base>.wad)
"baseSha256": "9cff…", // of the base WAD; a mismatch gives a load warning
"name": "Testwelt", // copies of settings for listing without the CD
"targetChapter": 2, // TestLevel chapter for new levels (0 = none)
"nextId": 264, // counter for new node ids ("kind:nN")
"settings": { "<prop>": value, … }, // changed LevelSettings props
"regions": { "<prop>": value, … }, // changed Regions props
"nodes": {
"<kind prefix>": { // chunk, page, tex, anim, model, tile, wp, strat, zone, light, mesh, brush
"removed": ["tile:12", …], // reference nodes deleted
"changed": { "strat:11": { "x": 120716, … } }, // changed props of reference nodes
"added": [ { "id": "strat:n1", "<prop>": value, … } ], // all persistent props (+ "brushKind")
"order": ["tile:0", …] // only if the list order differs from reference order + added
}
},
"…": … // unknown top-level keys are kept when saving
}
```
Node kinds, ids and properties: `notes/editor-api.md` (every persistent, settable `Prop`).
Values by `PropType`: numbers (INT/FIXED/ANGLE/FLAGS/COLOR as integers, DOUBLE), booleans,
strings, REF = node id (or `null`). Arrays: up to 64 elements as JSON lists, longer ones as
`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`. A *changed*
array of unchanged length is stored as runs of the changed elements only:
`{"runs": [[start, values], …]}` (values encoded as above) – e.g. editing a few vertices or
pixels of an original page stores just those values.
Loading: reference scene → removals → create all added nodes (all kinds, so references across
kinds resolve) → set values of added/changed nodes and settings → apply `order`. Problems
(reference to a missing node, a removed id that does not exist) become load warnings, not errors.
Unknown properties of nodes are kept and written back (forward compatibility); a file with a
higher `formatVersion` loads with a warning.
## Ids are positions in the original WAD
Reference node ids are `kind:index` of the original WAD, so a project stays valid as long as the
base level is the same file (checked through `baseSha256`). Deleting reference nodes keeps the ids
of the others (they are not renumbered in the project), but it does change the *indices* written
to the WAD: strat scripts that address tiles by index (stMapreplace / stMapadd) may then hit
other tiles – prefer moving or hiding original tiles over deleting them.
## Mods
`<data>/levels/*.llamalevel` are listed as mods (`mods.levels.LevelProjectSource`, see
`notes/mod-levels.md`). CLI:
```
llamamod --editor-import <level.wad|t1l1m001> <project> # edit project (diff to the CD original)
llamamod --editor-new <project> [base] [--name <name>] # empty new level
llamamod --editor-build <project> <out.wad> # build (prints statistics, warnings)
llamamod --import-level <project> # copy into <data>/levels
(all take --cd <CD folder>; default: the installed original CD of the data folder)
```
Example: `examples/testwelt.llamalevel` (the Test World as 125 brushes + 55 objects, 7 KB).
+199
View File
@@ -0,0 +1,199 @@
# LlamaMod Level Editor – user guide
A 3D level editor for *The Emperor's New Groove* (PC, 2000; German: *Ein Königreich für ein
Lama*). It is part of **LlamaMod**, the mod manager: levels you make are installed and played
as LlamaMod mods. Everything is built from **your own copy of the game** – a level project
contains only your changes, so it can be shared freely.
![The editor with the village](img/editor/ui_select_move.jpg)
* [Getting started](#getting-started)
* [Moving around](#moving-around)
* [Creating a new level](#creating-a-new-level)
* [Editing an original level](#editing-an-original-level)
* [Objects, waypoints and zones](#objects-waypoints-and-zones)
* [Importing meshes](#importing-3d-models)
* [Textures](#textures)
* [Play-testing](#play-testing)
* [Sharing levels](#sharing-levels)
* [Shortcuts](#shortcuts) · [Troubleshooting](#troubleshooting)
## Getting started
1. Install LlamaMod and let its wizard set up the game from your CD or ISO (Windows or Linux).
2. Click **Level Editor** in LlamaMod's header (or start `LlamaMod --editor`).
3. **Help → Getting started** (Shift+F1) shows a five-step tour inside the editor;
**Help → Shortcut sheet** gives a printable page with every key.
The window: menu and toolbar at the top, the **Outliner** (everything in the level as a tree)
on the left, the **Inspector** (properties of the selection) on the right, the **viewport** in
the middle and **Assets / Textures / Problems** at the bottom. Panels can be moved to another
side through their tab's context menu and reopened under **View → Panels**.
## Moving around
| Input | Action |
|---|---|
| right mouse button held + **W A S D**, **Q / E** | fly (Shift = faster, wheel = fly speed) |
| middle mouse (or Alt + left) | orbit around the selection or the point under the cursor |
| Shift + middle mouse | pan |
| mouse wheel | zoom towards the cursor |
| **F** / **Home** | frame the selection / the whole level |
| numpad 7 / 1 / 3 / 5 | top / front / side / perspective |
Click selects, Shift+click adds, Ctrl+click toggles, dragging in empty space draws a box
selection. **W** move, **E** rotate, **R** scale (brushes), **Q** select; **G** toggles grid
snapping, Ctrl while dragging inverts it. Every change can be undone (**Ctrl+Z**); **Edit →
History** lists all steps.
## Creating a new level
**File → New level** asks for a name, the **base level** and a template:
* **Empty** – nothing but the base level's sky, fog and music,
* **Flat floor** – a 16×14 grass floor with a start and an exit,
* **Island** – a grass island with trees in the sea.
The *base level* (default: Village chapter 1) supplies the object types, textures, sounds,
sky, the start zone (fog, music) and the directional lights. Its teleport zones, point lights,
paths and visibility regions are not taken over. New levels live in the game's hidden **TestLevel** world (world 9); each project gets one
of its 49 chapters (shown in **Level → Level settings**).
![New level dialog](img/editor/ui_dialog_new.jpg)
Build the level from **brushes** (Assets → Brushes: floors, boxes, platforms, ramps, stairs,
fences, trees, sea, kill planes …) and the base level's **tile models**. Drag them from the
Assets panel into the viewport. Brush parameters (size, textures, collision material) are in
the Inspector.
Every level needs
* a **Llama** – the player start (Assets → Objects → Player & start),
* an **exit**: a **LevelDoor** on floor with collision material **16** (the templates have one),
* floor with collision under the start. **Level → Check for problems** lists what is missing.
## Editing an original level
**File → Open original level** shows all chapters with their loading pictures. You can move,
add and delete objects, tiles, waypoints, zones and lights. The project stores only the
differences to the original, and building it with the same CD gives the same file every time.
Tip: prefer moving or hiding original tiles over deleting them – some object scripts address
tiles by their number, and deleting shifts those numbers.
## Objects, waypoints and zones
* **Objects** (Assets → Objects) are the base level's object types: coins, collectibles,
checkpoints, enemies, villagers, moving platforms … Their parameters and flags are in the
Inspector. Only types that exist in the base level can be placed.
* **Waypoint chains** belong to objects (paths of enemies, camera paths). Select an object to
see its chain; duplicating an object copies its chain.
* **Zones** set fog, colours, music and respawn/teleport targets; **lights** colour the level;
**visibility regions** decide which parts are drawn from where (View → Layers shows them).
## Importing 3D models
**File → Import mesh** reads OBJ (with MTL and textures) and glTF/GLB. The model becomes a
*mesh brush*: it is cut into the engine's 1×1-cell tiles at build time, gets collision (choose
the material in the Inspector) and lit vertex colours. The engine has no free texture
coordinates – each triangle shows one half of a texture rectangle – so simple, rectangular UV
layouts look best.
## Textures
The **Textures** panel (bottom dock, Ctrl+Shift+T, or **Window** for a large separate window)
is the texture manager:
![Texture manager](img/editor/tex_manager.jpg)
* **Pages** (left): the level's 256×256 texture pages with their blend class.
* **Atlas** (middle): the selected page with every texture rectangle – white = used, red =
unused, cyan = animated, orange = selected. Drag the selected rectangle to move it, drag its
handles to resize it, mouse wheel zooms.
* **Table**: number, name, page, rectangle, blend class, how many faces/models use it, animation.
Filter by *used / unused / animated / imported*; double-click a texture to paint with it.
* **Texture** tab: name, position and size, blend class, usage, *Duplicate*, *Select users*.
* **Animation** tab: flip-books (a sequence of frame textures, frame delay, back and forth) and
vertical scrolling (rivers, lava), with a live preview.
**Import…** adds PNG/JPG/BMP images:
![Import dialog](img/editor/tex_import.jpg)
| Option | |
|---|---|
| Size | longest side in pixels; the aspect ratio is kept |
| Blend class | **Opaque**, **Cut-out** (fully transparent pixels: leaves, fences), **Semi-transparent** (50 % blending: glass, water), **Additive glow** (light, fire), **Darken** |
| Transparent below alpha | pixels less opaque than this become invisible |
| Keep half-transparent pixels | for semi-transparent/additive textures |
| Transparent colour | magenta or the top-left pixel for images without alpha |
| Dither | smoother gradients in the game's 15-bit colour |
| Pack into free space | uses unused areas of existing pages of the same blend class before adding pages |
The preview shows the image as the game will show it. **Replace pixels…** puts a new image into
an existing texture's rectangle (all faces using it change), **Export PNG…** saves one; the
page list's context menu exports or replaces whole pages.
**Paint texture** tool (**M**): click a face of a tile to give it the current texture (the
*scope* box paints the face, all faces of that model with the same texture, or the whole
model). Clicking a brush sets its top/side/bottom texture by the side you click.
**Shift+click** or the **eyedropper** (**I**) picks the texture of a face. For the selected face:
*Rotate* turns the texture, *Mirror* flips it, *Other half* shows another half of the texture
rectangle. Tiles often share one model – tick **Unique** to give the clicked tile its own copy
first, otherwise every tile with that model changes.
![Painting a texture](img/editor/tex_paint.jpg)
## Play-testing
**Play** (**F5**) builds the level, installs it as a mod and starts the game **directly in the
level** – no intro, no menus, no map. **Play from camera position** (**Shift+F5**) puts the llama
on the ground in the middle of the view, facing the way the camera looks (the editor checks that
there is walkable collision there – trees and roofs often have none).
In the game, **Escape** quits straight back to the editor. When the game has ended, LlamaMod
switches your normal mod selection back on (the level itself stays installed as a mod).
![The game started straight in the level](img/editor/play_boot.jpg)
Options in the **Level** menu: *Play boots straight into the level* (off = the game starts at the
main menu with the map cursor on the level) and *Escape quits the play-test*. The save slot used
for play-testing (default 3, backed up first) is set in **Edit → Preferences**.
## Sharing levels
Projects are `.llamalevel` files (saved by default in LlamaMod's `levels` folder). They contain
your changes, imported meshes and textures and a thumbnail – **no game data** – and are usually
only a few kilobytes. To share a level:
1. **File → Save** and send the `.llamalevel` file (forum, Discord, e-mail).
2. The receiver uses **Mods → Levels → Import level…** in LlamaMod (or copies the file into the
`levels` folder), ticks the level and clicks **Play**. LlamaMod builds it from their own CD.
3. New levels appear on the world map as **TestLevel – chapter n**; edited original levels replace
that chapter while the mod is on.
If two level mods use the same TestLevel chapter, change the chapter in the mod's options.
## Shortcuts
| Keys | Action |
|---|---|
| Ctrl+N / Ctrl+O / Ctrl+S | new / open / save |
| Ctrl+Z / Ctrl+Y | undo / redo |
| Ctrl+D, Del | duplicate, delete |
| Q W E R | select, move, rotate, scale |
| T, I | paint texture, eyedropper |
| G, X, C, H / Alt+H, L | snapping, local space, collision view, hide / show all, lock |
| Ctrl+B, F5, Shift+F5 | build, play, play from camera position |
| Ctrl+Shift+T | texture manager |
| F1, Shift+F1 | shortcuts, getting started |
## Troubleshooting
| Problem | Solution |
|---|---|
| "OpenGL 3.3 is not available" | update the graphics driver; in a VM enable 3D acceleration |
| Play: "The game is still running" | quit the game first (level files cannot be replaced while it runs) |
| The llama falls through the floor (play from here) | point the view at walkable ground; check collision with **C** |
| Windows Defender warns about groove.exe | play-tests change groove.exe (boot to level); see LlamaMod's antivirus notice |
| A level does not load / the game crashes | **Level → Check for problems**; keep tiles inside the grid; one player start |
+95
View File
@@ -0,0 +1,95 @@
# Level editor – user manual and in-app help (`editor.tools.help`)
The complete user manual (English, forum audience) lives as Markdown in **`docs/manual/`** (one
file per chapter + `img/`). It is bundled into the jar (`build.gradle` → `processResources` copies
`docs/manual` to `com/eliasmueller/llamamod/editor/tools/help/manual/`) and shown by
**Help → Manual (F1)**.
![manual window](img/editor/manual_chapter.jpg)
## Chapters
`index.md` defines the order and the groups of the contents tree (H2 = group, list links = chapters):
Getting started (01 introduction & concepts, 02 installing, 03 editor window, 04 navigation,
05 selection), Tutorials (first level in 10 minutes, parkour, edit an original level, Blender model),
Building levels (06 geometry, 07 meshes & library, 08 textures, 09 objects incl. cross-world import,
10 waypoints, 11 cameras & triggers, 12 environment, 13 visibility, 14 level settings), Testing and
sharing (15 build/problems/play-test, 16 original levels, 17 sharing), Reference (18 mods, 19
keyboard/mouse/menu, 20 troubleshooting & FAQ, 21 glossary).
Markdown subset (own converter `Markdown`, no dependency): ATX headings (one H1 per file),
paragraphs, bold/italic/code, links, images on their own line, nested lists, GFM tables,
block quotes (callouts), fenced code, rules; no raw HTML. Anchors = GitHub slugs, so the files also
read well on GitHub/forums.
**Generated blocks** `<!-- generated:NAME -->…<!-- /generated:NAME -->` are filled from the live
program (`ManualContent`): `shortcuts` (tool keys, keyed commands, key conflicts), `menus` (every
menu command with key and tooltip), `panels` (dock panels) – from `EditorExtensions`, i.e. built-ins
and all plugins – and `mods` (built-in LlamaMod mods with options, from `ModRegistry`). The window
regenerates them at run time; `ManualTest` rewrites the copies in `docs/manual` when they are stale
(`-Dllamamod.manual.noUpdate=true` turns that off). Key names are locale-independent ("Ctrl+Shift+D").
## Manual window (`ManualWindow`)
Own frame (stays open next to the editor, works while a modal dialog is open): toolbar back /
forward (Alt+Left/Right), contents (Alt+Home), previous / next chapter, search field (Ctrl+F),
Export menu; left: **Contents** tree (groups → chapters → H2 sections, follows navigation) and
**Search** (all words must match; ranked by title hits; excerpt; the words are highlighted on the
page); right: the page (Swing HTML with CSS from the current FlatLaf colours, images scaled to the
page width and loaded synchronously so anchors land correctly, previous/next links at the end).
Internal links navigate, external ones open the browser. Re-rendered on theme change (light/dark).
`-Dllamamod.manual.dir=docs/manual` reads the Markdown from a folder (reloaded on every open) for
writing the manual.
**Export** (`ManualExport`): HTML folder (index.html + one page per chapter with a sidebar,
manual.css with light/dark `prefers-color-scheme`, used images), **one self-contained HTML file**
(all chapters, images as data URIs, links rewritten to in-page anchors – for forum attachments),
and *Print this chapter* (system print dialog → PDF). Also **Help → Export manual as HTML…**.
## Context help (`ContextHelp`, `context-help.txt`)
F1 opens the chapter for what the **mouse pointer** is over (else the focus owner), in the main
window and in every dialog/window owned by it. Topics, collected walking up the component tree:
client property `HelpTopics.PROP` (inspector sections `inspector.x:<title>` / `inspector.g:<group>`,
tool options bar `tool.<id>`), menu items / toolbar buttons (`action.<id>` via the action's
"spec"), tool buttons (`tool.<id>` by tooltip), inner tabs (`tab.<title>`), dock tabs
(`panel.<id>`), the selected node kind inside Inspector/Outliner (`node.<KIND>`), the viewport
(`tool.<active tool>`), the window title (`dialog.<title>`), `window.editor`. Mapping file
`src/main/resources/.../tools/help/context-help.txt` (`topic = file.md#anchor`, `prefix*` keys are
wildcards; exact matches of any candidate beat wildcards). The Help → Manual menu item opens the
manual where it was (F1 key = context).
"?" buttons: every Inspector section header, the tool options bar (next to the tool name), and the
right end of every dock's tab row (FlatLaf trailing component, help for the visible panel).
Shell changes (minimal, backwards compatible): new `editor/ui/HelpTopics` (handler hook + "?"
button helper; without a handler nothing is shown), `InspectorPanel.section` (topic property +
"?"), `ToolOptionsBar` (topic property + "?"), `build.gradle` (processResources). `help.shortcuts`
moved from F1 to Ctrl+F1 (re-registered by the help plugin).
## Tests / verification
* `ManualTest`: every .md is in the contents; exactly one H1 per file; **all internal links and
anchors resolve**, all images exist, no placeholder images; every `context-help.txt` target
resolves; generated references contain every tool / every built-in mod; search; HTML folder and
single-file export; Markdown subset unit checks. Unmapped tools/panels/sections are reported on
stdout (not a failure, so new plugins do not break the build).
* `ManualTour` (test classes, X display): screenshots for the manual and an end-to-end context-help
run with java.awt.Robot (real mouse/F1 events) on Xvfb :17. Results: `?` on the Inspector's
"Object parameters" → 09-objects#object-parameters; F1 over the Textures panel →
08-textures#texture-manager; F1 over the viewport with the Brush tool → 06-geometry#brush-tool-b;
F1 over the "Waypoint chain" section → 10-waypoints#waypoint-chain-section; F1 in the modal
Preferences dialog → 03-editor-window#preferences.
Images: `img/editor/manual_*.jpg`.
## Maintaining the manual
* Edit `docs/manual/*.md`; run `./gradlew test --tests '*ManualTest*'` (link check + regenerates the
generated blocks). New chapter: add it to `index.md`.
* New tool/panel/section/dialog: add a line to `context-help.txt` (the test prints what is unmapped).
* Screenshots: `ManualTour` steps `geo,nav,village,manual,context,dark`, `RetakeTour` steps
`library,insert` (open a `GeoLibDemo` level) and `texpaint` (village), and the other tours
(`EditorTour`, `ObjectsTour`, `EnvTour`); keep images ≤ 1000 px wide (jar size).
* `SmokeTour` (same command line, GeoLibDemo level): real-input checks of the death plane height
field (typed value without Enter + OK), Ctrl-inverted snapping of the place tool and the
Inspector's "Exclude from build"; prints PASS/FAIL.
+85
View File
@@ -0,0 +1,85 @@
# Object parameter schemas (strat bytecode analysis)
Registry in code: `editor.tools.objects.Schemas` (named, typed, documented parameters with ranges,
choices and waypoint meaning), statistics of all original instances:
`src/main/resources/.../editor/tools/objects/type-stats.tsv` (`TypeStats`). Types without a schema
fall back to raw values plus "values used in the original levels". Shown in the Inspector section
"Object parameters" and checked by the Problems panel (`ObjectChecks`).
## How parameters reach the script
* MAP strat record: count n + index into the parameter pool. The level spawner FUN_0054cfc0 passes
a fake stack, FUN_0054bfc0 pops the n values into the new strat's **variables 0..n−1**
(`strat+0xF4` = variable array). So *parameter i = variable i* of the strat code.
* Values are 20.12 fixed point by convention (0x1000 = 1.0); booleans are 0 / 0x1000, counts and
world/chapter numbers `n << 12`.
## Strat VM (FUN_0054d180) – enough to disassemble
Word-coded: `op = w & 0xFFFF`. `op < 0x45`: table 0x5796BC, operand = signed high 16 bits, one
word. `op ≥ 0x45`: table 0x578FF4 + op·4 (up to 0x1F6), inline operand words read via the pc.
Inline lengths (found by decompiling all 337 handlers with headless Ghidra and following helper
calls): 1 word for 0x45 0xA5 0xA6 0xB1 0xB2 0xD0 0xD3 0xD7 0xDB 0x115 0x11C 0x14B 0x14D 0x162
0x167 0x175; 2 for 0xD4 (name: `d4 ea rel`); 5 for 0x94 and 0xE0 (spawn: FUN_00553170 reads
nParams + 4 type fields); 6 for 0x95 (spawn at offset); short 0x0C (event slot) has 2; 0x02/0x03
(debug print: count then items, 0x45/0xEA items have one extra word) are variable. A linear sweep
restarted at every `d4` name site disassembles all 31 level WADs without desync and hits every MAP
strat entry (the entry is the name site). Strings live between strats.
| short op | meaning | short op | meaning |
|---|---|---|---|
| 04 n / 41 n | push / pop variable n | 06 n / 43 n | push / pop global n (0x6D9E8C) |
| 07 n / 40 n | push / pop own property n (FUN_00550E60 / FUN_005509F0) | 1C n / 37 n | player property |
| 44 v | push 16-bit constant | 14 / 15 / 16 | jz / jnz / jmp (pc+4+v·4) |
| 12 / 13 / 2B | jz / jnz / jmp + yield | 17 | switch (table at pc+4+v·4) |
Long ops used for the decoding: 45 push word, 49 add, 4A sub, 4B mul (fixed), 4C div, 4E eq,
4F lt, 53 gt, BC ne, BD/BE ge/le, AF not, B6 and-logical, B7/B8 bit or/and, 50/51 shl/shr,
AD/AE sin/cos (table 0x574318), AC floor, BF rand, 16E lerp, 54 set model, 8B radius (+0xFE),
83 +0xD2 (collision radius), A5/A6 set/clear strat flags (0x8000 floor collision, 0x40000 walls),
C2 update floor, 73/75/177 waypoint first/next/advance (+0x118 first, +0x120 current; next wraps
to the first node), FE move to waypoint, 178 "inside the box of the current and next waypoint
(x/z)", 192 level change (pops tribe, world, chapter), B4/8E die, E0/95 spawn.
Properties: 16 floor height, 17 floor material, 34–36 x/y/z, 37–39 rx/ry/rz, 3 waypoint
first/last bits, 4 waypoint field +26, 6–8 waypoint x/y/z, 9/10 waypoint rx/ry, 41 distance to
the player, 61–64 tribe/world/chapter/map, 202 distance to player (squared compare).
Camera globals: 47 horizontal distance, 48 height above the target, 29/44 yaw, 36–38 position
and 40–42 look-at of cut-scene cameras, 66 camera flag word, 95 progress bits.
Patterns: `push var i; push 30; mul` = seconds → frames; `push var; push var; mul; pop var` =
squared distance (radius).
## Decoded types
Confidence: **high** = code read and behaviour seen in game; **medium** = code read; **low** =
values only.
| Type | Parameters | Waypoints |
|---|---|---|
| Coin (high) | p0 snap to floor (drops onto floor, random spin), p1 falls (floor collision flag 0x8000), p2 no shadow | – |
| Coin5 (high) | p0 snap, p1 falls | – |
| Health | p0 snap, p1 no shadow, p2 falls | – |
| HealthFull, HealthGenerator, Wompy | p0 snap, p1 falls | – |
| Grapes, HealthBanana, ChargeBooster | p0 snap | – |
| RubyIdol | p0 falls, p1 snap | – |
| Secret (high) | p0 box mode (player inside the box of the 2 nodes, op 0x178) | box (2 nodes) |
| CoinLine / CoinCircle (low) | count (counted down), spacing / radius | – |
| Llama | p0 spawns a helper, p1 unused, p2 camera mode (global 107) | first node = camera start |
| CheckPointPost (high) | p0 collision radius (+0xD2, 0.2) | 1 node = respawn position + yaw (98 of 102) |
| LevelDoor (high) | tribe, world, chapter of the next level (7,7,7 = world finished); fires on material-16 floor of a tile in its cell (any height; often only in the swap model a RubyIdolDoor/other door object switches to when it opens) | – |
| CameraSide | p0 height (g48), p1 distance (g47), p2 yaw mode (1 node yaw, 2 node yaw or ±180°), p3 only below node y, p4 flag 0x10000, p10 several boxes, p11 height offset, p12/p13 alt. height/distance | box pairs, node 0 yaw = camera yaw |
| CameraLift | p0 yaw source (1 object, 2 player), p1 box test on helper strat, p3 smooth (g97), p4 height limit, p5 flag | box |
| CameraSetDistance | g82, g68, g83, height g48, g73 (each with a flag bit in g66) | – |
| CameraFocusPoint | radius², min height², max angle, height limit, 3 flags, blend factor | – |
| CutSceneCameraDirectional | p0 event bit (1..31), p1 mode, p2 wait, p3 disabled, p5 end mode | camera nodes: position, yaw/pitch = view (globals 36–42) |
| CutSceneTrigger | p0 character (floor snap, g72 counter), p2 radius², p3 event bit (skip if set in g95), p5 height check, p6 bits (1 face player, 2 not skippable) | camera path |
| ForcePlayerMovement | p0 active, p1 radius², p2 game state, p3 lock, p4 align yaw | target points |
| ObjectDontDropInBox, FallOffNoHurt | box mode (+ game state) | box |
| WaterReboundPoints | active | rebound points |
| RubyIdolDoor | p1 idols needed, p4 sign offset | – |
| CityGuard | p0 view range², p1 chase range², p2 event bit, p3 wall collision | patrol (wraps) |
| JumpingBeetleBlue, Spider, Bunny | times in seconds (×30), ranges² | patrol path |
The rest (~150 placed types) shows raw values with the original statistics. Next candidates:
PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog.
Tooling used (not in the repo): Ghidra script decompiling the handler table, a Python sweep
disassembler with an op-name table, "parameter use" context dumps per type.
+108
View File
@@ -0,0 +1,108 @@
# Level editor – play-test straight into the level (`editor.tools.playtest`, `mods.boottolevel`)
Before: Play installed the level and started the game at its main menu; the player had to
choose *Spiel starten* → slot 3 → Enter on the map. Now **Play (F5)** lands in the level a few
seconds after the game window opens, and **Escape** quits back to the editor.
![game booted into TestLevel chapter 2](img/editor/play_boot.jpg)
## Editor side (`PlaytestEditorPlugin`, `PlaytestRunner`, `FloorFinder`)
| Action | Key | |
|---|---|---|
| Play (replaces the built-in `level.play`) | F5 | build → install level mod → apply user mods + boot mod → save slot → start |
| Play from camera position | Shift+F5 (toolbar) | the llama starts on the ground in the middle of the view, facing the camera direction |
| Play boots straight into the level | Level menu (check) | off = old behaviour (main menu, map cursor) |
| Escape quits the play-test | Level menu (check) | boot mod option `quitOnEscape` |
* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to
`<data>/levels/<stem>.llamalevel` → the user's **active** mods (others on the same WAD and Test
World on chapter 1 turned off) + the level mod become `enabledMods`; `boot-to-level` is applied
**in addition** (not stored in `enabledMods`) with world/chapter of the project, slot
(Preferences, default 3), `logos=true`, `quitOnEscape`, `start` → save slot unlocked up to the
level → `GameLauncher.launch`.
* **Restore**: a daemon thread polls `GameLauncher.isRunning()` every 2 s; when the game has
ended it re-applies the user's selection without the boot mod (cached outputs, < 1 s) and
restores the boot mod's own options → LlamaMod's normal Play starts at the menu again
(`RestoreCheck`: active `[level-…, boot-to-level]` → `[level-…]`, exe without mod patches).
If the editor is closed before the game, LlamaMod shows the selection as "apply needed".
* **Play from camera position**: ground point = depth-buffer hit at the viewport centre
(export space → game x, y, −z), heading = camera yaw (`atan2(f.x, −f.z)`, 0 = +z).
`FloorFinder` then looks for **collision**: highest floor polygon (|n.y| ≥ 0.5) at or below
the hit + 0.6 among scene tiles and brush-generated tiles, searching up to 0.75 cells around
(rings of 8); material 30 (death floor) is rejected. Needed because render geometry often has
no collision (village: the hill edge east of the start renders, but only a death floor at
y 2.25 lies below – the llama fell to a game over).
* `-Dllamamod.editor.noLaunch=true` skips the start (tests start the game with apptest).
## Exe mod `boot-to-level` (GAMEPLAY, `modifiesExecutable`)
Options: `world` 1–9, `chapter` 1–49, `slot` 1–3, `logos` (skip logo movies/title, same patch
as intro-skip – only applied if the bytes are still original, so both mods can be on),
`quitOnEscape`, `start` ("x, y, z[, yaw]" in cells; empty = the level's own start). Order 90
(after level mods). Registered through `META-INF/services/com.eliasmueller.llamamod.mods.Mod`
(ServiceLoader; `ModRegistry` was not edited).
### Engine facts used
| | |
|---|---|
| 0x5846E4 | game state: 1 ?, 2 main menu, 3 in game, 4 attract demo, 5 options?, 6 save slots, 12 world map, 0x11 quit |
| 0x578F28 | frame handler per state (returns 1 = state changed → main loop runs the enter handler) |
| 0x578F7C | enter handler per state; state 3 = FUN_0054ae20: intro movie, loading screen `T1%d%d.COR`, FUN_00419510(tribe, world, chapter, 0) loads the level |
| 0x579050 | per-state update/render (3 = FUN_0054b590, the game frame) |
| 0x584EB4/B8/BC/C0 | tribe, world, chapter, map of the level to load; 0x585588/0x584C24/0x584C20/0x58557C = map cursor |
| 0x584700 | next state for fade transitions; 0x584638 = sub state (7 = enter level) |
| FUN_0041c0e0 | reads `king0..2.ksv` into 0x585274 + slot·0x100 |
| FUN_0041cc70 | loads slot 0x584C28 into the game variables (new game in it if empty) |
| 0x546CC0, 0x546990([0x6D94B4]), 0x5464D0 | music/sound shutdown done before every state switch (copied from the demo switch at 0x549E5D) |
| 0x5FCF08 bit 0x8 | "pause" pressed this frame (Escape) |
| main loop (WinMain) | exits through its normal cleanup (FUN_00545030, DestroyWindow, CoUninitialize) when the state is 0x11 – the main menu's *Beenden* |
### Patches
* **Boot**: `jmp` at 0x549C50 (state-2 frame handler, `A1 2C 37 58 00` = `mov eax,[0x58372c]`).
The cave runs once (flag in an `allocateData` block): pushad, read slots, `[0x584C28]=slot−1`,
load slot, set tribe 1/world/chapter/map 0 (+ map cursor), `[0x5855B0]=0`, sub state 7, music
shutdown, state 3, popad, `mov eax,1; ret` → the main loop enters state 3 exactly like the
map's Enter would. Later main-menu frames (after quitting to the menu) run the original code.
* **Escape quits** (`quitOnEscape`): `jmp` at 0x549F40 (state-3 frame handler, same 5 bytes):
`test byte [0x5FCF08],8; jz original; mov [0x5846E4],0x11; mov eax,1; ret`.
* **Logos**: 0x41D56E `68 28 25` → `53 EB 62` (see mod-introskip.md).
* **Start override** (data, not exe): the level's WAD (as written by earlier mods) is imported,
the Llama moved/turned and rebuilt (`StartOverride`; an unchanged rebuild is byte-identical).
Its waypoint chain moves and turns along only if it is an editor/levelbuild camera chain
(all nodes but the first on the llama); original levels use the llama's chain as a
level-wide list, which stays.
## Verified in game (apptest, display :9, Wine 6, 2026-11-06)
| Test | Result |
|---|---|
| `boot-to-level` world 1 chapter 2, original data | Village 2 fly-by 5 s after the window, gameplay at ~20 s, walking works |
| Editor Play on `examples/testwelt.llamalevel` (TestLevel 2) | game shows the Testwelt plaza with HUD 4 s after the window opens (`play_boot.jpg`) |
| Editor Play from camera position, Village 1, start 2.3 cells east | llama stands at the new spot (`play_from_here.jpg`); the original village fly-by is skipped (its cut-scene camera is tied to the original start) |
| Same with the view on the hill edge (no collision) | before FloorFinder: llama fell to a game over; now the editor finds the nearest floor or refuses |
| Escape in gameplay (village and TestLevel) | groove.exe gone after 1.3 s, no crash, no Wine error dialog, the next start works |
| Restore after the game | active mods back to the user's selection, exe has no mod patches |
Images: `img/editor/play_boot.jpg`, `play_from_here.jpg`, `play_editor.jpg`,
`play_windows_editor.jpg`.
## Windows package under Wine
`./gradlew packageWindows` → `LlamaMod-windows-x64.zip` (45 MB). Unpacked into an own win64
prefix (Wine 6.0.3), started as `JDK_JAVA_OPTIONS="-XX:ActiveProcessorCount=4
-Dllamamod.data=Z:\…\data" wine runtime/bin/javaw.exe -jar LlamaMod.exe --editor t1l1m001`
(the launch4j exe itself hangs in its `javaw -version` check under Wine 6, see llamamod-app.md):
the editor window opens (within about two minutes on the first start, software GL) and renders the village with all phase-3 plugins (Play from
camera position, Textures tab, paint tool) – `img/editor/play_windows_editor.jpg`. The game was
not started from the Windows build (the private Wine prefix has no game install); real Windows
hardware is untested.
## Tests
`BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only
when asked; start parsing; original village: only the llama moves, file size unchanged;
editor-style chain moves and turns with the llama), `RestoreCheck` (manual), `EdTour` +
`PlayTourSteps` (scripted editor runs on an X display: camera placement, play-from-here).
+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).
+131
View File
@@ -0,0 +1,131 @@
# Level editor – environment, lighting & visibility tools
Plugin `editor.tools.environment.EnvironmentEditorPlugin` (phase 3, `notes/editor-ui.md`).
Code: `src/main/java/com/eliasmueller/llamamod/editor/tools/environment/`, tests
`src/test/.../editor/tools/environment/` (`EnvironmentToolsTest` needs `-Dllamamod.cd`; `EnvTour`
= scripted screenshots, `EnvScenarios` = the in-game test projects).
![zones](img/editor/env_zones.jpg)
## What the user gets
| Where | What |
|---|---|
| **Environment** panel (left dock) – *Zones* tab | Live fog preview (on/off, zone: camera / start / selected, detail Min/Medium/Max, status "showing … fog a → b cells"), list of all zones (start zone first; fog colour swatch, kind, trigger → target, fog end, music), + Teleport (starts the Zone tool), + Environment zone, delete, frame |
| Inspector section "Environment zone" | trigger cell x/z/y + height test, *move the player here* (flag 4), target x/y/z, facing (°), step forward, fog mode, fog ramp bar, draw distance at Min/Medium/Max, the four fog values, fog/sky colour and ambient colour pickers (live preview while picking, one undo step), music dropdown (track names), sky model (STPC offsets used in the level) and sky yaw, *Make start zone*, *Duplicate*. Raw fields stay in the generic properties below |
| **Zones** tool | no zone selected: click a cell → new teleport zone (environment copied from the start zone); then click/drag = target, Shift+drag = facing (22.5° steps), `[` `]` turn; *Trigger cell* mode moves the trigger. Viewport: target ring + facing arrow (all zones), selected zone: filled trigger cell, box and an arc to the target; the target is a pickable marker |
| *Lights* tab | *Preview lights on objects* (engine lighting of all object models), list of lights (swatch, type, position, radius), + Point (Lights tool), + Directional, delete |
| Inspector section "Light" | type (directional / point / negative point), colour picker + intensity (0–2, colour value 128 = 1.0), directional: azimuth/elevation, point: position, full radius, falloff radius, flicker |
| **Lights** tool | click = point light 1 cell above the surface (colour preset: torch, fire, lamp, magic, moon, glow), drag = radius. Gizmos: directional = arrows above the level centre, point = radius rings, selected = spheres for both radii |
| *Lighting* tab | bake: presets Day / Dusk / Night / Cave / Flat or the level's own sun, sun azimuth/elevation/strength/colour, sky + ground colour, AO amount/radius, shadow light; scope all / selected tiles; options: add LGHT point lights to the tiles, also set the level lighting for brushes, set the start zone's fog + ambient to the preset. One undo step. Display: textured / vertex colours only; button for the paint tool |
| **Paint vertex colours** tool (V) | modes paint / lighten / darken / smooth, colour + brightness (1 = texture colour, 2 = max), radius, strength, falloff smooth/linear/constant, selection only; Ctrl+click = eyedropper; one undo step per stroke (`PropMapCommand` merges by stroke) |
| **Visibility** panel (right dock) | 32 × 32 mask matrix (row = camera region, column = drawn region; click/drag toggles, *symmetric* option, unused regions dimmed, row label picks a region), preview *off / follow the camera / from region n* (hides the tiles and objects the game would not draw), All visible, Self only, Make symmetric, Auto-generate… (one region / blocks / connected areas + distance) with a triangle-cost warning, statistics (triangles drawn per camera region, max/mean) |
| **Paint regions** tool | region (colour swatches) + brush 1…17 cells, Ctrl+click picks; switches the region layer on |
| Level → *Level settings…* (replaces the built-in action) | name, author, description, TestLevel chapter (+ chapters used by other projects), base level, grid width/height (grows east/south, region grid copied), death plane height (KILL_PLANE / SEA brushes and material-30 no-floor tiles move together, or add one), WFPC flags as check boxes (layout flags read-only, unknown set bits listed raw), strat pool |
| Level → Bake lighting / Environment zones / Visibility regions; View → Fog preview, Light objects, Vertex colours only (Shift+V) | shortcuts to the above |
| Problems (Check/Build) | fog end < 3 or > 30 cells at detail Max, fog start ≥ end, no / several start zones, trigger outside the grid, teleport target outside the grid (error), teleport without exit floor or door object in the trigger cell (info), player start in a region that does not draw itself (error), regions never drawn from anywhere, region drawing > 30k triangles (info), tiles with flat grey / black vertex colours, point lights without radius, black lights, ignored light types |
## Engine facts used (groove.exe, see also mod-fog.md / mod-mirror.md)
* **Zone records** (MAP block 4, 90 bytes; `EnvZone`): the start zone has flag 8 (`+0x20`) and
target (1,1,1) raw – exactly one per original level, trigger at (3, y, 0) outside the
playfield. Teleports: strat opcode **0x100** (`FUN_0054df30`, table 0x578ff4 + op·4) looks up the
zone of the calling object's cell (`FUN_0054d560`: same cell x/z, smallest cellY ≥ player cell
y (+1 with angle bit 0, −1 with bit 1)), then `FUN_0054de00`: unless flag 4, position = target,
yaw = (angle & 0xff0) << 12; then `FUN_0041e1c0` switches fog, colours, music; if angle bit 1
is clear the player is pushed `(short)+0x38` along the facing. In the originals the trigger
cells hold exit floors (collision material 16) or door objects (RubyIdolDoor, PressurePadDoor):
house doors in the village. Flags 0x400/0x800 are read by strat opcode 0x81 (unknown use).
* **Fog mode** (`+0x34` → DAT_005865cc, `FUN_00438180`): 0 none, 1 darkness with a white point
light on the player with radius fog start…end (no original uses it), 2 table fog, 3 vertex fog.
Fog fades to the fog colour (`+0x50`, also the backdrop clear colour); the far clip = fog end.
`+0x4c` = ambient colour of object lighting (`FUN_0041bcf0`, byte/255).
* **Music** ids: table at 0x578960 (20 bytes per entry, name pointer + 8) → `music/<NAME>.ASF`
(`FUN_00546b70`); ids 1–39 (VILLAGE … ENDGAME), 0 stops. `+0x56` u16 → `FUN_005460d0` (low
nibble, bits 8–14; probably ambience, kept raw).
* **Lights** (LGHT, `FUN_0042c180`): positions and directions are **game space** (z = grid row,
the engine negates z when loading) – the editor previously treated them as export space (fixed
in `SceneRenderer`, `Transformers.LIGHT`, `Edits.translate`, see "shared changes"). Colour factor
2c/255; type 4 = **negative** light −(1 + 2c/255)/2. Point light: full up to `a`, linear falloff
to 0 at `b` (`FUN_0041b8a0` / `FUN_0041ba10`); byte c = 1 → random ×1.5/×0.5 every frame
(DAT_005865d4 = random & 3) = flicker. Lights only light **object models** (strats, per vertex:
ambient + all directional + nearest point lights, max 6, `FUN_0041bdb0`); tiles use baked
vertex colours. 97 directional and 1729 point lights in the 31 levels, no type 4.
* **Regions**: camera cell → mask (`FUN_0041e020`), mask 0 = everything, camera outside the grid
= everything. Tiles (`FUN_0042bf40`) and strats (flag 0x40000 unless strat flag 0x800) in cells
whose region bit is not set are skipped. The second table value + the top tile's y goes to
DAT_00586424 (strat opcode 0x78; unknown use).
* **WFPC** bits: 0x10 / 0x100 / 0x10000 / 0x100000 / 0x200000 / 0x4000000 / 0x10000000 change the
data layout (read-only in the dialog); 0x800 renderer fog flag (`FUN_00424b90`), 0x80000 read by
strat opcode 0x8e, 0x8000000 collision materials 17/18 ignored are editable; others raw.
## How the previews work
* **Fog** (`EnvSession.updateFog`): zone = selected zone, else the start zone or the zone whose
*area* is nearest (teleport targets / player start; the game switches zones only by teleports),
fog start/end for the chosen detail exactly as `FUN_0041a480` (incl. the 1.5× loader rule).
Renderer addition `Viewport.setFogOverride(FogOverride)`: fog ramp to the zone colour, geometry
beyond the end discarded, clear colour = fog colour; distances are measured from the orbit
pivot (the game camera follows the llama a few cells behind), so an orbiting camera shows the
"visible bubble" around the point it looks at. When the camera is far from its pivot
(overview) the preview pauses ("Zoom in to see the fog of …").
* **Shading** `Viewport.setShading(SHADE_VERTEX_COLORS)`: texture rgb replaced by white (cut-out
alpha kept).
* **Object lighting**: per-vertex colours of every OBJECT render instance from the engine
formula (`ObjectLighting`, `EnvMath.shade`), re-applied after scene changes.
* **Region preview**: hides tiles/objects through `ctx.setHidden` and restores exactly the nodes
it hid (user-hidden nodes stay hidden).
## Bake (`VertexBake`)
Same shading model as `levelbuild.Lighting` (sky/ground hemisphere by normal, sun Lambert × shadow
ray, AO with 24 Fibonacci rays, `amb·ao + sun·f·(0.6 + 0.4·ao)`), but traced against the real
triangles of all tiles (opaque faces; uniform 1-cell xz grid with a 2D DDA, per-cell max height
skips upward rays). Optional point lights (engine falloff, shadowed, ×0.5). Writes frame 0, keeps
byte 3 (frame count) and other frames. Village (1358 tiles, 26k triangles): **0.7 s**. Brushes
are not tiles in the scene – their light comes from the level's Lighting settings, which the bake
can set (the brush preview recompiles).
## Shared changes (minimal, backwards compatible)
* `editor.render.Viewport`: `FogOverride` record, `setFogOverride/fogOverride`, `setShading/shading`,
`SHADE_*`; `GlRenderer.FrameParams.fogOverride/shading`; shader uniforms `uFogMode`, `uFogOrigin`,
`uFarClip`, `uShade` (default path unchanged: 40–160 cells, 0.85).
* Light z sign (stored z = game z): `ui.view.SceneRenderer` (marker position, direction line),
`ui.tools.Transformers.LIGHT`, `model.cmd.Edits.translate`; `model.Light` javadoc corrected.
## Verified in game (apptest, display :8, Wine 6, Village 1 edits built with `EnvScenarios`)
See `img/editor/env_game_*.jpg`.
* **New teleport zone (2026-11-07):** Village 1 edit with zones made by `ZoneTool.create` in the
2×3 cells east of the player start (all their floor polygons painted material 16, zone cellY 8,
target 40.5/7.25/62.5, red fog 2–7 cells). Walking east from the original start: black fade, the
llama stands at the target next to the checkpoint post, red fog active
(`img/editor/env_game_teleport.jpg`). Teleports fire only while the llama *moves* onto the
exit floor (player code checks a speed property > 0): spawning on a house-door pad
(43.5/48.45) does nothing.
* **Start positions around 34–35 / 58 crash the game while loading** (page fault 0x405527 in
FUN_004054c0, floor-normal lookup), both with boot-to-level's start override and with the Llama
moved in the project (`EnvScenarios` "village-teleport"); the original start is fine. Not
investigated further.
## Fog preview while zooming
Beyond max(10, 1.6 × fog end) cells from the orbit pivot the preview fades out over another half
of that distance (fog radius grows ×(1 + 30u²), amount × (1 − u)); the overview (no override)
starts when the fade is complete and ends again at 90 % of it. The override is recomputed on every
camera change (the zone lookup still runs 120 ms after the camera stops), so zooming no longer
pops between "fogged" and "whole level".
## Known limits
* Zone "areas" for the fog preview are a heuristic (nearest teleport target); the game's real
current zone depends on which door the player used last.
* The bake is not identical to the original Argonaut lighting (their vertex colours were
hand-tuned/radiosity); it reproduces levelbuild's look.
* The object lighting preview ignores the flicker and the second light set (fog mode 1); objects
whose model is rebuilt by the shell get their lit colours back after the next scene change.
* Grid resizing only grows/shrinks at the east/far end; WFPC layout flags cannot be changed
(would need converting the MAP blocks).
* Region preview uses the editor's hidden flags (visible in the outliner while active).
+164
View File
@@ -0,0 +1,164 @@
# Level editor – geometry tools (`editor.tools.geometry`)
Plugin `GeometryEditorPlugin` (phase 3, discovered automatically). Everything the owner asked for
under "geometry": draw primitives, resize/turn/scale/mirror them, align and repeat, paint tiles,
paint collision materials, import meshes, reuse geometry across levels, export to Blender, and
geometry checks. Screenshots: `img/editor/geo_*.jpg`.
![brush](img/editor/geo_handles.jpg)
## User guide
### Brush tool (B) – draw primitives
Toolbar button / **Geometry → Brush tool**. The options bar has one button per primitive, the
Inspector shows the same palette with names plus the options of the chosen primitive (textures with
a swatch picker, thickness, steps, segments, collision, solid sides, material).
| Primitive | How to draw | Brush |
|---|---|---|
| Block, Platform, Ramp, Stairs, Round (cylinder), Floor, Water, Death plane, Invisible block | drag the footprint, then move the mouse up/down for the height and click (Enter = height field, Esc cancels) | BOX, PLATFORM, RAMP, STAIRS, CYLINDER (new), FLOOR, SEA, KILL_PLANE, BOX invisible |
| Wall, Invisible wall | drag along the wall – any direction, snapped to the angle step; then the height | BOX (turned by `rotation` if diagonal) |
| Pillar, Round | click = default size, drag = size | PILLAR, CYLINDER |
| Fence | click the corners, double-click / Enter finishes | FENCE |
| Tree | click | TREE |
* Drawing happens on the surface under the cursor (top of other geometry, water, the grid) or on a
**work plane** at a fixed height. Footprints snap to the move step (Ctrl inverts snapping).
* Ramps and stairs rise in the main drag direction. Stairs start at the floor in front (`y0`), the
walkable invisible slope makes them climbable (the engine has no step-up logic).
* **Handles**: a selected brush shows cubes on its faces (orange = sides, green = top/bottom). Drag
one to resize; the opposite face stays where it is – also for turned brushes.
* `[` / `]` turn the selection by 90°, Page Up / Page Down raise / lower it by the snap step.
* Status bar shows the size while drawing/resizing.
### Rotate / scale / arrange
* **Rotate (E)**: the green ring turns everything about the vertical axis (as before). The red and
blue rings now turn **brushes freely in 3D** (`rotation` x·y·z Euler angles about the brush's
centre; meshes about their origin). Tiles and objects only turn about the vertical axis in the
engine – the status bar says so and they are left alone.
* **Scale (R)** on brushes changes the primitive's own sizes in its frame (width/depth/height,
thickness, radius, crown …; meshes stretch per axis) – the bottom centre stays.
* Quarter turns and mirrors of unrotated brushes change their parameters (texture stays aligned to
the grid; ramp/stairs directions, sides, per-face settings follow).
* **Geometry menu**: Turn left/right 90° (Ctrl+[ / Ctrl+]), Mirror X / Z (about the selection
centre; tiles and objects are moved and turned – tile models themselves cannot be mirrored),
**Array duplicate…** (Ctrl+Shift+D: n copies with offset and turn per copy, optionally circular
about the selection centre), **Align ▸** west/centre/east, bottom/middle/top, north/centre/south to
the last selected item, distribute along X / Z, **Grid & snapping…** (any move/angle/scale step,
grid on/off), **Death plane height…** (moves death-plane brushes and the original level's
material-30 tiles, or adds a plane over the whole grid), **Re-import mesh…**, **Save selection to
library…**.
* Inspector section **Brush shape** (one brush): size, rotation X/Y/Z fields, turn/mirror buttons,
*Visible* (off = invisible collision volume) and a face table: collision on/off and material per
face (top, bottom, north, east, south, west). Section **Tile**: turn/raise buttons, "use palette
model", tile swap (see below) and the honest note that tiles only turn about y and cannot scale.
### Tiles tool (T) – original-style levels
Modes **Paint / Pick / Erase / Raise-lower / Swap model**, palette of the level's tile models with
thumbnails in the Inspector (filter, "only used models"). Paint puts the palette model into the cell
under the cursor (on the surface height, or at a fixed height), rotation 0/90/180/270 (`[` `]`),
height Page Up/Down. Painting onto a tile at the same height swaps its model instead of stacking
(keeps tile indices that strat scripts use). Shift+click = pick (model, height, rotation). New tiles
copy the vertex colours of a tile with the same model. **Tile swaps** (doors/bridges, WFPC 0x10000):
the Tile section shows the swap entry, sets the alternative model from the palette, removes or adds
a swap; layer "Tile swap alternatives" draws the alternative model in cyan.
### Collision tool (K)
Turns the collision layer on. Hovering highlights the polygon (or, on brushes, the whole face) under
the cursor and names its material; click/drag **paints** the material from the palette (Inspector
list with the known materials of `debugviz.md`: 0 normal, **16 exit floor**, **30 no floor /
death**, 17/18 selective, specials 1…255 "meaning unknown", "Other…" for any number). Shift+click
picks a material. **Toggle collision** mode switches a brush face's collision off/on. Tile models
are painted per polygon; shared models: change the model (all tiles) or give the tile its own copy.
Invisible collision volumes (Invisible wall/block, colliders) are drawn translucent red (layer
"Invisible collision volumes").
### Mesh import (File → Import mesh)
File chooser, then a dialog: rotatable preview with a 1-cell grid and a llama-sized marker, source
info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground,
texture size and UV precision, double-sided, lit, collision **from the mesh / simplified invisible
box / none** + material, warnings (densest cell vs. the 8000-vertex limit, larger than the grid).
Placement: click in the viewport (a box follows the cursor) or at the view centre.
**Geometry → Re-import mesh…** reloads the selected mesh brush's file (remembered) or another file
and keeps the placement.
### Library (cross-level reuse) and OBJ export
* **Save selection to library…** (Geometry menu or Assets → **Library** tab): selected tiles
(original TRAK models with collision and vertex colours), brushes and meshes (compiled to tiles)
→ `<data>/library/<name>.llamamesh`: a zip with `library.json` (models: vertices, faces, planes,
three collision groups; tiles: offsets from the anchor cell, rotation, vertex colours) and one PNG
per used texture (pixels embedded – owner decision: game data may be embedded). Death-plane tiles
are skipped.
* **Insert**: drag an entry from the Library tab into the viewport (anchor = cell under the drop
point) or double-click / Insert (view centre). Textures with identical pixels in the target level
are reused (same base: nothing new), the others are packed onto new texture pages (blend mode
kept); identical tile models are reused. Works on any base level.
* **File → Export selection as OBJ…**: OBJ (export space = the importer's convention) + MTL + one
PNG per texture, UVs per triangle (the engine's texture-rectangle halves), vertex colours as
`v x y z r g b` (0x80 = 1.0), optionally `…_collision.obj` (polygons grouped by material). The
dialog notes that the textures are game data (personal use). The importer reads it back
(round trip verified: same shape and textures, `img/editor/geo_library_insert.jpg` – right the
library tiles, left the re-imported OBJ as a mesh brush).
### Checks (Level → Check, Build)
Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**;
**overlapping** identical tiles; **no floor collision under the player start** (cell-based like the
engine) or start far above the floor; **no exit floor (16)** in the LevelDoor's cell; **gaps** < 0.2
between neighbouring floors at the same height; brush compiler warnings.
## Design / implementation
* **Brush math** (`BrushMath`, pure, unit-tested): footprint → parameters (`Primitive.params`),
handles + `drag` (opposite face kept in world space via `keepWorld`), `rotate` (JOML matrices,
`getEulerAnglesXYZ`; quarter turns parametric), `scale`, `mirror` (local mirror + M·R·M), diff-based
commands (`Edits.set` with merge keys → one undo step per gesture). `BrushTransformer` replaces the
built-in brush transformer (position = bottom centre of the pivot column, yaw = rotation[1]).
`RotateTool3D` wraps the built-in Rotate tool (same id) and handles the non-vertical rings.
* **Generic brush parameters** (all kinds except SEA, KILL_PLANE, COLLIDER): `rotation` {x, y, z}°,
`visible`, `collFaces` ("tbnesw"), `faceMats` int[6] (−1 = brush material); MESH also `stretch`.
Applied by `BrushCompiler.postProcess` on the quads a brush added (`LevelBuilder.editQuads`/
`editSolids`): face class from the quad's normal in the brush frame, then rotation about
`Brush.pivot()`; default values leave the build byte-identical (golden/test-world tests pass).
* `CollisionIndex`: world polygons of scene tiles + compiled brush tiles (cached per scene,
invalidated by scene events) for picking, highlighting, checks.
* `MeshLibrary` (save/load/insert/OBJ), `LibraryTab` (asset tab + drag & drop kind `geolib`).
### Shared-file changes (backwards compatible)
| File | Change |
|---|---|
| `levelbuild/LevelBuilder` | `QuadEdit`, `quadCount/solidCount`, `editQuads/editSolids` (editor post-processing), `cylinder(...)` primitive |
| `editor/model/BrushKind` | kind `CYLINDER`; generic params (above); `FACES`, `transformable()`; INT_ARRAY params; STAIRS `y0` label "Start height (floor in front)" |
| `editor/model/Brush` | int[] defaults cloned; `bounds()` = rotated `localBounds()` (now includes platform/pillar/cylinder/tree/fence/mesh sizes), `pivot()`, `rotation()`, `hasRotation()`, `rotationMatrix()`, `rotate()` |
| `editor/io/BrushCompiler` | CYLINDER replay, `postProcess`, `faceClass/frontNormal`, mesh `stretch` (+ mirrored winding) |
| `editor/io/MeshImport` | bug fix: the source folder is normalised, otherwise a path with `..` silently dropped the MTL and textures (meshes rendered untextured after saving) |
## Verification
* Unit tests `BrushMathTest` (rotation matrix = JOML, Euler round trip, primitives from footprints,
diagonal wall end points, quarter turns = free 90° turn, mirror involution incl. rotated shapes,
handles keep the opposite face also when turned, scaling, transformer; compiled per-face material
16 / sides without collision / invisible = collision only / 45° walls / tilted top / cylinder) and
`GeometryChecksTest` (gap, player-start floor, exit floor; library save→load→insert into the same
level (textures reused) and into a world-4 base (textures imported, builds)).
* UI on Xvfb: `geo_draw` (ramp preview), `geo_handles`, `geo_rotate3d` (blue ring turned a ramp
60°, inspector updates live), `geo_collision` (hover highlight, palette), `geo_tiles` (palette +
cell preview, village), `geo_import` (dialog), `geo_placed`, `geo_menu` (Geometry ▸ Align),
`geo_library`, `geo_build_ok`.
* **In game** (apptest, Wine; display :21 because :5 was taken by another agent's session): test
level "Geometry Test" (TestLevel chapter 4, built with the same code paths, `GeoDemo`): start
plaza → ramp → platform → stairs → round platform → **imported OBJ bridge** → goal block whose top
face is painted **16** with a LevelDoor → walked through in one run, the door fired, level summary
shown (`geo_game_course.jpg`). **Death floor** (FLOOR material 30): stepping on it = death and
respawn (`geo_game_death.jpg`). **Invisible wall** at the plaza edge stops the llama
(`geo_game_invisible_wall.jpg`). **Library**: a village piece (9 tiles, 44 textures) saved and
inserted into new levels on the village base (textures reused) and on the world-4 base t1l4m001
(textures imported, 3 new pages): both build and load in game (`geo_game_library.jpg`); also built
on the river base t1l3m001 (no Llama placeable there, so not played).
## Known limits
* Collision materials of brushes are per face class, not per individual polygon; tile models per
polygon. Collision of tile-model polygons cannot be switched off (paint 30 or hide the tile).
* Water and death planes are cell based: resize/move/90° only. Gaps check only unrotated
box/platform/floor tops.
* Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library
(see the objects agent's prefabs / object catalogue). glTF export not done (OBJ only).
* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256).
+79
View File
@@ -0,0 +1,79 @@
# Level editor – objects, scripts, paths, cameras (`editor.tools.objects`)
Plugin `ObjectsEditorPlugin` (phase 3, discovered automatically). Parameter decoding and the strat
VM: `notes/editor-object-schemas.md`. Tests: `ObjectToolsTest` (needs `-Dllamamod.cd`),
screenshot tour `ObjectsTour` (test classes).
![camera zone](img/editor/obj_camera_zone.jpg)
## What it adds
| Part | Content |
|---|---|
| **Objects panel** (left dock) | every placeable type of the base with rendered thumbnail, category filter, search (name + schema summary), favourites (right-click, ★ filter), "parameters decoded" marker; drag into the viewport or click → Place tool. Quick setups: Player start, Checkpoint, Exit, Secret, Coin line, Coin arc |
| **Place tool** (P) | click = place on the collision floor under the cursor (snap to floor, grid snap), yaw = camera direction / facing the camera / 0° / type default; click + drag an object = move along the ground (its waypoints move along); drag the yellow knob = rotate (angle snap; a checkpoint's respawn node turns with it). Modes for the setups |
| Setups | **Player start** moves the existing player (exactly one) or places a Llama with its chain (camera node 8 cells behind). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes) |
| **Coin tool** (O) | Line: two clicks, coins every *spacing*; Arc: centre + point on the circle, *count* coins over *arc°*; Coin/Coin5, height above floor, follow floor; live preview |
| **Waypoint tool** (N) | select an object (or node): click = add node after the selected node / at the chain end (creates the chain if missing), Ctrl+click = insert before, drag = move (keeps height above floor), Backspace/Del = remove and relink; Reverse; orient new nodes along the path |
| **Zones & triggers** (J) | handles: 4 corners of box zones (node pairs: Secret, CameraSide/Lift, ObjectDontDropInBox, FallOffNoHurt …), radius knob for RADIUS parameters (CutSceneTrigger, guards, CameraFocusPoint) |
| Inspector "Object parameters" | named, typed fields (check boxes, choices, counts, seconds, distances) with doc/range/original-value tooltips, raw hex, inline validation, "Reset to defaults"; LevelDoor target picker with "Leads to: …"; waypoint row (role, node count, Create/Edit path, Unlink, Delete path, Link… to an existing chain, Look through). Undo-aware refresh |
| Inspector "Waypoint chain" | node i of n, owner and role, ◀ ▶, select object, delete node, look through |
| Viewport layers | *Path directions* (arrows, dashed wrap segment for patrol paths, node yaw ticks), *Zone areas & trigger radii* (boxes for all zone objects, fills for the selection), *Camera frusta* (CameraSide/Lift: computed game camera from height/distance/node yaw; cut-scene cameras: a frustum per node from node yaw/pitch) |
| Look through camera (Ctrl+Shift+L, View → Camera) | viewport takes the camera of the selected node / cut-scene camera / camera zone; while active, flying writes back node position + yaw/pitch, or for CameraSide the height/distance params and node yaw (one undo step per pilot session) |
| Problems (Level → Check, Build) | chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts |
| Copy / paste between levels | Edit → Copy objects (Ctrl+Shift+C) puts JSON (objects + chains + brushes, relative to their centre) on the system clipboard; Paste objects (Ctrl+Shift+V) at the view centre in any level – types are matched by name/variant in the target base (unknown types skipped with a message) |
| Prefabs | Tools → Save selection as prefab… / asset browser tab "Prefabs": `<data>/editor/prefabs/<name>.json` + thumbnail; double-click = place at view centre, drag = place under the cursor |
| Delete | `edit.delete` is re-registered: a selection of waypoints is removed with the chain relinked |
Engine fact used throughout: the MAP loader links waypoint record i to i+1 when "next" ≠ 0, so a
chain must be contiguous in the node list (`ChainOps` inserts at the right index and the check /
repair action guard it).
## Files
`ObjectsEditorPlugin` (registration, actions, waypoint inspector), `Schemas`/`ParamSchema`/
`TypeStats` (+ `type-stats.tsv`), `ParamsSection`, `ObjectChecks`, `PlaceTool`, `Setups`,
`ExitTarget`, `CoinTool`, `PathTool`, `ChainOps`, `ZoneTool`, `ObjectOverlays`, `CameraView`
(zone camera + pilot), `ObjectPalette`, `ObjectClipboard`, `Prefabs`, `LinkDialog`, `Ground`
(floor finder on the collision polygons of tiles + brush preview tiles), `Session`, `Roles`, `Draw`
(all game↔render conversions in one place).
Shared change: `editor.ui.view.SceneRenderer.brushOutput()` getter (brush preview tiles for the
floor finder). Nothing else outside the package.
## Verified
UI (Xvfb, `ObjectsTour`): `img/editor/obj_camera_zone.jpg`, `obj_cutscene_frusta.jpg`,
`obj_pilot.jpg`, `obj_exit_inspector.jpg`, `obj_place.jpg`, `obj_zone_handles.jpg`,
`obj_trigger_radius.jpg`, `obj_problems.jpg`, `obj_newlevel_path.jpg`, `obj_newlevel_camera.jpg`,
`obj_coin_arc.jpg`, `obj_newlevel_saved.jpg` (no problems), `obj_beetle_path.jpg`.
In game (apptest, display :7, new level on base t1l1m002 as TestLevel chapter 2; island of brushes,
coin line + coin arc, checkpoint, exit to Village chapter 2, CameraSide zone at the exit,
JumpingBeetleBlue with a 4-node patrol path, CityGuard with a square path):
* level loads, coins of line and arc stand where placed (`obj_game_coins.jpg`);
* walking through the checkpoint shows "KONTROLLPUNKT"; falling into the sea respawns the llama at
the post (`obj_game_checkpoint.jpg`);
* entering the camera zone switches to the zone camera (high view, `obj_game_camera_zone.jpg`);
* stepping on the exit floor shows the tally "TestLevel Kapitel 2"; Enter → "Dorf Kapitel 2"
loads = the chosen chapter (`obj_game_exit.jpg`);
* the beetle jumps back and forth along its path (`obj_game_patrol.jpg`).
## Known limits
* Parameters are decoded for ~35 common types (confidence per type in the schema doc); the rest
shows raw values with the statistics of the originals.
* The CityGuard (village-2 base) placed in the open did not show up in game – it probably waits for
a scene/event; the patrol test was done with JumpingBeetleBlue.
* In 8 original levels the exit check reports "no material 16 in the door cell": the exit floor
polygons decode one cell off the door (e.g. village door cell 42,49, floor polygon at 43,48) –
original tile collision positions vs. strat positions are not fully understood yet; brush-built
exits are correct (verified in game). Snap-to-floor on original levels uses the same polygons.
* Zone heights are estimated (+1.2 above the lower node); the engine tests x/z only.
* Camera preview of CameraSide assumes the player in the box centre and default distance 2 /
height 1 when the parameters are 0; the game's FOV is approximated by 60°.
* Look-through of the village cut-scene camera's first node looks into a wall (its first node may
be a fly-in start); not compared with the game.
* Pasting into another base matches types by name; object models/code are not imported (that is
the job of `editor.tools.catalog`).
+121
View File
@@ -0,0 +1,121 @@
# Level editor – texture tools (`editor.tools.textures`)
Plugin `TexturesEditorPlugin` (phase 3). User guide: `editor-guide.md#textures`.
![texture manager](img/editor/tex_manager.jpg)
## What it adds
| Item | Where | |
|---|---|---|
| Texture manager | panel `textures` (bottom dock, Ctrl+Shift+T; **Window** button = own 1300×760 frame) | pages, atlas, table, details, animation editor |
| Import texture… | File menu (replaces the built-in `file.importTexture`), panel toolbar | `ImportDialog` |
| Replace texture pixels… | Edit menu, panel toolbar | image → the selected texture's rectangle |
| Paint texture | tool `texpaint`, key **M** (own toolbar group) | `PaintTool` |
| Texture eyedropper | Edit menu, key **I**; Shift+click in the paint tool | |
| Checks | Build/Check | > 127 pages = error, entry on a missing page = error |
`TexState` holds the window's *current texture* (shared by panel, tool, eyedropper; selecting a
`TextureEntry` anywhere, e.g. in the outliner, makes it current).
### Texture manager (`TextureManagerPanel`)
* **Pages**: thumbnail, index, blend class of the page format (`Blend.ofPage`). Context menu:
export page PNG, replace page from PNG (scaled to 256×256, reduced with the page's class).
* **Atlas**: the page (nearest-neighbour, fit to the view until the wheel zooms) with all
rectangles: white = used, red = unused, cyan = animated, orange = current with 8 handles.
Drag inside the current rectangle = move, handles = resize (one undo step per drag:
`TextureLibrary.setRect` with a merge key). Click selects the smallest rectangle under the
cursor; double-click activates the paint tool; tooltips show size/usage.
* **Table**: #, preview, name (editable), page, rectangle, blend, faces (tile-model faces +
brushes), models, animation ("frame" = used as a frame). Filters: all / on this page / used /
unused / animated / imported; search by name or number.
* **Texture tab**: name, X/Y/W/H spinners, blend class (sets the entry flags), raw flags,
usage line, *Duplicate* (new entry with the same rectangle, e.g. for frames), *Select users*
(tiles whose models use it).
* **Animation tab** (`AnimEditor`): create/remove, type flip-book / scroll up / scroll down,
frame delay (≈ fps at 30 game frames/s), back and forth, frame list (+ − ◀ ▶), scroll band /
speed / top row, live preview (`AnimSpec.frameAt/offsetAt`).
Usage counts (`TextureLibrary.usage`): faces of TRAK tile models (`faces[i+4]`), brush string
parameters `"#n"`, animation targets and frames. Object (STPC) models are not counted – their
textures are in the same table, so "unused" means *not used by tiles/brushes/animations*.
### Import (`ImportDialog`, `ColorReduction`, `TextureLibrary.importItems`)
![import dialog](img/editor/tex_import.jpg)
* Size = longest side (16…256 or original), aspect kept; rectangles need not be powers of two.
* **Blend classes** (`Blend`), derived from the flag pairs of all original level WADs
(entry flags ↔ page flags & 0x7f, counts over 33 WADs):
| Class | entry flags | page flags | originals |
|---|---|---|---|
| Opaque | 0x0104 | 0x81 | 16 551 entries |
| Cut-out | 0x0406 | 0x84 | 1 774 |
| Semi-transparent (mode 1) | 0x1406 | 0x87 | 528 |
| Additive (mode 2) | 0x2406 | 0x85 | 649 |
| Darken (mode 3) | 0x3406 | 0x82 | 574 |
Others seen: 0x1104/class 6 (186), 0x2104/class 3 (38), 0x4406/class 5 (469), object-model
entries 0x8cc6/0xbbc6/0x9cc6/0xb8c6 (bit 15). Entry bits 12–14 = blend mode, 0x400 = has
transparent pixels.
* **Colour reduction** to ABGR1555: alpha < cut → 0x0000; with "keep half-transparent" alpha
< 192 → STP 0 (black becomes 0x0421, never 0); optional colour key (magenta / top-left pixel)
and 4×4 ordered dithering; opaque class ignores alpha. Preview = the game's 15-bit result.
* **Packing**: with "pack into free space" the importer searches pages of the same format
class for a free block (`FreeMap`, summed-area table over a 256×256 occupancy mask built from
all rectangles plus scroll bands), 8-pixel grid, largest images first; otherwise / if full it
appends pages (≤ 127). Optional 1-pixel border (edge pixels repeated) against filtering seams.
One undoable `Batch`: `SetProp pixels` of changed existing pages (the project stores only the
changed pixel runs), `AddNode` pages, `AddNode` entries (always appended – faces reference the
index).
* Village test: two 32×32 imports land in free space of existing pages, a 200×100 semi-transparent
one gets a new 0x87 page; the WAD builds and re-imports (`TextureLibraryTest`).
### Paint tool (`PaintTool`)
![paint](img/editor/tex_paint.jpg)
* Hover shows the face under the cursor (white outline) and model/face/texture/flags in the
status bar (plus "model shared by n tiles"). Face picking: `ctx.pickNode` → tile, then a
Möller–Trumbore ray test against that tile model's triangles (tile transform, export space).
* Click = assign the current texture; scope **Face / Same texture in model / Whole model**.
Shift+click or the eyedropper button / **I** = pick. The clicked face stays selected (orange).
* **Rotate** cycles the face's corners (v0,v1,v2 → v1,v2,v0; winding and plane unchanged),
**Mirror** toggles 0x20 (mirror U), **Other half** cycles 0x800/0x10 (`Geometry.faceUvs`: no
0x800 = corners (a,v1)(b,v1)(a,v0); 0x800 = (a,v1)(b,v1)(b,v0); 0x810 = (b,v0)(a,v0)(a,v1)).
* **Unique**: if the tile's model is shared, the edit first adds a copy of the model and points
the tile at it (one undo step).
* Brushes: the clicked side's normal (from three depth samples) picks the parameter `top`
(n.y > 0.7) / `bottom` / `side`, else the first texture parameter (`tex`, `post`, `trunk` …);
the value becomes `"#n"`. Rotate increments `texRot` (0–3) of the last painted brush.
## Texture animations (decoded, `AnimSpec`)
Loader FUN_00406e40 (16-byte runtime records at 0x581138, count 0x581134), update FUN_0040c6e0,
called once per rendered frame from FUN_0054b590 while the game is not paused. The runtime
texture record (20 bytes at 0x581154 + tex·0x14) holds the page and the UVs as floats
(+0xc / +0x10 = v0 / v1, divided by the page height at 0x58114c + page·0x20 + 8).
| Type | File payload | Behaviour |
|---|---|---|
| 8 flip-book | delay, n, pingPong, n × u16 texture | every delay+1 frames the texture's runtime record is replaced by the next frame's (loop or back and forth). 214 in the levels (torches, water, flags), e.g. `03 08 00 d0 03 …` |
| 1 / 2 scroll | band−1, speed, v0 | V range = v0+off … v0+off+band, off −= / += speed per frame, wraps in the band. 7× type 1, 2× type 2 (rivers, lava: `1f 02 01`) |
| 10 / 11 | band−1, speed, v0, n, n × u16 | scroll as 2 / 1, each wrap switches to the next / previous frame. Unused |
| 7 / 9 | u16 palette, u16 delay, u8 first, u8 last | palette cycling of 8-bit pages. Unused (no palettes) |
The editor viewport does not animate textures; the Animation tab previews them.
## Known limits
* Texture entries are never deleted (indices are referenced by faces and object models);
unused ones can be reused by moving their rectangle.
* Object (STPC) model faces cannot be painted.
* Painting a shared original tile model changes every tile using it unless **Unique** is on.
* The page format of an existing page is not changed by the importer; importing into a page of
another class is avoided.
Files: `src/main/java/com/eliasmueller/llamamod/editor/tools/textures/*`, tests
`TextureLibraryTest` (colour reduction, anim round trip of all village animations, packing,
build + undo), tour steps `TexTourSteps` (with `playtest.EdTour`).
+197
View File
@@ -0,0 +1,197 @@
# Level editor – application shell (`editor.ui`, phase 2)
The Swing/FlatLaf editor around the phase-1 core (`editor-api.md`, `editor-format.md`) and viewport
(`editor-render.md`). Package `com.eliasmueller.llamamod.editor.ui` (+ `.panels`, `.tools`, `.view`).
Design brief: `editor-design.md`.
![village](img/editor/ui_select_move.jpg)
## Launch
* LlamaMod header: **Level Editor** button next to Play (`ui/MainFrame.openEditor`). Same JVM, same
`AppPaths`/`Config`/`ModManager` objects; after a play-test LlamaMod refreshes (`EditorEnv.setModsChanged`).
* `llamamod --editor [project.llamalevel | t1l3m002]` – standalone (exits when closed). Without an
argument it offers crash recovery. `-Dllamamod.editor.cd=DIR` uses another CD folder,
`-Dllamamod.editor.msaa=0` disables multisampling (software GL).
* Programmatic: `EditorApp.open(new EditorEnv(paths, cfg, modManager, false), arg)` on the EDT.
## Window
| Area | Content |
|---|---|
| Menu | File (New level, Open original level, Open project, Open recent, Save, Save as, Close, Import mesh/texture, Export WAD), Edit (Undo/Redo with labels, History, Cut/Copy/Paste/Duplicate/Delete, Select all/none/similar, Hide/Show/Lock, Preferences), View (Frame, Camera ›, local space, snap, collision, dark theme, Panels ›, Layers ›), Level (Settings, Check, Build, Play), Help (Keyboard shortcuts, About) |
| Toolbar | new/open original/open/save · undo/redo · Select Q / Move W / Rotate E / Scale R · duplicate/delete/frame · **Build** · **Play** |
| Options bar | active tool + its options · Snap toggle, move step (1…1/16 cell), angle step · World/Local · camera view · Layers popup |
| Docks | left: Outliner, History · right: Inspector · bottom: Assets, Problems. Tabs can be closed, moved to another dock (tab context menu), reopened in View → Panels; layout and split sizes are remembered |
| Viewport | `render.Viewport` with the live scene; empty state ("Open a level or create a new one", recent projects) while no level is open |
| Status bar | messages, hovered item, cursor position (game cells) and grid cell, counts, last build (click → Problems), progress of background tasks |
Shortcuts (Help → Keyboard shortcuts lists all, incl. plugins): RMB+WASDQE fly, MMB orbit,
Shift+MMB pan, wheel zoom, F frame, Home frame level, numpad 7/1/3/5 views, Q/W/E/R tools, X local
space, G snap, C collision, H hide, Alt+H show all, L lock, Ctrl+D duplicate, Del delete,
Ctrl+Z/Y/Shift+Z, Ctrl+X/C/V, Ctrl+A, Ctrl+B build, F5 play, F1 help. Single keys never fire while a
text field has the focus; while the GL canvas has the focus a `KeyEventDispatcher` maps keys to
actions (AWT canvases do not get Swing key bindings).
## Architecture
| Class | Role |
|---|---|
| `EditorWindow` | the JFrame; implements `EditorContext`; owns selection, viewport, `SceneRenderer`, docks, actions, key dispatch, document switching |
| `EditorContext` | **the API plugins use** (document/scene/history, selection, viewport, pick helpers, execute, hidden/locked, layers, tools, status, background tasks, problems, panels) |
| `EditorExtensions` / `EditorPlugin` / `PluginLoader` | registry + discovery (below) |
| `BuiltinEditorPlugin` | everything built in, registered through the same API |
| `Document` | `Project` + `History` + file + autosave state; `dirty()` |
| `Selection` | ordered node set, primary = last, listeners get the source (avoid echo loops), auto-pruned |
| `ProjectController` | new/open/save/save as/recent, autosave + recovery, thumbnail, import/export |
| `PlayTest` | check, background build, play-test |
| `view.SceneRenderer` | scene model → `RenderScene`, live, stable pick ids |
| `view.Thumbnails` | software rasteriser for asset thumbnails |
| `tools.SelectTool`, `TransformSupport`, `Transformers`, `EditOps` | selection/transform tools and edit operations |
| `panels.*` | Outliner, Inspector, AssetBrowser, Problems, History |
All edits go through `ctx.execute(Command)` (→ `History`); drags use one merge key per gesture, so a
drag is one undo step (`TransformSupport`, inspector drag-scrub). Scene events arrive synchronously;
the window collects them and flushes the renderer once per EDT turn.
### Scene → render adapter (`view.SceneRenderer`)
* Pick ids `kind<<24 | n` (`PickId.TILE/OBJECT/WAYPOINT/ZONE/LIGHT`, `PICK_BRUSH`=6, `PICK_OTHER`=7),
assigned per node on first use, stable for the document (`idOf`, `nodeOf`).
* Tiles: one instance per tile (mesh per `TileModel`, collision overlay), transform + first colour
frame; a CHANGED tile only updates its instance (`RenderScene.changed(inst)` → one chunk rebuild);
a changed model rebuilds its mesh and its tiles. Objects: model via `StratModels` from the STPC raw
chunk (same rules as `LevelSceneAdapter`), markers for logic objects. Markers/overlays (waypoints,
zones + teleport targets, lights, regions, brush boxes) are rebuilt as a whole (`RenderScene.overlaysChanged()`,
cheap). Texture/STPC changes or RELOADED rebuild everything (~0.3 s for the village).
* Brushes: `BrushCompiler.compile` (the build's own code) for a live preview; generated tiles get the
pick id of the smallest enabled brush whose bounds contain them, plus an outline box and handle marker.
* Waypoints default to "selection only" (chains of selected objects/waypoints), View → Layers can show all.
* Hidden nodes: instance/marker `visible=false` (renderer change: invisible tiles leave the static
batches); locked nodes are drawn but `pickNode` ignores them.
## Extension API (phase 3)
**Rule for parallel agents: never edit `editor.ui` itself.** Create a package of your own, e.g.
`com.eliasmueller.llamamod.editor.tools.waypoints`, with a class whose simple name ends in
`EditorPlugin`:
```java
public final class WaypointEditorPlugin implements EditorPlugin {
@Override public void install(EditorExtensions x) {
x.tool(new WaypointTool()); // toolbar + shortcut
x.panel(new PanelSpec("waypoints", "Waypoints", "waypoint", Dock.RIGHT, 30, false, WaypointPanel::new));
x.action(ActionSpec.of("wp.newChain", "Tools", 10, 10, "New waypoint chain", "waypoint", "ctrl shift W",
ctx -> ...).enabledWhen(ctx -> ctx.document() != null));
x.inspector(new ChainSection()); // extra inspector content
x.layer(new LayerSpec("wpLabels", "Waypoint labels", "waypoint", false, 55));
x.renderContributor(new ChainLabels()); // overlay geometry in that layer
x.check((scene, out) -> ...); // build/check problems
x.transformer(new MyNodeTransformer()); // gizmo support for a node kind
x.template(new MyLevelTemplate()); // "New level" start content
x.assetTab(new AssetTab("prefabs", "Prefabs", "assets", 50, PrefabTab::new));
x.onOpen(ctx -> ctx.addSceneListener(e -> ...)); // runs once the window exists
AssetDrop.register("prefab", (ctx, data, ground) -> ...); // drag & drop into the viewport
}
}
```
Discovery: `PluginLoader` scans the classpath (jar or class folders) for `*EditorPlugin.class` below
`com/eliasmueller/llamamod/editor/` (no shared registration file); `-Dllamamod.editor.plugins=a.B,c.D`
adds others. A plugin that throws is skipped with a message. Built-ins install first; registering the
same id again replaces (panels, tools, actions, asset tabs, layers).
| API | Notes |
|---|---|
| `Tool` | `id/name/icon/tooltip/shortcut/group/order`; `activate/deactivate`; `mousePressed(ToolEvent)` → true = owns the drag (else default click-select); `mouseDragged/Released/Moved`, `keyPressed`; `gizmoMode()` (null = no gizmo) + `gizmo(ctx, GizmoEvent)` + `placeGizmo(ctx)`; `drawOverlay(depthTested, onTop)` (export space, call `ctx.repaintTool()`); `optionsBar(ctx)`; `inspectorSection(ctx, sel)`; `cursor()`. Right/middle mouse and Alt always stay with the camera |
| `ToolEvent` | `x/y/shift/ctrl/alt/clickCount`, lazy `pick()` (respects layers/hidden/locked), `surface()` (geometry point), `onPlane(y)`, `ground()`, `toGame(p)` / `toExport(x, y, z)` |
| `ActionSpec` | menu path `"Tools"` or `"View/Camera"`, group/order, label, icon, `KeyStroke` string, run, `enabledWhen`, `checked` (check box item), `tip`; `x.toolbarAction(id, section)` |
| `PanelSpec` | id, title, icon, default `Dock`, order, visible by default, factory `ctx → JComponent` (created once; follow `addDocumentListener`/`selection().addListener`/`addSceneListener`) |
| `InspectorSection` | `appliesTo(sel)`, `create(ctx, sel)` (rebuilt on selection change), `replacesProperties()` |
| `RenderContributor` | `layer()`, `affectedBy(SceneEvent)`, `followsSelection()`, `build(ctx, builder)` → `builder.overlay()` lines/triangles, `builder.marker(node, x, y, z, rgba)` (pickable → selects node) |
| `NodeTransformer` | position (game cells), yaw (deg or NaN), scale (or null), `set(node, pos, yaw, scale)` → prop map. `set` must compute from the node it gets (the shell passes a drag-start snapshot) |
| `LevelTemplate` | `fill(Scene)` adds nodes directly (no undo) before the editor shows the new level |
| `LevelCheck` | adds `Problem.error/warning/info(msg, node)`; errors block Build/Play |
| `Icons` | vector icons by name; `Icons.register(name, painter)` for your own (16×16 Java2D); `Icons.swatch(colour, w, h)` |
| `Widgets`, `util.SwingListeners` | `Widgets.button(text, icon, tip, run)`, `Widgets.listCell(c, list, selected)`; `onTextChange`, `onMenuOpen` |
| `AssetDrop` | `register(kind, handler)`; `dragSource(list, value → payload(kind, data))` makes a list draggable into the viewport |
| `EditorPrefs` | `ctx.prefs()` JSON settings in `<data>/editor/settings.json`; prefix your keys |
| `NodeCopy` | duplicate/snapshot nodes (fresh ids) |
| `EditOps` (`tools`) | `placeTile`, `placeObject`, `copies`, `duplicate`, `delete`, … reusable from plugins |
Coordinate reminder: render/viewport points are export space (z = −game z); model positions are
game space (`ToolEvent.toGame` / `toExport`). Drop/placement points: `ctx.groundAt(x, y)`, `ctx.viewCentre()`. Commands: `editor-api.md` (`Edits`, `SetProp`, `Batch` with merge key).
### Render additions used by tools (backwards compatible, `editor.render`)
`Viewport.setInput(ViewportInput)` (tool hook: left-button press/drag/release not on the gizmo, moves,
keys), `setToolOverlay(depthTested, onTop)`, `setScreenOverlay(pixels)` (marquee), `unproject(x, y,
depth)` public, `isFlying()`, `canvas()`; `RenderScene.overlaysChanged()`; `LevelSceneAdapter.mesh/page/collision`
public; invisible tile instances are no longer baked into chunks; `History.undoLabels()/redoLabels()`.
## Project lifecycle
* **New level**: dialog (name, author, base level by name with its loading picture, TestLevel
chapter = next free one, template Empty / Flat floor / Island). Templates add brushes, a Llama
(camera node 8 cells behind, as levelbuild) and a LevelDoor on a material-16 floor.
* **Open original level**: list "Village – Chapter 1" … "Lab – Chapter 5" (+ unused/front-end WADs)
with the CD loading pictures (`OriginalLevels`, `Cor`).
* **Save** (`.llamalevel`, default folder `<data>/levels` = listed as mods in LlamaMod), Save as, Open
recent (10), dirty marker "•" in the title, confirm on close/new/open. **Thumbnail**: viewport
screenshot 640×360 PNG on every save (also the TestLevel loading picture).
* **Autosave** every 90 s (Preferences) while dirty → `<data>/editor/autosave/*.llamalevel` + `.json`
(title, original file, time); deleted on save/close/discard. After a crash the next start offers
Recover / Discard / Later (verified: kill during editing → recovered with all edits).
## Build & Play-test
* **Check**: `Validator` + checks (no player start = error, no exit in a new level = warning, no
geometry, brushes outside the grid). Messages with node ids are double-clickable in Problems.
* **Build** (Ctrl+B): validates, snapshots the project (`ProjectIO.write`), then reads + builds the
WAD in the background (UI stays usable) → Problems: "Build OK: tiles (from brushes), objects,
waypoints, KB, time" + warnings.
* **Play** (F5): build → write the current state to `<data>/levels/<stem>.llamalevel` (if that is the
document's own file it counts as saved; otherwise the user's file stays untouched) → enable the level
mod next to the active mods (turns off other mods writing the same WAD, incl. Test World on chapter
1), TestLevel chapter option = project chapter → `ModManager.apply` → save slot (Preferences, default
3; backup first) unlocked up to the level if needed and map cursor on it → start the game.
* **Boot to level** (phase 3, `editor.tools.playtest`, `notes/editor-playtest.md`): Play now also
applies the exe mod `boot-to-level`, so the game loads the level directly (no menus) and Escape
quits; Shift+F5 = play from the camera position. The old menu-based run is still available
(Level → *Play boots straight into the level* off).
`-Dllamamod.editor.noLaunch=true` skips the launch (tests start the game through apptest).
## Tests
* `EditorShellTest` (CD needed, `-Dllamamod.cd`): drag = one undo step + world-grid snapping, rotate
about a pivot, duplicate with waypoint-chain copy + delete/undo, brush move/scale, renderer live
updates (tile move, marker move, delete/undo, waypoints of the selection), templates build brush
tiles + thumbnails render, plugin discovery, number-field arithmetic.
* `EditorTour` (test classes, not a unit test): scripted run on an X display, writes the screenshots
below. `java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR -Dllamamod.editor.msaa=0
com.eliasmueller.llamamod.editor.ui.EditorTour OUTDIR village,select,move,…` (steps: empty, recover,
village, select, move, rotate, marquee, panels, layers, build, drag, newlevel, place, save, play,
crash, dark, shortcuts, dialogs).
* **End-to-end (2026-11-06, Xvfb :8 editor, :7 game)**: new level "Editor Island" (Island template,
chapter 7) → 2 coins + checkpoint placed, checkpoint duplicated → saved → Build OK (1830 brush
tiles, 6 objects, 5.6 MB) → Play (noLaunch: mod applied, slot 3 unlocked, cursor on TestLevel 7) →
`apptest start/tolevel --world 9`: slot shows "TestLevel Kapitel 7", the island loads with trees,
coins and checkpoints, the llama walks (`img/editor/ui_e2e_game.jpg`). Drag & drop from the asset
browser into the viewport placed an object (Robot drag). Crash recovery verified.
Screenshots (`img/editor/ui_*.jpg`): empty state, village, select+move gizmo, moved, rotate, marquee
(+ result), assets (tile models, objects, textures), level settings, layers (collision), build, drag &
drop, new island, placed objects, play-test dialog, dark theme, shortcuts, new-level and
open-original dialogs, LlamaMod header button, game.
## Known limits / next steps
* Rotation only about the vertical axis (rx/rz via the inspector); scale only for brushes.
* Brush preview recompiles all brushes on every brush change (fine for dozens; a large level may
need per-brush compilation). Generated brush tiles are picked by bounds heuristics.
* Tile models without tiles have no "place" preview; dropping places in the cell under the cursor
with grey vertex colours (no lighting bake for original tile models).
* Clipboard is in-memory (same window), paste offsets by one step per paste.
* The LlamaMod mod list shows a newly installed play-test level only after restarting LlamaMod
(`AppState.registry` is created once).
* Not tested on Windows hardware (the viewport was tested under Wine in phase 1).
+203
View File
@@ -0,0 +1,203 @@
# Level-Geometrie (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC)
Stand: 2026-11-06. Quelle: groove.exe (Ghidra-Dekompilat `re/groove_decomp.c`, Capstone) +
Datenanalyse aller 34 Level-WADs. Tools: `tools/geomlib.py`, `tools/geom_export.py`,
`tools/geom_import.py`, `tools/geom_demo.py`, `tools/disx86.py`.
Konfidenz: **[sicher]** = aus Code + Daten verifiziert (Round-Trip byte-identisch / Rendering),
**[wahrsch.]** = aus Daten plausibel, **[?]** = unbekannt.
## 1. Überblick: welcher Chunk ist was
Chunk-Dispatcher: `FUN_00558xxx` bei VA 0x55899e (Vergleich auf Tag-Konstanten, Tag als u32 LE
gelesen, z. B. 'CPTS' im File = 0x53545043).
| Chunk | Loader (VA) | Inhalt |
|---|---|---|
| WFPC | inline → `DAT_006da330` | u32 Feature-Flags des Levels (steuern MAP/STPC-Aufbau) **[sicher]** |
| TEXT | 0x4067b0 / 0x406d30 | Texturseiten (256x256, 16 Bit) + Texturtabelle (UV-Rechtecke) **[sicher]** |
| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | Sound (SMPC: 'cvg ' VAG-Samples; AMPC: 'ambi'/'VABp'; SRPC Sound-Referenzen) – **keine Geometrie** |
| **TRAK** | 0x42aac0 → 0x5563f0 | **Welt-Geometrie**: Kachel-Modelle (Render-Dreiecke + Kollisionspolygone) → globales Array `DAT_005846ec` **[sicher]** |
| **STPC** | 0x42ab50 | Objekt-("Strat")-Modelle (gleiches Modellformat), Skelett-Animationen, danach Strat-Skriptdaten (Namen wie "SinkingStatuePad") **[sicher für Modelle]** |
| **MAP** | 0x42ac50 | Kachel-Raster: Platzierung der TRAK-Modelle + Vertexfarben + Zonen-PVS (+ Strats/Pfade/etc., siehe objects.md) **[sicher]** |
| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | Lichter / Level-Info / ? – nicht Geometrie |
**Die Welt ist ein Kachelsystem (Croc-Erbe):** TRAK enthält N "Kachel"-Modelle in lokalen
Koordinaten (ungefähr −0.5..0.5 in x/z, können aber bis ±2.4 hinausragen). MAP platziert sie
auf einem W×H-Raster (1 Einheit = 1 Zelle) mit Höhe y und Drehung um y. Es gibt **keinen BSP**;
die Raumaufteilung ist das Raster selbst (Zell-Listen) + eine 32-Zonen-Sichtbarkeitstabelle.
## 2. Koordinatenräume [sicher]
* **Exportraum** (= MAP-Floats "A", = Vertexdaten von TRAK/STPC): rechtshändig, **y oben**,
Rasterzellen liegen bei x ∈ [0,W), **z ∈ (−H, 0]**. OBJ-Export benutzt diesen Raum.
* **Spielraum** (MAP-Fixpunkt "B", Kollision, Strats): (x, y, −z), 20.12-Fixpunkt (4096 = 1).
Der Renderer rechnet B zurück: pos = (Bx, By, −Bz)/4096, Winkel = rot·2π/4096.
* Kachel-Transformation (Vertex v lokal → Welt, Exportraum), a = rot/4096·2π:
`X = x + vx·cos a − vz·sin a ; Y = y + vy ; Z = z + vx·sin a + vz·cos a`
(verifiziert: 93 % aller Kachel-Randvertices treffen exakt einen Nachbarvertex; die drei
anderen Vorzeichen-/Spiegelvarianten liefern 65–70 %).
* **Kollisionsdaten liegen im Spielraum** (z gespiegelt gegenüber den Render-Vertices desselben
Modells). Beim Dekodieren nz negieren (92 % der rekonstruierten Kollisionsecken liegen
< 0.02 neben einem Render-Vertex).
* rx/rz der Kacheln sind in allen Levels 0; nur Drehung um y (praktisch 0/1024/2048/3072).
## 3. Modellformat (TRAK und STPC) [sicher]
Fixup-Funktionen: TRAK `FUN_005563f0(&ptr, n, &out)` (Header 0x84),
STPC `FUN_0041f770(&ptr, n, &out)` (Header 0x8c). Datei-Layout: **alle Header eines Aufrufs
hintereinander, dann die Daten der Modelle in Reihenfolge** (TRAK: ein Aufruf mit n=Anzahl;
STPC: n Aufrufe mit je 1 Modell → Header/Daten abwechselnd). Zeigerfelder sind in der Datei 0
und werden beim Laden gesetzt.
### Header
| Off | Typ | Bedeutung |
|---|---|---|
| 0x00 | 3×f32 | Mittelpunkt der Bounding-Box |
| 0x0c | 8×3 f32 | Ecken der Box, Reihenfolge (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Etwas größer als die Vertices (Rand nicht konstant, ~0.004–0.014; vermutl. aus Originalmesh). Für Frustum-Culling (`FUN_00402840`). |
| 0x6c | u16 | nVerts |
| 0x6e | u16 | nFaces |
| 0x70 | u32 | → Verts (Datei: 0) |
| 0x74 | u32 | → Faces (Datei: 0) |
| 0x78 / 0x7a / 0x7c | 3×u16 | Anzahl Kollisionspolygone in 3 Gruppen (Code summiert immer alle drei; Gruppenbedeutung [?]) |
| 0x7e | u16 | 0 |
| 0x80 | u32 | → Kollision (Datei: 0) |
| *nur STPC* 0x84 | u16 | = nVerts bei starren Modellen, 0 bei Skin-Modellen |
| *nur STPC* 0x86 | u16 | nBones (Skin-Modelle) – davor in den Daten nBones×u16 = Vertexzahl je Bone (Summe = nVerts, verifiziert) |
| *nur STPC* 0x88 | u32 | → Bone-Tabelle (Datei: 0) |
### Daten (in dieser Reihenfolge)
1. (nur STPC) nBones × u16
2. **Vertex** 24 Byte: `f32 x,y,z ; f32 nx,ny,nz` (Normalen geglättet; Lichtwerte kommen aus MAP-Vertexfarben)
3. **Face** 28 Byte (nur Dreiecke):
`u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` mit n = normalize((v1−v0)×(v2−v0)), d = n·v0
(verifiziert für alle Faces). Beim Laden wird `tex` durch Zeiger auf Texturtabelleneintrag
(`DAT_00581154 + tex*20`) ersetzt. Ebene dient dem Backface-Culling (Kamera·n − d ≥ ε).
4. **Kollisionspolygon** 32 Byte, nGruppeA+B+C Stück:
`u8 typ ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }`
* Ebene 0 = Fläche, Ebenen 1..4 = Kanten (Normalen **nach innen**, Punkt drin ⇔ n·p − d ≥ −Toleranz).
* Normalen ×127 (Code rechnet ×32 → 4096-Skala), d ×4096 (Fixpunkt) → lokale Koordinaten nur ±8 Einheiten!
* typ Bit0 = Viereck (4 Kanten), sonst Dreieck (3 Kanten, 5. Ebene = 0). Bit2 ≈ Normale zeigt nach unten (ny<0; Decken/Überhänge) [wahrsch.].
* material: Untergrund-Typ (0 = normal; 0x11/0x12 werden je nach WFPC 0x8000000 ignoriert, 0x1e = Wasser-/Todesebene [wahrsch.]; Wert landet im Spieler-Bodenstatus).
* Kollisionsabfragen: `FUN_004036d0` (Boden/Decke), `FUN_00403ad0` (Wände), `FUN_004042f0`, `FUN_004046e0`. Getestet werden **nur die Kacheln der Rasterzelle, in der der Spieler steht** (Zellliste aus MAP), Punkt relativ zu Kachel-B-Position, um −rot gedreht.
### Face-Flags (u16)
| Bit | Bedeutung | Konfidenz |
|---|---|---|
| 0x0008 | neuer Render-Batch: Renderzustand (Seite, Blendmode) aus diesem Face neu laden, vorherigen Batch zeichnen. Sortierung nach Seite/Zustand + Bit am Wechsel. | sicher (Code) |
| 0x0010, 0x0800, 0x0020 | UV-Ecken-Auswahl (s. u.) | sicher |
| 0x0400 | kein Backface-Culling (doppelseitig) | sicher |
| 0x4000 | Vertexfarben ignorieren (weiß) | sicher |
| 0x0001, 0x0002 | Sonder-Rendermodi (Sortierung/Transparenz; zusammen mit Globalflags 0x5f6ee8) | wahrsch. |
| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | in Daten vorhanden, Renderer (0x556510) wertet sie nicht aus | ? (vermutl. PS1-Reste) |
### UVs – die Engine hat KEINE freien UVs [sicher]
Jedes Dreieck zeigt eine Hälfte eines **Texturrechtecks** (Eintrag der TEXT-Tabelle).
UV-Rechteck: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. Mit m = Flag 0x20 (spiegelt U),
a = m?U0:U1, b = m?U1:U0 bekommen die Ecken (v0,v1,v2):
* ohne 0x800: (a,V1) (b,V1) (a,V0)
* 0x800, ohne 0x10: (a,V1) (b,V1) (b,V0) ← erste Hälfte eines Vierecks
* 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← zweite Hälfte
(Renderer `FUN_00556510` ab Zeile "if ((uVar6 & 0x800) == 0)"). Texturen mit Flag Bit0 sind
Flachfarben (u0,u1,v0 = R,G,B). 96 % der Welt-Faces haben 0x800.
## 4. TRAK-Chunk [sicher]
`u32 n ; n × Header(0x84) ; Daten Modell 0..n−1`. Alle 34 Level-WADs parsen exakt bis
Chunkende; `geomlib.build_trak(parse_trak(x)) == x` byte-identisch für alle Levels.
Typisch 100–650 Modelle, max. 422 Verts / 798 Faces pro Modell. Modell 0 ist in vielen Levels
eine unsichtbare Ebene (4 Verts, 0 Faces, 1 Kollisionspolygon, material 0x1e) bei y=−2.75 –
die Wasser-/Absturzebene unter den Inseln.
## 5. MAP-Chunk – Geometrieteile (Loader FUN_0042ac50) [sicher]
(Strat/Pfad/Zonen-Blöcke: siehe notes/objects.md; hier nur, was die Geometrie betrifft.)
Reihenfolge in der Datei:
1. `u32 N (Kacheln), u32 W, u32 H` (Raster)
2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (Exportraum; x,z = Zellmitte .5).
Nur zum Einsortieren in die Zelllisten benutzt: Zelle = floor(x) + floor(−z)·W.
3. u32 n + n×u32 (Liste, von Strats referenziert)
4. u32 n + n×90 Byte (Lichter/Kameras? – objects.md)
5. u32 n + n×34 Byte (Pfadknoten)
6. **Zonentabelle** 256 Byte = 32 × `{u32 Sichtmaske, i32 Höhenwert}` → `DAT_006da350`
7. **Zonenraster** W·H × u32 (Zonennummer 0..31 je Zelle) → world+0x48
8. **B**: N × `{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}` 20.12-Fixpunkt, Spielraum (z = −A.z), ry = A.rot.
**Wird zum Rendern und für Kollision benutzt.** A und B sind in allen Levels konsistent.
9. **Modellindex**: N × u32 (= A.model)
10. falls WFPC & 0x10000: `u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}` –
Kachel-Wechsel (Türen/Brücken o. Ä.), Eintrag k gehört zur k-ten Kachel mit animFlag≠0.
11. **Vertexfarben** je Kachel i: nVerts(model) × u32 RGBA (0x80 = 1.0, Spiel ×2 mit Clamp);
Byte3 des ersten Eintrags = Anzahl Farbsätze (0/1 = einer; ≥2 → weitere nVerts×u32 folgen,
zweiter Satz wird bei `DAT_005865d4==1` benutzt). Für animFlag-Kacheln danach dieselbe
Struktur für modelB. Zeigertabelle world+0x58 (bei WFPC 0x10000000 doppelt groß).
12. Rest (u32 n2,n3 + 58-Byte-Strat-Einträge, optional Pfade (0x200000), Sichtweite (0x10), u16) – objects.md.
`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduziert alle MAP-Chunks byte-identisch.
### Raster / Sichtbarkeit (kein BSP) [sicher]
* Zelllisten: world+0x40 (W·H Zeiger, verkettete Listen von Kachelindizes), aus A gebaut.
* Rendern (`FUN_0042bf40`): Rechteck um die Kamera (Sichtweite) über das Raster; für jede Zelle
alle Kacheln der Liste, aber **nur wenn Bit zone[Zelle] in der Maske der Kamerazone gesetzt ist**
(`DAT_0058642c` = Zonentabelle[zone[Kamerazelle]].maske, `FUN_0041e020`). Zone 0 hat im
Dorf-Level Maske 0x1. → Neue Geometrie muss passende Zonen bekommen, sonst unsichtbar.
`geom_import --zones flat` setzt Raster=0 und Maske(Zone 0)=0xffffffff (alles sichtbar).
* Kollision: nur Kacheln der Spielerzelle (s. o.).
## 6. STPC-Chunk (Objekte) [sicher bis Animationen]
`u32 n1 ; n1 × (Header 0x8c + Daten)` statische/geskinnte Objektmodelle (33 im Dorf) ;
`u32 n2 ; n2 × Animation` (`FUN_0041f8b0`, Header 32 Byte: +0 nA (nA×4 Byte), +8 nFrames,
+0xc (0 → nFrames×8 Byte), +0x10 nDelta (Frame 0: nDelta×8, sonst nDelta×2 (+2 wenn ungerade)
Byte komprimierte Vertexdeltas), +0x18 nBones (je Frame nBones×64 Byte = 4×4-Matrix)) ;
falls WFPC & 0x100: `u32 n3 ; n3 × {16-Byte-Kopf (+4 Anzahl, +8 → n×8 Byte), Modelle}` ;
danach Strat-Daten (Skripte, Namen) – nicht vom Lader geparst, über Offsets (`DAT_006d9dbc+off`)
aus MAP referenziert. `geom_export --objects` exportiert die n1 Modelle nebeneinander.
## 7. TEXT – soweit für Geometrie nötig [sicher]
`u32 nPal ; nPal×{u32, u32 n, n×u16, n×u8}` ; `u32 nPages ; nPages×{u32 flags, u32 w, u32 h,
(flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h}` ; `u32 nTex ; nTex × 8 Byte` ; Rest (40 Byte im Dorf) [?].
Textureintrag: `u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1` (u1/v1 inklusiv).
Flags: Bit0 Flachfarbe, Bit2 1-Pixel-Rand, Bit3/Bit4 Breite/Höhe ×2, Bits 12–14 Blendmodus, Bits 8–10 weiterer Modus.
Pixel: ABGR1555 (R = Bits 0–4), 0 = transparent. Details: notes/textures.md (anderer Agent).
## 8. Neue Geometrie schreiben – was konsistent sein muss
Umgesetzt in `tools/geom_import.py` (OBJ → TRAK + MAP-Kachelteil, Rest des WADs unverändert):
1. **TRAK**: u32 Anzahl; Header mit korrekten nVerts/nFaces/nKoll; Box+Mittelpunkt (für Culling,
wird neu berechnet); Face-Ebenen aus Vertices; Vertexnormalen. Keine Prüfsummen.
2. **Face-Texturindex** < Anzahl Texturtabelleneinträge; UVs nur über Rechteck-Ecken (+Flags).
Bit 0x8 am Batch-Anfang (Seiten-/Zustandswechsel, spätestens alle 256 Faces).
3. **Limits**: ≤ 8000 Vertices pro Modell (Transformpuffer 0x58b0f0..0x5d15f8, 36 Byte/Vertex),
Batch-Ausgabepuffer 0x5d15f8 ≈ 4800 Vertices; Kollisions-d als i16/4096 → Kollisionspolygone
nur innerhalb ±8 Einheiten vom Kachelursprung. u16-Indizes.
4. **MAP**: N in Kopf, A (Floats) und B (Fixpunkt, z negiert) identisch halten, Modellindizes
< TRAK-Anzahl, Kachelzellen innerhalb W×H, **Vertexfarben-Block exakt nVerts(Modell) pro
Kachel** (sonst verschiebt sich der Rest von MAP = Strats!), Animationsblock (WFPC 0x10000)
passend zu den Flags (Import setzt ihn auf 0 Einträge), Zonen (s. o.).
5. Kollision gehört zur Zelle: ein Polygon wirkt nur, wenn es in der Kachelliste der Zelle steht,
in der der Spieler steht → Import trägt jedes Polygon in jede überdeckte Zelle ein
(auch als reine Kollisionskachel ohne Faces, wie Original-Modell 0).
6. WAD-Gesamtlänge (wadtool/geomlib schreiben sie neu). Keine weiteren Prüfsummen gefunden.
7. Strats (Startpunkt, Gegner, Kamera-Pfade) liegen weiter an ihren alten Positionen (objects.md).
### Tests (ohne Spielstart)
* Round-Trip TRAK und MAP aller 34 WADs byte-identisch.
* Export → Import (`--coll-obj`) → Export t1l1m001: Rendering identisch (img/), 26435/26442
Dreiecke (7 entartete fallen weg), Bodenhöhe an 2800 Zufallspunkten: 99 % < 5 mm Abweichung,
10 Punkte anders (Polygone jetzt auch in Nachbarzellen eingetragen).
* Unabhängiger Gegencheck: `tools/mapchunk.py` (anderer Agent) parst die erzeugten MAPs vollständig.
* Noch NICHT im Spiel getestet: Demo-WADs in $CLAUDE_JOB_DIR/tmp: `demo_add.wad` (Original +
2 Blöcke + Rampe, Zonen original), `demo.wad` (alles ersetzt durch Bodenplatte y=0 + Blöcke).
## 9. Offen / unbekannt
* Bedeutung der drei Kollisionsgruppen (Summe wird immer zusammen benutzt) und vollständige Materialliste.
* Face-Flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; genaue Wirkung 0x1/0x2.
* Exakte Herkunft des Box-Randes im Header (Neu-Berechnung mit 1/128 Rand reicht vermutlich).
* Zonentabelle 2. Wert (Höhe, `DAT_00586424`) – evtl. Wasser-/Nebelhöhe.
* STPC-Animation: Bedeutung von nA-Block und Delta-Kompression im Detail (`FUN_0041fa30`).
* Ob der Spieler-Startpunkt/Kamera an Geometrie gekoppelt ist (objects.md).
## 10. Werkzeuge
* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, Vertexfarben, ein
Objekt pro Kachel), page_NN.png, collision.obj (Gruppen matNN_grpG), objects.obj, preview_top/persp.png
(texturierter Software-Renderer).
* `geom_import.py BASIS.wad MESH.obj NEU.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]`
* `geom_demo.py BASIS.wad demo.obj [--plaza]` – Beispielgeometrie aus Texturkachel-Vierecken.
* `geomlib.py` – Parser/Builder (TRAK, STPC-Modelle, MAP-Kachelteil, TEXT-Tabelle/Seiten, Kollision).
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Loaded 100 of 826 files, more files were not shown because too many files have changed in this diff. Show more