# 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 `/levels/.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 `-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 `/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~~ – fixed 2026-11-07 (edui): `ErrorDialog` (all `ctx.error` calls) shows one plain sentence ("The project file is damaged.", "The file x does not exist.") and the exception + stack trace behind **Details**. * ~~After a failed open, the status bar keeps "Loading "~~ – fixed 2026-11-07 (edui): a failed background task sets " failed"; EditorStress `corrupt` now fails if the status still says "Loading…". * ~~`GlRenderer.dispose()` does not delete `dynVao`/`dynVbo`~~ – fixed 2026-11-07 (edgeo), see editor-render.md. * ~~`SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes~~ – fixed 2026-11-07 (edgeo): dropped every 256 removals. * ~~Tile-paint erase strokes are one undo step per tile~~ – fixed 2026-11-07 (edgeo): `cmd.Stroke`, one step per drag. * `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.