730 lines
29 KiB
Python
730 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
|
|
|
|
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
|
|
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
|
|
|
|
debugviz.py IN.wad OUT.wad [Optionen] ein Level
|
|
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle 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
|
|
|
|
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).
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import glob
|
|
import json
|
|
import math
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import geomlib as G # noqa: E402
|
|
import mapchunk # noqa: E402
|
|
import strats as S # noqa: E402
|
|
|
|
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
|
|
|
|
# ------------------------------------------------------------------ Farben
|
|
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
|
|
}
|
|
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
|
|
FILL_ALPHA = 96
|
|
|
|
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus 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)),
|
|
}
|
|
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe 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),
|
|
13: (255, 130, 110), 15: (255, 255, 150), 20: (0, 120, 140), 26: (110, 40, 160), 27: (190, 190, 190),
|
|
28: (255, 0, 140), 33: (150, 0, 0), 255: (20, 20, 20),
|
|
}
|
|
MAT_DEFAULT = (230, 230, 230)
|
|
|
|
MARK_COL = {
|
|
'start': (255, 255, 255),
|
|
'ausgang': (190, 60, 255),
|
|
'checkpoint': (0, 255, 140),
|
|
'kamera': (0, 255, 255),
|
|
'trigger': (255, 255, 0),
|
|
'kraft': (255, 90, 0),
|
|
'geheimnis': (255, 200, 0),
|
|
'sonst': (170, 170, 170),
|
|
'umgebung': (255, 100, 180),
|
|
'objekt': (90, 90, 90),
|
|
}
|
|
|
|
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
|
|
MARK_SIZE = 0.05 # halbe Kantenlaenge
|
|
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
|
|
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
|
|
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
|
|
'SuckingWPs', 'ObjectSuckPoints', 'RiverTrackChange', 'TrackSwitch',
|
|
'BackgroundRotationChanger', 'RiverWhirlpool'}
|
|
MISC_T = {'CheatTeleport', 'Wompy', 'WompySimple', 'WompyRollerCoaster', 'Rain', 'DrippingWater',
|
|
'WaterfallMist', 'Smoker', 'CoinLine', 'CoinCircle', 'CoinRollerCoaster', 'CoinBat',
|
|
'CoinSpawnOracle', 'MusicBox', 'SpitPlatformTarget', 'NewBlockheadKidHelper',
|
|
'FlockOfBirds', 'Bats', 'RiverBats', 'Scorpions1', 'Scorpions3', 'Scorpions5',
|
|
'GhostCatacombs', 'LabDumbGuards', 'FlyStuck', 'AppearingPlatforms', 'AppearingPlatformsCity',
|
|
'AppearingPlatformsJungle', 'AppearingPlatformsLab', 'AppearingPlatformsVillage', 'fat_bird',
|
|
'Column2', 'TipoHelp'}
|
|
TRIG_T = {'ChangeToBunny', 'ChangeToTurtle', 'RiverStartSign'}
|
|
|
|
|
|
def mark_cat(name):
|
|
n = name.replace('(spawn)', '')
|
|
if n in START_T:
|
|
return 'start'
|
|
if n in EXIT_T:
|
|
return 'ausgang'
|
|
if n.startswith('CheckPoint'):
|
|
return 'checkpoint'
|
|
if 'Camera' in n:
|
|
return 'kamera'
|
|
if n == 'Secret':
|
|
return 'geheimnis'
|
|
if n in FORCE_T or n.startswith('Force') or n.startswith('WaterRebound'):
|
|
return 'kraft'
|
|
if 'Trigger' in n or n.startswith('Say') or n in TRIG_T:
|
|
return 'trigger'
|
|
if n in MISC_T or n.endswith('Spawner') or 'Helper' in n:
|
|
return 'sonst'
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
|
|
|
|
def text_append_flats(d, entries):
|
|
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
|
|
o = 0
|
|
npal = struct.unpack_from('<I', d, o)[0]; o += 4
|
|
for _ in range(npal):
|
|
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
|
|
npg = struct.unpack_from('<I', d, o)[0]; o += 4
|
|
for _ in range(npg):
|
|
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
|
|
if fl == 0:
|
|
o += w * h
|
|
elif fl & 0x80:
|
|
o += 4 + struct.unpack_from('<I', d, o)[0]
|
|
else:
|
|
o += w * h * 2
|
|
nt = struct.unpack_from('<I', d, o)[0]
|
|
add = b''
|
|
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
|
|
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
|
|
|
|
|
|
class Palette:
|
|
def __init__(self):
|
|
self.entries = []
|
|
self.idx = {}
|
|
self.base = 0
|
|
|
|
def __call__(self, rgb, alpha=None):
|
|
k = (tuple(rgb), alpha)
|
|
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
|
|
|
|
|
|
# ------------------------------------------------------------------ Geometrie-Helfer
|
|
|
|
def norm(v):
|
|
n = np.linalg.norm(v)
|
|
return v / n if n > 1e-12 else v
|
|
|
|
|
|
class Mesh:
|
|
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
|
|
|
|
def __init__(self):
|
|
self.verts = []
|
|
self.tris = [] # (a, b, c, palidx)
|
|
|
|
def v(self, p, n):
|
|
self.verts.append((float(p[0]), float(p[1]), float(p[2]), float(n[0]), float(n[1]), float(n[2])))
|
|
return len(self.verts) - 1
|
|
|
|
def quad(self, p, n, col):
|
|
"""4 Punkte (Ring), eine Normale."""
|
|
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."""
|
|
a = np.asarray(a, float); b = np.asarray(b, float)
|
|
d = b - a
|
|
L = np.linalg.norm(d)
|
|
if L < 1e-6:
|
|
return
|
|
d /= L
|
|
up = np.array([0, 1.0, 0]) if abs(d[1]) < 0.9 else np.array([1.0, 0, 0])
|
|
s1 = norm(np.cross(d, up)) * (w / 2)
|
|
s2 = norm(np.cross(d, s1)) * (w / 2)
|
|
for s in (s1, s2):
|
|
n = norm(np.cross(d, s))
|
|
self.quad([a - s, b - s, b + s, a + s], n, col)
|
|
|
|
def cube(self, c, h, col):
|
|
c = np.asarray(c, float)
|
|
for ax in range(3):
|
|
for sg in (-1, 1):
|
|
n = np.zeros(3); n[ax] = sg
|
|
u = np.zeros(3); u[(ax + 1) % 3] = h
|
|
v = np.zeros(3); v[(ax + 2) % 3] = h
|
|
f = c + n * h
|
|
self.quad([f - u - v, f + u - v, f + u + v, f - u + v], n, col)
|
|
|
|
|
|
def seg_split(a, b, maxlen=1.0):
|
|
a = np.asarray(a, float); b = np.asarray(b, float)
|
|
n = max(1, int(math.ceil(np.linalg.norm(b - a) / maxlen)))
|
|
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
|
|
|
|
|
|
# ------------------------------------------------------------------ Kollision klassifizieren
|
|
|
|
def kind_of(ny):
|
|
if ny >= 0.7:
|
|
return 'boden'
|
|
if ny >= 0.2:
|
|
return 'schraege'
|
|
if ny > -0.2:
|
|
return 'wand'
|
|
return 'decke'
|
|
|
|
|
|
def decode_model_coll(m):
|
|
out = []
|
|
for g, grp in enumerate(m.coll):
|
|
for r in grp:
|
|
c = G.coll_decode(r)
|
|
pts = G.coll_polygon(c)
|
|
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
|
|
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
|
|
if ar < 1e-5:
|
|
ok = False
|
|
n = norm(np.array(c['plane'][:3]))
|
|
out.append(dict(c=c, pts=P if ok else None, n=n, mat=c['mat'], typ=c['typ'], grp=g,
|
|
kind=kind_of(n[1])))
|
|
return out
|
|
|
|
|
|
class RenderIndex:
|
|
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
|
|
|
|
def __init__(self, trak, mp):
|
|
cells = collections.defaultdict(list)
|
|
A, B, C = [], [], []
|
|
for i in range(mp['N']):
|
|
x, y, z, rot, mi, fl = mp['A'][i]
|
|
m = trak[mi]
|
|
if not m.faces:
|
|
continue
|
|
f, _ = G.tile_xform(x, y, z, rot)
|
|
W = np.array([f(*v[:3]) for v in m.verts])
|
|
for fc in m.faces:
|
|
a, b, c = (W[k] for k in fc['v'])
|
|
A.append(a); B.append(b); C.append(c)
|
|
self.A = np.array(A); self.B = np.array(B); self.C = np.array(C)
|
|
N = np.cross(self.B - self.A, self.C - self.A)
|
|
ln = np.linalg.norm(N, axis=1)
|
|
good = ln > 1e-10
|
|
N[good] /= ln[good, None]
|
|
self.N = N
|
|
self.D = np.einsum('ij,ij->i', N, self.A)
|
|
lo = np.minimum(np.minimum(self.A, self.B), self.C)
|
|
hi = np.maximum(np.maximum(self.A, self.B), self.C)
|
|
for t in np.nonzero(good)[0]:
|
|
for cx in range(int(math.floor(lo[t, 0])), int(math.floor(hi[t, 0])) + 1):
|
|
for cz in range(int(math.floor(lo[t, 2])), int(math.floor(hi[t, 2])) + 1):
|
|
cells[(cx, cz)].append(t)
|
|
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)?"""
|
|
ids = set()
|
|
for q in (p, p + n * dist, p - n * dist):
|
|
k = (int(math.floor(q[0])), int(math.floor(q[2])))
|
|
if k in self.cells:
|
|
ids.update(self.cells[k].tolist())
|
|
if not ids:
|
|
return False
|
|
ids = np.fromiter(ids, int)
|
|
N = self.N[ids]
|
|
cs = N @ n
|
|
sel = np.abs(cs) > cosmin
|
|
if not sel.any():
|
|
return False
|
|
ids, N, cs = ids[sel], N[sel], cs[sel]
|
|
t = (self.D[ids] - N @ p) / cs
|
|
sel = np.abs(t) <= dist
|
|
if not sel.any():
|
|
return False
|
|
ids, t = ids[sel], t[sel]
|
|
q = p[None, :] + t[:, None] * n[None, :]
|
|
a, b, c = self.A[ids], self.B[ids], self.C[ids]
|
|
v0, v1 = b - a, c - a
|
|
v2 = q - a
|
|
d00 = np.einsum('ij,ij->i', v0, v0); d01 = np.einsum('ij,ij->i', v0, v1)
|
|
d11 = np.einsum('ij,ij->i', v1, v1)
|
|
d20 = np.einsum('ij,ij->i', v2, v0); d21 = np.einsum('ij,ij->i', v2, v1)
|
|
den = d00 * d11 - d01 * d01
|
|
den[den == 0] = 1e-12
|
|
v = (d11 * d20 - d01 * d21) / den
|
|
w = (d00 * d21 - d01 * d20) / den
|
|
u = 1 - v - w
|
|
return bool(((u > -tol) & (v > -tol) & (w > -tol)).any())
|
|
|
|
|
|
def classify(trak, mp, decoded):
|
|
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
|
|
ri = RenderIndex(trak, mp)
|
|
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
|
|
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']):
|
|
inst[mp['midx'][i]].append(i)
|
|
if i in altmap:
|
|
inst[altmap[i][4]].append(i)
|
|
for mi, polys in enumerate(decoded):
|
|
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
|
|
for k, pl in enumerate(polys):
|
|
if pl['pts'] is None:
|
|
continue
|
|
P = pl['pts']
|
|
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
|
|
continue
|
|
for ti in tiles:
|
|
x, y, z, rot, _, _ = mp['A'][ti]
|
|
f, fn = G.tile_xform(x, y, z, rot)
|
|
nw = np.array(fn(*pl['n']))
|
|
hit = sum(ri.covered(np.array(f(*s)), nw) for s in samples)
|
|
votes[(mi, k)][0 if hit >= 0.6 * len(samples) else 1] += 1
|
|
for mi, polys in enumerate(decoded):
|
|
for k, pl in enumerate(polys):
|
|
vs = votes.get((mi, k), [1, 0])
|
|
pl['invis'] = vs[1] > vs[0]
|
|
|
|
|
|
# ------------------------------------------------------------------ Overlay je Modell
|
|
|
|
def mat_color(mat, matcols):
|
|
if mat in MAT_INFO:
|
|
return MAT_INFO[mat][1]
|
|
matcols[mat] = MAT_FIXED.get(mat, MAT_DEFAULT)
|
|
return matcols[mat]
|
|
|
|
|
|
def poly_overlay(mesh, pl, pal, opt, matcols):
|
|
P = pl['pts']
|
|
if P is None:
|
|
return
|
|
n = pl['n']
|
|
c = pl['c']
|
|
mat = pl['mat']
|
|
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
|
|
rmin = 1e9
|
|
for k in range(len(P)):
|
|
a, b = P[k], P[(k + 1) % len(P)]
|
|
e = b - a
|
|
L = np.linalg.norm(e)
|
|
if L < 1e-9:
|
|
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
|
|
E = c['edges']
|
|
m = len(E)
|
|
ok = True
|
|
pts2 = []
|
|
for k in range(m):
|
|
e1, e2 = E[k], E[(k + 1) % m]
|
|
Am = np.array([c['plane'][:3], e1[:3], e2[:3]])
|
|
Bm = np.array([c['plane'][3], e1[3] + w * np.linalg.norm(e1[:3]), e2[3] + w * np.linalg.norm(e2[:3])])
|
|
try:
|
|
pts2.append(np.linalg.solve(Am, Bm))
|
|
except np.linalg.LinAlgError:
|
|
ok = False
|
|
break
|
|
if ok and len(pts2) == len(P) and all(np.linalg.norm(pts2[k] - P[k]) < 4 * w + 0.05 for k in range(len(P))):
|
|
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)
|
|
I = [mesh.v(q + off, n) for q in inner]
|
|
ec = pal(ecol)
|
|
for k in range(len(P)):
|
|
k2 = (k + 1) % len(P)
|
|
mesh.tris.append((O[k], O[k2], I[k2], ec))
|
|
mesh.tris.append((O[k], I[k2], I[k], ec))
|
|
fill = None
|
|
if not opt.no_fill:
|
|
if mat == 16:
|
|
fill = mat_color(mat, matcols)
|
|
elif pl['invis'] and mat != 30:
|
|
fill = INVIS_FILL if mat == 0 else mat_color(mat, matcols)
|
|
if fill is not None:
|
|
fc = pal(fill, None if opt.opaque else FILL_ALPHA)
|
|
ids = I
|
|
for k in range(1, len(ids) - 1):
|
|
mesh.tris.append((ids[0], ids[k], ids[k + 1], fc))
|
|
|
|
|
|
def append_mesh(model, mesh, pal_base, limit=7900):
|
|
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
|
|
nv0 = len(model.verts)
|
|
if nv0 + len(mesh.verts) > limit:
|
|
return 0
|
|
model.verts += mesh.verts
|
|
tris = sorted(mesh.tris, key=lambda t: t[3])
|
|
last, run = None, 0
|
|
for a, b, c, col in tris:
|
|
tex = pal_base + col
|
|
fl = 0x0800 | 0x0400 | 0x4000
|
|
if tex != last or run >= 250:
|
|
fl |= 0x8
|
|
last, run = tex, 0
|
|
run += 1
|
|
p0, p1, p2 = (model.verts[nv0 + k][:3] for k in (a, b, c))
|
|
model.faces.append(dict(flags=fl, v=(nv0 + a, nv0 + b, nv0 + c), tex=tex, pad=0,
|
|
plane=G.face_plane(p0, p1, p2)))
|
|
model.dirty = True
|
|
return len(mesh.verts)
|
|
|
|
|
|
# ------------------------------------------------------------------ Marker fuer Objekte
|
|
|
|
def fx(v):
|
|
v = v - (1 << 32) if v >= 1 << 31 else v
|
|
return v / 4096
|
|
|
|
|
|
def strat_markers(ch, wf, pal, stats):
|
|
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
|
|
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)
|
|
per_cat = collections.Counter()
|
|
per_type = collections.Counter()
|
|
|
|
def chain(w):
|
|
out = []
|
|
while 0 <= w < len(nodes) and len(out) < 200:
|
|
nd = nodes[w]
|
|
out.append(np.array([fx(nd[5]), fx(nd[6]), fx(nd[7])]))
|
|
if nd[0] == 0:
|
|
break
|
|
w = nd[0]
|
|
return out
|
|
|
|
def box(lo_, hi_, col, w=0.04):
|
|
x0, y0, z0 = lo_; x1, y1, z1 = hi_
|
|
P = [np.array(p) for p in [(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1),
|
|
(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)]]
|
|
for a, b in [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]:
|
|
segs.append((P[a], P[b], w, col))
|
|
|
|
for s in mm['strats']:
|
|
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)
|
|
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
|
|
per_cat[cat] += 1
|
|
per_type[name] += 1
|
|
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
|
|
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
|
|
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))
|
|
segs.append((g + d * 0.4, g + d * 0.2 + q * 0.15, 0.025, col))
|
|
segs.append((g + d * 0.4, g + d * 0.2 - q * 0.15, 0.025, col))
|
|
else:
|
|
cubes.append((top, MARK_SIZE, col))
|
|
segs.append((p, top, 0.01, col))
|
|
if cat in ('kamera', 'checkpoint'):
|
|
d = np.array([math.sin(rot), 0, math.cos(rot)])
|
|
segs.append((top, top + d * 0.3, 0.012, col))
|
|
if cat == 'ausgang':
|
|
cx, cz = math.floor(p[0]), math.floor(p[2])
|
|
box((cx, p[1] - 0.05, cz), (cx + 1, p[1] + 1.0, cz + 1), col, 0.02)
|
|
ch_ = chain(s[13]) if s[13] >= 0 else []
|
|
if len(ch_) == 2:
|
|
a, b = ch_
|
|
lo_ = np.minimum(a, b); hi_ = np.maximum(a, b)
|
|
inside = (lo_[0] - 0.4 <= p[0] <= hi_[0] + 0.4) and (lo_[2] - 0.4 <= p[2] <= hi_[2] + 0.4)
|
|
if inside and hi_[0] - lo_[0] > 0.2 and hi_[2] - lo_[2] > 0.2:
|
|
y0 = min(lo_[1], p[1]) - 0.02
|
|
y1 = max(hi_[1], p[1]) + 1.2
|
|
box((lo_[0], y0, lo_[2]), (hi_[0], y1, hi_[2]), col, 0.02)
|
|
stats['boxen'] += 1
|
|
if (hi_[0] - lo_[0]) * (hi_[2] - lo_[2]) <= 64:
|
|
quads.append(([(lo_[0], y0 + 0.03, lo_[2]), (hi_[0], y0 + 0.03, lo_[2]),
|
|
(hi_[0], y0 + 0.03, hi_[2]), (lo_[0], y0 + 0.03, hi_[2])], col))
|
|
continue
|
|
if ch_ and cat in ('kamera', 'trigger', 'kraft', 'sonst', 'geheimnis', 'checkpoint', 'start'):
|
|
dim = tuple(int(c * 0.6) for c in col)
|
|
prev = p + [0, 0.45, 0]
|
|
for k, q in enumerate(ch_):
|
|
cubes.append((q, 0.025, dim))
|
|
if cat == 'kraft':
|
|
segs.append((prev, q, 0.008, dim))
|
|
prev = q
|
|
stats['pfadpunkte'] += len(ch_)
|
|
|
|
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
|
|
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)
|
|
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)
|
|
cells = collections.defaultdict(Mesh)
|
|
|
|
def cell_of(q):
|
|
return (int(math.floor(q[0])), int(math.floor(q[2])))
|
|
|
|
def ex(q):
|
|
return np.array([q[0], q[1], -q[2]])
|
|
for a, b, w, col in segs:
|
|
for s0, s1 in seg_split(a, b, 1.0):
|
|
m = (s0 + s1) / 2
|
|
cells[cell_of(m)].beam(ex(s0), ex(s1), w, pal(col))
|
|
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
|
|
(x0, y, z0), _, (x1, _, z1), _ = pts
|
|
xs = np.arange(math.floor(x0), math.ceil(x1))
|
|
zs = np.arange(math.floor(z0), math.ceil(z1))
|
|
for cx in xs:
|
|
for cz in zs:
|
|
a0, a1 = max(x0, cx), min(x1, cx + 1)
|
|
b0, b1 = max(z0, cz), min(z1, cz + 1)
|
|
if a1 - a0 < 1e-3 or b1 - b0 < 1e-3:
|
|
continue
|
|
ms = cells[(int(cx), int(cz))]
|
|
q = [ex((a0, y, b0)), ex((a1, y, b0)), ex((a1, y, b1)), ex((a0, y, b1))]
|
|
ids = [ms.v(t, (0, 1, 0)) for t in q]
|
|
fc = pal(col, 70)
|
|
ms.tris.append((ids[0], ids[1], ids[2], fc))
|
|
ms.tris.append((ids[0], ids[2], ids[3], fc))
|
|
stats['marker_kat'] = dict(per_cat)
|
|
stats['marker_typen'] = dict(per_type)
|
|
return cells
|
|
|
|
|
|
# ------------------------------------------------------------------ Hauptablauf
|
|
|
|
def process(src, dst, opt):
|
|
ch = G.wad_read(src)
|
|
wf = G.wfpc_flags(ch)
|
|
trak = G.parse_trak(G.wad_get(ch, 'TRAK'))
|
|
old_vc = [len(m.verts) for m in trak]
|
|
mp = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc)
|
|
pal = Palette()
|
|
matcols = {}
|
|
stats = collections.Counter()
|
|
info = dict(level=os.path.basename(src))
|
|
mats = collections.Counter()
|
|
add_v = [0] * len(trak)
|
|
if not opt.no_coll:
|
|
decoded = [decode_model_coll(m) for m in trak]
|
|
classify(trak, mp, decoded)
|
|
# Statistik je Kachelinstanz
|
|
inst = collections.Counter(mp['midx'])
|
|
for mi, polys in enumerate(decoded):
|
|
for pl in polys:
|
|
mats[pl['mat']] += 1
|
|
stats['koll_polys'] += 1
|
|
if pl['pts'] is None:
|
|
stats['koll_kaputt'] += 1
|
|
elif pl['invis']:
|
|
stats['koll_unsichtbar'] += 1
|
|
stats['koll_unsichtbar_inst'] += inst.get(mi, 0)
|
|
for mi, m in enumerate(trak):
|
|
mesh = Mesh()
|
|
for pl in decoded[mi]:
|
|
poly_overlay(mesh, pl, pal, opt, matcols)
|
|
add_v[mi] = len(mesh.verts)
|
|
mesh_store = mesh
|
|
trak[mi]._ov = mesh_store
|
|
# Marker
|
|
cells = {}
|
|
if not opt.no_markers:
|
|
cells = strat_markers(ch, wf, pal, stats)
|
|
# TEXT erweitern
|
|
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
|
|
G.wad_set(ch, 'TEXT', text)
|
|
for mi, m in enumerate(trak):
|
|
ov = getattr(m, '_ov', None)
|
|
if ov is not None and ov.verts:
|
|
got = append_mesh(m, ov, base)
|
|
if got == 0:
|
|
stats['modelle_zu_gross'] += 1
|
|
add_v[mi] = 0
|
|
# neue Marker-Kacheln
|
|
W, H = mp['W'], mp['H']
|
|
new_tiles = []
|
|
for (cx, cz), mesh in sorted(cells.items()):
|
|
if not mesh.verts:
|
|
continue
|
|
if not (0 <= cx < W and 0 <= cz < H):
|
|
stats['marker_ausserhalb'] += 1
|
|
continue
|
|
ys = [v[1] for v in mesh.verts]
|
|
oy = round((min(ys) + max(ys)) / 2 * 4) / 4
|
|
ox, oz = cx + 0.5, -(cz + 0.5)
|
|
mdl = G.Model()
|
|
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
|
|
if len(loc.verts) > 7900:
|
|
stats['marker_gekuerzt'] += 1
|
|
keep = [t for t in loc.tris if max(t[:3]) < 7900]
|
|
loc.tris = keep
|
|
loc.verts = loc.verts[:7900]
|
|
append_mesh(mdl, loc, base)
|
|
trak.append(mdl)
|
|
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
|
|
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
|
|
tiles = []
|
|
for i in range(mp['N']):
|
|
x, y, z, rot, mi, fl = mp['A'][i]
|
|
ext = [0x00808080] * add_v[mi]
|
|
t = dict(x=x, y=y, z=z, rot=rot, model=mi, cols=[fr + ext for fr in mp['cols'][i]], flag=fl)
|
|
if i in altmap:
|
|
am = altmap[i][4]
|
|
t['altcols'] = [fr + [0x00808080] * add_v[am] for fr in mp['altcols'][i]]
|
|
tiles.append(t)
|
|
tiles += new_tiles
|
|
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
|
|
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)
|
|
assert a['rest'] == b['rest'] and a['strats'] == b['strats'] and a['nodes'] == b['nodes']
|
|
assert a['tiles'][:mp['N']] == b['tiles']
|
|
nv_max = max(vc)
|
|
G.wad_write(dst, ch)
|
|
info.update(stats)
|
|
info['materialien'] = {str(k): v for k, v in sorted(mats.items())}
|
|
info['materialfarben'] = {str(k): v for k, v in matcols.items()}
|
|
info['max_verts_modell'] = nv_max
|
|
info['max_faces_modell'] = max(len(m.faces) for m in trak)
|
|
info['texturen_neu'] = len(pal.entries)
|
|
info['groesse_alt'] = os.path.getsize(src)
|
|
info['groesse_neu'] = os.path.getsize(dst)
|
|
info['kacheln'] = mp['N'] + len(new_tiles)
|
|
info['modelle'] = len(trak)
|
|
return info
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('inp', nargs='?')
|
|
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('--no-coll', action='store_true')
|
|
ap.add_argument('--no-markers', action='store_true')
|
|
ap.add_argument('--no-fill', action='store_true')
|
|
ap.add_argument('--opaque', action='store_true')
|
|
ap.add_argument('--width', type=float, default=0.007)
|
|
ap.add_argument('--offset', type=float, default=0.012)
|
|
ap.add_argument('--report')
|
|
opt = ap.parse_args()
|
|
infos = []
|
|
if opt.all:
|
|
os.makedirs(opt.all, exist_ok=True)
|
|
files = sorted(glob.glob(os.path.join(opt.src, 't[1-6]l*.wad')))
|
|
if opt.only:
|
|
want = {w.lower().replace('.wad', '') for w in opt.only.split(',')}
|
|
files = [f for f in files if os.path.basename(f).lower()[:-4] in want]
|
|
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' % (
|
|
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')
|
|
info = process(opt.inp, opt.out, opt)
|
|
infos.append(info)
|
|
print(json.dumps(info, indent=1, ensure_ascii=False))
|
|
if opt.report:
|
|
json.dump(infos, open(opt.report, 'w'), indent=1, ensure_ascii=False)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|