/* VPX link-adapter device (Tesla Rel 4.10 emulation)
 *
 * Impersonates the INMOS C012 transputer link adapter the Division VPX board
 * hung off the ISA bus (host source: sda4/DPL3/LINKIO.C, setLA):
 *
 *   base+0  (0x150) inputData    R  byte board->host
 *   base+1  (0x151) outputData   W  byte host->board
 *   base+2  (0x152) inputStatus  R  bit0 = inbound byte available
 *   base+3  (0x153) outputStatus R  bit0 = ready to accept outbound byte
 *   base+16 (0x160) resetRoot    W  board reset strobe
 *   base+17 (0x161) analyseRoot  W  board analyse strobe
 *
 * Two modes, selected by environment variables (device inert unless VPXLOG set):
 *
 *   VPXLOG=<path>      Phase 1: log every access to <path>.
 *   VPX_RESPOND=1      Phase 2: also answer as the transputer would, so the
 *                      game gets past boot_xputer()'s startup_handshake().
 *   VPX_HANDSHAKES=N   number of iserver transactions to feed (default 3;
 *                      Phil's note in VR_COMMS.C: the transputer C runtime
 *                      does 3 iserver transactions at startup).
 *
 * Protocol reference: sda4/DPL3/VR_COMMS.C.
 *   - receive_protocol(): host reads a 4-byte little-endian length/route word
 *     (bit31=iserver, bits16-23=sender, low16=payload length nb<=1040) then nb
 *     payload bytes. startup_handshake() does N such reads; each is answered by
 *     iserver_action() and only success is checked -- so feeding N well-formed
 *     iserver "version" requests (tag 42, no request-content dependency)
 *     satisfies the handshake.
 *   - The monitor (VRENDMON.BTL) and i860 (VRNOSTEX.MNG) segment downloads are
 *     pure host->board writes; the device just absorbs them.
 */

#include "dosbox.h"
#include "inout.h"
#include "logging.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const io_port_t VPX_BASE = 0x150;

static FILE *vpx_fp = NULL;      /* log file, or NULL */
static bool  vpx_respond = false;
static int   vpx_max_handshakes = 3;

/* ---- board->host FIFO (what the emulated board sends the game) ---------- */
static unsigned char in_fifo[64];
static int in_len = 0, in_pos = 0;

/* ---- boot state machine ------------------------------------------------- */
enum Phase { P_INIT, P_HANDSHAKE, P_POSTBOOT };
static Phase phase = P_INIT;
static bool  saw_write = false;      /* game has written at least one byte */
static int   handshakes_fed = 0;

/* ---- outbound frame parser (renderer messages, post-handshake) ---------- *
 * Wire format (VR_COMMS.C velocirender_transmit, little-endian PC path):
 *   [length_word:4][action:4][data:(nb-4)]  where nb = length_word & 0xffff.
 * iserver responses keep the same nb = length_word & 0xffff, so byte-count
 * alignment is preserved even when one slips through. We track the action of
 * the last complete message so the reply can echo it (the renderer echoes the
 * action it received: see velocirender_create/delete/sync in DPL_HOST.C). */
static void flush_run(void);            /* fwd decl (logging, defined below) */

static bool     parse_frames = false;   /* true once handshake requests done */
static int      wf_need_len = 4;        /* bytes still needed for length_word */
static unsigned wf_lenbuf = 0, wf_lenshift = 0;
static int      wf_payload_left = -1;   /* -1 => currently reading length_word */
static int      wf_action_pos = 0;
static unsigned wf_action = 0;
static int      wf_last_nb = 0;
static unsigned last_action = 0;

static void parse_out_byte(unsigned char v) {
    if (wf_payload_left < 0) {                 /* accumulating length_word */
        wf_lenbuf |= ((unsigned)v) << wf_lenshift;
        wf_lenshift += 8;
        if (--wf_need_len == 0) {
            int nb = (int)(wf_lenbuf & 0xffff);
            wf_lenbuf = 0; wf_lenshift = 0; wf_need_len = 4;
            wf_payload_left = nb > 0 ? nb : 0;
            wf_last_nb = nb;
            wf_action_pos = 0; wf_action = 0;
            if (wf_payload_left == 0) wf_payload_left = -1; /* empty msg */
        }
    } else if (wf_payload_left > 0) {          /* reading payload */
        if (wf_action_pos < 4) {
            wf_action |= ((unsigned)v) << (wf_action_pos * 8);
            wf_action_pos++;
        }
        if (--wf_payload_left == 0) {
            last_action = wf_action;            /* message complete */
            /* diagnostic: log small control frames (skip 512B i860 chunks) */
            if (vpx_fp && wf_last_nb <= 64) {
                flush_run();
                fprintf(vpx_fp, "# FRAME action=%u nb=%d (slow)\n", wf_action, wf_last_nb);
                fflush(vpx_fp);
            }
            wf_payload_left = -1;
        }
    }
}

