Phase 3b: live OpenGL render backend in DOSBox-X (VPX_RENDER=1)

The emulated VPX board now draws frames in real time. The FIFO burst
assembler feeds an in-process DPL scene store; each vr_draw_scene publishes a
frame snapshot to a dedicated WGL window thread that renders it fixed-function.

- vpxlog.cpp: scene_burst() decode + VScene store + rt_main GL window thread;
  glFrustum from the view-node window rect, glScalef(-1,1,1) for Division's
  mirrored screen-x, action-31 camera as modelview. No build change (opengl32
  already linked).
- Validated: flyk divrgb.scn opens the "VPX VelociRender (emulated)" window
  and draws the SMPTE bars live via the real camera.spl spline camera
  (divrgb-live-gl.png), matching the offline render_capture.py decode.
- Game path (alpha1.conf) opens the window + draws background but hits the
  pre-existing production btdpl.ini vr_sync timeout (sends_wo_rcv) -- not a 3b
  issue. Remaining work tracked in PHASE3-PROGRESS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-03 14:27:58 -05:00
co-authored by Claude Opus 4.8
parent 4b6d910f7b
commit dce89a8571
4 changed files with 386 additions and 20 deletions
+1
View File
@@ -5,6 +5,7 @@ image/
# Phase 3 evidence renders (keep: decoded from the captured VPX stream)
!divrgb-decoded.png
!divrgb-frame0.png
!divrgb-live-gl.png
dbx_out.txt
vpx*.txt
sweep_*.txt
+51 -12
View File
@@ -1,9 +1,10 @@
# Phase 3 — Render Backend: Progress
**Status (2026-07-03): Phase 3a complete — the render command stream is fully
captured and decoded, and a captured DIVRGB frame has been reconstructed to
pixels. First image ever produced from the Rel 4.10 VPX protocol without a
real board.**
**Status (2026-07-03): Phase 3a + 3b complete. 3a — the render command stream
is fully captured and decoded, and a captured DIVRGB frame reconstructed to
pixels offline. 3b — a live OpenGL window built into the emulated board draws
each frame in real time inside DOSBox-X. First images ever produced from the
Rel 4.10 VPX protocol without a real board.**
![decoded DIVRGB calibration screen](divrgb-decoded.png)
@@ -71,12 +72,50 @@ PLUGE row — which validates vertices, connectivity, materials, camera and
window mapping in one image. (Division screen x runs opposite to a GL-style
eye space; without negating x the pattern comes out mirrored.)
## Next: 3b — live OpenGL backend
## 3b. Live OpenGL backend (DONE)
Feed the same decode path a live stream inside DOSBox-X instead of a dump:
maintain the node store in `vpxlog.cpp` (or a sibling `vpxrender.cpp`), upload
geometry on set_geom_verts/conns, draw on draw_scene into an OpenGL window
(or the DOSBox surface). The `divrgb.fifodump` fixture allows developing the
backend offline; `flyk yip.scn` (CYCLE, Red Planet geometry) is the next
fixture up once the DOS/4GW sync variant is handled, then the game itself
(`buttee.bgf` etc. via the production content path).
![live GL window rendering DIVRGB](divrgb-live-gl.png)
`VPX_RENDER=1` turns on a live render backend built into `vpxlog.cpp`
(Windows/WGL). The same burst assembler that feeds `VPX_FIFODUMP` now also
feeds an in-process scene store (`scene_burst()``struct VScene`), and each
`vr_draw_scene` publishes a frame snapshot to a dedicated OpenGL window thread
(`rt_main`) that draws it with fixed-function GL. No build-system change was
needed — `opengl32` was already linked, and `vpxlog.cpp` is already in
`hardware/`.
Validated: `flyk divrgb.scn` (`divrgb.conf` with `VPX_RENDER=1`) opens the
"VPX VelociRender (emulated)" window and draws the SMPTE bars live, framed by
the real `camera.spl` spline camera the app sends — matching the offline
`render_capture.py` decode. The window runs on its own thread, so it keeps
displaying the last frame after the DOS app exits.
Design notes:
- Multi-burst payloads (`set_geom_verts`/`conns` continuations) are tracked
with `geom_active` / `conn_active` state, same as the offline decoder.
- Projection: `glFrustum` from the view-node window rect scaled by
`near/window_distance`; a `glScalef(-1,1,1)` handles Division's mirrored
screen-x. Camera is the row-major 3×3 from action 31 loaded as the
modelview rotation, then `glTranslatef(-eye)`.
- Frame handoff is a critical-section-guarded `VFrame` + auto-reset event;
the emulator thread never blocks on GL.
Game path (`alpha1.conf`, full BattleTech v4.10): the window opens and draws
the view background, but the run hits the **pre-existing production-path sync
timeout** (`velocirender_receive timed out — sends_wo_rcv=3`, then
`failed in vr_sync`) — the `btdpl.ini`/netnub launch bursts several sends
before its first receive, which the POLL_THRESHOLD gating stalls. This is not
a 3b issue (the `flyk` clean-launch path renders fine); it is the same
production-sync item still open from Phase 2.
## Remaining
1. **Production-path vr_sync timeout** (`sends_wo_rcv`): the game's
`btdpl.ini`/netnub launch path. Needed before the game itself renders.
2. **Content**: `test.egg` carries no models, so even past sync the game has
no `buttee.bgf`/`mslr.bgf` geometry to draw — needs a real mission/content
tree from the pod image.
3. **DOS/4GW sync variant** for the CYCLE `flyk yip.scn` fixture (Red Planet
geometry) — the older action-check sync.
4. Texturing (`set_texmap_texels`, action 23-data + SVT), lighting, and
depth/material niceties; current backend is flat-shaded untextured polys.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+334 -8
View File
@@ -141,9 +141,13 @@ static void fifo_arm_action(void) { fifo_arm = true; fifo_cap_pos = 0; fifo_cap
static FILE *fifo_dump_fp = NULL;
static unsigned char *fifo_buf = NULL;
static size_t fifo_buf_len = 0, fifo_buf_cap = 0;
static bool vpx_render = false; /* Phase 3b live GL backend */
static void scene_burst(const unsigned char *p, size_t n); /* fwd (3b) */
static void scene_reset(void); /* fwd (3b) */
static void fifo_buf_push(unsigned char v) {
if (fifo_dump_fp == NULL) return;
if (fifo_dump_fp == NULL && !vpx_render) return;
if (fifo_buf_len >= (1u << 20)) return; /* runaway guard */
if (fifo_buf_len == fifo_buf_cap) {
size_t ncap = fifo_buf_cap ? fifo_buf_cap * 2 : 4096;
unsigned char *nb = (unsigned char *)realloc(fifo_buf, ncap);
@@ -153,13 +157,16 @@ static void fifo_buf_push(unsigned char v) {
fifo_buf[fifo_buf_len++] = v;
}
static void fifo_flush_record(void) {
if (fifo_dump_fp == NULL || fifo_buf_len == 0) return;
unsigned char hdr[8] = { 'V','P','X','M',
(unsigned char)(fifo_buf_len), (unsigned char)(fifo_buf_len >> 8),
(unsigned char)(fifo_buf_len >> 16), (unsigned char)(fifo_buf_len >> 24) };
fwrite(hdr, 1, sizeof hdr, fifo_dump_fp);
fwrite(fifo_buf, 1, fifo_buf_len, fifo_dump_fp);
fflush(fifo_dump_fp);
if (fifo_buf_len == 0) return;
if (fifo_dump_fp) {
unsigned char hdr[8] = { 'V','P','X','M',
(unsigned char)(fifo_buf_len), (unsigned char)(fifo_buf_len >> 8),
(unsigned char)(fifo_buf_len >> 16), (unsigned char)(fifo_buf_len >> 24) };
fwrite(hdr, 1, sizeof hdr, fifo_dump_fp);
fwrite(fifo_buf, 1, fifo_buf_len, fifo_dump_fp);
fflush(fifo_dump_fp);
}
if (vpx_render) scene_burst(fifo_buf, fifo_buf_len);
fifo_buf_len = 0;
}
@@ -378,6 +385,7 @@ static void vpx_write(Bitu port, Bitu val, Bitu iolen) {
fifo_arm = false; fifo_cap_pos = 0; fifo_cap = 0;
expect_sync_token = false; sync_pending = false; sync_token = 0; frame_outstanding = false;
fifo_flush_record();
scene_reset();
note("board reset");
} else if (off == 1) {
saw_write = true; /* outputData: a download/response byte */
@@ -391,6 +399,317 @@ static void vpx_write(Bitu port, Bitu val, Bitu iolen) {
}
}
/* ================= Phase 3b: live render backend (VPX_RENDER=1) ==========
* Reconstructs the DPL scene graph from the FIFO message stream (protocol
* established in PHASE3-PROGRESS.md / render_capture.py) and draws each
* vr_draw_scene frame in a native OpenGL window on a dedicated thread.
* Windows-only for now (WGL); the scene decode itself is portable. */
#if defined(_WIN32) || defined(WIN32)
#define VPX_RENDER_SUPPORTED 1
#endif
#ifdef VPX_RENDER_SUPPORTED
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <GL/gl.h>
#include <map>
#include <vector>
static unsigned rd_u32(const unsigned char *p) {
return (unsigned)p[0] | ((unsigned)p[1] << 8) |
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24);
}
static float rd_f32(const unsigned char *p) {
float f; unsigned u = rd_u32(p); memcpy(&f, &u, 4); return f;
}
struct VCol { float c[3]; };
struct VPoly { float rgb[3]; std::vector<float> xyz; }; /* x,y,z triples */
struct VFrame {
bool valid;
float bg[3];
float win[5]; /* l, b, r, t, window-plane distance */
float nearp, farp;
int vw, vh;
bool has_cam;
float rot[9], eye[3]; /* row-major rotation; eye = R*(p - e) */
std::vector<VPoly> polys;
VFrame() : valid(false), nearp(2), farp(12000), vw(832), vh(512),
has_cam(false) {
bg[0] = bg[1] = bg[2] = 0;
win[0] = -1; win[1] = -0.6153846f; win[2] = 1; win[3] = 0.6153846f;
win[4] = 1.3f;
}
};
static struct VScene {
std::map<unsigned, unsigned> type; /* name -> node type */
std::map<unsigned, std::vector<float> > verts; /* geometry -> xyz */
std::map<unsigned, std::vector<std::vector<int> > > polys;
std::map<unsigned, VCol> mat; /* material -> RGB */
std::map<unsigned, unsigned> ggmat; /* geogroup -> material */
std::map<unsigned, std::vector<unsigned> > children;
VFrame view; /* view/bg/camera state */
/* multi-burst assembly */
unsigned geom_node; size_t geom_need; bool geom_active;
unsigned conn_node, conn_npolys, conn_loop; bool conn_active;
} S;
/* ---- render thread ------------------------------------------------------ */
static HANDLE rt_thread = NULL, rt_event = NULL;
static CRITICAL_SECTION rt_lock;
static VFrame rt_pending;
static bool rt_new = false;
static unsigned long rt_frames = 0;
static void rt_draw(HDC dc, const VFrame &f, int cw, int ch) {
glViewport(0, 0, cw, ch);
glClearColor(f.bg[0], f.bg[1], f.bg[2], 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
if (f.has_cam && !f.polys.empty()) {
double n = f.nearp > 0 ? f.nearp : 2.0;
double fa = f.farp > n ? f.farp : 12000.0;
double wd = f.win[4] != 0 ? f.win[4] : 1.3;
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
/* Division screen x runs opposite to GL eye x (SMPTE pattern comes
* out mirrored otherwise) -- flip x after projection. */
glScalef(-1.0f, 1.0f, 1.0f);
glFrustum(f.win[0] * n / wd, f.win[2] * n / wd,
f.win[1] * n / wd, f.win[3] * n / wd, n, fa);
glMatrixMode(GL_MODELVIEW);
GLfloat m[16];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
m[c * 4 + r] = (c < 3) ? f.rot[r * 3 + c] : 0.0f;
m[3] = m[7] = m[11] = 0.0f; m[15] = 1.0f;
glLoadMatrixf(m);
glTranslatef(-f.eye[0], -f.eye[1], -f.eye[2]);
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glDisable(GL_LIGHTING);
glShadeModel(GL_FLAT);
for (size_t i = 0; i < f.polys.size(); i++) {
const VPoly &p = f.polys[i];
glColor3f(p.rgb[0], p.rgb[1], p.rgb[2]);
glBegin(GL_POLYGON);
for (size_t v = 0; v + 2 < p.xyz.size(); v += 3)
glVertex3f(p.xyz[v], p.xyz[v + 1], p.xyz[v + 2]);
glEnd();
}
}
SwapBuffers(dc);
}
static LRESULT CALLBACK rt_wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == WM_CLOSE) { ShowWindow(w, SW_MINIMIZE); return 0; }
return DefWindowProcA(w, msg, wp, lp);
}
static DWORD WINAPI rt_main(LPVOID) {
WNDCLASSA wc; memset(&wc, 0, sizeof wc);
wc.style = CS_OWNDC;
wc.lpfnWndProc = rt_wndproc;
wc.hInstance = GetModuleHandleA(NULL);
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.lpszClassName = "VPXGL";
RegisterClassA(&wc);
RECT r = { 0, 0, 832, 512 };
AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
HWND wnd = CreateWindowA("VPXGL", "VPX VelociRender (emulated)",
WS_OVERLAPPEDWINDOW | WS_VISIBLE, 40, 40,
r.right - r.left, r.bottom - r.top, NULL, NULL, wc.hInstance, NULL);
if (!wnd) return 1;
HDC dc = GetDC(wnd);
PIXELFORMATDESCRIPTOR pfd; memset(&pfd, 0, sizeof pfd);
pfd.nSize = sizeof pfd; pfd.nVersion = 1;
pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER;
pfd.iPixelType = PFD_TYPE_RGBA; pfd.cColorBits = 24; pfd.cDepthBits = 24;
int pf = ChoosePixelFormat(dc, &pfd);
SetPixelFormat(dc, pf, &pfd);
HGLRC gl = wglCreateContext(dc);
if (!gl) return 1;
wglMakeCurrent(dc, gl);
VFrame cur;
for (;;) {
DWORD w = MsgWaitForMultipleObjects(1, &rt_event, FALSE, INFINITE,
QS_ALLINPUT);
MSG msg;
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
bool redraw = false;
if (w == WAIT_OBJECT_0) {
EnterCriticalSection(&rt_lock);
if (rt_new) { cur = rt_pending; rt_new = false; redraw = true; }
LeaveCriticalSection(&rt_lock);
}
if (redraw && cur.valid) {
RECT cr; GetClientRect(wnd, &cr);
rt_draw(dc, cur, cr.right, cr.bottom);
rt_frames++;
}
}
}
/* ---- scene-graph message decode ----------------------------------------- */
static void scene_publish_frame(void) {
VFrame f = S.view;
f.valid = true;
for (std::map<unsigned, unsigned>::const_iterator gi = S.ggmat.begin();
gi != S.ggmat.end(); ++gi) {
VCol col = { { 1.0f, 0.0f, 1.0f } }; /* missing = magenta */
std::map<unsigned, VCol>::const_iterator mi = S.mat.find(gi->second);
if (mi != S.mat.end()) col = mi->second;
std::map<unsigned, std::vector<unsigned> >::const_iterator ci =
S.children.find(gi->first);
if (ci == S.children.end()) continue;
for (size_t k = 0; k < ci->second.size(); k++) {
unsigned geo = ci->second[k];
std::map<unsigned, std::vector<float> >::const_iterator vi =
S.verts.find(geo);
std::map<unsigned, std::vector<std::vector<int> > >::const_iterator
pi = S.polys.find(geo);
if (vi == S.verts.end() || pi == S.polys.end()) continue;
const std::vector<float> &vv = vi->second;
for (size_t q = 0; q < pi->second.size(); q++) {
const std::vector<int> &idx = pi->second[q];
VPoly poly;
memcpy(poly.rgb, col.c, sizeof poly.rgb);
for (size_t j = 0; j < idx.size(); j++) {
size_t o = (size_t)idx[j] * 3;
if (o + 2 >= vv.size()) continue;
poly.xyz.push_back(vv[o]);
poly.xyz.push_back(vv[o + 1]);
poly.xyz.push_back(vv[o + 2]);
}
if (poly.xyz.size() >= 9) f.polys.push_back(poly);
}
}
}
EnterCriticalSection(&rt_lock);
rt_pending = f;
rt_new = true;
LeaveCriticalSection(&rt_lock);
SetEvent(rt_event);
}
static void scene_burst(const unsigned char *p, size_t n) {
if (n < 4) return;
unsigned action = rd_u32(p);
const unsigned char *d = p + 4;
size_t nb = n - 4;
/* multi-burst payload continuations take priority over new headers */
if (S.geom_active && action == 23) {
std::vector<float> &vl = S.verts[S.geom_node];
for (size_t o = 0; o + 11 < nb; o += 12) {
vl.push_back(rd_f32(d + o));
vl.push_back(rd_f32(d + o + 4));
vl.push_back(rd_f32(d + o + 8));
}
if (vl.size() / 3 >= S.geom_need) S.geom_active = false;
return;
}
if (S.conn_active && action == 25) {
std::vector<std::vector<int> > &pl = S.polys[S.conn_node];
size_t nw = nb / 4;
for (size_t o = 0; o + S.conn_loop <= nw; o += S.conn_loop) {
std::vector<int> loop;
for (unsigned j = 0; j + 1 < S.conn_loop; j++) /* drop closing dup */
loop.push_back((int)rd_u32(d + (o + j) * 4));
pl.push_back(loop);
}
if (pl.size() >= S.conn_npolys) S.conn_active = false;
return;
}
switch (action) {
case 1: /* create [type][name] */
if (nb >= 8) S.type[rd_u32(d + 4)] = rd_u32(d);
break;
case 3: { /* flush [name][type][struct] */
if (nb < 8) break;
unsigned name = rd_u32(d), t = rd_u32(d + 4);
if (t == 11 && nb >= 92) { /* material diffuse */
VCol c = { { rd_f32(d + 48), rd_f32(d + 52), rd_f32(d + 56) } };
S.mat[name] = c;
} else if (t == 9 && nb >= 80) { /* geogroup material */
S.ggmat[name] = rd_u32(d + 64);
} else if (t == 3 && nb >= 104) { /* view */
S.view.win[0] = rd_f32(d + 24); S.view.win[1] = rd_f32(d + 28);
S.view.win[2] = rd_f32(d + 32); S.view.win[3] = rd_f32(d + 36);
S.view.win[4] = rd_f32(d + 40);
S.view.vw = (int)rd_f32(d + 44); S.view.vh = (int)rd_f32(d + 48);
S.view.nearp = rd_f32(d + 52); S.view.farp = rd_f32(d + 56);
S.view.bg[0] = rd_f32(d + 60); S.view.bg[1] = rd_f32(d + 64);
S.view.bg[2] = rd_f32(d + 68);
}
break;
}
case 11: /* list_add [parent][child] */
if (nb >= 8) S.children[rd_u32(d)].push_back(rd_u32(d + 4));
break;
case 23: /* set_geom_verts header */
if (nb >= 36) {
S.geom_node = rd_u32(d);
S.geom_need = rd_u32(d + 8);
S.geom_active = S.geom_need > 0;
S.verts[S.geom_node].clear();
}
break;
case 25: /* set_geom_conns header [name][n_polys][loop_len][0] */
if (nb >= 16) {
S.conn_node = rd_u32(d);
S.conn_npolys = rd_u32(d + 4);
S.conn_loop = rd_u32(d + 8);
S.conn_active = (S.conn_npolys > 0 && S.conn_loop >= 2 &&
S.conn_loop <= 16);
S.polys[S.conn_node].clear();
}
break;
case 31: /* per-frame camera [?][view][3x3 rows][eye] */
if (nb >= 56) {
for (int i = 0; i < 9; i++)
S.view.rot[i] = rd_f32(d + 8 + i * 4);
for (int i = 0; i < 3; i++)
S.view.eye[i] = rd_f32(d + 44 + i * 4);
S.view.has_cam = true;
}
break;
case 9: /* draw_scene: commit */
scene_publish_frame();
break;
default:
break;
}
}
static void scene_reset(void) {
S.type.clear(); S.verts.clear(); S.polys.clear(); S.mat.clear();
S.ggmat.clear(); S.children.clear();
S.view = VFrame();
S.geom_active = false; S.conn_active = false;
}
static void vpx_render_start(void) {
InitializeCriticalSection(&rt_lock);
rt_event = CreateEventA(NULL, FALSE, FALSE, NULL);
rt_thread = CreateThread(NULL, 0, rt_main, NULL, 0, NULL);
vpx_render = (rt_thread != NULL);
}
#else /* !VPX_RENDER_SUPPORTED */
static void scene_burst(const unsigned char *, size_t) {}
static void scene_reset(void) {}
static void vpx_render_start(void) {}
#endif
void VPXLOG_Init(void) {
const char *env = getenv("VPXLOG");
if (env == NULL || env[0] == '\0') return;
@@ -411,6 +730,13 @@ void VPXLOG_Init(void) {
if (fifo_dump_fp == NULL) LOG_MSG("VPXLOG: cannot open fifodump '%s'", fd);
}
const char *rn = getenv("VPX_RENDER");
if (rn && rn[0] && rn[0] != '0') {
vpx_render_start();
LOG_MSG("VPXLOG: live render backend %s",
vpx_render ? "started" : "unavailable");
}
IO_RegisterReadHandler(VPX_BASE, vpx_read, IO_MB, 18);
IO_RegisterWriteHandler(VPX_BASE, vpx_write, IO_MB, 18);