translated the remaining german notes, python comments, help and messages to english (in-game strings, parsed report lines and cli values stay german)

This commit is contained in:
MineTec committed 2026-11-07 10:51:14 +01:00
1 parent 86fe26d786
commit 526f6072f6
36 files changed
+1553 -1544

No files matched your search

+11 -11
View File
@@ -1,5 +1,5 @@
"""Sprunglabor: misst, wie hoch/weit das Lama springt (fuer Parkour-Abstaende).
Spur A (z=20): Stufen mit wachsender Hoehe; Spur B (z=26): Luecken mit wachsender Breite."""
"""Sprunglabor ("jump lab"): measures how high/far the llama jumps (for parkour spacing).
Lane A (z=20): steps of increasing height; lane B (z=26): gaps of increasing width."""
LEVEL_ARGS = dict(name='Sprunglabor')
@@ -7,8 +7,8 @@ def build(L):
L.sea(0, 0, 60, 40, y=-2.0, kill=(4, 10, 56, 34))
lane = L.opts.get('spur', 'a')
if lane == 'f':
# Weitsprung ueber Wasser (Sturz = Respawn am Start): Startinsel, dann 2 lange Bloecke auf
# gleicher Hoehe mit Luecken 0.5/0.7/0.9/1.1/1.3; Stufen-Spur g daneben (z=26): +0.2/+0.3 mit 0.4
# long jump over water (fall = respawn at start): start island, then 2-long blocks at the
# same height with gaps 0.5/0.7/0.9/1.1/1.3; step lane g next to it (z=26): +0.2/+0.3 with 0.4
L.box(8, 17, 14, 29, -1, 0, top='stein', side='stein_kante', rot=0)
x = 14
for i, gap in enumerate([0.5, 0.7, 0.9, 1.1, 1.3]):
@@ -24,17 +24,17 @@ def build(L):
return
L.box(10, 14, 50, 30, -1, 0, top='gras', side='erde', side_top='graskante')
if lane == 'm':
# Muenz-Messbahn: Muenze alle 0.25 ab x=12 -> Muenzzaehler = zurueckgelegte Strecke x4
# coin measuring track: a coin every 0.25 from x=12 -> coin counter = distance travelled x4
L.coins([(12 + 0.25 * i, 20) for i in range(int(L.opts.get('n', 112)))])
L.player(11.5, 20, rot=90)
return
if lane == 'e':
# Messbahn: Querstreifen je 1 Einheit (abwechselnd weiss/rot), Lama bei x=11.5
# measuring track: cross stripes every 1 unit (alternating white/red), llama at x=11.5
for i in range(12, 48):
L.floor(i, 19, i + 0.15, 21, 0.01, 'weiss' if i % 5 else 'rot', coll=False)
L.player(11.5, 20, rot=90)
elif lane == 'd':
# Luecken 0.5 / 0.75 / 1.0 / 1.25 zwischen 0.6 hohen Bloecken (je 1.5 lang)
# gaps 0.5 / 0.75 / 1.0 / 1.25 between 0.6-high blocks (1.5 long each)
L.box(10, 18, 14, 22, 0, 0.6, top='stein', side='stein_kante', rot=0)
x = 14
for i, gap in enumerate([0.5, 0.75, 1.0, 1.25]):
@@ -43,21 +43,21 @@ def build(L):
x += 1.5
L.player(11, 20, rot=90)
elif lane == 'c':
# Einzelbloecke wachsender Hoehe, je 2 lang, 3 Abstand
# single blocks of increasing height, 2 long each, 3 apart
for i, h in enumerate([0.2, 0.35, 0.5, 0.7, 0.9]):
x = 16 + i * 5
L.box(x, 18, x + 2, 22, 0, h, top='holz_maser' if i % 2 else 'stein', side='stein_kante', rot=0)
L.box(x - 0.1, 17.4, x + 0.1, 17.6, 0, 1.5, top='rot', side='rot', solid=False) # Markierung
L.box(x - 0.1, 17.4, x + 0.1, 17.6, 0, 1.5, top='rot', side='rot', solid=False) # marker
L.player(12, 20, rot=90)
elif lane == 'a':
# Stufen: Hoehen 0.5, 1.0, 1.5, 2.25, 3.0 (je 2 tief)
# steps: heights 0.5, 1.0, 1.5, 2.25, 3.0 (2 deep each)
x = 16
for h, tex in [(0.5, 'stein'), (1.0, 'holz_maser'), (1.5, 'stein'), (2.25, 'holz_maser'), (3.0, 'stein')]:
L.box(x, 18, x + 2, 22, 0, h, top=tex, side='stein_kante', rot=0)
x += 2
L.player(12, 20, rot=90)
else:
# Luecken: Plattformen auf 1.0, Luecken 1.0, 1.5, 2.0, 2.5, 3.0
# gaps: platforms at 1.0, gaps 1.0, 1.5, 2.0, 2.5, 3.0
x = 12
L.box(x, 24, x + 4, 28, 0, 1.0, top='stein', side='stein_kante', rot=0)
x += 4
+1 -1
View File
@@ -1,4 +1,4 @@
"""Kleiner Orientierungstest: Platz, farbige Saeulen in +x (rot), +z (blau), -x (gelb), -z (weiss)."""
"""Small orientation test: plaza, coloured pillars at +x (red), +z (blue), -x (yellow), -z (white)."""
LEVEL_ARGS = dict(name='Orient')
+78 -77
View File
@@ -1,52 +1,53 @@
"""Testwelt - eigene Test-Map fuer "Ein Koenigreich fuer ein Lama" (ersetzt TestLevel Kapitel 1).
"""Testwelt - custom test map for "Ein Koenigreich fuer ein Lama" (replaces TestLevel chapter 1).
Bauen: python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad [--install]
Optionen (--set name=wert):
galerie=alle|keine|<Komma-Liste von Typen> welche Objekte in der Galerie stehen
start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 Startpunkt des Lamas (Tests)
tuer=1,9,1 Parameter des Ausgangs (Stamm, Welt, Kapitel des naechsten Levels)
Build: python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad [--install]
Options (--set name=value; the option values are German words):
galerie=alle|keine|<comma list of types> which objects stand in the gallery (all|none|list)
start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 llama start point (tests):
plaza | gallery row | path in front of row | enclosure | podium | parkour | parkour stage | goal
tuer=1,9,1 parameters of the exit door (tribe, world, chapter of the next level)
Masstab (im Spiel gemessen): Lama ~0.6 hoch, laeuft ~1.1 Einheiten/s, springt 0.35 hoch und mit
Anlauf ~1.3 weit. Stufen <= 0.3 (ohne Luecke = Anschlag), Luecken 0.3, sonst Treppen/Rampen.
Scale (measured in game): llama ~0.6 tall, walks ~1.1 units/s, jumps 0.35 high and, with a
run-up, ~1.3 far. Steps <= 0.3 (without a gap = stopper), gaps 0.3, otherwise stairs/ramps.
Aufbau (Spielkoordinaten: x nach Osten, z nach Sueden, y hoch; rot 0 = Blick nach +z), kompakt
(2026-11-06 zusammengerueckt: Insel 32.5x23.5 + Parkour-Streifen statt 42x34):
* Startplatz (x 15..21, z 12..19): Lama-Start, Checkpoint, Inka-Saeulen, Blumenbeete
* Objekt-Galerie direkt suedlich (x 14.5..32.5, z 20.5..32): 4 Reihen (Sammeln / Kisten+Toepfe /
Figuren / Mechanik) im Abstand 2.7, Spalten 1.9, jedes Objekt auf flachem Sockel, Wege dazwischen;
Tiere im Gehege gleich oestlich daneben (x 33..42, z 20.8..30) mit Aussichtspodest
* Parkour beginnt an der Ostkante des Platzes, alles auf der Linie z = 15.5: Treppe, Anschlag-Stufe,
2 Luecken, Balken, Checkpoint, Rampe, Anschlag-Stufe, 3 Trittsteine, schmaler Balken, Treppe,
Ziel-Plattform (y 4) mit Tor ueber dem Ausgang (LevelDoor auf "Tuerboden", Material 16).
Masse unveraendert gegenueber der ersten Fassung, nur um PX = -1 verschoben.
Skript-Durchlauf: levels/testwelt_parkour.seq (mit start=parkour)
* alles auf einer L-foermigen Insel, ringsum Meer (Todesebene -> Respawn am Checkpoint)
Layout (game coordinates: x east, z south, y up; rot 0 = facing +z), compact
(moved closer together on 2026-11-06: island 32.5x23.5 + parkour strip instead of 42x34):
* start plaza (x 15..21, z 12..19): llama start, checkpoint, Inca pillars, flower beds
* object gallery directly south (x 14.5..32.5, z 20.5..32): 4 rows (collectibles / crates+pots /
characters / mechanisms) 2.7 apart, columns 1.9, each object on a flat plinth, paths in between;
animals in the enclosure right next to it to the east (x 33..42, z 20.8..30) with a viewing podium
* parkour starts at the east edge of the plaza, all on the line z = 15.5: stairs, stopper step,
2 gaps, beam, checkpoint, ramp, stopper step, 3 stepping stones, narrow beam, stairs,
goal platform (y 4) with a gate over the exit (LevelDoor on "door floor", material 16).
Dimensions unchanged from the first version, only shifted by PX = -1.
Scripted run: levels/testwelt_parkour.seq (with start=parkour)
* everything on an L-shaped island, sea all around (death plane -> respawn at checkpoint)
"""
import random
LEVEL_ARGS = dict(name='Testwelt', seed=7)
# Galerie: (Kategorie, Farbe des Reihen-Pfostens, Typen). Typ = Name oder (Name, Variante)
# gallery: (category, colour of the row post, types). type = name or (name, variant)
GALERIE = [
('Sammeln', 'gelb', ['Coin', 'ChargeBooster', 'RubyIdol', 'HealthFull', 'HealthGenerator',
('Collectibles', 'gelb', ['Coin', 'ChargeBooster', 'RubyIdol', 'HealthFull', 'HealthGenerator',
'ChacaChargePotion', 'Secret']),
('Kisten/Toepfe', 'grau', [('SmashPot', 0), ('SmashPot', 1), 'Jar', 'ObjectDontDropInBox',
('Crates/pots', 'grau', [('SmashPot', 0), ('SmashPot', 1), 'Jar', 'ObjectDontDropInBox',
'WaterReboundPoints', 'CheatTeleport']),
('Figuren', 'blau', ['TipoVillage', 'ChacaVillage', 'PachaGivingInfo', 'KidOnLlama', 'Wompy']),
('Mechanik', 'rot', ['PressurePadDoor', 'RubyIdolDoor', 'SwitchPlatformPadVillage',
('Characters', 'blau', ['TipoVillage', 'ChacaVillage', 'PachaGivingInfo', 'KidOnLlama', 'Wompy']),
('Mechanisms', 'rot', ['PressurePadDoor', 'RubyIdolDoor', 'SwitchPlatformPadVillage',
'AppearingPlatformsVillage', 'RisingColumns', 'SinkingStatue', 'FallingTree',
'CheckPointPost']),
]
GEHEGE = ['Bunny', 'Bunny', 'Ladybird', 'FlockOfBirds', 'VillagerHerder']
# bewusst nicht platziert (siehe notes/levelbau.md)
NICHT = {'Llama': 'ist der Spieler', 'LevelDoor': 'Ausgang am Parkour-Ende',
'CameraSide': 'Kamera-Zone (unsichtbar)', 'CutSceneCameraDirectional': 'Zwischensequenz-Kamera (unsichtbar)',
'CutSceneTrigger': 'startet eine Zwischensequenz'}
# deliberately not placed (see notes/levelbau.md)
NICHT = {'Llama': 'is the player', 'LevelDoor': 'exit at the end of the parkour',
'CameraSide': 'camera zone (invisible)', 'CutSceneCameraDirectional': 'cutscene camera (invisible)',
'CutSceneTrigger': 'starts a cutscene'}
GX0, DX = 17.4, 1.9 # erste Galerie-Spalte, Abstand (vorher 17.0 / 2.5)
ROW_Z = [22.5, 25.2, 27.9, 30.6] # vorher 25 / 28.5 / 32 / 35.5
TUER_MAT = 16 # Kollisionsmaterial "Tuerboden" (Dorf: Boden in der Ausgangstuer)
PX = -1.0 # Versatz des Parkours gegenueber der alten Fassung (nur verschoben, Masse gleich)
GX0, DX = 17.4, 1.9 # first gallery column, spacing (previously 17.0 / 2.5)
ROW_Z = [22.5, 25.2, 27.9, 30.6] # previously 25 / 28.5 / 32 / 35.5
TUER_MAT = 16 # collision material "door floor" (village: floor in the exit door)
PX = -1.0 # parkour offset relative to the old version (only shifted, same dimensions)
def opt(L, k, d):
@@ -61,22 +62,22 @@ def build(L):
sel = opt(L, 'galerie', 'alle')
rnd = random.Random(3)
L.sun.update(azimuth=220, elevation=48, strength=0.72, sky=(0.47, 0.52, 0.62), ground=(0.27, 0.25, 0.21))
# ================================================================ Insel + Meer
# L-foermige Insel: Hauptteil (Platz, Galerie, Gehege) + schmaler Streifen unter dem Parkour-Ende
X0, Z0, X1, Z1 = 11, 9.5, 43.5, 33 # Hauptteil
XB = 49 # Ostende des Parkour-Streifens (z Z0..20.5)
# ================================================================ island + sea
# L-shaped island: main part (plaza, gallery, enclosure) + narrow strip below the end of the parkour
X0, Z0, X1, Z1 = 11, 9.5, 43.5, 33 # main part
XB = 49 # east end of the parkour strip (z Z0..20.5)
L.sea(0, 0, 60, 42, y=-1.5, kill=(X0 - 4, Z0 - 4, 54, Z1 + 4))
L.box(X0, Z0, X1, Z1, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False)
L.box(X1, Z0, XB, 20.5, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False, sides='nes')
# Grundflaechen (ohne Ueberlappung): Platz, Galerie bekommen eigene Boeden
L.floor(X0, Z0, XB, 12, 0, 'gras') # Nordrand
L.floor(X0, 12, 15, 20.5, 0, 'gras') # Westrand neben dem Platz
L.floor(X0, 20.5, 14.5, Z1, 0, 'gras') # Westrand neben der Galerie
L.floor(21, 12, XB, 20.5, 0, 'gras2') # Wiese unter dem Parkour
L.floor(15, 19, 21, 20.5, 0, 'gras') # zwischen Platz und Galerie
L.floor(32.5, 20.5, X1, 32, 0, 'gras') # Gehege
L.floor(14.5, 32, X1, Z1, 0, 'gras') # Suedrand
# ================================================================ Startplatz (6 x 7)
# base surfaces (no overlap): plaza and gallery get their own floors
L.floor(X0, Z0, XB, 12, 0, 'gras') # north edge
L.floor(X0, 12, 15, 20.5, 0, 'gras') # west edge next to the plaza
L.floor(X0, 20.5, 14.5, Z1, 0, 'gras') # west edge next to the gallery
L.floor(21, 12, XB, 20.5, 0, 'gras2') # meadow below the parkour
L.floor(15, 19, 21, 20.5, 0, 'gras') # between plaza and gallery
L.floor(32.5, 20.5, X1, 32, 0, 'gras') # enclosure
L.floor(14.5, 32, X1, Z1, 0, 'gras') # south edge
# ================================================================ start plaza (6 x 7)
L.box(15, 12, 21, 19, -0.25, 0.0, top='stein', side='stein_kante')
L.box(16.5, 14.0, 19.5, 17.0, 0.0, 0.08, top='inka', side='stein_kante', rot=0, side_coll=False)
for (x, z) in [(15.6, 12.6), (20.4, 12.6), (15.6, 18.4), (20.4, 18.4)]:
@@ -86,15 +87,15 @@ def build(L):
L.box(18.9, 19.2, 20.7, 20.1, 0, 0.1, top='blumen2', side='holz_dunkel', side_coll=False)
for (x, z) in [(12.5, 10.6), (23.5, 10.6), (12.4, 14.2), (12.6, 18.2)]:
baum(L, x, z, h=rnd.uniform(1.3, 1.9))
# ================================================================ Galerie (direkt suedlich vom Platz)
# ================================================================ gallery (directly south of the plaza)
L.box(14.5, 20.5, 32.5, 32, -0.2, 0.0, top=None, side='stein', solid=False)
L.floor(14.5, 20.5, 32.5, 32, 0, 'gras')
L.floor(15.6, 20.5, 16.3, 32, 0.02, 'sand', coll=False) # Hauptweg Nord-Sued
L.floor(15.6, 20.5, 16.3, 32, 0.02, 'sand', coll=False) # main path north-south
placed, skipped = [], []
for (cat, farbe, typen), zr in zip(GALERIE, ROW_Z):
xe = GX0 + (len(typen) - 1) * DX + 0.7
L.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, 'sand', coll=False) # Weg vor der Reihe
# Reihen-Pfosten (Kategoriefarbe) am Weganfang
L.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, 'sand', coll=False) # path in front of the row
# row post (category colour) at the start of the path
L.pillar(15.05, zr - 0.3, 0, 1.1, size=0.25, side=farbe, top=farbe)
L.box(14.85, zr - 0.5, 15.25, zr - 0.1, 1.1, 1.25, top=farbe, side='holz_dunkel', rot=0)
x = GX0
@@ -109,7 +110,7 @@ def build(L):
else:
skipped.append(name)
x += DX
# ================================================================ Gehege (Tiere/Gegner), oestlich der Galerie
# ================================================================ enclosure (animals/enemies), east of the gallery
gx0, gz0, gx1, gz1 = 33, 20.8, 42, 30
L.floor(gx0, gz0, gx1, gz1, 0.015, 'gras_streifen', coll=False)
L.fence([(gx0, gz0), (gx1, gz0), (gx1, gz1), (gx0, gz1), (gx0, gz0 + 3.2)], 0, height=0.45, thick=0.07,
@@ -120,59 +121,59 @@ def build(L):
if sel == 'alle' or name in sel.split(','):
L.strat(name, gx0 + 1.8 + (i % 3) * 2.7, gz0 + 2.3 + (i // 3) * 4.5)
placed.append(name)
# Aussichtspodest am Gehege (Treppe hoch, zwischen Reihe 2 und 3; Eingang ins Gehege noerdlich davon)
# viewing podium at the enclosure (stairs up, between rows 2 and 3; enclosure entrance north of it)
L.stairs(31.2, 25.3, 32.4, 26.9, 0, 0.6, 3, '+x', top='holz_maser', side='holz_dunkel')
L.box(32.4, 24.7, 33.0, 27.5, 0, 0.6, top='holz_maser', side='holz_dunkel', rot=0)
# ================================================================ Parkour
# Der Parkour liegt auf einer Linie (z = 15.5, nach Osten), damit man ohne Kameradrehen durchkommt.
# Zwei "Anschlag"-Stufen (+0.3, ohne Luecke) halten das Lama an einer festen Stelle an: dort
# springt man aus dem Stand hoch und hat danach genug Anlauf (wichtig fuer --seq-Laeufe).
# Lama: laeuft ~1.1 Einheiten/s, springt mit Anlauf ~1.3 weit und 0.35 hoch.
# Alle x-Werte unten sind die der alten Fassung + PX (Geometrie unveraendert, nur verschoben).
# The parkour lies on one line (z = 15.5, heading east) so it can be done without turning the camera.
# Two "stopper" steps (+0.3, no gap) stop the llama at a fixed spot: there you jump up from
# standing and then have enough run-up (important for --seq runs).
# Llama: walks ~1.1 units/s, jumps ~1.3 far with a run-up and 0.35 high.
# All x values below are those of the old version + PX (geometry unchanged, only shifted).
def X(v):
return v + PX
# 1) Treppe vom Platz (Ostkante x=21) auf 0.8, kleiner Absatz, Anschlag-Stufe 1 (+0.3)
# 1) stairs from the plaza (east edge x=21) up to 0.8, small landing, stopper step 1 (+0.3)
L.stairs(X(22), 14.5, X(24), 16.5, 0, 0.8, 4, '+x', top='stein', side='stein2')
L.box(X(24), 14.5, X(24.6), 16.5, 0, 0.8, top='stein', side='stein2', rot=0)
L.box(X(24.6), 14.5, X(26), 16.5, 0, 1.1, top='gras', side='erde', side_top='graskante')
# 2) zwei Luecken (0.3) mit kleinen Stufen (+0.2)
# 2) two gaps (0.3) with small steps (+0.2)
L.box(X(26.3), 14.7, X(27.8), 16.3, 0, 1.3, top='holz_maser', side='holz_dunkel', rot=0)
L.box(X(28.1), 14.9, X(29.6), 16.1, 0, 1.5, top='holz_maser', side='holz_dunkel', rot=0)
# 3) Balken (0.5 breit)
# 3) beam (0.5 wide)
L.box(X(29.6), 15.25, X(32.5), 15.75, 1.25, 1.5, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel',
rot=0)
L.pillar(X(31.05), 15.5, 0, 1.25, size=0.2, side='rinde', top='rinde')
# 4) Mittelplattform mit Checkpoint
# 4) middle platform with checkpoint
L.box(X(32.5), 14.0, X(35.0), 17.0, 0, 1.5, top='stein', side='stein_moos', side_top='stein_kante')
# 5) Rampe 1.5 -> 2.4, Absatz, Anschlag-Stufe 2 (+0.3)
# 5) ramp 1.5 -> 2.4, landing, stopper step 2 (+0.3)
L.ramp(X(35.0), 14.75, X(38.0), 16.25, 1.5, 2.4, '+x', top='sand', side='erde', base=0.0)
L.box(X(38.0), 14.75, X(38.6), 16.25, 0, 2.4, top='sand', side='erde', rot=0)
L.box(X(38.6), 14.6, X(39.8), 16.4, 0, 2.7, top='stein', side='stein_moos', side_top='stein_kante', rot=0)
# 6) drei Trittsteine (1.5 lang, 1.3 breit, Luecken 0.3, je +0.2)
# 6) three stepping stones (1.5 long, 1.3 wide, gaps 0.3, +0.2 each)
stones = [(X(40.85), 15.5, 2.9), (X(42.65), 15.5, 3.1), (X(44.45), 15.5, 3.3)]
for (x, z, y) in stones:
L.box(x - 0.75, z - 0.65, x + 0.75, z + 0.65, 0, y, top='stein_gruen', side='stein_moos', rot=0)
# 7) schmaler Balken (0.45 breit) mit Stuetze
# 7) narrow beam (0.45 wide) with support
L.box(X(45.2), 15.275, X(47.4), 15.725, 3.05, 3.3, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel',
rot=0)
L.pillar(X(46.3), 15.5, 0, 3.05, size=0.18, side='rinde', top='rinde')
# 8) Treppe auf 4.0
# 8) stairs up to 4.0
L.stairs(X(47.4), 14.7, X(48.6), 16.3, 3.3, 4.0, 3, '+x', top='stein', side='stein2', base=0.0)
# 9) Ziel-Plattform (ragt ueber die Kueste) mit Saeulen, Brustwehr und Tor ueber dem Ausgang
# 9) goal platform (overhanging the coast) with pillars, parapet and a gate over the exit
L.box(X(48.6), 12.6, X(53.6), 18.4, -2.2, 4.0, top='platte2', side='stein_moos', side_top='stein_kante')
for (x, z) in [(49.0, 13.0), (53.2, 13.0), (49.0, 18.0), (53.2, 18.0)]:
L.pillar(X(x), z, 4.0, 5.0, size=0.35, side='inka', top='stein')
L.box(X(53.35), 13.3, X(53.6), 17.7, 4.0, 4.45, top='stein', side='stein_kante', rot=0)
# Mosaik im Tor: Oberseite mit Kollisionsmaterial 16 = "Tuerboden" (LevelDoor loest nur aus, wenn das
# Lama in der Rasterzelle der Tuer auf einem Polygon mit Material 16 steht)
# mosaic in the gate: top with collision material 16 = "door floor" (LevelDoor only triggers when the
# llama stands on a polygon with material 16 in the door's grid cell)
L.box(X(50.5), 14.3, X(52.3), 16.7, 4.0, 4.08, top='muster_lila', side='inka', rot=0, side_coll=False,
mat=TUER_MAT)
# Tor ueber dem Ausgang (LevelDoor bei 50.5/15.5, Zelle 50/15)
# gate over the exit (LevelDoor at 50.5/15.5, cell 50/15)
for z in (14.45, 16.55):
L.pillar(X(51.5), z, 4.08, 5.3, size=0.3, side='inka', top='stein')
L.box(X(51.25), 14.3, X(51.75), 16.7, 5.3, 5.55, top='stein', side='stein_kante', bottom='stein_kante', rot=0)
# ================================================================ Deko (nur am Rand, dicht)
# ================================================================ decoration (edges only, dense)
for (x, z) in [(12.3, 22.5), (12.6, 27), (12.2, 31.5), (17.5, 32.6), (25, 32.7), (36, 32.5), (42.6, 32.3),
(42.8, 23), (29, 10.5), (35, 10.4), (41, 10.6), (46, 19.6), (45.5, 10.8)]:
baum(L, x, z, h=rnd.uniform(1.2, 2.0), c=rnd.uniform(0.9, 1.3))
@@ -184,7 +185,7 @@ def build(L):
(40.5, 18.6, 2, 1.6, 'blumen'), (25, 10.4, 1.5, 1.4, 'blumen3')]:
L.floor(x0, z0, x0 + w, z0 + d, 0.015, t, coll=False)
# ================================================================ Spieler, Checkpoints, Ausgang
# ================================================================ player, checkpoints, exit
starts = dict(platz=(18, 14.6, 0), parkour=(X(20.5), 15.5, 90), pk1=(X(24.3), 15.5, 90), pk2=(X(33.0), 15.5, 90),
pk3=(X(38.3), 15.5, 90), pk4=(X(44.45), 15.5, 90), pk5=(X(46.0), 15.5, 90), ziel=(X(49.0), 15.5, 90),
ziel2=(X(51.5), 17.8, 180), gehege=(30.4, 26.1, 90),
@@ -196,12 +197,12 @@ def build(L):
L.player(sx, sz, rot=srot)
L.checkpoint(16.1, 17.4)
L.checkpoint(X(33.6), 14.6)
# Ausgang: Parameter (Stamm, Welt, Kapitel) des naechsten Levels. (1,9,1) = Kapitel 1 neu laden (nach
# der Bilanz "TestLevel Kapitel 1 / Muenzen / Geheimnisse"). NICHT (7,7,7) ("Welt fertig" wie die
# letzten Kapitel der Original-Welten): das schaltet Welt 10 frei -> Absturz auf der Auswahlkarte.
# exit: parameters (tribe, world, chapter) of the next level. (1,9,1) = reload chapter 1 (after
# the summary "TestLevel Kapitel 1 / Muenzen / Geheimnisse"). NOT (7,7,7) ("world complete" like the
# last chapters of the original worlds): that unlocks world 10 -> crash on the selection map.
tuer = [int(v) for v in opt(L, 'tuer', '1,9,1').split(',')]
L.exit(X(51.5), 15.5, params=[v * 0x1000 for v in tuer])
# Muenzen entlang des Parkours (auf die Flaechen gestellt)
# coins along the parkour (placed on the surfaces)
L.coins([(X(x), z) for x, z in [(22.5, 15.5), (23.5, 15.5), (25.3, 15.5), (27.0, 15.5), (28.85, 15.5),
(30.0, 15.5), (31.0, 15.5), (32.0, 15.5), (35.8, 15.5), (36.6, 15.5),
(37.4, 15.5), (39.2, 15.5)]] + [(x, z) for x, z, _ in stones] +
@@ -212,6 +213,6 @@ def build(L):
def report(L):
placed, skipped = L._galerie
print('Galerie: %d Objekte: %s' % (len(placed), ', '.join(placed)))
print('gallery: %d objects: %s' % (len(placed), ', '.join(placed)))
if skipped:
print('nicht im Basislevel: %s' % ', '.join(skipped))
print('not in base level: %s' % ', '.join(skipped))
+185 -184
View File
@@ -1,221 +1,222 @@
# Debug-Ansicht: Kollision & unsichtbare Objekte im Spiel (`tools/debugviz.py`)
# Debug view: collision & invisible objects in game (`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.
As of: 2026-11-06. Shows, in **all 34 level WADs**, the collision geometry (including invisible
walls/floors, death planes) and invisible logic objects (cameras, triggers, exits,
checkpoints, force/box zones, environment zones …) directly in the game – as a coloured wireframe,
invisible surfaces additionally filled semi-transparently. Game mechanics stay unchanged.
## Benutzung
## Usage
```
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
lama-mod debug an # enable debug WADs (rebuilds the overlay, thereby also activating other mods)
lama-mod debug aus # back to the original levels (the testlevel mod stays as it was)
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:
Takes effect the next time a level is loaded (best to restart the game). Do not toggle
while a level is loading. Mod folder: `~/.wine-lama/mods/debug/wads/*.wad` (same names as
on the CD, e.g. `t1l1m001.wad`); the file `~/.wine-lama/mods/debug/.aus` = disabled.
Order in the overlay: alphabetical, later ones win → `testlevel` (T1L9*) always stays,
debug WADs only replace the original levels. `lama-mod an|aus|status` work as
before (`an` skips mod folders containing `.aus`). Backup of the old script:
`deb-build/lama-mod.bak-vor-debug`.
Neu bauen (z. B. nach Änderungen an Farben):
Rebuild (e.g. after changing colours):
```
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.
Run time ≈ 10–40 s per level.
## Farblegende
## Colour legend
Alle Farben sind RGB (0–255). **Linien** = Drahtgitter (deckend), **Füllung** = halbtransparent
(Alpha 96/255, Blendmodus 1 der Engine).
All colours are RGB (0–255). **Lines** = wireframe (opaque), **fill** = semi-transparent
(alpha 96/255, engine blend mode 1).
### Kollisionsflächen (Rahmen um jedes Kollisionspolygon der Welt-Kacheln)
Kantenfarbe nach Lage, solange das Material 0 (normal) ist:
### Collision surfaces (frame around every collision polygon of the world tiles)
Edge colour by orientation, as long as the material is 0 (normal):
| Farbe | RGB | Bedeutung |
| Colour | RGB | Meaning |
|---|---|---|
| **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. |
| **Green** (lines) | 40,255,40 | walkable floor (normal pointing steeply up, ny ≥ 0.7) |
| **Yellow-green** (lines) | 200,255,0 | slope (0.2 ≤ ny < 0.7) |
| **Blue** (lines) | 0,150,255 | wall (−0.2 < ny < 0.2) |
| **Orange** (lines) | 255,130,0 | ceiling / overhang (ny ≤ −0.2) |
| **Red, filled semi-transparently** | 255,0,0 | **invisible collision**: no render surface within ±0.5 units along the normal (= invisible wall/floor/ceiling). The edge colour still shows orientation or material. |
Kollision mit Sondermaterial (Byte 1 des Kollisionspolygons ≠ 0): Kanten in Materialfarbe,
bei unsichtbaren Polygonen auch die Füllung in Materialfarbe (statt Rot):
Collision with a special material (byte 1 of the collision polygon ≠ 0): edges in the material colour,
for invisible polygons the fill is also in the material colour (instead of red):
| Material | Farbe | RGB | Bedeutung |
| Material | Colour | RGB | Meaning |
|---|---|---|---|
| 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) |
| 30 (0x1e) | **Magenta**, lines only (no fill) | 255,0,255 | **no floor / fall and death plane**: the player falls through (FUN_004036d0 sets floor = none). Usually model 0 at y ≈ −2.75 in every grid cell below the islands → magenta grid = fall/death zone. |
| 16 | **Violet**, always filled | 190,60,255 | exit floor: LevelDoor only triggers when the player stands on material 16 in the door cell |
| 17 (0x11) | **Yellow** | 255,255,0 | selective collision: only affects objects with runtime flag 0x8000000 (strat+0xe8) |
| 18 (0x12) | **Light orange/peach** | 255,200,120 | selective collision: does NOT affect objects with flag 0x8000000 |
| 1 | Turquoise | 0,200,200 | special material, meaning unknown (evaluated by strat scripts) |
| 2 | White | 255,255,255 | ditto |
| 3 | Pink (rose) | 255,120,170 | ditto (common in world 5, mountain) |
| 5 | Mint | 120,255,200 | ditto (river, roller coaster) |
| 6 | Lavender | 160,120,255 | ditto |
| 7 | Light orange | 255,170,80 | ditto |
| 8 | Dark green | 0,130,60 | ditto |
| 9 | Light blue | 150,200,255 | ditto |
| 10 | Olive | 150,150,0 | ditto |
| 11 | Brown | 150,90,40 | ditto |
| 13 | Salmon | 255,130,110 | ditto |
| 15 | Light yellow | 255,255,150 | ditto |
| 20 | Petrol | 0,120,140 | ditto |
| 26 | Dark violet | 110,40,160 | ditto |
| 27 | Silver | 190,190,190 | ditto (chase t1l2m003) |
| 28 | Hot pink | 255,0,140 | ditto |
| 33 | Dark red | 150,0,0 | ditto |
| 255 | Almost black | 20,20,20 | ditto |
| other | Light grey | 230,230,230 | (does not occur in the originals) |
### 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).
### Object markers (invisible strats, MAP strat entries)
Marker cube (10 cm) on a thin pole 0.35 above the object origin; short horizontal
line = view direction (cameras, checkpoints).
| Farbe | RGB | Kategorie | Typen |
| Colour | RGB | Category | Types |
|---|---|---|---|
| **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) |
| **White**, arrow flat on the ground | 255,255,255 | player start (direction) | Llama, KuscoAndPachaTogetherOnATree (river), RollerCoaster, LlamaChase |
| **Violet** + wire cube 1×1×1 around the grid cell | 190,60,255 | exit (trigger cell) | LevelDoor, VillageEndDoor, EndGameDoor, TeleportDoor |
| **Green-turquoise** | 0,255,140 | checkpoint | CheckPointPost, CheckPointBox |
| **Cyan** | 0,255,255 | camera | Camera* (CameraSide, CameraLift, CameraForceInsideBox …), CutSceneCamera*, RiverWaterfallCamera |
| **Yellow** | 255,255,0 | trigger | CutSceneTrigger*, *Trigger*, Say* (voice lines), ChangeToBunny/Turtle, RiverStartSign |
| **Orange** | 255,90,0 | force/special zones | Force*, FallOffNoHurt, ObjectDontDropInBox, RiverCurrentBox, RiverRebound, WaterReboundPoints*, SuckingWPs, ObjectSuckPoints, RiverTrackChange, TrackSwitch, RiverWhirlpool, BackgroundRotationChanger |
| **Gold** | 255,200,0 | secret zone | Secret |
| **Grey** | 170,170,170 | other invisible control objects | CheatTeleport, Wompy*, Spawner, Helper, coin lines/circles, Rain, DrippingWater, WaterfallMist, MusicBox, FlockOfBirds, Bats, Scorpions*, AppearingPlatforms* … |
| **Dark grey**, small dot (6 cm) | 90,90,90 | all remaining objects (origin) | coins, enemies, doors … – so that really nothing is missing |
| **Pink** wire cube per grid cell | 255,100,180 | environment/music/fog zone | MAP block 4 (90-byte entries, cell 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).
**Zone boxes:** if a marker object has exactly 2 waypoints and the object lies in the rectangle
between them, it is a box area (Secret, CameraSide/Lift, ObjectDontDropInBox,
FallOffNoHurt, RiverCurrentBox …): wire cuboid in the category colour from min(y) to max(y)+1.2
(the height is estimated – the code probably only checks x/z), for areas ≤ 64 units² with a
semi-transparent floor (alpha 70).
**Waypoint chains** of other marker objects: small cubes (5 cm) in a darkened
category colour (60 %), for force zones with connecting lines (e.g. WaterReboundPoints paths,
CutSceneTrigger camera moves only as points).
## Wie es gebaut ist (Entscheidungen)
## How it is built (decisions)
* **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).
* **Collision overlay appended to the TRAK models** (not as separate tiles): per
collision polygon a frame of 2·n vertices (outer corners + corners offset inwards by the line
width, computed from the edge planes), 2·n triangles; the fill reuses the inner
corners. Everything offset by 0.012 along the surface normal (no z-fighting). This way
tile transform, zones/visibility and tile swaps (alt models, WFPC 0x10000) are
automatically correct. Collision is converted back from game space (geomlib.coll_decode/
coll_polygon, z mirrored).
* Face flags of the overlay triangles: 0x800 | 0x400 (double-sided) | 0x4000 (ignore vertex
colours) | 0x8 at every texture change and at the latest every 250 faces (render batch).
* **Colours** = appended flat-colour textures (TEXT table, page −1): opaque flags 0x105,
semi-transparent **0x1105** (bits 12–14 = 1 → table 0x573eb8: blend mode 1 =
SRCALPHA/INVSRCALPHA, alpha from the 4th byte (v1) of the entry; checked in game).
Semi-transparent triangles go through the sort buffer (FUN_0041ed90, 512 KB/frame ≈ 5000
triangles; when it is full they are simply dropped – no crash). That is why fills exist only
for invisible collision, material 16 and small zone floors.
* **Vertex colour block** in MAP: extended per tile (and per colour set/frame, also for alt models) by
nV(overlay) entries 0x00808080 (byte 3 = 0, does not change the frame count).
* **Invisibility detection**: render triangles of all tiles in world coordinates, bucketed by
cell; for the centre + 4 points (70 % towards the corners) of every collision polygon a
ray along the normal (±0.5) is tested against triangles with similar orientation (|cos| > 0.4);
< 60 % hits = invisible. Majority vote per model over up to 6 instances.
Strat models (STPC: doors, platforms, characters) do NOT count as render surfaces – collision
behind moving objects may therefore appear red.
(The first version with 0.06/0.15 distance marked entire house walls and cliff walls red, because the
collision hull lies 0.2–0.4 in front of the visible wall – too much noise.)
* **Markers** as new tiles without collision, one model per grid cell (lines split into pieces ≤ 1
and placed in the cell of their midpoint) → automatically get the zone of the
cell. New tiles are appended at the end (indices of the original tiles stay).
* **Game mechanics unchanged**: no collision polygons added/changed, strats,
waypoints, parameter pool, zones and the rest of MAP byte-identical (checked after writing:
strats/nodes/rest/original tiles identical).
* Size: WADs grow by 0.1–4 MB (max. 8.9 MB, t1l6m003). Level memory (16 MB arena,
FUN_0041ef00) holds TRAK+STPC+MAP – enough for all (load test).
## 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).
## Limits / known issues
* Collision of the **object models** (STPC: moving platforms, doors, crates …) is not
shown – they move; only their origin as a dark grey dot.
* Zone heights of the box areas are estimated; whether the engine checks y is open.
* Meaning of most special materials unknown (only colour + count, see table below);
material 30 = no floor, 16 = exit, 17/18 = selective are certain from the code.
* Environment zones (MAP block 4): cell certain, meaning (fog/music/camera) only moderately certain;
the default entry (position 1,1,1) is not drawn.
* If the camera is close to a marker/line, the engine's near-plane clip sometimes cuts the
triangles into large shreds (only briefly visible).
* Performance: no noticeable drop under Wine/llvmpipe (Xvfb); uncritical on a real GPU
(≤ 2000 extra vertices per tile model, on average ~1–2× as many triangles as the original).
* Test level T1L9 (test world) has no debug version (the testlevel mod takes precedence).
## Statistik je Level (Stand Build 2026-11-06)
## Statistics per level (build of 2026-11-06)
| Level | Welt | WAD alt→neu (KB) | Koll.-Polygone | davon unsichtbar | Sondermaterialien (Anzahl Polygone) | Marker |
Marker categories are the internal keys of `debugviz.py`: ausgang = exit, geheimnis = secret,
kamera = camera, kraft = force zone, objekt = other object, sonst = other control object,
umgebung = environment zone.
| Level | World | WAD old→new (KB) | Coll. polygons | of which invisible | Special materials (polygon count) | Markers |
|---|---|---|---|---|---|---|
| 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 |
| t1l1m001 | Village | 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 | Village | 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 | Village | 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 | Village | 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 | Jungle night | 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 | Jungle night | 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 | Jungle night (chase) | 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 | River | 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 | River | 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 | River | 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 | River | 2674→3754 | 2201 | 228 | 5:18, 16:5 | ausgang 2, kamera 5, kraft 4, objekt 31, sonst 7, start 1, trigger 2 |
| t1l4m001 | Jungle day | 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 | Jungle day | 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 | Jungle day | 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 | Mountain | 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 | Mountain | 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 | Mountain | 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 | City | 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 | City | 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 | City | 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 | Catacombs (roller coaster) | 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 | Catacombs | 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 | Catacombs (roller coaster) | 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 | Catacombs | 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 | Catacombs (roller coaster) | 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 | Lab | 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 | Lab | 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 | Lab | 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 | Lab | 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 | Lab | 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 | Lab | 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 | Special (options/map) | 1331→1482 | 333 | 0 | | kamera 1, objekt 3 |
| t6l6m002 | Special (options/map) | 3605→3640 | 64 | 0 | | kamera 1, objekt 2 |
(Kollisionspolygone je TRAK-Modell gezählt, nicht je Kachel-Instanz.)
(Collision polygons counted per TRAK model, not per tile instance.)
## 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).
**Offline (all 33 debug WADs, `$JOB/tmp/dv/offcheck.py`):** TRAK parses and rebuilds
byte-identically; ≤ 8000 vertices per model (max. 2012, t1l4m003); render batches ≤ 256 faces; all
texture indices < table size; vertex colour block per tile = nV(model) (all frames);
MAP fully parsed with `mapchunk` (0 bytes left over) and rebuilt byte-identically; strats,
waypoints, parameter pool, environment zones, zone table/grid, alt block and original tiles
unchanged; original models (vertices, faces, **collision**) unchanged byte for byte; new
marker models without collision; all other chunks (STPC, sound, LGPC …) identical;
tiles within the grid. TRAK+STPC+MAP+LGPC ≤ 7.6 MB (16 MB level arena).
**Loaded in game** (lamatest, via slot world 9 as T1L9M001.wad, ~40–90 s played/rotated,
no crash, no hang, no errors in the Wine log): t1l1m001 (several times, visual check),
t1l1m003, t1l1m004, t1l2m001, t1l2m002, t1l3m001–004 (the river also works normally in slot 9:
the boat moves), t1l4m001–003, t1l5m001–003, t1l6m001–003 = 19 levels.
The build tested was the one right before the final one: identical geometry, only the colour values
of the special materials were switched afterwards to fixed colours per material (+8–16 bytes
of texture table). The preview images also come from that build – special material colours
in them (e.g. turquoise fill in the mountain image) do not yet fully match the legend above.
**Only checked offline:** t1l1m002 and t1l2m003 (the load attempt previously failed at lamatest's
menu detection, not at the level), t1l7m001–005 (catacombs/roller coaster),
t1l8m001–006 (lab), t6l6m001/002. A test via the real save game slot was not done.
lamatest change on the side: save game selection with more attempts and 0.3 s long
key presses (under CPU load short presses got lost).
Images: `notes/img/debug/00_montage.jpg` (6 worlds), `01_…06_*.jpg` (1280×720).
+168 -168
View File
@@ -1,203 +1,203 @@
# Level-Geometrie (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC)
# Level geometry (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`,
As of: 2026-11-06. Source: groove.exe (Ghidra decompilation `re/groove_decomp.c`, Capstone) +
data analysis of all 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.
Confidence: **[certain]** = verified from code + data (byte-identical round trip / rendering),
**[likely]** = plausible from data, **[?]** = unknown.
## 1. Überblick: welcher Chunk ist was
## 1. Overview: which chunk is what
Chunk-Dispatcher: `FUN_00558xxx` bei VA 0x55899e (Vergleich auf Tag-Konstanten, Tag als u32 LE
gelesen, z. B. 'CPTS' im File = 0x53545043).
Chunk dispatcher: `FUN_00558xxx` at VA 0x55899e (compares against tag constants, tag read as u32 LE,
e.g. 'CPTS' in the file = 0x53545043).
| Chunk | Loader (VA) | Inhalt |
| Chunk | Loader (VA) | Contents |
|---|---|---|
| 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 |
| WFPC | inline → `DAT_006da330` | u32 feature flags of the level (control MAP/STPC layout) **[certain]** |
| TEXT | 0x4067b0 / 0x406d30 | texture pages (256x256, 16 bit) + texture table (UV rectangles) **[certain]** |
| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | sound (SMPC: 'cvg ' VAG samples; AMPC: 'ambi'/'VABp'; SRPC sound references) – **no geometry** |
| **TRAK** | 0x42aac0 → 0x5563f0 | **world geometry**: tile models (render triangles + collision polygons) → global array `DAT_005846ec` **[certain]** |
| **STPC** | 0x42ab50 | object ("strat") models (same model format), skeletal animations, then strat script data (names like "SinkingStatuePad") **[certain for models]** |
| **MAP** | 0x42ac50 | tile grid: placement of the TRAK models + vertex colours + zone PVS (+ strats/paths/etc., see objects.md) **[certain]** |
| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | lights / level info / ? – not geometry |
**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.
**The world is a tile system (inherited from Croc):** TRAK contains N "tile" models in local
coordinates (roughly −0.5..0.5 in x/z, but they can stick out up to ±2.4). MAP places them
on a W×H grid (1 unit = 1 cell) with height y and rotation around y. There is **no BSP**;
the spatial partition is the grid itself (cell lists) + a 32-zone visibility table.
## 2. Koordinatenräume [sicher]
## 2. Coordinate spaces [certain]
* **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π:
* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): right-handed, **y up**,
grid cells lie at x ∈ [0,W), **z ∈ (−H, 0]**. The OBJ export uses this space.
* **Game space** (MAP fixed point "B", collision, strats): (x, y, −z), 20.12 fixed point (4096 = 1).
The renderer converts B back: pos = (Bx, By, −Bz)/4096, angle = rot·2π/4096.
* Tile transform (local vertex v → world, export space), 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).
(verified: 93 % of all tile edge vertices hit a neighbour vertex exactly; the three
other sign/mirror variants give 65–70 %).
* **Collision data is in game space** (z mirrored relative to the render vertices of the same
model). Negate nz when decoding (92 % of the reconstructed collision corners lie
< 0.02 from a render vertex).
* rx/rz of the tiles are 0 in all levels; only rotation around y (in practice 0/1024/2048/3072).
## 3. Modellformat (TRAK und STPC) [sicher]
## 3. Model format (TRAK and STPC) [certain]
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.
Fixup functions: TRAK `FUN_005563f0(&ptr, n, &out)` (header 0x84),
STPC `FUN_0041f770(&ptr, n, &out)` (header 0x8c). File layout: **all headers of one call
in a row, then the data of the models in order** (TRAK: one call with n=count;
STPC: n calls with 1 model each → header/data alternating). Pointer fields are 0 in the file
and are set on load.
### Header
| Off | Typ | Bedeutung |
| Off | Type | Meaning |
|---|---|---|
| 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`). |
| 0x00 | 3×f32 | centre of the bounding box |
| 0x0c | 8×3 f32 | box corners, order (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Slightly larger than the vertices (margin not constant, ~0.004–0.014; probably from the original mesh). Used for 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 [?]) |
| 0x70 | u32 | → verts (file: 0) |
| 0x74 | u32 | → faces (file: 0) |
| 0x78 / 0x7a / 0x7c | 3×u16 | number of collision polygons in 3 groups (code always sums all three; group meaning [?]) |
| 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) |
| 0x80 | u32 | → collision (file: 0) |
| *STPC only* 0x84 | u16 | = nVerts for rigid models, 0 for skinned models |
| *STPC only* 0x86 | u16 | nBones (skinned models) – preceded in the data by nBones×u16 = vertex count per bone (sum = nVerts, verified) |
| *STPC only* 0x88 | u32 | → bone table (file: 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.
### Data (in this order)
1. (STPC only) nBones × u16
2. **Vertex** 24 bytes: `f32 x,y,z ; f32 nx,ny,nz` (smoothed normals; lighting comes from MAP vertex colours)
3. **Face** 28 bytes (triangles only):
`u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` with n = normalize((v1−v0)×(v2−v0)), d = n·v0
(verified for all faces). On load `tex` is replaced by a pointer to the texture table entry
(`DAT_00581154 + tex*20`). The plane is used for backface culling (camera·n − d ≥ ε).
4. **Collision polygon** 32 bytes, nGroupA+B+C of them:
`u8 type ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }`
* plane 0 = surface, planes 1..4 = edges (normals pointing **inwards**, point inside ⇔ n·p − d ≥ −tolerance).
* normals ×127 (code multiplies by 32 → 4096 scale), d ×4096 (fixed point) → local coordinates only ±8 units!
* type bit0 = quad (4 edges), otherwise triangle (3 edges, 5th plane = 0). Bit2 ≈ normal points down (ny<0; ceilings/overhangs) [likely].
* material: ground type (0 = normal; 0x11/0x12 are ignored depending on WFPC 0x8000000, 0x1e = water/death plane [likely]; the value ends up in the player's ground state).
* Collision queries: `FUN_004036d0` (floor/ceiling), `FUN_00403ad0` (walls), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
### Face-Flags (u16)
| Bit | Bedeutung | Konfidenz |
### Face flags (u16)
| Bit | Meaning | Confidence |
|---|---|---|
| 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) |
| 0x0008 | new render batch: reload render state (page, blend mode) from this face, draw the previous batch. Sorted by page/state + bit at each change. | certain (code) |
| 0x0010, 0x0800, 0x0020 | UV corner selection (see below) | certain |
| 0x0400 | no backface culling (double-sided) | certain |
| 0x4000 | ignore vertex colours (white) | certain |
| 0x0001, 0x0002 | special render modes (sorting/transparency; together with global flags 0x5f6ee8) | likely |
| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | present in the data, the renderer (0x556510) does not evaluate them | ? (probably PS1 leftovers) |
### 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.
### UVs – the engine has NO free UVs [certain]
Every triangle shows one half of a **texture rectangle** (entry of the TEXT table).
UV rectangle: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. With m = flag 0x20 (mirrors U),
a = m?U0:U1, b = m?U1:U0 the corners (v0,v1,v2) get:
* without 0x800: (a,V1) (b,V1) (a,V0)
* 0x800, without 0x10: (a,V1) (b,V1) (b,V0) ← first half of a quad
* 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← second half
(renderer `FUN_00556510` from the line "if ((uVar6 & 0x800) == 0)"). Textures with flag bit0 are
flat colours (u0,u1,v0 = R,G,B). 96 % of world faces have 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.
## 4. TRAK chunk [certain]
`u32 n ; n × header(0x84) ; data of model 0..n−1`. All 34 level WADs parse exactly up to the
end of the chunk; `geomlib.build_trak(parse_trak(x)) == x` byte-identical for all levels.
Typically 100–650 models, max. 422 verts / 798 faces per model. Model 0 is, in many levels,
an invisible plane (4 verts, 0 faces, 1 collision polygon, material 0x1e) at y=−2.75 –
the water/fall plane below the islands.
## 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:
## 5. MAP chunk – geometry parts (loader FUN_0042ac50) [certain]
(Strat/path/zone blocks: see notes/objects.md; only what concerns geometry here.)
Order in the file:
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.
1. `u32 N (tiles), u32 W, u32 H` (grid)
2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (export space; x,z = cell centre .5).
Only used to sort into the cell lists: cell = floor(x) + floor(−z)·W.
3. u32 n + n×u32 (list, referenced by strats)
4. u32 n + n×90 bytes (lights/cameras? – objects.md)
5. u32 n + n×34 bytes (path nodes)
6. **Zone table** 256 bytes = 32 × `{u32 visibility mask, i32 height value}` → `DAT_006da350`
7. **Zone grid** W·H × u32 (zone number 0..31 per cell) → world+0x48
8. **B**: N × `{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}` 20.12 fixed point, game space (z = −A.z), ry = A.rot.
**Used for rendering and collision.** A and B are consistent in all levels.
9. **Model index**: N × u32 (= A.model)
10. if WFPC & 0x10000: `u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}` –
tile swaps (doors/bridges etc.), entry k belongs to the k-th tile with animFlag≠0.
11. **Vertex colours** per tile i: nVerts(model) × u32 RGBA (0x80 = 1.0, game ×2 with clamp);
byte3 of the first entry = number of colour sets (0/1 = one; ≥2 → another nVerts×u32 follow,
the second set is used when `DAT_005865d4==1`). For animFlag tiles the same
structure follows for modelB. Pointer table world+0x58 (twice as large with WFPC 0x10000000).
12. Rest (u32 n2,n3 + 58-byte strat entries, optional paths (0x200000), view distance (0x10), u16) – objects.md.
`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduziert alle MAP-Chunks byte-identisch.
`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduces all MAP chunks byte-identically.
### 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.).
### Grid / visibility (no BSP) [certain]
* Cell lists: world+0x40 (W·H pointers, linked lists of tile indices), built from A.
* Rendering (`FUN_0042bf40`): rectangle around the camera (view distance) over the grid; for every cell
all tiles in the list, but **only if bit zone[cell] is set in the mask of the camera zone**
(`DAT_0058642c` = zonetable[zone[camera cell]].mask, `FUN_0041e020`). Zone 0 has mask 0x1 in the
village level. → New geometry must get matching zones, otherwise it is invisible.
`geom_import --zones flat` sets grid=0 and mask(zone 0)=0xffffffff (everything visible).
* Collision: only tiles of the player's cell (see above).
## 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.
## 6. STPC chunk (objects) [certain up to animations]
`u32 n1 ; n1 × (header 0x8c + data)` static/skinned object models (33 in the village) ;
`u32 n2 ; n2 × animation` (`FUN_0041f8b0`, header 32 bytes: +0 nA (nA×4 bytes), +8 nFrames,
+0xc (0 → nFrames×8 bytes), +0x10 nDelta (frame 0: nDelta×8, otherwise nDelta×2 (+2 if odd)
bytes of compressed vertex deltas), +0x18 nBones (per frame nBones×64 bytes = 4×4 matrix)) ;
if WFPC & 0x100: `u32 n3 ; n3 × {16-byte head (+4 count, +8 → n×8 bytes), models}` ;
then strat data (scripts, names) – not parsed by the loader, referenced from MAP via offsets
(`DAT_006d9dbc+off`). `geom_export --objects` exports the n1 models side by side.
## 7. TEXT – soweit für Geometrie nötig [sicher]
## 7. TEXT – as far as needed for geometry [certain]
`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).
(flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h}` ; `u32 nTex ; nTex × 8 bytes` ; rest (40 bytes in the village) [?].
Texture entry: `u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1` (u1/v1 inclusive).
Flags: bit0 flat colour, bit2 1-pixel border, bit3/bit4 width/height ×2, bits 12–14 blend mode, bits 8–10 another mode.
Pixels: ABGR1555 (R = bits 0–4), 0 = transparent. Details: notes/textures.md (other 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).
## 8. Writing new geometry – what must be consistent
Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the WAD unchanged):
1. **TRAK**: u32 count; header with correct nVerts/nFaces/nColl; box+centre (for culling,
recomputed); face planes from vertices; vertex normals. No checksums.
2. **Face texture index** < number of texture table entries; UVs only via rectangle corners (+flags).
Bit 0x8 at the start of a batch (page/state change, at the latest every 256 faces).
3. **Limits**: ≤ 8000 vertices per model (transform buffer 0x58b0f0..0x5d15f8, 36 bytes/vertex),
batch output buffer 0x5d15f8 ≈ 4800 vertices; collision d as i16/4096 → collision polygons
only within ±8 units of the tile origin. u16 indices.
4. **MAP**: keep N in the header, A (floats) and B (fixed point, z negated) identical, model indices
< TRAK count, tile cells within W×H, **vertex colour block exactly nVerts(model) per
tile** (otherwise the rest of MAP = strats shifts!), animation block (WFPC 0x10000)
matching the flags (import sets it to 0 entries), zones (see above).
5. Collision belongs to the cell: a polygon only takes effect if it is in the tile list of the cell
the player stands in → import enters every polygon into every cell it covers
(also as a pure collision tile without faces, like original model 0).
6. Total WAD length (wadtool/geomlib rewrite it). No other checksums found.
7. Strats (start point, enemies, camera paths) remain at their old positions (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).
### Tests (without starting the game)
* Round trip TRAK and MAP of all 34 WADs byte-identical.
* Export → import (`--coll-obj`) → export t1l1m001: rendering identical (img/), 26435/26442
triangles (7 degenerate ones dropped), floor height at 2800 random points: 99 % < 5 mm deviation,
10 points differ (polygons now also entered in neighbouring cells).
* Independent cross-check: `tools/mapchunk.py` (other agent) parses the generated MAPs completely.
* NOT yet tested in game: demo WADs in $CLAUDE_JOB_DIR/tmp: `demo_add.wad` (original +
2 blocks + ramp, original zones), `demo.wad` (everything replaced by a floor plate y=0 + blocks).
## 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).
## 9. Open / unknown
* Meaning of the three collision groups (the sum is always used together) and the complete material list.
* Face flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; exact effect of 0x1/0x2.
* Exact origin of the box margin in the header (recomputing with a 1/128 margin is probably enough).
* Zone table 2nd value (height, `DAT_00586424`) – possibly water/fog height.
* STPC animation: meaning of the nA block and the delta compression in detail (`FUN_0041fa30`).
* Whether the player start point/camera is coupled to geometry (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).
## 10. Tools
* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, vertex colours, one
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
(textured software renderer).
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]`
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
+222 -222
View File
@@ -1,31 +1,31 @@
# Levelbau mit `tools/levelbuild.py`
# Level building with `tools/levelbuild.py`
Stand: 2026-11-06 (Ausgang + Parkour überarbeitet). Baut komplette Level-WADs aus einer Level-Beschreibung (Python-Skript).
Basis ist immer ein Original-Level (Standard: Dorf `t1l1m001.wad`): dessen Strat-Code/Objektmodelle
(STPC), Texturen (TEXT), Sound usw. werden übernommen; **Geometrie (TRAK), Kachelraster,
Vertexfarben, Objekte und Wegpunkte (MAP) werden komplett neu erzeugt.**
Ergebnis im Spiel geprüft: `out/testwelt.wad` (= installierte Testwelt, siehe unten).
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).
## Aufruf
## Invocation
```
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad # bauen
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install # + als TestLevel installieren
python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel # Variablen ans Skript
python3 tools/levelbuild.py --types # platzierbare Strat-Typen des Basislevels
python3 tools/levelbuild.py --textures tex.png # Textur-Kontaktabzug + Namensliste
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` kopiert nach `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` (legt einmalig
`.orig` an) und legt die Kapitel-Symlinks 2..49 an (s. „Ausgang“). Test im Spiel: `tools/lamatest.py run out/x.wad ordner` (s. testharness.md).
`--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-Skript
## Level script
```python
LEVEL_ARGS = dict(name='Testwelt', seed=7) # NAME-Chunk, Zufallssaat (Texturdrehung)
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)) # Meer + Todesebene
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)
@@ -34,240 +34,240 @@ def build(L): # L = levelbuild.Level
L.strat('Bunny', 66, 34)
L.coins([(27, 20), (28, 20)])
def report(L): # optional, nach dem Bauen
def report(L): # optional, after building
...
```
`L.opts` enthält die `--set`-Werte (Strings).
`L.opts` contains the `--set` values (strings).
### Koordinaten
* Spielkoordinaten wie bei Strats: **x nach Osten, y nach oben, z = Rasterzeile (positiv,
"Süden")**, 1 Einheit = 1 Rasterzelle. Raster des Dorfes: 96×96 → alles muss in x,z ∈ [0,96) liegen.
* Winkel in **Grad**, 0° = Blick nach +z, 90° = Blick nach +x (im Spiel geprüft).
* Intern wird in den Exportraum (z gespiegelt) umgerechnet; Umlaufsinn wird automatisch
korrigiert (`_orient`), man gibt nur die gewünschte Außennormale an.
### 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.
### Geometrie-Bausteine
| Methode | Beschreibung |
### Geometry building blocks
| Method | Description |
|---|---|
| `floor(x0,z0,x1,z1,y, tex, rot='random', coll=True)` | waagerechte Fläche (in 1×1-Kacheln unterteilt) |
| `box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None)` | Quader; `side_top` = Textur der obersten Wandreihe (z. B. `'graskante'`), `side_coll=False` = Wände ohne Kollision (niedrige Sockel begehbar) |
| `platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde')` | schwebende Plattform, Oberkante y |
| `pillar(x,z,y0,y1,size, side, top)` | Säule um Mittelpunkt |
| `ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base)` | Rampe, steigt in Richtung `+x/-x/+z/-z` |
| `stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True)` | Treppe; `walkable`: unsichtbare Schräge als Kollision |
| `fence(pts, y, height)` | Zaun (Pfosten + Latte) entlang Punktliste |
| `tree(x,z,y, height, crown)` | Klotz-Baum |
| `sea(x0,z0,x1,z1, y, tex='meer', kill=(...))` | Wasserfläche ohne Kollision + Todesebene |
| `quad([BL,BR,TR,TL], tex, coll, rot)` | einzelnes Viereck (Ecken von vorne gesehen) |
| `collider(pts, normal)` | unsichtbare Kollisionsfläche |
| `obj(path, offset, scale, tex)` | OBJ-Datei (Spielkoordinaten), `usemtl tex_N` oder Texturname |
| `height_at(x,z)` | Höhe der obersten begehbaren Fläche |
| `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 |
Texturen: Name aus `TEX` (Dorf: `gras, gras2, gras_dunkel, blumen, sand, erde, graskante,
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, …`), direkter Index (int) oder Flachfarbe (`'meer'`, `'rot'`, … oder
`(r,g,b)`; wird als Flachfarb-Eintrag (Flags 0x105, Seite −1) an die Texturtabelle angehängt).
Kontaktabzug mit Indizes: `notes/img/texturen_dorf.png`.
**Jedes Dreieck zeigt eine ganze Texturkachel** (die Engine hat keine freien UVs) – daher wird
alles in 1×1-Stücke zerlegt; schmale/flache Wände zeigen die Textur gestaucht.
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`.
**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.
### Beleuchtung (Vertexfarben)
### 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)` – je Vertex:
Himmel/Boden-Halbkugellicht + Sonne (Lambert) mit **Schatten** (Strahl zur Sonne gegen alle
Quader) + **Umgebungsverdeckung** (24 Strahlen, Reichweite `ao_radius`). Quader, Säulen,
Treppen, Plattformen melden sich als Schattenwerfer an (`solids`). 1.0 = Texturfarbe
(Vertexfarbe 0x80), max. 2.0. `tint=(r,g,b)` je Baustein multipliziert.
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.
### Objekte (Strats)
### Objects (strats)
`L.strat(name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False)`
* `y=None`: auf die oberste begehbare Fläche bei (x,z) stellen.
* Typfelder (nvars, Slots, Flags, Zusatzspeicher) und Parameter werden von der **ersten
Original-Instanz** des Typs kopiert (`params=[…]` überschreibt; Anzahl muss passen).
* `variant`: Typen mit mehreren Code-Einstiegen (z. B. SmashPot: 2 Spawn-Stubs).
* **Wegpunkte** (`wp`): `'relative'` = Wegpunktkette des Originals mitverschieben (Versatz zum
Objekt bleibt, y wird auf den Boden gesetzt, wenn dort einer ist), `'here'` = alle Punkte auf
das Objekt, `'none'` = keine (−1), oder eigene Liste `[(x,z[,y[,grad]]), …]` (Zusatzfelder
der Original-Wegpunkte werden übernommen). Ketten werden neu nummeriert (keine Verweise ins
alte Level).
* `player(x,z,rot)` (Strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)`
* `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]]), …]` (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),…])`.
* Typen, die im Dorf-Code stehen, aber dort nicht platziert sind, über `Strats.BORROWED` mit Typfeldern
aus einem anderen Level: derzeit `RubyIdol` (aus t1l1m002).
* `poolExtra` wird auf max(Original, Anzahl Strats + 80) gesetzt; der Parameterpool des
Originals bleibt erhalten, neue Parameter werden angehängt.
* 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.
### Was `build()` erzeugt
1. Alle Dreiecke werden nach (Rasterzelle, Höhenband 10 Einheiten ab y = −3) auf Kacheln
verteilt; Kachelursprung = Zellmitte/Bandmitte (Kollisions-d muss in ±8 liegen).
2. **Kollision**: jedes Viereck mit `coll=True` wird ein Kollisionspolygon; Böden nur in der
eigenen Zelle, Wände/Schrägen zusätzlich in Nachbarzellen bis 0,3 Abstand (die Engine prüft
nur die Kacheln der Zelle, in der der Spieler steht). Unbenutzte Hilfsvertices sorgen dafür,
dass die Kachel-Box die Kollisionspolygone umfasst.
3. Identische Kachelmodelle werden zusammengefasst (Vertexfarben stehen in MAP je Kachel).
4. **Todesebene**: Original-Modell 0 des Dorfes (unsichtbar, Material 0x1e) in jeder Zelle
des `kill`-Bereichs, die nicht vollständig von Boden bedeckt ist.
5. MAP: Zonenraster = 0, Zone 0 sieht alles (Maske 0xffffffff), keine Kachel-Wechsel (Alt-Block
leer), Kachelblöcke A/B/Modelle/Vertexfarben konsistent, Strats + Wegpunkte neu, Licht-/
Umgebungszonen (90-Byte-Block) und Rest unverändert.
6. TEXT: angehängte Flachfarben. NAME: `LEVEL_ARGS['name']`.
7. Gegenprobe: MAP wird nach dem Schreiben wieder vollständig geparst.
### 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.
## Erkenntnisse aus den Spieltests (lamatest, Xvfb)
* **Maßstab**: Lama ≈ 0,6 Einheiten hoch, läuft ≈ 1,1 Einheiten/s (Münz-Messbahn, s. u.; die
früher notierten 0,85 waren zu niedrig), braucht ~1 s zum Beschleunigen. Sprung (X) überwindet
0,35 hohe Kanten, 0,5 nicht. **Mit Anlauf fliegt es ~1,3 weit** (horizontal gleich schnell wie
beim Laufen), auf +0,2 landet es nach ~1,05; aus dem Stand deutlich kürzer (0,5-Lücke nicht
geschafft). → Parkour: Lücken 0,3 mit +0,2, Plattformen davor/dahinter ≥ 1,5 lang (nach der
Landung muss Platz für den nächsten Absprung sein), größere Höhen über Treppen/Rampen.
Messlevel: `levels/sprunglabor.py` (`--set spur=a..g|m`; `m` = Münz-Messbahn: Münze alle 0,25 →
Münzzähler × 0,25 = gelaufene Strecke; `f` = Weitsprung über Wasser).
* **Keine Stufen-Logik**: schon eine 0,12 hohe Kante *mit Wandkollision* blockiert das Lama.
Ohne Wandkollision (`side_coll=False`) läuft es auf niedrige Sockel hinauf; Treppen mit
unsichtbarer Schräge (`stairs(walkable=True)`) und Rampen sind begehbar (geprüft).
* **Steuerung ist kamerarelativ** (Hoch = Kamerablickrichtung). Die Startkamera steht hinter
dem Lama; steht direkt dahinter ein Hindernis (z. B. Pfosten), weicht sie seitlich aus →
Startpunkte hinten freihalten.
* Rot 0° = Blick +z, 90° = Blick +x.
* **Todesebene funktioniert**: Sturz ins Meer → Lebensenergie weg, Respawn am letzten
Checkpoint (bzw. Start).
* Spieler-Wegpunktkette (14 Punkte beim Dorf-Lama, Bedeutung unklar): `player()` legt den
ersten 8 Einheiten hinter das Lama, den Rest auf das Lama, Zusatzfelder vom Original.
* **LevelDoor** (Ausgang) – gelöst, siehe Abschnitt „Ausgang (LevelDoor)“ unten.
* Größen, die laufen: TRAK 0,6–1,0 MB, bis 1100 Modelle, 5200 Kacheln, WAD bis 6,9 MB
(Originale: TRAK ≤ 1 MB, ≤ 5800 Kacheln, WAD ≤ 6,85 MB). Testwelt: 628 Modelle,
2474 Kacheln, TRAK 646 KB, WAD 6,3 MB.
## 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 (14 points for the village llama, meaning unclear): `player()` puts the
first one 8 units behind the llama, the rest on the llama, extra fields from the original.
* **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.
## Ausgang (LevelDoor) – wie er funktioniert
## Exit (LevelDoor) – how it works
Strat-Bytecode (Dorf, STPC 0x430332, 3 Parameter, unsichtbar, kein Modell), dekodiert über die
VM `FUN_0054d180` (Opcode < 0x45: Tabelle 0x5796bc mit 16-Bit-Operand, sonst 0x578ff4+op·4):
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 ; Stamm, Welt, Kapitel des nächsten Levels (20.12)
loop: if g21 == 3 ; Spielzustand "läuft"
and Spieler.Boden.Material == 16 ; Eigenschaft 0x11 = *(strat+0xc0)+4 = Material-Byte des
; Kollisionspolygons unter dem Spieler (FUN_00403020)
and floor(Spieler.x) == floor(Tür.x) and floor(Spieler.z) == floor(Tür.z) ; gleiche Rasterzelle
-> op 0x192 (FUN_0054d620): Levelwechsel(Param), g1 += 1, Ende
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
```
* Die Tür ist also nur ein Auslöser; im Dorf liegt im Türdurchgang ein Bodenpolygon mit
**Material 16** (8 Stück im Dorf, auch in allen anderen Levels). Unsere Testwelt hatte überall
Material 0 → nichts passierte. **Fix:** Mosaik im Tor mit `mat=16` (`box(..., mat=TUER_MAT)`),
Tür in dieselbe Rasterzelle, Spielerweg führt mitten hindurch.
* **Nur Kacheln der Türzelle zählen** (Zellliste, FUN_004036d0), die Höhe nicht (t1l3m002–4: Boden
3 Einheiten unter der Tür). **Dorf 1 & Co.:** In der Türzelle liegt zunächst *kein* Material 16 –
es steckt im **Wechselmodell** der Kachel (MAP-Kachel-Wechsel, WFPC 0x10000): die RubyIdolDoor in
derselben Zelle tauscht beim Öffnen (Op 0x11a/0x11b) Kachel 420 von Modell 48 auf 49, und erst
Modell 49 hat das Ausgangspolster (42,25–42,75 / 49,23–49,50, y 3,25). Im Spiel geprüft
(2026-11-07): Modell 49 eingesetzt + Tür entfernt, Lama bei 42,5/3,5/49,37 → sofort Bilanz
„Dorf Kapitel 1“; Original (Tür zu) → nichts. Betrifft 8 der 31 Original-Türen (Ruby-,
PressurePad-, Lab-Türen); das Polster bei 43,5/48,45 (Zelle 43,48) ist dagegen ein Hauseingang
(Zonen-Teleport). t1l3m003: 2 der 4 Türen (Zellen 71,48/71,49) haben gar kein Polster.
* Zweiter Weg (Strat-Code bei 0x4252aa, Spielerlogik [wahrsch.]): Boden-Material 16 und Zelle =
g104/g105 (setzt die LevelDoor beim Start, Llama setzt sie auf −1) → Levelwechsel(g101..g103).
* Parameter in den Originalen: (1, Welt, Kapitel+1) = nächstes Kapitel; im letzten Kapitel einer
Welt **(7,7,7)** = „Welt geschafft“ → zurück zur Auswahlkarte, nächste Welt freischalten.
* `FUN_0054d620`: merkt aktuelles Level (0x585008..14), setzt nächstes (0x584eb4/b8/bc), zeigt die
Bilanz (Münzen x/55, Geheimnisse) mit „EINGABETASTE = speichern / ESC = weiter“. Fortschritt
0x585018/19 (max. Welt/Kapitel, im KSV +0x42/+0x43): ist man im höchsten freien Kapitel, wird
das nächste Kapitel freigeschaltet; bei (7,7,7) die nächste Welt.
* **(7,7,7) in Welt 9 stürzt ab**: schaltet „Welt 10“ frei → nach dem Speichern steht der
Kartencursor auf Welt 10, Weltnamen-Zeiger 0x57bd40[9] = NULL → Page Fault bei 0x41B5CC.
* **Lösung Testwelt: Parameter (1,9,1)** – nach der Bilanz wird Kapitel 1 neu geladen (kein
Absturz, getestet mit Speichern). Nebenwirkung: Kapitel 2 wird freigeschaltet (und von
Kapitel n aus Kapitel n+1). Fehlt dessen WAD, stürzt die Auswahl ab („Couldnt load wad“).
Deshalb legt `levelbuild.py --install` (Funktion `kapitel_links()`) in der Mod
**Symlinks T1L9M002..049.wad → T1L9M001.wad und T192..T1949.COR → T191.COR** an (Welt 9 hat
49 Kapitel-Plätze, Tabelle 0x572420) und ruft `lama-mod an` auf. `lama-mod` übernimmt dafür jetzt
auch Symlinks aus Mod-Ordnern (`find … -type f -o -type l`). Geprüft: Kapitel 2 auf der Karte
wählbar, lädt die Testwelt.
* Die Auswahlkarte zeigt für Welt 9 keinen Hintergrund (nur Text) und 772 als Münzziel
(Münztabelle 0x5722f4 hat nur Welten 1–8) – kosmetisch.
* 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).
* 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.
* 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.
## Die Testwelt (`levels/testwelt.py` → `out/testwelt.wad`, installiert als T1L9M001.wad)
**2026-11-06 kompakt umgebaut** („alles näher zusammen“): L-förmige Insel, Hauptteil 32,5×23,5
(x 11..43,5, z 9,5..33) + Streifen unter dem Parkour-Ende (x 43,5..49, z 9,5..20,5), vorher
42×34 (x 10..52, z 8..42). Galerie, Gehege und Parkour grenzen direkt aneinander, keine leeren
Wiesen mehr. NAME-Chunk "Testwelt" (wird im Spiel nirgends angezeigt).
Vorschaubilder (Handy, 1280 px): `notes/img/vorschau/` (01–03 Blender-Renderings mit
Beschriftung, 04–11 Spielfotos, 12 Montage). Ältere Bilder `notes/img/testwelt_*.jpg` zeigen
noch das alte, weitläufige Layout.
## 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.
| Bereich | alt | neu |
| Area | old | new |
|---|---|---|
| Startplatz | x 14..22, z 11..19 (8×8) | x 15..21, z 12..19 (6×7) |
| Galerie | z 22..38, Spalten 2,5, Reihen z 25/28,5/32/35,5 | x 14,5..32,5, z 20,5..32, Spalten 1,9, Reihen z 22,5/25,2/27,9/30,6 (Abstand 2,7) |
| Gehege | x 39..49, z 24..35 | x 33..42, z 20,8..30 (direkt östlich der Galerie) |
| Parkour | x 22..53,6 | x 21..52,6 (nur um −1 in x verschoben, Maße gleich) |
| Ausgang | LevelDoor (51,5/15,5) | LevelDoor (50,5/15,5), Zelle 50/15, Mosaik mit Material 16 |
| 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 |
* **Startplatz** (x 15..21, z 12..19): Steinplatz mit Inka-Mosaik, 4 Inka-Säulen, Blumenbeete,
Bäume, 2 Münzen, Checkpoint (16,1/17,4). Lama startet bei (18, 14,6) mit Blick nach Süden
(Galerie direkt dahinter, Parkour-Treppe links im Bild).
* **Galerie** (z 20,5..32): 4 Reihen, je Objekt ein flacher Sockel (0,9 breit, 0,08 hoch,
begehbar), Lücke 1,0 zwischen Sockeln, Sandweg (0,6) vor jeder Reihe, farbiger Pfosten am
Reihenanfang (gelb/grau/blau/rot), Hauptweg Nord–Süd bei x 15,6..16,3:
1. Sammeln: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret
2. Kisten/Töpfe: SmashPot (2 Varianten), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport
3. Figuren: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy
4. Mechanik: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage,
* **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
* **Gehege** (x 33..42, z 20,8..30, Zaun, Eingang Westseite z 20,8..24): Bunny ×2, Ladybird,
FlockOfBirds, VillagerHerder; Aussichtspodest (0,6) mit Treppe an der Westseite zwischen
Galerie-Reihe 2 und 3.
* **Parkour** (beginnt an der Ostkante des Platzes, **alles auf der Linie z = 15,5 nach Osten**,
damit man ohne Kameradrehen durchkommt – Drehen per A/S oder Links/Rechts war im Skript nicht
reproduzierbar). Geometrie im Skript als „alte x + PX“ (PX = −1) geschrieben:
Treppe 0→0,8 · **Anschlag-Stufe** +0,3 (ohne Lücke: Lama läuft dagegen und steht still) ·
2 Lücken 0,3 mit je +0,2 auf 1,5 lange Holzklötze · Balken 0,5 breit · Mittelplattform (1,5) mit
Checkpoint · Sandrampe 1,5→2,4 · **Anschlag-Stufe** +0,3 · 3 Trittsteine (1,5 lang, Lücken 0,3,
je +0,2) · schmaler Balken 0,45 · Treppe 3,3→4,0 · Ziel-Plattform (4,0; x 47,6..52,6, ragt über
die Küste) mit Säulen, Brustwehr und Tor; darunter Mosaik mit Material 16, LevelDoor bei
(50,5 / 15,5). 18 Münzen entlang. Unter dem Parkour liegt Wiese (Herunterfallen ist harmlos).
* **Skript-Durchlauf** (Start → Ziel → Ausgang → Bilanz), alte Fassung 4/4, kompakte Fassung 2/2
Läufe erfolgreich (Bilanz 18/55 Münzen); die .seq ist zeitbasiert/relativ und blieb unverändert:
* **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 AUSGABE --seq "$(cat levels/testwelt_parkour.seq)"
lamatest.py run out/testwelt_parkour.wad OUTDIR --seq "$(cat levels/testwelt_parkour.seq)"
```
(`run:T:x@t,…` hält Up T s und drückt X zu festen Zeiten; die Anschlag-Stufen setzen den
Zeitfehler zurück. Fotos (`shot@`) blockieren ~0,4 s → nie kurz vor einem Sprung.)
* Im Spiel geprüft: Laden ohne Absturz (viele Läufe), alle 31 Galerie-Objekte erscheinen
(Münze/Secret/… reagieren: Münzzähler, "GEHEIMNIS!"), Lama läuft Galerie/Gehege ab,
Treppe hoch, Sprung auf Plattform B und auf Trittstein 1, Rampe hoch, Treppe zur
Ziel-Plattform, Meer = Respawn, kompletter Parkour per Skript, Ausgang → Bilanz → Kapitel 1 neu.
Bilder: aktuell `notes/img/vorschau/` (kompaktes Layout); `notes/img/testwelt_*.jpg` =
altes weitläufiges Layout (1–6 + Montage sogar noch mit altem Parkour).
* Startpunkt für Tests umschaltbar: `--set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2`
(`podest` = auf dem Aussichtspodest, Blick nach Westen über die Galerie);
Ausgangsparameter `--set tuer=1,9,1`.
(`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`.
### Nicht platzierte Dorf-Typen
| Typ | Grund |
### Village types not placed
| Type | Reason |
|---|---|
| Llama | ist der Spieler (Start) |
| LevelDoor | Ausgang am Parkour-Ende (braucht Boden mit Material 16, s. o.) |
| CameraSide | Kamera-Zone, unsichtbar |
| CutSceneCameraDirectional | Zwischensequenz-Kamera, unsichtbar |
| CutSceneTrigger | startet Zwischensequenz |
Kein platzierbarer Typ hat das Spiel zum Absturz gebracht (keine Bisektion nötig).
Hinweis: Mechanik-Objekte (Türen, Säulen, Statue) gehören im Dorf zu bestimmter Geometrie /
Kachel-Wechseln; in der Galerie stehen sie "nackt" (z. B. schwebendes RubyIdolDoor-Schild).
Typen, die nur im Code stehen (BouncingCoin, Sparkles, RubyIdolStatue …), haben keine
Original-Instanz mit Typfeldern und werden nicht angeboten (Ausnahme RubyIdol über `Strats.BORROWED`).
| 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`).
## Vorschau-Renderings (Blender)
`/usr/bin/blender` (Debian 3.0.1): **Cycles rendert nur Schwarz** (auch die Standardszene), OCIO
fehlt („Color management: fallback“, nur View „Standard“). **Eevee geht**, braucht aber ein
X-Display → eigenes `Xvfb :7` (nie :0, :5 gehört lamatest). Ablauf (Skripte in
`tools/vorschau/`: `strats_json.py` (aus levelbau/ aufrufen), `render_level.py`, `label.py`):
## 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-Positionen: Levelskript mit `levelbuild.Level` ausführen (ohne `L.build`), `L.strats` als JSON
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`:
eigener OBJ-Leser (der 3.0-Importer kennt keine Vertexfarben), Spiegelung OBJ→Spiel (y_blender =
−Spiel-z, Umlaufsinn drehen), Material = Textur (Closest) × Vertexfarbe, etwas Emission,
Sonne + Himmel, Marker für Strats (Münzen gold, Lama-Start oranger Kegel, Galerie-Objekte
in Reihenfarbe, Tiere weiß, Ausgang lila Ring); schreibt je Ansicht PNG + JSON mit
Bildkoordinaten der Bereichsnamen.
4. PIL: auf 1280 px skalieren, Beschriftungen (Start/Galerie/Gehege/Parkour/Ziel), JPG q85.
own OBJ reader (the 3.0 importer does not know vertex colours), mirroring OBJ→game (y_blender =
−game z, flip winding), 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.
+115 -115
View File
@@ -1,135 +1,135 @@
# MAP-Chunk & Objekte (Strats) – "Ein Königreich für ein Lama" (PC)
# MAP chunk & objects (strats) – "Ein Königreich für ein Lama" (PC)
Quelle: groove.exe, MAP-Lader `FUN_0042ac50`, Strat-Spawn `FUN_0054cfc0` → `FUN_0054bfc0`,
Strat-VM `FUN_0054d180`, Chunk-Dispatcher bei 0x558960. Werkzeuge in `../tools/`.
Alle 34 Original-MAPs werden von `mapchunk.parse()` vollständig gelesen (Rest 0 Byte) und
von `mapchunk.build()` **byte-identisch** zurückgeschrieben.
Source: groove.exe, MAP loader `FUN_0042ac50`, strat spawn `FUN_0054cfc0` → `FUN_0054bfc0`,
strat VM `FUN_0054d180`, chunk dispatcher at 0x558960. Tools in `../tools/`.
All 34 original MAPs are read completely by `mapchunk.parse()` (0 bytes left over) and
written back **byte-identically** by `mapchunk.build()`.
## Begriffe
* **Tile** = platziertes Geometrie-Modell aus **TRAK** (Level-"Baukasten", 1x1-Raster).
* **Strat** = Objekt/Script-Instanz (Gegner, Münze, Tür, Spieler …). Der Strat-Code
(Bytecode, u32-Wörter) und die Objektmodelle liegen in **STPC**; MAP enthält nur Instanzen
mit Offset in STPC. Es gibt **keine Typ-ID** – der Typ *ist* die Code-Adresse.
Typnamen stehen im Code: Opcode `0xd4, 0xea, s32 rel` → Name = C-String bei
(Adresse hinter rel) + rel. `strats.py` löst das auf.
* Koordinaten: Welt-y nach **oben**. Tiles speichern float x, y, z mit **z negativ**;
Strats/fixed-point (20.12, 4096 = 1.0) speichern z **positiv** (= −tile.z).
Winkel: 4096 = 360°. Boden-Tiles (flaches Modell, Vertex-y = +0.25) → Objekte stehen auf
`tile.y + 0.25` (bestätigt an Spieler/Checkpoints/Münzen).
## Terms
* **Tile** = placed geometry model from **TRAK** (the level "construction kit", 1x1 grid).
* **Strat** = object/script instance (enemy, coin, door, player …). The strat code
(bytecode, u32 words) and the object models live in **STPC**; MAP only contains instances
with an offset into STPC. There is **no type ID** – the type *is* the code address.
Type names are in the code: opcode `0xd4, 0xea, s32 rel` → name = C string at
(address after rel) + rel. `strats.py` resolves this.
* Coordinates: world y points **up**. Tiles store float x, y, z with **z negative**;
strats/fixed point (20.12, 4096 = 1.0) store z **positive** (= −tile.z).
Angles: 4096 = 360°. Floor tiles (flat model, vertex y = +0.25) → objects stand at
`tile.y + 0.25` (confirmed on player/checkpoints/coins).
## Layout MAP (alles little endian) — Konfidenz: hoch (aus Lader + Roundtrip)
WFPC (u32 Flags, globale 0x6da330) steuert optionale Blöcke.
## MAP layout (all little endian) — confidence: high (from loader + roundtrip)
WFPC (u32 flags, global 0x6da330) controls optional blocks.
| # | Block | Format |
|---|---|---|
| 1 | Kopf | u32 nTiles, u32 W, u32 H (Rastergröße in Zellen) |
| 2 | Tiles | nTiles × {f32 x, f32 y, f32 z, u32 rotY(0/1024/2048/3072), u32 modell, u32 hatAlt} |
| 3 | Parameterpool | u32 n, n × u32 (Strat-Parameter, von Strats per Index referenziert) |
| 4 | Zonen ("Environment") | u32 n, n × 90 Byte (s.u.) |
| 5 | Wegpunkte | u32 n, n × 34 Byte {u32 weiter(≠0: nächster Eintrag gehört zur selben Kette, 0 = Kettenende), u32, u16 rx, u16 ry, u16 rz, s32 x, y, z (fixed, z positiv), u32, u32} |
| 6 | Regionstabelle | 32 × {u32 flags, u32 wert} (→ 0x6da350) |
| 7 | Regionsraster | W×H × u32 Regionsindex (0..31) je Zelle, Zeile = z-Zelle |
| 8 | Tiles fixed | nTiles × 6 u32 {rx=0, rotY, rz=0, x·4096, y·4096, −z·4096} – **exakt aus Block 2 ableitbar** |
| 9 | Tile-Modelle | nTiles × u32 TRAK-Modellindex (= Feld 5 aus Block 2) |
| 10 | Alt-Modelle (WFPC&0x10000) | u32 n, n × 5 u32; n = Anzahl Tiles mit hatAlt≠0, in Tile-Reihenfolge; Feld 4 = alternatives TRAK-Modell (z. B. Tür offen/zu, stMapreplace) |
| 11 | Vertexfarben | je Tile: nv(TRAK-Modell) × u32 RGBA (Byte 3 = Frameanzahl F); wenn max F>1: (F−1)×nv weitere u32; danach dasselbe für das Alt-Modell, falls vorhanden. Farbe wird ×2 (Clamp 255) |
| 12 | Strats | u32 nStrats, u32 poolExtra, nStrats × 58 Byte (s.u.) |
| 13 | Pfade (WFPC&0x200000) | u32 n; je: u32 nsub; je sub: u16 cnt, u16 flags; cnt × {u32,u32,[u32 wenn WFPC&0x4000000],u16 typ,u16} – in keinem Original-Level benutzt |
| 14 | (WFPC&0x10) | u32 (Default 200; Größe eines 0x48-Puffers) |
| 15 | | u16 (→ mapobj+0xc, Bedeutung offen) |
| 1 | Header | u32 nTiles, u32 W, u32 H (grid size in cells) |
| 2 | Tiles | nTiles × {f32 x, f32 y, f32 z, u32 rotY(0/1024/2048/3072), u32 model, u32 hasAlt} |
| 3 | Parameter pool | u32 n, n × u32 (strat parameters, referenced by strats via index) |
| 4 | Zones ("Environment") | u32 n, n × 90 bytes (see below) |
| 5 | Waypoints | u32 n, n × 34 bytes {u32 next(≠0: next entry belongs to the same chain, 0 = end of chain), u32, u16 rx, u16 ry, u16 rz, s32 x, y, z (fixed, z positive), u32, u32} |
| 6 | Region table | 32 × {u32 flags, u32 value} (→ 0x6da350) |
| 7 | Region grid | W×H × u32 region index (0..31) per cell, row = z cell |
| 8 | Tiles fixed | nTiles × 6 u32 {rx=0, rotY, rz=0, x·4096, y·4096, −z·4096} – **exactly derivable from block 2** |
| 9 | Tile models | nTiles × u32 TRAK model index (= field 5 of block 2) |
| 10 | Alt models (WFPC&0x10000) | u32 n, n × 5 u32; n = number of tiles with hasAlt≠0, in tile order; field 4 = alternative TRAK model (e.g. door open/closed, stMapreplace) |
| 11 | Vertex colours | per tile: nv(TRAK model) × u32 RGBA (byte 3 = frame count F); if max F>1: (F−1)×nv more u32; then the same for the alt model, if present. Colour is ×2 (clamped to 255) |
| 12 | Strats | u32 nStrats, u32 poolExtra, nStrats × 58 bytes (see below) |
| 13 | Paths (WFPC&0x200000) | u32 n; each: u32 nsub; each sub: u16 cnt, u16 flags; cnt × {u32,u32,[u32 if WFPC&0x4000000],u16 type,u16} – not used in any original level |
| 14 | (WFPC&0x10) | u32 (default 200; size of a 0x48 buffer) |
| 15 | | u16 (→ mapobj+0xc, meaning open) |
Zellenzuordnung Tile: `col = int(x−0.5)`, `row = −int(z+0.5)`, Index `col + row·W` (verkettete
Liste je Zelle). **Tiles außerhalb 0..W−1 / 0..H−1 schreiben in fremden Speicher.**
Cell assignment of a tile: `col = int(x−0.5)`, `row = −int(z+0.5)`, index `col + row·W` (linked
list per cell). **Tiles outside 0..W−1 / 0..H−1 write into foreign memory.**
### Strat-Eintrag (58 Byte) — `mapchunk.STRAT = '3H3iIIi4Iiih'+'H'`
| Off | Typ | Feld | Konfidenz |
### Strat entry (58 bytes) — `mapchunk.STRAT = '3H3iIIi4Iiih'+'H'`
| Off | Type | Field | Confidence |
|---|---|---|---|
| 0 | u16 ×3 | Rotation x, y, z (4096=360°; x/z fast immer 0) | hoch |
| 6 | s32 ×3 | Position x, y, z fixed 20.12 (z positiv) | hoch |
| 18 | u32 | **Code-Offset in STPC** (= Objekttyp) | hoch |
| 22 | u32 | Anzahl Parameter n | hoch |
| 26 | s32 | Index in Parameterpool (−1 wenn n=0); Parameter werden auf den Strat-Stack gelegt | hoch |
| 30 | u32 | Anzahl Strat-Variablen (vom Compiler, **typkonstant**) | hoch |
| 34 | u32 | Anzahl 16-Byte-Slots (Kollision/Unterobjekte; typkonstant) | mittel |
| 38 | u32 | Typflags (0x8000/0x80000/0x40000 = Kollisions-/Physikblock; typkonstant) | mittel |
| 42 | u32 | Zusatzspeicher (&0x3fffffff, meist 0..3; typkonstant) | mittel |
| 46 | s32 | Wegpunkt-Index (Block 5) oder −1 | hoch |
| 50 | u32 | immer 0 | – |
| 54 | u16 | Flags; Bit 1 (2) = beim Levelstart nicht erzeugen | hoch |
| 56 | u16 | immer 0 | – |
| 0 | u16 ×3 | rotation x, y, z (4096=360°; x/z almost always 0) | high |
| 6 | s32 ×3 | position x, y, z fixed 20.12 (z positive) | high |
| 18 | u32 | **code offset in STPC** (= object type) | high |
| 22 | u32 | number of parameters n | high |
| 26 | s32 | index into parameter pool (−1 if n=0); parameters are pushed onto the strat stack | high |
| 30 | u32 | number of strat variables (from the compiler, **constant per type**) | high |
| 34 | u32 | number of 16-byte slots (collision/sub-objects; constant per type) | medium |
| 38 | u32 | type flags (0x8000/0x80000/0x40000 = collision/physics block; constant per type) | medium |
| 42 | u32 | extra memory (&0x3fffffff, mostly 0..3; constant per type) | medium |
| 46 | s32 | waypoint index (block 5) or −1 | high |
| 50 | u32 | always 0 | – |
| 54 | u16 | flags; bit 1 (2) = do not create at level start | high |
| 56 | u16 | always 0 | – |
`poolExtra` (zweites u32 im Strat-Kopf) = max. gleichzeitige Strats − 30 (Engine legt
poolExtra+30 Slots à 0x154 Byte an). **Muss ≥ Anzahl Strats + dynamisch gespawnte sein.**
`poolExtra` (second u32 in the strat header) = max. simultaneous strats − 30 (the engine allocates
poolExtra+30 slots of 0x154 bytes). **Must be ≥ number of strats + dynamically spawned ones.**
### Zonen (90 Byte, im Speicher 0x5c) — Konfidenz mittel
u32 cellX, cellY, cellZ, s32 x,y,z (fixed), u32 ?, u32 rot?, u32 flags(+0x20; **Bit 8 = Startzone**
→ DAT_00584644), u32, u32 ptr-Offset in STPC (0=keiner), u32, u32, u32, u16, u32 Nebel nah/fern
(+0x3c/+0x40), u32 ×2 (+0x44/+0x48), u32 Farbe (z. B. 0x404040), u32 Farbe, u16, u16, u32 Musik-ID
(+0x58; FUN_00546b70). Kamera-/Nebel-/Musikbereiche; nicht Lichtquellen (die stehen in LGHT).
### Zones (90 bytes, 0x5c in memory) — confidence medium
u32 cellX, cellY, cellZ, s32 x,y,z (fixed), u32 ?, u32 rot?, u32 flags(+0x20; **bit 8 = start zone**
→ DAT_00584644), u32, u32 ptr offset in STPC (0=none), u32, u32, u32, u16, u32 fog near/far
(+0x3c/+0x40), u32 ×2 (+0x44/+0x48), u32 colour (e.g. 0x404040), u32 colour, u16, u16, u32 music ID
(+0x58; FUN_00546b70). Camera/fog/music areas; not light sources (those are in LGHT).
## Spieler, Checkpoints, Ausgang
* **Spielerstart** = Position/Rotation der Strat-Instanz **`Llama`** (genau 1× in jedem
Lauf-Level; Typfelder nvars 78, flags 0x248000). Sonderlevel: Fluss (t1l3*) =
`KuscoAndPachaTogetherOnATree`, Achterbahn (t1l7m001/3/5) = `RollerCoaster`,
## Player, checkpoints, exit
* **Player start** = position/rotation of the strat instance **`Llama`** (exactly 1× in every
running level; type fields nvars 78, flags 0x248000). Special levels: river (t1l3*) =
`KuscoAndPachaTogetherOnATree`, roller coaster (t1l7m001/3/5) = `RollerCoaster`,
t1l2m003 = `LlamaChase`.
* **Checkpoints** = `CheckPointPost` (spawnt `CheckPointHead`), Variante `CheckPointBox`.
* **Ausgang** = `LevelDoor` (1×/Level), Sonderfälle `VillageEndDoor`, `EndGameDoor`.
* Immer vorhanden: `CheatTeleport`, `Wompy` (Funktion nicht geprüft), `CutSceneTrigger`.
* **Checkpoints** = `CheckPointPost` (spawns `CheckPointHead`), variant `CheckPointBox`.
* **Exit** = `LevelDoor` (1×/level), special cases `VillageEndDoor`, `EndGameDoor`.
* Always present: `CheatTeleport`, `Wompy` (function not checked), `CutSceneTrigger`.
## Objekttypen
Vollständige Tabelle Typ × Level (Instanzzahlen): `strat_types_table.md`
(erzeugt mit `tools/strat_table.py <wads>`). Kurz:
* in praktisch allen Levels: Coin, CutSceneTrigger, CutSceneCameraDirectional, LevelDoor,
## Object types
Complete table type × level (instance counts): `strat_types_table.md`
(generated with `tools/strat_table.py <wads>`). Summary:
* in practically all levels: Coin, CutSceneTrigger, CutSceneCameraDirectional, LevelDoor,
Secret, Llama, CheatTeleport, ChargeBooster, Wompy, CheckPointPost, Coin5, CameraSide, Health,
HealthFull, SmashPot, RubyIdol, Grapes, HealthGenerator, RubyIdolDoor …
* ~300 Typen insgesamt, ~200 weitere nur intern gespawnt (Splitter, Seile, Effekte).
* **Jeder Level hat seinen eigenen kompilierten Strat-Code + Modelle in STPC.** Ein Typ ist
nur in Levels platzierbar, deren STPC ihn enthält (`strats.py level.wad --all` listet alle
benannten Strats im Code, auch nie direkt platzierte). Der Code enthält **absolute
STPC-Offsets** (z. B. Spawn-Stubs `b2 <abs> e0 …`), STPC ist also nicht verschiebbar,
ohne zu relozieren. Typen aus anderen Levels zu übernehmen = STPC (+ TEXT-Indizes)
zusammenführen → aufwendig, vorerst nicht.
* "Spawn-Stub"-Einträge (z. B. SmashPot): MAP zeigt auf einen kleinen Code
`push X; spawn(abs Ziel, nvars, …); end` → `strats.py` meldet `Name(spawn)`.
* ~300 types in total, ~200 more only spawned internally (splinters, ropes, effects).
* **Every level has its own compiled strat code + models in STPC.** A type can only be
placed in levels whose STPC contains it (`strats.py level.wad --all` lists all
named strats in the code, including ones never placed directly). The code contains **absolute
STPC offsets** (e.g. spawn stubs `b2 <abs> e0 …`), so STPC cannot be moved
without relocating. Taking types from other levels = merging STPC (+ TEXT indices)
→ laborious, not for now.
* "Spawn stub" entries (e.g. SmashPot): MAP points to a small piece of code
`push X; spawn(abs target, nvars, …); end` → `strats.py` reports `Name(spawn)`.
## Regeln beim Schreiben einer neuen MAP
1. Tile-Blöcke 2/8/9/11 parallel halten (gleiche Anzahl/Reihenfolge); Block 8 aus 2 ableiten;
Block 9 = Feld 5 aus 2; Vertexfarben-Länge exakt = Σ nv(Modell)·Frames (+Alt).
2. Alt-Block: Anzahl = Tiles mit hatAlt≠0, gleiche Reihenfolge. Neue Tiles einfach hinten
anhängen (Strats können Tiles per Index ersetzen – `stMapadd/stMapreplace` – deshalb
bestehende Indizes nicht umsortieren).
3. Alle Tiles in 0..W × 0..H (z negativ!). Regionsraster W·H Einträge.
4. Strat: Code-Offset muss Strat-Anfang in **diesem** STPC sein; nvars/slots/flags/extra
**vom Original-Typ kopieren**; Parameter in Pool anhängen, Index/Anzahl setzen; Wegpunkt
−1 oder gültiger Index (manche Typen brauchen Wegpunkte, sonst "removing strat").
5. poolExtra erhöhen, wenn mehr Strats.
6. WFPC-Flags bestimmen, welche optionalen Blöcke vorhanden sein müssen – nicht ändern.
## Rules for writing a new MAP
1. Keep tile blocks 2/8/9/11 in parallel (same count/order); derive block 8 from 2;
block 9 = field 5 of 2; vertex colour length exactly = Σ nv(model)·frames (+alt).
2. Alt block: count = tiles with hasAlt≠0, same order. Simply append new tiles
at the end (strats can replace tiles by index – `stMapadd/stMapreplace` – so
do not reorder existing indices).
3. All tiles within 0..W × 0..H (z negative!). Region grid has W·H entries.
4. Strat: code offset must be a strat start in **this** STPC; **copy** nvars/slots/flags/extra
**from the original type**; append parameters to the pool, set index/count; waypoint
−1 or a valid index (some types need waypoints, otherwise "removing strat").
5. Increase poolExtra if there are more strats.
6. WFPC flags determine which optional blocks must be present – do not change them.
## Werkzeuge
* `mapchunk.py` – parse/build (Roundtrip identisch für alle 34 MAPs), `vcol_split` je Tile.
## Tools
* `mapchunk.py` – parse/build (identical roundtrip for all 34 MAPs), `vcol_split` per tile.
* `map_dump.py level.wad [--strats --tiles --lights]`
* `strats.py level.wad [--all]` – Typen mit Instanzzahlen, Code-Offsets, Typfeldern.
* `strat_table.py wads_dir` – Typ×Level-Tabelle.
* `stpcwalk.py` – STPC grob ablaufen (Modelle → Animsets → [WFPC&0x100-Block] → Strat-Code).
* `make_testlevel.py quelle.wad ziel.wad [--name …] [--spacing 3] [--floor-model N] [--keep-original]`
– sucht freien Rasterbereich, legt Bodenplatte aus Kopien des häufigsten flachen
Boden-Tiles (t1l1m001: TRAK-Modell 12) an, stellt je Typ eine Instanz (Parameter vom
Original) im 6er-Raster auf, Spieler an den Rand, Parkour (6 Trittplatten +0.5 mit
Lücken, Münzen) zu einer Zielplattform mit LevelDoor. Ausgelassen: Camera*/CutScene*/
Force*/Trigger. Test (nicht im Spiel geprüft): T1L9M001 → 1753 Tiles, 36 Strats, 27 Typen.
* `strats.py level.wad [--all]` – types with instance counts, code offsets, type fields.
* `strat_table.py wads_dir` – type×level table.
* `stpcwalk.py` – roughly walks STPC (models → animsets → [WFPC&0x100 block] → strat code).
* `make_testlevel.py src.wad dst.wad [--name …] [--spacing 3] [--floor-model N] [--keep-original]`
– finds a free grid area, lays a floor plate from copies of the most common flat
floor tile (t1l1m001: TRAK model 12), places one instance per type (parameters from the
original) on a 6-unit grid, the player at the edge, and a parkour (6 stepping plates +0.5 with
gaps, coins) leading to a goal platform with a LevelDoor. Skipped: Camera*/CutScene*/
Force*/Trigger. Test (not checked in game): T1L9M001 → 1753 tiles, 36 strats, 27 types.
## Andere Chunks (Dispatcher 0x558960)
* WFPC: u32 Feature-Flags (→ 0x6da330), z. B. 0x18410690.
* INFO/VERS: je u32 1 (ignoriert).
* NAME: u32 + Levelname.
* TRAK: Tile-Modelle (u32 n, n×0x84-Header, dann Daten; Header+0x6c u16 nVert, +0x6e nFace(0x1c),
+0x78/7a/7c Anzahl 0x20-Primitive). Face+8 u16 = Index in TEXT-Texturliste.
* STPC: u32 n Modelle (je 0x8c-Header direkt + Daten), u32 n Animsets (0x20-Header + Daten),
[WFPC&0x100: Zusatzmodelle], dann Strat-Code bis Chunkende.
* LGHT: u32 n; je Licht u8 typ, u8 r, u8 g, u8 b (×2), Typ 1/2/…: weitere u32 (Richtung/Position,
z negiert). FUN_0042c180.
* LNFO: 2 u32 (→ 0x5ff070, 0x5ff060), z. B. 55, 2.
* LGPC: u32 n(+1), u32 m, u32; dann (n+1)·m × u32 Größen, danach die Blobs. FUN_00558db0.
* FONT: 256 × 4 u16 (Glyphen-Rechtecke). SPRT: u32 (bei WFPC&0x100000 Liste).
* SMPC / AMPC / SRPC: Soundbänke ("cvg"-Samples / "ambi" Ambient / Referenzen), FUN_00545350.
* 'GRIM' (nicht in Level-WADs gesehen): FUN_00437b40.
## Other chunks (dispatcher 0x558960)
* WFPC: u32 feature flags (→ 0x6da330), e.g. 0x18410690.
* INFO/VERS: u32 1 each (ignored).
* NAME: u32 + level name.
* TRAK: tile models (u32 n, n×0x84 header, then data; header+0x6c u16 nVert, +0x6e nFace(0x1c),
+0x78/7a/7c count of 0x20 primitives). Face+8 u16 = index into the TEXT texture list.
* STPC: u32 n models (each 0x8c header directly + data), u32 n animsets (0x20 header + data),
[WFPC&0x100: extra models], then strat code up to the end of the chunk.
* LGHT: u32 n; per light u8 type, u8 r, u8 g, u8 b (×2), type 1/2/…: more u32 (direction/position,
z negated). FUN_0042c180.
* LNFO: 2 u32 (→ 0x5ff070, 0x5ff060), e.g. 55, 2.
* LGPC: u32 n(+1), u32 m, u32; then (n+1)·m × u32 sizes, followed by the blobs. FUN_00558db0.
* FONT: 256 × 4 u16 (glyph rectangles). SPRT: u32 (list if WFPC&0x100000).
* SMPC / AMPC / SRPC: sound banks ("cvg" samples / "ambi" ambient / references), FUN_00545350.
* 'GRIM' (not seen in level WADs): FUN_00437b40.
+2 -2
View File
@@ -1,4 +1,4 @@
| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |
| Type | Level (MAP instances) | Σ | in code of n levels |
|---|---|---|---|
| Coin | 1l1m001:36 1l1m002:29 1l1m003:11 1l1m004:6 1l2m001:12 1l2m002:26 1l2m003:23 1l3m001:45 1l3m002:55 1l3m003:25 1l3m004:20 1l4m001:54 1l4m002:28 1l4m003:41 1l5m001:32 1l5m002:22 1l5m003:29 1l6m001:19 1l6m002:33 1l6m003:1 1l7m002:39 1l7m004:46 1l8m001:27 1l8m002:45 1l8m003:10 1l8m004:46 1l8m005:27 | 787 | 27 |
| CutSceneTrigger | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l2m003:1 1l3m001:1 1l3m002:1 1l3m003:1 1l3m004:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:5 1l6m002:5 1l6m003:3 1l7m002:1 1l7m004:2 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:7 1l8m005:2 | 45 | 27 |
@@ -339,4 +339,4 @@
| YzmaThrone | 1l6m003:1 | 1 | 1 |
| fat_bird | 1l3m003:1 | 1 | 1 |
Nur im Code (nie direkt in MAP): AppearedPlatform, AppleHole, AppleSeedFX, AppleSeedPad, AxeGuardAxe, BalanceStatue, BallShrapnel, Balloon, BalloonFish, BalloonFishPlatform, BalloonPacha, BalloonPlatform, Bat, BeetleBlueBodyPart, BeetleBlueShell, BeetleBodyPart, BeetleShell, Bird, BlobOnTopCity, BlobOnTopJungle, BlobOnTopJungleDay, BlobShell, BlockShadow, BlockheadColl, BossBomb, BouncingCoin, BouncingHealth, BridgePiece, BridgeSmashStatue, BunnyPotion, BunnyRotors, Camera, CameraCollisionInFront, CameraPlayCutSceneAutomatically, ChacaGrapes, ChangePlayerTo, ChargingDoorPad, CheatCharge, CheckExitFinalBit, CheckPointHead, CircleDoorPlatform, CityBarrelHalf, CityGeneralBarrel, CityGeneralFinalAxe, CityMurderBunny, CityWallShrapnel, CoinForBat, Column, ColumnChild, CondorSleeping, CowGuardSpawned, CutSceneDirectionalWithWPs, DropObject, DumbGuardLab, EndDoorPad, EndDoorWeight, EndGameChacaSkip, EndGameChacaSkipShadow, EndGameGuardsGorrila, EndGameGuardsLizard, EndGameGuardsOstrich, EndGameGuardsWartHog, EndGameKronkRope, EndGameTipoSkip, EndGameTipoSkipShadow, EnemyCoin, EnemyCoin5, FallingPitLab, Feather, FishSpit, Flame, FrogPotion, FrogPotionFromPacha, GoldIdolStatue, Groove, GuardLookAhead, HammerForYzma, JengaSpiralLab, JungleDart, JungleDaySlidingDoorPad, JungleFallingRubyIdol, JunglePillarHelper, JunglePressurePad, KidOnLlamaCircle, KidsRubyIdol, KronkIdol, KronkLabNet, KronkOnIcePickup, KronkPotionLlama, KronkRubyIdol, KuscoBunny, KuscoFrog, KuscoTurtle, KuzcoStatueDoor, KuzcoStatuePart, LabFinalPotion, LabLiftPad, LabSlidingDoorBox, LabSlidingDoorBoxJamie, LabSlidingDoorPad, LabSlidingDoorPadJamie, LabSlidingDoorTile, LabSlidingDoorTileJamie, LabSlidingDoorTrigger, LabSlidingDoorTriggerJamie, LabSlidingDoorWhizzJamie, LabWallShrapnel, LabWaterfallPad, LlamaSpit, Lobster, LobsterPotShrapnel, LobsterStars, LobsterStarsChild, Lobsters, Lobsters1, MerryGoRoundCar, MerryGoRoundOracleHead, MurderBunny, NestForBridge, NestForHorizontalLift, New, NewBossBlockBit, OracleDoor, OracleHead, PachaLookAhead, PinataShrapnel, PlayerFallenOff, PressurePad, PressurePadCata, PressurePadPalace, PumpBlock, PumpLift, PumpLiftPad, RealAppleHole, RisingColumnForStatue, RiverBat, RiverCrocDam, RiverCrocMinGame, RiverDamShrapnel, RiverJumpingMonkeyItem, RiverJumpingMonkeyRedItem, RiverSwingingMonkeyItem, RiverSwingingMonkeyRedItem, RockPad, RockShrapnel, RollingBoulderForMountain, RollingBoulderForMountainNoWalls, RopeForBalloon, RopeForBalloonFish, RopeForBalloonPacha, RopeForHelpGirlSkip, RopeForRiverFallingSpider, RopeForRiverHangingSpider, RopeForSpider, RopeForTargetPlatform, RopeForTargetPlatformCity, RopeForTargetPlatformJungleDay, RopeForTargetSpider, RubyIdolReward, RubyIdolStatue, SalamanderNew, SandTimerDoorJungle, SandTimerPad, SandTimerPadCity, SandTimerPadJungle, ScorpionBlack, ScorpionBrown, ScorpionBrownStars, ScorpionBrownStarsChild, ScorpionSalamander, Scorpions, Scorpions2, SecretEffect, SinkingStatuePad, SlidingBlockheadPad2, SmartFlyDoor, SmashLobsterCrate, SmashPot2, SmashPotShrapnel, SmashStatueBloke, SmashWallShrapnel, SmokeOnPlayer, SmokeOnYzma, Sparkles, SparklesRGB, SpawnableNeedleBall, SpawnableNeedleBallRev, SpawnablePop, SpawnableRing, SpawnableSmoker, SpawnedAlligator, SpiderIceBlock, SpitPlatform, Squasher, StatueShrapnel, StunnedStars, StunnedStarsChild, SwitchForBridge, SwitchForHorizontalLift, SwitchForMouthPlatform, SwitchForSummonPlatform, SwitchForVerticalLift, SwitchPlatform, SwitchPlatformLab, SwitchPlatformVillage, TheNewGrooveCamera, ThreePartHead, ThrowAcorn, TickTockSound, TrampolineDoor, TreadmillForBridge, TreadmillForVerticalLift, TurtlePotion, TurtlePotionFromPacha, WaterArcDot, WaterFallSound, WaterSplash, WaterTrail, WaterfallBit, WompyBeingUsed, YzmaBigCutscenePotion, YzmaCatIntroFake, YzmaCatIntroPotion, YzmaDamMist, YzmaDamShrapnel, YzmaInVillageProjectile, YzmaInVillageProjectileCity, YzmaRiverItem, YzmaRubyIdol
Only in code (never placed directly in MAP): AppearedPlatform, AppleHole, AppleSeedFX, AppleSeedPad, AxeGuardAxe, BalanceStatue, BallShrapnel, Balloon, BalloonFish, BalloonFishPlatform, BalloonPacha, BalloonPlatform, Bat, BeetleBlueBodyPart, BeetleBlueShell, BeetleBodyPart, BeetleShell, Bird, BlobOnTopCity, BlobOnTopJungle, BlobOnTopJungleDay, BlobShell, BlockShadow, BlockheadColl, BossBomb, BouncingCoin, BouncingHealth, BridgePiece, BridgeSmashStatue, BunnyPotion, BunnyRotors, Camera, CameraCollisionInFront, CameraPlayCutSceneAutomatically, ChacaGrapes, ChangePlayerTo, ChargingDoorPad, CheatCharge, CheckExitFinalBit, CheckPointHead, CircleDoorPlatform, CityBarrelHalf, CityGeneralBarrel, CityGeneralFinalAxe, CityMurderBunny, CityWallShrapnel, CoinForBat, Column, ColumnChild, CondorSleeping, CowGuardSpawned, CutSceneDirectionalWithWPs, DropObject, DumbGuardLab, EndDoorPad, EndDoorWeight, EndGameChacaSkip, EndGameChacaSkipShadow, EndGameGuardsGorrila, EndGameGuardsLizard, EndGameGuardsOstrich, EndGameGuardsWartHog, EndGameKronkRope, EndGameTipoSkip, EndGameTipoSkipShadow, EnemyCoin, EnemyCoin5, FallingPitLab, Feather, FishSpit, Flame, FrogPotion, FrogPotionFromPacha, GoldIdolStatue, Groove, GuardLookAhead, HammerForYzma, JengaSpiralLab, JungleDart, JungleDaySlidingDoorPad, JungleFallingRubyIdol, JunglePillarHelper, JunglePressurePad, KidOnLlamaCircle, KidsRubyIdol, KronkIdol, KronkLabNet, KronkOnIcePickup, KronkPotionLlama, KronkRubyIdol, KuscoBunny, KuscoFrog, KuscoTurtle, KuzcoStatueDoor, KuzcoStatuePart, LabFinalPotion, LabLiftPad, LabSlidingDoorBox, LabSlidingDoorBoxJamie, LabSlidingDoorPad, LabSlidingDoorPadJamie, LabSlidingDoorTile, LabSlidingDoorTileJamie, LabSlidingDoorTrigger, LabSlidingDoorTriggerJamie, LabSlidingDoorWhizzJamie, LabWallShrapnel, LabWaterfallPad, LlamaSpit, Lobster, LobsterPotShrapnel, LobsterStars, LobsterStarsChild, Lobsters, Lobsters1, MerryGoRoundCar, MerryGoRoundOracleHead, MurderBunny, NestForBridge, NestForHorizontalLift, New, NewBossBlockBit, OracleDoor, OracleHead, PachaLookAhead, PinataShrapnel, PlayerFallenOff, PressurePad, PressurePadCata, PressurePadPalace, PumpBlock, PumpLift, PumpLiftPad, RealAppleHole, RisingColumnForStatue, RiverBat, RiverCrocDam, RiverCrocMinGame, RiverDamShrapnel, RiverJumpingMonkeyItem, RiverJumpingMonkeyRedItem, RiverSwingingMonkeyItem, RiverSwingingMonkeyRedItem, RockPad, RockShrapnel, RollingBoulderForMountain, RollingBoulderForMountainNoWalls, RopeForBalloon, RopeForBalloonFish, RopeForBalloonPacha, RopeForHelpGirlSkip, RopeForRiverFallingSpider, RopeForRiverHangingSpider, RopeForSpider, RopeForTargetPlatform, RopeForTargetPlatformCity, RopeForTargetPlatformJungleDay, RopeForTargetSpider, RubyIdolReward, RubyIdolStatue, SalamanderNew, SandTimerDoorJungle, SandTimerPad, SandTimerPadCity, SandTimerPadJungle, ScorpionBlack, ScorpionBrown, ScorpionBrownStars, ScorpionBrownStarsChild, ScorpionSalamander, Scorpions, Scorpions2, SecretEffect, SinkingStatuePad, SlidingBlockheadPad2, SmartFlyDoor, SmashLobsterCrate, SmashPot2, SmashPotShrapnel, SmashStatueBloke, SmashWallShrapnel, SmokeOnPlayer, SmokeOnYzma, Sparkles, SparklesRGB, SpawnableNeedleBall, SpawnableNeedleBallRev, SpawnablePop, SpawnableRing, SpawnableSmoker, SpawnedAlligator, SpiderIceBlock, SpitPlatform, Squasher, StatueShrapnel, StunnedStars, StunnedStarsChild, SwitchForBridge, SwitchForHorizontalLift, SwitchForMouthPlatform, SwitchForSummonPlatform, SwitchForVerticalLift, SwitchPlatform, SwitchPlatformLab, SwitchPlatformVillage, TheNewGrooveCamera, ThreePartHead, ThrowAcorn, TickTockSound, TrampolineDoor, TreadmillForBridge, TreadmillForVerticalLift, TurtlePotion, TurtlePotionFromPacha, WaterArcDot, WaterFallSound, WaterSplash, WaterTrail, WaterfallBit, WompyBeingUsed, YzmaBigCutscenePotion, YzmaCatIntroFake, YzmaCatIntroPotion, YzmaDamMist, YzmaDamShrapnel, YzmaInVillageProjectile, YzmaInVillageProjectileCity, YzmaRiverItem, YzmaRubyIdol
+110 -110
View File
@@ -1,146 +1,146 @@
# Test-Harness `tools/lamatest.py`
# Test harness `tools/lamatest.py`
Startet "Ein Königreich für ein Lama" unbeaufsichtigt und unsichtbar (Xvfb :5,
Ton in Null-Sink), lädt TestLevel Kapitel 1 (T1L9M001.wad) und macht Screenshots.
Getestet am 2026-11-06 mit Wine 6.0.3, llvmpipe.
Starts "Ein Königreich für ein Lama" unattended and invisibly (Xvfb :5,
sound into a null sink), loads TestLevel chapter 1 (T1L9M001.wad) and takes screenshots.
Tested on 2026-11-06 with Wine 6.0.3, llvmpipe.
## Befehle
## Commands
```
lamatest.py run <wad> <outdir> [n] # alles in einem: installieren, start, tolevel, tour, stop
lamatest.py start # Xvfb :5 + Null-Sink lama_stumm + groove.exe
lamatest.py tolevel # Menü -> Slot mit TestLevel -> Kapitel 1 -> Gameplay
lamatest.py tour <outdir> [n] # n Kamera-Fotos (Rundumblick) + n Lauf-Fotos + Lama-Sicht
lamatest.py shot out.png # Vollbild 1920x1080
lamatest.py key <xdotool-key> [s] # Taste s Sekunden halten (Default 0.12)
lamatest.py keys Up:1.5 a:0.5 x # Folge, Name[:Haltezeit], auch sleep=2
lamatest.py seq <outdir> "TOKENS" # --seq-Tokens (shot:, run:, rj: …) im laufenden Spiel
lamatest.py run <wad> <outdir> [n] # all in one: install, start, tolevel, tour, stop
lamatest.py start # Xvfb :5 + null sink lama_stumm + groove.exe
lamatest.py tolevel # menu -> slot with TestLevel -> chapter 1 -> gameplay
lamatest.py tour <outdir> [n] # n camera shots (look around) + n walking shots + llama view
lamatest.py shot out.png # full screen 1920x1080
lamatest.py key <xdotool-key> [s] # hold key for s seconds (default 0.12)
lamatest.py keys Up:1.5 a:0.5 x # sequence, name[:hold time], also sleep=2
lamatest.py seq <outdir> "TOKENS" # --seq tokens (shot:, run:, rj: …) in the running game
lamatest.py status | stop | restore-wad
```
### `run`-Optionen (2026-11-06 ergänzt)
### `run` options (added 2026-11-06)
```
lamatest.py run <wad> <outdir> [n] [--seq "TOKENS"] [--tour] [--keep]
```
* **WAD-Wiederherstellung**: `run` merkt sich die *vorher installierte*
`T1L9M001.wad` (`$LAMATEST_DIR/vorher_T1L9M001.wad`) und spielt sie nach dem Lauf zurück –
also nicht `.orig`. Mit `--keep` bleibt die getestete WAD installiert.
(`restore-wad` stellt weiterhin explizit `.orig` her.)
* `--seq` ersetzt die Standard-Tour durch eine Tastenfolge (mit `--tour` zusätzlich Tour):
* `Up:1.5`, `a:0.5`, `x` … Taste[:Haltezeit] wie bei `keys`; `sleep=1`
* `shot:NAME` → `seq_NN_NAME.jpg`, `fp:NAME` → Foto in Lama-Sicht (Return gehalten)
* `dialog` → Dialog/Letterbox wegklicken
* `Up+x:0.8` → Tasten gleichzeitig halten
* `rj:T1:T2` → Anlauf-Sprung (Up halten, nach T1 s X, Up noch T2 s)
* `dj:T` → Doppel-X mit Anlauf, `hop:N:T` → Up halten, N× alle T s springen
* `run:T:x@1.3,x@2.9,shot@5.0,Left@3.0-3.4` → Up T s halten, nach Zeitplan (s ab Beginn)
X tippen / fotografieren (`seq_NN_runT.jpg`) / Taste von–bis halten; `run/Left:T:…` hält
statt Up eine andere Taste. Echtzeit: ein Foto blockiert ~0,4 s (verzögert folgende Ereignisse
und bremst das Spiel) → keine Fotos kurz vor Sprüngen.
* Steuerung ist **kamerarelativ** (Up = in Kamerablickrichtung, Left/Right seitlich).
A/S drehen die Kamera, S:0.45 ≈ 90° nach links schauen.
* Messwerte (Lama): ~1,1 Einheiten/s Laufen, Sprung 0,35 hoch, mit Anlauf ~1,3 weit – siehe
levelbau.md. Zeitgesteuerte Läufe streuen um ~0,3 Einheiten (Startverzögerung, Bildrate);
längere Strecken brauchen „Anschläge“ (Wand/Stufe, an der das Lama stehen bleibt).
* Kamera: nach Laufen gegen eine Wand oder einem Sturz dreht sich die Kamera unvorhersehbar →
Up läuft danach in eine andere Richtung. A/S-Drehung (S:0.45) ist nur ungefähr 90°.
* Kompletter Testwelt-Parkour per Skript: `levels/testwelt_parkour.seq` (levelbau.md).
* **WAD restore**: `run` remembers the *previously installed*
`T1L9M001.wad` (`$LAMATEST_DIR/vorher_T1L9M001.wad`) and restores it after the run –
i.e. not `.orig`. With `--keep` the tested WAD stays installed.
(`restore-wad` still explicitly restores `.orig`.)
* `--seq` replaces the default tour with a key sequence (with `--tour` the tour runs as well):
* `Up:1.5`, `a:0.5`, `x` … key[:hold time] as with `keys`; `sleep=1`
* `shot:NAME` → `seq_NN_NAME.jpg`, `fp:NAME` → shot in llama view (Return held)
* `dialog` → click away dialog/letterbox
* `Up+x:0.8` → hold keys simultaneously
* `rj:T1:T2` → running jump (hold Up, X after T1 s, Up for another T2 s)
* `dj:T` → double X with run-up, `hop:N:T` → hold Up, jump N× every T s
* `run:T:x@1.3,x@2.9,shot@5.0,Left@3.0-3.4` → hold Up for T s, following a schedule (s from start)
tap X / take a shot (`seq_NN_runT.jpg`) / hold a key from–to; `run/Left:T:…` holds
another key instead of Up. Real time: a shot blocks ~0.4 s (delays subsequent events
and slows the game down) → no shots shortly before jumps.
* Controls are **camera-relative** (Up = in the camera's view direction, Left/Right sideways).
A/S rotate the camera, S:0.45 ≈ look 90° to the left.
* Measurements (llama): ~1.1 units/s walking, jump 0.35 high, ~1.3 far with run-up – see
levelbau.md. Timed runs scatter by ~0.3 units (start delay, frame rate);
longer distances need "stops" (a wall/step where the llama comes to a halt).
* Camera: after running into a wall or a fall the camera rotates unpredictably →
Up then walks in a different direction. A/S rotation (S:0.45) is only roughly 90°.
* Complete test world parkour as a script: `levels/testwelt_parkour.seq` (levelbau.md).
* `LAMATEST_SHOT=1280` (Umgebung): `shot:`/`fp:`-Fotos 1280×720, Qualität 85 statt 960×540 (Vorschaubilder).
* „EINGEFROREN?“ bei einer `--seq` ohne Bewegung (z. B. nur `sleep=3 shot:x`) ist ein Fehlalarm –
das stehende Lama ändert das Bild nicht; mit Kameradrehung/Laufen kommt „OK“.
* Gute Blickwinkel im Spiel sind schwer: die Kamera hängt tief hinter dem Lama; erhöhte
Startpunkte (Podest, Parkour-Checkpoint `pk2` + `A:0.6`) zeigen mehrere Bereiche.
* `LAMATEST_SHOT=1280` (environment): `shot:`/`fp:` shots at 1280×720, quality 85 instead of 960×540 (preview images).
* "EINGEFROREN?" (frozen?) for a `--seq` without movement (e.g. only `sleep=3 shot:x`) is a false alarm –
the standing llama does not change the image; with camera rotation/walking you get "OK".
* Good viewing angles in the game are hard: the camera hangs low behind the llama; elevated
start points (pedestal, parkour checkpoint `pk2` + `A:0.6`) show several areas.
Hilfsskript für Serien: `$CLAUDE_JOB_DIR/tmp/lb_run.sh WAD NAME "SEQ"` (Montage `lb_mNAME.jpg`,
WAD-Pfad absolut!), `rs.sh NAME "SEQ"` (Neustart mit installierter WAD + `seq`, Montage `i/NAME.jpg`).
**Achtung**: kein `pgrep -f groove.exe` in Warteschleifen – das trifft die eigene Shell
(Kommandozeile enthält "groove.exe") und lamatest hält sie für eine fremde Instanz.
Helper script for series: `$CLAUDE_JOB_DIR/tmp/lb_run.sh WAD NAME "SEQ"` (montage `lb_mNAME.jpg`,
WAD path absolute!), `rs.sh NAME "SEQ"` (restart with installed WAD + `seq`, montage `i/NAME.jpg`).
**Caution**: no `pgrep -f groove.exe` in wait loops – it matches your own shell
(the command line contains "groove.exe") and lamatest takes it for a foreign instance.
Zustand/PIDs/wine.log: `$LAMATEST_DIR` (Default `/tmp/lamatest-$USER`).
State/PIDs/wine.log: `$LAMATEST_DIR` (default `/tmp/lamatest-$USER`).
`run` Exit-Codes: 0 OK, 3 Absturz, 4 Timeout/hängt, 5 Bild eingefroren.
Ausgabe in `<outdir>`: `report.txt`, `wine.log` (stderr, auf 2 MB gekürzt),
`00_geladen.png` (voll) + `.jpg`, `tour_*.jpg` (960x540, ~30 KB), bei Fehler
`zz_absturz.jpg` / `zz_timeout.jpg`. Ein Lauf ≈ 75 s, ≈ 1,2 MB.
`run` exit codes: 0 OK, 3 crash, 4 timeout/hang, 5 image frozen.
Output in `<outdir>`: `report.txt`, `wine.log` (stderr, truncated to 2 MB),
`00_geladen.png` (full) + `.jpg`, `tour_*.jpg` (960x540, ~30 KB), on failure
`zz_absturz.jpg` / `zz_timeout.jpg`. One run ≈ 75 s, ≈ 1.2 MB.
## Sicherheitsregeln (im Skript umgesetzt)
## Safety rules (implemented in the script)
- Bricht ab, wenn eine groove.exe mit anderem DISPLAY läuft (Elias' Instanz).
- xdotool nur mit DISPLAY=:5.
- Ton: eigener Null-Sink, App wird nie stummgeschaltet; `stop` entlädt den Sink.
- Spielstände (`*.ksv` im Spielordner) werden bei `start` gesichert und bei `stop`
bei Änderung zurückgespielt (neu entstandene gelöscht).
- `stop`: `wineserver -k` nur, wenn keine fremde groove.exe läuft, sonst nur eigene PIDs.
- Der Ausgang der Testwelt speichert Fortschritt (Kapitel 2 frei) – wird bei `stop` mit den
Spielständen zurückgespielt.
- WAD: erstes `run` sichert das Original als `mods/testlevel/wads/T1L9M001.wad.orig`;
`restore-wad` spielt es zurück. Das Overlay (`lama-mod an`) besteht aus
**Symlinks** auf die Mod-Dateien → Überschreiben von
`~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` wirkt sofort; `lama-mod an` wird
nur aufgerufen, wenn der Overlay-Link fehlt.
- Aborts if a groove.exe with a different DISPLAY is running (Elias' instance).
- xdotool only with DISPLAY=:5.
- Sound: own null sink, the app is never muted; `stop` unloads the sink.
- Save games (`*.ksv` in the game folder) are backed up on `start` and restored
on `stop` if changed (newly created ones are deleted).
- `stop`: `wineserver -k` only if no foreign groove.exe is running, otherwise only own PIDs.
- The test world exit saves progress (chapter 2 unlocked) – restored on `stop` together with the
save games.
- WAD: the first `run` backs up the original as `mods/testlevel/wads/T1L9M001.wad.orig`;
`restore-wad` restores it. The overlay (`lama-mod an`) consists of
**symlinks** to the mod files → overwriting
`~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` takes effect immediately; `lama-mod an` is
only called if the overlay link is missing.
## Ablauf `tolevel` (gemessen)
## `tolevel` sequence (measured)
| Schritt | Taste | Erkennung | t (ab Start) |
| Step | Key | Detection | t (from start) |
|---|---|---|---|
| Logo → Hauptmenü | – | OCR Ausschnitt 700x300+1150+650, Schwelle 75 % → nur markierter Punkt "Spiel starten" lesbar | ~6 s |
| Spiel starten | Return | – | |
| Spielstandauswahl (Karussell, startet bei Slot 2, **kein** Wrap) | Left ×3, dann Right bis "TestLevel" | OCR Ausschnitt 1600x280+160+740 (fuzzy, 2 Schwellen) | ~20 s |
| Slot 3 laden | Return | Auswahlkarte = >75 % beige Pixel | ~25 s |
| Kapitel 1 aktivieren (Cursor aus KSV +4/+5) | Return | | |
| Kamera-Flug (Letterbox: oberste+unterste Zeile schwarz) | Return überspringt | | +1,5 s |
| Gameplay | – | hell, kein Letterbox, goldenes HUD oben links (oder >25 s) 3× hintereinander | ~29 s gesamt, Laden ~4 s |
| Logo → main menu | – | OCR crop 700x300+1150+650, threshold 75 % → only the highlighted item "Spiel starten" is readable | ~6 s |
| "Spiel starten" (start game) | Return | – | |
| Save slot selection (carousel, starts at slot 2, **no** wrap) | Left ×3, then Right until "TestLevel" | OCR crop 1600x280+160+740 (fuzzy, 2 thresholds) | ~20 s |
| Load slot 3 | Return | selection map = >75 % beige pixels | ~25 s |
| Activate chapter 1 (cursor from KSV +4/+5) | Return | | |
| Camera fly-through (letterbox: top+bottom row black) | Return skips | | +1.5 s |
| Gameplay | – | bright, no letterbox, golden HUD top left (or >25 s) 3× in a row | ~29 s total, loading ~4 s |
Ohne Überspringen dauert der Kamera-Flug im Dorf ~20 s.
Ladebild = T191.COR ("TestLevel Kapitel 1 … LADEVORGANG").
Without skipping, the camera fly-through in the village takes ~20 s.
Loading screen = T191.COR ("TestLevel Kapitel 1 … LADEVORGANG").
Fallback: wird kein Menü erkannt, nach 40 s Return (überspringt Demo). Achtung:
Return auf einem falsch erkannten Bildschirm kann ein anderes Spiel starten –
Spielstände werden aber am Ende zurückgespielt.
Fallback: if no menu is detected, Return after 40 s (skips the demo). Caution:
Return on a misdetected screen can start a different game –
but save games are restored at the end.
## Steuerung PC (Tastatur, aus "Optionen → Tastatur konfigurieren", verifiziert)
## PC controls (keyboard, from "Optionen → Tastatur konfigurieren", verified)
| Aktion | Taste |
| Action | Key |
|---|---|
| Auf / Ab (vorwärts / rückwärts laufen) | Up / Down |
| Links / Rechts (drehen) | Left / Right |
| Sprung (auch Dialog weiter) | X |
| Up / Down (walk forward / backward) | Up / Down |
| Left / Right (turn) | Left / Right |
| Jump (also advances dialog) | X |
| Action | Z |
| Power | Space |
| Lama-Sicht (Ego-Perspektive, halten) | Return |
| Kamera nach links / rechts drehen | A / S (0,5 s halten ≈ 90°) |
| Pausenmenü | Escape |
| Llama view (first person, hold) | Return |
| Rotate camera left / right | A / S (hold 0.5 s ≈ 90°) |
| Pause menu | Escape |
Registry `HKCU\Software\Disney Interactive\Emperors New Groove\1.0`,
`"InputDevice Wine Keyboard"` = 14 DIK-Codes (je 4 Hex):
`"InputDevice Wine Keyboard"` = 14 DIK codes (4 hex digits each):
CB Left, CD Right, C8 Up, D0 Down, 1E A, 1F S, 39 Space, 2D X, 1C Return,
19 P, C9 PgUp, D1 PgDn, 2C Z, 18 O. Die Reihenfolge der Registry ≠ Menüreihenfolge;
P/PgUp/PgDn/O vermutlich Pause/Inventar (laut exe-Strings: PAUSE GAME, CYCLE
INVENTORY UP/DOWN, USE AN ITEM, TURN 180, SELECT) – nicht verifiziert.
Weitere Werte dort: CameraMode, ControlType, ScreenW/H (1920x1142), CDPath=L:\.
19 P, C9 PgUp, D1 PgDn, 2C Z, 18 O. The registry order ≠ menu order;
P/PgUp/PgDn/O probably pause/inventory (according to exe strings: PAUSE GAME, CYCLE
INVENTORY UP/DOWN, USE AN ITEM, TURN 180, SELECT) – not verified.
Other values there: CameraMode, ControlType, ScreenW/H (1920x1142), CDPath=L:\.
Pausenmenü (Escape): Grafik-/Tonoptionen, Tastatur/Joystick konfigurieren,
Weitere Optionen, Weiter, "Beenden und zur Auswahlkarte wechseln" (Bestätigung
Ja/Nein, Default Nein → Up, Return), Spiel beenden. Hauptmenü-Untermenüs: Escape = zurück.
Pause menu (Escape): graphics/sound options, configure keyboard/joystick,
more options, continue, "Beenden und zur Auswahlkarte wechseln" (quit to selection map; confirmation
Ja/Nein, default Nein → Up, Return), quit game. Main menu submenus: Escape = back.
## Beobachtungen Dorf-1-Kopie
## Observations on the village 1 copy
- Läuft man im Dorf vorwärts, startet sofort der Pacha-Dialog (Letterbox mit
Untertiteln, ~9× X zum Durchklicken). `tour` erkennt das, fotografiert den
Dialog einmal und klickt ihn weg.
- Lama kann in der Tour von der Klippe fallen (Respawn) – egal für Sichtprüfung.
- Walking forward in the village immediately starts the Pacha dialog (letterbox with
subtitles, ~9× X to click through). `tour` detects this, photographs the
dialog once and clicks it away.
- The llama can fall off the cliff during the tour (respawn) – irrelevant for visual checks.
## Absturzerkennung
## Crash detection
Pro Poll: groove.exe-Prozess auf :5 weg / Fenster "Groove" weg / Fenster mit
"Programmfehler", "error", "exception" … (Wine-Crashdialog). Im Report steht die
Zeile `wine: Unhandled page fault … at address XXXXXXXX` (Adresse in Ghidra
nachschlagen). Getestet: auf 300 KB gekürzte WAD und WAD mit 2000 zufälligen
Bytes → beide Absturz während des Ladebilds, erkannt nach ~5 s (Exit 3).
Harmloses Log-Rauschen (gstreamer/MPEG-1 fehlt, ole, ChangeDisplaySettings,
MIDIMAP) wird im Report ausgefiltert.
Per poll: groove.exe process on :5 gone / window "Groove" gone / window with
"Programmfehler", "error", "exception" … (Wine crash dialog). The report contains the
line `wine: Unhandled page fault … at address XXXXXXXX` (look up the address in Ghidra).
Tested: WAD truncated to 300 KB and WAD with 2000 random
bytes → both crash during the loading screen, detected after ~5 s (exit 3).
Harmless log noise (gstreamer/MPEG-1 missing, ole, ChangeDisplaySettings,
MIDIMAP) is filtered out of the report.
## Abhängigkeiten
## Dependencies
Xvfb, xdotool, ImageMagick (import/convert), tesseract mit `deu`, pactl, wine.
Xvfb, xdotool, ImageMagick (import/convert), tesseract with `deu`, pactl, wine.
+39 -39
View File
@@ -1,51 +1,51 @@
# TEXT-Chunk (Texturen)
# TEXT chunk (textures)
Quelle: groove.exe `FUN_004067b0` (Größenlauf), `FUN_004071d0` → `FUN_00406880` (Paletten),
`FUN_00406ab0` (Seiten), `FUN_00406a10` (RLE), `FUN_00406d30` (Texturliste),
`FUN_00406e40` (Texturanimationen), `FUN_00407240`/`FUN_00408470` (Upload/Konvertierung).
Werkzeug: `tools/textchunk.py`, `tools/text_export.py`. Parse + Neuaufbau ist für **alle
Level-WADs byte-identisch** (auch der RLE-Encoder erzeugt exakt die Originaldaten).
Source: groove.exe `FUN_004067b0` (size pass), `FUN_004071d0` → `FUN_00406880` (palettes),
`FUN_00406ab0` (pages), `FUN_00406a10` (RLE), `FUN_00406d30` (texture list),
`FUN_00406e40` (texture animations), `FUN_00407240`/`FUN_00408470` (upload/conversion).
Tools: `tools/textchunk.py`, `tools/text_export.py`. Parse + rebuild is **byte-identical
for all level WADs** (the RLE encoder also reproduces the original data exactly).
## Aufbau
## Layout
```
u32 nPal (in allen WADs 0)
nPal × { u32 id, u32 n, u16 farbe[n], u8 extra[n] }
u32 nSeiten (13–22 je Level, alle 256×256)
nSeiten × { u32 flags, u32 w, u32 h, Daten }
flags == 0 : w·h Byte (8 Bit, Palette) – unbenutzt
flags & 0x80 : u32 Länge, RLE16 – alle Originalseiten
sonst : w·h u16 roh
u32 nPal (0 in all WADs)
nPal × { u32 id, u32 n, u16 colour[n], u8 extra[n] }
u32 nPages (13–22 per level, all 256×256)
nPages × { u32 flags, u32 w, u32 h, data }
flags == 0 : w·h bytes (8 bit, palette) – unused
flags & 0x80 : u32 length, RLE16 – all original pages
otherwise : w·h u16 raw
u32 nTex (~180–1260)
nTex × 8 Byte { u16 flags, u8 seite, u8 clut(0xff), u8 x0, u8 x1, u8 y0, u8 y1 } (Kanten inklusive)
nTex × 8 bytes { u16 flags, u8 page, u8 clut(0xff), u8 x0, u8 x1, u8 y0, u8 y1 } (edges inclusive)
u32 nAnim
nAnim × { u16 texIndex, u8 typ, Nutzdaten je typ }
typ 1,2 : 3 Byte typ 7,9 : u16,u16,u8,u8
typ 8 : u8, u8 nFrames, u8, nFrames × u16 texIndex (Daumenkino)
typ 10,11: u8,u8,u8,u8 n, n × u16 texIndex
nAnim × { u16 texIndex, u8 type, payload per type }
type 1,2 : 3 bytes type 7,9 : u16,u16,u8,u8
type 8 : u8, u8 nFrames, u8, nFrames × u16 texIndex (flipbook)
type 10,11: u8,u8,u8,u8 n, n × u16 texIndex
```
RLE16: Folge von s16 n; n>0 → n Literal-u16; n<0 → nächstes u16 (−n)-mal.
RLE16: sequence of s16 n; n>0 → n literal u16; n<0 → next u16 repeated (−n) times.
## Pixel
16 Bit **PS1-Reihenfolge**: Bits 0–4 R, 5–9 G, 10–14 B, Bit 15 = STP.
`0x0000` = durchsichtig (Konverter schreibt 0 für Pixel 0, alles andere wird umgerechnet).
STP=0 bei Farbe≠0 tritt auf Seiten mit Blendmodus auf (halbtransparente Pixel).
Export: Alpha 0 = 0x0000, Alpha 128 = STP 0, Alpha 255 = STP 1.
## Pixels
16 bit **PS1 order**: bits 0–4 R, 5–9 G, 10–14 B, bit 15 = STP.
`0x0000` = transparent (the converter writes 0 for pixel 0, everything else is converted).
STP=0 with colour≠0 occurs on pages with a blend mode (semi-transparent pixels).
Export: alpha 0 = 0x0000, alpha 128 = STP 0, alpha 255 = STP 1.
Seiten-flags & 0x7f = Format/Blendklasse (Tabelle 0x570370, je 0x20 Byte):
1 = opak, 2 = eigener Konverter (0x409640), 3 = opak+Flag, 4/7 = Alpha (Blend 1),
5 = Blend 3, 6 = wie 1. (Modi 1/2/3 ≈ PS1-Halbtransparenz-Modi; Details offen.)
Page flags & 0x7f = format/blend class (table 0x570370, 0x20 bytes each):
1 = opaque, 2 = own converter (0x409640), 3 = opaque+flag, 4/7 = alpha (blend 1),
5 = blend 3, 6 = same as 1. (Modes 1/2/3 ≈ PS1 semi-transparency modes; details open.)
## Texturliste
Faces der Modelle (TRAK/STPC, Face+8 u16) verweisen auf den Index in der Texturliste;
Eintrag = Rechteck auf einer Seite. `flags`-Lowbyte 4/5/6/0xc6, Highbyte 1/4/17/20/36/…
(Bedeutung offen; vermutl. Blend/Größenklasse). Fürs Ersetzen: Liste unverändert lassen
und nur Pixel in den Seiten tauschen → keine Querverweise betroffen.
## Texture list
Model faces (TRAK/STPC, face+8 u16) reference an index into the texture list;
entry = rectangle on a page. `flags` low byte 4/5/6/0xc6, high byte 1/4/17/20/36/…
(meaning open; probably blend/size class). For replacing: leave the list unchanged
and only swap pixels in the pages → no cross references affected.
## Export / Ersetzen
## Export / replace
```
text_export.py export level.wad ordner # pageNN_fXX.png + texinfo.txt (≈370 KB je Level)
text_export.py import level.wad ordner neu.wad
text_export.py export level.wad folder # pageNN_fXX.png + texinfo.txt (≈370 KB per level)
text_export.py import level.wad folder new.wad
```
Import ändert nur Pixel, deren RGBA vom Export abweicht → unveränderte Seiten bleiben
bitgenau. Größe muss 256×256 bleiben. Neue Seiten/Texturen anhängen ist möglich
(nTex/nSeiten erhöhen), dann müssen aber Faces in TRAK/STPC die neuen Indizes verwenden.
Import only changes pixels whose RGBA differs from the export → unchanged pages stay
bit-exact. Size must stay 256×256. Appending new pages/textures is possible
(increase nTex/nPages), but then faces in TRAK/STPC must use the new indices.
+10 -10
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Dekodiert Argonaut-ASF-Musik (v2.1, 4-Bit-ADPCM) wie asfcodec.adl aus dem Spiel.
"""Decodes Argonaut ASF music (v2.1, 4-bit ADPCM) like the game's asfcodec.adl.
Nachgebaut aus der Disassembly von asfcodec.adl (Routinen 0x26e01969 / 0x26e01ca1):
Reimplemented from the disassembly of asfcodec.adl (routines 0x26e01969 / 0x26e01ca1):
Filter 0: s = s1 + (n << shift)
Filter 1: s = 2*s1 - s2 + (n << shift) (+ Übertrag aus 'adc', s. u.)
- kein Clipping; Ausgabe wird als 16 Bit gespeichert (Überlauf = Wrap)
- s1 läuft innerhalb eines Blocks ungekürzt im Register weiter,
am Blockanfang wird es aus dem 16-Bit-Puffer gelesen
- s2 wird immer aus dem 16-Bit-Puffer gelesen
Aufruf: asf2wav.py eingabe.asf ausgabe.wav
Filter 1: s = 2*s1 - s2 + (n << shift) (+ carry from 'adc', see below)
- no clipping; output is stored as 16 bit (overflow = wrap)
- within a block s1 carries on untruncated in the register,
at the start of a block it is read from the 16-bit buffer
- s2 is always read from the 16-bit buffer
Usage: asf2wav.py input.asf output.wav
"""
import array
import struct
@@ -33,7 +33,7 @@ def decode(data):
ch = 2 if flags & 2 else 1
p = off + 20
out = array.array('h', bytes(nblocks * 32 * ch * 2))
mem = [[0, 0] for _ in range(ch)] # gespeicherte 16-Bit-Werte: [vorletztes, letztes]
mem = [[0, 0] for _ in range(ch)] # stored 16-bit values: [second to last, last]
o = 0
for _ in range(nblocks):
for c in range(ch):
@@ -42,7 +42,7 @@ def decode(data):
p += 17
cl = (ctl >> 4) + 2
prev2, prev1 = mem[c]
eax = prev1 # Blockanfang: s1 aus dem Speicher
eax = prev1 # block start: s1 from memory
k = o + c
for byte in blk:
for nib in (byte >> 4, byte & 15):
+9 -9
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Entpackt Argonaut-.cor-Bilder (Ein Königreich für ein Lama) nach PNG.
"""Unpacks Argonaut .cor images (Ein Königreich für ein Lama) to PNG.
Aufbau: 8 Byte Signatur 17 98 AF 89 FE 42 D1 12, u32 Version (1),
u32 entpackte Größe, u32 gepackte Größe, dann LZ-Daten:
n < 0x80 -> n Bytes Rohdaten folgen
1LLL oooo oooooooo -> kopiere L+3 Bytes ab o Bytes weiter vorne
Das Ergebnis sind 640x480 Pixel à 3 Byte (RGB) oder à 1 Byte (Graustufen, z. B. 151.cor).
Aufruf: cor2png.py eingabe.cor ausgabe.png (benötigt Pillow)
Layout: 8-byte signature 17 98 AF 89 FE 42 D1 12, u32 version (1),
u32 unpacked size, u32 packed size, then LZ data:
n < 0x80 -> n bytes of raw data follow
1LLL oooo oooooooo -> copy L+3 bytes from o bytes back
The result is 640x480 pixels at 3 bytes (RGB) or 1 byte (greyscale, e.g. 151.cor).
Usage: cor2png.py input.cor output.png (requires Pillow)
"""
import struct
import sys
@@ -16,7 +16,7 @@ MAGIC = bytes.fromhex('1798af89fe42d112')
def unpack(data):
if data[:8] != MAGIC:
raise ValueError('keine COR-Datei')
raise ValueError('not a COR file')
_, size, packed = struct.unpack_from('<III', data, 8)
src = data[20:20 + packed]
out = bytearray()
@@ -37,7 +37,7 @@ def unpack(data):
if __name__ == '__main__':
raw, size = unpack(open(sys.argv[1], 'rb').read())
print(f'{sys.argv[1]}: {len(raw)} von {size} Bytes')
print(f'{sys.argv[1]}: {len(raw)} of {size} bytes')
if len(sys.argv) > 2:
from PIL import Image
mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size]
+79 -77
View File
@@ -1,26 +1,28 @@
#!/usr/bin/env python3
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
"""Debug visualisation for level WADs of "Ein Koenigreich fuer ein Lama".
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
Makes collision geometry (including invisible walls/floors) and invisible logic objects
(cameras, triggers, exits, checkpoints, force zones ...) visible in the game.
debugviz.py IN.wad OUT.wad [Optionen] ein Level
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle Level-WADs (t1l*, t6l*)
debugviz.py IN.wad OUT.wad [options] one level
debugviz.py --all OUTDIR [--src CD-wads-folder] all level WADs (t1l*, t6l*)
Optionen: --no-coll --no-markers --no-fill (nur Kanten) --opaque (Fuellung deckend)
--width 0.03 (Kantenbreite) --offset 0.012 (Abstand entlang der Normale)
--report datei.json
Options: --no-coll --no-markers --no-fill (edges only) --opaque (opaque fill)
--width 0.03 (edge width) --offset 0.012 (offset along the normal)
--report file.json
Prinzip (Details: notes/debugviz.md):
* Kollision: an jedes TRAK-Modell werden Render-Dreiecke angehaengt (Rahmen entlang der
Kanten jedes Kollisionspolygons, leicht entlang der Normale versetzt; bei unsichtbarer
Kollision zusaetzlich halbtransparente Fuellung). Damit stimmen Kachel-Transformation,
Zonen/Sichtbarkeit und Kachel-Wechsel automatisch. Kollisionspolygone werden NICHT
veraendert/ergaenzt. Vertexfarben-Block in MAP waechst je Kachel und Farbsatz mit.
* Objekte: Marker (Wuerfel, Linien, Boxen) als neue Kacheln ohne Kollision, je Rasterzelle
eine Kachel (also in der richtigen Zone). Strats bleiben unveraendert.
* Farben: angehaengte Flachfarb-Texturen (Flags 0x105 deckend, 0x1105 = Blendmodus 1
Alpha-Blending, Alpha im 4. Byte).
Principle (details: notes/debugviz.md):
* Collision: render triangles are appended to every TRAK model (a frame along the
edges of each collision polygon, offset slightly along the normal; invisible
collision additionally gets a semi-transparent fill). This way tile transforms,
zones/visibility and tile swaps are automatically correct. Collision polygons are NOT
changed/added. The vertex colour block in MAP grows accordingly per tile and colour set.
* Objects: markers (cubes, lines, boxes) as new tiles without collision, one tile per
grid cell (so in the right zone). Strats stay unchanged.
* Colours: appended flat colour textures (flags 0x105 opaque, 0x1105 = blend mode 1
alpha blending, alpha in the 4th byte).
Report/JSON keys and category names (boden, wand, ausgang, koll_polys, ...) are German
identifiers and kept as they are.
"""
import argparse
import collections
@@ -40,24 +42,24 @@ import strats as S # noqa: E402
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
# ------------------------------------------------------------------ Farben
# ------------------------------------------------------------------ colours
KIND_COL = {
'boden': (40, 255, 40), # begehbar (Normale steil nach oben)
'schraege': (200, 255, 0), # schraeg
'wand': (0, 150, 255), # Wand
'decke': (255, 130, 0), # Decke / Ueberhang
'boden': (40, 255, 40), # walkable (normal steeply upwards)
'schraege': (200, 255, 0), # slope
'wand': (0, 150, 255), # wall
'decke': (255, 130, 0), # ceiling / overhang
}
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
INVIS_FILL = (255, 0, 0) # invisible collision (no render surface)
FILL_ALPHA = 96
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus PALETTE.
# material -> (name, colour). Unknown materials get colours from PALETTE.
MAT_INFO = {
16: ('Ausgang (LevelDoor-Boden)', (190, 60, 255)),
17: ('selektiv 0x11 (nur Objekte mit Flag 0x8000000)', (255, 255, 0)),
18: ('selektiv 0x12 (nicht fuer Objekte mit Flag 0x8000000)', (255, 200, 120)),
30: ('kein Boden / Fall-/Todesebene (0x1e)', (255, 0, 255)),
16: ('exit (LevelDoor floor)', (190, 60, 255)),
17: ('selective 0x11 (only objects with flag 0x8000000)', (255, 255, 0)),
18: ('selective 0x12 (not for objects with flag 0x8000000)', (255, 200, 120)),
30: ('no floor / fall/death plane (0x1e)', (255, 0, 255)),
}
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe notes/debugviz.md)
# fixed colours for the remaining materials (meaning in the code not yet known, see notes/debugviz.md)
MAT_FIXED = {
1: (0, 200, 200), 2: (255, 255, 255), 3: (255, 120, 170), 5: (120, 255, 200), 6: (160, 120, 255),
7: (255, 170, 80), 8: (0, 130, 60), 9: (150, 200, 255), 10: (150, 150, 0), 11: (150, 90, 40),
@@ -79,8 +81,8 @@ MARK_COL = {
'objekt': (90, 90, 90),
}
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
MARK_SIZE = 0.05 # halbe Kantenlaenge
MARK_H = 0.35 # marker cube above the strat origin
MARK_SIZE = 0.05 # half edge length
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
@@ -117,10 +119,10 @@ def mark_cat(name):
return None
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
# ------------------------------------------------------------------ TEXT: append flat colours
def text_append_flats(d, entries):
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
"""entries: list of (r,g,b,alpha|None). Returns (new TEXT, first new index)."""
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
@@ -139,7 +141,7 @@ def text_append_flats(d, entries):
for r, g, b, a in entries:
if a is None:
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
else: # Blendmodus 1 (Bits 12-14) = SRCALPHA/INVSRCALPHA, Alpha = 4. Byte
else: # blend mode 1 (bits 12-14) = SRCALPHA/INVSRCALPHA, alpha = 4th byte
add += struct.pack('<HbbBBBB', 0x1105, -1, -1, r, g, b, a)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(entries)) + d[o + 4:e] + add + d[e:], nt
@@ -156,10 +158,10 @@ class Palette:
if k not in self.idx:
self.idx[k] = len(self.entries)
self.entries.append((*rgb, alpha))
return self.idx[k] # relativ; + base beim Schreiben
return self.idx[k] # relative; + base when writing
# ------------------------------------------------------------------ Geometrie-Helfer
# ------------------------------------------------------------------ geometry helpers
def norm(v):
n = np.linalg.norm(v)
@@ -167,7 +169,7 @@ def norm(v):
class Mesh:
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
"""Triangle list with palette index; verts (pos, normal)."""
def __init__(self):
self.verts = []
@@ -178,13 +180,13 @@ class Mesh:
return len(self.verts) - 1
def quad(self, p, n, col):
"""4 Punkte (Ring), eine Normale."""
"""4 points (ring), one normal."""
ids = [self.v(q, n) for q in p]
self.tris.append((ids[0], ids[1], ids[2], col))
self.tris.append((ids[0], ids[2], ids[3], col))
def beam(self, a, b, w, col):
"""Linie a->b als Kreuz aus zwei doppelseitigen Streifen."""
"""Line a->b as a cross of two double-sided strips."""
a = np.asarray(a, float); b = np.asarray(b, float)
d = b - a
L = np.linalg.norm(d)
@@ -215,7 +217,7 @@ def seg_split(a, b, maxlen=1.0):
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
# ------------------------------------------------------------------ Kollision klassifizieren
# ------------------------------------------------------------------ classify collision
def kind_of(ny):
if ny >= 0.7:
@@ -236,7 +238,7 @@ def decode_model_coll(m):
ok = len(pts) >= 3 and all(abs(x) < 12 for p in pts for x in p)
P = np.array(pts, float) if ok else None
if ok:
# Flaeche pruefen
# check the surface
ar = 0.0
for k in range(1, len(P) - 1):
ar += np.linalg.norm(np.cross(P[k] - P[0], P[k + 1] - P[0])) / 2
@@ -249,7 +251,7 @@ def decode_model_coll(m):
class RenderIndex:
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
"""Render triangles of the world (export space), bucketed by grid cell."""
def __init__(self, trak, mp):
cells = collections.defaultdict(list)
@@ -280,7 +282,7 @@ class RenderIndex:
self.cells = {k: np.array(v) for k, v in cells.items()}
def covered(self, p, n, dist=0.5, cosmin=0.4, tol=0.02):
"""Trifft der Strahl p + t*n (|t| <= dist) ein Render-Dreieck (Neigung zur Normale < ~66 Grad)?"""
"""Does the ray p + t*n (|t| <= dist) hit a render triangle (angle to the normal < ~66 degrees)?"""
ids = set()
for q in (p, p + n * dist, p - n * dist):
k = (int(math.floor(q[0])), int(math.floor(q[2])))
@@ -316,9 +318,9 @@ class RenderIndex:
def classify(trak, mp, decoded):
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
"""Sets 'invis' per polygon (True = no render surface covers it)."""
ri = RenderIndex(trak, mp)
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [visible, invisible]
inst = collections.defaultdict(list)
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
for i in range(mp['N']):
@@ -326,7 +328,7 @@ def classify(trak, mp, decoded):
if i in altmap:
inst[altmap[i][4]].append(i)
for mi, polys in enumerate(decoded):
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
tiles = inst.get(mi, [])[:6] # a few instances are enough
for k, pl in enumerate(polys):
if pl['pts'] is None:
continue
@@ -334,7 +336,7 @@ def classify(trak, mp, decoded):
cen = P.mean(axis=0)
samples = [cen] + [cen + (q - cen) * 0.7 for q in P]
if not tiles:
votes[(mi, k)][1] += 1 # nie platziert: ohne Weltbezug -> als unsichtbar werten
votes[(mi, k)][1] += 1 # never placed: no world reference -> count as invisible
continue
for ti in tiles:
x, y, z, rot, _, _ = mp['A'][ti]
@@ -348,7 +350,7 @@ def classify(trak, mp, decoded):
pl['invis'] = vs[1] > vs[0]
# ------------------------------------------------------------------ Overlay je Modell
# ------------------------------------------------------------------ overlay per model
def mat_color(mat, matcols):
if mat in MAT_INFO:
@@ -367,7 +369,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
ecol = KIND_COL[pl['kind']] if mat == 0 else mat_color(mat, matcols)
off = n * opt.offset
cen = P.mean(axis=0)
# Inkreis-Abschaetzung: kleinster Abstand Mittelpunkt -> Kante
# incircle estimate: smallest distance centre -> edge
rmin = 1e9
for k in range(len(P)):
a, b = P[k], P[(k + 1) % len(P)]
@@ -377,7 +379,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
continue
rmin = min(rmin, np.linalg.norm(np.cross(e, cen - a)) / L)
w = min(opt.width, 0.4 * rmin)
# innere Ecken: Kantengeraden um w nach innen verschoben, mit Flaechenebene schneiden
# inner corners: edge lines shifted inwards by w, intersected with the surface plane
E = c['edges']
m = len(E)
ok = True
@@ -395,7 +397,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
inner = pts2
else:
inner = [cen + (q - cen) * 0.85 for q in P]
O = [mesh.v(q + off, n) for q in P] # Ecken einmal anlegen (Rahmen + Fuellung teilen sie)
O = [mesh.v(q + off, n) for q in P] # create corners once (frame + fill share them)
I = [mesh.v(q + off, n) for q in inner]
ec = pal(ecol)
for k in range(len(P)):
@@ -416,7 +418,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
def append_mesh(model, mesh, pal_base, limit=7900):
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
"""Appends a mesh to a model (batch flag 0x8 on texture change / every 256 faces)."""
nv0 = len(model.verts)
if nv0 + len(mesh.verts) > limit:
return 0
@@ -437,7 +439,7 @@ def append_mesh(model, mesh, pal_base, limit=7900):
return len(mesh.verts)
# ------------------------------------------------------------------ Marker fuer Objekte
# ------------------------------------------------------------------ markers for objects
def fx(v):
v = v - (1 << 32) if v >= 1 << 31 else v
@@ -445,15 +447,15 @@ def fx(v):
def strat_markers(ch, wf, pal, stats):
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
"""Returns the world mesh parts as dict cell -> Mesh in world coordinates (export space)."""
mm = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, mapchunk.trak_vertcounts(G.wad_get(ch, 'TRAK')))
st = G.wad_get(ch, 'STPC')
import stpcwalk
lo = stpcwalk.walk(st, wf)['code']
nodes = mm['nodes']
segs = [] # (a, b, breite, farbe) Weltraum Spielkoordinaten (z positiv)
cubes = [] # (c, h, farbe)
quads = [] # (4 Punkte, farbe, alpha)
segs = [] # (a, b, width, colour) world space, game coordinates (z positive)
cubes = [] # (c, h, colour)
quads = [] # (4 points, colour, alpha)
per_cat = collections.Counter()
per_type = collections.Counter()
@@ -478,7 +480,7 @@ def strat_markers(ch, wf, pal, stats):
name = S.strat_name(st, s[6], lo) or '?'
cat = mark_cat(name)
if cat is None:
# alle uebrigen Objekte: kleiner dunkler Punkt am Ursprung (nichts geht verloren)
# all other objects: small dark dot at the origin (nothing gets lost)
per_cat['objekt'] += 1
cubes.append((np.array([fx(s[3]), fx(s[4]), fx(s[5])]) + [0, 0.06, 0], 0.03, MARK_COL['objekt']))
continue
@@ -487,11 +489,11 @@ def strat_markers(ch, wf, pal, stats):
col = MARK_COL[cat]
p = np.array([fx(s[3]), fx(s[4]), fx(s[5])])
rot = s[1] / 4096 * 2 * math.pi
# Marker: Wuerfel + Pfahl
# marker: cube + post
top = p + [0, MARK_H, 0]
if cat == 'start':
# Pfeil flach auf dem Boden (blockiert die Startkamera nicht)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 Grad = +x
# arrow flat on the ground (doesn't block the start camera)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 degrees = +x
q = np.array([d[2], 0, -d[0]])
g = p + [0, 0.03, 0]
segs.append((g - d * 0.3, g + d * 0.4, 0.025, col))
@@ -530,16 +532,16 @@ def strat_markers(ch, wf, pal, stats):
prev = q
stats['pfadpunkte'] += len(ch_)
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
# environment zones (MAP block 4, 90 bytes: cell x/y/z, fog, music ...) -> wire cube per cell
col = MARK_COL['umgebung']
for L in mm['lights']:
cx, cy, cz = L[0], L[1], L[2]
if L[3:6] == (1, 1, 1):
continue # Vorgabe-Eintrag (ganzes Level)
continue # default entry (whole level)
box((cx + 0.04, cy, cz + 0.04), (cx + 0.96, cy + 0.92, cz + 0.96), col, 0.02)
per_cat['umgebung'] += 1
# alles in Zellen buckeln (Exportraum: z negieren)
# bucket everything into cells (export space: negate z)
cells = collections.defaultdict(Mesh)
def cell_of(q):
@@ -554,7 +556,7 @@ def strat_markers(ch, wf, pal, stats):
for c, h, col in cubes:
cells[cell_of(c)].cube(ex(c), h, pal(col))
for pts, col in quads:
# in <=1x1-Stuecke zerlegen
# split into <=1x1 pieces
(x0, y, z0), _, (x1, _, z1), _ = pts
xs = np.arange(math.floor(x0), math.ceil(x1))
zs = np.arange(math.floor(z0), math.ceil(z1))
@@ -575,7 +577,7 @@ def strat_markers(ch, wf, pal, stats):
return cells
# ------------------------------------------------------------------ Hauptablauf
# ------------------------------------------------------------------ main flow
def process(src, dst, opt):
ch = G.wad_read(src)
@@ -592,7 +594,7 @@ def process(src, dst, opt):
if not opt.no_coll:
decoded = [decode_model_coll(m) for m in trak]
classify(trak, mp, decoded)
# Statistik je Kachelinstanz
# statistics per tile instance
inst = collections.Counter(mp['midx'])
for mi, polys in enumerate(decoded):
for pl in polys:
@@ -610,11 +612,11 @@ def process(src, dst, opt):
add_v[mi] = len(mesh.verts)
mesh_store = mesh
trak[mi]._ov = mesh_store
# Marker
# markers
cells = {}
if not opt.no_markers:
cells = strat_markers(ch, wf, pal, stats)
# TEXT erweitern
# extend TEXT
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
G.wad_set(ch, 'TEXT', text)
for mi, m in enumerate(trak):
@@ -624,7 +626,7 @@ def process(src, dst, opt):
if got == 0:
stats['modelle_zu_gross'] += 1
add_v[mi] = 0
# neue Marker-Kacheln
# new marker tiles
W, H = mp['W'], mp['H']
new_tiles = []
for (cx, cz), mesh in sorted(cells.items()):
@@ -640,7 +642,7 @@ def process(src, dst, opt):
loc = Mesh()
loc.verts = [(v[0] - ox, v[1] - oy, v[2] - oz, v[3], v[4], v[5]) for v in mesh.verts]
loc.tris = mesh.tris
# zu grosse Zellen aufteilen
# split cells that are too large
if len(loc.verts) > 7900:
stats['marker_gekuerzt'] += 1
keep = [t for t in loc.tris if max(t[:3]) < 7900]
@@ -651,7 +653,7 @@ def process(src, dst, opt):
new_tiles.append(dict(x=ox, y=oy, z=oz, rot=0, model=len(trak) - 1,
cols=[[0x00808080] * len(mdl.verts)]))
stats['marker_kacheln'] = len(new_tiles)
# MAP neu
# rebuild MAP
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
tiles = []
for i in range(mp['N']):
@@ -666,7 +668,7 @@ def process(src, dst, opt):
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc, tiles, zones='keep', anim=mp['anim'])
G.wad_set(ch, 'TRAK', G.build_trak(trak))
G.wad_set(ch, 'MAP', newmap)
# Gegenprobe: MAP vollstaendig parsen, Strats unveraendert
# cross-check: MAP parses completely, strats unchanged
vc = [len(m.verts) for m in trak]
a = mapchunk.parse(newmap, wf, vc)
b = mapchunk.parse(G.wad_get(G.wad_read(src), 'MAP'), wf, old_vc)
@@ -693,7 +695,7 @@ def main():
ap.add_argument('out', nargs='?')
ap.add_argument('--all', metavar='OUTDIR')
ap.add_argument('--src', default=CD_WADS)
ap.add_argument('--only', help='Kommagetrennte Levelnamen (mit --all)')
ap.add_argument('--only', help='comma-separated level names (with --all)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--no-markers', action='store_true')
ap.add_argument('--no-fill', action='store_true')
@@ -712,12 +714,12 @@ def main():
for f in files:
info = process(f, os.path.join(opt.all, os.path.basename(f)), opt)
infos.append(info)
print('%-13s %5d KB -> %5d KB Koll %5d (unsichtbar %4d) Marker-Kacheln %3d maxV %4d' % (
print('%-13s %5d KB -> %5d KB coll %5d (invisible %4d) marker tiles %3d maxV %4d' % (
info['level'], info['groesse_alt'] // 1024, info['groesse_neu'] // 1024, info.get('koll_polys', 0),
info.get('koll_unsichtbar', 0), info.get('marker_kacheln', 0), info['max_verts_modell']), flush=True)
else:
if not (opt.inp and opt.out):
ap.error('IN.wad OUT.wad oder --all OUTDIR')
ap.error('IN.wad OUT.wad or --all OUTDIR')
info = process(opt.inp, opt.out, opt)
infos.append(info)
print(json.dumps(info, indent=1, ensure_ascii=False))
+13 -13
View File
@@ -23,33 +23,33 @@ for f in sorted(glob.glob(sys.argv[1] + '/*.wad')):
if run > 256: err.append('batch>256 model %d' % mi); break
if mi < len(otrak):
om = otrak[mi]
if m.coll != om.coll: err.append('KOLLISION GEAENDERT model %d' % mi)
if m.coll != om.coll: err.append('COLLISION CHANGED model %d' % mi)
if m.verts[:len(om.verts)] != om.verts or m.faces[:len(om.faces)] != om.faces:
err.append('Originalgeometrie geaendert %d' % mi)
elif any(len(g) for g in m.coll): err.append('neues Modell mit Kollision')
err.append('original geometry changed %d' % mi)
elif any(len(g) for g in m.coll): err.append('new model with collision')
vc = [len(m.verts) for m in trak]
mp = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, vc)
om = mapchunk.parse(G.wad_get(o, 'MAP'), wf, [len(m.verts) for m in otrak])
if mp['rest'] or mapchunk.build(mp, wf) != G.wad_get(ch, 'MAP'): err.append('MAP roundtrip')
for k in ('strats', 'nodes', 'list1', 'lights', 'tab32', 'grid', 'alts', 'strat_x', 'paths', 'x200', 'last'):
if mp[k] != om[k]: err.append('MAP %s geaendert' % k)
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('Kacheln geaendert')
if mp[k] != om[k]: err.append('MAP %s changed' % k)
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('tiles changed')
W, H = mp['W'], mp['H']
for t in mp['tiles']:
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('Kachel ausserhalb'); break
# Farbblock: je Kachel nv*frames
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('tile out of bounds'); break
# colour block: nv*frames per tile
gm = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, vc)
for i, fr in enumerate(gm['cols']):
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('Farbblock %d' % i); break
# Kollision unveraendert je Kachel-Instanz der Originalkacheln
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('colour block %d' % i); break
# collision unchanged per tile instance of the original tiles
for k in ('TEXT',):
pass
others = [t for t, _ in ch if t not in ('TRAK', 'MAP ', 'TEXT')]
for t in others:
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('Chunk %s geaendert' % t)
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('chunk %s changed' % t)
sz = sum(len(G.wad_get(ch, t)) for t in ('TRAK', 'STPC', 'MAP ', 'LGPC'))
print('%-13s %s maxV %4d maxF %4d Modelle %4d Kacheln %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
n, 'OK ' if not err else 'FEHLER ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
print('%-13s %s maxV %4d maxF %4d models %4d tiles %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
n, 'OK ' if not err else 'ERROR ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
len(trak), len(mp['tiles']), sz / 2**20))
bad += bool(err)
print('Fehler:', bad)
print('errors:', bad)
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Schnell-Disassembler fuer groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
"""Quick disassembler for groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
import os
import sys
from capstone import Cs, CS_ARCH_X86, CS_MODE_32
+14 -14
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""Erzeugt ein Beispiel-OBJ mit komplett neuer Geometrie fuer geom_import.py.
"""Generates an example OBJ with completely new geometry for geom_import.py.
geom_demo.py basis.wad demo.obj [--plaza]
geom_demo.py base.wad demo.obj [--plaza]
Baut aus Kachel-Vierecken (je 1x1, Textur = ganze Texturkachel, so wie die Engine es will):
- (--plaza) eine Bodenplatte y=0 ueber allen Rasterzellen, die im Basislevel belegt sind
(damit Strats/Startpunkt irgendwo Boden finden)
- einige Bloecke und eine Rampe in der Mitte des belegten Bereichs
Texturen: haeufigste Textur auf nach oben zeigenden Faces (Boden) bzw. auf Seitenflaechen (Wand).
Builds from tile quads (1x1 each, texture = whole texture tile, the way the engine wants it):
- (--plaza) a floor plate at y=0 over all grid cells occupied in the base level
(so strats/start point find floor somewhere)
- a few blocks and a ramp in the middle of the occupied area
Textures: most frequent texture on upward-facing faces (floor) and on side faces (wall).
"""
import argparse
import collections
@@ -24,7 +24,7 @@ class ObjW:
self.v, self.vt, self.f, self.texs = [], [], [], texs
def quad(self, p, tex, mat=0):
"""p: 4 Punkte, Reihenfolge so dass (p1-p0)x(p2-p0) nach aussen zeigt."""
"""p: 4 points, ordered so that (p1-p0)x(p2-p0) points outwards."""
U0, U1, V0, V1 = G.tex_uv(self.texs[tex])
uv = [(U1, V1), (U0, V1), (U0, V0), (U1, V0)]
base = len(self.v)
@@ -33,10 +33,10 @@ class ObjW:
self.f.append((tex, mat, [base + k for k in range(4)]))
def box(self, x0, z0, x1, z1, y0, y1, top, side):
# Koordinaten: x0<x1, z0>z1 (z negativ = weiter im Raster)
# coordinates: x0<x1, z0>z1 (negative z = further into the grid)
self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top)
for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]:
# Wand von a nach b, aussen = rechts herum
# wall from a to b, outside = clockwise
n = math.ceil(y1 - y0)
for k in range(n):
ya, yb = y0 + k * (y1 - y0) / n, y0 + (k + 1) * (y1 - y0) / n
@@ -74,7 +74,7 @@ def main():
o.quad([(cx, 0, -cz), (cx + 1, 0, -cz), (cx + 1, 0, -cz - 1), (cx, 0, -cz - 1)], grass)
mx = sorted(c[0] for c in cells)[len(cells) // 2]; mz = sorted(c[1] for c in cells)[len(cells) // 2]
if not a.plaza:
# Stelle suchen, an der das 9x4-Fenster moeglichst flach und voll belegt ist
# find a spot where the 9x4 window is as flat as possible and fully occupied
top = collections.defaultdict(lambda: -99.0)
for t in mp['A']:
if trak[t[4]].faces:
@@ -91,7 +91,7 @@ def main():
best = (spread, cx, cz)
if best:
mx, mz = best[1], best[2]
# Bloecke + Rampe um die Mitte; ohne --plaza auf die vorhandene Geometrie gestellt
# blocks + ramp around the centre; without --plaza placed on top of the existing geometry
b = 0.0
if not a.plaza:
ys = []
@@ -102,12 +102,12 @@ def main():
b = float(round(sorted(ys)[len(ys) * 3 // 4] * 4) / 4) if ys else 0.0
o.box(mx, -mz, mx + 2, -mz - 2, b, b + 1, grass, wall)
o.box(mx + 3, -mz, mx + 4, -mz - 1, b, b + 3, grass, wall)
for k in range(4): # Rampe in x-Richtung, 4 Zellen lang, steigt auf 1
for k in range(4): # ramp in x direction, 4 cells long, rises to 1
y0, y1 = b + k / 4, b + (k + 1) / 4
x = mx - 4 + k
o.quad([(x, y0, -mz), (x + 1, y1, -mz), (x + 1, y1, -mz - 2), (x, y0, -mz - 2)], grass)
o.save(a.out)
print(f'{a.out}: {len(o.f)} Vierecke, Boden-Textur {grass}, Wand-Textur {wall}, Mitte Zelle ({mx},{mz})')
print(f'{a.out}: {len(o.f)} quads, floor texture {grass}, wall texture {wall}, centre cell ({mx},{mz})')
if __name__ == '__main__':
+18 -18
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env python3
"""Level-WAD -> OBJ (+MTL, Texturseiten-PNGs, Vorschaubilder).
"""Level WAD -> OBJ (+MTL, texture page PNGs, preview images).
geom_export.py level.wad outdir [--collision] [--objects] [--no-png] [--size 1200x900]
Erzeugt in outdir:
level.obj / level.mtl Weltgeometrie (TRAK-Kacheln, platziert nach MAP), mit UVs und
Vertexfarben ("v x y z r g b"), ein OBJ-Objekt pro Kachel
page_NN.png Texturseiten (256x256, aus TEXT)
collision.obj (--collision) Kollisionspolygone, Gruppen nach Material
objects.obj (--objects) statische STPC-Objektmodelle nebeneinander
preview_top.png, preview_persp.png texturierte Software-Renderings zur Kontrolle
Writes to outdir:
level.obj / level.mtl world geometry (TRAK tiles, placed per MAP), with UVs and
vertex colours ("v x y z r g b"), one OBJ object per tile
page_NN.png texture pages (256x256, from TEXT)
collision.obj (--collision) collision polygons, grouped by material
objects.obj (--objects) static STPC object models side by side
preview_top.png, preview_persp.png textured software renderings for checking
Koordinaten: Exportraum = MAP-Floats (y oben, rechtshaendig, z < 0). Spielraum = (x, y, -z).
Coordinates: export space = MAP floats (y up, right-handed, z < 0). Game space = (x, y, -z).
Format: notes/geometry.md
"""
import argparse
@@ -27,7 +27,7 @@ import geomlib as G # noqa: E402
def world_tris(trak, mp, texs, use_alt=False):
"""Liefert Liste von Dreiecken: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
"""Returns a list of triangles: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
out = []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
@@ -57,13 +57,13 @@ def world_tris(trak, mp, texs, use_alt=False):
def flat_color(t):
# Flache Farbe (Texturflag Bit0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
# flat colour (texture flag bit 0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
def write_obj(path, tris, texs, mtlname):
with open(path, 'w') as fo:
fo.write('# Ein Koenigreich fuer ein Lama - Weltgeometrie (geom_export.py)\n')
fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n')
fo.write(f'mtllib {mtlname}\n')
vi = 1
cur_tile = None
@@ -96,7 +96,7 @@ def write_mtl(path, tris, texs, npages):
fo.write(f'newmtl flat_{tex}\nKd %.3f %.3f %.3f\n\n' % c)
# ------------------------------------------------------------ Software-Renderer
# ------------------------------------------------------------ software renderer
def render(tris, pages, cam, size, path, texs):
Wd, Hd = size
@@ -134,7 +134,7 @@ def render(tris, pages, cam, size, path, texs):
iz = w0 / cz[0] + w1 / cz[1] + w2 / cz[2]
depth = 1 / np.where(inside, iz, 1e-9)
sub = zb[y0:y1 + 1, x0:x1 + 1]
# Farbe
# colour
nrm = np.cross(np.array(P[1]) - P[0], np.array(P[2]) - P[0])
nl = np.linalg.norm(nrm)
shade = 0.55 + 0.45 * abs(nrm @ L) / nl if nl > 0 else 1
@@ -166,7 +166,7 @@ def main():
ap.add_argument('wad'); ap.add_argument('outdir')
ap.add_argument('--collision', action='store_true')
ap.add_argument('--objects', action='store_true')
ap.add_argument('--no-png', action='store_true', help='keine Seiten-PNGs/Vorschau')
ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews')
ap.add_argument('--size', default='1200x900')
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
@@ -175,8 +175,8 @@ def main():
tris = world_tris(trak, mp, texs)
write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl')
write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages))
print(f'{a.wad}: WFPC={wf:#x} TRAK-Modelle={len(trak)} Kacheln={mp["N"]} Raster={mp["W"]}x{mp["H"]} '
f'Dreiecke={len(tris)} Texturen={len(texs)} Seiten={len(pages)}')
print(f'{a.wad}: WFPC={wf:#x} TRAK models={len(trak)} tiles={mp["N"]} grid={mp["W"]}x{mp["H"]} '
f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}')
if a.collision:
with open(os.path.join(a.outdir, 'collision.obj'), 'w') as fo:
@@ -233,7 +233,7 @@ def main():
P = np.array([p for t in tris for p in t[0]])
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
ext = max(hi[0] - lo[0], hi[2] - lo[2])
# Draufsicht (fast senkrecht, Norden = -z oben)
# top view (almost vertical, north = -z at the top)
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
os.path.join(a.outdir, 'preview_top.png'), texs)
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
+43 -43
View File
@@ -1,31 +1,31 @@
#!/usr/bin/env python3
"""OBJ -> neue Weltgeometrie (TRAK + Kachelteil von MAP) in einer Kopie eines Level-WADs.
"""OBJ -> new world geometry (TRAK + tile part of MAP) in a copy of a level WAD.
geom_import.py basis.wad mesh.obj neu.wad [--default-tex N] [--no-coll] [--mat N]
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
[--keep-tiles] [--band 6]
Eingabe-OBJ im Exportraum von geom_export.py (y oben, 1 Einheit = 1 Kachel, z < 0 liegt im
Raster; Rasterzelle (cx,cz) = (floor(x), floor(-z)), muss in 0..W-1 / 0..H-1 liegen).
Input OBJ in the export space of geom_export.py (y up, 1 unit = 1 tile, z < 0 lies in the
grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1).
Texturen: Die Engine kennt KEINE freien UVs. Jedes Dreieck zeigt eine Haelfte eines
Texturrechtecks (Eintrag der TEXT-Tabelle), die Ecken werden ueber Face-Flags gewaehlt.
usemtl page_NN -> Texturindex wird aus dem UV-Rechteck (Bounding-Box der Face-UVs)
auf Seite NN gesucht; UVs muessen auf Rechteck-Ecken liegen
(so wie geom_export sie schreibt).
usemtl tex_N -> Texturindex N; UVs (falls vorhanden) waehlen nur die Ecken-Belegung,
ohne UVs wird die Standardbelegung genommen.
usemtl flat_N -> Texturindex N (so exportiert geom_export Flachfarben)
sonst -> --default-tex
Vierecke werden als (0,1,2)+(2,3,0) zerlegt; mit Rechteck-UVs ergibt das genau die
Original-Belegung (Flags 0x800 / 0x810).
Vertexfarben: "v x y z r g b" (0..1, 1.0 = Originalhelligkeit 0x80); sonst 0x80.
Kollision: --coll-obj datei.obj (Polygone, Gruppen "matNN...") oder aus jedem Face ein konvexes Kollisionspolygon (Viereck wenn planar), eingetragen
in jede Rasterzelle, die das Polygon (xz-Bounding-Box) beruehrt. Material: Gruppe/Objekt
"..._matNN" bzw. --mat. (--no-coll: keine Kollision)
Textures: the engine has NO free UVs. Each triangle shows one half of a texture
rectangle (entry of the TEXT table); the corners are chosen via face flags.
usemtl page_NN -> texture index is looked up from the UV rectangle (bounding box of the
face UVs) on page NN; UVs must lie on rectangle corners
(the way geom_export writes them).
usemtl tex_N -> texture index N; UVs (if present) only choose the corner assignment,
without UVs the default assignment is used.
usemtl flat_N -> texture index N (this is how geom_export exports flat colours)
otherwise -> --default-tex
Quads are split as (0,1,2)+(2,3,0); with rectangle UVs this gives exactly the
original assignment (flags 0x800 / 0x810).
Vertex colours: "v x y z r g b" (0..1, 1.0 = original brightness 0x80); otherwise 0x80.
Collision: --coll-obj file.obj (polygons, groups "matNN...") or one convex collision polygon from each face (quad if planar), entered
into every grid cell the polygon (xz bounding box) touches. Material: group/object
"..._matNN" or --mat. (--no-coll: no collision)
Ausgabe: Ein Modell pro belegter (Zelle, Hoehenband). Alle vorhandenen Kacheln werden ersetzt
(ausser --keep-tiles: alte Kacheln + Modelle bleiben, neue kommen dazu).
Strats/Pfade/Zonen (Rest von MAP) und alle anderen Chunks bleiben unveraendert.
Output: one model per occupied (cell, height band). All existing tiles are replaced
(except with --keep-tiles: old tiles + models stay, new ones are added).
Strats/paths/zones (rest of MAP) and all other chunks stay unchanged.
"""
import argparse
import math
@@ -72,7 +72,7 @@ def read_obj(path):
def find_tex(texs, page, uvs):
"""Texturindex auf Seite 'page', dessen Rechteck am besten zu den UVs passt."""
"""Texture index on page 'page' whose rectangle best matches the UVs."""
us = [u * 256 for u, v in uvs]; vs = [v * 256 for u, v in uvs]
best, bd = None, 1e9
for i, t in enumerate(texs):
@@ -85,7 +85,7 @@ def find_tex(texs, page, uvs):
def match_pattern(uvs, rect):
"""Beste (Flags, Rotation) so dass face_uvs(flags) die gegebenen Ecken-UVs trifft."""
"""Best (flags, rotation) so that face_uvs(flags) hits the given corner UVs."""
best = (1e9, 0x800, 0)
for fl in PATTERNS:
ref = G.face_uvs(fl, rect)
@@ -108,14 +108,14 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('base'); ap.add_argument('obj'); ap.add_argument('out')
ap.add_argument('--default-tex', type=int, default=None)
ap.add_argument('--mat', type=int, default=0, help='Kollisionsmaterial (Standard 0)')
ap.add_argument('--mat', type=int, default=0, help='collision material (default 0)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--coll-obj', help='Kollision aus eigenem OBJ (z.B. collision.obj von geom_export) '
'statt aus den Render-Faces')
ap.add_argument('--coll-obj', help='collision from a separate OBJ (e.g. collision.obj from geom_export) '
'instead of the render faces')
ap.add_argument('--keep-tiles', action='store_true')
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
help='flat: Zonen-Sichtbarkeit (PVS) abschalten, alles zeichnen; keep: Original')
ap.add_argument('--band', type=float, default=6.0, help='Hoehenband pro Kachel (Einheiten)')
help='flat: disable zone visibility (PVS), draw everything; keep: original')
ap.add_argument('--band', type=float, default=6.0, help='height band per tile (units)')
a = ap.parse_args()
ch, wf, trak, mp = G.load_level(a.base)
@@ -126,9 +126,9 @@ def main():
if deftex is None:
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
# ---- Dreiecke mit Textur + Flags
# ---- triangles with texture + flags
tris = [] # (pos[3], col[3], tex, flags)
polys = [] # (pos list, mat) fuer Kollision
polys = [] # (pos list, mat) for collision
warn = defaultdict(int)
for vi, ti, mat, grp in faces:
pts = [V[i] for i in vi]; cols = [C[i] for i in vi]
@@ -140,7 +140,7 @@ def main():
if mm and mm.group(1) == 'page' and uvs:
tex, dist = find_tex(texs, int(mm.group(2)), uvs)
if tex is None or dist > 2:
warn['UV-Rechteck nicht gefunden'] += 1
warn['UV rectangle not found'] += 1
elif mm:
tex = int(mm.group(2))
if tex is None:
@@ -153,7 +153,7 @@ def main():
if uvs and not (t['flags'] & 1):
fl, rot, d = match_pattern([uvs[j] for j in idx], rect)
if d > 2.5 / 256:
warn['UVs nicht auf Rechteck-Ecken'] += 1
warn['UVs not on rectangle corners'] += 1
P = [P[(j + rot) % 3] for j in range(3)]; Cc = [Cc[(j + rot) % 3] for j in range(3)]
else:
fl = 0x800 if k % 2 else 0x810
@@ -166,7 +166,7 @@ def main():
m = re.search(r'mat(\d+)', grp or '')
polys.append(([cV[i] for i in vi], int(m.group(1)) if m else a.mat))
# ---- Zellen/Baender
# ---- cells/bands
def key_of(x, y, z):
cx, cz = math.floor(x), math.floor(-z)
return (cx, cz, math.floor(y / a.band))
@@ -184,7 +184,7 @@ def main():
for cz in range(math.floor(-P[:, 2].max()), math.floor(-P[:, 2].min() - 1e-6) + 1):
cells[(cx, cz, band)]['coll'].append((pts, mat))
# ---- Modelle bauen
# ---- build models
models, tiles = [], []
if a.keep_tiles:
models = list(trak)
@@ -194,7 +194,7 @@ def main():
altcols=mp['altcols'].get(i, [])))
for (cx, cz, band), cell in sorted(cells.items()):
if not (0 <= cx < W and 0 <= cz < H):
warn[f'ausserhalb des Rasters {W}x{H}'] += 1
warn[f'outside the {W}x{H} grid'] += 1
continue
allp = [p for t in cell['tris'] for p in t[0]] or [p for c in cell['coll'] for p in c[0]]
ty = float(round(np.median([p[1] for p in allp])))
@@ -218,8 +218,8 @@ def main():
for ids, tex, fl in facelist:
plane = G.face_plane(*[m.verts[i][:3] for i in ids])
st = (texs[tex]['page'], texs[tex]['flags'])
# Bit 0x8 = neuer Render-Batch (Zustand neu laden). Batch-Puffer 0x5d15f8 fasst ~4800
# Ausgabe-Vertices -> spaetestens alle 256 Faces neu starten.
# bit 0x8 = new render batch (reload state). Batch buffer 0x5d15f8 holds ~4800
# output vertices -> restart at least every 256 faces.
if st != prev or len(m.faces) % 256 == 0:
fl |= 0x8
prev = st
@@ -233,7 +233,7 @@ def main():
for pts, mat in cell['coll']:
loc = [(p[0] - ox, p[1] - ty, p[2] - oz) for p in pts]
if max(abs(c) for p in loc for c in p) > 7.9:
warn['Kollisionspolygon > 7.9 Einheiten vom Kachelzentrum (uebersprungen)'] += 1
warn['collision polygon > 7.9 units from tile centre (skipped)'] += 1
continue
recs = []
if len(loc) == 4:
@@ -252,12 +252,12 @@ def main():
r = bytes([r[0] | 4]) + r[1:]
m.coll[0].append(r)
if len(m.verts) > 8000 or len(m.faces) > 65535:
sys.exit('Kachel zu gross (Transformpuffer 0x58b0f0 fasst 8000 Vertices)')
sys.exit('tile too large (transform buffer 0x58b0f0 holds 8000 vertices)')
models.append(m)
tiles.append(dict(x=ox, y=ty, z=oz, rot=0, model=len(models) - 1, cols=[cols]))
if not models:
sys.exit('keine Geometrie')
sys.exit('no geometry')
newtrak = G.build_trak(models)
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, [len(m.verts) for m in trak], tiles, a.zones,
mp['anim'] if a.keep_tiles else ())
@@ -265,9 +265,9 @@ def main():
G.wad_set(ch, 'MAP', newmap)
G.wad_write(a.out, ch)
nf = sum(len(m.faces) for m in models); nc = sum(len(m.coll[0]) for m in models)
print(f'{a.out}: {len(models)} Modelle, {len(tiles)} Kacheln, {nf} Dreiecke, {nc} Kollisionspolygone')
print(f'{a.out}: {len(models)} models, {len(tiles)} tiles, {nf} triangles, {nc} collision polygons')
for k, v in warn.items():
print(f' WARNUNG: {k}: {v}x')
print(f' WARNING: {k}: {v}x')
if __name__ == '__main__':
+59 -59
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Geometrie-Bibliothek fuer Level-WADs von "Ein Koenigreich fuer ein Lama" (Croc-2-Engine, PC).
"""Geometry library for level WADs of "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
Format siehe notes/geometry.md. Kurz:
TRAK = Kachel-Modelle (Welt-Geometrie + Kollision), u32 n, n x 0x84 Header, dann Daten
MAP = Kachel-Raster: Platzierung (Position/Rotation/Modell) + Vertexfarben (+ Strats usw.)
STPC = Objekt-Modelle (gleiches Modellformat, Header 0x8c) + Animationen + Strat-Skripte
TEXT = Texturseiten + Texturtabelle (UV-Rechtecke)
Format: see notes/geometry.md. In short:
TRAK = tile models (world geometry + collision), u32 n, n x 0x84 header, then data
MAP = tile grid: placement (position/rotation/model) + vertex colours (+ strats etc.)
STPC = object models (same model format, header 0x8c) + animations + strat scripts
TEXT = texture pages + texture table (UV rectangles)
Alle Parser sind aus groove.exe nachgebaut (FUN_005563f0 TRAK, FUN_0041f770 STPC-Modell,
FUN_0042ac50 MAP, FUN_00406d30 Texturtabelle, FUN_00556510 Kachel-Renderer).
All parsers are reimplemented from groove.exe (FUN_005563f0 TRAK, FUN_0041f770 STPC model,
FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer).
"""
import math
import struct
@@ -19,7 +19,7 @@ import struct
def wad_read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
assert total == len(d) - 4, 'WAD-Laengenfeld passt nicht'
assert total == len(d) - 4, 'WAD length field does not match'
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')
@@ -56,21 +56,21 @@ def wfpc_flags(chunks):
return struct.unpack('<I', c)[0] if c else 0
# ---------------------------------------------------------------- Modelle
# Header (TRAK 0x84 Byte, STPC 0x8c Byte):
# +0x00 3f Mittelpunkt der Bounding-Box
# +0x0c 24f 8 Ecken der (um 1/128 vergroesserten) Bounding-Box
# ---------------------------------------------------------------- models
# Header (TRAK 0x84 bytes, STPC 0x8c bytes):
# +0x00 3f centre of the bounding box
# +0x0c 24f 8 corners of the bounding box (enlarged by 1/128)
# +0x6c u16 nVerts +0x6e u16 nFaces
# +0x70 u32 (Zeiger Verts, in Datei 0) +0x74 u32 (Zeiger Faces, 0)
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (Kollisionspolygone, 32 Byte)
# +0x7e u16 0 +0x80 u32 (Zeiger Kollision, 0)
# nur STPC: +0x84 u16 ?(meist = nVerts oder 0) +0x86 u16 nX (n*2 Byte Daten vor den Verts)
# +0x88 u32 (Zeiger, 0)
# Daten: [STPC: nX*u16] Verts nV*24 (pos 3f, normal 3f), Faces nF*28, Kollision nCol*32
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f Ebenen-Normale, f d (n.v = d)
# Kollision (32): u8 typ (Bit0: Viereck, sonst Dreieck), u8 Material, 5 x {i8 nx,ny,nz, u8 0,
# i16 d}: Ebene 0 = Flaeche, Ebenen 1..4 = Kanten (nach innen). Normalen *127, d *4096,
# Koordinaten im Spielraum (z gespiegelt ggue. Vertexdaten!).
# +0x70 u32 (verts pointer, 0 in file) +0x74 u32 (faces pointer, 0)
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (collision polygons, 32 bytes)
# +0x7e u16 0 +0x80 u32 (collision pointer, 0)
# STPC only: +0x84 u16 ?(usually = nVerts or 0) +0x86 u16 nX (n*2 bytes of data before the verts)
# +0x88 u32 (pointer, 0)
# Data: [STPC: nX*u16] verts nV*24 (pos 3f, normal 3f), faces nF*28, collision nCol*32
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f plane normal, f d (n.v = d)
# Collision (32): u8 type (bit 0: quad, otherwise triangle), u8 material, 5 x {i8 nx,ny,nz, u8 0,
# i16 d}: plane 0 = surface, planes 1..4 = edges (pointing inwards). Normals *127, d *4096,
# coordinates in game space (z mirrored relative to the vertex data!).
CORNERS = [(-1, -1, 1), (1, -1, 1), (-1, 1, 1), (1, 1, 1), (-1, -1, -1), (1, -1, -1), (-1, 1, -1), (1, 1, -1)]
@@ -79,11 +79,11 @@ class Model:
def __init__(self):
self.verts = [] # [(x,y,z,nx,ny,nz)]
self.faces = [] # [dict(flags,v=(a,b,c),tex,pad,plane=(nx,ny,nz,d))]
self.coll = [[], [], []] # drei Gruppen; je Eintrag 32 Byte roh (bytes)
self.hdr = None # Original-Header (bytes) zum Vergleich
self.coll = [[], [], []] # three groups; 32 raw bytes per entry (bytes)
self.hdr = None # original header (bytes) for comparison
self.extra = b'' # STPC: nX*u16
self.x84 = 0
self.dirty = False # True setzen, wenn Verts geaendert -> Bounding-Box neu
self.dirty = False # set True when verts changed -> recompute bounding box
def _parse_models(d, o, n, hsize):
@@ -115,7 +115,7 @@ def _parse_models(d, o, n, hsize):
def parse_stpc_models(d):
"""Nur die statischen Objektmodelle am Anfang von STPC (u32 n, je 1 Modell mit 0x8c-Header)."""
"""Only the static object models at the start of STPC (u32 n, 1 model each with 0x8c header)."""
n = struct.unpack_from('<I', d, 0)[0]
o = 4
out = []
@@ -139,7 +139,7 @@ def bbox_header(m):
else:
lo = hi = (0.0, 0.0, 0.0)
c = tuple((a + b) / 2 for a, b in zip(lo, hi))
e = 1 / 128 # Original vergroessert die Box um 1/128
e = 1 / 128 # the original enlarges the box by 1/128
h = tuple((b - a) / 2 + e for a, b in zip(lo, hi))
pts = []
for s in CORNERS:
@@ -148,7 +148,7 @@ def bbox_header(m):
def build_models(models, hsize, recompute_bbox=None):
"""recompute_bbox=None: Original-Box behalten, wenn vorhanden und Verts unveraendert."""
"""recompute_bbox=None: keep the original box if present and verts unchanged."""
hdr, data = b'', b''
for m in models:
if recompute_bbox is False or (recompute_bbox is None and m.hdr is not None
@@ -180,17 +180,17 @@ def build_trak(models):
def face_plane(p0, p1, p2):
ax, ay, az = (p1[i] - p0[i] for i in range(3))
bx, by, bz = (p2[i] - p0[i] for i in range(3))
# Wicklung: Original-Normale = (v1-v0) x (v2-v0)
# winding: original normal = (v1-v0) x (v2-v0)
nx, ny, nz = ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx
ln = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
nx, ny, nz = nx / ln, ny / ln, nz / ln
return (nx, ny, nz, nx * p0[0] + ny * p0[1] + nz * p0[2])
# ---------------------------------------------------------------- Kollision
# ---------------------------------------------------------------- collision
def coll_decode(r):
"""32-Byte-Kollisionspolygon -> dict (Ebenen im Vertex-/Exportraum, d.h. z zurueckgespiegelt)."""
"""32-byte collision polygon -> dict (planes in vertex/export space, i.e. z mirrored back)."""
typ, mat = r[0], r[1]
planes = []
for k in range(5):
@@ -201,7 +201,7 @@ def coll_decode(r):
def coll_polygon(c):
"""Eckpunkte aus Flaechenebene + Kantenebenen (Schnitt benachbarter Kanten)."""
"""Corner points from surface plane + edge planes (intersection of adjacent edges)."""
import numpy as np
P = c['plane']; E = c['edges']
pts = []
@@ -217,7 +217,7 @@ def coll_polygon(c):
def coll_encode(typ, mat, plane, edges):
"""Ebenen im Vertexraum -> 32 Byte (z wird fuer den Spielraum gespiegelt)."""
"""Planes in vertex space -> 32 bytes (z is mirrored for game space)."""
def q(p):
nx, ny, nz, d = p
cl = lambda v: max(-127, min(127, int(round(v * 127))))
@@ -227,7 +227,7 @@ def coll_encode(typ, mat, plane, edges):
def coll_from_polygon(pts, mat=0, typ=None):
"""Konvexes Polygon (3 oder 4 Punkte, Vertexraum, Reihenfolge wie Faces) -> 32-Byte-Record."""
"""Convex polygon (3 or 4 points, vertex space, same order as faces) -> 32-byte record."""
import numpy as np
P = [np.array(p, float) for p in pts]
n = np.cross(P[1] - P[0], P[2] - P[0])
@@ -244,20 +244,20 @@ def coll_from_polygon(pts, mat=0, typ=None):
return None
e /= np.linalg.norm(e)
if e @ (cen - a) < 0:
e = -e # nach innen
e = -e # inwards
edges.append((*e, float(e @ a)))
if typ is None:
typ = 1 if len(P) == 4 else 0
return coll_encode(typ, mat, (*n, d), edges)
# ---------------------------------------------------------------- MAP (nur Kachelteile)
STRAT_REC = 90 # Bytes pro Eintrag des 2. Blocks (0x5c im Speicher)
NODE_REC = 34 # Bytes pro Eintrag des 3. Blocks (0x30 im Speicher)
# ---------------------------------------------------------------- MAP (tile parts only)
STRAT_REC = 90 # bytes per entry of the 2nd block (0x5c in memory)
NODE_REC = 34 # bytes per entry of the 3rd block (0x30 in memory)
def parse_map_tiles(d, wfpc, vcounts):
"""Liefert Kachel-Infos + Byte-Offsets der Abschnitte (fuer Splicing)."""
"""Returns tile info + byte offsets of the sections (for splicing)."""
N, W, H = struct.unpack_from('<3I', d, 0)
o = 12
offs = {'A': o}
@@ -299,16 +299,16 @@ def parse_map_tiles(d, wfpc, vcounts):
def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
"""Ersetzt die Kachelabschnitte im MAP-Chunk.
"""Replaces the tile sections in the MAP chunk.
tiles: Liste dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in Kacheleinheiten
(Exportraum: y oben, z<=0), rot in 4096tel (Rotation um y).
Kachel-Wechsel-Animationen: tiles[i]['flag']=1 + tiles[i]['altcols'] (Farben des Alt-Modells);
anim = Liste der 5-u32-Eintraege (Zelle x, y, z, Modell, Alt-Modell), in Reihenfolge der
geflaggten Kacheln. Ohne anim werden alle Flags geloescht.
zones='flat': Sichtbarkeitszonen abschalten (Zonenraster = 0, Maske von Zone 0 = alle),
sonst ('keep') Zonenraster + 32er-Tabelle unveraendert lassen.
Alles andere (Strat-/Knoten-/Lichtbloecke) bleibt unveraendert.
tiles: list of dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in tile units
(export space: y up, z<=0), rot in 1/4096 turns (rotation about y).
Tile swap animations: tiles[i]['flag']=1 + tiles[i]['altcols'] (colours of the alt model);
anim = list of the 5-u32 entries (cell x, y, z, model, alt model), in the order of the
flagged tiles. Without anim all flags are cleared.
zones='flat': disable visibility zones (zone grid = 0, mask of zone 0 = all),
otherwise ('keep') leave zone grid + 32-entry table unchanged.
Everything else (strat/node/light blocks) stays unchanged.
"""
m = parse_map_tiles(d, wfpc, old_vcounts)
of = m['offs']
@@ -320,7 +320,7 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
int(round(t['y'] * 4096)), int(round(-t['z'] * 4096))) for t in tiles)
midx = struct.pack('<%dI' % N, *[t['model'] for t in tiles])
if anim and sum(1 for t in tiles if t.get('flag')) != len(anim):
raise ValueError('Anzahl geflaggter Kacheln != Anzahl anim-Eintraege')
raise ValueError('number of flagged tiles != number of anim entries')
animb = (struct.pack('<I', len(anim)) + b''.join(struct.pack('<5I', *e) for e in anim)) if wfpc & 0x10000 else b''
cols = b''
for t in tiles:
@@ -328,13 +328,13 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
cols += struct.pack('<%dI' % len(frame), *frame)
mid = d[of['A_end']:of['B']]
if zones == 'flat':
g = of['grid'] - of['A_end'] # Offset der Zonentabelle+Raster innerhalb mid
g = of['grid'] - of['A_end'] # offset of zone table+grid within mid
tab = struct.pack('<2I', 0xffffffff, struct.unpack_from('<I', mid, g - 256 + 4)[0]) + mid[g - 248:g]
mid = mid[:g - 256] + tab + bytes(4 * m['W'] * m['H'])
return head + A + mid + B + midx + animb + cols + d[of['col_end']:]
# ---------------------------------------------------------------- TEXT (Texturtabelle + Seiten)
# ---------------------------------------------------------------- TEXT (texture table + pages)
def parse_text(d):
o = 0
@@ -362,11 +362,11 @@ def parse_text(d):
def page_rgba(p):
"""16-Bit-Seite -> numpy RGBA (Annahme: ABGR1555 wie PS1, RLE16 bei flags&0x80)."""
"""16-bit page -> numpy RGBA (assumption: ABGR1555 like PS1, RLE16 if flags&0x80)."""
import numpy as np
w, h = p['w'], p['h']
if p['flags'] == 0:
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # Graustufen-Notloesung
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # greyscale stopgap
elif p['comp']:
v = np.frombuffer(p['raw'], '<u2'); out = np.zeros(w * h, np.uint16)
i = q = 0
@@ -388,17 +388,17 @@ def page_rgba(p):
def tex_uv(t, pw=256, ph=256):
"""UV-Rechteck (U0,U1,V0,V1) wie FUN_00406880 (u1/v1 inklusiv -> +1)."""
"""UV rectangle (U0,U1,V0,V1) like FUN_00406880 (u1/v1 inclusive -> +1)."""
return (t['u0'] / pw, (t['u1'] + 1) / pw, t['v0'] / ph, (t['v1'] + 1) / ph)
def face_uvs(flags, rect):
"""Pro Face-Ecke (v0,v1,v2) die UV aus dem Texturrechteck, nach FUN_00556510.
"""Per face corner (v0,v1,v2) the UV from the texture rectangle, per FUN_00556510.
Bit 0x20 spiegelt U; Bit 0x800 + Bit 0x10 waehlen die Dreieckshaelfte des Vierecks."""
Bit 0x20 mirrors U; bit 0x800 + bit 0x10 select the triangle half of the quad."""
U0, U1, V0, V1 = rect
m = bool(flags & 0x20)
a, b = (U0, U1) if m else (U1, U0) # a = "rechts" ohne Spiegelung
a, b = (U0, U1) if m else (U1, U0) # a = "right" without mirroring
if not (flags & 0x800):
return [(a, V1), (b, V1), (a, V0)]
if not (flags & 0x10):
@@ -406,7 +406,7 @@ def face_uvs(flags, rect):
return [(b, V0), (a, V0), (a, V1)]
# ---------------------------------------------------------------- Welt zusammensetzen
# ---------------------------------------------------------------- assemble the world
def tile_xform(x, y, z, rot):
a = rot / 4096 * 2 * math.pi
+22 -22
View File
@@ -1,19 +1,19 @@
#!/bin/sh
# Mods für "Ein Königreich für ein Lama" ein- und ausschalten.
# Turn mods for "Ein Königreich für ein Lama" on and off.
#
# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren
# lama-mod aus Originalzustand (L: = unveränderte CD-Daten)
# lama-mod an|on enable the mods in ~/.wine-lama/mods/*/
# lama-mod aus|off original state (L: = unmodified CD data)
# lama-mod status
# lama-mod debug an Debug-Ansicht (Kollision/unsichtbare Objekte) zuschalten
# lama-mod debug aus Debug-Ansicht abschalten (andere Mods bleiben, wie sie sind)
# lama-mod debug an enable the debug view (collision/invisible objects)
# lama-mod debug aus disable the debug view (other mods stay as they are)
#
# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter
# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben
# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige.
# Mods mit einer Datei ".aus" im Ordner werden übersprungen (so schaltet
# "lama-mod debug an|aus" die Mod "debug" unabhängig von den anderen).
# Reihenfolge: alphabetisch, spätere Mods gewinnen (testlevel > debug).
# A mod is a folder whose files are laid out like on the CD under
# Setup/Eng/Cd (e.g. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
# Activation uses an overlay of symlinks: original files stay untouched,
# mod files are added or replace files of the same name.
# Mods with a file ".aus" ("off") in their folder are skipped (this is how
# "lama-mod debug an|aus" toggles the "debug" mod independently of the others).
# Order: alphabetical, later mods win (testlevel > debug).
set -e
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}"
MODS="$WINEPREFIX/mods"
@@ -34,12 +34,12 @@ overlay_an() {
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then
echo "Mod: $(basename "$m") (abgeschaltet)"
echo "Mod: $(basename "$m") (disabled)"
continue
fi
echo "Mod: $(basename "$m")"
(cd "$m" && find . -path ./src -prune -o -name .aus -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do
# vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen
# replace an existing file of the same name (case-insensitive)
dir=$(dirname "$f"); name=$(basename "$f")
mkdir -p "$OVL/$dir"
find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} +
@@ -48,7 +48,7 @@ overlay_an() {
done
done
ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:"
echo "Mods aktiv (L: -> $OVL)"
echo "Mods active (L: -> $OVL)"
}
case "${1:-status}" in
@@ -58,27 +58,27 @@ an|on)
aus|off)
ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:"
rm -rf "$OVL"
echo "Originalzustand (L: -> $BASE)"
echo "Original state (L: -> $BASE)"
;;
debug)
if [ ! -d "$DBG/wads" ]; then
echo "Debug-Mod fehlt ($DBG/wads)." >&2; exit 1
echo "Debug mod missing ($DBG/wads)." >&2; exit 1
fi
case "${2:-status}" in
an|on)
rm -f "$DBG/.aus"
LEISE=1 overlay_an
echo "Debug-Ansicht AN ($(ls "$DBG/wads" | wc -l) Level). Wirkt beim nächsten Laden eines Levels."
echo "Debug view ON ($(ls "$DBG/wads" | wc -l) levels). Takes effect the next time a level is loaded."
;;
aus|off)
touch "$DBG/.aus"
if [ "$(readlink "$WINEPREFIX/dosdevices/l:")" = "$OVL" ]; then
LEISE=1 overlay_an
fi
echo "Debug-Ansicht AUS."
echo "Debug view OFF."
;;
status)
if [ -e "$DBG/.aus" ]; then echo "Debug-Ansicht: aus"; else echo "Debug-Ansicht: an (wenn Mods aktiv)"; fi
if [ -e "$DBG/.aus" ]; then echo "Debug view: off"; else echo "Debug view: on (if mods are active)"; fi
;;
*)
sed -n '2,9p' "$0"; exit 1 ;;
@@ -88,8 +88,8 @@ status)
echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")"
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then s=" (abgeschaltet)"; else s=""; fi
echo "installierte Mod: $(basename "$m")$s"
if [ -e "$m/.aus" ]; then s=" (disabled)"; else s=""; fi
echo "installed mod: $(basename "$m")$s"
done
;;
*)
+129 -126
View File
@@ -1,34 +1,36 @@
#!/usr/bin/env python3
"""lamatest - unbeaufsichtigter Test-Harness fuer "Ein Koenigreich fuer ein Lama".
"""lamatest - unattended test harness for "Ein Koenigreich fuer ein Lama".
Startet groove.exe (Wine) unsichtbar auf Xvfb :5 mit Null-Audio-Sink, laedt den
TestLevel (Welt 9, Kapitel 1) ueber Spielstand-Slot 3 und macht Screenshots.
Starts groove.exe (Wine) invisibly on Xvfb :5 with a null audio sink, loads the
TestLevel (world 9, chapter 1) via save slot 3 and takes screenshots.
lamatest.py start Xvfb + Sink + Spiel, wartet aufs Fenster
lamatest.py shot out.png Screenshot (1920x1080) von :5
lamatest.py key <key> [hold] Taste halten (xdotool-Name, Default 0.12 s)
lamatest.py keys <k1> <k2> ... mehrere Tasten nacheinander (k[:hold])
lamatest.py seq <outdir> "TOKENS" Tastenfolge wie run --seq im laufenden Spiel (shot:/fp:/rj: ...)
lamatest.py tolevel Demo -> Hauptmenue -> Slot 3 -> TestLevel 1
lamatest.py tour <outdir> [n] Rundum-Fotos: drehen/laufen/Kamera
lamatest.py status laeuft was? Fenster? Prozesse?
lamatest.py stop Spiel beenden, Sink entladen, Xvfb killen
lamatest.py run <wad> <outdir> [n] WAD als T1L9M001.wad installieren, starten,
tolevel, tour, Crash-Erkennung, Log sichern
lamatest.py restore-wad Original-T1L9M001.wad (.orig) zurueck
run-Optionen: --seq "Up:1.5 x shot:name sleep=1 ..." statt Tour eine Tastenfolge;
shot:NAME = Foto NAME.jpg, fp:NAME = Foto in Lama-Sicht (Return gehalten),
dialog = Dialog wegklicken, keys wie bei 'keys' (Taste[:Haltezeit]),
Up+x:0.8 = Tasten gleichzeitig, rj:T1:T2 = Anlauf-Sprung (Up halten,
nach T1 s X, dann noch T2 s Up),
run:T:x@1.3,shot@5,Left@3-3.4 = Up T s halten, X/Foto/Taste nach Zeitplan
(run/Left:T:... haelt eine andere Taste)
--tour zusaetzlich zur --seq auch die Standard-Tour
--keep getestete WAD installiert lassen (sonst wird die vorher
installierte T1L9M001.wad wiederhergestellt - nicht .orig!)
lamatest.py start Xvfb + sink + game, waits for the window
lamatest.py shot out.png screenshot (1920x1080) of :5
lamatest.py key <key> [hold] hold a key (xdotool name, default 0.12 s)
lamatest.py keys <k1> <k2> ... several keys one after another (k[:hold])
lamatest.py seq <outdir> "TOKENS" key sequence like run --seq in the running game (shot:/fp:/rj: ...)
lamatest.py tolevel demo -> main menu -> slot 3 -> TestLevel 1
lamatest.py tour <outdir> [n] all-round photos: turn/walk/camera
lamatest.py status anything running? window? processes?
lamatest.py stop quit the game, unload the sink, kill Xvfb
lamatest.py run <wad> <outdir> [n] install WAD as T1L9M001.wad, start,
tolevel, tour, crash detection, save the log
lamatest.py restore-wad restore the original T1L9M001.wad (.orig)
run options: --seq "Up:1.5 x shot:name sleep=1 ..." a key sequence instead of the tour;
shot:NAME = photo NAME.jpg, fp:NAME = photo in llama view (Return held),
dialog = click dialog away, keys as with 'keys' (key[:hold time]),
Up+x:0.8 = keys simultaneously, rj:T1:T2 = running jump (hold Up,
X after T1 s, then Up for another T2 s),
run:T:x@1.3,shot@5,Left@3-3.4 = hold Up for T s, X/photo/key on a schedule
(run/Left:T:... holds a different key)
--tour run the standard tour in addition to --seq
--keep leave the tested WAD installed (otherwise the previously
installed T1L9M001.wad is restored - not .orig!)
Zustand/Logs: $LAMATEST_DIR (Default /tmp/lamatest-$USER).
Doku: ../notes/testharness.md
State/logs: $LAMATEST_DIR (default /tmp/lamatest-$USER).
Report lines (report.txt, "ergebnis: ...") and `status` output stay German: helper
scripts grep for them. OCR expectations are German game text.
Docs: ../notes/testharness.md
"""
import hashlib
import os
@@ -62,7 +64,7 @@ def log(*a):
def die(msg, code=1):
log("FEHLER:", msg)
log("ERROR:", msg)
sys.exit(code)
@@ -85,9 +87,9 @@ def read_pid(name):
return None
# ---------------------------------------------------------------- Prozesse
# ---------------------------------------------------------------- processes
def groove_procs():
"""Alle laufenden groove.exe-Prozesse: [(pid, environ-DISPLAY)]."""
"""All running groove.exe processes: [(pid, environ DISPLAY)]."""
out = []
for p in os.listdir("/proc"):
if not p.isdigit():
@@ -114,7 +116,7 @@ def foreign_groove():
def xdo(*args):
# Sicherheitsnetz: xdotool immer nur gegen :5
# safety net: xdotool only ever against :5
assert ENV["DISPLAY"] == DISPLAY
return sh(["xdotool"] + list(args))
@@ -130,7 +132,8 @@ def game_alive():
def error_dialog():
"""Wine-Fehlerdialog / Absturzfenster auf :5? Liefert Fenstertitel oder None."""
"""Wine error dialog / crash window on :5? Returns the window title or None
(matches English and German Wine dialog titles)."""
r = xdo("search", "--name", ".")
titles = []
for wid in r.stdout.split():
@@ -145,7 +148,7 @@ def error_dialog():
return None
# ---------------------------------------------------------------- Spielstaende
# ---------------------------------------------------------------- save games
def ksv_files():
return sorted(f for f in os.listdir(GAMEDIR) if f.lower().endswith(".ksv"))
@@ -162,11 +165,11 @@ def restore_saves():
for f in os.listdir(SAVEBAK):
src, dst = os.path.join(SAVEBAK, f), os.path.join(GAMEDIR, f)
if not os.path.exists(dst) or md5(src) != md5(dst):
log("Spielstand %s wurde veraendert -> wiederhergestellt" % f)
log("save game %s was modified -> restored" % f)
shutil.copy2(src, dst)
for f in ksv_files():
if not os.path.exists(os.path.join(SAVEBAK, f)):
log("neuer Spielstand %s vom Test -> entfernt" % f)
log("new save game %s from the test -> removed" % f)
os.remove(os.path.join(GAMEDIR, f))
@@ -177,35 +180,35 @@ def md5(p):
def check_slot3():
p = os.path.join(GAMEDIR, "(null)king2.ksv")
if not os.path.exists(p):
die("Slot 3 (%s) fehlt" % p)
die("slot 3 (%s) missing" % p)
b = open(p, "rb").read()
# Kartencursor (+4/+5) bestimmt, was auf der Auswahlkarte vorgewaehlt ist
# map cursor (+4/+5) decides what is preselected on the selection map
if len(b) != 256 or b[4] != 9 or b[5] != 1:
log("WARNUNG: Slot 3: Kartencursor nicht auf Welt 9/Kapitel 1 (Cursor %d/%d, "
"aktuell %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
log("WARNING: slot 3: map cursor not on world 9/chapter 1 (cursor %d/%d, "
"current %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
# ---------------------------------------------------------------- Kommandos
# ---------------------------------------------------------------- commands
def cmd_start(timeout=60):
os.makedirs(STATE, exist_ok=True)
f = foreign_groove()
if f:
die("Elias' eigene Spielinstanz laeuft (pid %s) - Test abgebrochen" % f)
die("Elias' own game instance is running (pid %s) - test aborted" % f)
if game_alive():
log("Spiel laeuft schon auf %s" % DISPLAY)
log("game already running on %s" % DISPLAY)
return
if not os.path.isdir(OVERLAY) or not os.path.realpath(
os.path.join(PREFIX, "dosdevices", "l:")) == os.path.realpath(OVERLAY):
die("Mod ist nicht aktiv (lama-mod an)")
die("mod is not active (lama-mod an)")
check_slot3()
backup_saves()
# Xvfb
if not read_pid("xvfb"):
if os.path.exists("/tmp/.X11-unix/X5") or os.path.exists("/tmp/.X5-lock"):
if sh(["xdpyinfo"]).returncode == 0:
log("fremder X-Server auf :5 laeuft bereits - wird mitbenutzt")
log("foreign X server already running on :5 - sharing it")
else:
die("/tmp/.X5-lock existiert, aber :5 antwortet nicht")
die("/tmp/.X5-lock exists, but :5 does not respond")
else:
xl = open(XVFBLOG, "w")
p = subprocess.Popen(["Xvfb", DISPLAY, "-screen", "0", "1920x1200x24",
@@ -217,16 +220,16 @@ def cmd_start(timeout=60):
break
time.sleep(0.1)
else:
die("Xvfb startet nicht (%s)" % XVFBLOG)
# Null-Sink (nie die App stummschalten!)
die("Xvfb does not start (%s)" % XVFBLOG)
# null sink (never mute the app!)
if not os.path.exists(pidfile("sink")):
r = sh(["pactl", "load-module", "module-null-sink", "sink_name=" + SINK,
"sink_properties=device.description=lama_stumm"])
if r.returncode == 0:
open(pidfile("sink"), "w").write(r.stdout.strip())
else:
log("WARNUNG: Null-Sink nicht ladbar:", r.stderr.strip())
# Spiel
log("WARNING: cannot load null sink:", r.stderr.strip())
# game
wl = open(WINELOG, "w")
p = subprocess.Popen(["wine", GAMEEXE], cwd=GAMEDIR, env=ENV, stdout=wl,
stderr=subprocess.STDOUT, start_new_session=True)
@@ -234,12 +237,12 @@ def cmd_start(timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
if find_window():
log("Fenster da nach %.1f s" % (time.time() - t0))
log("window up after %.1f s" % (time.time() - t0))
return
if p.poll() is not None and not game_alive():
die("wine beendet sich sofort (Log: %s)" % WINELOG)
die("wine exits immediately (log: %s)" % WINELOG)
time.sleep(0.5)
die("kein Groove-Fenster nach %d s" % timeout)
die("no Groove window after %d s" % timeout)
def shot(path):
@@ -253,8 +256,8 @@ def shot(path):
def small_shot(path):
"""Kleiner Screenshot (960x540 JPEG) fuer Serien - Platte ist knapp.
Mit LAMATEST_SHOT=1280 (Umgebung) 1280x720 / Qualitaet 85 (Vorschaubilder)."""
"""Small screenshot (960x540 JPEG) for series - disk space is tight.
With LAMATEST_SHOT=1280 (environment) 1280x720 / quality 85 (preview images)."""
w = os.environ.get("LAMATEST_SHOT")
scale, q = ("%sx" % w, "85") if w else ("50%", "80")
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
@@ -267,7 +270,7 @@ def small_shot(path):
def key(k, hold=0.12):
wid = find_window()
if not wid:
die("kein Groove-Fenster")
die("no Groove window")
xdo("windowfocus", "--sync", wid)
xdo("keydown", k)
time.sleep(float(hold))
@@ -284,7 +287,7 @@ def keys(seq, gap=0.35):
time.sleep(gap)
# Bildvergleich ueber kleine Graustufen-Thumbnails (nur ImageMagick noetig)
# image comparison via small greyscale thumbnails (only ImageMagick needed)
def thumb(path=None):
src = ["import", "-display", DISPLAY, "-window", "root", "-crop", "1920x1080+0+0",
"+repage"] if path is None else ["convert", path]
@@ -304,7 +307,7 @@ def mean(t):
def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
"""Warten bis sich das Bild `still` Sekunden kaum aendert."""
"""Wait until the image barely changes for `still` seconds."""
t0 = time.time()
time.sleep(min_time)
last, since = thumb(), time.time()
@@ -320,9 +323,9 @@ def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
# ---------------------------------------------------------------- Bilderkennung
# ---------------------------------------------------------------- image recognition
def rgb_thumb():
"""48x27 RGB-Thumbnail des aktuellen Bildes (Liste von (r,g,b)-Zeilen)."""
"""48x27 RGB thumbnail of the current image (list of rows of (r,g,b))."""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"1920x1080+0+0", "+repage", "-resize", "48x27!", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
@@ -339,18 +342,18 @@ def _rows_mean(t, ys):
def classify(t=None):
"""Grobe Bildklasse: black / letterbox / map / bright (Menue oder Spiel)."""
"""Rough image class: black / letterbox / map / bright (menu or game)."""
t = t or rgb_thumb()
if t is None:
return "none"
allm = _rows_mean(t, range(27))
if allm < 6:
return "black"
# nur oberste/unterste Thumbnail-Zeile pruefen: Untertitel liegen im unteren Balken
# only check the top/bottom thumbnail row: subtitles sit in the lower bar
top, bot, mid = _rows_mean(t, (0,)), _rows_mean(t, (26,)), _rows_mean(t, range(6, 18))
if top < 4 and bot < 4 and mid > 15:
return "letterbox" # Kamera-Flug / Dialog-Zwischensequenz
# Auswahlkarte Welt 9: fast einfarbig beige (~205,175,110)
return "letterbox" # camera fly-by / dialogue cutscene
# selection map world 9: almost uniformly beige (~205,175,110)
beige = sum(1 for row in t for (r, g, b) in row
if 180 < r < 230 and 150 < g < 200 and 85 < b < 140 and r > g > b)
if beige > 0.75 * 48 * 27:
@@ -359,7 +362,7 @@ def classify(t=None):
def hud_visible():
"""Goldene Energieleiste/Sonnenkrone oben links (Gameplay-HUD)?"""
"""Golden energy bar/sun crown at the top left (gameplay HUD)?"""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"900x110+100+10", "+repage", "-resize", "25%", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
@@ -370,8 +373,8 @@ def hud_visible():
def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
"""Text auf dem Bildschirm (tesseract, deu), klein geschrieben.
threshold (Prozent): nur sehr helle Schrift behalten (z. B. markierter Menuepunkt)."""
"""Text on screen (tesseract, deu), lower-cased.
threshold (percent): keep only very bright text (e.g. the highlighted menu item)."""
filt = ["-threshold", "%d%%" % threshold] if threshold else []
raw = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
crop, "+repage", "ppm:-"], stdout=subprocess.PIPE, env=ENV).stdout
@@ -384,7 +387,7 @@ def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
def crashed():
"""None oder Grund-String, wenn das Spiel abgestuerzt ist."""
"""None, or a reason string if the game has crashed (goes into report.txt)."""
if not game_alive():
return "groove.exe-Prozess weg"
e = error_dialog()
@@ -416,16 +419,16 @@ class Timeout(Exception):
pass
SLOT_CROP = "1600x280+160+740" # Textfeld "Gespeichertes Spiel N / Level Kapitel"
SLOT_CROP = "1600x280+160+740" # text field "Gespeichertes Spiel N / Level Kapitel" (German game text)
def slot_text():
# zwei Schwellen kombinieren - der Hintergrund flimmert, OCR schwankt
# combine two thresholds - the background flickers, OCR fluctuates
return ocr(SLOT_CROP, threshold=70, psm=6) + " | " + ocr(SLOT_CROP, threshold=82, psm=6)
def fuzzy_in(word, text, ratio=0.75):
"""word (evtl. mehrere Woerter) unscharf in text enthalten?"""
"""Is word (possibly several words) fuzzily contained in text?"""
import difflib
if word in text:
return True
@@ -446,63 +449,63 @@ def _has(*words):
def tolevel(skip_flyby=True, log_times=None):
"""Hauptmenue -> Spiel starten -> Slot mit TestLevel -> Kapitel 1 -> Gameplay."""
"""Main menu -> "Spiel starten" -> slot with TestLevel -> chapter 1 -> gameplay."""
T = log_times if log_times is not None else {}
t0 = time.time()
# 1) Hauptmenue (Logo ~2 s, Menue nach ~5-6 s). Kommt stattdessen die Demo,
# bringt Return zurueck ins Menue.
# 1) main menu (logo ~2 s, menu after ~5-6 s). If the demo comes instead,
# Return brings you back to the menu.
tx = time.time()
while True:
c = crashed()
if c:
raise Crash(c)
# Hauptmenue: Menuepunkte rechts unten; bei 75 % Schwelle bleibt nur der
# markierte (helle) Punkt lesbar -> prueft zugleich die Cursorposition.
# main menu: menu items at the bottom right; at a 75 % threshold only the
# highlighted (bright) item stays readable -> also checks the cursor position.
txt = ocr("700x300+1150+650", threshold=75, psm=6, scale="100%")
if fuzzy_in("spiel starten", txt):
log("Hauptmenue erkannt")
log("main menu detected")
time.sleep(0.5)
key("Return")
break
if fuzzy_in("gespeichertes", slot_text()):
break # schon im Slot-Bildschirm
break # already on the slot screen
if time.time() - tx > 40:
log("kein Menue erkannt -> Return (Demo/Logo ueberspringen)")
log("no menu detected -> Return (skip demo/logo)")
key("Return")
tx = time.time()
if time.time() - t0 > 90:
raise Timeout("Hauptmenue nicht erreicht")
time.sleep(1)
T["menu"] = round(time.time() - t0, 1)
# 2) Spielstandauswahl: ganz nach links (kein Wrap), dann den Slot mit TestLevel suchen
# 2) save game selection: all the way left (no wrap), then find the slot with TestLevel
wait_for(_has("gespeichertes"), 20, "Spielstandauswahl")
time.sleep(0.4)
# Dreier-Slider ohne Wrap: Slot 3 ist ganz rechts, weiteres Right bleibt dort.
# Also einfach 2x Right (egal wo man startet) und einmal per OCR pruefen.
# three-slot slider without wrap: slot 3 is at the far right, further Right stays there.
# So just press Right twice (wherever you start) and check once via OCR.
def _is_test():
return (fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=70, psm=6))
or fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=82, psm=6)))
for _ in range(2):
key("Right", 0.3) # 0.3 s halten: bei niedriger Bildrate gehen kurze Druecke verloren
key("Right", 0.3) # hold 0.3 s: short presses get lost at low frame rates
time.sleep(0.3)
for i in range(3): # unter Last kann ein Druck verloren gehen -> noch einmal Right
for i in range(3): # under load a press can get lost -> Right once more
time.sleep(0.6)
if _is_test():
log("Slot 3: TestLevel gefunden (Versuch %d)" % (i + 1))
log("slot 3: TestLevel found (attempt %d)" % (i + 1))
break
key("Right", 0.3)
else:
raise Timeout("kein Spielstand mit TestLevel gefunden (Slot 3 kaputt?)")
key("Return")
T["slot"] = round(time.time() - t0, 1)
# 3) Auswahlkarte
# 3) selection map
wait_for(lambda: classify() == "map", 30, "Auswahlkarte")
time.sleep(1.0)
# Vorauswahl auf der Karte = Kartencursor im KSV (+4/+5), geprueft in check_slot3()
# preselection on the map = map cursor in the KSV (+4/+5), checked in check_slot3()
key("Return")
t_load = time.time()
T["map"] = round(t_load - t0, 1)
# 4) Laden -> (Kamera-Flug im Letterbox) -> Gameplay
# 4) loading -> (camera fly-by in letterbox) -> gameplay
skipped = False
good = 0
states = []
@@ -513,16 +516,16 @@ def tolevel(skip_flyby=True, log_times=None):
st = classify()
if not states or states[-1] != st:
states.append(st)
log("Zustand: %s (%.1f s nach Level-Start)" % (st, time.time() - t_load))
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
if st == "letterbox" and skip_flyby and not skipped:
time.sleep(0.5)
key("Return") # Kamera-Flug ueberspringen
key("Return") # skip the camera fly-by
skipped = True
T["flyby"] = round(time.time() - t_load, 1)
elif st == "letterbox" and skipped and time.time() - t_load > 15:
key("x") # Dialog weiterklicken
# Gameplay = helles Bild, kein Letterbox, und HUD sichtbar (oder nach
# Kamera-Flug / spaetestens 25 s - falls das HUD schon ausgeblendet ist)
key("x") # click through the dialogue
# gameplay = bright image, no letterbox, and HUD visible (or after the
# camera fly-by / after 25 s at the latest - in case the HUD has already faded out)
el = time.time() - t_load
if st == "bright" and el > 2 and (hud_visible() or "letterbox" in states or el > 25):
good += 1
@@ -536,7 +539,7 @@ def tolevel(skip_flyby=True, log_times=None):
T["gameplay"] = round(time.time() - t0, 1)
T["load"] = round(time.time() - t_load, 1)
T["states"] = states
log("Gameplay nach %.1f s (Laden %.1f s), Zustaende %s" % (T["gameplay"], T["load"], states))
log("gameplay after %.1f s (loading %.1f s), states %s" % (T["gameplay"], T["load"], states))
return T
@@ -549,7 +552,7 @@ def cmd_tolevel():
def settle_dialog(maxpress=12):
"""Dialog-/Zwischensequenz (Letterbox) wegklicken."""
"""Click away a dialogue/cutscene (letterbox)."""
n = 0
while classify() == "letterbox" and n < maxpress:
key("x")
@@ -559,8 +562,8 @@ def settle_dialog(maxpress=12):
def tour(outdir, n=8, prefix="tour"):
"""Fotoserie: n Bilder Kamera-Rundumblick (A), dann n Bilder Laufen+Drehen.
Liefert Liste der Dateien. Bilder als 960x540-JPEG (Platz sparen)."""
"""Photo series: n images of a camera pan (A), then n images walking+turning.
Returns the list of files. Images as 960x540 JPEG (saves space)."""
os.makedirs(outdir, exist_ok=True)
files = []
@@ -573,13 +576,13 @@ def tour(outdir, n=8, prefix="tour"):
files.append(f)
snap("start")
# Kamera einmal rundum: 0.5 s A-Taste ~ 90 Grad
# camera once all round: 0.5 s of the A key ~ 90 degrees
hold = 2.0 / max(n, 1)
for i in range(n):
key("a", hold)
time.sleep(0.7)
snap("cam%d" % i)
# Laufen: vorwaerts, zwischendurch drehen; Dialoge wegklicken
# walking: forwards, turning in between; click dialogues away
for i in range(n):
key("Up", 1.2)
time.sleep(0.4)
@@ -589,7 +592,7 @@ def tour(outdir, n=8, prefix="tour"):
snap("walk%d" % i)
key("Left" if i % 3 != 2 else "Right", 0.45)
time.sleep(0.3)
# Lama-Sicht (Return gehalten) einmal pro Richtung
# llama view (Return held) once per direction
key("Return", 1.5)
time.sleep(0.2)
snap("lamasicht")
@@ -600,7 +603,7 @@ def cmd_tour(outdir, n):
try:
fs = tour(outdir, n)
except Crash as e:
die("ABSTURZ: %s" % e, 3)
die("CRASH: %s" % e, 3)
print("\n".join(fs))
@@ -610,31 +613,31 @@ def install_wad(wad):
orig = MODWAD + ".orig"
if not os.path.exists(orig):
shutil.copy2(MODWAD, orig)
log("Original gesichert: %s" % orig)
log("original backed up: %s" % orig)
if os.path.abspath(MODWAD) != wad:
shutil.copyfile(wad, MODWAD)
# Overlay zeigt per Symlink auf MODWAD -> neu bauen nur wenn Link fehlt/falsch
# overlay points to MODWAD via symlink -> rebuild only if the link is missing/wrong
link = os.path.join(OVERLAY, "wads", "T1L9M001.wad")
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(MODWAD)):
log("Overlay-Link fehlt -> lama-mod an")
log("overlay link missing -> lama-mod an")
sh([LAMA_MOD, "an"], check=True)
others = [f for f in os.listdir(os.path.dirname(link))
if f.lower() == "t1l9m001.wad" and f != "T1L9M001.wad"]
if others:
log("WARNUNG: weitere Schreibweise im Overlay: %s" % others)
log("WAD installiert: %s (%d Bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
log("WARNING: other spelling in the overlay: %s" % others)
log("WAD installed: %s (%d bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
def cmd_restore_wad():
orig = MODWAD + ".orig"
if not os.path.exists(orig):
die("kein %s" % orig)
die("no %s" % orig)
shutil.copy2(orig, MODWAD)
log("Original-T1L9M001.wad wiederhergestellt")
log("original T1L9M001.wad restored")
def run_seq(outdir, seq):
"""Tastenfolge abarbeiten; 'shot:NAME' fotografiert. Liefert Bilddateien."""
"""Process a key sequence; 'shot:NAME' takes a photo. Returns the image files."""
files = []
for item in seq.split():
c = crashed()
@@ -645,7 +648,7 @@ def run_seq(outdir, seq):
small_shot(f)
files.append(f)
elif item.startswith("fp:"):
# Lama-Sicht: Return halten, waehrenddessen fotografieren
# llama view: hold Return, take the photo meanwhile
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Return")
@@ -656,7 +659,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Return")
time.sleep(0.4)
elif item.startswith("rj:"):
# Anlauf-Sprung: Up halten, nach T1 s X tippen, Up noch T2 s halten
# running jump: hold Up, tap X after T1 s, keep holding Up for T2 s
_, t1, t2 = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -669,9 +672,9 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("run:") or item.startswith("run/"):
# Durchlaufen mit Zeitplan: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
# Up (bzw. bei run/Left:... die angegebene Taste) wird T s gehalten; zu den Zeiten (s ab Beginn) X tippen / Foto (seq_NN_runK) /
# Taste von-bis halten. Fotos dauern ~0.2 s, der Zeitplan laeuft weiter (Echtzeit).
# run through with a schedule: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
# Up (or the given key for run/Left:...) is held for T s; at the given times (s from start) tap X / photo (seq_NN_runK) /
# hold a key from-to. Photos take ~0.2 s, the schedule keeps running (real time).
parts = item.split(":", 2)
mk = parts[0][4:] or "Up"
total, ev = float(parts[1]), []
@@ -709,7 +712,7 @@ def run_seq(outdir, seq):
xdo("keyup", arg)
time.sleep(0.3)
elif item.startswith("hop:"):
# Up halten und N-mal alle T s springen (Fotos dazwischen: shot nach dem Token)
# hold Up and jump N times every T s (photos in between: shot after the token)
_, n, t = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -719,7 +722,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("dj:"):
# Doppelsprung mit Anlauf: Up halten, X, nach 0.3 s nochmal X, Up noch T s
# double jump with run-up: hold Up, X, X again after 0.3 s, Up for another T s
t = float(item.split(":")[1])
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -730,7 +733,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif "+" in item.split(":")[0]:
# mehrere Tasten gleichzeitig: Up+x:0.8
# several keys simultaneously: Up+x:0.8
ks, _, h = item.partition(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -753,8 +756,8 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
rep = ["wad: %s" % os.path.abspath(wad)]
status = 0
if foreign_groove():
die("Elias' eigene Spielinstanz laeuft - abgebrochen")
# vorher installierte WAD merken -> danach zurueck (nicht .orig!)
die("Elias' own game instance is running - aborted")
# remember the previously installed WAD -> put it back afterwards (not .orig!)
prev = os.path.join(STATE, "vorher_T1L9M001.wad")
os.makedirs(STATE, exist_ok=True)
if not keep and os.path.exists(MODWAD):
@@ -775,7 +778,7 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
if do_tour or (do_tour is None and not seq):
fs += tour(outdir, n)
rep.append("bilder: %d" % (len(fs) + 2))
# eingefroren? (Kamera dreht sich -> aufeinanderfolgende Bilder muessen sich aendern)
# frozen? (the camera turns -> consecutive images must differ)
th = [thumb(f) for f in fs]
dmax = max([diff(a, b) for a, b in zip(th, th[1:])] or [0])
rep.append("bildaenderung max: %.1f" % dmax)
@@ -808,12 +811,12 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
cmd_stop()
finally:
if os.path.exists(WINELOG):
# Log kuerzen: max. 2 MB behalten (Anfang + Ende)
# truncate the log: keep at most 2 MB (start + end)
data = open(WINELOG, "rb").read()
if len(data) > 2_000_000:
data = data[:1_000_000] + b"\n...[gekuerzt]...\n" + data[-1_000_000:]
open(os.path.join(outdir, "wine.log"), "wb").write(data)
# bekannte harmlose Meldungen (laufen auch bei funktionierendem Spiel)
# known harmless messages (also appear when the game works fine)
noise = ("com_get_class_object", "ChangeDisplaySettingsExW",
"wined3d_restore_display_modes", "gstreamer", "MIDIMAP",
"RestoreDisplayMode")
@@ -844,7 +847,7 @@ def cmd_stop():
pass
time.sleep(1)
if f:
log("fremde Instanz laeuft (%s) - kein wineserver -k, nur eigene Prozesse" % f)
log("foreign instance running (%s) - no wineserver -k, only own processes" % f)
for p, d in groove_procs():
if d == DISPLAY:
os.kill(p, signal.SIGKILL)
@@ -875,13 +878,13 @@ def cmd_stop():
os.remove(pidfile("sink"))
except OSError:
pass
# Sicherheitsnetz: Sink per Name entladen, falls Modulnummer verloren
# safety net: unload the sink by name in case the module number got lost
r = sh(["pactl", "list", "short", "modules"])
for line in r.stdout.splitlines():
if "sink_name=" + SINK in line:
sh(["pactl", "unload-module", line.split()[0]])
restore_saves()
log("gestoppt")
log("stopped")
def cmd_status():
@@ -911,7 +914,7 @@ def main(argv):
keys(a)
elif c == "tolevel":
cmd_tolevel()
elif c == "seq": # Tastenfolge wie run --seq, aber im laufenden Spiel: seq <outdir> "TOKENS"
elif c == "seq": # key sequence like run --seq, but in the running game: seq <outdir> "TOKENS"
os.makedirs(a[0], exist_ok=True)
for f in run_seq(a[0], a[1]):
print(f)
@@ -933,7 +936,7 @@ def main(argv):
sys.exit(cmd_run(pos[0], pos[1], int(pos[2]) if len(pos) > 2 else 8, seq, do_tour, keep))
elif c == "restore-wad":
cmd_restore_wad()
elif c == "mean": # Debug: Helligkeit/Diff
elif c == "mean": # debug: brightness/diff
print(mean(thumb(a[0] if a else None)))
else:
print(__doc__)
+109 -108
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env python3
"""levelbuild - eigene Level fuer "Ein Koenigreich fuer ein Lama" bauen (Croc-2-Engine, PC).
"""levelbuild - build custom levels for "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
Ein Level wird als Python-Skript beschrieben, das eine Funktion ``build(L)`` enthaelt.
``L`` ist ein :class:`Level` mit Grundkoerpern (Boxen, Plattformen, Rampen, Treppen, ...),
Texturen per Name, automatischer Vertexfarben-Beleuchtung (Sonne, Himmel, Schatten,
Umgebungsverdeckung) und Objekt-Platzierung (Strats per Typname, Parameter vom Original).
A level is described as a Python script containing a function ``build(L)``.
``L`` is a :class:`Level` with primitives (boxes, platforms, ramps, stairs, ...),
textures by name, automatic vertex colour lighting (sun, sky, shadows,
ambient occlusion) and object placement (strats by type name, parameters from the original).
levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info]
levelbuild.py --types [--base ...] platzierbare Strat-Typen des Basislevels
levelbuild.py --textures OUT.png Kontaktabzug der Texturen (mit Namen/Index)
levelbuild.py --types [--base ...] placeable strat types of the base level
levelbuild.py --textures OUT.png texture contact sheet (with names/indices)
Koordinaten (wie im Spiel / bei Strats): x nach Osten, y nach oben, z = Rasterzeile
(positiv). 1 Einheit = 1 Rasterzelle. Das Raster des Basislevels ist 96x96 (x,z in 0..96).
Winkel in Grad (0..360) um die y-Achse.
Doku: ../notes/levelbau.md
Coordinates (as in the game / for strats): x east, y up, z = grid row
(positive). 1 unit = 1 grid cell. The base level's grid is 96x96 (x,z in 0..96).
Angles in degrees (0..360) about the y axis.
Texture names (TEX/FLAT below) are German keys and part of the level script API.
Docs: ../notes/levelbau.md
"""
import argparse
import hashlib
@@ -38,7 +39,7 @@ BASE = os.path.join(WADS, 't1l1m001.wad')
MODWAD = os.path.expanduser('~/.wine-lama/mods/testlevel/wads/T1L9M001.wad')
FX = 4096
# Texturnamen fuer das Dorf (t1l1m001). Kontaktabzug: notes/img/texturen_dorf.png
# texture names for the village (t1l1m001). Contact sheet: notes/img/texturen_dorf.png
TEX = {
'gras': 1, 'gras2': 170, 'gras_hell': 190, 'gras_streifen': 212, 'gras_dunkel': 157,
'gras_fleck': 99, 'blumen': 87, 'blumen2': 81, 'blumen3': 82,
@@ -53,14 +54,14 @@ TEX = {
'inka': 131, 'muster_lila': 202, 'muster_lila2': 208, 'muster_lila3': 200,
'pilz_rot': 136, 'pilz_rot2': 137, 'treppe_stein': 63,
}
# zusaetzliche Flachfarben (werden an die Texturtabelle angehaengt): Name -> (R, G, B)
# additional flat colours (appended to the texture table): name -> (R, G, B)
FLAT = {
'meer': (40, 120, 150), 'meer_tief': (25, 85, 120), 'weiss': (235, 235, 225),
'rot': (190, 50, 40), 'gelb': (230, 190, 60), 'blau': (60, 90, 190), 'schwarz': (20, 20, 20),
'gruen': (70, 140, 50), 'grau': (120, 120, 120),
}
# Strat-Typen, die im Dorf-Code stehen, aber im Dorf nicht platziert sind:
# Typfelder (nvars, Slots, Flags, Zusatz) + Parameter aus einem anderen Level uebernehmen
# strat types present in the village code but not placed in the village:
# take type fields (nvars, slots, flags, extra) + parameters from another level
FOREIGN = {'RubyIdol': 't1l1m002.wad'}
@@ -68,14 +69,14 @@ def deg(a):
return int(round((a % 360) / 360 * 4096)) & 0xfff
# ---------------------------------------------------------------- Geometrie-Sammelbehaelter
# ---------------------------------------------------------------- geometry containers
class Quad:
__slots__ = ('p', 'tex', 'coll', 'mat', 'tint', 'shade', 'double', 'render')
def __init__(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), shade=True, double=False, render=True):
self.p = p # 3 oder 4 Punkte (Spielkoordinaten)
self.p = p # 3 or 4 points (game coordinates)
self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double
self.render = render # False: nur Kollision (unsichtbar)
self.render = render # False: collision only (invisible)
def _sub(a, b):
@@ -92,19 +93,19 @@ def _norm(v):
def to_exp(p):
"""Spielraum (z positiv) -> Exportraum/Vertexraum (z negativ)."""
"""Game space (z positive) -> export/vertex space (z negative)."""
return (p[0], p[1], -p[2])
class Level:
"""Baukasten. Alle Methoden nehmen Spielkoordinaten (x, z = Rasterzelle, y oben)."""
"""Construction kit. All methods take game coordinates (x, z = grid cell, y up)."""
def __init__(self, base=BASE, name='Testwelt', seed=1):
self.base = base
self.name = name
self.rng = random.Random(seed)
self.quads = []
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) fuer Schatten/Umgebungsverdeckung
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) for shadows/ambient occlusion
self.strats = [] # dict(name, x,y,z, rot, params, wp, variant)
self.kill = None # (y, x0, z0, x1, z1)
self.sun = dict(azimuth=215, elevation=50, color=(1.0, 0.93, 0.80), strength=0.62,
@@ -113,7 +114,7 @@ class Level:
self.flats_used = {}
self._load()
# ------------------------------------------------------------ Basislevel
# ------------------------------------------------------------ base level
def _load(self):
self.ch = G.wad_read(self.base)
self.wf = G.wfpc_flags(self.ch)
@@ -125,7 +126,7 @@ class Level:
st = G.wad_get(self.ch, 'STPC')
self.stpc = st
self.code_lo = stpcwalk.walk(st, self.wf)['code']
self.protos = {} # name -> [instanz-tupel je Code-Variante]
self.protos = {} # name -> [instance tuple per code variant]
for s in self.map['strats']:
nm = (S.strat_name(st, s[6], self.code_lo) or '?@%x' % s[6]).replace('(spawn)', '')
lst = self.protos.setdefault(nm, [])
@@ -153,7 +154,7 @@ class Level:
return sorted(self.protos)
def tex(self, t):
"""Texturname, Index (int) oder Flachfarbe ('meer' / (r,g,b))."""
"""Texture name, index (int) or flat colour ('meer' / (r,g,b))."""
if isinstance(t, int):
return t
if isinstance(t, tuple):
@@ -166,36 +167,36 @@ class Level:
if t not in self.flats_used:
self.flats_used[t] = len(self.texs) + len(self.flats_used)
return self.flats_used[t]
raise KeyError('unbekannte Textur %r (Namen: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
raise KeyError('unknown texture %r (names: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
# ------------------------------------------------------------ Grundbausteine
# ------------------------------------------------------------ basic building blocks
def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True):
"""Ein Viereck p = [unten-links, unten-rechts, oben-rechts, oben-links] von vorne gesehen
(Textur aufrecht). rot = 0..3 dreht die Textur in 90-Grad-Schritten."""
"""A quad p = [bottom-left, bottom-right, top-right, top-left] seen from the front
(texture upright). rot = 0..3 rotates the texture in 90-degree steps."""
p = [tuple(map(float, q)) for q in p]
rot %= 4
p = p[rot:] + p[:rot]
self.quads.append(Quad(p, self.tex(tex), coll, mat, tint, shade, double, render))
def collider(self, p, mat=0, normal=(0, 1, 0)):
"""Unsichtbare Kollisionsflaeche (3/4 Punkte), Vorderseite Richtung 'normal'."""
"""Invisible collision surface (3/4 points), front side towards 'normal'."""
self.quad(_orient([tuple(map(float, q)) for q in p], normal), 'gras', coll=True, mat=mat, render=False)
def _hface(self, x0, z0, x1, z1, y, tex, up=True, step=1.0, rot='random', tint=(1, 1, 1), coll=True, mat=0):
"""Waagerechte Flaeche, in Kacheln (step) unterteilt, an ganzen Zahlen ausgerichtet."""
"""Horizontal surface, subdivided into tiles (step), aligned to integers."""
xs = _cuts(x0, x1, step)
zs = _cuts(z0, z1, step)
for xa, xb in zip(xs, xs[1:]):
for za, zb in zip(zs, zs[1:]):
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 Varianten: weniger Modelle
if up: # von oben gesehen: unten-links = (xa, zb)? -> Normale +y
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 variants: fewer models
if up: # seen from above: bottom-left = (xa, zb)? -> normal +y
p = [(xa, y, za), (xb, y, za), (xb, y, zb), (xa, y, zb)]
else:
p = [(xa, y, zb), (xb, y, zb), (xb, y, za), (xa, y, za)]
self.quad(_orient(p, (0, 1 if up else -1, 0)), tex, coll=coll, mat=mat, tint=tint, rot=r)
def _vface(self, a, b, y0, y1, tex, top_tex=None, step=1.0, tint=(1, 1, 1), coll=True, mat=0, normal=None):
"""Senkrechte Wand von a=(x,z) nach b=(x,z), aussen = rechts von a->b (von oben, z nach unten)."""
"""Vertical wall from a=(x,z) to b=(x,z), outside = right of a->b (seen from above, z pointing down)."""
L = math.hypot(b[0] - a[0], b[1] - a[1])
if L < 1e-6 or y1 - y0 < 1e-6:
return
@@ -212,13 +213,13 @@ class Level:
self.quad(_orient(p, nn), tx, coll=coll, mat=mat, tint=tint)
def floor(self, x0, z0, x1, z1, y, tex='gras', rot='random', tint=(1, 1, 1), step=1.0, coll=True):
"""Flache Bodenflaeche (nur Oberseite)."""
"""Flat floor surface (top side only)."""
self._hface(x0, z0, x1, z1, y, tex, True, step, rot, tint, coll)
def box(self, x0, z0, x1, z1, y0, y1, top='gras', side='erde', side_top=None, bottom=None,
tint=(1, 1, 1), rot='random', sides='nesw', solid=True, coll=True, mat=0, step=1.0, side_coll=None):
"""Quader. side_top: Textur fuer die oberste Reihe der Seitenwaende (z.B. 'graskante').
bottom: Unterseite zeichnen (fuer schwebende Plattformen). sides: welche Seiten (n=z0,
"""Cuboid. side_top: texture for the top row of the side walls (e.g. 'graskante').
bottom: draw the underside (for floating platforms). sides: which sides (n=z0,
s=z1, w=x0, e=x1)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
@@ -229,7 +230,7 @@ class Level:
self._hface(x0, z0, x1, z1, y0, bottom, False, step, 0, tint, coll, mat)
sc = coll if side_coll is None else side_coll
if side:
# Waende so, dass die Aussenseite rechts liegt
# walls oriented so that the outside is on the right
if 'n' in sides:
self._vface((x1, z0), (x0, z0), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, -1))
if 's' in sides:
@@ -243,18 +244,18 @@ class Level:
def platform(self, x, z, w, d, y, top='gras', side='erde', thick=0.5, side_top='graskante',
bottom='erde', **kw):
"""Schwebende Plattform: Oberkante y, Ecke (x,z), Groesse w x d."""
"""Floating platform: top edge y, corner (x,z), size w x d."""
self.box(x, z, x + w, z + d, y - thick, y, top, side, side_top=side_top, bottom=bottom, **kw)
def pillar(self, x, z, y0, y1, size=1.0, side='stein', top='stein', **kw):
"""Saeule mit Mittelpunkt (x,z)."""
"""Pillar centred at (x,z)."""
h = size / 2
self.box(x - h, z - h, x + h, z + h, y0, y1, top, side, **kw)
def ramp(self, x0, z0, x1, z1, ya, yb, direction='+x', top='sand', side='erde', base=None,
tint=(1, 1, 1), step=1.0, coll=True):
"""Rampe ueber dem Rechteck; steigt in 'direction' (+x,-x,+z,-z) von ya auf yb.
Unterkante 'base' (Standard: ya - 0.5); Seiten und Stirnflaeche werden geschlossen."""
"""Ramp over the rectangle; rises in 'direction' (+x,-x,+z,-z) from ya to yb.
Bottom edge 'base' (default: ya - 0.5); sides and front face are closed."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (min(ya, yb) - 0.5) if base is None else base
@@ -271,7 +272,7 @@ class Level:
for za, zb in zip(zs, zs[1:]):
p = [(xa, h(xa, za), za), (xb, h(xb, za), za), (xb, h(xb, zb), zb), (xa, h(xa, zb), zb)]
self.quad(_orient(p, (0, 1, 0)), top, coll=coll, tint=tint)
# Seiten: Trapeze in Streifen
# sides: trapezoids in strips
edges = [((x1, z0), (x0, z0), (0, 0, -1)), ((x0, z1), (x1, z1), (0, 0, 1)),
((x0, z0), (x0, z1), (-1, 0, 0)), ((x1, z1), (x1, z0), (1, 0, 0))]
for a, b, nn in edges:
@@ -290,15 +291,15 @@ class Level:
def stairs(self, x0, z0, x1, z1, y0, y1, n, direction='+x', top='stein', side='stein2', base=None,
walkable=True, tint=(1, 1, 1)):
"""Treppe mit n Stufen auf dem Rechteck, steigt in 'direction' (+x,-x,+z,-z) von y0 auf y1.
walkable=True: Kollision als unsichtbare Schraege (Lama kann hochlaufen; die Engine hat
keine Stufen-Logik, schon 0.12 hohe Kanten blockieren), sonst echte Stufen (springen)."""
"""Stairs with n steps on the rectangle, rising in 'direction' (+x,-x,+z,-z) from y0 to y1.
walkable=True: collision as an invisible slope (the llama can walk up; the engine has
no step logic, even 0.12-high edges block), otherwise real steps (jump)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (y0 - 0.5) if base is None else base
ax = direction[1]
neg = direction[0] == '-'
# in lokale Koordinaten u (Steigrichtung 0..1) / v (quer 0..1) abbilden
# map to local coordinates u (rise direction 0..1) / v (across 0..1)
def P(u, v, y):
if neg:
u = 1 - u
@@ -315,18 +316,18 @@ class Level:
yt = y0 + (y1 - y0) * (i + 1) / n
yl = base if i == 0 else y0 + (y1 - y0) * i / n
last = i == n - 1
# Trittflaeche
# tread
q = [P(u0, 0, yt), P(u1, 0, yt), P(u1, 1, yt), P(u0, 1, yt)]
self.quad(_orient(q, (0, 1, 0)), top, coll=(rc or last), tint=tint)
# Setzstufe (Stirnseite), Fuss bis base
# riser (front face), foot down to base
q = [P(u0, 0, base), P(u0, 1, base), P(u0, 1, yt), P(u0, 0, yt)]
self.quad(_orient(q, nrm(-1, 0)), side, coll=rc, tint=tint)
# Seitenwaende dieser Stufe (bis base)
# side walls of this step (down to base)
for v, dv in ((0, -1), (1, 1)):
q = [P(u0, v, base), P(u1, v, base), P(u1, v, yt), P(u0, v, yt)]
self.quad(_orient(q, nrm(0, dv)), side, coll=True, tint=tint)
self.solids.append(_aabb([P(u0, 0, base), P(u1, 1, yt)]))
# Rueckwand
# back wall
q = [P(1, 0, base), P(1, 1, base), P(1, 1, y1), P(1, 0, y1)]
self.quad(_orient(q, nrm(1, 0)), side, coll=True, tint=tint)
if walkable:
@@ -335,14 +336,14 @@ class Level:
self.collider(q)
def fence(self, pts, y, height=0.6, thick=0.12, post='holz_dunkel', rail='holz_maser', posts_every=1.0):
"""Zaun (Pfosten + Querlatte) entlang Punktliste [(x,z),...] auf Hoehe y. Mit Kollision."""
"""Fence (posts + rail) along point list [(x,z),...] at height y. With collision."""
for (ax, az), (bx, bz) in zip(pts, pts[1:]):
L = math.hypot(bx - ax, bz - az)
n = max(1, int(round(L / posts_every)))
for i in range(n + 1):
px, pz = ax + (bx - ax) * i / n, az + (bz - az) * i / n
self.box(px - thick, pz - thick, px + thick, pz + thick, y, y + height, top=post, side=post, rot=0)
# Latte als duenner Quader
# rail as a thin cuboid
t = thick * 0.6
yb = y + height * 0.55
if abs(bx - ax) >= abs(bz - az):
@@ -353,7 +354,7 @@ class Level:
solid=False)
def tree(self, x, z, y, height=2.5, crown=1.4, trunk='rinde', leaves='gras_dunkel', trunk_size=None):
"""Einfacher Klotz-Baum (Stamm + zwei Kronen-Wuerfel)."""
"""Simple blocky tree (trunk + two crown cubes)."""
self.pillar(x, z, y, y + height, size=trunk_size or max(0.15, crown * 0.25), side=trunk, top=trunk)
c = crown / 2
self.box(x - c, z - c, x + c, z + c, y + height - 0.2, y + height + crown * 0.7, top=leaves, side=leaves,
@@ -363,15 +364,15 @@ class Level:
side=leaves, coll=True)
def sea(self, x0, z0, x1, z1, y=-2.0, tex='meer', kill=None, step=4.0):
"""Wasser-/Grundflaeche ohne Kollision (grob unterteilt, step) + toedliche Ebene knapp
darueber. kill=(x0,z0,x1,z1): Bereich der Todesebene (Standard: ganze Flaeche, False: keine).
Die Todesebene wird nur in Zellen ohne durchgehenden Boden angelegt."""
"""Water/ground surface without collision (coarsely subdivided, step) + deadly plane just
above it. kill=(x0,z0,x1,z1): area of the death plane (default: whole surface, False: none).
The death plane is only created in cells without continuous floor."""
self._hface(x0, z0, x1, z1, y, tex, True, step, 0, (1, 1, 1), False)
if kill is not False:
self.kill = (y + 0.05,) + tuple(kill or (x0, z0, x1, z1))
def obj(self, path, offset=(0, 0, 0), scale=1.0, tex='stein', coll=True):
"""OBJ-Datei (Spielkoordinaten, y oben). 'usemtl tex_N' bzw. Texturname als Material."""
"""OBJ file (game coordinates, y up). 'usemtl tex_N' or a texture name as material."""
V, cur = [], tex
for line in open(path, encoding='latin1'):
p = line.split()
@@ -391,9 +392,9 @@ class Level:
for k in range(1, len(pts) - 1):
self.quad([pts[0], pts[k], pts[k + 1]], cur, coll=coll)
# ------------------------------------------------------------ Hoehe
# ------------------------------------------------------------ height
def height_at(self, x, z, below=1e9):
"""Hoechste begehbare Flaeche bei (x,z) unterhalb 'below' (oder None)."""
"""Highest walkable surface at (x,z) below 'below' (or None)."""
best = None
for q in self.quads:
if not q.coll:
@@ -413,14 +414,14 @@ class Level:
best = y
return best
# ------------------------------------------------------------ Objekte
# ------------------------------------------------------------ objects
def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False):
"""Objekt platzieren. y=None: auf die hoechste Flaeche bei (x,z) stellen.
rot: Grad (None = wie im Original). params: Liste u32 (None = Original).
wp: 'relative' (Wegpunktkette des Originals mitverschieben, y auf Boden),
'here' (alle Wegpunkte auf das Objekt), 'none' (-1) oder Liste [(x,z[,y[,rot]]),...]."""
"""Place an object. y=None: put it on the highest surface at (x,z).
rot: degrees (None = as in the original). params: list of u32 (None = original).
wp: 'relative' (move the original's waypoint chain along, y snapped to the ground),
'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot]]),...]."""
if name not in self.protos:
raise KeyError('Strat-Typ %r nicht im Basislevel (vorhanden: %s)' % (name, ', '.join(self.types())))
raise KeyError('strat type %r not in base level (available: %s)' % (name, ', '.join(self.types())))
if y is None:
y = self.height_at(x, z)
if y is None:
@@ -430,8 +431,8 @@ class Level:
return d
def player(self, x, z, rot=0, y=None, cam_dist=8.0):
"""Spielerstart. Der erste Wegpunkt der Lama-Kette ist (im Original) die Startposition der
Kamera -> hinter das Lama legen (Abstand cam_dist), die uebrigen auf das Lama."""
"""Player start. The first waypoint of the llama chain is (in the original) the camera's
start position -> put it behind the llama (distance cam_dist), the rest on the llama."""
d = self.strat('Llama', x, z, y, rot, wp='here')
a = math.radians(rot or 0)
cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a)
@@ -453,7 +454,7 @@ class Level:
yy = self.height_at(p[0], p[1])
self.strat(name, p[0], p[1], (yy or 0) + y_off)
# ------------------------------------------------------------ Bauen
# ------------------------------------------------------------ build
def build(self, out, verbose=True):
geo = self._geometry()
models, tiles = geo
@@ -468,20 +469,20 @@ class Level:
if nm is not None and self.name:
G.wad_set(ch, 'NAME', nm[:4] + self.name.encode('latin1') + b'\0')
G.wad_write(out, ch)
# Gegenprobe: MAP parst wieder vollstaendig
# cross-check: MAP parses back completely
chk = M.parse(mp, self.wf, [len(m.verts) for m in models])
assert not chk['rest'] and len(chk['strats']) == len(self._srecs)
if verbose:
nf = sum(len(m.faces) for m in models)
nc = sum(len(m.coll[0]) for m in models)
print('%s: %d Modelle, %d Kacheln, %d Dreiecke, %d Kollisionspolygone, %d Strats, %d Wegpunkte, '
print('%s: %d models, %d tiles, %d triangles, %d collision polygons, %d strats, %d waypoints, '
'TRAK %d KB, WAD %d KB' % (out, len(models), len(tiles), nf, nc, len(self._srecs),
len(self._nodes), len(trak) // 1024, os.path.getsize(out) // 1024))
return out
# -- Beleuchtung
# -- lighting
def _light(self, P, N, tint):
"""P,N: (n,3) Spielkoordinaten; liefert (n,3) Farbe 1.0 = Texturfarbe."""
"""P,N: (n,3) game coordinates; returns (n,3) colour, 1.0 = texture colour."""
s = self.sun
az, el = math.radians(s['azimuth']), math.radians(s['elevation'])
L = np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)])
@@ -491,12 +492,12 @@ class Level:
hemi = (N[:, 1:2] * 0.5 + 0.5)
amb = gnd + (sky - gnd) * hemi
B = np.array(self.solids, float) if self.solids else np.zeros((0, 6))
# Strahlen: Sonne + Halbkugel
# rays: sun + hemisphere
dirs = _hemi_dirs()
shadow = np.ones(len(P))
ao = np.ones(len(P))
CH = 256
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # raeumlich sortieren -> kleine Chunks
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # sort spatially -> small chunks
P, N = P[order], N[order]
res_shadow, res_ao = np.ones(len(P)), np.ones(len(P))
ndl = ndl[order]
@@ -517,7 +518,7 @@ class Level:
lit = ndl[i:i + CH] > 0
hit = _ray_boxes(p, np.broadcast_to(L, p.shape), far, 60.0) if len(far) else np.zeros(len(p), bool)
res_shadow[i:i + CH] = np.where(lit & hit, 0.0, 1.0)
# AO: Richtungen in der Halbkugel um n
# AO: directions in the hemisphere around n
w = np.clip(n @ dirs.T, 0, None) # (c, R)
occ = np.zeros(len(p))
for r in range(len(dirs)):
@@ -536,18 +537,18 @@ class Level:
col = amb * ao[:, None] + sc * (s['strength'] * ndl * sh)[:, None] * (0.6 + 0.4 * ao[:, None])
return col * np.asarray(tint)
# -- Kacheln/Modelle
# -- tiles/models
def _geometry(self):
BAND, B0 = 10.0, -3.0 # Hoehenbaender je Kachel: [-3,7), [7,17), ...
BAND, B0 = 10.0, -3.0 # height bands per tile: [-3,7), [7,17), ...
MARGIN = 0.3
tris_by_tile = {}
coll_by_tile = {}
# Dreiecke (Exportraum) + Vertexfarben vorbereiten
# prepare triangles (export space) + vertex colours
allv, alln, allt = [], [], []
faces = []
for q in self.quads:
pts = q.p
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # aussen
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # outwards
if len(pts) == 4:
tri = [((0, 1, 2), 0x820), ((2, 3, 0), 0x830)]
else:
@@ -566,7 +567,7 @@ class Level:
xs = [a[0] for a in pts]; zs = [a[2] for a in pts]
cy = sum(a[1] for a in pts) / len(pts)
band = math.floor((cy - B0) / BAND)
# Boeden: nur ueberdeckte Zellen; Waende/Schraegen: auch Nachbarzellen (Spielerradius)
# floors: only covered cells; walls/slopes: neighbouring cells too (player radius)
mg = 0.0 if abs(n[1]) > 0.95 else MARGIN
for cx in range(math.floor(min(xs) - mg), math.floor(max(xs) + mg - 1e-6) + 1):
for cz in range(math.floor(min(zs) - mg), math.floor(max(zs) + mg - 1e-6) + 1):
@@ -586,7 +587,7 @@ class Level:
for key in sorted(set(tris_by_tile) | set(coll_by_tile)):
cx, cz, band = key
if not (0 <= cx < self.W and 0 <= cz < self.H):
raise SystemExit('Geometrie ausserhalb des Rasters %dx%d: Zelle %d,%d' % (self.W, self.H, cx, cz))
raise SystemExit('geometry outside the %dx%d grid: cell %d,%d' % (self.W, self.H, cx, cz))
ox, oy, oz = cx + 0.5, B0 + band * BAND + BAND / 2, cz + 0.5
m = G.Model(); m.dirty = True
vmap, cols, nacc = {}, [], []
@@ -629,7 +630,7 @@ class Level:
m.coll[0].append(r)
if not m.faces and not m.coll[0]:
continue
# Box der Kachel muss die Kollisionspolygone umfassen (unbenutzte Hilfsvertices)
# the tile's box must enclose the collision polygons (unused helper vertices)
cpts = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for pts, _ in coll_by_tile.get(key, []) for p in pts]
if cpts:
lo = [min(c[k] for c in cpts) for k in range(3)]
@@ -647,22 +648,22 @@ class Level:
m.verts.append((round(c[0], 5), round(c[1], 5), round(c[2], 5), 0.0, 1.0, 0.0))
cols.append(0x01808080)
if len(m.verts) > 4000:
raise SystemExit('Kachel %s zu gross (%d Vertices)' % (key, len(m.verts)))
if not m.verts: # reine Kollisionskachel braucht keine Vertices
raise SystemExit('tile %s too large (%d vertices)' % (key, len(m.verts)))
if not m.verts: # a collision-only tile needs no vertices
pass
h = _model_key(m)
if h not in cache:
cache[h] = len(models)
models.append(m)
tiles.append(dict(x=ox, y=oy, z=-oz, rot=0, model=cache[h], cols=cols))
# Todesebene (Original-Modell 0 des Dorfes: unsichtbar, Material 0x1e, y_lokal = -2.75)
# death plane (the village's original model 0: invisible, material 0x1e, y_local = -2.75)
if self.kill:
ky, x0, z0, x1, z1 = self.kill
km = self.trak0[0]
kill_model = len(models)
models.append(km)
ty = ky + 2.75
# nur Zellen ohne begehbaren Boden (spart Kacheln; unter Boden faellt man nicht)
# only cells without walkable floor (saves tiles; you can't fall below a floor)
covered = self._covered_cells()
for cx in range(max(0, math.floor(x0)), min(self.W, math.ceil(x1))):
for cz in range(max(0, math.floor(z0)), min(self.H, math.ceil(z1))):
@@ -673,7 +674,7 @@ class Level:
return models, tiles
def _covered_cells(self):
"""Zellen, die vollstaendig von nach oben zeigender Kollision (Boden) bedeckt sind."""
"""Cells fully covered by upward-facing collision (floor)."""
area = {}
for q in self.quads:
if not q.coll or len(q.p) != 4:
@@ -729,7 +730,7 @@ class Level:
tab[0] = 0xffffffff
m['tab32'] = tuple(tab)
m['grid'] = (0,) * (self.W * self.H)
# Strats + Wegpunkte
# strats + waypoints
pool = list(self.pool0)
nodes = []
recs = []
@@ -747,13 +748,13 @@ class Level:
else:
prm = self.pool0[proto[8]:proto[8] + proto[7]] if proto[7] else []
if len(prm) != proto[7]:
raise ValueError('%s braucht %d Parameter, nicht %d' % (d['name'], proto[7], len(prm)))
raise ValueError('%s needs %d parameters, not %d' % (d['name'], proto[7], len(prm)))
if prm:
s[8] = len(pool)
pool.extend(int(v) & 0xffffffff for v in prm)
else:
s[8] = -1
# Wegpunkte
# waypoints
wp = d['wp']
chain = []
if proto[13] >= 0:
@@ -770,7 +771,7 @@ class Level:
px, pz = p[0], p[1]
py = p[2] if len(p) > 2 and p[2] is not None else (self.height_at(px, pz) or d['y'])
pr = deg(p[3]) if len(p) > 3 else s[1]
# Zusatzfelder (Wartezeit/Modus o.ae.) vom Original-Wegpunkt uebernehmen
# copy extra fields (wait time/mode etc.) from the original waypoint
a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0)
new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_])
elif chain and wp != 'none':
@@ -809,7 +810,7 @@ class Level:
return M.build(m, self.wf)
# ---------------------------------------------------------------- Hilfsfunktionen
# ---------------------------------------------------------------- helpers
def _s32(v):
return v - (1 << 32) if v >= 1 << 31 else v
@@ -839,12 +840,12 @@ def _wall_normal(a, b):
def _orient(p, normal):
"""Reihenfolge so, dass die Flaeche nach 'normal' (Spielraum) zeigt. Die Engine rechnet im
Exportraum (z gespiegelt): dort muss (p1-p0)x(p2-p0) nach aussen zeigen, im Spielraum also
nach innen (Spiegelung dreht den Umlaufsinn)."""
"""Order the points so the surface faces 'normal' (game space). The engine works in
export space (z mirrored): there (p1-p0)x(p2-p0) must point outwards, i.e. inwards in
game space (mirroring reverses the winding)."""
n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0]))
if sum(a * b for a, b in zip(n, normal)) > 0:
# spiegeln, Ecke 0 bleibt unten-links -> [1,0,3,2]
# mirror, corner 0 stays bottom-left -> [1,0,3,2]
p = [p[1], p[0], p[3], p[2]] if len(p) == 4 else [p[1], p[0], p[2]]
return p
@@ -892,7 +893,7 @@ def _hemi_dirs():
global _HEMI
if _HEMI is None:
d = []
# Fibonacci-Kugel, 24 Richtungen
# Fibonacci sphere, 24 directions
n = 24
for i in range(n):
y = 1 - 2 * (i + 0.5) / n
@@ -904,7 +905,7 @@ def _hemi_dirs():
def _ray_boxes(p, d, B, tmax):
"""p,d: (n,3); B: (b,6). Liefert bool (n,): trifft irgendeine Box mit 0<t<tmax."""
"""p,d: (n,3); B: (b,6). Returns bool (n,): hits any box with 0<t<tmax."""
if len(p) == 0:
return np.zeros(0, bool)
with np.errstate(divide='ignore', invalid='ignore'):
@@ -931,17 +932,17 @@ def main():
ap.add_argument('level', nargs='?')
ap.add_argument('out', nargs='?')
ap.add_argument('--base', default=BASE)
ap.add_argument('--install', action='store_true', help='als TestLevel (T1L9M001.wad) installieren')
ap.add_argument('--install', action='store_true', help='install as TestLevel (T1L9M001.wad)')
ap.add_argument('--types', action='store_true')
ap.add_argument('--textures', metavar='PNG')
ap.add_argument('--set', action='append', default=[], help='Variable fuer das Levelskript, z.B. --set teil=a')
ap.add_argument('--set', action='append', default=[], help='variable for the level script, e.g. --set teil=a')
a = ap.parse_args()
if a.types:
L = Level(a.base)
for n in L.types():
p = L.protos[n][0]
print('%-28s Varianten %d Parameter %d Wegpunkte %s' % (n, len(L.protos[n]), p[7],
'ja' if p[13] >= 0 else '-'))
print('%-28s variants %d parameters %d waypoints %s' % (n, len(L.protos[n]), p[7],
'yes' if p[13] >= 0 else '-'))
return
if a.textures:
import subprocess
@@ -951,7 +952,7 @@ def main():
print('%4d %s' % (i, names[i]))
return
if not (a.level and a.out):
ap.error('LEVEL.py und OUT.wad angeben')
ap.error('specify LEVEL.py and OUT.wad')
mod = load_script(a.level)
opts = dict(kv.split('=', 1) for kv in a.set)
L = Level(a.base, **getattr(mod, 'LEVEL_ARGS', {}))
@@ -965,14 +966,14 @@ def main():
if not os.path.exists(MODWAD + '.orig'):
shutil.copy2(MODWAD, MODWAD + '.orig')
shutil.copyfile(a.out, MODWAD)
print('installiert ->', MODWAD)
print('installed ->', MODWAD)
kapitel_links()
def kapitel_links(n=49):
"""Kapitel 2..n von Welt 9 als Symlinks auf Kapitel 1 (WAD + Ladebild T1%d%d.COR) in der Mod.
Grund: jeder Ausgang (LevelDoor) schaltet im hoechsten freien Kapitel das naechste frei;
fehlt dessen WAD, stuerzt das Spiel bei der Auswahl ab ("Couldnt load wad")."""
"""Chapters 2..n of world 9 as symlinks to chapter 1 (WAD + loading image T1%d%d.COR) in the mod.
Reason: every exit (LevelDoor) in the highest unlocked chapter unlocks the next one;
if its WAD is missing, the game crashes on selecting it ("Couldnt load wad")."""
wd = os.path.dirname(MODWAD)
mod = os.path.dirname(wd)
neu = 0
@@ -984,7 +985,7 @@ def kapitel_links(n=49):
neu += 1
if neu:
import subprocess
print('%d Kapitel-Links angelegt -> lama-mod an' % neu)
print('%d chapter links created -> lama-mod an' % neu)
subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True,
stdout=subprocess.DEVNULL)
+25 -25
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""Testlevel bauen: in einem leeren Bereich des MAP-Rasters eine Bodenplatte aus Kopien
eines flachen Boden-Tiles anlegen, darauf je einen Strat (Objekt) pro Typ im Raster
aufstellen, den Spieler (Strat "Llama") an den Anfang setzen und einen kleinen
Parkour (schwebende Bodenplatten, treppauf) anhängen. Geometrie/Texturen/Strat-Code
(TRAK/STPC/TEXT …) bleiben die des Quell-Levels; nur der MAP-Chunk wird neu geschrieben.
"""Build a test level: in an empty area of the MAP grid, lay a floor plate made of copies
of a flat floor tile, place one strat (object) per type on it in a grid, put the
player (strat "Llama") at the start and append a small parkour (floating floor
plates, going up). Geometry/textures/strat code (TRAK/STPC/TEXT ...) stay those of
the source level; only the MAP chunk is rewritten.
make_testlevel.py quelle.wad ziel.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
make_testlevel.py source.wad target.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
--keep-original : Original-Strats behalten (sonst nur Spieler + Raster + Cheats)
Strat-Typen werden per Name erkannt (strats.py); Parameter/Typ-Felder werden von der
ersten Original-Instanz kopiert, Wegpunkt-Index bleibt (zeigt ggf. auf Original-Wegpunkte).
--keep-original : keep the original strats (otherwise only player + grid + cheats)
Strat types are recognised by name (strats.py); parameter/type fields are copied from the
first original instance, the waypoint index is kept (may point at original waypoints).
"""
import argparse
import os
@@ -23,7 +23,7 @@ import strats as S # noqa: E402
import wadtool # noqa: E402
FX = 4096
# nicht ins Raster: Kamera/Cutscene/Trigger-Zonen, Spieler selbst
# not in the grid: camera/cutscene/trigger zones, the player itself
SKIP_PREFIX = ('CutScene', 'Camera', 'MainMenu', 'Options', 'Title', 'TheNewGroove', 'Force',
'EndGameTrigger', 'RiverTriggerBox', 'FallOffNoHurt')
KEEP_ORIGINAL = ('CheatTeleport', 'CheatCharge')
@@ -45,7 +45,7 @@ def trak_models(trak):
def find_floor_tile(m, models, force=None):
"""Häufigstes Tile, dessen Modell eine flache 1x1-Fläche ist."""
"""Most frequent tile whose model is a flat 1x1 surface."""
from collections import Counter
flat = set()
for i, vs in enumerate(models):
@@ -54,7 +54,7 @@ def find_floor_tile(m, models, force=None):
min(v[2] for v in vs) <= -0.49 and max(v[2] for v in vs) >= 0.49:
flat.add(i)
c = Counter(mi for t, mi in zip(m['tiles'], m['tilemodel']) if mi in flat and not t[5])
# begehbare Böden liegen bei Modell-y = +0.25 (Strats stehen auf Tile-y + 0.25)
# walkable floors sit at model y = +0.25 (strats stand on tile y + 0.25)
best = [k for k, _ in c.most_common() if abs(models[k][0][1] - 0.25) < 1e-3]
mdl = force if force is not None else (best or [c.most_common(1)[0][0]])[0]
idx = next(i for i, (t, mi) in enumerate(zip(m['tiles'], m['tilemodel'])) if mi == mdl and not t[5])
@@ -68,7 +68,7 @@ def empty_rect(m, w, h):
if all((x, z) not in occ for x in range(x0 - 1, x0 + w + 1)
for z in range(z0 - 1, z0 + h + 1)):
return x0, z0
raise SystemExit(f'kein freier Bereich {w}x{h} im Raster {m["W"]}x{m["H"]}')
raise SystemExit(f'no free {w}x{h} area in the {m["W"]}x{m["H"]} grid')
def main():
@@ -78,8 +78,8 @@ def main():
ap.add_argument('--spacing', type=int, default=3)
ap.add_argument('--keep-original', action='store_true')
ap.add_argument('--name')
ap.add_argument('--floor-model', type=int, help='TRAK-Modellindex für Bodenplatten')
ap.add_argument('--y', type=float, default=None, help='Höhe der Bodenplatte (Tile-y)')
ap.add_argument('--floor-model', type=int, help='TRAK model index for floor plates')
ap.add_argument('--y', type=float, default=None, help='height of the floor plate (tile y)')
a = ap.parse_args()
ch, wf, m, lo, types = S.level_types(a.src)
@@ -90,13 +90,13 @@ def main():
cols = 6
rows = (len(names) + cols - 1) // cols
sp = a.spacing
gw, gh = cols * sp + 2, rows * sp + 4 # Raster + Startstreifen
pk = 14 # Parkour-Streifen
gw, gh = cols * sp + 2, rows * sp + 4 # grid + start strip
pk = 14 # parkour strip
x0, z0 = empty_rect(m, gw + pk, gh)
base = m['tiles'][ftile]
y = a.y if a.y is not None else base[1]
print(f'Boden-Tile #{ftile} Modell {fmodel} (Fläche y+{fsurf}), Bereich x{x0}..{x0 + gw + pk} '
f'z{z0}..{z0 + gh}, Höhe {y}')
print(f'floor tile #{ftile} model {fmodel} (surface y+{fsurf}), area x{x0}..{x0 + gw + pk} '
f'z{z0}..{z0 + gh}, height {y}')
def add_tile(cx, cz, ty):
m['tiles'].append((cx + 0.5, ty, -(cz + 0.5), base[3], fmodel, 0))
@@ -107,7 +107,7 @@ def main():
for cx in range(x0, x0 + gw):
for cz in range(z0, z0 + gh):
add_tile(cx, cz, y)
# Parkour: Trittplatten mit Lücken, je +0.5 höher, dann Zielplattform 3x3
# parkour: stepping plates with gaps, each +0.5 higher, then a 3x3 goal platform
px, pz, py = x0 + gw + 1, z0 + 1, y
steps = []
for i in range(6):
@@ -151,7 +151,7 @@ def main():
s[8] = len(m['list1'])
m['list1'].extend(m['_list1_orig'][pi:pi + s[7]])
new.append(tuple(s))
# Spieler an den Start (Rand des Rasters, Blick ins Raster)
# player at the start (edge of the grid, facing into it)
if PLAYER in types:
new = [s for s in new if S.strat_name(ch['STPC'], s[6], lo) != PLAYER]
new.insert(0, srec(types[PLAYER][0], x0 + 1, z0 + 1, y, ry=types[PLAYER][0][1]))
@@ -161,7 +161,7 @@ def main():
cz = z0 + 3 + (k // cols) * sp
new.append(srec(types[nm][0], cx, cz, y))
placed.append((nm, cx, cz))
# Münzen auf dem Parkour, Ausgang (LevelDoor) auf der Zielplattform
# coins on the parkour, exit (LevelDoor) on the goal platform
if 'Coin' in types:
for (cx, cz, ty) in steps:
new.append(srec(types['Coin'][0], cx, cz, ty + 0.5))
@@ -170,7 +170,7 @@ def main():
m['strats'] = new
m['strat_x'] = max(m['strat_x'], len(new) + 64)
m['vcol'] = b''.join(m['vcol_split'])
for i, t in enumerate(m['tiles']): # Konsistenz
for i, t in enumerate(m['tiles']): # consistency
assert 0 <= int(t[0] - 0.5) < m['W'] and 0 <= int(-t[2] - 0.5) < m['H'], (i, t)
data = M.build(m, wf)
@@ -183,9 +183,9 @@ def main():
c = wadtool.get(wch, 'NAME')
c[1] = c[1][:4] + a.name.encode('latin1') + b'\0'
wadtool.write(a.dst, wch)
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} Typen im Raster) -> {a.dst}')
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} types in the grid) -> {a.dst}')
for nm, cx, cz in placed:
print(f' {nm:30s} Zelle ({cx},{cz})')
print(f' {nm:30s} cell ({cx},{cz})')
if __name__ == '__main__':
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""MAP-Chunk eines Level-WADs ausgeben.
"""Dump the MAP chunk of a level WAD.
map_dump.py level.wad [--strats] [--tiles] [--lights] [--check]
"""
import os
+14 -14
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""MAP-Chunk von Level-WADs parsen/schreiben (Lama/Croc-2-Engine, PC).
"""Parse/write the MAP chunk of level WADs (Lama/Croc 2 engine, PC).
Nachgebaut aus groove.exe FUN_0042ac50 (MAP-Lader). Hängt ab von
WFPC-Flags (u32 im WFPC-Chunk, Bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
TRAK-Modell-Vertexzahlen (Vertexfarben-Block).
Siehe notes/objects.md.
Reimplemented from groove.exe FUN_0042ac50 (MAP loader). Depends on
WFPC flags (u32 in the WFPC chunk, bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
TRAK model vertex counts (vertex colour block).
See notes/objects.md.
"""
import struct
@@ -27,16 +27,16 @@ class R:
def trak_vertcounts(trak):
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = Vertexzahl), dann Daten."""
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = vertex count), then data."""
n = struct.unpack_from('<I', trak, 0)[0]
return [struct.unpack_from('<H', trak, 4 + i * 0x84 + 0x6c)[0] for i in range(n)]
# Feldnamen
# field names
TILE = ('x', 'y', 'z', 'rot', 'b', 'alt') # 3 f32 + 3 u32
LIGHT = '11I3IH4I2IHHI' # 90 Byte
PATHNODE = 'IIHHH5I' # 34 Byte
STRAT = '3H3iIIi4Iiih' + 'H' # 58 Byte (s.u.)
LIGHT = '11I3IH4I2IHHI' # 90 bytes
PATHNODE = 'IIHHH5I' # 34 bytes
STRAT = '3H3iIIi4Iiih' + 'H' # 58 bytes (see below)
def parse(d, wfpc, vcounts):
@@ -45,7 +45,7 @@ def parse(d, wfpc, vcounts):
nt, W, H = r.f('3I')
m['W'], m['H'] = W, H
m['tiles'] = [r.f('3f3I') for _ in range(nt)]
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # von Strats per Index referenziert
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # referenced by strats via index
n = r.u32(); m['lights'] = [r.f(LIGHT) for _ in range(n)]
n = r.u32(); m['nodes'] = [r.f(PATHNODE) for _ in range(n)]
m['tab32'] = r.f('64I') # 32 x (u32,u32) -> DAT_006da350
@@ -56,11 +56,11 @@ def parse(d, wfpc, vcounts):
if wfpc & 0x10000:
n = r.u32(); alts = [r.f('5I') for _ in range(n)]
m['alts'] = alts
# Vertexfarben: je Tile nv u32 (Byte3 = Frames), ggf. weitere Frames; dann Alt-Modell
# vertex colours: nv u32 per tile (byte 3 = frames), more frames if any; then alt model
altidx = [i for i, t in enumerate(m['tiles']) if t[5]]
altmap = dict(zip(altidx, alts))
start = r.o
split = [] # Vertexfarben-Bytes je Tile (inkl. Alt-Modell)
split = [] # vertex colour bytes per tile (incl. alt model)
for i in range(nt):
t0 = r.o
for mdl in [m['tilemodel'][i]] + ([altmap[i][4]] if i in altmap else []):
@@ -96,7 +96,7 @@ def parse(d, wfpc, vcounts):
def build(m, wfpc):
"""Gegenstück zu parse(); m['vcol'] muss zu tilemodel/alts passen (Rohbytes)."""
"""Counterpart of parse(); m['vcol'] must match tilemodel/alts (raw bytes)."""
P = struct.pack
o = [P('<3I', len(m['tiles']), m['W'], m['H'])]
o += [P('<3f3I', *t) for t in m['tiles']]
+8 -8
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Packt ein Bild als Argonaut-.cor (Gegenstück zu cor2png.py).
"""Packs an image as Argonaut .cor (counterpart of cor2png.py).
Das Bild wird auf 640x480 RGB gebracht und mit dem LZ-Schema des Spiels
komprimiert:
n < 0x80 -> n Bytes Rohdaten folgen (n = 1..127)
1LLL oooo oooooooo -> kopiere L+3 Bytes (3..10) ab o Bytes (1..4095) zurück
Aufruf: png2cor.py eingabe.png ausgabe.cor (benötigt Pillow)
The image is converted to 640x480 RGB and compressed with the game's LZ
scheme:
n < 0x80 -> n bytes of raw data follow (n = 1..127)
1LLL oooo oooooooo -> copy L+3 bytes (3..10) from o bytes (1..4095) back
Usage: png2cor.py input.png output.cor (requires Pillow)
"""
import struct
import sys
@@ -17,7 +17,7 @@ MAXOFF, MINLEN, MAXLEN = 0x0FFF, 3, 10
def pack(raw):
out = bytearray()
lit = bytearray()
last = {} # 3-Byte-Präfix -> Liste letzter Positionen
last = {} # 3-byte prefix -> list of recent positions
def flush():
for k in range(0, len(lit), 127):
@@ -65,4 +65,4 @@ if __name__ == '__main__':
img = Image.open(sys.argv[1]).convert('RGB').resize((640, 480), Image.LANCZOS)
data = pack(img.tobytes())
open(sys.argv[2], 'wb').write(data)
print(f'{sys.argv[2]}: {len(data)} Bytes')
print(f'{sys.argv[2]}: {len(data)} bytes')
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""STPC grob durchlaufen (nur Grenzen): Modelle, Animsets, Flag-0x100-Block, dann Strat-Code.
Nachgebaut aus FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
"""Rough STPC walk (boundaries only): models, animsets, flag-0x100 block, then strat code.
Reimplemented from FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
import struct
@@ -52,11 +52,11 @@ def animsets(d, o, n):
def walk(d, wfpc):
n = u32(d, 0)
o, mdl = 4, []
for _ in range(n): # FUN_0041f770(&p,1,..) je Modell: Header direkt gefolgt von Daten
for _ in range(n): # FUN_0041f770(&p,1,..) per model: header directly followed by data
o, mm = models(d, o, 1)
mdl += mm
na = u32(d, o); o += 4
for _ in range(na): # FUN_0041f8b0(&p,1,..) je Animset
for _ in range(na): # FUN_0041f8b0(&p,1,..) per animset
o = animsets(d, o, 1)
extra = []
if wfpc & 0x100:
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tabelle Strat-Typ x Level (Instanzzahl) über alle Level-WADs.
"""Table strat type x level (instance count) across all level WADs.
strat_table.py wads_dir > table.md"""
import glob
import os
@@ -25,11 +25,11 @@ for w in wads:
for o, nm in strats.all_named(ch['STPC'], lo):
avail[nm].add(name)
short = [l[1:].replace('i0m', '/').replace('l', '').replace('m00', '/') for l in lv]
print('| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |')
print('| Type | Level (MAP instances) | Σ | in code of n levels |')
print('|---|---|---|---|')
for nm in sorted(tab, key=lambda k: (-len(tab[k]), k)):
cells = ' '.join(f'{l[1:]}:{tab[nm][l]}' for l in lv if l in tab[nm])
base = nm.replace('(spawn)', '')
print(f'| {nm} | {cells} | {sum(tab[nm].values())} | {len(avail.get(base, ()))} |')
print()
print('Nur im Code (nie direkt in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
print('Only in code (never placed directly in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Strat-Typen (Objekttypen) eines Level-WADs: Namen aus STPC-Strat-Code, MAP-Instanzen.
"""Strat types (object types) of a level WAD: names from the STPC strat code, MAP instances.
Strat-Code liegt am Ende von STPC (nach Modellen/Animsets). Bytecode = u32-Wörter
(low16 = Opcode, high16 = Kurzoperand). Ein Strat beginnt (fast immer) mit
... 0xd4, 0xea, <s32 rel> -> Name = C-String bei (Adresse nach rel) + rel
Strat code sits at the end of STPC (after models/animsets). Bytecode = u32 words
(low16 = opcode, high16 = short operand). A strat (almost always) starts with
... 0xd4, 0xea, <s32 rel> -> name = C string at (address after rel) + rel
strats.py level.wad [--all]
"""
import os
@@ -21,7 +21,7 @@ def cstr(d, o):
def strat_name(st, entry, lo):
"""Name eines MAP-Einstiegs: Spawn-Stub (b2 <abs> e0 ...) -> Ziel; sonst erstes d4/ea ab entry."""
"""Name of a MAP entry: spawn stub (b2 <abs> e0 ...) -> target; otherwise first d4/ea from entry."""
w = struct.unpack_from('<8I', st, entry)
for k in range(5):
if w[k] == 0xb2 and w[k + 2] == 0xe0 and lo <= w[k + 1] < len(st):
@@ -41,7 +41,7 @@ def strat_name(st, entry, lo):
def all_named(st, lo):
"""Alle d4/ea-Namensstellen im Code-Bereich (Wort-Raster ab lo)."""
"""All d4/ea name sites in the code region (word grid from lo)."""
out = []
for o in range(lo, len(st) - 12, 4):
a, b = struct.unpack_from('<II', st, o)
@@ -74,6 +74,6 @@ if __name__ == '__main__':
f'flags={s[11]:#x} extra={s[12]:#x}')
if '--all' in sys.argv:
used = {s[6] for v in types.values() for s in v}
print('-- alle benannten Strats im Code (Namens-Op-Adresse):')
print('-- all named strats in the code (name op address):')
for o, n in all_named(ch['STPC'], lo):
print(f' {o:#x} {n}')
+8 -8
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Textur-Kontaktabzug: alle (oder nur die von der Welt-Geometrie benutzten) Texturen eines
Level-WADs als Raster mit Index-Beschriftung -> zum Aussuchen von Texturen fuer levelbuild.py.
"""Texture contact sheet: all textures of a level WAD (or only those used by the world
geometry) as a grid labelled with their index -> for picking textures for levelbuild.py.
texsheet.py level.wad out.png [--all] [--size 64] [--cols 16] [--only 12,40,77]
Ohne --all: nur Texturen, die in TRAK (Welt-Kacheln) vorkommen, sortiert nach Haeufigkeit.
Beschriftung: Index, darunter Nutzung O=oben (Boden) / S=Seite (Wand) / U=unten, Groesse in px.
Flachfarben (Flag Bit0) werden als Farbfeld gezeigt ("F").
Without --all: only textures that occur in TRAK (world tiles), sorted by frequency.
Label: index, below it usage O=top (floor) / S=side (wall) / U=bottom, size in px.
Flat colours (flag bit 0) are shown as a colour swatch ("F").
"""
import argparse
import collections
@@ -43,7 +43,7 @@ def main():
ap.add_argument('--all', action='store_true')
ap.add_argument('--size', type=int, default=64)
ap.add_argument('--cols', type=int, default=16)
ap.add_argument('--only', help='kommagetrennte Indizes')
ap.add_argument('--only', help='comma-separated indices')
a = ap.parse_args()
ch = G.wad_read(a.wad)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
@@ -64,7 +64,7 @@ def main():
for k, i in enumerate(idx):
t = texs[i]
x, y = (k % C) * cw + 4, (k // C) * chh + 2
# Schachbrett fuer Transparenz
# checkerboard for transparency
bg = Image.new('RGB', (S, S), (90, 90, 90))
bd = ImageDraw.Draw(bg)
for yy in range(0, S, 8):
@@ -82,7 +82,7 @@ def main():
d.text((x + S // 2 + 4, y + S + 1), tag, fill=(140, 220, 255))
d.text((x, y + S + 14), f'{w}x{h}', fill=(170, 170, 170))
sheet.save(a.out)
print(f'{a.out}: {len(idx)} Texturen, {sheet.size[0]}x{sheet.size[1]}')
print(f'{a.out}: {len(idx)} textures, {sheet.size[0]}x{sheet.size[1]}')
if __name__ == '__main__':
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""TEXT-Chunk -> PNG-Seiten (+ texinfo.txt) und zurück.
"""TEXT chunk -> PNG pages (+ texinfo.txt) and back.
text_export.py export level.wad outdir # pageNN_fXX.png, texinfo.txt
text_export.py import level.wad outdir neu.wad # geänderte PNGs zurückschreiben
text_export.py import level.wad outdir new.wad # write modified PNGs back
"""
import glob
import os
@@ -33,7 +33,7 @@ def main():
f.write('# idx flags page clut x0 x1 y0 y1\n')
for i, e in enumerate(T.tex_entries(t['tail'])):
f.write(f'{i} {e[0]:#06x} {e[1]} {e[2]:#04x} {e[3]} {e[4]} {e[5]} {e[6]}\n')
print(f'{len(t["pages"])} Seiten, {len(T.tex_entries(t["tail"]))} Texturen -> {out}')
print(f'{len(t["pages"])} pages, {len(T.tex_entries(t["tail"]))} textures -> {out}')
elif cmd == 'import':
for fn in glob.glob(f'{out}/page*.png'):
i = int(re.search(r'page(\d+)', fn).group(1))
@@ -41,13 +41,13 @@ def main():
img = np.asarray(Image.open(fn).convert('RGBA'))
assert img.shape[:2] == (p['h'], p['w']), fn
new = T.rgba_to_abgr1555(img)
# unveränderte Pixel exakt erhalten (Rundung 8->5 Bit verlustfrei rückwärts)
# keep unchanged pixels exactly (8->5 bit rounding is not lossless in reverse)
old = T.abgr1555_to_rgba(p['px'])
same = (old == img).all(-1)
p['px'] = np.where(same, p['px'], new).astype(np.uint16)
tc[1] = T.build(t)
wadtool.write(sys.argv[4], ch)
print('geschrieben:', sys.argv[4])
print('written:', sys.argv[4])
if __name__ == '__main__':
+16 -16
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""TEXT-Chunk (Texturen) von Level-WADs lesen/schreiben.
"""Read/write the TEXT chunk (textures) of level WADs.
Aufbau (aus groove.exe 0x4067b0):
Layout (from groove.exe 0x4067b0):
u32 nPal; nPal x { u32 id, u32 n, u16 col[n], u8 extra[n] }
u32 nPages; nPages x { u32 flags, u32 w, u32 h, Daten }
flags==0 : w*h Byte (8-Bit, Palette)
flags&0x80 : u32 Länge, RLE16 (s16 n: n>0 -> n Literal-u16, n<0 -> nächstes u16 (-n)-mal)
sonst : w*h u16 roh
u32 nTex; Rest = Textur-Einträge (8 Byte) + Anhang (siehe notes/textures.md)
u32 nPages; nPages x { u32 flags, u32 w, u32 h, data }
flags==0 : w*h bytes (8-bit, palette)
flags&0x80 : u32 length, RLE16 (s16 n: n>0 -> n literal u16, n<0 -> next u16 repeated -n times)
otherwise : w*h u16 raw
u32 nTex; rest = texture entries (8 bytes) + appendix (see notes/textures.md)
"""
import struct
import numpy as np
@@ -33,10 +33,10 @@ def rle_encode(px):
j = i
while j + 1 < N and px[j + 1] == px[i] and j - i < 32766:
j += 1
if j > i: # Lauf >= 2
if j > i: # run >= 2
out += [(-(j - i + 1)) & 0xffff, int(px[i])]; i = j + 1
continue
k = i # Literale bis zum nächsten Lauf
k = i # literals up to the next run
while k < N and k - i < 32767 and not (k + 1 < N and px[k + 1] == px[k]):
k += 1
out += [k - i] + [int(x) for x in px[i:k]]; i = k
@@ -85,9 +85,9 @@ def build(t, compress=True):
def abgr1555_to_rgba(px):
"""PS1-Format: Bits 0-4 R, 5-9 G, 10-14 B, Bit 15 = STP.
0x0000 = durchsichtig (Alpha 0). STP=1 -> Alpha 255, STP=0 (Pixel != 0) -> Alpha 128
(auf Seiten mit Blendmodus = halbtransparent)."""
"""PS1 format: bits 0-4 R, 5-9 G, 10-14 B, bit 15 = STP.
0x0000 = transparent (alpha 0). STP=1 -> alpha 255, STP=0 (pixel != 0) -> alpha 128
(semi-transparent on pages with a blend mode)."""
px = px.astype(np.uint32)
r = (px & 31) * 255 // 31
g = ((px >> 5) & 31) * 255 // 31
@@ -97,13 +97,13 @@ def abgr1555_to_rgba(px):
def rgba_to_abgr1555(img):
"""Umkehrung: Alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (oder RGB-Bild) -> STP=1."""
"""Inverse: alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (or RGB image) -> STP=1."""
img = np.asarray(img).astype(np.uint32)
v = ((img[..., 2] >> 3) << 10) | ((img[..., 1] >> 3) << 5) | (img[..., 0] >> 3)
if img.shape[-1] == 4:
a = img[..., 3]
v = np.where(a >= 192, v | 0x8000, v)
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # fast schwarz statt durchsichtig
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # near-black instead of transparent
v = np.where(a < 64, 0, v)
else:
v = v | 0x8000
@@ -111,7 +111,7 @@ def rgba_to_abgr1555(img):
def tex_entries(tail):
"""Textur-Einträge (Faces referenzieren diese Indizes): u16 flags, u8 page, u8 clut(0xff),
u8 x0, u8 x1, u8 y0, u8 y1 (inklusive Kanten)."""
"""Texture entries (faces reference these indices): u16 flags, u8 page, u8 clut(0xff),
u8 x0, u8 x1, u8 y0, u8 y1 (edges inclusive)."""
n = struct.unpack_from('<I', tail, 0)[0]
return [struct.unpack_from('<H6B', tail, 4 + 8 * i) for i in range(n)]
+2 -1
View File
@@ -1,4 +1,5 @@
# label.py IN.png LABELS.json OUT.jpg "Titel" [breite] -> beschriftete JPG-Vorschau
# label.py IN.png LABELS.json OUT.jpg "Title" [width] -> labelled JPG preview
# (label names Start/Galerie/Gehege/Parkour/Ziel are the keys written by render_level.py)
import sys, json
from PIL import Image, ImageDraw, ImageFont
src, lj, out, title = sys.argv[1:5]
+14 -14
View File
@@ -1,5 +1,5 @@
# Blender 3.0 (headless): level.obj aus geom_export (v x y z r g b, vt, usemtl page_NN/flat_N) laden,
# in Spielkoordinaten (Blender x = Spiel-x, y = -Spiel-z, z = Spiel-y) rendern.
# Blender 3.0 (headless): load level.obj from geom_export (v x y z r g b, vt, usemtl page_NN/flat_N),
# render in game coordinates (Blender x = game x, y = -game z, z = game y).
# blender -b -P render_level.py -- OBJDIR OUTPREFIX [samples] [scale%]
import bpy, sys, os, json, math
from mathutils import Vector
@@ -50,10 +50,10 @@ def make_mat(name, d):
else:
kd = d.get('kd', (1, 1, 1))
mul.inputs[1].default_value = (*kd, 1)
if kd[2] > 0.5 and kd[0] < 0.3: # Meer
if kd[2] > 0.5 and kd[0] < 0.3: # sea
bsdf.inputs['Roughness'].default_value = 0.25
bsdf.inputs['Specular'].default_value = 0.5
# leicht selbstleuchtend, damit die eingebackene Beleuchtung (Vertexfarben) sichtbar bleibt
# slightly emissive so the baked lighting (vertex colours) stays visible
Lk.new(mul.outputs[0], bsdf.inputs['Base Color'])
Lk.new(mul.outputs[0], bsdf.inputs['Emission'])
bsdf.inputs['Emission Strength'].default_value = 0.25
@@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
if ln.startswith('v '):
p = ln.split()
x, y, z = float(p[1]), float(p[2]), float(p[3])
V.append((x, z, y)) # Spiel (x, y, -z) -> Blender (x, -spiel_z, y) = (x, z_obj, y_obj)
V.append((x, z, y)) # game (x, y, -z) -> Blender (x, -game_z, y) = (x, z_obj, y_obj)
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
elif ln.startswith('vt '):
p = ln.split(); VT.append((float(p[1]), float(p[2])))
@@ -81,7 +81,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
q = t.split('/')
vi.append(int(q[0]) - 1)
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1)
faces.append((vi[::-1], ti[::-1], mat)) # Spiegelung -> Umlaufsinn umdrehen
faces.append((vi[::-1], ti[::-1], mat)) # mirroring -> reverse winding
matnames = sorted(set(f[2] for f in faces))
mesh = bpy.data.meshes.new('level')
@@ -103,7 +103,7 @@ ob = bpy.data.objects.new('level', mesh)
sc.collection.objects.link(ob)
# --------------------------------------------------------------- Objekt-Marker (Strats aus strats.json)
# --------------------------------------------------------------- object markers (strats from strats.json)
def flatmat(name, rgb, emit=0.6):
m = bpy.data.materials.new(name); m.use_nodes = True
b = m.node_tree.nodes['Principled BSDF']
@@ -143,7 +143,7 @@ if sj and os.path.exists(sj):
mat = RM[r]
o = bpy.context.active_object; o.data.materials.append(mat)
# --------------------------------------------------------------- Licht, Welt
# --------------------------------------------------------------- light, world
w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True
bg = w.node_tree.nodes['Background']
bg.inputs[0].default_value = (0.55, 0.72, 0.95, 1); bg.inputs[1].default_value = 0.9
@@ -151,7 +151,7 @@ sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 2.6; sun.angle = math.radi
so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
so.rotation_euler = (math.radians(42), 0, math.radians(-140))
# Cycles liefert mit dem Debian-Blender 3.0.1 nur Schwarz -> Eevee (braucht X: eigenes Xvfb, z. B. :7)
# Cycles renders only black with Debian Blender 3.0.1 -> Eevee (needs X: a separate Xvfb, e.g. :7)
sc.render.engine = 'BLENDER_EEVEE'
ee = sc.eevee
ee.taa_render_samples = samples
@@ -164,9 +164,9 @@ try:
sc.view_settings.view_transform = 'Filmic'
sc.view_settings.look = 'Medium High Contrast'
except TypeError:
pass # Debian-Blender ohne OCIO-Konfiguration: nur 'Standard'
pass # Debian Blender without OCIO config: only 'Standard'
# Bereiche (Spielkoordinaten x, z, y) fuer die Beschriftung
# areas (game coordinates x, z, y) for the labels; keys are the label text (German, as in the level)
LABELS = {'Start': (18, 15.5, 2.2), 'Galerie': (23.5, 26.5, 1.2), 'Gehege': (37.5, 25.4, 1.2),
'Parkour': (33.0, 15.5, 2.6), 'Ziel': (50.0, 15.5, 6.0)}
@@ -195,9 +195,9 @@ def shoot(name, loc, look, lens=35, ortho=None, res=(1600, 1000)):
views = os.environ.get('VIEWS', 'schraeg,oben').split(',')
if 'schraeg' in views: # von Suedwesten, schraeg oben
if 'schraeg' in views: # 'schraeg' = oblique: from the south-west, above
shoot('schraeg', (10, -50, 27), (30.5, -21.5, 0), lens=33)
if 'schraeg2' in views: # von Nordosten (hinter dem Ziel)
if 'schraeg2' in views: # from the north-east (behind the goal)
shoot('schraeg2', (60, 2, 22), (29, -21, 0), lens=30)
if 'oben' in views:
if 'oben' in views: # 'oben' = top view
shoot('oben', (31, -21.5, 80), (31, -21.4, 0), ortho=44)
+1 -1
View File
@@ -1,4 +1,4 @@
# Strat-Positionen eines Levelskripts als JSON (fuer Marker im Blender-Render)
# strat positions of a level script as JSON (for markers in the Blender render)
import sys, json, os
sys.path.insert(0, 'tools')
import levelbuild as LB
+8 -8
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Liest und schreibt Level-WADs von "Ein Königreich für ein Lama" (Argonaut).
"""Reads and writes level WADs of "Ein Königreich für ein Lama" (Argonaut).
Aufbau: u32 Gesamtlänge (ohne dieses Feld), dann Chunks:
4 Byte Kennung (rückwärts gespeichert, z. B. 'OFNI' = INFO), u32 Länge, Daten
bis zum Chunk 'END '.
Layout: u32 total length (excluding this field), then chunks:
4-byte tag (stored reversed, e.g. 'OFNI' = INFO), u32 length, data
up to the chunk 'END '.
wadtool.py list level.wad
wadtool.py name level.wad neu.wad "Neuer Levelname"
wadtool.py extract level.wad CHUNK datei.bin
wadtool.py replace level.wad neu.wad CHUNK datei.bin
wadtool.py name level.wad new.wad "New level name"
wadtool.py extract level.wad CHUNK file.bin
wadtool.py replace level.wad new.wad CHUNK file.bin
"""
import struct
import sys
@@ -18,7 +18,7 @@ def read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
if total != len(d) - 4:
raise ValueError(f'{path}: Längenfeld {total} passt nicht zu {len(d) - 4}')
raise ValueError(f'{path}: length field {total} does not match {len(d) - 4}')
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')