#!/usr/bin/env python3 """Geometry library for level WADs of "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC). 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) 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 # ---------------------------------------------------------------- OBJ file space # OBJ files (geom_export/geom_import/geom_demo, the editor's Import mesh and OBJ/glTF export) are # right-handed, y up, in game space: file (x, y, z) = export (x, y, -z). The game shows export space # left-handed, so this is how Blender shows the level the same way round as the game. Converting # mirrors, so the corner order reverses (first corner kept: export (0,1,2) <-> file (0,2,1)). def to_file(p): """Export-space point -> OBJ file point.""" return (p[0], p[1], -p[2]) def file_order(seq): """Corner order export <-> file (an involution).""" seq = list(seq) return seq[:1] + seq[:0:-1] # ---------------------------------------------------------------- WAD def wad_read(path): d = open(path, 'rb').read() total = struct.unpack_from(' recompute bounding box def _parse_models(d, o, n, hsize): hdr0 = o o += n * hsize out = [] for i in range(n): b = hdr0 + i * hsize m = Model() m.hdr = d[b:b + hsize] m._nv0 = struct.unpack_from(' dict (planes in vertex/export space, i.e. z mirrored back).""" typ, mat = r[0], r[1] planes = [] for k in range(5): nx, ny, nz, pad, d = struct.unpack_from(' 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)))) return struct.pack(' 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]) if np.linalg.norm(n) < 1e-9: return None n /= np.linalg.norm(n) d = float(n @ P[0]) edges = [] cen = sum(P) / len(P) for i in range(len(P)): a, b = P[i], P[(i + 1) % len(P)] e = np.cross(n, b - a) if np.linalg.norm(e) < 1e-9: return None e /= np.linalg.norm(e) if e @ (cen - a) < 0: 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 (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): """Returns tile info + byte offsets of the sections (for splicing).""" N, W, H = struct.unpack_from('<3I', d, 0) o = 12 offs = {'A': o} A = [struct.unpack_from('<3f3I', d, o + 24 * i) for i in range(N)] o += 24 * N offs['A_end'] = o nl = struct.unpack_from('> 24 for x in c] + [0]) frames = [c] for f in range(1, fr): frames.append(list(struct.unpack_from('<%dI' % nv, d, o))); o += 4 * nv if k == 0: cols.append(frames) else: altcols[i] = frames offs['col_end'] = o return dict(N=N, W=W, H=H, A=A, B=B, midx=midx, anim=anim, zone=zone, cols=cols, altcols=altcols, offs=offs) def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()): """Replaces the tile sections in the MAP chunk. 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'] N = len(tiles) head = struct.pack('<3I', N, m['W'], m['H']) A = b''.join(struct.pack('<3f3I', t['x'], t['y'], t['z'], t['rot'] & 0xfff, t['model'], 1 if (anim and t.get('flag')) else 0) for t in tiles) B = b''.join(struct.pack('<6i', 0, t['rot'] & 0xfff, 0, int(round(t['x'] * 4096)), 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('number of flagged tiles != number of anim entries') animb = (struct.pack(' 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 # greyscale stopgap elif p['comp']: v = np.frombuffer(p['raw'], ' 0: out[q:q + n] = v[i:i + n]; i += n; q += n elif n < 0: out[q:q - n] = v[i]; i += 1; q -= n px = out else: px = np.frombuffer(p['raw'], '> 5) & 31) << 3; b = ((px >> 10) & 31) << 3 a = np.where(px == 0, 0, 255) return np.dstack([r, g, b, a]).astype(np.uint8) def tex_uv(t, pw=256, ph=256): """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): """Per face corner (v0,v1,v2) the UV from the texture rectangle, per FUN_00556510. 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 = "right" without mirroring if not (flags & 0x800): return [(a, V1), (b, V1), (a, V0)] if not (flags & 0x10): return [(a, V1), (b, V1), (b, V0)] return [(b, V0), (a, V0), (a, V1)] # ---------------------------------------------------------------- assemble the world def tile_xform(x, y, z, rot): a = rot / 4096 * 2 * math.pi ca, sa = math.cos(a), math.sin(a) def f(vx, vy, vz): return (x + vx * ca - vz * sa, y + vy, z + vx * sa + vz * ca) def fn(nx, ny, nz): return (nx * ca - nz * sa, ny, nx * sa + nz * ca) return f, fn def load_level(path): ch = wad_read(path) wf = wfpc_flags(ch) trak = parse_trak(wad_get(ch, 'TRAK')) vcounts = [len(m.verts) for m in trak] mp = parse_map_tiles(wad_get(ch, 'MAP'), wf, vcounts) return ch, wf, trak, mp