/* ---- FIFO fast-path action extraction ---------------------------------- *
 * After the i860 boots the host switches to the FIFO path (OUTSW.ASM): each
 * message writes an outsw tag byte to outputData, then pumps the payload as
 * 16-bit words to the FIFO data port (observed at 0x154/0x155, low/high). The
 * payload of a control message begins with the 4-byte action. We arm on each
 * outputData write and capture the next 4 FIFO bytes; if they form a valid
 * action code (< 32, the vr_action enum range) we treat it as the action to
 * echo. Data runs (e.g. text/coords) yield large values and are ignored. */
static bool     fifo_arm = false;
static int      fifo_cap_pos = 0;
static unsigned fifo_cap = 0;
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; }

/* ---- Phase 3: full FIFO message dump (VPX_FIFODUMP=<path>) -------------- *
 * The FIFO wire format (VR_COMMS.C velocirender_transmit + OUTSW.ASM): each
 * transmit is TWO tag-delimited bursts. The C caller fires the first 3 bytes
 * of the protocol word at outputData, outsw() sends 0x40 as its final byte
 * and REP OUTSWs the payload into the FIFO port. Burst 1 carries the 4-byte
 * action, burst 2 the data (protocol word (0xff<<16)|(data+4)). So recording
 * every FIFO byte between outputData writes yields alternating action/data
 * records that a decoder can pair back into [action][data] messages.
 * Record format: 'VPXM' u32 magic, u32 length LE, then the raw burst bytes. */
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 && !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);
        if (nb == NULL) return;             /* drop byte rather than crash */
        fifo_buf = nb; fifo_buf_cap = ncap;
    }
    fifo_buf[fifo_buf_len++] = v;
}
static void fifo_flush_record(void) {
    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;
}

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) {
        unsigned w = fifo_cap;
        fifo_cap = 0; fifo_cap_pos = 0;
        if (vpx_fp) { flush_run();
            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;
        }
    }
}

/* A well-formed iserver "version" request (VR_COMMS.C iserver_action case 42):
 * length_word = nb | (sender<<16) | 0x80000000, little-endian; payload = tag.
 * nb is padded even for iserver; use nb=2, payload {0x2a,0x00}. sender=0. */
static const unsigned char VERSION_REQUEST[6] = {
    0x02, 0x00, 0x00, 0x80,   /* length_word 0x80000002 LE */
    0x2a, 0x00                /* tag 42 (version) + pad     */
};

static void queue_version_request(void) {
    memcpy(in_fifo, VERSION_REQUEST, sizeof VERSION_REQUEST);
    in_len = (int)sizeof VERSION_REQUEST;
    in_pos = 0;
}

/* A minimal non-iserver renderer reply for velocirender_receive() (VR_COMMS.C):
 * top bit of length_word clear (non-iserver), nb>=8, first int32 of payload =
 * 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;
/* Post-boot reply cap. This was a Phase-2 bring-up guard against a runaway
 * reply loop while the protocol was still being reversed. The protocol is now
 * solid and a real game session issues an unbounded number of sync/frame/
 * render replies -- BattleTech aborts with "velocirender_receive timed out -
 * sends_wo_rcv" the instant the device stops answering. Default is now
 * effectively unlimited; override with VPX_MAX_ACKS only for diagnostics. */
