#!/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 import lamapaths # noqa: E402 CD_WADS = lamapaths.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(' 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: lamapaths.need(opt.src, 'CD wads folder', 'pass --src or set LLAMAMOD_CD') 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()