diff --git a/emulator/firmware-decomp/emu860c/hud2d_overlay.py b/emulator/firmware-decomp/emu860c/hud2d_overlay.py new file mode 100644 index 00000000..5c96bbe6 --- /dev/null +++ b/emulator/firmware-decomp/emu860c/hud2d_overlay.py @@ -0,0 +1,146 @@ +"""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 up). + + 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('= 4: # flush + h = struct.unpack_from('= 8: + op = _EDGE_OP[action] + x, y = struct.unpack_from('= 4: # NewDisplayList + h = struct.unpack_from('= 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 UP) -> pixel space + (0..W, 0..H, y DOWN): px = (x-x0)/(x1-x0)*W, py = (1-(y-y0)/(y1-y0))*H. + 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 + return ((x - x0) / dx * W, (1.0 - (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