static int vpx_max_postboot_acks = 0x7fffffff;
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) {
    fifo_flush_record();   /* a receive means the outstanding burst is complete */
    unsigned char m[12] = {
        0x08, 0x00, 0x00, 0x00,               /* length_word 0x00000008 (nb=8) */
        action, 0x00, 0x00, 0x00,             /* payload[0..3] = action (LE)  */
        (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;
static io_port_t last_port = 0xFFFF; static unsigned last_val = ~0u;
static bool last_write = false; static unsigned long last_run = 0;

static char reg_name_buf[16];
static const char *reg_name(io_port_t port) {
    switch (port - VPX_BASE) {
        case 0:  return "inputData";
        case 1:  return "outputData";
        case 2:  return "inputStatus";
        case 3:  return "outputStatus";
        case 16: return "resetRoot";
        case 17: return "analyseRoot";
        default:
            snprintf(reg_name_buf, sizeof reg_name_buf, "port_%03X", (unsigned)port);
            return reg_name_buf;
    }
}
static void flush_run(void) {
    if (last_run == 0 || vpx_fp == NULL) return;
    if (last_run == 1)
        fprintf(vpx_fp, "%8lu  %s  %-13s 0x%02X\n", vpx_seq++,
                last_write ? "W" : "R", reg_name(last_port), last_val & 0xFF);
    else
        fprintf(vpx_fp, "%8lu  %s  %-13s 0x%02X  x%lu\n", vpx_seq++,
                last_write ? "W" : "R", reg_name(last_port), last_val & 0xFF, last_run);
    fflush(vpx_fp); last_run = 0;
}
static void record(io_port_t port, unsigned val, bool write) {
    if (vpx_fp == NULL) return;
    if (port == last_port && val == last_val && write == last_write) { last_run++; return; }
    flush_run();
    last_port = port; last_val = val; last_write = write; last_run = 1;
    io_port_t off = port - VPX_BASE;
    if (write && (off == 1 || off == 16 || off == 17)) flush_run();
}
static void note(const char *msg) {
    if (vpx_fp) { flush_run(); fprintf(vpx_fp, "# %s\n", msg); fflush(vpx_fp); }
}

/* ---- I/O handlers ------------------------------------------------------- */
static Bitu vpx_read(Bitu port, Bitu /*iolen*/) {
    io_port_t off = (io_port_t)port - VPX_BASE;
    Bitu ret = 0xFF;

    switch (off) {
        case 3: /* outputStatus: always ready to accept a byte */
            ret = 0x01;
            break;

        case 2: /* inputStatus: bit0 = inbound byte available */
            ret = 0x00;
            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;
                    if (phase == P_HANDSHAKE) {
                        if (handshakes_fed < vpx_max_handshakes) {
                            queue_version_request();
                            handshakes_fed++;
                            note("feeding iserver version request");
                            if (handshakes_fed >= vpx_max_handshakes)
                                parse_frames = true; /* i860 dl + init now framed */
                            ret = 0x01;
                        } else {
                            phase = P_POSTBOOT;
                            note("handshake complete; entering post-boot");
                        }
                    }
                    if (phase == P_POSTBOOT && in_pos >= in_len) {
                        /* 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) {
                            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++;
                            ret = 0x01;
                        }
                    }
                }
            }
            break;

        case 0: /* inputData: next byte from board->host FIFO */
            if (vpx_respond && in_pos < in_len) ret = in_fifo[in_pos++];
            else ret = 0xFF;
            break;

        default:
            ret = 0xFF;
            break;
    }
    record((io_port_t)port, (unsigned)ret, false);
    return ret;
}

static bool warned_iolen = false;
static void vpx_write(Bitu port, Bitu val, Bitu iolen) {
    io_port_t off = (io_port_t)port - VPX_BASE;
    if (iolen != 1 && !warned_iolen && vpx_fp) {
        warned_iolen = true; flush_run();
        fprintf(vpx_fp, "# NOTE: non-byte write iolen=%u port=0x%03X val=0x%X\n",
                (unsigned)iolen, (unsigned)port, (unsigned)val);
        fflush(vpx_fp);
    }
    record((io_port_t)port, (unsigned)val, true);

    if (off == 16 && (val & 1)) {
        /* resetRoot asserted: board reset -> clear all state. */
        phase = P_INIT; saw_write = false; handshakes_fed = 0;
        postboot_acks = 0; in_len = in_pos = 0;
        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;
        fifo_flush_record();
        scene_reset();
        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 */
        fifo_flush_record();   /* outputData write = FIFO burst boundary */
        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. */
        fifo_buf_push((unsigned char)val);
        if (parse_frames) parse_fifo_byte((unsigned char)val);
    }
}

/* ================= 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 */
    std::vector<float> uv;           /* u,v pairs (empty = untextured) */
    std::vector<float> nrm;          /* nx,ny,nz triples (empty = unlit) */
    unsigned matkey;                 /* material name for texture lookup, 0 = none */
    VPoly() : matkey(0) {}
};
/* Baked RGBA texture (texture intensity x material ramp), keyed by material.
 * Shared with the GL thread under rt_lock; ver bumps force re-upload. */
struct TexImg { int w, h; unsigned ver; std::vector<unsigned char> rgba; };
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;
    bool  ydown;                /* game world is y-down (DCS reflections) */
    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), ydown(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;
    }
};

struct M16 { float m[16]; };            /* row-major 4x4, row 3 = translation */

struct VRamp { float lo[3], hi[3]; };
struct VTex  { int w, h; unsigned ver; std::vector<unsigned char> px; };

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<float> > uvs;       /* geometry -> u,v */
    std::map<unsigned, std::vector<float> > nrms;      /* geometry -> nx,ny,nz */
    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;
    /* game (full DPL) hierarchy: instance placement + articulation */
    std::map<unsigned, unsigned> inst_object;          /* instance -> object */
    std::map<unsigned, M16> dcs_mat;                   /* dcs -> local matrix */
    std::map<unsigned, unsigned> dcs_parent;           /* dcs_link child->parent */
    /* texture chain: material -> texmap -> texture, colorized by the
     * material's ramp (type 14: lo/hi RGB; texels are intensities) */
    std::map<unsigned, VTex> tex;                      /* texture node -> texels */
    std::map<unsigned, unsigned> texmap_tex;           /* texmap -> texture */
    std::map<unsigned, unsigned> mat_texmap;           /* material -> texmap */
    std::map<unsigned, unsigned> mat_ramp;             /* material -> ramp */
    std::map<unsigned, VRamp> ramp;                    /* ramp -> lo/hi */
    VFrame view;                                       /* view/bg/camera state */
    /* multi-burst assembly (stride-aware: game verts are 3..9 floats each) */
    unsigned geom_node; size_t geom_need, geom_stride; bool geom_active;
    std::vector<float> geom_acc;
    unsigned conn_node, conn_npolys, conn_loop; bool conn_active;
    /* action-26 texel upload assembly */
    unsigned tex_node; size_t tex_need; bool tex_active;
    std::vector<unsigned char> tex_acc;
} S;

