From 526f6072f6549b1b8e32cf1ebefd82cba35055f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 7 Nov 2026 10:51:14 +0100 Subject: [PATCH] translated the remaining german notes, python comments, help and messages to english (in-game strings, parsed report lines and cli values stay german) --- levels/sprunglabor.py | 22 +- levels/test_orient.py | 2 +- levels/testwelt.py | 155 ++++++------ notes/debugviz.md | 369 +++++++++++++-------------- notes/geometry.md | 336 ++++++++++++------------- notes/levelbau.md | 444 ++++++++++++++++----------------- notes/objects.md | 230 ++++++++--------- notes/strat_types_table.md | 4 +- notes/testharness.md | 220 ++++++++-------- notes/textures.md | 78 +++--- tools/asf2wav.py | 20 +- tools/cor2png.py | 18 +- tools/debugviz.py | 156 ++++++------ tools/debugviz_check.py | 26 +- tools/disx86.py | 2 +- tools/geom_demo.py | 28 +-- tools/geom_export.py | 36 +-- tools/geom_import.py | 86 +++---- tools/geomlib.py | 118 ++++----- tools/lama-mod | 44 ++-- tools/lamatest.py | 255 +++++++++---------- tools/levelbuild.py | 217 ++++++++-------- tools/make_testlevel.py | 50 ++-- tools/map_dump.py | 2 +- tools/mapchunk.py | 28 +-- tools/png2cor.py | 16 +- tools/stpcwalk.py | 8 +- tools/strat_table.py | 6 +- tools/strats.py | 14 +- tools/texsheet.py | 16 +- tools/text_export.py | 10 +- tools/textchunk.py | 32 +-- tools/vorschau/label.py | 3 +- tools/vorschau/render_level.py | 28 +-- tools/vorschau/strats_json.py | 2 +- tools/wadtool.py | 16 +- 36 files changed, 1553 insertions(+), 1544 deletions(-) diff --git a/levels/sprunglabor.py b/levels/sprunglabor.py index 29f5ea4..5106377 100644 --- a/levels/sprunglabor.py +++ b/levels/sprunglabor.py @@ -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 diff --git a/levels/test_orient.py b/levels/test_orient.py index bbbdb14..98c9e12 100644 --- a/levels/test_orient.py +++ b/levels/test_orient.py @@ -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') diff --git a/levels/testwelt.py b/levels/testwelt.py index 550f89f..a0dc942 100644 --- a/levels/testwelt.py +++ b/levels/testwelt.py @@ -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| 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| 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)) diff --git a/notes/debugviz.md b/notes/debugviz.md index f0d615c..e3d07cf 100644 --- a/notes/debugviz.md +++ b/notes/debugviz.md @@ -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). diff --git a/notes/geometry.md b/notes/geometry.md index ae0db58..6ced0ab 100644 --- a/notes/geometry.md +++ b/notes/geometry.md @@ -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). diff --git a/notes/levelbau.md b/notes/levelbau.md index c4b9569..86fb1b1 100644 --- a/notes/levelbau.md +++ b/notes/levelbau.md @@ -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. diff --git a/notes/objects.md b/notes/objects.md index 8b0cb6c..b64738a 100644 --- a/notes/objects.md +++ b/notes/objects.md @@ -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 `). 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 `). 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 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 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. diff --git a/notes/strat_types_table.md b/notes/strat_types_table.md index c0576b5..80a86d7 100644 --- a/notes/strat_types_table.md +++ b/notes/strat_types_table.md @@ -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 diff --git a/notes/testharness.md b/notes/testharness.md index 05248c7..cd86739 100644 --- a/notes/testharness.md +++ b/notes/testharness.md @@ -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 [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 [n] # n Kamera-Fotos (Rundumblick) + n Lauf-Fotos + Lama-Sicht -lamatest.py shot out.png # Vollbild 1920x1080 -lamatest.py 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 "TOKENS" # --seq-Tokens (shot:, run:, rj: …) im laufenden Spiel +lamatest.py run [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 [n] # n camera shots (look around) + n walking shots + llama view +lamatest.py shot out.png # full screen 1920x1080 +lamatest.py 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 "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 [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 ``: `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 ``: `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. diff --git a/notes/textures.md b/notes/textures.md index 9427139..0ffae65 100644 --- a/notes/textures.md +++ b/notes/textures.md @@ -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. diff --git a/tools/asf2wav.py b/tools/asf2wav.py index 516584c..d87c17a 100644 --- a/tools/asf2wav.py +++ b/tools/asf2wav.py @@ -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): diff --git a/tools/cor2png.py b/tools/cor2png.py index 4f69cac..4bcd32d 100644 --- a/tools/cor2png.py +++ b/tools/cor2png.py @@ -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(' 2: from PIL import Image mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size] diff --git a/tools/debugviz.py b/tools/debugviz.py index ef1ba9d..24daf09 100644 --- a/tools/debugviz.py +++ b/tools/debugviz.py @@ -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('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)) diff --git a/tools/debugviz_check.py b/tools/debugviz_check.py index 6cb9e22..8da035f 100644 --- a/tools/debugviz_check.py +++ b/tools/debugviz_check.py @@ -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) diff --git a/tools/disx86.py b/tools/disx86.py index 08239a7..ea11dd5 100644 --- a/tools/disx86.py +++ b/tools/disx86.py @@ -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 diff --git a/tools/geom_demo.py b/tools/geom_demo.py index 23c28b6..d2d360a 100644 --- a/tools/geom_demo.py +++ b/tools/geom_demo.py @@ -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: x0z1 (z negativ = weiter im Raster) + # coordinates: x0z1 (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__': diff --git a/tools/geom_export.py b/tools/geom_export.py index a30a0e0..1169440 100644 --- a/tools/geom_export.py +++ b/tools/geom_export.py @@ -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, diff --git a/tools/geom_import.py b/tools/geom_import.py index 6322125..71cb0ed 100644 --- a/tools/geom_import.py +++ b/tools/geom_import.py @@ -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__': diff --git a/tools/geomlib.py b/tools/geomlib.py index 51f9131..b895cc6 100644 --- a/tools/geomlib.py +++ b/tools/geomlib.py @@ -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(' 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(' 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(' 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'], ' +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 diff --git a/tools/lama-mod b/tools/lama-mod index 276e8fa..0a8203c 100755 --- a/tools/lama-mod +++ b/tools/lama-mod @@ -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 ;; *) diff --git a/tools/lamatest.py b/tools/lamatest.py index 0c71a9e..1200e94 100755 --- a/tools/lamatest.py +++ b/tools/lamatest.py @@ -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 [hold] Taste halten (xdotool-Name, Default 0.12 s) - lamatest.py keys ... mehrere Tasten nacheinander (k[:hold]) - lamatest.py seq "TOKENS" Tastenfolge wie run --seq im laufenden Spiel (shot:/fp:/rj: ...) - lamatest.py tolevel Demo -> Hauptmenue -> Slot 3 -> TestLevel 1 - lamatest.py tour [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 [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 [hold] hold a key (xdotool name, default 0.12 s) + lamatest.py keys ... several keys one after another (k[:hold]) + lamatest.py seq "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 [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 [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 "TOKENS" + elif c == "seq": # key sequence like run --seq, but in the running game: seq "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__) diff --git a/tools/levelbuild.py b/tools/levelbuild.py index e680b63..bde8a31 100644 --- a/tools/levelbuild.py +++ b/tools/levelbuild.py @@ -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= 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) diff --git a/tools/make_testlevel.py b/tools/make_testlevel.py index 2d0d127..321ca2b 100644 --- a/tools/make_testlevel.py +++ b/tools/make_testlevel.py @@ -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__': diff --git a/tools/map_dump.py b/tools/map_dump.py index 1abd99f..8ac475c 100644 --- a/tools/map_dump.py +++ b/tools/map_dump.py @@ -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 diff --git a/tools/mapchunk.py b/tools/mapchunk.py index f9385e6..b5e4e0e 100644 --- a/tools/mapchunk.py +++ b/tools/mapchunk.py @@ -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(' 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']] diff --git a/tools/png2cor.py b/tools/png2cor.py index 9a5f4ca..e4a7a83 100644 --- a/tools/png2cor.py +++ b/tools/png2cor.py @@ -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') diff --git a/tools/stpcwalk.py b/tools/stpcwalk.py index 6675a1d..28bb131 100644 --- a/tools/stpcwalk.py +++ b/tools/stpcwalk.py @@ -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: diff --git a/tools/strat_table.py b/tools/strat_table.py index fec8959..e39d421 100644 --- a/tools/strat_table.py +++ b/tools/strat_table.py @@ -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}))) diff --git a/tools/strats.py b/tools/strats.py index 575907b..e490df7 100644 --- a/tools/strats.py +++ b/tools/strats.py @@ -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, -> 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, -> 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 e0 ...) -> Ziel; sonst erstes d4/ea ab entry.""" + """Name of a MAP entry: spawn stub (b2 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(' 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__': diff --git a/tools/text_export.py b/tools/text_export.py index c6991a0..f527ff7 100644 --- a/tools/text_export.py +++ b/tools/text_export.py @@ -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__': diff --git a/tools/textchunk.py b/tools/textchunk.py index bd62429..ad058de 100644 --- a/tools/textchunk.py +++ b/tools/textchunk.py @@ -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(' 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] diff --git a/tools/vorschau/render_level.py b/tools/vorschau/render_level.py index b0bf497..e1513f5 100644 --- a/tools/vorschau/render_level.py +++ b/tools/vorschau/render_level.py @@ -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) diff --git a/tools/vorschau/strats_json.py b/tools/vorschau/strats_json.py index 28d08ee..2792d28 100644 --- a/tools/vorschau/strats_json.py +++ b/tools/vorschau/strats_json.py @@ -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 diff --git a/tools/wadtool.py b/tools/wadtool.py index bd71660..982f227 100644 --- a/tools/wadtool.py +++ b/tools/wadtool.py @@ -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('