Files

26 KiB
Raw Permalink Blame History

Level building with tools/levelbuild.py

As of: 2026-11-06 (exit + parkour reworked). Builds complete level WADs from a level description (Python script). The base is always an original level (default: village t1l1m001.wad): its strat code/object models (STPC), textures (TEXT), sound etc. are taken over; geometry (TRAK), tile grid, vertex colours, objects and waypoints (MAP) are generated completely from scratch. Result checked in game: out/testwelt.wad (= installed test world, see below).

Invocation

python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad            # build
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install  # + install as TestLevel
python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel  # pass variables to the script
python3 tools/levelbuild.py --types                 # placeable strat types of the base level
python3 tools/levelbuild.py --textures tex.png      # texture contact sheet + name list
python3 tools/texsheet.py WAD out.png [--all] [--only 1,2,3] [--size 96]

--install copies to ~/.wine-lama/mods/testlevel/wads/T1L9M001.wad (creates .orig once) and creates the chapter symlinks 2..49 (see "Exit"). Test in game: tools/lamatest.py run out/x.wad outdir (see testharness.md).

Level script

LEVEL_ARGS = dict(name='Testwelt', seed=7)      # NAME chunk, random seed (texture rotation)

def build(L):                                     # L = levelbuild.Level
    L.sea(0, 0, 88, 72, y=-2.0, kill=(6, 4, 80, 66))   # sea + death plane
    L.box(14, 12, 26, 24, -0.4, 0, top='stein', side='stein')
    L.stairs(26, 18, 29, 22, 0, 1.5, 3, '+x')
    L.player(20, 14.5, rot=0)
    L.checkpoint(17, 18.5)
    L.exit(70.5, 12.5)
    L.strat('Bunny', 66, 34)
    L.coins([(27, 20), (28, 20)])

def report(L):                                    # optional, after building
    ...

L.opts contains the --set values (strings).

Coordinates

  • Game coordinates as for strats: x towards east, y up, z = grid row (positive, "south"), 1 unit = 1 grid cell. Village grid: 96×96 → everything must lie within x,z ∈ [0,96).
  • Angles in degrees, 0° = facing +z, 90° = facing +x (checked in game).
  • Internally converted into export space (z mirrored); winding order is corrected automatically (_orient), you only specify the desired outward normal.

Geometry building blocks

Method Description
floor(x0,z0,x1,z1,y, tex, rot='random', coll=True) horizontal surface (subdivided into 1×1 tiles)
box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None) cuboid; side_top = texture of the topmost wall row (e.g. 'graskante'), side_coll=False = walls without collision (low plinths walkable)
platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde') floating platform, top edge y
pillar(x,z,y0,y1,size, side, top) pillar around a centre point
ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base) ramp, rises in direction +x/-x/+z/-z
stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True) stairs; walkable: invisible slope as collision
fence(pts, y, height) fence (posts + rail) along a point list
tree(x,z,y, height, crown) blocky tree
sea(x0,z0,x1,z1, y, tex='meer', kill=(...)) water surface without collision + death plane
quad([BL,BR,TR,TL], tex, coll, rot) single quad (corners seen from the front)
collider(pts, normal) invisible collision surface
obj(path, offset, scale, tex) OBJ file (game coordinates), usemtl tex_N or texture name
height_at(x,z) height of the topmost walkable surface

Textures: name from TEX (village: gras, gras2, gras_dunkel, blumen, sand, erde, graskante, stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rinde, kiste, inka, muster_lila, …), a direct index (int) or a flat colour ('meer', 'rot', … or (r,g,b); appended to the texture table as a flat-colour entry (flags 0x105, page −1)). Contact sheet with indices: notes/img/texturen_dorf.png.