static void m16_id(M16 &o) {
    for (int i = 0; i < 16; i++) o.m[i] = (i % 5 == 0) ? 1.0f : 0.0f;
}
static void m16_mul(const M16 &a, const M16 &b, M16 &o) {  /* o = a * b */
    for (int r = 0; r < 4; r++)
        for (int c = 0; c < 4; c++) {
            float s = 0;
            for (int k = 0; k < 4; k++) s += a.m[r * 4 + k] * b.m[k * 4 + c];
            o.m[r * 4 + c] = s;
        }
}
static void m16_xform(const M16 &w, const float *v, float *o) {
    for (int c = 0; c < 3; c++)
        o[c] = v[0] * w.m[c] + v[1] * w.m[4 + c] + v[2] * w.m[8 + c] + w.m[12 + c];
}
static void m16_xform_dir(const M16 &w, const float *v, float *o) {
    for (int c = 0; c < 3; c++)             /* rotation only (normals) */
        o[c] = v[0] * w.m[c] + v[1] * w.m[4 + c] + v[2] * w.m[8 + c];
}
/* world transform of a dcs: local * parent_world (row-vector convention) */
static void dcs_world(unsigned dcs, std::map<unsigned, M16> &cache, M16 &out,
                      int depth = 0) {
    std::map<unsigned, M16>::iterator ci = cache.find(dcs);
    if (ci != cache.end()) { out = ci->second; return; }
    M16 local;
    std::map<unsigned, M16>::iterator mi = S.dcs_mat.find(dcs);
    if (mi != S.dcs_mat.end()) local = mi->second; else m16_id(local);
    std::map<unsigned, unsigned>::iterator pi = S.dcs_parent.find(dcs);
    if (pi != S.dcs_parent.end() && pi->second != dcs && depth < 64) {
        M16 pw;
        dcs_world(pi->second, cache, pw, depth + 1);
        M16 w;
        m16_mul(local, pw, w);
        out = w;
    } else {
        out = local;
    }
    cache[dcs] = out;
}

/* ---- 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 std::map<unsigned, TexImg> rt_texs;  /* material -> baked RGBA (rt_lock) */

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. The game world
         * is additionally y-down (its DCS matrices carry a reflection). */
        glScalef(-1.0f, f.ydown ? -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);
        glShadeModel(GL_SMOOTH);
        /* directional sun (world coords; modelview is loaded, so GL maps it
         * into eye space). World is y-down: up = -y. */
        {
            GLfloat lpos[4] = { 0.35f, -0.85f, 0.40f, 0.0f };
            GLfloat lamb[4] = { 0.45f, 0.45f, 0.45f, 1.0f };
            GLfloat ldif[4] = { 0.80f, 0.80f, 0.78f, 1.0f };
            glLightfv(GL_LIGHT0, GL_POSITION, lpos);
            glLightfv(GL_LIGHT0, GL_AMBIENT, lamb);
            glLightfv(GL_LIGHT0, GL_DIFFUSE, ldif);
            glEnable(GL_LIGHT0);
            glLightModeli(GL_LIGHT_MODEL_TWO_SIDE, 1);
            glEnable(GL_COLOR_MATERIAL);
            glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE);
            glEnable(GL_NORMALIZE);
        }
        /* upload any new/changed baked material textures */
        static std::map<unsigned, GLuint> gltex;       /* matkey -> GL name */
        static std::map<unsigned, unsigned> gltex_ver;
        EnterCriticalSection(&rt_lock);
        std::map<unsigned, TexImg> texs = rt_texs;     /* small; copy out */
        LeaveCriticalSection(&rt_lock);
        for (std::map<unsigned, TexImg>::const_iterator it = texs.begin();
             it != texs.end(); ++it) {
            std::map<unsigned, unsigned>::const_iterator vi =
                gltex_ver.find(it->first);
            if (vi != gltex_ver.end() && vi->second == it->second.ver) continue;
            GLuint id;
            std::map<unsigned, GLuint>::const_iterator gi = gltex.find(it->first);
            if (gi == gltex.end()) { glGenTextures(1, &id); gltex[it->first] = id; }
            else id = gi->second;
            glBindTexture(GL_TEXTURE_2D, id);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
            glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, it->second.w, it->second.h,
                         0, GL_RGBA, GL_UNSIGNED_BYTE, &it->second.rgba[0]);
            gltex_ver[it->first] = it->second.ver;
        }
        glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
        unsigned bound = 0;
        for (size_t i = 0; i < f.polys.size(); i++) {
            const VPoly &p = f.polys[i];
            bool tex = p.matkey && gltex.count(p.matkey) &&
                       p.uv.size() * 3 == p.xyz.size() * 2;
            if (tex) {
                if (bound != p.matkey) {
                    glBindTexture(GL_TEXTURE_2D, gltex[p.matkey]);
                    bound = p.matkey;
                }
                glEnable(GL_TEXTURE_2D);
                glColor3f(1.0f, 1.0f, 1.0f);
            } else {
                glDisable(GL_TEXTURE_2D);
                glColor3f(p.rgb[0], p.rgb[1], p.rgb[2]);
            }
            bool lit = !p.nrm.empty();
            if (lit) glEnable(GL_LIGHTING); else glDisable(GL_LIGHTING);
            glBegin(GL_POLYGON);
            for (size_t v = 0; v + 2 < p.xyz.size(); v += 3) {
                if (tex) glTexCoord2f(p.uv[v / 3 * 2], p.uv[v / 3 * 2 + 1]);
                if (lit) glNormal3f(p.nrm[v], p.nrm[v + 1], p.nrm[v + 2]);
                glVertex3f(p.xyz[v], p.xyz[v + 1], p.xyz[v + 2]);
            }
            glEnd();
        }
        glDisable(GL_TEXTURE_2D);
        glDisable(GL_LIGHTING);
    }
    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 ----------------------------------------- */

