Files
CydandClaude Fable 5 326d29b72a Extract all recoverable Division scenes; add Scene Archive gallery
Cracks the DIV-BIZ2 binary format (BGF/BMF) using Division's own
reader source (DPL3/BIZREAD.C) as reference: block stream, 14 vertex
layouts, pmesh/strip connectivity, LOD nesting, embedded materials.
Adds SVT raw-texture decoding, 1995-dialect VGF support (implicit
CONNECTION_LIST, header SCALE), and a whole-drive scene audit.

108 of 241 scenes on the Glaze drive are recoverable; 26 are curated
into restoration/vwe-archive.html, a self-contained WebGL gallery:
Star Trek, Hull Pressure, BattleTech (incl. the polar-map mech
lineup), Red Planet's Mars-canal raceway, and the Canyon demos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:03:12 -05:00

412 lines
18 KiB
Python

"""Parsers for Division Ltd. dVS/DPL file formats (1994-1996), reverse-engineered
from the VWE 'Glaze' drive. Binary BIZ2 layout comes from Division's own reader
source (DPL3/BIZREAD.C, PJA 1994); text formats from surviving examples.
Formats:
.VGF/.VMF DIV-VIZ2 text geometry / materials
.BGF/.BMF DIV-BIZ2 binary geometry / materials (b2z block stream)
.SVT raw square texture, 4 B/texel, size implies 64/128/256 edge
.TGA type-2 uncompressed 24/32-bit
.SPL text spline paths
.SCN text scene scripts
"""
import os, re, struct, zlib, base64
# ================================================================ helpers
def strip_comments(text):
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
text = re.sub(r"//[^\n]*", " ", text)
return text
def png_datauri(w, h, rgb_rows):
raw = b"".join(b"\x00" + r for r in rgb_rows)
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b""))
return "data:image/png;base64," + base64.b64encode(png).decode()
def r3(x): return round(x, 3)
def r4(x): return round(x, 4)
# ================================================================ VGF (text geometry)
def parse_vgf(path):
text = strip_comments(open(path, "r", errors="replace").read())
scale = 1.0
hm = re.search(r"HEADER\s*\((.*?)\)", text, re.S)
if hm:
sm = re.search(r"SCALE\s*=\s*([\d.eE+-]+)", hm.group(1))
if sm: scale = float(sm.group(1))
groups = []
for gm in re.finditer(r"GEOGROUP\s*\(([^)]*)\)\s*\{", text):
params = gm.group(1)
mmat = re.search(r'F_MATERIAL\s*=\s*"([^"]+)"', params)
material = mmat.group(1) if mmat else None
vflags = re.search(r"VERTEX\s*=\s*([^;]+);", params)
flags = vflags.group(1).upper() if vflags else ""
has_n = "NORMALS" in flags
has_uv = "2D_TEXTURE" in flags or "3D_TEXTURE" in flags
depth, i = 1, gm.end()
while depth > 0 and i < len(text):
if text[i] == "{": depth += 1
elif text[i] == "}": depth -= 1
i += 1
body = text[gm.end():i - 1]
g = {"material": material}
sph = re.search(r"SPHERELIST\s*\{(.*?)\n\s*\}", body, re.S)
if sph:
pts = []
for row in re.finditer(r"\{([^}]*)\}", sph.group(1)):
nums = [float(x) for x in re.findall(r"[-\d.eE+]+", row.group(1))]
pts.append([r3(v * scale) for v in nums[:3]])
g["mode"] = "points"; g["points"] = pts
groups.append(g); continue
vp = re.search(r"VERTEX_POOL\s*\{(.*?)\n\s*\}", body, re.S)
if not vp: continue
verts, norms, uvs = [], [], []
for row in re.finditer(r"\{([^}]*)\}", vp.group(1)):
nums = [float(x) for x in re.findall(r"[-\d.eE+]+", row.group(1))]
k = 0
verts.append([r3(v * scale) for v in nums[k:k+3]]); k += 3
if has_n: norms.append([r3(v) for v in nums[k:k+3]]); k += 3
if has_uv: uvs.append([r4(v) for v in nums[k:k+2]]); k += 2
indices = []
for cm in re.finditer(r"CONNECTION_LIST\s*(?:\(\s*PCOUNT\s*=\s*\d+\s*\))?\s*\{(.*?)(?:\n\s*\}|(?=CONNECTION_LIST))", body, re.S):
for row in re.finditer(r"\{([^}]*)\}", cm.group(1)):
idx = [int(x) for x in re.findall(r"\d+", row.group(1))]
for t in range(1, len(idx) - 1):
indices += [idx[0], idx[t], idx[t + 1]]
g["mode"] = "mesh"; g["v"] = verts
g["n"] = norms if has_n else None
g["uv"] = uvs if has_uv else None
g["i"] = indices
groups.append(g)
return groups
# ================================================================ VMF (text materials)
def parse_vmf(path, prefix):
text = strip_comments(open(path, "r", errors="replace").read())
textures, materials = {}, {}
for tm in re.finditer(r"TEXTURE\s*\(\s*NAME\s*=\s*\"?([\w-]+)\"?\s*;?([^)]*)\)\s*\{(.*?)\n\}", text, re.S):
name, params, body = tm.group(1), tm.group(2), tm.group(3)
mmap = re.search(r'MAP\s*\{\s*"([\w-]+)"\s*\}', body)
mslice = re.search(r"BITSLICE\s*\{\s*(\d+)\s*\}", body)
textures[name] = {"map": mmap.group(1).lower() if mmap else None,
"slice": int(mslice.group(1)) if mslice else None}
ramps = {}
for rm in re.finditer(r"RAMP\s*\(\s*NAME\s*=\s*\"?([\w-]+)\"?\s*\)\s*\{(.*?)\n\}", text, re.S):
nums = [float(x) for x in re.findall(r"[-\d.eE+]+", rm.group(2))]
if len(nums) >= 6: ramps[rm.group(1)] = [nums[0:3], nums[3:6]]
for mm in re.finditer(r"MATERIAL\s*\(\s*NAME\s*=\s*\"?([\w-]+)\"?\s*;?([^)]*)\)\s*\{(.*?)\n\}", text, re.S):
name, params, body = mm.group(1), mm.group(2), mm.group(3)
def col(tag, n=3):
m = re.search(tag + r"\s*\{([^}]*)\}", body)
return [float(x) for x in re.findall(r"[-\d.eE+]+", m.group(1))][:n] if m else None
mtex = re.search(r'TEXTURE\s*\{\s*"([\w-]+)"\s*\}', body)
mramp = re.search(r"RAMP\s*\{\s*\"?([\w-]+)\"?\s*\}", body)
materials[prefix + ":" + name] = {
"ambient": col("AMBIENT"), "diffuse": col("DIFFUSE"),
"emissive": col("EMISSIVE"),
"texture": mtex.group(1) if mtex else None,
"ramp": mramp.group(1) if mramp else None,
"immune": "IMMUNE" in params}
return textures, materials, ramps
# ================================================================ BIZ2 (binary container)
class B2Z:
def __init__(self, path):
self.d = open(path, "rb").read()
self.p = 0
def u8(self): v = self.d[self.p]; self.p += 1; return v
def u16(self): v = struct.unpack_from("<H", self.d, self.p)[0]; self.p += 2; return v
def i32(self): v = struct.unpack_from("<i", self.d, self.p)[0]; self.p += 4; return v
def f32(self): v = struct.unpack_from("<f", self.d, self.p)[0]; self.p += 4; return v
def floats(self, n):
v = struct.unpack_from("<%df" % n, self.d, self.p); self.p += 4 * n; return v
def ints(self, n):
v = struct.unpack_from("<%di" % n, self.d, self.p); self.p += 4 * n; return v
def cstr(self):
e = self.d.index(0, self.p)
s = self.d[self.p:e].decode("latin-1"); self.p = e + 1; return s
def block(self):
hdr = self.u16()
mode = (hdr >> 12) & 0xC
if mode == 0x8: ln = self.i32()
elif mode == 0x4: ln = self.u16()
elif mode == 0x0: ln = self.u8()
else: raise ValueError("bad block header 0x%x @%d" % (hdr, self.p))
return hdr & 0x3fff, ln # strip length-size bits, keep 0x2000 flag
VTX_LAYOUT = { # tag: (floats_per_vertex, n_off, uv_off, uv3_off, col_off, lum_off)
0x80: (3, -1, -1, -1, -1, -1),
0x81: (6, 3, -1, -1, -1, -1),
0x82: (7, -1, -1, -1, 3, -1),
0x83: (10, 3, -1, -1, 6, -1),
0x84: (5, -1, -1, -1, -1, 3),
0x85: (8, 3, -1, -1, -1, 6),
0x88: (5, -1, 3, -1, -1, -1),
0x89: (8, 3, 6, -1, -1, -1),
0x8a: (9, -1, 7, -1, 3, -1),
0x8c: (7, -1, 5, -1, -1, 3),
0x90: (6, -1, -1, 3, -1, -1),
0x91: (9, 3, -1, 6, -1, -1),
0x92: (10, -1, -1, 7, 3, -1),
0x94: (8, -1, -1, 5, 3, -1), # lum@3, 3dtex@5 per BIZREAD.C
}
def _tri_from_strip(nv):
out = []
for t in range(nv - 2):
if t % 2 == 0: out += [t, t + 1, t + 2]
else: out += [t + 1, t, t + 2]
return out
def _read_vertex_block(bz, tag, ln):
fpv, n_off, uv_off, uv3_off, col_off, lum_off = VTX_LAYOUT[tag]
if tag == 0x94: fpv, lum_off, uv3_off = 8, 3, 5
nv = (ln // 4) // fpv
verts, norms, uvs, cols = [], [], [], []
for _ in range(nv):
f = bz.floats(fpv)
verts.append([r3(f[0]), r3(f[1]), r3(f[2])])
if n_off >= 0: norms.append([r3(f[n_off]), r3(f[n_off+1]), r3(f[n_off+2])])
if uv_off >= 0: uvs.append([r4(f[uv_off]), r4(f[uv_off+1])])
if uv3_off >= 0: uvs.append([r4(f[uv3_off]), r4(f[uv3_off+1])])
if col_off >= 0: cols.append([r3(f[col_off]), r3(f[col_off+1]), r3(f[col_off+2])])
if lum_off >= 0: cols.append([r3(f[lum_off])] * 3)
return verts, norms or None, uvs or None, cols or None
def _parse_bgf_vertices(bz, length, geotype, out_groups, material):
end = bz.p + length
pend_v = pend_n = pend_uv = pend_c = None
conns = []
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x47: # triangle connectivity
for _ in range(ln // 12):
conns.append(list(bz.ints(3)))
elif tag == 0x4d: # polygon connectivity
vpp = bz.u8()
n = (ln - 1) // (vpp * 4)
for _ in range(n):
conns.append(list(bz.ints(vpp)))
elif tag in VTX_LAYOUT:
v, n, uv, c = _read_vertex_block(bz, tag, ln)
if geotype == "pmesh":
if pend_v is None:
pend_v, pend_n, pend_uv, pend_c = v, n, uv, c
else:
pend_v += v
if pend_n and n: pend_n += n
if pend_uv and uv: pend_uv += uv
if pend_c and c: pend_c += c
else: # strip / polygon: emit immediately
idx = (_tri_from_strip(len(v)) if geotype == "tristrip"
else [x for t in range(1, len(v)-1) for x in (0, t, t+1)])
out_groups.append({"material": material, "mode": "mesh",
"v": v, "n": n, "uv": uv, "c": c, "i": idx})
bz.p = nxt
if geotype == "pmesh" and pend_v is not None:
idx = []
for poly in conns:
for t in range(1, len(poly) - 1):
idx += [poly[0], poly[t], poly[t + 1]]
out_groups.append({"material": material, "mode": "mesh",
"v": pend_v, "n": pend_n, "uv": pend_uv,
"c": pend_c, "i": idx})
def _parse_bgf_patch(bz, length, out_groups, obj_mtl):
end = bz.p + length
f_mtl = obj_mtl
pend = [] # geometry blocks seen before the material tag
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x2008: bz.cstr()
elif tag in (0x2030, 0x2031):
t = bz.u8()
name = bz.cstr() if t == 1 else (None if t != 2 else "DEFAULT")
if tag == 0x2030: f_mtl = name or f_mtl
elif tag == 0x43: _parse_bgf_vertices(bz, ln, "polygon", pend, None)
elif tag == 0x44: _parse_bgf_vertices(bz, ln, "tristrip", pend, None)
elif tag == 0x45: _parse_bgf_vertices(bz, ln, "tristrip", pend, None)
elif tag == 0x46: _parse_bgf_vertices(bz, ln, "pmesh", pend, None)
bz.p = nxt
for g in pend:
g["material"] = f_mtl
out_groups.append(g)
def parse_bgf(path):
"""Parse DIV-BIZ2 geometry -> groups like parse_vgf. Also returns any
embedded materials/textures/ramps."""
bz = B2Z(path)
if bz.d[:8] != b"DIV-BIZ2":
raise ValueError("not a DIV-BIZ2 file: " + path)
bz.p = 8
groups, materials, textures, ramps = [], {}, {}, {}
while bz.p < len(bz.d) - 2:
tag, ln = bz.block()
nxt = bz.p + ln
top = tag & 0xfff
if top == 0x005: break # trailer
elif top == 0x003 or top == 0x004: pass
elif top == 0x010: _parse_biz_texture(bz, ln, textures)
elif top == 0x020: _parse_biz_material(bz, ln, materials)
elif top == 0x030: _parse_biz_ramp(bz, ln, ramps)
elif top == 0x040: _parse_bgf_object(bz, ln, groups)
bz.p = nxt
return groups, materials, textures, ramps
def _parse_bgf_lod(bz, length, groups, obj_mtl):
end = bz.p + length
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x42: _parse_bgf_patch(bz, ln, groups, obj_mtl)
bz.p = nxt
def _parse_bgf_object(bz, length, groups):
end = bz.p + length
obj_mtl = None
lods = [] # (size, offset) of nested LOD blocks
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x2008: bz.cstr()
elif tag == 0x2030:
t = bz.u8()
if t == 1: obj_mtl = bz.cstr()
elif tag == 0x2031:
t = bz.u8()
if t == 1: bz.cstr()
elif tag == 0x42: _parse_bgf_patch(bz, ln, groups, obj_mtl)
elif tag == 0x41: lods.append((ln, bz.p))
bz.p = nxt
if lods:
# newer writers nest patches inside LODs (Division's 1994 reader
# predates this); take the largest LOD = highest detail
ln, off = max(lods)
bz.p = off
_parse_bgf_lod(bz, ln, groups, obj_mtl)
bz.p = end
def _parse_biz_material(bz, length, materials):
end = bz.p + length
m = {"ambient": None, "diffuse": None, "emissive": None,
"texture": None, "ramp": None, "immune": False}
name = "unnamed"
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x2008: name = bz.cstr()
elif tag == 0x0021:
mode = bz.u8()
if mode == 2: m["texture"] = bz.cstr()
elif tag == 0x0023: m["ambient"] = [r3(x) for x in bz.floats(3)]
elif tag == 0x0024: m["diffuse"] = [r3(x) for x in bz.floats(3)]
elif tag == 0x0026: m["emissive"] = [r3(x) for x in bz.floats(3)]
elif tag == 0x0028: m["ramp"] = bz.cstr()
bz.p = nxt
materials[name] = m
def _parse_biz_texture(bz, length, textures):
end = bz.p + length
name, mapname = None, None
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x2008: name = bz.cstr()
elif tag == 0x0011: mapname = bz.cstr()
bz.p = nxt
if name: textures[name] = {"map": (mapname or "").lower() or None, "slice": None}
def _parse_biz_ramp(bz, length, ramps):
end = bz.p + length
name, data = None, None
while bz.p < end:
tag, ln = bz.block()
nxt = bz.p + ln
if tag == 0x2008: name = bz.cstr()
elif tag == 0x0031:
f = bz.floats(6); data = [list(f[0:3]), list(f[3:6])]
bz.p = nxt
if name and data: ramps[name] = data
# ================================================================ textures
def tga_datauri(path):
d = open(path, "rb").read()
idlen, imgtype = d[0], d[2]
w, h = struct.unpack("<HH", d[12:16])
bpp, desc = d[16], d[17]
if imgtype != 2 or bpp not in (24, 32): raise ValueError("TGA %s type=%d bpp=%d" % (path, imgtype, bpp))
off = 18 + idlen; nb = bpp // 8
rows = []
for y in range(h):
row = bytearray()
base = off + y * w * nb
for x in range(w):
row += bytes((d[base+x*nb+2], d[base+x*nb+1], d[base+x*nb]))
rows.append(bytes(row))
if not (desc & 0x20): rows.reverse()
return png_datauri(w, h, rows)
def svt_datauri(path):
d = open(path, "rb").read()
edge = {16384: 64, 65536: 128, 262144: 256}.get(len(d))
if not edge: raise ValueError("SVT %s bad size %d" % (path, len(d)))
rows = []
for y in range(edge):
row = bytearray()
base = y * edge * 4
for x in range(edge):
# texel layout: X R G B (first byte unused/alpha)
row += d[base + x*4 + 1: base + x*4 + 4]
rows.append(bytes(row))
return png_datauri(edge, edge, rows)
# ================================================================ SPL / SCN
def parse_spl(path):
pts = []
for l in open(path, errors="replace").read().splitlines()[1:]:
try: nums = [float(x) for x in l.split()]
except ValueError: continue
if len(nums) >= 3: pts.append([r3(v) for v in nums[:3]])
return pts
def parse_scn(path):
scene = {"lights": [], "placements": []}
last = None
for raw in open(path, errors="replace"):
line = raw.split("//")[0].split("#")[0].strip()
if not line: continue
t = line.split()
k = t[0].upper()
try:
if k == "VIEWANGLE": scene["fov"] = float(t[1])
elif k == "CLIP": scene["clip"] = [float(t[1]), float(t[2])]
elif k == "FOG": scene["fog"] = [float(x) for x in t[1:6]]
elif k == "BACKGND": scene["bg"] = [float(x) for x in t[1:4]]
elif k == "AMBIENT": scene["ambient"] = [float(x) for x in t[1:4]]
elif k == "LIGHT": scene["lights"].append([float(x) for x in t[1:7]])
elif k == "START": scene["start"] = [float(x) for x in t[1:7]]
elif k in ("GEOMETRY", "MATERIAL", "TEXTURE"): scene[k.lower()] = t[1]
elif k == "STATIC":
last = {"kind": "static", "geo": t[1].lower(), "scale": float(t[2]),
"pos": [float(x) for x in t[3:6]], "rot": [float(x) for x in t[6:9]],
"children": []}
scene["placements"].append(last)
elif k == "DYNAMIC":
last = {"kind": "dynamic", "geo": t[1].lower(), "scale": float(t[2]),
"spl": os.path.basename(t[3].replace("\\", "/")).lower(),
"phase": float(t[4]), "speed": float(t[5]), "children": []}
scene["placements"].append(last)
elif k in ("SCHILD", "DCHILD") and last is not None:
last["children"].append({"geo": t[1].lower(), "scale": float(t[2])})
except (ValueError, IndexError):
pass
return scene