M4b: offline end-to-end seam -- live mission -> faithful frame SEQUENCE

m4b_frames.py fuses the two proven halves: battle_frames.py's live per-draw
program capture (from running-firmware C memory) + render_final.py's verified
M5 texel render (perspective divide + real-texture decode). Result: the whole
renderer chain runs offline from a real fifodump -- production VREND.MNG on the
C i860 core -> per-draw coefficient program -> faithful frame -> PNG sequence.

Verified over netdeath-20260708.fifodump: 12 frames, scene assembles draw by
draw (26->48->78->87->127 quads), frame 11 = coherent perspective-correct arena
interior (tiled floor to vanishing point, ceiling, side structures). This is
the dress rehearsal for the live DOSBox seam -- identical pipeline, wire from a
file instead of the C012 device.

FINDING: firmware is real-time-capable (3.7s), the CPU numpy per-poly render is
the bottleneck (69.9s/12 frames, ~5.8s/frame). The fix already exists: the
conformant GPU tile path (igc_gpu). Moving the per-draw raster onto it is the
M4b->M4c bridge to real-time. Full writeup in M4B-RESULTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-20 09:01:50 -05:00
co-authored by Claude Opus 4.8
parent 0248a78840
commit 695aacec30
2 changed files with 223 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# M4b — offline end-to-end seam: RESULTS (2026-07-20)
`emu860c/m4b_frames.py` — the whole renderer chain, offline, no DOSBox:
```
fifodump (VPXM wire) -> production VREND.MNG on the C i860 core (351x)
-> per-draw coefficient program captured LIVE from C memory (0x08158000..)
-> M5 faithful render (verified perspective divide + real-texture texel decode)
-> frame_NNNN.png sequence + timing
```
## Run (netdeath-20260708.fifodump, 12 frames)
```
queued 53088 records
13 textures decoded from the wire
frame 0: 26 quads (cmd 4584)
frame 2: 48 quads (cmd 5155)
frame 4: 78 quads (cmd 6234)
frame 6: 87 quads (cmd 8046)
frame 10: 127 quads (cmd 10273)
frame 11: 127 quads (cmd 10478)
done: 12 faithful frames in 73.6s (3.7s firmware, 69.9s render), cmd 10479
```
**The chain works end-to-end.** Frames show the scene *assembling* draw by draw
(26 -> 48 -> 78 -> 87 -> 127 quads): frame 0 is ceiling + horizon before the
floor lands; frame 11 is a coherent, perspective-correct arena interior —
tiled floor receding to a vanishing point, paneled ceiling, side structures,
with green/blue detail where distinctive textures (emblems/labels) land.
## The one finding: render is the bottleneck, not the firmware
- **Firmware: 3.7 s** for 10,479 wire commands producing 12 frames — real-time-capable.
- **Render: 69.9 s** — the CPU numpy per-poly path (O(quads x 832 x 512) float ops
per draw). ~5.8 s/frame. This is the gap to close for the live seam.
The fix already exists: the **GPU tile path** (`igc_gpu` / `igc_gpu_frame`,
M1/M2-conformant) does the raster on the RTX in the compute shader. Moving the
per-draw raster onto it — reading the full pixel fields (texz/texu/texv/texid)
from the tile output and running the same verified texel decode as a vectorized
post-pass — is the M4b->M4c bridge to real-time.
## Honest scope
- Verified: geometry, winding, perspective divide, texel decode, live per-draw
program capture, full-mission drive, frame sequencing.
- Best-effort (documented M5-B limit): exact per-surface texid->texture handle.
Palette shifts with the mapping (grayscale here vs teal on the static pkl);
both are real decoded textures, best-effort placement.
## Next (M4c)
1. GPU raster in the per-draw loop (real-time render to match the real-time firmware).
2. The C012 link device in DOSBox-X + socket bridge (wire from the live game
instead of a file — identical pipeline downstream).
3. Present path (render-bridge window / vr_readpixels).
@@ -0,0 +1,167 @@
"""M4b -- the offline end-to-end seam: real game wire in, FAITHFUL frame
sequence out, no DOSBox.
The whole chain at speed:
fifodump (VPXM wire) -> production VREND.MNG on the C i860 core (351x)
-> per-draw coefficient program captured LIVE from C memory
-> M5 faithful render (verified perspective divide + real-texture texel decode)
-> frame_NNNN.png sequence + timing report
This is the dress-rehearsal for the live DOSBox seam (M4c): identical pipeline,
the wire arriving from a file instead of the C012 link device. Fidelity is the
already-verified M5 path (b74b0c89 / a24210ad); what M4b proves is that the
FULL mission drives it, per draw, at speed.
python m4b_frames.py <capture.fifodump> [max_frames]
"""
import sys, os, time, struct
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
import numpy as np
from PIL import Image
from driver import boot, CpuShim
from vrboard import A
from texstore import build_texstore
from dpl_sampler import mode_to_texflags, wrap_index, composite
ANAME = {int(a): a.name for a in A}
emu860.Mem.log = lambda self, *a, **k: None
SRC = sys.argv[1]
MAXF = int(sys.argv[2]) if len(sys.argv) > 2 else 12
PROG_LO, PROG_HI = 0x08158000, 0x08170000 # per-draw effect-program window
W, H = 832, 512
EDGE = {0x42, 0x0d, 0x2c}
# --- parse the wire once: build the queue AND the texture store from it -------
data = open(SRC, 'rb').read()
recs = []; 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:
recs.append((a, body[4:]))
print("queued %d records" % len(recs), flush=True)
store = build_texstore(recs)
texlist = list(store.items()) # [(handle,(u,v,mode,arr)), ...]
print("%d textures decoded from the wire" % len(texlist), flush=True)
r = boot(fw='vrend410', queue=recs)
shim = CpuShim()
RECV = emu_main.MAPS['vrend410']['receive']
# grid, reused every frame
yy, xx = np.mgrid[0:H, 0:W].astype(np.float64)
def f32(w):
return struct.unpack('<f', struct.pack('<I', w & 0xffffffff))[0]
def render_faithful(nth):
"""The verified M5 render, driven by the LIVE per-draw program in C memory."""
prog = {a: emu860c.r32(a) for a in range(PROG_LO, PROG_HI, 4) if emu860c.r32(a)}
def rd(a):
return prog.get(a, 0)
setups = [a for a in sorted(prog) if rd(a) == 0x100]
if not setups:
return False, 0
zbuf = np.full((H, W), -1e30)
img = np.zeros((H, W, 3), np.uint8)
drawn = 0
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
ep = np.ones((H, W), bool); en = np.ones((H, W), bool)
for Ae, Be, Ce in edges:
v = Ae * xx + Be * yy + Ce
ep &= v >= 0; en &= v < 0
inside = ep | en # inside = all-positive OR all-negative
if not inside.any():
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
z = zp[0] * xx + zp[1] * yy + zp[2]
inside &= z > zbuf
if not inside.any():
continue
if up and vp and tzp and texlist:
h, (tu, tv, mode, arr) = texlist[(tid or 0) % len(texlist)]
wrap_u, wrap_v, cut_mode = mode_to_texflags(mode)
tzv = tzp[0] * xx + tzp[1] * yy + tzp[2]
tzv = np.where(np.abs(tzv) < 1.0, np.sign(tzv) + (tzv == 0), tzv)
# VERIFIED divide: texel8 = (texu/texz)*2048 (3 int + 8 texel bits)
ucoord = ((up[0] * xx + up[1] * yy + up[2]) / tzv * 2048).astype(np.int64)
vcoord = ((vp[0] * xx + vp[1] * yy + vp[2]) / tzv * 2048).astype(np.int64)
ui = wrap_index((ucoord * tu) >> 8, tu, wrap_u)
vi = wrap_index((vcoord * tv) >> 8, tv, wrap_v)
samp = arr[vi, ui]
drawmask = composite(img, inside, samp, cut_mode)
else:
img[inside] = (60, 60, 70)
drawmask = inside
zbuf[drawmask] = z[drawmask]
drawn += 1
if not drawn:
return False, 0
Image.fromarray(img, 'RGB').save(os.path.join(HERE, 'frame_%04d.png' % nth))
return True, drawn
# --- run the whole mission, one faithful frame per draw_scene ----------------
frames = 0
prev_draw = False
t0 = time.time()
render_t = 0.0
while frames < MAXF and r.qi < len(r.queue):
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:
rt0 = time.time()
ok, drawn = render_faithful(frames)
render_t += time.time() - rt0
if ok:
print("frame %d: %d quads (cmd %d)" % (frames, drawn, r.qi), flush=True)
frames += 1
prev_draw = False
if r.qi < len(r.queue) and ANAME.get(r.queue[r.qi][0]) == 'draw_scene':
prev_draw = True
if h(shim) == 'done':
break
wall = time.time() - t0
print("done: %d faithful frames in %.1fs (%.1fs firmware, %.1fs render), cmd %d"
% (frames, wall, wall - render_t, render_t, r.qi), flush=True)