- IR / predator-vision thermal: the cockpit IR button toggles it via the wire (dpl_Effect on action 0x1b, mode -1 ON / -2 OFF -> board.pvision). vrview_gl present pass remaps scene luminance to a thermal palette (default heat/Predator; mono|green|amber via VRVIEW_PVISION_PALETTE; VRVIEW_PVISION / bridge 'v' key force it on). Exact palette + HUD-exempt pending crew review. - Texture filter default flipped to bilinear (operator preference); the i860 board itself point-sampled, so VRVIEW_FILTER=nearest reverts. (A CRT present-pass bleed/scanline prototype was built and rejected; removed.) - Searchlight: no code needed -- night A/B proved it is a VIEW-FOG push-back (fog near 5->60, far 400->500 on), already rendered by the existing view fog path. Not a light cone. Added TESTNITE.EGG (arena1/night/fog) + gauge_arena_night_pipe.conf for the test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
651 lines
34 KiB
Python
651 lines
34 KiB
Python
#!/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('<II', lw, int(action)) + payload
|
|
|
|
def pack_iserver(target, payload):
|
|
if len(payload) & 1: # host pads iserver msgs to even length
|
|
payload += b'\x00'
|
|
lw = LW_FLAG | 0x80000000 | (target << 16) | len(payload)
|
|
return struct.pack('<I', lw) + payload
|
|
|
|
class Msg:
|
|
__slots__ = ('iserver', 'sender', 'action', 'payload')
|
|
def __init__(self, iserver, sender, action, payload):
|
|
self.iserver, self.sender, self.action, self.payload = iserver, sender, action, payload
|
|
def __repr__(self):
|
|
if self.iserver:
|
|
return f"<iserver sender={self.sender} {len(self.payload)}B>"
|
|
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('<I', self.buf, 0)
|
|
iserver = bool(lw & 0x80000000)
|
|
sender = (lw >> 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('<I', body, 0)[0]
|
|
return Msg(False, sender, action, body[4:])
|
|
|
|
# ---- the board ---------------------------------------------------------------
|
|
class VirtualBoard:
|
|
"""Mirror of VRENDER/VR_REMOT.C:remote_velocirender(). Returns reply bytes (or b'')."""
|
|
def __init__(self, renderer=None):
|
|
self.renderer = renderer # dpl3-revive pipeline (optional)
|
|
self.next_handle = 0x1000 # board-side node handles
|
|
self.nodes = {} # handle -> {'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('<II', LW_FLAG | (0xff << 16) | 8, msg.action)
|
|
+ cookie)
|
|
if msg.action == 0x19:
|
|
return self.do_add_connections(msg.payload)
|
|
if msg.action == 0x1a:
|
|
return self.do_texels(msg.payload)
|
|
if msg.action == 0x1d:
|
|
p = msg.payload
|
|
if self.munga and len(p) >= 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('<I', p, 0)[0]
|
|
pos = (f[5], f[6], f[7])
|
|
col = (max(0.0, f[29]), max(0.0, f[30]), max(0.0, f[31]))
|
|
ecol = (max(0.0, f[45]), max(0.0, f[46]), max(0.0, f[47])) \
|
|
if n > 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('<I', p, 4)[0]
|
|
self.anim[h] = struct.unpack_from('<12f', p, 8)
|
|
return b''
|
|
if msg.action == 0x1f and len(msg.payload) >= 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('<I', p, 0)[0], 64)
|
|
is_dcs = lambda h: self.nodes.get(h, {}).get('type') == 5
|
|
|
|
def parse(off, k):
|
|
if k == 0:
|
|
return [] if off == len(p) else None
|
|
if off + 4 > len(p):
|
|
return None
|
|
h = struct.unpack_from('<I', p, off)[0]
|
|
if not is_dcs(h):
|
|
return None
|
|
for nf in (12, 2, 5):
|
|
end = off + 4 + nf * 4
|
|
if end > 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('<II', msg.payload, 0)
|
|
self.names[nm] = h
|
|
return b''
|
|
if msg.action == 0x1b and len(msg.payload) >= 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('<i', msg.payload, 0)[0]
|
|
if mode == -1 or mode == -2:
|
|
self.pvision = (mode == -1)
|
|
if self.pvision:
|
|
self.pvision_mat = struct.unpack_from('<16f', msg.payload, 8)
|
|
return b''
|
|
h = struct.unpack_from('<I', msg.payload, 4)[0]
|
|
self.anim4[h] = struct.unpack_from('<16f', msg.payload, 8)
|
|
return b''
|
|
if msg.action == 0x29 and len(msg.payload) >= 4:
|
|
# dpl2d_NewDisplayList: [remote handle][0] (create/reset a 2D
|
|
# overlay display list -- the HUD layer; fire-and-forget)
|
|
h = struct.unpack_from('<I', msg.payload, 0)[0]
|
|
self.dl2d[h] = []
|
|
return b''
|
|
if msg.action == 0x2b and len(msg.payload) >= 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('<I', 1))
|
|
def do_create(self, p):
|
|
typ = struct.unpack_from('<I', p, 0)[0]
|
|
# create is fire-and-forget in BOTH shipped dialects: the host assigns
|
|
# the handle locally and sends it (2nd word). MUNGA confirmed by absence
|
|
# of the 1994 "unexpected action %d returned in velocirender_create"
|
|
# string in rpl4opt (an experimental create-ack desynced the stream).
|
|
h = struct.unpack_from('<I', p, 4)[0] if len(p) >= 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('<I', p, 0)[0]; self.nodes.pop(h, None)
|
|
self.log.append(f"delete handle {h:#x}")
|
|
return pack_vr(A.delete, p[:4])
|
|
def do_flush(self, p):
|
|
h = struct.unpack_from('<I', p, 0)[0] # payload starts at &n->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('<II', p, 0); self.edges.append((op, x, y))
|
|
self.log.append(f"{op} {x:#x} {y:#x}"); return b''
|
|
def do_dcs_nest(self, p): return self._edge('nest', p)
|
|
def do_dcs_link(self, p): return self._edge('link', p)
|
|
def do_dcs_prune(self, p): return self._edge('prune', p)
|
|
def do_list_add(self, p): return self._edge('list_add', p)
|
|
def do_list_remove(self, p):return self._edge('list_remove', p)
|
|
|
|
# -- geometry (header msg, then vert blocks, then conn blocks; one ack) --
|
|
def do_set_geom_verts(self, p):
|
|
if self._geom_acc is None and len(p) == 20:
|
|
h, vb, cb, nv, nc = struct.unpack('<5I', p)
|
|
self._geom_acc = {'h': h, 'vb': vb, 'cb': cb, 'blocks': [], 'need': vb + cb}
|
|
self.log.append(f"set_geom_verts handle {h:#x}: {vb} vert + {cb} conn blocks, {nv}v {nc}c")
|
|
return b''
|
|
acc = self._geom_acc
|
|
acc['blocks'].append(p)
|
|
if len(acc['blocks']) >= 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('<I', 1))
|
|
return b''
|
|
|
|
# -- shipped action 23: bulk upload stream (geometry vertices, and -- per the
|
|
# 1994 enum name -- texmap texels; the header self-describes the stream).
|
|
# Header 36B = 9 words:
|
|
# [target][k?][count][stride_words][n_conns][vert_type][geo_type][conn_sz][scale:f]
|
|
# then ceil(count*stride*4 / 508) framed data messages; ONE ack when done.
|
|
# (Decoded live from FLYK 2026-07-04: ocean = 4 verts x 9 floats
|
|
# [x y z r g b a u v], vert_type=0x15, geo_type=5, scale=GEOMSCALE.)
|
|
def do_set_texmap_texels(self, p):
|
|
if self._tex_acc is None:
|
|
if len(p) != 36:
|
|
self.log.append(f"upload23: unexpected header {len(p)}B: {p[:36].hex()}")
|
|
return b''
|
|
w = struct.unpack_from('<8I', p, 0)
|
|
(scale,) = struct.unpack_from('<f', p, 32)
|
|
need = w[2] * w[3] * 4
|
|
self._tex_acc = {'h': w[0], 'hdr': w, 'scale': scale,
|
|
'need': need, 'data': bytearray()}
|
|
self.log.append(
|
|
f"upload23 handle {w[0]:#x}: count={w[2]} stride={w[3]}w "
|
|
f"conns={w[4]} vtype={w[5]:#x} gtype={w[6]} csz={w[7]} scale={scale:.3f} "
|
|
f"({need}B expected)")
|
|
return b''
|
|
acc = self._tex_acc
|
|
acc['data'] += p
|
|
if len(acc['data']) >= 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('<I', 1))
|
|
return b''
|
|
|
|
# -- shipped action 0x19: add geometry connections --
|
|
# Header 16B = [geom_handle][n_conns][indices_per_conn][flags?], then framed
|
|
# index words (n_conns * per x int32); ONE ack when complete.
|
|
# (dpl_AddGeometryConnections; decoded live 2026-07-04: ocean quad =
|
|
# [0x1f][1][4][0] + indices 0,1,3,2; fish pmesh = [h][70][3][0] + 70 tris.)
|
|
def do_add_connections(self, p):
|
|
if self._conn_acc is None:
|
|
if len(p) != 16:
|
|
self.log.append(f"conn19: unexpected header {len(p)}B: {p.hex()}")
|
|
return b''
|
|
h, nc, per, fl = struct.unpack_from('<4I', p, 0)
|
|
self._conn_acc = {'h': h, 'n_conns': nc, 'per': per, 'flags': fl,
|
|
'need': nc * per * 4, 'data': bytearray()}
|
|
self.log.append(f"conn19 handle {h:#x}: {nc} conns x {per} idx, flags={fl}")
|
|
return b''
|
|
acc = self._conn_acc
|
|
acc['data'] += p
|
|
if len(acc['data']) >= 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('<I', 1))
|
|
return b''
|
|
|
|
# -- shipped action 0x1a: texmap texel upload (1994 set_texmap_texels shape) --
|
|
# Header 32B = [texmap_handle][n_texels][u_size][v_size][mode][3 junk ptr words],
|
|
# then n_texels x int32 in 256B (64-texel) chunks; ONE ack when complete.
|
|
# Texel word = [pad,B,G,R] bytes (SVT xbgr). mode 1 = 8-bit path.
|
|
# (Decoded live from SDEMO 2026-07-04: [0x21][0x4000][128][128][1]...)
|
|
def do_texels(self, p):
|
|
if self._texel_acc is None:
|
|
if len(p) != 32:
|
|
self.log.append(f"texel1a: unexpected header {len(p)}B: {p.hex()}")
|
|
return b''
|
|
h, nt, u, v, mode = struct.unpack_from('<5I', p, 0)
|
|
self._texel_acc = {'h': h, 'u': u, 'v': v, 'mode': mode,
|
|
'need': nt * 4, 'data': bytearray()}
|
|
self.log.append(f"texel1a handle {h:#x}: {u}x{v} mode={mode} ({nt} texels)")
|
|
return b''
|
|
acc = self._texel_acc
|
|
acc['data'] += p
|
|
if len(acc['data']) >= 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('<I', 1))
|
|
return b''
|
|
|
|
# -- morph (action 13): [morphed][a][b][alpha:f][flags] -- vertex-blend a->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('<f', p, 12)[0]
|
|
self.morphs[mo] = (a, b, alpha)
|
|
return b''
|
|
|
|
# -- render + readback --
|
|
def do_draw_scene(self, p):
|
|
db = struct.unpack_from('<I', p, 0)[0]; self.frames += 1
|
|
# TODO: hand self.nodes/edges/geom/tex to the dpl3-revive renderer here.
|
|
self.log.append(f"draw_scene(double_buffered={db}) -> frame {self.frames}, async ack")
|
|
return pack_vr(A.draw_scene, struct.pack('<I', 1)) # frame-ack (dpl_frame_replied)
|
|
def do_readpixels(self, p):
|
|
# [x, y, n_pixels] -> 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('<I', h))
|
|
|
|
def do_sect_pixel(self, p):
|
|
# dpl_POINT[4f] + screen x,y. Coordinates may be pixels (832x512 board
|
|
# frame) or already-normalized -- normalize by magnitude and unproject
|
|
# through the live camera (pick_screen).
|
|
h = 0
|
|
view = getattr(self, '_view', None)
|
|
if view is not None and len(p) >= 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('<I', h))
|
|
|
|
def do_statistics(self, p):
|
|
# 1994 board reply: single int32 = 1 (VR_REMOT.C velocirender_statistics)
|
|
return pack_vr(A.statistics, struct.pack('<I', 1))
|
|
|
|
def do_boot(self, p): return b''
|
|
|
|
# discard boot-time firmware downloads
|
|
for _a in BOOT_ACTIONS:
|
|
setattr(VirtualBoard, f"do_{_a.name}", lambda self, p, _n=_a.name: (self.log.append(f"{_n}: firmware {len(p)}B discarded"), b'')[1])
|
|
|
|
|
|
# ---- self-test: synthesize a realistic sequence with the host's exact framing ----
|
|
def _selftest():
|
|
import io
|
|
board = VirtualBoard()
|
|
asm = Assembler()
|
|
replies = []
|
|
|
|
def host_send(raw): # host -> 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('<I', 0x0006))) # dcs type
|
|
dcs_handle = struct.unpack('<I', replies[-1][1][8:12])[0]
|
|
host_send(pack_vr(A.create, struct.pack('<I', 0x000a))) # geometry type
|
|
geo_handle = struct.unpack('<I', replies[-1][1][8:12])[0]
|
|
# flush the DCS with an identity matrix as its struct body
|
|
import struct as _s
|
|
identity = b''.join(_s.pack('<f', v) for v in
|
|
(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1))
|
|
host_send(pack_vr(A.flush, struct.pack('<I', dcs_handle) + identity))
|
|
host_send(pack_vr(A.dcs_nest, struct.pack('<II', dcs_handle, geo_handle)))
|
|
# upload 3 verts + 1 triangle: header, then 3 vert blocks (32B each), 1 conn block (16B)
|
|
host_send(pack_vr(A.set_geom_verts, struct.pack('<5I', geo_handle, 3, 1, 3, 1)))
|
|
for i in range(3):
|
|
host_send(pack_vr(A.set_geom_verts, _s.pack('<8f', i, i, 0, 0,0,1, 0,0)))
|
|
host_send(pack_vr(A.set_geom_verts, _s.pack('<4I', 0, 1, 2, 0)))
|
|
# draw + readback
|
|
host_send(pack_vr(A.draw_scene, struct.pack('<I', 1)))
|
|
host_send(pack_vr(A.readpixels, struct.pack('<3I', 0, 0, 320*240)))
|
|
|
|
print("=== board dispatch trace ===")
|
|
for line in board.log: print(" " + line)
|
|
print(f"\n=== replies to host: {len(replies)} ===")
|
|
for msg, r in replies:
|
|
(lw, act) = struct.unpack_from('<II', r, 0)
|
|
print(f" {msg!r:22} -> 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()
|