Bring the graphics-dev collaborator's dpl3-revive into the repo as first-class
project code (they've handed it off; it's ours now). This is the proven
Division renderer that our in-process rt_draw has been trying to be.
What's here:
- parser/ B2Z/V2Z/SVT/SCN/SPL/BGF/BMF/BSL decoders (pure Python).
- spec/ reverse-engineered format + the definitive VelociRender wire
protocol (from the original DIVISION source, matches our live
VPX node/action tables exactly).
- source-ref/ read-only copies of the original DIVISION C (BIZREAD.C,
DPLTYPES.H, DPL.H) that define the formats.
- patha/ the "virtual VelociRender board": vrboard.py (24-action protocol
server), vrview.py (numpy software rasterizer, the reference),
vrview_gl.py (moderngl GPU backend, 832x512@60Hz), plus the
run/replay/regress tooling and evidence renders. Drives FLYK/BLADE/
Star Trek demos AND our btl4opt/rpl4opt game binaries.
- viewer/ WebGL archive generators (.py); prebuilt HTML/data regeneratable.
- samples/ small test models/textures.
- bt*.raw.bin real BTL4OPT arena wire captures (kept for offline renderer
testing/regression against OUR game).
.gitignore keeps the multi-hundred-MB demo capture dumps + debug logs +
regeneratable viewer bundles out of history (they stay on disk).
Phase 0 of the integration is validated: their board decodes our bt8 capture
with zero errors (3748 nodes, 507 instances, 4 mechs) and renders our arena
(terrain/dome/sky, correct Division DAC gamma). Plan + status in memory;
integration continues in emulator/RENDERER-COLLAB.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
#!/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))
|