Weapon visuals + aim: beams render from the guns; pick self-target fixes

Renderer (dpl3-revive/patha):
- Instance visibility honored at DRAW time (stored word 4 = live
  dpl_SetInstanceVisibility field): laser beams (4-frame pulses) and
  missile models now render; cache tags w3 0/1 instances 'gated'
- PSFX world bursts (dcs=0 muzzle/impact/explosion) rendered; psfx
  storage list-based so one-shot bursts stop collapsing onto one key
- fix_degenerate applied to INSTANCE chains (was camera-only): the
  beam chain rides four rank-2 rig DCSs; composed rank-2 drew beams
  away from the guns. Offline f1635 now shows both arm beams
  converging on the aim point
- First-person canopy cull: the always-armed cockpit model at the
  head drew as a vertical line down screen center (user-confirmed
  fixed live)

Device (vpx-device/vpxlog.cpp):
- CAM backchannel on the fifosock: bridge streams its validated
  camera (+ vehicle root DCS); raycast_pick aims along the player's
  real look instead of the static view-node pose, which had locked
  every missile/beam onto one fixed wrong world point
- raycast_pick skips hidden-until-armed instances and the player's
  own articulation subtree: picking own beams created a target
  feedback loop, picking own missiles retargeted salvos mid-flight,
  and a picked missile's deleted DCS froze the game (arena2 crash)

Sound (vpx-device/vweawe.cpp): stereo-linked peak limiter replaces
the hard output clamp (the "generator out" crackle); VWE_AWE_LOG
reports pre-limit peaks + duck counts, enabled in launch_pod.ps1.
Missile-fire crackle persists: per-voice EMU8000 clamp suspected.

