Phase 2 COMPLETE: full VPX protocol working; game reaches content loader

Disassembled BTL4OPT.EXE (PE-in-DOS32RTM) to crack the last unknowns:

- velocirender_sync (@0x48D220): sends action 0x2D with a data token and
  checks cmp token, reply.node[0] (the 'unexpected action' text only prints
  the action; the real check is the echoed token). Device now echoes the
  token in node[0]. Sync passes.
- Speculative-poll gating: only feed a reply after consecutive empty polls
  (a blocking inRecord spin), not single handle_iserver_stuff() checks.
- Frame-ack: velocirender_frameack expects action 9 (vr_draw_scene); device
  tracks outstanding draw_scene and replies 9. Frame-ack passes.

The game now passes sync, loads renderer config, builds the scene
(create/flush/list_add/draw_scene), completes frame-ack, and enters its own
content loader (L4VIDEO.cpp) loading BattleTech models. Remaining issues are
content packaging/paths and an empty DPL config -- not VPX protocol. The
board emulation is functionally complete for boot + scene setup + frame-ack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-03 00:04:33 -05:00
co-authored by Claude Fable 5
parent e13575d3ac
commit 939ea17d17
2 changed files with 116 additions and 27 deletions
+38
View File
@@ -127,3 +127,41 @@ Console progression now: `BattleTech v4.10` → `BTL4Application` → RIO check
(the FIFO-path render handshake, the next target). The log's `#` note lines
trace the device's decisions (handshake requests fed, frames parsed, actions
echoed, mystery ports resolved to 0x154/0x155).
## Update 3 (2026-07-03): full VPX protocol working — game reaches content load
Disassembling `BTL4OPT.EXE` (a PE-in-DOS32RTM binary; CODE @VA 0x401000,
`velocirender_sync` @0x48D220) cracked the last protocol unknowns and the
emulated board now carries the game through the **entire** boot + render
protocol into its own game logic:
1. **velocirender_sync** (was the blocker): from the disassembly, it sends
action **0x2D** (a Rel4.10 sync/ping outside the DPL3 enum, which is why my
`<24` filter dropped it) with data `[token,0]`, then checks
`cmp token, reply.node[0]` at 0x48D271 — the "unexpected action %d" message
only *prints* the received action; the real test is the echoed **data
token**. Device now replies to 0x2D with `node[0] = token`. **Sync passes.**
2. **Speculative-poll gating**: `velocirender_transmit` calls
`handle_iserver_stuff()` which polls inputStatus once and proceeds if
not-ready. Feeding a reply into those polls corrupted alignment. The device
now only feeds a reply after several *consecutive* empty polls (a blocking
`inRecord` spin), not single speculative checks.
3. **Frame-ack**: `velocirender_frameack` expects a message with action
**9 (vr_draw_scene)**. The device tracks an outstanding `draw_scene` send and
replies 9 to the frame-ack. **Frame-ack passes.**
With these, the game now: passes sync, loads the renderer config, builds the
scene (create/flush/list_add/draw_scene), completes frame-ack, and enters its
own content loader (`L4VIDEO.cpp`), where it tries to load BattleTech models
(`buttee.bgf`, `mslr.bgf`, …). The remaining errors are **content packaging /
paths** (the minimal `test.egg` lacks these objects) and an **empty renderer
config** (`dpldflt.ini`) — *not* VPX protocol issues.
**The VPX board emulation is functionally complete for boot + scene setup +
frame-ack.** Next: (a) run against a full mission/content set (a real `.egg`
or the pod's content tree) so objects load; (b) provide a proper DPL config;
(c) Phase 3 — decode the geometry/material/texture commands already flowing over
the FIFO into the OpenGL backend (formats in `restoration/divformats.py`) to put
actual pixels on screen.
+78 -27
View File
@@ -113,7 +113,19 @@ static void parse_out_byte(unsigned char v) {
static bool fifo_arm = false;
static int fifo_cap_pos = 0;
static unsigned fifo_cap = 0;
static const unsigned VR_ACTION_MAX = 24; /* enum has 24 actions (0..23) */
static const unsigned VR_ACTION_MAX = 24; /* vr_action enum has 24 (0..23) */
static const unsigned VR_SYNC_ACTION = 0x2d; /* Rel4.10 sync/ping (from disasm) */
/* Sync protocol (velocirender_sync in BTL4OPT.EXE @0x48D220, disassembled):
* sends action 0x2d with data [token, 0], receives, and checks that the
* REPLY's node[0] == token. (The "unexpected action %d" message only prints
* the received action; the real check at 0x48D271 is `cmp token, node[0]`.)
* So the device must reply to a 0x2d message with node[0] = the sent token.
* The token is the data word of the NEXT FIFO run after the 0x2d action. */
static bool expect_sync_token = false;
static bool sync_pending = false;
static unsigned sync_token = 0;
static bool frame_outstanding = false; /* draw_scene sent, frame-ack owed */
static void fifo_arm_action(void) { fifo_arm = true; fifo_cap_pos = 0; fifo_cap = 0; }
@@ -121,12 +133,22 @@ static void parse_fifo_byte(unsigned char v) {
if (!fifo_arm) return;
fifo_cap |= ((unsigned)v) << (fifo_cap_pos * 8);
if (++fifo_cap_pos == 4) {
fifo_arm = false;
unsigned w = fifo_cap;
fifo_cap = 0; fifo_cap_pos = 0;
if (vpx_fp) { flush_run();
fprintf(vpx_fp, "# FIFOCAP 0x%08X%s\n", fifo_cap,
fifo_cap < VR_ACTION_MAX ? " (action)" : "");
fflush(vpx_fp); }
if (fifo_cap < VR_ACTION_MAX) last_action = fifo_cap;
fprintf(vpx_fp, "# FIFOCAP 0x%08X\n", w); fflush(vpx_fp); }
if (expect_sync_token) {
sync_token = w; sync_pending = true; expect_sync_token = false;
fifo_arm = false;
} else if (w == VR_SYNC_ACTION) {
expect_sync_token = true; /* token follows contiguously; stay armed */
} else {
/* don't clear sync_pending here: a pending sync token must survive
* intervening sends until the sync receive consumes it. */
if (w < VR_ACTION_MAX) last_action = w;
if (w == 9 /*vr_draw_scene*/) frame_outstanding = true;
fifo_arm = false;
}
}
}
@@ -149,17 +171,21 @@ static void queue_version_request(void) {
* action code. The post-boot vr_init reply is ignored by the caller
* (DPL_HOST.C), so any action != vr_draw_scene_action satisfies it. */
static int postboot_acks = 0;
static int vpx_max_postboot_acks = 40; /* safety cap; tune via env */
static void queue_render_ack(unsigned char action) {
static int vpx_max_postboot_acks = 200; /* safety cap */
static int empty_polls = 0;
static const int POLL_THRESHOLD = 6; /* consecutive empty polls => blocking receive */
static void queue_render_ack_node(unsigned char action, unsigned node) {
unsigned char m[12] = {
0x08, 0x00, 0x00, 0x00, /* length_word 0x00000008 (nb=8) */
action, 0x00, 0x00, 0x00, /* payload[0..3] = action (LE) */
0x00, 0x00, 0x00, 0x00 /* payload[4..7] = node (unused) */
(unsigned char)(node), (unsigned char)(node >> 8),
(unsigned char)(node >> 16), (unsigned char)(node >> 24) /* node[0] */
};
memcpy(in_fifo, m, sizeof m);
in_len = (int)sizeof m;
in_pos = 0;
}
static void queue_render_ack(unsigned char action) { queue_render_ack_node(action, 0); }
/* ---- logging (run-length coalesced) ------------------------------------- */
static unsigned long vpx_seq = 0;
@@ -217,6 +243,7 @@ static Bitu vpx_read(Bitu port, Bitu /*iolen*/) {
if (vpx_respond) {
if (in_pos < in_len) {
ret = 0x01; /* still draining a queued message */
empty_polls = 0;
} else {
/* FIFO empty: decide whether to start a new transaction. */
if (phase == P_INIT && saw_write) phase = P_HANDSHAKE;
@@ -234,26 +261,48 @@ static Bitu vpx_read(Bitu port, Bitu /*iolen*/) {
}
}
if (phase == P_POSTBOOT && in_pos >= in_len) {
/* game is reading -> expects a renderer reply
* (first is the vr_init ack after i860 boot). */
/* Distinguish a blocking receive (inRecord spins,
* polling inputStatus many times) from a speculative
* single poll (handle_iserver_stuff/altRecord polls
* once and proceeds if not-ready). Only feed a reply
* after several consecutive empty polls, so we don't
* inject renderer replies into iserver-drain checks. */
if (++empty_polls < POLL_THRESHOLD) { ret = 0x00; break; }
empty_polls = 0;
if (postboot_acks < vpx_max_postboot_acks) {
/* Reply action is handler-specific (board side,
* VR_REMOT.C): most handlers preserve data[0] (echo
* the sent action), but a few overwrite it:
* vr_init_action(0) -> *data = 1
* vr_statistics_action(15) -> *data = 1
* everything else echoes. */
unsigned reply = last_action;
if (last_action == 0 /*init*/ ||
last_action == 15 /*statistics*/) reply = 1;
/* experiment hook: override init reply action */
{ const char *o = getenv("VPX_INIT_REPLY");
if (o && last_action == 0) reply = (unsigned)atoi(o); }
queue_render_ack((unsigned char)(reply & 0xff));
if (sync_pending) {
/* velocirender_sync: reply node[0] = token so
* its `cmp token, node[0]` passes. Action field
* is not checked (just printed on failure). */
queue_render_ack_node((unsigned char)VR_SYNC_ACTION,
sync_token);
sync_pending = false;
if (vpx_fp) { flush_run();
fprintf(vpx_fp, "# post-boot: sync reply token=0x%X\n",
sync_token); fflush(vpx_fp); }
} else if (frame_outstanding) {
/* velocirender_frameack expects a message with
* action == vr_draw_scene_action (9). */
frame_outstanding = false;
queue_render_ack(9);
if (vpx_fp) { flush_run();
fprintf(vpx_fp, "# post-boot: frame ack (action 9)\n");
fflush(vpx_fp); }
} else {
/* Reply action is handler-specific (board side,
* VR_REMOT.C): most echo data[0] (the sent
* action); a few overwrite it:
* vr_init_action(0)/statistics(15) -> 1 */
unsigned reply = last_action;
if (last_action == 0 || last_action == 15) reply = 1;
{ const char *o = getenv("VPX_INIT_REPLY");
if (o && last_action == 0) reply = (unsigned)atoi(o); }
queue_render_ack((unsigned char)(reply & 0xff));
if (vpx_fp) { flush_run();
fprintf(vpx_fp, "# post-boot: reply action %u (sent %u)\n",
reply, last_action); fflush(vpx_fp); }
}
postboot_acks++;
if (vpx_fp) { flush_run();
fprintf(vpx_fp, "# post-boot: reply action %u (sent %u)\n",
reply, last_action); fflush(vpx_fp); }
ret = 0x01;
}
}
@@ -292,9 +341,11 @@ static void vpx_write(Bitu port, Bitu val, Bitu iolen) {
parse_frames = false; wf_need_len = 4; wf_lenbuf = 0; wf_lenshift = 0;
wf_payload_left = -1; wf_action_pos = 0; wf_action = 0; last_action = 0;
fifo_arm = false; fifo_cap_pos = 0; fifo_cap = 0;
expect_sync_token = false; sync_pending = false; sync_token = 0; frame_outstanding = false;
note("board reset");
} else if (off == 1) {
saw_write = true; /* outputData: a download/response byte */
empty_polls = 0; /* a write means the game isn't blocking-reading */
if (parse_frames) { parse_out_byte((unsigned char)val); fifo_arm_action(); }
} else if (off == 4 || off == 5) {
/* FIFO data port (link B): payload words, low/high bytes. */