BTL4.RES stores each watcher as a (SUBSYSTEM, ATTRIBUTE) string pair, so the complete list can be extracted directly instead of discovering one name per 6-minute run. Banked the extraction method and the full normalised table. Sixth rung climbed: Torso/MotionState (staged member, appended at the end of Torso's own range so no downstream id moves). LocalVelocity and LocalAcceleration turn out to need nothing -- the engine's Mover already publishes them. Also banked, and deliberately NOT acted on: ReportLeak and ConfigureActivePress are base-class attributes inside the pinned range, and chasing them turned up what looks like the authentic attribute layout. The shipped pool gives MechSubsystem 1, HeatableSubsystem 3, HeatSink 6, PoweredSubsystem 5 -- which counted from Subsystem::NextAttributeID=2 lands MechWeapon's real ids on the binary-pinned 0x12 with EXACTLY ONE pad, where our tree needs three. That arithmetic is strong evidence for the original layout, and reconciling to it would replace three guessed pads with the real table. But it moves ids underneath a cockpit currently verified at 98.9% pixel-identical, so it wants a deliberate session with the A/B rig open rather than a bulk edit at the end of a long one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
The render roadmap — from headless to visible (researched 2026-07-24)
Four-dossier workflow synthesis (card protocol / video-TU delta / emulator side / gauge path). The headline: the render seam is far closer than expected — the engine half of BOTH render paths already compiles and links into our btl4opt.exe from the CODE originals, and the emulator's board-side device is EXE-agnostic.
The two independent render paths (either can run without the other)
- The 3D main view (division card): the game speaks dPL — a
RETAINED-mode scene graph API (never pixels, never transformed polys) —
through the statically linked libDPL, which frames every call into
vr_action messages on the INMOS C012 link adapter at port 0x150
(FIFO fast path: body as REP OUTSW words to 0x154, trailing length word
to 0x151 commits). Gate:
DPLARGenv (set by SETENV.BAT when L4VIDEO != OFF); NULL DPLARG = the headless shape we run today (MakeVideoRenderer()returns NULL, tolerated).- Per-frame submission: engine code —
DPLRenderer:: ExecuteImplementation(CODE/RP/MUNGA_L4/L4VIDEO.CPP:5065) walks the dplRenderableSocket; renderables (L4VIDRND.CPP, ~30 classes) set DCS matrices;dpl_FlushDCSArticulations= wire action 0x1f (batched poses, absolute 3x3+T or [sin,cos] joint records);vr_draw_scene(9) kicks the frame;velocirender_sync(0x2d, exe @0x48D220) is the ack-less bulk-op fence. - Our build ALREADY COMPILES this whole engine layer (mungal4.lib: l4video/l4vidrnd/l4dplmem + prebuilt 1995 STARTDPL.obj + LIBDPL.LIB; see build410 obj logs). The missing piece is GAME code: btl4vid (BTL4VideoRenderer — MakeEntityRenderables/MakeMechRenderables/ effects/wreck swap/material substitution; BT411 donor 3039/848 lines with per-method @ADDR evidence, binary extent @4cc40c..@4d2bbc; our stub Fails at LoadMissionImplementation).
- Per-frame submission: engine code —
- The gauge path (cockpit instruments): completely separate hardware —
the PC's own SVGA (VESA 0x111, 640x480x16, L4SVGA16.ASM banked blits),
split across VGA heads by the VDB board (port 0x300, palette groups; the
octopus-cable 5-mono-MFDs + color-radar decode). Gate:
L4GAUGEenv. The plasma readout is a gauge-renderer graphics port (PlasmaDisplay on COM2), not a separate renderer.- Engine half (GaugeRenderer/L4GaugeRenderer/the 5171-line L4GAUGE widget interpreter/GAUGMAP/L4VB16) already compiles + links in build410; one documented deviation (interpreter table 46864 -> 262144 for BT's larger CFG).
- Missing: the 6 BT gauge TUs — btl4gaug (130 fn) / btl4gau2 (77) / btl4gau3 (46) / btl4rdr (36) / btl4grnd (13, ours is a 4-fn stub) / btl4galm — ~302 functions/~43KB, binary extent 0x4c19fc-0x4cc40c, the largest function-count block left in btl4.lib. BT411 donors are COMPLETE (semantic .cpp/.hpp with @ADDR evidence, ~5900 donor lines); the work is RE-HOSTING onto 1995 engine APIs, not fresh decompilation. Class inventory per TU in the dossier (widget primitives / composite clusters / MP-HUD / radar MapDisplay / the renderer / alarm manager).
The emulator side is READY and EXE-agnostic
The DOSBox-X fork's VPX device (emulator/vpx-device/vpxlog.cpp, ports 0x150-0x161 + VDB 0x300-0x31A) implements the BOARD side purely at the wire level: blind firmware absorb, iserver handshake (VPX_RESPOND=1), generic acks, the 0x2d sync echo, draw_scene frame-acks with a REAL raycast reticle pick against its own decoded scene, and the GL renderer (VPX_RENDER=1) that draws the main view + cockpit windows. It never reads game memory and keys only on wire contents — our reconstructed exe drives it unmodified once it emits the same traffic. The AWE32 pair is mandatory for any timed run (SOS FAST clock); gauges also render live on the emulated S3 with the shipped exe (GAUGE-NOTES.md: HEAPSIZE=15000000 + granularityInKB=64).
The plan (order of value)
- Gauges-only first (decisively cheaper): set
L4GAUGE=640x480x16(+ HEAPSIZE), reconstruct the 6 gauge TUs from the BT411 donors. Built-in oracle: the shipped exe renders reference output from the SAME GAUGE content — per-head diffable. Live instruments need the attribute sources, which the 5.3.x waves have largely built (weapon states, heat, power, damage, radar). - Then btl4vid (the 3D world renderer) + DPLARG on: the engine DPLRenderer + libDPL do the wire; btl4vid builds the renderables per entity; verify against the VPX device's GL window and the dpl3-revive bridge.
- The review/playback pair (btl4pb + the BTL4.CPP review branch) closes the last absent TUs.
Message-id / protocol quick refs: vr_create=1 (host-assigned handles), vr_flush=3 (node structs; VIEW proves 832x512), geometry 0x17/0x19/0x1a (texel word [pad,B,G,R]), artics 0x1f, draw 9, sync 0x2d, fire 0x23, reticle 0x26. DPLARG reference value in emulator/baseline.conf:21.
================================================================================ 2026-07-27 -- THE 3-D PATH RUNS TO MISSION LOAD (measured, not estimated)
Our reconstruction, run against the emulated Division card, gets ALL THE WAY to the mission scene load before it stops -- at the one method we stubbed.
c:/.../bt_l4/btl4vid.cpp(42): BTL4VideoRenderer::LoadMissionImplementation
-- btl4vid.cpp not yet reconstructed
What that single line proves is working end to end in OUR build:
- BTL4Application::MakeVideoRenderer builds a BTL4VideoRenderer
- the BTL4VideoRenderer ctor chains the engine's DPLRenderer
- the board boots: transputer + i860 firmware upload, ~858K wire transactions logged by the VPX device HLE
- Renderer::LoadMission runs (RENDERER.CPP:273) and calls the hook
So btl4vid is NOT a from-scratch climb. The engine half was always linked; what is missing is the game's mission-load hook and the renderables it builds.
HOW TO REPRODUCE (this is the rig, and it is cheap): emulator/vidtest.conf -- baseline.conf with the mount made absolute and BTL4REC.EXE (our build, staged beside the shipped BTL4OPT.EXE which is NOT touched) instead of the shipped exe. Host env before launching dosbox-x:
VPX_RESPOND=1 VPX_RENDER=1 VPX_NOMAIN=1 VPXLOG=<file> VPX_DUMPDIR=<dir>
VPX_RESPOND alone is NOT enough -- with only that, the board boot dies at "Protocol error : length 65535 too big / rcv_protocol fail during iserver handling", which is the iserver handshake reading a floating bus. VPX_NOMAIN=1 keeps it from opening windows; add VPX_EXPLODE=1 only when you want the 7-display pentapus. The full documented pod launch is render-bridge/launch_pod.ps1 (LAUNCH.md) when the GL bridge is wanted.
THE CONTRACT for the next brick (authentic, CODE/RP/MUNGA/RENDERER.CPP:263): Renderer::LoadMission verifies InactiveRendererStatus, sets LoadingRendererStatus, stamps nextRenderTime, calls GetRendererManager()->StartRenderer(this), THEN LoadMissionImplementation. The base implementation Fails ("should never reach here") -- every concrete renderer must override it.
DONOR: BT411 game/reconstructed/btl4vid.cpp, 3188 lines / 27 methods, carrying the renderable family (BTReticleRenderable, BTTranslocationRenderable, the pending-wrecks map). NOTE it does not contain a LoadMissionImplementation by that name -- the WinTesla port restructured the hook -- so take the CONTRACT from the 1995 engine above and the CONTENT from the donor's renderables.
CORRECTION to the note earlier today: a transient 3-D cockpit frame captured during a gauge-rig run was NOT evidence of the dPL path emitting anything. MakeVideoRenderer returns NULL unless DPLARG is set (BTL4APP.CPP:152), and no gauge conf sets it -- so no video renderer exists in those runs at all. That frame was almost certainly attract/loading art out of BTL4.RES. The evidence above is the real thing, and it came from setting DPLARG deliberately.
TWO RUNGS CLIMBED -- the 3-D pipeline now runs end to end to the geometry load
RUNG 1: BTL4VideoRenderer::LoadMissionImplementation, Fail -> bring-up no-op. A no-op is a LEGAL body, not a cheat: the engine's own VideoRenderer::LoadMissionImplementation (CODE/RP/MUNGA/VIDREND.CPP:259) is a bare Tell, and our working GaugeRenderer (GAUGREND.CPP:3275) ships the same. The renderer comes up and runs its frame loop with an EMPTY scene, which is what we wanted to measure before writing content.
RUNG 2: Mech::EyepointRotation PUBLISHED. With the renderer live the engine immediately Failed at
l4video.cpp(5004): DPLRenderer::SetupCull jointed mover had no
EyepointRotation attribute
SetupCull (CODE/RP/MUNGA_L4/L4VIDEO.CPP:4990) fetches "EyepointRotation" BY NAME off the viewpoint entity and, for a JointedMover, composes it with the "siteeyepoint" segment to build worldToEyeMatrix. Our mech already HAD EulerAngles eyepointRotation with a getter (MECH.HPP) -- only the attribute publication was missing. One enum id + one table row. (The siteeyepoint check passed first time, so that segment was already streaming correctly.)
WHERE IT STOPS NOW, and why that is not a defect:
L4VIDEO.cpp couldn't load object buttee.bgf (x123, also dg100/mslr)
NULL instance
ZERO Fails, zero exceptions -- the run reaches the full mech build (all 7 weapons), constructs the renderer, boots the board (~907K VPX wire transactions), loads the mission, runs the per-frame cull and gets all the way into geometry loading. The models ARE present (VIDEO/GEO/BUTTEE.BGF etc, 879 BGFs) and BTDPL.INI points at them (objectpath=.ideo\geo), but loading a .BGF goes through the board to the EXTERNAL GL render bridge that the VPX HLE tees over VPX_FIFOSOCK -- and that bridge was not running. "NULL instance" comes from the prebuilt LIBDPL.LIB, i.e. the library refusing to instance an object that never loaded.
NEXT RUNG: run under the documented full rig (render-bridge/launch_pod.ps1, see LAUNCH.md) so the bridge is up and geometry actually loads. Only then is it worth writing real MakeEntityRenderables content -- until the bridge is in the loop there is nothing to see even if the scene graph is correct.
THE FULL POD RIG: geometry loads, and the ladder is now the AUDIO WATCHER SET
Run under the documented rig (render-bridge/launch_pod.ps1 with the GL bridge up) the geometry complaint COUNT WENT TO ZERO -- .BGF loading was never a reconstruction problem, only a missing bridge, exactly as predicted.
emulator/render-bridge/gauge_arena_rec.conf = gauge_arena_sound.conf with
the exe swapped to BTL4REC.EXE (our build, staged into ALPHA_1/REL410/BT
beside the shipped BTL4OPT.EXE, which is NOT touched).
Launch: .\launch_pod.ps1 -Conf ...\gauge_arena_rec.conf
What blocks now is a SERIES of authored AttributeWatchers. BTL4.RES binds watchers BY NAME and the engine Fails outright on any that does not resolve (WATCHER.CPP:141) -- these are the sound triggers, so every one of them is a name our subsystems must publish.
RUNGS CLIMBED THIS PASS (each one run-verified, ~6 min per cycle): UnstablePercentage Mech, staged member (no instability model yet) SpeedEffect Myomers -- member existed, table published AnimationState Mech -- member existed (bound 307x in the RES!) CollisionState/Normal Mech -- state existed, normal staged ReduceButton Mech, staged SpeedOfTorsoHorizontal Torso -- all six names mapped to real members
THE METHOD THAT MAKES THIS CHEAP (use it instead of one rung per run): The shipped binary's string pool carries each class's attribute names CONTIGUOUSLY, in ID ORDER, right after the class name and its message names. So: strings -a BTL4OPT.EXE | grep -n -A14 '^Torso$' gives the authentic table for that class, and in every case so far our member declarations sit in the SAME ORDER -- which both confirms the reconstruction's layout and lets the ids be pinned rather than guessed. Cross-reference against the resource with
comm -12 <(strings BTL4.RES|grep -x '[A-Z][A-Za-z0-9]\{3,\}'|sort -u) <(strings BTL4OPT.EXE|...|sort -u)
which returns 59 candidate names; the attribute-like ones are the work list.
STILL TO PUBLISH (from that intersection, owner in brackets where known): MotionState [Torso? -- sits between StickPosition and TorsoHorizontalEnabled in the pool, but that region MIXES attribute names with resource-field names, so confirm before publishing] LocalVelocity, LocalAcceleration [Mover/Entity] AmmoState [AmmoBin] CondenserState [Condenser] ReservoirState [Reservoir] GeneratorState [Generator] SimulationState [Subsystem] DisplayMode [ControlsMapper] LookForward/Left/Right/Down/Behind [MechControlsMapper] LaserOn, ReportLeak, FireCountdownStarted, TargetRangeExponent, ConfigureActivePress, StickPosition
A THIRD MISWIRED SharedData FOUND ON THE WAY: Myomers carried Subsystem::MessageHandlers + Subsystem::AttributeIndex where it derives from PoweredSubsystem -- the same defect as MissileLauncher (5.3.33), dropping every powered attribute AND the powered message handlers. Torso is wired to Subsystem's too, but there the chain (PowerWatcher -> HeatWatcher -> MechSubsystem) genuinely publishes nothing, so only its AttributeIndex moved. WORTH A SWEEP: check every subsystem's DefaultData against its actual parent.
TYPES ARE PROVISIONAL on the staged members. AttributeWatcherOf reads (T)attributePointer (WATCHER.HPP:288) and the instantiation comes from the resource, which the string pool does not reveal. Nothing drives these yet so no watcher can fire -- settle the types WITH the models that write them.
THE WATCHER LIST IS NOW FULLY KNOWN -- stop discovering it one run at a time
BTL4.RES stores each watcher as a (SUBSYSTEM, ATTRIBUTE) string pair, so the whole list can be read straight out of the resource instead of one 6-minute run per name:
for each candidate name, find its NUL-terminated occurrences in BTL4.RES
and take the printable string immediately before it -- that is the owning
subsystem. Candidates = strings present in BOTH BTL4.RES and BTL4OPT.EXE
matching ^[A-Z][A-Za-z0-9]{3,}$ (59 of them).
Normalised across instance names (ERMLaser_1/2/3 -> the weapon class, AmmoBinSRM6_1 -> AmmoBin, Condenser1..6 -> Condenser, GeneratorA..D -> Generator), the COMPLETE set the engine will demand is:
Entity AnimationState(306) LocalVelocity(324) SimulationState(74)
CollisionSpeed(72) CollisionState(54) FootStep(36)
IncomingLock(36) UnstablePercentage(36) LocalAcceleration(18)
DistanceToMissile(18) ReduceButton(18) CollisionNormal(18)
weapons WeaponState PercentDone LaserOn ReportLeak
ConfigureActivePress SimulationState
AmmoBin AmmoState FireCountdownStarted SimulationState
Condenser CondenserState ReportLeak
Generator GeneratorOn GeneratorState ReportLeak
Reservoir ReservoirState
Myomers SpeedEffect ConfigureActivePress ReportLeak SimulationState
Avionics(Sensor) ConfigureActivePress ReportLeak SimulationState
ControlsMapper DisplayMode Look{Forward,Left,Right,Down,Behind}
TargetRangeExponent
Torso SpeedOfTorsoHorizontal MotionState
HeatSink CurrentTemperature
DONE so far (6 rungs, each run-verified): UnstablePercentage, SpeedEffect, AnimationState, CollisionState/Normal, ReduceButton, SpeedOfTorsoHorizontal (+ the whole Torso table), MotionState. LocalVelocity / LocalAcceleration need nothing -- the ENGINE's Mover already publishes them (MOVER.CPP:104).
LEAF-SAFE APPENDS (do these next; each goes at the END of a class that nothing chains off, so no id moves): ReservoirState, CondenserState, GeneratorState (maps to the EXISTING stateAlarm), AmmoState + FireCountdownStarted (AmmoBin has no table at all yet), LaserOn (Emitter), TargetRangeExponent (ControlsMapper).
!! ReportLeak AND ConfigureActivePress ARE NOT LEAF-SAFE -- and they reveal the authentic attribute layout. DO NOT bulk-edit these without deciding.
Both belong to BASE classes in the pinned range. The shipped string pool gives each class's attribute names contiguously in id order:
MechSubsystem ConfigureActivePress (1)
HeatableSubsystem CurrentTemperature DegradationTemperature
FailureTemperature (3)
HeatSink NormalizedPressure DegradationPressure
CoolantCapacity CoolantMass
CoolantMassLeakRate ReportLeak (6)
PoweredSubsystem InputVoltage AuxScreenNumber AuxScreenPlacement
AuxScreenLabel EngScreenLabel (5)
Count them from Subsystem::NextAttributeID = 2: 2 | 3..5 | 6..0x0B | 0x0C..0x10 -> NextAttributeID = 0x11 -> MechWeapon needs EXACTLY ONE pad to land its real ids on the binary-pinned 0x12. Our tree needs THREE pads, because our tables differ: we publish HeatLoad (authentic does not), CoolantAvailable (authentic does not), and a different PoweredSubsystem five (OutputVoltage/RatedVoltage/VoltageState/ConnectMode instead of the four AuxScreen* names). The one-pad arithmetic landing exactly on 0x12 is strong evidence the four rows above ARE the original layout.
Reconciling to it is a real improvement -- it would replace three guessed pads with the authentic table -- but it moves ids underneath the gauge cockpit that is currently verified at 98.9% pixel-identical, so it wants a deliberate session with the A/B rig open, not a bulk edit at the end of a long one. Neither HeatLoad nor CoolantAvailable is bound by any gauge (0 hits in L4GAUGE.CFG), so the drop side looks cheap; the risk is the id shift, not the names.