/* Bake material's texture (intensity) through its ramp into RGBA and stash it
 * for the GL thread. Returns the material key if textured, else 0. */
static std::map<unsigned, unsigned> baked_ver;   /* material -> baked tex ver */
static unsigned bake_material_tex(unsigned matname) {
    std::map<unsigned, unsigned>::const_iterator tmi = S.mat_texmap.find(matname);
    if (tmi == S.mat_texmap.end()) return 0;
    std::map<unsigned, unsigned>::const_iterator txi = S.texmap_tex.find(tmi->second);
    if (txi == S.texmap_tex.end()) return 0;
    std::map<unsigned, VTex>::const_iterator ti = S.tex.find(txi->second);
    if (ti == S.tex.end() || ti->second.px.empty()) return 0;
    const VTex &t = ti->second;
    if ((size_t)t.w * t.h > t.px.size()) return 0;
    std::map<unsigned, unsigned>::const_iterator bi = baked_ver.find(matname);
    if (bi != baked_ver.end() && bi->second == t.ver) return matname;
    float lo[3] = { 0, 0, 0 }, hi[3] = { 1, 1, 1 };
    std::map<unsigned, unsigned>::const_iterator ri = S.mat_ramp.find(matname);
    if (ri != S.mat_ramp.end()) {
        std::map<unsigned, VRamp>::const_iterator rr = S.ramp.find(ri->second);
        if (rr != S.ramp.end()) {
            memcpy(lo, rr->second.lo, sizeof lo);
            memcpy(hi, rr->second.hi, sizeof hi);
        }
    }
    TexImg img;
    img.w = t.w; img.h = t.h; img.ver = t.ver;
    img.rgba.resize((size_t)t.w * t.h * 4);
    for (size_t i = 0; i < (size_t)t.w * t.h; i++) {
        float a = t.px[i] / 255.0f;
        for (int c = 0; c < 3; c++) {
            float v = lo[c] + (hi[c] - lo[c]) * a;
            img.rgba[i * 4 + c] =
                (unsigned char)(v < 0 ? 0 : v > 1 ? 255 : v * 255.0f + 0.5f);
        }
        img.rgba[i * 4 + 3] = 255;
    }
    EnterCriticalSection(&rt_lock);
    rt_texs[matname] = img;
    LeaveCriticalSection(&rt_lock);
    baked_ver[matname] = t.ver;
    return matname;
}

static void emit_geogroup(VFrame &f, unsigned gg, const M16 *world) {
    VCol col = { { 1.0f, 0.0f, 1.0f } };                /* missing = magenta */
    unsigned matname = 0;
    std::map<unsigned, unsigned>::const_iterator gmi = S.ggmat.find(gg);
    if (gmi != S.ggmat.end()) {
        matname = gmi->second;
        std::map<unsigned, VCol>::const_iterator mi = S.mat.find(matname);
        if (mi != S.mat.end()) col = mi->second;
    }
    unsigned texkey = matname ? bake_material_tex(matname) : 0;
    std::map<unsigned, std::vector<unsigned> >::const_iterator ci =
        S.children.find(gg);
    if (ci == S.children.end()) return;
    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;
        const std::vector<float> *uv = NULL;
        const std::vector<float> *nr = NULL;
        std::map<unsigned, std::vector<float> >::const_iterator ui =
            S.uvs.find(geo);
        if (ui != S.uvs.end() && ui->second.size() * 3 == vv.size() * 2)
            uv = &ui->second;
        std::map<unsigned, std::vector<float> >::const_iterator ni =
            S.nrms.find(geo);
        if (ni != S.nrms.end() && ni->second.size() == vv.size()) {
            /* only meaningful if not all-zero (stride < 8 stores zeros) */
            for (size_t z = 0; z < ni->second.size(); z++)
                if (ni->second[z] != 0.0f) { nr = &ni->second; break; }
        }
        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);
            poly.matkey = (texkey && uv) ? texkey : 0;
            for (size_t j = 0; j < idx.size(); j++) {
                size_t o = (size_t)idx[j] * 3;
                if (o + 2 >= vv.size()) continue;
                float out[3];
                if (world) m16_xform(*world, &vv[o], out);
                else { out[0] = vv[o]; out[1] = vv[o + 1]; out[2] = vv[o + 2]; }
                poly.xyz.push_back(out[0]);
                poly.xyz.push_back(out[1]);
                poly.xyz.push_back(out[2]);
                if (poly.matkey) {
                    poly.uv.push_back((*uv)[(size_t)idx[j] * 2]);
                    poly.uv.push_back((*uv)[(size_t)idx[j] * 2 + 1]);
                }
                if (nr) {
                    float nout[3];
                    if (world) m16_xform_dir(*world, &(*nr)[o], nout);
                    else { nout[0] = (*nr)[o]; nout[1] = (*nr)[o + 1];
                           nout[2] = (*nr)[o + 2]; }
                    poly.nrm.push_back(nout[0]);
                    poly.nrm.push_back(nout[1]);
                    poly.nrm.push_back(nout[2]);
                }
            }
            if (poly.nrm.size() != poly.xyz.size()) poly.nrm.clear();
            if (poly.xyz.size() >= 9) f.polys.push_back(poly);
        }
    }
}