Texture names on other bases (2026-11-07). The TEX indices are village (t1l1m001) indices; on another base the same index is a different picture (village 2: index 1 = dark red floor). Names are therefore resolved per base by picture: levelbuild.BaseTextures holds a fingerprint (FNV-1a over page format, size and pixels of the entry's rectangle) of every named village texture; a name maps to the village index if the base shows that picture there (village 1: always → outputs unchanged), else to the first base entry with the same picture (flags 0x104 preferred). Coverage of the 47 names: t1l1m002 38 (missing: the three flower textures, wood_grain*, wood_brown*, wood_rings, wood_edge), t1l1m003 29, t1l1m004 28, t1l2m001 9, t1l3m001/t1l5m001 0–2.

  • Java builder (LevelBuilder, test world/arena/jump lab, fromCd): a missing picture is copied from the village (CD file loader wads/t1l1m001.wad): packed onto a new 256×256 page (same page flags, 1 px clamped border) and appended as an entry after the base table; flat colours and copies share the appended range in order of first use. Without a loader (editor brushes: BrushCompiler passes none) the name is rejected: "texture 'flowers' is a village texture this base level does not have …" (the editor shows that as a brush warning and only offers the names the scene's base has). LevelBuilder.baseTextureNames() lists the names a base has.
  • Python tools/levelbuild.py --base: same matching (md5 of RGBA rectangle); missing names raise a KeyError (no copying).
  • In game (apptest, display :22, 2026-11-07): Java-built level on t1l1m002 (grass box, stone box, sand, trees, plus copied flowers, wood_rings, wood_grain), --editor-import → level mod, Village chapter 2: grass green, stone grey, trees bark + leaves, copied textures correct (notes/img/editor/village2_levelbuild_textures.jpg, …_textures2.jpg). Before: grass = dark red (village2_levelbuild_start_camera.jpg).
  • Editor (display :22): on a t1l1m002 scene the brush swatches and the texture grid show the base's own pictures for the names, without the names it lacks (notes/img/editor/village2_texture_picker.jpg). Every triangle shows a whole texture tile (the engine has no free UVs) – so everything is split into 1×1 pieces; narrow/flat walls show the texture squashed.

Lighting (vertex colours)

L.sun = dict(azimuth=215, elevation=50, color=(1,.93,.8), strength=.62, sky=(.5,.55,.66), ground=(.3,.28,.24), ao=.55, ao_radius=1.3, shadow=.35) – per vertex: sky/ground hemisphere light + sun (Lambert) with shadows (ray towards the sun against all cuboids) + ambient occlusion (24 rays, range ao_radius). Cuboids, pillars, stairs, platforms register as shadow casters (solids). 1.0 = texture colour (vertex colour 0x80), max. 2.0. tint=(r,g,b) multiplies per building block.

Objects (strats)

L.strat(name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False)

  • y=None: place on the topmost walkable surface at (x,z).
  • Type fields (nvars, slots, flags, extra memory) and parameters are copied from the first original instance of the type (params=[…] overrides; count must match).
  • variant: types with several code entry points (e.g. SmashPot: 2 spawn stubs).
  • Waypoints (wp): 'relative' = move the original's waypoint chain along (offset to the object is kept, y is set to the floor if there is one), 'here' = all points on the object, 'none' = none (−1), or your own list [(x,z[,y[,deg[,unk2]]]), …] (other extra fields of the original waypoints are taken over). Chains are renumbered (no references into the old level).
  • player(x,z,rot) (strat Llama), checkpoint(x,z) (CheckPointPost), exit(x,z) (LevelDoor), coins([(x,z),…]).
  • Types that are in the village code but not placed there, via Strats.BORROWED with type fields from another level: currently RubyIdol (from t1l1m002).
  • poolExtra is set to max(original, number of strats + 80); the original's parameter pool is kept, new parameters are appended.

What build() generates

  1. All triangles are distributed onto tiles by (grid cell, height band of 10 units from y = −3); tile origin = cell centre/band centre (collision d must be within ±8).
  2. Collision: every quad with coll=True becomes a collision polygon; floors only in their own cell, walls/slopes additionally in neighbouring cells up to 0.3 distance (the engine only checks the tiles of the cell the player stands in). Unused helper vertices make sure the tile box encloses the collision polygons.
  3. Identical tile models are merged (vertex colours are stored per tile in MAP).
  4. Death plane: original model 0 of the village (invisible, material 0x1e) in every cell of the kill area that is not completely covered by floor.
  5. MAP: zone grid = 0, zone 0 sees everything (mask 0xffffffff), no tile swaps (alt block empty), tile blocks A/B/models/vertex colours consistent, strats + waypoints new, light/ environment zones (90-byte block) and rest unchanged.
  6. TEXT: appended flat colours. NAME: LEVEL_ARGS['name'].
  7. Cross-check: MAP is fully parsed again after writing.

Findings from the game tests (lamatest, Xvfb)

  • Scale: llama ≈ 0.6 units tall, walks ≈ 1.1 units/s (coin measuring track, see below; the 0.85 noted earlier was too low), needs ~1 s to accelerate. A jump (X) clears 0.35 high edges, 0.5 not. With a run-up it flies ~1.3 far (horizontally as fast as when walking), onto +0.2 it lands after ~1.05; from standing much shorter (0.5 gap not cleared). → Parkour: gaps 0.3 with +0.2, platforms before/after ≥ 1.5 long (after landing there must be room for the next take-off), larger heights via stairs/ramps. Measuring level: levels/sprunglabor.py (--set spur=a..g|m; m = coin measuring track: a coin every 0.25 → coin counter × 0.25 = distance walked; f = long jump over water).
  • No step logic: even a 0.12 high edge with wall collision blocks the llama. Without wall collision (side_coll=False) it walks up onto low plinths; stairs with an invisible slope (stairs(walkable=True)) and ramps are walkable (checked).
  • Controls are camera-relative (Up = camera view direction). The start camera is behind the llama; if there is an obstacle right behind it (e.g. a post), it moves aside → keep the area behind start points clear.
  • Rot 0° = facing +z, 90° = facing +x.
  • Death plane works: falling into the sea → health gone, respawn at the last checkpoint (or start).
  • Player waypoint chain = start camera positions (the game takes the first node within 2.5 cells of the llama, see editor.model.cmd.PlayerStart): player() gives the llama ONE node 2 cells behind it, 0.5 above, unk2 = 0 (default camera distance/height) – like the editor's player start. Until 2026-11-07 it copied the prototype's 14-node chain (first node 8 behind = out of range, the rest on the llama): on village 2 bases (node 2 has unk2 ≠ 0) that put the camera into the llama's head. Waypoint lists take an optional 5th value unk2: [(x,z,y,deg,unk2)]. In game (test world, apptest 2026-11-07): the start camera is behind the llama at the default distance (notes/img/editor/testworld_start_camera.jpg). Village 2 base (levelbuild floor + player on t1l1m002, --editor-import as an edit project, boot-to-level 1/2): camera behind the llama, not in its head (notes/img/editor/village2_levelbuild_start_camera.jpg).
  • LevelDoor (exit) – solved, see section "Exit (LevelDoor)" below.
  • Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB (originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models, 2474 tiles, TRAK 646 KB, WAD 6.3 MB.

Exit (LevelDoor) – how it works

Corrected 2026-11-07 (in game, Jungle Day 2): the listing below was the first reading. The LevelDoor only sets g101–g105 and checks global 0x21 once; the level is ended by the Llama's script (material 16 + exit cell + at most ~0.6 above the floor). See "Exit via stale floor material" further down and docs/reference/objects.md.

Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the VM FUN_0054d180 (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):

g65,g66,g67 = param 0,1,2            ; tribe, world, chapter of the next level (20.12)
loop:  if g21 == 3                    ; game state "running"
   and player.floor.material == 16     ; property 0x11 = *(strat+0xc0)+4 = material byte of the
                                       ; collision polygon below the player (FUN_00403020)
   and floor(player.x) == floor(door.x) and floor(player.z) == floor(door.z)   ; same grid cell
   -> op 0x192 (FUN_0054d620): level change(param), g1 += 1, end
   yield
  • So the door is only a trigger; in the village there is a floor polygon with material 16 in the doorway (8 of them in the village, also in all other levels). Our test world had material 0 everywhere → nothing happened. Fix: mosaic in the gate with mat=16 (box(..., mat=TUER_MAT)), door in the same grid cell, the player's path leads right through it.
  • Only tiles of the door cell count (cell list, FUN_004036d0), not the height (t1l3m002–4: floor 3 units below the door). Village 1 & co.: the door cell initially contains no material 16 – it is in the swap model of the tile (MAP tile swap, WFPC 0x10000): the RubyIdolDoor in the same cell swaps tile 420 from model 48 to 49 when opening (op 0x11a/0x11b), and only model 49 has the exit pad (42.25–42.75 / 49.23–49.50, y 3.25). Checked in game (2026-11-07): model 49 inserted + door removed, llama at 42.5/3.5/49.37 → immediately the summary "Dorf Kapitel 1"; original (door closed) → nothing. Affects 8 of the 31 original doors (Ruby, PressurePad, Lab doors); the pad at 43.5/48.45 (cell 43,48), on the other hand, is a house entrance (zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
  • Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell = g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).

Exit via stale floor material (hover + position jump, t1l4m002) – checked 2026-11-07

Speedrun video (position/hover hack): Kuzco hovers above a house-door stump, is moved to the exit and the level ends. Reproduced in game (apptest, gdb through winedbg --gdb, position written by gdb):

  • Jungle Day chapter 2 = t1l4m002 (LNFO 95 coins / 2 secrets; tally "Dschungeltag Kapitel 2, 0/95, 0/2"). LevelDoor at 44.5/25.5 (cell 44,25), params (1,4,3); RubyIdolDoor in the same cell. Cell 44,25 (tile 255) has material 16 only in swap model 95 (pad x 44.67–45.0, z 25.25–25.75, y 2.25); main model 94 has none → with the door closed the exit cannot fire. House-door pads (material 16, main models) in cells 38,20 / 51,0 / 52,5 / 41,24 / 44,27 / 50,27 / 41,29 / 57,55 / 41,38 / 41,44 plus swap pads in 22,21 / 26,21 / 23,23 / 50,1 / 43,21 / 46,30 / 58,49 / 31,50; the zones pair them (e.g. 44,27 → 50.5/27.5, 50,27 → 44.5/27.5).
  • Floor test (FUN_004036d0): only tiles of the player's own cell; per upward polygon the plane height at (x,z) is compared with a probe point (y + 0.25, or y + (floor record +0x18)/2 with +0xe8 flag 0x200000 as for the llama); the nearest floor below the probe wins, with no distance limit. Material 17 is skipped for the llama (+0xe8 = 0x24882a, no 0x8000000). If nothing is below: for the llama (+0xe8 & 0x40000) material/height are not updated (stale) and x/z go back to the previous position. In game: llama held at 44.9/6.0/27.5 (3.7 above the pad of the stump at 44,27) → floor record h 2.30, material 16, tile 282 poly 15, every frame.
  • Order per tick (FUN_0054c4a0): all strat scripts (VM FUN_0054d180) first, then the collision pass (FUN_00403020 for every strat with +0xe8 & 0x8000). A position written after the collision pass is seen by the scripts of the next tick with the old floor record. In game: after hovering above the 44,27 pad, llama set to 44.5/2.3/25.5 right after its floor test → next event endLevel FUN_0054d620 (called from the VM, floor record still 16), before any new floor test → tally (twice with screenshot, once traced). Control: same jump from normal ground (material 0) → no exit; jump written just before the floor test → record becomes 0, no exit.
  • Who ends the level here: the Llama, not the LevelDoor. LevelDoor bytecode: wait 5 ticks, g101..g105 = params + cell, then once if global 0x21 != 3 → end (so "g21 == 3" above is a one-time check, and global 0x21 is not the game state 0x5846e4), then the per-tick loop. In t1l4m002 the LevelDoor was gone from the strat list (scan of 0x6d9e38) while g101..g105 = 1,4,3,44,25 and global 0x21 = 0. The Llama block at STPC 0x39ff4c–0x3a0064: if floorMaterial == 16 and health > 0 and (y < floorHeight + 0.6 and global 0x21 != 2, or y < floorHeight + 0.025 and (g1 == 0 or global 0x23)): cell == (g104, g105) → level change(g101..g103), else fade-out + getDoorPos (op 0x100, zone teleport = house door) + forceCollision. The 0.6 limit is why hovering high above a house pad does not trigger the house teleport (bytecode; not separately tested), while landing at floor height in the exit cell with the stale record does.
  • Not explained: in two sessions with a moved player start (boot-to-level start 43.3/25.5) the same jump did not end the level (Llama script ran with record 16 in cell 44,25, no endLevel).
  • Images: img/debug/exit-hover-trick.jpg (top: video; bottom: our pool/door platform, tally).
  • Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a world (7,7,7) = "world completed" → back to the selection map, unlock the next world.
  • FUN_0054d620: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the summary (coins x/55, secrets) with "EINGABETASTE = speichern / ESC = weiter" (Enter = save / Esc = continue). Progress 0x585018/19 (max. world/chapter, in KSV +0x42/+0x43): if you are in the highest unlocked chapter, the next chapter is unlocked; with (7,7,7) the next world.
  • (7,7,7) in world 9 crashes: unlocks "world 10" → after saving, the map cursor is on world 10, world name pointer 0x57bd40[9] = NULL → page fault at 0x41B5CC.
  • Solution for the test world: parameters (1,9,1) – after the summary chapter 1 is reloaded (no crash, tested with saving). Side effect: chapter 2 is unlocked (and from chapter n, chapter n+1). If its WAD is missing, the selection crashes ("Couldnt load wad"). That is why levelbuild.py --install (function kapitel_links()) creates in the mod symlinks T1L9M002..049.wad → T1L9M001.wad and T192..T1949.COR → T191.COR (world 9 has 49 chapter slots, table 0x572420) and calls lama-mod an. For this, lama-mod now also takes symlinks from mod folders (find … -type f -o -type l). Checked: chapter 2 on the map is selectable and loads the test world.
  • Only chapters 1 and 32–49 are safe for world 9: the per-chapter coin counter and flag byte of the other chapters overwrite game globals (chapter 9 destroys the furthest progress), see testlevel-chapters.md.
  • The selection map shows no background for world 9 (only text) and 772 as the coin target (coin table 0x5722f4 only has worlds 1–8) – cosmetic.

The test world (levels/testwelt.py → out/testwelt.wad, installed as T1L9M001.wad)

Rebuilt compactly on 2026-11-06 ("everything closer together"): L-shaped island, main part 32.5×23.5 (x 11..43.5, z 9.5..33) + strip below the parkour end (x 43.5..49, z 9.5..20.5), previously 42×34 (x 10..52, z 8..42). Gallery, enclosure and parkour adjoin each other directly, no empty meadows any more. NAME chunk "Testwelt" (not shown anywhere in the game). Preview images (phone, 1280 px): notes/img/vorschau/ (01–03 Blender renderings with labels, 04–11 game shots, 12 montage). Older images notes/img/testwelt_*.jpg still show the old, sprawling layout.

Area old new
Start square x 14..22, z 11..19 (8×8) x 15..21, z 12..19 (6×7)
Gallery z 22..38, columns 2.5, rows z 25/28.5/32/35.5 x 14.5..32.5, z 20.5..32, columns 1.9, rows z 22.5/25.2/27.9/30.6 (spacing 2.7)
Enclosure x 39..49, z 24..35 x 33..42, z 20.8..30 (directly east of the gallery)
Parkour x 22..53.6 x 21..52.6 (only shifted by −1 in x, same dimensions)
Exit LevelDoor (51.5/15.5) LevelDoor (50.5/15.5), cell 50/15, mosaic with material 16
  • Start square (x 15..21, z 12..19): stone square with Inca mosaic, 4 Inca pillars, flower beds, trees, 2 coins, checkpoint (16.1/17.4). The llama starts at (18, 14.6) facing south (gallery directly behind it, parkour stairs on the left of the picture).
  • Gallery (z 20.5..32): 4 rows, each object on a flat plinth (0.9 wide, 0.08 high, walkable), gap 1.0 between plinths, sand path (0.6) in front of each row, coloured post at the start of the row (yellow/grey/blue/red), main path north–south at x 15.6..16.3:
    1. Collectables: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret
    2. Crates/pots: SmashPot (2 variants), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport
    3. Characters: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy
    4. Mechanics: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage, RisingColumns, SinkingStatue, FallingTree, CheckPointPost
  • Enclosure (x 33..42, z 20.8..30, fence, entrance on the west side z 20.8..24): Bunny ×2, Ladybird, FlockOfBirds, VillagerHerder; viewing pedestal (0.6) with stairs on the west side between gallery rows 2 and 3.
  • Parkour (starts at the east edge of the square, everything on the line z = 15.5 going east, so it can be done without rotating the camera – rotating via A/S or Left/Right was not reproducible in scripts). Geometry is written in the script as "old x + PX" (PX = −1): stairs 0→0.8 · stop step +0.3 (no gap: the llama runs into it and stands still) · 2 gaps of 0.3 with +0.2 each onto 1.5 long wooden blocks · beam 0.5 wide · middle platform (1.5) with checkpoint · sand ramp 1.5→2.4 · stop step +0.3 · 3 stepping stones (1.5 long, gaps 0.3, +0.2 each) · narrow beam 0.45 · stairs 3.3→4.0 · goal platform (4.0; x 47.6..52.6, juts out over the coast) with pillars, parapet and gate; below it a mosaic with material 16, LevelDoor at (50.5 / 15.5). 18 coins along the way. Below the parkour there is meadow (falling down is harmless).
  • Scripted run (start → goal → exit → summary), old version 4/4, compact version 2/2 runs successful (summary 18/55 coins); the .seq is time-based/relative and stayed unchanged:
    python3 tools/levelbuild.py levels/testwelt.py out/testwelt_parkour.wad --set start=parkour
    lamatest.py run out/testwelt_parkour.wad OUTDIR --seq "$(cat levels/testwelt_parkour.seq)"
    
    (run:T:x@t,… holds Up for T s and presses X at fixed times; the stop steps reset the timing error. Shots (shot@) block ~0.4 s → never shortly before a jump.)
  • Checked in game: loading without a crash (many runs), all 31 gallery objects appear (coin/secret/… react: coin counter, "GEHEIMNIS!"), the llama walks through gallery/enclosure, up the stairs, jumps onto platform B and onto stepping stone 1, up the ramp, stairs to the goal platform, sea = respawn, complete parkour by script, exit → summary → chapter 1 again. Images: current notes/img/vorschau/ (compact layout); notes/img/testwelt_*.jpg = old sprawling layout (1–6 + montage even still with the old parkour).
  • Start point for tests is switchable: --set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 (podest = on the viewing pedestal, looking west over the gallery); exit parameters --set tuer=1,9,1.

Village types not placed

Type Reason
Llama is the player (start)
LevelDoor exit at the end of the parkour (needs a floor with material 16, see above)
CameraSide camera zone, invisible
CutSceneCameraDirectional cutscene camera, invisible
CutSceneTrigger starts a cutscene
No placeable type crashed the game (no bisection needed).
Note: mechanics objects (doors, pillars, statue) belong to specific geometry /
tile swaps in the village; in the gallery they stand "bare" (e.g. a floating RubyIdolDoor sign).
Types that only exist in the code (BouncingCoin, Sparkles, RubyIdolStatue …) have no
original instance with type fields and are not offered (exception: RubyIdol via Strats.BORROWED).

Preview renderings (Blender)

/usr/bin/blender (Debian 3.0.1): Cycles renders only black (even the default scene), OCIO is missing ("Color management: fallback", only the "Standard" view). Eevee works, but needs an X display → own Xvfb :7 (never :0, :5 belongs to lamatest). Procedure (scripts in tools/vorschau/: strats_json.py (call from levelbau/), render_level.py, label.py):

  1. geom_export.py out/testwelt.wad GE → level.obj/mtl + page_NN.png
  2. Strat positions: run the level script with levelbuild.Level (without L.build), L.strats as JSON
  3. DISPLAY=:7 STRATS=strats.json VIEWS=schraeg,schraeg2,oben blender -b -P render_level.py -- GE r 64: own OBJ reader (the 3.0 importer does not know vertex colours), OBJ (= game space since 2026-11-07) → Blender z up (y_blender = −game z, winding kept), material = texture (closest) × vertex colour, a little emission, sun + sky, markers for strats (coins gold, llama start orange cone, gallery objects in row colour, animals white, exit purple ring); writes PNG + JSON per view with image coordinates of the area names.
  4. PIL: scale to 1280 px, labels (Start/Galerie/Gehege/Parkour/Ziel), JPG q85.