#!/usr/bin/env python3 """ scn.py -- reader for DPL3 ".SCN" scene files (the FLYK "cheesy scene format"). .SCN is plain text, one command per line. This module implements the core subset that FLYK.C parses -- environment + object placement -- which covers the vast majority of every scene on the disk (STATIC alone is ~5000 of ~9000 lines): FOG z0 z1 r g b fog start/end distance + colour BACKGND r g b background clear colour AMBIENT r g b ambient light LIGHT r g b ax ay az directional light: colour + orientation angles STATIC name scale x y z ax ay az place a model instance DYNAMIC name scale splinefile t0 dt model that follows a camera spline SCROLL texname u0 v0 du dv scroll rate for a TEXTURE entity ("lib:name" or a model-own name); overrides the SPECIAL " SCROLL ..." baked into the material source Comments start with // or #. Other keywords seen in game-tool scenes (SPECIALFX, MORPH, TEXTURE/GEOMETRY/MATERIAL, ZONE/START/END, CLIP, VIEWANGLE, ...) are recognised and skipped by this loader -- see spec/SCN_FORMAT.md. Object placement transform (from FLYK.C + MATRIX.C), row-vector convention (a point is a row, p' = p . M), angles in DEGREES: M = Rz(az) . Rx(ax) . Ry(ay) . Scale(s) . Translate(x,y,z) """ import math import sys # ---- row-vector 4x4 matrices (DPL convention: translation in the bottom row) -- def ident(): return [[1.0 if i == j else 0.0 for j in range(4)] for i in range(4)] def matmul(a, b): return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)] def m_translate(x, y, z): m = ident(); m[3][0], m[3][1], m[3][2] = x, y, z; return m def m_scale(s): m = ident(); m[0][0] = m[1][1] = m[2][2] = s; return m def m_rotZ(deg): c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) return [[c, s, 0, 0], [-s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] def m_rotX(deg): c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) return [[1, 0, 0, 0], [0, c, s, 0], [0, -s, c, 0], [0, 0, 0, 1]] def m_rotY(deg): c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) return [[c, 0, -s, 0], [0, 1, 0, 0], [s, 0, c, 0], [0, 0, 0, 1]] def instance_matrix(scale, tx, ty, tz, ax, ay, az): """Exactly the sequence FLYK applies: Rz . Rx . Ry . Scale . Translate.""" m = m_rotZ(az) m = matmul(m, m_rotX(ax)) m = matmul(m, m_rotY(ay)) m = matmul(m, m_scale(scale)) m = matmul(m, m_translate(tx, ty, tz)) return m def xform_point(m, x, y, z): return (x*m[0][0] + y*m[1][0] + z*m[2][0] + m[3][0], x*m[0][1] + y*m[1][1] + z*m[2][1] + m[3][1], x*m[0][2] + y*m[1][2] + z*m[2][2] + m[3][2]) def xform_dir(m, x, y, z): """rotate a direction/normal by the upper-left 3x3 (no translation).""" return (x*m[0][0] + y*m[1][0] + z*m[2][0], x*m[0][1] + y*m[1][1] + z*m[2][1], x*m[0][2] + y*m[1][2] + z*m[2][2]) # ---- scene model ----------------------------------------------------------- class Instance: __slots__ = ("name", "scale", "pos", "rot", "dynamic", "spline", "matrix") def __init__(self, name, scale, pos, rot, dynamic=False, spline=None): self.name = name self.scale = scale self.pos = pos self.rot = rot self.dynamic = dynamic self.spline = spline self.matrix = instance_matrix(scale, pos[0], pos[1], pos[2], rot[0], rot[1], rot[2]) class Scene: def __init__(self): self.fog = None # (z0, z1, r, g, b) self.background = (0, 0, 0) self.ambient = (0, 0, 0) self.lights = [] # list of (r,g,b, ax,ay,az) (ambient excluded) self.instances = [] self.start = None # START x y z ax ay az -- player/camera spawn self.scrolls = {} # texture entity name -> [u0, v0, du, dv] self.unhandled = {} # keyword -> count (recognised but skipped) def _nums(parts, n): return [float(x) for x in parts[:n]] def load(path) -> Scene: sc = Scene() with open(path, "r", encoding="latin-1") as fp: for raw in fp: line = raw.strip() if not line or line.startswith("//") or line.startswith("#"): continue p = line.split() kw = p[0].upper() a = p[1:] try: if kw == "FOG": z0, z1, r, g, b = _nums(a, 5); sc.fog = (z0, z1, r, g, b) elif kw == "BACKGND": sc.background = tuple(_nums(a, 3)) elif kw == "AMBIENT": sc.ambient = tuple(_nums(a, 3)) elif kw == "LIGHT": r, g, b, ax, ay, az = _nums(a, 6) sc.lights.append((r, g, b, ax, ay, az)) elif kw in ("STATIC", "SSTATIC"): name = a[0]; s, x, y, z, ax, ay, az = _nums(a[1:], 7) sc.instances.append(Instance(name, s, (x, y, z), (ax, ay, az))) elif kw == "DYNAMIC": name = a[0]; s = float(a[1]); spline = a[2] sc.instances.append(Instance(name, s, (0, 0, 0), (0, 0, 0), dynamic=True, spline=spline)) elif kw == "START": sc.start = _nums(a, 6) # x y z ax ay az elif kw == "SCROLL": sc.scrolls[a[0].lower()] = _nums(a[1:], 4) elif kw == "SCHILD": pass # LOD child of the preceding STATIC -- skip, don't double-draw elif kw in ("GEOMETRY", "MATERIAL", "TEXTURE"): pass # search-path hints; the archive-wide index covers these else: sc.unhandled[kw] = sc.unhandled.get(kw, 0) + 1 except (ValueError, IndexError): sc.unhandled[kw + "?"] = sc.unhandled.get(kw + "?", 0) + 1 return sc if __name__ == "__main__": sc = load(sys.argv[1]) print("fog :", sc.fog) print("background:", sc.background) print("ambient :", sc.ambient) print("lights :", len(sc.lights), sc.lights[:2]) print("instances :", len(sc.instances)) from collections import Counter c = Counter(i.name for i in sc.instances) for name, n in c.most_common(): print(" %-14s x%d" % (name, n)) if sc.unhandled: print("skipped :", dict(sc.unhandled))