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)
No files matched your search
@@ -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.
|
||||
@@ -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\`).
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
* [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**).
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
* **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:
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
@@ -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)**.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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`.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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`.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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`).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Level editor – texture tools (`editor.tools.textures`)
|
||||
|
||||
Plugin `TexturesEditorPlugin` (phase 3). User guide: `editor-guide.md#textures`.
|
||||
|
||||

|
||||
|
||||
## 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`)
|
||||
|
||||

|
||||
|
||||
* 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`)
|
||||
|
||||

|
||||
|
||||
* 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`).
|
||||
@@ -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`.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 177 KiB |