Files
TeslaRel410/emulator/firmware-decomp/emu860c/live_render.py
T
CydandClaude Opus 4.8 3e766c13c1 Wire the HUD reticle into live_render.py
Integrates hud2d_overlay.HudTracker into the live pipeline: fed exactly one
wire record per firmware consumption (synchronized with r.qi, not just queue
arrival, so hud2d_root's view/display-list state matches what the firmware
has actually processed at render time), composited onto each rendered frame
before present.

Verified via the same integration pattern (truncated direct-queue boot, no
socket overhead) against netdeath-20260708.fifodump: the reticle composites
starting at wire command 22906 (frame 22) -- identical resolution point and
visual result to the standalone hud2d_overlay test. A full-mission run
through the live socket path is genuinely slow around this capture's "battle"
content (multi-billion firmware steps per command in that section, pre-
existing and unrelated to this change) but not stalled -- confirmed by
reaching the same cmd/frame numbers via the direct path in 14.7s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:48:14 -05:00

266 lines
11 KiB
Python

"""M4c-device: the LIVE seam. Connects to the DOSBox-X C012 device's VPX_FIFOSOCK
tee (vpxlog.cpp), consumes the game's wire AS IT ARRIVES, runs the production
firmware live, and renders faithful frames in real time via the verified GPU
raster (gpu_raster.Renderer -- bit-identical to the M5 CPU reference).
Topology (matches the real hardware path):
DOSBox-X (game) -> vpxlog.cpp C012 device -> VPX_FIFOSOCK (listens :PORT)
<- live_render.py (connects)
-> emu860c firmware -> GPU -> frames
The firmware and the socket run CONCURRENTLY: when the firmware reaches its
receive point with the queue drained, we pump the socket for more wire; a draw
boundary renders + presents. Frames -> live_NNNN.png (and a window if --present).
python live_render.py sock:<port> [max_frames] [--present]
Validate without the live pod: feed a real capture through a local socket with
feed_sock.py (mimics vpxlog's FIFOSOCK server) -- the frames come out identical
to the offline frame_*.png.
"""
import sys, os, time, struct, socket
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE); sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, r'C:\VWE\TeslaRel410\dpl3-revive\patha')
import emu860, emu_main, emu860c, igc_gpu
import numpy as np
from PIL import Image
from driver import boot, CpuShim
from vrboard import A
from texstore import build_texstore
from gpu_raster import Renderer, W, H
from hud2d_overlay import HudTracker, composite_hud
ANAME = {int(a): a.name for a in A}
emu860.Mem.log = lambda self, *a, **k: None
SPEC = sys.argv[1]
MAXF = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else 0 # 0 = unbounded
PRESENT = '--present' in sys.argv
# --pin <fifodump>: pre-load the FULL texture set (disables incremental rebuild).
# Isolates the live firmware+raster seam from texture-arrival timing so the live
# frames can be checked bit-for-bit against the offline reference.
PIN = None
if '--pin' in sys.argv:
PIN = sys.argv[sys.argv.index('--pin') + 1]
# --catchup <fifodump>: replay the ARCHIVAL dump (vpxlog's VPX_FIFODUMP -- a
# second, independent sink of the same wire, written since pod boot) before
# going live. Required when connecting mid-mission: the socket tee only
# forwards NEW traffic to a fresh client, so without this our firmware boots
# with an EMPTY scene graph -- draw_scene only says "render what's already
# loaded," it carries no geometry itself, so a from-scratch board shows black.
CATCHUP = None
if '--catchup' in sys.argv:
CATCHUP = sys.argv[sys.argv.index('--catchup') + 1]
def parse_fifodump(path):
data = open(path, 'rb').read()
out = []; off = 0
while True:
i = data.find(b'VPXM', off)
if i < 0:
break
ln = struct.unpack_from('<I', data, i + 4)[0]
body = data[i + 8:i + 8 + ln]; off = i + 8 + ln
if len(body) >= 4:
a = struct.unpack_from('<I', body, 0)[0]
if a < 0x100:
out.append((a, body[4:]))
return out
class SockSource:
"""Incremental VPXM record source over a client socket to VPX_FIFOSOCK."""
def __init__(self, port):
self.buf = b''; self.off = 0; self.eof = False
self.sock = socket.socket()
for _ in range(120):
try:
self.sock.connect(('127.0.0.1', port)); break
except OSError:
time.sleep(0.25)
else:
raise SystemExit("could not connect to VPX_FIFOSOCK :%d" % port)
self.sock.settimeout(0.2)
print("connected to VPX_FIFOSOCK :%d" % port, flush=True)
def pump(self):
"""Drain everything CURRENTLY available (loop while a full-size read
keeps coming back), not just one small recv() -- so has_pending()
reflects the true backlog instead of an arbitrary slice of it."""
while True:
try:
data = self.sock.recv(1 << 16)
except socket.timeout:
return
except OSError:
self.eof = True
return
if not data:
self.eof = True
return
self.buf += data
if len(data) < (1 << 16):
return # short read: likely caught up for now
def next_record(self):
i = self.buf.find(b'VPXM', self.off)
if i < 0 or i + 8 > len(self.buf):
return None
ln = struct.unpack_from('<I', self.buf, i + 4)[0]
if i + 8 + ln > len(self.buf):
return None
body = self.buf[i + 8:i + 8 + ln]
self.off = i + 8 + ln
if len(body) >= 4:
action = struct.unpack_from('<I', body, 0)[0]
if action < 0x100:
return (action, body[4:])
return self.next_record() # skip non-message burst
def has_pending(self):
"""True if another complete record is ALREADY buffered locally (no
socket read needed) -- i.e. we're backlogged relative to what has
already arrived. Used to skip rendering stale intermediate frames."""
i = self.buf.find(b'VPXM', self.off)
if i < 0 or i + 8 > len(self.buf):
return False
ln = struct.unpack_from('<I', self.buf, i + 4)[0]
return i + 8 + ln <= len(self.buf)
src = SockSource(int(SPEC.split(':')[1]))
# Read the catch-up file AFTER connecting the socket (not before): the socket
# then buffers everything from connect-time forward while we parse, so any
# small race window biases toward a harmless duplicate rather than a missed
# command (a stray duplicate create/list_add is far less damaging than a
# missing one -- the latter is exactly what produced the black screen).
catchup_recs = parse_fifodump(CATCHUP) if CATCHUP else []
if CATCHUP:
print("catch-up: replaying %d records from %s" %
(len(catchup_recs), os.path.basename(CATCHUP)), flush=True)
N_CATCHUP = len(catchup_recs)
r = boot(fw='vrend410', queue=list(catchup_recs))
shim = CpuShim()
RECV = emu_main.MAPS['vrend410']['receive']
g = igc_gpu.GpuTile()
print("GPU:", g.ctx.info['GL_RENDERER'], flush=True)
recs = list(catchup_recs) # running wire, for the texture store
tex_dirty = bool(catchup_recs)
renderer = Renderer(g.ctx, []) # texlist filled once textures arrive
hud = HudTracker() # cockpit reticle/pips (dpl2d overlay)
pinned = False
if PIN:
renderer.texlist = list(build_texstore(parse_fifodump(PIN)).items())
pinned = True
print("PINNED %d textures from %s" % (len(renderer.texlist), os.path.basename(PIN)), flush=True)
win = None
if PRESENT:
try:
import pygame
pygame.init()
win = pygame.display.set_mode((W, H))
pygame.display.set_caption("VelociRender (reconstructed) -- live")
except Exception as e:
print("no window (%s); writing PNGs only" % e, flush=True)
def feed_more(block):
"""Pull at least one new record into r.queue (and recs). Returns False at EOF."""
global tex_dirty
deadline = time.time() + 10.0
while True:
rec = src.next_record()
if rec is not None:
r.queue.append(rec); recs.append(rec)
if rec[0] == 0x1a:
tex_dirty = True
return True
if src.eof:
return False
src.pump()
if not block and time.time() > deadline:
return False
def present(img, nth):
if win is not None:
# live window: skip the per-frame PNG write (a real disk-I/O cost
# that was adding latency for no benefit once a window is showing)
import pygame
surf = pygame.surfarray.make_surface(np.transpose(img, (1, 0, 2)))
win.blit(surf, (0, 0)); pygame.display.flip()
for ev in pygame.event.get():
if ev.type == pygame.QUIT:
return False
else:
Image.fromarray(img, 'RGB').save(os.path.join(HERE, 'live_%04d.png' % nth))
return True
frames = 0
skipped = 0
prev_draw = False
t0 = time.time()
fps_t = t0
stopped = False
while not stopped:
reason, _ = emu860c.run(500_000_000)
if reason == 3:
continue
if reason != 0:
print("core reason %d" % reason, flush=True); break
pc = emu860c.getstate()['pc']
h = r.hooks.get(pc)
if h is None:
print("sentinel", flush=True); break
if pc == RECV:
if prev_draw:
# Skip stale intermediate draws when a backlog exists: the game
# runs at a fixed real tick rate (measured ~28 draw_scene/s on
# this rig's FAST clock) that our render can fall behind, so
# rendering EVERY draw_scene means perpetually catching up on
# old content. Only render+present when we're at the live edge --
# this bounds perceived lag to render-time, not backlog depth.
# Also skip unconditionally while still draining the catch-up
# history (r.qi <= N_CATCHUP) -- no point rendering thousands of
# historical frames just to reach the live edge.
if r.qi <= N_CATCHUP or src.has_pending():
skipped += 1
else:
if tex_dirty and not pinned:
renderer.texlist = list(build_texstore(recs).items())
tex_dirty = False
img = renderer.frame(emu860c.dump_range)
if img is not None:
composite_hud(img, hud, W, H) # no-op until the reticle resolves
if not present(img, frames):
break
frames += 1
if time.time() - fps_t >= 2.0:
print("live: %d frames (%d skipped), %.1f fps (cmd %d)"
% (frames, skipped, frames / (time.time() - t0), r.qi),
flush=True)
fps_t = time.time()
if MAXF and frames >= MAXF:
break
prev_draw = False
# make sure the next record is present before the firmware consumes it
while r.qi >= len(r.queue):
if not feed_more(block=True):
stopped = True
break
if stopped:
break
act, pl = r.queue[r.qi]
hud.feed(act, pl) # every record, in wire order, exactly
if ANAME.get(act) == 'draw_scene': # once -- matches consumption, not arrival
prev_draw = True
if h(shim) == 'done':
break
dt = time.time() - t0
print("live done: %d frames (%d skipped) in %.1fs = %.1f fps (cmd %d)"
% (frames, skipped, dt, frames / max(dt, 1e-9), r.qi), flush=True)