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

948 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
bundle.py -- pack DPL3 models (.B2Z) + textures (.SVT) into a single, self-contained
WebGL viewer HTML. No server, no dependencies, no external assets: geometry is inlined
as JSON and textures as base64 RGBA. Open the output in any browser.
python viewer/bundle.py # builds viewer/dpl3-viewer.html from SHOWCASE
The viewer proves the whole recovered art pipeline end-to-end: B2Z geometry + UVs
rendered on modern hardware, textured with decoded SVT bitmaps.
"""
import base64
import glob
import json
import math
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(ROOT, "parser"))
import b2z
import scn
import spl
import bsl
import gamemap
import svt
# the read-only source archive (models/textures are resolved by name from here)
ARCHIVE = r"s:/OneDrive/Tesla III/DaveMcCoy/sda4"
# ---- what to put in the viewer ---------------------------------------------
# Each model: file + per-material {color | texture}. Material is matched by the
# f_material name carried in the B2Z; "*" is the fallback for any other material.
SAMPLES = os.path.join(ROOT, "samples")
SHOWCASE = [
{
"name": "Raptor terrain flyover (fly-through)",
"scene": "DPL3RLS/SCENES/RAPTOR.SCN",
"spline": "DPL3RLS/EXAMPLES/CAMERA.SPL",
"up": "y",
},
{
"name": "Red Planet canal (assembled scene, 47 objects)",
"scene": "DPL3RLS/SCENES/CANAL.SCN",
"up": "y",
},
{
"name": "Aquarium reef (underwater scene)",
"scene": "DPL3/EXAMPLES/SHARKS.SCN",
"up": "y",
},
{
"name": "Star Trek — the Tesla (TREK.SCN)",
"scene": "STDAVE/TREK.SCN",
"up": "y",
},
{
"name": "Red Planet canyon (textured terrain)",
"file": "CANYONS1.B2Z",
"up": "z",
"materials": {
"redrock_mtl": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.55, 0.4]},
"water1_mtl": {"color": [0.15, 0.28, 0.5]},
"*": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.6, 0.45]},
},
},
{
"name": "Test box (multi-material, per-part colour)",
"file": "TESTBOX.B2Z",
"up": "y",
"materials": {"*": {"from_material": True}},
},
{
"name": "Sphere (smooth-shaded)",
"file": "BALL.B2Z",
"up": "y",
"materials": {"*": {"color": [0.72, 0.74, 0.8]}},
},
]
def compute_normals(positions, indices):
n = [0.0] * len(positions)
for t in range(0, len(indices), 3):
i, j, k = indices[t] * 3, indices[t + 1] * 3, indices[t + 2] * 3
ax, ay, az = positions[i], positions[i + 1], positions[i + 2]
bx, by, bz = positions[j], positions[j + 1], positions[j + 2]
cx, cy, cz = positions[k], positions[k + 1], positions[k + 2]
ux, uy, uz = bx - ax, by - ay, bz - az
vx, vy, vz = cx - ax, cy - ay, cz - az
nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
for base in (i, j, k):
n[base] += nx; n[base + 1] += ny; n[base + 2] += nz
for o in range(0, len(n), 3):
L = math.sqrt(n[o]**2 + n[o+1]**2 + n[o+2]**2) or 1.0
n[o] /= L; n[o+1] /= L; n[o+2] /= L
return n
_TEX_INFO = {}
def load_texture_rgba(path):
"""Decode a texture ref to (edge, rgba, is_luminance).
Refs are .SVT paths or ("bsl", path, slice) bit-slice picks.
Full-colour painted skins (vehicle textures) are shown as-is. Greyscale 'noise'
maps, bit-slices, and broken intensity maps (a dead colour channel) are rebuilt
as luminance and flagged -- those get coloured by the material's diffuse."""
if path in _TEX_INFO:
return _TEX_INFO[path]
if isinstance(path, tuple) and path[0] == "bsl":
b = bsl.load(path[1])
rgba = bsl.slice_plane(b, path[2])
res = (b["edge"], rgba, True)
_TEX_INFO[path] = res
return res
data, edge = svt.load(path)
rgba = bytearray(svt.to_rgba(data, "xbgr"))
chan_max = [max(rgba[c::4]) for c in range(3)]
is_lum = False
if min(chan_max) < 32: # a dead channel -> intensity map
for i in range(0, len(rgba), 4):
L = max(rgba[i], rgba[i+1], rgba[i+2])
rgba[i] = rgba[i+1] = rgba[i+2] = L
is_lum = True
else: # inherently greyscale (low saturation)?
sat = n = 0
for i in range(0, len(rgba), 4 * 17):
sat += max(rgba[i], rgba[i+1], rgba[i+2]) - min(rgba[i], rgba[i+1], rgba[i+2])
n += 1
if n and sat / n < 22:
is_lum = True
res = (edge, bytes(rgba), is_lum)
_TEX_INFO[path] = res
return res
def build_texture(svt_path, cache):
if svt_path in cache:
return cache[svt_path]
edge, rgba, _is_lum = load_texture_rgba(svt_path)
idx = len(cache)
cache[svt_path] = idx
build_texture.records.append({
"w": edge, "h": edge,
"rgba": base64.b64encode(rgba).decode("ascii"),
})
return idx
# ---- scene assembly --------------------------------------------------------
_INDEX = None
_MODEL_CACHE = {}
_MATS_CACHE = {}
FLIP_V = False # The original renderer indexed texture rows directly: v=0 = SVT
# row 0 (image top). Verified by A/B renders: mech pod vents vs
# missile tubes, the MADCOL "08" insignia, and the shark's
# back/belly shading are all correct UNFLIPPED and inverted when
# flipped. GL's texImage2D also puts the first uploaded row at
# t=0, so v passes through unchanged.
def load_model_cached(path):
if path not in _MODEL_CACHE:
_MODEL_CACHE[path] = b2z.load(path)
return _MODEL_CACHE[path]
def load_mats_cached(geo_path):
if geo_path not in _MATS_CACHE:
v = v2z_for(geo_path)
_MATS_CACHE[geo_path] = parse_v2z_materials(v) if v else {}
return _MATS_CACHE[geo_path]
def archive_index():
"""basename(lower, no ext) -> path indexes over the read-only archive:
geo : geometry (.B2Z/.BIZ first, then game-side .BGF)
tex : .SVT texture maps
bsl : .BSL bit-slice texture packs
v2z : .V2Z text model sources (materials)
mtl : material libraries (.VMF text preferred over .BMF binary)"""
global _INDEX
if _INDEX is None:
geo, tex, v2z, bsl, mtl = {}, {}, {}, {}, {}
for ext in ("B2Z", "BIZ", "BGF"):
for f in glob.glob(ARCHIVE + "/**/*." + ext, recursive=True):
geo.setdefault(os.path.basename(f)[:-4].lower(), f)
for f in glob.glob(ARCHIVE + "/**/*.SVT", recursive=True):
tex.setdefault(os.path.basename(f)[:-4].lower(), f)
for f in glob.glob(ARCHIVE + "/**/*.BSL", recursive=True):
bsl.setdefault(os.path.basename(f)[:-4].lower(), f)
for f in glob.glob(ARCHIVE + "/**/*.V2Z", recursive=True):
v2z.setdefault(os.path.basename(f)[:-4].lower(), f)
for f in glob.glob(ARCHIVE + "/**/*.VMF", recursive=True):
mtl.setdefault(os.path.basename(f)[:-4].lower(), f)
for f in glob.glob(ARCHIVE + "/**/*.BMF", recursive=True):
mtl.setdefault(os.path.basename(f)[:-4].lower(), f)
mod = {}
for f in glob.glob(ARCHIVE + "/**/*.MOD", recursive=True):
mod.setdefault(os.path.basename(f)[:-4].lower(), f)
_INDEX = {"geo": geo, "tex": tex, "v2z": v2z, "bsl": bsl, "mtl": mtl, "mod": mod}
return _INDEX
def v2z_for(geo_path):
"""The material text source matching a geometry file -- SAME directory/basename
first (geometry and materials must come from one source; different asset dirs
define the same material name with different colours), else any same-name V2Z.
Game-side .BGF models pair with a .VGF text twin the same way."""
if not geo_path:
return None
base = os.path.splitext(geo_path)[0]
for ext in (".V2Z", ".v2z", ".VGF", ".vgf"):
if os.path.exists(base + ext):
return base + ext
return archive_index()["v2z"].get(os.path.basename(base).lower())
_NAME_RE = re.compile(r'NAME\s*=\s*"?([\w.]+)"?', re.I)
_SPECIAL_RE = re.compile(r'SPECIAL\s*=\s*"([^"]*)"', re.I)
_SCROLL_RE = re.compile(r"SCROLL\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)")
_MAP_RE = re.compile(r"MAP\s*\{\"?(\w+)\"?\}")
_DIF_RE = re.compile(r"DIFFUSE\s*\{([^}]*)\}")
_MTLTEX_RE = re.compile(r"TEXTURE\s*\{\"?([\w.]+)\"?\}")
_SLICE_RE = re.compile(r"BITSLICE\s*\{(\d+)\}")
def _blocks(text, kw):
"""yield (header, body) for `KW(header) {body}` blocks, brace-matched.
Two source dialects exist: modeller exports (NAME=x, `// End of ...` marker
comments) and the compact game libraries (NAME="x", no markers, SPECIAL=
hooks in the header). Matching braces handles both; `KW {...}` references
(no parens) inside other bodies are not block starts and are skipped."""
pos = 0
while True:
j = text.find(kw + "(", pos)
if j < 0:
return
if j and (text[j - 1].isalnum() or text[j - 1] == "_"):
pos = j + len(kw)
continue
h_end = text.find(")", j)
if h_end < 0:
return
b = text.find("{", h_end)
nxt = text.find(kw + "(", h_end)
if b < 0 or (0 <= nxt < b): # headerless block (no body braces)
yield text[j + len(kw) + 1:h_end], ""
pos = h_end + 1
continue
depth, e = 0, b
for e in range(b, len(text)):
c = text[e]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
yield text[j + len(kw) + 1:h_end], text[b + 1:e]
pos = e + 1
def _parse_scroll(special):
"""texture SPECIAL hook ' SCROLL u0 v0 du dv' -> [u0,v0,du,dv] or None."""
if not special:
return None
m = _SCROLL_RE.search(special)
return [float(m.group(i)) for i in range(1, 5)] if m else None
def parse_v2z_materials(path):
"""Authoritative materials from a .V2Z/.VGF/.VMF text source:
name -> (diffuse[3], map_name_or_None, bitslice, scroll_or_None, texname).
scroll is the texture entity's SPECIAL " SCROLL u0 v0 du dv" hook (UV drift
rates -- rivers, smoke, laser beams); texname is the TEXTURE entity name a
scene-side SCROLL override addresses."""
try:
t = open(path, encoding="latin-1").read()
except OSError:
return {}
texmap = {}
for header, body in _blocks(t, "TEXTURE"):
nm = _NAME_RE.search(header)
if not nm:
continue
mm = _MAP_RE.search(body)
sl = _SLICE_RE.search(body)
sp = _SPECIAL_RE.search(header)
texmap[nm.group(1).lower()] = (mm.group(1).lower() if mm else None,
int(sl.group(1)) if sl else 0,
_parse_scroll(sp.group(1) if sp else None))
out = {}
for header, body in _blocks(t, "MATERIAL"):
nm = _NAME_RE.search(header)
if not nm:
continue
dif = _DIF_RE.search(body)
diffuse = [float(x) for x in dif.group(1).split(",")] if dif else [0.7, 0.7, 0.7]
tx = _MTLTEX_RE.search(body)
texname = tx.group(1).lower() if tx else None
tmap, sl, scroll = texmap.get(texname, (None, 0, None)) if texname else (None, 0, None)
out[nm.group(1).lower()] = (diffuse, tmap, sl, scroll, texname)
return out
_LIB_CACHE = {}
def load_material_library(libname):
"""Load a named material library (`stships` in `stships:eblu4_mtl`).
Text .VMF preferred; binary .BMF parsed with the b2z reader (which now
captures the 0x018 BITSLICE tag)."""
key = libname.lower()
if key in _LIB_CACHE:
return _LIB_CACHE[key]
path = archive_index()["mtl"].get(key)
mats = {}
if path and path.lower().endswith((".vmf", ".vgf", ".v2z")):
mats = parse_v2z_materials(path)
elif path:
try:
lib = b2z.load(path)
for name, mt in lib.materials.items():
tx = lib.textures.get(mt.texture) if mt.texture else None
mats[name.lower()] = (list(mt.diffuse),
tx.mapfile.lower() if tx and tx.mapfile else None,
tx.bitslice if tx else 0,
_parse_scroll(tx.special) if tx else None,
mt.texture.lower() if mt.texture else None)
except Exception:
mats = {}
_LIB_CACHE[key] = mats
return mats
# fallback surface colours when a material has no texture (keyed by name substring)
COLOR_HINTS = [
("water", [0.15, 0.28, 0.5]), ("redrock", [0.55, 0.32, 0.24]),
("rednoi", [0.58, 0.3, 0.24]), ("rock", [0.5, 0.42, 0.38]),
("techy", [0.5, 0.52, 0.57]), ("grey", [0.55, 0.55, 0.58]),
("metal", [0.55, 0.57, 0.6]), ("sky", [0.4, 0.6, 0.8]),
]
# when a material has no exact <stem>.SVT, fall back to a representative texture
# for common terrain surfaces (the authoritative map lives in material libraries
# we haven't decoded; this is a documented plausibility fallback -- see SCN spec).
# keyword -> (texture stem, tint). Grey noise textures are tinted by the material
# colour, as the original renderer did (e.g. red rock = grey rock-noise * red diffuse).
TEXTURE_HINTS = [
("redrock", ("rocknoiz", [1.0, 0.50, 0.38])),
("rednoi", ("rocknoiz", [1.0, 0.50, 0.38])),
("cliff", ("rocknoiz", [1.0, 0.50, 0.38])),
("rock", ("rocknoiz", [0.95, 0.62, 0.52])),
("techy", ("generic", [0.78, 0.80, 0.85])),
]
def surface_for(matname, has_uv, idx, tint=(1.0, 1.0, 1.0)):
"""Resolve a material name to (texture_path_or_None, color).
First the exact stem==texture convention (e.g. 'vtvcab_mtl' -> VTVCAB.SVT),
then a keyword texture fallback (tinted), then a flat colour."""
stem = (matname or "").lower()
if stem.endswith("_mtl"):
stem = stem[:-4]
if has_uv and stem in idx["tex"]:
return idx["tex"][stem], list(tint)
if has_uv:
for key, (tname, ttint) in TEXTURE_HINTS:
if key in stem and tname in idx["tex"]:
return idx["tex"][tname], list(ttint)
for key, col in COLOR_HINTS:
if key in stem:
return None, col
return None, [0.62, 0.6, 0.6]
def bake_instances(instances, idx, scrolls=None):
"""Bake a list of (model, materials, matrix, model_name) into merged, textured
surfaces in world space. Shared by whole scenes and single-model previews.
`scrolls` is the scene's SCROLL override map: texture entity id ("lib:name"
or plain name) -> [u0,v0,du,dv]; a texture's own SPECIAL scroll applies
even without an override. Returns (groups, tex_records, bounds, tris)."""
surfaces = {} # (texpath, colour) -> dict(pos,normal,uv,idx,color,texture)
tex_ids = {}
tex_records = []
def resolve(matname, has_uv, mats, model_name=None):
"""-> (texture_ref|None, colour, from_material, scroll|None, texid|None).
A texture_ref is an .SVT path or ("bsl", path, slice) for bit-slice packs;
texid is the addressable texture entity ("lib:name" / plain name).
Namespaced names (`stships:eblu4_mtl`, the game-side convention) resolve
through the named material library (.VMF/.BMF) and use its declared map
directly. Plain names use the model's own materials with candidates:
per-part skin <stem>.SVT, the declared map, then the model-name skin."""
mname = (matname or "").lower()
if ":" in mname:
lib, sub = mname.split(":", 1)
info = load_material_library(lib).get(sub)
if info is not None:
diffuse, tmap, sl, scroll, texname = info
tref = None
if has_uv and tmap:
if tmap in idx["bsl"]:
tref = ("bsl", idx["bsl"][tmap], sl)
elif tmap in idx["tex"]:
tref = idx["tex"][tmap]
return tref, list(diffuse), True, scroll, \
(lib + ":" + texname) if texname else None
return None, [0.62, 0.6, 0.6], True, None, None
info = mats.get(mname)
stem = mname[:-4] if mname.endswith("_mtl") else mname
tmap, sl, scroll, texname = (info[1], info[2], info[3], info[4]) if info \
else (None, 0, None, None)
tref = None
if has_uv:
for cand in (stem, tmap, model_name):
if cand and cand in idx["tex"]:
tref = idx["tex"][cand]
break
if tref is None and tmap and tmap in idx["bsl"]:
tref = ("bsl", idx["bsl"][tmap], sl)
if info is not None:
return tref, list(info[0]), True, scroll, texname
if tref is not None: # generic material, but a model skin exists
return tref, [1.0, 1.0, 1.0], True, None, None
tp, color = surface_for(matname, has_uv, idx)
return tp, color, False, None, None
def get_surface(matname, has_uv, mats, model_name=None, anim=None, akey=None):
tpath, color, from_mat, scroll, texid = resolve(matname, has_uv, mats, model_name)
if anim is None and tpath is not None:
rates = (scrolls or {}).get(texid, scroll)
if rates and (rates[2] or rates[3]):
anim = {"type": "scroll", "rate": [rates[2], rates[3]],
"phase": [rates[0], rates[1]]}
akey = ("scroll", texid, rates[2], rates[3])
tid = -1
if tpath is not None:
edge, rgba, is_lum = load_texture_rgba(tpath)
if from_mat and not is_lum:
color = [1.0, 1.0, 1.0] # full-colour skin: show texture as-is
if tpath not in tex_ids:
tex_ids[tpath] = len(tex_records)
tex_records.append({"w": edge, "h": edge,
"rgba": base64.b64encode(rgba).decode("ascii")})
tid = tex_ids[tpath]
# key on texture AND colour so materials sharing a texture but differing
# in tint stay separate; animated parts get their own surfaces (akey) so
# the viewer can move them independently of the static batch.
key = (tpath, tuple(round(c, 4) for c in color), akey)
if key not in surfaces:
surfaces[key] = {"positions": [], "normals": [], "uvs": [],
"indices": [], "color": color, "texture": tid}
if anim:
surfaces[key]["anim"] = anim
return surfaces[key]
for inst in instances:
model, mats, M, mname = inst[0], inst[1], inst[2], inst[3]
anim = inst[4] if len(inst) > 4 else None
if model is None:
continue
akey = id(anim) if anim else None
for g in model.geogroups:
uv_present = any(geom.verts and geom.verts[0].uv for geom in g.geoms)
surf = get_surface(g.f_material, uv_present, mats, mname, anim, akey)
for geom in g.geoms:
gbase = len(surf["positions"]) // 3
for v in geom.verts:
wx, wy, wz = scn.xform_point(M, v.pos[0], v.pos[1], v.pos[2])
surf["positions"] += [wx, wy, wz]
if v.normal:
nx, ny, nz = scn.xform_dir(M, *v.normal)
else:
nx, ny, nz = 0.0, 1.0, 0.0
surf["normals"] += [nx, ny, nz]
if v.uv:
surf["uvs"] += [v.uv[0], (1.0 - v.uv[1]) if FLIP_V else v.uv[1]]
else:
surf["uvs"] += [0.0, 0.0]
for (a, b, c) in b2z.triangles(geom):
surf["indices"] += [gbase + a, gbase + b, gbase + c]
groups = []
for s in surfaces.values():
if not s["indices"]:
continue
g = {
"positions": [round(x, 3) for x in s["positions"]],
"normals": [round(x, 4) for x in s["normals"]],
"uvs": [round(x, 4) for x in s["uvs"]],
"indices": s["indices"], "color": s["color"], "texture": s["texture"],
}
if s.get("anim"):
g["anim"] = s["anim"]
groups.append(g)
xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)]
ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)]
zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)]
bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]} if xs \
else {"min": [-1, -1, -1], "max": [1, 1, 1]}
tris = sum(len(g["indices"]) for g in groups) // 3
return groups, tex_records, bounds, tris
def build_model_archive(geo_path, name):
"""Build one standalone model (identity transform) with authoritative materials."""
idx = archive_index()
model = load_model_cached(geo_path)
mats = load_mats_cached(geo_path)
groups, tex_records, bounds, tris = bake_instances(
[(model, mats, scn.ident(), name.lower())], idx)
return {"name": name.upper(), "up": "y", "groups": groups,
"textures": tex_records, "bounds": bounds, "tris": tris}
def _tree_file(map_path, subdir, base, exts):
"""Look for <maproot>/../<subdir>/<base>.<ext> -- assets from the SAME game
tree as the map (BTDAVE maps use BTDAVE models, not BTLIVE's)."""
root = os.path.dirname(os.path.dirname(map_path))
for d in (os.path.join(root, subdir), os.path.join(root, subdir, "EDITBACK")):
for ext in exts:
p = os.path.join(d, base + ext)
if os.path.exists(p):
return p
return None
def _dropzone_marker(x, y, z):
"""A small red pyramid marking a pod dropzone."""
s, h = 1.5, 4.0
pos = [x, y + h, z, x - s, y, z - s, x + s, y, z - s,
x + s, y, z + s, x - s, y, z + s]
idx = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 3, 2, 1, 4, 3]
return pos, idx
def build_map(cfg):
"""Assemble a game .MAP (arena / track) into a renderable scene."""
map_path = os.path.join(ARCHIVE, cfg["map"])
idx = archive_index()
def mod_resolver(modfile):
"""-> [(bgf_name, local_anim_or_None), ...] for every video object in the
MOD. Door leaves get a data-driven slide from the .SUB (MotionExtent /
TravelTime / DeadTime); laser-drill objects ('laz*') get a glow pulse."""
base = os.path.splitext(os.path.basename(modfile or ""))[0].lower()
if not base:
return []
p = _tree_file(map_path, "MODELS", base, (".MOD",)) or idx["mod"].get(base)
if not p:
return []
names, gclass, subfile = gamemap.parse_mod_objects(p)
doors = {}
if subfile:
sp = os.path.join(os.path.dirname(p), os.path.basename(subfile))
if os.path.exists(sp):
doors = gamemap.parse_sub(sp)
out = []
for n in names:
anim = None
if n in doors:
d = doors[n]
anim = {"type": "slide", "extent": d["extent"],
"travel": d["travel"], "dead": d["dead"]}
elif n.startswith("laz"):
anim = {"type": "pulse"} # laser drill: engine-driven; approximated
out.append((n, anim))
return out
placements, dropzones = gamemap.expand(map_path, mod_resolver)
instances, missing = [], set()
for (bgf, anim), M in placements:
p = _tree_file(map_path, "VIDEO", bgf, (".BGF",)) or idx["geo"].get(bgf)
if not p:
missing.add(bgf)
continue
wanim = None
if anim and anim.get("type") == "slide":
ex, ey, ez = scn.xform_dir(M, *anim["extent"]) # local -> world axis
wanim = {"type": "slide", "extent": [round(ex, 3), round(ey, 3), round(ez, 3)],
"travel": anim["travel"], "dead": anim["dead"]}
elif anim:
wanim = dict(anim)
instances.append((load_model_cached(p), load_mats_cached(p), M, bgf, wanim))
groups, tex_records, bounds, tris = bake_instances(instances, idx)
if dropzones:
pos, nrm, uv, ind = [], [], [], []
for d in dropzones:
mp, mi = _dropzone_marker(d[0], d[1], d[2])
base = len(pos) // 3
pos += mp
nrm += [0.0, 1.0, 0.0] * (len(mp) // 3)
uv += [0.0, 0.0] * (len(mp) // 3)
ind += [base + i for i in mi]
groups.append({"positions": [round(v, 3) for v in pos], "normals": nrm,
"uvs": uv, "indices": ind,
"color": [0.9, 0.1, 0.1], "texture": -1})
out = {"name": cfg["name"], "up": "y", "groups": groups,
"textures": tex_records, "bounds": bounds, "tris": tris,
"background": [0.05, 0.05, 0.08]}
print(" map %-22s %d placements (%d missing: %s), %d dropzones, %d surfaces"
% (os.path.basename(cfg["map"]), len(placements), len(missing),
sorted(missing)[:4], len(dropzones), len(groups)))
return out
def build_scene(cfg):
sc = scn.load(os.path.join(ARCHIVE, cfg["scene"]))
idx = archive_index()
cache, mat_cache = {}, {}
for inst in sc.instances:
n = inst.name.lower()
if n not in cache:
path = idx["geo"].get(n)
cache[n] = load_model_cached(path) if path else None
mat_cache[n] = load_mats_cached(path) if path else {}
instances = [(cache.get(i.name.lower()), mat_cache.get(i.name.lower(), {}),
i.matrix, i.name.lower()) for i in sc.instances]
placed = sum(1 for m, _, _, _ in instances if m is not None)
groups, tex_records, bounds, tris = bake_instances(instances, idx, sc.scrolls)
out = {
"name": cfg["name"], "up": cfg.get("up", "y"), "groups": groups,
"textures": tex_records, "bounds": bounds, "tris": tris,
"background": list(sc.background),
}
if sc.fog:
z0, z1, r, g, b = sc.fog
out["fog"] = [r, g, b, z0, z1]
if cfg.get("spline"):
frames = spl.sample_flythrough(os.path.join(ARCHIVE, cfg["spline"]))
out["flythrough"] = [{"eye": [round(x, 2) for x in f["eye"]],
"center": [round(x, 2) for x in f["center"]],
"up": [round(x, 3) for x in f["up"]]} for f in frames]
print(" scene %-22s %d/%d objects placed, %d surfaces, %d textures%s"
% (os.path.basename(cfg["scene"]), placed, len(sc.instances),
len(groups), len(tex_records),
", %d camera frames" % len(out["flythrough"]) if "flythrough" in out else ""))
return out
def build_model(cfg):
if "scene" in cfg:
return build_scene(cfg)
model = b2z.load(os.path.join(SAMPLES, cfg["file"]))
idx = archive_index()
v2z = idx["v2z"].get(os.path.splitext(cfg["file"])[0].lower())
mats = parse_v2z_materials(v2z) if v2z else {}
tex_cache = {}
build_texture.records = []
groups = []
for g in model.geogroups:
# merge every geometry in the geogroup into one indexed mesh
pos, uv, ind = [], [], []
have_uv = False
for geom in g.geoms:
base = len(pos) // 3
for v in geom.verts:
pos += [v.pos[0], v.pos[1], v.pos[2]]
if v.uv:
have_uv = True
uv += [v.uv[0], 1.0 - v.uv[1]] # flip V for GL origin
else:
uv += [0.0, 0.0]
for (a, b, c) in b2z.triangles(geom):
ind += [base + a, base + b, base + c]
if not ind:
continue
# resolve material -> colour / texture. Prefer the model's own authoritative
# material (.V2Z: diffuse x texture); fall back to the showcase cfg.
color = [0.7, 0.7, 0.72]
tex = -1
info = mats.get((g.f_material or "").lower())
if info is not None:
diffuse, tmap = info[0], info[1]
color = list(diffuse)
if have_uv and tmap and tmap in idx["tex"]:
tex = build_texture(idx["tex"][tmap], tex_cache) # modulated by diffuse
else:
spec = cfg.get("materials", {}).get(g.f_material) or cfg.get("materials", {}).get("*", {})
tint = spec.get("tint", [1.0, 1.0, 1.0])
if spec.get("from_material") and g.f_material in model.materials:
d = model.materials[g.f_material].diffuse
color = [d[0], d[1], d[2]]
elif "color" in spec:
color = spec["color"]
if "texture" in spec and have_uv:
tex = build_texture(os.path.join(SAMPLES, spec["texture"]), tex_cache)
color = tint
groups.append({
"positions": [round(x, 4) for x in pos],
"normals": [round(x, 4) for x in compute_normals(pos, ind)],
"uvs": [round(x, 4) for x in uv],
"indices": ind,
"color": color,
"texture": tex,
})
# bounds
xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)]
ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)]
zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)]
bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]}
tris = sum(len(g["indices"]) for g in groups) // 3
return {
"name": cfg["name"], "up": cfg["up"], "groups": groups,
"textures": build_texture.records, "bounds": bounds, "tris": tris,
}
def main():
scene = {"models": [build_model(c) for c in SHOWCASE]}
payload = json.dumps(scene, separators=(",", ":"))
html = TEMPLATE.replace("/*__SCENE__*/null", payload)
out = os.path.join(HERE, "dpl3-viewer.html")
with open(out, "w", encoding="utf-8") as fp:
fp.write(html)
kb = len(html) / 1024
print("wrote %s (%.0f KB, %d models)" % (out, kb, len(scene["models"])))
for m in scene["models"]:
print(" - %-42s %5d tris, %d textures" % (m["name"], m["tris"], len(m["textures"])))
# ---- the viewer (content-only: no <html>/<head>/<body> so it doubles as an
# Artifact source; browsers render it fine opened directly too) ----------
TEMPLATE = r"""<title>DPL3 Model Viewer</title>
<style>
:root{--bg:#0d0b09;--panel:#17130d;--hair:#3a3126;--amber:#f2a73b;--ink:#ece2d0;--muted:#9a8d76;}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--ink);font:14px/1.5 ui-monospace,Consolas,monospace;overflow:hidden}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
#c:active{cursor:grabbing}
.panel{position:fixed;left:16px;top:16px;background:rgba(23,19,13,.88);border:1px solid var(--hair);
border-radius:10px;padding:14px 16px;max-width:340px;backdrop-filter:blur(4px)}
.eyebrow{color:var(--amber);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin-bottom:8px}
h1{font:600 16px/1.2 system-ui,sans-serif;margin-bottom:2px}
.sub{color:var(--muted);font-size:12px;margin-bottom:12px}
select{width:100%;background:#0d0b09;color:var(--ink);border:1px solid var(--hair);border-radius:6px;
padding:7px 9px;font:13px ui-monospace,monospace;margin-bottom:10px}
.stat{display:flex;justify-content:space-between;color:var(--muted);font-size:12px}
.stat b{color:var(--ink);font-weight:600}
.hint{color:var(--muted);font-size:11px;margin-top:10px;border-top:1px solid var(--hair);padding-top:9px}
label.row{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;margin-top:8px;cursor:pointer}
.err{position:fixed;inset:0;display:none;place-items:center;text-align:center;padding:40px;color:var(--muted)}
</style>
<canvas id="c"></canvas>
<div class="panel">
<div class="eyebrow">DPL3 &middot; recovered art</div>
<h1>Model Viewer</h1>
<div class="sub">Virtual World Entertainment, c.1994 &mdash; on modern hardware</div>
<select id="pick"></select>
<div class="stat"><span>triangles</span><b id="tris">&mdash;</b></div>
<div class="stat"><span>geo&#8203;groups</span><b id="grps">&mdash;</b></div>
<div class="stat"><span>textures</span><b id="texs">&mdash;</b></div>
<label class="row" id="flyrow" style="display:none"><input type="checkbox" id="fly"> play fly&#8209;through</label>
<label class="row"><input type="checkbox" id="spin" checked> auto-rotate</label>
<label class="row"><input type="checkbox" id="wire"> wireframe</label>
<div class="hint">drag to orbit &middot; scroll to zoom</div>
</div>
<div class="err" id="err"></div>
<script>
"use strict";
var SCENE=/*__SCENE__*/null;
// ---- tiny mat4 -------------------------------------------------------------
var M={
mul:function(a,b){var o=new Float32Array(16);for(var r=0;r<4;r++)for(var c=0;c<4;c++){var s=0;
for(var k=0;k<4;k++)s+=a[k*4+r]*b[c*4+k];o[c*4+r]=s;}return o;},
persp:function(f,asp,n,fr){var t=1/Math.tan(f/2),o=new Float32Array(16);
o[0]=t/asp;o[5]=t;o[10]=(fr+n)/(n-fr);o[11]=-1;o[14]=2*fr*n/(n-fr);return o;},
look:function(e,c,u){var z=nrm(sub(e,c)),x=nrm(crs(u,z)),y=crs(z,x);
return new Float32Array([x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
-dot(x,e),-dot(y,e),-dot(z,e),1]);},
norm3:function(m){return new Float32Array([m[0],m[1],m[2],m[4],m[5],m[6],m[8],m[9],m[10]]);}
};
function sub(a,b){return[a[0]-b[0],a[1]-b[1],a[2]-b[2]];}
function crs(a,b){return[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];}
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
function nrm(a){var l=Math.hypot(a[0],a[1],a[2])||1;return[a[0]/l,a[1]/l,a[2]/l];}
var gl,prog,loc,canvas=document.getElementById("c");
var VS="attribute vec3 aPos;attribute vec3 aNrm;attribute vec2 aUV;"+
"uniform mat4 uMVP;uniform mat4 uView;uniform mat3 uN;uniform vec2 uUVOff;"+
"varying vec3 vN;varying vec2 vUV;varying float vDist;"+
"void main(){vN=uN*aNrm;vUV=aUV+uUVOff;"+
"vDist=length((uView*vec4(aPos,1.0)).xyz);gl_Position=uMVP*vec4(aPos,1.0);}";
var FS="precision mediump float;varying vec3 vN;varying vec2 vUV;varying float vDist;"+
"uniform vec3 uColor;uniform sampler2D uTex;uniform float uUseTex;"+
"uniform vec3 uFog;uniform vec2 uFogRange;uniform float uFogOn;"+
"void main(){vec3 L=normalize(vec3(0.4,0.7,0.6));"+
"float d=abs(dot(normalize(vN),L));float lit=0.35+0.65*d;"+ // two-sided
"vec3 base=(uUseTex>0.5?texture2D(uTex,vUV).rgb*uColor:uColor)*lit;"+
"if(uFogOn>0.5){float f=clamp((vDist-uFogRange.x)/(uFogRange.y-uFogRange.x),0.0,1.0);"+
"base=mix(base,uFog,f);}"+
"gl_FragColor=vec4(pow(base,vec3(1.0/1.7)),1.0);}"; // Division DAC gamma 1.7
function sh(t,s){var o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);
if(!gl.getShaderParameter(o,gl.COMPILE_STATUS))throw gl.getShaderInfoLog(o);return o;}
function b64bytes(b64){var s=atob(b64),n=s.length,a=new Uint8Array(n);
for(var i=0;i<n;i++)a[i]=s.charCodeAt(i);return a;}
var models=[],cur=0,az=0.7,el=0.5,dist=3,target=[0,0,0],flyPos=0;
function initGL(){
gl=canvas.getContext("webgl")||canvas.getContext("experimental-webgl");
if(!gl)throw"WebGL is not available in this browser.";
prog=gl.createProgram();gl.attachShader(prog,sh(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,FS));gl.linkProgram(prog);gl.useProgram(prog);
loc={pos:gl.getAttribLocation(prog,"aPos"),nrm:gl.getAttribLocation(prog,"aNrm"),
uv:gl.getAttribLocation(prog,"aUV"),mvp:gl.getUniformLocation(prog,"uMVP"),
view:gl.getUniformLocation(prog,"uView"),
n:gl.getUniformLocation(prog,"uN"),color:gl.getUniformLocation(prog,"uColor"),
tex:gl.getUniformLocation(prog,"uTex"),useTex:gl.getUniformLocation(prog,"uUseTex"),
fog:gl.getUniformLocation(prog,"uFog"),fogRange:gl.getUniformLocation(prog,"uFogRange"),
fogOn:gl.getUniformLocation(prog,"uFogOn"),uvoff:gl.getUniformLocation(prog,"uUVOff")};
gl.enable(gl.DEPTH_TEST);
}
function buildModel(md){
var texs=md.textures.map(function(t){
var tx=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,tx);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,t.w,t.h,0,gl.RGBA,gl.UNSIGNED_BYTE,b64bytes(t.rgba));
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.generateMipmap(gl.TEXTURE_2D);return tx;});
var groups=md.groups.map(function(g){
function buf(arr,tp){var b=gl.createBuffer();gl.bindBuffer(tp,b);
gl.bufferData(tp,arr,gl.STATIC_DRAW);return b;}
return {pos:buf(new Float32Array(g.positions),gl.ARRAY_BUFFER),
nrm:buf(new Float32Array(g.normals),gl.ARRAY_BUFFER),
uv:buf(new Float32Array(g.uvs),gl.ARRAY_BUFFER),
idx:buf(new Uint16Array(g.indices),gl.ELEMENT_ARRAY_BUFFER),
n:g.indices.length,color:g.color,tex:g.texture,anim:g.anim||null,
line:buf(new Uint16Array(edges(g.indices)),gl.ELEMENT_ARRAY_BUFFER),nline:edges(g.indices).length};});
var b=md.bounds,ctr=[(b.min[0]+b.max[0])/2,(b.min[1]+b.max[1])/2,(b.min[2]+b.max[2])/2];
var rad=Math.hypot(b.max[0]-b.min[0],b.max[1]-b.min[1],b.max[2]-b.min[2])/2||1;
return {groups:groups,textures:texs,center:ctr,radius:rad,up:md.up,
bg:md.background||[0.05,0.045,0.04],fog:md.fog||null,frames:md.flythrough||null};
}
function lerp3(a,b,f){return [a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f,a[2]+(b[2]-a[2])*f];}
function edges(idx){var o=[];for(var i=0;i<idx.length;i+=3){o.push(idx[i],idx[i+1],idx[i+1],idx[i+2],idx[i+2],idx[i]);}return o;}
function select(i){cur=i;var md=SCENE.models[i];
document.getElementById("tris").textContent=md.tris.toLocaleString();
document.getElementById("grps").textContent=md.groups.length;
document.getElementById("texs").textContent=md.textures.length;
var m=models[i];target=m.center;dist=m.radius*2.6;az=0.7;el=0.5;flyPos=0;
var hasFly=!!m.frames;
document.getElementById("flyrow").style.display=hasFly?"flex":"none";
document.getElementById("fly").checked=hasFly;
document.getElementById("spin").checked=!hasFly;}
function draw(){
var w=canvas.clientWidth,h=canvas.clientHeight;
if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
var m=models[cur];var ig=1.0/1.7;
gl.viewport(0,0,w,h);gl.clearColor(Math.pow(m.bg[0],ig),Math.pow(m.bg[1],ig),Math.pow(m.bg[2],ig),1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
var eye,center,camUp,near,far;
var flying=m.frames&&document.getElementById("fly").checked;
if(flying){
var N=m.frames.length;flyPos=(flyPos+0.15)%N;
var i=Math.floor(flyPos),f=flyPos-i,a=m.frames[i],b=m.frames[(i+1)%N];
eye=lerp3(a.eye,b.eye,f);center=lerp3(a.center,b.center,f);camUp=lerp3(a.up,b.up,f);
near=50;far=m.radius*5+2000;
}else{
var up=m.up==="z"?[0,0,1]:[0,1,0];
var ce=Math.cos(el);
if(m.up==="z"){eye=[target[0]+dist*ce*Math.sin(az),target[1]+dist*ce*Math.cos(az),target[2]+dist*Math.sin(el)];}
else{eye=[target[0]+dist*ce*Math.sin(az),target[1]+dist*Math.sin(el),target[2]+dist*ce*Math.cos(az)];}
center=target;camUp=up;near=dist*0.02;far=dist*10+m.radius*4;
}
var proj=M.persp(1.0,w/h,near,far);
var view=M.look(eye,center,camUp);var mvp=M.mul(proj,view);
gl.uniformMatrix4fv(loc.mvp,false,mvp);
gl.uniformMatrix4fv(loc.view,false,view);
gl.uniformMatrix3fv(loc.n,false,M.norm3(view));
if(m.fog){gl.uniform1f(loc.fogOn,1);gl.uniform3fv(loc.fog,[m.fog[0],m.fog[1],m.fog[2]]);
gl.uniform2fv(loc.fogRange,[m.fog[3],m.fog[4]]);}else{gl.uniform1f(loc.fogOn,0);}
var wire=document.getElementById("wire").checked;
var now=performance.now()/1000;
for(var k=0;k<m.groups.length;k++){var g=m.groups[k];
gl.bindBuffer(gl.ARRAY_BUFFER,g.pos);gl.enableVertexAttribArray(loc.pos);gl.vertexAttribPointer(loc.pos,3,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,g.nrm);gl.enableVertexAttribArray(loc.nrm);gl.vertexAttribPointer(loc.nrm,3,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,g.uv);gl.enableVertexAttribArray(loc.uv);gl.vertexAttribPointer(loc.uv,2,gl.FLOAT,false,0,0);
var uvo=[0,0];
if(g.anim&&g.anim.type==="scroll"){var ph=g.anim.phase||[0,0],rt=g.anim.rate;
uvo=[(ph[0]+now*rt[0])%1,(ph[1]+now*rt[1])%1];} // texture drift, UV/second
gl.uniform2fv(loc.uvoff,uvo);
gl.uniform3fv(loc.color,g.color);
if(g.tex>=0&&!wire){gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,m.textures[g.tex]);
gl.uniform1i(loc.tex,0);gl.uniform1f(loc.useTex,1);}else{gl.uniform1f(loc.useTex,0);}
if(wire){gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.line);gl.drawElements(gl.LINES,g.nline,gl.UNSIGNED_SHORT,0);}
else{gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.idx);gl.drawElements(gl.TRIANGLES,g.n,gl.UNSIGNED_SHORT,0);}
}
if(document.getElementById("spin").checked)az+=0.005;
requestAnimationFrame(draw);
}
// ---- input -----------------------------------------------------------------
var drag=false,px=0,py=0;
canvas.addEventListener("pointerdown",function(e){drag=true;px=e.clientX;py=e.clientY;
document.getElementById("spin").checked=false;
var fb=document.getElementById("fly");if(fb)fb.checked=false;
canvas.setPointerCapture(e.pointerId);});
canvas.addEventListener("pointermove",function(e){if(!drag)return;
az-=(e.clientX-px)*0.01;el+=(e.clientY-py)*0.01;
el=Math.max(-1.5,Math.min(1.5,el));px=e.clientX;py=e.clientY;});
canvas.addEventListener("pointerup",function(){drag=false;});
canvas.addEventListener("wheel",function(e){e.preventDefault();
dist*=Math.pow(1.0015,e.deltaY);},{passive:false});
function fail(msg){document.querySelector(".panel").style.display="none";
var el=document.getElementById("err");el.style.display="grid";
el.innerHTML="<div><h1 style='color:#f2a73b;margin-bottom:8px'>Cant render</h1>"+msg+"</div>";}
try{
initGL();
models=SCENE.models.map(buildModel);
var pick=document.getElementById("pick");
SCENE.models.forEach(function(md,i){var o=document.createElement("option");
o.value=i;o.textContent=md.name;pick.appendChild(o);});
pick.addEventListener("change",function(){select(+this.value);});
select(0);draw();
}catch(err){fail(String(err));}
</script>"""
if __name__ == "__main__":
main()