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
+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. */