static void scene_publish_frame(void) {
    VFrame f = S.view;
    f.valid = true;
    f.ydown = !S.dcs_mat.empty();     /* game path: world is y-down */
    /* Game (full DPL) path: instances are list_add children of DCS nodes;
     * instance -> object -> lod -> geogroup -> geometry, transformed by the
     * dcs_link articulation tree. */
    std::map<unsigned, M16> cache;
    std::map<unsigned, bool> gg_done;
    for (std::map<unsigned, std::vector<unsigned> >::const_iterator di =
             S.children.begin(); di != S.children.end(); ++di) {
        if (S.type.count(di->first) == 0 || S.type[di->first] != 5) continue;
        M16 world;
        dcs_world(di->first, cache, world);
        for (size_t i = 0; i < di->second.size(); i++) {
            unsigned inst = di->second[i];
            std::map<unsigned, unsigned>::const_iterator oi =
                S.inst_object.find(inst);
            if (oi == S.inst_object.end()) continue;
            std::map<unsigned, std::vector<unsigned> >::const_iterator li =
                S.children.find(oi->second);
            if (li == S.children.end() || li->second.empty()) continue;
            /* First LOD child: the host maintains the active LOD at the list
             * head (the wire lod flush carries no switch distances -- LOD
             * selection is host-side). */
            unsigned lod = li->second[0];
            std::map<unsigned, std::vector<unsigned> >::const_iterator ggi =
                S.children.find(lod);
            if (ggi == S.children.end()) continue;
            for (size_t g = 0; g < ggi->second.size(); g++) {
                emit_geogroup(f, ggi->second[g], &world);
                gg_done[ggi->second[g]] = true;
            }
        }
    }
    /* flyk (flat) path: geogroups with geometry directly, no instance */
    for (std::map<unsigned, unsigned>::const_iterator gi = S.ggmat.begin();
         gi != S.ggmat.end(); ++gi)
        if (!gg_done.count(gi->first)) emit_geogroup(f, gi->first, NULL);
    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) {
        for (size_t o = 0; o + 3 < nb; o += 4)
            S.geom_acc.push_back(rd_f32(d + o));
        if (S.geom_acc.size() >= S.geom_need * S.geom_stride) {
            std::vector<float> &vl = S.verts[S.geom_node];
            std::vector<float> &tl = S.uvs[S.geom_node];
            std::vector<float> &nl = S.nrms[S.geom_node];
            vl.clear(); tl.clear(); nl.clear();
            /* record layout by stride: 5 = xyz+uv,
             * 8/9 = xyz + normal(3..5) + uv(6..7) (+extra) */
            size_t uvo = (S.geom_stride >= 8) ? 6 :
                         (S.geom_stride == 5) ? 3 : 0;
            bool has_n = S.geom_stride >= 8;
            for (size_t i = 0; i + 2 < S.geom_need * S.geom_stride;
                 i += S.geom_stride) {
                vl.push_back(S.geom_acc[i]);
                vl.push_back(S.geom_acc[i + 1]);
                vl.push_back(S.geom_acc[i + 2]);
                if (uvo && i + uvo + 1 < S.geom_acc.size()) {
                    tl.push_back(S.geom_acc[i + uvo]);
                    tl.push_back(S.geom_acc[i + uvo + 1]);
                } else { tl.push_back(0); tl.push_back(0); }
                if (has_n && i + 5 < S.geom_acc.size()) {
                    nl.push_back(S.geom_acc[i + 3]);
                    nl.push_back(S.geom_acc[i + 4]);
                    nl.push_back(S.geom_acc[i + 5]);
                } else { nl.push_back(0); nl.push_back(0); nl.push_back(0); }
            }
            S.geom_acc.clear();
            S.geom_active = false;
        }
        return;
    }
    if (S.tex_active && action == 26) {
        for (size_t o = 0; o < nb; o++) S.tex_acc.push_back(d[o]);
        if (S.tex_acc.size() >= S.tex_need) {
            VTex &t = S.tex[S.tex_node];
            t.px.assign(S.tex_acc.begin(), S.tex_acc.begin() + S.tex_need);
            t.ver++;
            S.tex_acc.clear();
            S.tex_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;
                /* texmap / ramp refs: identify fields by node type */
                for (size_t o = 8; o + 3 < nb; o += 4) {
                    unsigned val = rd_u32(d + o);
                    if (!val || val == 0xFFFFFFFFu) continue;
                    std::map<unsigned, unsigned>::const_iterator ti =
                        S.type.find(val);
                    if (ti == S.type.end()) continue;
                    if (ti->second == 12) S.mat_texmap[name] = val;
                    else if (ti->second == 14) S.mat_ramp[name] = val;
                }
            } else if (t == 12 && nb >= 12) {           /* texmap -> texture */
                for (size_t o = 8; o + 3 < nb; o += 4) {
                    unsigned val = rd_u32(d + o);
                    if (!val || val == 0xFFFFFFFFu) continue;
                    std::map<unsigned, unsigned>::const_iterator ti =
                        S.type.find(val);
                    if (ti != S.type.end() && ti->second == 13) {
                        S.texmap_tex[name] = val;
                        break;
                    }
                }
            } else if (t == 14 && nb >= 36) {           /* ramp lo/hi RGB */
                VRamp &r = S.ramp[name];
                for (int i = 0; i < 3; i++) {
                    r.lo[i] = rd_f32(d + 8 + i * 4);
                    r.hi[i] = rd_f32(d + 20 + i * 4);
                }
            } 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);
            } else if (t == 5 && nb >= 132) {           /* dcs: 4x4 at f[4..19] */
                M16 &mm = S.dcs_mat[name];
                for (int i = 0; i < 16; i++) mm.m[i] = rd_f32(d + 16 + i * 4);
                for (int r = 0; r < 3; r++) mm.m[r * 4 + 3] = 0.0f;
                mm.m[15] = 1.0f;
            } else if (t == 4) {                        /* instance: object ref */
                for (size_t o = 8; o + 3 < nb; o += 4) {
                    unsigned val = rd_u32(d + o);
                    if (val && val != 0xFFFFFFFFu) {
                        std::map<unsigned, unsigned>::const_iterator ti =
                            S.type.find(val);
                        if (ti != S.type.end() && ti->second == 7)
                            S.inst_object[name] = val;
                    }
                }
            }
            break;
        }
        case 7:  /* dcs_link [parent][child]: articulation tree */
            if (nb >= 8) S.dcs_parent[rd_u32(d + 4)] = rd_u32(d);
            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: [name][0][n_verts][stride_floats].. */
            if (nb >= 36) {
                S.geom_node = rd_u32(d);
                S.geom_need = rd_u32(d + 8);
                S.geom_stride = rd_u32(d + 12);
                if (S.geom_stride < 3 || S.geom_stride > 16) S.geom_stride = 3;
                S.geom_active = S.geom_need > 0;
                S.geom_acc.clear();
                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 26: /* texel upload header [node][nbytes][w][h]... (8bpp) */
            if (nb >= 16) {
                unsigned node = rd_u32(d), nbytes = rd_u32(d + 4);
                unsigned tw = rd_u32(d + 8), th = rd_u32(d + 12);
                if (nbytes && nbytes <= (1u << 20) && tw && th &&
                    tw <= 1024 && th <= 1024 && (size_t)tw * th <= nbytes) {
                    S.tex_node = node;
                    S.tex_need = nbytes;
                    S.tex_active = true;
                    S.tex_acc.clear();
                    VTex &t = S.tex[node];
                    t.w = (int)tw; t.h = (int)th;
                }
            }
            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.inst_object.clear(); S.dcs_mat.clear(); S.dcs_parent.clear();
    S.view = VFrame();
    S.geom_active = false; S.conn_active = false;
    S.geom_acc.clear();
}

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

