# Phase 3 — Render Backend: Progress

**Status (2026-07-03): Phase 3a + 3b + 3c 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. 3c — the
production `vr_sync` abort is fixed and the full BattleTech v4.10 game now runs
indefinitely through the emulated board, drawing 1000s of frames (was hard-
capped at 200). First images ever produced from the Rel 4.10 VPX protocol
without a real board. Next (3d): traverse the game's DPL hierarchy + DCS
transforms so its mechs actually appear.**

![decoded DIVRGB calibration screen](divrgb-decoded.png)

That is `flyk divrgb.scn` — Division's SMPTE color-bar calibration scene —
rendered *entirely from the FIFO command stream captured by the emulated VPX
board*: geometry, connectivity, materials, camera, viewport and background all
come from the wire, none from the scene files. `divrgb-frame0.png` is the same
capture drawn with the actual frame-0 spline camera the app sent.

## 3a. Full FIFO capture

`vpxlog.cpp` now records every FIFO burst when `VPX_FIFODUMP=<path>` is set
(records: `'VPXM'` magic, u32 length, raw bytes; one record per burst between
`outputData` tag writes). The capture used here: `divrgb.fifodump`
(1297 messages) from `divrgb.conf`.

Tools:

- `decode_fifodump.py <dump> [--hex N] [--action A]` — action census + payload
  hexdumps.
- `render_capture.py <dump> [-o out.png] [--frame N] [--eye x,y,z]` —
  reconstructs the scene graph and software-renders the frame each
  `draw_scene` commits.

## The Rel 4.10 wire protocol (established from this capture)

One burst per message: `[action:4][payload]`, packetized at 508 bytes.
The i860 image download itself rides the FIFO in this build (actions 18–20,
`VREND.MNG`). The DPL3 `vr_action` enum holds for 0–23 but Rel 4.10 extends
and re-purposes the tail:

| action | meaning | payload |
|---|---|---|
| 0 | init | args string (`/device~0x150~/video~svga~…`) |
| 1 | create | `[type][name]` — **the host assigns node names** (1,2,3…); the board's create reply node value is ignored |
| 3 | flush | `[name][type][node struct]` (see node types below) |
| 7 | dcs_link | `[parent][child]` |
| 9 | draw_scene | `[view?][0][0][1][1.0f]` — commits the frame |
| 11 | list_add | `[parent][child]` |
| 18–21 | 860code/data/bss/args | i860 download (FIFO in this build) |
| 23 | **set_geom_verts** (Rel4.10; DPL3 had 22) | hdr `[name][0][n_verts][3][n_blocks][1][5][n_verts][1.0f]`, then float32 x,y,z per vertex |
| 25 | **set_geom_conns** (new) | hdr `[name][n_polys][loop_len][0]`, then indices; each poly a closed loop (last=first) |
| 31 | **camera** (new) | `[?][view][3×3 rotation, row-major][eye x,y,z]` per frame |
| 45 (0x2D) | sync | token ping (see PHASE2) |

Node types (from create/flush pairs in this capture):

| type | node | flush payload highlights |
|---|---|---|
| 2 | texture/ramp? | one word, `0xFFFFFFFF` / `0x0FFFFFFF` |
| 3 | view | window l,b,r,t (±1 × ±0.6154), window-plane distance 1.3, viewport 832×512, near 2, far 12000, background RGB |
| 4 | light | |
| 5 | dcs | 4×4 matrix |
| 6 | material (old-style) | RGB floats |
| 7 | object | |
| 8 | lod | 2×4 bounds + lists |
| 9 | geogroup | bounds; **payload int 14 = material node name** |
| 10 | geometry | `[geo_type][n_polys][n_verts]…[radius]` |
| 11 | material | **floats 10–12 (payload) = diffuse RGB** |

Graph: `list_add` links object→lod→geogroup→geometry (and zone-level nodes to
node 0); geogroups bind materials by name. The 13 DIVRGB bars decode to the
textbook SMPTE pattern — 7 color bars, the reverse strip, and the −I/white/+Q
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.)

