448 lines
17 KiB
Python
448 lines
17 KiB
Python
#!/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('<I', d, 0)[0]
|
|
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')
|
|
ln = struct.unpack_from('<I', d, o + 4)[0]
|
|
chunks.append([tag, d[o + 8:o + 8 + ln]])
|
|
o += 8 + ln
|
|
if tag == 'END ':
|
|
break
|
|
return chunks
|
|
|
|
|
|
def wad_write(path, chunks):
|
|
body = b''.join(t.encode('latin1')[::-1] + struct.pack('<I', len(c)) + c for t, c in chunks)
|
|
open(path, 'wb').write(struct.pack('<I', len(body)) + body)
|
|
|
|
|
|
def wad_get(chunks, tag):
|
|
for t, c in chunks:
|
|
if t == tag.ljust(4):
|
|
return c
|
|
return None
|
|
|
|
|
|
def wad_set(chunks, tag, data):
|
|
for c in chunks:
|
|
if c[0] == tag.ljust(4):
|
|
c[1] = data
|
|
return
|
|
raise KeyError(tag)
|
|
|
|
|
|
def wfpc_flags(chunks):
|
|
c = wad_get(chunks, 'WFPC')
|
|
return struct.unpack('<I', c)[0] if c else 0
|
|
|
|
|
|
# ---------------------------------------------------------------- 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 (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)]
|
|
|
|
|
|
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 = [[], [], []] # 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 # set True when verts changed -> 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('<H', d, b + 0x6c)[0]
|
|
nv, nf = struct.unpack_from('<HH', d, b + 0x6c)
|
|
nc = struct.unpack_from('<3H', d, b + 0x78)
|
|
if hsize == 0x8c:
|
|
m.x84, nx = struct.unpack_from('<HH', d, b + 0x84)
|
|
m.extra = d[o:o + nx * 2]
|
|
o += nx * 2
|
|
for k in range(nv):
|
|
m.verts.append(struct.unpack_from('<6f', d, o)); o += 24
|
|
for k in range(nf):
|
|
fl, a, b_, c, tex, pad = struct.unpack_from('<6H', d, o)
|
|
m.faces.append(dict(flags=fl, v=(a, b_, c), tex=tex, pad=pad, plane=struct.unpack_from('<4f', d, o + 12)))
|
|
o += 28
|
|
for g in range(3):
|
|
for k in range(nc[g]):
|
|
m.coll[g].append(d[o:o + 32]); o += 32
|
|
out.append(m)
|
|
return out, o
|
|
|
|
|
|
def parse_stpc_models(d):
|
|
"""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 = []
|
|
for i in range(n):
|
|
ms, o = _parse_models(d, o, 1, 0x8c)
|
|
out += ms
|
|
return out, o
|
|
|
|
|
|
def parse_trak(d):
|
|
n = struct.unpack_from('<I', d, 0)[0]
|
|
models, o = _parse_models(d, 4, n, 0x84)
|
|
assert o == len(d), f'TRAK: {o} != {len(d)}'
|
|
return models
|
|
|
|
|
|
def bbox_header(m):
|
|
if m.verts:
|
|
xs = [v[0] for v in m.verts]; ys = [v[1] for v in m.verts]; zs = [v[2] for v in m.verts]
|
|
lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs))
|
|
else:
|
|
lo = hi = (0.0, 0.0, 0.0)
|
|
c = tuple((a + b) / 2 for a, b in zip(lo, hi))
|
|
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:
|
|
pts += [c[k] + s[k] * h[k] for k in range(3)]
|
|
return c, pts
|
|
|
|
|
|
def build_models(models, hsize, recompute_bbox=None):
|
|
"""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
|
|
and getattr(m, '_nv0', None) == len(m.verts) and not getattr(m, 'dirty', False)):
|
|
h = m.hdr[:0x6c]
|
|
else:
|
|
c, pts = bbox_header(m)
|
|
h = struct.pack('<3f24f', *c, *pts)
|
|
h += struct.pack('<HHII3HHI', len(m.verts), len(m.faces), 0, 0, *(len(g) for g in m.coll), 0, 0)
|
|
if hsize == 0x8c:
|
|
h += struct.pack('<HHI', m.x84, len(m.extra) // 2, 0)
|
|
data += m.extra
|
|
assert len(h) == hsize
|
|
hdr += h
|
|
for v in m.verts:
|
|
data += struct.pack('<6f', *v)
|
|
for f in m.faces:
|
|
data += struct.pack('<6H4f', f['flags'], *f['v'], f['tex'], f.get('pad', 0), *f['plane'])
|
|
for g in m.coll:
|
|
for r in g:
|
|
data += r
|
|
return hdr + data
|
|
|
|
|
|
def build_trak(models):
|
|
return struct.pack('<I', len(models)) + build_models(models, 0x84)
|
|
|
|
|
|
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))
|
|
# 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])
|
|
|
|
|
|
# ---------------------------------------------------------------- collision
|
|
|
|
def coll_decode(r):
|
|
"""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):
|
|
nx, ny, nz, pad, d = struct.unpack_from('<bbbBh', r, 2 + 6 * k)
|
|
planes.append((nx / 127, ny / 127, -nz / 127, d / 4096))
|
|
nedge = 4 if typ & 1 else 3
|
|
return dict(typ=typ, mat=mat, plane=planes[0], edges=planes[1:1 + nedge])
|
|
|
|
|
|
def coll_polygon(c):
|
|
"""Corner points from surface plane + edge planes (intersection of adjacent edges)."""
|
|
import numpy as np
|
|
P = c['plane']; E = c['edges']
|
|
pts = []
|
|
n = len(E)
|
|
for i in range(n):
|
|
a, b = E[i], E[(i + 1) % n]
|
|
A = np.array([P[:3], a[:3], b[:3]]); B = np.array([P[3], a[3], b[3]])
|
|
try:
|
|
pts.append(tuple(np.linalg.solve(A, B)))
|
|
except np.linalg.LinAlgError:
|
|
return []
|
|
return pts
|
|
|
|
|
|
def coll_encode(typ, mat, plane, edges):
|
|
"""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))))
|
|
return struct.pack('<bbbBh', cl(nx), cl(ny), cl(-nz), 0, max(-32768, min(32767, int(round(d * 4096)))))
|
|
out = bytes([typ, mat]) + q(plane) + b''.join(q(e) for e in edges)
|
|
return out.ljust(32, b'\0')
|
|
|
|
|
|
def coll_from_polygon(pts, mat=0, typ=None):
|
|
"""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])
|
|
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('<I', d, o)[0]; o += 4 + 4 * nl
|
|
ns = struct.unpack_from('<I', d, o)[0]; o += 4 + ns * STRAT_REC
|
|
nn = struct.unpack_from('<I', d, o)[0]; o += 4 + nn * NODE_REC
|
|
o += 256
|
|
offs['grid'] = o
|
|
zone = struct.unpack_from('<%dI' % (W * H), d, o); o += 4 * W * H
|
|
offs['B'] = o
|
|
B = [struct.unpack_from('<6i', d, o + 24 * i) for i in range(N)]; o += 24 * N
|
|
midx = list(struct.unpack_from('<%dI' % N, d, o)); o += 4 * N
|
|
anim = []
|
|
if wfpc & 0x10000:
|
|
na = struct.unpack_from('<I', d, o)[0]; o += 4
|
|
anim = [struct.unpack_from('<5I', d, o + 20 * i) for i in range(na)]; o += 20 * na
|
|
offs['col'] = o
|
|
flagged = [i for i in range(N) if A[i][5]]
|
|
altmap = dict(zip(flagged, anim))
|
|
cols, altcols = [], {}
|
|
for i in range(N):
|
|
for k, mdl in enumerate([midx[i]] + ([altmap[i][4]] if i in altmap else [])):
|
|
nv = vcounts[mdl]
|
|
c = list(struct.unpack_from('<%dI' % nv, d, o)); o += 4 * nv
|
|
fr = max([x >> 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('<I', len(anim)) + b''.join(struct.pack('<5I', *e) for e in anim)) if wfpc & 0x10000 else b''
|
|
cols = b''
|
|
for t in tiles:
|
|
for frame in t['cols'] + (t['altcols'] if (anim and t.get('flag')) else []):
|
|
cols += struct.pack('<%dI' % len(frame), *frame)
|
|
mid = d[of['A_end']:of['B']]
|
|
if zones == 'flat':
|
|
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 (texture table + pages)
|
|
|
|
def parse_text(d):
|
|
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
|
|
pages = []
|
|
for _ in range(npg):
|
|
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
|
|
if fl == 0:
|
|
sz = w * h; raw = d[o:o + sz]; comp = False
|
|
elif fl & 0x80:
|
|
sz = struct.unpack_from('<I', d, o)[0]; o += 4; raw = d[o:o + sz]; comp = True
|
|
else:
|
|
sz = w * h * 2; raw = d[o:o + sz]; comp = False
|
|
o += sz
|
|
pages.append(dict(flags=fl, w=w, h=h, raw=raw, comp=comp))
|
|
nt = struct.unpack_from('<I', d, o)[0]; o += 4
|
|
texs = []
|
|
for i in range(nt):
|
|
fl, pg, p2, u0, u1, v0, v1 = struct.unpack_from('<HbbBBBB', d, o); o += 8
|
|
texs.append(dict(flags=fl, page=pg, page2=p2, u0=u0, u1=u1, v0=v0, v1=v1))
|
|
return pages, texs
|
|
|
|
|
|
def page_rgba(p):
|
|
"""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 # greyscale stopgap
|
|
elif p['comp']:
|
|
v = np.frombuffer(p['raw'], '<u2'); out = np.zeros(w * h, np.uint16)
|
|
i = q = 0
|
|
while q < w * h and i < len(v):
|
|
n = int(v[i]); i += 1
|
|
if n & 0x8000:
|
|
n -= 0x10000
|
|
if n > 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'], '<u2')
|
|
px = px[:w * h].reshape(h, w)
|
|
r = (px & 31) << 3; g = ((px >> 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
|