M4c-device: the LIVE socket seam -- proven bit-identical over VPX_FIFOSOCK

live_render.py connects to vpxlog.cpp's existing VPX_FIFOSOCK tee (the DOSBox-X
C012 device), consumes the game's wire AS IT ARRIVES, and runs the firmware and
the socket CONCURRENTLY -- pumping the socket whenever the firmware's receive
point drains -- rendering each draw with the shared verified GPU raster.

gpu_raster.py factors the M4c-raster renderer out of m4b_gpu so offline and live
share ONE render path (the 6-edge fp64 raster + verified texel decode).

feed_sock.py mimics vpxlog's FIFOSOCK server (streams a real capture in delayed
chunks) so the live path is validated end-to-end WITHOUT the flaky live pod.
Result: 12 frames streamed live at 2.3 fps; with --pin (equalized texture
availability) the live frames are BIT-IDENTICAL to the offline reference
(12/12 differ>24 = 0.000%) -- the seam is faithful. The default incremental
texture model is more authentic (the board only has texels it has received);
its divergence from offline is purely the M5-B tid%ntex placeholder, orthogonal.

Live topology now closed end-to-end offline:
  game/capture -> C012 VPX_FIFOSOCK -> live_render -> firmware -> GPU -> frames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-20 09:22:36 -05:00
