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>
266 lines
11 KiB
Python
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)
|