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:
1 parent
86fe26d786
commit
526f6072f6
36 files changed
+1553
-1544
No files matched your search
+11
-11
@@ -1,5 +1,5 @@
|
||||
"""Sprunglabor: misst, wie hoch/weit das Lama springt (fuer Parkour-Abstaende).
|
||||
Spur A (z=20): Stufen mit wachsender Hoehe; Spur B (z=26): Luecken mit wachsender Breite."""
|
||||
"""Sprunglabor ("jump lab"): measures how high/far the llama jumps (for parkour spacing).
|
||||
Lane A (z=20): steps of increasing height; lane B (z=26): gaps of increasing width."""
|
||||
LEVEL_ARGS = dict(name='Sprunglabor')
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ def build(L):
|
||||
L.sea(0, 0, 60, 40, y=-2.0, kill=(4, 10, 56, 34))
|
||||
lane = L.opts.get('spur', 'a')
|
||||
if lane == 'f':
|
||||
# Weitsprung ueber Wasser (Sturz = Respawn am Start): Startinsel, dann 2 lange Bloecke auf
|
||||
# gleicher Hoehe mit Luecken 0.5/0.7/0.9/1.1/1.3; Stufen-Spur g daneben (z=26): +0.2/+0.3 mit 0.4
|
||||
# long jump over water (fall = respawn at start): start island, then 2-long blocks at the
|
||||
# same height with gaps 0.5/0.7/0.9/1.1/1.3; step lane g next to it (z=26): +0.2/+0.3 with 0.4
|
||||
L.box(8, 17, 14, 29, -1, 0, top='stein', side='stein_kante', rot=0)
|
||||
x = 14
|
||||
for i, gap in enumerate([0.5, 0.7, 0.9, 1.1, 1.3]):
|
||||
@@ -24,17 +24,17 @@ def build(L):
|
||||
return
|
||||
L.box(10, 14, 50, 30, -1, 0, top='gras', side='erde', side_top='graskante')
|
||||
if lane == 'm':
|
||||
# Muenz-Messbahn: Muenze alle 0.25 ab x=12 -> Muenzzaehler = zurueckgelegte Strecke x4
|
||||
# coin measuring track: a coin every 0.25 from x=12 -> coin counter = distance travelled x4
|
||||
L.coins([(12 + 0.25 * i, 20) for i in range(int(L.opts.get('n', 112)))])
|
||||
L.player(11.5, 20, rot=90)
|
||||
return
|
||||
if lane == 'e':
|
||||
# Messbahn: Querstreifen je 1 Einheit (abwechselnd weiss/rot), Lama bei x=11.5
|
||||
# measuring track: cross stripes every 1 unit (alternating white/red), llama at x=11.5
|
||||
for i in range(12, 48):
|
||||
L.floor(i, 19, i + 0.15, 21, 0.01, 'weiss' if i % 5 else 'rot', coll=False)
|
||||
L.player(11.5, 20, rot=90)
|
||||
elif lane == 'd':
|
||||
# Luecken 0.5 / 0.75 / 1.0 / 1.25 zwischen 0.6 hohen Bloecken (je 1.5 lang)
|
||||
# gaps 0.5 / 0.75 / 1.0 / 1.25 between 0.6-high blocks (1.5 long each)
|
||||
L.box(10, 18, 14, 22, 0, 0.6, top='stein', side='stein_kante', rot=0)
|
||||
x = 14
|
||||
for i, gap in enumerate([0.5, 0.75, 1.0, 1.25]):
|
||||
@@ -43,21 +43,21 @@ def build(L):
|
||||
x += 1.5
|
||||
L.player(11, 20, rot=90)
|
||||
elif lane == 'c':
|
||||
# Einzelbloecke wachsender Hoehe, je 2 lang, 3 Abstand
|
||||
# single blocks of increasing height, 2 long each, 3 apart
|
||||
for i, h in enumerate([0.2, 0.35, 0.5, 0.7, 0.9]):
|
||||
x = 16 + i * 5
|
||||
L.box(x, 18, x + 2, 22, 0, h, top='holz_maser' if i % 2 else 'stein', side='stein_kante', rot=0)
|
||||
L.box(x - 0.1, 17.4, x + 0.1, 17.6, 0, 1.5, top='rot', side='rot', solid=False) # Markierung
|
||||
L.box(x - 0.1, 17.4, x + 0.1, 17.6, 0, 1.5, top='rot', side='rot', solid=False) # marker
|
||||
L.player(12, 20, rot=90)
|
||||
elif lane == 'a':
|
||||
# Stufen: Hoehen 0.5, 1.0, 1.5, 2.25, 3.0 (je 2 tief)
|
||||
# steps: heights 0.5, 1.0, 1.5, 2.25, 3.0 (2 deep each)
|
||||
x = 16
|
||||
for h, tex in [(0.5, 'stein'), (1.0, 'holz_maser'), (1.5, 'stein'), (2.25, 'holz_maser'), (3.0, 'stein')]:
|
||||
L.box(x, 18, x + 2, 22, 0, h, top=tex, side='stein_kante', rot=0)
|
||||
x += 2
|
||||
L.player(12, 20, rot=90)
|
||||
else:
|
||||
# Luecken: Plattformen auf 1.0, Luecken 1.0, 1.5, 2.0, 2.5, 3.0
|
||||
# gaps: platforms at 1.0, gaps 1.0, 1.5, 2.0, 2.5, 3.0
|
||||
x = 12
|
||||
L.box(x, 24, x + 4, 28, 0, 1.0, top='stein', side='stein_kante', rot=0)
|
||||
x += 4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Kleiner Orientierungstest: Platz, farbige Saeulen in +x (rot), +z (blau), -x (gelb), -z (weiss)."""
|
||||
"""Small orientation test: plaza, coloured pillars at +x (red), +z (blue), -x (yellow), -z (white)."""
|
||||
LEVEL_ARGS = dict(name='Orient')
|
||||
|
||||
|
||||
|
||||
+78
-77
@@ -1,52 +1,53 @@
|
||||
"""Testwelt - eigene Test-Map fuer "Ein Koenigreich fuer ein Lama" (ersetzt TestLevel Kapitel 1).
|
||||
"""Testwelt - custom test map for "Ein Koenigreich fuer ein Lama" (replaces TestLevel chapter 1).
|
||||
|
||||
Bauen: python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad [--install]
|
||||
Optionen (--set name=wert):
|
||||
galerie=alle|keine|<Komma-Liste von Typen> welche Objekte in der Galerie stehen
|
||||
start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 Startpunkt des Lamas (Tests)
|
||||
tuer=1,9,1 Parameter des Ausgangs (Stamm, Welt, Kapitel des naechsten Levels)
|
||||
Build: python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad [--install]
|
||||
Options (--set name=value; the option values are German words):
|
||||
galerie=alle|keine|<comma list of types> which objects stand in the gallery (all|none|list)
|
||||
start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 llama start point (tests):
|
||||
plaza | gallery row | path in front of row | enclosure | podium | parkour | parkour stage | goal
|
||||
tuer=1,9,1 parameters of the exit door (tribe, world, chapter of the next level)
|
||||
|
||||
Masstab (im Spiel gemessen): Lama ~0.6 hoch, laeuft ~1.1 Einheiten/s, springt 0.35 hoch und mit
|
||||
Anlauf ~1.3 weit. Stufen <= 0.3 (ohne Luecke = Anschlag), Luecken 0.3, sonst Treppen/Rampen.
|
||||
Scale (measured in game): llama ~0.6 tall, walks ~1.1 units/s, jumps 0.35 high and, with a
|
||||
run-up, ~1.3 far. Steps <= 0.3 (without a gap = stopper), gaps 0.3, otherwise stairs/ramps.
|
||||
|
||||
Aufbau (Spielkoordinaten: x nach Osten, z nach Sueden, y hoch; rot 0 = Blick nach +z), kompakt
|
||||
(2026-11-06 zusammengerueckt: Insel 32.5x23.5 + Parkour-Streifen statt 42x34):
|
||||
* Startplatz (x 15..21, z 12..19): Lama-Start, Checkpoint, Inka-Saeulen, Blumenbeete
|
||||
* Objekt-Galerie direkt suedlich (x 14.5..32.5, z 20.5..32): 4 Reihen (Sammeln / Kisten+Toepfe /
|
||||
Figuren / Mechanik) im Abstand 2.7, Spalten 1.9, jedes Objekt auf flachem Sockel, Wege dazwischen;
|
||||
Tiere im Gehege gleich oestlich daneben (x 33..42, z 20.8..30) mit Aussichtspodest
|
||||
* Parkour beginnt an der Ostkante des Platzes, alles auf der Linie z = 15.5: Treppe, Anschlag-Stufe,
|
||||
2 Luecken, Balken, Checkpoint, Rampe, Anschlag-Stufe, 3 Trittsteine, schmaler Balken, Treppe,
|
||||
Ziel-Plattform (y 4) mit Tor ueber dem Ausgang (LevelDoor auf "Tuerboden", Material 16).
|
||||
Masse unveraendert gegenueber der ersten Fassung, nur um PX = -1 verschoben.
|
||||
Skript-Durchlauf: levels/testwelt_parkour.seq (mit start=parkour)
|
||||
* alles auf einer L-foermigen Insel, ringsum Meer (Todesebene -> Respawn am Checkpoint)
|
||||
Layout (game coordinates: x east, z south, y up; rot 0 = facing +z), compact
|
||||
(moved closer together on 2026-11-06: island 32.5x23.5 + parkour strip instead of 42x34):
|
||||
* start plaza (x 15..21, z 12..19): llama start, checkpoint, Inca pillars, flower beds
|
||||
* object gallery directly south (x 14.5..32.5, z 20.5..32): 4 rows (collectibles / crates+pots /
|
||||
characters / mechanisms) 2.7 apart, columns 1.9, each object on a flat plinth, paths in between;
|
||||
animals in the enclosure right next to it to the east (x 33..42, z 20.8..30) with a viewing podium
|
||||
* parkour starts at the east edge of the plaza, all on the line z = 15.5: stairs, stopper step,
|
||||
2 gaps, beam, checkpoint, ramp, stopper step, 3 stepping stones, narrow beam, stairs,
|
||||
goal platform (y 4) with a gate over the exit (LevelDoor on "door floor", material 16).
|
||||
Dimensions unchanged from the first version, only shifted by PX = -1.
|
||||
Scripted run: levels/testwelt_parkour.seq (with start=parkour)
|
||||
* everything on an L-shaped island, sea all around (death plane -> respawn at checkpoint)
|
||||
"""
|
||||
import random
|
||||
|
||||
LEVEL_ARGS = dict(name='Testwelt', seed=7)
|
||||
|
||||
# Galerie: (Kategorie, Farbe des Reihen-Pfostens, Typen). Typ = Name oder (Name, Variante)
|
||||
# gallery: (category, colour of the row post, types). type = name or (name, variant)
|
||||
GALERIE = [
|
||||
('Sammeln', 'gelb', ['Coin', 'ChargeBooster', 'RubyIdol', 'HealthFull', 'HealthGenerator',
|
||||
('Collectibles', 'gelb', ['Coin', 'ChargeBooster', 'RubyIdol', 'HealthFull', 'HealthGenerator',
|
||||
'ChacaChargePotion', 'Secret']),
|
||||
('Kisten/Toepfe', 'grau', [('SmashPot', 0), ('SmashPot', 1), 'Jar', 'ObjectDontDropInBox',
|
||||
('Crates/pots', 'grau', [('SmashPot', 0), ('SmashPot', 1), 'Jar', 'ObjectDontDropInBox',
|
||||
'WaterReboundPoints', 'CheatTeleport']),
|
||||
('Figuren', 'blau', ['TipoVillage', 'ChacaVillage', 'PachaGivingInfo', 'KidOnLlama', 'Wompy']),
|
||||
('Mechanik', 'rot', ['PressurePadDoor', 'RubyIdolDoor', 'SwitchPlatformPadVillage',
|
||||
('Characters', 'blau', ['TipoVillage', 'ChacaVillage', 'PachaGivingInfo', 'KidOnLlama', 'Wompy']),
|
||||
('Mechanisms', 'rot', ['PressurePadDoor', 'RubyIdolDoor', 'SwitchPlatformPadVillage',
|
||||
'AppearingPlatformsVillage', 'RisingColumns', 'SinkingStatue', 'FallingTree',
|
||||
'CheckPointPost']),
|
||||
]
|
||||
GEHEGE = ['Bunny', 'Bunny', 'Ladybird', 'FlockOfBirds', 'VillagerHerder']
|
||||
# bewusst nicht platziert (siehe notes/levelbau.md)
|
||||
NICHT = {'Llama': 'ist der Spieler', 'LevelDoor': 'Ausgang am Parkour-Ende',
|
||||
'CameraSide': 'Kamera-Zone (unsichtbar)', 'CutSceneCameraDirectional': 'Zwischensequenz-Kamera (unsichtbar)',
|
||||
'CutSceneTrigger': 'startet eine Zwischensequenz'}
|
||||
# deliberately not placed (see notes/levelbau.md)
|
||||
NICHT = {'Llama': 'is the player', 'LevelDoor': 'exit at the end of the parkour',
|
||||
'CameraSide': 'camera zone (invisible)', 'CutSceneCameraDirectional': 'cutscene camera (invisible)',
|
||||
'CutSceneTrigger': 'starts a cutscene'}
|
||||
|
||||
GX0, DX = 17.4, 1.9 # erste Galerie-Spalte, Abstand (vorher 17.0 / 2.5)
|
||||
ROW_Z = [22.5, 25.2, 27.9, 30.6] # vorher 25 / 28.5 / 32 / 35.5
|
||||
TUER_MAT = 16 # Kollisionsmaterial "Tuerboden" (Dorf: Boden in der Ausgangstuer)
|
||||
PX = -1.0 # Versatz des Parkours gegenueber der alten Fassung (nur verschoben, Masse gleich)
|
||||
GX0, DX = 17.4, 1.9 # first gallery column, spacing (previously 17.0 / 2.5)
|
||||
ROW_Z = [22.5, 25.2, 27.9, 30.6] # previously 25 / 28.5 / 32 / 35.5
|
||||
TUER_MAT = 16 # collision material "door floor" (village: floor in the exit door)
|
||||
PX = -1.0 # parkour offset relative to the old version (only shifted, same dimensions)
|
||||
|
||||
|
||||
def opt(L, k, d):
|
||||
@@ -61,22 +62,22 @@ def build(L):
|
||||
sel = opt(L, 'galerie', 'alle')
|
||||
rnd = random.Random(3)
|
||||
L.sun.update(azimuth=220, elevation=48, strength=0.72, sky=(0.47, 0.52, 0.62), ground=(0.27, 0.25, 0.21))
|
||||
# ================================================================ Insel + Meer
|
||||
# L-foermige Insel: Hauptteil (Platz, Galerie, Gehege) + schmaler Streifen unter dem Parkour-Ende
|
||||
X0, Z0, X1, Z1 = 11, 9.5, 43.5, 33 # Hauptteil
|
||||
XB = 49 # Ostende des Parkour-Streifens (z Z0..20.5)
|
||||
# ================================================================ island + sea
|
||||
# L-shaped island: main part (plaza, gallery, enclosure) + narrow strip below the end of the parkour
|
||||
X0, Z0, X1, Z1 = 11, 9.5, 43.5, 33 # main part
|
||||
XB = 49 # east end of the parkour strip (z Z0..20.5)
|
||||
L.sea(0, 0, 60, 42, y=-1.5, kill=(X0 - 4, Z0 - 4, 54, Z1 + 4))
|
||||
L.box(X0, Z0, X1, Z1, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False)
|
||||
L.box(X1, Z0, XB, 20.5, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False, sides='nes')
|
||||
# Grundflaechen (ohne Ueberlappung): Platz, Galerie bekommen eigene Boeden
|
||||
L.floor(X0, Z0, XB, 12, 0, 'gras') # Nordrand
|
||||
L.floor(X0, 12, 15, 20.5, 0, 'gras') # Westrand neben dem Platz
|
||||
L.floor(X0, 20.5, 14.5, Z1, 0, 'gras') # Westrand neben der Galerie
|
||||
L.floor(21, 12, XB, 20.5, 0, 'gras2') # Wiese unter dem Parkour
|
||||
L.floor(15, 19, 21, 20.5, 0, 'gras') # zwischen Platz und Galerie
|
||||
L.floor(32.5, 20.5, X1, 32, 0, 'gras') # Gehege
|
||||
L.floor(14.5, 32, X1, Z1, 0, 'gras') # Suedrand
|
||||
# ================================================================ Startplatz (6 x 7)
|
||||
# base surfaces (no overlap): plaza and gallery get their own floors
|
||||
L.floor(X0, Z0, XB, 12, 0, 'gras') # north edge
|
||||
L.floor(X0, 12, 15, 20.5, 0, 'gras') # west edge next to the plaza
|
||||
L.floor(X0, 20.5, 14.5, Z1, 0, 'gras') # west edge next to the gallery
|
||||
L.floor(21, 12, XB, 20.5, 0, 'gras2') # meadow below the parkour
|
||||
L.floor(15, 19, 21, 20.5, 0, 'gras') # between plaza and gallery
|
||||
L.floor(32.5, 20.5, X1, 32, 0, 'gras') # enclosure
|
||||
L.floor(14.5, 32, X1, Z1, 0, 'gras') # south edge
|
||||
# ================================================================ start plaza (6 x 7)
|
||||
L.box(15, 12, 21, 19, -0.25, 0.0, top='stein', side='stein_kante')
|
||||
L.box(16.5, 14.0, 19.5, 17.0, 0.0, 0.08, top='inka', side='stein_kante', rot=0, side_coll=False)
|
||||
for (x, z) in [(15.6, 12.6), (20.4, 12.6), (15.6, 18.4), (20.4, 18.4)]:
|
||||
@@ -86,15 +87,15 @@ def build(L):
|
||||
L.box(18.9, 19.2, 20.7, 20.1, 0, 0.1, top='blumen2', side='holz_dunkel', side_coll=False)
|
||||
for (x, z) in [(12.5, 10.6), (23.5, 10.6), (12.4, 14.2), (12.6, 18.2)]:
|
||||
baum(L, x, z, h=rnd.uniform(1.3, 1.9))
|
||||
# ================================================================ Galerie (direkt suedlich vom Platz)
|
||||
# ================================================================ gallery (directly south of the plaza)
|
||||
L.box(14.5, 20.5, 32.5, 32, -0.2, 0.0, top=None, side='stein', solid=False)
|
||||
L.floor(14.5, 20.5, 32.5, 32, 0, 'gras')
|
||||
L.floor(15.6, 20.5, 16.3, 32, 0.02, 'sand', coll=False) # Hauptweg Nord-Sued
|
||||
L.floor(15.6, 20.5, 16.3, 32, 0.02, 'sand', coll=False) # main path north-south
|
||||
placed, skipped = [], []
|
||||
for (cat, farbe, typen), zr in zip(GALERIE, ROW_Z):
|
||||
xe = GX0 + (len(typen) - 1) * DX + 0.7
|
||||
L.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, 'sand', coll=False) # Weg vor der Reihe
|
||||
# Reihen-Pfosten (Kategoriefarbe) am Weganfang
|
||||
L.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, 'sand', coll=False) # path in front of the row
|
||||
# row post (category colour) at the start of the path
|
||||
L.pillar(15.05, zr - 0.3, 0, 1.1, size=0.25, side=farbe, top=farbe)
|
||||
L.box(14.85, zr - 0.5, 15.25, zr - 0.1, 1.1, 1.25, top=farbe, side='holz_dunkel', rot=0)
|
||||
x = GX0
|
||||
@@ -109,7 +110,7 @@ def build(L):
|
||||
else:
|
||||
skipped.append(name)
|
||||
x += DX
|
||||
# ================================================================ Gehege (Tiere/Gegner), oestlich der Galerie
|
||||
# ================================================================ enclosure (animals/enemies), east of the gallery
|
||||
gx0, gz0, gx1, gz1 = 33, 20.8, 42, 30
|
||||
L.floor(gx0, gz0, gx1, gz1, 0.015, 'gras_streifen', coll=False)
|
||||
L.fence([(gx0, gz0), (gx1, gz0), (gx1, gz1), (gx0, gz1), (gx0, gz0 + 3.2)], 0, height=0.45, thick=0.07,
|
||||
@@ -120,59 +121,59 @@ def build(L):
|
||||
if sel == 'alle' or name in sel.split(','):
|
||||
L.strat(name, gx0 + 1.8 + (i % 3) * 2.7, gz0 + 2.3 + (i // 3) * 4.5)
|
||||
placed.append(name)
|
||||
# Aussichtspodest am Gehege (Treppe hoch, zwischen Reihe 2 und 3; Eingang ins Gehege noerdlich davon)
|
||||
# viewing podium at the enclosure (stairs up, between rows 2 and 3; enclosure entrance north of it)
|
||||
L.stairs(31.2, 25.3, 32.4, 26.9, 0, 0.6, 3, '+x', top='holz_maser', side='holz_dunkel')
|
||||
L.box(32.4, 24.7, 33.0, 27.5, 0, 0.6, top='holz_maser', side='holz_dunkel', rot=0)
|
||||
# ================================================================ Parkour
|
||||
# Der Parkour liegt auf einer Linie (z = 15.5, nach Osten), damit man ohne Kameradrehen durchkommt.
|
||||
# Zwei "Anschlag"-Stufen (+0.3, ohne Luecke) halten das Lama an einer festen Stelle an: dort
|
||||
# springt man aus dem Stand hoch und hat danach genug Anlauf (wichtig fuer --seq-Laeufe).
|
||||
# Lama: laeuft ~1.1 Einheiten/s, springt mit Anlauf ~1.3 weit und 0.35 hoch.
|
||||
# Alle x-Werte unten sind die der alten Fassung + PX (Geometrie unveraendert, nur verschoben).
|
||||
# The parkour lies on one line (z = 15.5, heading east) so it can be done without turning the camera.
|
||||
# Two "stopper" steps (+0.3, no gap) stop the llama at a fixed spot: there you jump up from
|
||||
# standing and then have enough run-up (important for --seq runs).
|
||||
# Llama: walks ~1.1 units/s, jumps ~1.3 far with a run-up and 0.35 high.
|
||||
# All x values below are those of the old version + PX (geometry unchanged, only shifted).
|
||||
def X(v):
|
||||
return v + PX
|
||||
# 1) Treppe vom Platz (Ostkante x=21) auf 0.8, kleiner Absatz, Anschlag-Stufe 1 (+0.3)
|
||||
# 1) stairs from the plaza (east edge x=21) up to 0.8, small landing, stopper step 1 (+0.3)
|
||||
L.stairs(X(22), 14.5, X(24), 16.5, 0, 0.8, 4, '+x', top='stein', side='stein2')
|
||||
L.box(X(24), 14.5, X(24.6), 16.5, 0, 0.8, top='stein', side='stein2', rot=0)
|
||||
L.box(X(24.6), 14.5, X(26), 16.5, 0, 1.1, top='gras', side='erde', side_top='graskante')
|
||||
# 2) zwei Luecken (0.3) mit kleinen Stufen (+0.2)
|
||||
# 2) two gaps (0.3) with small steps (+0.2)
|
||||
L.box(X(26.3), 14.7, X(27.8), 16.3, 0, 1.3, top='holz_maser', side='holz_dunkel', rot=0)
|
||||
L.box(X(28.1), 14.9, X(29.6), 16.1, 0, 1.5, top='holz_maser', side='holz_dunkel', rot=0)
|
||||
# 3) Balken (0.5 breit)
|
||||
# 3) beam (0.5 wide)
|
||||
L.box(X(29.6), 15.25, X(32.5), 15.75, 1.25, 1.5, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel',
|
||||
rot=0)
|
||||
L.pillar(X(31.05), 15.5, 0, 1.25, size=0.2, side='rinde', top='rinde')
|
||||
# 4) Mittelplattform mit Checkpoint
|
||||
# 4) middle platform with checkpoint
|
||||
L.box(X(32.5), 14.0, X(35.0), 17.0, 0, 1.5, top='stein', side='stein_moos', side_top='stein_kante')
|
||||
# 5) Rampe 1.5 -> 2.4, Absatz, Anschlag-Stufe 2 (+0.3)
|
||||
# 5) ramp 1.5 -> 2.4, landing, stopper step 2 (+0.3)
|
||||
L.ramp(X(35.0), 14.75, X(38.0), 16.25, 1.5, 2.4, '+x', top='sand', side='erde', base=0.0)
|
||||
L.box(X(38.0), 14.75, X(38.6), 16.25, 0, 2.4, top='sand', side='erde', rot=0)
|
||||
L.box(X(38.6), 14.6, X(39.8), 16.4, 0, 2.7, top='stein', side='stein_moos', side_top='stein_kante', rot=0)
|
||||
# 6) drei Trittsteine (1.5 lang, 1.3 breit, Luecken 0.3, je +0.2)
|
||||
# 6) three stepping stones (1.5 long, 1.3 wide, gaps 0.3, +0.2 each)
|
||||
stones = [(X(40.85), 15.5, 2.9), (X(42.65), 15.5, 3.1), (X(44.45), 15.5, 3.3)]
|
||||
for (x, z, y) in stones:
|
||||
L.box(x - 0.75, z - 0.65, x + 0.75, z + 0.65, 0, y, top='stein_gruen', side='stein_moos', rot=0)
|
||||
# 7) schmaler Balken (0.45 breit) mit Stuetze
|
||||
# 7) narrow beam (0.45 wide) with support
|
||||
L.box(X(45.2), 15.275, X(47.4), 15.725, 3.05, 3.3, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel',
|
||||
rot=0)
|
||||
L.pillar(X(46.3), 15.5, 0, 3.05, size=0.18, side='rinde', top='rinde')
|
||||
# 8) Treppe auf 4.0
|
||||
# 8) stairs up to 4.0
|
||||
L.stairs(X(47.4), 14.7, X(48.6), 16.3, 3.3, 4.0, 3, '+x', top='stein', side='stein2', base=0.0)
|
||||
# 9) Ziel-Plattform (ragt ueber die Kueste) mit Saeulen, Brustwehr und Tor ueber dem Ausgang
|
||||
# 9) goal platform (overhanging the coast) with pillars, parapet and a gate over the exit
|
||||
L.box(X(48.6), 12.6, X(53.6), 18.4, -2.2, 4.0, top='platte2', side='stein_moos', side_top='stein_kante')
|
||||
for (x, z) in [(49.0, 13.0), (53.2, 13.0), (49.0, 18.0), (53.2, 18.0)]:
|
||||
L.pillar(X(x), z, 4.0, 5.0, size=0.35, side='inka', top='stein')
|
||||
L.box(X(53.35), 13.3, X(53.6), 17.7, 4.0, 4.45, top='stein', side='stein_kante', rot=0)
|
||||
# Mosaik im Tor: Oberseite mit Kollisionsmaterial 16 = "Tuerboden" (LevelDoor loest nur aus, wenn das
|
||||
# Lama in der Rasterzelle der Tuer auf einem Polygon mit Material 16 steht)
|
||||
# mosaic in the gate: top with collision material 16 = "door floor" (LevelDoor only triggers when the
|
||||
# llama stands on a polygon with material 16 in the door's grid cell)
|
||||
L.box(X(50.5), 14.3, X(52.3), 16.7, 4.0, 4.08, top='muster_lila', side='inka', rot=0, side_coll=False,
|
||||
mat=TUER_MAT)
|
||||
# Tor ueber dem Ausgang (LevelDoor bei 50.5/15.5, Zelle 50/15)
|
||||
# gate over the exit (LevelDoor at 50.5/15.5, cell 50/15)
|
||||
for z in (14.45, 16.55):
|
||||
L.pillar(X(51.5), z, 4.08, 5.3, size=0.3, side='inka', top='stein')
|
||||
L.box(X(51.25), 14.3, X(51.75), 16.7, 5.3, 5.55, top='stein', side='stein_kante', bottom='stein_kante', rot=0)
|
||||
|
||||
# ================================================================ Deko (nur am Rand, dicht)
|
||||
# ================================================================ decoration (edges only, dense)
|
||||
for (x, z) in [(12.3, 22.5), (12.6, 27), (12.2, 31.5), (17.5, 32.6), (25, 32.7), (36, 32.5), (42.6, 32.3),
|
||||
(42.8, 23), (29, 10.5), (35, 10.4), (41, 10.6), (46, 19.6), (45.5, 10.8)]:
|
||||
baum(L, x, z, h=rnd.uniform(1.2, 2.0), c=rnd.uniform(0.9, 1.3))
|
||||
@@ -184,7 +185,7 @@ def build(L):
|
||||
(40.5, 18.6, 2, 1.6, 'blumen'), (25, 10.4, 1.5, 1.4, 'blumen3')]:
|
||||
L.floor(x0, z0, x0 + w, z0 + d, 0.015, t, coll=False)
|
||||
|
||||
# ================================================================ Spieler, Checkpoints, Ausgang
|
||||
# ================================================================ player, checkpoints, exit
|
||||
starts = dict(platz=(18, 14.6, 0), parkour=(X(20.5), 15.5, 90), pk1=(X(24.3), 15.5, 90), pk2=(X(33.0), 15.5, 90),
|
||||
pk3=(X(38.3), 15.5, 90), pk4=(X(44.45), 15.5, 90), pk5=(X(46.0), 15.5, 90), ziel=(X(49.0), 15.5, 90),
|
||||
ziel2=(X(51.5), 17.8, 180), gehege=(30.4, 26.1, 90),
|
||||
@@ -196,12 +197,12 @@ def build(L):
|
||||
L.player(sx, sz, rot=srot)
|
||||
L.checkpoint(16.1, 17.4)
|
||||
L.checkpoint(X(33.6), 14.6)
|
||||
# Ausgang: Parameter (Stamm, Welt, Kapitel) des naechsten Levels. (1,9,1) = Kapitel 1 neu laden (nach
|
||||
# der Bilanz "TestLevel Kapitel 1 / Muenzen / Geheimnisse"). NICHT (7,7,7) ("Welt fertig" wie die
|
||||
# letzten Kapitel der Original-Welten): das schaltet Welt 10 frei -> Absturz auf der Auswahlkarte.
|
||||
# exit: parameters (tribe, world, chapter) of the next level. (1,9,1) = reload chapter 1 (after
|
||||
# the summary "TestLevel Kapitel 1 / Muenzen / Geheimnisse"). NOT (7,7,7) ("world complete" like the
|
||||
# last chapters of the original worlds): that unlocks world 10 -> crash on the selection map.
|
||||
tuer = [int(v) for v in opt(L, 'tuer', '1,9,1').split(',')]
|
||||
L.exit(X(51.5), 15.5, params=[v * 0x1000 for v in tuer])
|
||||
# Muenzen entlang des Parkours (auf die Flaechen gestellt)
|
||||
# coins along the parkour (placed on the surfaces)
|
||||
L.coins([(X(x), z) for x, z in [(22.5, 15.5), (23.5, 15.5), (25.3, 15.5), (27.0, 15.5), (28.85, 15.5),
|
||||
(30.0, 15.5), (31.0, 15.5), (32.0, 15.5), (35.8, 15.5), (36.6, 15.5),
|
||||
(37.4, 15.5), (39.2, 15.5)]] + [(x, z) for x, z, _ in stones] +
|
||||
@@ -212,6 +213,6 @@ def build(L):
|
||||
|
||||
def report(L):
|
||||
placed, skipped = L._galerie
|
||||
print('Galerie: %d Objekte: %s' % (len(placed), ', '.join(placed)))
|
||||
print('gallery: %d objects: %s' % (len(placed), ', '.join(placed)))
|
||||
if skipped:
|
||||
print('nicht im Basislevel: %s' % ', '.join(skipped))
|
||||
print('not in base level: %s' % ', '.join(skipped))
|
||||
+185
-184
@@ -1,221 +1,222 @@
|
||||
# Debug-Ansicht: Kollision & unsichtbare Objekte im Spiel (`tools/debugviz.py`)
|
||||
# Debug view: collision & invisible objects in game (`tools/debugviz.py`)
|
||||
|
||||
Stand: 2026-11-06. Zeigt in **allen 34 Level-WADs** die Kollisionsgeometrie (auch unsichtbare
|
||||
Wände/Böden, Todesebenen) und unsichtbare Logik-Objekte (Kameras, Trigger, Ausgänge,
|
||||
Checkpoints, Kraft-/Box-Zonen, Umgebungszonen …) direkt im Spiel – als farbiges Drahtgitter,
|
||||
unsichtbare Flächen zusätzlich halbtransparent gefüllt. Spielmechanik bleibt unverändert.
|
||||
As of: 2026-11-06. Shows, in **all 34 level WADs**, the collision geometry (including invisible
|
||||
walls/floors, death planes) and invisible logic objects (cameras, triggers, exits,
|
||||
checkpoints, force/box zones, environment zones …) directly in the game – as a coloured wireframe,
|
||||
invisible surfaces additionally filled semi-transparently. Game mechanics stay unchanged.
|
||||
|
||||
## Benutzung
|
||||
## Usage
|
||||
|
||||
```
|
||||
lama-mod debug an # Debug-WADs zuschalten (baut das Overlay neu, aktiviert damit auch andere Mods)
|
||||
lama-mod debug aus # wieder Original-Level (testlevel-Mod bleibt, wie sie war)
|
||||
lama-mod debug # Status
|
||||
lama-mod debug an # enable debug WADs (rebuilds the overlay, thereby also activating other mods)
|
||||
lama-mod debug aus # back to the original levels (the testlevel mod stays as it was)
|
||||
lama-mod debug # status
|
||||
```
|
||||
Wirkt beim nächsten Laden eines Levels (am besten Spiel neu starten). Nicht umschalten,
|
||||
während ein Level lädt. Mod-Ordner: `~/.wine-lama/mods/debug/wads/*.wad` (gleiche Namen wie
|
||||
auf der CD, z. B. `t1l1m001.wad`); die Datei `~/.wine-lama/mods/debug/.aus` = abgeschaltet.
|
||||
Reihenfolge im Overlay: alphabetisch, spätere gewinnen → `testlevel` (T1L9*) bleibt immer,
|
||||
Debug-WADs ersetzen nur die Original-Level. `lama-mod an|aus|status` funktionieren wie
|
||||
vorher (`an` überspringt Mod-Ordner mit `.aus`). Sicherung des alten Skripts:
|
||||
Takes effect the next time a level is loaded (best to restart the game). Do not toggle
|
||||
while a level is loading. Mod folder: `~/.wine-lama/mods/debug/wads/*.wad` (same names as
|
||||
on the CD, e.g. `t1l1m001.wad`); the file `~/.wine-lama/mods/debug/.aus` = disabled.
|
||||
Order in the overlay: alphabetical, later ones win → `testlevel` (T1L9*) always stays,
|
||||
debug WADs only replace the original levels. `lama-mod an|aus|status` work as
|
||||
before (`an` skips mod folders containing `.aus`). Backup of the old script:
|
||||
`deb-build/lama-mod.bak-vor-debug`.
|
||||
|
||||
Neu bauen (z. B. nach Änderungen an Farben):
|
||||
Rebuild (e.g. after changing colours):
|
||||
```
|
||||
python3 tools/debugviz.py --all ~/.wine-lama/mods/debug/wads --report /tmp/dv.json
|
||||
python3 tools/debugviz.py IN.wad OUT.wad [--no-fill] [--no-markers] [--no-coll] [--opaque]
|
||||
[--width 0.007] [--offset 0.012]
|
||||
```
|
||||
Laufzeit ≈ 10–40 s je Level.
|
||||
Run time ≈ 10–40 s per level.
|
||||
|
||||
## Farblegende
|
||||
## Colour legend
|
||||
|
||||
Alle Farben sind RGB (0–255). **Linien** = Drahtgitter (deckend), **Füllung** = halbtransparent
|
||||
(Alpha 96/255, Blendmodus 1 der Engine).
|
||||
All colours are RGB (0–255). **Lines** = wireframe (opaque), **fill** = semi-transparent
|
||||
(alpha 96/255, engine blend mode 1).
|
||||
|
||||
### Kollisionsflächen (Rahmen um jedes Kollisionspolygon der Welt-Kacheln)
|
||||
Kantenfarbe nach Lage, solange das Material 0 (normal) ist:
|
||||
### Collision surfaces (frame around every collision polygon of the world tiles)
|
||||
Edge colour by orientation, as long as the material is 0 (normal):
|
||||
|
||||
| Farbe | RGB | Bedeutung |
|
||||
| Colour | RGB | Meaning |
|
||||
|---|---|---|
|
||||
| **Grün** (Linien) | 40,255,40 | begehbarer Boden (Normale steil nach oben, ny ≥ 0,7) |
|
||||
| **Gelbgrün** (Linien) | 200,255,0 | Schräge (0,2 ≤ ny < 0,7) |
|
||||
| **Blau** (Linien) | 0,150,255 | Wand (−0,2 < ny < 0,2) |
|
||||
| **Orange** (Linien) | 255,130,0 | Decke / Überhang (ny ≤ −0,2) |
|
||||
| **Rot, halbtransparent gefüllt** | 255,0,0 | **unsichtbare Kollision**: keine Render-Fläche innerhalb ±0,5 Einheiten entlang der Normale (= unsichtbare Wand/Boden/Decke). Die Kantenfarbe zeigt weiterhin Lage bzw. Material. |
|
||||
| **Green** (lines) | 40,255,40 | walkable floor (normal pointing steeply up, ny ≥ 0.7) |
|
||||
| **Yellow-green** (lines) | 200,255,0 | slope (0.2 ≤ ny < 0.7) |
|
||||
| **Blue** (lines) | 0,150,255 | wall (−0.2 < ny < 0.2) |
|
||||
| **Orange** (lines) | 255,130,0 | ceiling / overhang (ny ≤ −0.2) |
|
||||
| **Red, filled semi-transparently** | 255,0,0 | **invisible collision**: no render surface within ±0.5 units along the normal (= invisible wall/floor/ceiling). The edge colour still shows orientation or material. |
|
||||
|
||||
Kollision mit Sondermaterial (Byte 1 des Kollisionspolygons ≠ 0): Kanten in Materialfarbe,
|
||||
bei unsichtbaren Polygonen auch die Füllung in Materialfarbe (statt Rot):
|
||||
Collision with a special material (byte 1 of the collision polygon ≠ 0): edges in the material colour,
|
||||
for invisible polygons the fill is also in the material colour (instead of red):
|
||||
|
||||
| Material | Farbe | RGB | Bedeutung |
|
||||
| Material | Colour | RGB | Meaning |
|
||||
|---|---|---|---|
|
||||
| 30 (0x1e) | **Magenta**, nur Linien (keine Füllung) | 255,0,255 | **kein Boden / Fall- und Todesebene**: Spieler fällt hindurch (FUN_004036d0 setzt Boden = keiner). Meist Modell 0 bei y ≈ −2,75 in jeder Rasterzelle unter den Inseln → magentafarbenes Gitter = Absturz-/Todeszone. |
|
||||
| 16 | **Violett**, immer gefüllt | 190,60,255 | Ausgangs-Boden: LevelDoor löst nur aus, wenn der Spieler auf Material 16 in der Türzelle steht |
|
||||
| 17 (0x11) | **Gelb** | 255,255,0 | selektive Kollision: wirkt nur für Objekte mit Laufzeit-Flag 0x8000000 (Strat+0xe8) |
|
||||
| 18 (0x12) | **Hellorange/Pfirsich** | 255,200,120 | selektive Kollision: wirkt NICHT für Objekte mit Flag 0x8000000 |
|
||||
| 1 | Türkis | 0,200,200 | Sondermaterial, Bedeutung unbekannt (Strat-Skripte werten es aus) |
|
||||
| 2 | Weiß | 255,255,255 | dto. |
|
||||
| 3 | Rosa | 255,120,170 | dto. (häufig in Welt 5 Berg) |
|
||||
| 5 | Mint | 120,255,200 | dto. (Fluss, Achterbahn) |
|
||||
| 6 | Lavendel | 160,120,255 | dto. |
|
||||
| 7 | Hellorange | 255,170,80 | dto. |
|
||||
| 8 | Dunkelgrün | 0,130,60 | dto. |
|
||||
| 9 | Hellblau | 150,200,255 | dto. |
|
||||
| 10 | Oliv | 150,150,0 | dto. |
|
||||
| 11 | Braun | 150,90,40 | dto. |
|
||||
| 13 | Lachs | 255,130,110 | dto. |
|
||||
| 15 | Hellgelb | 255,255,150 | dto. |
|
||||
| 20 | Petrol | 0,120,140 | dto. |
|
||||
| 26 | Dunkelviolett | 110,40,160 | dto. |
|
||||
| 27 | Silber | 190,190,190 | dto. (Verfolgungsjagd t1l2m003) |
|
||||
| 28 | Pink | 255,0,140 | dto. |
|
||||
| 33 | Dunkelrot | 150,0,0 | dto. |
|
||||
| 255 | Fast schwarz | 20,20,20 | dto. |
|
||||
| sonstige | Hellgrau | 230,230,230 | (kommt in den Originalen nicht vor) |
|
||||
| 30 (0x1e) | **Magenta**, lines only (no fill) | 255,0,255 | **no floor / fall and death plane**: the player falls through (FUN_004036d0 sets floor = none). Usually model 0 at y ≈ −2.75 in every grid cell below the islands → magenta grid = fall/death zone. |
|
||||
| 16 | **Violet**, always filled | 190,60,255 | exit floor: LevelDoor only triggers when the player stands on material 16 in the door cell |
|
||||
| 17 (0x11) | **Yellow** | 255,255,0 | selective collision: only affects objects with runtime flag 0x8000000 (strat+0xe8) |
|
||||
| 18 (0x12) | **Light orange/peach** | 255,200,120 | selective collision: does NOT affect objects with flag 0x8000000 |
|
||||
| 1 | Turquoise | 0,200,200 | special material, meaning unknown (evaluated by strat scripts) |
|
||||
| 2 | White | 255,255,255 | ditto |
|
||||
| 3 | Pink (rose) | 255,120,170 | ditto (common in world 5, mountain) |
|
||||
| 5 | Mint | 120,255,200 | ditto (river, roller coaster) |
|
||||
| 6 | Lavender | 160,120,255 | ditto |
|
||||
| 7 | Light orange | 255,170,80 | ditto |
|
||||
| 8 | Dark green | 0,130,60 | ditto |
|
||||
| 9 | Light blue | 150,200,255 | ditto |
|
||||
| 10 | Olive | 150,150,0 | ditto |
|
||||
| 11 | Brown | 150,90,40 | ditto |
|
||||
| 13 | Salmon | 255,130,110 | ditto |
|
||||
| 15 | Light yellow | 255,255,150 | ditto |
|
||||
| 20 | Petrol | 0,120,140 | ditto |
|
||||
| 26 | Dark violet | 110,40,160 | ditto |
|
||||
| 27 | Silver | 190,190,190 | ditto (chase t1l2m003) |
|
||||
| 28 | Hot pink | 255,0,140 | ditto |
|
||||
| 33 | Dark red | 150,0,0 | ditto |
|
||||
| 255 | Almost black | 20,20,20 | ditto |
|
||||
| other | Light grey | 230,230,230 | (does not occur in the originals) |
|
||||
|
||||
### Objekt-Marker (unsichtbare Strats, MAP-Strat-Einträge)
|
||||
Markerwürfel (10 cm) auf einem dünnen Pfahl 0,35 über dem Objekt-Ursprung; kurzer waagrechter
|
||||
Strich = Blickrichtung (Kameras, Checkpoints).
|
||||
### Object markers (invisible strats, MAP strat entries)
|
||||
Marker cube (10 cm) on a thin pole 0.35 above the object origin; short horizontal
|
||||
line = view direction (cameras, checkpoints).
|
||||
|
||||
| Farbe | RGB | Kategorie | Typen |
|
||||
| Colour | RGB | Category | Types |
|
||||
|---|---|---|---|
|
||||
| **Weiß**, Pfeil flach am Boden | 255,255,255 | Spielerstart (Richtung) | Llama, KuscoAndPachaTogetherOnATree (Fluss), RollerCoaster, LlamaChase |
|
||||
| **Violett** + Drahtwürfel 1×1×1 um die Rasterzelle | 190,60,255 | Ausgang (Auslösezelle) | LevelDoor, VillageEndDoor, EndGameDoor, TeleportDoor |
|
||||
| **Grüntürkis** | 0,255,140 | Checkpoint | CheckPointPost, CheckPointBox |
|
||||
| **Cyan** | 0,255,255 | Kamera | Camera* (CameraSide, CameraLift, CameraForceInsideBox …), CutSceneCamera*, RiverWaterfallCamera |
|
||||
| **Gelb** | 255,255,0 | Trigger | CutSceneTrigger*, *Trigger*, Say* (Sprüche), ChangeToBunny/Turtle, RiverStartSign |
|
||||
| **Orange** | 255,90,0 | Kraft-/Sonderzonen | Force*, FallOffNoHurt, ObjectDontDropInBox, RiverCurrentBox, RiverRebound, WaterReboundPoints*, SuckingWPs, ObjectSuckPoints, RiverTrackChange, TrackSwitch, RiverWhirlpool, BackgroundRotationChanger |
|
||||
| **Gold** | 255,200,0 | Geheimnis-Zone | Secret |
|
||||
| **Grau** | 170,170,170 | sonstige unsichtbare Steuer-Objekte | CheatTeleport, Wompy*, Spawner, Helper, Coin-Linien/-Kreise, Rain, DrippingWater, WaterfallMist, MusicBox, FlockOfBirds, Bats, Scorpions*, AppearingPlatforms* … |
|
||||
| **Dunkelgrau**, kleiner Punkt (6 cm) | 90,90,90 | alle übrigen Objekte (Ursprung) | Münzen, Gegner, Türen … – damit wirklich nichts fehlt |
|
||||
| **Rosa** Drahtwürfel je Rasterzelle | 255,100,180 | Umgebungs-/Musik-/Nebelzone | MAP-Block 4 (90-Byte-Einträge, Zelle x/y/z) |
|
||||
| **White**, arrow flat on the ground | 255,255,255 | player start (direction) | Llama, KuscoAndPachaTogetherOnATree (river), RollerCoaster, LlamaChase |
|
||||
| **Violet** + wire cube 1×1×1 around the grid cell | 190,60,255 | exit (trigger cell) | LevelDoor, VillageEndDoor, EndGameDoor, TeleportDoor |
|
||||
| **Green-turquoise** | 0,255,140 | checkpoint | CheckPointPost, CheckPointBox |
|
||||
| **Cyan** | 0,255,255 | camera | Camera* (CameraSide, CameraLift, CameraForceInsideBox …), CutSceneCamera*, RiverWaterfallCamera |
|
||||
| **Yellow** | 255,255,0 | trigger | CutSceneTrigger*, *Trigger*, Say* (voice lines), ChangeToBunny/Turtle, RiverStartSign |
|
||||
| **Orange** | 255,90,0 | force/special zones | Force*, FallOffNoHurt, ObjectDontDropInBox, RiverCurrentBox, RiverRebound, WaterReboundPoints*, SuckingWPs, ObjectSuckPoints, RiverTrackChange, TrackSwitch, RiverWhirlpool, BackgroundRotationChanger |
|
||||
| **Gold** | 255,200,0 | secret zone | Secret |
|
||||
| **Grey** | 170,170,170 | other invisible control objects | CheatTeleport, Wompy*, Spawner, Helper, coin lines/circles, Rain, DrippingWater, WaterfallMist, MusicBox, FlockOfBirds, Bats, Scorpions*, AppearingPlatforms* … |
|
||||
| **Dark grey**, small dot (6 cm) | 90,90,90 | all remaining objects (origin) | coins, enemies, doors … – so that really nothing is missing |
|
||||
| **Pink** wire cube per grid cell | 255,100,180 | environment/music/fog zone | MAP block 4 (90-byte entries, cell x/y/z) |
|
||||
|
||||
**Zonen-Boxen:** hat ein Marker-Objekt genau 2 Wegpunkte und liegt das Objekt im Rechteck
|
||||
dazwischen, ist das ein Box-Bereich (Secret, CameraSide/Lift, ObjectDontDropInBox,
|
||||
FallOffNoHurt, RiverCurrentBox …): Drahtquader in der Kategoriefarbe von min(y) bis max(y)+1,2
|
||||
(die Höhe ist geschätzt – der Code prüft vermutlich nur x/z), bei Flächen ≤ 64 Einheiten² mit
|
||||
halbtransparentem Boden (Alpha 70).
|
||||
**Wegpunkt-Ketten** anderer Marker-Objekte: kleine Würfel (5 cm) in abgedunkelter
|
||||
Kategoriefarbe (60 %), bei Kraftzonen mit Verbindungslinien (z. B. WaterReboundPoints-Pfade,
|
||||
CutSceneTrigger-Kamerafahrt nur als Punkte).
|
||||
**Zone boxes:** if a marker object has exactly 2 waypoints and the object lies in the rectangle
|
||||
between them, it is a box area (Secret, CameraSide/Lift, ObjectDontDropInBox,
|
||||
FallOffNoHurt, RiverCurrentBox …): wire cuboid in the category colour from min(y) to max(y)+1.2
|
||||
(the height is estimated – the code probably only checks x/z), for areas ≤ 64 units² with a
|
||||
semi-transparent floor (alpha 70).
|
||||
**Waypoint chains** of other marker objects: small cubes (5 cm) in a darkened
|
||||
category colour (60 %), for force zones with connecting lines (e.g. WaterReboundPoints paths,
|
||||
CutSceneTrigger camera moves only as points).
|
||||
|
||||
## Wie es gebaut ist (Entscheidungen)
|
||||
## How it is built (decisions)
|
||||
|
||||
* **Kollisions-Overlay an die TRAK-Modelle angehängt** (nicht als eigene Kacheln): pro
|
||||
Kollisionspolygon ein Rahmen aus 2·n Vertices (äußere Ecken + um die Linienbreite nach innen
|
||||
versetzte Ecken, aus den Kantenebenen berechnet), 2·n Dreiecke; Füllung nutzt die inneren
|
||||
Ecken mit. Alles 0,012 entlang der Flächennormale versetzt (kein Z-Fighting). Dadurch stimmen
|
||||
Kachel-Transformation, Zonen-/Sichtbarkeit und Kachel-Wechsel (Alt-Modelle, WFPC 0x10000)
|
||||
automatisch. Kollision wird aus dem Spielraum zurückgerechnet (geomlib.coll_decode/
|
||||
coll_polygon, z gespiegelt).
|
||||
* Face-Flags der Overlay-Dreiecke: 0x800 | 0x400 (doppelseitig) | 0x4000 (Vertexfarben
|
||||
ignorieren) | 0x8 bei jedem Texturwechsel und spätestens alle 250 Faces (Render-Batch).
|
||||
* **Farben** = angehängte Flachfarb-Texturen (TEXT-Tabelle, Seite −1): deckend Flags 0x105,
|
||||
halbtransparent **0x1105** (Bits 12–14 = 1 → Tabelle 0x573eb8: Blendmodus 1 =
|
||||
SRCALPHA/INVSRCALPHA, Alpha aus dem 4. Byte (v1) des Eintrags; im Spiel geprüft).
|
||||
Halbtransparente Dreiecke laufen über den Sortierpuffer (FUN_0041ed90, 512 KB/Frame ≈ 5000
|
||||
Dreiecke; ist er voll, fallen sie einfach weg – kein Absturz). Deshalb gibt es Füllungen nur
|
||||
für unsichtbare Kollision, Material 16 und kleine Zonenböden.
|
||||
* **Vertexfarben-Block** in MAP: je Kachel (und je Farbsatz/Frame, auch für Alt-Modelle) um
|
||||
nV(Overlay) Einträge 0x00808080 verlängert (Byte 3 = 0, ändert die Frame-Anzahl nicht).
|
||||
* **Unsichtbar-Erkennung**: Render-Dreiecke aller Kacheln in Weltkoordinaten, nach Zellen
|
||||
gebuckelt; für Mittelpunkt + 4 Punkte (70 % Richtung Ecken) jedes Kollisionspolygons wird ein
|
||||
Strahl entlang der Normale (±0,5) gegen Dreiecke mit ähnlicher Ausrichtung (|cos| > 0,4)
|
||||
getestet; < 60 % Treffer = unsichtbar. Je Modell Mehrheitsentscheid über bis zu 6 Instanzen.
|
||||
Strat-Modelle (STPC: Türen, Plattformen, Figuren) zählen NICHT als Render-Fläche – Kollision
|
||||
hinter beweglichen Objekten kann daher rot erscheinen.
|
||||
(Erste Version mit 0,06/0,15 Abstand markierte ganze Hauswände und Felswände rot, weil die
|
||||
Kollisionshülle 0,2–0,4 vor der sichtbaren Wand liegt – zu viel Rauschen.)
|
||||
* **Marker** als neue Kacheln ohne Kollision, je Rasterzelle ein Modell (Linien in ≤ 1-Stücke
|
||||
zerteilt und in die Zelle ihres Mittelpunkts gelegt) → bekommen automatisch die Zone der
|
||||
Zelle. Neue Kacheln werden hinten angehängt (Indizes der Original-Kacheln bleiben).
|
||||
* **Spielmechanik unverändert**: keine Kollisionspolygone hinzugefügt/geändert, Strats,
|
||||
Wegpunkte, Parameterpool, Zonen und Rest von MAP byte-gleich (wird nach dem Schreiben
|
||||
geprüft: Strats/Knoten/Rest/Original-Kacheln identisch).
|
||||
* Größe: WADs wachsen um 0,1–4 MB (max. 8,9 MB, t1l6m003). Level-Speicher (Arena 16 MB,
|
||||
FUN_0041ef00) hält TRAK+STPC+MAP – reicht für alle (Ladetest).
|
||||
* **Collision overlay appended to the TRAK models** (not as separate tiles): per
|
||||
collision polygon a frame of 2·n vertices (outer corners + corners offset inwards by the line
|
||||
width, computed from the edge planes), 2·n triangles; the fill reuses the inner
|
||||
corners. Everything offset by 0.012 along the surface normal (no z-fighting). This way
|
||||
tile transform, zones/visibility and tile swaps (alt models, WFPC 0x10000) are
|
||||
automatically correct. Collision is converted back from game space (geomlib.coll_decode/
|
||||
coll_polygon, z mirrored).
|
||||
* Face flags of the overlay triangles: 0x800 | 0x400 (double-sided) | 0x4000 (ignore vertex
|
||||
colours) | 0x8 at every texture change and at the latest every 250 faces (render batch).
|
||||
* **Colours** = appended flat-colour textures (TEXT table, page −1): opaque flags 0x105,
|
||||
semi-transparent **0x1105** (bits 12–14 = 1 → table 0x573eb8: blend mode 1 =
|
||||
SRCALPHA/INVSRCALPHA, alpha from the 4th byte (v1) of the entry; checked in game).
|
||||
Semi-transparent triangles go through the sort buffer (FUN_0041ed90, 512 KB/frame ≈ 5000
|
||||
triangles; when it is full they are simply dropped – no crash). That is why fills exist only
|
||||
for invisible collision, material 16 and small zone floors.
|
||||
* **Vertex colour block** in MAP: extended per tile (and per colour set/frame, also for alt models) by
|
||||
nV(overlay) entries 0x00808080 (byte 3 = 0, does not change the frame count).
|
||||
* **Invisibility detection**: render triangles of all tiles in world coordinates, bucketed by
|
||||
cell; for the centre + 4 points (70 % towards the corners) of every collision polygon a
|
||||
ray along the normal (±0.5) is tested against triangles with similar orientation (|cos| > 0.4);
|
||||
< 60 % hits = invisible. Majority vote per model over up to 6 instances.
|
||||
Strat models (STPC: doors, platforms, characters) do NOT count as render surfaces – collision
|
||||
behind moving objects may therefore appear red.
|
||||
(The first version with 0.06/0.15 distance marked entire house walls and cliff walls red, because the
|
||||
collision hull lies 0.2–0.4 in front of the visible wall – too much noise.)
|
||||
* **Markers** as new tiles without collision, one model per grid cell (lines split into pieces ≤ 1
|
||||
and placed in the cell of their midpoint) → automatically get the zone of the
|
||||
cell. New tiles are appended at the end (indices of the original tiles stay).
|
||||
* **Game mechanics unchanged**: no collision polygons added/changed, strats,
|
||||
waypoints, parameter pool, zones and the rest of MAP byte-identical (checked after writing:
|
||||
strats/nodes/rest/original tiles identical).
|
||||
* Size: WADs grow by 0.1–4 MB (max. 8.9 MB, t1l6m003). Level memory (16 MB arena,
|
||||
FUN_0041ef00) holds TRAK+STPC+MAP – enough for all (load test).
|
||||
|
||||
## Grenzen / bekannt
|
||||
* Kollision der **Objekt-Modelle** (STPC: bewegliche Plattformen, Türen, Kisten …) wird nicht
|
||||
gezeigt – die bewegen sich; nur ihr Ursprung als dunkelgrauer Punkt.
|
||||
* Zonen-Höhen der Box-Bereiche sind geschätzt; ob die Engine y prüft, ist offen.
|
||||
* Bedeutung der meisten Sondermaterialien unbekannt (nur Farbe + Anzahl, s. Tabelle unten);
|
||||
Material 30 = kein Boden, 16 = Ausgang, 17/18 = selektiv sicher aus dem Code.
|
||||
* Umgebungszonen (MAP-Block 4): Zelle sicher, Bedeutung (Nebel/Musik/Kamera) nur mittel sicher;
|
||||
Vorgabe-Eintrag (Position 1,1,1) wird nicht gezeichnet.
|
||||
* Liegt die Kamera dicht an einem Marker/einer Linie, zerschneidet der Near-Plane-Clip der
|
||||
Engine die Dreiecke manchmal zu großen Fetzen (nur kurz sichtbar).
|
||||
* Leistung: unter Wine/llvmpipe (Xvfb) ohne spürbaren Einbruch; auf echter GPU unkritisch
|
||||
(≤ 2000 zusätzliche Vertices je Kachelmodell, Ø ~1–2× so viele Dreiecke wie das Original).
|
||||
* Testlevel T1L9 (Testwelt) hat keine Debug-Version (testlevel-Mod hat Vorrang).
|
||||
## Limits / known issues
|
||||
* Collision of the **object models** (STPC: moving platforms, doors, crates …) is not
|
||||
shown – they move; only their origin as a dark grey dot.
|
||||
* Zone heights of the box areas are estimated; whether the engine checks y is open.
|
||||
* Meaning of most special materials unknown (only colour + count, see table below);
|
||||
material 30 = no floor, 16 = exit, 17/18 = selective are certain from the code.
|
||||
* Environment zones (MAP block 4): cell certain, meaning (fog/music/camera) only moderately certain;
|
||||
the default entry (position 1,1,1) is not drawn.
|
||||
* If the camera is close to a marker/line, the engine's near-plane clip sometimes cuts the
|
||||
triangles into large shreds (only briefly visible).
|
||||
* Performance: no noticeable drop under Wine/llvmpipe (Xvfb); uncritical on a real GPU
|
||||
(≤ 2000 extra vertices per tile model, on average ~1–2× as many triangles as the original).
|
||||
* Test level T1L9 (test world) has no debug version (the testlevel mod takes precedence).
|
||||
|
||||
## Statistik je Level (Stand Build 2026-11-06)
|
||||
## Statistics per level (build of 2026-11-06)
|
||||
|
||||
| Level | Welt | WAD alt→neu (KB) | Koll.-Polygone | davon unsichtbar | Sondermaterialien (Anzahl Polygone) | Marker |
|
||||
Marker categories are the internal keys of `debugviz.py`: ausgang = exit, geheimnis = secret,
|
||||
kamera = camera, kraft = force zone, objekt = other object, sonst = other control object,
|
||||
umgebung = environment zone.
|
||||
|
||||
| Level | World | WAD old→new (KB) | Coll. polygons | of which invisible | Special materials (polygon count) | Markers |
|
||||
|---|---|---|---|---|---|---|
|
||||
| t1l1m001 | Dorf | 6158→8235 | 3474 | 215 | 2:138, 6:4, 8:35, 9:13, 16:8, 17:5, 18:84, 28:19, 30:1 | ausgang 1, checkpoint 6, geheimnis 2, kamera 6, kraft 6, objekt 95, sonst 4, start 1, trigger 1, umgebung 12 |
|
||||
| t1l1m002 | Dorf | 5471→7573 | 3909 | 130 | 6:4, 8:14, 16:9, 18:65, 28:23, 30:1 | ausgang 1, checkpoint 5, geheimnis 1, kamera 8, kraft 5, objekt 89, sonst 9, start 1, trigger 1, umgebung 15 |
|
||||
| t1l1m003 | Dorf | 5891→8348 | 3893 | 79 | 2:15, 6:4, 8:8, 16:5, 17:3, 18:458, 20:6, 28:23, 30:1 | ausgang 1, kamera 3, kraft 6, objekt 45, sonst 3, start 1, trigger 2, umgebung 4 |
|
||||
| t1l1m004 | Dorf | 5866→7006 | 2169 | 60 | 2:15, 8:8, 16:16, 17:3, 18:90, 30:1 | ausgang 2, geheimnis 1, kamera 2, kraft 1, objekt 31, sonst 2, start 1, trigger 1, umgebung 5 |
|
||||
| t1l2m001 | Dschungelnacht | 5094→7491 | 3933 | 460 | 1:26, 2:111, 6:156, 9:34, 16:22, 17:24, 18:284, 20:80, 28:28, 30:138, 33:1 | ausgang 1, checkpoint 2, geheimnis 1, kamera 3, kraft 6, objekt 39, sonst 8, start 1, trigger 1, umgebung 8 |
|
||||
| t1l2m002 | Dschungelnacht | 5329→7561 | 3715 | 389 | 1:216, 2:57, 6:154, 9:24, 16:34, 17:23, 18:319, 28:28, 30:50 | ausgang 1, checkpoint 4, geheimnis 1, kamera 3, kraft 7, objekt 105, sonst 2, start 1, trigger 1, umgebung 18 |
|
||||
| t1l2m003 | Dschungelnacht (Verfolgung) | 2837→5142 | 4844 | 630 | 5:3, 9:6, 10:6, 11:37, 16:26, 27:206, 30:46 | kamera 2, kraft 1, objekt 33, sonst 4, start 1, trigger 1 |
|
||||
| t1l3m001 | Fluss | 4099→8220 | 8371 | 1000 | 5:26, 16:8, 18:23 | ausgang 2, geheimnis 1, kamera 13, kraft 4, objekt 140, sonst 13, start 1, trigger 5 |
|
||||
| t1l3m002 | Fluss | 3799→8249 | 8824 | 1097 | 5:40, 16:5, 18:23, 30:1 | ausgang 2, geheimnis 1, kamera 19, kraft 44, objekt 115, sonst 39, start 1, trigger 6 |
|
||||
| t1l3m003 | Fluss | 4497→8537 | 8387 | 868 | 5:56, 16:5 | ausgang 4, geheimnis 1, kamera 15, kraft 7, objekt 52, sonst 15, start 1, trigger 3 |
|
||||
| t1l3m004 | Fluss | 2674→3754 | 2201 | 228 | 5:18, 16:5 | ausgang 2, kamera 5, kraft 4, objekt 31, sonst 7, start 1, trigger 2 |
|
||||
| t1l4m001 | Dschungeltag | 5469→7380 | 3214 | 230 | 1:148, 6:105, 10:33, 16:18, 17:33, 18:158, 20:25, 28:24, 30:10 | ausgang 1, checkpoint 9, geheimnis 1, kamera 5, kraft 12, objekt 120, sonst 3, start 1, trigger 1, umgebung 10 |
|
||||
| t1l4m002 | Dschungeltag | 5807→8045 | 3820 | 157 | 1:186, 6:123, 10:80, 16:33, 17:55, 18:309, 20:6, 28:98, 30:22 | ausgang 1, checkpoint 7, geheimnis 2, kamera 1, kraft 14, objekt 135, sonst 3, start 1, trigger 1, umgebung 14 |
|
||||
| t1l4m003 | Dschungeltag | 6320→8886 | 4805 | 135 | 1:174, 2:52, 6:10, 10:33, 16:36, 17:110, 18:890, 20:186, 28:27, 30:33, 255:8 | ausgang 1, checkpoint 3, geheimnis 2, kamera 6, kraft 4, objekt 99, sonst 3, start 1, trigger 1, umgebung 16 |
|
||||
| t1l5m001 | Berg | 6090→8613 | 4419 | 188 | 3:356, 8:96, 16:24, 17:35, 18:539, 20:66, 28:6, 30:87 | ausgang 1, checkpoint 3, geheimnis 2, kamera 5, kraft 2, objekt 83, sonst 7, start 1, trigger 1, umgebung 15 |
|
||||
| t1l5m002 | Berg | 5949→8558 | 5242 | 220 | 2:20, 3:396, 8:23, 9:15, 16:19, 17:33, 18:342, 20:57, 30:120 | ausgang 1, checkpoint 3, geheimnis 4, kamera 10, kraft 3, objekt 67, sonst 6, start 1, trigger 1, umgebung 7 |
|
||||
| t1l5m003 | Berg | 4989→6796 | 3399 | 98 | 3:232, 8:223, 16:17, 18:20, 20:6, 30:78 | ausgang 1, checkpoint 5, geheimnis 1, kamera 4, kraft 1, objekt 71, sonst 6, start 1, trigger 1, umgebung 2 |
|
||||
| t1l6m001 | Stadt | 6210→8227 | 2857 | 169 | 6:29, 16:18, 17:140, 18:110, 30:38 | ausgang 1, checkpoint 4, geheimnis 1, kamera 5, kraft 3, objekt 83, sonst 3, start 1, trigger 5, umgebung 20 |
|
||||
| t1l6m002 | Stadt | 5155→7060 | 1771 | 106 | 6:56, 16:9, 17:20, 18:39, 30:17 | ausgang 1, checkpoint 10, geheimnis 1, kamera 7, kraft 9, objekt 127, sonst 16, start 1, trigger 5, umgebung 46 |
|
||||
| t1l6m003 | Stadt | 6693→8924 | 2462 | 148 | 1:39, 16:13, 17:80, 18:154, 28:3, 30:10 | ausgang 1, checkpoint 11, geheimnis 6, kamera 11, kraft 6, objekt 107, sonst 15, start 1, trigger 3, umgebung 59 |
|
||||
| t1l7m001 | Katakomben (Achterbahn) | 3054→6705 | 4043 | 20 | 5:54, 11:18, 13:18, 16:9, 30:18 | kamera 1, objekt 2, sonst 36, start 1, trigger 1, umgebung 14 |
|
||||
| t1l7m002 | Katakomben | 5280→7722 | 1795 | 111 | 2:1, 11:3, 16:44, 30:93 | ausgang 3, checkpoint 12, geheimnis 5, kamera 6, kraft 4, objekt 186, sonst 12, start 1, trigger 1, umgebung 23 |
|
||||
| t1l7m003 | Katakomben (Achterbahn) | 2496→5140 | 1999 | 11 | 5:54, 11:90, 13:36, 15:18, 16:9, 30:9 | kamera 1, objekt 11, sonst 41, start 1, trigger 1 |
|
||||
| t1l7m004 | Katakomben | 5299→6978 | 1892 | 61 | 2:7, 16:32, 30:70 | ausgang 1, checkpoint 3, geheimnis 3, kamera 4, kraft 7, objekt 93, sonst 10, start 1, trigger 2, umgebung 15 |
|
||||
| t1l7m005 | Katakomben (Achterbahn) | 2390→4782 | 1885 | 9 | 5:54, 11:36, 13:36, 16:9, 30:9 | kamera 1, objekt 1, sonst 51, start 1, trigger 1, umgebung 9 |
|
||||
| t1l8m001 | Labor | 6632→8824 | 3396 | 185 | 1:40, 8:9, 16:16, 17:8, 18:181, 30:56 | ausgang 1, checkpoint 2, geheimnis 2, kamera 8, kraft 2, objekt 99, sonst 3, start 1, trigger 2, umgebung 19 |
|
||||
| t1l8m002 | Labor | 5427→7112 | 2204 | 76 | 16:1, 17:44, 18:496, 30:42 | ausgang 1, geheimnis 1, kamera 5, kraft 2, objekt 74, sonst 2, start 1, trigger 3, umgebung 1 |
|
||||
| t1l8m003 | Labor | 4656→5546 | 1398 | 34 | 16:9, 17:10, 18:196, 30:21 | ausgang 1, checkpoint 2, geheimnis 1, kamera 2, kraft 2, objekt 36, sonst 2, start 1, trigger 3, umgebung 6 |
|
||||
| t1l8m004 | Labor | 5817→8407 | 2993 | 124 | 9:10, 16:66, 17:1, 30:57 | ausgang 1, checkpoint 7, geheimnis 3, kamera 12, kraft 6, objekt 164, sonst 3, start 1, trigger 7, umgebung 46 |
|
||||
| t1l8m005 | Labor | 5375→8660 | 4041 | 179 | 7:10, 16:78, 17:1, 30:82 | checkpoint 7, geheimnis 1, kamera 14, kraft 5, objekt 113, sonst 15, start 1, trigger 2, umgebung 47 |
|
||||
| t1l8m006 | Labor | 5423→5948 | 738 | 44 | 2:13, 8:24, 16:3, 17:5, 18:27, 26:4 | ausgang 1, objekt 15, start 1, trigger 17 |
|
||||
| t6l6m001 | Sonder (Optionen/Karte) | 1331→1482 | 333 | 0 | | kamera 1, objekt 3 |
|
||||
| t6l6m002 | Sonder (Optionen/Karte) | 3605→3640 | 64 | 0 | | kamera 1, objekt 2 |
|
||||
| t1l1m001 | Village | 6158→8235 | 3474 | 215 | 2:138, 6:4, 8:35, 9:13, 16:8, 17:5, 18:84, 28:19, 30:1 | ausgang 1, checkpoint 6, geheimnis 2, kamera 6, kraft 6, objekt 95, sonst 4, start 1, trigger 1, umgebung 12 |
|
||||
| t1l1m002 | Village | 5471→7573 | 3909 | 130 | 6:4, 8:14, 16:9, 18:65, 28:23, 30:1 | ausgang 1, checkpoint 5, geheimnis 1, kamera 8, kraft 5, objekt 89, sonst 9, start 1, trigger 1, umgebung 15 |
|
||||
| t1l1m003 | Village | 5891→8348 | 3893 | 79 | 2:15, 6:4, 8:8, 16:5, 17:3, 18:458, 20:6, 28:23, 30:1 | ausgang 1, kamera 3, kraft 6, objekt 45, sonst 3, start 1, trigger 2, umgebung 4 |
|
||||
| t1l1m004 | Village | 5866→7006 | 2169 | 60 | 2:15, 8:8, 16:16, 17:3, 18:90, 30:1 | ausgang 2, geheimnis 1, kamera 2, kraft 1, objekt 31, sonst 2, start 1, trigger 1, umgebung 5 |
|
||||
| t1l2m001 | Jungle night | 5094→7491 | 3933 | 460 | 1:26, 2:111, 6:156, 9:34, 16:22, 17:24, 18:284, 20:80, 28:28, 30:138, 33:1 | ausgang 1, checkpoint 2, geheimnis 1, kamera 3, kraft 6, objekt 39, sonst 8, start 1, trigger 1, umgebung 8 |
|
||||
| t1l2m002 | Jungle night | 5329→7561 | 3715 | 389 | 1:216, 2:57, 6:154, 9:24, 16:34, 17:23, 18:319, 28:28, 30:50 | ausgang 1, checkpoint 4, geheimnis 1, kamera 3, kraft 7, objekt 105, sonst 2, start 1, trigger 1, umgebung 18 |
|
||||
| t1l2m003 | Jungle night (chase) | 2837→5142 | 4844 | 630 | 5:3, 9:6, 10:6, 11:37, 16:26, 27:206, 30:46 | kamera 2, kraft 1, objekt 33, sonst 4, start 1, trigger 1 |
|
||||
| t1l3m001 | River | 4099→8220 | 8371 | 1000 | 5:26, 16:8, 18:23 | ausgang 2, geheimnis 1, kamera 13, kraft 4, objekt 140, sonst 13, start 1, trigger 5 |
|
||||
| t1l3m002 | River | 3799→8249 | 8824 | 1097 | 5:40, 16:5, 18:23, 30:1 | ausgang 2, geheimnis 1, kamera 19, kraft 44, objekt 115, sonst 39, start 1, trigger 6 |
|
||||
| t1l3m003 | River | 4497→8537 | 8387 | 868 | 5:56, 16:5 | ausgang 4, geheimnis 1, kamera 15, kraft 7, objekt 52, sonst 15, start 1, trigger 3 |
|
||||
| t1l3m004 | River | 2674→3754 | 2201 | 228 | 5:18, 16:5 | ausgang 2, kamera 5, kraft 4, objekt 31, sonst 7, start 1, trigger 2 |
|
||||
| t1l4m001 | Jungle day | 5469→7380 | 3214 | 230 | 1:148, 6:105, 10:33, 16:18, 17:33, 18:158, 20:25, 28:24, 30:10 | ausgang 1, checkpoint 9, geheimnis 1, kamera 5, kraft 12, objekt 120, sonst 3, start 1, trigger 1, umgebung 10 |
|
||||
| t1l4m002 | Jungle day | 5807→8045 | 3820 | 157 | 1:186, 6:123, 10:80, 16:33, 17:55, 18:309, 20:6, 28:98, 30:22 | ausgang 1, checkpoint 7, geheimnis 2, kamera 1, kraft 14, objekt 135, sonst 3, start 1, trigger 1, umgebung 14 |
|
||||
| t1l4m003 | Jungle day | 6320→8886 | 4805 | 135 | 1:174, 2:52, 6:10, 10:33, 16:36, 17:110, 18:890, 20:186, 28:27, 30:33, 255:8 | ausgang 1, checkpoint 3, geheimnis 2, kamera 6, kraft 4, objekt 99, sonst 3, start 1, trigger 1, umgebung 16 |
|
||||
| t1l5m001 | Mountain | 6090→8613 | 4419 | 188 | 3:356, 8:96, 16:24, 17:35, 18:539, 20:66, 28:6, 30:87 | ausgang 1, checkpoint 3, geheimnis 2, kamera 5, kraft 2, objekt 83, sonst 7, start 1, trigger 1, umgebung 15 |
|
||||
| t1l5m002 | Mountain | 5949→8558 | 5242 | 220 | 2:20, 3:396, 8:23, 9:15, 16:19, 17:33, 18:342, 20:57, 30:120 | ausgang 1, checkpoint 3, geheimnis 4, kamera 10, kraft 3, objekt 67, sonst 6, start 1, trigger 1, umgebung 7 |
|
||||
| t1l5m003 | Mountain | 4989→6796 | 3399 | 98 | 3:232, 8:223, 16:17, 18:20, 20:6, 30:78 | ausgang 1, checkpoint 5, geheimnis 1, kamera 4, kraft 1, objekt 71, sonst 6, start 1, trigger 1, umgebung 2 |
|
||||
| t1l6m001 | City | 6210→8227 | 2857 | 169 | 6:29, 16:18, 17:140, 18:110, 30:38 | ausgang 1, checkpoint 4, geheimnis 1, kamera 5, kraft 3, objekt 83, sonst 3, start 1, trigger 5, umgebung 20 |
|
||||
| t1l6m002 | City | 5155→7060 | 1771 | 106 | 6:56, 16:9, 17:20, 18:39, 30:17 | ausgang 1, checkpoint 10, geheimnis 1, kamera 7, kraft 9, objekt 127, sonst 16, start 1, trigger 5, umgebung 46 |
|
||||
| t1l6m003 | City | 6693→8924 | 2462 | 148 | 1:39, 16:13, 17:80, 18:154, 28:3, 30:10 | ausgang 1, checkpoint 11, geheimnis 6, kamera 11, kraft 6, objekt 107, sonst 15, start 1, trigger 3, umgebung 59 |
|
||||
| t1l7m001 | Catacombs (roller coaster) | 3054→6705 | 4043 | 20 | 5:54, 11:18, 13:18, 16:9, 30:18 | kamera 1, objekt 2, sonst 36, start 1, trigger 1, umgebung 14 |
|
||||
| t1l7m002 | Catacombs | 5280→7722 | 1795 | 111 | 2:1, 11:3, 16:44, 30:93 | ausgang 3, checkpoint 12, geheimnis 5, kamera 6, kraft 4, objekt 186, sonst 12, start 1, trigger 1, umgebung 23 |
|
||||
| t1l7m003 | Catacombs (roller coaster) | 2496→5140 | 1999 | 11 | 5:54, 11:90, 13:36, 15:18, 16:9, 30:9 | kamera 1, objekt 11, sonst 41, start 1, trigger 1 |
|
||||
| t1l7m004 | Catacombs | 5299→6978 | 1892 | 61 | 2:7, 16:32, 30:70 | ausgang 1, checkpoint 3, geheimnis 3, kamera 4, kraft 7, objekt 93, sonst 10, start 1, trigger 2, umgebung 15 |
|
||||
| t1l7m005 | Catacombs (roller coaster) | 2390→4782 | 1885 | 9 | 5:54, 11:36, 13:36, 16:9, 30:9 | kamera 1, objekt 1, sonst 51, start 1, trigger 1, umgebung 9 |
|
||||
| t1l8m001 | Lab | 6632→8824 | 3396 | 185 | 1:40, 8:9, 16:16, 17:8, 18:181, 30:56 | ausgang 1, checkpoint 2, geheimnis 2, kamera 8, kraft 2, objekt 99, sonst 3, start 1, trigger 2, umgebung 19 |
|
||||
| t1l8m002 | Lab | 5427→7112 | 2204 | 76 | 16:1, 17:44, 18:496, 30:42 | ausgang 1, geheimnis 1, kamera 5, kraft 2, objekt 74, sonst 2, start 1, trigger 3, umgebung 1 |
|
||||
| t1l8m003 | Lab | 4656→5546 | 1398 | 34 | 16:9, 17:10, 18:196, 30:21 | ausgang 1, checkpoint 2, geheimnis 1, kamera 2, kraft 2, objekt 36, sonst 2, start 1, trigger 3, umgebung 6 |
|
||||
| t1l8m004 | Lab | 5817→8407 | 2993 | 124 | 9:10, 16:66, 17:1, 30:57 | ausgang 1, checkpoint 7, geheimnis 3, kamera 12, kraft 6, objekt 164, sonst 3, start 1, trigger 7, umgebung 46 |
|
||||
| t1l8m005 | Lab | 5375→8660 | 4041 | 179 | 7:10, 16:78, 17:1, 30:82 | checkpoint 7, geheimnis 1, kamera 14, kraft 5, objekt 113, sonst 15, start 1, trigger 2, umgebung 47 |
|
||||
| t1l8m006 | Lab | 5423→5948 | 738 | 44 | 2:13, 8:24, 16:3, 17:5, 18:27, 26:4 | ausgang 1, objekt 15, start 1, trigger 17 |
|
||||
| t6l6m001 | Special (options/map) | 1331→1482 | 333 | 0 | | kamera 1, objekt 3 |
|
||||
| t6l6m002 | Special (options/map) | 3605→3640 | 64 | 0 | | kamera 1, objekt 2 |
|
||||
|
||||
(Kollisionspolygone je TRAK-Modell gezählt, nicht je Kachel-Instanz.)
|
||||
(Collision polygons counted per TRAK model, not per tile instance.)
|
||||
|
||||
## Tests (2026-11-06)
|
||||
|
||||
**Offline (alle 33 Debug-WADs, `$JOB/tmp/dv/offcheck.py`):** TRAK parst und baut byte-gleich
|
||||
zurück; ≤ 8000 Vertices je Modell (max. 2012, t1l4m003); Render-Batches ≤ 256 Faces; alle
|
||||
Texturindizes < Tabellengröße; Vertexfarben-Block je Kachel = nV(Modell) (alle Frames);
|
||||
MAP mit `mapchunk` vollständig geparst (Rest 0 Byte) und byte-gleich neu gebaut; Strats,
|
||||
Wegpunkte, Parameterpool, Umgebungszonen, Zonentabelle/-raster, Alt-Block und Original-Kacheln
|
||||
unverändert; Original-Modelle (Vertices, Faces, **Kollision**) byte-genau unverändert; neue
|
||||
Marker-Modelle ohne Kollision; alle anderen Chunks (STPC, Sound, LGPC …) identisch;
|
||||
Kacheln im Raster. TRAK+STPC+MAP+LGPC ≤ 7,6 MB (Level-Arena 16 MB).
|
||||
|
||||
**Im Spiel geladen** (lamatest, über Slot Welt 9 als T1L9M001.wad, ~40–90 s gespielt/gedreht,
|
||||
kein Absturz, kein Hänger, keine Fehler im Wine-Log): t1l1m001 (mehrfach, Sichtprüfung),
|
||||
t1l1m003, t1l1m004, t1l2m001, t1l2m002, t1l3m001–004 (Fluss läuft auch im Slot 9 normal:
|
||||
Boot fährt), t1l4m001–003, t1l5m001–003, t1l6m001–003 = 19 Level.
|
||||
Getestet wurde der Build direkt vor dem endgültigen: identische Geometrie, nur die Farbwerte
|
||||
der Sondermaterialien wurden danach auf feste Farben je Material umgestellt (+8–16 Byte
|
||||
Texturtabelle). Die Vorschaubilder stammen ebenfalls aus diesem Build – Sondermaterial-Farben
|
||||
darin (z. B. Türkis-Füllung im Berg-Bild) entsprechen noch nicht ganz der Legende oben.
|
||||
**Nur offline geprüft:** t1l1m002 und t1l2m003 (Ladeversuch scheiterte vorher an der
|
||||
Menü-Erkennung von lamatest, nicht am Level), t1l7m001–005 (Katakomben/Achterbahn),
|
||||
t1l8m001–006 (Labor), t6l6m001/002. Ein Test über den echten Spielstand-Slot wurde nicht gemacht.
|
||||
|
||||
lamatest-Änderung nebenbei: Spielstand-Auswahl mit mehr Versuchen und 0,3 s langen
|
||||
Tastendrücken (unter CPU-Last gingen kurze Drücke verloren).
|
||||
|
||||
Bilder: `notes/img/debug/00_montage.jpg` (6 Welten), `01_…06_*.jpg` (1280×720).
|
||||
**Offline (all 33 debug WADs, `$JOB/tmp/dv/offcheck.py`):** TRAK parses and rebuilds
|
||||
byte-identically; ≤ 8000 vertices per model (max. 2012, t1l4m003); render batches ≤ 256 faces; all
|
||||
texture indices < table size; vertex colour block per tile = nV(model) (all frames);
|
||||
MAP fully parsed with `mapchunk` (0 bytes left over) and rebuilt byte-identically; strats,
|
||||
waypoints, parameter pool, environment zones, zone table/grid, alt block and original tiles
|
||||
unchanged; original models (vertices, faces, **collision**) unchanged byte for byte; new
|
||||
marker models without collision; all other chunks (STPC, sound, LGPC …) identical;
|
||||
tiles within the grid. TRAK+STPC+MAP+LGPC ≤ 7.6 MB (16 MB level arena).
|
||||
|
||||
**Loaded in game** (lamatest, via slot world 9 as T1L9M001.wad, ~40–90 s played/rotated,
|
||||
no crash, no hang, no errors in the Wine log): t1l1m001 (several times, visual check),
|
||||
t1l1m003, t1l1m004, t1l2m001, t1l2m002, t1l3m001–004 (the river also works normally in slot 9:
|
||||
the boat moves), t1l4m001–003, t1l5m001–003, t1l6m001–003 = 19 levels.
|
||||
The build tested was the one right before the final one: identical geometry, only the colour values
|
||||
of the special materials were switched afterwards to fixed colours per material (+8–16 bytes
|
||||
of texture table). The preview images also come from that build – special material colours
|
||||
in them (e.g. turquoise fill in the mountain image) do not yet fully match the legend above.
|
||||
**Only checked offline:** t1l1m002 and t1l2m003 (the load attempt previously failed at lamatest's
|
||||
menu detection, not at the level), t1l7m001–005 (catacombs/roller coaster),
|
||||
t1l8m001–006 (lab), t6l6m001/002. A test via the real save game slot was not done.
|
||||
|
||||
lamatest change on the side: save game selection with more attempts and 0.3 s long
|
||||
key presses (under CPU load short presses got lost).
|
||||
|
||||
Images: `notes/img/debug/00_montage.jpg` (6 worlds), `01_…06_*.jpg` (1280×720).
|
||||
+168
-168
@@ -1,203 +1,203 @@
|
||||
# Level-Geometrie (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC)
|
||||
# Level geometry (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC)
|
||||
|
||||
Stand: 2026-11-06. Quelle: groove.exe (Ghidra-Dekompilat `re/groove_decomp.c`, Capstone) +
|
||||
Datenanalyse aller 34 Level-WADs. Tools: `tools/geomlib.py`, `tools/geom_export.py`,
|
||||
As of: 2026-11-06. Source: groove.exe (Ghidra decompilation `re/groove_decomp.c`, Capstone) +
|
||||
data analysis of all 34 level WADs. Tools: `tools/geomlib.py`, `tools/geom_export.py`,
|
||||
`tools/geom_import.py`, `tools/geom_demo.py`, `tools/disx86.py`.
|
||||
Konfidenz: **[sicher]** = aus Code + Daten verifiziert (Round-Trip byte-identisch / Rendering),
|
||||
**[wahrsch.]** = aus Daten plausibel, **[?]** = unbekannt.
|
||||
Confidence: **[certain]** = verified from code + data (byte-identical round trip / rendering),
|
||||
**[likely]** = plausible from data, **[?]** = unknown.
|
||||
|
||||
## 1. Überblick: welcher Chunk ist was
|
||||
## 1. Overview: which chunk is what
|
||||
|
||||
Chunk-Dispatcher: `FUN_00558xxx` bei VA 0x55899e (Vergleich auf Tag-Konstanten, Tag als u32 LE
|
||||
gelesen, z. B. 'CPTS' im File = 0x53545043).
|
||||
Chunk dispatcher: `FUN_00558xxx` at VA 0x55899e (compares against tag constants, tag read as u32 LE,
|
||||
e.g. 'CPTS' in the file = 0x53545043).
|
||||
|
||||
| Chunk | Loader (VA) | Inhalt |
|
||||
| Chunk | Loader (VA) | Contents |
|
||||
|---|---|---|
|
||||
| WFPC | inline → `DAT_006da330` | u32 Feature-Flags des Levels (steuern MAP/STPC-Aufbau) **[sicher]** |
|
||||
| TEXT | 0x4067b0 / 0x406d30 | Texturseiten (256x256, 16 Bit) + Texturtabelle (UV-Rechtecke) **[sicher]** |
|
||||
| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | Sound (SMPC: 'cvg ' VAG-Samples; AMPC: 'ambi'/'VABp'; SRPC Sound-Referenzen) – **keine Geometrie** |
|
||||
| **TRAK** | 0x42aac0 → 0x5563f0 | **Welt-Geometrie**: Kachel-Modelle (Render-Dreiecke + Kollisionspolygone) → globales Array `DAT_005846ec` **[sicher]** |
|
||||
| **STPC** | 0x42ab50 | Objekt-("Strat")-Modelle (gleiches Modellformat), Skelett-Animationen, danach Strat-Skriptdaten (Namen wie "SinkingStatuePad") **[sicher für Modelle]** |
|
||||
| **MAP** | 0x42ac50 | Kachel-Raster: Platzierung der TRAK-Modelle + Vertexfarben + Zonen-PVS (+ Strats/Pfade/etc., siehe objects.md) **[sicher]** |
|
||||
| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | Lichter / Level-Info / ? – nicht Geometrie |
|
||||
| WFPC | inline → `DAT_006da330` | u32 feature flags of the level (control MAP/STPC layout) **[certain]** |
|
||||
| TEXT | 0x4067b0 / 0x406d30 | texture pages (256x256, 16 bit) + texture table (UV rectangles) **[certain]** |
|
||||
| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | sound (SMPC: 'cvg ' VAG samples; AMPC: 'ambi'/'VABp'; SRPC sound references) – **no geometry** |
|
||||
| **TRAK** | 0x42aac0 → 0x5563f0 | **world geometry**: tile models (render triangles + collision polygons) → global array `DAT_005846ec` **[certain]** |
|
||||
| **STPC** | 0x42ab50 | object ("strat") models (same model format), skeletal animations, then strat script data (names like "SinkingStatuePad") **[certain for models]** |
|
||||
| **MAP** | 0x42ac50 | tile grid: placement of the TRAK models + vertex colours + zone PVS (+ strats/paths/etc., see objects.md) **[certain]** |
|
||||
| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | lights / level info / ? – not geometry |
|
||||
|
||||
**Die Welt ist ein Kachelsystem (Croc-Erbe):** TRAK enthält N "Kachel"-Modelle in lokalen
|
||||
Koordinaten (ungefähr −0.5..0.5 in x/z, können aber bis ±2.4 hinausragen). MAP platziert sie
|
||||
auf einem W×H-Raster (1 Einheit = 1 Zelle) mit Höhe y und Drehung um y. Es gibt **keinen BSP**;
|
||||
die Raumaufteilung ist das Raster selbst (Zell-Listen) + eine 32-Zonen-Sichtbarkeitstabelle.
|
||||
**The world is a tile system (inherited from Croc):** TRAK contains N "tile" models in local
|
||||
coordinates (roughly −0.5..0.5 in x/z, but they can stick out up to ±2.4). MAP places them
|
||||
on a W×H grid (1 unit = 1 cell) with height y and rotation around y. There is **no BSP**;
|
||||
the spatial partition is the grid itself (cell lists) + a 32-zone visibility table.
|
||||
|
||||
## 2. Koordinatenräume [sicher]
|
||||
## 2. Coordinate spaces [certain]
|
||||
|
||||
* **Exportraum** (= MAP-Floats "A", = Vertexdaten von TRAK/STPC): rechtshändig, **y oben**,
|
||||
Rasterzellen liegen bei x ∈ [0,W), **z ∈ (−H, 0]**. OBJ-Export benutzt diesen Raum.
|
||||
* **Spielraum** (MAP-Fixpunkt "B", Kollision, Strats): (x, y, −z), 20.12-Fixpunkt (4096 = 1).
|
||||
Der Renderer rechnet B zurück: pos = (Bx, By, −Bz)/4096, Winkel = rot·2π/4096.
|
||||
* Kachel-Transformation (Vertex v lokal → Welt, Exportraum), a = rot/4096·2π:
|
||||
* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): right-handed, **y up**,
|
||||
grid cells lie at x ∈ [0,W), **z ∈ (−H, 0]**. The OBJ export uses this space.
|
||||
* **Game space** (MAP fixed point "B", collision, strats): (x, y, −z), 20.12 fixed point (4096 = 1).
|
||||
The renderer converts B back: pos = (Bx, By, −Bz)/4096, angle = rot·2π/4096.
|
||||
* Tile transform (local vertex v → world, export space), a = rot/4096·2π:
|
||||
`X = x + vx·cos a − vz·sin a ; Y = y + vy ; Z = z + vx·sin a + vz·cos a`
|
||||
(verifiziert: 93 % aller Kachel-Randvertices treffen exakt einen Nachbarvertex; die drei
|
||||
anderen Vorzeichen-/Spiegelvarianten liefern 65–70 %).
|
||||
* **Kollisionsdaten liegen im Spielraum** (z gespiegelt gegenüber den Render-Vertices desselben
|
||||
Modells). Beim Dekodieren nz negieren (92 % der rekonstruierten Kollisionsecken liegen
|
||||
< 0.02 neben einem Render-Vertex).
|
||||
* rx/rz der Kacheln sind in allen Levels 0; nur Drehung um y (praktisch 0/1024/2048/3072).
|
||||
(verified: 93 % of all tile edge vertices hit a neighbour vertex exactly; the three
|
||||
other sign/mirror variants give 65–70 %).
|
||||
* **Collision data is in game space** (z mirrored relative to the render vertices of the same
|
||||
model). Negate nz when decoding (92 % of the reconstructed collision corners lie
|
||||
< 0.02 from a render vertex).
|
||||
* rx/rz of the tiles are 0 in all levels; only rotation around y (in practice 0/1024/2048/3072).
|
||||
|
||||
## 3. Modellformat (TRAK und STPC) [sicher]
|
||||
## 3. Model format (TRAK and STPC) [certain]
|
||||
|
||||
Fixup-Funktionen: TRAK `FUN_005563f0(&ptr, n, &out)` (Header 0x84),
|
||||
STPC `FUN_0041f770(&ptr, n, &out)` (Header 0x8c). Datei-Layout: **alle Header eines Aufrufs
|
||||
hintereinander, dann die Daten der Modelle in Reihenfolge** (TRAK: ein Aufruf mit n=Anzahl;
|
||||
STPC: n Aufrufe mit je 1 Modell → Header/Daten abwechselnd). Zeigerfelder sind in der Datei 0
|
||||
und werden beim Laden gesetzt.
|
||||
Fixup functions: TRAK `FUN_005563f0(&ptr, n, &out)` (header 0x84),
|
||||
STPC `FUN_0041f770(&ptr, n, &out)` (header 0x8c). File layout: **all headers of one call
|
||||
in a row, then the data of the models in order** (TRAK: one call with n=count;
|
||||
STPC: n calls with 1 model each → header/data alternating). Pointer fields are 0 in the file
|
||||
and are set on load.
|
||||
|
||||
### Header
|
||||
|
||||
| Off | Typ | Bedeutung |
|
||||
| Off | Type | Meaning |
|
||||
|---|---|---|
|
||||
| 0x00 | 3×f32 | Mittelpunkt der Bounding-Box |
|
||||
| 0x0c | 8×3 f32 | Ecken der Box, Reihenfolge (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Etwas größer als die Vertices (Rand nicht konstant, ~0.004–0.014; vermutl. aus Originalmesh). Für Frustum-Culling (`FUN_00402840`). |
|
||||
| 0x00 | 3×f32 | centre of the bounding box |
|
||||
| 0x0c | 8×3 f32 | box corners, order (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Slightly larger than the vertices (margin not constant, ~0.004–0.014; probably from the original mesh). Used for frustum culling (`FUN_00402840`). |
|
||||
| 0x6c | u16 | nVerts |
|
||||
| 0x6e | u16 | nFaces |
|
||||
| 0x70 | u32 | → Verts (Datei: 0) |
|
||||
| 0x74 | u32 | → Faces (Datei: 0) |
|
||||
| 0x78 / 0x7a / 0x7c | 3×u16 | Anzahl Kollisionspolygone in 3 Gruppen (Code summiert immer alle drei; Gruppenbedeutung [?]) |
|
||||
| 0x70 | u32 | → verts (file: 0) |
|
||||
| 0x74 | u32 | → faces (file: 0) |
|
||||
| 0x78 / 0x7a / 0x7c | 3×u16 | number of collision polygons in 3 groups (code always sums all three; group meaning [?]) |
|
||||
| 0x7e | u16 | 0 |
|
||||
| 0x80 | u32 | → Kollision (Datei: 0) |
|
||||
| *nur STPC* 0x84 | u16 | = nVerts bei starren Modellen, 0 bei Skin-Modellen |
|
||||
| *nur STPC* 0x86 | u16 | nBones (Skin-Modelle) – davor in den Daten nBones×u16 = Vertexzahl je Bone (Summe = nVerts, verifiziert) |
|
||||
| *nur STPC* 0x88 | u32 | → Bone-Tabelle (Datei: 0) |
|
||||
| 0x80 | u32 | → collision (file: 0) |
|
||||
| *STPC only* 0x84 | u16 | = nVerts for rigid models, 0 for skinned models |
|
||||
| *STPC only* 0x86 | u16 | nBones (skinned models) – preceded in the data by nBones×u16 = vertex count per bone (sum = nVerts, verified) |
|
||||
| *STPC only* 0x88 | u32 | → bone table (file: 0) |
|
||||
|
||||
### Daten (in dieser Reihenfolge)
|
||||
1. (nur STPC) nBones × u16
|
||||
2. **Vertex** 24 Byte: `f32 x,y,z ; f32 nx,ny,nz` (Normalen geglättet; Lichtwerte kommen aus MAP-Vertexfarben)
|
||||
3. **Face** 28 Byte (nur Dreiecke):
|
||||
`u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` mit n = normalize((v1−v0)×(v2−v0)), d = n·v0
|
||||
(verifiziert für alle Faces). Beim Laden wird `tex` durch Zeiger auf Texturtabelleneintrag
|
||||
(`DAT_00581154 + tex*20`) ersetzt. Ebene dient dem Backface-Culling (Kamera·n − d ≥ ε).
|
||||
4. **Kollisionspolygon** 32 Byte, nGruppeA+B+C Stück:
|
||||
`u8 typ ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }`
|
||||
* Ebene 0 = Fläche, Ebenen 1..4 = Kanten (Normalen **nach innen**, Punkt drin ⇔ n·p − d ≥ −Toleranz).
|
||||
* Normalen ×127 (Code rechnet ×32 → 4096-Skala), d ×4096 (Fixpunkt) → lokale Koordinaten nur ±8 Einheiten!
|
||||
* typ Bit0 = Viereck (4 Kanten), sonst Dreieck (3 Kanten, 5. Ebene = 0). Bit2 ≈ Normale zeigt nach unten (ny<0; Decken/Überhänge) [wahrsch.].
|
||||
* material: Untergrund-Typ (0 = normal; 0x11/0x12 werden je nach WFPC 0x8000000 ignoriert, 0x1e = Wasser-/Todesebene [wahrsch.]; Wert landet im Spieler-Bodenstatus).
|
||||
* Kollisionsabfragen: `FUN_004036d0` (Boden/Decke), `FUN_00403ad0` (Wände), `FUN_004042f0`, `FUN_004046e0`. Getestet werden **nur die Kacheln der Rasterzelle, in der der Spieler steht** (Zellliste aus MAP), Punkt relativ zu Kachel-B-Position, um −rot gedreht.
|
||||
### Data (in this order)
|
||||
1. (STPC only) nBones × u16
|
||||
2. **Vertex** 24 bytes: `f32 x,y,z ; f32 nx,ny,nz` (smoothed normals; lighting comes from MAP vertex colours)
|
||||
3. **Face** 28 bytes (triangles only):
|
||||
`u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` with n = normalize((v1−v0)×(v2−v0)), d = n·v0
|
||||
(verified for all faces). On load `tex` is replaced by a pointer to the texture table entry
|
||||
(`DAT_00581154 + tex*20`). The plane is used for backface culling (camera·n − d ≥ ε).
|
||||
4. **Collision polygon** 32 bytes, nGroupA+B+C of them:
|
||||
`u8 type ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }`
|
||||
* plane 0 = surface, planes 1..4 = edges (normals pointing **inwards**, point inside ⇔ n·p − d ≥ −tolerance).
|
||||
* normals ×127 (code multiplies by 32 → 4096 scale), d ×4096 (fixed point) → local coordinates only ±8 units!
|
||||
* type bit0 = quad (4 edges), otherwise triangle (3 edges, 5th plane = 0). Bit2 ≈ normal points down (ny<0; ceilings/overhangs) [likely].
|
||||
* material: ground type (0 = normal; 0x11/0x12 are ignored depending on WFPC 0x8000000, 0x1e = water/death plane [likely]; the value ends up in the player's ground state).
|
||||
* Collision queries: `FUN_004036d0` (floor/ceiling), `FUN_00403ad0` (walls), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
|
||||
|
||||
### Face-Flags (u16)
|
||||
| Bit | Bedeutung | Konfidenz |
|
||||
### Face flags (u16)
|
||||
| Bit | Meaning | Confidence |
|
||||
|---|---|---|
|
||||
| 0x0008 | neuer Render-Batch: Renderzustand (Seite, Blendmode) aus diesem Face neu laden, vorherigen Batch zeichnen. Sortierung nach Seite/Zustand + Bit am Wechsel. | sicher (Code) |
|
||||
| 0x0010, 0x0800, 0x0020 | UV-Ecken-Auswahl (s. u.) | sicher |
|
||||
| 0x0400 | kein Backface-Culling (doppelseitig) | sicher |
|
||||
| 0x4000 | Vertexfarben ignorieren (weiß) | sicher |
|
||||
| 0x0001, 0x0002 | Sonder-Rendermodi (Sortierung/Transparenz; zusammen mit Globalflags 0x5f6ee8) | wahrsch. |
|
||||
| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | in Daten vorhanden, Renderer (0x556510) wertet sie nicht aus | ? (vermutl. PS1-Reste) |
|
||||
| 0x0008 | new render batch: reload render state (page, blend mode) from this face, draw the previous batch. Sorted by page/state + bit at each change. | certain (code) |
|
||||
| 0x0010, 0x0800, 0x0020 | UV corner selection (see below) | certain |
|
||||
| 0x0400 | no backface culling (double-sided) | certain |
|
||||
| 0x4000 | ignore vertex colours (white) | certain |
|
||||
| 0x0001, 0x0002 | special render modes (sorting/transparency; together with global flags 0x5f6ee8) | likely |
|
||||
| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | present in the data, the renderer (0x556510) does not evaluate them | ? (probably PS1 leftovers) |
|
||||
|
||||
### UVs – die Engine hat KEINE freien UVs [sicher]
|
||||
Jedes Dreieck zeigt eine Hälfte eines **Texturrechtecks** (Eintrag der TEXT-Tabelle).
|
||||
UV-Rechteck: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. Mit m = Flag 0x20 (spiegelt U),
|
||||
a = m?U0:U1, b = m?U1:U0 bekommen die Ecken (v0,v1,v2):
|
||||
* ohne 0x800: (a,V1) (b,V1) (a,V0)
|
||||
* 0x800, ohne 0x10: (a,V1) (b,V1) (b,V0) ← erste Hälfte eines Vierecks
|
||||
* 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← zweite Hälfte
|
||||
(Renderer `FUN_00556510` ab Zeile "if ((uVar6 & 0x800) == 0)"). Texturen mit Flag Bit0 sind
|
||||
Flachfarben (u0,u1,v0 = R,G,B). 96 % der Welt-Faces haben 0x800.
|
||||
### UVs – the engine has NO free UVs [certain]
|
||||
Every triangle shows one half of a **texture rectangle** (entry of the TEXT table).
|
||||
UV rectangle: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. With m = flag 0x20 (mirrors U),
|
||||
a = m?U0:U1, b = m?U1:U0 the corners (v0,v1,v2) get:
|
||||
* without 0x800: (a,V1) (b,V1) (a,V0)
|
||||
* 0x800, without 0x10: (a,V1) (b,V1) (b,V0) ← first half of a quad
|
||||
* 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← second half
|
||||
(renderer `FUN_00556510` from the line "if ((uVar6 & 0x800) == 0)"). Textures with flag bit0 are
|
||||
flat colours (u0,u1,v0 = R,G,B). 96 % of world faces have 0x800.
|
||||
|
||||
## 4. TRAK-Chunk [sicher]
|
||||
`u32 n ; n × Header(0x84) ; Daten Modell 0..n−1`. Alle 34 Level-WADs parsen exakt bis
|
||||
Chunkende; `geomlib.build_trak(parse_trak(x)) == x` byte-identisch für alle Levels.
|
||||
Typisch 100–650 Modelle, max. 422 Verts / 798 Faces pro Modell. Modell 0 ist in vielen Levels
|
||||
eine unsichtbare Ebene (4 Verts, 0 Faces, 1 Kollisionspolygon, material 0x1e) bei y=−2.75 –
|
||||
die Wasser-/Absturzebene unter den Inseln.
|
||||
## 4. TRAK chunk [certain]
|
||||
`u32 n ; n × header(0x84) ; data of model 0..n−1`. All 34 level WADs parse exactly up to the
|
||||
end of the chunk; `geomlib.build_trak(parse_trak(x)) == x` byte-identical for all levels.
|
||||
Typically 100–650 models, max. 422 verts / 798 faces per model. Model 0 is, in many levels,
|
||||
an invisible plane (4 verts, 0 faces, 1 collision polygon, material 0x1e) at y=−2.75 –
|
||||
the water/fall plane below the islands.
|
||||
|
||||
## 5. MAP-Chunk – Geometrieteile (Loader FUN_0042ac50) [sicher]
|
||||
(Strat/Pfad/Zonen-Blöcke: siehe notes/objects.md; hier nur, was die Geometrie betrifft.)
|
||||
Reihenfolge in der Datei:
|
||||
## 5. MAP chunk – geometry parts (loader FUN_0042ac50) [certain]
|
||||
(Strat/path/zone blocks: see notes/objects.md; only what concerns geometry here.)
|
||||
Order in the file:
|
||||
|
||||
1. `u32 N (Kacheln), u32 W, u32 H` (Raster)
|
||||
2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (Exportraum; x,z = Zellmitte .5).
|
||||
Nur zum Einsortieren in die Zelllisten benutzt: Zelle = floor(x) + floor(−z)·W.
|
||||
3. u32 n + n×u32 (Liste, von Strats referenziert)
|
||||
4. u32 n + n×90 Byte (Lichter/Kameras? – objects.md)
|
||||
5. u32 n + n×34 Byte (Pfadknoten)
|
||||
6. **Zonentabelle** 256 Byte = 32 × `{u32 Sichtmaske, i32 Höhenwert}` → `DAT_006da350`
|
||||
7. **Zonenraster** W·H × u32 (Zonennummer 0..31 je Zelle) → world+0x48
|
||||
8. **B**: N × `{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}` 20.12-Fixpunkt, Spielraum (z = −A.z), ry = A.rot.
|
||||
**Wird zum Rendern und für Kollision benutzt.** A und B sind in allen Levels konsistent.
|
||||
9. **Modellindex**: N × u32 (= A.model)
|
||||
10. falls WFPC & 0x10000: `u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}` –
|
||||
Kachel-Wechsel (Türen/Brücken o. Ä.), Eintrag k gehört zur k-ten Kachel mit animFlag≠0.
|
||||
11. **Vertexfarben** je Kachel i: nVerts(model) × u32 RGBA (0x80 = 1.0, Spiel ×2 mit Clamp);
|
||||
Byte3 des ersten Eintrags = Anzahl Farbsätze (0/1 = einer; ≥2 → weitere nVerts×u32 folgen,
|
||||
zweiter Satz wird bei `DAT_005865d4==1` benutzt). Für animFlag-Kacheln danach dieselbe
|
||||
Struktur für modelB. Zeigertabelle world+0x58 (bei WFPC 0x10000000 doppelt groß).
|
||||
12. Rest (u32 n2,n3 + 58-Byte-Strat-Einträge, optional Pfade (0x200000), Sichtweite (0x10), u16) – objects.md.
|
||||
1. `u32 N (tiles), u32 W, u32 H` (grid)
|
||||
2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (export space; x,z = cell centre .5).
|
||||
Only used to sort into the cell lists: cell = floor(x) + floor(−z)·W.
|
||||
3. u32 n + n×u32 (list, referenced by strats)
|
||||
4. u32 n + n×90 bytes (lights/cameras? – objects.md)
|
||||
5. u32 n + n×34 bytes (path nodes)
|
||||
6. **Zone table** 256 bytes = 32 × `{u32 visibility mask, i32 height value}` → `DAT_006da350`
|
||||
7. **Zone grid** W·H × u32 (zone number 0..31 per cell) → world+0x48
|
||||
8. **B**: N × `{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}` 20.12 fixed point, game space (z = −A.z), ry = A.rot.
|
||||
**Used for rendering and collision.** A and B are consistent in all levels.
|
||||
9. **Model index**: N × u32 (= A.model)
|
||||
10. if WFPC & 0x10000: `u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}` –
|
||||
tile swaps (doors/bridges etc.), entry k belongs to the k-th tile with animFlag≠0.
|
||||
11. **Vertex colours** per tile i: nVerts(model) × u32 RGBA (0x80 = 1.0, game ×2 with clamp);
|
||||
byte3 of the first entry = number of colour sets (0/1 = one; ≥2 → another nVerts×u32 follow,
|
||||
the second set is used when `DAT_005865d4==1`). For animFlag tiles the same
|
||||
structure follows for modelB. Pointer table world+0x58 (twice as large with WFPC 0x10000000).
|
||||
12. Rest (u32 n2,n3 + 58-byte strat entries, optional paths (0x200000), view distance (0x10), u16) – objects.md.
|
||||
|
||||
`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduziert alle MAP-Chunks byte-identisch.
|
||||
`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduces all MAP chunks byte-identically.
|
||||
|
||||
### Raster / Sichtbarkeit (kein BSP) [sicher]
|
||||
* Zelllisten: world+0x40 (W·H Zeiger, verkettete Listen von Kachelindizes), aus A gebaut.
|
||||
* Rendern (`FUN_0042bf40`): Rechteck um die Kamera (Sichtweite) über das Raster; für jede Zelle
|
||||
alle Kacheln der Liste, aber **nur wenn Bit zone[Zelle] in der Maske der Kamerazone gesetzt ist**
|
||||
(`DAT_0058642c` = Zonentabelle[zone[Kamerazelle]].maske, `FUN_0041e020`). Zone 0 hat im
|
||||
Dorf-Level Maske 0x1. → Neue Geometrie muss passende Zonen bekommen, sonst unsichtbar.
|
||||
`geom_import --zones flat` setzt Raster=0 und Maske(Zone 0)=0xffffffff (alles sichtbar).
|
||||
* Kollision: nur Kacheln der Spielerzelle (s. o.).
|
||||
### Grid / visibility (no BSP) [certain]
|
||||
* Cell lists: world+0x40 (W·H pointers, linked lists of tile indices), built from A.
|
||||
* Rendering (`FUN_0042bf40`): rectangle around the camera (view distance) over the grid; for every cell
|
||||
all tiles in the list, but **only if bit zone[cell] is set in the mask of the camera zone**
|
||||
(`DAT_0058642c` = zonetable[zone[camera cell]].mask, `FUN_0041e020`). Zone 0 has mask 0x1 in the
|
||||
village level. → New geometry must get matching zones, otherwise it is invisible.
|
||||
`geom_import --zones flat` sets grid=0 and mask(zone 0)=0xffffffff (everything visible).
|
||||
* Collision: only tiles of the player's cell (see above).
|
||||
|
||||
## 6. STPC-Chunk (Objekte) [sicher bis Animationen]
|
||||
`u32 n1 ; n1 × (Header 0x8c + Daten)` statische/geskinnte Objektmodelle (33 im Dorf) ;
|
||||
`u32 n2 ; n2 × Animation` (`FUN_0041f8b0`, Header 32 Byte: +0 nA (nA×4 Byte), +8 nFrames,
|
||||
+0xc (0 → nFrames×8 Byte), +0x10 nDelta (Frame 0: nDelta×8, sonst nDelta×2 (+2 wenn ungerade)
|
||||
Byte komprimierte Vertexdeltas), +0x18 nBones (je Frame nBones×64 Byte = 4×4-Matrix)) ;
|
||||
falls WFPC & 0x100: `u32 n3 ; n3 × {16-Byte-Kopf (+4 Anzahl, +8 → n×8 Byte), Modelle}` ;
|
||||
danach Strat-Daten (Skripte, Namen) – nicht vom Lader geparst, über Offsets (`DAT_006d9dbc+off`)
|
||||
aus MAP referenziert. `geom_export --objects` exportiert die n1 Modelle nebeneinander.
|
||||
## 6. STPC chunk (objects) [certain up to animations]
|
||||
`u32 n1 ; n1 × (header 0x8c + data)` static/skinned object models (33 in the village) ;
|
||||
`u32 n2 ; n2 × animation` (`FUN_0041f8b0`, header 32 bytes: +0 nA (nA×4 bytes), +8 nFrames,
|
||||
+0xc (0 → nFrames×8 bytes), +0x10 nDelta (frame 0: nDelta×8, otherwise nDelta×2 (+2 if odd)
|
||||
bytes of compressed vertex deltas), +0x18 nBones (per frame nBones×64 bytes = 4×4 matrix)) ;
|
||||
if WFPC & 0x100: `u32 n3 ; n3 × {16-byte head (+4 count, +8 → n×8 bytes), models}` ;
|
||||
then strat data (scripts, names) – not parsed by the loader, referenced from MAP via offsets
|
||||
(`DAT_006d9dbc+off`). `geom_export --objects` exports the n1 models side by side.
|
||||
|
||||
## 7. TEXT – soweit für Geometrie nötig [sicher]
|
||||
## 7. TEXT – as far as needed for geometry [certain]
|
||||
`u32 nPal ; nPal×{u32, u32 n, n×u16, n×u8}` ; `u32 nPages ; nPages×{u32 flags, u32 w, u32 h,
|
||||
(flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h}` ; `u32 nTex ; nTex × 8 Byte` ; Rest (40 Byte im Dorf) [?].
|
||||
Textureintrag: `u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1` (u1/v1 inklusiv).
|
||||
Flags: Bit0 Flachfarbe, Bit2 1-Pixel-Rand, Bit3/Bit4 Breite/Höhe ×2, Bits 12–14 Blendmodus, Bits 8–10 weiterer Modus.
|
||||
Pixel: ABGR1555 (R = Bits 0–4), 0 = transparent. Details: notes/textures.md (anderer Agent).
|
||||
(flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h}` ; `u32 nTex ; nTex × 8 bytes` ; rest (40 bytes in the village) [?].
|
||||
Texture entry: `u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1` (u1/v1 inclusive).
|
||||
Flags: bit0 flat colour, bit2 1-pixel border, bit3/bit4 width/height ×2, bits 12–14 blend mode, bits 8–10 another mode.
|
||||
Pixels: ABGR1555 (R = bits 0–4), 0 = transparent. Details: notes/textures.md (other agent).
|
||||
|
||||
## 8. Neue Geometrie schreiben – was konsistent sein muss
|
||||
Umgesetzt in `tools/geom_import.py` (OBJ → TRAK + MAP-Kachelteil, Rest des WADs unverändert):
|
||||
1. **TRAK**: u32 Anzahl; Header mit korrekten nVerts/nFaces/nKoll; Box+Mittelpunkt (für Culling,
|
||||
wird neu berechnet); Face-Ebenen aus Vertices; Vertexnormalen. Keine Prüfsummen.
|
||||
2. **Face-Texturindex** < Anzahl Texturtabelleneinträge; UVs nur über Rechteck-Ecken (+Flags).
|
||||
Bit 0x8 am Batch-Anfang (Seiten-/Zustandswechsel, spätestens alle 256 Faces).
|
||||
3. **Limits**: ≤ 8000 Vertices pro Modell (Transformpuffer 0x58b0f0..0x5d15f8, 36 Byte/Vertex),
|
||||
Batch-Ausgabepuffer 0x5d15f8 ≈ 4800 Vertices; Kollisions-d als i16/4096 → Kollisionspolygone
|
||||
nur innerhalb ±8 Einheiten vom Kachelursprung. u16-Indizes.
|
||||
4. **MAP**: N in Kopf, A (Floats) und B (Fixpunkt, z negiert) identisch halten, Modellindizes
|
||||
< TRAK-Anzahl, Kachelzellen innerhalb W×H, **Vertexfarben-Block exakt nVerts(Modell) pro
|
||||
Kachel** (sonst verschiebt sich der Rest von MAP = Strats!), Animationsblock (WFPC 0x10000)
|
||||
passend zu den Flags (Import setzt ihn auf 0 Einträge), Zonen (s. o.).
|
||||
5. Kollision gehört zur Zelle: ein Polygon wirkt nur, wenn es in der Kachelliste der Zelle steht,
|
||||
in der der Spieler steht → Import trägt jedes Polygon in jede überdeckte Zelle ein
|
||||
(auch als reine Kollisionskachel ohne Faces, wie Original-Modell 0).
|
||||
6. WAD-Gesamtlänge (wadtool/geomlib schreiben sie neu). Keine weiteren Prüfsummen gefunden.
|
||||
7. Strats (Startpunkt, Gegner, Kamera-Pfade) liegen weiter an ihren alten Positionen (objects.md).
|
||||
## 8. Writing new geometry – what must be consistent
|
||||
Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the WAD unchanged):
|
||||
1. **TRAK**: u32 count; header with correct nVerts/nFaces/nColl; box+centre (for culling,
|
||||
recomputed); face planes from vertices; vertex normals. No checksums.
|
||||
2. **Face texture index** < number of texture table entries; UVs only via rectangle corners (+flags).
|
||||
Bit 0x8 at the start of a batch (page/state change, at the latest every 256 faces).
|
||||
3. **Limits**: ≤ 8000 vertices per model (transform buffer 0x58b0f0..0x5d15f8, 36 bytes/vertex),
|
||||
batch output buffer 0x5d15f8 ≈ 4800 vertices; collision d as i16/4096 → collision polygons
|
||||
only within ±8 units of the tile origin. u16 indices.
|
||||
4. **MAP**: keep N in the header, A (floats) and B (fixed point, z negated) identical, model indices
|
||||
< TRAK count, tile cells within W×H, **vertex colour block exactly nVerts(model) per
|
||||
tile** (otherwise the rest of MAP = strats shifts!), animation block (WFPC 0x10000)
|
||||
matching the flags (import sets it to 0 entries), zones (see above).
|
||||
5. Collision belongs to the cell: a polygon only takes effect if it is in the tile list of the cell
|
||||
the player stands in → import enters every polygon into every cell it covers
|
||||
(also as a pure collision tile without faces, like original model 0).
|
||||
6. Total WAD length (wadtool/geomlib rewrite it). No other checksums found.
|
||||
7. Strats (start point, enemies, camera paths) remain at their old positions (objects.md).
|
||||
|
||||
### Tests (ohne Spielstart)
|
||||
* Round-Trip TRAK und MAP aller 34 WADs byte-identisch.
|
||||
* Export → Import (`--coll-obj`) → Export t1l1m001: Rendering identisch (img/), 26435/26442
|
||||
Dreiecke (7 entartete fallen weg), Bodenhöhe an 2800 Zufallspunkten: 99 % < 5 mm Abweichung,
|
||||
10 Punkte anders (Polygone jetzt auch in Nachbarzellen eingetragen).
|
||||
* Unabhängiger Gegencheck: `tools/mapchunk.py` (anderer Agent) parst die erzeugten MAPs vollständig.
|
||||
* Noch NICHT im Spiel getestet: Demo-WADs in $CLAUDE_JOB_DIR/tmp: `demo_add.wad` (Original +
|
||||
2 Blöcke + Rampe, Zonen original), `demo.wad` (alles ersetzt durch Bodenplatte y=0 + Blöcke).
|
||||
### Tests (without starting the game)
|
||||
* Round trip TRAK and MAP of all 34 WADs byte-identical.
|
||||
* Export → import (`--coll-obj`) → export t1l1m001: rendering identical (img/), 26435/26442
|
||||
triangles (7 degenerate ones dropped), floor height at 2800 random points: 99 % < 5 mm deviation,
|
||||
10 points differ (polygons now also entered in neighbouring cells).
|
||||
* Independent cross-check: `tools/mapchunk.py` (other agent) parses the generated MAPs completely.
|
||||
* NOT yet tested in game: demo WADs in $CLAUDE_JOB_DIR/tmp: `demo_add.wad` (original +
|
||||
2 blocks + ramp, original zones), `demo.wad` (everything replaced by a floor plate y=0 + blocks).
|
||||
|
||||
## 9. Offen / unbekannt
|
||||
* Bedeutung der drei Kollisionsgruppen (Summe wird immer zusammen benutzt) und vollständige Materialliste.
|
||||
* Face-Flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; genaue Wirkung 0x1/0x2.
|
||||
* Exakte Herkunft des Box-Randes im Header (Neu-Berechnung mit 1/128 Rand reicht vermutlich).
|
||||
* Zonentabelle 2. Wert (Höhe, `DAT_00586424`) – evtl. Wasser-/Nebelhöhe.
|
||||
* STPC-Animation: Bedeutung von nA-Block und Delta-Kompression im Detail (`FUN_0041fa30`).
|
||||
* Ob der Spieler-Startpunkt/Kamera an Geometrie gekoppelt ist (objects.md).
|
||||
## 9. Open / unknown
|
||||
* Meaning of the three collision groups (the sum is always used together) and the complete material list.
|
||||
* Face flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; exact effect of 0x1/0x2.
|
||||
* Exact origin of the box margin in the header (recomputing with a 1/128 margin is probably enough).
|
||||
* Zone table 2nd value (height, `DAT_00586424`) – possibly water/fog height.
|
||||
* STPC animation: meaning of the nA block and the delta compression in detail (`FUN_0041fa30`).
|
||||
* Whether the player start point/camera is coupled to geometry (objects.md).
|
||||
|
||||
## 10. Werkzeuge
|
||||
* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, Vertexfarben, ein
|
||||
Objekt pro Kachel), page_NN.png, collision.obj (Gruppen matNN_grpG), objects.obj, preview_top/persp.png
|
||||
(texturierter Software-Renderer).
|
||||
* `geom_import.py BASIS.wad MESH.obj NEU.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]`
|
||||
* `geom_demo.py BASIS.wad demo.obj [--plaza]` – Beispielgeometrie aus Texturkachel-Vierecken.
|
||||
* `geomlib.py` – Parser/Builder (TRAK, STPC-Modelle, MAP-Kachelteil, TEXT-Tabelle/Seiten, Kollision).
|
||||
## 10. Tools
|
||||
* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, vertex colours, one
|
||||
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
|
||||
(textured software renderer).
|
||||
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]`
|
||||
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
|
||||
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
|
||||
+222
-222
@@ -1,31 +1,31 @@
|
||||
# Levelbau mit `tools/levelbuild.py`
|
||||
# Level building with `tools/levelbuild.py`
|
||||
|
||||
Stand: 2026-11-06 (Ausgang + Parkour überarbeitet). Baut komplette Level-WADs aus einer Level-Beschreibung (Python-Skript).
|
||||
Basis ist immer ein Original-Level (Standard: Dorf `t1l1m001.wad`): dessen Strat-Code/Objektmodelle
|
||||
(STPC), Texturen (TEXT), Sound usw. werden übernommen; **Geometrie (TRAK), Kachelraster,
|
||||
Vertexfarben, Objekte und Wegpunkte (MAP) werden komplett neu erzeugt.**
|
||||
Ergebnis im Spiel geprüft: `out/testwelt.wad` (= installierte Testwelt, siehe unten).
|
||||
As of: 2026-11-06 (exit + parkour reworked). Builds complete level WADs from a level description (Python script).
|
||||
The base is always an original level (default: village `t1l1m001.wad`): its strat code/object models
|
||||
(STPC), textures (TEXT), sound etc. are taken over; **geometry (TRAK), tile grid,
|
||||
vertex colours, objects and waypoints (MAP) are generated completely from scratch.**
|
||||
Result checked in game: `out/testwelt.wad` (= installed test world, see below).
|
||||
|
||||
## Aufruf
|
||||
## Invocation
|
||||
|
||||
```
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad # bauen
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install # + als TestLevel installieren
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel # Variablen ans Skript
|
||||
python3 tools/levelbuild.py --types # platzierbare Strat-Typen des Basislevels
|
||||
python3 tools/levelbuild.py --textures tex.png # Textur-Kontaktabzug + Namensliste
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad # build
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install # + install as TestLevel
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel # pass variables to the script
|
||||
python3 tools/levelbuild.py --types # placeable strat types of the base level
|
||||
python3 tools/levelbuild.py --textures tex.png # texture contact sheet + name list
|
||||
python3 tools/texsheet.py WAD out.png [--all] [--only 1,2,3] [--size 96]
|
||||
```
|
||||
`--install` kopiert nach `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` (legt einmalig
|
||||
`.orig` an) und legt die Kapitel-Symlinks 2..49 an (s. „Ausgang“). Test im Spiel: `tools/lamatest.py run out/x.wad ordner` (s. testharness.md).
|
||||
`--install` copies to `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` (creates
|
||||
`.orig` once) and creates the chapter symlinks 2..49 (see "Exit"). Test in game: `tools/lamatest.py run out/x.wad outdir` (see testharness.md).
|
||||
|
||||
## Level-Skript
|
||||
## Level script
|
||||
|
||||
```python
|
||||
LEVEL_ARGS = dict(name='Testwelt', seed=7) # NAME-Chunk, Zufallssaat (Texturdrehung)
|
||||
LEVEL_ARGS = dict(name='Testwelt', seed=7) # NAME chunk, random seed (texture rotation)
|
||||
|
||||
def build(L): # L = levelbuild.Level
|
||||
L.sea(0, 0, 88, 72, y=-2.0, kill=(6, 4, 80, 66)) # Meer + Todesebene
|
||||
L.sea(0, 0, 88, 72, y=-2.0, kill=(6, 4, 80, 66)) # sea + death plane
|
||||
L.box(14, 12, 26, 24, -0.4, 0, top='stein', side='stein')
|
||||
L.stairs(26, 18, 29, 22, 0, 1.5, 3, '+x')
|
||||
L.player(20, 14.5, rot=0)
|
||||
@@ -34,240 +34,240 @@ def build(L): # L = levelbuild.Level
|
||||
L.strat('Bunny', 66, 34)
|
||||
L.coins([(27, 20), (28, 20)])
|
||||
|
||||
def report(L): # optional, nach dem Bauen
|
||||
def report(L): # optional, after building
|
||||
...
|
||||
```
|
||||
`L.opts` enthält die `--set`-Werte (Strings).
|
||||
`L.opts` contains the `--set` values (strings).
|
||||
|
||||
### Koordinaten
|
||||
* Spielkoordinaten wie bei Strats: **x nach Osten, y nach oben, z = Rasterzeile (positiv,
|
||||
"Süden")**, 1 Einheit = 1 Rasterzelle. Raster des Dorfes: 96×96 → alles muss in x,z ∈ [0,96) liegen.
|
||||
* Winkel in **Grad**, 0° = Blick nach +z, 90° = Blick nach +x (im Spiel geprüft).
|
||||
* Intern wird in den Exportraum (z gespiegelt) umgerechnet; Umlaufsinn wird automatisch
|
||||
korrigiert (`_orient`), man gibt nur die gewünschte Außennormale an.
|
||||
### Coordinates
|
||||
* Game coordinates as for strats: **x towards east, y up, z = grid row (positive,
|
||||
"south")**, 1 unit = 1 grid cell. Village grid: 96×96 → everything must lie within x,z ∈ [0,96).
|
||||
* Angles in **degrees**, 0° = facing +z, 90° = facing +x (checked in game).
|
||||
* Internally converted into export space (z mirrored); winding order is corrected
|
||||
automatically (`_orient`), you only specify the desired outward normal.
|
||||
|
||||
### Geometrie-Bausteine
|
||||
| Methode | Beschreibung |
|
||||
### Geometry building blocks
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `floor(x0,z0,x1,z1,y, tex, rot='random', coll=True)` | waagerechte Fläche (in 1×1-Kacheln unterteilt) |
|
||||
| `box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None)` | Quader; `side_top` = Textur der obersten Wandreihe (z. B. `'graskante'`), `side_coll=False` = Wände ohne Kollision (niedrige Sockel begehbar) |
|
||||
| `platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde')` | schwebende Plattform, Oberkante y |
|
||||
| `pillar(x,z,y0,y1,size, side, top)` | Säule um Mittelpunkt |
|
||||
| `ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base)` | Rampe, steigt in Richtung `+x/-x/+z/-z` |
|
||||
| `stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True)` | Treppe; `walkable`: unsichtbare Schräge als Kollision |
|
||||
| `fence(pts, y, height)` | Zaun (Pfosten + Latte) entlang Punktliste |
|
||||
| `tree(x,z,y, height, crown)` | Klotz-Baum |
|
||||
| `sea(x0,z0,x1,z1, y, tex='meer', kill=(...))` | Wasserfläche ohne Kollision + Todesebene |
|
||||
| `quad([BL,BR,TR,TL], tex, coll, rot)` | einzelnes Viereck (Ecken von vorne gesehen) |
|
||||
| `collider(pts, normal)` | unsichtbare Kollisionsfläche |
|
||||
| `obj(path, offset, scale, tex)` | OBJ-Datei (Spielkoordinaten), `usemtl tex_N` oder Texturname |
|
||||
| `height_at(x,z)` | Höhe der obersten begehbaren Fläche |
|
||||
| `floor(x0,z0,x1,z1,y, tex, rot='random', coll=True)` | horizontal surface (subdivided into 1×1 tiles) |
|
||||
| `box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None)` | cuboid; `side_top` = texture of the topmost wall row (e.g. `'graskante'`), `side_coll=False` = walls without collision (low plinths walkable) |
|
||||
| `platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde')` | floating platform, top edge y |
|
||||
| `pillar(x,z,y0,y1,size, side, top)` | pillar around a centre point |
|
||||
| `ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base)` | ramp, rises in direction `+x/-x/+z/-z` |
|
||||
| `stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True)` | stairs; `walkable`: invisible slope as collision |
|
||||
| `fence(pts, y, height)` | fence (posts + rail) along a point list |
|
||||
| `tree(x,z,y, height, crown)` | blocky tree |
|
||||
| `sea(x0,z0,x1,z1, y, tex='meer', kill=(...))` | water surface without collision + death plane |
|
||||
| `quad([BL,BR,TR,TL], tex, coll, rot)` | single quad (corners seen from the front) |
|
||||
| `collider(pts, normal)` | invisible collision surface |
|
||||
| `obj(path, offset, scale, tex)` | OBJ file (game coordinates), `usemtl tex_N` or texture name |
|
||||
| `height_at(x,z)` | height of the topmost walkable surface |
|
||||
|
||||
Texturen: Name aus `TEX` (Dorf: `gras, gras2, gras_dunkel, blumen, sand, erde, graskante,
|
||||
Textures: name from `TEX` (village: `gras, gras2, gras_dunkel, blumen, sand, erde, graskante,
|
||||
stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rinde, kiste,
|
||||
inka, muster_lila, …`), direkter Index (int) oder Flachfarbe (`'meer'`, `'rot'`, … oder
|
||||
`(r,g,b)`; wird als Flachfarb-Eintrag (Flags 0x105, Seite −1) an die Texturtabelle angehängt).
|
||||
Kontaktabzug mit Indizes: `notes/img/texturen_dorf.png`.
|
||||
**Jedes Dreieck zeigt eine ganze Texturkachel** (die Engine hat keine freien UVs) – daher wird
|
||||
alles in 1×1-Stücke zerlegt; schmale/flache Wände zeigen die Textur gestaucht.
|
||||
inka, muster_lila, …`), a direct index (int) or a flat colour (`'meer'`, `'rot'`, … or
|
||||
`(r,g,b)`; appended to the texture table as a flat-colour entry (flags 0x105, page −1)).
|
||||
Contact sheet with indices: `notes/img/texturen_dorf.png`.
|
||||
**Every triangle shows a whole texture tile** (the engine has no free UVs) – so
|
||||
everything is split into 1×1 pieces; narrow/flat walls show the texture squashed.
|
||||
|
||||
### Beleuchtung (Vertexfarben)
|
||||
### Lighting (vertex colours)
|
||||
`L.sun = dict(azimuth=215, elevation=50, color=(1,.93,.8), strength=.62, sky=(.5,.55,.66),
|
||||
ground=(.3,.28,.24), ao=.55, ao_radius=1.3, shadow=.35)` – je Vertex:
|
||||
Himmel/Boden-Halbkugellicht + Sonne (Lambert) mit **Schatten** (Strahl zur Sonne gegen alle
|
||||
Quader) + **Umgebungsverdeckung** (24 Strahlen, Reichweite `ao_radius`). Quader, Säulen,
|
||||
Treppen, Plattformen melden sich als Schattenwerfer an (`solids`). 1.0 = Texturfarbe
|
||||
(Vertexfarbe 0x80), max. 2.0. `tint=(r,g,b)` je Baustein multipliziert.
|
||||
ground=(.3,.28,.24), ao=.55, ao_radius=1.3, shadow=.35)` – per vertex:
|
||||
sky/ground hemisphere light + sun (Lambert) with **shadows** (ray towards the sun against all
|
||||
cuboids) + **ambient occlusion** (24 rays, range `ao_radius`). Cuboids, pillars,
|
||||
stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
|
||||
(vertex colour 0x80), max. 2.0. `tint=(r,g,b)` multiplies per building block.
|
||||
|
||||
### Objekte (Strats)
|
||||
### Objects (strats)
|
||||
`L.strat(name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False)`
|
||||
* `y=None`: auf die oberste begehbare Fläche bei (x,z) stellen.
|
||||
* Typfelder (nvars, Slots, Flags, Zusatzspeicher) und Parameter werden von der **ersten
|
||||
Original-Instanz** des Typs kopiert (`params=[…]` überschreibt; Anzahl muss passen).
|
||||
* `variant`: Typen mit mehreren Code-Einstiegen (z. B. SmashPot: 2 Spawn-Stubs).
|
||||
* **Wegpunkte** (`wp`): `'relative'` = Wegpunktkette des Originals mitverschieben (Versatz zum
|
||||
Objekt bleibt, y wird auf den Boden gesetzt, wenn dort einer ist), `'here'` = alle Punkte auf
|
||||
das Objekt, `'none'` = keine (−1), oder eigene Liste `[(x,z[,y[,grad]]), …]` (Zusatzfelder
|
||||
der Original-Wegpunkte werden übernommen). Ketten werden neu nummeriert (keine Verweise ins
|
||||
alte Level).
|
||||
* `player(x,z,rot)` (Strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)`
|
||||
* `y=None`: place on the topmost walkable surface at (x,z).
|
||||
* Type fields (nvars, slots, flags, extra memory) and parameters are copied from the **first
|
||||
original instance** of the type (`params=[…]` overrides; count must match).
|
||||
* `variant`: types with several code entry points (e.g. SmashPot: 2 spawn stubs).
|
||||
* **Waypoints** (`wp`): `'relative'` = move the original's waypoint chain along (offset to the
|
||||
object is kept, y is set to the floor if there is one), `'here'` = all points on
|
||||
the object, `'none'` = none (−1), or your own list `[(x,z[,y[,deg]]), …]` (extra fields
|
||||
of the original waypoints are taken over). Chains are renumbered (no references into the
|
||||
old level).
|
||||
* `player(x,z,rot)` (strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)`
|
||||
(`LevelDoor`), `coins([(x,z),…])`.
|
||||
* Typen, die im Dorf-Code stehen, aber dort nicht platziert sind, über `Strats.BORROWED` mit Typfeldern
|
||||
aus einem anderen Level: derzeit `RubyIdol` (aus t1l1m002).
|
||||
* `poolExtra` wird auf max(Original, Anzahl Strats + 80) gesetzt; der Parameterpool des
|
||||
Originals bleibt erhalten, neue Parameter werden angehängt.
|
||||
* Types that are in the village code but not placed there, via `Strats.BORROWED` with type fields
|
||||
from another level: currently `RubyIdol` (from t1l1m002).
|
||||
* `poolExtra` is set to max(original, number of strats + 80); the original's parameter pool
|
||||
is kept, new parameters are appended.
|
||||
|
||||
### Was `build()` erzeugt
|
||||
1. Alle Dreiecke werden nach (Rasterzelle, Höhenband 10 Einheiten ab y = −3) auf Kacheln
|
||||
verteilt; Kachelursprung = Zellmitte/Bandmitte (Kollisions-d muss in ±8 liegen).
|
||||
2. **Kollision**: jedes Viereck mit `coll=True` wird ein Kollisionspolygon; Böden nur in der
|
||||
eigenen Zelle, Wände/Schrägen zusätzlich in Nachbarzellen bis 0,3 Abstand (die Engine prüft
|
||||
nur die Kacheln der Zelle, in der der Spieler steht). Unbenutzte Hilfsvertices sorgen dafür,
|
||||
dass die Kachel-Box die Kollisionspolygone umfasst.
|
||||
3. Identische Kachelmodelle werden zusammengefasst (Vertexfarben stehen in MAP je Kachel).
|
||||
4. **Todesebene**: Original-Modell 0 des Dorfes (unsichtbar, Material 0x1e) in jeder Zelle
|
||||
des `kill`-Bereichs, die nicht vollständig von Boden bedeckt ist.
|
||||
5. MAP: Zonenraster = 0, Zone 0 sieht alles (Maske 0xffffffff), keine Kachel-Wechsel (Alt-Block
|
||||
leer), Kachelblöcke A/B/Modelle/Vertexfarben konsistent, Strats + Wegpunkte neu, Licht-/
|
||||
Umgebungszonen (90-Byte-Block) und Rest unverändert.
|
||||
6. TEXT: angehängte Flachfarben. NAME: `LEVEL_ARGS['name']`.
|
||||
7. Gegenprobe: MAP wird nach dem Schreiben wieder vollständig geparst.
|
||||
### What `build()` generates
|
||||
1. All triangles are distributed onto tiles by (grid cell, height band of 10 units from y = −3);
|
||||
tile origin = cell centre/band centre (collision d must be within ±8).
|
||||
2. **Collision**: every quad with `coll=True` becomes a collision polygon; floors only in their
|
||||
own cell, walls/slopes additionally in neighbouring cells up to 0.3 distance (the engine only checks
|
||||
the tiles of the cell the player stands in). Unused helper vertices make sure
|
||||
the tile box encloses the collision polygons.
|
||||
3. Identical tile models are merged (vertex colours are stored per tile in MAP).
|
||||
4. **Death plane**: original model 0 of the village (invisible, material 0x1e) in every cell
|
||||
of the `kill` area that is not completely covered by floor.
|
||||
5. MAP: zone grid = 0, zone 0 sees everything (mask 0xffffffff), no tile swaps (alt block
|
||||
empty), tile blocks A/B/models/vertex colours consistent, strats + waypoints new, light/
|
||||
environment zones (90-byte block) and rest unchanged.
|
||||
6. TEXT: appended flat colours. NAME: `LEVEL_ARGS['name']`.
|
||||
7. Cross-check: MAP is fully parsed again after writing.
|
||||
|
||||
## Erkenntnisse aus den Spieltests (lamatest, Xvfb)
|
||||
* **Maßstab**: Lama ≈ 0,6 Einheiten hoch, läuft ≈ 1,1 Einheiten/s (Münz-Messbahn, s. u.; die
|
||||
früher notierten 0,85 waren zu niedrig), braucht ~1 s zum Beschleunigen. Sprung (X) überwindet
|
||||
0,35 hohe Kanten, 0,5 nicht. **Mit Anlauf fliegt es ~1,3 weit** (horizontal gleich schnell wie
|
||||
beim Laufen), auf +0,2 landet es nach ~1,05; aus dem Stand deutlich kürzer (0,5-Lücke nicht
|
||||
geschafft). → Parkour: Lücken 0,3 mit +0,2, Plattformen davor/dahinter ≥ 1,5 lang (nach der
|
||||
Landung muss Platz für den nächsten Absprung sein), größere Höhen über Treppen/Rampen.
|
||||
Messlevel: `levels/sprunglabor.py` (`--set spur=a..g|m`; `m` = Münz-Messbahn: Münze alle 0,25 →
|
||||
Münzzähler × 0,25 = gelaufene Strecke; `f` = Weitsprung über Wasser).
|
||||
* **Keine Stufen-Logik**: schon eine 0,12 hohe Kante *mit Wandkollision* blockiert das Lama.
|
||||
Ohne Wandkollision (`side_coll=False`) läuft es auf niedrige Sockel hinauf; Treppen mit
|
||||
unsichtbarer Schräge (`stairs(walkable=True)`) und Rampen sind begehbar (geprüft).
|
||||
* **Steuerung ist kamerarelativ** (Hoch = Kamerablickrichtung). Die Startkamera steht hinter
|
||||
dem Lama; steht direkt dahinter ein Hindernis (z. B. Pfosten), weicht sie seitlich aus →
|
||||
Startpunkte hinten freihalten.
|
||||
* Rot 0° = Blick +z, 90° = Blick +x.
|
||||
* **Todesebene funktioniert**: Sturz ins Meer → Lebensenergie weg, Respawn am letzten
|
||||
Checkpoint (bzw. Start).
|
||||
* Spieler-Wegpunktkette (14 Punkte beim Dorf-Lama, Bedeutung unklar): `player()` legt den
|
||||
ersten 8 Einheiten hinter das Lama, den Rest auf das Lama, Zusatzfelder vom Original.
|
||||
* **LevelDoor** (Ausgang) – gelöst, siehe Abschnitt „Ausgang (LevelDoor)“ unten.
|
||||
* Größen, die laufen: TRAK 0,6–1,0 MB, bis 1100 Modelle, 5200 Kacheln, WAD bis 6,9 MB
|
||||
(Originale: TRAK ≤ 1 MB, ≤ 5800 Kacheln, WAD ≤ 6,85 MB). Testwelt: 628 Modelle,
|
||||
2474 Kacheln, TRAK 646 KB, WAD 6,3 MB.
|
||||
## Findings from the game tests (lamatest, Xvfb)
|
||||
* **Scale**: llama ≈ 0.6 units tall, walks ≈ 1.1 units/s (coin measuring track, see below; the
|
||||
0.85 noted earlier was too low), needs ~1 s to accelerate. A jump (X) clears
|
||||
0.35 high edges, 0.5 not. **With a run-up it flies ~1.3 far** (horizontally as fast as
|
||||
when walking), onto +0.2 it lands after ~1.05; from standing much shorter (0.5 gap not
|
||||
cleared). → Parkour: gaps 0.3 with +0.2, platforms before/after ≥ 1.5 long (after
|
||||
landing there must be room for the next take-off), larger heights via stairs/ramps.
|
||||
Measuring level: `levels/sprunglabor.py` (`--set spur=a..g|m`; `m` = coin measuring track: a coin every 0.25 →
|
||||
coin counter × 0.25 = distance walked; `f` = long jump over water).
|
||||
* **No step logic**: even a 0.12 high edge *with wall collision* blocks the llama.
|
||||
Without wall collision (`side_coll=False`) it walks up onto low plinths; stairs with
|
||||
an invisible slope (`stairs(walkable=True)`) and ramps are walkable (checked).
|
||||
* **Controls are camera-relative** (Up = camera view direction). The start camera is behind
|
||||
the llama; if there is an obstacle right behind it (e.g. a post), it moves aside →
|
||||
keep the area behind start points clear.
|
||||
* Rot 0° = facing +z, 90° = facing +x.
|
||||
* **Death plane works**: falling into the sea → health gone, respawn at the last
|
||||
checkpoint (or start).
|
||||
* Player waypoint chain (14 points for the village llama, meaning unclear): `player()` puts the
|
||||
first one 8 units behind the llama, the rest on the llama, extra fields from the original.
|
||||
* **LevelDoor** (exit) – solved, see section "Exit (LevelDoor)" below.
|
||||
* Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB
|
||||
(originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models,
|
||||
2474 tiles, TRAK 646 KB, WAD 6.3 MB.
|
||||
|
||||
## Ausgang (LevelDoor) – wie er funktioniert
|
||||
## Exit (LevelDoor) – how it works
|
||||
|
||||
Strat-Bytecode (Dorf, STPC 0x430332, 3 Parameter, unsichtbar, kein Modell), dekodiert über die
|
||||
VM `FUN_0054d180` (Opcode < 0x45: Tabelle 0x5796bc mit 16-Bit-Operand, sonst 0x578ff4+op·4):
|
||||
Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the
|
||||
VM `FUN_0054d180` (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):
|
||||
```
|
||||
g65,g66,g67 = Param 0,1,2 ; Stamm, Welt, Kapitel des nächsten Levels (20.12)
|
||||
loop: if g21 == 3 ; Spielzustand "läuft"
|
||||
and Spieler.Boden.Material == 16 ; Eigenschaft 0x11 = *(strat+0xc0)+4 = Material-Byte des
|
||||
; Kollisionspolygons unter dem Spieler (FUN_00403020)
|
||||
and floor(Spieler.x) == floor(Tür.x) and floor(Spieler.z) == floor(Tür.z) ; gleiche Rasterzelle
|
||||
-> op 0x192 (FUN_0054d620): Levelwechsel(Param), g1 += 1, Ende
|
||||
g65,g66,g67 = param 0,1,2 ; tribe, world, chapter of the next level (20.12)
|
||||
loop: if g21 == 3 ; game state "running"
|
||||
and player.floor.material == 16 ; property 0x11 = *(strat+0xc0)+4 = material byte of the
|
||||
; collision polygon below the player (FUN_00403020)
|
||||
and floor(player.x) == floor(door.x) and floor(player.z) == floor(door.z) ; same grid cell
|
||||
-> op 0x192 (FUN_0054d620): level change(param), g1 += 1, end
|
||||
yield
|
||||
```
|
||||
* Die Tür ist also nur ein Auslöser; im Dorf liegt im Türdurchgang ein Bodenpolygon mit
|
||||
**Material 16** (8 Stück im Dorf, auch in allen anderen Levels). Unsere Testwelt hatte überall
|
||||
Material 0 → nichts passierte. **Fix:** Mosaik im Tor mit `mat=16` (`box(..., mat=TUER_MAT)`),
|
||||
Tür in dieselbe Rasterzelle, Spielerweg führt mitten hindurch.
|
||||
* **Nur Kacheln der Türzelle zählen** (Zellliste, FUN_004036d0), die Höhe nicht (t1l3m002–4: Boden
|
||||
3 Einheiten unter der Tür). **Dorf 1 & Co.:** In der Türzelle liegt zunächst *kein* Material 16 –
|
||||
es steckt im **Wechselmodell** der Kachel (MAP-Kachel-Wechsel, WFPC 0x10000): die RubyIdolDoor in
|
||||
derselben Zelle tauscht beim Öffnen (Op 0x11a/0x11b) Kachel 420 von Modell 48 auf 49, und erst
|
||||
Modell 49 hat das Ausgangspolster (42,25–42,75 / 49,23–49,50, y 3,25). Im Spiel geprüft
|
||||
(2026-11-07): Modell 49 eingesetzt + Tür entfernt, Lama bei 42,5/3,5/49,37 → sofort Bilanz
|
||||
„Dorf Kapitel 1“; Original (Tür zu) → nichts. Betrifft 8 der 31 Original-Türen (Ruby-,
|
||||
PressurePad-, Lab-Türen); das Polster bei 43,5/48,45 (Zelle 43,48) ist dagegen ein Hauseingang
|
||||
(Zonen-Teleport). t1l3m003: 2 der 4 Türen (Zellen 71,48/71,49) haben gar kein Polster.
|
||||
* Zweiter Weg (Strat-Code bei 0x4252aa, Spielerlogik [wahrsch.]): Boden-Material 16 und Zelle =
|
||||
g104/g105 (setzt die LevelDoor beim Start, Llama setzt sie auf −1) → Levelwechsel(g101..g103).
|
||||
* Parameter in den Originalen: (1, Welt, Kapitel+1) = nächstes Kapitel; im letzten Kapitel einer
|
||||
Welt **(7,7,7)** = „Welt geschafft“ → zurück zur Auswahlkarte, nächste Welt freischalten.
|
||||
* `FUN_0054d620`: merkt aktuelles Level (0x585008..14), setzt nächstes (0x584eb4/b8/bc), zeigt die
|
||||
Bilanz (Münzen x/55, Geheimnisse) mit „EINGABETASTE = speichern / ESC = weiter“. Fortschritt
|
||||
0x585018/19 (max. Welt/Kapitel, im KSV +0x42/+0x43): ist man im höchsten freien Kapitel, wird
|
||||
das nächste Kapitel freigeschaltet; bei (7,7,7) die nächste Welt.
|
||||
* **(7,7,7) in Welt 9 stürzt ab**: schaltet „Welt 10“ frei → nach dem Speichern steht der
|
||||
Kartencursor auf Welt 10, Weltnamen-Zeiger 0x57bd40[9] = NULL → Page Fault bei 0x41B5CC.
|
||||
* **Lösung Testwelt: Parameter (1,9,1)** – nach der Bilanz wird Kapitel 1 neu geladen (kein
|
||||
Absturz, getestet mit Speichern). Nebenwirkung: Kapitel 2 wird freigeschaltet (und von
|
||||
Kapitel n aus Kapitel n+1). Fehlt dessen WAD, stürzt die Auswahl ab („Couldnt load wad“).
|
||||
Deshalb legt `levelbuild.py --install` (Funktion `kapitel_links()`) in der Mod
|
||||
**Symlinks T1L9M002..049.wad → T1L9M001.wad und T192..T1949.COR → T191.COR** an (Welt 9 hat
|
||||
49 Kapitel-Plätze, Tabelle 0x572420) und ruft `lama-mod an` auf. `lama-mod` übernimmt dafür jetzt
|
||||
auch Symlinks aus Mod-Ordnern (`find … -type f -o -type l`). Geprüft: Kapitel 2 auf der Karte
|
||||
wählbar, lädt die Testwelt.
|
||||
* Die Auswahlkarte zeigt für Welt 9 keinen Hintergrund (nur Text) und 772 als Münzziel
|
||||
(Münztabelle 0x5722f4 hat nur Welten 1–8) – kosmetisch.
|
||||
* So the door is only a trigger; in the village there is a floor polygon with
|
||||
**material 16** in the doorway (8 of them in the village, also in all other levels). Our test world had
|
||||
material 0 everywhere → nothing happened. **Fix:** mosaic in the gate with `mat=16` (`box(..., mat=TUER_MAT)`),
|
||||
door in the same grid cell, the player's path leads right through it.
|
||||
* **Only tiles of the door cell count** (cell list, FUN_004036d0), not the height (t1l3m002–4: floor
|
||||
3 units below the door). **Village 1 & co.:** the door cell initially contains *no* material 16 –
|
||||
it is in the **swap model** of the tile (MAP tile swap, WFPC 0x10000): the RubyIdolDoor in
|
||||
the same cell swaps tile 420 from model 48 to 49 when opening (op 0x11a/0x11b), and only
|
||||
model 49 has the exit pad (42.25–42.75 / 49.23–49.50, y 3.25). Checked in game
|
||||
(2026-11-07): model 49 inserted + door removed, llama at 42.5/3.5/49.37 → immediately the summary
|
||||
"Dorf Kapitel 1"; original (door closed) → nothing. Affects 8 of the 31 original doors (Ruby,
|
||||
PressurePad, Lab doors); the pad at 43.5/48.45 (cell 43,48), on the other hand, is a house entrance
|
||||
(zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
|
||||
* Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell =
|
||||
g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).
|
||||
* Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a
|
||||
world **(7,7,7)** = "world completed" → back to the selection map, unlock the next world.
|
||||
* `FUN_0054d620`: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the
|
||||
summary (coins x/55, secrets) with "EINGABETASTE = speichern / ESC = weiter" (Enter = save / Esc = continue). Progress
|
||||
0x585018/19 (max. world/chapter, in KSV +0x42/+0x43): if you are in the highest unlocked chapter,
|
||||
the next chapter is unlocked; with (7,7,7) the next world.
|
||||
* **(7,7,7) in world 9 crashes**: unlocks "world 10" → after saving, the
|
||||
map cursor is on world 10, world name pointer 0x57bd40[9] = NULL → page fault at 0x41B5CC.
|
||||
* **Solution for the test world: parameters (1,9,1)** – after the summary chapter 1 is reloaded (no
|
||||
crash, tested with saving). Side effect: chapter 2 is unlocked (and from
|
||||
chapter n, chapter n+1). If its WAD is missing, the selection crashes ("Couldnt load wad").
|
||||
That is why `levelbuild.py --install` (function `kapitel_links()`) creates in the mod
|
||||
**symlinks T1L9M002..049.wad → T1L9M001.wad and T192..T1949.COR → T191.COR** (world 9 has
|
||||
49 chapter slots, table 0x572420) and calls `lama-mod an`. For this, `lama-mod` now also takes
|
||||
symlinks from mod folders (`find … -type f -o -type l`). Checked: chapter 2 on the map is
|
||||
selectable and loads the test world.
|
||||
* The selection map shows no background for world 9 (only text) and 772 as the coin target
|
||||
(coin table 0x5722f4 only has worlds 1–8) – cosmetic.
|
||||
|
||||
## Die Testwelt (`levels/testwelt.py` → `out/testwelt.wad`, installiert als T1L9M001.wad)
|
||||
**2026-11-06 kompakt umgebaut** („alles näher zusammen“): L-förmige Insel, Hauptteil 32,5×23,5
|
||||
(x 11..43,5, z 9,5..33) + Streifen unter dem Parkour-Ende (x 43,5..49, z 9,5..20,5), vorher
|
||||
42×34 (x 10..52, z 8..42). Galerie, Gehege und Parkour grenzen direkt aneinander, keine leeren
|
||||
Wiesen mehr. NAME-Chunk "Testwelt" (wird im Spiel nirgends angezeigt).
|
||||
Vorschaubilder (Handy, 1280 px): `notes/img/vorschau/` (01–03 Blender-Renderings mit
|
||||
Beschriftung, 04–11 Spielfotos, 12 Montage). Ältere Bilder `notes/img/testwelt_*.jpg` zeigen
|
||||
noch das alte, weitläufige Layout.
|
||||
## The test world (`levels/testwelt.py` → `out/testwelt.wad`, installed as T1L9M001.wad)
|
||||
**Rebuilt compactly on 2026-11-06** ("everything closer together"): L-shaped island, main part 32.5×23.5
|
||||
(x 11..43.5, z 9.5..33) + strip below the parkour end (x 43.5..49, z 9.5..20.5), previously
|
||||
42×34 (x 10..52, z 8..42). Gallery, enclosure and parkour adjoin each other directly, no empty
|
||||
meadows any more. NAME chunk "Testwelt" (not shown anywhere in the game).
|
||||
Preview images (phone, 1280 px): `notes/img/vorschau/` (01–03 Blender renderings with
|
||||
labels, 04–11 game shots, 12 montage). Older images `notes/img/testwelt_*.jpg` still show
|
||||
the old, sprawling layout.
|
||||
|
||||
| Bereich | alt | neu |
|
||||
| Area | old | new |
|
||||
|---|---|---|
|
||||
| Startplatz | x 14..22, z 11..19 (8×8) | x 15..21, z 12..19 (6×7) |
|
||||
| Galerie | z 22..38, Spalten 2,5, Reihen z 25/28,5/32/35,5 | x 14,5..32,5, z 20,5..32, Spalten 1,9, Reihen z 22,5/25,2/27,9/30,6 (Abstand 2,7) |
|
||||
| Gehege | x 39..49, z 24..35 | x 33..42, z 20,8..30 (direkt östlich der Galerie) |
|
||||
| Parkour | x 22..53,6 | x 21..52,6 (nur um −1 in x verschoben, Maße gleich) |
|
||||
| Ausgang | LevelDoor (51,5/15,5) | LevelDoor (50,5/15,5), Zelle 50/15, Mosaik mit Material 16 |
|
||||
| Start square | x 14..22, z 11..19 (8×8) | x 15..21, z 12..19 (6×7) |
|
||||
| Gallery | z 22..38, columns 2.5, rows z 25/28.5/32/35.5 | x 14.5..32.5, z 20.5..32, columns 1.9, rows z 22.5/25.2/27.9/30.6 (spacing 2.7) |
|
||||
| Enclosure | x 39..49, z 24..35 | x 33..42, z 20.8..30 (directly east of the gallery) |
|
||||
| Parkour | x 22..53.6 | x 21..52.6 (only shifted by −1 in x, same dimensions) |
|
||||
| Exit | LevelDoor (51.5/15.5) | LevelDoor (50.5/15.5), cell 50/15, mosaic with material 16 |
|
||||
|
||||
* **Startplatz** (x 15..21, z 12..19): Steinplatz mit Inka-Mosaik, 4 Inka-Säulen, Blumenbeete,
|
||||
Bäume, 2 Münzen, Checkpoint (16,1/17,4). Lama startet bei (18, 14,6) mit Blick nach Süden
|
||||
(Galerie direkt dahinter, Parkour-Treppe links im Bild).
|
||||
* **Galerie** (z 20,5..32): 4 Reihen, je Objekt ein flacher Sockel (0,9 breit, 0,08 hoch,
|
||||
begehbar), Lücke 1,0 zwischen Sockeln, Sandweg (0,6) vor jeder Reihe, farbiger Pfosten am
|
||||
Reihenanfang (gelb/grau/blau/rot), Hauptweg Nord–Süd bei x 15,6..16,3:
|
||||
1. Sammeln: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret
|
||||
2. Kisten/Töpfe: SmashPot (2 Varianten), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport
|
||||
3. Figuren: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy
|
||||
4. Mechanik: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage,
|
||||
* **Start square** (x 15..21, z 12..19): stone square with Inca mosaic, 4 Inca pillars, flower beds,
|
||||
trees, 2 coins, checkpoint (16.1/17.4). The llama starts at (18, 14.6) facing south
|
||||
(gallery directly behind it, parkour stairs on the left of the picture).
|
||||
* **Gallery** (z 20.5..32): 4 rows, each object on a flat plinth (0.9 wide, 0.08 high,
|
||||
walkable), gap 1.0 between plinths, sand path (0.6) in front of each row, coloured post at the
|
||||
start of the row (yellow/grey/blue/red), main path north–south at x 15.6..16.3:
|
||||
1. Collectables: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret
|
||||
2. Crates/pots: SmashPot (2 variants), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport
|
||||
3. Characters: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy
|
||||
4. Mechanics: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage,
|
||||
RisingColumns, SinkingStatue, FallingTree, CheckPointPost
|
||||
* **Gehege** (x 33..42, z 20,8..30, Zaun, Eingang Westseite z 20,8..24): Bunny ×2, Ladybird,
|
||||
FlockOfBirds, VillagerHerder; Aussichtspodest (0,6) mit Treppe an der Westseite zwischen
|
||||
Galerie-Reihe 2 und 3.
|
||||
* **Parkour** (beginnt an der Ostkante des Platzes, **alles auf der Linie z = 15,5 nach Osten**,
|
||||
damit man ohne Kameradrehen durchkommt – Drehen per A/S oder Links/Rechts war im Skript nicht
|
||||
reproduzierbar). Geometrie im Skript als „alte x + PX“ (PX = −1) geschrieben:
|
||||
Treppe 0→0,8 · **Anschlag-Stufe** +0,3 (ohne Lücke: Lama läuft dagegen und steht still) ·
|
||||
2 Lücken 0,3 mit je +0,2 auf 1,5 lange Holzklötze · Balken 0,5 breit · Mittelplattform (1,5) mit
|
||||
Checkpoint · Sandrampe 1,5→2,4 · **Anschlag-Stufe** +0,3 · 3 Trittsteine (1,5 lang, Lücken 0,3,
|
||||
je +0,2) · schmaler Balken 0,45 · Treppe 3,3→4,0 · Ziel-Plattform (4,0; x 47,6..52,6, ragt über
|
||||
die Küste) mit Säulen, Brustwehr und Tor; darunter Mosaik mit Material 16, LevelDoor bei
|
||||
(50,5 / 15,5). 18 Münzen entlang. Unter dem Parkour liegt Wiese (Herunterfallen ist harmlos).
|
||||
* **Skript-Durchlauf** (Start → Ziel → Ausgang → Bilanz), alte Fassung 4/4, kompakte Fassung 2/2
|
||||
Läufe erfolgreich (Bilanz 18/55 Münzen); die .seq ist zeitbasiert/relativ und blieb unverändert:
|
||||
* **Enclosure** (x 33..42, z 20.8..30, fence, entrance on the west side z 20.8..24): Bunny ×2, Ladybird,
|
||||
FlockOfBirds, VillagerHerder; viewing pedestal (0.6) with stairs on the west side between
|
||||
gallery rows 2 and 3.
|
||||
* **Parkour** (starts at the east edge of the square, **everything on the line z = 15.5 going east**,
|
||||
so it can be done without rotating the camera – rotating via A/S or Left/Right was not
|
||||
reproducible in scripts). Geometry is written in the script as "old x + PX" (PX = −1):
|
||||
stairs 0→0.8 · **stop step** +0.3 (no gap: the llama runs into it and stands still) ·
|
||||
2 gaps of 0.3 with +0.2 each onto 1.5 long wooden blocks · beam 0.5 wide · middle platform (1.5) with
|
||||
checkpoint · sand ramp 1.5→2.4 · **stop step** +0.3 · 3 stepping stones (1.5 long, gaps 0.3,
|
||||
+0.2 each) · narrow beam 0.45 · stairs 3.3→4.0 · goal platform (4.0; x 47.6..52.6, juts out over
|
||||
the coast) with pillars, parapet and gate; below it a mosaic with material 16, LevelDoor at
|
||||
(50.5 / 15.5). 18 coins along the way. Below the parkour there is meadow (falling down is harmless).
|
||||
* **Scripted run** (start → goal → exit → summary), old version 4/4, compact version 2/2
|
||||
runs successful (summary 18/55 coins); the .seq is time-based/relative and stayed unchanged:
|
||||
```
|
||||
python3 tools/levelbuild.py levels/testwelt.py out/testwelt_parkour.wad --set start=parkour
|
||||
lamatest.py run out/testwelt_parkour.wad AUSGABE --seq "$(cat levels/testwelt_parkour.seq)"
|
||||
lamatest.py run out/testwelt_parkour.wad OUTDIR --seq "$(cat levels/testwelt_parkour.seq)"
|
||||
```
|
||||
(`run:T:x@t,…` hält Up T s und drückt X zu festen Zeiten; die Anschlag-Stufen setzen den
|
||||
Zeitfehler zurück. Fotos (`shot@`) blockieren ~0,4 s → nie kurz vor einem Sprung.)
|
||||
* Im Spiel geprüft: Laden ohne Absturz (viele Läufe), alle 31 Galerie-Objekte erscheinen
|
||||
(Münze/Secret/… reagieren: Münzzähler, "GEHEIMNIS!"), Lama läuft Galerie/Gehege ab,
|
||||
Treppe hoch, Sprung auf Plattform B und auf Trittstein 1, Rampe hoch, Treppe zur
|
||||
Ziel-Plattform, Meer = Respawn, kompletter Parkour per Skript, Ausgang → Bilanz → Kapitel 1 neu.
|
||||
Bilder: aktuell `notes/img/vorschau/` (kompaktes Layout); `notes/img/testwelt_*.jpg` =
|
||||
altes weitläufiges Layout (1–6 + Montage sogar noch mit altem Parkour).
|
||||
* Startpunkt für Tests umschaltbar: `--set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2`
|
||||
(`podest` = auf dem Aussichtspodest, Blick nach Westen über die Galerie);
|
||||
Ausgangsparameter `--set tuer=1,9,1`.
|
||||
(`run:T:x@t,…` holds Up for T s and presses X at fixed times; the stop steps reset the
|
||||
timing error. Shots (`shot@`) block ~0.4 s → never shortly before a jump.)
|
||||
* Checked in game: loading without a crash (many runs), all 31 gallery objects appear
|
||||
(coin/secret/… react: coin counter, "GEHEIMNIS!"), the llama walks through gallery/enclosure,
|
||||
up the stairs, jumps onto platform B and onto stepping stone 1, up the ramp, stairs to the
|
||||
goal platform, sea = respawn, complete parkour by script, exit → summary → chapter 1 again.
|
||||
Images: current `notes/img/vorschau/` (compact layout); `notes/img/testwelt_*.jpg` =
|
||||
old sprawling layout (1–6 + montage even still with the old parkour).
|
||||
* Start point for tests is switchable: `--set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2`
|
||||
(`podest` = on the viewing pedestal, looking west over the gallery);
|
||||
exit parameters `--set tuer=1,9,1`.
|
||||
|
||||
### Nicht platzierte Dorf-Typen
|
||||
| Typ | Grund |
|
||||
### Village types not placed
|
||||
| Type | Reason |
|
||||
|---|---|
|
||||
| Llama | ist der Spieler (Start) |
|
||||
| LevelDoor | Ausgang am Parkour-Ende (braucht Boden mit Material 16, s. o.) |
|
||||
| CameraSide | Kamera-Zone, unsichtbar |
|
||||
| CutSceneCameraDirectional | Zwischensequenz-Kamera, unsichtbar |
|
||||
| CutSceneTrigger | startet Zwischensequenz |
|
||||
Kein platzierbarer Typ hat das Spiel zum Absturz gebracht (keine Bisektion nötig).
|
||||
Hinweis: Mechanik-Objekte (Türen, Säulen, Statue) gehören im Dorf zu bestimmter Geometrie /
|
||||
Kachel-Wechseln; in der Galerie stehen sie "nackt" (z. B. schwebendes RubyIdolDoor-Schild).
|
||||
Typen, die nur im Code stehen (BouncingCoin, Sparkles, RubyIdolStatue …), haben keine
|
||||
Original-Instanz mit Typfeldern und werden nicht angeboten (Ausnahme RubyIdol über `Strats.BORROWED`).
|
||||
| Llama | is the player (start) |
|
||||
| LevelDoor | exit at the end of the parkour (needs a floor with material 16, see above) |
|
||||
| CameraSide | camera zone, invisible |
|
||||
| CutSceneCameraDirectional | cutscene camera, invisible |
|
||||
| CutSceneTrigger | starts a cutscene |
|
||||
No placeable type crashed the game (no bisection needed).
|
||||
Note: mechanics objects (doors, pillars, statue) belong to specific geometry /
|
||||
tile swaps in the village; in the gallery they stand "bare" (e.g. a floating RubyIdolDoor sign).
|
||||
Types that only exist in the code (BouncingCoin, Sparkles, RubyIdolStatue …) have no
|
||||
original instance with type fields and are not offered (exception: RubyIdol via `Strats.BORROWED`).
|
||||
|
||||
## Vorschau-Renderings (Blender)
|
||||
`/usr/bin/blender` (Debian 3.0.1): **Cycles rendert nur Schwarz** (auch die Standardszene), OCIO
|
||||
fehlt („Color management: fallback“, nur View „Standard“). **Eevee geht**, braucht aber ein
|
||||
X-Display → eigenes `Xvfb :7` (nie :0, :5 gehört lamatest). Ablauf (Skripte in
|
||||
`tools/vorschau/`: `strats_json.py` (aus levelbau/ aufrufen), `render_level.py`, `label.py`):
|
||||
## Preview renderings (Blender)
|
||||
`/usr/bin/blender` (Debian 3.0.1): **Cycles renders only black** (even the default scene), OCIO
|
||||
is missing ("Color management: fallback", only the "Standard" view). **Eevee works**, but needs an
|
||||
X display → own `Xvfb :7` (never :0, :5 belongs to lamatest). Procedure (scripts in
|
||||
`tools/vorschau/`: `strats_json.py` (call from levelbau/), `render_level.py`, `label.py`):
|
||||
1. `geom_export.py out/testwelt.wad GE` → level.obj/mtl + page_NN.png
|
||||
2. Strat-Positionen: Levelskript mit `levelbuild.Level` ausführen (ohne `L.build`), `L.strats` als JSON
|
||||
2. Strat positions: run the level script with `levelbuild.Level` (without `L.build`), `L.strats` as JSON
|
||||
3. `DISPLAY=:7 STRATS=strats.json VIEWS=schraeg,schraeg2,oben blender -b -P render_level.py -- GE r 64`:
|
||||
eigener OBJ-Leser (der 3.0-Importer kennt keine Vertexfarben), Spiegelung OBJ→Spiel (y_blender =
|
||||
−Spiel-z, Umlaufsinn drehen), Material = Textur (Closest) × Vertexfarbe, etwas Emission,
|
||||
Sonne + Himmel, Marker für Strats (Münzen gold, Lama-Start oranger Kegel, Galerie-Objekte
|
||||
in Reihenfarbe, Tiere weiß, Ausgang lila Ring); schreibt je Ansicht PNG + JSON mit
|
||||
Bildkoordinaten der Bereichsnamen.
|
||||
4. PIL: auf 1280 px skalieren, Beschriftungen (Start/Galerie/Gehege/Parkour/Ziel), JPG q85.
|
||||
own OBJ reader (the 3.0 importer does not know vertex colours), mirroring OBJ→game (y_blender =
|
||||
−game z, flip winding), material = texture (closest) × vertex colour, a little emission,
|
||||
sun + sky, markers for strats (coins gold, llama start orange cone, gallery objects
|
||||
in row colour, animals white, exit purple ring); writes PNG + JSON per view with
|
||||
image coordinates of the area names.
|
||||
4. PIL: scale to 1280 px, labels (Start/Galerie/Gehege/Parkour/Ziel), JPG q85.
|
||||
+115
-115
@@ -1,135 +1,135 @@
|
||||
# MAP-Chunk & Objekte (Strats) – "Ein Königreich für ein Lama" (PC)
|
||||
# MAP chunk & objects (strats) – "Ein Königreich für ein Lama" (PC)
|
||||
|
||||
Quelle: groove.exe, MAP-Lader `FUN_0042ac50`, Strat-Spawn `FUN_0054cfc0` → `FUN_0054bfc0`,
|
||||
Strat-VM `FUN_0054d180`, Chunk-Dispatcher bei 0x558960. Werkzeuge in `../tools/`.
|
||||
Alle 34 Original-MAPs werden von `mapchunk.parse()` vollständig gelesen (Rest 0 Byte) und
|
||||
von `mapchunk.build()` **byte-identisch** zurückgeschrieben.
|
||||
Source: groove.exe, MAP loader `FUN_0042ac50`, strat spawn `FUN_0054cfc0` → `FUN_0054bfc0`,
|
||||
strat VM `FUN_0054d180`, chunk dispatcher at 0x558960. Tools in `../tools/`.
|
||||
All 34 original MAPs are read completely by `mapchunk.parse()` (0 bytes left over) and
|
||||
written back **byte-identically** by `mapchunk.build()`.
|
||||
|
||||
## Begriffe
|
||||
* **Tile** = platziertes Geometrie-Modell aus **TRAK** (Level-"Baukasten", 1x1-Raster).
|
||||
* **Strat** = Objekt/Script-Instanz (Gegner, Münze, Tür, Spieler …). Der Strat-Code
|
||||
(Bytecode, u32-Wörter) und die Objektmodelle liegen in **STPC**; MAP enthält nur Instanzen
|
||||
mit Offset in STPC. Es gibt **keine Typ-ID** – der Typ *ist* die Code-Adresse.
|
||||
Typnamen stehen im Code: Opcode `0xd4, 0xea, s32 rel` → Name = C-String bei
|
||||
(Adresse hinter rel) + rel. `strats.py` löst das auf.
|
||||
* Koordinaten: Welt-y nach **oben**. Tiles speichern float x, y, z mit **z negativ**;
|
||||
Strats/fixed-point (20.12, 4096 = 1.0) speichern z **positiv** (= −tile.z).
|
||||
Winkel: 4096 = 360°. Boden-Tiles (flaches Modell, Vertex-y = +0.25) → Objekte stehen auf
|
||||
`tile.y + 0.25` (bestätigt an Spieler/Checkpoints/Münzen).
|
||||
## Terms
|
||||
* **Tile** = placed geometry model from **TRAK** (the level "construction kit", 1x1 grid).
|
||||
* **Strat** = object/script instance (enemy, coin, door, player …). The strat code
|
||||
(bytecode, u32 words) and the object models live in **STPC**; MAP only contains instances
|
||||
with an offset into STPC. There is **no type ID** – the type *is* the code address.
|
||||
Type names are in the code: opcode `0xd4, 0xea, s32 rel` → name = C string at
|
||||
(address after rel) + rel. `strats.py` resolves this.
|
||||
* Coordinates: world y points **up**. Tiles store float x, y, z with **z negative**;
|
||||
strats/fixed point (20.12, 4096 = 1.0) store z **positive** (= −tile.z).
|
||||
Angles: 4096 = 360°. Floor tiles (flat model, vertex y = +0.25) → objects stand at
|
||||
`tile.y + 0.25` (confirmed on player/checkpoints/coins).
|
||||
|
||||
## Layout MAP (alles little endian) — Konfidenz: hoch (aus Lader + Roundtrip)
|
||||
WFPC (u32 Flags, globale 0x6da330) steuert optionale Blöcke.
|
||||
## MAP layout (all little endian) — confidence: high (from loader + roundtrip)
|
||||
WFPC (u32 flags, global 0x6da330) controls optional blocks.
|
||||
|
||||
| # | Block | Format |
|
||||
|---|---|---|
|
||||
| 1 | Kopf | u32 nTiles, u32 W, u32 H (Rastergröße in Zellen) |
|
||||
| 2 | Tiles | nTiles × {f32 x, f32 y, f32 z, u32 rotY(0/1024/2048/3072), u32 modell, u32 hatAlt} |
|
||||
| 3 | Parameterpool | u32 n, n × u32 (Strat-Parameter, von Strats per Index referenziert) |
|
||||
| 4 | Zonen ("Environment") | u32 n, n × 90 Byte (s.u.) |
|
||||
| 5 | Wegpunkte | u32 n, n × 34 Byte {u32 weiter(≠0: nächster Eintrag gehört zur selben Kette, 0 = Kettenende), u32, u16 rx, u16 ry, u16 rz, s32 x, y, z (fixed, z positiv), u32, u32} |
|
||||
| 6 | Regionstabelle | 32 × {u32 flags, u32 wert} (→ 0x6da350) |
|
||||
| 7 | Regionsraster | W×H × u32 Regionsindex (0..31) je Zelle, Zeile = z-Zelle |
|
||||
| 8 | Tiles fixed | nTiles × 6 u32 {rx=0, rotY, rz=0, x·4096, y·4096, −z·4096} – **exakt aus Block 2 ableitbar** |
|
||||
| 9 | Tile-Modelle | nTiles × u32 TRAK-Modellindex (= Feld 5 aus Block 2) |
|
||||
| 10 | Alt-Modelle (WFPC&0x10000) | u32 n, n × 5 u32; n = Anzahl Tiles mit hatAlt≠0, in Tile-Reihenfolge; Feld 4 = alternatives TRAK-Modell (z. B. Tür offen/zu, stMapreplace) |
|
||||
| 11 | Vertexfarben | je Tile: nv(TRAK-Modell) × u32 RGBA (Byte 3 = Frameanzahl F); wenn max F>1: (F−1)×nv weitere u32; danach dasselbe für das Alt-Modell, falls vorhanden. Farbe wird ×2 (Clamp 255) |
|
||||
| 12 | Strats | u32 nStrats, u32 poolExtra, nStrats × 58 Byte (s.u.) |
|
||||
| 13 | Pfade (WFPC&0x200000) | u32 n; je: u32 nsub; je sub: u16 cnt, u16 flags; cnt × {u32,u32,[u32 wenn WFPC&0x4000000],u16 typ,u16} – in keinem Original-Level benutzt |
|
||||
| 14 | (WFPC&0x10) | u32 (Default 200; Größe eines 0x48-Puffers) |
|
||||
| 15 | | u16 (→ mapobj+0xc, Bedeutung offen) |
|
||||
| 1 | Header | u32 nTiles, u32 W, u32 H (grid size in cells) |
|
||||
| 2 | Tiles | nTiles × {f32 x, f32 y, f32 z, u32 rotY(0/1024/2048/3072), u32 model, u32 hasAlt} |
|
||||
| 3 | Parameter pool | u32 n, n × u32 (strat parameters, referenced by strats via index) |
|
||||
| 4 | Zones ("Environment") | u32 n, n × 90 bytes (see below) |
|
||||
| 5 | Waypoints | u32 n, n × 34 bytes {u32 next(≠0: next entry belongs to the same chain, 0 = end of chain), u32, u16 rx, u16 ry, u16 rz, s32 x, y, z (fixed, z positive), u32, u32} |
|
||||
| 6 | Region table | 32 × {u32 flags, u32 value} (→ 0x6da350) |
|
||||
| 7 | Region grid | W×H × u32 region index (0..31) per cell, row = z cell |
|
||||
| 8 | Tiles fixed | nTiles × 6 u32 {rx=0, rotY, rz=0, x·4096, y·4096, −z·4096} – **exactly derivable from block 2** |
|
||||
| 9 | Tile models | nTiles × u32 TRAK model index (= field 5 of block 2) |
|
||||
| 10 | Alt models (WFPC&0x10000) | u32 n, n × 5 u32; n = number of tiles with hasAlt≠0, in tile order; field 4 = alternative TRAK model (e.g. door open/closed, stMapreplace) |
|
||||
| 11 | Vertex colours | per tile: nv(TRAK model) × u32 RGBA (byte 3 = frame count F); if max F>1: (F−1)×nv more u32; then the same for the alt model, if present. Colour is ×2 (clamped to 255) |
|
||||
| 12 | Strats | u32 nStrats, u32 poolExtra, nStrats × 58 bytes (see below) |
|
||||
| 13 | Paths (WFPC&0x200000) | u32 n; each: u32 nsub; each sub: u16 cnt, u16 flags; cnt × {u32,u32,[u32 if WFPC&0x4000000],u16 type,u16} – not used in any original level |
|
||||
| 14 | (WFPC&0x10) | u32 (default 200; size of a 0x48 buffer) |
|
||||
| 15 | | u16 (→ mapobj+0xc, meaning open) |
|
||||
|
||||
Zellenzuordnung Tile: `col = int(x−0.5)`, `row = −int(z+0.5)`, Index `col + row·W` (verkettete
|
||||
Liste je Zelle). **Tiles außerhalb 0..W−1 / 0..H−1 schreiben in fremden Speicher.**
|
||||
Cell assignment of a tile: `col = int(x−0.5)`, `row = −int(z+0.5)`, index `col + row·W` (linked
|
||||
list per cell). **Tiles outside 0..W−1 / 0..H−1 write into foreign memory.**
|
||||
|
||||
### Strat-Eintrag (58 Byte) — `mapchunk.STRAT = '3H3iIIi4Iiih'+'H'`
|
||||
| Off | Typ | Feld | Konfidenz |
|
||||
### Strat entry (58 bytes) — `mapchunk.STRAT = '3H3iIIi4Iiih'+'H'`
|
||||
| Off | Type | Field | Confidence |
|
||||
|---|---|---|---|
|
||||
| 0 | u16 ×3 | Rotation x, y, z (4096=360°; x/z fast immer 0) | hoch |
|
||||
| 6 | s32 ×3 | Position x, y, z fixed 20.12 (z positiv) | hoch |
|
||||
| 18 | u32 | **Code-Offset in STPC** (= Objekttyp) | hoch |
|
||||
| 22 | u32 | Anzahl Parameter n | hoch |
|
||||
| 26 | s32 | Index in Parameterpool (−1 wenn n=0); Parameter werden auf den Strat-Stack gelegt | hoch |
|
||||
| 30 | u32 | Anzahl Strat-Variablen (vom Compiler, **typkonstant**) | hoch |
|
||||
| 34 | u32 | Anzahl 16-Byte-Slots (Kollision/Unterobjekte; typkonstant) | mittel |
|
||||
| 38 | u32 | Typflags (0x8000/0x80000/0x40000 = Kollisions-/Physikblock; typkonstant) | mittel |
|
||||
| 42 | u32 | Zusatzspeicher (&0x3fffffff, meist 0..3; typkonstant) | mittel |
|
||||
| 46 | s32 | Wegpunkt-Index (Block 5) oder −1 | hoch |
|
||||
| 50 | u32 | immer 0 | – |
|
||||
| 54 | u16 | Flags; Bit 1 (2) = beim Levelstart nicht erzeugen | hoch |
|
||||
| 56 | u16 | immer 0 | – |
|
||||
| 0 | u16 ×3 | rotation x, y, z (4096=360°; x/z almost always 0) | high |
|
||||
| 6 | s32 ×3 | position x, y, z fixed 20.12 (z positive) | high |
|
||||
| 18 | u32 | **code offset in STPC** (= object type) | high |
|
||||
| 22 | u32 | number of parameters n | high |
|
||||
| 26 | s32 | index into parameter pool (−1 if n=0); parameters are pushed onto the strat stack | high |
|
||||
| 30 | u32 | number of strat variables (from the compiler, **constant per type**) | high |
|
||||
| 34 | u32 | number of 16-byte slots (collision/sub-objects; constant per type) | medium |
|
||||
| 38 | u32 | type flags (0x8000/0x80000/0x40000 = collision/physics block; constant per type) | medium |
|
||||
| 42 | u32 | extra memory (&0x3fffffff, mostly 0..3; constant per type) | medium |
|
||||
| 46 | s32 | waypoint index (block 5) or −1 | high |
|
||||
| 50 | u32 | always 0 | – |
|
||||
| 54 | u16 | flags; bit 1 (2) = do not create at level start | high |
|
||||
| 56 | u16 | always 0 | – |
|
||||
|
||||
`poolExtra` (zweites u32 im Strat-Kopf) = max. gleichzeitige Strats − 30 (Engine legt
|
||||
poolExtra+30 Slots à 0x154 Byte an). **Muss ≥ Anzahl Strats + dynamisch gespawnte sein.**
|
||||
`poolExtra` (second u32 in the strat header) = max. simultaneous strats − 30 (the engine allocates
|
||||
poolExtra+30 slots of 0x154 bytes). **Must be ≥ number of strats + dynamically spawned ones.**
|
||||
|
||||
### Zonen (90 Byte, im Speicher 0x5c) — Konfidenz mittel
|
||||
u32 cellX, cellY, cellZ, s32 x,y,z (fixed), u32 ?, u32 rot?, u32 flags(+0x20; **Bit 8 = Startzone**
|
||||
→ DAT_00584644), u32, u32 ptr-Offset in STPC (0=keiner), u32, u32, u32, u16, u32 Nebel nah/fern
|
||||
(+0x3c/+0x40), u32 ×2 (+0x44/+0x48), u32 Farbe (z. B. 0x404040), u32 Farbe, u16, u16, u32 Musik-ID
|
||||
(+0x58; FUN_00546b70). Kamera-/Nebel-/Musikbereiche; nicht Lichtquellen (die stehen in LGHT).
|
||||
### Zones (90 bytes, 0x5c in memory) — confidence medium
|
||||
u32 cellX, cellY, cellZ, s32 x,y,z (fixed), u32 ?, u32 rot?, u32 flags(+0x20; **bit 8 = start zone**
|
||||
→ DAT_00584644), u32, u32 ptr offset in STPC (0=none), u32, u32, u32, u16, u32 fog near/far
|
||||
(+0x3c/+0x40), u32 ×2 (+0x44/+0x48), u32 colour (e.g. 0x404040), u32 colour, u16, u16, u32 music ID
|
||||
(+0x58; FUN_00546b70). Camera/fog/music areas; not light sources (those are in LGHT).
|
||||
|
||||
## Spieler, Checkpoints, Ausgang
|
||||
* **Spielerstart** = Position/Rotation der Strat-Instanz **`Llama`** (genau 1× in jedem
|
||||
Lauf-Level; Typfelder nvars 78, flags 0x248000). Sonderlevel: Fluss (t1l3*) =
|
||||
`KuscoAndPachaTogetherOnATree`, Achterbahn (t1l7m001/3/5) = `RollerCoaster`,
|
||||
## Player, checkpoints, exit
|
||||
* **Player start** = position/rotation of the strat instance **`Llama`** (exactly 1× in every
|
||||
running level; type fields nvars 78, flags 0x248000). Special levels: river (t1l3*) =
|
||||
`KuscoAndPachaTogetherOnATree`, roller coaster (t1l7m001/3/5) = `RollerCoaster`,
|
||||
t1l2m003 = `LlamaChase`.
|
||||
* **Checkpoints** = `CheckPointPost` (spawnt `CheckPointHead`), Variante `CheckPointBox`.
|
||||
* **Ausgang** = `LevelDoor` (1×/Level), Sonderfälle `VillageEndDoor`, `EndGameDoor`.
|
||||
* Immer vorhanden: `CheatTeleport`, `Wompy` (Funktion nicht geprüft), `CutSceneTrigger`.
|
||||
* **Checkpoints** = `CheckPointPost` (spawns `CheckPointHead`), variant `CheckPointBox`.
|
||||
* **Exit** = `LevelDoor` (1×/level), special cases `VillageEndDoor`, `EndGameDoor`.
|
||||
* Always present: `CheatTeleport`, `Wompy` (function not checked), `CutSceneTrigger`.
|
||||
|
||||
## Objekttypen
|
||||
Vollständige Tabelle Typ × Level (Instanzzahlen): `strat_types_table.md`
|
||||
(erzeugt mit `tools/strat_table.py <wads>`). Kurz:
|
||||
* in praktisch allen Levels: Coin, CutSceneTrigger, CutSceneCameraDirectional, LevelDoor,
|
||||
## Object types
|
||||
Complete table type × level (instance counts): `strat_types_table.md`
|
||||
(generated with `tools/strat_table.py <wads>`). Summary:
|
||||
* in practically all levels: Coin, CutSceneTrigger, CutSceneCameraDirectional, LevelDoor,
|
||||
Secret, Llama, CheatTeleport, ChargeBooster, Wompy, CheckPointPost, Coin5, CameraSide, Health,
|
||||
HealthFull, SmashPot, RubyIdol, Grapes, HealthGenerator, RubyIdolDoor …
|
||||
* ~300 Typen insgesamt, ~200 weitere nur intern gespawnt (Splitter, Seile, Effekte).
|
||||
* **Jeder Level hat seinen eigenen kompilierten Strat-Code + Modelle in STPC.** Ein Typ ist
|
||||
nur in Levels platzierbar, deren STPC ihn enthält (`strats.py level.wad --all` listet alle
|
||||
benannten Strats im Code, auch nie direkt platzierte). Der Code enthält **absolute
|
||||
STPC-Offsets** (z. B. Spawn-Stubs `b2 <abs> e0 …`), STPC ist also nicht verschiebbar,
|
||||
ohne zu relozieren. Typen aus anderen Levels zu übernehmen = STPC (+ TEXT-Indizes)
|
||||
zusammenführen → aufwendig, vorerst nicht.
|
||||
* "Spawn-Stub"-Einträge (z. B. SmashPot): MAP zeigt auf einen kleinen Code
|
||||
`push X; spawn(abs Ziel, nvars, …); end` → `strats.py` meldet `Name(spawn)`.
|
||||
* ~300 types in total, ~200 more only spawned internally (splinters, ropes, effects).
|
||||
* **Every level has its own compiled strat code + models in STPC.** A type can only be
|
||||
placed in levels whose STPC contains it (`strats.py level.wad --all` lists all
|
||||
named strats in the code, including ones never placed directly). The code contains **absolute
|
||||
STPC offsets** (e.g. spawn stubs `b2 <abs> e0 …`), so STPC cannot be moved
|
||||
without relocating. Taking types from other levels = merging STPC (+ TEXT indices)
|
||||
→ laborious, not for now.
|
||||
* "Spawn stub" entries (e.g. SmashPot): MAP points to a small piece of code
|
||||
`push X; spawn(abs target, nvars, …); end` → `strats.py` reports `Name(spawn)`.
|
||||
|
||||
## Regeln beim Schreiben einer neuen MAP
|
||||
1. Tile-Blöcke 2/8/9/11 parallel halten (gleiche Anzahl/Reihenfolge); Block 8 aus 2 ableiten;
|
||||
Block 9 = Feld 5 aus 2; Vertexfarben-Länge exakt = Σ nv(Modell)·Frames (+Alt).
|
||||
2. Alt-Block: Anzahl = Tiles mit hatAlt≠0, gleiche Reihenfolge. Neue Tiles einfach hinten
|
||||
anhängen (Strats können Tiles per Index ersetzen – `stMapadd/stMapreplace` – deshalb
|
||||
bestehende Indizes nicht umsortieren).
|
||||
3. Alle Tiles in 0..W × 0..H (z negativ!). Regionsraster W·H Einträge.
|
||||
4. Strat: Code-Offset muss Strat-Anfang in **diesem** STPC sein; nvars/slots/flags/extra
|
||||
**vom Original-Typ kopieren**; Parameter in Pool anhängen, Index/Anzahl setzen; Wegpunkt
|
||||
−1 oder gültiger Index (manche Typen brauchen Wegpunkte, sonst "removing strat").
|
||||
5. poolExtra erhöhen, wenn mehr Strats.
|
||||
6. WFPC-Flags bestimmen, welche optionalen Blöcke vorhanden sein müssen – nicht ändern.
|
||||
## Rules for writing a new MAP
|
||||
1. Keep tile blocks 2/8/9/11 in parallel (same count/order); derive block 8 from 2;
|
||||
block 9 = field 5 of 2; vertex colour length exactly = Σ nv(model)·frames (+alt).
|
||||
2. Alt block: count = tiles with hasAlt≠0, same order. Simply append new tiles
|
||||
at the end (strats can replace tiles by index – `stMapadd/stMapreplace` – so
|
||||
do not reorder existing indices).
|
||||
3. All tiles within 0..W × 0..H (z negative!). Region grid has W·H entries.
|
||||
4. Strat: code offset must be a strat start in **this** STPC; **copy** nvars/slots/flags/extra
|
||||
**from the original type**; append parameters to the pool, set index/count; waypoint
|
||||
−1 or a valid index (some types need waypoints, otherwise "removing strat").
|
||||
5. Increase poolExtra if there are more strats.
|
||||
6. WFPC flags determine which optional blocks must be present – do not change them.
|
||||
|
||||
## Werkzeuge
|
||||
* `mapchunk.py` – parse/build (Roundtrip identisch für alle 34 MAPs), `vcol_split` je Tile.
|
||||
## Tools
|
||||
* `mapchunk.py` – parse/build (identical roundtrip for all 34 MAPs), `vcol_split` per tile.
|
||||
* `map_dump.py level.wad [--strats --tiles --lights]`
|
||||
* `strats.py level.wad [--all]` – Typen mit Instanzzahlen, Code-Offsets, Typfeldern.
|
||||
* `strat_table.py wads_dir` – Typ×Level-Tabelle.
|
||||
* `stpcwalk.py` – STPC grob ablaufen (Modelle → Animsets → [WFPC&0x100-Block] → Strat-Code).
|
||||
* `make_testlevel.py quelle.wad ziel.wad [--name …] [--spacing 3] [--floor-model N] [--keep-original]`
|
||||
– sucht freien Rasterbereich, legt Bodenplatte aus Kopien des häufigsten flachen
|
||||
Boden-Tiles (t1l1m001: TRAK-Modell 12) an, stellt je Typ eine Instanz (Parameter vom
|
||||
Original) im 6er-Raster auf, Spieler an den Rand, Parkour (6 Trittplatten +0.5 mit
|
||||
Lücken, Münzen) zu einer Zielplattform mit LevelDoor. Ausgelassen: Camera*/CutScene*/
|
||||
Force*/Trigger. Test (nicht im Spiel geprüft): T1L9M001 → 1753 Tiles, 36 Strats, 27 Typen.
|
||||
* `strats.py level.wad [--all]` – types with instance counts, code offsets, type fields.
|
||||
* `strat_table.py wads_dir` – type×level table.
|
||||
* `stpcwalk.py` – roughly walks STPC (models → animsets → [WFPC&0x100 block] → strat code).
|
||||
* `make_testlevel.py src.wad dst.wad [--name …] [--spacing 3] [--floor-model N] [--keep-original]`
|
||||
– finds a free grid area, lays a floor plate from copies of the most common flat
|
||||
floor tile (t1l1m001: TRAK model 12), places one instance per type (parameters from the
|
||||
original) on a 6-unit grid, the player at the edge, and a parkour (6 stepping plates +0.5 with
|
||||
gaps, coins) leading to a goal platform with a LevelDoor. Skipped: Camera*/CutScene*/
|
||||
Force*/Trigger. Test (not checked in game): T1L9M001 → 1753 tiles, 36 strats, 27 types.
|
||||
|
||||
## Andere Chunks (Dispatcher 0x558960)
|
||||
* WFPC: u32 Feature-Flags (→ 0x6da330), z. B. 0x18410690.
|
||||
* INFO/VERS: je u32 1 (ignoriert).
|
||||
* NAME: u32 + Levelname.
|
||||
* TRAK: Tile-Modelle (u32 n, n×0x84-Header, dann Daten; Header+0x6c u16 nVert, +0x6e nFace(0x1c),
|
||||
+0x78/7a/7c Anzahl 0x20-Primitive). Face+8 u16 = Index in TEXT-Texturliste.
|
||||
* STPC: u32 n Modelle (je 0x8c-Header direkt + Daten), u32 n Animsets (0x20-Header + Daten),
|
||||
[WFPC&0x100: Zusatzmodelle], dann Strat-Code bis Chunkende.
|
||||
* LGHT: u32 n; je Licht u8 typ, u8 r, u8 g, u8 b (×2), Typ 1/2/…: weitere u32 (Richtung/Position,
|
||||
z negiert). FUN_0042c180.
|
||||
* LNFO: 2 u32 (→ 0x5ff070, 0x5ff060), z. B. 55, 2.
|
||||
* LGPC: u32 n(+1), u32 m, u32; dann (n+1)·m × u32 Größen, danach die Blobs. FUN_00558db0.
|
||||
* FONT: 256 × 4 u16 (Glyphen-Rechtecke). SPRT: u32 (bei WFPC&0x100000 Liste).
|
||||
* SMPC / AMPC / SRPC: Soundbänke ("cvg"-Samples / "ambi" Ambient / Referenzen), FUN_00545350.
|
||||
* 'GRIM' (nicht in Level-WADs gesehen): FUN_00437b40.
|
||||
## Other chunks (dispatcher 0x558960)
|
||||
* WFPC: u32 feature flags (→ 0x6da330), e.g. 0x18410690.
|
||||
* INFO/VERS: u32 1 each (ignored).
|
||||
* NAME: u32 + level name.
|
||||
* TRAK: tile models (u32 n, n×0x84 header, then data; header+0x6c u16 nVert, +0x6e nFace(0x1c),
|
||||
+0x78/7a/7c count of 0x20 primitives). Face+8 u16 = index into the TEXT texture list.
|
||||
* STPC: u32 n models (each 0x8c header directly + data), u32 n animsets (0x20 header + data),
|
||||
[WFPC&0x100: extra models], then strat code up to the end of the chunk.
|
||||
* LGHT: u32 n; per light u8 type, u8 r, u8 g, u8 b (×2), type 1/2/…: more u32 (direction/position,
|
||||
z negated). FUN_0042c180.
|
||||
* LNFO: 2 u32 (→ 0x5ff070, 0x5ff060), e.g. 55, 2.
|
||||
* LGPC: u32 n(+1), u32 m, u32; then (n+1)·m × u32 sizes, followed by the blobs. FUN_00558db0.
|
||||
* FONT: 256 × 4 u16 (glyph rectangles). SPRT: u32 (list if WFPC&0x100000).
|
||||
* SMPC / AMPC / SRPC: sound banks ("cvg" samples / "ambi" ambient / references), FUN_00545350.
|
||||
* 'GRIM' (not seen in level WADs): FUN_00437b40.
|
||||
@@ -1,4 +1,4 @@
|
||||
| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |
|
||||
| Type | Level (MAP instances) | Σ | in code of n levels |
|
||||
|---|---|---|---|
|
||||
| Coin | 1l1m001:36 1l1m002:29 1l1m003:11 1l1m004:6 1l2m001:12 1l2m002:26 1l2m003:23 1l3m001:45 1l3m002:55 1l3m003:25 1l3m004:20 1l4m001:54 1l4m002:28 1l4m003:41 1l5m001:32 1l5m002:22 1l5m003:29 1l6m001:19 1l6m002:33 1l6m003:1 1l7m002:39 1l7m004:46 1l8m001:27 1l8m002:45 1l8m003:10 1l8m004:46 1l8m005:27 | 787 | 27 |
|
||||
| CutSceneTrigger | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l2m003:1 1l3m001:1 1l3m002:1 1l3m003:1 1l3m004:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:5 1l6m002:5 1l6m003:3 1l7m002:1 1l7m004:2 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:7 1l8m005:2 | 45 | 27 |
|
||||
@@ -339,4 +339,4 @@
|
||||
| YzmaThrone | 1l6m003:1 | 1 | 1 |
|
||||
| fat_bird | 1l3m003:1 | 1 | 1 |
|
||||
|
||||
Nur im Code (nie direkt in MAP): AppearedPlatform, AppleHole, AppleSeedFX, AppleSeedPad, AxeGuardAxe, BalanceStatue, BallShrapnel, Balloon, BalloonFish, BalloonFishPlatform, BalloonPacha, BalloonPlatform, Bat, BeetleBlueBodyPart, BeetleBlueShell, BeetleBodyPart, BeetleShell, Bird, BlobOnTopCity, BlobOnTopJungle, BlobOnTopJungleDay, BlobShell, BlockShadow, BlockheadColl, BossBomb, BouncingCoin, BouncingHealth, BridgePiece, BridgeSmashStatue, BunnyPotion, BunnyRotors, Camera, CameraCollisionInFront, CameraPlayCutSceneAutomatically, ChacaGrapes, ChangePlayerTo, ChargingDoorPad, CheatCharge, CheckExitFinalBit, CheckPointHead, CircleDoorPlatform, CityBarrelHalf, CityGeneralBarrel, CityGeneralFinalAxe, CityMurderBunny, CityWallShrapnel, CoinForBat, Column, ColumnChild, CondorSleeping, CowGuardSpawned, CutSceneDirectionalWithWPs, DropObject, DumbGuardLab, EndDoorPad, EndDoorWeight, EndGameChacaSkip, EndGameChacaSkipShadow, EndGameGuardsGorrila, EndGameGuardsLizard, EndGameGuardsOstrich, EndGameGuardsWartHog, EndGameKronkRope, EndGameTipoSkip, EndGameTipoSkipShadow, EnemyCoin, EnemyCoin5, FallingPitLab, Feather, FishSpit, Flame, FrogPotion, FrogPotionFromPacha, GoldIdolStatue, Groove, GuardLookAhead, HammerForYzma, JengaSpiralLab, JungleDart, JungleDaySlidingDoorPad, JungleFallingRubyIdol, JunglePillarHelper, JunglePressurePad, KidOnLlamaCircle, KidsRubyIdol, KronkIdol, KronkLabNet, KronkOnIcePickup, KronkPotionLlama, KronkRubyIdol, KuscoBunny, KuscoFrog, KuscoTurtle, KuzcoStatueDoor, KuzcoStatuePart, LabFinalPotion, LabLiftPad, LabSlidingDoorBox, LabSlidingDoorBoxJamie, LabSlidingDoorPad, LabSlidingDoorPadJamie, LabSlidingDoorTile, LabSlidingDoorTileJamie, LabSlidingDoorTrigger, LabSlidingDoorTriggerJamie, LabSlidingDoorWhizzJamie, LabWallShrapnel, LabWaterfallPad, LlamaSpit, Lobster, LobsterPotShrapnel, LobsterStars, LobsterStarsChild, Lobsters, Lobsters1, MerryGoRoundCar, MerryGoRoundOracleHead, MurderBunny, NestForBridge, NestForHorizontalLift, New, NewBossBlockBit, OracleDoor, OracleHead, PachaLookAhead, PinataShrapnel, PlayerFallenOff, PressurePad, PressurePadCata, PressurePadPalace, PumpBlock, PumpLift, PumpLiftPad, RealAppleHole, RisingColumnForStatue, RiverBat, RiverCrocDam, RiverCrocMinGame, RiverDamShrapnel, RiverJumpingMonkeyItem, RiverJumpingMonkeyRedItem, RiverSwingingMonkeyItem, RiverSwingingMonkeyRedItem, RockPad, RockShrapnel, RollingBoulderForMountain, RollingBoulderForMountainNoWalls, RopeForBalloon, RopeForBalloonFish, RopeForBalloonPacha, RopeForHelpGirlSkip, RopeForRiverFallingSpider, RopeForRiverHangingSpider, RopeForSpider, RopeForTargetPlatform, RopeForTargetPlatformCity, RopeForTargetPlatformJungleDay, RopeForTargetSpider, RubyIdolReward, RubyIdolStatue, SalamanderNew, SandTimerDoorJungle, SandTimerPad, SandTimerPadCity, SandTimerPadJungle, ScorpionBlack, ScorpionBrown, ScorpionBrownStars, ScorpionBrownStarsChild, ScorpionSalamander, Scorpions, Scorpions2, SecretEffect, SinkingStatuePad, SlidingBlockheadPad2, SmartFlyDoor, SmashLobsterCrate, SmashPot2, SmashPotShrapnel, SmashStatueBloke, SmashWallShrapnel, SmokeOnPlayer, SmokeOnYzma, Sparkles, SparklesRGB, SpawnableNeedleBall, SpawnableNeedleBallRev, SpawnablePop, SpawnableRing, SpawnableSmoker, SpawnedAlligator, SpiderIceBlock, SpitPlatform, Squasher, StatueShrapnel, StunnedStars, StunnedStarsChild, SwitchForBridge, SwitchForHorizontalLift, SwitchForMouthPlatform, SwitchForSummonPlatform, SwitchForVerticalLift, SwitchPlatform, SwitchPlatformLab, SwitchPlatformVillage, TheNewGrooveCamera, ThreePartHead, ThrowAcorn, TickTockSound, TrampolineDoor, TreadmillForBridge, TreadmillForVerticalLift, TurtlePotion, TurtlePotionFromPacha, WaterArcDot, WaterFallSound, WaterSplash, WaterTrail, WaterfallBit, WompyBeingUsed, YzmaBigCutscenePotion, YzmaCatIntroFake, YzmaCatIntroPotion, YzmaDamMist, YzmaDamShrapnel, YzmaInVillageProjectile, YzmaInVillageProjectileCity, YzmaRiverItem, YzmaRubyIdol
|
||||
Only in code (never placed directly in MAP): AppearedPlatform, AppleHole, AppleSeedFX, AppleSeedPad, AxeGuardAxe, BalanceStatue, BallShrapnel, Balloon, BalloonFish, BalloonFishPlatform, BalloonPacha, BalloonPlatform, Bat, BeetleBlueBodyPart, BeetleBlueShell, BeetleBodyPart, BeetleShell, Bird, BlobOnTopCity, BlobOnTopJungle, BlobOnTopJungleDay, BlobShell, BlockShadow, BlockheadColl, BossBomb, BouncingCoin, BouncingHealth, BridgePiece, BridgeSmashStatue, BunnyPotion, BunnyRotors, Camera, CameraCollisionInFront, CameraPlayCutSceneAutomatically, ChacaGrapes, ChangePlayerTo, ChargingDoorPad, CheatCharge, CheckExitFinalBit, CheckPointHead, CircleDoorPlatform, CityBarrelHalf, CityGeneralBarrel, CityGeneralFinalAxe, CityMurderBunny, CityWallShrapnel, CoinForBat, Column, ColumnChild, CondorSleeping, CowGuardSpawned, CutSceneDirectionalWithWPs, DropObject, DumbGuardLab, EndDoorPad, EndDoorWeight, EndGameChacaSkip, EndGameChacaSkipShadow, EndGameGuardsGorrila, EndGameGuardsLizard, EndGameGuardsOstrich, EndGameGuardsWartHog, EndGameKronkRope, EndGameTipoSkip, EndGameTipoSkipShadow, EnemyCoin, EnemyCoin5, FallingPitLab, Feather, FishSpit, Flame, FrogPotion, FrogPotionFromPacha, GoldIdolStatue, Groove, GuardLookAhead, HammerForYzma, JengaSpiralLab, JungleDart, JungleDaySlidingDoorPad, JungleFallingRubyIdol, JunglePillarHelper, JunglePressurePad, KidOnLlamaCircle, KidsRubyIdol, KronkIdol, KronkLabNet, KronkOnIcePickup, KronkPotionLlama, KronkRubyIdol, KuscoBunny, KuscoFrog, KuscoTurtle, KuzcoStatueDoor, KuzcoStatuePart, LabFinalPotion, LabLiftPad, LabSlidingDoorBox, LabSlidingDoorBoxJamie, LabSlidingDoorPad, LabSlidingDoorPadJamie, LabSlidingDoorTile, LabSlidingDoorTileJamie, LabSlidingDoorTrigger, LabSlidingDoorTriggerJamie, LabSlidingDoorWhizzJamie, LabWallShrapnel, LabWaterfallPad, LlamaSpit, Lobster, LobsterPotShrapnel, LobsterStars, LobsterStarsChild, Lobsters, Lobsters1, MerryGoRoundCar, MerryGoRoundOracleHead, MurderBunny, NestForBridge, NestForHorizontalLift, New, NewBossBlockBit, OracleDoor, OracleHead, PachaLookAhead, PinataShrapnel, PlayerFallenOff, PressurePad, PressurePadCata, PressurePadPalace, PumpBlock, PumpLift, PumpLiftPad, RealAppleHole, RisingColumnForStatue, RiverBat, RiverCrocDam, RiverCrocMinGame, RiverDamShrapnel, RiverJumpingMonkeyItem, RiverJumpingMonkeyRedItem, RiverSwingingMonkeyItem, RiverSwingingMonkeyRedItem, RockPad, RockShrapnel, RollingBoulderForMountain, RollingBoulderForMountainNoWalls, RopeForBalloon, RopeForBalloonFish, RopeForBalloonPacha, RopeForHelpGirlSkip, RopeForRiverFallingSpider, RopeForRiverHangingSpider, RopeForSpider, RopeForTargetPlatform, RopeForTargetPlatformCity, RopeForTargetPlatformJungleDay, RopeForTargetSpider, RubyIdolReward, RubyIdolStatue, SalamanderNew, SandTimerDoorJungle, SandTimerPad, SandTimerPadCity, SandTimerPadJungle, ScorpionBlack, ScorpionBrown, ScorpionBrownStars, ScorpionBrownStarsChild, ScorpionSalamander, Scorpions, Scorpions2, SecretEffect, SinkingStatuePad, SlidingBlockheadPad2, SmartFlyDoor, SmashLobsterCrate, SmashPot2, SmashPotShrapnel, SmashStatueBloke, SmashWallShrapnel, SmokeOnPlayer, SmokeOnYzma, Sparkles, SparklesRGB, SpawnableNeedleBall, SpawnableNeedleBallRev, SpawnablePop, SpawnableRing, SpawnableSmoker, SpawnedAlligator, SpiderIceBlock, SpitPlatform, Squasher, StatueShrapnel, StunnedStars, StunnedStarsChild, SwitchForBridge, SwitchForHorizontalLift, SwitchForMouthPlatform, SwitchForSummonPlatform, SwitchForVerticalLift, SwitchPlatform, SwitchPlatformLab, SwitchPlatformVillage, TheNewGrooveCamera, ThreePartHead, ThrowAcorn, TickTockSound, TrampolineDoor, TreadmillForBridge, TreadmillForVerticalLift, TurtlePotion, TurtlePotionFromPacha, WaterArcDot, WaterFallSound, WaterSplash, WaterTrail, WaterfallBit, WompyBeingUsed, YzmaBigCutscenePotion, YzmaCatIntroFake, YzmaCatIntroPotion, YzmaDamMist, YzmaDamShrapnel, YzmaInVillageProjectile, YzmaInVillageProjectileCity, YzmaRiverItem, YzmaRubyIdol
|
||||
+110
-110
@@ -1,146 +1,146 @@
|
||||
# Test-Harness `tools/lamatest.py`
|
||||
# Test harness `tools/lamatest.py`
|
||||
|
||||
Startet "Ein Königreich für ein Lama" unbeaufsichtigt und unsichtbar (Xvfb :5,
|
||||
Ton in Null-Sink), lädt TestLevel Kapitel 1 (T1L9M001.wad) und macht Screenshots.
|
||||
Getestet am 2026-11-06 mit Wine 6.0.3, llvmpipe.
|
||||
Starts "Ein Königreich für ein Lama" unattended and invisibly (Xvfb :5,
|
||||
sound into a null sink), loads TestLevel chapter 1 (T1L9M001.wad) and takes screenshots.
|
||||
Tested on 2026-11-06 with Wine 6.0.3, llvmpipe.
|
||||
|
||||
## Befehle
|
||||
## Commands
|
||||
|
||||
```
|
||||
lamatest.py run <wad> <outdir> [n] # alles in einem: installieren, start, tolevel, tour, stop
|
||||
lamatest.py start # Xvfb :5 + Null-Sink lama_stumm + groove.exe
|
||||
lamatest.py tolevel # Menü -> Slot mit TestLevel -> Kapitel 1 -> Gameplay
|
||||
lamatest.py tour <outdir> [n] # n Kamera-Fotos (Rundumblick) + n Lauf-Fotos + Lama-Sicht
|
||||
lamatest.py shot out.png # Vollbild 1920x1080
|
||||
lamatest.py key <xdotool-key> [s] # Taste s Sekunden halten (Default 0.12)
|
||||
lamatest.py keys Up:1.5 a:0.5 x # Folge, Name[:Haltezeit], auch sleep=2
|
||||
lamatest.py seq <outdir> "TOKENS" # --seq-Tokens (shot:, run:, rj: …) im laufenden Spiel
|
||||
lamatest.py run <wad> <outdir> [n] # all in one: install, start, tolevel, tour, stop
|
||||
lamatest.py start # Xvfb :5 + null sink lama_stumm + groove.exe
|
||||
lamatest.py tolevel # menu -> slot with TestLevel -> chapter 1 -> gameplay
|
||||
lamatest.py tour <outdir> [n] # n camera shots (look around) + n walking shots + llama view
|
||||
lamatest.py shot out.png # full screen 1920x1080
|
||||
lamatest.py key <xdotool-key> [s] # hold key for s seconds (default 0.12)
|
||||
lamatest.py keys Up:1.5 a:0.5 x # sequence, name[:hold time], also sleep=2
|
||||
lamatest.py seq <outdir> "TOKENS" # --seq tokens (shot:, run:, rj: …) in the running game
|
||||
lamatest.py status | stop | restore-wad
|
||||
```
|
||||
|
||||
### `run`-Optionen (2026-11-06 ergänzt)
|
||||
### `run` options (added 2026-11-06)
|
||||
```
|
||||
lamatest.py run <wad> <outdir> [n] [--seq "TOKENS"] [--tour] [--keep]
|
||||
```
|
||||
* **WAD-Wiederherstellung**: `run` merkt sich die *vorher installierte*
|
||||
`T1L9M001.wad` (`$LAMATEST_DIR/vorher_T1L9M001.wad`) und spielt sie nach dem Lauf zurück –
|
||||
also nicht `.orig`. Mit `--keep` bleibt die getestete WAD installiert.
|
||||
(`restore-wad` stellt weiterhin explizit `.orig` her.)
|
||||
* `--seq` ersetzt die Standard-Tour durch eine Tastenfolge (mit `--tour` zusätzlich Tour):
|
||||
* `Up:1.5`, `a:0.5`, `x` … Taste[:Haltezeit] wie bei `keys`; `sleep=1`
|
||||
* `shot:NAME` → `seq_NN_NAME.jpg`, `fp:NAME` → Foto in Lama-Sicht (Return gehalten)
|
||||
* `dialog` → Dialog/Letterbox wegklicken
|
||||
* `Up+x:0.8` → Tasten gleichzeitig halten
|
||||
* `rj:T1:T2` → Anlauf-Sprung (Up halten, nach T1 s X, Up noch T2 s)
|
||||
* `dj:T` → Doppel-X mit Anlauf, `hop:N:T` → Up halten, N× alle T s springen
|
||||
* `run:T:x@1.3,x@2.9,shot@5.0,Left@3.0-3.4` → Up T s halten, nach Zeitplan (s ab Beginn)
|
||||
X tippen / fotografieren (`seq_NN_runT.jpg`) / Taste von–bis halten; `run/Left:T:…` hält
|
||||
statt Up eine andere Taste. Echtzeit: ein Foto blockiert ~0,4 s (verzögert folgende Ereignisse
|
||||
und bremst das Spiel) → keine Fotos kurz vor Sprüngen.
|
||||
* Steuerung ist **kamerarelativ** (Up = in Kamerablickrichtung, Left/Right seitlich).
|
||||
A/S drehen die Kamera, S:0.45 ≈ 90° nach links schauen.
|
||||
* Messwerte (Lama): ~1,1 Einheiten/s Laufen, Sprung 0,35 hoch, mit Anlauf ~1,3 weit – siehe
|
||||
levelbau.md. Zeitgesteuerte Läufe streuen um ~0,3 Einheiten (Startverzögerung, Bildrate);
|
||||
längere Strecken brauchen „Anschläge“ (Wand/Stufe, an der das Lama stehen bleibt).
|
||||
* Kamera: nach Laufen gegen eine Wand oder einem Sturz dreht sich die Kamera unvorhersehbar →
|
||||
Up läuft danach in eine andere Richtung. A/S-Drehung (S:0.45) ist nur ungefähr 90°.
|
||||
* Kompletter Testwelt-Parkour per Skript: `levels/testwelt_parkour.seq` (levelbau.md).
|
||||
* **WAD restore**: `run` remembers the *previously installed*
|
||||
`T1L9M001.wad` (`$LAMATEST_DIR/vorher_T1L9M001.wad`) and restores it after the run –
|
||||
i.e. not `.orig`. With `--keep` the tested WAD stays installed.
|
||||
(`restore-wad` still explicitly restores `.orig`.)
|
||||
* `--seq` replaces the default tour with a key sequence (with `--tour` the tour runs as well):
|
||||
* `Up:1.5`, `a:0.5`, `x` … key[:hold time] as with `keys`; `sleep=1`
|
||||
* `shot:NAME` → `seq_NN_NAME.jpg`, `fp:NAME` → shot in llama view (Return held)
|
||||
* `dialog` → click away dialog/letterbox
|
||||
* `Up+x:0.8` → hold keys simultaneously
|
||||
* `rj:T1:T2` → running jump (hold Up, X after T1 s, Up for another T2 s)
|
||||
* `dj:T` → double X with run-up, `hop:N:T` → hold Up, jump N× every T s
|
||||
* `run:T:x@1.3,x@2.9,shot@5.0,Left@3.0-3.4` → hold Up for T s, following a schedule (s from start)
|
||||
tap X / take a shot (`seq_NN_runT.jpg`) / hold a key from–to; `run/Left:T:…` holds
|
||||
another key instead of Up. Real time: a shot blocks ~0.4 s (delays subsequent events
|
||||
and slows the game down) → no shots shortly before jumps.
|
||||
* Controls are **camera-relative** (Up = in the camera's view direction, Left/Right sideways).
|
||||
A/S rotate the camera, S:0.45 ≈ look 90° to the left.
|
||||
* Measurements (llama): ~1.1 units/s walking, jump 0.35 high, ~1.3 far with run-up – see
|
||||
levelbau.md. Timed runs scatter by ~0.3 units (start delay, frame rate);
|
||||
longer distances need "stops" (a wall/step where the llama comes to a halt).
|
||||
* Camera: after running into a wall or a fall the camera rotates unpredictably →
|
||||
Up then walks in a different direction. A/S rotation (S:0.45) is only roughly 90°.
|
||||
* Complete test world parkour as a script: `levels/testwelt_parkour.seq` (levelbau.md).
|
||||
|
||||
* `LAMATEST_SHOT=1280` (Umgebung): `shot:`/`fp:`-Fotos 1280×720, Qualität 85 statt 960×540 (Vorschaubilder).
|
||||
* „EINGEFROREN?“ bei einer `--seq` ohne Bewegung (z. B. nur `sleep=3 shot:x`) ist ein Fehlalarm –
|
||||
das stehende Lama ändert das Bild nicht; mit Kameradrehung/Laufen kommt „OK“.
|
||||
* Gute Blickwinkel im Spiel sind schwer: die Kamera hängt tief hinter dem Lama; erhöhte
|
||||
Startpunkte (Podest, Parkour-Checkpoint `pk2` + `A:0.6`) zeigen mehrere Bereiche.
|
||||
* `LAMATEST_SHOT=1280` (environment): `shot:`/`fp:` shots at 1280×720, quality 85 instead of 960×540 (preview images).
|
||||
* "EINGEFROREN?" (frozen?) for a `--seq` without movement (e.g. only `sleep=3 shot:x`) is a false alarm –
|
||||
the standing llama does not change the image; with camera rotation/walking you get "OK".
|
||||
* Good viewing angles in the game are hard: the camera hangs low behind the llama; elevated
|
||||
start points (pedestal, parkour checkpoint `pk2` + `A:0.6`) show several areas.
|
||||
|
||||
Hilfsskript für Serien: `$CLAUDE_JOB_DIR/tmp/lb_run.sh WAD NAME "SEQ"` (Montage `lb_mNAME.jpg`,
|
||||
WAD-Pfad absolut!), `rs.sh NAME "SEQ"` (Neustart mit installierter WAD + `seq`, Montage `i/NAME.jpg`).
|
||||
**Achtung**: kein `pgrep -f groove.exe` in Warteschleifen – das trifft die eigene Shell
|
||||
(Kommandozeile enthält "groove.exe") und lamatest hält sie für eine fremde Instanz.
|
||||
Helper script for series: `$CLAUDE_JOB_DIR/tmp/lb_run.sh WAD NAME "SEQ"` (montage `lb_mNAME.jpg`,
|
||||
WAD path absolute!), `rs.sh NAME "SEQ"` (restart with installed WAD + `seq`, montage `i/NAME.jpg`).
|
||||
**Caution**: no `pgrep -f groove.exe` in wait loops – it matches your own shell
|
||||
(the command line contains "groove.exe") and lamatest takes it for a foreign instance.
|
||||
|
||||
Zustand/PIDs/wine.log: `$LAMATEST_DIR` (Default `/tmp/lamatest-$USER`).
|
||||
State/PIDs/wine.log: `$LAMATEST_DIR` (default `/tmp/lamatest-$USER`).
|
||||
|
||||
`run` Exit-Codes: 0 OK, 3 Absturz, 4 Timeout/hängt, 5 Bild eingefroren.
|
||||
Ausgabe in `<outdir>`: `report.txt`, `wine.log` (stderr, auf 2 MB gekürzt),
|
||||
`00_geladen.png` (voll) + `.jpg`, `tour_*.jpg` (960x540, ~30 KB), bei Fehler
|
||||
`zz_absturz.jpg` / `zz_timeout.jpg`. Ein Lauf ≈ 75 s, ≈ 1,2 MB.
|
||||
`run` exit codes: 0 OK, 3 crash, 4 timeout/hang, 5 image frozen.
|
||||
Output in `<outdir>`: `report.txt`, `wine.log` (stderr, truncated to 2 MB),
|
||||
`00_geladen.png` (full) + `.jpg`, `tour_*.jpg` (960x540, ~30 KB), on failure
|
||||
`zz_absturz.jpg` / `zz_timeout.jpg`. One run ≈ 75 s, ≈ 1.2 MB.
|
||||
|
||||
## Sicherheitsregeln (im Skript umgesetzt)
|
||||
## Safety rules (implemented in the script)
|
||||
|
||||
- Bricht ab, wenn eine groove.exe mit anderem DISPLAY läuft (Elias' Instanz).
|
||||
- xdotool nur mit DISPLAY=:5.
|
||||
- Ton: eigener Null-Sink, App wird nie stummgeschaltet; `stop` entlädt den Sink.
|
||||
- Spielstände (`*.ksv` im Spielordner) werden bei `start` gesichert und bei `stop`
|
||||
bei Änderung zurückgespielt (neu entstandene gelöscht).
|
||||
- `stop`: `wineserver -k` nur, wenn keine fremde groove.exe läuft, sonst nur eigene PIDs.
|
||||
- Der Ausgang der Testwelt speichert Fortschritt (Kapitel 2 frei) – wird bei `stop` mit den
|
||||
Spielständen zurückgespielt.
|
||||
- WAD: erstes `run` sichert das Original als `mods/testlevel/wads/T1L9M001.wad.orig`;
|
||||
`restore-wad` spielt es zurück. Das Overlay (`lama-mod an`) besteht aus
|
||||
**Symlinks** auf die Mod-Dateien → Überschreiben von
|
||||
`~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` wirkt sofort; `lama-mod an` wird
|
||||
nur aufgerufen, wenn der Overlay-Link fehlt.
|
||||
- Aborts if a groove.exe with a different DISPLAY is running (Elias' instance).
|
||||
- xdotool only with DISPLAY=:5.
|
||||
- Sound: own null sink, the app is never muted; `stop` unloads the sink.
|
||||
- Save games (`*.ksv` in the game folder) are backed up on `start` and restored
|
||||
on `stop` if changed (newly created ones are deleted).
|
||||
- `stop`: `wineserver -k` only if no foreign groove.exe is running, otherwise only own PIDs.
|
||||
- The test world exit saves progress (chapter 2 unlocked) – restored on `stop` together with the
|
||||
save games.
|
||||
- WAD: the first `run` backs up the original as `mods/testlevel/wads/T1L9M001.wad.orig`;
|
||||
`restore-wad` restores it. The overlay (`lama-mod an`) consists of
|
||||
**symlinks** to the mod files → overwriting
|
||||
`~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` takes effect immediately; `lama-mod an` is
|
||||
only called if the overlay link is missing.
|
||||
|
||||
## Ablauf `tolevel` (gemessen)
|
||||
## `tolevel` sequence (measured)
|
||||
|
||||
| Schritt | Taste | Erkennung | t (ab Start) |
|
||||
| Step | Key | Detection | t (from start) |
|
||||
|---|---|---|---|
|
||||
| Logo → Hauptmenü | – | OCR Ausschnitt 700x300+1150+650, Schwelle 75 % → nur markierter Punkt "Spiel starten" lesbar | ~6 s |
|
||||
| Spiel starten | Return | – | |
|
||||
| Spielstandauswahl (Karussell, startet bei Slot 2, **kein** Wrap) | Left ×3, dann Right bis "TestLevel" | OCR Ausschnitt 1600x280+160+740 (fuzzy, 2 Schwellen) | ~20 s |
|
||||
| Slot 3 laden | Return | Auswahlkarte = >75 % beige Pixel | ~25 s |
|
||||
| Kapitel 1 aktivieren (Cursor aus KSV +4/+5) | Return | | |
|
||||
| Kamera-Flug (Letterbox: oberste+unterste Zeile schwarz) | Return überspringt | | +1,5 s |
|
||||
| Gameplay | – | hell, kein Letterbox, goldenes HUD oben links (oder >25 s) 3× hintereinander | ~29 s gesamt, Laden ~4 s |
|
||||
| Logo → main menu | – | OCR crop 700x300+1150+650, threshold 75 % → only the highlighted item "Spiel starten" is readable | ~6 s |
|
||||
| "Spiel starten" (start game) | Return | – | |
|
||||
| Save slot selection (carousel, starts at slot 2, **no** wrap) | Left ×3, then Right until "TestLevel" | OCR crop 1600x280+160+740 (fuzzy, 2 thresholds) | ~20 s |
|
||||
| Load slot 3 | Return | selection map = >75 % beige pixels | ~25 s |
|
||||
| Activate chapter 1 (cursor from KSV +4/+5) | Return | | |
|
||||
| Camera fly-through (letterbox: top+bottom row black) | Return skips | | +1.5 s |
|
||||
| Gameplay | – | bright, no letterbox, golden HUD top left (or >25 s) 3× in a row | ~29 s total, loading ~4 s |
|
||||
|
||||
Ohne Überspringen dauert der Kamera-Flug im Dorf ~20 s.
|
||||
Ladebild = T191.COR ("TestLevel Kapitel 1 … LADEVORGANG").
|
||||
Without skipping, the camera fly-through in the village takes ~20 s.
|
||||
Loading screen = T191.COR ("TestLevel Kapitel 1 … LADEVORGANG").
|
||||
|
||||
Fallback: wird kein Menü erkannt, nach 40 s Return (überspringt Demo). Achtung:
|
||||
Return auf einem falsch erkannten Bildschirm kann ein anderes Spiel starten –
|
||||
Spielstände werden aber am Ende zurückgespielt.
|
||||
Fallback: if no menu is detected, Return after 40 s (skips the demo). Caution:
|
||||
Return on a misdetected screen can start a different game –
|
||||
but save games are restored at the end.
|
||||
|
||||
## Steuerung PC (Tastatur, aus "Optionen → Tastatur konfigurieren", verifiziert)
|
||||
## PC controls (keyboard, from "Optionen → Tastatur konfigurieren", verified)
|
||||
|
||||
| Aktion | Taste |
|
||||
| Action | Key |
|
||||
|---|---|
|
||||
| Auf / Ab (vorwärts / rückwärts laufen) | Up / Down |
|
||||
| Links / Rechts (drehen) | Left / Right |
|
||||
| Sprung (auch Dialog weiter) | X |
|
||||
| Up / Down (walk forward / backward) | Up / Down |
|
||||
| Left / Right (turn) | Left / Right |
|
||||
| Jump (also advances dialog) | X |
|
||||
| Action | Z |
|
||||
| Power | Space |
|
||||
| Lama-Sicht (Ego-Perspektive, halten) | Return |
|
||||
| Kamera nach links / rechts drehen | A / S (0,5 s halten ≈ 90°) |
|
||||
| Pausenmenü | Escape |
|
||||
| Llama view (first person, hold) | Return |
|
||||
| Rotate camera left / right | A / S (hold 0.5 s ≈ 90°) |
|
||||
| Pause menu | Escape |
|
||||
|
||||
Registry `HKCU\Software\Disney Interactive\Emperors New Groove\1.0`,
|
||||
`"InputDevice Wine Keyboard"` = 14 DIK-Codes (je 4 Hex):
|
||||
`"InputDevice Wine Keyboard"` = 14 DIK codes (4 hex digits each):
|
||||
CB Left, CD Right, C8 Up, D0 Down, 1E A, 1F S, 39 Space, 2D X, 1C Return,
|
||||
19 P, C9 PgUp, D1 PgDn, 2C Z, 18 O. Die Reihenfolge der Registry ≠ Menüreihenfolge;
|
||||
P/PgUp/PgDn/O vermutlich Pause/Inventar (laut exe-Strings: PAUSE GAME, CYCLE
|
||||
INVENTORY UP/DOWN, USE AN ITEM, TURN 180, SELECT) – nicht verifiziert.
|
||||
Weitere Werte dort: CameraMode, ControlType, ScreenW/H (1920x1142), CDPath=L:\.
|
||||
19 P, C9 PgUp, D1 PgDn, 2C Z, 18 O. The registry order ≠ menu order;
|
||||
P/PgUp/PgDn/O probably pause/inventory (according to exe strings: PAUSE GAME, CYCLE
|
||||
INVENTORY UP/DOWN, USE AN ITEM, TURN 180, SELECT) – not verified.
|
||||
Other values there: CameraMode, ControlType, ScreenW/H (1920x1142), CDPath=L:\.
|
||||
|
||||
Pausenmenü (Escape): Grafik-/Tonoptionen, Tastatur/Joystick konfigurieren,
|
||||
Weitere Optionen, Weiter, "Beenden und zur Auswahlkarte wechseln" (Bestätigung
|
||||
Ja/Nein, Default Nein → Up, Return), Spiel beenden. Hauptmenü-Untermenüs: Escape = zurück.
|
||||
Pause menu (Escape): graphics/sound options, configure keyboard/joystick,
|
||||
more options, continue, "Beenden und zur Auswahlkarte wechseln" (quit to selection map; confirmation
|
||||
Ja/Nein, default Nein → Up, Return), quit game. Main menu submenus: Escape = back.
|
||||
|
||||
## Beobachtungen Dorf-1-Kopie
|
||||
## Observations on the village 1 copy
|
||||
|
||||
- Läuft man im Dorf vorwärts, startet sofort der Pacha-Dialog (Letterbox mit
|
||||
Untertiteln, ~9× X zum Durchklicken). `tour` erkennt das, fotografiert den
|
||||
Dialog einmal und klickt ihn weg.
|
||||
- Lama kann in der Tour von der Klippe fallen (Respawn) – egal für Sichtprüfung.
|
||||
- Walking forward in the village immediately starts the Pacha dialog (letterbox with
|
||||
subtitles, ~9× X to click through). `tour` detects this, photographs the
|
||||
dialog once and clicks it away.
|
||||
- The llama can fall off the cliff during the tour (respawn) – irrelevant for visual checks.
|
||||
|
||||
## Absturzerkennung
|
||||
## Crash detection
|
||||
|
||||
Pro Poll: groove.exe-Prozess auf :5 weg / Fenster "Groove" weg / Fenster mit
|
||||
"Programmfehler", "error", "exception" … (Wine-Crashdialog). Im Report steht die
|
||||
Zeile `wine: Unhandled page fault … at address XXXXXXXX` (Adresse in Ghidra
|
||||
nachschlagen). Getestet: auf 300 KB gekürzte WAD und WAD mit 2000 zufälligen
|
||||
Bytes → beide Absturz während des Ladebilds, erkannt nach ~5 s (Exit 3).
|
||||
Harmloses Log-Rauschen (gstreamer/MPEG-1 fehlt, ole, ChangeDisplaySettings,
|
||||
MIDIMAP) wird im Report ausgefiltert.
|
||||
Per poll: groove.exe process on :5 gone / window "Groove" gone / window with
|
||||
"Programmfehler", "error", "exception" … (Wine crash dialog). The report contains the
|
||||
line `wine: Unhandled page fault … at address XXXXXXXX` (look up the address in Ghidra).
|
||||
Tested: WAD truncated to 300 KB and WAD with 2000 random
|
||||
bytes → both crash during the loading screen, detected after ~5 s (exit 3).
|
||||
Harmless log noise (gstreamer/MPEG-1 missing, ole, ChangeDisplaySettings,
|
||||
MIDIMAP) is filtered out of the report.
|
||||
|
||||
## Abhängigkeiten
|
||||
## Dependencies
|
||||
|
||||
Xvfb, xdotool, ImageMagick (import/convert), tesseract mit `deu`, pactl, wine.
|
||||
Xvfb, xdotool, ImageMagick (import/convert), tesseract with `deu`, pactl, wine.
|
||||
+39
-39
@@ -1,51 +1,51 @@
|
||||
# TEXT-Chunk (Texturen)
|
||||
# TEXT chunk (textures)
|
||||
|
||||
Quelle: groove.exe `FUN_004067b0` (Größenlauf), `FUN_004071d0` → `FUN_00406880` (Paletten),
|
||||
`FUN_00406ab0` (Seiten), `FUN_00406a10` (RLE), `FUN_00406d30` (Texturliste),
|
||||
`FUN_00406e40` (Texturanimationen), `FUN_00407240`/`FUN_00408470` (Upload/Konvertierung).
|
||||
Werkzeug: `tools/textchunk.py`, `tools/text_export.py`. Parse + Neuaufbau ist für **alle
|
||||
Level-WADs byte-identisch** (auch der RLE-Encoder erzeugt exakt die Originaldaten).
|
||||
Source: groove.exe `FUN_004067b0` (size pass), `FUN_004071d0` → `FUN_00406880` (palettes),
|
||||
`FUN_00406ab0` (pages), `FUN_00406a10` (RLE), `FUN_00406d30` (texture list),
|
||||
`FUN_00406e40` (texture animations), `FUN_00407240`/`FUN_00408470` (upload/conversion).
|
||||
Tools: `tools/textchunk.py`, `tools/text_export.py`. Parse + rebuild is **byte-identical
|
||||
for all level WADs** (the RLE encoder also reproduces the original data exactly).
|
||||
|
||||
## Aufbau
|
||||
## Layout
|
||||
```
|
||||
u32 nPal (in allen WADs 0)
|
||||
nPal × { u32 id, u32 n, u16 farbe[n], u8 extra[n] }
|
||||
u32 nSeiten (13–22 je Level, alle 256×256)
|
||||
nSeiten × { u32 flags, u32 w, u32 h, Daten }
|
||||
flags == 0 : w·h Byte (8 Bit, Palette) – unbenutzt
|
||||
flags & 0x80 : u32 Länge, RLE16 – alle Originalseiten
|
||||
sonst : w·h u16 roh
|
||||
u32 nPal (0 in all WADs)
|
||||
nPal × { u32 id, u32 n, u16 colour[n], u8 extra[n] }
|
||||
u32 nPages (13–22 per level, all 256×256)
|
||||
nPages × { u32 flags, u32 w, u32 h, data }
|
||||
flags == 0 : w·h bytes (8 bit, palette) – unused
|
||||
flags & 0x80 : u32 length, RLE16 – all original pages
|
||||
otherwise : w·h u16 raw
|
||||
u32 nTex (~180–1260)
|
||||
nTex × 8 Byte { u16 flags, u8 seite, u8 clut(0xff), u8 x0, u8 x1, u8 y0, u8 y1 } (Kanten inklusive)
|
||||
nTex × 8 bytes { u16 flags, u8 page, u8 clut(0xff), u8 x0, u8 x1, u8 y0, u8 y1 } (edges inclusive)
|
||||
u32 nAnim
|
||||
nAnim × { u16 texIndex, u8 typ, Nutzdaten je typ }
|
||||
typ 1,2 : 3 Byte typ 7,9 : u16,u16,u8,u8
|
||||
typ 8 : u8, u8 nFrames, u8, nFrames × u16 texIndex (Daumenkino)
|
||||
typ 10,11: u8,u8,u8,u8 n, n × u16 texIndex
|
||||
nAnim × { u16 texIndex, u8 type, payload per type }
|
||||
type 1,2 : 3 bytes type 7,9 : u16,u16,u8,u8
|
||||
type 8 : u8, u8 nFrames, u8, nFrames × u16 texIndex (flipbook)
|
||||
type 10,11: u8,u8,u8,u8 n, n × u16 texIndex
|
||||
```
|
||||
RLE16: Folge von s16 n; n>0 → n Literal-u16; n<0 → nächstes u16 (−n)-mal.
|
||||
RLE16: sequence of s16 n; n>0 → n literal u16; n<0 → next u16 repeated (−n) times.
|
||||
|
||||
## Pixel
|
||||
16 Bit **PS1-Reihenfolge**: Bits 0–4 R, 5–9 G, 10–14 B, Bit 15 = STP.
|
||||
`0x0000` = durchsichtig (Konverter schreibt 0 für Pixel 0, alles andere wird umgerechnet).
|
||||
STP=0 bei Farbe≠0 tritt auf Seiten mit Blendmodus auf (halbtransparente Pixel).
|
||||
Export: Alpha 0 = 0x0000, Alpha 128 = STP 0, Alpha 255 = STP 1.
|
||||
## Pixels
|
||||
16 bit **PS1 order**: bits 0–4 R, 5–9 G, 10–14 B, bit 15 = STP.
|
||||
`0x0000` = transparent (the converter writes 0 for pixel 0, everything else is converted).
|
||||
STP=0 with colour≠0 occurs on pages with a blend mode (semi-transparent pixels).
|
||||
Export: alpha 0 = 0x0000, alpha 128 = STP 0, alpha 255 = STP 1.
|
||||
|
||||
Seiten-flags & 0x7f = Format/Blendklasse (Tabelle 0x570370, je 0x20 Byte):
|
||||
1 = opak, 2 = eigener Konverter (0x409640), 3 = opak+Flag, 4/7 = Alpha (Blend 1),
|
||||
5 = Blend 3, 6 = wie 1. (Modi 1/2/3 ≈ PS1-Halbtransparenz-Modi; Details offen.)
|
||||
Page flags & 0x7f = format/blend class (table 0x570370, 0x20 bytes each):
|
||||
1 = opaque, 2 = own converter (0x409640), 3 = opaque+flag, 4/7 = alpha (blend 1),
|
||||
5 = blend 3, 6 = same as 1. (Modes 1/2/3 ≈ PS1 semi-transparency modes; details open.)
|
||||
|
||||
## Texturliste
|
||||
Faces der Modelle (TRAK/STPC, Face+8 u16) verweisen auf den Index in der Texturliste;
|
||||
Eintrag = Rechteck auf einer Seite. `flags`-Lowbyte 4/5/6/0xc6, Highbyte 1/4/17/20/36/…
|
||||
(Bedeutung offen; vermutl. Blend/Größenklasse). Fürs Ersetzen: Liste unverändert lassen
|
||||
und nur Pixel in den Seiten tauschen → keine Querverweise betroffen.
|
||||
## Texture list
|
||||
Model faces (TRAK/STPC, face+8 u16) reference an index into the texture list;
|
||||
entry = rectangle on a page. `flags` low byte 4/5/6/0xc6, high byte 1/4/17/20/36/…
|
||||
(meaning open; probably blend/size class). For replacing: leave the list unchanged
|
||||
and only swap pixels in the pages → no cross references affected.
|
||||
|
||||
## Export / Ersetzen
|
||||
## Export / replace
|
||||
```
|
||||
text_export.py export level.wad ordner # pageNN_fXX.png + texinfo.txt (≈370 KB je Level)
|
||||
text_export.py import level.wad ordner neu.wad
|
||||
text_export.py export level.wad folder # pageNN_fXX.png + texinfo.txt (≈370 KB per level)
|
||||
text_export.py import level.wad folder new.wad
|
||||
```
|
||||
Import ändert nur Pixel, deren RGBA vom Export abweicht → unveränderte Seiten bleiben
|
||||
bitgenau. Größe muss 256×256 bleiben. Neue Seiten/Texturen anhängen ist möglich
|
||||
(nTex/nSeiten erhöhen), dann müssen aber Faces in TRAK/STPC die neuen Indizes verwenden.
|
||||
Import only changes pixels whose RGBA differs from the export → unchanged pages stay
|
||||
bit-exact. Size must stay 256×256. Appending new pages/textures is possible
|
||||
(increase nTex/nPages), but then faces in TRAK/STPC must use the new indices.
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dekodiert Argonaut-ASF-Musik (v2.1, 4-Bit-ADPCM) wie asfcodec.adl aus dem Spiel.
|
||||
"""Decodes Argonaut ASF music (v2.1, 4-bit ADPCM) like the game's asfcodec.adl.
|
||||
|
||||
Nachgebaut aus der Disassembly von asfcodec.adl (Routinen 0x26e01969 / 0x26e01ca1):
|
||||
Reimplemented from the disassembly of asfcodec.adl (routines 0x26e01969 / 0x26e01ca1):
|
||||
Filter 0: s = s1 + (n << shift)
|
||||
Filter 1: s = 2*s1 - s2 + (n << shift) (+ Übertrag aus 'adc', s. u.)
|
||||
- kein Clipping; Ausgabe wird als 16 Bit gespeichert (Überlauf = Wrap)
|
||||
- s1 läuft innerhalb eines Blocks ungekürzt im Register weiter,
|
||||
am Blockanfang wird es aus dem 16-Bit-Puffer gelesen
|
||||
- s2 wird immer aus dem 16-Bit-Puffer gelesen
|
||||
Aufruf: asf2wav.py eingabe.asf ausgabe.wav
|
||||
Filter 1: s = 2*s1 - s2 + (n << shift) (+ carry from 'adc', see below)
|
||||
- no clipping; output is stored as 16 bit (overflow = wrap)
|
||||
- within a block s1 carries on untruncated in the register,
|
||||
at the start of a block it is read from the 16-bit buffer
|
||||
- s2 is always read from the 16-bit buffer
|
||||
Usage: asf2wav.py input.asf output.wav
|
||||
"""
|
||||
import array
|
||||
import struct
|
||||
@@ -33,7 +33,7 @@ def decode(data):
|
||||
ch = 2 if flags & 2 else 1
|
||||
p = off + 20
|
||||
out = array.array('h', bytes(nblocks * 32 * ch * 2))
|
||||
mem = [[0, 0] for _ in range(ch)] # gespeicherte 16-Bit-Werte: [vorletztes, letztes]
|
||||
mem = [[0, 0] for _ in range(ch)] # stored 16-bit values: [second to last, last]
|
||||
o = 0
|
||||
for _ in range(nblocks):
|
||||
for c in range(ch):
|
||||
@@ -42,7 +42,7 @@ def decode(data):
|
||||
p += 17
|
||||
cl = (ctl >> 4) + 2
|
||||
prev2, prev1 = mem[c]
|
||||
eax = prev1 # Blockanfang: s1 aus dem Speicher
|
||||
eax = prev1 # block start: s1 from memory
|
||||
k = o + c
|
||||
for byte in blk:
|
||||
for nib in (byte >> 4, byte & 15):
|
||||
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entpackt Argonaut-.cor-Bilder (Ein Königreich für ein Lama) nach PNG.
|
||||
"""Unpacks Argonaut .cor images (Ein Königreich für ein Lama) to PNG.
|
||||
|
||||
Aufbau: 8 Byte Signatur 17 98 AF 89 FE 42 D1 12, u32 Version (1),
|
||||
u32 entpackte Größe, u32 gepackte Größe, dann LZ-Daten:
|
||||
n < 0x80 -> n Bytes Rohdaten folgen
|
||||
1LLL oooo oooooooo -> kopiere L+3 Bytes ab o Bytes weiter vorne
|
||||
Das Ergebnis sind 640x480 Pixel à 3 Byte (RGB) oder à 1 Byte (Graustufen, z. B. 151.cor).
|
||||
Aufruf: cor2png.py eingabe.cor ausgabe.png (benötigt Pillow)
|
||||
Layout: 8-byte signature 17 98 AF 89 FE 42 D1 12, u32 version (1),
|
||||
u32 unpacked size, u32 packed size, then LZ data:
|
||||
n < 0x80 -> n bytes of raw data follow
|
||||
1LLL oooo oooooooo -> copy L+3 bytes from o bytes back
|
||||
The result is 640x480 pixels at 3 bytes (RGB) or 1 byte (greyscale, e.g. 151.cor).
|
||||
Usage: cor2png.py input.cor output.png (requires Pillow)
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
@@ -16,7 +16,7 @@ MAGIC = bytes.fromhex('1798af89fe42d112')
|
||||
|
||||
def unpack(data):
|
||||
if data[:8] != MAGIC:
|
||||
raise ValueError('keine COR-Datei')
|
||||
raise ValueError('not a COR file')
|
||||
_, size, packed = struct.unpack_from('<III', data, 8)
|
||||
src = data[20:20 + packed]
|
||||
out = bytearray()
|
||||
@@ -37,7 +37,7 @@ def unpack(data):
|
||||
|
||||
if __name__ == '__main__':
|
||||
raw, size = unpack(open(sys.argv[1], 'rb').read())
|
||||
print(f'{sys.argv[1]}: {len(raw)} von {size} Bytes')
|
||||
print(f'{sys.argv[1]}: {len(raw)} of {size} bytes')
|
||||
if len(sys.argv) > 2:
|
||||
from PIL import Image
|
||||
mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size]
|
||||
|
||||
+79
-77
@@ -1,26 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
|
||||
"""Debug visualisation for level WADs of "Ein Koenigreich fuer ein Lama".
|
||||
|
||||
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
|
||||
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
|
||||
Makes collision geometry (including invisible walls/floors) and invisible logic objects
|
||||
(cameras, triggers, exits, checkpoints, force zones ...) visible in the game.
|
||||
|
||||
debugviz.py IN.wad OUT.wad [Optionen] ein Level
|
||||
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle Level-WADs (t1l*, t6l*)
|
||||
debugviz.py IN.wad OUT.wad [options] one level
|
||||
debugviz.py --all OUTDIR [--src CD-wads-folder] all level WADs (t1l*, t6l*)
|
||||
|
||||
Optionen: --no-coll --no-markers --no-fill (nur Kanten) --opaque (Fuellung deckend)
|
||||
--width 0.03 (Kantenbreite) --offset 0.012 (Abstand entlang der Normale)
|
||||
--report datei.json
|
||||
Options: --no-coll --no-markers --no-fill (edges only) --opaque (opaque fill)
|
||||
--width 0.03 (edge width) --offset 0.012 (offset along the normal)
|
||||
--report file.json
|
||||
|
||||
Prinzip (Details: notes/debugviz.md):
|
||||
* Kollision: an jedes TRAK-Modell werden Render-Dreiecke angehaengt (Rahmen entlang der
|
||||
Kanten jedes Kollisionspolygons, leicht entlang der Normale versetzt; bei unsichtbarer
|
||||
Kollision zusaetzlich halbtransparente Fuellung). Damit stimmen Kachel-Transformation,
|
||||
Zonen/Sichtbarkeit und Kachel-Wechsel automatisch. Kollisionspolygone werden NICHT
|
||||
veraendert/ergaenzt. Vertexfarben-Block in MAP waechst je Kachel und Farbsatz mit.
|
||||
* Objekte: Marker (Wuerfel, Linien, Boxen) als neue Kacheln ohne Kollision, je Rasterzelle
|
||||
eine Kachel (also in der richtigen Zone). Strats bleiben unveraendert.
|
||||
* Farben: angehaengte Flachfarb-Texturen (Flags 0x105 deckend, 0x1105 = Blendmodus 1
|
||||
Alpha-Blending, Alpha im 4. Byte).
|
||||
Principle (details: notes/debugviz.md):
|
||||
* Collision: render triangles are appended to every TRAK model (a frame along the
|
||||
edges of each collision polygon, offset slightly along the normal; invisible
|
||||
collision additionally gets a semi-transparent fill). This way tile transforms,
|
||||
zones/visibility and tile swaps are automatically correct. Collision polygons are NOT
|
||||
changed/added. The vertex colour block in MAP grows accordingly per tile and colour set.
|
||||
* Objects: markers (cubes, lines, boxes) as new tiles without collision, one tile per
|
||||
grid cell (so in the right zone). Strats stay unchanged.
|
||||
* Colours: appended flat colour textures (flags 0x105 opaque, 0x1105 = blend mode 1
|
||||
alpha blending, alpha in the 4th byte).
|
||||
Report/JSON keys and category names (boden, wand, ausgang, koll_polys, ...) are German
|
||||
identifiers and kept as they are.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
@@ -40,24 +42,24 @@ import strats as S # noqa: E402
|
||||
|
||||
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
|
||||
|
||||
# ------------------------------------------------------------------ Farben
|
||||
# ------------------------------------------------------------------ colours
|
||||
KIND_COL = {
|
||||
'boden': (40, 255, 40), # begehbar (Normale steil nach oben)
|
||||
'schraege': (200, 255, 0), # schraeg
|
||||
'wand': (0, 150, 255), # Wand
|
||||
'decke': (255, 130, 0), # Decke / Ueberhang
|
||||
'boden': (40, 255, 40), # walkable (normal steeply upwards)
|
||||
'schraege': (200, 255, 0), # slope
|
||||
'wand': (0, 150, 255), # wall
|
||||
'decke': (255, 130, 0), # ceiling / overhang
|
||||
}
|
||||
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
|
||||
INVIS_FILL = (255, 0, 0) # invisible collision (no render surface)
|
||||
FILL_ALPHA = 96
|
||||
|
||||
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus PALETTE.
|
||||
# material -> (name, colour). Unknown materials get colours from PALETTE.
|
||||
MAT_INFO = {
|
||||
16: ('Ausgang (LevelDoor-Boden)', (190, 60, 255)),
|
||||
17: ('selektiv 0x11 (nur Objekte mit Flag 0x8000000)', (255, 255, 0)),
|
||||
18: ('selektiv 0x12 (nicht fuer Objekte mit Flag 0x8000000)', (255, 200, 120)),
|
||||
30: ('kein Boden / Fall-/Todesebene (0x1e)', (255, 0, 255)),
|
||||
16: ('exit (LevelDoor floor)', (190, 60, 255)),
|
||||
17: ('selective 0x11 (only objects with flag 0x8000000)', (255, 255, 0)),
|
||||
18: ('selective 0x12 (not for objects with flag 0x8000000)', (255, 200, 120)),
|
||||
30: ('no floor / fall/death plane (0x1e)', (255, 0, 255)),
|
||||
}
|
||||
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe notes/debugviz.md)
|
||||
# fixed colours for the remaining materials (meaning in the code not yet known, see notes/debugviz.md)
|
||||
MAT_FIXED = {
|
||||
1: (0, 200, 200), 2: (255, 255, 255), 3: (255, 120, 170), 5: (120, 255, 200), 6: (160, 120, 255),
|
||||
7: (255, 170, 80), 8: (0, 130, 60), 9: (150, 200, 255), 10: (150, 150, 0), 11: (150, 90, 40),
|
||||
@@ -79,8 +81,8 @@ MARK_COL = {
|
||||
'objekt': (90, 90, 90),
|
||||
}
|
||||
|
||||
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
|
||||
MARK_SIZE = 0.05 # halbe Kantenlaenge
|
||||
MARK_H = 0.35 # marker cube above the strat origin
|
||||
MARK_SIZE = 0.05 # half edge length
|
||||
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
|
||||
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
|
||||
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
|
||||
@@ -117,10 +119,10 @@ def mark_cat(name):
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
|
||||
# ------------------------------------------------------------------ TEXT: append flat colours
|
||||
|
||||
def text_append_flats(d, entries):
|
||||
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
|
||||
"""entries: list of (r,g,b,alpha|None). Returns (new TEXT, first new index)."""
|
||||
o = 0
|
||||
npal = struct.unpack_from('<I', d, o)[0]; o += 4
|
||||
for _ in range(npal):
|
||||
@@ -139,7 +141,7 @@ def text_append_flats(d, entries):
|
||||
for r, g, b, a in entries:
|
||||
if a is None:
|
||||
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
|
||||
else: # Blendmodus 1 (Bits 12-14) = SRCALPHA/INVSRCALPHA, Alpha = 4. Byte
|
||||
else: # blend mode 1 (bits 12-14) = SRCALPHA/INVSRCALPHA, alpha = 4th byte
|
||||
add += struct.pack('<HbbBBBB', 0x1105, -1, -1, r, g, b, a)
|
||||
e = o + 4 + 8 * nt
|
||||
return d[:o] + struct.pack('<I', nt + len(entries)) + d[o + 4:e] + add + d[e:], nt
|
||||
@@ -156,10 +158,10 @@ class Palette:
|
||||
if k not in self.idx:
|
||||
self.idx[k] = len(self.entries)
|
||||
self.entries.append((*rgb, alpha))
|
||||
return self.idx[k] # relativ; + base beim Schreiben
|
||||
return self.idx[k] # relative; + base when writing
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Geometrie-Helfer
|
||||
# ------------------------------------------------------------------ geometry helpers
|
||||
|
||||
def norm(v):
|
||||
n = np.linalg.norm(v)
|
||||
@@ -167,7 +169,7 @@ def norm(v):
|
||||
|
||||
|
||||
class Mesh:
|
||||
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
|
||||
"""Triangle list with palette index; verts (pos, normal)."""
|
||||
|
||||
def __init__(self):
|
||||
self.verts = []
|
||||
@@ -178,13 +180,13 @@ class Mesh:
|
||||
return len(self.verts) - 1
|
||||
|
||||
def quad(self, p, n, col):
|
||||
"""4 Punkte (Ring), eine Normale."""
|
||||
"""4 points (ring), one normal."""
|
||||
ids = [self.v(q, n) for q in p]
|
||||
self.tris.append((ids[0], ids[1], ids[2], col))
|
||||
self.tris.append((ids[0], ids[2], ids[3], col))
|
||||
|
||||
def beam(self, a, b, w, col):
|
||||
"""Linie a->b als Kreuz aus zwei doppelseitigen Streifen."""
|
||||
"""Line a->b as a cross of two double-sided strips."""
|
||||
a = np.asarray(a, float); b = np.asarray(b, float)
|
||||
d = b - a
|
||||
L = np.linalg.norm(d)
|
||||
@@ -215,7 +217,7 @@ def seg_split(a, b, maxlen=1.0):
|
||||
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Kollision klassifizieren
|
||||
# ------------------------------------------------------------------ classify collision
|
||||
|
||||
def kind_of(ny):
|
||||
if ny >= 0.7:
|
||||
@@ -236,7 +238,7 @@ def decode_model_coll(m):
|
||||
ok = len(pts) >= 3 and all(abs(x) < 12 for p in pts for x in p)
|
||||
P = np.array(pts, float) if ok else None
|
||||
if ok:
|
||||
# Flaeche pruefen
|
||||
# check the surface
|
||||
ar = 0.0
|
||||
for k in range(1, len(P) - 1):
|
||||
ar += np.linalg.norm(np.cross(P[k] - P[0], P[k + 1] - P[0])) / 2
|
||||
@@ -249,7 +251,7 @@ def decode_model_coll(m):
|
||||
|
||||
|
||||
class RenderIndex:
|
||||
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
|
||||
"""Render triangles of the world (export space), bucketed by grid cell."""
|
||||
|
||||
def __init__(self, trak, mp):
|
||||
cells = collections.defaultdict(list)
|
||||
@@ -280,7 +282,7 @@ class RenderIndex:
|
||||
self.cells = {k: np.array(v) for k, v in cells.items()}
|
||||
|
||||
def covered(self, p, n, dist=0.5, cosmin=0.4, tol=0.02):
|
||||
"""Trifft der Strahl p + t*n (|t| <= dist) ein Render-Dreieck (Neigung zur Normale < ~66 Grad)?"""
|
||||
"""Does the ray p + t*n (|t| <= dist) hit a render triangle (angle to the normal < ~66 degrees)?"""
|
||||
ids = set()
|
||||
for q in (p, p + n * dist, p - n * dist):
|
||||
k = (int(math.floor(q[0])), int(math.floor(q[2])))
|
||||
@@ -316,9 +318,9 @@ class RenderIndex:
|
||||
|
||||
|
||||
def classify(trak, mp, decoded):
|
||||
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
|
||||
"""Sets 'invis' per polygon (True = no render surface covers it)."""
|
||||
ri = RenderIndex(trak, mp)
|
||||
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
|
||||
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [visible, invisible]
|
||||
inst = collections.defaultdict(list)
|
||||
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
|
||||
for i in range(mp['N']):
|
||||
@@ -326,7 +328,7 @@ def classify(trak, mp, decoded):
|
||||
if i in altmap:
|
||||
inst[altmap[i][4]].append(i)
|
||||
for mi, polys in enumerate(decoded):
|
||||
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
|
||||
tiles = inst.get(mi, [])[:6] # a few instances are enough
|
||||
for k, pl in enumerate(polys):
|
||||
if pl['pts'] is None:
|
||||
continue
|
||||
@@ -334,7 +336,7 @@ def classify(trak, mp, decoded):
|
||||
cen = P.mean(axis=0)
|
||||
samples = [cen] + [cen + (q - cen) * 0.7 for q in P]
|
||||
if not tiles:
|
||||
votes[(mi, k)][1] += 1 # nie platziert: ohne Weltbezug -> als unsichtbar werten
|
||||
votes[(mi, k)][1] += 1 # never placed: no world reference -> count as invisible
|
||||
continue
|
||||
for ti in tiles:
|
||||
x, y, z, rot, _, _ = mp['A'][ti]
|
||||
@@ -348,7 +350,7 @@ def classify(trak, mp, decoded):
|
||||
pl['invis'] = vs[1] > vs[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Overlay je Modell
|
||||
# ------------------------------------------------------------------ overlay per model
|
||||
|
||||
def mat_color(mat, matcols):
|
||||
if mat in MAT_INFO:
|
||||
@@ -367,7 +369,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
|
||||
ecol = KIND_COL[pl['kind']] if mat == 0 else mat_color(mat, matcols)
|
||||
off = n * opt.offset
|
||||
cen = P.mean(axis=0)
|
||||
# Inkreis-Abschaetzung: kleinster Abstand Mittelpunkt -> Kante
|
||||
# incircle estimate: smallest distance centre -> edge
|
||||
rmin = 1e9
|
||||
for k in range(len(P)):
|
||||
a, b = P[k], P[(k + 1) % len(P)]
|
||||
@@ -377,7 +379,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
|
||||
continue
|
||||
rmin = min(rmin, np.linalg.norm(np.cross(e, cen - a)) / L)
|
||||
w = min(opt.width, 0.4 * rmin)
|
||||
# innere Ecken: Kantengeraden um w nach innen verschoben, mit Flaechenebene schneiden
|
||||
# inner corners: edge lines shifted inwards by w, intersected with the surface plane
|
||||
E = c['edges']
|
||||
m = len(E)
|
||||
ok = True
|
||||
@@ -395,7 +397,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
|
||||
inner = pts2
|
||||
else:
|
||||
inner = [cen + (q - cen) * 0.85 for q in P]
|
||||
O = [mesh.v(q + off, n) for q in P] # Ecken einmal anlegen (Rahmen + Fuellung teilen sie)
|
||||
O = [mesh.v(q + off, n) for q in P] # create corners once (frame + fill share them)
|
||||
I = [mesh.v(q + off, n) for q in inner]
|
||||
ec = pal(ecol)
|
||||
for k in range(len(P)):
|
||||
@@ -416,7 +418,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
|
||||
|
||||
|
||||
def append_mesh(model, mesh, pal_base, limit=7900):
|
||||
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
|
||||
"""Appends a mesh to a model (batch flag 0x8 on texture change / every 256 faces)."""
|
||||
nv0 = len(model.verts)
|
||||
if nv0 + len(mesh.verts) > limit:
|
||||
return 0
|
||||
@@ -437,7 +439,7 @@ def append_mesh(model, mesh, pal_base, limit=7900):
|
||||
return len(mesh.verts)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Marker fuer Objekte
|
||||
# ------------------------------------------------------------------ markers for objects
|
||||
|
||||
def fx(v):
|
||||
v = v - (1 << 32) if v >= 1 << 31 else v
|
||||
@@ -445,15 +447,15 @@ def fx(v):
|
||||
|
||||
|
||||
def strat_markers(ch, wf, pal, stats):
|
||||
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
|
||||
"""Returns the world mesh parts as dict cell -> Mesh in world coordinates (export space)."""
|
||||
mm = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, mapchunk.trak_vertcounts(G.wad_get(ch, 'TRAK')))
|
||||
st = G.wad_get(ch, 'STPC')
|
||||
import stpcwalk
|
||||
lo = stpcwalk.walk(st, wf)['code']
|
||||
nodes = mm['nodes']
|
||||
segs = [] # (a, b, breite, farbe) Weltraum Spielkoordinaten (z positiv)
|
||||
cubes = [] # (c, h, farbe)
|
||||
quads = [] # (4 Punkte, farbe, alpha)
|
||||
segs = [] # (a, b, width, colour) world space, game coordinates (z positive)
|
||||
cubes = [] # (c, h, colour)
|
||||
quads = [] # (4 points, colour, alpha)
|
||||
per_cat = collections.Counter()
|
||||
per_type = collections.Counter()
|
||||
|
||||
@@ -478,7 +480,7 @@ def strat_markers(ch, wf, pal, stats):
|
||||
name = S.strat_name(st, s[6], lo) or '?'
|
||||
cat = mark_cat(name)
|
||||
if cat is None:
|
||||
# alle uebrigen Objekte: kleiner dunkler Punkt am Ursprung (nichts geht verloren)
|
||||
# all other objects: small dark dot at the origin (nothing gets lost)
|
||||
per_cat['objekt'] += 1
|
||||
cubes.append((np.array([fx(s[3]), fx(s[4]), fx(s[5])]) + [0, 0.06, 0], 0.03, MARK_COL['objekt']))
|
||||
continue
|
||||
@@ -487,11 +489,11 @@ def strat_markers(ch, wf, pal, stats):
|
||||
col = MARK_COL[cat]
|
||||
p = np.array([fx(s[3]), fx(s[4]), fx(s[5])])
|
||||
rot = s[1] / 4096 * 2 * math.pi
|
||||
# Marker: Wuerfel + Pfahl
|
||||
# marker: cube + post
|
||||
top = p + [0, MARK_H, 0]
|
||||
if cat == 'start':
|
||||
# Pfeil flach auf dem Boden (blockiert die Startkamera nicht)
|
||||
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 Grad = +x
|
||||
# arrow flat on the ground (doesn't block the start camera)
|
||||
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 degrees = +x
|
||||
q = np.array([d[2], 0, -d[0]])
|
||||
g = p + [0, 0.03, 0]
|
||||
segs.append((g - d * 0.3, g + d * 0.4, 0.025, col))
|
||||
@@ -530,16 +532,16 @@ def strat_markers(ch, wf, pal, stats):
|
||||
prev = q
|
||||
stats['pfadpunkte'] += len(ch_)
|
||||
|
||||
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
|
||||
# environment zones (MAP block 4, 90 bytes: cell x/y/z, fog, music ...) -> wire cube per cell
|
||||
col = MARK_COL['umgebung']
|
||||
for L in mm['lights']:
|
||||
cx, cy, cz = L[0], L[1], L[2]
|
||||
if L[3:6] == (1, 1, 1):
|
||||
continue # Vorgabe-Eintrag (ganzes Level)
|
||||
continue # default entry (whole level)
|
||||
box((cx + 0.04, cy, cz + 0.04), (cx + 0.96, cy + 0.92, cz + 0.96), col, 0.02)
|
||||
per_cat['umgebung'] += 1
|
||||
|
||||
# alles in Zellen buckeln (Exportraum: z negieren)
|
||||
# bucket everything into cells (export space: negate z)
|
||||
cells = collections.defaultdict(Mesh)
|
||||
|
||||
def cell_of(q):
|
||||
@@ -554,7 +556,7 @@ def strat_markers(ch, wf, pal, stats):
|
||||
for c, h, col in cubes:
|
||||
cells[cell_of(c)].cube(ex(c), h, pal(col))
|
||||
for pts, col in quads:
|
||||
# in <=1x1-Stuecke zerlegen
|
||||
# split into <=1x1 pieces
|
||||
(x0, y, z0), _, (x1, _, z1), _ = pts
|
||||
xs = np.arange(math.floor(x0), math.ceil(x1))
|
||||
zs = np.arange(math.floor(z0), math.ceil(z1))
|
||||
@@ -575,7 +577,7 @@ def strat_markers(ch, wf, pal, stats):
|
||||
return cells
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Hauptablauf
|
||||
# ------------------------------------------------------------------ main flow
|
||||
|
||||
def process(src, dst, opt):
|
||||
ch = G.wad_read(src)
|
||||
@@ -592,7 +594,7 @@ def process(src, dst, opt):
|
||||
if not opt.no_coll:
|
||||
decoded = [decode_model_coll(m) for m in trak]
|
||||
classify(trak, mp, decoded)
|
||||
# Statistik je Kachelinstanz
|
||||
# statistics per tile instance
|
||||
inst = collections.Counter(mp['midx'])
|
||||
for mi, polys in enumerate(decoded):
|
||||
for pl in polys:
|
||||
@@ -610,11 +612,11 @@ def process(src, dst, opt):
|
||||
add_v[mi] = len(mesh.verts)
|
||||
mesh_store = mesh
|
||||
trak[mi]._ov = mesh_store
|
||||
# Marker
|
||||
# markers
|
||||
cells = {}
|
||||
if not opt.no_markers:
|
||||
cells = strat_markers(ch, wf, pal, stats)
|
||||
# TEXT erweitern
|
||||
# extend TEXT
|
||||
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
|
||||
G.wad_set(ch, 'TEXT', text)
|
||||
for mi, m in enumerate(trak):
|
||||
@@ -624,7 +626,7 @@ def process(src, dst, opt):
|
||||
if got == 0:
|
||||
stats['modelle_zu_gross'] += 1
|
||||
add_v[mi] = 0
|
||||
# neue Marker-Kacheln
|
||||
# new marker tiles
|
||||
W, H = mp['W'], mp['H']
|
||||
new_tiles = []
|
||||
for (cx, cz), mesh in sorted(cells.items()):
|
||||
@@ -640,7 +642,7 @@ def process(src, dst, opt):
|
||||
loc = Mesh()
|
||||
loc.verts = [(v[0] - ox, v[1] - oy, v[2] - oz, v[3], v[4], v[5]) for v in mesh.verts]
|
||||
loc.tris = mesh.tris
|
||||
# zu grosse Zellen aufteilen
|
||||
# split cells that are too large
|
||||
if len(loc.verts) > 7900:
|
||||
stats['marker_gekuerzt'] += 1
|
||||
keep = [t for t in loc.tris if max(t[:3]) < 7900]
|
||||
@@ -651,7 +653,7 @@ def process(src, dst, opt):
|
||||
new_tiles.append(dict(x=ox, y=oy, z=oz, rot=0, model=len(trak) - 1,
|
||||
cols=[[0x00808080] * len(mdl.verts)]))
|
||||
stats['marker_kacheln'] = len(new_tiles)
|
||||
# MAP neu
|
||||
# rebuild MAP
|
||||
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
|
||||
tiles = []
|
||||
for i in range(mp['N']):
|
||||
@@ -666,7 +668,7 @@ def process(src, dst, opt):
|
||||
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc, tiles, zones='keep', anim=mp['anim'])
|
||||
G.wad_set(ch, 'TRAK', G.build_trak(trak))
|
||||
G.wad_set(ch, 'MAP', newmap)
|
||||
# Gegenprobe: MAP vollstaendig parsen, Strats unveraendert
|
||||
# cross-check: MAP parses completely, strats unchanged
|
||||
vc = [len(m.verts) for m in trak]
|
||||
a = mapchunk.parse(newmap, wf, vc)
|
||||
b = mapchunk.parse(G.wad_get(G.wad_read(src), 'MAP'), wf, old_vc)
|
||||
@@ -693,7 +695,7 @@ def main():
|
||||
ap.add_argument('out', nargs='?')
|
||||
ap.add_argument('--all', metavar='OUTDIR')
|
||||
ap.add_argument('--src', default=CD_WADS)
|
||||
ap.add_argument('--only', help='Kommagetrennte Levelnamen (mit --all)')
|
||||
ap.add_argument('--only', help='comma-separated level names (with --all)')
|
||||
ap.add_argument('--no-coll', action='store_true')
|
||||
ap.add_argument('--no-markers', action='store_true')
|
||||
ap.add_argument('--no-fill', action='store_true')
|
||||
@@ -712,12 +714,12 @@ def main():
|
||||
for f in files:
|
||||
info = process(f, os.path.join(opt.all, os.path.basename(f)), opt)
|
||||
infos.append(info)
|
||||
print('%-13s %5d KB -> %5d KB Koll %5d (unsichtbar %4d) Marker-Kacheln %3d maxV %4d' % (
|
||||
print('%-13s %5d KB -> %5d KB coll %5d (invisible %4d) marker tiles %3d maxV %4d' % (
|
||||
info['level'], info['groesse_alt'] // 1024, info['groesse_neu'] // 1024, info.get('koll_polys', 0),
|
||||
info.get('koll_unsichtbar', 0), info.get('marker_kacheln', 0), info['max_verts_modell']), flush=True)
|
||||
else:
|
||||
if not (opt.inp and opt.out):
|
||||
ap.error('IN.wad OUT.wad oder --all OUTDIR')
|
||||
ap.error('IN.wad OUT.wad or --all OUTDIR')
|
||||
info = process(opt.inp, opt.out, opt)
|
||||
infos.append(info)
|
||||
print(json.dumps(info, indent=1, ensure_ascii=False))
|
||||
|
||||
+13
-13
@@ -23,33 +23,33 @@ for f in sorted(glob.glob(sys.argv[1] + '/*.wad')):
|
||||
if run > 256: err.append('batch>256 model %d' % mi); break
|
||||
if mi < len(otrak):
|
||||
om = otrak[mi]
|
||||
if m.coll != om.coll: err.append('KOLLISION GEAENDERT model %d' % mi)
|
||||
if m.coll != om.coll: err.append('COLLISION CHANGED model %d' % mi)
|
||||
if m.verts[:len(om.verts)] != om.verts or m.faces[:len(om.faces)] != om.faces:
|
||||
err.append('Originalgeometrie geaendert %d' % mi)
|
||||
elif any(len(g) for g in m.coll): err.append('neues Modell mit Kollision')
|
||||
err.append('original geometry changed %d' % mi)
|
||||
elif any(len(g) for g in m.coll): err.append('new model with collision')
|
||||
vc = [len(m.verts) for m in trak]
|
||||
mp = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, vc)
|
||||
om = mapchunk.parse(G.wad_get(o, 'MAP'), wf, [len(m.verts) for m in otrak])
|
||||
if mp['rest'] or mapchunk.build(mp, wf) != G.wad_get(ch, 'MAP'): err.append('MAP roundtrip')
|
||||
for k in ('strats', 'nodes', 'list1', 'lights', 'tab32', 'grid', 'alts', 'strat_x', 'paths', 'x200', 'last'):
|
||||
if mp[k] != om[k]: err.append('MAP %s geaendert' % k)
|
||||
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('Kacheln geaendert')
|
||||
if mp[k] != om[k]: err.append('MAP %s changed' % k)
|
||||
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('tiles changed')
|
||||
W, H = mp['W'], mp['H']
|
||||
for t in mp['tiles']:
|
||||
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('Kachel ausserhalb'); break
|
||||
# Farbblock: je Kachel nv*frames
|
||||
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('tile out of bounds'); break
|
||||
# colour block: nv*frames per tile
|
||||
gm = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, vc)
|
||||
for i, fr in enumerate(gm['cols']):
|
||||
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('Farbblock %d' % i); break
|
||||
# Kollision unveraendert je Kachel-Instanz der Originalkacheln
|
||||
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('colour block %d' % i); break
|
||||
# collision unchanged per tile instance of the original tiles
|
||||
for k in ('TEXT',):
|
||||
pass
|
||||
others = [t for t, _ in ch if t not in ('TRAK', 'MAP ', 'TEXT')]
|
||||
for t in others:
|
||||
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('Chunk %s geaendert' % t)
|
||||
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('chunk %s changed' % t)
|
||||
sz = sum(len(G.wad_get(ch, t)) for t in ('TRAK', 'STPC', 'MAP ', 'LGPC'))
|
||||
print('%-13s %s maxV %4d maxF %4d Modelle %4d Kacheln %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
|
||||
n, 'OK ' if not err else 'FEHLER ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
|
||||
print('%-13s %s maxV %4d maxF %4d models %4d tiles %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
|
||||
n, 'OK ' if not err else 'ERROR ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
|
||||
len(trak), len(mp['tiles']), sz / 2**20))
|
||||
bad += bool(err)
|
||||
print('Fehler:', bad)
|
||||
print('errors:', bad)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schnell-Disassembler fuer groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
|
||||
"""Quick disassembler for groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
|
||||
import os
|
||||
import sys
|
||||
from capstone import Cs, CS_ARCH_X86, CS_MODE_32
|
||||
|
||||
+14
-14
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Erzeugt ein Beispiel-OBJ mit komplett neuer Geometrie fuer geom_import.py.
|
||||
"""Generates an example OBJ with completely new geometry for geom_import.py.
|
||||
|
||||
geom_demo.py basis.wad demo.obj [--plaza]
|
||||
geom_demo.py base.wad demo.obj [--plaza]
|
||||
|
||||
Baut aus Kachel-Vierecken (je 1x1, Textur = ganze Texturkachel, so wie die Engine es will):
|
||||
- (--plaza) eine Bodenplatte y=0 ueber allen Rasterzellen, die im Basislevel belegt sind
|
||||
(damit Strats/Startpunkt irgendwo Boden finden)
|
||||
- einige Bloecke und eine Rampe in der Mitte des belegten Bereichs
|
||||
Texturen: haeufigste Textur auf nach oben zeigenden Faces (Boden) bzw. auf Seitenflaechen (Wand).
|
||||
Builds from tile quads (1x1 each, texture = whole texture tile, the way the engine wants it):
|
||||
- (--plaza) a floor plate at y=0 over all grid cells occupied in the base level
|
||||
(so strats/start point find floor somewhere)
|
||||
- a few blocks and a ramp in the middle of the occupied area
|
||||
Textures: most frequent texture on upward-facing faces (floor) and on side faces (wall).
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
@@ -24,7 +24,7 @@ class ObjW:
|
||||
self.v, self.vt, self.f, self.texs = [], [], [], texs
|
||||
|
||||
def quad(self, p, tex, mat=0):
|
||||
"""p: 4 Punkte, Reihenfolge so dass (p1-p0)x(p2-p0) nach aussen zeigt."""
|
||||
"""p: 4 points, ordered so that (p1-p0)x(p2-p0) points outwards."""
|
||||
U0, U1, V0, V1 = G.tex_uv(self.texs[tex])
|
||||
uv = [(U1, V1), (U0, V1), (U0, V0), (U1, V0)]
|
||||
base = len(self.v)
|
||||
@@ -33,10 +33,10 @@ class ObjW:
|
||||
self.f.append((tex, mat, [base + k for k in range(4)]))
|
||||
|
||||
def box(self, x0, z0, x1, z1, y0, y1, top, side):
|
||||
# Koordinaten: x0<x1, z0>z1 (z negativ = weiter im Raster)
|
||||
# coordinates: x0<x1, z0>z1 (negative z = further into the grid)
|
||||
self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top)
|
||||
for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]:
|
||||
# Wand von a nach b, aussen = rechts herum
|
||||
# wall from a to b, outside = clockwise
|
||||
n = math.ceil(y1 - y0)
|
||||
for k in range(n):
|
||||
ya, yb = y0 + k * (y1 - y0) / n, y0 + (k + 1) * (y1 - y0) / n
|
||||
@@ -74,7 +74,7 @@ def main():
|
||||
o.quad([(cx, 0, -cz), (cx + 1, 0, -cz), (cx + 1, 0, -cz - 1), (cx, 0, -cz - 1)], grass)
|
||||
mx = sorted(c[0] for c in cells)[len(cells) // 2]; mz = sorted(c[1] for c in cells)[len(cells) // 2]
|
||||
if not a.plaza:
|
||||
# Stelle suchen, an der das 9x4-Fenster moeglichst flach und voll belegt ist
|
||||
# find a spot where the 9x4 window is as flat as possible and fully occupied
|
||||
top = collections.defaultdict(lambda: -99.0)
|
||||
for t in mp['A']:
|
||||
if trak[t[4]].faces:
|
||||
@@ -91,7 +91,7 @@ def main():
|
||||
best = (spread, cx, cz)
|
||||
if best:
|
||||
mx, mz = best[1], best[2]
|
||||
# Bloecke + Rampe um die Mitte; ohne --plaza auf die vorhandene Geometrie gestellt
|
||||
# blocks + ramp around the centre; without --plaza placed on top of the existing geometry
|
||||
b = 0.0
|
||||
if not a.plaza:
|
||||
ys = []
|
||||
@@ -102,12 +102,12 @@ def main():
|
||||
b = float(round(sorted(ys)[len(ys) * 3 // 4] * 4) / 4) if ys else 0.0
|
||||
o.box(mx, -mz, mx + 2, -mz - 2, b, b + 1, grass, wall)
|
||||
o.box(mx + 3, -mz, mx + 4, -mz - 1, b, b + 3, grass, wall)
|
||||
for k in range(4): # Rampe in x-Richtung, 4 Zellen lang, steigt auf 1
|
||||
for k in range(4): # ramp in x direction, 4 cells long, rises to 1
|
||||
y0, y1 = b + k / 4, b + (k + 1) / 4
|
||||
x = mx - 4 + k
|
||||
o.quad([(x, y0, -mz), (x + 1, y1, -mz), (x + 1, y1, -mz - 2), (x, y0, -mz - 2)], grass)
|
||||
o.save(a.out)
|
||||
print(f'{a.out}: {len(o.f)} Vierecke, Boden-Textur {grass}, Wand-Textur {wall}, Mitte Zelle ({mx},{mz})')
|
||||
print(f'{a.out}: {len(o.f)} quads, floor texture {grass}, wall texture {wall}, centre cell ({mx},{mz})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+18
-18
@@ -1,17 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Level-WAD -> OBJ (+MTL, Texturseiten-PNGs, Vorschaubilder).
|
||||
"""Level WAD -> OBJ (+MTL, texture page PNGs, preview images).
|
||||
|
||||
geom_export.py level.wad outdir [--collision] [--objects] [--no-png] [--size 1200x900]
|
||||
|
||||
Erzeugt in outdir:
|
||||
level.obj / level.mtl Weltgeometrie (TRAK-Kacheln, platziert nach MAP), mit UVs und
|
||||
Vertexfarben ("v x y z r g b"), ein OBJ-Objekt pro Kachel
|
||||
page_NN.png Texturseiten (256x256, aus TEXT)
|
||||
collision.obj (--collision) Kollisionspolygone, Gruppen nach Material
|
||||
objects.obj (--objects) statische STPC-Objektmodelle nebeneinander
|
||||
preview_top.png, preview_persp.png texturierte Software-Renderings zur Kontrolle
|
||||
Writes to outdir:
|
||||
level.obj / level.mtl world geometry (TRAK tiles, placed per MAP), with UVs and
|
||||
vertex colours ("v x y z r g b"), one OBJ object per tile
|
||||
page_NN.png texture pages (256x256, from TEXT)
|
||||
collision.obj (--collision) collision polygons, grouped by material
|
||||
objects.obj (--objects) static STPC object models side by side
|
||||
preview_top.png, preview_persp.png textured software renderings for checking
|
||||
|
||||
Koordinaten: Exportraum = MAP-Floats (y oben, rechtshaendig, z < 0). Spielraum = (x, y, -z).
|
||||
Coordinates: export space = MAP floats (y up, right-handed, z < 0). Game space = (x, y, -z).
|
||||
Format: notes/geometry.md
|
||||
"""
|
||||
import argparse
|
||||
@@ -27,7 +27,7 @@ import geomlib as G # noqa: E402
|
||||
|
||||
|
||||
def world_tris(trak, mp, texs, use_alt=False):
|
||||
"""Liefert Liste von Dreiecken: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
|
||||
"""Returns a list of triangles: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
|
||||
out = []
|
||||
for i in range(mp['N']):
|
||||
x, y, z, rot, mi, fl = mp['A'][i]
|
||||
@@ -57,13 +57,13 @@ def world_tris(trak, mp, texs, use_alt=False):
|
||||
|
||||
|
||||
def flat_color(t):
|
||||
# Flache Farbe (Texturflag Bit0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
|
||||
# flat colour (texture flag bit 0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
|
||||
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
|
||||
|
||||
|
||||
def write_obj(path, tris, texs, mtlname):
|
||||
with open(path, 'w') as fo:
|
||||
fo.write('# Ein Koenigreich fuer ein Lama - Weltgeometrie (geom_export.py)\n')
|
||||
fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n')
|
||||
fo.write(f'mtllib {mtlname}\n')
|
||||
vi = 1
|
||||
cur_tile = None
|
||||
@@ -96,7 +96,7 @@ def write_mtl(path, tris, texs, npages):
|
||||
fo.write(f'newmtl flat_{tex}\nKd %.3f %.3f %.3f\n\n' % c)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Software-Renderer
|
||||
# ------------------------------------------------------------ software renderer
|
||||
|
||||
def render(tris, pages, cam, size, path, texs):
|
||||
Wd, Hd = size
|
||||
@@ -134,7 +134,7 @@ def render(tris, pages, cam, size, path, texs):
|
||||
iz = w0 / cz[0] + w1 / cz[1] + w2 / cz[2]
|
||||
depth = 1 / np.where(inside, iz, 1e-9)
|
||||
sub = zb[y0:y1 + 1, x0:x1 + 1]
|
||||
# Farbe
|
||||
# colour
|
||||
nrm = np.cross(np.array(P[1]) - P[0], np.array(P[2]) - P[0])
|
||||
nl = np.linalg.norm(nrm)
|
||||
shade = 0.55 + 0.45 * abs(nrm @ L) / nl if nl > 0 else 1
|
||||
@@ -166,7 +166,7 @@ def main():
|
||||
ap.add_argument('wad'); ap.add_argument('outdir')
|
||||
ap.add_argument('--collision', action='store_true')
|
||||
ap.add_argument('--objects', action='store_true')
|
||||
ap.add_argument('--no-png', action='store_true', help='keine Seiten-PNGs/Vorschau')
|
||||
ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews')
|
||||
ap.add_argument('--size', default='1200x900')
|
||||
a = ap.parse_args()
|
||||
os.makedirs(a.outdir, exist_ok=True)
|
||||
@@ -175,8 +175,8 @@ def main():
|
||||
tris = world_tris(trak, mp, texs)
|
||||
write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl')
|
||||
write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages))
|
||||
print(f'{a.wad}: WFPC={wf:#x} TRAK-Modelle={len(trak)} Kacheln={mp["N"]} Raster={mp["W"]}x{mp["H"]} '
|
||||
f'Dreiecke={len(tris)} Texturen={len(texs)} Seiten={len(pages)}')
|
||||
print(f'{a.wad}: WFPC={wf:#x} TRAK models={len(trak)} tiles={mp["N"]} grid={mp["W"]}x{mp["H"]} '
|
||||
f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}')
|
||||
|
||||
if a.collision:
|
||||
with open(os.path.join(a.outdir, 'collision.obj'), 'w') as fo:
|
||||
@@ -233,7 +233,7 @@ def main():
|
||||
P = np.array([p for t in tris for p in t[0]])
|
||||
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
|
||||
ext = max(hi[0] - lo[0], hi[2] - lo[2])
|
||||
# Draufsicht (fast senkrecht, Norden = -z oben)
|
||||
# top view (almost vertical, north = -z at the top)
|
||||
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
|
||||
os.path.join(a.outdir, 'preview_top.png'), texs)
|
||||
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
|
||||
|
||||
+43
-43
@@ -1,31 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OBJ -> neue Weltgeometrie (TRAK + Kachelteil von MAP) in einer Kopie eines Level-WADs.
|
||||
"""OBJ -> new world geometry (TRAK + tile part of MAP) in a copy of a level WAD.
|
||||
|
||||
geom_import.py basis.wad mesh.obj neu.wad [--default-tex N] [--no-coll] [--mat N]
|
||||
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
|
||||
[--keep-tiles] [--band 6]
|
||||
|
||||
Eingabe-OBJ im Exportraum von geom_export.py (y oben, 1 Einheit = 1 Kachel, z < 0 liegt im
|
||||
Raster; Rasterzelle (cx,cz) = (floor(x), floor(-z)), muss in 0..W-1 / 0..H-1 liegen).
|
||||
Input OBJ in the export space of geom_export.py (y up, 1 unit = 1 tile, z < 0 lies in the
|
||||
grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1).
|
||||
|
||||
Texturen: Die Engine kennt KEINE freien UVs. Jedes Dreieck zeigt eine Haelfte eines
|
||||
Texturrechtecks (Eintrag der TEXT-Tabelle), die Ecken werden ueber Face-Flags gewaehlt.
|
||||
usemtl page_NN -> Texturindex wird aus dem UV-Rechteck (Bounding-Box der Face-UVs)
|
||||
auf Seite NN gesucht; UVs muessen auf Rechteck-Ecken liegen
|
||||
(so wie geom_export sie schreibt).
|
||||
usemtl tex_N -> Texturindex N; UVs (falls vorhanden) waehlen nur die Ecken-Belegung,
|
||||
ohne UVs wird die Standardbelegung genommen.
|
||||
usemtl flat_N -> Texturindex N (so exportiert geom_export Flachfarben)
|
||||
sonst -> --default-tex
|
||||
Vierecke werden als (0,1,2)+(2,3,0) zerlegt; mit Rechteck-UVs ergibt das genau die
|
||||
Original-Belegung (Flags 0x800 / 0x810).
|
||||
Vertexfarben: "v x y z r g b" (0..1, 1.0 = Originalhelligkeit 0x80); sonst 0x80.
|
||||
Kollision: --coll-obj datei.obj (Polygone, Gruppen "matNN...") oder aus jedem Face ein konvexes Kollisionspolygon (Viereck wenn planar), eingetragen
|
||||
in jede Rasterzelle, die das Polygon (xz-Bounding-Box) beruehrt. Material: Gruppe/Objekt
|
||||
"..._matNN" bzw. --mat. (--no-coll: keine Kollision)
|
||||
Textures: the engine has NO free UVs. Each triangle shows one half of a texture
|
||||
rectangle (entry of the TEXT table); the corners are chosen via face flags.
|
||||
usemtl page_NN -> texture index is looked up from the UV rectangle (bounding box of the
|
||||
face UVs) on page NN; UVs must lie on rectangle corners
|
||||
(the way geom_export writes them).
|
||||
usemtl tex_N -> texture index N; UVs (if present) only choose the corner assignment,
|
||||
without UVs the default assignment is used.
|
||||
usemtl flat_N -> texture index N (this is how geom_export exports flat colours)
|
||||
otherwise -> --default-tex
|
||||
Quads are split as (0,1,2)+(2,3,0); with rectangle UVs this gives exactly the
|
||||
original assignment (flags 0x800 / 0x810).
|
||||
Vertex colours: "v x y z r g b" (0..1, 1.0 = original brightness 0x80); otherwise 0x80.
|
||||
Collision: --coll-obj file.obj (polygons, groups "matNN...") or one convex collision polygon from each face (quad if planar), entered
|
||||
into every grid cell the polygon (xz bounding box) touches. Material: group/object
|
||||
"..._matNN" or --mat. (--no-coll: no collision)
|
||||
|
||||
Ausgabe: Ein Modell pro belegter (Zelle, Hoehenband). Alle vorhandenen Kacheln werden ersetzt
|
||||
(ausser --keep-tiles: alte Kacheln + Modelle bleiben, neue kommen dazu).
|
||||
Strats/Pfade/Zonen (Rest von MAP) und alle anderen Chunks bleiben unveraendert.
|
||||
Output: one model per occupied (cell, height band). All existing tiles are replaced
|
||||
(except with --keep-tiles: old tiles + models stay, new ones are added).
|
||||
Strats/paths/zones (rest of MAP) and all other chunks stay unchanged.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
@@ -72,7 +72,7 @@ def read_obj(path):
|
||||
|
||||
|
||||
def find_tex(texs, page, uvs):
|
||||
"""Texturindex auf Seite 'page', dessen Rechteck am besten zu den UVs passt."""
|
||||
"""Texture index on page 'page' whose rectangle best matches the UVs."""
|
||||
us = [u * 256 for u, v in uvs]; vs = [v * 256 for u, v in uvs]
|
||||
best, bd = None, 1e9
|
||||
for i, t in enumerate(texs):
|
||||
@@ -85,7 +85,7 @@ def find_tex(texs, page, uvs):
|
||||
|
||||
|
||||
def match_pattern(uvs, rect):
|
||||
"""Beste (Flags, Rotation) so dass face_uvs(flags) die gegebenen Ecken-UVs trifft."""
|
||||
"""Best (flags, rotation) so that face_uvs(flags) hits the given corner UVs."""
|
||||
best = (1e9, 0x800, 0)
|
||||
for fl in PATTERNS:
|
||||
ref = G.face_uvs(fl, rect)
|
||||
@@ -108,14 +108,14 @@ def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('base'); ap.add_argument('obj'); ap.add_argument('out')
|
||||
ap.add_argument('--default-tex', type=int, default=None)
|
||||
ap.add_argument('--mat', type=int, default=0, help='Kollisionsmaterial (Standard 0)')
|
||||
ap.add_argument('--mat', type=int, default=0, help='collision material (default 0)')
|
||||
ap.add_argument('--no-coll', action='store_true')
|
||||
ap.add_argument('--coll-obj', help='Kollision aus eigenem OBJ (z.B. collision.obj von geom_export) '
|
||||
'statt aus den Render-Faces')
|
||||
ap.add_argument('--coll-obj', help='collision from a separate OBJ (e.g. collision.obj from geom_export) '
|
||||
'instead of the render faces')
|
||||
ap.add_argument('--keep-tiles', action='store_true')
|
||||
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
|
||||
help='flat: Zonen-Sichtbarkeit (PVS) abschalten, alles zeichnen; keep: Original')
|
||||
ap.add_argument('--band', type=float, default=6.0, help='Hoehenband pro Kachel (Einheiten)')
|
||||
help='flat: disable zone visibility (PVS), draw everything; keep: original')
|
||||
ap.add_argument('--band', type=float, default=6.0, help='height band per tile (units)')
|
||||
a = ap.parse_args()
|
||||
|
||||
ch, wf, trak, mp = G.load_level(a.base)
|
||||
@@ -126,9 +126,9 @@ def main():
|
||||
if deftex is None:
|
||||
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
|
||||
|
||||
# ---- Dreiecke mit Textur + Flags
|
||||
# ---- triangles with texture + flags
|
||||
tris = [] # (pos[3], col[3], tex, flags)
|
||||
polys = [] # (pos list, mat) fuer Kollision
|
||||
polys = [] # (pos list, mat) for collision
|
||||
warn = defaultdict(int)
|
||||
for vi, ti, mat, grp in faces:
|
||||
pts = [V[i] for i in vi]; cols = [C[i] for i in vi]
|
||||
@@ -140,7 +140,7 @@ def main():
|
||||
if mm and mm.group(1) == 'page' and uvs:
|
||||
tex, dist = find_tex(texs, int(mm.group(2)), uvs)
|
||||
if tex is None or dist > 2:
|
||||
warn['UV-Rechteck nicht gefunden'] += 1
|
||||
warn['UV rectangle not found'] += 1
|
||||
elif mm:
|
||||
tex = int(mm.group(2))
|
||||
if tex is None:
|
||||
@@ -153,7 +153,7 @@ def main():
|
||||
if uvs and not (t['flags'] & 1):
|
||||
fl, rot, d = match_pattern([uvs[j] for j in idx], rect)
|
||||
if d > 2.5 / 256:
|
||||
warn['UVs nicht auf Rechteck-Ecken'] += 1
|
||||
warn['UVs not on rectangle corners'] += 1
|
||||
P = [P[(j + rot) % 3] for j in range(3)]; Cc = [Cc[(j + rot) % 3] for j in range(3)]
|
||||
else:
|
||||
fl = 0x800 if k % 2 else 0x810
|
||||
@@ -166,7 +166,7 @@ def main():
|
||||
m = re.search(r'mat(\d+)', grp or '')
|
||||
polys.append(([cV[i] for i in vi], int(m.group(1)) if m else a.mat))
|
||||
|
||||
# ---- Zellen/Baender
|
||||
# ---- cells/bands
|
||||
def key_of(x, y, z):
|
||||
cx, cz = math.floor(x), math.floor(-z)
|
||||
return (cx, cz, math.floor(y / a.band))
|
||||
@@ -184,7 +184,7 @@ def main():
|
||||
for cz in range(math.floor(-P[:, 2].max()), math.floor(-P[:, 2].min() - 1e-6) + 1):
|
||||
cells[(cx, cz, band)]['coll'].append((pts, mat))
|
||||
|
||||
# ---- Modelle bauen
|
||||
# ---- build models
|
||||
models, tiles = [], []
|
||||
if a.keep_tiles:
|
||||
models = list(trak)
|
||||
@@ -194,7 +194,7 @@ def main():
|
||||
altcols=mp['altcols'].get(i, [])))
|
||||
for (cx, cz, band), cell in sorted(cells.items()):
|
||||
if not (0 <= cx < W and 0 <= cz < H):
|
||||
warn[f'ausserhalb des Rasters {W}x{H}'] += 1
|
||||
warn[f'outside the {W}x{H} grid'] += 1
|
||||
continue
|
||||
allp = [p for t in cell['tris'] for p in t[0]] or [p for c in cell['coll'] for p in c[0]]
|
||||
ty = float(round(np.median([p[1] for p in allp])))
|
||||
@@ -218,8 +218,8 @@ def main():
|
||||
for ids, tex, fl in facelist:
|
||||
plane = G.face_plane(*[m.verts[i][:3] for i in ids])
|
||||
st = (texs[tex]['page'], texs[tex]['flags'])
|
||||
# Bit 0x8 = neuer Render-Batch (Zustand neu laden). Batch-Puffer 0x5d15f8 fasst ~4800
|
||||
# Ausgabe-Vertices -> spaetestens alle 256 Faces neu starten.
|
||||
# bit 0x8 = new render batch (reload state). Batch buffer 0x5d15f8 holds ~4800
|
||||
# output vertices -> restart at least every 256 faces.
|
||||
if st != prev or len(m.faces) % 256 == 0:
|
||||
fl |= 0x8
|
||||
prev = st
|
||||
@@ -233,7 +233,7 @@ def main():
|
||||
for pts, mat in cell['coll']:
|
||||
loc = [(p[0] - ox, p[1] - ty, p[2] - oz) for p in pts]
|
||||
if max(abs(c) for p in loc for c in p) > 7.9:
|
||||
warn['Kollisionspolygon > 7.9 Einheiten vom Kachelzentrum (uebersprungen)'] += 1
|
||||
warn['collision polygon > 7.9 units from tile centre (skipped)'] += 1
|
||||
continue
|
||||
recs = []
|
||||
if len(loc) == 4:
|
||||
@@ -252,12 +252,12 @@ def main():
|
||||
r = bytes([r[0] | 4]) + r[1:]
|
||||
m.coll[0].append(r)
|
||||
if len(m.verts) > 8000 or len(m.faces) > 65535:
|
||||
sys.exit('Kachel zu gross (Transformpuffer 0x58b0f0 fasst 8000 Vertices)')
|
||||
sys.exit('tile too large (transform buffer 0x58b0f0 holds 8000 vertices)')
|
||||
models.append(m)
|
||||
tiles.append(dict(x=ox, y=ty, z=oz, rot=0, model=len(models) - 1, cols=[cols]))
|
||||
|
||||
if not models:
|
||||
sys.exit('keine Geometrie')
|
||||
sys.exit('no geometry')
|
||||
newtrak = G.build_trak(models)
|
||||
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, [len(m.verts) for m in trak], tiles, a.zones,
|
||||
mp['anim'] if a.keep_tiles else ())
|
||||
@@ -265,9 +265,9 @@ def main():
|
||||
G.wad_set(ch, 'MAP', newmap)
|
||||
G.wad_write(a.out, ch)
|
||||
nf = sum(len(m.faces) for m in models); nc = sum(len(m.coll[0]) for m in models)
|
||||
print(f'{a.out}: {len(models)} Modelle, {len(tiles)} Kacheln, {nf} Dreiecke, {nc} Kollisionspolygone')
|
||||
print(f'{a.out}: {len(models)} models, {len(tiles)} tiles, {nf} triangles, {nc} collision polygons')
|
||||
for k, v in warn.items():
|
||||
print(f' WARNUNG: {k}: {v}x')
|
||||
print(f' WARNING: {k}: {v}x')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+59
-59
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Geometrie-Bibliothek fuer Level-WADs von "Ein Koenigreich fuer ein Lama" (Croc-2-Engine, PC).
|
||||
"""Geometry library for level WADs of "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
|
||||
|
||||
Format siehe notes/geometry.md. Kurz:
|
||||
TRAK = Kachel-Modelle (Welt-Geometrie + Kollision), u32 n, n x 0x84 Header, dann Daten
|
||||
MAP = Kachel-Raster: Platzierung (Position/Rotation/Modell) + Vertexfarben (+ Strats usw.)
|
||||
STPC = Objekt-Modelle (gleiches Modellformat, Header 0x8c) + Animationen + Strat-Skripte
|
||||
TEXT = Texturseiten + Texturtabelle (UV-Rechtecke)
|
||||
Format: see notes/geometry.md. In short:
|
||||
TRAK = tile models (world geometry + collision), u32 n, n x 0x84 header, then data
|
||||
MAP = tile grid: placement (position/rotation/model) + vertex colours (+ strats etc.)
|
||||
STPC = object models (same model format, header 0x8c) + animations + strat scripts
|
||||
TEXT = texture pages + texture table (UV rectangles)
|
||||
|
||||
Alle Parser sind aus groove.exe nachgebaut (FUN_005563f0 TRAK, FUN_0041f770 STPC-Modell,
|
||||
FUN_0042ac50 MAP, FUN_00406d30 Texturtabelle, FUN_00556510 Kachel-Renderer).
|
||||
All parsers are reimplemented from groove.exe (FUN_005563f0 TRAK, FUN_0041f770 STPC model,
|
||||
FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer).
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
@@ -19,7 +19,7 @@ import struct
|
||||
def wad_read(path):
|
||||
d = open(path, 'rb').read()
|
||||
total = struct.unpack_from('<I', d, 0)[0]
|
||||
assert total == len(d) - 4, 'WAD-Laengenfeld passt nicht'
|
||||
assert total == len(d) - 4, 'WAD length field does not match'
|
||||
chunks, o = [], 4
|
||||
while o + 8 <= len(d):
|
||||
tag = d[o:o + 4][::-1].decode('latin1')
|
||||
@@ -56,21 +56,21 @@ def wfpc_flags(chunks):
|
||||
return struct.unpack('<I', c)[0] if c else 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Modelle
|
||||
# Header (TRAK 0x84 Byte, STPC 0x8c Byte):
|
||||
# +0x00 3f Mittelpunkt der Bounding-Box
|
||||
# +0x0c 24f 8 Ecken der (um 1/128 vergroesserten) Bounding-Box
|
||||
# ---------------------------------------------------------------- models
|
||||
# Header (TRAK 0x84 bytes, STPC 0x8c bytes):
|
||||
# +0x00 3f centre of the bounding box
|
||||
# +0x0c 24f 8 corners of the bounding box (enlarged by 1/128)
|
||||
# +0x6c u16 nVerts +0x6e u16 nFaces
|
||||
# +0x70 u32 (Zeiger Verts, in Datei 0) +0x74 u32 (Zeiger Faces, 0)
|
||||
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (Kollisionspolygone, 32 Byte)
|
||||
# +0x7e u16 0 +0x80 u32 (Zeiger Kollision, 0)
|
||||
# nur STPC: +0x84 u16 ?(meist = nVerts oder 0) +0x86 u16 nX (n*2 Byte Daten vor den Verts)
|
||||
# +0x88 u32 (Zeiger, 0)
|
||||
# Daten: [STPC: nX*u16] Verts nV*24 (pos 3f, normal 3f), Faces nF*28, Kollision nCol*32
|
||||
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f Ebenen-Normale, f d (n.v = d)
|
||||
# Kollision (32): u8 typ (Bit0: Viereck, sonst Dreieck), u8 Material, 5 x {i8 nx,ny,nz, u8 0,
|
||||
# i16 d}: Ebene 0 = Flaeche, Ebenen 1..4 = Kanten (nach innen). Normalen *127, d *4096,
|
||||
# Koordinaten im Spielraum (z gespiegelt ggue. Vertexdaten!).
|
||||
# +0x70 u32 (verts pointer, 0 in file) +0x74 u32 (faces pointer, 0)
|
||||
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (collision polygons, 32 bytes)
|
||||
# +0x7e u16 0 +0x80 u32 (collision pointer, 0)
|
||||
# STPC only: +0x84 u16 ?(usually = nVerts or 0) +0x86 u16 nX (n*2 bytes of data before the verts)
|
||||
# +0x88 u32 (pointer, 0)
|
||||
# Data: [STPC: nX*u16] verts nV*24 (pos 3f, normal 3f), faces nF*28, collision nCol*32
|
||||
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f plane normal, f d (n.v = d)
|
||||
# Collision (32): u8 type (bit 0: quad, otherwise triangle), u8 material, 5 x {i8 nx,ny,nz, u8 0,
|
||||
# i16 d}: plane 0 = surface, planes 1..4 = edges (pointing inwards). Normals *127, d *4096,
|
||||
# coordinates in game space (z mirrored relative to the vertex data!).
|
||||
|
||||
CORNERS = [(-1, -1, 1), (1, -1, 1), (-1, 1, 1), (1, 1, 1), (-1, -1, -1), (1, -1, -1), (-1, 1, -1), (1, 1, -1)]
|
||||
|
||||
@@ -79,11 +79,11 @@ class Model:
|
||||
def __init__(self):
|
||||
self.verts = [] # [(x,y,z,nx,ny,nz)]
|
||||
self.faces = [] # [dict(flags,v=(a,b,c),tex,pad,plane=(nx,ny,nz,d))]
|
||||
self.coll = [[], [], []] # drei Gruppen; je Eintrag 32 Byte roh (bytes)
|
||||
self.hdr = None # Original-Header (bytes) zum Vergleich
|
||||
self.coll = [[], [], []] # three groups; 32 raw bytes per entry (bytes)
|
||||
self.hdr = None # original header (bytes) for comparison
|
||||
self.extra = b'' # STPC: nX*u16
|
||||
self.x84 = 0
|
||||
self.dirty = False # True setzen, wenn Verts geaendert -> Bounding-Box neu
|
||||
self.dirty = False # set True when verts changed -> recompute bounding box
|
||||
|
||||
|
||||
def _parse_models(d, o, n, hsize):
|
||||
@@ -115,7 +115,7 @@ def _parse_models(d, o, n, hsize):
|
||||
|
||||
|
||||
def parse_stpc_models(d):
|
||||
"""Nur die statischen Objektmodelle am Anfang von STPC (u32 n, je 1 Modell mit 0x8c-Header)."""
|
||||
"""Only the static object models at the start of STPC (u32 n, 1 model each with 0x8c header)."""
|
||||
n = struct.unpack_from('<I', d, 0)[0]
|
||||
o = 4
|
||||
out = []
|
||||
@@ -139,7 +139,7 @@ def bbox_header(m):
|
||||
else:
|
||||
lo = hi = (0.0, 0.0, 0.0)
|
||||
c = tuple((a + b) / 2 for a, b in zip(lo, hi))
|
||||
e = 1 / 128 # Original vergroessert die Box um 1/128
|
||||
e = 1 / 128 # the original enlarges the box by 1/128
|
||||
h = tuple((b - a) / 2 + e for a, b in zip(lo, hi))
|
||||
pts = []
|
||||
for s in CORNERS:
|
||||
@@ -148,7 +148,7 @@ def bbox_header(m):
|
||||
|
||||
|
||||
def build_models(models, hsize, recompute_bbox=None):
|
||||
"""recompute_bbox=None: Original-Box behalten, wenn vorhanden und Verts unveraendert."""
|
||||
"""recompute_bbox=None: keep the original box if present and verts unchanged."""
|
||||
hdr, data = b'', b''
|
||||
for m in models:
|
||||
if recompute_bbox is False or (recompute_bbox is None and m.hdr is not None
|
||||
@@ -180,17 +180,17 @@ def build_trak(models):
|
||||
def face_plane(p0, p1, p2):
|
||||
ax, ay, az = (p1[i] - p0[i] for i in range(3))
|
||||
bx, by, bz = (p2[i] - p0[i] for i in range(3))
|
||||
# Wicklung: Original-Normale = (v1-v0) x (v2-v0)
|
||||
# winding: original normal = (v1-v0) x (v2-v0)
|
||||
nx, ny, nz = ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx
|
||||
ln = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
|
||||
nx, ny, nz = nx / ln, ny / ln, nz / ln
|
||||
return (nx, ny, nz, nx * p0[0] + ny * p0[1] + nz * p0[2])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Kollision
|
||||
# ---------------------------------------------------------------- collision
|
||||
|
||||
def coll_decode(r):
|
||||
"""32-Byte-Kollisionspolygon -> dict (Ebenen im Vertex-/Exportraum, d.h. z zurueckgespiegelt)."""
|
||||
"""32-byte collision polygon -> dict (planes in vertex/export space, i.e. z mirrored back)."""
|
||||
typ, mat = r[0], r[1]
|
||||
planes = []
|
||||
for k in range(5):
|
||||
@@ -201,7 +201,7 @@ def coll_decode(r):
|
||||
|
||||
|
||||
def coll_polygon(c):
|
||||
"""Eckpunkte aus Flaechenebene + Kantenebenen (Schnitt benachbarter Kanten)."""
|
||||
"""Corner points from surface plane + edge planes (intersection of adjacent edges)."""
|
||||
import numpy as np
|
||||
P = c['plane']; E = c['edges']
|
||||
pts = []
|
||||
@@ -217,7 +217,7 @@ def coll_polygon(c):
|
||||
|
||||
|
||||
def coll_encode(typ, mat, plane, edges):
|
||||
"""Ebenen im Vertexraum -> 32 Byte (z wird fuer den Spielraum gespiegelt)."""
|
||||
"""Planes in vertex space -> 32 bytes (z is mirrored for game space)."""
|
||||
def q(p):
|
||||
nx, ny, nz, d = p
|
||||
cl = lambda v: max(-127, min(127, int(round(v * 127))))
|
||||
@@ -227,7 +227,7 @@ def coll_encode(typ, mat, plane, edges):
|
||||
|
||||
|
||||
def coll_from_polygon(pts, mat=0, typ=None):
|
||||
"""Konvexes Polygon (3 oder 4 Punkte, Vertexraum, Reihenfolge wie Faces) -> 32-Byte-Record."""
|
||||
"""Convex polygon (3 or 4 points, vertex space, same order as faces) -> 32-byte record."""
|
||||
import numpy as np
|
||||
P = [np.array(p, float) for p in pts]
|
||||
n = np.cross(P[1] - P[0], P[2] - P[0])
|
||||
@@ -244,20 +244,20 @@ def coll_from_polygon(pts, mat=0, typ=None):
|
||||
return None
|
||||
e /= np.linalg.norm(e)
|
||||
if e @ (cen - a) < 0:
|
||||
e = -e # nach innen
|
||||
e = -e # inwards
|
||||
edges.append((*e, float(e @ a)))
|
||||
if typ is None:
|
||||
typ = 1 if len(P) == 4 else 0
|
||||
return coll_encode(typ, mat, (*n, d), edges)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- MAP (nur Kachelteile)
|
||||
STRAT_REC = 90 # Bytes pro Eintrag des 2. Blocks (0x5c im Speicher)
|
||||
NODE_REC = 34 # Bytes pro Eintrag des 3. Blocks (0x30 im Speicher)
|
||||
# ---------------------------------------------------------------- MAP (tile parts only)
|
||||
STRAT_REC = 90 # bytes per entry of the 2nd block (0x5c in memory)
|
||||
NODE_REC = 34 # bytes per entry of the 3rd block (0x30 in memory)
|
||||
|
||||
|
||||
def parse_map_tiles(d, wfpc, vcounts):
|
||||
"""Liefert Kachel-Infos + Byte-Offsets der Abschnitte (fuer Splicing)."""
|
||||
"""Returns tile info + byte offsets of the sections (for splicing)."""
|
||||
N, W, H = struct.unpack_from('<3I', d, 0)
|
||||
o = 12
|
||||
offs = {'A': o}
|
||||
@@ -299,16 +299,16 @@ def parse_map_tiles(d, wfpc, vcounts):
|
||||
|
||||
|
||||
def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
|
||||
"""Ersetzt die Kachelabschnitte im MAP-Chunk.
|
||||
"""Replaces the tile sections in the MAP chunk.
|
||||
|
||||
tiles: Liste dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in Kacheleinheiten
|
||||
(Exportraum: y oben, z<=0), rot in 4096tel (Rotation um y).
|
||||
Kachel-Wechsel-Animationen: tiles[i]['flag']=1 + tiles[i]['altcols'] (Farben des Alt-Modells);
|
||||
anim = Liste der 5-u32-Eintraege (Zelle x, y, z, Modell, Alt-Modell), in Reihenfolge der
|
||||
geflaggten Kacheln. Ohne anim werden alle Flags geloescht.
|
||||
zones='flat': Sichtbarkeitszonen abschalten (Zonenraster = 0, Maske von Zone 0 = alle),
|
||||
sonst ('keep') Zonenraster + 32er-Tabelle unveraendert lassen.
|
||||
Alles andere (Strat-/Knoten-/Lichtbloecke) bleibt unveraendert.
|
||||
tiles: list of dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in tile units
|
||||
(export space: y up, z<=0), rot in 1/4096 turns (rotation about y).
|
||||
Tile swap animations: tiles[i]['flag']=1 + tiles[i]['altcols'] (colours of the alt model);
|
||||
anim = list of the 5-u32 entries (cell x, y, z, model, alt model), in the order of the
|
||||
flagged tiles. Without anim all flags are cleared.
|
||||
zones='flat': disable visibility zones (zone grid = 0, mask of zone 0 = all),
|
||||
otherwise ('keep') leave zone grid + 32-entry table unchanged.
|
||||
Everything else (strat/node/light blocks) stays unchanged.
|
||||
"""
|
||||
m = parse_map_tiles(d, wfpc, old_vcounts)
|
||||
of = m['offs']
|
||||
@@ -320,7 +320,7 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
|
||||
int(round(t['y'] * 4096)), int(round(-t['z'] * 4096))) for t in tiles)
|
||||
midx = struct.pack('<%dI' % N, *[t['model'] for t in tiles])
|
||||
if anim and sum(1 for t in tiles if t.get('flag')) != len(anim):
|
||||
raise ValueError('Anzahl geflaggter Kacheln != Anzahl anim-Eintraege')
|
||||
raise ValueError('number of flagged tiles != number of anim entries')
|
||||
animb = (struct.pack('<I', len(anim)) + b''.join(struct.pack('<5I', *e) for e in anim)) if wfpc & 0x10000 else b''
|
||||
cols = b''
|
||||
for t in tiles:
|
||||
@@ -328,13 +328,13 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
|
||||
cols += struct.pack('<%dI' % len(frame), *frame)
|
||||
mid = d[of['A_end']:of['B']]
|
||||
if zones == 'flat':
|
||||
g = of['grid'] - of['A_end'] # Offset der Zonentabelle+Raster innerhalb mid
|
||||
g = of['grid'] - of['A_end'] # offset of zone table+grid within mid
|
||||
tab = struct.pack('<2I', 0xffffffff, struct.unpack_from('<I', mid, g - 256 + 4)[0]) + mid[g - 248:g]
|
||||
mid = mid[:g - 256] + tab + bytes(4 * m['W'] * m['H'])
|
||||
return head + A + mid + B + midx + animb + cols + d[of['col_end']:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- TEXT (Texturtabelle + Seiten)
|
||||
# ---------------------------------------------------------------- TEXT (texture table + pages)
|
||||
|
||||
def parse_text(d):
|
||||
o = 0
|
||||
@@ -362,11 +362,11 @@ def parse_text(d):
|
||||
|
||||
|
||||
def page_rgba(p):
|
||||
"""16-Bit-Seite -> numpy RGBA (Annahme: ABGR1555 wie PS1, RLE16 bei flags&0x80)."""
|
||||
"""16-bit page -> numpy RGBA (assumption: ABGR1555 like PS1, RLE16 if flags&0x80)."""
|
||||
import numpy as np
|
||||
w, h = p['w'], p['h']
|
||||
if p['flags'] == 0:
|
||||
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # Graustufen-Notloesung
|
||||
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # greyscale stopgap
|
||||
elif p['comp']:
|
||||
v = np.frombuffer(p['raw'], '<u2'); out = np.zeros(w * h, np.uint16)
|
||||
i = q = 0
|
||||
@@ -388,17 +388,17 @@ def page_rgba(p):
|
||||
|
||||
|
||||
def tex_uv(t, pw=256, ph=256):
|
||||
"""UV-Rechteck (U0,U1,V0,V1) wie FUN_00406880 (u1/v1 inklusiv -> +1)."""
|
||||
"""UV rectangle (U0,U1,V0,V1) like FUN_00406880 (u1/v1 inclusive -> +1)."""
|
||||
return (t['u0'] / pw, (t['u1'] + 1) / pw, t['v0'] / ph, (t['v1'] + 1) / ph)
|
||||
|
||||
|
||||
def face_uvs(flags, rect):
|
||||
"""Pro Face-Ecke (v0,v1,v2) die UV aus dem Texturrechteck, nach FUN_00556510.
|
||||
"""Per face corner (v0,v1,v2) the UV from the texture rectangle, per FUN_00556510.
|
||||
|
||||
Bit 0x20 spiegelt U; Bit 0x800 + Bit 0x10 waehlen die Dreieckshaelfte des Vierecks."""
|
||||
Bit 0x20 mirrors U; bit 0x800 + bit 0x10 select the triangle half of the quad."""
|
||||
U0, U1, V0, V1 = rect
|
||||
m = bool(flags & 0x20)
|
||||
a, b = (U0, U1) if m else (U1, U0) # a = "rechts" ohne Spiegelung
|
||||
a, b = (U0, U1) if m else (U1, U0) # a = "right" without mirroring
|
||||
if not (flags & 0x800):
|
||||
return [(a, V1), (b, V1), (a, V0)]
|
||||
if not (flags & 0x10):
|
||||
@@ -406,7 +406,7 @@ def face_uvs(flags, rect):
|
||||
return [(b, V0), (a, V0), (a, V1)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Welt zusammensetzen
|
||||
# ---------------------------------------------------------------- assemble the world
|
||||
|
||||
def tile_xform(x, y, z, rot):
|
||||
a = rot / 4096 * 2 * math.pi
|
||||
|
||||
+22
-22
@@ -1,19 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Mods für "Ein Königreich für ein Lama" ein- und ausschalten.
|
||||
# Turn mods for "Ein Königreich für ein Lama" on and off.
|
||||
#
|
||||
# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren
|
||||
# lama-mod aus Originalzustand (L: = unveränderte CD-Daten)
|
||||
# lama-mod an|on enable the mods in ~/.wine-lama/mods/*/
|
||||
# lama-mod aus|off original state (L: = unmodified CD data)
|
||||
# lama-mod status
|
||||
# lama-mod debug an Debug-Ansicht (Kollision/unsichtbare Objekte) zuschalten
|
||||
# lama-mod debug aus Debug-Ansicht abschalten (andere Mods bleiben, wie sie sind)
|
||||
# lama-mod debug an enable the debug view (collision/invisible objects)
|
||||
# lama-mod debug aus disable the debug view (other mods stay as they are)
|
||||
#
|
||||
# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter
|
||||
# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
|
||||
# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben
|
||||
# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige.
|
||||
# Mods mit einer Datei ".aus" im Ordner werden übersprungen (so schaltet
|
||||
# "lama-mod debug an|aus" die Mod "debug" unabhängig von den anderen).
|
||||
# Reihenfolge: alphabetisch, spätere Mods gewinnen (testlevel > debug).
|
||||
# A mod is a folder whose files are laid out like on the CD under
|
||||
# Setup/Eng/Cd (e.g. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
|
||||
# Activation uses an overlay of symlinks: original files stay untouched,
|
||||
# mod files are added or replace files of the same name.
|
||||
# Mods with a file ".aus" ("off") in their folder are skipped (this is how
|
||||
# "lama-mod debug an|aus" toggles the "debug" mod independently of the others).
|
||||
# Order: alphabetical, later mods win (testlevel > debug).
|
||||
set -e
|
||||
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}"
|
||||
MODS="$WINEPREFIX/mods"
|
||||
@@ -34,12 +34,12 @@ overlay_an() {
|
||||
for m in "$MODS"/*/; do
|
||||
[ -d "$m" ] || continue
|
||||
if [ -e "$m/.aus" ]; then
|
||||
echo "Mod: $(basename "$m") (abgeschaltet)"
|
||||
echo "Mod: $(basename "$m") (disabled)"
|
||||
continue
|
||||
fi
|
||||
echo "Mod: $(basename "$m")"
|
||||
(cd "$m" && find . -path ./src -prune -o -name .aus -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do
|
||||
# vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen
|
||||
# replace an existing file of the same name (case-insensitive)
|
||||
dir=$(dirname "$f"); name=$(basename "$f")
|
||||
mkdir -p "$OVL/$dir"
|
||||
find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} +
|
||||
@@ -48,7 +48,7 @@ overlay_an() {
|
||||
done
|
||||
done
|
||||
ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:"
|
||||
echo "Mods aktiv (L: -> $OVL)"
|
||||
echo "Mods active (L: -> $OVL)"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
@@ -58,27 +58,27 @@ an|on)
|
||||
aus|off)
|
||||
ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:"
|
||||
rm -rf "$OVL"
|
||||
echo "Originalzustand (L: -> $BASE)"
|
||||
echo "Original state (L: -> $BASE)"
|
||||
;;
|
||||
debug)
|
||||
if [ ! -d "$DBG/wads" ]; then
|
||||
echo "Debug-Mod fehlt ($DBG/wads)." >&2; exit 1
|
||||
echo "Debug mod missing ($DBG/wads)." >&2; exit 1
|
||||
fi
|
||||
case "${2:-status}" in
|
||||
an|on)
|
||||
rm -f "$DBG/.aus"
|
||||
LEISE=1 overlay_an
|
||||
echo "Debug-Ansicht AN ($(ls "$DBG/wads" | wc -l) Level). Wirkt beim nächsten Laden eines Levels."
|
||||
echo "Debug view ON ($(ls "$DBG/wads" | wc -l) levels). Takes effect the next time a level is loaded."
|
||||
;;
|
||||
aus|off)
|
||||
touch "$DBG/.aus"
|
||||
if [ "$(readlink "$WINEPREFIX/dosdevices/l:")" = "$OVL" ]; then
|
||||
LEISE=1 overlay_an
|
||||
fi
|
||||
echo "Debug-Ansicht AUS."
|
||||
echo "Debug view OFF."
|
||||
;;
|
||||
status)
|
||||
if [ -e "$DBG/.aus" ]; then echo "Debug-Ansicht: aus"; else echo "Debug-Ansicht: an (wenn Mods aktiv)"; fi
|
||||
if [ -e "$DBG/.aus" ]; then echo "Debug view: off"; else echo "Debug view: on (if mods are active)"; fi
|
||||
;;
|
||||
*)
|
||||
sed -n '2,9p' "$0"; exit 1 ;;
|
||||
@@ -88,8 +88,8 @@ status)
|
||||
echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")"
|
||||
for m in "$MODS"/*/; do
|
||||
[ -d "$m" ] || continue
|
||||
if [ -e "$m/.aus" ]; then s=" (abgeschaltet)"; else s=""; fi
|
||||
echo "installierte Mod: $(basename "$m")$s"
|
||||
if [ -e "$m/.aus" ]; then s=" (disabled)"; else s=""; fi
|
||||
echo "installed mod: $(basename "$m")$s"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
|
||||
+129
-126
@@ -1,34 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lamatest - unbeaufsichtigter Test-Harness fuer "Ein Koenigreich fuer ein Lama".
|
||||
"""lamatest - unattended test harness for "Ein Koenigreich fuer ein Lama".
|
||||
|
||||
Startet groove.exe (Wine) unsichtbar auf Xvfb :5 mit Null-Audio-Sink, laedt den
|
||||
TestLevel (Welt 9, Kapitel 1) ueber Spielstand-Slot 3 und macht Screenshots.
|
||||
Starts groove.exe (Wine) invisibly on Xvfb :5 with a null audio sink, loads the
|
||||
TestLevel (world 9, chapter 1) via save slot 3 and takes screenshots.
|
||||
|
||||
lamatest.py start Xvfb + Sink + Spiel, wartet aufs Fenster
|
||||
lamatest.py shot out.png Screenshot (1920x1080) von :5
|
||||
lamatest.py key <key> [hold] Taste halten (xdotool-Name, Default 0.12 s)
|
||||
lamatest.py keys <k1> <k2> ... mehrere Tasten nacheinander (k[:hold])
|
||||
lamatest.py seq <outdir> "TOKENS" Tastenfolge wie run --seq im laufenden Spiel (shot:/fp:/rj: ...)
|
||||
lamatest.py tolevel Demo -> Hauptmenue -> Slot 3 -> TestLevel 1
|
||||
lamatest.py tour <outdir> [n] Rundum-Fotos: drehen/laufen/Kamera
|
||||
lamatest.py status laeuft was? Fenster? Prozesse?
|
||||
lamatest.py stop Spiel beenden, Sink entladen, Xvfb killen
|
||||
lamatest.py run <wad> <outdir> [n] WAD als T1L9M001.wad installieren, starten,
|
||||
tolevel, tour, Crash-Erkennung, Log sichern
|
||||
lamatest.py restore-wad Original-T1L9M001.wad (.orig) zurueck
|
||||
run-Optionen: --seq "Up:1.5 x shot:name sleep=1 ..." statt Tour eine Tastenfolge;
|
||||
shot:NAME = Foto NAME.jpg, fp:NAME = Foto in Lama-Sicht (Return gehalten),
|
||||
dialog = Dialog wegklicken, keys wie bei 'keys' (Taste[:Haltezeit]),
|
||||
Up+x:0.8 = Tasten gleichzeitig, rj:T1:T2 = Anlauf-Sprung (Up halten,
|
||||
nach T1 s X, dann noch T2 s Up),
|
||||
run:T:x@1.3,shot@5,Left@3-3.4 = Up T s halten, X/Foto/Taste nach Zeitplan
|
||||
(run/Left:T:... haelt eine andere Taste)
|
||||
--tour zusaetzlich zur --seq auch die Standard-Tour
|
||||
--keep getestete WAD installiert lassen (sonst wird die vorher
|
||||
installierte T1L9M001.wad wiederhergestellt - nicht .orig!)
|
||||
lamatest.py start Xvfb + sink + game, waits for the window
|
||||
lamatest.py shot out.png screenshot (1920x1080) of :5
|
||||
lamatest.py key <key> [hold] hold a key (xdotool name, default 0.12 s)
|
||||
lamatest.py keys <k1> <k2> ... several keys one after another (k[:hold])
|
||||
lamatest.py seq <outdir> "TOKENS" key sequence like run --seq in the running game (shot:/fp:/rj: ...)
|
||||
lamatest.py tolevel demo -> main menu -> slot 3 -> TestLevel 1
|
||||
lamatest.py tour <outdir> [n] all-round photos: turn/walk/camera
|
||||
lamatest.py status anything running? window? processes?
|
||||
lamatest.py stop quit the game, unload the sink, kill Xvfb
|
||||
lamatest.py run <wad> <outdir> [n] install WAD as T1L9M001.wad, start,
|
||||
tolevel, tour, crash detection, save the log
|
||||
lamatest.py restore-wad restore the original T1L9M001.wad (.orig)
|
||||
run options: --seq "Up:1.5 x shot:name sleep=1 ..." a key sequence instead of the tour;
|
||||
shot:NAME = photo NAME.jpg, fp:NAME = photo in llama view (Return held),
|
||||
dialog = click dialog away, keys as with 'keys' (key[:hold time]),
|
||||
Up+x:0.8 = keys simultaneously, rj:T1:T2 = running jump (hold Up,
|
||||
X after T1 s, then Up for another T2 s),
|
||||
run:T:x@1.3,shot@5,Left@3-3.4 = hold Up for T s, X/photo/key on a schedule
|
||||
(run/Left:T:... holds a different key)
|
||||
--tour run the standard tour in addition to --seq
|
||||
--keep leave the tested WAD installed (otherwise the previously
|
||||
installed T1L9M001.wad is restored - not .orig!)
|
||||
|
||||
Zustand/Logs: $LAMATEST_DIR (Default /tmp/lamatest-$USER).
|
||||
Doku: ../notes/testharness.md
|
||||
State/logs: $LAMATEST_DIR (default /tmp/lamatest-$USER).
|
||||
Report lines (report.txt, "ergebnis: ...") and `status` output stay German: helper
|
||||
scripts grep for them. OCR expectations are German game text.
|
||||
Docs: ../notes/testharness.md
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
@@ -62,7 +64,7 @@ def log(*a):
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
log("FEHLER:", msg)
|
||||
log("ERROR:", msg)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
@@ -85,9 +87,9 @@ def read_pid(name):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Prozesse
|
||||
# ---------------------------------------------------------------- processes
|
||||
def groove_procs():
|
||||
"""Alle laufenden groove.exe-Prozesse: [(pid, environ-DISPLAY)]."""
|
||||
"""All running groove.exe processes: [(pid, environ DISPLAY)]."""
|
||||
out = []
|
||||
for p in os.listdir("/proc"):
|
||||
if not p.isdigit():
|
||||
@@ -114,7 +116,7 @@ def foreign_groove():
|
||||
|
||||
|
||||
def xdo(*args):
|
||||
# Sicherheitsnetz: xdotool immer nur gegen :5
|
||||
# safety net: xdotool only ever against :5
|
||||
assert ENV["DISPLAY"] == DISPLAY
|
||||
return sh(["xdotool"] + list(args))
|
||||
|
||||
@@ -130,7 +132,8 @@ def game_alive():
|
||||
|
||||
|
||||
def error_dialog():
|
||||
"""Wine-Fehlerdialog / Absturzfenster auf :5? Liefert Fenstertitel oder None."""
|
||||
"""Wine error dialog / crash window on :5? Returns the window title or None
|
||||
(matches English and German Wine dialog titles)."""
|
||||
r = xdo("search", "--name", ".")
|
||||
titles = []
|
||||
for wid in r.stdout.split():
|
||||
@@ -145,7 +148,7 @@ def error_dialog():
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Spielstaende
|
||||
# ---------------------------------------------------------------- save games
|
||||
def ksv_files():
|
||||
return sorted(f for f in os.listdir(GAMEDIR) if f.lower().endswith(".ksv"))
|
||||
|
||||
@@ -162,11 +165,11 @@ def restore_saves():
|
||||
for f in os.listdir(SAVEBAK):
|
||||
src, dst = os.path.join(SAVEBAK, f), os.path.join(GAMEDIR, f)
|
||||
if not os.path.exists(dst) or md5(src) != md5(dst):
|
||||
log("Spielstand %s wurde veraendert -> wiederhergestellt" % f)
|
||||
log("save game %s was modified -> restored" % f)
|
||||
shutil.copy2(src, dst)
|
||||
for f in ksv_files():
|
||||
if not os.path.exists(os.path.join(SAVEBAK, f)):
|
||||
log("neuer Spielstand %s vom Test -> entfernt" % f)
|
||||
log("new save game %s from the test -> removed" % f)
|
||||
os.remove(os.path.join(GAMEDIR, f))
|
||||
|
||||
|
||||
@@ -177,35 +180,35 @@ def md5(p):
|
||||
def check_slot3():
|
||||
p = os.path.join(GAMEDIR, "(null)king2.ksv")
|
||||
if not os.path.exists(p):
|
||||
die("Slot 3 (%s) fehlt" % p)
|
||||
die("slot 3 (%s) missing" % p)
|
||||
b = open(p, "rb").read()
|
||||
# Kartencursor (+4/+5) bestimmt, was auf der Auswahlkarte vorgewaehlt ist
|
||||
# map cursor (+4/+5) decides what is preselected on the selection map
|
||||
if len(b) != 256 or b[4] != 9 or b[5] != 1:
|
||||
log("WARNUNG: Slot 3: Kartencursor nicht auf Welt 9/Kapitel 1 (Cursor %d/%d, "
|
||||
"aktuell %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
|
||||
log("WARNING: slot 3: map cursor not on world 9/chapter 1 (cursor %d/%d, "
|
||||
"current %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Kommandos
|
||||
# ---------------------------------------------------------------- commands
|
||||
def cmd_start(timeout=60):
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
f = foreign_groove()
|
||||
if f:
|
||||
die("Elias' eigene Spielinstanz laeuft (pid %s) - Test abgebrochen" % f)
|
||||
die("Elias' own game instance is running (pid %s) - test aborted" % f)
|
||||
if game_alive():
|
||||
log("Spiel laeuft schon auf %s" % DISPLAY)
|
||||
log("game already running on %s" % DISPLAY)
|
||||
return
|
||||
if not os.path.isdir(OVERLAY) or not os.path.realpath(
|
||||
os.path.join(PREFIX, "dosdevices", "l:")) == os.path.realpath(OVERLAY):
|
||||
die("Mod ist nicht aktiv (lama-mod an)")
|
||||
die("mod is not active (lama-mod an)")
|
||||
check_slot3()
|
||||
backup_saves()
|
||||
# Xvfb
|
||||
if not read_pid("xvfb"):
|
||||
if os.path.exists("/tmp/.X11-unix/X5") or os.path.exists("/tmp/.X5-lock"):
|
||||
if sh(["xdpyinfo"]).returncode == 0:
|
||||
log("fremder X-Server auf :5 laeuft bereits - wird mitbenutzt")
|
||||
log("foreign X server already running on :5 - sharing it")
|
||||
else:
|
||||
die("/tmp/.X5-lock existiert, aber :5 antwortet nicht")
|
||||
die("/tmp/.X5-lock exists, but :5 does not respond")
|
||||
else:
|
||||
xl = open(XVFBLOG, "w")
|
||||
p = subprocess.Popen(["Xvfb", DISPLAY, "-screen", "0", "1920x1200x24",
|
||||
@@ -217,16 +220,16 @@ def cmd_start(timeout=60):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
die("Xvfb startet nicht (%s)" % XVFBLOG)
|
||||
# Null-Sink (nie die App stummschalten!)
|
||||
die("Xvfb does not start (%s)" % XVFBLOG)
|
||||
# null sink (never mute the app!)
|
||||
if not os.path.exists(pidfile("sink")):
|
||||
r = sh(["pactl", "load-module", "module-null-sink", "sink_name=" + SINK,
|
||||
"sink_properties=device.description=lama_stumm"])
|
||||
if r.returncode == 0:
|
||||
open(pidfile("sink"), "w").write(r.stdout.strip())
|
||||
else:
|
||||
log("WARNUNG: Null-Sink nicht ladbar:", r.stderr.strip())
|
||||
# Spiel
|
||||
log("WARNING: cannot load null sink:", r.stderr.strip())
|
||||
# game
|
||||
wl = open(WINELOG, "w")
|
||||
p = subprocess.Popen(["wine", GAMEEXE], cwd=GAMEDIR, env=ENV, stdout=wl,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
@@ -234,12 +237,12 @@ def cmd_start(timeout=60):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if find_window():
|
||||
log("Fenster da nach %.1f s" % (time.time() - t0))
|
||||
log("window up after %.1f s" % (time.time() - t0))
|
||||
return
|
||||
if p.poll() is not None and not game_alive():
|
||||
die("wine beendet sich sofort (Log: %s)" % WINELOG)
|
||||
die("wine exits immediately (log: %s)" % WINELOG)
|
||||
time.sleep(0.5)
|
||||
die("kein Groove-Fenster nach %d s" % timeout)
|
||||
die("no Groove window after %d s" % timeout)
|
||||
|
||||
|
||||
def shot(path):
|
||||
@@ -253,8 +256,8 @@ def shot(path):
|
||||
|
||||
|
||||
def small_shot(path):
|
||||
"""Kleiner Screenshot (960x540 JPEG) fuer Serien - Platte ist knapp.
|
||||
Mit LAMATEST_SHOT=1280 (Umgebung) 1280x720 / Qualitaet 85 (Vorschaubilder)."""
|
||||
"""Small screenshot (960x540 JPEG) for series - disk space is tight.
|
||||
With LAMATEST_SHOT=1280 (environment) 1280x720 / quality 85 (preview images)."""
|
||||
w = os.environ.get("LAMATEST_SHOT")
|
||||
scale, q = ("%sx" % w, "85") if w else ("50%", "80")
|
||||
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
||||
@@ -267,7 +270,7 @@ def small_shot(path):
|
||||
def key(k, hold=0.12):
|
||||
wid = find_window()
|
||||
if not wid:
|
||||
die("kein Groove-Fenster")
|
||||
die("no Groove window")
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
xdo("keydown", k)
|
||||
time.sleep(float(hold))
|
||||
@@ -284,7 +287,7 @@ def keys(seq, gap=0.35):
|
||||
time.sleep(gap)
|
||||
|
||||
|
||||
# Bildvergleich ueber kleine Graustufen-Thumbnails (nur ImageMagick noetig)
|
||||
# image comparison via small greyscale thumbnails (only ImageMagick needed)
|
||||
def thumb(path=None):
|
||||
src = ["import", "-display", DISPLAY, "-window", "root", "-crop", "1920x1080+0+0",
|
||||
"+repage"] if path is None else ["convert", path]
|
||||
@@ -304,7 +307,7 @@ def mean(t):
|
||||
|
||||
|
||||
def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
|
||||
"""Warten bis sich das Bild `still` Sekunden kaum aendert."""
|
||||
"""Wait until the image barely changes for `still` seconds."""
|
||||
t0 = time.time()
|
||||
time.sleep(min_time)
|
||||
last, since = thumb(), time.time()
|
||||
@@ -320,9 +323,9 @@ def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Bilderkennung
|
||||
# ---------------------------------------------------------------- image recognition
|
||||
def rgb_thumb():
|
||||
"""48x27 RGB-Thumbnail des aktuellen Bildes (Liste von (r,g,b)-Zeilen)."""
|
||||
"""48x27 RGB thumbnail of the current image (list of rows of (r,g,b))."""
|
||||
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
||||
"1920x1080+0+0", "+repage", "-resize", "48x27!", "-depth", "8",
|
||||
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
|
||||
@@ -339,18 +342,18 @@ def _rows_mean(t, ys):
|
||||
|
||||
|
||||
def classify(t=None):
|
||||
"""Grobe Bildklasse: black / letterbox / map / bright (Menue oder Spiel)."""
|
||||
"""Rough image class: black / letterbox / map / bright (menu or game)."""
|
||||
t = t or rgb_thumb()
|
||||
if t is None:
|
||||
return "none"
|
||||
allm = _rows_mean(t, range(27))
|
||||
if allm < 6:
|
||||
return "black"
|
||||
# nur oberste/unterste Thumbnail-Zeile pruefen: Untertitel liegen im unteren Balken
|
||||
# only check the top/bottom thumbnail row: subtitles sit in the lower bar
|
||||
top, bot, mid = _rows_mean(t, (0,)), _rows_mean(t, (26,)), _rows_mean(t, range(6, 18))
|
||||
if top < 4 and bot < 4 and mid > 15:
|
||||
return "letterbox" # Kamera-Flug / Dialog-Zwischensequenz
|
||||
# Auswahlkarte Welt 9: fast einfarbig beige (~205,175,110)
|
||||
return "letterbox" # camera fly-by / dialogue cutscene
|
||||
# selection map world 9: almost uniformly beige (~205,175,110)
|
||||
beige = sum(1 for row in t for (r, g, b) in row
|
||||
if 180 < r < 230 and 150 < g < 200 and 85 < b < 140 and r > g > b)
|
||||
if beige > 0.75 * 48 * 27:
|
||||
@@ -359,7 +362,7 @@ def classify(t=None):
|
||||
|
||||
|
||||
def hud_visible():
|
||||
"""Goldene Energieleiste/Sonnenkrone oben links (Gameplay-HUD)?"""
|
||||
"""Golden energy bar/sun crown at the top left (gameplay HUD)?"""
|
||||
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
||||
"900x110+100+10", "+repage", "-resize", "25%", "-depth", "8",
|
||||
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
|
||||
@@ -370,8 +373,8 @@ def hud_visible():
|
||||
|
||||
|
||||
def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
|
||||
"""Text auf dem Bildschirm (tesseract, deu), klein geschrieben.
|
||||
threshold (Prozent): nur sehr helle Schrift behalten (z. B. markierter Menuepunkt)."""
|
||||
"""Text on screen (tesseract, deu), lower-cased.
|
||||
threshold (percent): keep only very bright text (e.g. the highlighted menu item)."""
|
||||
filt = ["-threshold", "%d%%" % threshold] if threshold else []
|
||||
raw = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
||||
crop, "+repage", "ppm:-"], stdout=subprocess.PIPE, env=ENV).stdout
|
||||
@@ -384,7 +387,7 @@ def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
|
||||
|
||||
|
||||
def crashed():
|
||||
"""None oder Grund-String, wenn das Spiel abgestuerzt ist."""
|
||||
"""None, or a reason string if the game has crashed (goes into report.txt)."""
|
||||
if not game_alive():
|
||||
return "groove.exe-Prozess weg"
|
||||
e = error_dialog()
|
||||
@@ -416,16 +419,16 @@ class Timeout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
SLOT_CROP = "1600x280+160+740" # Textfeld "Gespeichertes Spiel N / Level Kapitel"
|
||||
SLOT_CROP = "1600x280+160+740" # text field "Gespeichertes Spiel N / Level Kapitel" (German game text)
|
||||
|
||||
|
||||
def slot_text():
|
||||
# zwei Schwellen kombinieren - der Hintergrund flimmert, OCR schwankt
|
||||
# combine two thresholds - the background flickers, OCR fluctuates
|
||||
return ocr(SLOT_CROP, threshold=70, psm=6) + " | " + ocr(SLOT_CROP, threshold=82, psm=6)
|
||||
|
||||
|
||||
def fuzzy_in(word, text, ratio=0.75):
|
||||
"""word (evtl. mehrere Woerter) unscharf in text enthalten?"""
|
||||
"""Is word (possibly several words) fuzzily contained in text?"""
|
||||
import difflib
|
||||
if word in text:
|
||||
return True
|
||||
@@ -446,63 +449,63 @@ def _has(*words):
|
||||
|
||||
|
||||
def tolevel(skip_flyby=True, log_times=None):
|
||||
"""Hauptmenue -> Spiel starten -> Slot mit TestLevel -> Kapitel 1 -> Gameplay."""
|
||||
"""Main menu -> "Spiel starten" -> slot with TestLevel -> chapter 1 -> gameplay."""
|
||||
T = log_times if log_times is not None else {}
|
||||
t0 = time.time()
|
||||
# 1) Hauptmenue (Logo ~2 s, Menue nach ~5-6 s). Kommt stattdessen die Demo,
|
||||
# bringt Return zurueck ins Menue.
|
||||
# 1) main menu (logo ~2 s, menu after ~5-6 s). If the demo comes instead,
|
||||
# Return brings you back to the menu.
|
||||
tx = time.time()
|
||||
while True:
|
||||
c = crashed()
|
||||
if c:
|
||||
raise Crash(c)
|
||||
# Hauptmenue: Menuepunkte rechts unten; bei 75 % Schwelle bleibt nur der
|
||||
# markierte (helle) Punkt lesbar -> prueft zugleich die Cursorposition.
|
||||
# main menu: menu items at the bottom right; at a 75 % threshold only the
|
||||
# highlighted (bright) item stays readable -> also checks the cursor position.
|
||||
txt = ocr("700x300+1150+650", threshold=75, psm=6, scale="100%")
|
||||
if fuzzy_in("spiel starten", txt):
|
||||
log("Hauptmenue erkannt")
|
||||
log("main menu detected")
|
||||
time.sleep(0.5)
|
||||
key("Return")
|
||||
break
|
||||
if fuzzy_in("gespeichertes", slot_text()):
|
||||
break # schon im Slot-Bildschirm
|
||||
break # already on the slot screen
|
||||
if time.time() - tx > 40:
|
||||
log("kein Menue erkannt -> Return (Demo/Logo ueberspringen)")
|
||||
log("no menu detected -> Return (skip demo/logo)")
|
||||
key("Return")
|
||||
tx = time.time()
|
||||
if time.time() - t0 > 90:
|
||||
raise Timeout("Hauptmenue nicht erreicht")
|
||||
time.sleep(1)
|
||||
T["menu"] = round(time.time() - t0, 1)
|
||||
# 2) Spielstandauswahl: ganz nach links (kein Wrap), dann den Slot mit TestLevel suchen
|
||||
# 2) save game selection: all the way left (no wrap), then find the slot with TestLevel
|
||||
wait_for(_has("gespeichertes"), 20, "Spielstandauswahl")
|
||||
time.sleep(0.4)
|
||||
# Dreier-Slider ohne Wrap: Slot 3 ist ganz rechts, weiteres Right bleibt dort.
|
||||
# Also einfach 2x Right (egal wo man startet) und einmal per OCR pruefen.
|
||||
# three-slot slider without wrap: slot 3 is at the far right, further Right stays there.
|
||||
# So just press Right twice (wherever you start) and check once via OCR.
|
||||
def _is_test():
|
||||
return (fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=70, psm=6))
|
||||
or fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=82, psm=6)))
|
||||
for _ in range(2):
|
||||
key("Right", 0.3) # 0.3 s halten: bei niedriger Bildrate gehen kurze Druecke verloren
|
||||
key("Right", 0.3) # hold 0.3 s: short presses get lost at low frame rates
|
||||
time.sleep(0.3)
|
||||
for i in range(3): # unter Last kann ein Druck verloren gehen -> noch einmal Right
|
||||
for i in range(3): # under load a press can get lost -> Right once more
|
||||
time.sleep(0.6)
|
||||
if _is_test():
|
||||
log("Slot 3: TestLevel gefunden (Versuch %d)" % (i + 1))
|
||||
log("slot 3: TestLevel found (attempt %d)" % (i + 1))
|
||||
break
|
||||
key("Right", 0.3)
|
||||
else:
|
||||
raise Timeout("kein Spielstand mit TestLevel gefunden (Slot 3 kaputt?)")
|
||||
key("Return")
|
||||
T["slot"] = round(time.time() - t0, 1)
|
||||
# 3) Auswahlkarte
|
||||
# 3) selection map
|
||||
wait_for(lambda: classify() == "map", 30, "Auswahlkarte")
|
||||
time.sleep(1.0)
|
||||
# Vorauswahl auf der Karte = Kartencursor im KSV (+4/+5), geprueft in check_slot3()
|
||||
# preselection on the map = map cursor in the KSV (+4/+5), checked in check_slot3()
|
||||
key("Return")
|
||||
t_load = time.time()
|
||||
T["map"] = round(t_load - t0, 1)
|
||||
# 4) Laden -> (Kamera-Flug im Letterbox) -> Gameplay
|
||||
# 4) loading -> (camera fly-by in letterbox) -> gameplay
|
||||
skipped = False
|
||||
good = 0
|
||||
states = []
|
||||
@@ -513,16 +516,16 @@ def tolevel(skip_flyby=True, log_times=None):
|
||||
st = classify()
|
||||
if not states or states[-1] != st:
|
||||
states.append(st)
|
||||
log("Zustand: %s (%.1f s nach Level-Start)" % (st, time.time() - t_load))
|
||||
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
|
||||
if st == "letterbox" and skip_flyby and not skipped:
|
||||
time.sleep(0.5)
|
||||
key("Return") # Kamera-Flug ueberspringen
|
||||
key("Return") # skip the camera fly-by
|
||||
skipped = True
|
||||
T["flyby"] = round(time.time() - t_load, 1)
|
||||
elif st == "letterbox" and skipped and time.time() - t_load > 15:
|
||||
key("x") # Dialog weiterklicken
|
||||
# Gameplay = helles Bild, kein Letterbox, und HUD sichtbar (oder nach
|
||||
# Kamera-Flug / spaetestens 25 s - falls das HUD schon ausgeblendet ist)
|
||||
key("x") # click through the dialogue
|
||||
# gameplay = bright image, no letterbox, and HUD visible (or after the
|
||||
# camera fly-by / after 25 s at the latest - in case the HUD has already faded out)
|
||||
el = time.time() - t_load
|
||||
if st == "bright" and el > 2 and (hud_visible() or "letterbox" in states or el > 25):
|
||||
good += 1
|
||||
@@ -536,7 +539,7 @@ def tolevel(skip_flyby=True, log_times=None):
|
||||
T["gameplay"] = round(time.time() - t0, 1)
|
||||
T["load"] = round(time.time() - t_load, 1)
|
||||
T["states"] = states
|
||||
log("Gameplay nach %.1f s (Laden %.1f s), Zustaende %s" % (T["gameplay"], T["load"], states))
|
||||
log("gameplay after %.1f s (loading %.1f s), states %s" % (T["gameplay"], T["load"], states))
|
||||
return T
|
||||
|
||||
|
||||
@@ -549,7 +552,7 @@ def cmd_tolevel():
|
||||
|
||||
|
||||
def settle_dialog(maxpress=12):
|
||||
"""Dialog-/Zwischensequenz (Letterbox) wegklicken."""
|
||||
"""Click away a dialogue/cutscene (letterbox)."""
|
||||
n = 0
|
||||
while classify() == "letterbox" and n < maxpress:
|
||||
key("x")
|
||||
@@ -559,8 +562,8 @@ def settle_dialog(maxpress=12):
|
||||
|
||||
|
||||
def tour(outdir, n=8, prefix="tour"):
|
||||
"""Fotoserie: n Bilder Kamera-Rundumblick (A), dann n Bilder Laufen+Drehen.
|
||||
Liefert Liste der Dateien. Bilder als 960x540-JPEG (Platz sparen)."""
|
||||
"""Photo series: n images of a camera pan (A), then n images walking+turning.
|
||||
Returns the list of files. Images as 960x540 JPEG (saves space)."""
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
files = []
|
||||
|
||||
@@ -573,13 +576,13 @@ def tour(outdir, n=8, prefix="tour"):
|
||||
files.append(f)
|
||||
|
||||
snap("start")
|
||||
# Kamera einmal rundum: 0.5 s A-Taste ~ 90 Grad
|
||||
# camera once all round: 0.5 s of the A key ~ 90 degrees
|
||||
hold = 2.0 / max(n, 1)
|
||||
for i in range(n):
|
||||
key("a", hold)
|
||||
time.sleep(0.7)
|
||||
snap("cam%d" % i)
|
||||
# Laufen: vorwaerts, zwischendurch drehen; Dialoge wegklicken
|
||||
# walking: forwards, turning in between; click dialogues away
|
||||
for i in range(n):
|
||||
key("Up", 1.2)
|
||||
time.sleep(0.4)
|
||||
@@ -589,7 +592,7 @@ def tour(outdir, n=8, prefix="tour"):
|
||||
snap("walk%d" % i)
|
||||
key("Left" if i % 3 != 2 else "Right", 0.45)
|
||||
time.sleep(0.3)
|
||||
# Lama-Sicht (Return gehalten) einmal pro Richtung
|
||||
# llama view (Return held) once per direction
|
||||
key("Return", 1.5)
|
||||
time.sleep(0.2)
|
||||
snap("lamasicht")
|
||||
@@ -600,7 +603,7 @@ def cmd_tour(outdir, n):
|
||||
try:
|
||||
fs = tour(outdir, n)
|
||||
except Crash as e:
|
||||
die("ABSTURZ: %s" % e, 3)
|
||||
die("CRASH: %s" % e, 3)
|
||||
print("\n".join(fs))
|
||||
|
||||
|
||||
@@ -610,31 +613,31 @@ def install_wad(wad):
|
||||
orig = MODWAD + ".orig"
|
||||
if not os.path.exists(orig):
|
||||
shutil.copy2(MODWAD, orig)
|
||||
log("Original gesichert: %s" % orig)
|
||||
log("original backed up: %s" % orig)
|
||||
if os.path.abspath(MODWAD) != wad:
|
||||
shutil.copyfile(wad, MODWAD)
|
||||
# Overlay zeigt per Symlink auf MODWAD -> neu bauen nur wenn Link fehlt/falsch
|
||||
# overlay points to MODWAD via symlink -> rebuild only if the link is missing/wrong
|
||||
link = os.path.join(OVERLAY, "wads", "T1L9M001.wad")
|
||||
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(MODWAD)):
|
||||
log("Overlay-Link fehlt -> lama-mod an")
|
||||
log("overlay link missing -> lama-mod an")
|
||||
sh([LAMA_MOD, "an"], check=True)
|
||||
others = [f for f in os.listdir(os.path.dirname(link))
|
||||
if f.lower() == "t1l9m001.wad" and f != "T1L9M001.wad"]
|
||||
if others:
|
||||
log("WARNUNG: weitere Schreibweise im Overlay: %s" % others)
|
||||
log("WAD installiert: %s (%d Bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
|
||||
log("WARNING: other spelling in the overlay: %s" % others)
|
||||
log("WAD installed: %s (%d bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
|
||||
|
||||
|
||||
def cmd_restore_wad():
|
||||
orig = MODWAD + ".orig"
|
||||
if not os.path.exists(orig):
|
||||
die("kein %s" % orig)
|
||||
die("no %s" % orig)
|
||||
shutil.copy2(orig, MODWAD)
|
||||
log("Original-T1L9M001.wad wiederhergestellt")
|
||||
log("original T1L9M001.wad restored")
|
||||
|
||||
|
||||
def run_seq(outdir, seq):
|
||||
"""Tastenfolge abarbeiten; 'shot:NAME' fotografiert. Liefert Bilddateien."""
|
||||
"""Process a key sequence; 'shot:NAME' takes a photo. Returns the image files."""
|
||||
files = []
|
||||
for item in seq.split():
|
||||
c = crashed()
|
||||
@@ -645,7 +648,7 @@ def run_seq(outdir, seq):
|
||||
small_shot(f)
|
||||
files.append(f)
|
||||
elif item.startswith("fp:"):
|
||||
# Lama-Sicht: Return halten, waehrenddessen fotografieren
|
||||
# llama view: hold Return, take the photo meanwhile
|
||||
wid = find_window()
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
xdo("keydown", "Return")
|
||||
@@ -656,7 +659,7 @@ def run_seq(outdir, seq):
|
||||
xdo("keyup", "Return")
|
||||
time.sleep(0.4)
|
||||
elif item.startswith("rj:"):
|
||||
# Anlauf-Sprung: Up halten, nach T1 s X tippen, Up noch T2 s halten
|
||||
# running jump: hold Up, tap X after T1 s, keep holding Up for T2 s
|
||||
_, t1, t2 = item.split(":")
|
||||
wid = find_window()
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
@@ -669,9 +672,9 @@ def run_seq(outdir, seq):
|
||||
xdo("keyup", "Up")
|
||||
time.sleep(0.3)
|
||||
elif item.startswith("run:") or item.startswith("run/"):
|
||||
# Durchlaufen mit Zeitplan: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
|
||||
# Up (bzw. bei run/Left:... die angegebene Taste) wird T s gehalten; zu den Zeiten (s ab Beginn) X tippen / Foto (seq_NN_runK) /
|
||||
# Taste von-bis halten. Fotos dauern ~0.2 s, der Zeitplan laeuft weiter (Echtzeit).
|
||||
# run through with a schedule: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
|
||||
# Up (or the given key for run/Left:...) is held for T s; at the given times (s from start) tap X / photo (seq_NN_runK) /
|
||||
# hold a key from-to. Photos take ~0.2 s, the schedule keeps running (real time).
|
||||
parts = item.split(":", 2)
|
||||
mk = parts[0][4:] or "Up"
|
||||
total, ev = float(parts[1]), []
|
||||
@@ -709,7 +712,7 @@ def run_seq(outdir, seq):
|
||||
xdo("keyup", arg)
|
||||
time.sleep(0.3)
|
||||
elif item.startswith("hop:"):
|
||||
# Up halten und N-mal alle T s springen (Fotos dazwischen: shot nach dem Token)
|
||||
# hold Up and jump N times every T s (photos in between: shot after the token)
|
||||
_, n, t = item.split(":")
|
||||
wid = find_window()
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
@@ -719,7 +722,7 @@ def run_seq(outdir, seq):
|
||||
xdo("keyup", "Up")
|
||||
time.sleep(0.3)
|
||||
elif item.startswith("dj:"):
|
||||
# Doppelsprung mit Anlauf: Up halten, X, nach 0.3 s nochmal X, Up noch T s
|
||||
# double jump with run-up: hold Up, X, X again after 0.3 s, Up for another T s
|
||||
t = float(item.split(":")[1])
|
||||
wid = find_window()
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
@@ -730,7 +733,7 @@ def run_seq(outdir, seq):
|
||||
xdo("keyup", "Up")
|
||||
time.sleep(0.3)
|
||||
elif "+" in item.split(":")[0]:
|
||||
# mehrere Tasten gleichzeitig: Up+x:0.8
|
||||
# several keys simultaneously: Up+x:0.8
|
||||
ks, _, h = item.partition(":")
|
||||
wid = find_window()
|
||||
xdo("windowfocus", "--sync", wid)
|
||||
@@ -753,8 +756,8 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
|
||||
rep = ["wad: %s" % os.path.abspath(wad)]
|
||||
status = 0
|
||||
if foreign_groove():
|
||||
die("Elias' eigene Spielinstanz laeuft - abgebrochen")
|
||||
# vorher installierte WAD merken -> danach zurueck (nicht .orig!)
|
||||
die("Elias' own game instance is running - aborted")
|
||||
# remember the previously installed WAD -> put it back afterwards (not .orig!)
|
||||
prev = os.path.join(STATE, "vorher_T1L9M001.wad")
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
if not keep and os.path.exists(MODWAD):
|
||||
@@ -775,7 +778,7 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
|
||||
if do_tour or (do_tour is None and not seq):
|
||||
fs += tour(outdir, n)
|
||||
rep.append("bilder: %d" % (len(fs) + 2))
|
||||
# eingefroren? (Kamera dreht sich -> aufeinanderfolgende Bilder muessen sich aendern)
|
||||
# frozen? (the camera turns -> consecutive images must differ)
|
||||
th = [thumb(f) for f in fs]
|
||||
dmax = max([diff(a, b) for a, b in zip(th, th[1:])] or [0])
|
||||
rep.append("bildaenderung max: %.1f" % dmax)
|
||||
@@ -808,12 +811,12 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
|
||||
cmd_stop()
|
||||
finally:
|
||||
if os.path.exists(WINELOG):
|
||||
# Log kuerzen: max. 2 MB behalten (Anfang + Ende)
|
||||
# truncate the log: keep at most 2 MB (start + end)
|
||||
data = open(WINELOG, "rb").read()
|
||||
if len(data) > 2_000_000:
|
||||
data = data[:1_000_000] + b"\n...[gekuerzt]...\n" + data[-1_000_000:]
|
||||
open(os.path.join(outdir, "wine.log"), "wb").write(data)
|
||||
# bekannte harmlose Meldungen (laufen auch bei funktionierendem Spiel)
|
||||
# known harmless messages (also appear when the game works fine)
|
||||
noise = ("com_get_class_object", "ChangeDisplaySettingsExW",
|
||||
"wined3d_restore_display_modes", "gstreamer", "MIDIMAP",
|
||||
"RestoreDisplayMode")
|
||||
@@ -844,7 +847,7 @@ def cmd_stop():
|
||||
pass
|
||||
time.sleep(1)
|
||||
if f:
|
||||
log("fremde Instanz laeuft (%s) - kein wineserver -k, nur eigene Prozesse" % f)
|
||||
log("foreign instance running (%s) - no wineserver -k, only own processes" % f)
|
||||
for p, d in groove_procs():
|
||||
if d == DISPLAY:
|
||||
os.kill(p, signal.SIGKILL)
|
||||
@@ -875,13 +878,13 @@ def cmd_stop():
|
||||
os.remove(pidfile("sink"))
|
||||
except OSError:
|
||||
pass
|
||||
# Sicherheitsnetz: Sink per Name entladen, falls Modulnummer verloren
|
||||
# safety net: unload the sink by name in case the module number got lost
|
||||
r = sh(["pactl", "list", "short", "modules"])
|
||||
for line in r.stdout.splitlines():
|
||||
if "sink_name=" + SINK in line:
|
||||
sh(["pactl", "unload-module", line.split()[0]])
|
||||
restore_saves()
|
||||
log("gestoppt")
|
||||
log("stopped")
|
||||
|
||||
|
||||
def cmd_status():
|
||||
@@ -911,7 +914,7 @@ def main(argv):
|
||||
keys(a)
|
||||
elif c == "tolevel":
|
||||
cmd_tolevel()
|
||||
elif c == "seq": # Tastenfolge wie run --seq, aber im laufenden Spiel: seq <outdir> "TOKENS"
|
||||
elif c == "seq": # key sequence like run --seq, but in the running game: seq <outdir> "TOKENS"
|
||||
os.makedirs(a[0], exist_ok=True)
|
||||
for f in run_seq(a[0], a[1]):
|
||||
print(f)
|
||||
@@ -933,7 +936,7 @@ def main(argv):
|
||||
sys.exit(cmd_run(pos[0], pos[1], int(pos[2]) if len(pos) > 2 else 8, seq, do_tour, keep))
|
||||
elif c == "restore-wad":
|
||||
cmd_restore_wad()
|
||||
elif c == "mean": # Debug: Helligkeit/Diff
|
||||
elif c == "mean": # debug: brightness/diff
|
||||
print(mean(thumb(a[0] if a else None)))
|
||||
else:
|
||||
print(__doc__)
|
||||
|
||||
+109
-108
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""levelbuild - eigene Level fuer "Ein Koenigreich fuer ein Lama" bauen (Croc-2-Engine, PC).
|
||||
"""levelbuild - build custom levels for "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
|
||||
|
||||
Ein Level wird als Python-Skript beschrieben, das eine Funktion ``build(L)`` enthaelt.
|
||||
``L`` ist ein :class:`Level` mit Grundkoerpern (Boxen, Plattformen, Rampen, Treppen, ...),
|
||||
Texturen per Name, automatischer Vertexfarben-Beleuchtung (Sonne, Himmel, Schatten,
|
||||
Umgebungsverdeckung) und Objekt-Platzierung (Strats per Typname, Parameter vom Original).
|
||||
A level is described as a Python script containing a function ``build(L)``.
|
||||
``L`` is a :class:`Level` with primitives (boxes, platforms, ramps, stairs, ...),
|
||||
textures by name, automatic vertex colour lighting (sun, sky, shadows,
|
||||
ambient occlusion) and object placement (strats by type name, parameters from the original).
|
||||
|
||||
levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info]
|
||||
levelbuild.py --types [--base ...] platzierbare Strat-Typen des Basislevels
|
||||
levelbuild.py --textures OUT.png Kontaktabzug der Texturen (mit Namen/Index)
|
||||
levelbuild.py --types [--base ...] placeable strat types of the base level
|
||||
levelbuild.py --textures OUT.png texture contact sheet (with names/indices)
|
||||
|
||||
Koordinaten (wie im Spiel / bei Strats): x nach Osten, y nach oben, z = Rasterzeile
|
||||
(positiv). 1 Einheit = 1 Rasterzelle. Das Raster des Basislevels ist 96x96 (x,z in 0..96).
|
||||
Winkel in Grad (0..360) um die y-Achse.
|
||||
Doku: ../notes/levelbau.md
|
||||
Coordinates (as in the game / for strats): x east, y up, z = grid row
|
||||
(positive). 1 unit = 1 grid cell. The base level's grid is 96x96 (x,z in 0..96).
|
||||
Angles in degrees (0..360) about the y axis.
|
||||
Texture names (TEX/FLAT below) are German keys and part of the level script API.
|
||||
Docs: ../notes/levelbau.md
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
@@ -38,7 +39,7 @@ BASE = os.path.join(WADS, 't1l1m001.wad')
|
||||
MODWAD = os.path.expanduser('~/.wine-lama/mods/testlevel/wads/T1L9M001.wad')
|
||||
FX = 4096
|
||||
|
||||
# Texturnamen fuer das Dorf (t1l1m001). Kontaktabzug: notes/img/texturen_dorf.png
|
||||
# texture names for the village (t1l1m001). Contact sheet: notes/img/texturen_dorf.png
|
||||
TEX = {
|
||||
'gras': 1, 'gras2': 170, 'gras_hell': 190, 'gras_streifen': 212, 'gras_dunkel': 157,
|
||||
'gras_fleck': 99, 'blumen': 87, 'blumen2': 81, 'blumen3': 82,
|
||||
@@ -53,14 +54,14 @@ TEX = {
|
||||
'inka': 131, 'muster_lila': 202, 'muster_lila2': 208, 'muster_lila3': 200,
|
||||
'pilz_rot': 136, 'pilz_rot2': 137, 'treppe_stein': 63,
|
||||
}
|
||||
# zusaetzliche Flachfarben (werden an die Texturtabelle angehaengt): Name -> (R, G, B)
|
||||
# additional flat colours (appended to the texture table): name -> (R, G, B)
|
||||
FLAT = {
|
||||
'meer': (40, 120, 150), 'meer_tief': (25, 85, 120), 'weiss': (235, 235, 225),
|
||||
'rot': (190, 50, 40), 'gelb': (230, 190, 60), 'blau': (60, 90, 190), 'schwarz': (20, 20, 20),
|
||||
'gruen': (70, 140, 50), 'grau': (120, 120, 120),
|
||||
}
|
||||
# Strat-Typen, die im Dorf-Code stehen, aber im Dorf nicht platziert sind:
|
||||
# Typfelder (nvars, Slots, Flags, Zusatz) + Parameter aus einem anderen Level uebernehmen
|
||||
# strat types present in the village code but not placed in the village:
|
||||
# take type fields (nvars, slots, flags, extra) + parameters from another level
|
||||
FOREIGN = {'RubyIdol': 't1l1m002.wad'}
|
||||
|
||||
|
||||
@@ -68,14 +69,14 @@ def deg(a):
|
||||
return int(round((a % 360) / 360 * 4096)) & 0xfff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Geometrie-Sammelbehaelter
|
||||
# ---------------------------------------------------------------- geometry containers
|
||||
class Quad:
|
||||
__slots__ = ('p', 'tex', 'coll', 'mat', 'tint', 'shade', 'double', 'render')
|
||||
|
||||
def __init__(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), shade=True, double=False, render=True):
|
||||
self.p = p # 3 oder 4 Punkte (Spielkoordinaten)
|
||||
self.p = p # 3 or 4 points (game coordinates)
|
||||
self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double
|
||||
self.render = render # False: nur Kollision (unsichtbar)
|
||||
self.render = render # False: collision only (invisible)
|
||||
|
||||
|
||||
def _sub(a, b):
|
||||
@@ -92,19 +93,19 @@ def _norm(v):
|
||||
|
||||
|
||||
def to_exp(p):
|
||||
"""Spielraum (z positiv) -> Exportraum/Vertexraum (z negativ)."""
|
||||
"""Game space (z positive) -> export/vertex space (z negative)."""
|
||||
return (p[0], p[1], -p[2])
|
||||
|
||||
|
||||
class Level:
|
||||
"""Baukasten. Alle Methoden nehmen Spielkoordinaten (x, z = Rasterzelle, y oben)."""
|
||||
"""Construction kit. All methods take game coordinates (x, z = grid cell, y up)."""
|
||||
|
||||
def __init__(self, base=BASE, name='Testwelt', seed=1):
|
||||
self.base = base
|
||||
self.name = name
|
||||
self.rng = random.Random(seed)
|
||||
self.quads = []
|
||||
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) fuer Schatten/Umgebungsverdeckung
|
||||
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) for shadows/ambient occlusion
|
||||
self.strats = [] # dict(name, x,y,z, rot, params, wp, variant)
|
||||
self.kill = None # (y, x0, z0, x1, z1)
|
||||
self.sun = dict(azimuth=215, elevation=50, color=(1.0, 0.93, 0.80), strength=0.62,
|
||||
@@ -113,7 +114,7 @@ class Level:
|
||||
self.flats_used = {}
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ Basislevel
|
||||
# ------------------------------------------------------------ base level
|
||||
def _load(self):
|
||||
self.ch = G.wad_read(self.base)
|
||||
self.wf = G.wfpc_flags(self.ch)
|
||||
@@ -125,7 +126,7 @@ class Level:
|
||||
st = G.wad_get(self.ch, 'STPC')
|
||||
self.stpc = st
|
||||
self.code_lo = stpcwalk.walk(st, self.wf)['code']
|
||||
self.protos = {} # name -> [instanz-tupel je Code-Variante]
|
||||
self.protos = {} # name -> [instance tuple per code variant]
|
||||
for s in self.map['strats']:
|
||||
nm = (S.strat_name(st, s[6], self.code_lo) or '?@%x' % s[6]).replace('(spawn)', '')
|
||||
lst = self.protos.setdefault(nm, [])
|
||||
@@ -153,7 +154,7 @@ class Level:
|
||||
return sorted(self.protos)
|
||||
|
||||
def tex(self, t):
|
||||
"""Texturname, Index (int) oder Flachfarbe ('meer' / (r,g,b))."""
|
||||
"""Texture name, index (int) or flat colour ('meer' / (r,g,b))."""
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
if isinstance(t, tuple):
|
||||
@@ -166,36 +167,36 @@ class Level:
|
||||
if t not in self.flats_used:
|
||||
self.flats_used[t] = len(self.texs) + len(self.flats_used)
|
||||
return self.flats_used[t]
|
||||
raise KeyError('unbekannte Textur %r (Namen: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
|
||||
raise KeyError('unknown texture %r (names: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
|
||||
|
||||
# ------------------------------------------------------------ Grundbausteine
|
||||
# ------------------------------------------------------------ basic building blocks
|
||||
def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True):
|
||||
"""Ein Viereck p = [unten-links, unten-rechts, oben-rechts, oben-links] von vorne gesehen
|
||||
(Textur aufrecht). rot = 0..3 dreht die Textur in 90-Grad-Schritten."""
|
||||
"""A quad p = [bottom-left, bottom-right, top-right, top-left] seen from the front
|
||||
(texture upright). rot = 0..3 rotates the texture in 90-degree steps."""
|
||||
p = [tuple(map(float, q)) for q in p]
|
||||
rot %= 4
|
||||
p = p[rot:] + p[:rot]
|
||||
self.quads.append(Quad(p, self.tex(tex), coll, mat, tint, shade, double, render))
|
||||
|
||||
def collider(self, p, mat=0, normal=(0, 1, 0)):
|
||||
"""Unsichtbare Kollisionsflaeche (3/4 Punkte), Vorderseite Richtung 'normal'."""
|
||||
"""Invisible collision surface (3/4 points), front side towards 'normal'."""
|
||||
self.quad(_orient([tuple(map(float, q)) for q in p], normal), 'gras', coll=True, mat=mat, render=False)
|
||||
|
||||
def _hface(self, x0, z0, x1, z1, y, tex, up=True, step=1.0, rot='random', tint=(1, 1, 1), coll=True, mat=0):
|
||||
"""Waagerechte Flaeche, in Kacheln (step) unterteilt, an ganzen Zahlen ausgerichtet."""
|
||||
"""Horizontal surface, subdivided into tiles (step), aligned to integers."""
|
||||
xs = _cuts(x0, x1, step)
|
||||
zs = _cuts(z0, z1, step)
|
||||
for xa, xb in zip(xs, xs[1:]):
|
||||
for za, zb in zip(zs, zs[1:]):
|
||||
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 Varianten: weniger Modelle
|
||||
if up: # von oben gesehen: unten-links = (xa, zb)? -> Normale +y
|
||||
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 variants: fewer models
|
||||
if up: # seen from above: bottom-left = (xa, zb)? -> normal +y
|
||||
p = [(xa, y, za), (xb, y, za), (xb, y, zb), (xa, y, zb)]
|
||||
else:
|
||||
p = [(xa, y, zb), (xb, y, zb), (xb, y, za), (xa, y, za)]
|
||||
self.quad(_orient(p, (0, 1 if up else -1, 0)), tex, coll=coll, mat=mat, tint=tint, rot=r)
|
||||
|
||||
def _vface(self, a, b, y0, y1, tex, top_tex=None, step=1.0, tint=(1, 1, 1), coll=True, mat=0, normal=None):
|
||||
"""Senkrechte Wand von a=(x,z) nach b=(x,z), aussen = rechts von a->b (von oben, z nach unten)."""
|
||||
"""Vertical wall from a=(x,z) to b=(x,z), outside = right of a->b (seen from above, z pointing down)."""
|
||||
L = math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
if L < 1e-6 or y1 - y0 < 1e-6:
|
||||
return
|
||||
@@ -212,13 +213,13 @@ class Level:
|
||||
self.quad(_orient(p, nn), tx, coll=coll, mat=mat, tint=tint)
|
||||
|
||||
def floor(self, x0, z0, x1, z1, y, tex='gras', rot='random', tint=(1, 1, 1), step=1.0, coll=True):
|
||||
"""Flache Bodenflaeche (nur Oberseite)."""
|
||||
"""Flat floor surface (top side only)."""
|
||||
self._hface(x0, z0, x1, z1, y, tex, True, step, rot, tint, coll)
|
||||
|
||||
def box(self, x0, z0, x1, z1, y0, y1, top='gras', side='erde', side_top=None, bottom=None,
|
||||
tint=(1, 1, 1), rot='random', sides='nesw', solid=True, coll=True, mat=0, step=1.0, side_coll=None):
|
||||
"""Quader. side_top: Textur fuer die oberste Reihe der Seitenwaende (z.B. 'graskante').
|
||||
bottom: Unterseite zeichnen (fuer schwebende Plattformen). sides: welche Seiten (n=z0,
|
||||
"""Cuboid. side_top: texture for the top row of the side walls (e.g. 'graskante').
|
||||
bottom: draw the underside (for floating platforms). sides: which sides (n=z0,
|
||||
s=z1, w=x0, e=x1)."""
|
||||
x0, x1 = min(x0, x1), max(x0, x1)
|
||||
z0, z1 = min(z0, z1), max(z0, z1)
|
||||
@@ -229,7 +230,7 @@ class Level:
|
||||
self._hface(x0, z0, x1, z1, y0, bottom, False, step, 0, tint, coll, mat)
|
||||
sc = coll if side_coll is None else side_coll
|
||||
if side:
|
||||
# Waende so, dass die Aussenseite rechts liegt
|
||||
# walls oriented so that the outside is on the right
|
||||
if 'n' in sides:
|
||||
self._vface((x1, z0), (x0, z0), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, -1))
|
||||
if 's' in sides:
|
||||
@@ -243,18 +244,18 @@ class Level:
|
||||
|
||||
def platform(self, x, z, w, d, y, top='gras', side='erde', thick=0.5, side_top='graskante',
|
||||
bottom='erde', **kw):
|
||||
"""Schwebende Plattform: Oberkante y, Ecke (x,z), Groesse w x d."""
|
||||
"""Floating platform: top edge y, corner (x,z), size w x d."""
|
||||
self.box(x, z, x + w, z + d, y - thick, y, top, side, side_top=side_top, bottom=bottom, **kw)
|
||||
|
||||
def pillar(self, x, z, y0, y1, size=1.0, side='stein', top='stein', **kw):
|
||||
"""Saeule mit Mittelpunkt (x,z)."""
|
||||
"""Pillar centred at (x,z)."""
|
||||
h = size / 2
|
||||
self.box(x - h, z - h, x + h, z + h, y0, y1, top, side, **kw)
|
||||
|
||||
def ramp(self, x0, z0, x1, z1, ya, yb, direction='+x', top='sand', side='erde', base=None,
|
||||
tint=(1, 1, 1), step=1.0, coll=True):
|
||||
"""Rampe ueber dem Rechteck; steigt in 'direction' (+x,-x,+z,-z) von ya auf yb.
|
||||
Unterkante 'base' (Standard: ya - 0.5); Seiten und Stirnflaeche werden geschlossen."""
|
||||
"""Ramp over the rectangle; rises in 'direction' (+x,-x,+z,-z) from ya to yb.
|
||||
Bottom edge 'base' (default: ya - 0.5); sides and front face are closed."""
|
||||
x0, x1 = min(x0, x1), max(x0, x1)
|
||||
z0, z1 = min(z0, z1), max(z0, z1)
|
||||
base = (min(ya, yb) - 0.5) if base is None else base
|
||||
@@ -271,7 +272,7 @@ class Level:
|
||||
for za, zb in zip(zs, zs[1:]):
|
||||
p = [(xa, h(xa, za), za), (xb, h(xb, za), za), (xb, h(xb, zb), zb), (xa, h(xa, zb), zb)]
|
||||
self.quad(_orient(p, (0, 1, 0)), top, coll=coll, tint=tint)
|
||||
# Seiten: Trapeze in Streifen
|
||||
# sides: trapezoids in strips
|
||||
edges = [((x1, z0), (x0, z0), (0, 0, -1)), ((x0, z1), (x1, z1), (0, 0, 1)),
|
||||
((x0, z0), (x0, z1), (-1, 0, 0)), ((x1, z1), (x1, z0), (1, 0, 0))]
|
||||
for a, b, nn in edges:
|
||||
@@ -290,15 +291,15 @@ class Level:
|
||||
|
||||
def stairs(self, x0, z0, x1, z1, y0, y1, n, direction='+x', top='stein', side='stein2', base=None,
|
||||
walkable=True, tint=(1, 1, 1)):
|
||||
"""Treppe mit n Stufen auf dem Rechteck, steigt in 'direction' (+x,-x,+z,-z) von y0 auf y1.
|
||||
walkable=True: Kollision als unsichtbare Schraege (Lama kann hochlaufen; die Engine hat
|
||||
keine Stufen-Logik, schon 0.12 hohe Kanten blockieren), sonst echte Stufen (springen)."""
|
||||
"""Stairs with n steps on the rectangle, rising in 'direction' (+x,-x,+z,-z) from y0 to y1.
|
||||
walkable=True: collision as an invisible slope (the llama can walk up; the engine has
|
||||
no step logic, even 0.12-high edges block), otherwise real steps (jump)."""
|
||||
x0, x1 = min(x0, x1), max(x0, x1)
|
||||
z0, z1 = min(z0, z1), max(z0, z1)
|
||||
base = (y0 - 0.5) if base is None else base
|
||||
ax = direction[1]
|
||||
neg = direction[0] == '-'
|
||||
# in lokale Koordinaten u (Steigrichtung 0..1) / v (quer 0..1) abbilden
|
||||
# map to local coordinates u (rise direction 0..1) / v (across 0..1)
|
||||
def P(u, v, y):
|
||||
if neg:
|
||||
u = 1 - u
|
||||
@@ -315,18 +316,18 @@ class Level:
|
||||
yt = y0 + (y1 - y0) * (i + 1) / n
|
||||
yl = base if i == 0 else y0 + (y1 - y0) * i / n
|
||||
last = i == n - 1
|
||||
# Trittflaeche
|
||||
# tread
|
||||
q = [P(u0, 0, yt), P(u1, 0, yt), P(u1, 1, yt), P(u0, 1, yt)]
|
||||
self.quad(_orient(q, (0, 1, 0)), top, coll=(rc or last), tint=tint)
|
||||
# Setzstufe (Stirnseite), Fuss bis base
|
||||
# riser (front face), foot down to base
|
||||
q = [P(u0, 0, base), P(u0, 1, base), P(u0, 1, yt), P(u0, 0, yt)]
|
||||
self.quad(_orient(q, nrm(-1, 0)), side, coll=rc, tint=tint)
|
||||
# Seitenwaende dieser Stufe (bis base)
|
||||
# side walls of this step (down to base)
|
||||
for v, dv in ((0, -1), (1, 1)):
|
||||
q = [P(u0, v, base), P(u1, v, base), P(u1, v, yt), P(u0, v, yt)]
|
||||
self.quad(_orient(q, nrm(0, dv)), side, coll=True, tint=tint)
|
||||
self.solids.append(_aabb([P(u0, 0, base), P(u1, 1, yt)]))
|
||||
# Rueckwand
|
||||
# back wall
|
||||
q = [P(1, 0, base), P(1, 1, base), P(1, 1, y1), P(1, 0, y1)]
|
||||
self.quad(_orient(q, nrm(1, 0)), side, coll=True, tint=tint)
|
||||
if walkable:
|
||||
@@ -335,14 +336,14 @@ class Level:
|
||||
self.collider(q)
|
||||
|
||||
def fence(self, pts, y, height=0.6, thick=0.12, post='holz_dunkel', rail='holz_maser', posts_every=1.0):
|
||||
"""Zaun (Pfosten + Querlatte) entlang Punktliste [(x,z),...] auf Hoehe y. Mit Kollision."""
|
||||
"""Fence (posts + rail) along point list [(x,z),...] at height y. With collision."""
|
||||
for (ax, az), (bx, bz) in zip(pts, pts[1:]):
|
||||
L = math.hypot(bx - ax, bz - az)
|
||||
n = max(1, int(round(L / posts_every)))
|
||||
for i in range(n + 1):
|
||||
px, pz = ax + (bx - ax) * i / n, az + (bz - az) * i / n
|
||||
self.box(px - thick, pz - thick, px + thick, pz + thick, y, y + height, top=post, side=post, rot=0)
|
||||
# Latte als duenner Quader
|
||||
# rail as a thin cuboid
|
||||
t = thick * 0.6
|
||||
yb = y + height * 0.55
|
||||
if abs(bx - ax) >= abs(bz - az):
|
||||
@@ -353,7 +354,7 @@ class Level:
|
||||
solid=False)
|
||||
|
||||
def tree(self, x, z, y, height=2.5, crown=1.4, trunk='rinde', leaves='gras_dunkel', trunk_size=None):
|
||||
"""Einfacher Klotz-Baum (Stamm + zwei Kronen-Wuerfel)."""
|
||||
"""Simple blocky tree (trunk + two crown cubes)."""
|
||||
self.pillar(x, z, y, y + height, size=trunk_size or max(0.15, crown * 0.25), side=trunk, top=trunk)
|
||||
c = crown / 2
|
||||
self.box(x - c, z - c, x + c, z + c, y + height - 0.2, y + height + crown * 0.7, top=leaves, side=leaves,
|
||||
@@ -363,15 +364,15 @@ class Level:
|
||||
side=leaves, coll=True)
|
||||
|
||||
def sea(self, x0, z0, x1, z1, y=-2.0, tex='meer', kill=None, step=4.0):
|
||||
"""Wasser-/Grundflaeche ohne Kollision (grob unterteilt, step) + toedliche Ebene knapp
|
||||
darueber. kill=(x0,z0,x1,z1): Bereich der Todesebene (Standard: ganze Flaeche, False: keine).
|
||||
Die Todesebene wird nur in Zellen ohne durchgehenden Boden angelegt."""
|
||||
"""Water/ground surface without collision (coarsely subdivided, step) + deadly plane just
|
||||
above it. kill=(x0,z0,x1,z1): area of the death plane (default: whole surface, False: none).
|
||||
The death plane is only created in cells without continuous floor."""
|
||||
self._hface(x0, z0, x1, z1, y, tex, True, step, 0, (1, 1, 1), False)
|
||||
if kill is not False:
|
||||
self.kill = (y + 0.05,) + tuple(kill or (x0, z0, x1, z1))
|
||||
|
||||
def obj(self, path, offset=(0, 0, 0), scale=1.0, tex='stein', coll=True):
|
||||
"""OBJ-Datei (Spielkoordinaten, y oben). 'usemtl tex_N' bzw. Texturname als Material."""
|
||||
"""OBJ file (game coordinates, y up). 'usemtl tex_N' or a texture name as material."""
|
||||
V, cur = [], tex
|
||||
for line in open(path, encoding='latin1'):
|
||||
p = line.split()
|
||||
@@ -391,9 +392,9 @@ class Level:
|
||||
for k in range(1, len(pts) - 1):
|
||||
self.quad([pts[0], pts[k], pts[k + 1]], cur, coll=coll)
|
||||
|
||||
# ------------------------------------------------------------ Hoehe
|
||||
# ------------------------------------------------------------ height
|
||||
def height_at(self, x, z, below=1e9):
|
||||
"""Hoechste begehbare Flaeche bei (x,z) unterhalb 'below' (oder None)."""
|
||||
"""Highest walkable surface at (x,z) below 'below' (or None)."""
|
||||
best = None
|
||||
for q in self.quads:
|
||||
if not q.coll:
|
||||
@@ -413,14 +414,14 @@ class Level:
|
||||
best = y
|
||||
return best
|
||||
|
||||
# ------------------------------------------------------------ Objekte
|
||||
# ------------------------------------------------------------ objects
|
||||
def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False):
|
||||
"""Objekt platzieren. y=None: auf die hoechste Flaeche bei (x,z) stellen.
|
||||
rot: Grad (None = wie im Original). params: Liste u32 (None = Original).
|
||||
wp: 'relative' (Wegpunktkette des Originals mitverschieben, y auf Boden),
|
||||
'here' (alle Wegpunkte auf das Objekt), 'none' (-1) oder Liste [(x,z[,y[,rot]]),...]."""
|
||||
"""Place an object. y=None: put it on the highest surface at (x,z).
|
||||
rot: degrees (None = as in the original). params: list of u32 (None = original).
|
||||
wp: 'relative' (move the original's waypoint chain along, y snapped to the ground),
|
||||
'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot]]),...]."""
|
||||
if name not in self.protos:
|
||||
raise KeyError('Strat-Typ %r nicht im Basislevel (vorhanden: %s)' % (name, ', '.join(self.types())))
|
||||
raise KeyError('strat type %r not in base level (available: %s)' % (name, ', '.join(self.types())))
|
||||
if y is None:
|
||||
y = self.height_at(x, z)
|
||||
if y is None:
|
||||
@@ -430,8 +431,8 @@ class Level:
|
||||
return d
|
||||
|
||||
def player(self, x, z, rot=0, y=None, cam_dist=8.0):
|
||||
"""Spielerstart. Der erste Wegpunkt der Lama-Kette ist (im Original) die Startposition der
|
||||
Kamera -> hinter das Lama legen (Abstand cam_dist), die uebrigen auf das Lama."""
|
||||
"""Player start. The first waypoint of the llama chain is (in the original) the camera's
|
||||
start position -> put it behind the llama (distance cam_dist), the rest on the llama."""
|
||||
d = self.strat('Llama', x, z, y, rot, wp='here')
|
||||
a = math.radians(rot or 0)
|
||||
cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a)
|
||||
@@ -453,7 +454,7 @@ class Level:
|
||||
yy = self.height_at(p[0], p[1])
|
||||
self.strat(name, p[0], p[1], (yy or 0) + y_off)
|
||||
|
||||
# ------------------------------------------------------------ Bauen
|
||||
# ------------------------------------------------------------ build
|
||||
def build(self, out, verbose=True):
|
||||
geo = self._geometry()
|
||||
models, tiles = geo
|
||||
@@ -468,20 +469,20 @@ class Level:
|
||||
if nm is not None and self.name:
|
||||
G.wad_set(ch, 'NAME', nm[:4] + self.name.encode('latin1') + b'\0')
|
||||
G.wad_write(out, ch)
|
||||
# Gegenprobe: MAP parst wieder vollstaendig
|
||||
# cross-check: MAP parses back completely
|
||||
chk = M.parse(mp, self.wf, [len(m.verts) for m in models])
|
||||
assert not chk['rest'] and len(chk['strats']) == len(self._srecs)
|
||||
if verbose:
|
||||
nf = sum(len(m.faces) for m in models)
|
||||
nc = sum(len(m.coll[0]) for m in models)
|
||||
print('%s: %d Modelle, %d Kacheln, %d Dreiecke, %d Kollisionspolygone, %d Strats, %d Wegpunkte, '
|
||||
print('%s: %d models, %d tiles, %d triangles, %d collision polygons, %d strats, %d waypoints, '
|
||||
'TRAK %d KB, WAD %d KB' % (out, len(models), len(tiles), nf, nc, len(self._srecs),
|
||||
len(self._nodes), len(trak) // 1024, os.path.getsize(out) // 1024))
|
||||
return out
|
||||
|
||||
# -- Beleuchtung
|
||||
# -- lighting
|
||||
def _light(self, P, N, tint):
|
||||
"""P,N: (n,3) Spielkoordinaten; liefert (n,3) Farbe 1.0 = Texturfarbe."""
|
||||
"""P,N: (n,3) game coordinates; returns (n,3) colour, 1.0 = texture colour."""
|
||||
s = self.sun
|
||||
az, el = math.radians(s['azimuth']), math.radians(s['elevation'])
|
||||
L = np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)])
|
||||
@@ -491,12 +492,12 @@ class Level:
|
||||
hemi = (N[:, 1:2] * 0.5 + 0.5)
|
||||
amb = gnd + (sky - gnd) * hemi
|
||||
B = np.array(self.solids, float) if self.solids else np.zeros((0, 6))
|
||||
# Strahlen: Sonne + Halbkugel
|
||||
# rays: sun + hemisphere
|
||||
dirs = _hemi_dirs()
|
||||
shadow = np.ones(len(P))
|
||||
ao = np.ones(len(P))
|
||||
CH = 256
|
||||
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # raeumlich sortieren -> kleine Chunks
|
||||
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # sort spatially -> small chunks
|
||||
P, N = P[order], N[order]
|
||||
res_shadow, res_ao = np.ones(len(P)), np.ones(len(P))
|
||||
ndl = ndl[order]
|
||||
@@ -517,7 +518,7 @@ class Level:
|
||||
lit = ndl[i:i + CH] > 0
|
||||
hit = _ray_boxes(p, np.broadcast_to(L, p.shape), far, 60.0) if len(far) else np.zeros(len(p), bool)
|
||||
res_shadow[i:i + CH] = np.where(lit & hit, 0.0, 1.0)
|
||||
# AO: Richtungen in der Halbkugel um n
|
||||
# AO: directions in the hemisphere around n
|
||||
w = np.clip(n @ dirs.T, 0, None) # (c, R)
|
||||
occ = np.zeros(len(p))
|
||||
for r in range(len(dirs)):
|
||||
@@ -536,18 +537,18 @@ class Level:
|
||||
col = amb * ao[:, None] + sc * (s['strength'] * ndl * sh)[:, None] * (0.6 + 0.4 * ao[:, None])
|
||||
return col * np.asarray(tint)
|
||||
|
||||
# -- Kacheln/Modelle
|
||||
# -- tiles/models
|
||||
def _geometry(self):
|
||||
BAND, B0 = 10.0, -3.0 # Hoehenbaender je Kachel: [-3,7), [7,17), ...
|
||||
BAND, B0 = 10.0, -3.0 # height bands per tile: [-3,7), [7,17), ...
|
||||
MARGIN = 0.3
|
||||
tris_by_tile = {}
|
||||
coll_by_tile = {}
|
||||
# Dreiecke (Exportraum) + Vertexfarben vorbereiten
|
||||
# prepare triangles (export space) + vertex colours
|
||||
allv, alln, allt = [], [], []
|
||||
faces = []
|
||||
for q in self.quads:
|
||||
pts = q.p
|
||||
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # aussen
|
||||
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # outwards
|
||||
if len(pts) == 4:
|
||||
tri = [((0, 1, 2), 0x820), ((2, 3, 0), 0x830)]
|
||||
else:
|
||||
@@ -566,7 +567,7 @@ class Level:
|
||||
xs = [a[0] for a in pts]; zs = [a[2] for a in pts]
|
||||
cy = sum(a[1] for a in pts) / len(pts)
|
||||
band = math.floor((cy - B0) / BAND)
|
||||
# Boeden: nur ueberdeckte Zellen; Waende/Schraegen: auch Nachbarzellen (Spielerradius)
|
||||
# floors: only covered cells; walls/slopes: neighbouring cells too (player radius)
|
||||
mg = 0.0 if abs(n[1]) > 0.95 else MARGIN
|
||||
for cx in range(math.floor(min(xs) - mg), math.floor(max(xs) + mg - 1e-6) + 1):
|
||||
for cz in range(math.floor(min(zs) - mg), math.floor(max(zs) + mg - 1e-6) + 1):
|
||||
@@ -586,7 +587,7 @@ class Level:
|
||||
for key in sorted(set(tris_by_tile) | set(coll_by_tile)):
|
||||
cx, cz, band = key
|
||||
if not (0 <= cx < self.W and 0 <= cz < self.H):
|
||||
raise SystemExit('Geometrie ausserhalb des Rasters %dx%d: Zelle %d,%d' % (self.W, self.H, cx, cz))
|
||||
raise SystemExit('geometry outside the %dx%d grid: cell %d,%d' % (self.W, self.H, cx, cz))
|
||||
ox, oy, oz = cx + 0.5, B0 + band * BAND + BAND / 2, cz + 0.5
|
||||
m = G.Model(); m.dirty = True
|
||||
vmap, cols, nacc = {}, [], []
|
||||
@@ -629,7 +630,7 @@ class Level:
|
||||
m.coll[0].append(r)
|
||||
if not m.faces and not m.coll[0]:
|
||||
continue
|
||||
# Box der Kachel muss die Kollisionspolygone umfassen (unbenutzte Hilfsvertices)
|
||||
# the tile's box must enclose the collision polygons (unused helper vertices)
|
||||
cpts = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for pts, _ in coll_by_tile.get(key, []) for p in pts]
|
||||
if cpts:
|
||||
lo = [min(c[k] for c in cpts) for k in range(3)]
|
||||
@@ -647,22 +648,22 @@ class Level:
|
||||
m.verts.append((round(c[0], 5), round(c[1], 5), round(c[2], 5), 0.0, 1.0, 0.0))
|
||||
cols.append(0x01808080)
|
||||
if len(m.verts) > 4000:
|
||||
raise SystemExit('Kachel %s zu gross (%d Vertices)' % (key, len(m.verts)))
|
||||
if not m.verts: # reine Kollisionskachel braucht keine Vertices
|
||||
raise SystemExit('tile %s too large (%d vertices)' % (key, len(m.verts)))
|
||||
if not m.verts: # a collision-only tile needs no vertices
|
||||
pass
|
||||
h = _model_key(m)
|
||||
if h not in cache:
|
||||
cache[h] = len(models)
|
||||
models.append(m)
|
||||
tiles.append(dict(x=ox, y=oy, z=-oz, rot=0, model=cache[h], cols=cols))
|
||||
# Todesebene (Original-Modell 0 des Dorfes: unsichtbar, Material 0x1e, y_lokal = -2.75)
|
||||
# death plane (the village's original model 0: invisible, material 0x1e, y_local = -2.75)
|
||||
if self.kill:
|
||||
ky, x0, z0, x1, z1 = self.kill
|
||||
km = self.trak0[0]
|
||||
kill_model = len(models)
|
||||
models.append(km)
|
||||
ty = ky + 2.75
|
||||
# nur Zellen ohne begehbaren Boden (spart Kacheln; unter Boden faellt man nicht)
|
||||
# only cells without walkable floor (saves tiles; you can't fall below a floor)
|
||||
covered = self._covered_cells()
|
||||
for cx in range(max(0, math.floor(x0)), min(self.W, math.ceil(x1))):
|
||||
for cz in range(max(0, math.floor(z0)), min(self.H, math.ceil(z1))):
|
||||
@@ -673,7 +674,7 @@ class Level:
|
||||
return models, tiles
|
||||
|
||||
def _covered_cells(self):
|
||||
"""Zellen, die vollstaendig von nach oben zeigender Kollision (Boden) bedeckt sind."""
|
||||
"""Cells fully covered by upward-facing collision (floor)."""
|
||||
area = {}
|
||||
for q in self.quads:
|
||||
if not q.coll or len(q.p) != 4:
|
||||
@@ -729,7 +730,7 @@ class Level:
|
||||
tab[0] = 0xffffffff
|
||||
m['tab32'] = tuple(tab)
|
||||
m['grid'] = (0,) * (self.W * self.H)
|
||||
# Strats + Wegpunkte
|
||||
# strats + waypoints
|
||||
pool = list(self.pool0)
|
||||
nodes = []
|
||||
recs = []
|
||||
@@ -747,13 +748,13 @@ class Level:
|
||||
else:
|
||||
prm = self.pool0[proto[8]:proto[8] + proto[7]] if proto[7] else []
|
||||
if len(prm) != proto[7]:
|
||||
raise ValueError('%s braucht %d Parameter, nicht %d' % (d['name'], proto[7], len(prm)))
|
||||
raise ValueError('%s needs %d parameters, not %d' % (d['name'], proto[7], len(prm)))
|
||||
if prm:
|
||||
s[8] = len(pool)
|
||||
pool.extend(int(v) & 0xffffffff for v in prm)
|
||||
else:
|
||||
s[8] = -1
|
||||
# Wegpunkte
|
||||
# waypoints
|
||||
wp = d['wp']
|
||||
chain = []
|
||||
if proto[13] >= 0:
|
||||
@@ -770,7 +771,7 @@ class Level:
|
||||
px, pz = p[0], p[1]
|
||||
py = p[2] if len(p) > 2 and p[2] is not None else (self.height_at(px, pz) or d['y'])
|
||||
pr = deg(p[3]) if len(p) > 3 else s[1]
|
||||
# Zusatzfelder (Wartezeit/Modus o.ae.) vom Original-Wegpunkt uebernehmen
|
||||
# copy extra fields (wait time/mode etc.) from the original waypoint
|
||||
a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0)
|
||||
new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_])
|
||||
elif chain and wp != 'none':
|
||||
@@ -809,7 +810,7 @@ class Level:
|
||||
return M.build(m, self.wf)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Hilfsfunktionen
|
||||
# ---------------------------------------------------------------- helpers
|
||||
def _s32(v):
|
||||
return v - (1 << 32) if v >= 1 << 31 else v
|
||||
|
||||
@@ -839,12 +840,12 @@ def _wall_normal(a, b):
|
||||
|
||||
|
||||
def _orient(p, normal):
|
||||
"""Reihenfolge so, dass die Flaeche nach 'normal' (Spielraum) zeigt. Die Engine rechnet im
|
||||
Exportraum (z gespiegelt): dort muss (p1-p0)x(p2-p0) nach aussen zeigen, im Spielraum also
|
||||
nach innen (Spiegelung dreht den Umlaufsinn)."""
|
||||
"""Order the points so the surface faces 'normal' (game space). The engine works in
|
||||
export space (z mirrored): there (p1-p0)x(p2-p0) must point outwards, i.e. inwards in
|
||||
game space (mirroring reverses the winding)."""
|
||||
n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0]))
|
||||
if sum(a * b for a, b in zip(n, normal)) > 0:
|
||||
# spiegeln, Ecke 0 bleibt unten-links -> [1,0,3,2]
|
||||
# mirror, corner 0 stays bottom-left -> [1,0,3,2]
|
||||
p = [p[1], p[0], p[3], p[2]] if len(p) == 4 else [p[1], p[0], p[2]]
|
||||
return p
|
||||
|
||||
@@ -892,7 +893,7 @@ def _hemi_dirs():
|
||||
global _HEMI
|
||||
if _HEMI is None:
|
||||
d = []
|
||||
# Fibonacci-Kugel, 24 Richtungen
|
||||
# Fibonacci sphere, 24 directions
|
||||
n = 24
|
||||
for i in range(n):
|
||||
y = 1 - 2 * (i + 0.5) / n
|
||||
@@ -904,7 +905,7 @@ def _hemi_dirs():
|
||||
|
||||
|
||||
def _ray_boxes(p, d, B, tmax):
|
||||
"""p,d: (n,3); B: (b,6). Liefert bool (n,): trifft irgendeine Box mit 0<t<tmax."""
|
||||
"""p,d: (n,3); B: (b,6). Returns bool (n,): hits any box with 0<t<tmax."""
|
||||
if len(p) == 0:
|
||||
return np.zeros(0, bool)
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
@@ -931,17 +932,17 @@ def main():
|
||||
ap.add_argument('level', nargs='?')
|
||||
ap.add_argument('out', nargs='?')
|
||||
ap.add_argument('--base', default=BASE)
|
||||
ap.add_argument('--install', action='store_true', help='als TestLevel (T1L9M001.wad) installieren')
|
||||
ap.add_argument('--install', action='store_true', help='install as TestLevel (T1L9M001.wad)')
|
||||
ap.add_argument('--types', action='store_true')
|
||||
ap.add_argument('--textures', metavar='PNG')
|
||||
ap.add_argument('--set', action='append', default=[], help='Variable fuer das Levelskript, z.B. --set teil=a')
|
||||
ap.add_argument('--set', action='append', default=[], help='variable for the level script, e.g. --set teil=a')
|
||||
a = ap.parse_args()
|
||||
if a.types:
|
||||
L = Level(a.base)
|
||||
for n in L.types():
|
||||
p = L.protos[n][0]
|
||||
print('%-28s Varianten %d Parameter %d Wegpunkte %s' % (n, len(L.protos[n]), p[7],
|
||||
'ja' if p[13] >= 0 else '-'))
|
||||
print('%-28s variants %d parameters %d waypoints %s' % (n, len(L.protos[n]), p[7],
|
||||
'yes' if p[13] >= 0 else '-'))
|
||||
return
|
||||
if a.textures:
|
||||
import subprocess
|
||||
@@ -951,7 +952,7 @@ def main():
|
||||
print('%4d %s' % (i, names[i]))
|
||||
return
|
||||
if not (a.level and a.out):
|
||||
ap.error('LEVEL.py und OUT.wad angeben')
|
||||
ap.error('specify LEVEL.py and OUT.wad')
|
||||
mod = load_script(a.level)
|
||||
opts = dict(kv.split('=', 1) for kv in a.set)
|
||||
L = Level(a.base, **getattr(mod, 'LEVEL_ARGS', {}))
|
||||
@@ -965,14 +966,14 @@ def main():
|
||||
if not os.path.exists(MODWAD + '.orig'):
|
||||
shutil.copy2(MODWAD, MODWAD + '.orig')
|
||||
shutil.copyfile(a.out, MODWAD)
|
||||
print('installiert ->', MODWAD)
|
||||
print('installed ->', MODWAD)
|
||||
kapitel_links()
|
||||
|
||||
|
||||
def kapitel_links(n=49):
|
||||
"""Kapitel 2..n von Welt 9 als Symlinks auf Kapitel 1 (WAD + Ladebild T1%d%d.COR) in der Mod.
|
||||
Grund: jeder Ausgang (LevelDoor) schaltet im hoechsten freien Kapitel das naechste frei;
|
||||
fehlt dessen WAD, stuerzt das Spiel bei der Auswahl ab ("Couldnt load wad")."""
|
||||
"""Chapters 2..n of world 9 as symlinks to chapter 1 (WAD + loading image T1%d%d.COR) in the mod.
|
||||
Reason: every exit (LevelDoor) in the highest unlocked chapter unlocks the next one;
|
||||
if its WAD is missing, the game crashes on selecting it ("Couldnt load wad")."""
|
||||
wd = os.path.dirname(MODWAD)
|
||||
mod = os.path.dirname(wd)
|
||||
neu = 0
|
||||
@@ -984,7 +985,7 @@ def kapitel_links(n=49):
|
||||
neu += 1
|
||||
if neu:
|
||||
import subprocess
|
||||
print('%d Kapitel-Links angelegt -> lama-mod an' % neu)
|
||||
print('%d chapter links created -> lama-mod an' % neu)
|
||||
subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True,
|
||||
stdout=subprocess.DEVNULL)
|
||||
|
||||
|
||||
+25
-25
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Testlevel bauen: in einem leeren Bereich des MAP-Rasters eine Bodenplatte aus Kopien
|
||||
eines flachen Boden-Tiles anlegen, darauf je einen Strat (Objekt) pro Typ im Raster
|
||||
aufstellen, den Spieler (Strat "Llama") an den Anfang setzen und einen kleinen
|
||||
Parkour (schwebende Bodenplatten, treppauf) anhängen. Geometrie/Texturen/Strat-Code
|
||||
(TRAK/STPC/TEXT …) bleiben die des Quell-Levels; nur der MAP-Chunk wird neu geschrieben.
|
||||
"""Build a test level: in an empty area of the MAP grid, lay a floor plate made of copies
|
||||
of a flat floor tile, place one strat (object) per type on it in a grid, put the
|
||||
player (strat "Llama") at the start and append a small parkour (floating floor
|
||||
plates, going up). Geometry/textures/strat code (TRAK/STPC/TEXT ...) stay those of
|
||||
the source level; only the MAP chunk is rewritten.
|
||||
|
||||
make_testlevel.py quelle.wad ziel.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
|
||||
make_testlevel.py source.wad target.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
|
||||
|
||||
--keep-original : Original-Strats behalten (sonst nur Spieler + Raster + Cheats)
|
||||
Strat-Typen werden per Name erkannt (strats.py); Parameter/Typ-Felder werden von der
|
||||
ersten Original-Instanz kopiert, Wegpunkt-Index bleibt (zeigt ggf. auf Original-Wegpunkte).
|
||||
--keep-original : keep the original strats (otherwise only player + grid + cheats)
|
||||
Strat types are recognised by name (strats.py); parameter/type fields are copied from the
|
||||
first original instance, the waypoint index is kept (may point at original waypoints).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -23,7 +23,7 @@ import strats as S # noqa: E402
|
||||
import wadtool # noqa: E402
|
||||
|
||||
FX = 4096
|
||||
# nicht ins Raster: Kamera/Cutscene/Trigger-Zonen, Spieler selbst
|
||||
# not in the grid: camera/cutscene/trigger zones, the player itself
|
||||
SKIP_PREFIX = ('CutScene', 'Camera', 'MainMenu', 'Options', 'Title', 'TheNewGroove', 'Force',
|
||||
'EndGameTrigger', 'RiverTriggerBox', 'FallOffNoHurt')
|
||||
KEEP_ORIGINAL = ('CheatTeleport', 'CheatCharge')
|
||||
@@ -45,7 +45,7 @@ def trak_models(trak):
|
||||
|
||||
|
||||
def find_floor_tile(m, models, force=None):
|
||||
"""Häufigstes Tile, dessen Modell eine flache 1x1-Fläche ist."""
|
||||
"""Most frequent tile whose model is a flat 1x1 surface."""
|
||||
from collections import Counter
|
||||
flat = set()
|
||||
for i, vs in enumerate(models):
|
||||
@@ -54,7 +54,7 @@ def find_floor_tile(m, models, force=None):
|
||||
min(v[2] for v in vs) <= -0.49 and max(v[2] for v in vs) >= 0.49:
|
||||
flat.add(i)
|
||||
c = Counter(mi for t, mi in zip(m['tiles'], m['tilemodel']) if mi in flat and not t[5])
|
||||
# begehbare Böden liegen bei Modell-y = +0.25 (Strats stehen auf Tile-y + 0.25)
|
||||
# walkable floors sit at model y = +0.25 (strats stand on tile y + 0.25)
|
||||
best = [k for k, _ in c.most_common() if abs(models[k][0][1] - 0.25) < 1e-3]
|
||||
mdl = force if force is not None else (best or [c.most_common(1)[0][0]])[0]
|
||||
idx = next(i for i, (t, mi) in enumerate(zip(m['tiles'], m['tilemodel'])) if mi == mdl and not t[5])
|
||||
@@ -68,7 +68,7 @@ def empty_rect(m, w, h):
|
||||
if all((x, z) not in occ for x in range(x0 - 1, x0 + w + 1)
|
||||
for z in range(z0 - 1, z0 + h + 1)):
|
||||
return x0, z0
|
||||
raise SystemExit(f'kein freier Bereich {w}x{h} im Raster {m["W"]}x{m["H"]}')
|
||||
raise SystemExit(f'no free {w}x{h} area in the {m["W"]}x{m["H"]} grid')
|
||||
|
||||
|
||||
def main():
|
||||
@@ -78,8 +78,8 @@ def main():
|
||||
ap.add_argument('--spacing', type=int, default=3)
|
||||
ap.add_argument('--keep-original', action='store_true')
|
||||
ap.add_argument('--name')
|
||||
ap.add_argument('--floor-model', type=int, help='TRAK-Modellindex für Bodenplatten')
|
||||
ap.add_argument('--y', type=float, default=None, help='Höhe der Bodenplatte (Tile-y)')
|
||||
ap.add_argument('--floor-model', type=int, help='TRAK model index for floor plates')
|
||||
ap.add_argument('--y', type=float, default=None, help='height of the floor plate (tile y)')
|
||||
a = ap.parse_args()
|
||||
|
||||
ch, wf, m, lo, types = S.level_types(a.src)
|
||||
@@ -90,13 +90,13 @@ def main():
|
||||
cols = 6
|
||||
rows = (len(names) + cols - 1) // cols
|
||||
sp = a.spacing
|
||||
gw, gh = cols * sp + 2, rows * sp + 4 # Raster + Startstreifen
|
||||
pk = 14 # Parkour-Streifen
|
||||
gw, gh = cols * sp + 2, rows * sp + 4 # grid + start strip
|
||||
pk = 14 # parkour strip
|
||||
x0, z0 = empty_rect(m, gw + pk, gh)
|
||||
base = m['tiles'][ftile]
|
||||
y = a.y if a.y is not None else base[1]
|
||||
print(f'Boden-Tile #{ftile} Modell {fmodel} (Fläche y+{fsurf}), Bereich x{x0}..{x0 + gw + pk} '
|
||||
f'z{z0}..{z0 + gh}, Höhe {y}')
|
||||
print(f'floor tile #{ftile} model {fmodel} (surface y+{fsurf}), area x{x0}..{x0 + gw + pk} '
|
||||
f'z{z0}..{z0 + gh}, height {y}')
|
||||
|
||||
def add_tile(cx, cz, ty):
|
||||
m['tiles'].append((cx + 0.5, ty, -(cz + 0.5), base[3], fmodel, 0))
|
||||
@@ -107,7 +107,7 @@ def main():
|
||||
for cx in range(x0, x0 + gw):
|
||||
for cz in range(z0, z0 + gh):
|
||||
add_tile(cx, cz, y)
|
||||
# Parkour: Trittplatten mit Lücken, je +0.5 höher, dann Zielplattform 3x3
|
||||
# parkour: stepping plates with gaps, each +0.5 higher, then a 3x3 goal platform
|
||||
px, pz, py = x0 + gw + 1, z0 + 1, y
|
||||
steps = []
|
||||
for i in range(6):
|
||||
@@ -151,7 +151,7 @@ def main():
|
||||
s[8] = len(m['list1'])
|
||||
m['list1'].extend(m['_list1_orig'][pi:pi + s[7]])
|
||||
new.append(tuple(s))
|
||||
# Spieler an den Start (Rand des Rasters, Blick ins Raster)
|
||||
# player at the start (edge of the grid, facing into it)
|
||||
if PLAYER in types:
|
||||
new = [s for s in new if S.strat_name(ch['STPC'], s[6], lo) != PLAYER]
|
||||
new.insert(0, srec(types[PLAYER][0], x0 + 1, z0 + 1, y, ry=types[PLAYER][0][1]))
|
||||
@@ -161,7 +161,7 @@ def main():
|
||||
cz = z0 + 3 + (k // cols) * sp
|
||||
new.append(srec(types[nm][0], cx, cz, y))
|
||||
placed.append((nm, cx, cz))
|
||||
# Münzen auf dem Parkour, Ausgang (LevelDoor) auf der Zielplattform
|
||||
# coins on the parkour, exit (LevelDoor) on the goal platform
|
||||
if 'Coin' in types:
|
||||
for (cx, cz, ty) in steps:
|
||||
new.append(srec(types['Coin'][0], cx, cz, ty + 0.5))
|
||||
@@ -170,7 +170,7 @@ def main():
|
||||
m['strats'] = new
|
||||
m['strat_x'] = max(m['strat_x'], len(new) + 64)
|
||||
m['vcol'] = b''.join(m['vcol_split'])
|
||||
for i, t in enumerate(m['tiles']): # Konsistenz
|
||||
for i, t in enumerate(m['tiles']): # consistency
|
||||
assert 0 <= int(t[0] - 0.5) < m['W'] and 0 <= int(-t[2] - 0.5) < m['H'], (i, t)
|
||||
|
||||
data = M.build(m, wf)
|
||||
@@ -183,9 +183,9 @@ def main():
|
||||
c = wadtool.get(wch, 'NAME')
|
||||
c[1] = c[1][:4] + a.name.encode('latin1') + b'\0'
|
||||
wadtool.write(a.dst, wch)
|
||||
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} Typen im Raster) -> {a.dst}')
|
||||
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} types in the grid) -> {a.dst}')
|
||||
for nm, cx, cz in placed:
|
||||
print(f' {nm:30s} Zelle ({cx},{cz})')
|
||||
print(f' {nm:30s} cell ({cx},{cz})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MAP-Chunk eines Level-WADs ausgeben.
|
||||
"""Dump the MAP chunk of a level WAD.
|
||||
map_dump.py level.wad [--strats] [--tiles] [--lights] [--check]
|
||||
"""
|
||||
import os
|
||||
|
||||
+14
-14
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MAP-Chunk von Level-WADs parsen/schreiben (Lama/Croc-2-Engine, PC).
|
||||
"""Parse/write the MAP chunk of level WADs (Lama/Croc 2 engine, PC).
|
||||
|
||||
Nachgebaut aus groove.exe FUN_0042ac50 (MAP-Lader). Hängt ab von
|
||||
WFPC-Flags (u32 im WFPC-Chunk, Bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
|
||||
TRAK-Modell-Vertexzahlen (Vertexfarben-Block).
|
||||
Siehe notes/objects.md.
|
||||
Reimplemented from groove.exe FUN_0042ac50 (MAP loader). Depends on
|
||||
WFPC flags (u32 in the WFPC chunk, bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
|
||||
TRAK model vertex counts (vertex colour block).
|
||||
See notes/objects.md.
|
||||
"""
|
||||
import struct
|
||||
|
||||
@@ -27,16 +27,16 @@ class R:
|
||||
|
||||
|
||||
def trak_vertcounts(trak):
|
||||
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = Vertexzahl), dann Daten."""
|
||||
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = vertex count), then data."""
|
||||
n = struct.unpack_from('<I', trak, 0)[0]
|
||||
return [struct.unpack_from('<H', trak, 4 + i * 0x84 + 0x6c)[0] for i in range(n)]
|
||||
|
||||
|
||||
# Feldnamen
|
||||
# field names
|
||||
TILE = ('x', 'y', 'z', 'rot', 'b', 'alt') # 3 f32 + 3 u32
|
||||
LIGHT = '11I3IH4I2IHHI' # 90 Byte
|
||||
PATHNODE = 'IIHHH5I' # 34 Byte
|
||||
STRAT = '3H3iIIi4Iiih' + 'H' # 58 Byte (s.u.)
|
||||
LIGHT = '11I3IH4I2IHHI' # 90 bytes
|
||||
PATHNODE = 'IIHHH5I' # 34 bytes
|
||||
STRAT = '3H3iIIi4Iiih' + 'H' # 58 bytes (see below)
|
||||
|
||||
|
||||
def parse(d, wfpc, vcounts):
|
||||
@@ -45,7 +45,7 @@ def parse(d, wfpc, vcounts):
|
||||
nt, W, H = r.f('3I')
|
||||
m['W'], m['H'] = W, H
|
||||
m['tiles'] = [r.f('3f3I') for _ in range(nt)]
|
||||
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # von Strats per Index referenziert
|
||||
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # referenced by strats via index
|
||||
n = r.u32(); m['lights'] = [r.f(LIGHT) for _ in range(n)]
|
||||
n = r.u32(); m['nodes'] = [r.f(PATHNODE) for _ in range(n)]
|
||||
m['tab32'] = r.f('64I') # 32 x (u32,u32) -> DAT_006da350
|
||||
@@ -56,11 +56,11 @@ def parse(d, wfpc, vcounts):
|
||||
if wfpc & 0x10000:
|
||||
n = r.u32(); alts = [r.f('5I') for _ in range(n)]
|
||||
m['alts'] = alts
|
||||
# Vertexfarben: je Tile nv u32 (Byte3 = Frames), ggf. weitere Frames; dann Alt-Modell
|
||||
# vertex colours: nv u32 per tile (byte 3 = frames), more frames if any; then alt model
|
||||
altidx = [i for i, t in enumerate(m['tiles']) if t[5]]
|
||||
altmap = dict(zip(altidx, alts))
|
||||
start = r.o
|
||||
split = [] # Vertexfarben-Bytes je Tile (inkl. Alt-Modell)
|
||||
split = [] # vertex colour bytes per tile (incl. alt model)
|
||||
for i in range(nt):
|
||||
t0 = r.o
|
||||
for mdl in [m['tilemodel'][i]] + ([altmap[i][4]] if i in altmap else []):
|
||||
@@ -96,7 +96,7 @@ def parse(d, wfpc, vcounts):
|
||||
|
||||
|
||||
def build(m, wfpc):
|
||||
"""Gegenstück zu parse(); m['vcol'] muss zu tilemodel/alts passen (Rohbytes)."""
|
||||
"""Counterpart of parse(); m['vcol'] must match tilemodel/alts (raw bytes)."""
|
||||
P = struct.pack
|
||||
o = [P('<3I', len(m['tiles']), m['W'], m['H'])]
|
||||
o += [P('<3f3I', *t) for t in m['tiles']]
|
||||
|
||||
+8
-8
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Packt ein Bild als Argonaut-.cor (Gegenstück zu cor2png.py).
|
||||
"""Packs an image as Argonaut .cor (counterpart of cor2png.py).
|
||||
|
||||
Das Bild wird auf 640x480 RGB gebracht und mit dem LZ-Schema des Spiels
|
||||
komprimiert:
|
||||
n < 0x80 -> n Bytes Rohdaten folgen (n = 1..127)
|
||||
1LLL oooo oooooooo -> kopiere L+3 Bytes (3..10) ab o Bytes (1..4095) zurück
|
||||
Aufruf: png2cor.py eingabe.png ausgabe.cor (benötigt Pillow)
|
||||
The image is converted to 640x480 RGB and compressed with the game's LZ
|
||||
scheme:
|
||||
n < 0x80 -> n bytes of raw data follow (n = 1..127)
|
||||
1LLL oooo oooooooo -> copy L+3 bytes (3..10) from o bytes (1..4095) back
|
||||
Usage: png2cor.py input.png output.cor (requires Pillow)
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
@@ -17,7 +17,7 @@ MAXOFF, MINLEN, MAXLEN = 0x0FFF, 3, 10
|
||||
def pack(raw):
|
||||
out = bytearray()
|
||||
lit = bytearray()
|
||||
last = {} # 3-Byte-Präfix -> Liste letzter Positionen
|
||||
last = {} # 3-byte prefix -> list of recent positions
|
||||
|
||||
def flush():
|
||||
for k in range(0, len(lit), 127):
|
||||
@@ -65,4 +65,4 @@ if __name__ == '__main__':
|
||||
img = Image.open(sys.argv[1]).convert('RGB').resize((640, 480), Image.LANCZOS)
|
||||
data = pack(img.tobytes())
|
||||
open(sys.argv[2], 'wb').write(data)
|
||||
print(f'{sys.argv[2]}: {len(data)} Bytes')
|
||||
print(f'{sys.argv[2]}: {len(data)} bytes')
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""STPC grob durchlaufen (nur Grenzen): Modelle, Animsets, Flag-0x100-Block, dann Strat-Code.
|
||||
Nachgebaut aus FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
|
||||
"""Rough STPC walk (boundaries only): models, animsets, flag-0x100 block, then strat code.
|
||||
Reimplemented from FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
|
||||
import struct
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ def animsets(d, o, n):
|
||||
def walk(d, wfpc):
|
||||
n = u32(d, 0)
|
||||
o, mdl = 4, []
|
||||
for _ in range(n): # FUN_0041f770(&p,1,..) je Modell: Header direkt gefolgt von Daten
|
||||
for _ in range(n): # FUN_0041f770(&p,1,..) per model: header directly followed by data
|
||||
o, mm = models(d, o, 1)
|
||||
mdl += mm
|
||||
na = u32(d, o); o += 4
|
||||
for _ in range(na): # FUN_0041f8b0(&p,1,..) je Animset
|
||||
for _ in range(na): # FUN_0041f8b0(&p,1,..) per animset
|
||||
o = animsets(d, o, 1)
|
||||
extra = []
|
||||
if wfpc & 0x100:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tabelle Strat-Typ x Level (Instanzzahl) über alle Level-WADs.
|
||||
"""Table strat type x level (instance count) across all level WADs.
|
||||
strat_table.py wads_dir > table.md"""
|
||||
import glob
|
||||
import os
|
||||
@@ -25,11 +25,11 @@ for w in wads:
|
||||
for o, nm in strats.all_named(ch['STPC'], lo):
|
||||
avail[nm].add(name)
|
||||
short = [l[1:].replace('i0m', '/').replace('l', '').replace('m00', '/') for l in lv]
|
||||
print('| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |')
|
||||
print('| Type | Level (MAP instances) | Σ | in code of n levels |')
|
||||
print('|---|---|---|---|')
|
||||
for nm in sorted(tab, key=lambda k: (-len(tab[k]), k)):
|
||||
cells = ' '.join(f'{l[1:]}:{tab[nm][l]}' for l in lv if l in tab[nm])
|
||||
base = nm.replace('(spawn)', '')
|
||||
print(f'| {nm} | {cells} | {sum(tab[nm].values())} | {len(avail.get(base, ()))} |')
|
||||
print()
|
||||
print('Nur im Code (nie direkt in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
|
||||
print('Only in code (never placed directly in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strat-Typen (Objekttypen) eines Level-WADs: Namen aus STPC-Strat-Code, MAP-Instanzen.
|
||||
"""Strat types (object types) of a level WAD: names from the STPC strat code, MAP instances.
|
||||
|
||||
Strat-Code liegt am Ende von STPC (nach Modellen/Animsets). Bytecode = u32-Wörter
|
||||
(low16 = Opcode, high16 = Kurzoperand). Ein Strat beginnt (fast immer) mit
|
||||
... 0xd4, 0xea, <s32 rel> -> Name = C-String bei (Adresse nach rel) + rel
|
||||
Strat code sits at the end of STPC (after models/animsets). Bytecode = u32 words
|
||||
(low16 = opcode, high16 = short operand). A strat (almost always) starts with
|
||||
... 0xd4, 0xea, <s32 rel> -> name = C string at (address after rel) + rel
|
||||
strats.py level.wad [--all]
|
||||
"""
|
||||
import os
|
||||
@@ -21,7 +21,7 @@ def cstr(d, o):
|
||||
|
||||
|
||||
def strat_name(st, entry, lo):
|
||||
"""Name eines MAP-Einstiegs: Spawn-Stub (b2 <abs> e0 ...) -> Ziel; sonst erstes d4/ea ab entry."""
|
||||
"""Name of a MAP entry: spawn stub (b2 <abs> e0 ...) -> target; otherwise first d4/ea from entry."""
|
||||
w = struct.unpack_from('<8I', st, entry)
|
||||
for k in range(5):
|
||||
if w[k] == 0xb2 and w[k + 2] == 0xe0 and lo <= w[k + 1] < len(st):
|
||||
@@ -41,7 +41,7 @@ def strat_name(st, entry, lo):
|
||||
|
||||
|
||||
def all_named(st, lo):
|
||||
"""Alle d4/ea-Namensstellen im Code-Bereich (Wort-Raster ab lo)."""
|
||||
"""All d4/ea name sites in the code region (word grid from lo)."""
|
||||
out = []
|
||||
for o in range(lo, len(st) - 12, 4):
|
||||
a, b = struct.unpack_from('<II', st, o)
|
||||
@@ -74,6 +74,6 @@ if __name__ == '__main__':
|
||||
f'flags={s[11]:#x} extra={s[12]:#x}')
|
||||
if '--all' in sys.argv:
|
||||
used = {s[6] for v in types.values() for s in v}
|
||||
print('-- alle benannten Strats im Code (Namens-Op-Adresse):')
|
||||
print('-- all named strats in the code (name op address):')
|
||||
for o, n in all_named(ch['STPC'], lo):
|
||||
print(f' {o:#x} {n}')
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Textur-Kontaktabzug: alle (oder nur die von der Welt-Geometrie benutzten) Texturen eines
|
||||
Level-WADs als Raster mit Index-Beschriftung -> zum Aussuchen von Texturen fuer levelbuild.py.
|
||||
"""Texture contact sheet: all textures of a level WAD (or only those used by the world
|
||||
geometry) as a grid labelled with their index -> for picking textures for levelbuild.py.
|
||||
|
||||
texsheet.py level.wad out.png [--all] [--size 64] [--cols 16] [--only 12,40,77]
|
||||
|
||||
Ohne --all: nur Texturen, die in TRAK (Welt-Kacheln) vorkommen, sortiert nach Haeufigkeit.
|
||||
Beschriftung: Index, darunter Nutzung O=oben (Boden) / S=Seite (Wand) / U=unten, Groesse in px.
|
||||
Flachfarben (Flag Bit0) werden als Farbfeld gezeigt ("F").
|
||||
Without --all: only textures that occur in TRAK (world tiles), sorted by frequency.
|
||||
Label: index, below it usage O=top (floor) / S=side (wall) / U=bottom, size in px.
|
||||
Flat colours (flag bit 0) are shown as a colour swatch ("F").
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
@@ -43,7 +43,7 @@ def main():
|
||||
ap.add_argument('--all', action='store_true')
|
||||
ap.add_argument('--size', type=int, default=64)
|
||||
ap.add_argument('--cols', type=int, default=16)
|
||||
ap.add_argument('--only', help='kommagetrennte Indizes')
|
||||
ap.add_argument('--only', help='comma-separated indices')
|
||||
a = ap.parse_args()
|
||||
ch = G.wad_read(a.wad)
|
||||
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
|
||||
@@ -64,7 +64,7 @@ def main():
|
||||
for k, i in enumerate(idx):
|
||||
t = texs[i]
|
||||
x, y = (k % C) * cw + 4, (k // C) * chh + 2
|
||||
# Schachbrett fuer Transparenz
|
||||
# checkerboard for transparency
|
||||
bg = Image.new('RGB', (S, S), (90, 90, 90))
|
||||
bd = ImageDraw.Draw(bg)
|
||||
for yy in range(0, S, 8):
|
||||
@@ -82,7 +82,7 @@ def main():
|
||||
d.text((x + S // 2 + 4, y + S + 1), tag, fill=(140, 220, 255))
|
||||
d.text((x, y + S + 14), f'{w}x{h}', fill=(170, 170, 170))
|
||||
sheet.save(a.out)
|
||||
print(f'{a.out}: {len(idx)} Texturen, {sheet.size[0]}x{sheet.size[1]}')
|
||||
print(f'{a.out}: {len(idx)} textures, {sheet.size[0]}x{sheet.size[1]}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TEXT-Chunk -> PNG-Seiten (+ texinfo.txt) und zurück.
|
||||
"""TEXT chunk -> PNG pages (+ texinfo.txt) and back.
|
||||
text_export.py export level.wad outdir # pageNN_fXX.png, texinfo.txt
|
||||
text_export.py import level.wad outdir neu.wad # geänderte PNGs zurückschreiben
|
||||
text_export.py import level.wad outdir new.wad # write modified PNGs back
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
@@ -33,7 +33,7 @@ def main():
|
||||
f.write('# idx flags page clut x0 x1 y0 y1\n')
|
||||
for i, e in enumerate(T.tex_entries(t['tail'])):
|
||||
f.write(f'{i} {e[0]:#06x} {e[1]} {e[2]:#04x} {e[3]} {e[4]} {e[5]} {e[6]}\n')
|
||||
print(f'{len(t["pages"])} Seiten, {len(T.tex_entries(t["tail"]))} Texturen -> {out}')
|
||||
print(f'{len(t["pages"])} pages, {len(T.tex_entries(t["tail"]))} textures -> {out}')
|
||||
elif cmd == 'import':
|
||||
for fn in glob.glob(f'{out}/page*.png'):
|
||||
i = int(re.search(r'page(\d+)', fn).group(1))
|
||||
@@ -41,13 +41,13 @@ def main():
|
||||
img = np.asarray(Image.open(fn).convert('RGBA'))
|
||||
assert img.shape[:2] == (p['h'], p['w']), fn
|
||||
new = T.rgba_to_abgr1555(img)
|
||||
# unveränderte Pixel exakt erhalten (Rundung 8->5 Bit verlustfrei rückwärts)
|
||||
# keep unchanged pixels exactly (8->5 bit rounding is not lossless in reverse)
|
||||
old = T.abgr1555_to_rgba(p['px'])
|
||||
same = (old == img).all(-1)
|
||||
p['px'] = np.where(same, p['px'], new).astype(np.uint16)
|
||||
tc[1] = T.build(t)
|
||||
wadtool.write(sys.argv[4], ch)
|
||||
print('geschrieben:', sys.argv[4])
|
||||
print('written:', sys.argv[4])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+16
-16
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TEXT-Chunk (Texturen) von Level-WADs lesen/schreiben.
|
||||
"""Read/write the TEXT chunk (textures) of level WADs.
|
||||
|
||||
Aufbau (aus groove.exe 0x4067b0):
|
||||
Layout (from groove.exe 0x4067b0):
|
||||
u32 nPal; nPal x { u32 id, u32 n, u16 col[n], u8 extra[n] }
|
||||
u32 nPages; nPages x { u32 flags, u32 w, u32 h, Daten }
|
||||
flags==0 : w*h Byte (8-Bit, Palette)
|
||||
flags&0x80 : u32 Länge, RLE16 (s16 n: n>0 -> n Literal-u16, n<0 -> nächstes u16 (-n)-mal)
|
||||
sonst : w*h u16 roh
|
||||
u32 nTex; Rest = Textur-Einträge (8 Byte) + Anhang (siehe notes/textures.md)
|
||||
u32 nPages; nPages x { u32 flags, u32 w, u32 h, data }
|
||||
flags==0 : w*h bytes (8-bit, palette)
|
||||
flags&0x80 : u32 length, RLE16 (s16 n: n>0 -> n literal u16, n<0 -> next u16 repeated -n times)
|
||||
otherwise : w*h u16 raw
|
||||
u32 nTex; rest = texture entries (8 bytes) + appendix (see notes/textures.md)
|
||||
"""
|
||||
import struct
|
||||
import numpy as np
|
||||
@@ -33,10 +33,10 @@ def rle_encode(px):
|
||||
j = i
|
||||
while j + 1 < N and px[j + 1] == px[i] and j - i < 32766:
|
||||
j += 1
|
||||
if j > i: # Lauf >= 2
|
||||
if j > i: # run >= 2
|
||||
out += [(-(j - i + 1)) & 0xffff, int(px[i])]; i = j + 1
|
||||
continue
|
||||
k = i # Literale bis zum nächsten Lauf
|
||||
k = i # literals up to the next run
|
||||
while k < N and k - i < 32767 and not (k + 1 < N and px[k + 1] == px[k]):
|
||||
k += 1
|
||||
out += [k - i] + [int(x) for x in px[i:k]]; i = k
|
||||
@@ -85,9 +85,9 @@ def build(t, compress=True):
|
||||
|
||||
|
||||
def abgr1555_to_rgba(px):
|
||||
"""PS1-Format: Bits 0-4 R, 5-9 G, 10-14 B, Bit 15 = STP.
|
||||
0x0000 = durchsichtig (Alpha 0). STP=1 -> Alpha 255, STP=0 (Pixel != 0) -> Alpha 128
|
||||
(auf Seiten mit Blendmodus = halbtransparent)."""
|
||||
"""PS1 format: bits 0-4 R, 5-9 G, 10-14 B, bit 15 = STP.
|
||||
0x0000 = transparent (alpha 0). STP=1 -> alpha 255, STP=0 (pixel != 0) -> alpha 128
|
||||
(semi-transparent on pages with a blend mode)."""
|
||||
px = px.astype(np.uint32)
|
||||
r = (px & 31) * 255 // 31
|
||||
g = ((px >> 5) & 31) * 255 // 31
|
||||
@@ -97,13 +97,13 @@ def abgr1555_to_rgba(px):
|
||||
|
||||
|
||||
def rgba_to_abgr1555(img):
|
||||
"""Umkehrung: Alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (oder RGB-Bild) -> STP=1."""
|
||||
"""Inverse: alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (or RGB image) -> STP=1."""
|
||||
img = np.asarray(img).astype(np.uint32)
|
||||
v = ((img[..., 2] >> 3) << 10) | ((img[..., 1] >> 3) << 5) | (img[..., 0] >> 3)
|
||||
if img.shape[-1] == 4:
|
||||
a = img[..., 3]
|
||||
v = np.where(a >= 192, v | 0x8000, v)
|
||||
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # fast schwarz statt durchsichtig
|
||||
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # near-black instead of transparent
|
||||
v = np.where(a < 64, 0, v)
|
||||
else:
|
||||
v = v | 0x8000
|
||||
@@ -111,7 +111,7 @@ def rgba_to_abgr1555(img):
|
||||
|
||||
|
||||
def tex_entries(tail):
|
||||
"""Textur-Einträge (Faces referenzieren diese Indizes): u16 flags, u8 page, u8 clut(0xff),
|
||||
u8 x0, u8 x1, u8 y0, u8 y1 (inklusive Kanten)."""
|
||||
"""Texture entries (faces reference these indices): u16 flags, u8 page, u8 clut(0xff),
|
||||
u8 x0, u8 x1, u8 y0, u8 y1 (edges inclusive)."""
|
||||
n = struct.unpack_from('<I', tail, 0)[0]
|
||||
return [struct.unpack_from('<H6B', tail, 4 + 8 * i) for i in range(n)]
|
||||
@@ -1,4 +1,5 @@
|
||||
# label.py IN.png LABELS.json OUT.jpg "Titel" [breite] -> beschriftete JPG-Vorschau
|
||||
# label.py IN.png LABELS.json OUT.jpg "Title" [width] -> labelled JPG preview
|
||||
# (label names Start/Galerie/Gehege/Parkour/Ziel are the keys written by render_level.py)
|
||||
import sys, json
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
src, lj, out, title = sys.argv[1:5]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Blender 3.0 (headless): level.obj aus geom_export (v x y z r g b, vt, usemtl page_NN/flat_N) laden,
|
||||
# in Spielkoordinaten (Blender x = Spiel-x, y = -Spiel-z, z = Spiel-y) rendern.
|
||||
# Blender 3.0 (headless): load level.obj from geom_export (v x y z r g b, vt, usemtl page_NN/flat_N),
|
||||
# render in game coordinates (Blender x = game x, y = -game z, z = game y).
|
||||
# blender -b -P render_level.py -- OBJDIR OUTPREFIX [samples] [scale%]
|
||||
import bpy, sys, os, json, math
|
||||
from mathutils import Vector
|
||||
@@ -50,10 +50,10 @@ def make_mat(name, d):
|
||||
else:
|
||||
kd = d.get('kd', (1, 1, 1))
|
||||
mul.inputs[1].default_value = (*kd, 1)
|
||||
if kd[2] > 0.5 and kd[0] < 0.3: # Meer
|
||||
if kd[2] > 0.5 and kd[0] < 0.3: # sea
|
||||
bsdf.inputs['Roughness'].default_value = 0.25
|
||||
bsdf.inputs['Specular'].default_value = 0.5
|
||||
# leicht selbstleuchtend, damit die eingebackene Beleuchtung (Vertexfarben) sichtbar bleibt
|
||||
# slightly emissive so the baked lighting (vertex colours) stays visible
|
||||
Lk.new(mul.outputs[0], bsdf.inputs['Base Color'])
|
||||
Lk.new(mul.outputs[0], bsdf.inputs['Emission'])
|
||||
bsdf.inputs['Emission Strength'].default_value = 0.25
|
||||
@@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
|
||||
if ln.startswith('v '):
|
||||
p = ln.split()
|
||||
x, y, z = float(p[1]), float(p[2]), float(p[3])
|
||||
V.append((x, z, y)) # Spiel (x, y, -z) -> Blender (x, -spiel_z, y) = (x, z_obj, y_obj)
|
||||
V.append((x, z, y)) # game (x, y, -z) -> Blender (x, -game_z, y) = (x, z_obj, y_obj)
|
||||
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
|
||||
elif ln.startswith('vt '):
|
||||
p = ln.split(); VT.append((float(p[1]), float(p[2])))
|
||||
@@ -81,7 +81,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
|
||||
q = t.split('/')
|
||||
vi.append(int(q[0]) - 1)
|
||||
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1)
|
||||
faces.append((vi[::-1], ti[::-1], mat)) # Spiegelung -> Umlaufsinn umdrehen
|
||||
faces.append((vi[::-1], ti[::-1], mat)) # mirroring -> reverse winding
|
||||
|
||||
matnames = sorted(set(f[2] for f in faces))
|
||||
mesh = bpy.data.meshes.new('level')
|
||||
@@ -103,7 +103,7 @@ ob = bpy.data.objects.new('level', mesh)
|
||||
sc.collection.objects.link(ob)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Objekt-Marker (Strats aus strats.json)
|
||||
# --------------------------------------------------------------- object markers (strats from strats.json)
|
||||
def flatmat(name, rgb, emit=0.6):
|
||||
m = bpy.data.materials.new(name); m.use_nodes = True
|
||||
b = m.node_tree.nodes['Principled BSDF']
|
||||
@@ -143,7 +143,7 @@ if sj and os.path.exists(sj):
|
||||
mat = RM[r]
|
||||
o = bpy.context.active_object; o.data.materials.append(mat)
|
||||
|
||||
# --------------------------------------------------------------- Licht, Welt
|
||||
# --------------------------------------------------------------- light, world
|
||||
w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True
|
||||
bg = w.node_tree.nodes['Background']
|
||||
bg.inputs[0].default_value = (0.55, 0.72, 0.95, 1); bg.inputs[1].default_value = 0.9
|
||||
@@ -151,7 +151,7 @@ sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 2.6; sun.angle = math.radi
|
||||
so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
|
||||
so.rotation_euler = (math.radians(42), 0, math.radians(-140))
|
||||
|
||||
# Cycles liefert mit dem Debian-Blender 3.0.1 nur Schwarz -> Eevee (braucht X: eigenes Xvfb, z. B. :7)
|
||||
# Cycles renders only black with Debian Blender 3.0.1 -> Eevee (needs X: a separate Xvfb, e.g. :7)
|
||||
sc.render.engine = 'BLENDER_EEVEE'
|
||||
ee = sc.eevee
|
||||
ee.taa_render_samples = samples
|
||||
@@ -164,9 +164,9 @@ try:
|
||||
sc.view_settings.view_transform = 'Filmic'
|
||||
sc.view_settings.look = 'Medium High Contrast'
|
||||
except TypeError:
|
||||
pass # Debian-Blender ohne OCIO-Konfiguration: nur 'Standard'
|
||||
pass # Debian Blender without OCIO config: only 'Standard'
|
||||
|
||||
# Bereiche (Spielkoordinaten x, z, y) fuer die Beschriftung
|
||||
# areas (game coordinates x, z, y) for the labels; keys are the label text (German, as in the level)
|
||||
LABELS = {'Start': (18, 15.5, 2.2), 'Galerie': (23.5, 26.5, 1.2), 'Gehege': (37.5, 25.4, 1.2),
|
||||
'Parkour': (33.0, 15.5, 2.6), 'Ziel': (50.0, 15.5, 6.0)}
|
||||
|
||||
@@ -195,9 +195,9 @@ def shoot(name, loc, look, lens=35, ortho=None, res=(1600, 1000)):
|
||||
|
||||
|
||||
views = os.environ.get('VIEWS', 'schraeg,oben').split(',')
|
||||
if 'schraeg' in views: # von Suedwesten, schraeg oben
|
||||
if 'schraeg' in views: # 'schraeg' = oblique: from the south-west, above
|
||||
shoot('schraeg', (10, -50, 27), (30.5, -21.5, 0), lens=33)
|
||||
if 'schraeg2' in views: # von Nordosten (hinter dem Ziel)
|
||||
if 'schraeg2' in views: # from the north-east (behind the goal)
|
||||
shoot('schraeg2', (60, 2, 22), (29, -21, 0), lens=30)
|
||||
if 'oben' in views:
|
||||
if 'oben' in views: # 'oben' = top view
|
||||
shoot('oben', (31, -21.5, 80), (31, -21.4, 0), ortho=44)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Strat-Positionen eines Levelskripts als JSON (fuer Marker im Blender-Render)
|
||||
# strat positions of a level script as JSON (for markers in the Blender render)
|
||||
import sys, json, os
|
||||
sys.path.insert(0, 'tools')
|
||||
import levelbuild as LB
|
||||
|
||||
+8
-8
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Liest und schreibt Level-WADs von "Ein Königreich für ein Lama" (Argonaut).
|
||||
"""Reads and writes level WADs of "Ein Königreich für ein Lama" (Argonaut).
|
||||
|
||||
Aufbau: u32 Gesamtlänge (ohne dieses Feld), dann Chunks:
|
||||
4 Byte Kennung (rückwärts gespeichert, z. B. 'OFNI' = INFO), u32 Länge, Daten
|
||||
bis zum Chunk 'END '.
|
||||
Layout: u32 total length (excluding this field), then chunks:
|
||||
4-byte tag (stored reversed, e.g. 'OFNI' = INFO), u32 length, data
|
||||
up to the chunk 'END '.
|
||||
|
||||
wadtool.py list level.wad
|
||||
wadtool.py name level.wad neu.wad "Neuer Levelname"
|
||||
wadtool.py extract level.wad CHUNK datei.bin
|
||||
wadtool.py replace level.wad neu.wad CHUNK datei.bin
|
||||
wadtool.py name level.wad new.wad "New level name"
|
||||
wadtool.py extract level.wad CHUNK file.bin
|
||||
wadtool.py replace level.wad new.wad CHUNK file.bin
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
@@ -18,7 +18,7 @@ def read(path):
|
||||
d = open(path, 'rb').read()
|
||||
total = struct.unpack_from('<I', d, 0)[0]
|
||||
if total != len(d) - 4:
|
||||
raise ValueError(f'{path}: Längenfeld {total} passt nicht zu {len(d) - 4}')
|
||||
raise ValueError(f'{path}: length field {total} does not match {len(d) - 4}')
|
||||
chunks, o = [], 4
|
||||
while o + 8 <= len(d):
|
||||
tag = d[o:o + 4][::-1].decode('latin1')
|
||||
|
||||
Reference in new issue
Block a user