Files
llamamod/tools/debugviz.py
T

732 lines
29 KiB
Python

#!/usr/bin/env python3
"""Debug visualisation for level WADs of "Ein Koenigreich fuer ein Lama".
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 [options] one level
debugviz.py --all OUTDIR [--src CD-wads-folder] all level WADs (t1l*, t6l*)
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
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
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')
# ------------------------------------------------------------------ colours
KIND_COL = {
'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) # invisible collision (no render surface)
FILL_ALPHA = 96
# material -> (name, colour). Unknown materials get colours from PALETTE.
MAT_INFO = {
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)),
}
# 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),
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 # 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',
'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: append flat colours
def text_append_flats(d, entries):
"""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):
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: # 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
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] # relative; + base when writing
# ------------------------------------------------------------------ geometry helpers
def norm(v):
n = np.linalg.norm(v)
return v / n if n > 1e-12 else v
class Mesh:
"""Triangle list with palette 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 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):
"""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)
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)]
# ------------------------------------------------------------------ classify collision
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:
# 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
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 triangles of the world (export space), bucketed by grid cell."""
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):
"""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])))
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):
"""Sets 'invis' per polygon (True = no render surface covers it)."""
ri = RenderIndex(trak, mp)
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']):
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] # a few instances are enough
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 # never placed: no world reference -> count as invisible
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 per model
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)
# incircle estimate: smallest distance centre -> edge
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)
# inner corners: edge lines shifted inwards by w, intersected with the surface plane
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] # 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)):
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):
"""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
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)
# ------------------------------------------------------------------ markers for objects
def fx(v):
v = v - (1 << 32) if v >= 1 << 31 else v
return v / 4096
def strat_markers(ch, wf, pal, stats):
"""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, 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()
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:
# 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
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: cube + post
top = p + [0, MARK_H, 0]
if cat == 'start':
# 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))
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_)
# 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 # 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
# bucket everything into cells (export space: negate z)
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:
# 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))
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
# ------------------------------------------------------------------ main flow
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)
# statistics per tile instance
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
# markers
cells = {}
if not opt.no_markers:
cells = strat_markers(ch, wf, pal, stats)
# 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):
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
# new marker tiles
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
# 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]
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)
# 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']):
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)
# 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)
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='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')
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 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 or --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()