Eggs/confs: TESTAR2.EGG + gauge_arena2_sound.conf (map=arena2 city
variant); cavern.egg (console-era reference); plasma display on COM2
in the gauge confs; gauge_arena_net_sound.conf (slirp net stack).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-08 00:09:03 -05:00
co-authored by Claude Fable 5
parent 2bb2ff7302
commit cbf7015620
16 changed files with 830 additions and 167 deletions
+40 -5
View File
@@ -106,6 +106,9 @@ class VirtualBoard:
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)
@@ -147,11 +150,43 @@ class VirtualBoard:
return self.do_add_connections(msg.payload)
if msg.action == 0x1a:
return self.do_texels(msg.payload)
if msg.action == 0x1d and len(msg.payload) >= 56:
# per-frame transform (shipped flush_artics):
# [dcs_type=1 matrix][dcs handle][3x3 row-major][tx ty tz]
h = struct.unpack_from('<I', msg.payload, 4)[0]
self.anim[h] = struct.unpack_from('<12f', msg.payload, 8)
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
+47 -5
View File
@@ -32,6 +32,18 @@ TYPE = {2:'zone',3:'view',4:'instance',5:'dcs',6:'lmodel',7:'object',8:'lod',
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('<I', b, 16)[0] != 0
# NOTE: vrboard.do_flush stores the flush payload MINUS the leading remote word,
# so stored-body word k = wire-payload word k+1. Offsets below are stored-body.
def _mat_from_dcs(body):
@@ -386,8 +398,14 @@ class SceneCache:
# translocation, SHARKS' single w3=0 is the null-object 'fishes'.
w3 = struct.unpack_from('<I', body, 12)[0]
bboard = w3 == 2
if w3 not in (2, 3):
continue
# w3 0/1 = HIDDEN-until-armed: stored word 4 is the live
# visibility field (dpl_SetInstanceVisibility) -- BT pulses it
# ~4 frames per laser shot (beam quad) and missile models fly
# with it set. A flush bumps no node/edge counts so the cache
# never rebuilds on the pulse: keep these instances in the list
# tagged 'gated' and test the CURRENT body word at draw time.
# w3 2/3 ignore the field (BT arena buildings sit at 0 yet draw).
gated = w3 not in (2, 3)
if tname(obj) != 'object':
continue
# SCHILD/DCHILD effect attachments (Trek shield bubbles) carry a
@@ -436,12 +454,24 @@ class SceneCache:
# 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})
'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)
@@ -688,7 +718,9 @@ class Renderer:
d = d / dn
best_t, best_h = np.inf, 0
for inst in self.cache.instances:
M = self.chain_matrix(board, inst['chain'])
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)
@@ -789,9 +821,19 @@ class Renderer:
FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]]
for inst in c.instances:
Mw = self.chain_matrix(board, inst['chain'])
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 (inst.get('gated') and not inst.get('hud')
and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0):
continue
if inst['chain'] and inst['chain'][0] in board.anim:
M = FIX @ M
if inst['billboard']:
+89 -1
View File
@@ -463,8 +463,16 @@ class GLRenderer(vrview.Renderer):
FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]]
for inst in c.instances:
Mw = self.chain_matrix(board, inst['chain'])
if not vrview.inst_visible(board.nodes, inst):
continue
# fix_degenerate: see vrview draw loop (rank-2 rig DCSs skewed
# the laser beam off the gun)
Mw = self.chain_matrix(board, inst['chain'], fix_degenerate=True)
M = Mw if inst.get('hud') else Mw @ V
# first-person canopy cull (see SceneCache radius note in vrview)
if (inst.get('gated') and not inst.get('hud')
and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0):
continue
if inst['chain'] and inst['chain'][0] in board.anim:
M = FIX @ M
if inst['billboard']:
@@ -488,6 +496,7 @@ class GLRenderer(vrview.Renderer):
self._draw_geom(board, c, gh, mesh, M, Mw)
self._draw_particles(board, V, vp, self.skip / 60.0)
self._draw_psfx(board, V)
self._draw_hud2d_gl(board, vp)
# present: FBO -> window with the DAC gamma
@@ -686,3 +695,82 @@ class GLRenderer(vrview.Renderer):
self._points_vao(data).render(mgl.POINTS, vertices=len(data))
self._fbo.depth_mask = True
ctx.disable(mgl.BLEND)
def _draw_psfx(self, board, V):
"""Triggered PSFX (0x1d) as glowing additive discs, two kinds:
- DCS-attached bolts (weapon fire): the emitter rides the flying
projectile DCS, so the glow tracks the shot. We keep a short trail of
recent world positions and draw a fading tail, so the continuous
particle stream (rate ~300/s) reads as a streak, not a lone dot. The
bolt lives until the projectile DCS is deleted (impact).
- DCS=0 world bursts (muzzle flash, impact spark, explosion): a fixed
world-space burst at (px,py,pz); big-radius explosions expand and the
colour lerps from the start interior toward the end interior over the
burst's ttl, then it fades out."""
if not board.psfx:
return
cache = {}
rows, live = [], []
for e in board.psfx:
dcs = e['dcs']
if dcs:
nd = board.nodes.get(dcs)
if not nd or nd.get('type') != 5:
continue # projectile consumed (impact)
m = cache.get(dcs)
if m is None:
try:
m = self.chain_matrix(board, self._chain(board, dcs, links=True))
except Exception:
m = self.dcs_matrix(board, dcs)
cache[dcs] = m
pos = np.asarray(m[3, :3], float)
else:
e['ttl'] -= 1
if e['ttl'] <= 0:
continue
pos = np.asarray(e['pos'], float)
if not np.all(np.isfinite(pos)):
continue
e['age'] += 1
live.append(e)
frac = e['age'] / max(e['maxttl'], 1) # 0..1 over life
col = np.clip((1.0 - frac) * np.array(e['col'])
+ frac * np.array(e['ecol']), 0.0, 2.0)
a0 = min(e['alpha'], 1.0)
if dcs: # bolt + trail
tr = e.setdefault('trail', [])
tr.append(pos)
if len(tr) > 9:
del tr[0]
nt = len(tr)
for k, tp in enumerate(tr):
w = (k + 1) / nt # head brightest
rows.append([tp[0], tp[1], tp[2],
max(e['rad'] * (0.35 + 0.65 * w), 1.2),
col[0], col[1], col[2], a0 * w * w])
else: # world burst
grow = 1.0 + min(e['exp'], 12.0) * 0.09 * frac # explosions swell
fade = min(1.0, e['ttl'] / 10.0)
rows.append([pos[0], pos[1], pos[2], max(e['rad'] * grow, 1.5),
col[0], col[1], col[2], a0 * fade])
board.psfx = live
if not rows:
return
data = np.array(rows, '<f4')
# back-to-front for the order-dependent additive-over blend
pe_z = data[:, 0:3] @ V[:3, :3] + V[3, :3]
data = data[np.argsort(pe_z[:, 2])]
ctx, mgl = self.ctx, self.moderngl
prog = self._point_prog
self._mat(prog, 'u_mv', V)
self._set(prog, 'u_minpr', 0.6)
self._set(prog, 'u_maxpr', float(self.h * 0.22))
self._set(prog, 'u_particle', 1)
ctx.enable(mgl.BLEND)
ctx.blend_func = (mgl.ONE, mgl.ONE_MINUS_SRC_ALPHA)
self._fbo.depth_mask = False
self._points_vao(data).render(mgl.POINTS, vertices=len(data))
self._fbo.depth_mask = True
ctx.disable(mgl.BLEND)