diff --git a/emulator/.gitignore b/emulator/.gitignore index 93487a6..4dcd35b 100644 --- a/emulator/.gitignore +++ b/emulator/.gitignore @@ -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 diff --git a/emulator/PHASE3-PROGRESS.md b/emulator/PHASE3-PROGRESS.md index 9bb7198..d12e9b4 100644 --- a/emulator/PHASE3-PROGRESS.md +++ b/emulator/PHASE3-PROGRESS.md @@ -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. diff --git a/emulator/divrgb-live-gl.png b/emulator/divrgb-live-gl.png new file mode 100644 index 0000000..381bf10 Binary files /dev/null and b/emulator/divrgb-live-gl.png differ diff --git a/emulator/vpx-device/vpxlog.cpp b/emulator/vpx-device/vpxlog.cpp index 2cd1d1f..20ad767 100644 --- a/emulator/vpx-device/vpxlog.cpp +++ b/emulator/vpx-device/vpxlog.cpp @@ -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 +#include +#include +#include + +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 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 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 type; /* name -> node type */ + std::map > verts; /* geometry -> xyz */ + std::map > > polys; + std::map mat; /* material -> RGB */ + std::map ggmat; /* geogroup -> material */ + std::map > 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::const_iterator gi = S.ggmat.begin(); + gi != S.ggmat.end(); ++gi) { + VCol col = { { 1.0f, 0.0f, 1.0f } }; /* missing = magenta */ + std::map::const_iterator mi = S.mat.find(gi->second); + if (mi != S.mat.end()) col = mi->second; + std::map >::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 >::const_iterator vi = + S.verts.find(geo); + std::map > >::const_iterator + pi = S.polys.find(geo); + if (vi == S.verts.end() || pi == S.polys.end()) continue; + const std::vector &vv = vi->second; + for (size_t q = 0; q < pi->second.size(); q++) { + const std::vector &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 &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 > &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 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);