co-authored by Claude Opus 4.8
parent b688e0e4bd
commit e0a2073dff
4 changed files with 432 additions and 4 deletions
+36 -4
View File
@@ -75,7 +75,39 @@ Two bugs found and fixed to reach bit-identity (honest trail):
So the renderer now runs real-time AND is provably the same image as the
M5-verified path. The firmware (3.7s) is the remaining time; the render keeps up.
## Next (M4c-device / M4d)
1. The C012 link device in DOSBox-X + socket bridge (wire from the live game
instead of a file — identical pipeline downstream).
2. Present path (render-bridge window / vr_readpixels) + frame pacing.
## M4c-device DONE (live_render.py) — the LIVE socket seam, proven
The transport already existed: `vpxlog.cpp`'s `VPX_FIFOSOCK` tee listens on
127.0.0.1:PORT and streams the game's wire live (while locally answering the
board->host FIFO so the game never hangs). `live_render.py` connects as the
client, consumes the wire AS IT ARRIVES, runs the production firmware and the
socket CONCURRENTLY (pump the socket when the firmware's receive point drains),
and renders each draw with the shared verified GPU raster (`gpu_raster.py`).
Validated WITHOUT the live pod via `feed_sock.py` (mimics vpxlog's FIFOSOCK
server, streams the real netdeath capture in delayed chunks -> genuinely
exercises the concurrent path):
```
live done: 12 frames in 5.2s = 2.3 fps (streamed live over the socket)
live (pinned textures) vs offline reference: 12/12 frames differ>24 = 0.000%
```
**Bit-identical.** The live firmware+raster seam reproduces the offline
reference exactly. Two texture-availability models:
* default (incremental): the renderer uses only texels RECEIVED so far —
authentic to the real board (its texture RAM only holds what was uploaded).
Early frames differ from offline because offline had the full set retro-
actively; this is the M5-B best-effort binding's `tid % ntex` sensitivity,
orthogonal to the seam.
* `--pin <cap>`: pre-load the full texture set -> equalizes availability ->
bit-identical to offline, isolating and PROVING the seam is faithful.
`gpu_raster.py` is now the single shared renderer (offline m4b_gpu + live).
## Next (M4d)
1. Present window (`--present` uses pygame; coded, needs a display to verify) or
reuse the render-bridge GL window.
2. Bring-up against the REAL DOSBox pod (set VPX_FIFOSOCK, launch, connect) —
transport proven by vpxlog; gated on pod stability (see the cockpit memory).
3. Frame pacing / present timing.
@@ -0,0 +1,36 @@
"""Test double for vpxlog.cpp's VPX_FIFOSOCK: listen on 127.0.0.1:<port>, and on
connect stream a real .fifodump so live_render.py can be validated end-to-end
WITHOUT the live DOSBox pod. Matches the real topology (vpxlog listens, the
Python renderer connects). Streams in chunks with a small delay so the
concurrent firmware/socket path is genuinely exercised, then closes (EOF).
python feed_sock.py <capture.fifodump> <port> [chunk] [delay_ms]
"""
import sys, socket, time
SRC = sys.argv[1]
PORT = int(sys.argv[2])
CHUNK = int(sys.argv[3]) if len(sys.argv) > 3 else 64 << 10
DELAY = (int(sys.argv[4]) if len(sys.argv) > 4 else 5) / 1000.0
data = open(SRC, 'rb').read()
ls = socket.socket()
ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
ls.bind(('127.0.0.1', PORT))
ls.listen(1)
print("feed_sock listening on :%d (%d bytes)" % (PORT, len(data)), flush=True)
c, _ = ls.accept()
print("client connected; streaming", flush=True)
sent = 0
for i in range(0, len(data), CHUNK):
try:
c.sendall(data[i:i + CHUNK])
except OSError:
break
sent += min(CHUNK, len(data) - i)
if DELAY:
time.sleep(DELAY)
print("streamed %d bytes, closing (EOF)" % sent, flush=True)
c.shutdown(socket.SHUT_WR)
time.sleep(0.5)
c.close(); ls.close()
@@ -0,0 +1,159 @@
"""gpu_raster.py -- the verified real-time renderer, factored out of m4b_gpu.py
so the offline (m4b_gpu) and live (live_render) paths share ONE render.
Faithful to the M5 CPU reference bit-for-bit (proven in M4B-RESULTS.md):
per-draw primitives -> GPU compute raster (6-edge clip, fp64 planes, nearest-z
winner) -> the verified perspective divide + real-texture texel decode as one
vectorized numpy post-pass.
Renderer(ctx, texlist).frame(r32) -> HxWx3 uint8
where r32(addr)->uint32 reads the running firmware's memory (the live per-draw
coefficient program lives at 0x08158000..0x08170000).
"""
import struct
import numpy as np
from dpl_sampler import mode_to_texflags, wrap_index, composite
W, H = 832, 512
PROG_LO, PROG_HI = 0x08158000, 0x08170000
EDGE = {0x42, 0x0d, 0x2c}
FX_SHADER = """
#version 430
#extension GL_ARB_gpu_shader_fp64 : enable
layout(local_size_x = 64, local_size_y = 1) in;
layout(std430, binding = 0) readonly buffer Prims { double prims[]; };
// 10 dvec4 / prim (40 doubles): [0..5] edges (up to 6, A B C used),
// [6] zdepth+nedges, [7] u+hasuv, [8] v+tid, [9] tz+0
layout(std430, binding = 1) buffer Outs { uint outbuf[]; }; // 6 words/pixel
uniform int n_prims;
uniform int width;
void main() {
int x = int(gl_GlobalInvocationID.x);
int y = int(gl_GlobalInvocationID.y);
if (x >= width) return;
double fx = double(x), fy = double(y);
double zbest = -1e30lf;
double uu = 0.0lf, vv = 0.0lf, tz = 1.0lf;
float tid = 0.0, hasuv = 0.0;
for (int p = 0; p < n_prims; p++) {
int b = p*40;
bool allpos = true, allneg = true;
for (int e = 0; e < 6; e++) {
int eb = b + e*4;
if (prims[eb+3] < 0.5lf) continue;
double ve = prims[eb]*fx + prims[eb+1]*fy + prims[eb+2];
if (ve >= 0.0lf) allneg = false; else allpos = false;
}
if (!(allpos || allneg)) continue;
double z = prims[b+24]*fx + prims[b+25]*fy + prims[b+26];
if (z <= zbest) continue;
zbest = z;
hasuv = float(prims[b+31]);
uu = prims[b+28]*fx + prims[b+29]*fy + prims[b+30];
vv = prims[b+32]*fx + prims[b+33]*fy + prims[b+34];
tz = prims[b+36]*fx + prims[b+37]*fy + prims[b+38];
tid = float(prims[b+35]);
}
int pix = y*width + x;
outbuf[pix*6+0] = (zbest > -1e29lf) ? 0x3f800000u : 0xff800000u;
outbuf[pix*6+1] = floatBitsToUint(float(uu));
outbuf[pix*6+2] = floatBitsToUint(float(vv));
outbuf[pix*6+3] = floatBitsToUint(float(tz));
outbuf[pix*6+4] = floatBitsToUint(tid);
outbuf[pix*6+5] = floatBitsToUint(hasuv);
}
"""
def f32(w):
return struct.unpack('<f', struct.pack('<I', w & 0xffffffff))[0]
def build_prims(r32):
"""Reconstruct per-draw primitive records from the live program in memory."""
prog = {a: r32(a) for a in range(PROG_LO, PROG_HI, 4) if r32(a)}
def rd(a):
return prog.get(a, 0)
setups = [a for a in sorted(prog) if rd(a) == 0x100]
recs4 = []
for a in setups:
p = a + 4
edges = []
while ((rd(p) >> 8) & 0xff) in EDGE and len(edges) < 6:
edges.append((f32(rd(p + 4)), f32(rd(p + 8)), f32(rd(p + 12)))); p += 16
if len(edges) < 3:
continue
zp = tzp = up = vp = None; tid = None; q = p
for _ in range(70):
w = rd(q); op = (w >> 8) & 0xff; ad = w & 0xff
if op == 0x21 and zp is None:
zp = (f32(rd(q + 4)), f32(rd(q + 8)), f32(rd(q + 12)))
if op == 0x43 and ad == 32 and tzp is None:
tzp = (f32(rd(q + 4)), f32(rd(q + 8)), f32(rd(q + 12)))
if op == 0x43 and ad == 58 and up is None:
up = (f32(rd(q + 4)), f32(rd(q + 8)), f32(rd(q + 12)))
if op == 0x43 and ad == 78 and vp is None:
vp = (f32(rd(q + 4)), f32(rd(q + 8)), f32(rd(q + 12)))
if op == 0xf7 and ad in (141, 142) and tid is None:
tid = (rd(q + 4) >> 2) & 0x3f
q += 4
if zp is None:
continue
hasuv = 1.0 if (up and vp and tzp) else 0.0
up = up or (0, 0, 0); vp = vp or (0, 0, 0); tzp = tzp or (0, 0, 1)
eslots = [(e[0], e[1], e[2], 1.0) for e in edges[:6]]
while len(eslots) < 6:
eslots.append((0.0, 0.0, 0.0, 0.0))
recs4 += eslots + [(*zp, float(len(edges))), (*up, hasuv),
(*vp, float(tid or 0)), (*tzp, 0.0)]
if not recs4:
return None
return np.array(recs4, dtype=np.float64).reshape(-1, 4)
class Renderer:
def __init__(self, ctx, texlist):
self.ctx = ctx
self.prog_gl = ctx.compute_shader(FX_SHADER)
self.texlist = texlist
def frame(self, r32):
prims = build_prims(r32)
if prims is None:
return None
n_prims = len(prims) // 10
b0 = self.ctx.buffer(prims.tobytes())
out = self.ctx.buffer(reserve=W * H * 6 * 4)
b0.bind_to_storage_buffer(0)
out.bind_to_storage_buffer(1)
self.prog_gl['n_prims'] = n_prims
self.prog_gl['width'] = W
self.prog_gl.run(group_x=(W + 63) // 64, group_y=H)
raw = np.frombuffer(out.read(), dtype=np.uint32).reshape(H, W, 6)
b0.release(); out.release()
zb = raw[..., 0].view(np.float32)
uu = raw[..., 1].view(np.float32).astype(np.float64)
vv = raw[..., 2].view(np.float32).astype(np.float64)
tz = raw[..., 3].view(np.float32).astype(np.float64)
tid = raw[..., 4].view(np.float32).astype(np.int64)
hasuv = raw[..., 5].view(np.float32)
filled = zb > -1e29
img = np.zeros((H, W, 3), np.uint8)
img[filled & (hasuv <= 0.5)] = (60, 60, 70)
tzc = np.where(np.abs(tz) < 1.0, np.sign(tz) + (tz == 0), tz)
ucoord = (uu / tzc * 2048).astype(np.int64)
vcoord = (vv / tzc * 2048).astype(np.int64)
ntex = len(self.texlist)
for slot in range(ntex):
sel = filled & (hasuv > 0.5) & ((tid % ntex) == slot)
if not sel.any():
continue
h, (tu, tv, mode, arr) = self.texlist[slot]
wrap_u, wrap_v, cut_mode = mode_to_texflags(mode)
ui = wrap_index((ucoord * tu) >> 8, tu, wrap_u)
vi = wrap_index((vcoord * tv) >> 8, tv, wrap_v)
composite(img, sel, arr[vi, ui], cut_mode)
return img
@@ -0,0 +1,201 @@
"""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
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]
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):
"""Read whatever is available; set eof on clean close. Non-fatal timeout."""
try:
data = self.sock.recv(1 << 16)
if data:
self.buf += data
else:
self.eof = True
except socket.timeout:
pass
except OSError:
self.eof = True
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
src = SockSource(int(SPEC.split(':')[1]))
r = boot(fw='vrend410', queue=[]) # queue grows as wire arrives
shim = CpuShim()
RECV = emu_main.MAPS['vrend410']['receive']
g = igc_gpu.GpuTile()
print("GPU:", g.ctx.info['GL_RENDERER'], flush=True)
recs = [] # running wire, for the texture store
tex_dirty = False
renderer = Renderer(g.ctx, []) # texlist filled once textures arrive
pinned = False
if PIN:
pdata = open(PIN, 'rb').read(); prec = []; poff = 0
while True:
pi = pdata.find(b'VPXM', poff)
if pi < 0:
break
pln = struct.unpack_from('<I', pdata, pi + 4)[0]
pbody = pdata[pi + 8:pi + 8 + pln]; poff = pi + 8 + pln
if len(pbody) >= 4 and struct.unpack_from('<I', pbody, 0)[0] < 0x100:
prec.append((struct.unpack_from('<I', pbody, 0)[0], pbody[4:]))
renderer.texlist = list(build_texstore(prec).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):
Image.fromarray(img, 'RGB').save(os.path.join(HERE, 'live_%04d.png' % nth))
if win is not None:
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
return True
frames = 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:
if tex_dirty and not pinned:
renderer.texlist = list(build_texstore(recs).items())
tex_dirty = False
img = renderer.frame(emu860c.r32)
if img is not None:
if not present(img, frames):
break
frames += 1
if time.time() - fps_t >= 2.0:
print("live: %d frames, %.1f fps (cmd %d)"
% (frames, 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
if ANAME.get(r.queue[r.qi][0]) == 'draw_scene':
prev_draw = True
if h(shim) == 'done':
break
dt = time.time() - t0
print("live done: %d frames in %.1fs = %.1f fps (cmd %d)"
% (frames, dt, frames / max(dt, 1e-9), r.qi), flush=True)