Files
CydandClaude Fable 5 afc3fd839e Vendor dpl3-revive: the Division/DPL3 renderer, now ours
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>
2026-07-05 22:06:25 -05:00

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))