/* ================= VDB: VWE LBE4 video splitter board ==================== *
 * ISA card (I/O 0x300-0x31A) that taps the PC's Cirrus Logic VGA output off
 * the feature connector and fans the single framebuffer out to the six
 * secondary cockpit displays (5 mono + 1 color), dividing the pixel clock.
 * Register map from the driver (CODE/RP/MUNGA_L4/L4SVGA16.ASM + L4VB16.CPP;
 * "Adam's port decoder design" -- Adam G., VWE hardware):
 *
 *   0x300 / 0x308 / 0x310   three palette register groups, each VGA-DAC-like:
 *        +0 write-address   +1 data (R,G,B triplets)  +2 pixel-mask  +3 read-address
 *   0x319  write => splitter high-color clock divider OFF (VWE_HC_OFF)
 *   0x31A  write => splitter high-color clock divider ON  (VWE_HC_ON)
 *
 * The game only WRITES to the VDB (fire-and-forget, no status/ACK -- confirmed
 * from the driver and by the hardware owner). This device records the palette
 * contents + clock-divider state so the six-display encoding can be decoded
 * later; reads return 0xFF. Active when VPXLOG is set; logs to the same file. */
static const io_port_t VDB_BASE = 0x300;

struct VDBPalette { unsigned char waddr, raddr, sub, mask; unsigned char ram[768]; };
static VDBPalette vdb_pal[3];
static bool vdb_splitter_on = false;
/* lazy coalescing of palette data-byte writes so a 768-byte load is one line */
static int vdb_data_group = -1;
static unsigned long vdb_data_count = 0;

