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

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

No files matched your search

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