#!/usr/bin/env python3 """ vrview.py -- board-side real-time display for the virtual VelociRender board. Renders, in a pygame window, exactly what FLYK.EXE streams over the link: geometry uploaded via action 0x17 (vertices) + 0x19 (connections), placed by the instance/DCS graph, viewed through the VIEW node's projection with the camera pose taken from DCS 0x2 under the animated root DCS 0x1, and animated by the per-frame 0x1d transform updates. This is the picture the dead i860/PixelPlanes board would have put on its video output. Wire facts this depends on (see spec/VELOCIRENDER_PROTOCOL.md, session 2026-07-04): - DCS flush body: 4x4 row-major matrix at words 4..19 (translation in row 3), parent DCS handle at word 20. - instance flush body: object handle at word 6. - object -> lod -> geogroup -> geometry via list_add; geogroup's f_material at word 16 of its flush body. - vertex strides: vtype 0x13 = [x y z nx ny nz u v], vtype 0x15 = [x y z r g b a u v]. - gtype 2 = single implicit polygon (fan); others use conn19 index lists (n_conns x per indices, fan-triangulated per connection). - view body floats [6..17] = x0 y0 x1 y1 zeye xs ys hither yon back_r g b; [18..23] = fog_enable, fog near/far/r/g/b. Camera looks down -Z, row-vector convention (p' = p @ M), child-to-parent composition = M_child @ M_parent. - 0x1d = [1][dcs handle][3x3][t] replacing that DCS's rotation+translation. """ import os import struct import numpy as np TYPE = {2:'zone',3:'view',4:'instance',5:'dcs',6:'lmodel',7:'object',8:'lod', 9:'geogroup',0xa:'geometry',0xb:'material',0xc:'texture',0xd:'texmap', 0xe:'light'} def inst_visible(nodes, inst): """Draw-time gate for 'gated' (hidden-until-armed, w3 0/1) instances: stored word 4 of the CURRENT instance body is the live visibility field (dpl_SetInstanceVisibility). Checked per draw because a flush bumps no node/edge counts, so the cache never rebuilds on a visibility pulse (BT laser beams arm for ~4 frames per shot).""" if not inst.get('gated'): return True b = nodes.get(inst['handle'], {}).get('body') or b'' return len(b) >= 20 and struct.unpack_from(' [(kind, (r,g,b), width, [(x,y)..])]. kind: 'strip' (connected polyline), 'segs' (independent segments, point pairs), 'poly' (filled convex polygon), 'points'.""" prims = [] st = {'m': (1.0, 0.0, 0.0, 1.0, 0.0, 0.0), 'col': (0.0, 0.75, 0.0), 'w': 1.0} stack = [] mode = [None] # current open path kind (or 'clip') pts = [] def xf(x, y): m = st['m'] return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) def emit(kind): if pts and kind not in (None, 'clip'): prims.append((kind, st['col'], st['w'], list(pts))) del pts[:] mode[0] = None def run(h, depth): w = dl2d.get(h) if w is None or depth > 8: return i, n = 0, len(w) while i < n: t = w[i] i += 1 if t == 0: # open_polyline emit(mode[0]); mode[0] = 'strip' elif t == 1: emit('strip') elif t == 2: # open_polypoint emit(mode[0]); mode[0] = 'points' elif t == 3: emit('points') elif t == 4: # open_polygon emit(mode[0]); mode[0] = 'poly' elif t == 5: emit('poly') elif t == 6: # open_lines (point PAIRS) emit(mode[0]); mode[0] = 'segs' elif t == 7: emit('segs') elif t == 8 and i + 2 <= n: # point x y if mode[0] is not None: pts.append(xf(_hud2d_f(w[i]), _hud2d_f(w[i + 1]))) i += 2 elif t == 9 and i + 4 <= n: # circle x y r filled cx, cy, r = (_hud2d_f(v) for v in w[i:i + 3]) filled = w[i + 3] != 0 i += 4 ring = [xf(cx + r * np.cos(a), cy + r * np.sin(a)) for a in np.linspace(0, 2 * np.pi, 25)] prims.append(('poly' if filled else 'strip', st['col'], st['w'], ring)) elif t == 10: # open_clip_polygon (unsupported: emit(mode[0]); mode[0] = 'clip' # collect + discard) elif t == 11: # close_clip_polygon emit('clip'); i += 1 elif t == 12: # clip_circle x y r i += 4 elif t in (13, 14): # clip_full / clip_none pass elif t == 15 and i + 3 <= n: # set_drawcolor r g b st['col'] = tuple(_hud2d_f(v) for v in w[i:i + 3]) i += 3 elif t == 16 and i + 6 <= n: # set_matrix (2x3 affine) st['m'] = tuple(_hud2d_f(v) for v in w[i:i + 6]) i += 6 elif t == 17 and i + 7 <= n: # concat_matrix + post flag a = tuple(_hud2d_f(v) for v in w[i:i + 6]) post = w[i + 6] != 0 i += 7 m = st['m'] x, y = (m, a) if post else (a, m) st['m'] = (x[0] * y[0] + x[1] * y[2], x[0] * y[1] + x[1] * y[3], x[2] * y[0] + x[3] * y[2], x[2] * y[1] + x[3] * y[3], x[4] * y[0] + x[5] * y[2] + y[4], x[4] * y[1] + x[5] * y[3] + y[5]) elif t == 18: # push_state stack.append(dict(st)) elif t == 19: # pop_state if stack: st.update(stack.pop()) elif t == 20 and i + 1 <= n: # call_displaylist run(w[i], depth + 1) i += 1 elif t == 21 and i + 1 <= n: # set_linewidth st['w'] = _hud2d_f(w[i]) i += 1 elif t == 22 and i + 1 <= n: # set_alpha (HUD is opaque; skip) i += 1 # unknown word: skip (keeps a malformed chunk from cascading) run(root, 0) emit(mode[0]) return prims def hud2d_root(board, view): """The view node's bound 2D display list (dpl2d_SetViewDisplayList): the word appended after the 96B view struct (stored-body offset 96). Short view re-flushes (fog animation) truncate the stored body, so the binding from the last full-length flush is cached per view. 0 = none.""" if view is None: return 0 cache = getattr(board, '_hud2d_bind', None) if cache is None: cache = board._hud2d_bind = {} vb = board.nodes.get(view, {}).get('body') or b'' if len(vb) >= 100: cache[view] = struct.unpack_from('root), tris,...}] self.view = None self.cam_chain = [] self._meshcache = {} # geom handle -> {ver, mesh} def maybe_rebuild(self, board): key = (len(board.nodes), len(board.edges), len(board.uploads), len(board.conns), len(board.tex)) if key == getattr(self, '_key', None): return self._key = key self.rebuild(board) def rebuild(self, board): nodes = board.nodes tname = lambda h: TYPE.get(nodes.get(h, {}).get('type')) kids = {} inst_dcs = {} # vr_dcs_NEST = parent/child; vr_dcs_LINK = SIBLING ring (1994 protocol: # [bro, sis]) -- FLYK's top-level DCSs are a flat sibling list, NOT a # tree. Treating link as parentage chained every instance through the # tracker-animated head DCS 0x1, cancelling all camera motion. self.dcs_parent = {} self.link_parent = {} for op, a, b in board.edges: if op == 'nest': # true hierarchy only self.dcs_parent[b] = a if op == 'link': # camera-rig hierarchy (MUNGA: self.link_parent[b] = a # vehicle->cockpit->view head) if op != 'list_add': continue if tname(a) == 'dcs' and tname(b) == 'instance': inst_dcs[b] = a else: kids.setdefault(a, []).append(b) # camera: the view hangs off its DCS via list_add; the tracker-animated # HEAD is DCS 0x1 (a sibling root, not a parent) -- pose = M_viewdcs @ M_head self.view = None view_dcs = None for op, a, b in board.edges: if op == 'list_add' and tname(b) == 'view' and tname(a) == 'dcs': self.view = b; view_dcs = a # MUNGA ONLY: the camera chain follows dcs_link parentage -- the game # rigs the view as vehicle --link--> cockpit --link--> head DCS (world # pose lives in the vehicle DCS, 0x1f-animated). FLYK's links are flat # SIBLING rings; following them there relocated every scene's camera # (regress diffs blew up), so FLYK keeps M(view dcs) . M(head 0x1). # INSTANCE chains stay nest-only in both dialects (link-as-parentage # on instances is the session-3 camera-cancellation bug). self.cam_chain = self._chain(board, view_dcs, links=getattr(board, 'munga', False)) if tname(1) == 'dcs' and 1 not in self.cam_chain: self.cam_chain = self.cam_chain + [1] # lights: lmodel (0x6) and light (0xe) bodies carry [dcs][type][r g b] # at stored words 2,3 + floats 4..6; type 2 = ambient, 3 = directional # (aim = the light DCS's -Z; SHARKS: ambient .1/.3/.4, sun (0,1,0) ✓) self.ambient = np.zeros(3) self.dirlights = [] # [(L_toward_light_world, color)] for h, nd in nodes.items(): if nd.get('type') not in (0x6, 0xe): continue body = nd.get('body') or b'' if len(body) < 28: continue ldcs, ltype = struct.unpack_from(' 100: continue if ltype == 2: self.ambient += col elif ltype == 3: chain = self._chain(board, ldcs) M = np.eye(4) for d in chain: b2 = nodes.get(d, {}).get('body') or b'' if len(b2) >= 76: M = M @ _mat_from_dcs(b2) L = M[2, :3] # light DCS +Z row = toward light n = np.linalg.norm(L) if n > 1e-6: self.dirlights.append((L / n, col)) # meshes are resolved lazily per-draw via _mesh() (version-cached per # handle) so per-frame vertex-update uploads don't rebuild the graph # geometry -> texture RGB array, resolved geogroup -> f_material -> texture # node -> texmap node -> board.tex. Refs are found type-directed (scan the # body words for a handle of the right node type) -- robust to layout drift. def find_ref(h, want_type): body = nodes.get(h, {}).get('body') or b'' for i in range(len(body) // 4): w = struct.unpack_from(' 0) texture node self._sliced_packs = set() for h, nd in nodes.items(): if nd.get('type') == 0xc: body = nd.get('body') or b'' if len(body) >= 56 and struct.unpack_from(' file bitslice 0 (unsliced / whole page) sel = 0x13 + b -> file bitslice b (1..8) and bitslice b addresses nibble plane b + 2 (pad byte occupies planes 0-1; proven vs GENH: bitslices 0..5 = the exact set of content planes 2..7). b >= 6 clamps to plane 7 (rare; only the 9-texture GENS-class packs). """ entry = board.tex.get(texm) if entry is None: return None body = nodes.get(texn, {}).get('body') or b'' sel = struct.unpack_from('= 56 else 0 # sel==0 on a pack that also has sliced siblings = bitslice 0; # sel==0 on a plain page = full-colour texture if entry['mode'] == 0 and (sel or texm in self._sliced_packs): b = (sel - 0x13) if sel else 0 plane = min(b + 2, 7) key = (texm, plane) if key not in texcache: w = np.frombuffer(bytes(entry['data']), '> (4 * plane)) & 0xF).astype(np.float32)) * 17.0 texcache[key] = np.repeat(g[:, :, None], 3, axis=2) return texcache[key] if texm not in texcache: a = np.frombuffer(bytes(entry['data']), np.uint8) a = a[:entry['u'] * entry['v'] * 4].reshape(entry['v'], entry['u'], 4) texcache[texm] = a[:, :, [3, 2, 1]].astype(np.float32) # [pad,B,G,R] return texcache[texm] def mtl_props(mtl): body = nodes.get(mtl, {}).get('body') or b'' if len(body) < 84: return None # MUNGA material bodies carry one extra leading word (88B vs 84B): # every FLYK offset shifts +1 (misparse showed as a green-washed, # all-dithered world -- ambient read one float early) s = 1 if getattr(board, 'munga', False) and len(body) >= 88 else 0 f = struct.unpack_from('<22f' if s else '<21f', body, 0) amb, dif = np.array(f[7+s:10+s]), np.array(f[10+s:13+s]) # exact (1,0,0)/(1,0,0) ambient+diffuse = the shipped build's UNSET # material marker (seen on all SHARKS/.B2Z-default and star mtls), # not a colour -- render as white if np.array_equal(amb, [1, 0, 0]) and np.array_equal(dif, [1, 0, 0]): amb = dif = np.ones(3) op = float(np.clip(np.mean(f[13+s:16+s]), 0, 1)) return {'emissive': np.array(f[4+s:7+s]), 'ambient': amb, 'diffuse': dif, 'opacity': op, 'specular': np.array(f[16+s:20+s])} for gg, members in kids.items(): if tname(gg) != 'geogroup': continue mtl = find_ref(gg, 0xb) texn = find_ref(mtl, 0xc) if mtl else None gg_texm = find_ref(texn, 0xd) if texn else None props = mtl_props(mtl) if mtl else None for g in members: if tname(g) != 'geometry': continue # PER-GEOMETRY refs take precedence: FLYK geometry bodies name # their texmap directly (word 3); MUNGA/BTL4 bodies name their # TEXTURE node (word 2) and often their material -- resolving # only through the geogroup chain assigned one shared texture # (BT: every terrain tile got the cloud page). g_texn = find_ref(g, 0xc) tn = g_texn or texn g_texm = (find_ref(g, 0xd) or (find_ref(g_texn, 0xd) if g_texn else None)) texm = g_texm or gg_texm rgb = texmap_rgb(texm, tn) if texm else None if rgb is not None: self.geom_tex[g] = rgb if tn is not None: self.geom_texn[g] = tn # for live u0/v0/du/dv scroll g_mtl = find_ref(g, 0xb) g_props = mtl_props(g_mtl) if g_mtl else props if g_props is not None: self.geom_mtl[g] = g_props # instances -> flattened triangle lists in model space self.instances = [] for h, nd in nodes.items(): if tname(h) != 'instance' or h not in inst_dcs: continue body = nd.get('body') or b'' if len(body) < 24: continue obj = struct.unpack_from('= 84 and struct.unpack_from(' render in # camera space (skip the view matrix) hud = False if len(dbody) >= 80: par = struct.unpack_from('= 68: sw_in, sw_out = struct.unpack_from('<2f', lb, 60) if not (np.isfinite(sw_in) and np.isfinite(sw_out) and 0 <= sw_in < sw_out < 1e7): sw_in, sw_out = 0.0, 1e9 lods.append((sw_in, sw_out, lg)) if not lods: continue # BT's wire LOD bodies carry no usable switch distances (zeros -> # the (0,1e9) fallback on every lod), and much of its content is # authored ADDITIVE_LODS (BGF SV_SPECIAL: the arena walls are a # posts-lod + panel-lod that draw TOGETHER). Range-select over # identical windows always took lods[0] -- the wall posts without # the wall. When every window is the degenerate fallback, draw # the union of all lods; genuine distance windows keep switching. if len(lods) > 1 and all(a == 0.0 and b >= 1e8 for a, b, _ in lods): allg = [] for _, _, lg in lods: for g in lg: if g not in allg: allg.append(g) lods = [(0.0, 1e9, allg)] # MUNGA: vehicle/mech part instances get their world pose through # the same dcs_link rig as the camera (torso 0x1f-animated) -- # nest-only chains left the player mech rendering at world origin. # FLYK stays nest-only (links there are sibling rings; session 3). # gated instances carry their model-space radius so the draw can # cull the player's own cockpit canopy (a small always-armed # fixture AT the camera -- our eye is not at the true head node, # so it smears a sliver across the view). Beams/missiles are # gated too but extend far, so radius keeps them drawing. rad = 0.0 if gated: for g in lods[0][2]: m = self._mesh(board, g) if m is not None and len(m.get('pos', [])): rad = max(rad, float(np.abs(m['pos']).max())) self.instances.append({'dcs': inst_dcs[h], 'handle': h, 'chain': self._chain( board, inst_dcs[h], links=getattr(board, 'munga', False)), 'geoms': lods[0][2], 'lods': lods, 'billboard': bboard, 'hud': hud, 'gated': gated, 'radius': rad}) # vertex strides seen on the wire (SHARKS + SDEMO): stride -> field slices # 3 = [x y z] (vtype 0x01) # 4 = [x y z r?] (vtype 0x41, gtype 0xa -- spheres/points; positions only) # 5 = [x y z u v] (vtype 0x11) # 8 = [x y z nx ny nz u v] (vtype 0x13) # 9 = [x y z r g b a u v] (vtype 0x15) def _mesh(self, board, gh, _depth=0): ups = board.uploads.get(gh) or [] cns = board.conns.get(gh) or [] morph = board.morphs.get(gh) if _depth == 0 else None ver = (len(ups), len(cns), len(ups[-1]['data']) if ups else 0, ups[-1]['hdr'][1] if ups else 0, morph) cached = self._meshcache.get(gh) if cached is not None and cached['ver'] == ver: return cached['mesh'] stride = ups[-1]['hdr'][3] scale = ups[-1]['scale'] or 1.0 # merge uploads at their vertex offsets (hdr word1 = first vertex, # word7 = total) -- later offset uploads are vertex animation updates total = 0 for up in ups: w = up['hdr'] total = max(total, w[1] + w[2], w[7]) v = np.zeros((total, stride)) for up in ups: w = up['hdr'] if w[3] != stride: continue fl = np.frombuffer(bytes(up['data']), dtype='= 9: # vtype 0x15 = [x y z r g b ? u v]: field 6 is NOT alpha -- values # run -3.75..1.0 across products (blending on it made SHARKS' kelp # ghostly). Kept as mesh['alpha'] DATA only; semantics undecoded. col = v[:, 3:6]; alpha = v[:, 6]; uv = v[:, 7:9] tris = [] if cns: for c in cns: idx = np.frombuffer(bytes(c['data']), dtype='= 3 and ups[-1]['hdr'][6] == 2: # gtype 2: implicit polygon fan fan = np.arange(1, n - 1) tris.append(np.stack([np.zeros_like(fan), fan, fan + 1], 1)) mesh = None if tris: mesh = {'pos': pos, 'col': col, 'nrm': nrm, 'uv': uv, 'alpha': alpha, 'tri': np.concatenate(tris, 0)} self._meshcache[gh] = {'ver': ver, 'mesh': mesh} return mesh def _chain(self, board, dcs, links=False): """leaf->root list of DCS handles following vr_dcs_nest hierarchy ONLY (the flush-body word at stored offset 76 is the SIBLING pointer, not a parent -- following it merged the camera head into every instance chain and cancelled all tracker motion). links=True additionally follows vr_dcs_link parentage where no nest parent exists -- used for the CAMERA chain only (MUNGA vehicle->cockpit->head rig; FLYK 0x1->0x2).""" nodes = board.nodes chain = [] seen = set() while dcs and dcs not in seen and nodes.get(dcs, {}).get('type') == 5: chain.append(dcs); seen.add(dcs) nxt = getattr(self, 'dcs_parent', {}).get(dcs) if nxt is None and links: nxt = getattr(self, 'link_parent', {}).get(dcs) dcs = nxt return chain class Renderer: def __init__(self, w=512, h=320, title="VelociRender virtual board -- FLYK live"): import pygame self.pygame = pygame pygame.init() sz = os.environ.get('VRVIEW_SIZE') # e.g. 384x240 for more fps if sz and 'x' in sz: try: w, h = (int(v) for v in sz.lower().split('x')) except ValueError: pass self.w, self.h = w, h self.screen = pygame.display.set_mode((w, h), pygame.SCALED | pygame.RESIZABLE) pygame.display.set_caption(title) self.cache = SceneCache() self.frame = 0 self.clock = pygame.time.Clock() # RETRACE pacing: run_demo sets VRVIEW_FPS from the scene's divider self.fps = int(os.environ.get('VRVIEW_FPS', '60')) self.skip = 1 # adaptive: render 1 of N draw_scenes self._last_ms = 0.0 self._psys = {} # SPECIALFX particle pools by code self.light = np.array([0.3, 0.8, 0.5]); self.light /= np.linalg.norm(self.light) # Division 10-bit-DAC output gamma. GAMMA.C (the original source) builds # out = (i/255)^(1/1.7); this live renderer historically used 1.25. # VRVIEW_GAMMA selects it so the two can be compared against the real pod. self.gamma = float(os.environ.get('VRVIEW_GAMMA', '1.25')) def dcs_matrix(self, board, h): m = None body = board.nodes.get(h, {}).get('body') or b'' if len(body) >= 80: m = _mat_from_dcs(body) else: m = np.eye(4) if h in getattr(board, 'anim_abs', {}): # live 0x1f pose (MUNGA): REPLACES f = board.anim_abs[h] a = np.eye(4) a[:3, :3] = np.array(f[:9]).reshape(3, 3) a[3, :3] = f[9:12] if np.all(np.isfinite(a)): m = a elif h in board.anim: # live 0x1d articulation f = board.anim[h] a = np.eye(4) # 0x1d 3x3 is row-vector convention, same as flush matrices # (heading_test.py: model -Z tracks velocity, dot~-1, 95% of samples) a[:3, :3] = np.array(f[:9]).reshape(3, 3) a[3, :3] = f[9:12] if np.all(np.isfinite(a)): # flush_artics COMPOSES with the flushed base matrix (KLNGVID: # the DCS holds the 0.1 model scale, the spline pose rides on # top; SHARKS' identity base hid the distinction) m = m @ a if h in board.anim4: # live 0x1b full-matrix overrides f4 = np.array(board.anim4[h]).reshape(4, 4) # identity 3x3 + junk row3 = host sent an identity-flagged DCS # verbatim (uninitialized translation) -- keep the flushed matrix if not np.allclose(f4[:3, :3], np.eye(3)): m = f4.copy() m[:, 3] = (0, 0, 0, 1) return m def chain_matrix(self, board, chain, fix_degenerate=False): m = np.eye(4) for h in chain: # leaf first: M_leaf @ ... @ M_root dm = self.dcs_matrix(board, h) if fix_degenerate: # Some pass-through DCS (e.g. 0xa2c in the BTL4 cockpit rig) # flush a rank-2 rotation whose X and Y rows both collapse to # +-Y (shifted body -> wrong read window). Composed into the # camera chain this drops the whole rig to rank 2 (only the # look-row survives) and, once the head DCS articulates, mixes # the head glance into the wrong axis (left/right hat -> pitch). # A degenerate rotation carries no real orientation -- treat it # as identity. Preserves the neutral look exactly; lets the # glance compose as a proper yaw. R = dm[:3, :3] if np.linalg.matrix_rank(R, tol=1e-3) < 3: dm = dm.copy(); dm[:3, :3] = np.eye(3) m = m @ dm return m # MUNGA vehicles fly nose-along-+Z: straight-fast-flight samples of the # RAW vehicle 0x1f records put local velocity 3.8 deg off +Z (the earlier # +X reading came from the composed chain, biased by the attract head-yaw # swinging +/-54 deg). The view projection looks down -Z, so the raw chain # renders 180 deg backward -- yaw(180) maps render-forward onto vehicle +Z. # Camera only; instances carry their pose in their own DCS frames. _CAMFIX = np.eye(4) _CAMFIX[:3, :3] = [[-1, 0, 0], [0, 1, 0], [0, 0, -1]] def cam_matrix(self, board): if os.environ.get('VRVIEW_CHASE') in ('1', '2'): m = self._chase_cam(board) if m is not None: return m m = self.chain_matrix(board, self.cache.cam_chain, fix_degenerate=True) if getattr(board, 'munga', False): m = self._CAMFIX @ m return m def _chase_cam(self, board): """Debug camera: follow the most-traveled 0x1f-animated DCS (the vehicle/mech torso) from behind-above. VRVIEW_CHASE=1 chases behind the target's nose (turns with it); =2 keeps a world-locked offset (no rotation when the target spins in place).""" anim = getattr(board, 'anim_abs', None) if not anim: return None # prefer the PLAYER vehicle: the root of the camera rig (last DCS in # the cam chain) when animated; else the most-traveled animated DCS chain = getattr(self.cache, 'cam_chain', []) if chain and chain[-1] in anim: h = chain[-1] else: h = max(anim, key=lambda k: float(np.abs(np.array(anim[k][9:12])).sum())) f = anim[h] R = np.array(f[:9]).reshape(3, 3) t = np.array(f[9:12]) if os.environ.get('VRVIEW_CHASE') == '2': offset = np.array([-45.0, 25.0, -45.0]) # world-locked NE-above else: nose = R[2, :3] # MUNGA vehicles nose along +Z offset = -nose * 60.0 + np.array([0.0, 25.0, 0.0]) eye = t + offset fwd = t - eye fwd /= max(np.linalg.norm(fwd), 1e-6) back = -fwd right = np.cross([0.0, 1.0, 0.0], back) right /= max(np.linalg.norm(right), 1e-6) up = np.cross(back, right) M = np.eye(4) M[0, :3], M[1, :3], M[2, :3], M[3, :3] = right, up, back, eye return M def pick(self, board, origin, direction): """World-space ray -> nearest instance handle (sect_pixel/sect_vector). Moller-Trumbore over every instance's world triangles.""" self.cache.maybe_rebuild(board) o = np.asarray(origin, np.float64) d = np.asarray(direction, np.float64) dn = np.linalg.norm(d) if dn < 1e-9: return 0 d = d / dn best_t, best_h = np.inf, 0 for inst in self.cache.instances: if not inst_visible(board.nodes, inst): continue M = self.chain_matrix(board, inst['chain'], fix_degenerate=True) R, T = M[:3, :3], M[3, :3] for gh in inst['geoms']: mesh = self.cache._mesh(board, gh) if mesh is None or 'sphere' in mesh: continue pw = mesh['pos'] @ R + T tri = mesh['tri'] v0, v1, v2 = pw[tri[:, 0]], pw[tri[:, 1]], pw[tri[:, 2]] e1, e2 = v1 - v0, v2 - v0 pv = np.cross(d[None, :], e2) det = (e1 * pv).sum(1) okd = np.abs(det) > 1e-12 inv = np.where(okd, 1.0 / np.where(okd, det, 1.0), 0.0) tv = o[None, :] - v0 u = (tv * pv).sum(1) * inv qv = np.cross(tv, e1) vv = (d[None, :] * qv).sum(1) * inv t = (e2 * qv).sum(1) * inv hit = okd & (u >= 0) & (vv >= 0) & (u + vv <= 1) & (t > 1e-6) if hit.any(): tm = float(t[hit].min()) if tm < best_t: best_t, best_h = tm, inst['handle'] return best_h def pick_screen(self, board, u, v): """Pick through screen point (u,v in 0..1) using the live camera + view projection (0.5,0.5 = crosshair center). Returns instance handle.""" c = self.cache c.maybe_rebuild(board) cam = self.cam_matrix(board) origin = cam[3, :3] # image-plane point in eye space: x0..x1 / y0..y1 at z = -zeye x0, x1, y0, y1, zeye = -1.0, 1.0, -0.615, 0.615, 1.3 if c.view is not None: vb = board.nodes[c.view].get('body') or b'' if len(vb) >= 96: f = struct.unpack_from('<24f', vb, 0) x0, y0, x1, y1, zeye = f[5], f[6], f[7], f[8], max(f[9], 1e-3) de = np.array([x0 + u * (x1 - x0), y0 + (1 - v) * (y1 - y0), -zeye]) dw = de @ cam[:3, :3] return self.pick(board, origin, dw) def pump(self): """Keep the window responsive while the link is idle (no draw_scene).""" pg = self.pygame for ev in pg.event.get(): if ev.type == pg.QUIT: pg.quit(); raise KeyboardInterrupt def draw(self, board): pg = self.pygame for ev in pg.event.get(): if ev.type == pg.QUIT: pg.quit(); raise KeyboardInterrupt self.frame += 1 # adaptive frame skip: keep FLYK paced at 60/skip Hz while the software # rasterizer draws what it can (heavy scenes render 1 of N frames) if self.frame % self.skip: self.clock.tick(self.fps) return import time as _t _t0 = _t.perf_counter() self.cache.maybe_rebuild(board) c = self.cache W, H = self.w, self.h # view parameters vp = None if c.view is not None: vb = board.nodes[c.view].get('body') or b'' if len(vb) >= 96: f = struct.unpack_from('<24f', vb, 0) # stored-body float idx = wire idx - 1: x0@5 y0@6 x1@7 y1@8 zeye@9 # xs@10 ys@11 hither@12 yon@13 back@14..16 fog_enable@17 fog@18..22 vp = {'x0': f[5], 'y0': f[6], 'x1': f[7], 'y1': f[8], 'zeye': f[9], 'hither': max(f[12], 1e-3), 'yon': f[13], 'back': f[14:17], 'fog_on': struct.unpack_from(' vp['fog'][0]: back = np.array(vp['fog'][2:5]) * 255 else: back = np.array(vp['back']) * 255 img = np.empty((H, W, 3), np.float32); img[:] = back zbuf = np.full((H, W), np.inf, np.float32) fog_near, fog_far = vp['fog'][0], vp['fog'][1] fog_col = np.array(vp['fog'][2:5]) * 255 # FLYK's spline animation (0x1d) builds headings for nose-along--Z models. # The original ..\sharks shark is lost; our stand-in SHARK.B2Z is authored # nose-along-+X, so dynamically animated instances get a +90-deg yaw # model correction (X -> -Z). Statics carry their pose in the .SCN. FIX = np.eye(4) FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]] for inst in c.instances: if not inst_visible(board.nodes, inst): continue # fix_degenerate: BTL4 vehicle rigs contain pass-through DCSs # whose flushed rotation is rank-2 (shifted body variant); the # laser-beam chain rides four of them and composed rank-2 = beam # drawn away from the gun. Same treatment the camera chain gets. Mw = self.chain_matrix(board, inst['chain'], fix_degenerate=True) # NOVIEWMATRIX instances live in camera space: no view transform M = Mw if inst.get('hud') else Mw @ V # first-person canopy cull (see SceneCache radius note) if (NEARCULL and inst.get('gated') and not inst.get('hud') and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0): continue # FIX is the FLYK stand-in shark's +90-deg yaw correction (the # replacement model was authored nose-along-+X). BT/munga models # are authored correctly -- applying it yawed the player's own # mech 90 degrees ("looking through the right shoulder"). if (not getattr(board, 'munga', False) and inst['chain'] and inst['chain'][0] in board.anim): M = FIX @ M if inst['billboard']: # spherical billboard: keep scale + eye position, face the camera s = np.linalg.norm(M[0, :3]) M = M.copy() M[:3, :3] = np.eye(3) * s R, T = M[:3, :3], M[3, :3] geoms = inst['geoms'] if len(inst['lods']) > 1: # range-select LOD by eye distance dist = float(np.linalg.norm(T)) for sw_in, sw_out, lg in inst['lods']: if sw_in <= dist < sw_out: geoms = lg break for gh in geoms: mesh = c._mesh(board, gh) if mesh is None: continue if 'sphere' in mesh: self._spheres(mesh, M, c.geom_mtl.get(gh), vp, img, zbuf, W, H) continue pe = mesh['pos'] @ R + T # eye space z = -pe[:, 2] # near AND far clip (yon): beyond the fog wall nothing is # visible anyway -- kills most of a 6 km canal per frame ok = (z > vp['hither']) & (z < vp['yon'] * 1.05) zs = np.where(ok, z, 1.0) px = (pe[:, 0] * vp['zeye'] / zs - vp['x0']) / (vp['x1'] - vp['x0']) * W py = (1 - (pe[:, 1] * vp['zeye'] / zs - vp['y0']) / (vp['y1'] - vp['y0'])) * H # whole-mesh early-out: nothing in front of the camera, or the # visible verts' screen bbox misses the viewport entirely if not ok.any(): continue vx, vy = px[ok], py[ok] if (vx.max() < 0 or vx.min() >= W or vy.max() < 0 or vy.min() >= H): continue # per-vertex shade: emissive + diffuse * (wire ambient + suns) props = c.geom_mtl.get(gh) diffuse = props['diffuse'] if props is not None else np.array([0.75, 0.78, 0.82]) emissive = props['emissive'] if props is not None else np.zeros(3) if c.dirlights or c.ambient.any(): amb, suns = c.ambient, c.dirlights else: # no lights on the wire amb, suns = np.full(3, 0.35), [(self.light, np.full(3, 0.65))] lit_rgb = np.repeat(amb[None, :], len(pe), 0).copy() if mesh['nrm'] is not None and suns: # world-space normals; DPL lights double-sided (abs) Rm = Mw[:3, :3] nw = mesh['nrm'] @ Rm nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-9) spec = props['specular'] if props is not None else None for L, lcol in suns: nl = np.abs(nw @ L) lit_rgb += nl[:, None] * lcol[None, :] if spec is not None and spec[:3].max() > 1e-3: p_exp = spec[3] if 1.0 < spec[3] < 200 else 16.0 lit_rgb += (nl ** p_exp)[:, None] * spec[None, :3] * lcol[None, :] else: for L, lcol in suns: lit_rgb += 0.7 * lcol[None, :] base = (emissive[None, :] + diffuse[None, :] * lit_rgb) * 255 if mesh['col'] is not None: base = mesh['col'] * base # vertex colors modulate base = np.clip(base, 0, 255) # board-side SCROLL: texture body u0/v0 + du/dv * seconds # (FLYK); MUNGA treats floats 10/11 as a STATIC uv offset uvoff = (0.0, 0.0) texn = c.geom_texn.get(gh) if texn is not None: tb = board.nodes.get(texn, {}).get('body') or b'' if len(tb) >= 48: u0, v0, du, dv = struct.unpack_from('<4f', tb, 32) if all(np.isfinite((u0, v0, du, dv))): if getattr(board, 'munga', False): uvoff = (u0 + du, v0 + dv) else: t = self.frame / 60.0 uvoff = (u0 + du * t, v0 + dv * t) tex = c.geom_tex.get(gh) if tex is not None: # texture modulated by shade; fog applied to the modulator only # when untextured, so blend fog after texturing instead shade = base / 200.0 else: shade = None fz = None if vp['fog_on'] and fog_far > fog_near: fz = np.clip((z - fog_near) / (fog_far - fog_near), 0, 1) if tex is None: base = base * (1 - fz)[:, None] + fog_col[None, :] * fz[:, None] # transparency: texture alpha flag (texture body stored word 4) # -> cutout near-black texels; material opacity < 1 (DITHER n) # -> ordered screen-door acut = False if texn is not None and tex is not None: tb = board.nodes.get(texn, {}).get('body') or b'' if len(tb) >= 20 and struct.unpack_from('= 2: pg.draw.lines(self.screen, c8, False, sp, wd) elif kind == 'segs': for i in range(0, len(sp) - 1, 2): pg.draw.line(self.screen, c8, sp[i], sp[i + 1], wd) elif kind == 'poly' and len(sp) >= 3: pg.draw.polygon(self.screen, c8, sp) elif kind == 'points': for p in sp: self.screen.fill(c8, (p, (wd, wd))) # clips at edges return bool(prims) def _particles(self, board, eye, V, vp, img, zbuf, W, H, dt): """Ambient SPECIALFX emitters (installed 0x1c defs): the shipped board stepped these autonomously. Approximation: per-def particle pools spawned in a volume around the camera (SHARKS bubbles/marine snow), rising with the def's velocity, cooled/faded by o_cool, respawned per frags x rpt. Event-TRIGGERED effects (PSFX .EVT lines) need the input path first. OPT-IN (VRVIEW_SFX=1 / run_demo --sfx): most scenes install their defs for event use only -- ambient-simulating them puts bubbles everywhere.""" if not board.sfx or os.environ.get('VRVIEW_SFX') != '1': return psys = self._psys for code, d in board.sfx.items(): n = int(min(max(d['frags'], 1) * max(d['rpt'], 1) * 4, 192)) ps = psys.get(code) if ps is None or len(ps['age']) != n: ps = psys[code] = { 'pos': np.zeros((n, 3)), 'vel': np.zeros((n, 3)), 'age': np.full(n, 1e9), 'rng': np.random.default_rng(0xC0DE + code)} rng = ps['rng'] life = 1.0 / max(d['cool'], 1e-3) / 60.0 # seconds dead = ps['age'] > life nd = int(dead.sum()) if nd: rad = float(np.clip(d['size'] * 0.15, 4.0, 90.0)) p = rng.uniform(-1, 1, (nd, 3)) * rad p[:, 1] = rng.uniform(0, 1, nd) * rad * 0.5 + d['bias'] + d['off_y'] # keep spawns off the camera lens nrm = np.linalg.norm(p, axis=1) close = nrm < 8.0 p[close] *= (8.0 / np.maximum(nrm[close], 1e-6))[:, None] ps['pos'][dead] = eye + p v = rng.uniform(-1, 1, (nd, 3)) * d['variance'] * max(d['velocity'], 0.5) v[:, 1] = d['velocity'] * rng.uniform(0.6, 1.2, nd) ps['vel'][dead] = v ps['age'][dead] = rng.uniform(0, life * 0.5, nd) ps['vel'][:, 1] -= d['gravity'] * 0.002 * dt ps['pos'] += ps['vel'] * dt ps['age'] += dt # draw as fading discs pe = ps['pos'] @ V[:3, :3] + V[3, :3] z = -pe[:, 2] fade = np.clip(d['o_cool'] ** (ps['age'] * 60.0), 0.05, 1.0) cook = np.clip(np.array(d['cook']), 0, 2) pr_world = float(np.clip(d['size'] * 0.008, 0.15, 4.0)) kx = vp['zeye'] / (vp['x1'] - vp['x0']) * W for i in np.argsort(-z): zi = z[i] if zi <= vp['hither'] or zi > vp['yon']: continue cx = (pe[i, 0] * vp['zeye'] / zi - vp['x0']) / (vp['x1'] - vp['x0']) * W cy = (1 - (pe[i, 1] * vp['zeye'] / zi - vp['y0']) / (vp['y1'] - vp['y0'])) * H pr = min(max(pr_world * kx / zi, 0.6), H * 0.08) x0 = int(max(0, cx - pr)); x1 = int(min(W - 1, cx + pr)) + 1 y0 = int(max(0, cy - pr)); y1 = int(min(H - 1, cy + pr)) + 1 if x0 >= x1 or y0 >= y1: continue gx, gy = np.meshgrid(np.arange(x0, x1), np.arange(y0, y1)) m = ((gx - cx) ** 2 + (gy - cy) ** 2) <= pr * pr tz = zbuf[y0:y1, x0:x1] upd = m & (zi < tz) if upd.any(): # additive-ish blend, no zbuf write (soft particles) tile = img[y0:y1, x0:x1] tile[upd] = np.clip( tile[upd] * (1 - 0.6 * fade[i]) + cook[None, :] * 255 * 0.75 * fade[i], 0, 255) def _spheres(self, mesh, M, props, vp, img, zbuf, W, H): """Sphere-list geometry (vtype 0x41): shaded screen-space discs.""" R, T = M[:3, :3], M[3, :3] s = np.linalg.norm(M[0, :3]) pe = mesh['sphere'] @ R + T z = -pe[:, 2] col = np.array([0.8, 0.8, 0.8]) if props is not None: col = np.clip(props['emissive'] + props['diffuse'], 0, 1) rgb = col * 255 kx = vp['zeye'] / (vp['x1'] - vp['x0']) * W for i in np.argsort(-z): zi = z[i] if zi <= vp['hither']: continue cx = (pe[i, 0] * vp['zeye'] / zi - vp['x0']) / (vp['x1'] - vp['x0']) * W cy = (1 - (pe[i, 1] * vp['zeye'] / zi - vp['y0']) / (vp['y1'] - vp['y0'])) * H pr = max(mesh['rad'][i] * s * kx / zi, 0.7) x0 = int(max(0, cx - pr)); x1 = int(min(W - 1, cx + pr)) + 1 y0 = int(max(0, cy - pr)); y1 = int(min(H - 1, cy + pr)) + 1 if x0 >= x1 or y0 >= y1: continue gx, gy = np.meshgrid(np.arange(x0, x1), np.arange(y0, y1)) m = ((gx - cx) ** 2 + (gy - cy) ** 2) <= pr * pr tz = zbuf[y0:y1, x0:x1] upd = m & (zi < tz) if upd.any(): tz[upd] = zi img[y0:y1, x0:x1][upd] = rgb def _raster(self, tri, px, py, z, ok, col, uv, tex, shade, fz, fog_col, img, zbuf, W, H, uvoff=(0.0, 0.0), alpha_cut=False, opacity=1.0): if len(tri) == 0: return # vectorized pre-cull: clip-rejected verts, off-screen bboxes and # degenerate triangles never reach the per-triangle Python loop A, B, C = tri[:, 0], tri[:, 1], tri[:, 2] keep = ok[A] & ok[B] & ok[C] if not keep.any(): return xs = np.stack([px[A], px[B], px[C]]) ys = np.stack([py[A], py[B], py[C]]) minxs, maxxs = xs.min(0), xs.max(0) minys, maxys = ys.min(0), ys.max(0) keep &= (maxxs >= 0) & (minxs < W) & (maxys >= 0) & (minys < H) areas = ((xs[1] - xs[0]) * (ys[2] - ys[0]) - (xs[2] - xs[0]) * (ys[1] - ys[0])) keep &= np.abs(areas) > 1e-9 for t in tri[keep]: a, b, cc = int(t[0]), int(t[1]), int(t[2]) x0, y0, x1, y1, x2, y2 = px[a], py[a], px[b], py[b], px[cc], py[cc] minx = int(max(0, min(x0, x1, x2))); maxx = int(min(W - 1, max(x0, x1, x2))) + 1 miny = int(max(0, min(y0, y1, y2))); maxy = int(min(H - 1, max(y0, y1, y2))) + 1 if minx >= maxx or miny >= maxy: continue area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0) xs = np.arange(minx, maxx, dtype=np.float32) + 0.5 ys = np.arange(miny, maxy, dtype=np.float32) + 0.5 gx, gy = np.meshgrid(xs, ys) w0 = ((x1 - gx) * (y2 - gy) - (x2 - gx) * (y1 - gy)) / area w1 = ((x2 - gx) * (y0 - gy) - (x0 - gx) * (y2 - gy)) / area w2 = 1 - w0 - w1 inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0) if not inside.any(): continue # perspective-correct weights via 1/z wa = w0 / z[a]; wb = w1 / z[b]; wc = w2 / z[cc] iz = wa + wb + wc zi = 1.0 / np.maximum(iz, 1e-12) tile_z = zbuf[miny:maxy, minx:maxx] upd = inside & (zi < tile_z) if opacity < 0.99: # DITHER screen-door: 2x2 ordered pattern vs opacity bay = (((gx.astype(np.int32) & 1) * 2 + (gy.astype(np.int32) & 1)) + 0.5) / 4.0 upd &= bay <= opacity if not upd.any(): continue na = wa / iz; nb = wb / iz; nc = wc / iz if tex is not None and uv is not None: u = na * uv[a, 0] + nb * uv[b, 0] + nc * uv[cc, 0] + uvoff[0] v = na * uv[a, 1] + nb * uv[b, 1] + nc * uv[cc, 1] + uvoff[1] th, tw = tex.shape[:2] tx = (u % 1.0 * tw).astype(np.int32) % tw ty = (v % 1.0 * th).astype(np.int32) % th rgb = tex[ty, tx] if alpha_cut: upd &= rgb.sum(axis=-1) > 24.0 # near-black = transparent if not upd.any(): continue mod = (na[..., None] * shade[a] + nb[..., None] * shade[b] + nc[..., None] * shade[cc]) rgb = rgb * mod if fz is not None: f = na * fz[a] + nb * fz[b] + nc * fz[cc] rgb = rgb * (1 - f)[..., None] + fog_col[None, None, :] * f[..., None] else: rgb = (na[..., None] * col[a] + nb[..., None] * col[b] + nc[..., None] * col[cc]) tile_z[upd] = zi[upd] tile_c = img[miny:maxy, minx:maxx] tile_c[upd] = rgb[upd]