## 3b. Live OpenGL backend (DONE)

![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.

## 3d. The game's own world decodes and renders — LIVE

![live GL window: BattleTech cockpit view](game-live-gl.png)

The live backend now draws the game's actual out-the-window view in real
time: sky, the arena floor receding to the horizon, and the player's own gun
barrels at frame bottom (static scene — without a RIO the sim doesn't
advance). Same frame decoded offline below.

![decoded BattleTech mech from the game's wire stream](game-mech-decoded.png)

That is an enemy mech (object 1048, 488 verts) standing in the mission arena —
reconstructed **entirely from the game's captured FIFO stream** by
`render_game.py` (real hull/armor/cockpit-glass materials from the wire; the
half-buried look is the offline painter's-algorithm artifact, which the live
GL depth buffer doesn't have). `game-cockpit-decoded.png` is the actual
cockpit camera: the arena floor to the horizon with the player's own gun
barrels rising at frame bottom.

What the game adds over flyk's flat scene (all now handled, offline +
live backend):

- **Stride-aware vertices.** `set_geom_verts` header word 3 = floats per
  vertex: 3 (xyz), 4, 5 (xyz+uv), 8 (xyz+normal+uv), 9. Mech meshes carry
  normals and texture coordinates — lighting/texturing data is on the wire.
- **Full DPL hierarchy.** Instances are `list_add` children of DCS nodes
  (dcs→instance); instance flush field 4 references the object (type 7);
  object→lod→geogroup→geometry via list_add. `dcs_link` (action 7) builds the
  articulation tree (mech torso/arms/legs), each DCS a 4×4 at payload floats
  4–19 (row-major, row 3 = translation).
- **The world is y-down** (the DCS matrices carry a y reflection; game coords
  vs Division's). The renderer flips both x (Division mirror) and y.
- **Mission scene scale**: 10 km arena of 1000-unit ground tiles, 246 placed
  instances, 330 geometries, 280 materials; the player's Thor (644 verts,
  articulated sub-parts as sibling instances) sits exactly at the camera;
  six enemy mechs stand ~1.5 km north.
- Camera: action 31 (rotation + eye at the cockpit position); with the sim
  stalled (no RIO) it arrives once and the scene is static.

Offline tool: `render_game.py <fifodump>` (near-plane clipping, hierarchy
traversal). Fixture: a 954-frame capture. The live backend (`vpxlog.cpp`)
gained the same traversal: stride-aware geometry, dcs/instance/object link
decode, cached world transforms per DCS, y-down projection.

### 3e. Texturing (DONE) — the texture chain

![live textured terrain](game-live-textured.png)

The wire texture model is Division's intensity+ramp scheme (no palette ever
crosses the link):

- **action 26** uploads texels: header `[node][nbytes][w][h]` then 8-bit
  intensity rows (64×64…128×128; node type 13 = texture).
- **texmap (type 12)** flush references its texture node; **material (type
  11)** flush references its texmap *and* a **ramp (type 14)**: two RGBs
  (lo/hi), e.g. dark red-brown → light tan for mech hulls.
- Final texel color = `lerp(ramp_lo, ramp_hi, intensity/255)`.

The live backend bakes each textured material's RGBA once (texture × ramp),
uploads it to GL keyed by material, and maps it with the wire UVs (stride-5
verts: floats 3–4; stride-8/9: floats 6–7). Result: the arena renders with
the ravine's actual brown dirt textures, live (`game-live-textured.png`).

### 3f. Lighting + the shipped-in debug crash

Lighting: stride-8/9 vertices carry a **normal** at floats 3–5 (uv at 6–7).
The backend transforms them by the instance world matrix (rotation only) and
lights with a single directional "sun" (`GL_LIGHT0`, smooth shading,
two-sided, `GL_COLOR_MATERIAL` so texture/material color survives). Terrain
and hulls now shade instead of reading flat.

LOD note: the `lod` flush carries **no switch distances** — the host game
keeps the active LOD at the head of the object's child list and re-orders it
over the wire, so `children[0]` is correct and LOD changes as the sim runs
come through for free.

**The crash that halted the game with the RIO in sync was not ours.** Fault
`Exception 0E at 0047E1D1` writing to `0xFFFFFFFF` is a debug landmine the
build shipped with: the RIO serial driver's `DISABLE_AND_DIE` macro
(`PCSPAK.ASM`, gated by `DIE_ON_ERROR equ 1`) deliberately faults on any
serial-transmit anomaly. Error code 3 = a body char > 0x7F during a RIO
packet (`PCSPAK.ASM:1630`) — i.e. a serial glitch during a board reset. The
release build (`DIE_ON_ERROR equ 0`) compiles it out and takes the recovery
path (`and al,07Fh`). `patch_btl4opt.py` finds all 12 sites by their 14-byte
signature and NOPs them — exactly the release behavior — with a `.orig`
backup. After patching, the game survives RIO resets (1200+ frames).

## 3c. The full game runs through the live renderer (sync abort fixed)

**The production `vr_sync` abort is fixed, and BattleTech v4.10 now runs
indefinitely through the emulated board + live renderer.** Root cause was a
Phase-2 bring-up guard left in the device: `vpx_max_postboot_acks = 200`
capped how many post-boot replies the device would ever feed. A real game
session issues an unbounded stream of sync/frame/render replies; the game
aborted with `velocirender_receive timed out — sends_wo_rcv` the instant the
device fell silent — at *exactly* the 200th ack (measured: 143 sync + 55
frame-ack + 2 render = 200). The cap is now effectively unlimited
(`0x7fffffff`, override with `VPX_MAX_ACKS`). Same run after the fix: 2500+
syncs, 1264+ frame-acks, no abort. The game progresses past sync into
`L4VIDEO.cpp` content/entity setup (loading the Thor mech and terrain) and
draws continuously.

Contrary to the earlier note, the content **is** on the production image: 841
`.bgf` models under `ALPHA_1/REL410/BT/VIDEO/GEO` (incl. `BUTTEE.BGF`,
`MSLR.BGF`, the `THR*` Thor mech). `test.egg` is only mission parameters (as
the pod owner said); the geometry loads from `VIDEO/GEO` regardless.

Game render config: `game.conf` (RIO disabled so the missing cockpit board
doesn't stall the control manager; `cycles=max`).

### What the game's wire stream looks like (vs. flyk)

The game geometry path is heavier and different from flyk's flat DIVRGB:
- **action 26 (0x1A) dominates** — texture texel upload. 32-byte headers
  `[node][size][w][h]` (`0x1000`=64×64, `0x4000`=128×128 tiles) then 256-byte
  texel rows. 2880+ of these = the mission texture set streaming in.
- action 28 (0x1C): material/light parameter blocks.
- actions 23/25 (verts/conns) still carry mesh geometry but are a small
  fraction of the stream.
- The scene graph is the **full DPL hierarchy** — zone → dcs → instance →
  object → lod → geogroup → geometry — with **DCS transform matrices**
  positioning each mech/prop. flyk's DIVRGB put geometry directly in
  geogroups at world coordinates.

## Remaining (Phase 3d: game geometry on screen)

The window currently shows only the view background for the game because the
live backend's `scene_publish_frame()` walks the *flat* flyk layout
(geogroup→geometry at world coords). To draw the game's mechs it must:

1. **Traverse the full hierarchy** (instance/object/lod/geogroup) and **apply
   DCS transform matrices** (accumulate the 4×4s down the graph) so each mech
   lands at its world position; pick a LOD.
2. **Arena content search path**: the game logs `couldn't load object
   thr_tshd.bgf` — that object lives in `VIDEO/GEO/ARENA/` and `…/POLAR/`
   (arena-specific), not the top `GEO` dir; the geometry search path needs the
   selected arena subdir (a `setenv`/config detail, not protocol).
3. **Texturing** (action 26 texel maps + SVT), lighting, depth/material.
   Current backend is flat-shaded untextured polys.
4. **DOS/4GW sync variant** for the CYCLE `flyk yip.scn` fixture (Red Planet
   geometry) — the older action-check sync.
