#!/usr/bin/env python3 """ vrboard.py -- virtual VelociRender board (protocol core for Path A). This is the piece the DOSBox-X C012 device wraps: it consumes the byte stream the DOS host (FLYK.EXE via VR_COMMS.C) writes to the link, reassembles messages, dispatches the 24 vr_actions, and produces reply bytes. The rendering guts (geometry/texture ingest, DPL transform, raster) plug in where marked TODO -- those are the dpl3-revive parser/viewer pipeline. Framing is implemented straight from sda4/DPL3/VR_COMMS.C and VR_PROT.H. See spec/VELOCIRENDER_PROTOCOL.md. Run directly for a self-test that round-trips a realistic command sequence: python vrboard.py """ import struct from enum import IntEnum # ---- vr_action enum (VR_PROT.H, positional/0-based) ------------------------- class A(IntEnum): init=0; create=1; delete=2; flush=3 sect_pixel=4; sect_vector=5 dcs_nest=6; dcs_link=7; dcs_prune=8 draw_scene=9; draw_scene_complete=10 list_add=11; list_remove=12 morph=13; version=14; statistics=15; readpixels=16 hspcode=17; code860=18; data860=19; bss860=20; args860=21 set_geom_verts=22; set_texmap_texels=23 BOOT_ACTIONS = {A.hspcode, A.code860, A.data860, A.bss860, A.args860} # ---- envelope (VR_COMMS.C velocirender_transmit / receive_protocol) ---------- # length word = [flags : top bits][id : bits 16-23][count : low 16] # count = bytes following the length word (= 4 action + N payload for vr_net) # bit31 = iserver control message (else vr_net render command) # bit30 = 0x40000000: set on EVERY length word by the shipped Aug-1995 HPDAVE build # (verify_framing.py found `or ebp,0x40000000` in FLYK's transmit + a global # pre-set to 0x40000000). Absent in the 1994 VR_COMMS.C source. Exact receive- # side semantics TBD (confirm in the dynamic capture); we MIRROR it so our # replies are byte-identical to what FLYK's board would send. LW_FLAG = 0x40000000 def pack_vr(action, payload=b''): lw = LW_FLAG | (0xff << 16) | (len(payload) + 4) # broadcast id 0xff return struct.pack('" return f"<{A(self.action).name:16} {len(self.payload):4}B>" class Assembler: """Byte stream (host->board) -> complete Msg objects. Models the C012 input FIFO.""" def __init__(self): self.buf = bytearray() def feed(self, data): self.buf += data def __iter__(self): return self def __next__(self): if len(self.buf) < 4: raise StopIteration (lw,) = struct.unpack_from('> 16) & 0xff nb = lw & 0xffff # bytes following the length word if len(self.buf) < 4 + nb: raise StopIteration # wait for more bytes body = bytes(self.buf[4:4 + nb]) del self.buf[:4 + nb] if iserver: return Msg(True, sender, None, body) action = struct.unpack_from(' {'type','body'} self.edges = [] # (op, parent, child) scene-graph log self.geom = {} # handle -> list of raw vertex/conn blocks self.tex = {} # handle -> {'u','v','mode','texels'} self._geom_acc = None # in-progress set_geom_verts self._tex_acc = None # in-progress action-23 upload self._conn_acc = None # in-progress action-0x19 upload self._texel_acc = None # in-progress action-0x1a upload self.uploads = {} # handle -> [ {hdr, scale, data} ] self.conns = {} # handle -> [ {n_conns, per, flags, data} ] self.anim = {} # handle -> latest 0x1d (9f mat + 3f t, COMPOSES) self.anim_abs = {} # handle -> latest 0x1f record (REPLACES) self.anim4 = {} # handle -> latest 0x1b (16f 4x4) self.pvision = False # IR / "predator vision" thermal self.pvision_mat = None # view-colour transform (dpl_Effect # type-0, action 0x1b, mode -1 ON/-2 OFF) self.morphs = {} # morphed geom -> (a, b, alpha) self.names = {} # 0x8000xxxx name id -> handle (0x22) self.sfx = {} # SPECIALFX defs by code (0x1c) self.psfx = [] # active PSFX effects (0x1d in # MUNGA): list of decoded bursts # = weapon bolts / muzzle / impacts self.dl2d = {} # 2D display lists (0x29/0x2b): handle -> [tag words] self._dl2d_acc = None # in-progress chunked 0x2b flush self.munga = False # game-build dialect (rpl4opt/btl4opt) self.frames = 0 self.log = [] def _alloc(self): h = self.next_handle; self.next_handle += 1; return h def handle(self, msg): """Process one Msg; return reply bytes (b'' = no reply).""" if msg.iserver: self.log.append(f"iserver {len(msg.payload)}B (serviced, discarded)") return b'' try: a = A(msg.action) except ValueError: # shipped build has actions beyond the 1994 enum. 0x2a is the velocirender_sync # barrier -- it expects an echo reply. Others (e.g. 0x1c, an 80B data op) are # fire-and-forget; replying to them desyncs the next sync. if msg.action in (0x2a, 0x2d): # velocirender_sync barrier: 0x2a in the FLYK demos, 0x2d in the # MUNGA game builds. Disasm of rpl4opt's vr_sync (hunt_pe.py, # 0x497080): reply ACTION is never checked -- the reply PAYLOAD # word0 must echo the request's word0 (a sync cookie; cmp ebx, # [ebp-0x28] / exit(9) on mismatch). FLYK's 0x2a cookie is 0, so # the old zero reply was accidentally correct. # 0x2d is also the MUNGA dialect marker -- the init argv is # IDENTICAL between builds ('/device~0x150~...' tildes included), # so argv sniffing misclassified FLYK and yawed every camera. if msg.action == 0x2d and not self.munga: self.munga = True self.log.append("[MUNGA dialect: sync 0x2d seen]") cookie = msg.payload[:4] if len(msg.payload) >= 4 else b'\x00' * 4 self.log.append(f"sync {msg.action:#x} cookie={cookie.hex()} -> ack") return (struct.pack('= 160: # vr_psfx_action = dpl_Effect(particlestart|explosion, DCS, # &info): a glowing particle burst. Wire = [DCS handle] # [dpl_PARTICLESTART_EFFECT_INFO] (DPLTYPES.H), so struct # field K is at word K+1: [5..7]=px,py,pz release position, # [8]=pv, [9..11]=vel, [15]=rad, [17]=exp (expansion), # [29..31]=start INTERIOR rgb, [32]=start alpha, [45..47]= # end INTERIOR rgb. DCS!=0 -> emitter rides that projectile # DCS (weapon bolt, px,py,pz is a ~0 local offset); DCS==0 # -> one-shot WORLD-space burst at (px,py,pz) (muzzle flash, # impact spark, explosion -- big rad). MUNGA articulates via # 0x1f, so a >=160B 0x1d is always a psfx, never a transform. n = len(p) // 4 f = struct.unpack_from('<%df' % n, p) dcs = struct.unpack_from(' 47 else (0.0, 0.0, 0.0) rad = abs(f[15]) if n > 15 else 1.0 exp = abs(f[17]) if n > 17 else 0.0 alpha = min(1.0, abs(f[32])) if n > 32 else 1.0 ttl = 30 if rad >= 3.0 else (14 if dcs == 0 else 48) self.psfx.append({'dcs': dcs, 'pos': pos, 'col': col, 'ecol': ecol, 'rad': rad, 'exp': exp, 'alpha': alpha, 'ttl': ttl, 'maxttl': ttl, 'age': 0}) if len(self.psfx) > 256: # bound it; renderer expires del self.psfx[:-256] return b'' if len(p) >= 56: # FLYK per-frame transform (shipped flush_artics): # [dcs_type=1 matrix][dcs handle][3x3 row-major][tx ty tz] h = struct.unpack_from('= 8: # MUNGA batched flush_artics: [n_records] then n records of # [dcs handle][payload], where payload is EITHER a full pose # (12f: 3x3 row-major + t, ABSOLUTE -- replaces the flush # matrix; composing doubles it) OR a joint angle (sin,cos: # 2f in BTL4 v1.1, 5f sin,cos,0,0,0 in RPL4 v1.2). Record # sizes are version-dependent, so parse by BACKTRACKING: every # record starts with a valid DCS handle, and float bit # patterns don't collide with small handle ints. (A fixed- # size guess corrupted mid-packet records -- BT camera chain # picked up det=0 matrices and rendered nothing.) p = msg.payload n = min(struct.unpack_from(' len(p): return None h = struct.unpack_from(' len(p): continue rest = parse(end, k - 1) if rest is not None: return [(h, off + 4, nf)] + rest return None recs = parse(4, n) for h, off, nf in recs or (): if nf == 12: self.anim_abs[h] = struct.unpack_from('<12f', p, off) # joint sin/cos records: axis semantics unknown -- the # flushed matrix stands (mech limbs won't articulate yet) return b'' if msg.action == 0x1c and len(msg.payload) >= 64: # install_sfx: SPECIALFX def, fields per the .SCN column comments: # [code][texture ref][type][velocity][size][off_y][bias] # [cook_r][cook_g][cook_b][variance][gravity][o_cool][cool] # [frags][rpt][host ptrs...] w = struct.unpack_from('<3I', msg.payload, 0) f = struct.unpack_from('<11f', msg.payload, 12) fr, rpt = struct.unpack_from('<2I', msg.payload, 56) self.sfx[w[0]] = { 'tex': w[1], 'type': w[2], 'velocity': f[0], 'size': f[1], 'off_y': f[2], 'bias': f[3], 'cook': f[4:7], 'variance': f[7], 'gravity': f[8], 'o_cool': f[9], 'cool': f[10], 'frags': fr, 'rpt': rpt} self.log.append(f"install_sfx code={w[0]} type={w[2]} " f"vel={f[0]:.2f} frags={fr} rpt={rpt}") return b'' if msg.action == 0x18 and len(msg.payload) >= 12: return self.do_get_geom_vertices(msg.payload) if msg.action == 0x23 and len(msg.payload) >= 8: # fire/pick at screen (u,v) -- observed on joystick trigger: # [u:f][v:f][host ptr][-1]. EXPERIMENT: reply with the instance # under the crosshair (sect-style); may drive the damage-FX chain. u, v = struct.unpack_from('<2f', msg.payload, 0) h = 0 view = getattr(self, '_view', None) if view is not None: try: h = view.pick_screen(self, u, v) except Exception as e: self.log.append(f"0x23 pick error: {e}") self.log.append(f"fire/pick 0x23 ({u:.2f},{v:.2f}) -> {h:#x}") # CONFIRMED fire-and-forget: replying desyncs the host stream # (fxfire run stalled immediately after the first reply, while # unanswered 0x23s in the prior run were harmless). The pick # result stays board-side telemetry only. return b'' if msg.action == 0x22 and len(msg.payload) >= 8: # name binding: [handle][0x8000xxxx board-side name id] # (board createName; fire-and-forget) h, nm = struct.unpack_from('= 72: # dpl_Effect type-0 (view colour) + zone matrix refresh share # this action: [word0][handle][4x4 row-major]. word0 = -1/-2 is # the IR/"predator vision" toggle (BTL4OPT FUN_0045fe44 -> # dpl_Effect(0,0,{grey,grey,grey,mode}); the 4x4 is the colour # transform). Otherwise word0 is a zone handle (SDEMO per-frame # matrix update; fire-and-forget, row-3 junk = uninit host mem). mode = struct.unpack_from('= 4: # dpl2d_NewDisplayList: [remote handle][0] (create/reset a 2D # overlay display list -- the HUD layer; fire-and-forget) h = struct.unpack_from('= 20: # dpl2d_FlushDisplayList: the host streams the dpl2d_DISPLAY # chunk structs verbatim (DPL_2D.H: remote, next, tail, size, # open, data[30]). First chunk carries remote=handle and # tail=total chunk count; continuation chunks have remote=0. # Data words concatenate across chunks (tag args split freely # at chunk boundaries); tags per DPL2DTAG.H. hdr, _nxt, tail, size = struct.unpack_from('<4I', msg.payload, 0) size = min(size, (len(msg.payload) - 20) // 4) dat = list(struct.unpack_from(f'<{size}I', msg.payload, 20)) if hdr: self._dl2d_acc = [hdr, max(tail, 1) - 1, dat] elif self._dl2d_acc is not None: self._dl2d_acc[1] -= 1 self._dl2d_acc[2] += dat if self._dl2d_acc is not None and self._dl2d_acc[1] <= 0: self.dl2d[self._dl2d_acc[0]] = self._dl2d_acc[2] self._dl2d_acc = None return b'' self.log.append(f"EXTRA action {msg.action:#x} ({len(msg.payload)}B) -- no reply") return b'' p = msg.payload fn = getattr(self, f"do_{a.name}", None) if fn is None: self.log.append(f"{a.name}: no handler (discarded)") return b'' return fn(p) or b'' # -- node lifecycle -- def do_init(self, p): # NOTE: the init argv CANNOT distinguish dialects -- shipped FLYK and # rpl4opt send byte-identical '/device~0x150~/video~svga~...' strings. # The marker is the first vr_sync: 0x2d = MUNGA, 0x2a = FLYK. self.munga = False self.log.append(f"init argv={p.rstrip(bytes(1)).decode('latin1','replace')!r}") return pack_vr(A.init, struct.pack('= 8 else self._alloc() self.nodes[h] = {'type': typ, 'body': b''} self.log.append(f"create type={typ:#x} handle={h:#x} (no reply)") return b'' def do_delete(self, p): h = struct.unpack_from('remote n = self.nodes.setdefault(h, {'type': None, 'body': b''}); n['body'] = p[4:] self.log.append(f"flush handle {h:#x} ({len(p)-4}B struct body)") return b'' # no reply # -- scene graph plumbing -- def _edge(self, op, p): x, y = struct.unpack_from('= acc['need']: self.geom[acc['h']] = acc['blocks'] self.log.append(f" geometry {acc['h']:#x} complete ({len(acc['blocks'])} blocks) -> ack") self._geom_acc = None return pack_vr(A.set_geom_verts, struct.pack('= acc['need']: self.uploads.setdefault(acc['h'], []).append(acc) self._tex_acc = None # MUNGA bulk ops are ack-less: the host follows each with # vr_sync(cookie=action) and the cookie echo IS the ack (an extra # 0x1a ack surfaced as "unexpected action 26 in velocirender_sync") if self.munga: self.log.append(f" upload23 {acc['h']:#x} complete " f"({len(acc['data'])}B) [no ack: MUNGA syncs]") return b'' self.log.append(f" upload23 {acc['h']:#x} complete ({len(acc['data'])}B) -> ack") return pack_vr(A.set_texmap_texels, struct.pack('= acc['need']: self.conns.setdefault(acc['h'], []).append(acc) self._conn_acc = None if self.munga: # see upload23: sync IS the ack self.log.append(f" conn19 {acc['h']:#x} complete " f"({len(acc['data'])}B) [no ack: MUNGA syncs]") return b'' self.log.append(f" conn19 {acc['h']:#x} complete ({len(acc['data'])}B) -> ack") return pack_vr(0x19, struct.pack('= acc['need']: self.tex[acc['h']] = acc self._texel_acc = None if self.munga: # see upload23: sync IS the ack self.log.append(f" texel1a {acc['h']:#x} complete " f"({len(acc['data'])}B) [no ack: MUNGA syncs]") return b'' self.log.append(f" texel1a {acc['h']:#x} complete ({len(acc['data'])}B) -> ack") return pack_vr(0x1a, struct.pack('b # into the morphed geometry (SDEMO fish schools, .V2Z). Fire-and-forget. def do_morph(self, p): if len(p) >= 20: mo, a, b = struct.unpack_from('<3I', p, 0) alpha = struct.unpack_from(' frame {self.frames}, async ack") return pack_vr(A.draw_scene, struct.pack(' pixel words from the last rendered frame, # fragmented into <=508B action-echoed messages (mirrors upload # packetization). Texel word convention [pad,B,G,R]. EXPERIMENTAL: # no shipped binary has exercised this path yet. view = getattr(self, '_view', None) if len(p) < 12 or view is None or getattr(view, 'last_frame', None) is None: self.log.append("readpixels: no frame available -> zero pixels") return pack_vr(A.readpixels, b'\x00\x00\x00\x00') x, y, n = struct.unpack_from('<3I', p, 0) arr = view.last_frame # (H, W, 3) uint8 H, W = arr.shape[:2] flat = arr.reshape(-1, 3) start = min(y * W + x, len(flat)) n = min(n, len(flat) - start) px = flat[start:start + n] words = bytearray(n * 4) words[1::4] = px[:, 2].tobytes() # B words[2::4] = px[:, 1].tobytes() # G words[3::4] = px[:, 0].tobytes() # R out = bytearray() for off in range(0, len(words), 508): out += pack_vr(A.readpixels, bytes(words[off:off + 508])) self.log.append(f"readpixels {x},{y} n={n} -> {len(out)}B in fragments") return bytes(out) # -- action 0x18 = get_geom_vertices (host reads vertex data back) -- # Located by LE-fixup + capstone hunt in shipped FLYK: transmit with # eax=0x18, 0x28-byte request, reply MUST echo action 0x18; host receives # into a 0x100-byte buffer per chunk. Request word0 = geometry handle, # then vertex0/count-style fields. Reply = echoed-action frames carrying # the geometry's current vertex floats (<=240B data per frame). # EXPERIMENTAL until a binary calls it live. def do_get_geom_vertices(self, p): w = struct.unpack_from('<%dI' % (len(p) // 4), p, 0) gh = w[0] ups = self.uploads.get(gh) or [] if not ups: self.log.append(f"get_geom_vertices {gh:#x}: no data -> empty reply") return pack_vr(0x18, b'') stride = ups[-1]['hdr'][3] total = 0 for up in ups: h = up['hdr'] total = max(total, h[1] + h[2], h[7]) buf = bytearray(total * stride * 4) for up in ups: h = up['hdr'] if h[3] != stride: continue n = min(h[2] * stride * 4, len(up['data'])) buf[h[1] * stride * 4: h[1] * stride * 4 + n] = up['data'][:n] # optional sub-range: request words 1/2 as vertex0/count when sane v0 = w[1] if len(w) > 1 and w[1] < total else 0 cnt = w[2] if len(w) > 2 and 0 < w[2] <= total - v0 else total - v0 data = bytes(buf[v0 * stride * 4:(v0 + cnt) * stride * 4]) out = bytearray() for off in range(0, len(data), 240): out += pack_vr(0x18, data[off:off + 240]) if not out: out = pack_vr(0x18, b'') self.log.append(f"get_geom_vertices {gh:#x} v0={v0} n={cnt} " f"stride={stride} -> {len(out)}B") return bytes(out) # -- intersection queries: ray-triangle over the live scene (via the view) -- # 1994 payloads: sect_pixel = dpl_POINT[4f] + screen x,y (24B); # sect_vector = dpl_POINT[4f] + x0 y0 z0 x1 y1 z1 (40B). # Reply = int32 instance handle (0 = miss). Games use these for collision. def do_sect_vector(self, p): h = 0 view = getattr(self, '_view', None) if view is not None and len(p) >= 40: f = struct.unpack_from('<6f', p, 16) o = f[0:3] d = (f[3] - f[0], f[4] - f[1], f[5] - f[2]) try: h = view.pick(self, o, d) except Exception as e: self.log.append(f"sect_vector pick error: {e}") self.log.append(f"sect_vector -> instance {h:#x}") return pack_vr(A.sect_vector, struct.pack('= 24: x, y = struct.unpack_from('<2f', p, 16) u = x / 832.0 if abs(x) > 1.5 else x v = y / 512.0 if abs(y) > 1.5 else y try: h = view.pick_screen(self, u, v) except Exception as e: self.log.append(f"sect_pixel pick error: {e}") self.log.append(f"sect_pixel ({x:.1f},{y:.1f}) -> {h:#x}") return pack_vr(A.sect_pixel, struct.pack(' board wire bytes asm.feed(raw) for msg in asm: r = board.handle(msg) if r: replies.append((msg, r)) # boot firmware (discarded) + init host_send(pack_vr(A.code860, b'\x00' * 40)) host_send(pack_vr(A.init, b'video=svga|qual=0x14|\n\x00')) # build a tiny scene: a DCS (transform) with a 4x4 matrix, a geometry node host_send(pack_vr(A.create, struct.pack(' reply {A(act).name} ({len(r)-8}B payload)") # sanity assertions assert board.frames == 1 assert dcs_handle in board.nodes and board.nodes[dcs_handle]['body'] == identity assert geo_handle in board.geom and len(board.geom[geo_handle]) == 4 assert ('nest', dcs_handle, geo_handle) in board.edges assert asm.buf == bytearray(), "assembler left partial bytes" print("\nOK: framing round-trips, handles/flush/geometry/scene-graph all consistent.") if __name__ == '__main__': _selftest()