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>
524 lines
19 KiB
Python
524 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
b2z.py -- reader for DIVISION / Virtual World Entertainment "DIV-BIZ2" (.B2Z / .V2Z)
|
|
binary geometry objects, used by the DPL3 renderer (c. 1994).
|
|
|
|
Faithful transcription of DPL3/BIZREAD.C (Copyright DIVISION Ltd 1994, author PJA).
|
|
The original read the file in 8 KB blocks with endian correction; here we load the
|
|
whole file into memory and walk an absolute cursor -- identical results, less code.
|
|
|
|
Little-endian throughout (the files were authored on x86 / i860 hosts).
|
|
|
|
Usage:
|
|
python b2z.py dump <file.b2z> # structural dump
|
|
python b2z.py obj <file.b2z> [out.obj] # export triangulated Wavefront OBJ
|
|
"""
|
|
import struct
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# low-level stream (mirrors get_char / get_int* / get_float32 / get_struct32)
|
|
# ----------------------------------------------------------------------------
|
|
class Stream:
|
|
def __init__(self, data: bytes):
|
|
self.d = data
|
|
self.pos = 0
|
|
|
|
def eof(self) -> bool:
|
|
return self.pos >= len(self.d)
|
|
|
|
def u8(self) -> int:
|
|
v = self.d[self.pos]
|
|
self.pos += 1
|
|
return v
|
|
|
|
def i8(self) -> int:
|
|
v = self.u8()
|
|
return v - 256 if v >= 128 else v
|
|
|
|
def u16(self) -> int:
|
|
v = struct.unpack_from("<H", self.d, self.pos)[0]
|
|
self.pos += 2
|
|
return v
|
|
|
|
def i32(self) -> int:
|
|
v = struct.unpack_from("<i", self.d, self.pos)[0]
|
|
self.pos += 4
|
|
return v
|
|
|
|
def f32(self) -> float:
|
|
v = struct.unpack_from("<f", self.d, self.pos)[0]
|
|
self.pos += 4
|
|
return v
|
|
|
|
def floats(self, n: int):
|
|
v = struct.unpack_from("<%df" % n, self.d, self.pos)
|
|
self.pos += 4 * n
|
|
return list(v)
|
|
|
|
def cstr(self) -> str:
|
|
"""null-terminated string (the `while (c=get_char())` idiom)."""
|
|
start = self.pos
|
|
while self.d[self.pos] != 0:
|
|
self.pos += 1
|
|
s = self.d[start:self.pos].decode("latin-1")
|
|
self.pos += 1 # skip the NUL
|
|
return s
|
|
|
|
def fixed_str(self, n: int) -> str:
|
|
s = self.d[self.pos:self.pos + n].decode("latin-1")
|
|
self.pos += n
|
|
return s
|
|
|
|
def skip(self, n: int):
|
|
self.pos += n
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# block header: 16-bit type; top nibble selects the width of the length field.
|
|
# (hdr >> 12) & 0xc == 0x8 -> int32 len, 0x4 -> int16 len, 0x0 -> int8 len
|
|
# The record TYPE is (hdr & 0xfff); the top nibble is only a length-encoding flag,
|
|
# which is why every record type appears as 0x0NNN / 0x4NNN / 0x8NNN in the source.
|
|
# ----------------------------------------------------------------------------
|
|
def read_block(s: Stream):
|
|
hdr = s.u16()
|
|
sel = (hdr >> 12) & 0xc
|
|
if sel == 0x8:
|
|
length = s.i32() & 0xffffffff
|
|
elif sel == 0x4:
|
|
length = s.u16()
|
|
elif sel == 0x0:
|
|
length = s.u8()
|
|
else:
|
|
raise ValueError("Unrecognised block header 0x%04x" % hdr)
|
|
return hdr, length
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# data model (subset of dpltypes.h that a .b2z can express)
|
|
# ----------------------------------------------------------------------------
|
|
@dataclass
|
|
class Vertex:
|
|
pos: tuple # (x, y, z)
|
|
normal: tuple = None # (nx, ny, nz)
|
|
rgba: tuple = None # (r, g, b, a)
|
|
lum: tuple = None # (l, a)
|
|
uv: tuple = None # (u, v) or (u, v, w)
|
|
|
|
|
|
# geometry primitive types (dpl_geo_type)
|
|
TRISTRIP, POLYSTRIP, PMESH, POLYGON = "tristrip", "polystrip", "pmesh", "polygon"
|
|
|
|
|
|
@dataclass
|
|
class Geometry:
|
|
geotype: str
|
|
verts: list = field(default_factory=list)
|
|
conns: list = field(default_factory=list) # explicit faces (pmesh); each = list[int]
|
|
|
|
|
|
@dataclass
|
|
class Geogroup:
|
|
name: str = ""
|
|
draw_mode: int = 0
|
|
f_material: str = None
|
|
b_material: str = None
|
|
geoms: list = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Material:
|
|
name: str
|
|
ambient: tuple = (1, 1, 1)
|
|
diffuse: tuple = (1, 1, 1)
|
|
specular: tuple = (0, 0, 0, 0)
|
|
emissive: tuple = (1, 1, 1)
|
|
opacity: tuple = (1, 1, 1)
|
|
texture: str = None
|
|
ramp: str = None
|
|
|
|
|
|
@dataclass
|
|
class Texture:
|
|
name: str
|
|
mapfile: str = None
|
|
minify: int = 0
|
|
magnify: int = 0
|
|
alpha: int = 0
|
|
wrap_u: int = 0
|
|
wrap_v: int = 0
|
|
bitslice: int = 0 # tag 0x018 (1996+): 4-bit plane index into a .BSL pack
|
|
special: str = None # tag 0x037: free-text hook, e.g. " SCROLL u0 v0 du dv"
|
|
|
|
|
|
@dataclass
|
|
class Model:
|
|
object_name: str = ""
|
|
scale: float = 1.0
|
|
units: float = 1.0
|
|
geogroups: list = field(default_factory=list)
|
|
materials: dict = field(default_factory=dict)
|
|
textures: dict = field(default_factory=dict)
|
|
ramps: dict = field(default_factory=dict)
|
|
|
|
|
|
# vertex-format table: header type (12-bit) -> (floats_per_vertex, offsets dict)
|
|
# offsets are indices into the per-vertex float array. Mirrors parse_vertices().
|
|
VFMT = {
|
|
0x080: (3, {}), # flat x y z
|
|
0x081: (6, {"n": 3}), # + normal
|
|
0x082: (7, {"c": 3}), # + rgba (cooked)
|
|
0x083: (10, {"n": 3, "c": 6}), # normal + rgba
|
|
0x084: (5, {"l": 3}), # + luminance/alpha
|
|
0x085: (8, {"n": 3, "l": 6}), # normal + la
|
|
0x088: (5, {"t2": 3}), # + 2d texture uv
|
|
0x089: (8, {"n": 3, "t2": 6}), # normal + uv
|
|
0x08a: (9, {"c": 3, "t2": 7}), # rgba + uv
|
|
0x08c: (7, {"l": 3, "t2": 5}), # la + uv
|
|
0x090: (6, {"t3": 3}), # + 3d texture uvw
|
|
0x091: (9, {"n": 3, "t3": 6}), # normal + uvw
|
|
0x092: (10, {"c": 3, "t3": 7}), # rgba + uvw
|
|
0x094: (8, {"l": 3, "t3": 5}), # la + uvw
|
|
}
|
|
|
|
PRIM = {0x043: POLYGON, 0x044: TRISTRIP, 0x045: POLYSTRIP, 0x046: PMESH}
|
|
|
|
|
|
class Reader:
|
|
def __init__(self, data: bytes):
|
|
self.s = Stream(data)
|
|
self.m = Model()
|
|
|
|
# -- header ------------------------------------------------------------
|
|
def parse_header(self, length):
|
|
end = self.s.pos + length
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x005: # scale (0x2005)
|
|
self.m.scale = self.s.f32()
|
|
elif t == 0x006: # precision (0x2006): 0 = single precision
|
|
if self.s.u8() != 0:
|
|
raise ValueError("double-precision files unsupported (as in original)")
|
|
elif t == 0x009: # unit (0x2009)
|
|
u = self.s.u8()
|
|
self.m.units = 1.0 if u == 0 else (1.0 / 25.4)
|
|
# version/date/time/filetype -> skipped
|
|
self.s.pos = body + ln
|
|
|
|
# -- vertices ----------------------------------------------------------
|
|
def read_vertices(self, fpv, offs, n):
|
|
out = []
|
|
for _ in range(n):
|
|
f = self.s.floats(fpv)
|
|
v = Vertex(pos=(f[0], f[1], f[2]))
|
|
if "n" in offs:
|
|
o = offs["n"]; v.normal = (f[o], f[o+1], f[o+2])
|
|
if "c" in offs:
|
|
o = offs["c"]; v.rgba = (f[o], f[o+1], f[o+2], f[o+3])
|
|
if "l" in offs:
|
|
o = offs["l"]; v.lum = (f[o], f[o+1])
|
|
if "t2" in offs:
|
|
o = offs["t2"]; v.uv = (f[o], f[o+1])
|
|
if "t3" in offs:
|
|
o = offs["t3"]; v.uv = (f[o], f[o+1], f[o+2])
|
|
out.append(v)
|
|
return out
|
|
|
|
def parse_vertices(self, length, geotype, group):
|
|
end = self.s.pos + length
|
|
geom = Geometry(geotype)
|
|
pre_conns = [] # pmesh connectivity accumulates across sub-blocks
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
nfloats = ln >> 2
|
|
if t == 0x047: # pmesh triangle connectivity
|
|
for _ in range(ln // 12):
|
|
pre_conns.append([self.s.i32(), self.s.i32(), self.s.i32()])
|
|
elif t == 0x04d: # pmesh polygon connectivity
|
|
vpp = self.s.u8()
|
|
for _ in range((ln - 1) // (vpp * 4)):
|
|
pre_conns.append([self.s.i32() for _ in range(vpp)])
|
|
elif t in VFMT: # a vertex block
|
|
fpv, offs = VFMT[t]
|
|
geom.verts = self.read_vertices(fpv, offs, nfloats // fpv)
|
|
# sphere/line/text (0x048/0x04a/0x04b) not implemented in original
|
|
self.s.pos = body + ln
|
|
if geotype == PMESH:
|
|
geom.conns = pre_conns
|
|
group.geoms.append(geom)
|
|
|
|
# -- patch (geogroup) --------------------------------------------------
|
|
def parse_patch(self, length, draw_mode):
|
|
end = self.s.pos + length
|
|
g = Geogroup(draw_mode=draw_mode)
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x008: # name (0x2008)
|
|
g.name = self.s.cstr()
|
|
elif t == 0x030: # front material (0x2030)
|
|
g.f_material = self._material_ref()
|
|
elif t == 0x031: # back material (0x2031)
|
|
g.b_material = self._material_ref(back=True, front=g.f_material)
|
|
elif t == 0x036: # vertex format / draw mode
|
|
g.draw_mode = self.s.u8()
|
|
elif t in PRIM: # a geometry primitive
|
|
self.s.pos = body # parse_vertices re-reads from body
|
|
# length already known (ln); rewind not needed -- body is content start
|
|
self.parse_vertices(ln, PRIM[t], g)
|
|
# plane/decal/facet/voodoo/sphere/line/text -> skipped
|
|
self.s.pos = body + ln
|
|
self.m.geogroups.append(g)
|
|
|
|
def _material_ref(self, back=False, front=None):
|
|
typ = self.s.u8()
|
|
if typ == 0:
|
|
return None
|
|
if typ == 2:
|
|
return "DEFAULT"
|
|
if typ == 3 and back:
|
|
return front
|
|
if typ == 1:
|
|
return self.s.cstr()
|
|
return None
|
|
|
|
# -- object ------------------------------------------------------------
|
|
def parse_object(self, length):
|
|
self._lod_taken = False
|
|
self._object_body(self.s.pos + length, 0)
|
|
|
|
def _object_body(self, end, draw_mode, in_lod=False):
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x008: # object name
|
|
self.m.object_name = self.s.cstr()
|
|
elif t == 0x036: # vertex format -> draw_mode
|
|
draw_mode = self.s.u8()
|
|
elif t == 0x042: # patch
|
|
self.s.pos = body
|
|
self.parse_patch(ln, draw_mode)
|
|
elif t == 0x041 and not in_lod:
|
|
# LOD. In the 1994 format its payload was empty (a marker the
|
|
# original loader skipped); the 1996 game format nests the
|
|
# patches INSIDE it, with 0x046(len 8) = switch in/out floats.
|
|
# Parse only the FIRST (highest-detail) LOD -- later ones are
|
|
# lower-poly alternates and would double-draw.
|
|
if not self._lod_taken:
|
|
self._lod_taken = True
|
|
self._object_body(body + ln, draw_mode, in_lod=True)
|
|
# materials-on-object / comments -> skipped
|
|
self.s.pos = body + ln
|
|
|
|
# -- texture / material / ramp ----------------------------------------
|
|
def parse_texture(self, length):
|
|
end = self.s.pos + length
|
|
tx = Texture(name="")
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x008:
|
|
tx.name = self.s.cstr()
|
|
elif t == 0x011:
|
|
tx.mapfile = self.s.cstr()
|
|
elif t == 0x012:
|
|
tx.minify = self.s.u8()
|
|
elif t == 0x013:
|
|
tx.magnify = self.s.u8()
|
|
elif t == 0x014:
|
|
tx.alpha = self.s.u8()
|
|
elif t == 0x015:
|
|
tx.wrap_u = self.s.u8()
|
|
elif t == 0x016:
|
|
tx.wrap_v = self.s.u8()
|
|
elif t == 0x018:
|
|
tx.bitslice = self.s.u8()
|
|
elif t == 0x037:
|
|
tx.special = self.s.cstr()
|
|
self.s.pos = body + ln
|
|
self.m.textures[tx.name] = tx
|
|
|
|
def parse_material(self, length):
|
|
end = self.s.pos + length
|
|
mt = Material(name="")
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x008:
|
|
mt.name = self.s.cstr()
|
|
elif t == 0x021: # texture ref
|
|
mode = self.s.u8()
|
|
if mode == 2:
|
|
mt.texture = self.s.cstr()
|
|
elif t == 0x023:
|
|
mt.ambient = tuple(self.s.floats(3))
|
|
elif t == 0x024:
|
|
mt.diffuse = tuple(self.s.floats(3))
|
|
elif t == 0x025:
|
|
mt.specular = tuple(self.s.floats(4))
|
|
elif t == 0x026:
|
|
mt.emissive = tuple(self.s.floats(3))
|
|
elif t == 0x027:
|
|
mt.opacity = tuple(self.s.floats(3))
|
|
elif t == 0x028:
|
|
mt.ramp = self.s.cstr()
|
|
self.s.pos = body + ln
|
|
self.m.materials[mt.name] = mt
|
|
|
|
def parse_ramp(self, length):
|
|
end = self.s.pos + length
|
|
name, c0, c1 = "", (0, 0, 0), (0, 0, 0)
|
|
while self.s.pos < end:
|
|
hdr, ln = read_block(self.s)
|
|
body = self.s.pos
|
|
t = hdr & 0xfff
|
|
if t == 0x008:
|
|
name = self.s.cstr()
|
|
elif t == 0x031:
|
|
c0 = tuple(self.s.floats(3))
|
|
c1 = tuple(self.s.floats(3))
|
|
self.s.pos = body + ln
|
|
self.m.ramps[name] = (c0, c1)
|
|
|
|
# -- top level ---------------------------------------------------------
|
|
def read(self):
|
|
magic = self.s.fixed_str(8)
|
|
if magic != "DIV-BIZ2":
|
|
raise ValueError("Not a DIV-BIZ2 file (magic=%r)" % magic)
|
|
while not self.s.eof():
|
|
hdr, length = read_block(self.s)
|
|
t = hdr & 0xfff
|
|
if t == 0x005: # trailer
|
|
break
|
|
elif t == 0x003: # header
|
|
self.parse_header(length)
|
|
elif t == 0x010: # texture
|
|
self.parse_texture(length)
|
|
elif t == 0x020: # material
|
|
self.parse_material(length)
|
|
elif t == 0x030: # ramp
|
|
self.parse_ramp(length)
|
|
elif t == 0x040: # object
|
|
self.parse_object(length)
|
|
elif t == 0x004: # comment
|
|
self.s.skip(length)
|
|
else:
|
|
self.s.skip(length)
|
|
return self.m
|
|
|
|
|
|
def load(path: str) -> Model:
|
|
with open(path, "rb") as fp:
|
|
return Reader(fp.read()).read()
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# triangulation for export
|
|
# ----------------------------------------------------------------------------
|
|
def triangles(geom: Geometry):
|
|
"""Yield (i0, i1, i2) index triples in the geometry's own vertex space."""
|
|
n = len(geom.verts)
|
|
if geom.geotype == TRISTRIP:
|
|
for i in range(n - 2):
|
|
yield (i, i + 1, i + 2) if i % 2 == 0 else (i + 1, i, i + 2)
|
|
elif geom.geotype == PMESH:
|
|
for face in geom.conns:
|
|
for k in range(1, len(face) - 1):
|
|
yield (face[0], face[k], face[k + 1])
|
|
elif geom.geotype in (POLYGON, POLYSTRIP):
|
|
# a strip of polygons stored as a fan of the block's vertices
|
|
for k in range(1, n - 1):
|
|
yield (0, k, k + 1)
|
|
|
|
|
|
def export_obj(model: Model, out) -> tuple:
|
|
base = 1 # OBJ indices are 1-based
|
|
nv = nf = 0
|
|
out.write("# exported from DIV-BIZ2 by b2z.py\n")
|
|
out.write("# object: %s\n" % model.object_name)
|
|
for gi, g in enumerate(model.geogroups):
|
|
out.write("g %s\n" % (g.name or ("geogroup_%d" % gi)))
|
|
for geom in g.geoms:
|
|
for v in geom.verts:
|
|
out.write("v %.6g %.6g %.6g\n" % v.pos)
|
|
has_uv = any(v.uv for v in geom.verts)
|
|
if has_uv:
|
|
for v in geom.verts:
|
|
u, w = (v.uv[0], v.uv[1]) if v.uv else (0.0, 0.0)
|
|
out.write("vt %.6g %.6g\n" % (u, w))
|
|
for (a, b, c) in triangles(geom):
|
|
if has_uv:
|
|
out.write("f %d/%d %d/%d %d/%d\n" %
|
|
(base+a, base+a, base+b, base+b, base+c, base+c))
|
|
else:
|
|
out.write("f %d %d %d\n" % (base+a, base+b, base+c))
|
|
nf += 1
|
|
base += len(geom.verts)
|
|
nv += len(geom.verts)
|
|
return nv, nf
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# CLI
|
|
# ----------------------------------------------------------------------------
|
|
def cmd_dump(path):
|
|
m = load(path)
|
|
print("file :", path)
|
|
print("object :", m.object_name or "(unnamed)")
|
|
print("scale/units: %g / %g" % (m.scale, m.units))
|
|
print("materials : %d %s" % (len(m.materials), list(m.materials)))
|
|
print("textures : %d %s" % (len(m.textures), list(m.textures)))
|
|
print("ramps : %d %s" % (len(m.ramps), list(m.ramps)))
|
|
print("geogroups : %d" % len(m.geogroups))
|
|
tot_v = tot_t = 0
|
|
for gi, g in enumerate(m.geogroups):
|
|
print(" [%d] name=%r draw_mode=0x%x f_mtl=%r b_mtl=%r geoms=%d"
|
|
% (gi, g.name, g.draw_mode, g.f_material, g.b_material, len(g.geoms)))
|
|
for geom in g.geoms:
|
|
tris = list(triangles(geom))
|
|
tot_v += len(geom.verts); tot_t += len(tris)
|
|
attrs = []
|
|
if geom.verts:
|
|
v0 = geom.verts[0]
|
|
for a in ("normal", "rgba", "lum", "uv"):
|
|
if getattr(v0, a) is not None:
|
|
attrs.append(a)
|
|
print(" - %-9s verts=%-4d faces=%-4d tris=%-4d attrs=%s"
|
|
% (geom.geotype, len(geom.verts), len(geom.conns), len(tris),
|
|
",".join(attrs) or "pos"))
|
|
print("TOTAL : %d verts, %d triangles" % (tot_v, tot_t))
|
|
for name, mt in m.materials.items():
|
|
print(" material %-16s diffuse=%s tex=%s" %
|
|
(name, tuple(round(x, 3) for x in mt.diffuse), mt.texture))
|
|
|
|
|
|
def cmd_obj(path, outpath=None):
|
|
m = load(path)
|
|
outpath = outpath or (path.rsplit(".", 1)[0] + ".obj")
|
|
with open(outpath, "w") as fp:
|
|
nv, nf = export_obj(m, fp)
|
|
print("wrote %s : %d vertices, %d triangles" % (outpath, nv, nf))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 3:
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
cmd, path = sys.argv[1], sys.argv[2]
|
|
if cmd == "dump":
|
|
cmd_dump(path)
|
|
elif cmd == "obj":
|
|
cmd_obj(path, sys.argv[3] if len(sys.argv) > 3 else None)
|
|
else:
|
|
print("unknown command", cmd); sys.exit(1)
|