static int vdb_group_of(io_port_t off) {   /* group index for 0x300/0x308/0x310 */
    if (off <= 3) return 0;
    if (off >= 8 && off <= 11) return 1;
    if (off >= 16 && off <= 19) return 2;
    return -1;
}
static void vdb_flush_data(void) {
    if (vpx_fp && vdb_data_group >= 0 && vdb_data_count) {
        flush_run();
        fprintf(vpx_fp, "# VDB pal%d loaded %lu data bytes\n",
                vdb_data_group, vdb_data_count);
        fflush(vpx_fp);
    }
    vdb_data_group = -1; vdb_data_count = 0;
}
static void vdb_note(const char *msg) {
    if (vpx_fp) { vdb_flush_data(); flush_run();
        fprintf(vpx_fp, "# VDB %s\n", msg); fflush(vpx_fp); }
}

static void vdb_write(Bitu port, Bitu val, Bitu /*iolen*/) {
    io_port_t off = (io_port_t)port - VDB_BASE;
    unsigned char v = (unsigned char)val;
    if (port == 0x319) { vdb_splitter_on = false; vdb_note("splitter clock OFF (0x319)"); return; }
    if (port == 0x31A) { vdb_splitter_on = true;  vdb_note("splitter clock ON  (0x31A)"); return; }
    int g = vdb_group_of(off);
    if (g < 0) return;
    VDBPalette &p = vdb_pal[g];
    switch (off & 3) {
        case 0: /* write-address */
            vdb_flush_data();
            p.waddr = v; p.sub = 0;
            if (vpx_fp) { flush_run();
                fprintf(vpx_fp, "# VDB pal%d write-addr=%u\n", g, v); fflush(vpx_fp); }
            break;
        case 1: /* data: R,G,B triplet with auto-increment */
            p.ram[((unsigned)p.waddr * 3 + p.sub) % 768] = v;
            if (++p.sub == 3) { p.sub = 0; p.waddr++; }
            if (vdb_data_group != g) { vdb_flush_data(); vdb_data_group = g; }
            vdb_data_count++;
            break;
        case 2: /* pixel-mask */
            p.mask = v; vdb_note("pixel-mask set");
            break;
        case 3: /* read-address */
            p.raddr = v; p.sub = 0; vdb_note("read-addr set");
            break;
    }
}
static Bitu vdb_read(Bitu port, Bitu /*iolen*/) {
    /* the game does not read the VDB; provide DAC-style read-back anyway. */
    io_port_t off = (io_port_t)port - VDB_BASE;
    int g = vdb_group_of(off);
    if (g >= 0 && (off & 3) == 1) {
        VDBPalette &p = vdb_pal[g];
        unsigned char v = p.ram[((unsigned)p.raddr * 3 + p.sub) % 768];
        if (++p.sub == 3) { p.sub = 0; p.raddr++; }
        return v;
    }
    return 0xFF;
}
static void vdb_reset(void) {
    memset(vdb_pal, 0, sizeof vdb_pal);
    vdb_splitter_on = false; vdb_data_group = -1; vdb_data_count = 0;
}

void VPXLOG_Init(void) {
    const char *env = getenv("VPXLOG");
    if (env == NULL || env[0] == '\0') return;

    const char *path = (strchr(env, '/') || strchr(env, '\\') ||
                        strchr(env, '.')) ? env : "vpxlog.txt";
    vpx_fp = fopen(path, "w");
    if (vpx_fp == NULL) { LOG_MSG("VPXLOG: cannot open '%s'", path); }

    const char *r = getenv("VPX_RESPOND");
    vpx_respond = (r && r[0] && r[0] != '0');
    const char *h = getenv("VPX_HANDSHAKES");
    if (h && atoi(h) > 0) vpx_max_handshakes = atoi(h);
    const char *ma = getenv("VPX_MAX_ACKS");
    if (ma && atoi(ma) > 0) vpx_max_postboot_acks = atoi(ma);

    const char *fd = getenv("VPX_FIFODUMP");
    if (fd && fd[0]) {
        fifo_dump_fp = fopen(fd, "wb");
        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);

    /* VDB video splitter board (0x300-0x31A). Registered whenever we are
     * logging, unless VDB=0. Records palette + splitter-clock state. */
    const char *vdbenv = getenv("VDB");
    if (!(vdbenv && vdbenv[0] == '0')) {
        vdb_reset();
        IO_RegisterReadHandler(VDB_BASE, vdb_read, IO_MB, 0x1B);   /* 0x300-0x31A */
        IO_RegisterWriteHandler(VDB_BASE, vdb_write, IO_MB, 0x1B);
        if (vpx_fp) { fprintf(vpx_fp,
            "# VDB video splitter board at 0x%03X-0x31A (palettes 300/308/310, clock 319/31A)\n",
            VDB_BASE); fflush(vpx_fp); }
    }

    if (vpx_fp) {
        fprintf(vpx_fp, "# VPX link adapter, base 0x%03X, respond=%d handshakes=%d\n",
                VPX_BASE, vpx_respond ? 1 : 0, vpx_max_handshakes);
        fprintf(vpx_fp, "# seq  dir  register      value  [run]\n");
        fflush(vpx_fp);
    }
    LOG_MSG("VPXLOG: base 0x%03X respond=%d handshakes=%d log='%s'",
            VPX_BASE, vpx_respond ? 1 : 0, vpx_max_handshakes, vpx_fp ? path : "(none)");
}
