From 5e9f191e0b9ef03a04de72f171831a7e550664ff Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 2 Jul 2026 15:19:52 -0500 Subject: [PATCH] Add TREK.SCN restoration: converter and WebGL viewer Parses the DIV-VIZ2 formats (VGF geometry, VMF materials, TGA textures, SPL splines, SCN scenes) from the STDAVE directory of the Glaze drive and re-renders VWE's unreleased 1996 Star Trek pod scenes in a self-contained WebGL page: the Enterprise-D among drifting starfields (TREK.SCN) and the Klingon flyby (KLNGVID.SCN). Co-Authored-By: Claude Fable 5 --- HISTORY.md | 3 +- restoration/README.md | 42 +++ restoration/convert.py | 230 +++++++++++++ restoration/trek-scn.html | 540 +++++++++++++++++++++++++++++++ restoration/viewer_template.html | 540 +++++++++++++++++++++++++++++++ 5 files changed, 1354 insertions(+), 1 deletion(-) create mode 100644 restoration/README.md create mode 100644 restoration/convert.py create mode 100644 restoration/trek-scn.html create mode 100644 restoration/viewer_template.html diff --git a/HISTORY.md b/HISTORY.md index 1d46c3f..290a832 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -84,7 +84,8 @@ The most historically significant material on the drive: A working Division scene (`TREK.SCN`) running on the Tesla renderer: layered starfield splines, an **Enterprise-D model** (`EPRISED.VGF`), and **Klingon ship models and textures** (`KLNGN*.VGF`, `KLNG1–6.TGA`). A VWE Star Trek pod -experience that never shipped. +experience that never shipped. These scenes have been restored and can be +viewed in a browser — see [restoration/](restoration/). ### Hull Pressure (`HPDAVE/`, August 1995) A **submarine game** prototype: sharks, submarines, Atlantis geometry, fish diff --git a/restoration/README.md b/restoration/README.md new file mode 100644 index 0000000..c323881 --- /dev/null +++ b/restoration/README.md @@ -0,0 +1,42 @@ +# TREK.SCN Restoration + +A modern re-rendering of the unreleased **Star Trek** simulator-pod scenes found +in the `STDAVE` directory of the Glaze developer drive (see +[HISTORY.md](../HISTORY.md)). The original scenes ran on Division Ltd.'s dVS +pixel-pipeline hardware in 1996; this restoration parses the original data +files and re-renders them in WebGL, in the browser. + +## Files + +- **`trek-scn.html`** — the finished, self-contained viewer (open in any + browser; no server needed). Renders `TREK.SCN` (the Enterprise-D among + three parallax layers of drifting stars) and `KLNGVID.SCN` (a Klingon + cruiser at rest while two more drift past on spline paths). Fly camera: + drag to look, WASD to fly. +- **`convert.py`** — the converter. Parses the DIV-VIZ2 text formats from + the drive dump (expects it at `..\sda4\STDAVE`) and emits `trekdata.json`: + - `.VGF` geometry (vertex pools + polygon connection lists, converted from + Wavefront OBJ in 1996 by Ken Olsen's `obj2vgf`) + - `.VMF` materials (ambient/diffuse/emissive, shading ramps, texture + bit-slice references) + - `.TGA` textures (decoded and re-encoded as PNG data URIs) + - `.SPL` splines (starfield drift paths, ship flyby paths) + - `.SCN` scene scripts (camera, lights, fog, object placements) +- **`viewer_template.html`** — the viewer source with a `%%DATA%%` + placeholder; `convert.py`'s JSON output is injected to produce + `trek-scn.html`. + +## Rebuild + +``` +python convert.py +python -c "open('trek-scn.html','w',encoding='utf-8').write(open('viewer_template.html',encoding='utf-8').read().replace('%%DATA%%',open('trekdata.json',encoding='utf-8').read()))" +``` + +## Fidelity notes + +Faithful: vertices, triangles, material colors, textures, fog and light +parameters, star paths and layer speeds, camera start positions — all from +the original files. Approximated: Division's shading ramps and rasterizer +behavior, point-sprite star sizes, ship motion timing (original simulation +ran at 30 Hz). Not simulated: the event-driven SPECIALFX particle effects. diff --git a/restoration/convert.py b/restoration/convert.py new file mode 100644 index 0000000..9e3b5c5 --- /dev/null +++ b/restoration/convert.py @@ -0,0 +1,230 @@ +"""Convert Division DIV-VIZ2 assets (VGF/VMF/TGA/SPL/SCN) to JSON for the web viewer. + +Formats reverse-engineered from the VWE 'Glaze' drive (STDAVE, 1996): +- VGF: text geometry. OBJECT { GEOGROUP(F_MATERIAL=..;VERTEX=flags) { + PMESH { VERTEX_POOL {..} CONNECTION_LIST (PCOUNT=n) {..} .. } + | SPHERELIST {..} } } + Vertex tuple = pos(3) [+ normal(3) if NORMALS] [+ uv(2) if 2D_TEXTURE]. +- VMF: text materials. TEXTURE(NAME=..){MAP{"base"} BITSLICE{n}} selects + grayscale TGA "BASE.TGA"; MATERIAL(NAME=..){AMBIENT/DIFFUSE/EMISSIVE + {r,g,b} TEXTURE{"t"}}. +- SPL: first line = knot count, rows = x y z (+3 unused here). +- SCN: scene script (camera, lights, fog, STATIC/DYNAMIC placements). +- TGA: type-2 uncompressed 24bpp, bottom-up. +""" +import json, re, struct, sys, zlib, base64, os + +ROOT = r"c:\VWE\TeslaRel410\sda4\STDAVE" +BUILD = os.path.join(ROOT, "VIDEO", "BUILD") + +# ---------------------------------------------------------------- tokenizer +def strip_comments(text): + text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S) + text = re.sub(r"//[^\n]*", " ", text) + return text + +# ---------------------------------------------------------------- VGF +def parse_vgf(path): + """Return list of groups: {material, mode, verts/normals/uvs/indices or points}.""" + text = strip_comments(open(path, "r", errors="replace").read()) + groups = [] + pos = 0 + 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 + body_start = gm.end() + # find matching close brace for the group body + depth, i = 1, body_start + while depth > 0 and i < len(text): + if text[i] == "{": depth += 1 + elif text[i] == "}": depth -= 1 + i += 1 + body = text[body_start: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(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(nums[k:k+3]); k += 3 + if has_n: + norms.append(nums[k:k+3]); k += 3 + if has_uv: + uvs.append(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*\}", body, re.S): + pc = int(cm.group(1)) + for row in re.finditer(r"\{([^}]*)\}", cm.group(2)): + idx = [int(x) for x in re.findall(r"\d+", row.group(1))] + # fan-triangulate polygons + 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 +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) + scroll = re.search(r"SCROLL\s+([-\d. ]+)", params + body) + textures[name] = { + "map": mmap.group(1) if mmap else None, + "slice": int(mslice.group(1)) if mslice else 0, + "scroll": [float(x) for x in scroll.group(1).split()] if scroll else None, + } + 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): + m = re.search(tag + r"\s*\{([^}]*)\}", body) + return [float(x) for x in re.findall(r"[-\d.eE]+", m.group(1))][:3] if m else None + mtex = re.search(r'TEXTURE\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, + "immune": "IMMUNE" in params, # fog-immune (self-lit stars, running lights) + } + return textures, materials + +# ---------------------------------------------------------------- TGA -> PNG data URI +def tga_to_png_datauri(path): + d = open(path, "rb").read() + idlen, cmap, imgtype = d[0], d[1], d[2] + w, h = struct.unpack(" flip to top-down + rows.reverse() + raw = b"".join(b"\x00" + r for r in 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() + +# ---------------------------------------------------------------- SPL +def parse_spl(path): + lines = [l for l in open(path).read().splitlines() if l.strip()] + n = int(lines[0].split()[0]) + pts = [] + for l in lines[1:]: + try: + nums = [float(x) for x in l.split()] + except ValueError: + continue + if len(nums) >= 3: + pts.append(nums[:3]) + return pts + +# ---------------------------------------------------------------- SCN +def parse_scn(path): + scene = {"lights": [], "statics": [], "dynamics": []} + 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 == "STATIC": + scene["statics"].append({"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]]}) + elif k == "DYNAMIC": + scene["dynamics"].append({"geo": t[1].lower(), "scale": float(t[2]), + "spl": os.path.basename(t[3].replace("\\", "/")).lower(), + "phase": float(t[4]), "speed": float(t[5])}) + except (ValueError, IndexError): + pass + return scene + +# ---------------------------------------------------------------- drive it +def main(): + out = {"models": {}, "materials": {}, "textures": {}, "images": {}, + "splines": {}, "scenes": {}} + + tex1, mat1 = parse_vmf(os.path.join(BUILD, "STSHIPS.VMF"), "stships") + tex2, mat2 = parse_vmf(os.path.join(BUILD, "STAR.VMF"), "star") + out["textures"] = {**tex1, **tex2} + out["materials"] = {**mat1, **mat2} + + for name, f in [("tesall", "TESALL.VGF"), ("tesall_lp", "TESALL_L.VGF"), + ("klngn", "KLNGN.VGF"), ("klngn_lp", "KLNGN_LP.VGF"), + ("stars", "STARS.VGF")]: + gs = parse_vgf(os.path.join(BUILD, f)) + out["models"][name] = gs + nv = sum(len(g.get("v", g.get("points", []))) for g in gs) + nt = sum(len(g.get("i", [])) // 3 for g in gs) + print(f"model {name}: {len(gs)} groups, {nv} verts, {nt} tris") + + # texture maps: map base 'steh'/'klng', slice n -> BUILD/.TGA + used_maps = {(t["map"], t["slice"]) for t in out["textures"].values() if t["map"]} + for base, sl in sorted(used_maps): + f = os.path.join(BUILD, f"{base.upper()}{sl+1}.TGA") + if os.path.exists(f): + out["images"][f"{base}{sl}"] = tga_to_png_datauri(f) + print(f"texture {base} slice {sl}: {os.path.basename(f)} ok") + else: + print(f"texture MISSING: {f}") + + for i in range(1, 7): + out["splines"][f"field{i}.spl"] = parse_spl(os.path.join(ROOT, f"FIELD{i}.SPL")) + for f in ["KLNGN2.SPL", "KLNGN3.SPL"]: + out["splines"][f.lower()] = parse_spl(os.path.join(ROOT, f)) + + out["scenes"]["TREK.SCN"] = parse_scn(os.path.join(ROOT, "TREK.SCN")) + out["scenes"]["KLNGVID.SCN"] = parse_scn(os.path.join(ROOT, "KLNGVID.SCN")) + + dst = os.path.join(os.path.dirname(os.path.abspath(__file__)), "trekdata.json") + with open(dst, "w") as fh: + json.dump(out, fh, separators=(",", ":")) + print("wrote", dst, f"{os.path.getsize(dst)/1e6:.2f} MB") + +if __name__ == "__main__": + main() diff --git a/restoration/trek-scn.html b/restoration/trek-scn.html new file mode 100644 index 0000000..01fd780 --- /dev/null +++ b/restoration/trek-scn.html @@ -0,0 +1,540 @@ +TREK.SCN — VWE Star Trek Prototype (1996) + + + +
RESTORING TREK.SCN…
+ +
+

Virtual World Entertainment · unreleased prototype · 1996

+

TREK.SCN

+

Star Trek scene — Division dVS data, re-rendered in WebGL

+
+ + + +
+ drag look   W A S D fly   Q E rise/sink   shift fast   R reset +
+ + + + + + + diff --git a/restoration/viewer_template.html b/restoration/viewer_template.html new file mode 100644 index 0000000..65b1f5f --- /dev/null +++ b/restoration/viewer_template.html @@ -0,0 +1,540 @@ +TREK.SCN — VWE Star Trek Prototype (1996) + + + +
RESTORING TREK.SCN…
+ +
+

Virtual World Entertainment · unreleased prototype · 1996

+

TREK.SCN

+

Star Trek scene — Division dVS data, re-rendered in WebGL

+
+ + + +
+ drag look   W A S D fly   Q E rise/sink   shift fast   R reset +
+ + + + + + +