Files
BT412/CLAUDE.md
T
arcattackandClaude Opus 4.8 1746c5bb1f docs: record the map (radar) gauge as done + its follow-ups
GAUGE_COMPOSITE.md + CLAUDE.md: add the map/MapDisplay radar (increment 6, the
view wedge renders) to the done list; note the systemic pattern (a newly-consumed
reconstructed gauge is the first reader of stubbed helpers -- the map exposed 3);
narrow the remaining gauge widgets to PlayerStatus + vehicleSubSystems, and list
the map follow-ups (contact classification / stubbed pip+name infra, SetTargetRange
zoom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:17:52 -05:00

205 KiB
Raw Blame History

Virtual World Pod Games — Port Project (BattleTech / Red Planet)

⚠️ REPO ORIENTATION (bt411): This is the clean standalone bt411 repo — start with README.md for the current build/run and the actual tree layout (engine/ game/ content/ docs/ reference/ tools/). The historical/progressive sections below were written in the original RE workspace and still cite absolute C:/git/nick-games/... paths and its scattered layout (decomp/reconstructed/, elsewhen_extracted/.../MUNGA_L4/, btbuild/, enginebuild/, the pod content tree); those map into this repo as: reconstructed BT → game/reconstructed/, the engine → engine/MUNGA{,_L4}/, content → content/, raw decomp → reference/decomp/. Treat those paths as history; the build of record is the top-level CMakeLists.txt + README.md. Everything else below (decomp findings, reconstruction history, gotchas) is accurate and durable.

Knowledge base + roadmap for porting Virtual World Entertainment's arcade pod games (Tesla platform, release 4.10, ~199596) to run on modern Windows and, ultimately, the actual arcade pod hardware. Primary target: BattleTech (BT); Red Planet (RP) shares the engine.

Companion docs: docs/BGF_FORMAT.md (model format), docs/ASSET_PIPELINE.md (textures/materials/ gauges). The working port code is under port/ (see "Port codebase" below).

📌 This is a PROGRESSIVE CONTEXT system — keep it current. This file is the single, durable source of project context across sessions. Whenever you learn something new and non-obvious about the project — a decompilation finding, a build/runtime gotcha, a reconstruction insight, an offset/format detail, a decision or its rationale, a dead end to avoid — ADD it here (in the most relevant section, or §10 for combat bring-up / reconstruction findings) as part of the same task, not as an afterthought. Update existing statements when reality changes them rather than letting the doc drift. Goal: a later session (or a fresh agent) can read this file and have the full working context with nothing lost to chat history. Durable project facts → here; per-session/cross-cutting reminders → the memory/ files (indexed in MEMORY.md); detailed running findings → docs/RECONCILE.md.


1. What this is

  • Two games share one engine: BT (BattleTech) and RP (Red Planet). Engine = MUNGA; hardware abstraction layer = L4 (MUNGA_L4). Game logic = CODE/BT, CODE/RP/RP.
  • Original platform: 32-bit DOS under a DPMI32 extender, built with Borland C++ 5 + TASM32. Ran on Novell DOS pods.
  • 3D was rendered by a proprietary Division "IG" image-generator board (dual EISA), driven by a closed-source library libDPL.lib (ships as binary only, + VREND*.BTL/.MNG board microcode). The port replaces this with a dpl_* shim on a modern GPU. (Nick: "we'll just yoink that out.")
  • Pods now run Windows 10 with modern-ish GPUs + a Voodoo/Glide wrapper. BT was abandoned; MechWarrior 4 was ported for the current pods. This project revives BT.

2. People / goals

  • arcattack (user): doing the port. Has NVIDIA 3060 + A6000 dev GPUs.
  • Nick: owns the pods + the software license (porting is authorized). Runs the pod hardware.
  • Goal: run on (a) a modern dev box and (b) the fixed pod hardware. Multiplayer (pod-to-pod) wanted.

3. Pod hardware (fixed target)

  • 2 video cards → 7 monitors: main 3D view 800×600; radar 640×480; five monochrome MFDs driven as one 1280×480 horizontally-spanned surface.
  • Requires old NVIDIA drivers for the MFD horizontal spanning — a hard constraint that bounds the graphics API. → Target Direct3D 9 (lowest common denominator that runs on old-driver pods AND modern cards; one maintainable codepath). NOT Vulkan/D3D11+. CUDA is irrelevant (compute, not display).
  • Cockpit I/O ("RIO") = joystick X/Y, throttle, pedals, buttons over serial COM (L4RIO, L4SERIAL). Must be remapped to keyboard/gamepad on a dev box; real wiring is a pod bring-up task.

4. Where the content lives (3 archives, in repo root)

The deployed game data was never in source control — it lived on the pod machines. Spread across:

Archive What it is Has
410srczipped.zip Source code (36MB) all .cpp/.hpp, geometry .bgf, material defs .bmf, animations .ani (428), a few sample textures/skeletons. NO per-mech skins/skeletons.
Tesla4NovellTechPC-*.zip pod technician/test PC (77MB) game binary btrel410.exe (in vgl_labs/archive/), Division VPX board diagnostic tools (vwetest/vpx/), shared generic textures, Novell DOS.
Tesla4PodPCNovell-*.zip the pod game machine (92MB) the full deployed tree at …/CopyofNovellDisk/REL410/ (BT+RP): per-mech skeletons (AVA.SKL etc., 124), skins (AVATAR.BSL etc.), 1143 models, 1624 material libs, textures.
Elsewhen RP411 Source-*.zip the WINDOWS PORT ("WinTesla", VS2008) modernized source: WinTesla.sln; MUNGA(150 cpp)+MUNGA_L4(43); L4D3D.cpp = IG-board→Direct3D renderer bypass DONE; OpenAL+libsndfile = SOS audio replaced; full Red Planet game logic (VTV/VTVMPPR/WEAPSYS/...); asset exporters (MeshExp/TextureExp); DivLoader. Solution wires both RP_L4 AND a BT410_L4 target — but the BT410_L4 folder + all BT game source are ABSENT (only the .sln reference remains → BT was being ported, code not included). Extracted: elsewhen_extracted/.

Extracted to: 410srczipped_extracted/, tesla4_extracted/, tesla4pod_extracted/. Content root for the port = tesla4pod_extracted/Tesla4PodPCNovell/CopyofNovellDisk/REL410/ (use the pod tree for skeleton/skins/geometry; animations come from the source tree's 410srczipped_extracted/.../CONTENT/BT/ANIMS/).

Still likely missing / ask Nick: confirmation this is the complete content master. (UPDATE: the walk/run animation set is NOT missing after all — CONTENT/BT/ANIMS/ has the full per-mech set, e.g. BLHRRL/BLHRRR/BLHWWLI/BLHWWRI..., and they are compiled into BTL4.RES — the game plays blhrrl=904. The earlier "only AVABMP present" worry was looking at an incomplete slice.)

4a. LEVELS / MAPS — what's loadable (content audit)

The mission is an INI .EGG ([mission] map= scenario= time= weather=). The engine resolves map=<name> from BTL4.RES (MISSION.cpp:344 FindResourceDescription(name, MakeMessageStreamResourceType); a missing name → null-deref crash at :355). The runtime BTL4.RES (pod tree == decomp/BTL4.RES, byte-identical, 3.23 MB) contains 8 loadable maps: cavern (the default night cave, enclosed/no sky), grass (grassland), rav (ravine), polar3/polar4 (snow), arena1/arena2, dbase. Switch levels by editing a COPY of TEST.EGG (e.g. map=grass + time=day) and running -egg <that>.egg — verified: grass boots + the mech walks, 0 crashes (GRASSDAY.EGG created). ⚠ Names are arena1/polar3/rav (with the suffix), NOT bare arena/polar; map=burnt/frstrm/des are NOT in this RES (they crash) — those are lab/other-build maps whose .map SOURCE exists in CONTENT/BT/MAPS/ (DES.MAP = a sky+buttes desert) but would need compiling into a RES via the DOS btl4tool.exe (BUILDRES.BAT, manifest CONTENT/BT/BTL4.BLD) — a separate content-build sub-project. The full content SOURCE tree 410srczipped_extracted/.../CONTENT/BT/ is comprehensive (17 .map, 636 MODELS/, 219 SOLIDS/ collision, 331 ANIMS/, 1958 VIDEO/ textures, 207 AUDIO/, 404 GAUGE/) — i.e. the assets to build ANY map exist; the runtime tree is the already-built subset. No consolidation needed to test the 8 maps. ⚠ Collision is OFF on ALL maps until MoveAndCollide is reconstructed (the mech phases through terrain/objects) — the .sld collision solids ARE present in the RES + SOLIDS/, so it is a code-revival task, not a content gap.

5. Asset formats (all reverse-engineered; details in docs/)

  • .MOD — INI mech/object definition: skeleton, skins, collision, gauges, physics, animations, class.
  • .BGFDIV-BIZ2 little-endian nested-TLV geometry. OBJECT→LOD→PATCH(=geogroup)→PMESH; interleaved float32 verts; quad faces (triangulate!); materials by library:material name; damage/articulation are name-driven via SV_SPECIAL dz_* tokens, not stored geometry.
  • .BMFDIV-BIZ2 (FILETYPE=1) material+texture library (no pixels). Material→DIFFUSE color
    • MATERIAL_TEXTURE→texture→TEXTURE_MAP image basename.
  • Texture pixels: .tga (Truevision), .vtx (DIV-VTX2, trailing RGB), .bsl (DIV-BSL2: a BIT-SLICED container — w*h 32-bit texel words holding SIX independent 4-bit GRAYSCALE sub-images (nibble pair-swapped; byte 0 pad) or RGB444/RGBA4444 (sliceType 7/8); the BMF TEXTURE tag 0x18 BITSLICE selects the slice, absent = 0. The old "[pad,R,G,B] confirmed" note was WRONG — full spec in docs/ASSET_PIPELINE.md §Pixel formats; reference reader DivLoader/VGCDivLoader.cpp:323-410).
  • .SKL — INI joint skeleton: [joint] parent=, Type=(hingex/y/z|ball|balltranslate), Object=part.bgf, dzone=, tranx/y/z, pitch/yaw/roll. Build a DCS tree; neutral pose ≈ translations (pyr ≈ 0).
  • .ANI — INI keyframe animation: [HEADER] framecount/framerate(30)/skeletonfile, [Time] frameN→seconds, [JointType] per-joint DOF, [frameN] joint→rx ry rz radians (balltranslate→translate). Walk anims have no baked root translation → forward motion is procedural/physics (see §7).
  • .GIM (vector MFD/radar line maps) + .GAT (color table) + .PCC (ZSoft PCX 8-bit gauge rasters) — the cockpit HUD assets. .DZM = INI damage-zone→material map (mech skin states).
  • .SLD collision solids, .DMG/.TBL damage zones/tables, .SUB subsystems, .CTL control maps.

5a. ⚠ SOURCE COMPLETENESS — the gating blocker

The archive has the engine and all headers, but NOT the game's implementation code. Verified across all three zips:

  • MUNGA engine (shared): ~183 .cpp — largely complete (renderer, MOVER/JMOVER, math, scene).
  • All game .hpp headers present (every class/interface defined).
  • BT game logic .cpp: MISSINGmech, mech2/3/4, mechsub, mechmppr, mechtech, heat, sensor, gyro, torso, myomers, hud, mechweap, powersub, btplayer, and the main app btl4 are all absent. Only 10 BT .cpp are present (BTCNSL/BTMSSN/BTREG/BTSCNRL/BTTEAM/BTTOOL + weapons GAUSS/PPC + BTL4ARND/BTL4MODE). RP game logic is similarly gutted (5 .cpp).
  • Compiled btrel410.exe exists (binary only, in the TechPC zip).
  • RP can't backfill BT: RP's game source is gutted the same way (only RPCNSL/RPMSSN/RPTOOL/VTVPWR/ VTVSUB + L4 glue); none of the missing BT files are under RP. Only shared engine game-logic is present (MUNGA\DAMAGE.CPP, MUNGA\MOVER.CPP). The per-game source for BOTH titles is missing — the archive captured the engine fully but only a partial subset of game source → a complete repo likely exists elsewhere (Nick).

Consequence: Route A (recompile/port original source) is NOT possible as-is — the code that is the game isn't here. Paths forward: (1) get the missing .cpp from Nick [gating — top priority]; (2) run btrel410.exe by emulating the VPX/IG board in DOSBox (deep RE project; no source needed); (3) decompile btrel410.exe + reconstruct from headers (Plan B, analyzed below); (4) rewrite game logic from headers blind (worst). Do not start the shim/port build until the source situation is resolved.

btrel410.exe (UNWRAPPED — see decomp/): it's a PKZIP self-extractor (unzip lists 324 files). Inside is the real game binary BTL4OPT.EXE (1.24 MB, clean uncompressed PE32, Phar Lap TNT 32rtm stub) + BTL4.RES, NETNUB.EXE, TNT extender, IG microcode, full content. Extracted to decomp/. BTL4OPT.EXE is a near-ideal decompile target: class names, Class::Method strings, and source-file paths in asserts (d:\tesla_bt\bt\mech.cpp) are left in — confirming the missing modules (mech, mech2/4, mechmppr, mechweap, heat, powersub, btplayer, btl4app/gaug/mssn/rdr/vid, …) ARE in the binary. No COFF symbol table (naming comes from those strings). Mined: decomp/src_files_in_binary.txt (78 files compiled in), decomp/methods_in_binary.txt (151 methods).

Decompilation — UNDERWAY (Plan B in progress). JDK21 + Ghidra 12.1.2 installed under tools/ (portable). tools/ghidra_scripts/ExportBTSource.java run headless on BTL4OPT.EXEreal decompiled C recovered to decomp/recovered/ for heat/mech/mech2/mechmppr/mechweap/powersub/btplayer/btmssn/ btl4app/gaug/mssn/rdr/vid (proven: heat.cpp HeatSink ctor w/ real constants+asserts). Current pass = assert-anchored only (few funcs/file). Next: decompile ALL funcs + recover vtables → map to header classes for COMPLETE modules → reconstruct to compilable C++ (header + RP analog + binary oracle) → WinTesla BT410_L4 → build/verify. This is mechanized now but the reconstruction volume (~40 files) is large.

PILOT DONE — heat reconstructed (decomp/reconstructed/heat.{hpp,cpp}). From the 46-func cluster: ~17 confidently reconstructed (the real heat logic — ctor, ResetToInitialState, heat-flow/coolant formulas, status/message handlers, resource parse), 5 correctly identified as inherited base methods (excluded), ~24 template/glue folded into members; Condenser bodies fell just outside the captured window (needs a re-slice). Recovered the full field layout (offset→named member), HeatSink vtable slot map, and resource struct. Output is faithful, named, VWE-styled C++ — a strong draft a human finishes (won't compile clean yet). Honest effort: ~0.51.5 hr/module → ~3050 agent-hours + human review for ~40 modules, with a long tail (modules with no surviving .TCP/.HPP fragment, or split across decomp windows, cost more). Process upgrades before scaling: per-class function lists (not address clusters), complete vtable rows, and recover .data float constants (the biggest "couldn't" bucket = tuning values).

Decompilation feasibility (why viable): the headers are an answer key (exact class layouts, vtable order, signatures) and the MUNGA engine source (~183 cpp) anchors ~half the binary by pattern-matching — and scope is bounded to only the ~15 missing modules. Stages: unwrap (PKLITE→TNT image→strip zip) → load Ghidra/IDA → compile engine to auto-identify known funcs → decompile missing modules method-by-method (vtable-located, header-typed) → rewrite into compilable C++. Result = behavior-equivalent reconstruction (binary usable as an oracle to verify), NOT original source; ~months of expert RE. Pilot before committing: unwrap, prove anchoring, decompile one module (e.g. heat).

5b. The Windows port (WinTesla) changes the plan

The Elsewhen RP411 archive is a working Windows port of the engine (elsewhen_extracted/.../trunk/, WinTesla.sln). It means the hardest infrastructure is already solved and need not be rebuilt:

  • Renderer bypass: DONE (MUNGA_L4/L4D3D.cpp + DXUtils → Direct3D; libDPL/IG board removed — no libdpl.lib in the tree). Our from-scratch port/ viewer is now a reference/sandbox, not the path.
  • Audio: DONE (OpenAL + libsndfile replacing HMI SOS).
  • L4 HAL on Windows + VS build system: DONE; Red Planet game logic: COMPLETE & buildable.

So Red Planet is (at/near) playable today — build WinTesla.sln (VS2008 → upgrade to current VS). And for BattleTech: everything below the game layer (engine, renderer, audio, HAL, build) already exists here and is shared. The ONLY thing BT still needs is its game-logic source (mech/subsystems/ HUD/app), which is confirmed gone (§5a) → must be reconstructed, now with THREE strong anchors: (1) all BT headers, (2) the working WinTesla engine to compile against, (3) Red Planet's parallel implementations as a live reference (VTV≈mech, VTVMPPR≈mechmppr, WEAPSYS≈mech weapons, RP_L4 app≈BT_L4 app) — plus btrel410.exe as a behavioral oracle.

6. Port codebase (port/) — the standalone viewer sandbox (NOT in bt411)

⚠️ This section documents the standalone D3D9 viewer, which lives in the ORIGINAL workspace (nick-games/port/), NOT in bt411. It is a dev sandbox that proved the renderer bypass + asset pipeline; the game itself does not use it. Its image.{cpp,h} codec IS in bt411 (as engine/MUNGA_L4/image.{cpp,h} — the engine loader shares it). If the viewer is ever wanted here, it's a small self-contained add. The rest of this section is retained as reference.

Standalone D3D9 model/animation viewer — proves the renderer bypass + asset pipeline. Not the game.

  • Build: cmake -S port -B port/build -G "Visual Studio 16 2019" -A x64 -DCMAKE_GENERATOR_INSTANCE="C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools" then cmake --build port/build --config Debug.
  • Toolchain gotchas: the VS2019 Community install on this box is broken — must use the BuildTools instance via CMAKE_GENERATOR_INSTANCE. Don't name a header math.h (shadows <math.h>; ours is vmath.h).
  • Files: vmath.h (math), bgf.{h,cpp} (model + material/texture resolution), image.{h,cpp} (tga/vtx/bsl decode), scene.{h,cpp} (retained scene graph: Object/DCS/Instance), skeleton.{h,cpp} (.skl→DCS), anim.{h,cpp} (.ani player), main.cpp (Win32+D3D9), tools/bgftest.cpp (headless loader test).
  • Run: btviewer.exe <model.bgf | skeleton.skl> [animation.ani]. Keys: W wire · C cull · T textures · P pose · -/+ walk speed · Space pause · Esc.
  • Working today: load any mech (geometry + real BMF/BSL skin), assemble from its .SKL skeleton, play real .ANI animations, walk across a ground grid with procedural root motion. Verified on Avatar
    • Raptor. Walk speed currently tuned by hand (being derived from source — see §7).

7. Open technical notes

  • Walk speed / locomotion (SOLVED): locomotion is animation-driven, NOT physics-derived. Each .ANI has a [RootTranslation] section (per keyframe x y z) where .z = forward root SPEED in world units/sec (meters/s); [KeyJointPos].y = vertical bob; y ≈ constant root height. The engine (RP\MUNGA\JMOVER.CPP AnimationInstance::Animate ~1497-1723) integrates movement += Δt·rootTrans.z and moves the body that far — so feet plant by construction (no separate stride constant; scale k=1). Idle anims have z=0 (no travel); walks carry their speed (e.g. RAPWWR z≈20). The viewer reads this: g_walkPos += dt * forwardSpeedAt(anim, animTime). The .MOD physics (MoverMass/drag/MaxAcceleration) governed thrust vehicles (RP hovercraft VTVPWR, camera ship) + collisions, NOT walk gait. NOTE: the BT walking-mech mover/mapper code is NOT in the source dump (BT here is weapons-only; the mech consumer of Animate() is absent) — but the per-clip speed is fully recoverable from the .ANI data above. P3 LOCOMOTION CUTOVER — STEP 1 DONE (docs/P3_LOCOMOTION.md). btl4's live drive (mech4.cpp PerformAndWatch) is now ANIMATION-DRIVEN: the engine AnimationInstance::Animate(dt,True) return adv (the clip's per-frame [RootTranslation] distance) drives the forward step (localOrigin.linearPosition += facing * adv), replacing the hand-tuned kDriveMaxSpeed slide. Verified: mech walks at the clip's AUTHENTIC ~60 u/s (Blackhawk run; the guess was 30), forward, feet plant (travel==stride), combat+damage un-regressed. NOTE: the reconstructed two-channel gait (mech2.cpp AdvanceLeg/BodyAnimation, IntegrateMotion) is bracketed by no-op stubs (ReconSeq controllers → Advance()=0; Matrix34=ReconMatrix no-op), so the full gait (STEP 7) needs those backed by real engine AnimationInstance/affine-matrix first. Plan + 7 steps in docs/P3_LOCOMOTION.md; the smallest milestone (STEPs 1-2, real per-clip speeds on the proven engine path) is the near-zero-risk win. P3 STEP 7 CUTOVER — the real gait controller drives locomotion LIVE (gated BT_GAIT_CUTOVER=1; verified). The BT-specific SequenceController (decomp/reconstructed/seqctl.cpp, reconstructed from binary SelectSequence@004277a8 + Advance@0042790c) is now reconstructed, built, and wired: the Mech ctor Inits legAnimation/bodyAnimation, and PerformAndWatch (mech4.cpp, cutover branch) drives the forward step from bodyAnimation.Advance(dt,1) (the real controller: parses the clip, animates the skeleton joints via the engine Joint API SetHinge/SetRotation/SetTranslation, returns the root-translation forward step) instead of the STEP-1/2 free-standing AnimationInstance. Verified live under cdb: [gait] SequenceController -> blhrrl id=904 (run), mech walks (adv≈1.07, matching baseline), 0 crashes; combat is byte-for-byte un-regressed vs a same-harness baseline run (both fire/damage identically). KEY BUG FIXED: clip fetch was SearchList(clip_id,16) (which treats its arg as a resource LIST → walks the clip bytes as garbage IDs → av); the clip_id from ResolveAnimationClip is a DIRECT resource ID → use FindResourceDescription(clip_id)+Lock, mirroring the engine's own AnimationInstance::SetAnimation (JMOVER.cpp:1406) EXACTLY. DECOMP FACT — CORRECTED (re-verified vs Advance@0042790c raw, part_003.c:6878): the earlier "lumpy distance" claim here was WRONG — the binary's partial-frame branch DOES accrue (t-currentTime)*stride[currentFrame] (distance is CONTINUOUS, matching seqctl.cpp:298), and IntegrateMotion (@004ab1c8:14997) integrates localVelocity.z = -adv/dt directly with NO smoothing. FOOT-PLANT FACT (raw FUN_004ab430:15076): the binary advances the BODY controller with move_joints=1 — the DISPLAYED joints and the travel distance come from the SAME Advance (same clip/phase), so feet plant by construction; the leg channel (also hardcoded move_joints=1, FUN_004a5028:12006) runs FIRST and its joint writes are overwritten. Port matched in commit 01312e8 (body channel Advance(dt,1)); the same commit also added a ground-velocity smoothing (averaging adv/dt across keyframe spans) that is NOT in the binary — a deliberate deviation; if residual micro foot-slip is ever chased, that smoothing is the first suspect. Cutover is kept GATED (not default) pending two flagged fidelity refinements: (a) end-of-clip uses carryover*stride[last] in place of the binary's recursive finished-callback @0x48 (the source of the occasional adv spike at clip-loop boundaries); (b) partial-frame rotation uses component-LERP vs the binary's slerp (FUN_00409390). The deeper full-IntegrateMotion velocity path (leg+body channels + world-step transform + DeadReckonPose, smoothing the lumpy step) is the remaining STEP-7 work. STEP 7 — the AUTHENTIC world-step (velocity model) now drives the mech LIVE (under BT_GAIT_CUTOVER). The cutover no longer slides the position directly (localOrigin += facing*adv); it runs the real IntegrateMotion motion-tail (@004ab1c8): localVelocity = {0,0,-adv/dt} (forward = local Z) → rotate into world by the body orientation via the now-backed FUN_00408744 (part_000.c:8331, a matrix×vector; backed with the engine AffineMatrix::GetFromAxis column basis — the same convention the drive facing uses, so it is sign-consistent) → localOrigin.linearPosition += worldVelocity*dt. Result == facing*adv, but through the authentic velocity→rotate→integrate machinery (runs FUN_00408744 live). KEY DECOMP FACT (verified): the 1995 motion transform { Point3D @0x260; Quaternion @0x26c } IS the engine localOriginOrigin = { Point3D linearPosition; Quaternion angularPosition } (ORIGIN.h:15), and raw FUN_0040ab44 builds the matrix from BOTH halves (rotation from 0x26c, translation from 0x260). So IntegrateMotion's motion state maps DIRECTLY onto localOrigin (NOT the parallel motionDelta/ worldPose members the earlier draft declared — those were placeholders). Also fixed a latent IntegrateMotion bug (unexercised while the fn was dead): it wrote only the separate spinRate@0x508 and left angularAccum[2] (the velocity.z the world-step reads) stale → now sets it. Verified live under cdb: walk-only (BT_GAIT_CUTOVER=1) walks straight forward at the authentic ~60 u/s, 0 crashes; combat fire/damage identical to STEP-1/2. Remaining full-IntegrateMotion work: the AdvanceBodyAnimation gait STATE MACHINE (stand→walk→run transitions) + LoadLocomotionClips (real, mech3.cpp:326 — populates namedClip[]/gait-speed caps via legAnimation.SelectSequence) in place of the inline clip-select; orientation INTEGRATION (turn via angular rate, not the bring-up gDriveHeading); and velocity STORAGE for MP dead-reckon. ⚠ PRE-EXISTING crash observed (NOT locomotion): a combat run walked into a building's interest range and faulted in the ENGINE BGF loader (LoadBgfFile("bld08.bgf")Builder::~Builder vector<float> teardown → bad operator delete, bgfload.cpp:491) — a heap corruption in model loading, hit position-dependently (the STEP-1/2 run with a different spawn fired 600+ shots clean). My world-step does zero heap ops + walk-only is clean, so it is not the cause; likely the known enemy-spawn over-sizeof or a fragile BGF Builder for certain building assets. Track separately. STEP 7 steps 1-2 — LoadLocomotionClips + the real gait STATE MACHINE drive the walk LIVE (BT_GAIT_SM). (1) LoadLocomotionClips (mech3.cpp:326, real) is now called in the Mech ctor (gated BT_GAIT_CUTOVER) and completes cleanly for the Blackhawk — it resolves the full gait clip set (swr/wwr/wwl/rrr/rrl/...) + measures the speed caps via legAnimation.SelectSequence+keyframeData (verified real: walkStride=22.02, standSpeed=6.83). KEY RECONCILIATION: namedClip[]@0x5e0 and animationClips[]@0x5cc were declared as SEPARATE arrays but in the binary are ONE (namedClip[i]==animationClips[i+5], 0x5e0==0x5cc+0x14) — so LoadLocomotionClips' writes weren't visible to SetBodyAnimation/MeasureClipStride. Fixed: namedClip is now a pointer aliased to &animationClips[5] in the ctor (same storage). (2) AdvanceBodyAnimation state machine (mech2.cpp, real) now drives the walk under BT_GAIT_SM (case 6/7: slew bodyCycleSpeed toward bodyTargetSpeed with the loaded caps → bodyAnimation.Advance the wwr clip → world-step). Fixed bodyAnimationState@0x728 == bodyStateAlarm.level (also declared-separate; re-synced from the alarm each frame so SetBodyAnimation's level change reaches the switch). Verified live: mech walks forward (z 1600→−975), adv≈0.35-0.42 at the authentic walk cadence (correctly slower than run's 1.07), clip loops through its 15 keyframes, 0 crashes. Bring-up substitute (marked): SetBodyAnimation passes a non-null loop sentinel (void*)1 where the binary passes PTR_LAB_0050d6fc — the real body finished-callback (gait-state TRANSITION stand→walk→run + leg alternation, @0x48), NOT yet reconstructed; without it the cycle clip would stick at its last keyframe (adv=0). STEP 7 — the REAL gait TRANSITION CALLBACK reconstructed + wired (walk/run leg alternation + walk→run). Replaced the (void*)1 sentinel with the real FUN_004a6d8c = Mech::BodyClipFinished. RESOLVED the .data fn pointer by PE-parsing (0x50d6fc→body cb 0x4a6d8c; 0x50d6f0→leg cb 0x4a6928) — not in the decomp, so DISASSEMBLED with capstone + decoded the jump table (@0x4a6e0a; 33 states→10 handlers). Reconstructed byte-for-byte: at end-of-clip it dispatches on bodyAnimationState, compares bodyTargetSpeed to the loaded caps (standSpeed/walkStrideLength/reverseSpeedMax), picks the next state, re-arms it (SetBodyAnimation, which re-binds the clip WITH this same callback), and recursively advances the carryover (shared tail 0x4a6e66/0x4a6ed1/0x4a7001). Walk alternates 6↔7, run 12↔13, stop→8/9, walk→run 6→11→12. Plumbing: SequenceController::Advance now CALLS cbCode as Scalar(*)(Mech*,unsigned,Scalar,int) at end-of-clip (matching the binary's **(code**)(this+0x48)), folding the returned distance; inline cutover uses Mech::LoopBodyClip. Verified live: walk 6↔7 (adv≈0.42), run 12↔13 (adv≈0.5-0.85), walk→run fires, pos advances, 0 crashes; inline cutover + combat un-regressed. Bring-up gap fixed: reverseSpeedMax2@0x7a0 (run bodyCycleSpeed clamp, AdvanceBodyAnimation case 12/13) is unset by LoadLocomotionClips → read 0xCDCDCDCD → run exploded (adv=-7e6); set to reverseStrideLength (the case-12 divisor → ratio≈1). DECOMP TECHNIQUE: resolve a Ghidra PTR_LAB_xxxx .data fn ptr by PE-parsing the DWORD at its VA, then capstone-disassemble the target (/tmp/readptr.py+disas.py) — for any callback/vtable slot the assert-anchored decomp didn't export. Remaining: the LEG callback FUN_004a6928 (leg alternation, state@0x3b0 + motion source@0x128); airborne FUN_004a6344; gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand); gait SELECTION from real throttle (MechControlsMapper still bypassed; SM forces walk-6 then the callback transitions).
  • MoveAndCollide — the mech now COLLIDES with world solids (engine Mover pipeline; gated BT_COLLISION). The bring-up drive wrote localOrigin.linearPosition directly with no collision, so the mech phased through terrain/objects + floated. KEY FINDING: the 2007 RP411 engine models terrain AS BoxedSolid collision volumes (TERRAIN.h: Terrain = an Entity with collisionVolumes), so terrain-follow AND object collision go through ONE path — the engine Mover collision pipeline (MOVER.cpp), NOT the 1995 heightfield query (FUN_0040e5f0, which the dead Simulate used). Wired into PerformAndWatch (mech4.cpp) after the drive's world-step: snapshot the pre-move pos → set worldLinearVelocity from the move delta → MoveCollisionVolume() (reposition the volume to localToWorld) → GetCurrentCollisions() (gather overlapping solids) → ProcessCollisionList(cols, dt, oldPos, &damage) (the engine resolves each penetration via ProcessCollisionStaticBounce, WRITING localOrigin.linearPosition to push the mech out; MOVER.cpp:1162/1200/1217). All methods are public on Mover; guarded on GetCollisionVolumeCount()>0 so it is a no-op for volume-less mechs. Verified live: in the cavern the mech walks flat (Y=0.35), hits a building solid at z≈150 and is pushed out once (x+7, y+2.9) over ~6 frames then continues stably (Y holds, no drift/fly-away); combat un-regressed (structure→0.533, 0 crashes, 109 collide events while fighting). GRAVITY / terrain-settle — DONE (same BT_COLLISION block; tunable BT_GRAVITY, default 20 u/s). Collision alone only pushes OUT of solids (no downward force → a floating mech never came down). Added a per-frame downward Y (localOrigin.y -= kGravityRate*dt) BEFORE the collision resolve, so gravity presses the mech down + the collision holds it at the terrain surface (equilibrium ON the ground). SAFETY NET: a FLOOR CLAMP at the spawn/ground Y (captured on first frame) — on a map with NO terrain solid the mech rests at ground level instead of falling forever; on real terrain (solids) the collision catches it above the floor so it follows hills/valleys. Verified live: grass — mech settles at Y=0 (clamp; flat grass has no ground solid) and climbs a terrain feature to Y=4.68; cavern — settles at the floor Y=0.35, stable while walking, combat un-regressed (structure→0.933), 0 crashes. So the mech now STANDS + walks on the ground + climbs terrain + collides with objects. Remaining refinements: (b) wall-blocking vs climb — the engine resolves along the surface normal, so a wall-top contact pushes UP (climb) rather than blocking forward; may need game-tuning. (c) collision damage is computed by ProcessCollisionList but not applied here (bring-up). Kept GATED (BT_COLLISION) pending (b) + confirmation on more maps.
  • THE AUTHENTIC 1995 GROUND MODEL — DECODED, IMPLEMENTED, DEFAULT-ON (task #15 CLOSED for ground; BT_GROUND_REAL, =0 falls back to the gravity+push baseline above). Product of the 10-agent ground-model-decode workflow (6 mappers over binary/1995-source/2007-engine/postmortem/content + synthesis
    • 3 adversarial verifiers, all SOUND). THE MODEL (binary FUN_004a9b5c = the MASTER per-frame performance, PTR_LAB_0050c0f4, decoded from raw asm — the "undefined 5.6KB gap" in mech4's window; FUN_004ab430 is the REPLICANT interior only, NOT "Simulate"): there is NO GRAVITY anywhere in the mech (accelerations zeroed every frame @4a9cb6). Vertical position comes exclusively from an ABSOLUTE per-frame ground snap: (1) ctor lifts collisionTemplate->minY by 5% of volume X-width (BLH: 2.0+0.37969=2.37969 — the probe height AND the wall-vs-floor separation); (2) per frame: MoveCollisionVolume() (rebuilds localToWorld, places volume, re-finds containedByNode in the zone's BoundingBoxTree — BOXTREE.cpp, identical 1995→2007, NOT a heightfield); (3) probe q = origin + (0, lift, 0)BoundingBoxTreeNode::FindBoundingBoxUnder(q, &h) (h = distance down to the highest solid top; 1 = miss); (4) snap gate h > 1e-4 (@0x4ab16c): origin.y -= (h lift) ⇒ y = surfaceY EXACTLY (up-slope bounded by the lift window = implicit step allowance; walk-offs drop instantly; MISS ⇒ NOTHING — y holds, no runaway possible); (5) GetCurrentCollisionsProcessCollisionList → per-contact Mech::ProcessCollision override (FUN_004abb40, vtbl+0x3c — the fn the old recon misread as "ResolveWeaponImpact"): BoxedSolid resolver → StaticBounce → owner classification — Mover owner = separating-gate + (deferred) mech damage; CulturalIcon owner without StoppingCollisionVolume (flags&0x8000) = crushable, damage=0.00123f sentinel; plain UnscalableTerrain (cliffs/canyon walls/most buildings) matches neither branch; (6) response policy: sentinel = move STANDS (restore post-snap saves); damage>0 = FULL FRAME REJECTION (velocity zeroed, origin=start-of-frame — walls block by rejection, never slide/climb); impactVel²>40 = crash-anim trigger (SetLegAnimation(0x20) — deferred, logged). Implementation: Mech::AuthenticGroundAndCollide (mech4.cpp) called for the player (post-drive) AND every non-player master; ctor half in mech.cpp; shared gate GroundReal() (mechrecon.hpp). KEY FIXES/FACTS: (a) FUN_0040e36c/FUN_0040e5f0 are BoundingBoxTreeNode::FindSmallestNodeContainingColumn/FindBoundingBoxUnder (BOXTREE.CPP:503/867), ALIVE in the linked 2007 engine — the "Terrain::CellAt/HeightAt heightfield" labels were wrong (the cause of both reverted attempts: v2 assigned the DISTANCE h as an absolute height → the y=13301 ladder). (b) binary 0x2e8/0x2ec/0x2f0/0x2f8 = ENGINE Mover collisionVolume/collisionTemplate/containedByNode/ lastCollisionList (NOT game-layer "physicsBody/groundRef/groundCell"). (c) grass DOES have a ground slab (gr100_cv Block Y[50,0]) — the old "flat grass has no ground solid" note was wrong. (d) a master mech needs a CollisionAssistant (GetCurrentCollisions iterates it UNCHECKED, MOVER.cpp:894): only the viewpoint mech gets one (MakeViewpointEntity); the BT_SPAWN_ENEMY dummy now StartCollisionAssistant()s at spawn + the block has a defensive assistant guard (in the real game exactly ONE master exists per pod, so the invariant held by construction). (e) BT SOLIDS ship ZERO tiles (only Block/YCyl/Cone/Ramp/Wedge, all FindDistanceBelowBounded clamped ≥0) → h is never negative in BT. VERIFIED (render-side + telemetry): grass census lift=0.379688/minY=2.37969 exact, y=0 EXACT on the slab; arena 65s anti-ladder soak y pinned 0, wall BLOCKs hold (no creep); dbase two full hill climbs (y→24/y→31) + exact floor return 0.453125; screenshots standing/climbing; combat DESTROYED after the same ~8 hits; baseline (=0) byte-identical; 7-min BT_HEAPCHECK soak clean. EXPECTED-AUTHENTIC (do not "fix"): sticky-ledge frame rejections walking off 2.4-5.9u terraces; crushable props pass through WITH crunch; canyon backdrops (rav pwin*)/arena detail have no solids = pass-through; over void y HOLDS (no fall). CRASH/KNOCKDOWN LIVE (commit 6452ab7): wall impacts above iv2>40 (~6.3 u/s) bind the bmp bump/stagger clip — SetLegAnimation(0x20) + RequestActionFlags(1) + (0x20) (binary @4aaae2; FUN_004a4c54 == RequestActionFlags, NOT "ForceUpdate"). ALIASING FIX (namedClip bug class): gimpBaseClip @0x64c IS animationClips[0x20] (0x5cc+324) — bmp now loads into slot 32; the clip table is zeroed in the ctor. Recovery = LegClipFinished case 32 → SetLevel(0) (already reconstructed). BRING-UP GUARD (marked): no rebind while legState==0x20 — the bmp clip's ~6.5 u/s root motion re-contacts the wall and the actionRequestFlags consumers (likely the stagger drive-suppressor) are unreconstructed; without the guard the clip restarted every frame. Verified: knockdown → play out → stand → walk → re-knockdown at ~3s cadence at a butte wall; mid-stagger screenshot ON the surface; combat clean. Why leg-burial appeared at walls before this: frame rejection freezes pre-snap Y while grinding (the snap is undone every rejected frame) — the knockdown is what prevented sustained grinding in the original. Related content facts (measured, tools/mapscan.py + BGF triangle sampling): dune mounds visual≈solid (median 0.2u, p90 1.1u); buttes deviate ±5u typical / ±15-25u at cliff faces — authentic 1995 coarseness. The transform chain (quat→matrix identical formulas both paths, row-vector convention, ToD3DMatrix raw copy) was audit-verified — no visual-vs-solid placement bug exists. KNOCKDOWN RE-TRIGGER STUTTER — FIXED (user: "leg animation glitchy/stuttery when I run into an obstacle, fixes if I fully stop"; reproduced + spec-confirmed). Running INTO a blocking solid (a wall, a steep terrain flank, OR the spawned dummy MECH — not slope-specific) at speed made the leg animation flip-flop between the stagger clip (legState 0x20) and walk. Repro (drive into the dummy on grass, BT_GROUND_LOG): the FIRST hit at run speed is a genuine knockdown, but then the mech is pressed against the obstacle with throttle held, so every recovery frame it steps ~6.9 u/s back into it and the iv2>40 (=6.32 u/s) knockdown RE-FIRES → rapid freeze↔walk = the stutter (BLOCK=319 / CRASH=107 in 22 s; leg flip-flopping 5↔32). Full stop clears it (velocity→0→iv2<40). The ground-model spec (scratchpad gd_spec.md, the hand-decoded master perf FUN_004a9b5c @4aaae2) shows the binary's mitigation: localVelocity = {0,0,adv/dt} is ZEROED while crashed (@4aa3d3), so a knocked-down mech can't keep advancing into the obstacle — our reconstruction re-derived localVelocity from the position delta and never zeroed it, and re-armed the knockdown every fresh block. TWO fixes (mech4.cpp): (1) crash motion suppression — while legState==0x20, zero worldLinearVelocity + localVelocity.linearMotion (spec-faithful; no mid-stagger creep, no re-detect during the clip); (2) contact-hysteresis knockdown gate — the knockdown fires only on a FRESH strike, where "fresh" = the mech has been OUT of blocking contact for kBlockHysteresis (0.4 s). A 1-frame edge (!gWasBlocked) was tried FIRST and FAILED for the user: a GLANCING / sliding contact alternates blocked↔not-blocked (the frame rejection pushes the mech out each blocked frame), which flickers a 1-frame edge back to "fresh" and re-knocks-down — that intermittent case is exactly what a head-on force-throttle repro never hits (it shows a clean 1 bind, hiding the bug). The 0.4 s gBlockCooldown window bridges those gaps: any sustained OR intermittent press just BLOCKS (runs/walks in place), only a real separate-then-re-approach re-arms it. gBlockCooldown is a single global gated to the VIEWPOINT mech (the stationary dummy + MP masters also call AuthenticGroundAndCollide, so they keep the original per-fresh-block behavior and never touch the shared state). Verified across cases: dune-beeline 21→1, head-on dummy →1, terrain-crossing (BLOCK=497) →1; still blocks (no phase-through); combat un-regressed (TARGET DESTROYED, 0 crashes / 0 heap); normal walking unaffected (suppression only triggers on legState 0x20). ⚠ DIAGNOSTIC LESSON: a head-on repro gives 1 bind even with the brittle edge gate — the bug only shows on INTERMITTENT contact (glancing / sliding / rough-terrain), so reproduce with an angled or terrain-crossing approach, not a straight ram. NOTE: holding full-throttle into a wall still yields ONE knockdown then a clean block — a real player releasing throttle sees just the single stagger. TWO-CHANNEL GAIT DESYNC — FIXED (the ACTUAL "legs stay glitchy after a bump until full stop" bug). The knockdown re-trigger work above reduced the crash spam but the user reported the legs stayed glitchy AFTER moving away, only clearing on a full stop. ROOT CAUSE (measured, not guessed): the real-controls gait runs TWO animation channels — the BODY channel draws the displayed legs (AdvanceBodyAnimation(dt,1), runs last, wins the joint writes) while the LEG channel drives the mech's TRAVEL (localAdv = legAdv). In normal walking they run in EXACT lockstep (the [sync] log: advSum == legSum frame-for-frame). The knockdown staggered ONLY the leg channel (SetLegAnimation(0x20)), freezing TRAVEL while the body channel kept animating the DISPLAY -> the two clocks split PERMANENTLY (measured advSum=229 vs legSum=112: the displayed legs animate ~2× the real travel = foot-slip) and never reconverged until a full stop reset both to Standing. FIX (one line): the knockdown now staggers BOTH channels — SetBodyAnimation(0x20) alongside SetLegAnimation(0x20) — so display + travel freeze and recover from the SAME bmp clip IN PHASE (BodyClipFinished case 32 recovers the body, mech2.cpp:308, mirroring the leg). Verified: advSum == legSum stays EXACT through+after the bump (was 229 vs 112); mech still BLOCKS the obstacle (BLOCK=328, motion stays on the leg channel so no tunnel-through); combat un-regressed (DESTROYED, 0 crashes). NOTE: the now-correct behavior is a FULL STOP + stagger on hard contact (authentic: frame-rejection + the iv2>40 crash anim); the old "run through / run in place" was the desync bug, not original behavior. v4 — DESYNC CLASS KILLED FOR GOOD: travel now comes from the BODY channel (the SAME Advance that writes the displayed joints) — BINARY-FAITHFUL (raw FUN_004ab430:15076 advances the body with move_joints=1 and takes travel from it). The stagger-both-channels fix (above) killed the KNOCKDOWN trigger, but the desync CLASS resurfaced on the Mad Cat + the analog throttle lever: the lever sweeps the demand THROUGH the walk/run boundaries, and each channel's end-of-clip callback samples the demand at a different instant — near a threshold they pick DIFFERENT next states → permanent phase split → the foot-slip again. v4 sources localAdv = adv (body) so display == travel BY CONSTRUCTION — no two-machine agreement needed, ever. The earlier "tunnels through obstacles" objection to exactly this change is OBSOLETE: it predated SetBodyAnimation(0x20) — with the knockdown staggering the body channel too, a hard impact freezes travel correctly. RE-VERIFIED: head-on run-speed ram into the dummy = pinned at its face for seconds (BLOCK=801, 1 knockdown, NO tunnel-through, then a normal grazing slide around the corner); combat DESTROYED on BOTH BLH and Mad Cat, 0 crashes. The leg channel keeps running as the binary's local sim (its joint writes land first and are overwritten by the body's). The full actionRequestFlags(1/0x20) drive-suppressor + crashed(0x650) lifecycle remain unreconstructed (master-perf ASM gap); the edge gate is the faithful stand-in until they land. RESIDUAL-SINK ALIGNMENT AUDIT — quantified; verdict = authentic coarseness, NOT a bug or a mesh error (dbase; scratchpad audit scripts). After the CONN trilist fix, the user still noticed "sink in a bit" in some spots and asked for a triangle-error audit. TWO sweeps run: (1) GEOMETRY — all 877 pod GEO BGFs / 127,171 tris: 0 CONN chunks with n%3≠0 (fan-bug corpus-complete), 0 out-of-bounds indices; the only 52 anomalies are zero-area (repeated-index) no-op tris, ALL in props/vehicles (AB01/ARBAN/BULLET/MSLBASE/ TK1/WL), NONE in terrain → no mesh defect causes sinking. (2) VISUAL-vs-COLLISION deviation — per dbase terrain model, sampled each visual vertex's Y vs the analytic .SLD surface (Cone apex-up / YCyl / Block) at its XZ; worst clip in the walkable band (Y<15): mesas (buttea-e) 23.5u, dunes (dhillg1-3) 03.1u, flat pad (calpb) 1.6u (⇒ NO global snap offset — the probe/lift is correct), buildings (bld06/bld08) 1214u = the outliers. Butte "overall" 23u is on the VERTICAL CLIFF FACES (unwalkable, the stepped-cylinder cliff approximation — faithful). KEY per-feature facts: dbase terrain is placed by class-42 instances (NOT the class-94 vehicle props); mesa collision = a STACK of YCyls + a base Cone whose footprint is ~522u narrower than the visual skirt (the small skirt clip). Building collision = separate COLUMN/WALL Blocks with GAPS (bld08=26 blocks, bld06=7), and the visual has floors/overhangs spanning the gaps → a mech walks into a gap / under an overhang where there's no solid and its legs clip the visual (THIS is the user's "some things are solid, some aren't" + the biggest residual sink). ALL authentic 1995 coarse collision — the original pods clipped identically; the only "fix" (gap-filling collision on buildings) is an intentional content DEVIATION, not a port correction → left as-is by default. DEFERRED (bound together, marked in code): collisionTemporaryState zero @4aa741 + ProcessCollision state tail; 0x4a4/0x4a8/0x4b4 caches (material/local normal/approach speed); the TakeDamageMessage dispatches (zone=1 dropped by the engine handler today + inflictor global DAT_0050b9ac unmapped); SetLegAnimation(0x20) crash anim; gyro crunch feed; duck maxY swap wiring; replicant-path snap (MP — gate h != 1, part_012.c: 15144-15167, the 0x4ab9d8 wrapper). Full spec + verdicts: scratchpad gd_spec.md/gd_verdicts.md (session); the workflow reports are the durable citations.
  • Renderer dependency is isolated to 3 HAL files in the original (L4VIDEO/L4VIDPER/L4VIDRND call dpl_*). The full dpl_*/dpl2d_* API surface the game uses is cataloged (see chat history / shim contract).

8. Roadmap: viewer → playable

REVISED after finding the WinTesla Windows port (§5b). The original "build a shim + port the engine" plan is largely already done in WinTesla. New plan:

  • Track 1 — Red Planet build (integration spike measured the gap): WinTesla.sln is VS2005 (Format 9 / vcproj v8), 225 source files, and hard-depends on the legacy DirectX SDK (Jun 2010) — it includes <d3dx9.h>, <d3dx9math.h>, <dinput.h>, <dxerr.h>, all REMOVED from the modern Windows SDK (only d3d9.h remains). OpenAL32/libsndfile libs are in trunk/lib. So building RP is a real toolchain-modernization sub-project, not a recompile: (1) obtain legacy DXSDK DONE — installed at C:\Program Files (x86)\Microsoft DirectX SDK (June 2010)\ (Include + Lib\x86 present: d3dx9/dinput/dxerr/dxguid). Note: the installer threw S1023 (harmless — caused by a newer VC++2010 redist v10.0.40219 already present blocking the bundled 10.0.30319; the SDK files install before that failing step, so they survive — just dismiss the error), (2) drive the 225 files via CMake (relative ..\munga\ includes → compile each from its project dir or add all dirs to the include path) or upgrade the vcprojs, (3) fix VS2005→VS2019 C++/header drift across the tree, (4) link d3dx9/dinput8/dxguid/dxerr + OpenAL/libsndfile. Bounded but a dedicated session; best done where the DXSDK can be installed normally. Validates the platform + yields a playable RP. BT integration = same build + drop in the reconstructed decomp/reconstructed/ modules.

    ENGINE COMPILES GREEN (C:\git\nick-games\enginebuild\, CMake/MSVC Win32) — the shared Munga_L4 193-file engine+HAL builds to munga_engine.lib (0 errors). Recipe (4298→0 errors): (a) include path = DXSDK Include + enginebuild/shim ONLY — do NOT add MUNGA dirs (they shadow <time.h> → 3840 errors); rely on the code's relative/quoted includes. (b) defines: WIN32 _WINDOWS UNICODE _UNICODE NOMINMAX _CRT_SECURE_NO_DEPRECATE _CRT_SECURE_NO_WARNINGS _SILENCE_STDEXT_HASH_DEPRECATION_WARNINGS _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS. (c) opts /permissive /W0 /EHsc /bigobj /MP. (d) source edits: MUNGA/CAMMGR.cpp std::ios.instd::ios::in (2007 typo); add Time(const Time&) copy ctor to MUNGA/time.h. (e) enginebuild/shim/atlbase.h+atlconv.h = minimal ATL USES_CONVERSION/W2A shim. Build Unicode (matches original — wide literals → ...W APIs).

  • Track 2 — BattleTech: reuse the WinTesla engine (shared MUNGA/MUNGA_L4/L4D3D/audio/build); add a BT410_L4 + BT game library by reconstructing the missing BT .cpp (mech, subsystems, HUD, app) from: BT headers + RP's parallel code (§5b) + btrel410.exe oracle + pod content. This is the real remaining work for BT — bounded to game logic, since the engine/renderer/HAL/audio are done. → NOW LIVE (§10): btl4.exe boots + renders + runs a drive→animate→target→fire→damage→destroy single-player loop; the active work is reconstructing each subsystem's authentic behavior (damage zones first, then weapon/heat/sensor subsystems — they are still RECON_SUBSYS stubs). See §10 for the build/run/debug recipe, the no-stand-ins reconstruction method, and the per-subsystem decomp findings.

Old roadmap below is superseded for Phases 13 (engine/renderer/HAL/audio = done in WinTesla); Phases 48 still apply to BT's game layer + pod bring-up. The from-scratch libDPL shim is no longer needed (WinTesla replaced libDPL with L4D3D outright).

(Historical plan — keep for reference of remaining game-layer/pod work:)

  • Phase 0 — Foundation (done): asset formats cracked + documented; D3D9 viewer renders skinned, skeleton-assembled, animated mechs. Renderer bypass proven feasible.
  • Phase 1 — libDPL shim as a real API: turn the viewer renderer into a drop-in lib exposing the actual dpl_* (3D) + dpl2d_* (2D) C functions the game calls, backed by D3D9. Linchpin: once the API matches, the game's own rendering code compiles unchanged. Risk: matching semantics (fog, LOD, materials, dpl_SectPixel picking, 2D display lists, morph/damage/articulation).
  • Phase 2 — Compile the game code (modern toolchain): get MUNGA + BT C++ building on MSVC/Clang — modernize Borland-isms, replace .asm, stub the L4 layer — linked against the shim. Goal: it builds. Risk: pervasive non-standard 1995 C++; DOS/DPMI assumptions.
  • Phase 3 — L4 HAL on SDL/Win32: replace L4 video-out, input, timing, file I/O. Goal: the game first runs — boots to menu / loads a mission.
  • Phase 4 — Cockpit (HUD/controls): dpl2d_ HUD + .pcc/.gim gauges + radar; map RIO cockpit controls API → keyboard/gamepad; data-driven multi-surface output (dev windows vs pod's 7 monitors).
  • Phase 5 — Gameplay loop: one single-player mission end to end — drive, fire, heat, damage, AI bots. → playable (single player).
  • Phase 6 — Audio: HMI "SOS" sound engine → modern mixer (SDL_audio / similar).
  • Phase 7 — Multiplayer:CORRECTED (scope-hardest workflow, verified): NOT "reimplement over UDP" — the WinTesla port ALREADY replaced the DOS NETNUB driver with a 3446-line WinSock2 TCP reimplementation (MUNGA_L4/L4NET.CPP, SOCK_STREAM/ReliableMode, zero SOCK_DGRAM) and the master/replicant distributed- sim core (InterestManager/Entity/HostManager) is complete. So multiplayer = integrate + smoke-test that existing TCP stack, gated on P3 (locomotion update writer) + P5 (entity create/destroy lifecycle) + subsystem WAVEs. Unknown: whether that TCP path has ever run successfully even in RP (a send-size bug at L4NET.CPP:1853). This is problem P6 in docs/HARD_PROBLEMS.md. P6 SMOKE TEST PASSED — TWO INSTANCES SHARE A WORLD (cross-pod entity replication live). The full chain works on one box: console egg delivery → TCP mesh → synchronized mission start → bidirectional entity replication (each instance renders its own mech + the peer's REPLICANT; [BTrender] mech tree built ×2 on both sides; 0 crashes). HOW IT WORKS (workflow-mapped + verified): (1) -net <port> CLI arg = networked mode; the pod listens on for a CONSOLE and boots ConsoleOnly; game mesh port = +1. Solo (no -net) never touches WinSock. (2) The mission egg's [pilots] section IS the session roster (pilot=ip[:port] + a per-address page with hostType=0/name/vehicle/dropzone...); each pod self-identifies by local-IP+game-port match, CONNECTS to pilots listed before it, LISTENS for those after — a deterministic full mesh; mission loads when all peers connect ("All connections completed!"). (3) The CONSOLE (the operator station — ABSENT from every archive) delivers the egg as chunked ReceiveEggFileMessage packets; tools/btconsole.py is our console emulator (wire spec in its docstring, constants probed from the build via BT_NET_PROBE=1). ⚠ NotationFile::ReadText expects NUL-SEPARATED LINES, not raw file text (NOTATION.cpp:1043 walks strchr(buf,0)+1) — the tool converts. (4) One-box recipe: instance A -egg MP.EGG -net 1501 BT_LOG=mp_a.log BT_AFFINITY=0x1, instance B -net 1601 BT_LOG=mp_b.log BT_AFFINITY=0x2 (new env vars: per-instance log + CPU pin), then python tools/btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601. MP.EGG/MP1.EGG (2-pilot loopback / 1-pilot networked) live in btbuild/ (copy to the pod BT dir). Egg entries MUST carry explicit :port on one box (else both instances self-identify as the same pilot). (5) WIRE-FORMAT BUG CLASS FOUND+FIXED: MakeMessages replicate RAW over TCP, so string payload must be INLINE — the reconstructed BTPlayer::MakeMessage carried const char* pointers (fine solo, garbage cross-pod: the first remote BTPlayer replication crashed in CString(roleName)); fixed to inline char[0x40] at the binary offsets (+0x50 teamName/+0x90 roleName, static_assert-locked, size 0xD0). CHECK EVERY MakeMessage for pointer payloads before it replicates (swept: no others currently). (6) The rumored L4NET.CPP:1853 send-size bug is DEAD CODE (#if MESSAGE_BUFFERING == enum True == 0 to the preprocessor); live send paths are correct. MOVEMENT REPLICATION COMPLETE — A's mech WALKS ON B'S SCREEN (replicant tracks the master to ~1.5u through dead-reckoning). The full authentic mission-start ladder now runs: console egg → CreatingMission → (event-queue quiet timeout, APP.cpp:667 QUIET_TIME_OUT 3s) → LoadingMission → WaitingForLaunch → console sends RunMissionMessage ×2 (the operator's LAUNCH button; btconsole.py sends them after a 20s settle + 4s step; ID=5, clientID=4, probed) → LaunchingMission → RunningMission (state 5). SIX bugs/gaps found+fixed to get here (each verified live): (1) Mech ctor now SetDeadReckoner(&Mover::AcceleratedDeadReckoner) (the VTV pattern); (2) REPLICANT MOTION: Mech::PerformAndWatch runs DeadReckon(dt) (engine lerp toward projectedOrigin) for GetInstance()==ReplicantInstance instead of the drive; (3) MASTER EMISSION: the VTV error-threshold block (self-predict via own deadReckoner; ForceUpdate() on posErr²>0.04 / quat-w<0.997 / 2s heartbeat) — without it masters NEVER emit update records; (4) emission GATED on RunningMission — pre-launch update spam kept the PEER's CreatingMission event-queue from ever going quiet (a cross-pod deadlock); (5) the console must send the LAUNCH — even solo had been running entirely on the IsPreRunnable() player-escape in a pre-launch state, forever (nothing posts RunMissionMessage locally); solo still works that way (unchanged); (6) REPLICANT VALIDITY: an invalid entity defers EVERY message as an event forever (Entity::Receive/Dispatch, the '07 edit defers all) — replicants never get the master's MakeReady/CheckLoad handshake (the interest layer is partial), so their network updates never applied AND the deferred-event churn also blocked the quiet-timeout. FIX: Mech::Make/BTPlayer::Make SetValidFlag() replicant instances at creation (same fix the spawned test dummy needed). Debug tooling added (env BT_NET_TRACE, permanent, in the ENGINE tree): [net-tx]/[net-rx] (L4NET send/receive), [net-upd] (update entity lookup), [upd-repl] (replicant scheduling), [ent-exec] (Execute state ladder, 1-per-240 frames) — plus [emit]/[repl]/[mech-exec] in mech4.cpp under BT_REPL_LOG. Update stream measured: ~60 Hz × 144-byte records per moving master. NEXT (P6 phase 4): cross-pod combat (target a REPLICANT + damage routed to the owning master — Entity::Dispatch on a replicant already reroutes to the master via SendMessage), interactive 2-window driving, replicant gait animation (replicants don't animate joints yet — derive from replicated velocity), then the pod-LAN config (real IPs, both -net 1501, bare-IP pilot entries).
  • Phase 8 — Pod bring-up (with Nick): 7-monitor layout, old-driver compatibility, RIO cockpit I/O wiring, on-pod testing.

PLATFORM PROFILE scaffold DONE (-platform pod|dev, or BT_PLATFORM env, or run\run.cmd [EGG] pod; default DEV). The gate before Phase-4 HUD/MFD work: a boot switch selecting a runtime profile WITHOUT forking the codepath — it just picks WHICH environment preset the existing video/gauge/input code reads (btl4main.cpp env-defaults block). KEY FINDING (a workflow map): the pod multi-surface path already EXISTS and is intactDPLRenderer::FindBestAdapterIndices (multi-adapter/monitor selector; honors PRIMGAUGE/SECGAUGE/MFDGAUGE/ SPANDISABLE), SVGA16::BuildWindows (L4VB16.cpp — a fullscreen D3D device + window per gauge/MFD surface, MFD span = width×2), L4GaugeRenderer (gated on getenv("L4GAUGE"), btl4app.cpp:353), and content/SETENV.BAT IS the authentic pod env preset (L4CONTROLS=RIO,KEYBOARD; L4GAUGE=640x480x16; L4PLASMA=com2). So the profile is an env-preset selector: DEV (default) = keyboard + single 800×600 window (no L4GAUGE → MakeGaugeRenderer NULL → single surface); POD = RIO cockpit input (→ keyboard fallback when no serial) + the multi-surface gauges/MFDs. Verified: dev un-regressed (boots/ticks/combat DESTROYED); POD boots on a dev box single-window, 0 crashes; both the CLI (-platform pod — ParseCommandLine tolerates it) and env (BT_PLATFORM=pod) forms work. ⚠ The multi-surface is a POD-HARDWARE capability (each surface needs its OWN fullscreen device on one of the pod's 2 video CARDS — monitor count alone is NOT a valid proxy), so -platform pod does NOT auto-enable L4GAUGE: it stays bootable everywhere as single-window + pod input; on the real pod the gauges come from SETENV.BAT (or an explicit L4GAUGE), running the UNTOUCHED FindBestAdapterIndices/SVGA16 path. Also fixed a real engine bug (L4VB16.cpp:201): SVGA16::BuildWindows PostQuitMessage'd on CreateDevice failure but fell through to a null mDevice[j] deref (segfault) → now breaks (inert on pod where CreateDevice succeeds). FOLLOW-UPS (the deep work this scaffold gates): (1) MFD compositing on a dev box — render the gauges/MFDs into the single window / extra windows so POD mode is VISUALLY testable off-pod (the real "dev test of the pod HUD"); (2) graceful multi-surface fallback — the gauge renderer returns NULL instead of crashing when its surfaces can't be built, so a forced L4GAUGE on non-pod hardware degrades cleanly; (3) pod main-view fullscreen/adapter tuning (btl4vid.cpp:863 still binds the main 3D view to 800×600 windowed on ::GetActiveWindow() — fine for dev + the pod's 800×600 main monitor, but pod fullscreen-on-a-chosen-adapter is a Phase-8 detail). The Phase-4 HUD/MFD render work now targets the profile's LOGICAL surfaces, not a hardcoded single window.

GAUGE DEV-COMPOSITE — the pod's SECONDARY/RADAR MFD renders LIVE on a dev box (opt-in BT_DEV_GAUGES; full map + findings in docs/GAUGE_COMPOSITE.md). The FOLLOW-UP (1) above is DONE for the secondary surface: an inset in the bottom-left of the 800×600 window shows the authentic BT cockpit secondary MFD — radar/ tactical grid (SCALE/SECTOR + crosshair), SPEED/HEADING/MAGNETIC/PROP dials, and the color-coded ARMOR DAMAGE mech schematic (green intact / red damaged) — REAL content (nzSec=27247 non-zero pixels). The draw model is CPU-raster→texture-upload, DEVICE-INDEPENDENT: gauge widgets raster 16-bit R5G6B5 into ONE shared Video16BitBuffered::pixelBuffer; SVGA16::DrawDevInset (L4VB16.cpp) palette-expands the secondary plane into a MANAGED texture on the MAIN device + draws an XYZRHW|TEX1 inset quad; BTDrawGaugeInset(mDevice) is called from DPLRenderer::ExecuteImplementation as the last draw before EndScene. THREE recon bugs fixed to get real content (1 genuine fix + 2 gated dev accommodations): (a) BTL4Application:: MakeGaugeRenderer SIGNATURE MISMATCH — the reconstructed no-arg override only HID the 2007-engine's widened 3-arg virtual MakeGaugeRenderer(int*,int*,int*) (APP.cpp:382), so the engine built a BASE L4GaugeRenderer whose ctor never calls BuildConfigurationFilegauge\l4gauge.cfg was NEVER PARSED → undefined label 'bhk1Init' → no ports. FIXED to the 3-arg signature (args ignored; BT is fullscreen). Same bug class as the BTL4GaugeRenderer(false,NULL,NULL,NULL) ctor fix — recon written to the ORIGINAL signature vs the WIDER 2007 virtual. (The gauge label = GetGameModel()+"Init"=bhk1Init; CFG has Bhk1Init but the symbol lookup is stricmp, so case is irrelevant — the table was just empty. MechInit, CFG:4390, is the shared macro that builds the sec/overlay/Mfd/Heat/Comm PORTS via the base configure primitive.) (b) parse HUNG on undefined primitivesBTL4MethodDescription (btl4grnd.cpp) is a STUB (BT gauge widget classes unreconstructed), so an unknown primitive → ReportParsingErrorFail()→a MODAL dialog freezing mid-parse; gated skip (GAUGREND.cpp) walks past the unknown primitive's params so labels register + base configure builds the ports. (c) gauge widgets AV on NULL data bindingsNumericDisplayScalar value_pointer NULL → GaugeConnectionDirectOf ctor deref (bind NULL→static zero, gauge.h); RankAndScore::Execute derefs unreconstructed game state (Gauge::GuardedExecute SEH wrapper → Disable(True) on first fault). All gated BT_DEV_GAUGES; default DEV (no gauges) un-regressed (TARGET DESTROYED, 0 crashes) + pod path byte-unchanged (strict Fail, no guards). REMAINING = the real gauge WIDGET reconstruction: the BTL4MethodDescription method table (BT gauge classes: PlayerStatus + ~16 recovered methodDescription entries) + the gauge→ game-state DATA BINDINGS (why some gauges resolve NULL) — that is what the dev skip/guards stand in for, and what the POD needs. Then Step 2 (RP↔BT MFD port-name reconcile → the 5 MFDs) + Step 3 (2-window layout).

GAUGE MILESTONE C — ALL SIX instrument surfaces in a SEPARATE dev window (default under BT_DEV_GAUGES; BT_DEV_GAUGES_DOCK=1 = docked-in-main-window instead; full map: docs/GAUGE_COMPOSITE.md). Steps 2+3 done for the dev path. A 960×384 top-level window tiles all 6 pod screens with authentic content: Heat (COOLANT/BALANCE/RES + condenser/temp-leak gauges), Comm (KILLS/DEATHS/SELECT TARGET), Mfd1/2/3 (DISPLAY/PROGRAM/NEAREST/TEMP-STATUS frames), and the color radar (SCALE grid + dials + ARMOR DAMAGE). Main 800×600 3D view is un-occluded; default DEV un-regressed (TARGET DESTROYED, 0 crashes). Architecture (from the mfd-multisurface-map workflow): all 6 surfaces are bit-plane MASKS over the ONE shared SVGA16/pixelBuffer (sec=palette low byte; Heat=0x4000 UL, Mfd2=0x0400 UC, Comm=0x8000 UR, Mfd1=0x0100 LL, Mfd3=0x1000 LR; Eng1-3 = engineering-mode alt planes, not extra monitors), so the compositor reaches the SVGA16 ONCE and extracts each by mask. No port-name reconcile needed on the dev path — we fetch the BT port names directly via GetGraphicsPort (the RP auxUL2/… names only matter to the POD's own SVGA16::Update demux — a deferred pod-only fallback). SVGA16::DrawDevSurface has two kernels: palette-LUT (sec, == Update case 0) + mono bit-plane→ tint ((word&mask)?tint:0, the reduced core of Update cases 1/2); BTDrawGaugeSurfaces iterates a 6-entry table. The separate window = one CreateAdditionalSwapChain on the EXISTING device (no 2nd D3D device); rendered AFTER the main EndScene (BTGaugeWindowRenderAndPresent, L4VIDEO): SetRenderTarget→SetDepthStencilSurface(NULL) →Clear→BeginScene→6 tiles→EndScene→restore→swap->Present. ⚠ KEY BUG (cost a cycle): depth-stencil size mismatch — the main 800×600 depth stays bound when you SetRenderTarget to the 960×384 gauge backbuffer; a bound depth SMALLER than the RT is INVALID → all draws silently fail (window shows the Clear color, no geometry). Fix: unbind depth (Z is off anyway), restore after. Pod BuildWindows/Update path byte-unchanged. REMAINING = the widget recon (above) + the pod MFD port-name fallback.

GAUGE WIDGET RECONSTRUCTION — the cockpit instruments now read LIVE game state (attribute wave DONE; full mechanism + technique in docs/GAUGE_COMPOSITE.md §Attribute wave). After Milestone C put all 6 surfaces on screen (drawing NULL), two waves made them read real data. (a) The ColorMapper family + headingPointer (prior session): the palette-tint schematics (cmHeat/cmCrit/cmArmor/colorMapperMultiArmor) + the rotating compass, each a reconstructed BT gauge class registered in BTL4MethodDescription[] (btl4grnd.cpp). (b) The attribute wave (this session, 4 commits): gauges bind to game state by NAME via the engine attribute-pointer system — the config's Subsystem/Attribute (e.g. HeatSink/CurrentTemperature) or bare Attribute (e.g. LinearSpeed) resolves through ParseAttributeFindSubsystem(stricmp on GetName())→GetAttributePointer→ the class's activeAttributeIndex. A class publishes attributes by declaring an <Name>AttributeID enum + a static AttributePointers[] (ATTRIBUTE_ENTRY) + GetAttributeIndex() chained to its parent, and passing GetAttributeIndex() to its DefaultData. ⚠ DENSE-TABLE HAZARD (systemic): AttributeIndexSet::Build leaves gap slots uninitialized and Find strcmps EVERY slot → a published table MUST be a dense prefix from the parent's NextAttributeID; fill gaps with a shared read-only pad member. Done: HeatSink table (CurrentTemperature → Heat readout shows 77), Mech table (dense prefix 0x15..0x21, LinearSpeed → SPEED readout shows ~225), vertBar (coolant bars; x87 pixel math recovered by disassembly), segmentArcRatio (speed arc; thin subclass of engine SegmentArc), oneOfSeveralPixInt (the button-state lamps — reconstructed the whole OneOfSeveral strip-selector family + PixMap8 blit path), and map (the RADAR / tactical display — the marquee; the MapDisplay renderer was already reconstructed, so this was the registration glue + data: the Mech table extended to the full 0x15..0x38 with the radar Point3D*/Quaternion* attributes + a Sensor "Avionics"/RadarPercent table + a reconstructed MapDisplay::Make/methodDescription; renders the FOV view wedge live). Base primitives numeric/numericSpeed/ digitalClock/rankAndScore are ENGINE (L4GAUGE.cpp) — only need the data; vertBar/segmentArcRatio/ oneOfSeveralPixInt/map/PlayerStatus/vehicleSubSystems are BT-specific (reconstruct + register). Technique added: tools/disas2.py <VA> <len> (recover FPU math Ghidra drops — FUN_004dcd94=round, FUN_004dcd00=fabs) + tools/vtdump.py <vtVA> <n> (find a vtable override the assert-anchored decomp didn't export). ⚠ SYSTEMIC PATTERN (map): a newly-consumed reconstructed subsystem/gauge is often the FIRST reader of a stubbed helper — the map exposed 3 (Sensor radarPercent negative from un-normalized heatEnergy; ResolveOperatorEntity→NULL; unguarded NULL entity), each fixed with the established viewpoint-fallback/guard patterns. REMAINING: PlayerStatus, vehicleSubSystems (task #13) + the map's contact classification (0 contacts — the spatial-query Get* EntitiesWithinBounds + the stubbed pip/name/video-object infra) + SetTargetRange (radar zoom, 500m default) + the aggregate HeatSink-bank AmbientTemperature (#if 0'd 0xBBE) + the Reservoir coolantCapacity shadow + the Searchlight LightOn table (duck button already resolves via the 0x37 Mech entry).

Alternate route considered: run original btrel410.exe under DOSBox — blocked: it needs the VPX/IG board DOSBox can't emulate; bypassing it = the shim work anyway.

9. Things to get from Nick

  • #1 (gating): the BattleTech game SOURCE CODE — the missing implementation .cpp (mech, subsystems, mapper, HUD, main app; see §5a). Without it the source port can't proceed. Likely on a backup/dev drive/repo separate from these machine images.
  • Confirmation the PodPC image is the complete content master (or where the rest is). NOTE: the runtime BTL4.RES already has 8 maps + the full mech anim set (§4a); genuinely-absent items are the lab/other-build maps (des/burnt/frstrm — source-only) and possibly a fuller content build. Likely low priority now.
  • A working session on pod specifics: the 7-monitor driver setup, the RIO cockpit I/O protocol, and how the current Win10+wrapper pods are configured (for Phase 8).

10. Combat bring-up — btl4.exe runs a single-player loop (this is the live front)

The reconstructed BT (C:\git\bt411build\Debug\btl4.exe, run via run\run.cmd) now boots, renders the cavern world + a skinned Blackhawk, and runs a drive → animate → target → fire → damage → destroy loop. The remaining work is making each link authentic (reconstructed from the binary), not bringing it up. Companion ledgers: docs/RECONCILE.md (running findings), docs/BT_SOURCE_STATUS.md (missing-file map), memory files bt-combat-bringup, reconstruction-no-standins.

10a. Build / run / debug recipe (bt411 layout — see README.md)

  • Configure once (32-bit / Win32 — the DXSDK link libs are Lib/x86): cmake -S C:\git\bt411 -B C:\git\bt411\build -G "Visual Studio 16 2019" -A Win32 -DCMAKE_GENERATOR_INSTANCE="C:/Program Files (x86)/Microsoft Visual Studio/2019/BuildTools".
  • Build: cmake --build C:\git\bt411\build --config Debug. The one top-level CMakeLists.txt builds munga_engine (engine lib, engine/MUNGA{,_L4}/) + bt410_l4 (reconstructed BT game lib, game/reconstructed/ + game/original/) + btl4.exe, linking DXSDK d3d9/d3dx9/dinput8 + OpenAL/libsndfile (engine/lib/). Linker uses /FORCE (tolerates header-defined globals + the dead offline-factory unresolved externals in mech3.cpp — see the CMakeLists comment; a cleanup TODO). ⚠ Editing an engine/ file rebuilds the engine lib automatically now (one project) — no separate engine build step; but a NEW member on a DPLRenderer/d3d_OBJECT class still needs the game objs that embed its layout recompiled (delete stale objs if layout-mismatch corruption appears).
  • Run: run\run.cmd [EGG] (default DEV.EGG) — it cds to content\ and launches build\Debug\btl4.exe -egg <EGG>. cwd MUST be content\ (the engine resolves BTL4.RES, VIDEO\, BTDPL.INI, eggs relative to cwd — see the loadTables gotcha below). Logs to btl4.log in content\ (grep [anim]/[drive]/[target]/[fire]/[damage]/[boot] markers). The DLLs (OpenAL32/libsndfile) are copied next to the exe automatically at build time.
  • THE AUTHENTIC STACK IS NOW DEFAULT-ON (BTEnvOn, mechrecon.hpp): BT_GAIT_CUTOVER, BT_GAIT_SM, BT_COLLISION, BT_REAL_CONTROLS all default ON — set =0 to fall back to the historic bring-up path (e.g. BT_GAIT_SM=0). Verified: an env-free run gets the full authentic chain (mapper speed/turn demands → two-channel gait → collision+gravity) with combat/damage intact, and each =0 fallback still works.
  • Bring-up env vars (DEBUG/headless; default OFF): BT_FORCE_THROTTLE=1 (auto-walk fwd, no key), BT_SPAWN_ENEMY=1 (spawn a target mech 120u ahead ALONG THE SPAWN FACING — facing-aware since the real-controls drive keeps the authentic spawn orientation), BT_FORCE_FIRE=1 (auto-fire on cooldown), BT_ASSERT_TO_DEBUGGER=1 (route CRT asserts to the debugger instead of a modal box), BT_HEAPCHECK=1 (whole-heap validation on every alloc/free — heap-corruption hunts), BT_PROBE_BGF=<name|ALL> (+ BT_PROBE_N) (boot-time BGF loader probe, exits after). Interactive: WASD drive, Space/Ctrl fire, X all-stop. Gated/marked in btl4main.cpp + mech4.cpp. VIRTUAL CONTROLS (dev keyboard → the pod's analog inputs; mech4.cpp drive block): the pod's throttle was an ABSOLUTE analog LEVER + an analog turn stick — momentary 0/1 keys can't express either. After the perf fix took the game 10fps→60fps, every key tap landed as a frame-perfect FULL demand (at 10fps most taps aliased away between sim ticks, which had LOOKED like fine control) → "controls too sensitive, tap-back reverses instead of stopping". mech4 integrates dt-scaled: W/S sweep a PERSISTENT throttle lever (tap ≈ ±0.08 step, full sweep ~1.4s; a lever below standSpeed = the mech stands, authentic) with a DETENT at zero (braking from forward stops AT 0 and latches until the key is released — no accidental reverse), X = all stop; A/D deflect a momentary stick (~0.4s to full, auto-centers) so a tap = a gentle nudge. gBTDrive.throttle/turn are the integrator OUTPUTS; the mapper bridge + forced-throttle harness consume them unchanged (BT_FORCE_THROTTLE bypasses the lever). ⚠⚠ KEY-INPUT GOTCHA — the WndProc NEVER RECEIVES WM_KEYUP: the engine's per-frame keyboard reader (L4CTRL.cpp:1506) PeekMessage(PM_NOREMOVE) + GetMessage()s every WM_KEYUP/WM_SYSKEYUP/WM_CHAR OUT of the queue for its key-command channel — only KEYDOWNs reach the launcher WndProc, so ANY message-based key-state tracking latches keys on forever (this was ALSO true of the old 0/1 scheme: its throttle/turn/fire never cleared on release; the old "feel" was that quirk + 10fps aliasing). The integrator therefore POLLS GetAsyncKeyState per frame (via GetProcAddress, no headers needed), gated on the game window being FOREGROUND (GetForegroundWindow pid == ours) so background typing doesn't drive the mech. BT_KEY_NOFOCUS=1 = test hook accepting keys without focus (SendInput automation from a background shell can't grant real foreground; scratchpad/realkey.ps1 injects real keys for harness runs); BT_KEY_LOG=1 = 1 Hz [vctl] key/lever/stick state. BTDriveInput struct is duplicated in btl4main/mech4/mechmppr/emitter — keep in sync (the emitter copy had already silently drifted). Levels: default test EGG is now DEV.EGG (map=grass + time=day, outdoor); the RES has 8 maps (cavern/grass/rav/polar3/polar4/arena1/arena2/dbase) — swap via a copied EGG's map= field (§4a). MECH SELECTION IS EGG DATA — the code path is mech-agnostic (VERIFIED with a second mech). The player vehicle = the egg's pilot page vehicle=<name> (DEV.EGG: bhk1), resolved via playerMission->GetGameModel()FindResourceDescription(name, ModelListResourceType) (btplayer.cpp CreatePlayerVehicle). RES type-18 names incl.: bhk1/blkhawk ava1/avatar mad1/mad2/madcat lok1/lok2/loki thor/thr1 vul1/vulture owens/own1 snd1 sunder strk. MECH2.EGG (pod BT dir) = DEV.EGG with vehicle=madcat — the Mad Cat booted, rendered, walked and fought ON FIRST TRY with ZERO code changes: own clips loaded (walkStride 18.5 vs BLH 22.0 — real per-mech data measured), 22 damage zones (BLH 20), 30-subsystem roster constructed, gait/collision/shadow/heat live, combat → TARGET DESTROYED, 0 crashes. The per-mech worklist that surfaced (all known warts, nothing new): (1) the visual-fire muzzles fell back to the centre point (the gun-port site names are hardcoded BLH.SKL names — the real fix is per-weapon .SUB segmentReference muzzle resolution, already how Emitter::GetMuzzlePoint works); (2) roster 32 vs 30 present = its LRM racks hit the still-stubbed missile factory cases (0xBCE/0xBD0 — SUBSYS_PLAN WAVEs, per weapon FAMILY not per mech); (3) beam colour still the BLH red (per-weapon VideoObjectName pending weapon enumeration). Everything else is foundational.
  • cdb (x86): "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe". Pattern for a faulting stack (set cwd to content\ first): -g -c ".lines;sxe av;g;kp 24;q" with BT_ASSERT_TO_DEBUGGER=1. The debug CRT marks fresh heap 0xCDCDCDCD (uninitialized) and freed 0xFEEEFEEE — invaluable for "was this ever constructed?" questions. gflags PageHeap (needs elevation) caught the earlier subsystem heap overruns.

10a-bis. Repo layout — where everything lives (CONSOLIDATED into bt411)

The build was formerly a "virtual assembly" scattered across 6 absolute-path locations in the RE workspace; it is now one relocatable tree (C:\git\bt411, all repo-relative). Map of the tree (full detail in README.md):

  • engine/MUNGA/ + engine/MUNGA_L4/ — the shared 2007 MUNGA engine + Win32/D3D9 HAL, carrying our BT render/loader work (bgfload/L4D3D/L4VIDEO + the image.{cpp,h} codec). Plus engine/shim/ (ATL shim), engine/lib/ (OpenAL/libsndfile libs + runtime DLLs), engine/rp/ (the ~21 RP headers the audio HAL includes via ..\rp\vtv.h — headers only, no RP game source), and engine/DPLSTUB.h/LOGGER.h (the trunk-root headers reached via ..\).
  • game/reconstructed/ — the reconstructed BT source (the bulk; was decomp/reconstructed/).
  • game/original/BT/ + game/original/BT_L4/ — the surviving original BT .cpp (BTMSSN/BTREG/BTTEAM/BTSCNRL/BTCNSL/GAUSS/PPC/BTL4MODE/BTL4ARND) + all the BT headers (these two dirs are the game's header include path).
  • game/fwd/ — the ~186 forwarding shims letting reconstructed code do #include <EXPLODE.hpp>; each now #include "../../engine/MUNGA/<NAME>.h" (relativized). fwd/app.thp is a self-contained TestApplication reconstruction shim; fwd/bgfload.h forwards to the engine loader header.
  • game/btl4main.cpp — the WinMain launcher.
  • content/ — the runtime tree (was tesla4pod_extracted/.../REL410/BT/): BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI, VIDEO/REPLACEMATS.tbl. Run cwd = content/.
  • reference/decomp/ — the raw Ghidra pseudocode all/part_*.c (was decomp/recovered/), the source-of-truth every reconstruction is verified against. reference/ghidra_scripts/ = the exporter.
  • docs/ — format specs (BGF_FORMAT, ASSET_PIPELINE, BT_SOURCE_STATUS) + the reconstruction ledgers (RECONCILE, HARD_PROBLEMS, RESOURCE_AUDIT, SUBSYS_PLAN, WAVE_PLAN, P3_LOCOMOTION).
  • tools/btconsole.py (MP console emulator) + mapscan.py/resscan.py scanners.
  • External (not vendored): DXSDK June 2010 (overridable -DDXSDK). ⚠ Historical sections below still cite the old nick-games/… absolute paths as provenance — see the orientation banner at the top; they map onto the dirs above. NOT copied into bt411: the standalone D3D9 viewer (§6 port/, a dev sandbox) and the RE toolchain blobs (Ghidra/JDK) — those stay in the original workspace.

10b. Reconstruction workflow (the method) — RULE: no stand-ins

The loop for each feature: (1) read the RAW decomp decomp/recovered/all/part_*.c for the relevant FUN_xxxx; (2) map FUN_/DAT_/this+0xNN to engine symbols using BT headers + the WinTesla MUNGA source + decomp/reconstructed/CLASSMAP.md + RP's parallel code; (3) write the real reconstruction into decomp/reconstructed/*.cpp; (4) compile1.cmd then full build; (5) run env-gated + read btl4.log; (6) cdb on any crash. Never write stand-in/placeholder code for an apparent "gap" — the full game logic IS in the pseudocode (the binary ran the game); a "gap" is just a reconstruction stub not yet filled (e.g. ResourceStream/MemStreamX no-op proxies, an uninitialised field). Said by the user: "there are no gaps, just work to be done." Bring-up scaffolding (the env-var paths, the explosion-for-beam, a player-gated drive) is clearly marked and meant to be replaced by the real reconstructed system, never to substitute for reading the decomp. For exhaustive multi-function decomp analysis, fan out a read-only Workflow (understanding phase), then implement hands-on so each change is visible.

10c. Decompilation insights (per subsystem — durable facts)

  • Resource lookup is by NAME or by ID. ResourceFile::FindResourceDescription(name, type, index=-1) (raw FUN_00406ff8 = find-by-type-and-name; FUN_00407064 = SearchList(resourceID, type)). Resource type enum: SkeletonStream, DamageZoneStream, ModelList, AnimationResourceType==16 (0x10), damage-zone segment list type 0x14, critical-subsystem segment 0x1e. Animation clip names in BTL4.RES are lowercase (blhrrl=904, blhrrr=905, blhwwli=910, blhrrli=916). Mech:: ResolveAnimationClip(prefix,suffix) builds the name and calls FindResourceDescription.

  • Animation (engine MUNGA/JMOVER.cpp): AnimationInstance(JointedMover*) then SetAnimation( ResourceID, finishedCallback) (Lock()s the clip, parses frameCount/jointCount/jointIndices/frameStart/ keyFrames/rootTranslations from resourceAddress). Animate(dt, move_joints=True) writes each joint's DCS per keyframe. At end-of-clip it calls (moverToAnimate->*finishedCallback)(...) (line ~1592) — a NULL callback crashes. Loop a gait via a non-virtual Mech::OnBodyAnimFinished re-arming the clip, passed reinterpret_cast<JointedMover::AnimationCallback> (Mech derives from JointedMover; the member- ptr cast is how the original stores a Mech method in the JointedMover-typed slot).

  • loadTables gotcha (MUNGA_L4/L4VIDEO.cpp:849): fopen("VIDEO\\REPLACEMATS.tbl","rb") is relative to cwd with no null-check → fread on NULL if cwd isn't the pod BT dir. (RP material-substitution table.)

  • application/l4_application is NULL outside the frame loopApplicationManager (APPMGR.cpp:73) REASSIGNS the global application while iterating the running-app chain each frame and leaves it NULL between frames. Any code running at Windows message-pump time (WndProc handlers, timers) reads NULL even while the game runs. Pattern: record the input in a global at message time; consume/apply it inside a per-frame path where this is the engine object (how the window-resize aspect fix works, L4VIDEO.cpp gWindowAspect → per-frame dirty-check rebuild; commit 06683cf).

  • Entity spawn: Mech::Make(MakeMessage*) (FUN_004a2d48) allocates sizeof(Mech)=0x854 + runs the ctor; the base Entity ctor self-registers it into the world (renders + ticks). Registry::MakeEntity dispatches per-class makeHandler (BT registry maps MechClassID=0xBB9 → Mech::Make). Unique IDs via HostManager::MakeUniqueEntityID(). The mission system is NETWORKED and BT is PvP-ONLY — it never had NPCs/AI (verified, not a recovery gap): BTREG.cpp's entity registry knows only Mech/Projectile/ Missile/BTPlayer/BTTeam; the COMPLETE BT header set has no Bot/Brain/AI class; BTL4OPT.EXE has no AI strings; every mech is driven by a BTPlayer (or a BTCameraDirector for the "camera"/spectator game model), players grouped into BTTeams. Opponents were always other human pods over the net (NETNUB/ L4NET). The AI entities Crusher/Blocker/Runner are Red Planet classes (RP/), NOT BT and NOT engine — reference only for how the engine supports AI if we ever ADD it (that would be new dev, not a port). ⇒ A solo mission has no enemy by design; the env-gated BT_SPAWN_ENEMY mech is a test dummy (uncontrolled Mech), not a game feature. The authentic opponent path is multiplayer (Phase 7).

  • Mech::PerformAndWatch runs for EVERY mech → gate player-only logic on this == application->GetViewpointEntity() (else a spawned enemy is driven by player input / shares the player's AnimationInstance).

  • Targeting (mech offsets verified vs part_013.c): mech+0x37c = Point3D target world pos (range/ aim source), mech+0x388 = Entity* target (the field HasActiveTarget() / Emitter::FireWeapon gate on), mech+0x38c = int targeted sub-zone (-1 = whole). Weapons cache hasTarget/targetPoint/ muzzlePoint, refreshed each frame from the mech's target by the mech's targeting update.

  • Firing: Emitter::FireWeapon (emitter.cpp, real) builds a muzzle→target beam but weapon-effect renderables (beams/tracers) are NOT built in the port (btl4vid.cpp:455) → nothing draws. Explosions DO render (L4VIDEO.cpp:5356), so a shot stands in as Explosion::Make(MakeMessage*) at the target (effect resource "explode" = id 13). Weapon ClassIDs (CLASSMAP, note the factory enum mislabels): EmitterWeapon 0xBC8, MechWeapon 0xBCD, Missile 0xBCE, Ballistic 0xBD0, PPC 0xBD4 (factory calls it "GaussRifle"), MechTech 0xBD6. Mech ctor offsets: damageZoneCount @0x11c (param[0x47]), damageZones @0x120 (param[0x48]), resourceID @0x1bc (param[0x6f]).

  • Damage delivery: Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)target->Dispatch(&msg). Base Entity::TakeDamageMessageHandler IGNORES zone==-1; the comment (ENTITY3.h) says "Mechs use cylinder lookup table" for an unaimed hit — that Mech override is NOT yet reconstructed, so send a VALID zone index for now. class Damage{ damageType(enum Collision/Ballistic/ Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }. DestroyEntityMessage(id, size) removes an entity. Engine Explosion also splash-damages nearby entities via a collision query (fragile) — direct Dispatch to the known target is what we do.

  • Damage zones — REAL damage model reconstructed & working (replaced the health-model stand-in). The chain: aimed TakeDamageMessage → engine Entity::TakeDamageMessageHandlerMech__DamageZone:: TakeDamage → engine base armor model (damageLevel += damageAmount * damageScale[type]) → accumulates to 1.0 (zone destroyed) → graphic-state/death branch → death detected. Verified: structure climbs 0.133/hit, vital zone destroyed → *** TARGET DESTROYED ***, stable. Build mechanics: engine Entity ctor (ENTITY.cpp:961-987) reads damageZoneCount from the DamageZoneStreamResourceType (type 0x14) resource and allocates the array (entries left 0xCDCDCDCD); the Mech ctor populates that inherited array (mech.cpp Pass-3): MechFindResource(resourceID, 0x14)LockDynamicMemoryStream(addr, size, initial_offset=4) (skips the count word the base consumed) → for z<damageZoneCount: new (Memory::Allocate(0x1b8)) Mech__DamageZone(this,z,&stream)SetLODParentPointers → crit subsystems (type 0x1e) + MechDeathHandler. The engine DamageZone ctor (DAMAGE.cpp:200, shared MUNGA == binary FUN_0041df5c) reads name/effect-sites/defaultArmorPoints/damageScale[5]/materials — so stream alignment is exact.

    ⚠ THE SYSTEMIC BUG (check this first on every reconstructed class): re-declared engine base fields. The reconstructed BT classes re-declared fields that already exist in their WinTesla engine base, at the binary's offsets. Two failures result: (a) the re-declaration shadows the engine field (the engine ctor writes the base field; the reconstruction reads its own uninitialised copy = 0xCDCDCDCD), and (b) compiled as a subclass of the engine base, the copy lands at a different offset than the binary assumed. Fixed instances this session — Mech: damageZoneCount/damageZones (shadowed Entity's → zones never built); Mech__DamageZone: structureLevel→engine damageLevel, damageScale[5]/ defaultArmorPoints (raw Wword(0x51)/Wword(0x50) offsets → inherited members), graphicState/ graphicStateAlarm/flags/state→engine StateIndicators damageZoneState/damageZoneGraphicState (+ SetDamageZoneState/SetGraphicState/GetGraphicState accessors; the reconstructed SetGraphicState override was byte-identical to the base → removed). Use the inherited member/accessor, never a raw Wword(N)/re-declared copy, for any field the engine base owns. (subsystemCount/subsystemArray were already fixed this way earlier — same lesson.) Wword(int i) is itself a TRAP: mechrecon.hpp:192 defines it as static BTVal bank[0x400]; return bank[i&0x3ff] — a global scratch bank, NOT this+i*4. Any Wword(N) used for object access reads/writes garbage (e.g. (Mech*)Wword(3) for a zone's owner → use GetOwningSimulation()).

    ⚠ SIBLING SYSTEMIC BUG — resource-struct layout mismatch (check when RESOURCE fields read garbage). The *__SubsystemResource structs are overlaid on pre-built 256-byte records loaded verbatim at the binary's fixed offsets, so OUR struct MUST match the binary byte-for-byte or every member read is wrong (and the failure is silent — garbage that happens to be non-fatal, like a heatSinkIndex reading 10.0f). Two ways it breaks: (a) wrong resource inheritance base — the resource must mirror the CLASS hierarchy (HeatableSubsystem:MechSubsystem → resource must inherit MechSubsystem__SubsystemResource (0xE4), not Subsystem::SubsystemResource (0x30)); a skipped base slides every field low. (b) under-sized fields — e.g. alarmModel at res+0xCC is a 12-byte record field but was typed as a 4-byte ResourceID (typedef int) → 8-byte gap. DIAGNOSE by logging compiled offsets ((char*)&res->field - (char*)res) vs the binary's known offsets and dumping raw record bytes as int+float; anchor the true field offsets from the raw decomp of the resource parser (CreateStreamedSubsystem/@004ac9ec — param_4+0xNN reads). See §10d heat-flow.

    Two more non-field fixes were needed for damage to flow: (1) message-handler chaining — a reconstructed class's MessageHandlers set must be built chained to the parent's (Receiver::Message HandlerSet(Entity::GetMessageHandlers())); an empty default-ctor set has NO parent chain, so Receiver::Receive finds no handler and every inherited message (TakeDamage, etc.) is silently dropped. (2) entity validityEntity::Dispatch only delivers synchronously (Receiver::Receive) for a VALID master; an invalid entity Posts the message as a deferred event that never fires. A manually-spawned entity (no MakeViewpointEntity/CheckLoad handshake) must call SetValidFlag() itself.

    Remaining damage gaps (scoped, non-blocking): EnergyDamageType(4) shorts attached generators via criticalSubsystems[]->subsystemPlug.Resolve() → needs the REAL subsystem roster (still RECON_SUBSYS stubs) → use ExplosiveDamageType meanwhile (correct for the explosion weapon anyway). Per-impact aim needs the cylinder lookup (deferred STEP 6 — Mech::TakeDamageMessageHandler override FUN_004a0230 + FUN_0049ed0c); meanwhile aim a vital zone directly. IsDisabled() (FUN_0049fb54) reconstructed (reads movementMode==2||9), but movementMode is set by the bypassed gait/death-transition → bring-up death reads the real zone state directly (a vitalDamageZone with damageLevel>=1.0). Removing the wreck (DestroyEntityMessage) crashes on teardown — death = explosion + stop-targeting for now. ⚠ ROOT-CAUSE PIVOT (P5 — full writeup docs/HARD_PROBLEMS.md; repro BT_ENABLE_TEARDOWN=1): it is a TEARDOWN-SEQUENCE double-free, NOT a base-region stomp (two earlier theories DISPROVEN). (a) "solids never built" — WRONG (Mover::Mover:1756 allocs collisionLists unconditionally). (b) "reconstructed Mech raw offsets stomp the engine collision cluster" — ALSO WRONG: those stomps (Mech::Simulate collision/ terrain/telemetry, FeedHeat*Gauge) are DEAD CODE, never called (grep-confirmed). The BT_ENABLE_TEARDOWN dump proves the enemy's engine base is FULLY VALID at death (collisionLists@0x2e4, segmentTable@0x2f0 =51 segs, jointSubsystem@0x30c all valid). The crash is in the destruction SEQUENCE (FryDeathRow → ~Mech → ~JointedMover → ~Mover): cdb shows collisionLists's array already freed (0xdd/count=0xdddddddb) by the time ~Mover delete[]s it → a double-free. ~Mech (reconstructed) runs before the engine base dtors → prime suspect is a Mech member dtor / the minimal-spawn collision-or-death-row setup freeing (or aliasing) a base resource the engine dtor frees again. PINNED (trace done): it's the skeleton SEGMENT teardown double-free, not collisionLists. ~JointedMover (JMOVER.cpp:436 SegmentTableIterator(segmentTable).DeletePlugs()) → SocketIterator::DeletePlugs (SOCKET.cpp:157-161 delete plug) force-deletes each of the enemy's 51 EntitySegments with defeat_release_node=1 (bypassing the ref-count release path); one is already freedSTATUS_HEAP_CORRUPTION 0xC0000374 + 0xFEEE fill. P5 CLOSED — the whole premise was WRONG (verified): a killed mech does NOT disappear in the real game, so DestroyEntityMessage-on-death should NEVER be issued. RP death = VTV::DeathShutdown (VTV.cpp:1681 — subsystems shut down, vehicle STAYS); CondemnToDeathRow/removal is ONLY for transient objects (RIVET projectiles, DEMOPACK cleanup). BT death = a STATE transition (SetGraphicState(Destroyed GraphicState) mechdmg.cpp:355 + death anim deathAnimationLatched@0x650 + effect/splash) → the mech becomes a persistent WRECK. So the wreck-stays behavior we already ship IS faithful; the teardown crash is an artifact of forcing a removal the original never does (only reachable via BT_ENABLE_TEARDOWN). NOTHING to fix. Real future death work = reconstruct DeathShutdown + collapse anim + destroyed skin (does NOT touch the teardown path). Later trace also showed it's heap corruption in a SINGLE teardown, not the double-free the line above hypothesized. Full trail: docs/HARD_PROBLEMS.md. P5 TRULY CLOSED — ROOT CAUSE FOUND & FIXED (the whole trail above was chasing run-2 of a DOUBLED dtor chain). Mech::~Mech (mech.cpp:1106) contained an EXPLICIT JointedMover::~JointedMover(); — the Ghidra decomp shows the binary's FUN_00425550(this,0) tail call, which IS the compiler-emitted implicit base-dtor; reconstructing it as a source statement ran the whole ~JointedMover → ~Mover → ~Entity chain TWICE per Mech. Run 2 re-delete[]d Mover::collisionLists (count word read from the 0xDD freed fill = the observed 0xdddddddb) and re-ran DeletePlugs over the destroyed segment table (= the P5 "EntitySegment already freed" 0xC0000374). ONE bug = BOTH crashes (death-row teardown AND app exit — every window close crashed once entities existed; symptom timing varied with heap reuse, which is why idle-close sometimes survived and why the earlier traces looked contradictory; the old ENTRY/EXIT probes bracketed the EXPLICIT call, misreading run-1 as "a member dtor freed it"). ⚠ RECONSTRUCTION RULE (add to the systemic checklist): in a decompiled DESTRUCTOR, the trailing member-dtor calls (FUN_xxx(this+N, 2)), the base-dtor call (FUN_xxx(this, 0)), and the (flags&1) && operator delete(this) tail are COMPILER EPILOGUE GLUE — reconstruct only the dtor body ABOVE them; C++ re-emits member+base destruction implicitly at the closing brace. Explicit member-dtor calls were also removed from ~Mech (were benign only because the Recon proxies are no-op) and the same pattern documented out of Mech__DamageZone's dtor (mechdmg.cpp). VERIFIED: interactive play → window close under BT_HEAPCHECK=1 = clean exit; combat kill + BT_ENABLE_TEARDOWN=1 forced entity teardown = TARGET DESTROYED, teardown completes, sim continues, clean close — 0 crashes across the matrix. App exit no longer crashes; entity removal (DestroyEntityMessage) is usable again (relevant to MP disconnect cleanup). Repro harness kept: BT_FORCE_SECONDS=<n> (release forced throttle after n sim-s)

    • BT_FORCE_TURN=<t> (hold steering) in mech4.cpp — the tap-release/turn paths no soak had exercised. (Forensics: a 14-agent adversarial workflow; secondary REAL-but-dormant hazard found on the way: AddCollisionToList BNDGBOX.cpp:389 has no live bounds check — Verify compiles out at DEBUG_LEVEL=0 — so >10 simultaneous collision contacts from >1 source overflow the 10-slot list; NOW FIXED — a live clamp in AddCollisionToList (BNDGBOX.cpp, engine tree) drops overflow contacts instead of writing past the block. Trigger scenario was real: the user spawned INSIDE a building solid on grass (mass simultaneous contacts + gravity pressing into terrain boxes) → silent AV mid-play, where the CRT assert dialog ITSELF crashed while formatting (why no popup appeared). Verified: full combat+teardown+close matrix under BT_HEAPCHECK=1 = 0 crashes, clean exit. Spawn-inside-solid placement itself is a separate tuning item.) (Latent, non-causal: compiled sizeof(Mech)=0x638 < binary 0x854; the over-sizeof writes 0x7e0-0x828 live only in the dead Simulate, so no live overflow — but pad to 0x854 before reviving that code.)

10d. Reconstructed modules wired into bt410_l4 (state)

~43 reconstructed .cpp compile+link. Mech runtime (mech.cpp/2/3/4, mechdmg, mechsub, mechtech), heat (heat), weapons (emitter/mechweap/mislanch/missile/projweap/projtile/gauss/ppc), subsystems (gyro/sensor/gnrator/powersub/myomers/torso/...), app/HUD (btl4app/btl4vid/ btl4mppr/btl4mssn/btplayer/hud/dpl2d). Subsystem instances are RECON_SUBSYS STUBS — the un-stub wave is IN PROGRESS (full per-family plan: docs/SUBSYS_PLAN.md, from the bt-subsystem-recon workflow). KEY FINDING: the real subsystem classes mostly already exist (heat→heatfamily_reslice, weapons→emitter/projweap/mislanch, gyro/sensor/power/torso/myomers/searchlight/thermalsight/ammobin all reconstructed); the work is wiring them in + the 4 systemic checks (shadowing, Wword trap, handler chain, validity). The factory case LABELS are systematically MISLABELED — the real class is identified by the // FUN_004xxxxx ctor-address comment reconciled via CLASSMAP.md, NOT the case <Name>ClassID label. Master map (case→real): 0xBBD→Condenser, 0xBBE→HeatSink-bank (cache slot=heat bank, not "sensor"), 0xBC0→Reservoir, 0xBC1→Generator, 0xBC2→PoweredSubsystem, 0xBC3→Sensor, 0xBC4→Gyroscope, 0xBC5→Torso, 0xBC6→Myomers, 0xBC8→Emitter, 0xBCB→AmmoBin, 0xBCD→ProjectileWeapon, 0xBCE→UNRESOLVED (decompile 4bdcb4), 0xBD0→MissileLauncher, 0xBD3→SubsystemMessageManager, 0xBD4→PPC(Emitter), 0xBD6→HUD, 0xBDC→MechTech, 0xBD8→Searchlight, 0xBDE→ThermalSight. Two bugs to fix: case 0xBDC discards the ptr (no subsystemArray[id]=); MechTech(real)+HUD are SWAPPED (MechTech wrongly built at 0xBD6). Swap recipe: per-class Create<Class> Subsystem bridge fn in the class's own .cpp (do NOT #include real subsystem headers into mech.cpp — the local stubs collide) + extern it in mech.cpp; keep the alloc SIZE + ++weaponCount/special-cache. Order: WAVE 1 heat base re-base (HeatableSubsystem : Subsystem: MechSubsystem, delete shadow fields owner/flags/statusFlags/statusBits/destroyed, fix thermal offsets) = universal prerequisite; then heat family + MechTech/HUD fix; then power bus + Emitter/PPC; then standalone readouts; gyro/torso/myomers later. DEFER: MechControlsMapper tick (reads unddocumented App offsets → AV, mech4 bypasses it); 0xBCE. WAVE 1 DONEHeatableSubsystem re-based onto MechSubsystem; shadow fields removed; the cascade fixed across heat.cpp/heatfamily_reslice/mechsub + powersub/hud/mechweap (the de-shadow propagates to EVERY heat/powered subclass: statusFlagssimulationFlags, destroyedsimulationState, statusBitsForceUpdate(), and ResetToInitialState()(Logical powered) on every override). Full build GREEN, exe runs, combat (zones/anim/damage→death) un-regressed. DEEPER FINDING: the shadowing is more systemic than the plan estimated — MechSubsystem ITSELF shadows the engine Subsystem::damageZone (mechsub.hpp:252 ReconDamageZone *damageZone shadows SUBSYSTM.h:159 DamageZone *damageZone), exposed once MechWeapon's chain ran through MechSubsystem. Worked around by qualifying this->Subsystem::damageZone (mechweap.cpp); the proper fix = remove the MechSubsystem::damageZone shim shadow (cascades to mechsub.cpp) — a follow-up. Two include fixes also needed: heat.hpp now #include <mechsub.hpp>; mechrecon.hpp now #include <ROTATION.hpp> (self-sufficient Quaternion, for myomers's include order). Next = WAVE 2 (heat factory wiring + MechTech/HUD un-swap) per docs/SUBSYS_PLAN.md. WAVE 2 DONE — real heat family + HUD + MechTech wired into the factory. Swap mechanism: a per-class Create<Class>Subsystem(Mech*,int,void*) BRIDGE fn appended to each real class's own .cpp (heat.cpp: Condenser 0x230 + HeatSink-bank 0x1e4; heatfamily_reslice.cpp: Reservoir 0x230; hud.cpp: HUD 0x2a4; mechtech.cpp: MechTech 0x140), extern-declared + called from the mislabel-correct factory cases in mech.cpp (0xBBD→Condenser, 0xBBE→HeatSink-bank+cache, 0xBC0→Reservoir, 0xBD6→HUD, 0xBDC→MechTech+store+cache — fixing the MechTech/HUD swap + the discarded-ptr bug). RESULT: 30 of 31 subsystems now tick their real per-frame Performance (1 bypassed = controls mapper), up from MechTech-only; build green, 16s stable, combat un-regressed. RUNTIME FIX: HUD::HUD read the torso source at raw owner+0x438 (garbage in the recon Mech layout) → defaulted the torso/range readouts to 0 (HudSimulation refreshes per-frame). NEW SYSTEMIC PATTERN for the remaining waves: subsystem ctors read the owner Mech at RAW binary offsets (*(int*)(owner+0xNN)) that are wrong in the recon Mech → guard/zero + per-frame refresh, or wire a real Mech accessor. Heat self-load from firing (temperature climb) needs WAVE 3 (real Emitter writing the inherited heat accumulator). Next = WAVE 3: power bus (Generator/PoweredSubsystem) + Emitter/PPC weapon. WAVE 3 (power bus + Emitter) — CONSTRUCT+TICK done; full fire-path pending. 3a: bridges for Generator (0xBC1, 0x250) + PoweredSubsystem (0xBC2, 0x31c) in powersub.cpp → factory wired → power bus real. 3b: CreateEmitterSubsystem (0x478) in emitter.cpp, wired for BOTH 0xBC8 (Emitter) AND 0xBD4 (PPC = Emitter subclass, same FireWeapon) → real Emitter constructs + ticks its EmitterSimulation state machine. Runtime fix (E2): Emitter::TrackSeekVoltage (emitter.cpp:311) derefs a null voltage source (the source plug at raw this+0x1d0 isn't registry-linked in bring-up) → guarded if (src==0 || dtScale==0) return (no charge that frame). All real subsystems now construct + tick; build green, combat un-regressed. WAVE 3 fire-path (E3-E8) DONE — the real Emitter fires end-to-end (verified: repeated [emitter] FIRED damage=.. heat=0.49 pendingHeat=0.49, 17s stable, combat intact). Implemented: E3 MechWeapon:: HasActiveTarget()/GetTargetPosition read the owner's target slots (owner+0x388/+0x37c via the inherited MechSubsystem::owner) not the never-set hasTarget; E4 firingArmed=1 in the Emitter ctor; E5 deleted the Emitter-local heatAccumulator SHADOW, FireWeapon now writes the inherited HeatSink:: pendingHeat@0x1c8 (the heat-sim input); E7 force-charge in EmitterSimulation Loading (the source plug at raw this+0x1d0 never resolves -> currentLevel=seekVoltage[idx] -> outputVoltage 1.0 -> Loaded; also pre-init the seek table in the ctor so it's valid when the ctor src is null); E8 trigger via a per-FRAME int gBTWeaponTrigger pulsed by mech4 (controls mapper bypassed) -> CheckFireEdge sees clean edges. ROOT-CAUSE BUG fixed along the way (benefits ALL subsystems): the MechSubsystem ctor set hostEntity but never set owner (mechsub.cpp) -> every subsystem's owner was garbage once heat de-shadowed; now set in both MechSubsystem ctors. Also: the Mech ctor now zeroes the target slots (+0x37c/+0x388/+0x38c) so an un-targeted mech (spawned enemy) reads "no target" (else its weapons fire at a garbage pointer). WAVE 4 (standalone readouts) DONE — Sensor / Searchlight / ThermalSight / AmmoBin un-stubbed + ticking (verified: BLH tick 20→26 executable, Mad Cat 24, combat DESTROYED un-regressed on both, 0 crashes). The 4 readout classes were reconstructed but built as RECON_SUBSYS stubs; each is now wired via a Create<Class>Subsystem bridge (in its own .cpp) at the mislabel-correct factory case: 0xBC3→Sensor (MyomersClassID label), 0xBD8→Searchlight (LegSubsystem), 0xBDE→ThermalSight (MechDisplay), 0xBCB→AmmoBin (JumpJet). Each got the Torso de-shim: delete the cross-family shadow fields, redirect accessors to the real inherited base, add a friend struct <Class>LayoutCheck with static_assert offset/sizeof locks. Redirects by branch: Sensor (PoweredSubsystem:HeatSink leaf) → heatAlarm/ electricalStateAlarm/simulationState/heatEnergy/simulationFlags|=8; Searchlight/ThermalSight (PowerWatcher) → watchdogAlarm/heatAlarm/simulationState/ForceUpdate()/simulationFlags; AmmoBin (HeatWatcher) → simulationState (deleted the statusState@0x40 shadow).

  • FUNCTIONAL BUG FIXED (Searchlight + ThermalSight): the ctor gate read the shadow segmentFlags (=0), so ((0&0xC)==0 && (0&0x100)!=0) was ALWAYS FALSE → their Performance NEVER installed (dead since reconstruction). The de-shim switches the gate to owner->simulationFlags (binary-exact part_013.c: 6012/6140; mirrors torso.cpp:181) → they now tick. (Sensor uses the simpler !=4 gate; AmmoBin's gate was already correct.)
  • AmmoBin layout: ammoAlarm retyped HeatAlarm(8B)→WatcherGaugeAlarm(0x54) + the statusState shadow deleted → ammoModelFile lands at the binary 0x1E8, sizeof==0x22C exact (locked, the load-bearing lock). ⚠ CORRECTION to an earlier assumption: the all-laser Blackhawk is NOT AmmoBin-free — it DOES carry AmmoBins (the un-stub exposed the crash below ON THE BLH, and BLH tick rose 20→26 = Sensor+Searchlight+Thermal+AmmoBins).
  • ⚠ SHARED LATENT BUG EXPOSED + GUARDED (HeatWatcher::WatchSimulation, heatfamily_reslice.cpp:305): the un-stub made AmmoBinSimulation (and via PowerWatcher::Simulation, the light/thermal sims) call the HeatWatcher base WatchSimulation, which derefs watchedLink.Resolve()->currentTemperature — the watch link isn't resolved in bring-up (the "WatchedSubsystem" name→roster-segment bind doesn't happen), so Resolve() returns null → AV (AmmoBinSimulationWatchSimulation, caught under cdb). The binary never null-checks (its link always resolves at load). GUARDED (marked): if (watched==0){ heatAlarm.SetLevel(0); return; } — same pattern as Emitter::TrackSeekVoltage. FAITHFUL FOLLOW-UP = resolve watchedLink so the heat-watch alarm tracks the real watched temperature (it gates cook-off / light-blank / thermal-blank — secondary behaviors, inert until then).
  • LAYOUT-LOCK FINDING (a new systemic fact): the heat-leaf branch (PoweredSubsystem:HeatSink) is NOT byte-exact to the binary — Sensor's exact-offset asserts (radarPercent@0x31C) FAILED at compile time, unlike the re-based WATCHER branch (Searchlight/ThermalSight/AmmoBin offsets ARE exact and locked). So Sensor uses only the OVERFLOW lock (sizeof<=0x328); its own fields don't land at the binary offsets — fine until the attribute-table RAW read (the RadarPercent gauge) is wired (a Phase-4 HUD follow-up needing the heat-leaf branch re-based, same surgery the Watcher branch got). Nothing reads these subsystems at raw offsets today, so named-member access (compiled-consistent) is correct meanwhile.
  • Factory now: 14 of 20 cases wired to real ticking classes (was 10). Remaining stubs: Myomers (0xBC6, WAVE 6), ProjectileWeapon/MissileLauncher + the unresolved 0xBCE (0xBCD/0xBD0, WAVE 7), SubsystemMessageManager (0xBD3, WAVE 8), Gyroscope (0xBC4, deferred — NaN integrator). Kept DEFAULT-ON (no env gate — pure readouts, no bring-up fallback needed). WAVE 6 (Myomers 0xBC6, mover-coupled) — STRUCTURAL un-stub DONE (gated BT_MYOMERS, default ON; verified: BLH tick 26→27, locomotion + combat un-regressed, 0 crashes; BT_MYOMERS=0 falls back to the Actuator stub → 26). The real Myomers (the mech's artificial-muscle drive — voltage→speedEffect, throttled by heat + damage; ctor @4b8fec, PoweredSubsystem:HeatSink leaf) is now built at factory case 0xBC6 ("Actuator" mislabel) via CreateMyomersSubsystem. KEY: it is wired INERT — it constructs + ticks its real Performance but does NOT affect gameplay, which is exactly what makes a mover-coupled subsystem safe to land: (a) MyomersSimulation early-returns on OwnerAdvancedDamage() (the advanced-damage sim gate; the real source is the still-stubbed SubsystemMessageManager 0xBD3, so it's a False constant → no motion-heat); (b) the mover feed (ConnectToMoverMoverAttach) + SetOwnerMaxSpeed are no-op/local stubs → the live JointedMover is never touched → the gait/drive cannot regress (confirmed: the mech walks normally, pos advances, adv ramps 0.13→1.05). The ctor DOES do real work: resolves the powering Generator (ResolveVoltageSource) + builds the seekVoltage[] gear table from the resource. De-shim: the ~10 cross-family Mech/Mover/DamageZone shim BACKING fields (ownerBaseSpeed/…/damageStructureLevel + segmentFlags) were DROPPED and their accessors return the neutral defaults the ctor primed — necessary because the real object is EXACTLY 0x358 (zero headroom) and the shim fields would overflow the alloc; locked static_assert(sizeof(Myomers)<=0x358) (heat-leaf branch not byte-exact → overflow-lock only, like Sensor). The Myomers name collision is already handled in myomers.hpp (#define Myomers Myomers__powersub_stale around the powersub.hpp include, since powersub.hpp still carries the OLD Sensor-mislabel "Myomers" — that stale class should eventually be deleted from powersub.hpp). ⚠ AUTHENTIC COUPLING = the real Wave-6 payoff, DEFERRED: making the mech actually SLOW when damaged/overheated + motion GENERATE heat needs (1) the advanced-damage gate from a real messmgr (0xBD3, WAVE 8), (2) the owner* shims redirected to real Mech motion/mass/motion-gain accessors, and (3) MoverAttach actually routing speedEffect@0x31C into the JointedMover roster — which drives the LIVE mover, so it must be reconciled with the gait cutover FIRST (both would feed the mover) before enabling. Factory now: 15 of 20 cases wired to real classes (remaining: ProjectileWeapon/0xBCE/MissileLauncher 0xBCD/0xBD0 [WAVE 7], SubsystemMessageManager 0xBD3 [WAVE 8], Gyroscope 0xBC4 [deferred]). HEAT-FLOW LINKAGE CONNECTED — weapon heat now conducts to the central sink (verified: [heat] conduct self.E=.. -> other.E, sustained fire FIRED #1..#21+, combat intact, no crash). THREE fixes, each faithful to the decomp (no stand-ins):
    1. EmitterSimulation ran a no-op stub FUN_004b0bd0 for the "PoweredSubsystem step" -> the emitter never ran its heat sim. Replaced with the REAL PoweredSubsystem::PoweredSubsystemSimulation (@004b0bd0, chains to HeatSink::HeatSinkSimulation = absorb pendingHeat + ConductHeat). Emitter is Emitter:MechWeapon:PoweredSubsystem:HeatSink, so the qualified base call is valid. (emitter.cpp)
    2. Linked-sink Add-gate read the wrong flags. Raw decomp of the HeatSink ctor @004adda0 gates BOTH the Performance-install AND the linkedSinks.Add on param_2+0x28 = owner->simulationFlags, NOT the per-segment subsystem_resource->subsystemFlags (which stream 0 -> dead gate -> no links). The reconstruction had only half-fixed this (Performance gate on owner, Add-gate still on resource). Fixed the Add-gate to owner flags too. (heat.cpp) The link TARGET is still per-subsystem heatSinkIndex.
    3. ⚠ NEW SYSTEMIC BUG — resource-struct layout mismatch (a sibling of the field-shadowing bug). HeatableSubsystem__SubsystemResource inherited Subsystem::SubsystemResource (ends 0x30) instead of MechSubsystem__SubsystemResource (ends 0xE4) -- the class hierarchy is HeatableSubsystem:MechSubsystem so the RESOURCE must mirror it. Result: every heat field slid 0xB4 low; heatSinkIndex read a neighbouring float (10.0f=1/thermalMass) -> GetSegment OOB -> no link, and thermalMass/startTemp were garbage. Fixed the base. Then an 8-byte residual gap: alarmModel was typed as a 4-byte ResourceID (typedef int), but the RECORD reserves a 12-byte field at 0xCC (raw @004ac9ec parses it via FUN_00408944 as 3 floats, with PrintSimulationState at 0xD8). Added int _alarmModelReserved[2]. Now startTemp=0xE4 thermalMass=0xF4 heatSinkIndex=0xF8 EXACTLY match the binary; heatSinkIndex reads real indices (5,2,4,..), thermalMass real values (1.39e6..), links resolve, ConductHeat flows. DIAGNOSTIC TECHNIQUE (reusable for any resource-layout doubt): log compiled member offsets via (char*)&res->field - (char*)res and compare to the binary's known offsets; dump raw record bytes as int+float side-by-side to see the true on-disk layout (records are pre-built 256-byte blobs loaded verbatim at fixed offsets, so OUR struct MUST match the binary or every member read is wrong). NOTE on the payoff: with the REAL data, currentTemperature stays ~77 (startTemp) because thermalMass is genuinely huge (~1e6) vs 0.24 heat/shot; the visible metric that responds to fire is heatLoad (the smoothed radiated-heat reading), which rises/decays with firing. Not a bug -- authentic data behavior. (The "linked other sink reads heatEnergy0" follow-up is RESOLVED — the link target was WRONG; see the heap-corruption root cause below.) THE BGF-LOAD HEAP CORRUPTION — ROOT-CAUSED & FIXED (was: LoadBgfFile("bld08.bgf")Builder::~Builder → AV in operator delete, position-dependent). A 4-agent workflow + hands-on forensics converged:
    • The loader was INNOCENT — an exact Python mirror of bgfload.cpp swept all 879 content BGFs with zero anomalies; every Builder write is bounds-guarded; and the crashed 0x768 block is exactly the correct 474-float capacity for bld08's 472 verts (MSVC 1.5× growth ...316→474). The AV was ntdll's free-list walk reading 0xDDDDDDDD (the CRT freed-fill) inside the ADJACENT block's NT heap metadata — prior corruption, merely DETECTED at the loader's teardown. BT_PROBE_BGF=<name|ALL> + BT_PROBE_N (btl4main.cpp) now direct-loads models at boot for regression (bld08 clean 25×; bld08_lp is a bound-only file, benign FAIL).
    • THE CULPRIT — a TYPE CONFUSION in the heat/power linkage (reconstruction bug): HeatSink's ctor resolved its linked sink via owner->GetSegment(heatSinkIndex) — the Nth skeleton EntitySegment (288 bytes) — cast to Subsystem*. The binary (@004adda0, part_012.c:16999) instead reads owner->subsystemArray[heatSinkIndex] (the SUBSYSTEM ROSTER @0x128, bounds-checked vs subsystemCount@0x124, null-guarded). Through the bogus pointer, every per-frame ConductHeat wrote other->pendingHeat at offset 388 = 100 bytes past the 288-byte block, and BalanceCoolant wrote coolantLevel 20 bytes past — thousands of 4-byte OOB heap writes during sustained fire → smashed NT free-list links next to the mech's EntitySegments → the next big alloc/free churn (a lazy building load) AV'd. Sibling bug in PoweredSubsystem (raw part_013.c:1198): voltageSourceIndex (res+0xFC) is ALSO a roster index; the GetSegment draft made AttachToVoltageSource write currentTapCount 136 bytes past the segment block at every mech spawn. BOTH fixed to the roster lookup via the public owner->GetSubsystemCount()/GetSubsystem(i) (+ the powersub else-gate fixed to OWNER flags per raw). Verified: sustained combat under BT_HEAPCHECK=1 (_CRTDBG_CHECK_ALWAYS_DF, whole-heap validation on every alloc/free — now a runtime env gate in btl4main.cpp) = 100+ shots, ZERO detections; the heat link now reads a REAL sink (heatEnergy=1.34e+07, was ~0 through the segment). LESSON (add to the systemic checklist): a +0x128-style owner offset in subsystem code is the subsystem ROSTER (subsystemArray), NOT the segment table — check every GetSegment(int) call in reconstructed subsystem ctors.
    • Bonus findings from the audit (non-corrupting, tracked): keyframeData[keyframeCount] in LoadLocomotionClips is BINARY-FAITHFUL (the raw does the same read; the value is stable/in-bounds in real clip data — do NOT "fix" to [count-1]); crashClipA/B/C+gimpBaseClip are separate members here but live INSIDE animationClips[] in the binary ([3]/[2]/[4]/[32]) — a wrong-clip fidelity gap for crash/gimp gait states when those go live; Mech::CreateModelResource alloc 200 < sizeof 356 is a LATENT overflow in dead offline-tool code; Torso 640==0x280 alloc is an EXACT fit (zero headroom — any Torso/Watcher growth must bump the factory alloc); StatusMessagePool->New() at btplayer.cpp:460 derefs a NULL stub pool (latent); mechdmg.cpp:289 leaks IntegerPlugs into a no-op DZIndexTable::Add. RESOURCE-LAYOUT AUDIT — DONE (full report: docs/RESOURCE_AUDIT.md; commits 802a7a6/7de66ad/975a397). A 33-agent adversarial audit of all 24 *__SubsystemResource structs found 8 confirmed layout bugs (same silent-garbage class as the heat fix), all rooting to 2 broken base resources (HeatWatcher, PowerWatcher)
  • 2 field defects (Gyroscope, Searchlight). Raw-decomp-verified + FIXED: HeatWatcher re-based onto MechSubsystem (record 0xF0; class+resource+ctor+parser, AmmoBin child follows) and PowerWatcher re-based onto HeatWatcher (record 0xF4; HUD/ThermalSight/Torso children follow) — the reconstruction had approximated BOTH as : public HeatableSubsystem, sliding every Watcher field low. Gyroscope got its unread +0xF4 record slot (exageration→0xF8); Searchlight now reads the inherited segmentIndex@0x28 (was a bogus appended field). Combat + heat-flow un-regressed. KEY PRACTICE ESTABLISHED: lock every resource-struct layout with static_assert(offsetof(...)==0xNN) + static_assert(sizeof(...)==0xNN) against the binary's known offsets — compile-time proof that can never silently regress (far better than a runtime log; the test mech may not even instantiate the subsystem). Cascade notes: re-basing a Watcher class off HeatableSubsystem un-shadows its degradation/failureTemperature; child HandleMessage/Simulation delegates that called HeatableSubsystem:: must redirect to the real base (MechSubsystem::HandleMessage, HeatWatcher::ResetToInitialState); a re-based class's header needs a guarded #include of its new base's header (powersub.hpp now includes heatfamily_reslice.hpp).

MechControlsMapper REVIVED — the real input-interpretation tick runs LIVE (gated BT_REAL_CONTROLS=1). The fully-reconstructed mapper family (mechmppr.cpp MechControlsMapper @004afbe0-@004b08c0; btl4mppr.cpp L4MechControlsMapper/MechThrustmasterMapper/MechRIOMapper @004d11e8-@004d27f8) was already built + constructed at runtime (btl4app MakeViewpointEntity builds a MechRIOMapper into roster slot 0 via SetMappingSubsystem); the ONLY bypass was the tick being skipped (mech4.cpp roster walk) because of raw-offset bugs. FIXED (a 4-agent workflow mapped the binary/API/state first): (1) wild typed-pointer arithmetic in InterpretControls (*(Scalar*)(mech + 0x34c) on Mech* = +0x34c*sizeof(Mech) — THE AV) → all owner reads now reconciled members: mech+0x34c→reverseStrideLength (naming caveat: it is the TOP/run speed the throttle scales — CONFIRMED by data: speedDemand=61.5 == the authentic run speed), 0x534→walkStrideLength, 0x5c0→forwardThrottleScale (NEW member; writer not in decomp, ctor=1.0), 0x438→sinkSourceSubsystem (real binary-exact Torso: SetAnalogTwist/ElevationAxis@0x1F0/0x1F4, GetHorizontalEnabled@0x250), 0x5b4→hudSubsystem; (2) the 0x5b4 cache was MISWIRED: raw factory (part_012.c:10155-10164) case 0xbd6 (HUD, alloc 0x2a4) writes param_1[0x16d] — case 0xbdc (MechTech, binary alloc 0x104!) has NO cache; our factory had it on MechTech (renamed member mechTechSubsystemhudSubsystem, cache moved to the HUD case; MechTech's 0x104 alloc could never hold the mapper's +0x28c write); (3) HUD flickerPhase@0x28C renamed freeAimSlew — it IS the mapper's free-aim/reticle-slew output (was init-only placeholder); (4) FillPilotArray's wild app reads (application+0x6c→station+0x190 — the OTHER AV) → the native application->GetMissionPlayer() + GetEntityID() (the same drift-fix btl4mppr's SendFakeButtonEvent uses); (5) the look/eyepoint COMMIT deferred (marked): its binary offsets collide with declared members (mechName@0x360, stateFlags@0x410, poweredSubsystems@0x7bc) — arbitrate before enabling; no port consumer. Wiring (mech4.cpp, gated): an INPUT BRIDGE writes the mapper's published input attributes from WASD/ env exactly as a streamed Direct .CTL mapping would (the dev box has no RIO/Thrustmaster; interpretation stays 100% authentic), the roster walk un-skips the mapper, the SM consumes bodyTargetSpeed=|speedDemand| and the drive heading uses turnDemand (mode-shaped steering with the authentic speed-vs-turn clamps). Verified live: [mppr] thr=1 → speedDemand=61.501 → SM walk→run at the commanded speed; full tick chain (RIO→L4→main + BuildPilotArray) clean; baseline (env off) combat un-regressed (FIRED #221); heapcheck run with real controls + combat = 61+ shots, zero detections. Controls plumbing facts (from the workflow): the engine is a PUSH model — LBE4ControlsManager groups (scalarGroup/joystickGroup/keyboardGroup/ buttonGroup) are fed by ALL devices (RIO serial on the pod, DirectInput on dev) and mapper members register via group[elem].Add(mask,&member,this); L4CONTROLS=KEYBOARD gives NO analog axes (key-RELEASE events only) — so a real stick needs L4CONTROLS=DIJOYSTICK/THRUSTMASTER, or the bridge. Remaining mapper work: fire-trigger through the mapper (streamed .CTL event mappings → weapon demand; gBTWeaponTrigger still the bring-up), look/eyepoint offset arbitration, control-mode cycling (message handlers exist), ControlsMapper-vs-SubsystemMessageManager untangle at factory 0xBD3 (binary 0xBD3 = messmgr; the mapper's canonical home is roster slot 0 — our 0xBD3 case builds a base mapper as a stand-in).

PILLAR A — THE MOTION MODEL IS COMPLETE & DEFAULT-ON (locomotion P3 closed for MP purposes). (1) LEG callback reconstructed (FUN_004a6928 == PTR_LAB_0050d6f0, PE-parse + capstone jump table @0x4a69aa): Mech::LegClipFinished — same 33-state shape as the body's but compares the LIVE mapper speedDemand (binary: *(subsystemArray[0])+0x128; typed mirror controlsMapper), slews legCycleSpeed @0x348, re-arms via SetLegAnimation, gimp alternates 0x12↔0x13 with |ratio| (negative gimp stride). (2) The two-channel split is LIVE (under real-controls): the LEG channel writes the skeleton JOINTS from the live demand, the BODY channel is pure motion (Advance(dt, move_joints=0)) — the binary's leg=local-sim / body=displayed-motion model. The leg enters NATURALLY from Standing: verified progression legState 0→5 (stand→walk) →11 (walk→run) →13↔12 alternation converging on the commanded 61.5 u/s. KEY RAW FIX: the Standing case was INVERTED in BOTH channels (recon had commanded < standSpeed → walk; raw FUN_004a5028/FUN_004a5678 case 0 is standSpeed < commanded → SetAnimation(5), <0 → reverse 0x10, else stand) — without the fix a commanded mech never left Standing. Also fixed: AdvanceLegAnimation double-deref'd the never-initialized controlSource alias (AV) → reads controlsMapper->speedDemand (null → 0 = idle); legAnimationState@0x3b0 == legStateAlarm.level re-synced (same as body). (3) Orientation now composes the yaw RATE into localOrigin.angularPosition via the engine integrate op (Quaternion::Add == FUN_00409f58) instead of rebuilding from a scalar heading — the mech keeps its authentic spawn orientation (rate CONSTANT still bring-up; the per-mech turn-rate param is deferred). (4) Velocity storage every frame (was collision-gated): worldLinearVelocity from the frame delta + localVelocity (linear fwd Z + yaw angular) — the exact fields Mover::WriteUpdateRecord publishes = the MP dead-reckon update-writer data. DeadReckonPose was already reconstructed; consuming it is MP-side. (5) Defaults flipped (see §10a). Consequence of the authentic spawn orientation: BT_SPAWN_ENEMY now places the dummy along the spawn FACING (btplayer.cpp; the fixed z-offset assumed the old forced heading and the mech walked away → "no damage" red herring). Verified default-stack combat: structure→0.933. Remaining locomotion polish (non-gating): authentic turn-rate constant; body-callback gimp handlers (targets 0x70b2/0x7161 undecoded); airborne callbacks (FUN_004a6344/FUN_004a7970); wall-block-vs-climb tuning; collision damage application; the eyepoint/look commit (mapper).

SHADOW — the AUTHENTIC 1995 model decoded + implemented, render-verified (task #20, commit b38e94b). BT 4.10's shadow is NOT a projection: it is the flat *_tshd.bgf proxy drawn as ordinary geometry, posed by jointshadow (balltranslate under ROOT; SKL comment "apply terrain angle to pitch and roll") and its child jointtshadow (hingey; "apply torso twist to yaw", Torso record key TorsoHorizontalShadowJoint). Configured by the model record's ShadowJointName @0xB4 (≤19 chars, "Unspecified"=none), resolved in the Mech ctor (part_012.c:10285-10310 → this[0x10b] @0x42c = the shadow Joint — the earlier recon misread this block as a death-effect lookup; now Mech::shadowJointNode). dpl_SetGeometryShadowVolume exists in libDPL headers but has NO surviving game callers. Port implementation: (1) d3d_OBJECT::mIsShadow + a dedicated DrawMesh path — blend-pass only, SRCALPHA/INVSRCALPHA, no Z-write, constant TFACTOR 0x8C000000 (~55% black), all touched states saved/restored exactly; btl4vid tags tshd objects at load AND sets each drawOp alphaTest=true (pass scheduling fact: HierarchicalDrawComponent:: Execute L4VIDRND.cpp:149 routes objects into PASS lists per-drawOp on alphaTest/drawAsDecal/drawAsSky — an object with all-false drawOps only ever reaches PASS_OPAQUE). (2) Mech::UpdateShadowJoint(normal) writes pitch/roll to jointshadow per-frame. SHADOW TERRAIN-TILT NOW LIVE (was the flat-up placeholder; default-on, gate BT_SHADOW_TILT=0 restores flat). The user reported the shadow DISAPPEARS on elevation + the feet look sunk. Root cause: mech4.cpp fed UpdateShadowJoint a hardcoded flat-up normal, so on a slope the flat *_tshd quad was buried in the hillside and Z-culled (it draws Z-tested, no Z-write, in PASS_ALPHABLEND) → gone; and the missing ground-contact cue made the feet READ as sunk even though the origin is measurably ON the surface (alignment probe at the mech's climb positions: visualorigin = 0.052.7u, i.e. NOT a placement bug — the snap is correct). Fix (mech4.cpp shadow block): now that the ground decode (#15) is live, sample the collision surface via GetMoverCollisionRoot()->FindBoundingBoxUnder at the mech's XZ + two world-axis offsets (D=12u), build the world normal from the height gradient (-dH/dx,1,-dH/dz), rotate it into the mech-local frame by -gDriveHeading about Y (mech is upright, yaw only), and pass it in — jointshadow's SKL comment is literally "apply terrain angle to pitch and roll". A probe miss (mech at its collision node's edge) falls back to flat. Render-verified: on a butte-flank slope the shadow now LIES ON the ground (tilted, follows the slope) and anchors the feet; pre-fix the same steep mid-climb showed NO shadow; combat un-regressed (TARGET DESTROYED after 8 hits), 0 crashes / 0 heap detections under BT_HEAPCHECK. NOTE the probe uses the mech's containedByNode so far offsets can miss (→ flat fallback); a residual uphill-foot dip on steep slopes is the authentic upright-biped geometry + coarse-collision (02.7u), not a placement bug. (3) Torso yaw→jointtshadow was already wired (Torso::UpdateJoints; data-disabled on the BLH record, byte-faithful). THE "MECH SINKS INTO INCLINES + SHADOW DISAPPEARS" BUG — ROOT-CAUSED & FIXED (shadow DEPTH BIAS, L4D3D.cpp mIsShadow block; BT_SHADOW_BIAS= tunes, =0 restores plain Z-test). The user reported feet sinking + shadow vanishing on ANY moderate incline (original vids show neither). Systematic elimination: (a) flat ground placement EXACT — dg100's instance Y is 0.453125 (map dumps printing "0" hid it); visual floor AND slab top both at 0.4531 = the mech's resting Y, zero offset; (b) snap faithful (origin = solid surfaceY, verified decode); (c) LOD selection correct — BGF LODs are FINEST-FIRST (dhillg1 136→47 tris) and the loader takes the first; ⚠ DG100 the desert-floor model is PERFECTLY FLAT (all verts Y=0) — the rolling desert relief is the giant dhillg mounds (r≈950); (d) LOD0-exact content gap (earlier merged-LOD measurements were inflated): mound visual runs 02.1u ABOVE the collision cones on slopes (median 00.5, p90 1.21.5) = ankle-to-shin foot clip at worst patches — authentic, the binary had the same. THE REAL RENDER DIVERGENCE: the shadow proxy drew with a plain Z-TEST, so wherever the visual rose even ~0.3u over the quad the WHOLE shadow was buried+culled — and without its ground-contact cue the small authentic foot-clip reads as "the mech is sinking". The IG board drew ground shadows decal-style (depth-biased coplanar geometry — the classic blob-shadow technique; the shadow FLICKER in pod footage is that bias fighting at edges). FIX: the mIsShadow draw now sets D3DRS_DEPTHBIAS (default 0.004 normalized ≈ 24u at chase range) + SLOPESCALEDEPTHBIAS 1, saved/restored exactly. Render-verified: standing mid-slope at the WORST measured gap spot (606,621 on dhillg2) = shadow fully present lying on the slope + feet visible planted (was: shadow gone, feet "buried"); moderate inclines while walking ✓; flat ground crisp, no bleed; combat un-regressed (DESTROYED after 8 hits, 0 heap detections). Note the residual ≤2.1u foot-clip at worst patches is authentic content coarseness — with the shadow anchoring the mech it reads as standing in soft sand, matching the vids. (A whole-mech terrain-tilt experiment was tried and REJECTED — the 1995 mech is upright-only; do not tilt the body.) BT_CAM_Y/BT_CAM_Z (btl4vid.cpp) override the debug chase-camera offset — diagnostic for separating camera occlusion from real geometry clipping. VISUAL-GROUND CONFORM — the residual foot-clip is now GONE too (btvisgnd.cpp, PORT ADDITION, presentation-only; gate BT_VISUAL_GROUND default ON). The user (rightly) rejected "authentic ≤2.1u clip": the resolution to "how did the devs let this slide" is the 1995 game never rendered this view — it was cockpit-only (you can't see your own feet; other mechs are 100+m away = subpixel), so the artists never needed visual-solid agreement; OUR port adds a close external camera, so the port also owns making it correct. The fix samples the ACTUAL rendered terrain triangles under the mech and lifts the RENDER matrix to the visible surface: at mission load, walk the map's MakeMessage stream (mission->GetMapID()FindResourceDescription → Lock; class-42 instances, skip includes/sky/profile/rwin backdrops), load each terrain model's LOD0 triangles via the ENGINE LoadBgfFile (bgfload.h — game-side callable; indices are double-sided, 6 per tri, take first 3), cache per-model + per-instance world XZ AABBs; per frame BTVisualGroundLift(x,y,z) = highest triangle surface at the mech XZ within a LOCAL band [y1, y+3] (band keeps a butte-base query on the skirt, not the mesa top; clamp [1,+3]) → localToWorld(3,1) += lift at the PerformAndWatch tail (mech4.cpp). localOrigin is NEVER touched — collision/aim/damage/dead-reckoning stay on the authentic solid model; next frame's drive rebuilds localToWorld, so the lift is per-frame render-only. Zero tuning constants — exact content geometry both ways (lift up where visual bulges over the solid, settle down where it dips). Verified: flat desert lift≈0 (float epsilon — confirms both sampler and the flat-exactness finding); full mound crossing lift tracks ±0.02..0.79u continuously; standing + walking mid-mound = feet ON the visible sand, shadow present (the user's exact repro case); combat un-regressed (DESTROYED after 8 hits), 0 crashes / 0 heap detections. NOTE: masters only (replicant render-conform is an MP follow-up); needs cwd = pod BT dir (LoadBgfFile indexes VIDEO/ relative — already a runtime requirement). ⚠ ENGINE-CLASS GOTCHA (add to the checklist): a NEW member added to a 2007 engine class (d3d_OBJECT etc.) MUST be initialized in EVERY ctor init-list — the debug heap fills fresh allocs 0xCDCDCDCD, so an uninitialized flag reads TRUE (here: every object in the world drew as translucent-black "shadow" → near-black scene); and any state the shadow/special draw path sets on the D3D device must be save/restored exactly (a leaked LIGHTING FALSE + ALPHAARG1=TFACTOR also darkened the whole frame). Render-verified via PrintWindow A/B on DBASE: baseline opaque black blob → dark translucent silhouette, terrain/feet visible through it.

BACKFACE CULLING RESTORED (task #20, commit a2ed21a; BT_CULL env cw/ccw/off, default CW). The bring-up D3DCULL_NONE drew interior/back faces the original culled — the "dark wedge" shapes in the distance were the INSIDES of dune/butte meshes, and standing inside a mound visual looked like being sealed in rock. CW is the correct winding for our BGF quad triangulation [a b c][a c d] (CCW/none selectable for debugging). Render-verified on dbase; combat clean. CONTENT FACT (the "mound swallow" investigation): dbase's dune-mound groups (dhillg*) have CRESCENT-RIDGE visuals with HOLLOW interiors but solid FULL CONES (hillg2_c.sld: 2 cones r=200/250 h=36/60) — where the visual exists it matches the solid within ~1u (verified by triangle-exact BGF parsing vs the analytic cone), but a mech can walk through a ridge crest into the hollow and ride the invisible cone — AUTHENTIC 1995 coarse collision, not a port bug. Butte cliff tiers verified BLOCKING (beeline repro: 403 frame rejections, zero penetration). Analysis tooling now permanent: [contact] telemetry in Mech::ProcessCollision, an INSIDE-SOLID streak detector (BT_GROUND_LOG), BT_GOTO="x z" beeline harness (mech4 computes → mapper bridge consumes; the turn-sign/convergence is rough — debug only), and the RES/map/solid scanners preserved in the scratchpad (resscan.py/mapscan.py — worth moving to tools/).

THE AUTHENTIC DPL ENVIRONMENT IS NOW HONORED — day sky + sun + haze + draw distance (task #20, commits 5b615e7 + 9ca9a48). The user compared the desert level to pod footage — ours was dark/muddy with a wrong sky; the fix transformed it to match. ROOT CAUSE: the env pipeline (DPLReadEnvironmentDPLReadINIPage, reading BTDPL.INI in the pod BT dir — set via L4DPLCFG) already ran and correctly resolved the map+time via compare/branch (main → dpl_defaults → des_day → dsdayclear for dbase/day), setting the AUTHENTIC clip=2100, fog=600/2050 (0.46,0.46,0.68 haze), ambient=0.45 0.4 0.45, and a directional sun (1.10,0.9,1.00 @ 50,10,0) — but THREE bring-up stubs clobbered it: (1) EnsureValidProjection (the mission-load safety net, btstubs.cpp) forced clip far=1300, killed fog (currentFogNear/Far=1e9+FOGENABLE FALSE), set WHITE ambient → now a FALLBACK that only overrides when the env failed (viewAngle<=0 || clipFar<=clipNear); when valid it just re-asserts the env projection. (2) the per-frame lighting block re-set D3DRS_AMBIENT to gray 0x404040 (~0.25) every frame → now re-asserts the captured mEnvAmbient (from the map's ambient=); BT_AMBIENT=<hex> still overrides. (3) mCloudRed/Green/Blue+mCloudEmit* were UNINITIALIZED in the ctor (0xCDCDCDCD garbage into the sky material multiply) → init 1.0/0.0. THE SKY: the sky is a *sky.bgf dome (dbase=dsky, grass/arena= sky) authored with a cloud TEXTURE (btfx:dsky_mtl) + white vertex colours. The original drew it in a dedicated fullbright PASS_SKY (D3DRS_LIGHTING FALSE + extended fog, already present from earlier task-#20 work), but the BGF loader route hardcoded drawAsSky=false (the .sky sidecar the .x path keys on doesn't exist), so the dome fell into PASS_OPAQUE → LIT (half-dark) + FOGGED → the dark navy dome. FIX: LoadObjectBGF tags any *sky* object drawAsSky=true → PASS_SKY → the cloud texture shows fullbright. Render-verified dbase A/B: dark muddy + navy dome → bright warm sand + blue cloudy sky + soft horizon haze + distant mesas (2100 far). Combat clean. KEY DEBUG TECHNIQUE: the env parser's DPLReadINIPage processing <page> + Clip/Fog/Ambient/Light banners are gated on [main] debug= — copy BTDPL.INI*DBG.INI with debug=True, point L4DPLCFG at it, and the log shows every page visited + value set. STILL STUBBED (follow-ups): the objectpath/materialpath/texmappath day/night PRIORITY is commented out (dpl_AddToObjectFilePath calls) — the BGF loader (bgfload.cpp) recursively indexes the whole VIDEO/ tree first-match-wins, which happens to pick DAY/ (alphabetically first) for day missions, so day is accidentally correct; NIGHT/morning/evening missions would get the wrong (day) sky+textures, and the grass map renders pale pink/purple terrain (a wrong-time or wrong material picked). Proper fix = reimplement the path priority so the loader prefers the env's objectpath/materialpath dirs in order. ◐ DAY/NIGHT PATH PRIORITY — infrastructure done, materials gated (commit 90a8d92). bgfload.cpp now ranks every index candidate by the INI's search dirs (most-specific-first) and keeps the best per stem; SetVideoPathPriority(cacheKey, dirs) (bgf/bmf/img) is fed the accumulated objectpath/materialpath/texmappath lists from DPLReadEnvironment. BGF priority (geometry) is ON; BMF/IMG (materials/textures) are GATED BT_MATPRI=1, default OFF — a naive "prefer mat\day" over-applies to the SHARED desert terrain materials and washes the warm day terrain to GRAY (render-verified regression on the user's dbase/day). The sky's day/night is itself MATERIAL-driven (btfx:dsky_mtl in mat\day|night), so a correct night sky needs BMF priority — which trades against the day terrain; resolving cleanly needs the terrain-material model understood (why mat\day grays the desert — likely a shared lib whose mat\day variant differs, or a resolution-failure gray fallback). ⚠⚠ CRITICAL GOTCHA — /FORCE turns an unresolved symbol into a runtime crash, NOT a link error. SetVideoPathPriority was first defined INSIDE bgfload.cpp's anonymous namespace → internal linkage → L4VIDEO.obj's call was unresolved → the BT game link (uses /FORCE) silently stubbed it → a call jumped to __ImageBase (0x009d0000), a DETERMINISTIC AV in LoadMissionImplementation that looked like heap corruption. Fix: define it OUTSIDE the anonymous namespace. When a /FORCE-linked build crashes with a garbage call target near the image base, grep the link log for unresolved external — the "successful" build is lying. Also: BTL4VideoRenderer is allocated by the GAME (btl4app.cpp:242) as a DPLRenderer subclass, so ANY member added to DPLRenderer (L4VIDEO.h) needs the game's btl4app/btstubs/btl4vid objects recompiled — CMake header-dep tracking through the btbuild/fwd/* shims is unreliable; delete stale .objs if layout-mismatch corruption appears.

RENDER FIDELITY — the dbase desert now matches the pod footage (task #20; a 10-agent measured render-fidelity workflow found 6 defects, 3 fixed = the "big three"). The user compared our dbase to authentic footage (C:/git/original.PNG): ours was harsh/saturated/faceted; the original is soft, pale, low-contrast. Root causes (measured, not guessed):

  1. Mountains "inside-out/faceted" = MALFORMED GEOMETRY, not lighting (commit d189371). BGF_FORMAT.md wrongly documented CONNECTION_LIST (tag 0x0047) as "one polygon per chunk, fan-triangulate". It is actually a FLAT TRIANGLE LIST (3 indices/face). Fan-triangulating the whole chunk as one N-gon turned buttec's 115 real triangles into 341 fan-garbage with smeared UVs + cancelled normals. FIX: group-of-3 walk in the !gotPconn branch (bgfload.cpp buildPmesh + port/src/bgf.cpp). Corpus-verified: ALL 3488 CONN chunks in the pod GEO tree have n%3==0. bgftest BUTTEC.BGF: 341→115 tris. Gated BT_TERRAIN_TRILIST.
  2. Horizon seam = flat sky plane truncated by the world projection (commit 2434bbc). DSKY is a FLAT plane at Y~110 (±6000), NOT a dome. The world far=2100 projection truncated it above the true horizon → the gap showed the fog-colour Clear as a hard lavender band; the old sky-pass fog (near×3/far×6) left the rim unfogged. FIX: sky-pass gets its own far=9000 projection + the SAME fog as the world (drop ×3/×6), BOTH restored after the sky loop (mandatory — the alpha/particle/HUD passes need the world projection). Gated BT_SKY_FAR.
  3. Sky "two-tone navy, nothing like clouds" = MODULATE vs SCREEN (commit 2a8aa37). The cloud art (bintA) is a GRAYSCALE intensity map; the sky colour is the material tint (0.3,0.5,1.0) baked into the vertex diffuse. MODULATE (texel×tint) → saturated navy. The original SCREENed toward white: screen(D,T)=T+D·(1-T)=lerp(D,white,T) → bright texels near-white, dark → pale tint. FIX: one texture stage D3DTOP_LERP(Arg0=TEXTURE, Arg1=TFACTOR=white, Arg2=DIFFUSE), states saved/restored around the sky draw. Measured screen(0.3,0.5,1.0,0.85)=(228,236,255) ≈ original top (229,230,255). Gated BT_SKY_SCREEN. THE AUTHENTIC IG-BOARD SHADING MODEL (from libDPL DPLTYPES.H:189-199): shading is selected PER-GEOMETRY by vertex type — no-normal geometry (XYZ_UV / XYZ_RGBA_UV: mesas/ground/sky/buildings/mech, all with WHITE baked verts) is drawn UNLIT with the texture colorized by the material's 2-endpoint RAMP (dpl_SetMaterialRamp, texture luminance indexes a low→high gradient — rock (0.25,0.21,0.16)→(0.8,0.5,0.4) = warm tan); only ~150 vehicle/missile files (XYZ_N_UV) carry real normals and were lit by the single map light.
  4. RAMP colorize + day/night material priority — DONE (commits d5c155b, cc0055a); the ground/mountain colour match + sky whites. The ground looked PALER than the mountains with a seam because they use different material libs: the mountains' ravinerock_mtl has a warm diffuse (0.781,0.525,0.353) so they render warm, but the ground's grass_mtl has NO diffuse — it relied ENTIRELY on its colour RAMP, which we didn't apply → gray placeholder = the pale ground. The authentic IG board colours terrain by the material's 2-endpoint RAMP (dpl_SetMaterialRamp): texture luminance indexes a low→high gradient. Implemented: bgfload parses the ramp def (0x30 name + 0x31 6-floats) + the material's ramp ref (0x28), threads rampLo/Hi onto BgfDrawBatch→drawOp; L4D3D bakes lerp(rampLo,rampHi,texLum) into the texture at load and draws ramped batches with a WHITE material + WHITE vertex colour (so the ramp colour shows untinted — critical: a leftover tint MODULATEs the ramp again). DAY/NIGHT MATERIAL PRIORITY (bmf/img) is now DEFAULT ON: the mat\day libs carry the AUTHENTIC warm ramps (rock 0.25,0.21,0.16→0.8,0.5,0.4 = warm tan; grass →warm brown); first-match had picked the generic GEO/*.BMF whose ramps are GRAY (0.8,0.75,0.75) — which only looked warm before because the vertex tint covered for it. The earlier "BT_MATPRI grays the terrain" regression is GONE precisely because the ramp is now applied. SKY via its own ramp: dsky_mtl has a 'sky' ramp (0,0,0.6→0.99,0.99,0.99) → bright cloud texels → 0.99 near-white = the "whites" the user wanted; BT_SKY_SCREEN (the pre-ramp heuristic) now default OFF. Render-verified ALL DEFAULTS: dbase/day = pale pastel sky + soft white clouds + uniform warm tan ground matching the warm mesas = the pod footage; dbase/night = dark twilight + warm-lit red mesas; grass = warm brown; combat un-regressed. Gates: BT_RAMP / BT_MATPRI / BT_SKY_SCREEN.
  5. UNLIT no-normal geometry (STEP 5) — NOT done, and no longer needed. Since STEP 1 fixed the geometry + the ramp gives correct colour, the mesas/terrain look natural lit; the residual faceting is gone. Deferred (⚠ per-drawOp NOT per-object — APC/HUMMER mix XYZ_N_UV + XYZ_UV; ⚠ 0xCD ctor-init gotcha; ⚠ would change the mech to unlit). Full spec: scratchpad rf_plan.md/rf_verdicts.md; the workflow reports are the durable cites.
  6. RAMP over-applied to LIT vehicle geometry → "whitish-tan blobs" — FIXED (bgfload.cpp hasNormals gate). The user saw the map's scattered vehicles (hummer/semi/dozer/apc…) render as uniform dusty-white blobs. ROOT CAUSE: the vehicles' shared basev material library (VIDEO/MAT/BASEV.BMF) gives every material a REAL diffuse colour (bv1_mtl tan 0.66,0.47,0.30; bv1gry gray 0.41; bv5dkgrn green; bv5red; bv1ylw; blk) PLUS a ramp ref (tag 0x28) to the cdusty ramp (0.33,0.27,0.22→0.90,0.85,0.80 = dark-brown→near-white) over the shared atlas BASEV.BSL. Our task-#20 ramp path set batch.hasRamp purely from the material, with no vertex-type gate → it ramped the vehicles too, baking cdusty over BASEV.BSL + forcing a WHITE material → every vehicle washed to the same dusty-white, killing its diffuse colour. But the authentic IG model (DPLTYPES.H) selects shading BY VERTEX TYPE: normal-bearing geometry (vehicles/missiles, XYZ_N_UV/ XYZ_N) is LIT and shows diffuse×texture; the RAMP is a NO-NORMAL feature only (terrain/mesas/sky/ buildings/mech). Vehicles carry XYZ_N_UV (SEMI = 400 XYZ_N_UV + 248 XYZ_RGBA_UV). FIX: bool hasNormals (id) (0x81/0x89) + useRamp = currentHasRamp && !hasNormals(pmeshVtag) per-PMESH in buildPmesh — replaces currentHasRamp for the vertex colour + batch.hasRamp/batch.color. Now normal-bearing batches keep their diffuse colour + BASEV.BSL texture (colored trucks); no-normal batches (incl. the vehicles' own no-normal detail parts) still ramp. Per-PMESH so a mixed-vertex-type model splits correctly. Verified A/B (zoomed distant-vehicle crop): before = gray/white blobs → after = gray trucks + tan/brown cargo + brown container, distinct materials; terrain ramp un-regressed; combat un-regressed (TARGET DESTROYED after 8 hits); no crash / 0 heap detections under BT_HEAPCHECK. NOTE: not separately env-gated — it's a correctness fix inside the existing BT_RAMP system (BT_RAMP=0 still disables all ramping in L4D3D). CORRECTION to an in-chat claim: the vehicles are ALREADY per-light shaded once un-ramped — the loader is NOT missing normals. buildPmesh pushes 0 into nx/ny/nz only as INITIALISATION; emitTri then accumulates the forward face normal into each vertex (bgfload.cpp:442-444) and the finalise pass normalizes them (:597-601) = smooth per-vertex normals for ALL geometry. Lighting is globally ON whenever the map ships a light: DPLReadINIPage parses the env page's light entry (BTDPL.INI, e.g. dbase/day dsdayclear) into sceneLight[] as a D3DLIGHT_DIRECTIONAL sun (L4VIDEO.cpp:3086 SetLight+LightEnable), and the frame setup (:6196-6233) sets D3DRS_LIGHTING TRUE + NORMALIZENORMALS + COLORVERTEX + diffuse/ambient from vertex COLOR1 when sceneLightCount>0. Non-ramp batches get material Diffuse=bv color, Emissive=0 (L4D3D.cpp:303-310), so a vehicle renders texture × [ambient·color + sun·(N·L)·color] = real directional shading (render-verified: a gray tanker shows a bright sun-lit top, dark shadowed side faces, smooth gradients — a flat render would be uniform). The ramp-white was the ONLY thing suppressing it; no separate lighting work was needed. (STEP 5 — making no-normal geometry authentically UNLIT — is the OPPOSITE change and still "not needed": global lighting on the ramped terrain/mesas/mech looks correct vs the footage.)

WEAPON FIRE IS NOW VISIBLE — the muzzle→hit BEAM renders + fires on the trigger, target or not. The user pressed SPACE and saw nothing: (1) the whole fire path was gated if (gEnemyMech != 0) (the BT_SPAWN_ENEMY test dummy) → in solo there was NO target so the trigger ran no fire code; (2) even against the dummy the only visual was an Explosion at the target — the dpl_* weapon-beam renderable was never ported. FIX (two parts): (a) a self-contained additive-quad BEAM renderer in the engineBTPushBeam(from,to,color,ttl,width) queues a world segment, BTDrawBeams(dev,view,dt) (L4VIDEO.cpp, called in the alpha pass so beams are Z-occluded by terrain) billboards each as a camera-facing additive quad and fades it; no content needed (fills the unported dpl_ layer). (b) a visual-fire block in Mech::PerformAndWatch (mech4.cpp) that runs ALWAYS — computes the muzzle (origin + kMuzzleHeight 7u / kMuzzleForward 3u) + the mech's Z forward, picks the aim = the locked enemy (torso) OR the crosshair raycast to terrain (BTGroundRayHit, btvisgnd.cpp, else muzzle+fwdrange), and on the trigger (own cooldown gBeamCooldown) pushes the bolt = red-orange glow (w6) + white-hot core (w1.6) + muzzle flash and (solo) an impact Explosion at the hit. The existing enemy-damage block is UNCHANGED and still runs below (beam → "target" while locked, then "free" after the kill). Render-verified: solo desert — a bright white-cored laser fires forward to the aim point; combat — [fire] BEAM -> target while the dummy is locked, structure 0.8→1.0, TARGET DESTROYED after 8 hits, then "free" beams post-kill; 0 crashes / 0 heap detections under BT_HEAPCHECK. SPACE/Ctrl fire the beam interactively (btl4main VK_SPACE→gBTDrive.fire). AUTHENTIC MUZZLES — the beams now fire from the mech's REAL gun-port SITE segments (arm guns). The BLH.SKL has arm-gun joints jointrgun/jointlgun with muzzle site segments siter/lu/dgunport + sitel/lu/dgunport (upper/down/back ports per arm). The fire block (mech4.cpp) resolves them by name each frame — GetSegment(CString("siterugunport"))EntitySegment::GetSegmentToEntity() (segment→entity LinearMatrix) → AffineMatrix::Multiply(segToEntity, localToWorld)Point3D = matrix (extracts the W_Axis translation) = the world muzzle — and fires one bolt per resolved port, all converging on the aim point (falls back to a centre muzzle if none resolve). Verified: muzzle logged at (2.6,+4.4,+2.7) off the origin = the arm gun, NOT the centre; render shows TWO beams emerging from the left+right arm guns in a converging V, with muzzle flashes at each gun, tracking the arms; combat un-regressed (DESTROYED after 8 hits), 0 crashes / 0 heap detections. The beams are now RED LASER (glow 0x00E01808 + white-hot core), authentic for the all-laser BLH (Nova). ◐ TIE-TO-REAL-FIRE — TRIED, REVERTED (over-saturates; needs a beam throttle). The authentic next step is driving the beam from each weapon's OWN fire: Emitter::FireWeapon has owner (Mech), muzzlePoint, targetPoint, dischargeTime, and damageData.damageType all in scope, so pushing BTPushBeam there (muzzle from owner->GetSegment(segmentReference)→segmentToEntity×localToWorld, coloured by damageType: Laser red / Energy blue / Ballistic amber) gives real rate + heat + per-type colour, and it BUILT + ran (combat: [emitter] FIRED heat=0.239, DESTROYED-in-8). BUT the BLH is a ~12-laser ALPHA STRIKE and FireWeapon runs ~16×/s across the roster; a persistent (dischargeTime) additive beam pushed per fire ACCUMULATES into a white screen-flood at point-blank. So Emitter::FireWeapon no longer draws (kept the mech4 controlled 0.8s salvo). To revive: throttle to ONE beam per weapon per discharge (push only on the fire EDGE, dischargeTimer just reset) + short TTL (no per-frame stacking) + dim per-beam / alpha-blend the glow so N overlapping beams don't saturate. simulationFlags & 0x8 is NOT a safe weapon discriminator (bit 3 is in the engine host/replicant flags & 0xc mask) — enumerate weapons via the factory class IDs, not the flag. FIRE RATE — grounded on the recovered weapon data (was a 0.8s bring-up guess). The user noticed the laser cadence looked too slow vs the pod videos — correct, 0.8s was arbitrary. The real laser params (ERMLASER.SUB/ERLLASER.SUB/... — the BLH is ER-medium): DischargeTime=0.2 (beam-on), WeaponRange=500, DamageAmount=3.5, PipColor=1.0 0.0 0.0 (RED, confirms the laser colour), RechargeRate scales with size (ER-S 1.0 / ER-M 2.0 / ER-L 4.0 / PPC 5.0). Applied: beam-on = kBeamOnTime=0.2 (DischargeTime, recovered hard value); cadence kFireCooldown=0.3 (0.2 + short recharge) → ~62% beam duty cycle, snappy rapid fire vs the old ~15%. RECHARGE CADENCE — FULLY DECODED from the binary (RechargeRate IS the recharge in seconds). PE-parsed the charge-curve constants from BTL4OPT.EXE (.data at the _DAT_ VAs, read as the x87 80-bit float10 the decomp uses, NOT 32-bit float): C1=_DAT_004bb3c4=1.0, C2=_DAT_004bb3b8=1e-4, _DAT_004bb3b4=0.5 (the energyCoefficient ½). The Emitter ctor (raw part_013.c:7899-7906) computes energyCoefficient = energyTotal/ (0.5·seekV²) and energyRampTime = rechargeRate / (VoltageCurve(C1C2·seekV)) / energyCoefficient; the per-frame charge (FUN_004b0d50 dtScale × TrackSeekVoltage) is an exponential approach toward the source voltage with time-constant τ = dtScale·energyCoefficient, and the fire threshold is seekVoltage[rec] = 0.8·RatedVoltage. The constants make it cancel exactly: seekV = 0.8·RatedVoltage, RatedVoltage (GENRATOR.SUB) = 1e4 → seekV = 8000 → curve arg = 1 1e-4·8000 = 0.2 → VoltageCurve=ln (must be, for energyRampTime>0) → ln(0.2) = ln5 = 1.609; and the exponential charge from 0 to 0.8·srcV also takes ln(1/0.2) = ln5. The two ln5 factors cancel ⇒ recharge time = RechargeRate seconds exactly (ER-S 1.0 / ER-M 2.0 / ER-L 4.0 / PPC 5.0). So one laser's full cycle = DischargeTime + RechargeRate = 0.2 + 2.0 = 2.2s for the BLH's ER-mediums — much SLOWER per weapon than any guess; the fast on-screen fire is the ~12 lasers firing STAGGERED (chain), aggregate ≈ 12/2.2 ≈ 5/s. Applied (mech4.cpp): kPortRecharge=2.2, fire ONE gun port per stagger tick (kPortRecharge/nMuz) rotating through the 6 arm ports (added siter/lbgunport) → a rapid alternating single-beam stream, each port honouring the 2.2s recharge (render-verified: single red bolt per frame, rotating ports; combat DESTROYED after 8 hits, 0 crashes / 0 heap). ⚠ ASSUMPTION: that the game STAGGERS rather than synced-volleys (a synced alpha strike would flash every 2.2s = slow, contradicting the pod footage) — the per-weapon 2.2s is decoded-exact; the stagger pattern + exact weapon→port count is the estimate (needs the weapon enumeration via factory class IDs, not simulationFlags). BEAM APPEARANCE — DECODED from the assets (the "gritty" look is a real scrolling texture, NOT invented). The user noted the lasers looked too clean. Decoded the real beam: the weapon resource names its beam model (VideoObjectName=ermlaser.bgf, GraphicLength=2000; beamLengthRatio=dist/2000 scales it). ermlaser.bgf = a thin TUBE (89 verts, Z 0..2000) with UVs tiled ~8× down its length, material btfx:beamwhite_mtl (core)

  • btfx:beamred_mtl (glow). The GRIT is a SCROLLING TEXTURE: VIDEO/MAT/DAY/BTFX.VMF (DIV-VIZ2 source) defines TEXTURE(NAME="beamwhite_scr_tex"; SPECIAL="SCROLL 0.0 0.0 0.10 9.5") { MAP {"bexp"} } — i.e. the core samples the bexp image = VIDEO/TEX/BEXP.BSL (a 128×64 chaotic red/yellow/green noise) scrolled FAST along the beam (u=0.10, v=9.5), ramp-colourised by softer (0.25→0.99) to a white core; the glow is solid red (fiery ramp). ⚠ I FIRST WRONGLY SAID THE TEXTURE WAS MISSING — it is present; I'd missed the MAP{} directive in the .VMF (the .BMF only names the texture, the .VMF binds it to the image + carries the SCROLL). THE .VMF IS THE KEY EFFECTS ASSET (scroll speeds for beams/muzzle/smoke/sky: beamwhite v=9.5, beamyellow v=5.3, beamgreen v=1.3, muzinner/muzouter, firesmoke, sky). Beam COLOR is per-weapon via the beam model: ER lasers→ermlaser(RED), standard lasers→l/m/slaser(YELLOW), PPC→ppc.bgf(BLUE), AC/Gauss→ballistic (no beam), missiles→projectiles. Implemented (L4VIDEO.cpp BTDrawBeams; gate BT_BEAM_TEX): load BEXP.BSL once, ramp its luminance by softer → grayscale grit texture, MODULATE the beam colour by it, tile U down the length + scroll (u=0.10/s, v=9.5/s) → the streaming gritty beam. Render-verified: the beam shows banded/streaky grit (was a clean gradient); combat un-regressed (DESTROYED after 8), 0 crashes / 0 heap detections. THE REAL ermlaser.bgf TUBE NOW RENDERS (replaced the camera-facing billboard quad; gate BT_BEAM_TUBE, default ON). BTDrawBeams (L4VIDEO.cpp) loads the tube geometry ONCE (LoadBgfFile("ermlaser.bgf") → static s_tubeVB/s_tubeIB, x,y,z,u,v per vert) and per beam builds a world transform Scale(w,w,beamLen/2000) × Rotate(local Z → muzzle→target dir) × Translate(muzzle) — the tube's native Z 0..2000 maps onto the shot, its baked UVs (U 0..7.73 tiled down the length) carry the scrolling bexp grit via a D3DTS_TEXTURE0 transform (D3DTTFF_COUNT2, _31=uScroll _32=vScroll), beam colour from D3DRS_TEXTUREFACTOR (the tube verts carry no diffuse — FVF XYZ|TEX1, COLORARG2/ALPHAARG1 = TFACTOR; the billboard path stays XYZ|DIFFUSE|TEX1 with DIFFUSE, kept as the BT_BEAM_TUBE=0 fallback). Native tube is ~0.3u wide so a width scale is applied: BT_BEAM_WIDTH (default 3.0 → ~0.9u; the width scaling ONLY affects the tube path, which is how a run was confirmed to use the tube not the billboard). All touched state saved/restored (TEXTUREFACTOR, TEXTURETRANSFORMFLAGS, D3DTS_TEXTURE0). Render-verified (dbase/grass, BT_FORCE_FIRE+BT_SPAWN_ENEMY): a clean 3D volumetric bolt from the arm-gun muzzle to the target, staggered single-port fire, scene un-regressed, 7+ shots, 0 crashes. ⚠ TEST-HARNESS GOTCHA (cost an hour — not a game bug): the env gates check getenv(name) for NON-NULL, so $env:BT_HEAPCHECK="" (empty string) still ENABLES heapcheck — every run after I first set it crawled through CrtCheckMemory on each string alloc in the material scanDir (O(n²) during mission BGF indexing) and looked like a mission-load HANG at [zonebuild] (Responding:False). cdb on the "hung" process showed the main thread live in ucrtbased!CrtCheckMemoryscanDir/bmfIndexLoadBgfFileDPLReadINIPage, not stuck. FIX: Remove-Item Env:\BT_HEAPCHECK to actually clear it (setting ="" does not). Applies to ALL BTEnvOn-style gates. AUTHENTIC DISTANCE LODs — "first LOD only" was WRONG; the arena "structures not rendering fully" bug (bgfload.cpp + L4D3D + L4VIDEO). The user found arena1 structures rendering as floating fragments (poles/ letters/rails with no massing). Triage ruled out the CONN fix (A/B: 28 vs 29 tris on AR03, identical) and load failures (none). ROOT CAUSE: each BGF LOD chunk carries a 0x2046 header = [near..far) VIEWING BAND (AD01: [0..57.7]=close-up 3D logo letters, [57.7..231]=full billboard, [231..1155]=far; AB07: near band = a sliver+pole DETAIL SUBSET, [115..1155] = the whole building) — the IG board selected the LOD whose band contains the camera distance. Our loader always took the FIRST LOD; for classic reduce-LODs (calpb 152→16 verts, dhillg, buttes) first==finest so it LOOKED right, but the arena's composite structures author their massing in the FAR bands → only near-detail fragments drew. FIX: the loader collects EVERY LOD tagging batches with the band (BgfDrawBatch/L4DRAWOP lodNear/lodFar); DrawMesh selects per draw-op by |object camera| (camera published per frame via d3d_OBJECT::SetCameraPosition, eye = t·Rᵀ from the view matrix). CONSOLIDATION IS NOW DEFAULT OFF (BT_CONSOL=1 re-enables) — merged ops combine many instances
  • LODs of one material, so per-op bands can't survive the merge (a consolidated run would draw EVERY LOD simultaneously). Frustum culling + HWVP carry the cost — measured FASTER than consolidation: dbase combat avg 1.05ms, 79 batches (distant objects draw their small far-LODs). VERIFIED: arena renders a coherent scene (continuous perimeter wall, full buildings, no floaters); dbase terrain/buttes unchanged; combat TARGET DESTROYED after 8 hits, 0 crashes. Viewer: BT_LOD_INDEX=n inspects a specific LOD. FOLLOW-UP: a few PINK horizon patches on dbase = far-LOD materials that never resolved before (their libs never loaded); minor. ⚠ LESSON: "first LOD" was validated only on reduce-style models — a format assumption verified on a subset can still be wrong for another authoring style; the 0x2046 header was readable all along. ⚠⚠ LOD-DISTANCE BUGS FIXED THE NEXT SESSION (user: "mech disappearing/reappearing with viewing angle", then "scenery still blinks"): (1) the CAMERA EXTRACTION from the view matrix was TRANSPOSED — the D3D row-vector LookAt stores the camera basis in COLUMNS (xaxis=(_11,_21,_31); eye.x = (t·col1) = (_41·_11+_42·_12+_43·_13)); using ROWS gave a pseudo-position that VARIED WITH THE VIEW ANGLE, so LOD bands popped in/out as the camera rotated (structures AND the mech blinked). (2) the METRIC IS THE INSTANCE ORIGIN distance — a bounding-sphere-SURFACE metric (tried briefly, blamed for a floor vanish that was really bug #1) subtracts each component's OWN radius, smearing the COORDINATED band handoffs of composite structures (ADWLK1 ends [..115] exactly where AB07's far band [115..] takes over) by 2050u per piece → holes/doubles the orbiting chase camera swept pieces in and out of = the scenery blinking. Floor slabs' single bands are wide (ctarmac [0..1155], dtarmac [0..1039], afloor [0..577]) — origin distance keeps them visible across the play space; LODing out beyond that range (far outside the arena) is authentic. BT_CULL_LOG=1 dumps big culled objects (bounds sanity). Verified: 10-frame full-rotation sweep = mech + floor + scenery stable (2 frames camera-inside-a-wall, expected); dbase combat DESTROYED after 8 hits. ⚠ LESSON: changing two things at once (camera fix + metric) attributed the floor vanish to the wrong one — the sphere metric was never needed. ⚠⚠ RUNTIME LOD SELECTION — REVERTED TO EXPERIMENTAL (BT_LODSEL=1); DEFAULT = SHIPPING-ENGINE BEHAVIOR. Even with the correct camera + origin metric + the authentic mech-eyepoint reference (fed via BTSetLodEye, kept wired), runtime band selection still broke arena content (floor vanished when the mech wandered >577u from afloor's origin — impossible in the real game, so the 1995 semantics for huge grounds involve something not yet decoded: per-LOD reference points (dpfGetLODReference/dpl_SetObjectLodHotSpot exist in the libDPL API!), the unparsed class-0 arena map records, or ground-type exemptions). DEFINITIVE REFERENCE FOUND: DivLoader/VGCDivLoader.cpp parseLOD — the SHIPPING 2007 engine keeps ONLY the nearest-band LOD (if (in < 5.0f) parse = true) and does NO runtime LOD selection at all — i.e. our original "first LOD" behavior IS what Red Planet shipped with; the arena-composite fragments exist in the 2007 engine too. DEFAULT restored to that (first LOD + consolidation ON; BT_LODSEL=1 switches to the experimental full-LOD path and auto-disables consolidation). TO FINISH PROPERLY: decompile BTL4OPT.EXE's own LOD path (the 1995 software renderer's selection incl. hot-spots) instead of guessing — then the arena backdrop can be made authentic. All the plumbing (bands parsed+tagged, per-op DrawMesh test, eyepoint feed) is in place and gated. ADDITIVE_LODS DECODED — the arena "fragment structures" FIXED, authentically (default ON, gate BT_ADDLOD=0). The follow-up research cracked it WITHOUT decompiling the binary: the 1995 MUNGA_L4/L4VIDEO.CPP TestSpecialCallBack (~line 486) reads the OBJECT-level SV_SPECIAL (0x2037) token ADDITIVE_LODS from the BGF itself and calls dpl_SetObjectAdditiveLODs (DPL_VPX.H). 135 pod BGFs carry the token — ALL arena structures (AB*/AD*/AR*/AW*), the PGN calliope-turret family, buildings (HUT/TWR/BNK/JAX/FT/HN), vehicle props (TRK*), and EVERY *D mech damage model. Corpus sweep (841 GEO BGFs): additive objects author their LODs as a clean PARTITION of [0..outmax) with COMPLEMENTARY geometry (AW01: LOD1 = upper structure, LOD2 = base — different regions, not nested representations) — so replacement-selection is provably WRONG for them (one complementary piece at any distance) and first-LOD-only shows just the near-detail piece = the "floating fragments". THE RULE: additive object → at eye distance d, EVERY LOD with d < OutDist draws (InDist ignored; near detail ADDS onto the coarser massing and drops first as you retreat). One corpus quirk: MSLG.BGF has an inverted band → clamp max(in,out). Implementation: bgfload.cpp TAG_OBJECT parses 0x2037 → additive objects collect ALL LODs, each batch banded [0..OutDist); non-additive keep shipping first-LOD-only (mech/terrain/afloor → zero regression risk); L4VIDEO.cpp RecurseStaticObject EXCLUDES banded objects from static consolidation (merged runs can't band-gate) — they render individually via the per-op DrawMesh distance test (eye = the BTSetLodEye mech feed). Verified: arena1 spawn A/B — BT_ADDLOD=0 reproduces the user's exact floating-panels bug AT THE SPAWN (the spawn sits inside a dropzone gate whose massing never drew!), default ON = the complete bay/gate + walkway, coherent structures at all ranges; consol srcMeshes 255→152 (103 additive instances excluded); grass combat TARGET DESTROYED, 0 crashes; cavern + dbase identical to baseline. NOTE: the arena spawn's debug CHASE camera now starts inside the (correctly solid) bay wall — the pod's cockpit camera sat at the mech's head so the original never saw this; drive forward to exit. THE FULL LOD DECODE — √3 RANGES + ORIGIN METRIC + PUNCH CUTOUTS (the 6-agent lod-blink-decode workflow; closes the arena "blinks with movement" + "noisy unstable surfaces" reports).
  1. THE √3 RANGE DECODE (corpus-exact): every stored 0x2046 band value is the AUTHORED euclidean distance ÷ √3 — 92% of the corpus' 3263 nonzero band slots are EXACTLY nice-number/√3 to 6 significant figures, with hand-conversion TYPOS as proof (BUTTEB 2000.73 for 2020.73=3500/√3; MSLG's inverted band = the author typing 10 for 100). afloor 577.35 = authored 1000 (the historic floor-vanish explained); ADWLK1 115.47 = 200; the arena massing 1154.7 = 2000 ≈ des_day clip 2100. Comparing plain euclidean eye distance against stored values made EVERY piece switch at 57.7% of its authored range — inside the arena's clear air (fog 200→1250, clip 1300 [ardayclear]) = the position-dependent blinking. FIX: bgfload.cpp multiplies parsed bands by √3 (kLodRangeScale=1.7320508). With it, NO massing pops in-play (2000 ≥ the arena's max reachable 1972; 54 of 104 popping instances — ALL walls/buildings/gates — stop popping; the remaining are authored detail dropouts at 400-900u, 19-66% fogged). A disjoint 7.45% RAW-integer band population exists (one prop-family artist skipped the conversion — GD/RB/WL/SW/SB/TK1 etc), 100% NON-additive → unaffected today.
  2. METRIC = the instance-transformed OBJECT HOT SPOT (default = origin), ONE shared d for all LODs of an object (s_dplobject.lod_hot_spot; InDist doesn't even survive to the runtime — the board keeps only lod_ranges[16] switch-out floats, which STRUCTURALLY confirms the cumulative rule). The bounding-sphere-SURFACE metric (previous commit) was UNAUTHENTIC — per-piece radius offsets desync sibling bands (the OpenFlight "use previous slant range" contract: siblings must range against the SAME point). Reverted to origin distance in DrawMesh.
  3. PUNCH cutouts (dpl_Punchize) implemented — PATCH-level SV_SPECIAL PUNCH (1995 TestSpecialCallBack L4VIDEO.CPP:563-569) marks cutout geogroups (arena scaffold layers AR01-04, gratings): black texels = HOLES. The port drew them SOLID, shingled 0.05-0.5u over the wall massing = near-coplanar depth shimmer in motion (the "noisy unstable surfaces"). Now: loader tags batches (bgfload punch flag) → texture loads with alpha-0 on near-black texels → DrawMesh alpha-TESTS punch ops in the opaque pass (ALPHAREF 0x80, z-write preserved, states restored). Gate BT_PUNCH=0. Render-verified: the catwalk rails/barriers draw as see-through lattice. Corpus classification (the same workflow): additive LODs are overwhelmingly COMPLEMENTARY (families: detail+massing 54, progressive layered series 49 [AR01 = trim→scaffold→scaffold+panels→ SOLID WALL, disjoint layers, correct under cumulative], single-detail 9, mixed 23); real coplanar DOUBLED overlap only in props (TRK1/3/4 grille detail flush on the body) + mech damage arms — those carry GEOMETRIZE 0x8000xxxx hints (decal/priority, unimplemented lead), authentic-coexisting on the board too, left as-is. Other decoded facts: map class-0 records = INCLUDE directives (mid==3 + type-14 target; arena1→{arenall→cavern, artrucks} — the arena sits INSIDE the cavern map; class-48 = named dropzones); NO 0x2047/0x2048 exists anywhere in the corpus; Division dVS has no additive concept (VPX-local extension); OpenFlight LOD semantics (OGC 19-065) match the tag set 1:1 (reference-point ranging, transition ranges) — the reference for future replacement-LOD work. Diagnostics kept (permanent): BT_LOD_LOG=1[lodband] per-model banded-op census at load, [lodeye] eye-feed trace, [lodflip] per-op band-visibility FLIP events with model name + band + d (how the oscillation was caught: awbox1 flipping IN 3× at d≈461 vs edge 461.88). Verified: arena traverse = no massing flips (only authored detail handoffs at 400-900u under fog); walk-out renders banners + see-through rails + complete walls; grass combat TARGET DESTROYED, 0 crashes; dbase/cavern identical; avg frame 4.5ms. THE COPLANAR-SHELL DECODE — the "extremely unstable walls" (noise/diagonal/dot patterns crawling with movement, stable parked) ROOT-CAUSED + FIXED (submission-order depth bias; commit bcc9210). Diagnosis chain (each step eliminated a hypothesis): two stills at one position = pixel-identical (pattern is a pure function of camera position); layer-isolation imaging (BT_ONLY_LOD=k) showed the dot lattice INSIDE A SINGLE LOD layer (not layer-vs-layer); BT_PUNCH=0 left it unchanged; analytic geometry (AR02 LOD2) found the truth: the content layers EXACTLY-COPLANAR full shells inside one LOD — a solid concrete shell + a coplanar PUNCH-cutout overlay + an inner shell, plane separations measured 0.0000.003u. The Division board composited coplanar polygons DETERMINISTICALLY (exact per-polygon plane equations → identical depth → later-submitted wins via less-equal); D3D9 interpolates depth from vertices, so two tessellations of one plane differ by rounding PER PIXEL = crawling interference at ANY distance (near-plane precision, per-LOD bias, punch alpha all irrelevant — hence the failed attempts). FIX = reproduce the board's rule: every batch of an additive object is depth-biased ~4 LSB of D24 CLOSER per submission ordinal (file order = authored order; 2.5e-7 NDC/step, ordinal capped 20, BT_LAYER_BIAS overrides) — coplanar contests resolve to the overlay patch at all distances. Verified by bias on/off A/B at a teleport-reproduced viewpoint. Also: punch cutout mips are built MANUALLY with alpha RE-BINARIZED per level (box-filtered alpha hovers at the test threshold → texels flicker as movement shifts the mip level). And BT_LOD_SCALE is BLANKET again (commit 1bea626): the outermost-only restriction guarded against extending shingled detail into z-fight range — obsolete under the bias — so inner detail (wall-top caps, authored 800) now holds to 2000 = zero wall-detail pops in-arena (traverse flip census 104 → 7, remaining = two small pieces at 375500u under fog). HARNESSES (permanent): BT_SPAWN_AT="x z [hdg]" teleports the player on the first driven frame for exact-viewpoint A/B repro — MUST set the orientation QUATERNION (localOrigin.angularPosition = EulerAngles(0,h,0)), not just the scalar gDriveHeading mirror (the chase camera derives from the quat; without it the view keeps the random dropzone's facing). Teleporting INTO a dune can leave the mech sunk (ground probe from inside a solid) — cosmetic, walking recovers. BT_ONLY_LOD=k = additive layer-isolation imaging. ⚠ GOTCHA (cost a loop): bgfload.cpp builds into the ENGINE lib — a game-only rebuild silently ships the stale loader; grep the exe for a new string literal when in doubt. Remaining authentic-LOD work: replacement selection for NON-additive multi-LOD models (detail pop at range — needs the board's hot-spot/reference semantics; leads catalogued: s_dplobject.lod_ranges[16]/ lod_hot_spot/lod_fade_range in DPL_PRIV.H, per-LOD 0x2047 reference points, 0x2048 transition snap|blend, dpl_SetLodStress perf governor, dpl_SetInstanceForceLOD). RESEARCH TECHNIQUE (reusable): LIBDPL.LIB is an OMF library with full symbol names — parse THEADR/PUBDEF records (python ~40 lines) to map every dpl_* entry point; the 1995 DOS MUNGA_L4 HAL source is the game-side caller reference; dsys/PFBIZTAG.H names every BGF tag (0x2046=LOD_DISTANCE, 0x2047=LOD_REFERENCE, 0x2048=LOD_TRANSITION, 0x2037=SV_SPECIAL).

THE RAINBOW/GRAFFITI MECH SKINS — THE BSL BIT-SLICE FORMAT DECODED + FIXED (user: "are the mechs colored accurately? they all seem to have a rainbow/graffiti motif" — they were not; every mech now renders its authentic gray-metal paint). A 4-agent workflow (BSL corpus census / format ground truth / our-loader audit / BMF material spec) + hands-on. ROOT CAUSE: .BSL is NOT one RGBA image — it is a bit-sliced container: w*h 32-bit texel words, byte 0 pad, the other 3 bytes = SIX independent 4-bit grayscale sub-images packed 2-per-byte with the nibble pair SWAPPED (even slice = HIGH nibble; shift=(c+((c+1)%2)*2)*4), plus truecolor sliceTypes 7=RGB444/8=RGBA4444 (r=s5,g=s4,b=s3,a=s2) that COEXIST with mono slices in one file. BLKHWK.BSL holds the Blackhawk's 4 skin sheets this way; our decoder read bytes[1..3] as RGB = 2-3 different gray sheets overlaid as colour channels = the graffiti. Slice selection = the BMF TEXTURE record tag 0x18 BITSLICE (u8, absent=0) — the entry NAMES in the BSL directory are authoring records, read-and-discarded at runtime (proof: BASEV.BMF bexp9_tex→slice 8 vs entry named bexp99). Definitive reference found: the shipping 2007 loader DivLoader/VGCDivLoader.cpp:323-410 (LoadBSLFile/getBSLData) + Division dsys/PIMAGE.H dpiBSLTYPE + content build scripts (img2vtx.exe -b -m0..-m5 <tga>); corpus-verified on all 66 archive BSLs (GEN.BSL's 6 entries can't fit any byte-plane model — the smoking gun). FIXES: (1) port/src/image.cpp decodeBSL rewritten to slice decode (one file serves engine+viewer; BT_BSL=0 = legacy fallback); (2) bgfload.cpp parses tag 0x18 → MatInfo.texChannelBgfDrawBatch.texChannelL4D3D decodeImage(tp, channel); (3) cross-library RAMP registry — every mech-skin material references ramp softer (neutral 0.25→0.99) which is defined only in OTHER libs (BTARENA/BTFX/BTPOLAR/BTVEH); per-file lookup left mech skins un-ramped forever; on first miss the loader now sweeps the indexed BMFs once into a global registry (BT_RAMP_XLIB=0 disables); (4) RAMP TINT RULE — the content pins the (binary-only) board combine from both sides: dsky_mtl has diffuse (0.3,0.5,1.0) + the COLOURED 'sky' ramp yet renders footage-white ⇒ diffuse must NOT modulate coloured ramps; the mech variants (gen2medgry vs gen3drkgry etc.) share texture+NEUTRAL 'softer' ramp and differ ONLY by diffuse ⇒ diffuse MUST tint neutral ramps. Rule: explicit diffuse + NEUTRAL ramp ⇒ tint; coloured ramp ⇒ white (BT_RAMP_TINT=0 restores always-white). Terrain/sky pixels UNCHANGED by construction; (5) truecolor slices (≥6, e.g. damcolor's bdam8 damage sheet) bypass the ramp; (6) beam grit (L4VIDEO BTDrawBeams inline decode) now samples slice 0 (bexp1 — beamwhite_scr_tex has no 0x18 tag); (7) viewer mirrored + its texture cache re-keyed path#channel (4 textures share BLKHWK.BSL). Render-verified: grass + close-up = coherent gray-metal Blackhawk with crisp panel/vent detail, dark charcoal accent zones, mauve gen*a leg tints (arena shot), green lgo6 leg-logo edge visible — the BMF-spec oracle look ("gray panel-detailed mech", per-zone) exactly; dbase terrain/sky/vehicles byte-identical to the tuned look; arena walls/punch gate clean, 0 lodflips; combat under BT_HEAPCHECK = TARGET DESTROYED after 8 hits (exact baseline), 0 crashes / 0 heap detections. Docs corrected (ASSET_PIPELINE.md §Pixel formats, CLAUDE.md §5). FOLLOW-UPS: RGBA4444 alpha (TREE.BSL cutout) is decoded but not yet alpha-tested outside punch batches; VTX/TGA paths untouched; the DZM damage-state material swap (THOR stores damage skins as extra slices thor12d/thor34d) still pending damage-graphic-state work.

WRECK FLOOR-TILE FLICKER — FIXED (baked ground-shadow models + consolidated-world recession; commits 15fc70d, 7d5071a). The user reported the floor tile under arena wreckage flickering exactly like the wall panels (pattern changes with movement, freezes parked — the coplanar-shell signature, and visible in original demo vids too; user chose to fix). TWO coplanar fighters found: (1) baked ground-shadow MODELS — the <prop>S.BGF naming convention (MECHMOVS/COMS/ARMRS/COOLS = shadow companions to wreck props): flat quads of basev:shadow_mtl at y≈0.1. Detection = all-shadow-material model (shadowMat per batch in bgfload; material name contains "shadow") → routed through the mech-shadow pipeline (SetIsShadow: translucent dark, depth-biased, no z-write) instead of opaque. (2) The ACTUAL fighter for the tile: MECHMOVR's own ground plates at y=0.000 vs the consolidated arena floor (cross-object coplanarity the per-object submission bias can't order) → consolidated static world is now recessed +7.5e-7 NDC (~3 LSB) (ConsolidateSingleObject op copy), so individually-drawn objects (entity props, additive structures, the mech) win floor-plane ties deterministically. Verified at the user's parked repro spot. Shadow-material models are also EXCLUDED from static consolidation (GetIsShadow → banded), same as banded additive objects.

POLAR MAPS VERIFIED — two "bugs" that are AUTHENTIC + the emissive decode (commit cfbe6d2). polar3/ polar4 boot + render + fight clean. (1) No mech shadow on polar = AUTHORED: GEO/POLAR/*_TSHD.BGF are 188-byte STUBS (deliberate shadow disable on snow; only RAP_TSHD is real) — chased through burial/tilt/opaque- color probes first (BT_SHADOW_BIAS/BT_SHADOW_TILT/BT_SHADOW_COLOR all no-effect) before checking the FILES. Check the content before debugging the pipeline. (2) Pink ice-mound perimeter = AUTHENTIC: the pint material family (MAT/DAY/BTPOLAR.BMF pintBIceEmit_mtl: diffuse BLACK + emissive 0.676/0.614/0.718 + ramp 'icy') = pink-tinted self-lit ice. This surfaced that EMISSIVE (tag 0x26) wasn't parsed — now threaded through MatInfo→BgfDrawBatch (hasEmissive/emissive[3]); PURE-emissive materials (diffuse black + emissive set) render as an UNLIT glow: vertex color forced BLACK (the engine drives lit diffuse from vertex COLOR1) + m.Emissive = batch.emissive — tex × emissive, no sun/ambient. ⚠ pure-emissive test is (color&0xFFFFFF)==0; a material with BOTH diffuse and emissive keeps the lit path.

ALL 8 RES MAPS BOOT + RENDER (map sweep complete): cavern, grass, rav, polar3, polar4, arena1, arena2, dbase — each verified live (screenshot + log: 0 crashes, 0 lodflips). Eggs for every map now exist in the pod BT dir (POLAR3/POLAR4/RAV/ARENA1M/ARENA2/DBASE/MECH2/DEV/TEST...). Arena2 note: the flat brown band between wall-top and sky is the cavern backdrop (arena maps sit inside the cavern map via class-0 includes) — believed authored; re-check only if it misbehaves in motion.

CONN+PCONN COEXIST — 370 of 841 MODELS WERE MISSING TRIANGLES (the "turret base missing panels" bug; bgfload.cpp + port/src/bgf.cpp). The user spotted the calliope turret (calp gun + calpb base pad, on dbase AND cavern) rendering with gaps in the base and confirmed vs pod videos. Chain that found it: loader-mirror (bgfcheck.py) said calpb loads "clean" at 78 tris → the standalone port VIEWER showed the same gaps (ruling out the new consolidation/cull work) → dumping the chunk TREE showed calpb LOD0's PMESH carries BOTH PCONN_LIST chunks (37 quads + 1 hexagon = 78 tris; note ppf=6 hexagons exist) AND a CONNECTION_LIST chunk (24 plain triangles = the base panels) — and buildPmesh processed CONN only if (!gotPconn). CONN and PCONN are NOT alternatives — a pmesh carries quads/hexes in PCONN plus loose triangles in CONN. Corpus scan: 370/841 GEO models have mixed pmeshes (incl. AVA mech parts, buildings, vehicles) — all silently missing their CONN triangles until now. FIX: process both, always (both loaders); probe verified calpb 78→102 tris (+24 = exact); viewer A/B shows the base gaps filled; combat regression clean. BGF_FORMAT.md §Indices + Open-Items updated (second correction to the CONN story: first "one polygon" → flat trilist (task #20), now "either/or" → BOTH). ⚠ LESSON: a "loads clean, all indices in bounds" audit can still be dropping whole chunks — audit what's NOT consumed, not just what is.

THE "TURN HITCH" / 10FPS BASELINE — ROOT-CAUSED & FIXED (~50-80× render speedup; user: "game occasionally hangs/stutters... definitely somehow related to turning"). Instrumentation chain that found it ([spike] frame-time log → [loadobj] load timer → cdb stack sampling → [rstat] draw/present split → per-draw section timers): the hitches happened with AND without input (the user's own repro log: 3 of 6 spikes at turn=0) — turning only made them VISIBLE (at 10fps a 400ms frame while holding a turn snaps the view through 4 frames of rotation = reads as a freeze; going straight the same stall just pauses the scenery). THE REAL PROBLEM was the render baseline: ~107ms CPU per frame (Present <1ms, GPU IDLE). THREE stacked causes, all fixed (engine tree, git add -f):

  1. D3DCREATE_SOFTWARE_VERTEXPROCESSING (L4VIDEO.cpp:2023) — THE ROOT CAUSE. Every vertex of every draw was transformed + LIT + fogged on the CPU. The first CreateDevice attempt used SOFTWARE (the error message below it says "Couldn't create HARDWARE..." — the fallback was always intended); now HARDWARE first, software fallback kept. This alone: dense-view frames 490ms → ~3ms. Also why the RelWithDebInfo build was EQUALLY slow (the D3D software pipeline is external optimized code either way).
  2. D3DX DrawSubset attribute-table scan (~44µs/call × ~1400). Our BGF triangles are DOUBLE-SIDED (each face emitted twice) → GenerateAdjacency degenerate → OptimizeInplace(ATTRSORT) failed SILENTLY → no attribute table → DrawSubset scanned the whole attribute buffer per call; on the huge CONSOLIDATED static meshes ~354µs/call = the 490ms stretches whenever a merged map chunk entered the view. FIX: the batches are already contiguous face-sorted ranges — SetAttributeTable EXPLICITLY (no adjacency at all)
    • per-op bgfStartIndex/bgfPrimCount in L4DRAWOP and a direct DrawIndexedPrimitive path in DrawMesh (DrawSubset kept as fallback for non-BGF meshes). ⚠ D3DXConcatenateMeshes (static-world consolidation) REQUIRES an attribute table on its INPUTS — removing OptimizeInplace without SetAttributeTable made it fail hr=0x8876086c and the WHOLE TERRAIN silently skipped ("206 static meshes") — the mech floated in fog-colored void. ConsolidateSingleObject now sorts faces by op itself (no D3DX optimize) + sets the table + direct-draw ranges.
  3. No frustum culling (the 1995 IG board clipped in hardware; the port drew EVERYTHING every frame). Added bounding-sphere culling: model-space sphere computed at load (mesh ctor), per-frame frustum from view × mProjectionMatrix (NOT a GetTransform read-back — device state between frames holds pass-specific matrices and the planes silently degenerate), 4 side planes + FAR plane (near skipped for pass-projection differences; sky pass never culled; mCullRadius<=0 never culled). Gate BT_CULL_FRUSTUM (default ON). Measured: dbase circling 107ms/2729 batches → 1.2-2.8ms; grass combat avg 4.5-8.7ms; spikes 8→~1 (residual = first-view managed-buffer upload). Verified: full scene renders (terrain/mesas/sky/mech/ shadow/beams), combat DESTROYED, 0 crashes. Diagnostic infra kept (cheap): [spike] (mech4, frame>0.2s), [loadobj] (L4D3D, load>5ms), [rslow]/[rstat] (L4VIDEO, 1Hz frame/batch/cull stats). Also gated the weapon-aim terrain raycast (mech4) to trigger-down/discharge-active frames (was marching SampleBand over every map instance EVERY frame). ⚠ LESSON: the user plays the DEBUG build — but the 10fps was NOT the debug build (RelWithDebInfo identical); always split draw-vs-present and check the DEVICE flags before blaming the compiler. ⚠ D3DXComputeBoundingSphere/SetAttributeTable/etc: any NEW d3d_OBJECT member must be inited in BOTH ctors (0xCD fill reads as garbage bounds → everything culled).

FIRING ARC — no more shooting the enemy out of your back; the arc is DATA-DRIVEN from the torso. The bring-up fire path aimed the beam AND applied damage at the locked target regardless of where the mech pointed (turning away fired lasers backward THROUGH the mech at the enemy behind). DECOMP FINDING: there is NO firing-arc check in the binaryEmitter::FireWeapon (@004bb6d4) fires whenever HasActiveTarget() (entity+0x388) and builds a straight muzzle→target beam, and the weapon .SUB resources carry NO arc field (ERMLASER.SUB = range/damage/recharge/heat only). The 1995 game got away with this because it was cockpit- only: a beam from a front muzzle to a target behind you passes behind the camera — invisible to the pilot (same reason the foot-clip never mattered). Our external camera exposes it, so the port owns the fix. The AUTHENTIC per-mech weapon traverse is the TORSO twist range (what actually lets a mech point its guns off dead-ahead): Torso::GetHorizontalReach() = the wider of the software limits horizontalLimitLeft/Right (res +0x100/+0xFC), or 0 when TorsoHorizontalEnabled=0 (the Blackhawk → guns lined up with the mech facing). Wired: Mech::GetHorizontalFiringReach() (mechmppr.cpp — the TU that safely knows the full Torso type; mech.cpp/mech4.cpp must not include subsystem headers, stub-collision) reads the cached torso @0x438; the mech4 fire block gates BOTH the visual beam aim AND the real Emitter trigger + explosion/damage on targetInArc = angle-to-target ≤ (BASE aim tolerance + torso reach). Base tolerance = BT_FIRE_ARC degrees (default 30) — the reticle/convergence box, a labeled PORT presentation parameter (the cockpit view never needed one); the torso reach is the authentic data part (0 for BLH, widens the arc for a twist-capable mech). In-arc: the beam converges on the target and the shot hits; out-of-arc: the guns fire straight ahead and nothing is hit. Verified live ([target] log shows torsoReach=0rad for the BLH → arc = base 30°): circling — enemy sweeping out of arc → beams go -> free (forward), no hit; facing → -> target converge + damage; walking in to close range → TARGET DESTROYED after 8 hits (un-regressed). ⚠ kWeaponRange=100 (mech4.cpp) is a stale BRING-UP damage-gate threshold (the real ERMLASER WeaponRange=500); the enemy spawns at 120u so a STATIONARY facing test sits just outside it (visual beam fires, no damage until you close to <100u) — cosmetic bring-up wart, not the arc. Remaining: wire the real torso ACTIVE-TRACK (the torso twists toward the target within its limits, so a twist mech's guns visibly follow) — currently the reach only WIDENS the hit test; the joints don't move in combat (torso sim dormant, §Torso). BEAM ORIGIN TRACKS THE LIVE MUZZLE during the discharge (you can't run out from under your own beam). The user saw the beam's near end lag behind the mech when running — the bring-up pushed ONE world-space beam at fire time with a 0.2s life, so the mech moved out from under the frozen segment. DECOMP: the real Emitter re-anchors the beam every frame. Emitter::FireWeapon (FUN_004bace8) computes the muzzle LIVE via GetMuzzlePoint (FUN_004b9948 — resolves the gun's muzzle SEGMENT current world position from owner+0x300 segment table, ref +0xdc) and does NOT store the muzzle on the emitter (only the aim orientation @0x107 and the target point @0xf2 are saved); then EmitterSimulation case 0 (Firing) calls ContinueDischarge (FUN_004baa20) EVERY frame while dischargeTimer>0, re-sending the beam keepalive. Since the muzzle isn't stored, the display re-obtains it live each frame → the beam ORIGIN follows the gun as the mech moves (invisible in the 1995 cockpit-only view; our chase cam exposes the lag). Reproduced in the mech4 visual: a single active discharge is tracked (s_dischargePort/s_dischargeRemain, one at a time since beam-on 0.2s < stagger 0.37s); on the trigger edge it STARTS the discharge, then a per-frame KEEPALIVE redraws glow+core+flash from muzzles[port] (recomputed from the gun segments every frame) to the live aim with a one-frame ttl (exactly one beam alive/frame = same brightness, re-anchored to the moving gun). Verified: running forward while firing, the beam emanates from the arm gun (near end at the gun, not behind); combat un-regressed (TARGET DESTROYED after 8 hits, 0 crashes) while the mech walked the whole time. ⚠ FOLLOW-ON (colour/grit washout from the run-out fix): re-pushing the beam every frame at a one-frame ttl made f = ttl/maxTtl = 1.0 CONSTANT (the old 0.2s ttl FADED, so it was only briefly at full brightness). Three full-bright overlapping tubes (red glow + white core + white flash) at the SAME s_tubeWidth then additively saturated to a solid WHITE cylinder — the red and the scrolling bexp grit vanished. Root cause was TWO things: (1) the tube path ignored per-beam width (all beams drew at the global s_tubeWidth), so the white core covered the FULL beam width instead of just the centre; (2) too much additive brightness. FIX: the tube now scales by b.width * s_tubeWidth (per-beam width honoured; BT_BEAM_WIDTH is now a global MULTIPLIER, default 1.0), and the keepalive pushes just TWO dimmer beams — a WIDE dim red glow (0x00C81404, width 3.0 → carries the ER-laser colour + grit) + a THIN warm core (0x00A07868, width 0.9 → hot centre, no white-out) — dropping the always-on white flash. Result: a steady red bolt with a bright core and visible scrolling grit (render-verified, 24 beams, 0 crashes). A laser is steady-on (not fading), so constant brightness is correct — the bug was saturation, not the lack of fade. Remaining (real weapon fidelity): per-weapon color from the beam model (blue PPC, yellow standard lasers) once weapon enumeration is wired (factory class IDs, not simulationFlags); the throttled real-fire beam (drive off Emitter::FireWeapon, heat); damage by the aimed zone (cylinder lookup, STEP 6). Remaining subsystem families (projectile/missile weapons 0xBCD/0xBCE/0xBD0 [WAVE 7]; SubsystemMessageManager 0xBD3 [WAVE 8]; Gyroscope 0xBC4 deferred — NaN integrator) per SUBSYS_PLAN.md. DONE: Torso (WAVE 5) + Sensor/Searchlight/ThermalSight/AmmoBin readouts (WAVE 4) + Myomers structural un-stub (WAVE 6, INERT — authentic mover/heat coupling deferred).

TORSO twist/elevation aim — joint-I/O + simulation RECONSTRUCTED (dormant; blocked on the WAVE-4 un-stub). The torso's aim→skeleton path is now fully reconstructed and builds green with zero combat regression, but is NOT yet live because the Torso subsystem itself is still a HeatSinkSource RECON_SUBSYS stub at factory case 0xBC5 (its real class is Torso, ctor @004b6b0c — the "SinkSource" label is the usual mislabel). What was done:

  • Mech::ResolveJoint(name)→Joint* (mech.cpp, == binary FUN_00424b60): resolves a named skeleton node via the PUBLIC engine path GetSegment(CString)EntitySegment::GetJointIndex()JointSubsystem:: GetJoint(i) (JMOVER.h/SEGMENT.h/JOINT.h, all in munga_engine.lib) — never touches the protected EntitySegment::jointPointer. The Torso ctor @004b6b0c + Gyro ctor @004b3778 inline this; hoisted as the reuse point (gyro un-stub will call it too). Torso::ResolveJoint forwards to owner->ResolveJoint (out-of-line — needs complete Mech).
  • Torso::PushTwist(Joint*,Scalar) (== FUN_004b66b4/FUN_004b67ec inner block): dispatch on Joint::GetJointType() — hinge (0..2)→SetRotation(Radian(twist)) (FUN_0041d0a8); ball (4..5)→ GetEulerAngles() keep pitch[0]/roll[2], yaw[1]←twist, SetRotation(EulerAngles) (FUN_0041cfa0/ FUN_0041d020). Nodes retyped void*Joint*.
  • TorsoSimulation @004b5cf0 corrected to match the raw decomp (part_013.c 4471-4693): the earlier draft had the command roles SWAPPED — verified true roles: @0x1F8/@0x1FC = elevate up/down (drive currentElevation 0x1e4, clamp vertTop/Bottom), @0x200/@0x204 = twist left/right (drive currentTwist 0x1d8, clamp horiz Left/Right), @0x208 = recenter; analog axes @0x1F0 = twist, @0x1F4 = elevation (the draft misused elevationHalfBottom@0x22C and omitted @0x1F4). Settle test uses targetTwist(0x218) + the OLD twistRate snapshot; limit latches are edge-triggered with @0x260/0x264=twist L/R, @0x258/0x25C=elev top/bottom (the draft had these swapped/non-edge). ResetToInitialState @004b5bf8 likewise made faithful (clears all 4 latches + analog axes + aim state).
  • Per-frame skeleton write wired. KEY DECOMP FACT: TorsoSimulation AND TorsoCopySimulation compute currentTwist but never write the joints; WriteJoints (@004b66b4) is called ONLY by ResetToInitialState; UpdateJoints (@004b67ec, the out-param-free twin) has ZERO direct callers — the per-frame torso→skeleton write was dispatched by the engine's generic joint pass (indirect/virtual). This port ticks the torso's own Performance, so UpdateJoints() is now called at the end of both Torso(Copy)Simulation — the resolved equivalent at the same cadence. Verification is env-gated BT_TORSO_LOG (ctor resolve + layout dump; PushTwist fire log).

WAVE-4 Torso base-chain RE-BASE + un-stub — DONE (real Torso is live, binary-exact layout, zero regression). The Torso is now built by the real Torso class at factory case 0xBC5 (CreateTorsoSubsystem), no longer a stub.

  • The corrected hierarchy (verified from headers + a 6-agent decomp-mapping workflow): Torso : PowerWatcher : HeatWatcher : MechSubsystemNOT HeatableSubsystem. The Watcher branch is SEPARATE from the working PoweredSubsystem : HeatSink : HeatableSubsystem : MechSubsystem heat leaves; the two share ONLY MechSubsystem. So the 156-byte deficit lives entirely in the Watcher branch and re-basing it CANNOT touch the working heat leaves. (Corollary, verified: Torso is NOT IsDerivedFrom(HeatSink) → it never joins the heat-conduction roster, so there was never a heat-roster-corruption risk; the earlier note to the contrary was wrong.)
  • The +156 fix (all in the Watcher branch; MechSubsystem untouched): the binary's HeatWatcher/PowerWatcher use a 0xC-byte connection (watchedLink) and a 0x54-byte gauge alarm (heatAlarm/watchdogAlarm = a full GaugeAlarm), NOT the 4-byte SubsystemConnection / 8-byte HeatAlarm the HeatSink branch uses. Introduced Watcher-local WatchedConnection(0xC) + WatcherGaugeAlarm(0x54) in heatfamily_reslice.hpp (NOT heat.hpp, so the shared types the working leaves depend on are never resized). HeatWatcher: watchedLink→0xC (+8), heatAlarm→0x54 (+76) = +84 → own ends 0x180. PowerWatcher: DELETE the shadow watchedLink (the binary has ONE, inherited@0x114) (4), watchdogAlarm→0x54 (+76) = +72 → minVoltage@0x180, watchdogAlarm@0x184, own ends 0x1D8.
  • Torso own block: DELETED the 7 CROSS-FAMILY shim backing fields (segmentFlags/heatModelOff/heatStateLevel/ electricalState/creationTime/currentTime/movedFlag, 28) and their accessors now read the REAL inherited base (heatAlarm.GetLevel(), watchdogAlarm.GetLevel(), MechSubsystem::simulationState, engine Simulation:: lastPerformance/ForceUpdate()); ADDED the 4-byte _reserved0x270 slot (+4) so recenterActive@0x274 / joints@0x278/0x27C match the binary. Net 24 → own block 0xA8, currentTwist@0x1D8, sizeof(Torso)==0x280.
  • Compile-time LOCKS (the practice from the resource audit, now applied to OBJECT layout — protected fields need a friend struct XLayoutCheck): TorsoLayoutCheck (currentTwist==0x1D8, sizeof==0x280, +0x1E4/0x1F0/0x1F4/0x250/ 0x274/0x27C), HeatWatcher (watchedLink@0x114, heatAlarm@0x12C, sizeof==0x180), PowerWatcher (minVoltage@0x180, watchdogAlarm@0x184, sizeof==0x1D8), WatcherGaugeAlarm==0x54 / WatchedConnection==0xC, and HUDLayoutCheck (HUD::flickerRate@0x1D8 — HUD is the only OTHER live Watcher-branch class; the lock proves the re-base fixes HUD's own torso+0x1D8 read too). All pass. KEY: sizeof() static_asserts work at namespace scope, but offsetof() on a PROTECTED object member needs a friend struct (namespace-scope offsetof → C2248).
  • Verified live (BT_TORSO_LOG): the real Torso constructs with currentTwist@472 (0x1D8), sizeof=640 (0x280), no crash over 26s; heat converges to the identical steady state (pendingHeat 0.239, temp 77, heatLoad plateaus — the transient 0.265 on shot #1 was benign startup-timing variance); full combat loop intact (TARGET DESTROYED after 8 hits at walk speed, matching the prior baseline). The gyro cross-link sinkSourceSubsystem+0x1D8 now reads the live currentTwist (was stub garbage).
  • Why the torso still doesn't visibly twist for the Blackhawk (a FAITHFUL result, NOT a bug): the Blackhawk's 0xBC5 record has TorsoHorizontalEnabled=0 + empty joint names + hRotPerSec=0 (dumped raw; the record IS a valid Torso record — classID=0xBC5, modelSize=0x158). Our resource struct is CONFIRMED correct — the binary ctor @004b6b0c reads enabled at param_4+0x14C (line 5038) and gates joint resolution on enabled!=0 (line 5039), joints at +0x10C/+0x12C, exactly our layout. So the ORIGINAL binary ALSO skips torso joints for this mech; our behavior is byte-faithful. Visible twist needs a twist-enabled mech (a record with enabled=1 + real joint names), or the clearly-marked bring-up force-enable below.
  • FULL PATH PROVEN end-to-end via BT_FORCE_TORSO (bring-up demo; default OFF; NOT faithful). Since the Blackhawk record disables the torso, a gated demo (BT_FORCE_TORSO=1, torso.cpp ctor + TorsoSimulation) force- enables the torso, resolves the REAL BLH.SKL joints, widens the limits, and sweeps an analog twist demand. Verified (BT_TORSO_LOG): Mech::ResolveJoint resolved jointshakey2→type=4 (Ball, torso body) + jointtshadow→type=1 (HingeY, the skeleton's "apply torso twist to yaw" joint); PushTwist then fires every frame with currentTwist sweeping cleanly 0→+0.7 (clamped at limit) → reverse → -0.7 → back — i.e. the WHOLE reconstructed chain (analog axis → TorsoSimulation integrate → clamp → UpdateJoints → PushTwist → Joint::SetRotation, ball branch yaw←twist) runs correctly; no crash over 14s. The only unconfirmed link is the render pass consuming the joint (needs an eyeball). The env-gated demo (+BT_FORCE_TORSO_JOINT=<name> to pick the main joint) stays as an on-demand verifier; faithful behavior is untouched when unset. BLH.SKL torso joints for reference: jointshakey2 (ball, blh_tor.bgf, under jointhip) = torso body; jointtshadow (hingey, under jointshadow) = ground-shadow twist.
  • Follow-on for the sibling un-stubs (Gyro/Searchlight/ThermalSight): they inherit PowerWatcher, so after this re-base their own fields also begin at 0x1D8, but they STILL carry the same CROSS-FAMILY shim fields → each must get the same shim-delete + accessor-redirect (like Torso) BEFORE its un-stub, or wiring it will over-size the object.

◐ WAVE-5 GYROSCOPE (0xBC4) — layout + joint-I/O DONE; un-stub DEFERRED (ctor/integrator reconstruction incomplete). The gyro plumbing is reconstructed and the build is GREEN with the gyro back on its stub (reverted — see why below); the infra is dormant + ready in gyro.hpp/gyro.cpp:

  • Re-base + layout (DONE, locked): Gyroscope : PowerWatcher : HeatWatcher : MechSubsystem (Watcher branch). Deleted its 4 CROSS-FAMILY shim fields (segmentFlags/heatModelOff/heatStateLevel/electricalState) + redirected the 3 used accessors to the real inherited base (heatAlarm/watchdogAlarm/simulationState), retyped eyeJointNode/mechJointNode void*Joint*. GyroLayoutCheck friend locks exageration@0x1D8 + sizeof<=0x3D0 (both pass live).
  • Joint I/O (DONE, real): the 8 node-helper stubs (NodeType/NodeScalar/SetNodeScalar/NodeVector/SetNodeVector/ NodeRotationEquals/SetNodeRotation/NodeVectorEquals) now call the real engine Joint API — GetJointType/GetRadians/ SetRotation(Radian) [hinge]/GetEulerAngles/SetRotation(EulerAngles) [ball]/GetTranslation/SetTranslation [BallTranslation]; FUN_0041d11c = SetTranslation (writes *(joint+0xc) + jointModified). ResolveJoint forwards to Mech::ResolveJoint. WriteEyeJoint/WriteMechJoint (no direct caller in the decomp, like the torso's UpdateJoints — dispatched by the engine joint pass) are wired at the end of GyroscopeSimulation.
  • Verified live then REVERTED: the real gyro constructs, resolves REAL joints (EyeJoint='jointlocal', MechJoint='jointeye', both BallTranslation type=5), and WriteMechJoint FIRES every frame — but with garbage values (-4.31e8 = 0xCDCDCDCD-as-float, and NaN) written to jointlocal (the ROOT joint). Root cause: the gyro's ctor field-init + integrator field-MAPPING are incomplete/incorrect in the reconstruction. The binary ctor @004b3778 puts springConstant@0x1E8, dampingConstant@0x1F4, posSpring@0x218, negSpring@0x224, rotation springs @0x2C0/0x2CC/ 0x2F0/0x2FC, but the recon mislabels several (e.g. assigns springConstant to eyeOrientation@0x1DC; the field the integrator reads as eyeSpringConstant@0x1F4 is really dampingConstant) and leaves the integrator accumulators (eyeAccel/eyeForce/eyeWork/bodyOrientation/bodyAccel/bodyForce/bodyWork/eyeVelocity/limits) UNINITIALISED. Writing NaN to the root joint is unshippable, so 0xBC4 was reverted to the Gyro stub (combat/heat un-regressed, TARGET DESTROYED).
  • ⚠ Cross-link landmine (mech.cpp): the gyro↔torso cross-link ((SubProxy*)gyro)->linkTarget = sinkSource+0x1D8 writes through SubProxy::linkTarget which sits at gyro+4 (an engine base field), not the real gyro torso-link offset — harmless vs the stub, would STOMP the live gyro base. Commented out; needs the real gyro torso-link field.
  • Remaining work (the real gyro un-stub): faithfully reconstruct the ctor field-init + the two integrators (IntegrateEyeJoint @004b2ec0 / IntegrateBody @004b30ec) and the field LAYOUT/labels from @004b3778 (the recon has a ~196-byte layout gap + uncertain field map). This is a full subsystem-simulation reconstruction (bigger than the torso), not a quick un-stub — do NOT guess the integrator mapping (no stand-ins). Env BT_GYRO_LOG dumps the layout + joint resolve + WriteMechJoint values for verification.