Files
TeslaRel410/restoration/convert.py
T
CydandClaude Fable 5 5e9f191e0b 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 <noreply@anthropic.com>
2026-07-02 15:19:52 -05:00

231 lines
10 KiB
Python

"""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<n+1>.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("<HH", d[12:16])
bpp, desc = d[16], d[17]
assert imgtype == 2 and bpp in (24, 32), f"unsupported TGA {path} type={imgtype} bpp={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):
b, g, r = d[base + x*nb], d[base + x*nb + 1], d[base + x*nb + 2]
row += bytes((r, g, b))
rows.append(bytes(row))
if not (desc & 0x20): # bottom-up -> 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/<BASE><n+1>.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()