User-reported live: the torso-twist tape appeared at the TOP of the reticle (should be bottom) and the range ladder filled the wrong direction. I had assumed dpl2d used a standard math y-up convention (y0=bottom, y1=top) and inverted accordingly. Found the exact, already-solved answer in vrview_gl.py's own HUD vertex shader comment: "dpl2d Y grows DOWN (screen convention): period VHS footage shows the twist dial UNDER the reticle and the range bar filling top-to- bottom; mapping straight onto GL's Y-up NDC rendered the whole HUD flipped" -- the exact same bug, already diagnosed and fixed there. y0 (the smaller raw view-rect value) is the TOP of screen, y1 the bottom -- direct mapping, no inversion needed. Verified offline: the composited reticle now shows the twist tape (with its bowtie shape) at the bottom and the range ladder/pips reading top-to-bottom, matching BT411's documented layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.8 KiB
Python
155 lines
6.8 KiB
Python
"""hud2d_overlay.py -- the cockpit HUD (reticle + weapon pips), composited on
|
|
top of the i860+GPU 3D render.
|
|
|
|
This is a HOST-SIDE overlay, entirely independent of the i860 firmware: the
|
|
game builds it as a dpl2d_* 2D display list (wire actions 0x29 NewDisplayList /
|
|
0x2b FlushDisplayList) and binds it to the view node (view body offset 96).
|
|
vrview.py (the OTHER renderer, dpl3-revive/patha) already has a complete,
|
|
tested interpreter for this -- hud2d_prims()/hud2d_root() -- reused directly
|
|
here rather than re-derived. See BT411 context/gauges-hud.md ("Cockpit HUD
|
|
reticle... LIVE") for the full documented semantics (range ladder, weapon
|
|
pips colour-coded by range, lock ring, torso-twist tape, compass) -- this
|
|
module only needs to reproduce the WIRE-side geometry extraction + composite
|
|
it, not re-implement that logic.
|
|
|
|
Verified live (2026-07-20) against netdeath-20260708.fifodump: the resolved
|
|
primitives' colours match the documented reticle exactly (green crosshair/
|
|
ladder, yellow width-2 range bar, red/orange filled circles = weapon pips by
|
|
range class) -- see IG-SHADING-MODEL.md sec 6c.
|
|
"""
|
|
import struct, sys, os
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, r'C:\VWE\TeslaRel410\dpl3-revive\patha')
|
|
from vrview import hud2d_prims, hud2d_root, TYPE # noqa: E402
|
|
|
|
_EDGE_OP = {6: 'nest', 7: 'link', 8: 'prune', 11: 'list_add', 12: 'list_remove'}
|
|
|
|
|
|
class HudTracker:
|
|
"""Incremental wire-record feed -> the view's live dpl2d overlay
|
|
primitives, in view-rect space (x0..x1, y0..y1, y grows DOWN --
|
|
y0 is the top of screen, y1 the bottom; see composite_hud's to_px).
|
|
|
|
Only tracks what hud2d_root/hud2d_prims need: node type+body (create/
|
|
flush), the dcs->view list_add edge, and the dl2d display-list words
|
|
(0x29/0x2b). Deliberately NOT a full VirtualBoard -- the 3D scene is the
|
|
i860 firmware's job; this is purely the 2D overlay layer."""
|
|
|
|
def __init__(self):
|
|
self.nodes = {}
|
|
self.dl2d = {}
|
|
self.view = None
|
|
self._acc = None # in-progress 0x2b chunk accumulation
|
|
self._hud2d_bind = {} # hud2d_root's own cache, keyed by view
|
|
|
|
def feed(self, action, payload):
|
|
p = payload
|
|
if action == 1 and len(p) >= 8: # create
|
|
typ = struct.unpack_from('<I', p, 0)[0]
|
|
h = struct.unpack_from('<I', p, 4)[0]
|
|
self.nodes[h] = {'type': typ, 'body': b''}
|
|
elif action == 3 and len(p) >= 4: # flush
|
|
h = struct.unpack_from('<I', p, 0)[0]
|
|
n = self.nodes.setdefault(h, {'type': None, 'body': b''})
|
|
n['body'] = p[4:]
|
|
elif action in _EDGE_OP and len(p) >= 8:
|
|
op = _EDGE_OP[action]
|
|
x, y = struct.unpack_from('<II', p, 0)
|
|
if op == 'list_add':
|
|
tn = lambda h: TYPE.get(self.nodes.get(h, {}).get('type'))
|
|
if tn(y) == 'view' and tn(x) == 'dcs':
|
|
self.view = y
|
|
elif action == 0x29 and len(p) >= 4: # NewDisplayList
|
|
h = struct.unpack_from('<I', p, 0)[0]
|
|
self.dl2d[h] = []
|
|
elif action == 0x2b and len(p) >= 20: # FlushDisplayList
|
|
hdr, _nxt, tail, size = struct.unpack_from('<4I', p, 0)
|
|
size = min(size, (len(p) - 20) // 4)
|
|
dat = list(struct.unpack_from('<%dI' % size, p, 20))
|
|
if hdr:
|
|
self._acc = [hdr, max(tail, 1) - 1, dat]
|
|
elif self._acc is not None:
|
|
self._acc[1] -= 1
|
|
self._acc[2] += dat
|
|
if self._acc is not None and self._acc[1] <= 0:
|
|
self.dl2d[self._acc[0]] = self._acc[2]
|
|
self._acc = None
|
|
|
|
def view_rect(self):
|
|
"""(x0, y0, x1, y1, hither, yon) from the current view's body, or
|
|
None if no full-length view flush has been seen yet."""
|
|
if self.view is None:
|
|
return None
|
|
vb = self.nodes.get(self.view, {}).get('body') or b''
|
|
if len(vb) < 68: # need stored-body floats up to index 16
|
|
return None
|
|
f = struct.unpack_from('<24f', vb, 0)
|
|
# docstring's wire-payload word k == stored-body word k-1 (vrview.py's
|
|
# own note): x0,y0,x1,y1,zeye,xs,ys,hither,yon at payload words 6..14.
|
|
x0, y0, x1, y1 = f[5], f[6], f[7], f[8]
|
|
hither, yon = f[12], f[13]
|
|
return (x0, y0, x1, y1, hither, yon)
|
|
|
|
def primitives(self):
|
|
"""Current HUD primitives (view-rect space), or [] if not resolvable
|
|
yet (no view, or its display-list binding hasn't landed)."""
|
|
root = hud2d_root(self, self.view)
|
|
if not root:
|
|
return []
|
|
return hud2d_prims(self.dl2d, root)
|
|
|
|
|
|
def composite_hud(img, tracker, W, H):
|
|
"""Draw the tracker's current HUD primitives onto img (HxWx3 uint8,
|
|
modified in place). view-rect (x0..x1, y0..y1, y grows DOWN -- confirmed
|
|
live, see to_px) -> pixel space (0..W, 0..H, y down, same direction):
|
|
px = (x-x0)/(x1-x0)*W, py = (y-y0)/(y1-y0)*H, no inversion.
|
|
No-op (returns False) if the view rect or display list isn't resolved
|
|
yet -- callers should keep showing the last good frame, same as the 3D
|
|
render's own "skip if not ready" convention."""
|
|
rect = tracker.view_rect()
|
|
if rect is None:
|
|
return False
|
|
x0, y0, x1, y1, _hither, _yon = rect
|
|
prims = tracker.primitives()
|
|
if not prims:
|
|
return False
|
|
from PIL import Image, ImageDraw
|
|
pim = Image.fromarray(img, 'RGB')
|
|
draw = ImageDraw.Draw(pim)
|
|
dx = x1 - x0 or 1.0
|
|
dy = y1 - y0 or 1.0
|
|
|
|
def to_px(pt):
|
|
x, y = pt
|
|
# dpl2d Y grows DOWN (screen convention), confirmed both by the user
|
|
# (live: horizontal/twist tape appeared at top instead of bottom, the
|
|
# range ladder filled the wrong direction) and by vrview_gl.py's own
|
|
# comment/fix for the identical symptom ("mapping straight onto GL's
|
|
# Y-up NDC rendered the whole HUD flipped") -- y0 (the smaller raw
|
|
# value) is the TOP of screen, y1 the bottom. No inversion needed.
|
|
return ((x - x0) / dx * W, (y - y0) / dy * H)
|
|
|
|
for kind, col, width, pts in prims:
|
|
if len(pts) < 1:
|
|
continue
|
|
rgb = tuple(max(0, min(255, int(round(c * 255)))) for c in col)
|
|
w = max(1, int(round(width)))
|
|
xy = [to_px(p) for p in pts]
|
|
if kind == 'points':
|
|
r = 1.5
|
|
for (px, py) in xy:
|
|
draw.ellipse((px - r, py - r, px + r, py + r), fill=rgb)
|
|
elif kind == 'segs':
|
|
for i in range(0, len(xy) - 1, 2):
|
|
draw.line([xy[i], xy[i + 1]], fill=rgb, width=w)
|
|
elif kind == 'strip':
|
|
if len(xy) >= 2:
|
|
draw.line(xy, fill=rgb, width=w)
|
|
elif kind == 'poly':
|
|
if len(xy) >= 3:
|
|
draw.polygon(xy, fill=rgb)
|
|
img[:] = np.asarray(pim)
|
|
return True
|