GAUGE_COMPOSITE.md + CLAUDE.md: add the OneOfSeveral/oneOfSeveralPixInt button- lamp reconstruction to the done list; narrow the remaining widgets to map (radar marquee), PlayerStatus, vehicleSubSystems, plus the follow-ups to make the duck/searchlight buttons dynamic (extend the Mech table to 0x37 + a Searchlight table). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 KiB
Virtual World Pod Games — Port Project (BattleTech / Red Planet)
⚠️ REPO ORIENTATION (bt411): This is the clean standalone bt411 repo — start with
README.mdfor 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 absoluteC:/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-levelCMakeLists.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, ~1995–96) 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 underport/(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 inMEMORY.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/.MNGboard microcode). The port replaces this with adpl_*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..BGF—DIV-BIZ2little-endian nested-TLV geometry. OBJECT→LOD→PATCH(=geogroup)→PMESH; interleaved float32 verts; quad faces (triangulate!); materials bylibrary:materialname; damage/articulation are name-driven viaSV_SPECIALdz_*tokens, not stored geometry..BMF—DIV-BIZ2(FILETYPE=1) material+texture library (no pixels). Material→DIFFUSE colorMATERIAL_TEXTURE→texture→TEXTURE_MAPimage 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 tag0x18 BITSLICEselects the slice, absent = 0. The old "[pad,R,G,B] confirmed" note was WRONG — full spec indocs/ASSET_PIPELINE.md§Pixel formats; reference readerDivLoader/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 rzradians (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)..SLDcollision solids,.DMG/.TBLdamage zones/tables,.SUBsubsystems,.CTLcontrol 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
.hppheaders present (every class/interface defined). - BT game logic
.cpp: MISSING —mech,mech2/3/4,mechsub,mechmppr,mechtech,heat,sensor,gyro,torso,myomers,hud,mechweap,powersub,btplayer, and the main appbtl4are all absent. Only 10 BT.cppare present (BTCNSL/BTMSSN/BTREG/BTSCNRL/BTTEAM/BTTOOL + weapons GAUSS/PPC + BTL4ARND/BTL4MODE). RP game logic is similarly gutted (5.cpp). - Compiled
btrel410.exeexists (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.EXE → real 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.5–1.5 hr/module → ~30–50 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 — nolibdpl.libin the tree). Our from-scratchport/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. Itsimage.{cpp,h}codec IS in bt411 (asengine/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"thencmake --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 headermath.h(shadows<math.h>; ours isvmath.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:Wwire ·Ccull ·Ttextures ·Ppose ·-/+walk speed ·Spacepause ·Esc. - Working today: load any mech (geometry + real BMF/BSL skin), assemble from its
.SKLskeleton, play real.ANIanimations, 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
.ANIhas a[RootTranslation]section (per keyframex 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.CPPAnimationInstance::Animate~1497-1723) integratesmovement += Δt·rootTrans.zand 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.MODphysics (MoverMass/drag/MaxAcceleration) governed thrust vehicles (RP hovercraftVTVPWR, 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.ANIdata above. ✅ P3 LOCOMOTION CUTOVER — STEP 1 DONE (docs/P3_LOCOMOTION.md). btl4's live drive (mech4.cppPerformAndWatch) is now ANIMATION-DRIVEN: the engineAnimationInstance::Animate(dt,True)returnadv(the clip's per-frame[RootTranslation]distance) drives the forward step (localOrigin.linearPosition += facing * adv), replacing the hand-tunedkDriveMaxSpeedslide. 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.cppAdvanceLeg/BodyAnimation,IntegrateMotion) is bracketed by no-op stubs (ReconSeqcontrollers → Advance()=0;Matrix34=ReconMatrixno-op), so the full gait (STEP 7) needs those backed by real engineAnimationInstance/affine-matrix first. Plan + 7 steps indocs/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 (gatedBT_GAIT_CUTOVER=1; verified). The BT-specificSequenceController(decomp/reconstructed/seqctl.cpp, reconstructed from binarySelectSequence@004277a8+Advance@0042790c) is now reconstructed, built, and wired: the Mech ctorInitslegAnimation/bodyAnimation, andPerformAndWatch(mech4.cpp, cutover branch) drives the forward step frombodyAnimation.Advance(dt,1)(the real controller: parses the clip, animates the skeleton joints via the engineJointAPISetHinge/SetRotation/SetTranslation, returns the root-translation forward step) instead of the STEP-1/2 free-standingAnimationInstance. 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 wasSearchList(clip_id,16)(which treats its arg as a resource LIST → walks the clip bytes as garbage IDs → av); the clip_id fromResolveAnimationClipis a DIRECT resource ID → useFindResourceDescription(clip_id)+Lock, mirroring the engine's ownAnimationInstance::SetAnimation(JMOVER.cpp:1406) EXACTLY. DECOMP FACT — CORRECTED (re-verified vsAdvance@0042790craw, 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), andIntegrateMotion(@004ab1c8:14997) integrateslocalVelocity.z = -adv/dtdirectly with NO smoothing. FOOT-PLANT FACT (raw FUN_004ab430:15076): the binary advances the BODY controller withmove_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 hardcodedmove_joints=1, FUN_004a5028:12006) runs FIRST and its joint writes are overwritten. Port matched in commit 01312e8 (body channelAdvance(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 usescarryover*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-IntegrateMotionvelocity 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 (underBT_GAIT_CUTOVER). The cutover no longer slides the position directly (localOrigin += facing*adv); it runs the realIntegrateMotionmotion-tail (@004ab1c8):localVelocity = {0,0,-adv/dt}(forward = local −Z) → rotate into world by the body orientation via the now-backedFUN_00408744(part_000.c:8331, a matrix×vector; backed with the engineAffineMatrix::GetFromAxiscolumn 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 (runsFUN_00408744live). KEY DECOMP FACT (verified): the 1995 motion transform{ Point3D @0x260; Quaternion @0x26c }IS the enginelocalOrigin—Origin = { Point3D linearPosition; Quaternion angularPosition }(ORIGIN.h:15), and rawFUN_0040ab44builds the matrix from BOTH halves (rotation from 0x26c, translation from 0x260). SoIntegrateMotion's motion state maps DIRECTLY ontolocalOrigin(NOT the parallelmotionDelta/worldPosemembers the earlier draft declared — those were placeholders). Also fixed a latentIntegrateMotionbug (unexercised while the fn was dead): it wrote only the separatespinRate@0x508 and leftangularAccum[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-IntegrateMotionwork: theAdvanceBodyAnimationgait STATE MACHINE (stand→walk→run transitions) +LoadLocomotionClips(real, mech3.cpp:326 — populatesnamedClip[]/gait-speed caps vialegAnimation.SelectSequence) in place of the inline clip-select; orientation INTEGRATION (turn via angular rate, not the bring-upgDriveHeading); 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::~Buildervector<float>teardown → badoperator 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-sizeofor 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 (gatedBT_GAIT_CUTOVER) and completes cleanly for the Blackhawk — it resolves the full gait clip set (swr/wwr/wwl/rrr/rrl/...) + measures the speed caps vialegAnimation.SelectSequence+keyframeData(verified real:walkStride=22.02,standSpeed=6.83). KEY RECONCILIATION:namedClip[]@0x5e0 andanimationClips[]@0x5cc were declared as SEPARATE arrays but in the binary are ONE (namedClip[i]==animationClips[i+5], 0x5e0==0x5cc+0x14) — soLoadLocomotionClips' writes weren't visible toSetBodyAnimation/MeasureClipStride. Fixed:namedClipis now a pointer aliased to&animationClips[5]in the ctor (same storage). (2)AdvanceBodyAnimationstate machine (mech2.cpp, real) now drives the walk underBT_GAIT_SM(case 6/7: slewbodyCycleSpeedtowardbodyTargetSpeedwith the loaded caps →bodyAnimation.Advancethewwrclip → world-step). FixedbodyAnimationState@0x728 ==bodyStateAlarm.level(also declared-separate; re-synced from the alarm each frame soSetBodyAnimation's level change reaches the switch). Verified live: mech walks forward (z 1600→−975),adv≈0.35-0.42at the authentic walk cadence (correctly slower than run's 1.07), clip loops through its 15 keyframes, 0 crashes. Bring-up substitute (marked):SetBodyAnimationpasses a non-null loop sentinel(void*)1where the binary passesPTR_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*)1sentinel with the realFUN_004a6d8c=Mech::BodyClipFinished. RESOLVED the.datafn pointer by PE-parsing (0x50d6fc→body cb0x4a6d8c;0x50d6f0→leg cb0x4a6928) — 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 onbodyAnimationState, comparesbodyTargetSpeedto 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 tail0x4a6e66/0x4a6ed1/0x4a7001). Walk alternates 6↔7, run 12↔13, stop→8/9, walk→run 6→11→12. Plumbing:SequenceController::Advancenow CALLScbCodeasScalar(*)(Mech*,unsigned,Scalar,int)at end-of-clip (matching the binary's**(code**)(this+0x48)), folding the returned distance; inline cutover usesMech::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 → read0xCDCDCDCD→ run exploded (adv=-7e6); set toreverseStrideLength(the case-12 divisor → ratio≈1). DECOMP TECHNIQUE: resolve a GhidraPTR_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 callbackFUN_004a6928(leg alternation, state@0x3b0 + motion source@0x128); airborneFUN_004a6344; gimp handlers (states 16-19, targets0x70b2/0x7161undecoded → fall back to stand); gait SELECTION from real throttle (MechControlsMapperstill 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 wrotelocalOrigin.linearPositiondirectly with no collision, so the mech phased through terrain/objects + floated. KEY FINDING: the 2007 RP411 engine models terrain ASBoxedSolidcollision volumes (TERRAIN.h:Terrain= anEntitywithcollisionVolumes), so terrain-follow AND object collision go through ONE path — the engineMovercollision pipeline (MOVER.cpp), NOT the 1995 heightfield query (FUN_0040e5f0, which the deadSimulateused). Wired intoPerformAndWatch(mech4.cpp) after the drive's world-step: snapshot the pre-move pos → setworldLinearVelocityfrom the move delta →MoveCollisionVolume()(reposition the volume tolocalToWorld) →GetCurrentCollisions()(gather overlapping solids) →ProcessCollisionList(cols, dt, oldPos, &damage)(the engine resolves each penetration viaProcessCollision→StaticBounce, WRITINGlocalOrigin.linearPositionto push the mech out; MOVER.cpp:1162/1200/1217). All methods are public onMover; guarded onGetCollisionVolumeCount()>0so 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 (sameBT_COLLISIONblock; tunableBT_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 byProcessCollisionListbut 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-agentground-model-decodeworkflow (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->minYby 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-findscontainedByNodein the zone's BoundingBoxTree — BOXTREE.cpp, identical 1995→2007, NOT a heightfield); (3) probeq = origin + (0, lift, 0)→BoundingBoxTreeNode::FindBoundingBoxUnder(q, &h)(h = distance down to the highest solid top; −1 = miss); (4) snap gateh > 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)GetCurrentCollisions→ProcessCollisionList→ per-contactMech::ProcessCollisionoverride (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 withoutStoppingCollisionVolume(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 gateGroundReal()(mechrecon.hpp). KEY FIXES/FACTS: (a) FUN_0040e36c/FUN_0040e5f0 areBoundingBoxTreeNode::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 MovercollisionVolume/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 nowStartCollisionAssistant()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 thebmpbump/stagger clip —SetLegAnimation(0x20)+RequestActionFlags(1)+(0x20)(binary @4aaae2; FUN_004a4c54 == RequestActionFlags, NOT "ForceUpdate"). ALIASING FIX (namedClip bug class):gimpBaseClip@0x64c ISanimationClips[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 theactionRequestFlagsconsumers (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 theiv2>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 (scratchpadgd_spec.md, the hand-decoded master perfFUN_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 — whilelegState==0x20, zeroworldLinearVelocity+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 forkBlockHysteresis(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 sgBlockCooldownwindow bridges those gaps: any sustained OR intermittent press just BLOCKS (runs/walks in place), only a real separate-then-re-approach re-arms it.gBlockCooldownis a single global gated to the VIEWPOINT mech (the stationary dummy + MP masters also callAuthenticGroundAndCollide, 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 == legSumframe-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 (measuredadvSum=229vslegSum=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)alongsideSetLegAnimation(0x20)— so display + travel freeze and recover from the SAME bmp clip IN PHASE (BodyClipFinishedcase 32 recovers the body, mech2.cpp:308, mirroring the leg). Verified:advSum == legSumstays 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 sourceslocalAdv = 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 predatedSetBodyAnimation(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 fullactionRequestFlags(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.SLDsurface (Cone apex-up / YCyl / Block) at its XZ; worst clip in the walkable band (Y<15): mesas (buttea-e) 2–3.5u, dunes (dhillg1-3) 0–3.1u, flat pad (calpb) 1.6u (⇒ NO global snap offset — the probe/lift is correct), buildings (bld06/bld08) 12–14u = 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 ~5–22u 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 — gateh != −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.
- 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
- Renderer dependency is isolated to 3 HAL files in the original (
L4VIDEO/L4VIDPER/L4VIDRNDcalldpl_*). The fulldpl_*/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.slnis 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 (onlyd3d9.hremains). OpenAL32/libsndfile libs are intrunk/lib. So building RP is a real toolchain-modernization sub-project, not a recompile: (1)obtain legacy DXSDKDONE — installed atC:\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 reconstructeddecomp/reconstructed/modules.✅ ENGINE COMPILES GREEN (
C:\git\nick-games\enginebuild\, CMake/MSVC Win32) — the sharedMunga_L4193-file engine+HAL builds tomunga_engine.lib(0 errors). Recipe (4298→0 errors): (a) include path = DXSDK Include +enginebuild/shimONLY — 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.cppstd::ios.in→std::ios::in(2007 typo); addTime(const Time&)copy ctor toMUNGA/time.h. (e)enginebuild/shim/atlbase.h+atlconv.h= minimal ATLUSES_CONVERSION/W2Ashim. Build Unicode (matches original — wide literals →...WAPIs). -
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.exeoracle + 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.exeboots + 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 stillRECON_SUBSYSstubs). 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 1–3 (engine/renderer/HAL/audio = done in WinTesla); Phases
4–8 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 —
libDPLshim as a real API: turn the viewer renderer into a drop-in lib exposing the actualdpl_*(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_SectPixelpicking, 2D display lists, morph/damage/articulation). - Phase 2 — Compile the game code (modern toolchain): get
MUNGA+BTC++ building on MSVC/Clang — modernize Borland-isms, replace.asm, stub theL4layer — linked against the shim. Goal: it builds. Risk: pervasive non-standard 1995 C++; DOS/DPMI assumptions. - Phase 3 — L4 HAL on SDL/Win32: replace
L4video-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/.gimgauges + 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
NETNUBdriver with a 3446-line WinSock2 TCP reimplementation (MUNGA_L4/L4NET.CPP,SOCK_STREAM/ReliableMode, zeroSOCK_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 atL4NET.CPP:1853). This is problem P6 indocs/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 bootsConsoleOnly; 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 chunkedReceiveEggFileMessagepackets;tools/btconsole.pyis our console emulator (wire spec in its docstring, constants probed from the build viaBT_NET_PROBE=1). ⚠NotationFile::ReadTextexpects 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), thenpython 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 inbtbuild/(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 reconstructedBTPlayer::MakeMessagecarriedconst char*pointers (fine solo, garbage cross-pod: the first remote BTPlayer replication crashed inCString(roleName)); fixed to inlinechar[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:667QUIET_TIME_OUT3s) → LoadingMission → WaitingForLaunch → console sendsRunMissionMessage×2 (the operator's LAUNCH button;btconsole.pysends 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 nowSetDeadReckoner(&Mover::AcceleratedDeadReckoner)(the VTV pattern); (2) REPLICANT MOTION:Mech::PerformAndWatchrunsDeadReckon(dt)(engine lerp toward projectedOrigin) forGetInstance()==ReplicantInstanceinstead 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 onRunningMission— 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 theIsPreRunnable()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::MakeSetValidFlag()replicant instances at creation (same fix the spawned test dummy needed). Debug tooling added (envBT_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 underBT_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 intact —
DPLRenderer::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 BuildConfigurationFile → gauge\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
primitives — BTL4MethodDescription (btl4grnd.cpp) is a STUB (BT gauge widget classes unreconstructed), so
an unknown primitive → ReportParsingError→Fail()→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 bindings — NumericDisplayScalar 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 ParseAttribute→FindSubsystem(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), and oneOfSeveralPixInt (the button-state lamps — reconstructed the whole OneOfSeveral
strip-selector family + PixMap8 blit path). 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). REMAINING: map (radar — the marquee, needs the
Mech table extended to 0x31 + a Sensor table + the MapDisplay renderer), PlayerStatus, vehicleSubSystems
(task #13) + the aggregate HeatSink-bank AmbientTemperature (#if 0'd 0xBBE) + the Reservoir coolantCapacity
shadow + making the duck/searchlight buttons dynamic (extend the Mech table to 0x37 + a Searchlight table).
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.RESalready 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\bt411 → build\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-levelCMakeLists.txtbuildsmunga_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 inmech3.cpp— see the CMakeLists comment; a cleanup TODO). ⚠ Editing anengine/file rebuilds the engine lib automatically now (one project) — no separate engine build step; but a NEW member on aDPLRenderer/d3d_OBJECTclass still needs the game objs that embed its layout recompiled (delete stale objs if layout-mismatch corruption appears). - Run:
run\run.cmd [EGG](defaultDEV.EGG) — itcds tocontent\and launchesbuild\Debug\btl4.exe -egg <EGG>. cwd MUST becontent\(the engine resolvesBTL4.RES,VIDEO\,BTDPL.INI, eggs relative to cwd — see theloadTablesgotcha below). Logs tobtl4.logincontent\(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_CONTROLSall default ON — set=0to 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=0fallback 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 inbtl4main.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/turnare 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 everyWM_KEYUP/WM_SYSKEYUP/WM_CHAROUT 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 POLLSGetAsyncKeyStateper frame (via GetProcAddress, no headers needed), gated on the game window being FOREGROUND (GetForegroundWindowpid == 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.ps1injects 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 nowDEV.EGG(map=grass+time=day, outdoor); the RES has 8 maps (cavern/grass/rav/polar3/polar4/arena1/arena2/dbase) — swap via a copied EGG'smap=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 pagevehicle=<name>(DEV.EGG:bhk1), resolved viaplayerMission->GetGameModel()→FindResourceDescription(name, ModelListResourceType)(btplayer.cppCreatePlayerVehicle). 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 withvehicle=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.SUBsegmentReferencemuzzle resolution, already howEmitter::GetMuzzlePointworks); (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-weaponVideoObjectNamepending 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 tocontent\first):-g -c ".lines;sxe av;g;kp 24;q"withBT_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+ theimage.{cpp,h}codec). Plusengine/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), andengine/DPLSTUB.h/LOGGER.h(the trunk-root headers reached via..\).game/reconstructed/— the reconstructed BT source (the bulk; wasdecomp/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.thpis a self-containedTestApplicationreconstruction shim;fwd/bgfload.hforwards to the engine loader header.game/btl4main.cpp— the WinMain launcher.content/— the runtime tree (wastesla4pod_extracted/.../REL410/BT/):BTL4.RES,VIDEO/,GAUGE/,AUDIO/, eggs,BTDPL.INI,VIDEO/REPLACEMATS.tbl. Run cwd =content/.reference/decomp/— the raw Ghidra pseudocodeall/part_*.c(wasdecomp/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.pyscanners.- External (not vendored): DXSDK June 2010 (overridable
-DDXSDK). ⚠ Historical sections below still cite the oldnick-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 (§6port/, 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)(rawFUN_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 inBTL4.RESare 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*)thenSetAnimation( ResourceID, finishedCallback)(Lock()s the clip, parsesframeCount/jointCount/jointIndices/frameStart/ keyFrames/rootTranslationsfromresourceAddress).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-virtualMech::OnBodyAnimFinishedre-arming the clip, passedreinterpret_cast<JointedMover::AnimationCallback>(Mech derives from JointedMover; the member- ptr cast is how the original stores a Mech method in the JointedMover-typed slot). -
loadTablesgotcha (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_applicationis NULL outside the frame loop —ApplicationManager(APPMGR.cpp:73) REASSIGNS the globalapplicationwhile 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 wherethisis the engine object (how the window-resize aspect fix works, L4VIDEO.cppgWindowAspect→ per-frame dirty-check rebuild; commit 06683cf). -
Entity spawn:
Mech::Make(MakeMessage*)(FUN_004a2d48) allocatessizeof(Mech)=0x854+ runs the ctor; the baseEntityctor self-registers it into the world (renders + ticks).Registry::MakeEntitydispatches per-classmakeHandler(BT registry mapsMechClassID=0xBB9 → Mech::Make). Unique IDs viaHostManager::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 onlyMech/Projectile/Missile/BTPlayer/BTTeam; the COMPLETE BT header set has no Bot/Brain/AI class;BTL4OPT.EXEhas no AI strings; every mech is driven by aBTPlayer(or aBTCameraDirectorfor the "camera"/spectator game model), players grouped intoBTTeams. Opponents were always other human pods over the net (NETNUB/L4NET). The AI entitiesCrusher/Blocker/Runnerare 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-gatedBT_SPAWN_ENEMYmech is a test dummy (uncontrolledMech), not a game feature. The authentic opponent path is multiplayer (Phase 7). -
Mech::PerformAndWatchruns for EVERY mech → gate player-only logic onthis == 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=Point3Dtarget world pos (range/ aim source),mech+0x388=Entity*target (the fieldHasActiveTarget()/Emitter::FireWeapongate on),mech+0x38c=inttargeted sub-zone (-1 = whole). Weapons cachehasTarget/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 asExplosion::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). BaseEntity::TakeDamageMessageHandlerIGNORESzone==-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. EngineExplosionalso 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→ engineEntity::TakeDamageMessageHandler→Mech__DamageZone:: TakeDamage→ engine base armor model (damageLevel += damageAmount * damageScale[type]) → accumulates to 1.0 (zone destroyed) → graphic-state/death branch → death detected. Verified:structureclimbs 0.133/hit, vital zone destroyed →*** TARGET DESTROYED ***, stable. Build mechanics: engineEntityctor (ENTITY.cpp:961-987) readsdamageZoneCountfrom theDamageZoneStreamResourceType(type 0x14) resource and allocates the array (entries left0xCDCDCDCD); the Mech ctor populates that inherited array (mech.cpp Pass-3):MechFindResource(resourceID, 0x14)→Lock→DynamicMemoryStream(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 engineDamageZonector (DAMAGE.cpp:200, shared MUNGA == binaryFUN_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(shadowedEntity's → zones never built);Mech__DamageZone:structureLevel→enginedamageLevel,damageScale[5]/defaultArmorPoints(rawWword(0x51)/Wword(0x50)offsets → inherited members),graphicState/graphicStateAlarm/flags/state→engineStateIndicatorsdamageZoneState/damageZoneGraphicState(+SetDamageZoneState/SetGraphicState/GetGraphicStateaccessors; the reconstructedSetGraphicStateoverride was byte-identical to the base → removed). Use the inherited member/accessor, never a rawWword(N)/re-declared copy, for any field the engine base owns. (subsystemCount/subsystemArraywere already fixed this way earlier — same lesson.)Wword(int i)is itself a TRAP:mechrecon.hpp:192defines it asstatic BTVal bank[0x400]; return bank[i&0x3ff]— a global scratch bank, NOTthis+i*4. AnyWword(N)used for object access reads/writes garbage (e.g.(Mech*)Wword(3)for a zone's owner → useGetOwningSimulation()).⚠ SIBLING SYSTEMIC BUG — resource-struct layout mismatch (check when RESOURCE fields read garbage). The
*__SubsystemResourcestructs 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 reading10.0f). Two ways it breaks: (a) wrong resource inheritance base — the resource must mirror the CLASS hierarchy (HeatableSubsystem:MechSubsystem→ resource must inheritMechSubsystem__SubsystemResource(0xE4), notSubsystem::SubsystemResource(0x30)); a skipped base slides every field low. (b) under-sized fields — e.g.alarmModelat res+0xCC is a 12-byte record field but was typed as a 4-byteResourceID(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+0xNNreads). See §10d heat-flow.Two more non-field fixes were needed for damage to flow: (1) message-handler chaining — a reconstructed class's
MessageHandlersset must be built chained to the parent's (Receiver::Message HandlerSet(Entity::GetMessageHandlers())); an empty default-ctor set has NO parent chain, soReceiver::Receivefinds no handler and every inherited message (TakeDamage, etc.) is silently dropped. (2) entity validity —Entity::Dispatchonly delivers synchronously (Receiver::Receive) for a VALID master; an invalid entityPosts the message as a deferred event that never fires. A manually-spawned entity (noMakeViewpointEntity/CheckLoad handshake) must callSetValidFlag()itself.Remaining damage gaps (scoped, non-blocking): EnergyDamageType(4) shorts attached generators via
criticalSubsystems[]->subsystemPlug.Resolve()→ needs the REAL subsystem roster (stillRECON_SUBSYSstubs) → use ExplosiveDamageType meanwhile (correct for the explosion weapon anyway). Per-impact aim needs the cylinder lookup (deferred STEP 6 —Mech::TakeDamageMessageHandleroverrideFUN_004a0230+FUN_0049ed0c); meanwhile aim a vital zone directly.IsDisabled()(FUN_0049fb54) reconstructed (readsmovementMode==2||9), butmovementModeis set by the bypassed gait/death-transition → bring-up death reads the real zone state directly (avitalDamageZonewithdamageLevel>=1.0). Removing the wreck (DestroyEntityMessage) crashes on teardown — death = explosion + stop-targeting for now. ⚠ ROOT-CAUSE PIVOT (P5 — full writeupdocs/HARD_PROBLEMS.md; reproBT_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 allocscollisionListsunconditionally). (b) "reconstructed Mech raw offsets stomp the engine collision cluster" — ALSO WRONG: those stomps (Mech::Simulatecollision/ terrain/telemetry,FeedHeat*Gauge) are DEAD CODE, never called (grep-confirmed). TheBT_ENABLE_TEARDOWNdump proves the enemy's engine base is FULLY VALID at death (collisionLists@0x2e4,segmentTable@0x2f0=51 segs,jointSubsystem@0x30call valid). The crash is in the destruction SEQUENCE (FryDeathRow → ~Mech → ~JointedMover → ~Mover): cdb showscollisionLists's array already freed (0xdd/count=0xdddddddb) by the time~Moverdelete[]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:436SegmentTableIterator(segmentTable).DeletePlugs()) →SocketIterator::DeletePlugs(SOCKET.cpp:157-161delete plug) force-deletes each of the enemy's 51EntitySegments withdefeat_release_node=1(bypassing the ref-count release path); one is already freed →STATUS_HEAP_CORRUPTION 0xC0000374+0xFEEEfill. ⭐ P5 CLOSED — the whole premise was WRONG (verified): a killed mech does NOT disappear in the real game, soDestroyEntityMessage-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 animdeathAnimationLatched@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 viaBT_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 EXPLICITJointedMover::~JointedMover();— the Ghidra decomp shows the binary'sFUN_00425550(this,0)tail call, which IS the compiler-emitted implicit base-dtor; reconstructing it as a source statement ran the whole~JointedMover → ~Mover → ~Entitychain TWICE per Mech. Run 2 re-delete[]dMover::collisionLists(count word read from the 0xDD freed fill = the observed0xdddddddb) and re-ranDeletePlugsover 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 ofMech__DamageZone's dtor (mechdmg.cpp). VERIFIED: interactive play → window close underBT_HEAPCHECK=1= clean exit; combat kill +BT_ENABLE_TEARDOWN=1forced 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:AddCollisionToListBNDGBOX.cpp:389 has no live bounds check —Verifycompiles out at DEBUG_LEVEL=0 — so >10 simultaneous collision contacts from >1 source overflow the 10-slot list; NOW FIXED — a live clamp inAddCollisionToList(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 underBT_HEAPCHECK=1= 0 crashes, clean exit. Spawn-inside-solid placement itself is a separate tuning item.) (Latent, non-causal: compiledsizeof(Mech)=0x638< binary0x854; the over-sizeof writes0x7e0-0x828live 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 DONE — HeatableSubsystem 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: statusFlags→simulationFlags, destroyed→simulationState, statusBits→
ForceUpdate(), 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 toowner->simulationFlags(binary-exact part_013.c: 6012/6140; mirrors torso.cpp:181) → they now tick. (Sensor uses the simpler!=4gate; AmmoBin's gate was already correct.) - AmmoBin layout:
ammoAlarmretypedHeatAlarm(8B)→WatcherGaugeAlarm(0x54) + thestatusStateshadow deleted →ammoModelFilelands at the binary 0x1E8,sizeof==0x22Cexact (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 madeAmmoBinSimulation(and viaPowerWatcher::Simulation, the light/thermal sims) call the HeatWatcher baseWatchSimulation, which derefswatchedLink.Resolve()->currentTemperature— the watch link isn't resolved in bring-up (the "WatchedSubsystem" name→roster-segment bind doesn't happen), soResolve()returns null → AV (AmmoBinSimulation→WatchSimulation, 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 asEmitter::TrackSeekVoltage. FAITHFUL FOLLOW-UP = resolvewatchedLinkso 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=0falls back to the Actuator stub → 26). The real Myomers (the mech's artificial-muscle drive — voltage→speedEffect, throttled by heat + damage; ctor @4b8fec,PoweredSubsystem:HeatSinkleaf) is now built at factory case 0xBC6 ("Actuator" mislabel) viaCreateMyomersSubsystem. 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)MyomersSimulationearly-returns onOwnerAdvancedDamage()(the advanced-damage sim gate; the real source is the still-stubbed SubsystemMessageManager 0xBD3, so it's aFalseconstant → no motion-heat); (b) the mover feed (ConnectToMover→MoverAttach) +SetOwnerMaxSpeedare 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 theseekVoltage[]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; lockedstatic_assert(sizeof(Myomers)<=0x358)(heat-leaf branch not byte-exact → overflow-lock only, like Sensor). TheMyomersname collision is already handled in myomers.hpp (#define Myomers Myomers__powersub_stalearound 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) theowner*shims redirected to real Mech motion/mass/motion-gain accessors, and (3)MoverAttachactually routingspeedEffect@0x31Cinto 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 fireFIRED #1..#21+, combat intact, no crash). THREE fixes, each faithful to the decomp (no stand-ins):- EmitterSimulation ran a no-op stub
FUN_004b0bd0for the "PoweredSubsystem step" -> the emitter never ran its heat sim. Replaced with the REALPoweredSubsystem::PoweredSubsystemSimulation(@004b0bd0, chains toHeatSink::HeatSinkSimulation= absorb pendingHeat +ConductHeat). Emitter isEmitter:MechWeapon:PoweredSubsystem:HeatSink, so the qualified base call is valid. (emitter.cpp) - Linked-sink Add-gate read the wrong flags. Raw decomp of the HeatSink ctor @004adda0 gates BOTH
the Performance-install AND the
linkedSinks.Addonparam_2+0x28=owner->simulationFlags, NOT the per-segmentsubsystem_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-subsystemheatSinkIndex. - ⚠ NEW SYSTEMIC BUG — resource-struct layout mismatch (a sibling of the field-shadowing bug).
HeatableSubsystem__SubsystemResourceinheritedSubsystem::SubsystemResource(ends 0x30) instead ofMechSubsystem__SubsystemResource(ends 0xE4) -- the class hierarchy isHeatableSubsystem:MechSubsystemso the RESOURCE must mirror it. Result: every heat field slid 0xB4 low;heatSinkIndexread a neighbouring float (10.0f=1/thermalMass) ->GetSegmentOOB -> no link, and thermalMass/startTemp were garbage. Fixed the base. Then an 8-byte residual gap:alarmModelwas typed as a 4-byteResourceID(typedef int), but the RECORD reserves a 12-byte field at 0xCC (raw@004ac9ecparses it via FUN_00408944 as 3 floats, withPrintSimulationStateat 0xD8). Addedint _alarmModelReserved[2]. NowstartTemp=0xE4 thermalMass=0xF4 heatSinkIndex=0xF8EXACTLY 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*)resand 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,currentTemperaturestays ~77 (startTemp) becausethermalMassis genuinely huge (~1e6) vs0.24 heat/shot; the visible metric that responds to fire is0" 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:heatLoad(the smoothed radiated-heat reading), which rises/decays with firing. Not a bug -- authentic data behavior. (The "linkedothersink reads heatEnergyLoadBgfFile("bld08.bgf")→Builder::~Builder→ AV inoperator delete, position-dependent). A 4-agent workflow + hands-on forensics converged:
- The loader was INNOCENT — an exact Python mirror of
bgfload.cppswept 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 reading0xDDDDDDDD(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 viaowner->GetSegment(heatSinkIndex)— the Nth skeleton EntitySegment (288 bytes) — cast toSubsystem*. The binary (@004adda0, part_012.c:16999) instead readsowner->subsystemArray[heatSinkIndex](the SUBSYSTEM ROSTER @0x128, bounds-checked vs subsystemCount@0x124, null-guarded). Through the bogus pointer, every per-frameConductHeatwroteother->pendingHeatat offset 388 = 100 bytes past the 288-byte block, andBalanceCoolantwrotecoolantLevel20 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 inPoweredSubsystem(raw part_013.c:1198):voltageSourceIndex(res+0xFC) is ALSO a roster index; the GetSegment draft madeAttachToVoltageSourcewritecurrentTapCount136 bytes past the segment block at every mech spawn. BOTH fixed to the roster lookup via the publicowner->GetSubsystemCount()/GetSubsystem(i)(+ the powersub else-gate fixed to OWNER flags per raw). Verified: sustained combat underBT_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 everyGetSegment(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 INSIDEanimationClips[]in the binary ([3]/[2]/[4]/[32]) — a wrong-clip fidelity gap for crash/gimp gait states when those go live;Mech::CreateModelResourcealloc 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*__SubsystemResourcestructs found 8 confirmed layout bugs (same silent-garbage class as the heat fix), all rooting to 2 broken base resources (HeatWatcher,PowerWatcher)
- EmitterSimulation ran a no-op stub
- 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 inheritedsegmentIndex@0x28 (was a bogus appended field). Combat + heat-flow un-regressed. KEY PRACTICE ESTABLISHED: lock every resource-struct layout withstatic_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 itsdegradation/failureTemperature; childHandleMessage/Simulationdelegates that calledHeatableSubsystem::must redirect to the real base (MechSubsystem::HandleMessage,HeatWatcher::ResetToInitialState); a re-based class's header needs a guarded#includeof 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 mechTechSubsystem→hudSubsystem, 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: visual−origin = 0.05–2.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 (0–2.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 0–2.1u ABOVE the collision cones on slopes (median 0–0.5, p90 1.2–1.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 ≈ 2–4u 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 [y−1, 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 (DPLReadEnvironment →
DPLReadINIPage, 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):
- ⭐ Mountains "inside-out/faceted" = MALFORMED GEOMETRY, not lighting (commit d189371).
BGF_FORMAT.mdwrongly documentedCONNECTION_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!gotPconnbranch (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. - 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.
- 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 stageD3DTOP_LERP(Arg0=TEXTURE, Arg1=TFACTOR=white, Arg2=DIFFUSE), states saved/restored around the sky draw. Measuredscreen(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. - ⭐ 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_mtlhas a warm diffuse (0.781,0.525,0.353) so they render warm, but the ground'sgrass_mtlhas 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:bgfloadparses the ramp def (0x30 name + 0x31 6-floats) + the material's ramp ref (0x28), threads rampLo/Hi ontoBgfDrawBatch→drawOp;L4D3Dbakeslerp(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. - 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.
- ⭐ RAMP over-applied to LIT vehicle geometry → "whitish-tan blobs" — FIXED (bgfload.cpp
hasNormalsgate). The user saw the map's scattered vehicles (hummer/semi/dozer/apc…) render as uniform dusty-white blobs. ROOT CAUSE: the vehicles' sharedbasevmaterial 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 thecdustyramp (0.33,0.27,0.22→0.90,0.85,0.80 = dark-brown→near-white) over the shared atlasBASEV.BSL. Our task-#20 ramp path setbatch.hasRamppurely 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 carryXYZ_N_UV(SEMI = 400 XYZ_N_UV + 248 XYZ_RGBA_UV). FIX:bool hasNormals (id)(0x81/0x89) +useRamp = currentHasRamp && !hasNormals(pmeshVtag)per-PMESH inbuildPmesh— replacescurrentHasRampfor 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.buildPmeshpushes 0 into nx/ny/nz only as INITIALISATION;emitTrithen 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:DPLReadINIPageparses the env page'slightentry (BTDPL.INI, e.g. dbase/daydsdayclear) intosceneLight[]as aD3DLIGHT_DIRECTIONALsun (L4VIDEO.cpp:3086SetLight+LightEnable), and the frame setup (:6196-6233) setsD3DRS_LIGHTING TRUE+NORMALIZENORMALS+COLORVERTEX+ diffuse/ambient from vertex COLOR1 whensceneLightCount>0. Non-ramp batches get material Diffuse=bv color, Emissive=0 (L4D3D.cpp:303-310), so a vehicle renderstexture × [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 engine —
BTPushBeam(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(C1−C2·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) definesTEXTURE(NAME="beamwhite_scr_tex"; SPECIAL="SCROLL 0.0 0.0 0.10 9.5") { MAP {"bexp"} }— i.e. the core samples thebexpimage =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 bysofter(0.25→0.99) to a white core; the glow is solid red (fieryramp). ⚠ I FIRST WRONGLY SAID THE TEXTURE WAS MISSING — it is present; I'd missed theMAP{}directive in the.VMF(the.BMFonly names the texture, the.VMFbinds it to the image + carries the SCROLL). THE.VMFIS 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 bysofter→ 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 REALermlaser.bgfTUBE NOW RENDERS (replaced the camera-facing billboard quad; gateBT_BEAM_TUBE, default ON).BTDrawBeams(L4VIDEO.cpp) loads the tube geometry ONCE (LoadBgfFile("ermlaser.bgf")→ statics_tubeVB/s_tubeIB, x,y,z,u,v per vert) and per beam builds a world transformScale(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 scrollingbexpgrit via aD3DTS_TEXTURE0transform (D3DTTFF_COUNT2,_31=uScroll _32=vScroll), beam colour fromD3DRS_TEXTUREFACTOR(the tube verts carry no diffuse — FVFXYZ|TEX1, COLORARG2/ALPHAARG1 =TFACTOR; the billboard path staysXYZ|DIFFUSE|TEX1withDIFFUSE, kept as theBT_BEAM_TUBE=0fallback). 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 checkgetenv(name)for NON-NULL, so$env:BT_HEAPCHECK=""(empty string) still ENABLES heapcheck — every run after I first set it crawled throughCrtCheckMemoryon each string alloc in the materialscanDir(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 inucrtbased!CrtCheckMemory←scanDir/bmfIndex←LoadBgfFile←DPLReadINIPage, not stuck. FIX:Remove-Item Env:\BT_HEAPCHECKto actually clear it (setting=""does not). Applies to ALLBTEnvOn-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 a0x2046header = [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 viad3d_OBJECT::SetCameraPosition, eye = −t·Rᵀ from the view matrix). CONSOLIDATION IS NOW DEFAULT OFF (BT_CONSOL=1re-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=ninspects 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 20–50u 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=1dumps 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 viaBTSetLodEye, kept wired), runtime band selection still broke arena content (floor vanished when the mech wandered >577u fromafloor'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_SetObjectLodHotSpotexist 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=1switches 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, gateBT_ADDLOD=0). The follow-up research cracked it WITHOUT decompiling the binary: the 1995MUNGA_L4/L4VIDEO.CPPTestSpecialCallBack(~line 486) reads the OBJECT-level SV_SPECIAL (0x2037) tokenADDITIVE_LODSfrom the BGF itself and callsdpl_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*Dmech 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 withd < OutDistdraws (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.cppTAG_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 RecurseStaticObjectEXCLUDES banded objects from static consolidation (merged runs can't band-gate) — they render individually via the per-op DrawMesh distance test (eye = theBTSetLodEyemech feed). Verified: arena1 spawn A/B —BT_ADDLOD=0reproduces 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-agentlod-blink-decodeworkflow; closes the arena "blinks with movement" + "noisy unstable surfaces" reports).
- ⭐ 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.cppmultiplies 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. - 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 onlylod_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. - ✅ 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). GateBT_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 carryGEOMETRIZE 0x8000xxxxhints (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=0left 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.000–0.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-7NDC/step, ordinal capped 20,BT_LAYER_BIASoverrides) — 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). AndBT_LOD_SCALEis 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 375–500u 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 scalargDriveHeadingmirror (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.cppbuilds 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_rangein DPL_PRIV.H, per-LOD 0x2047 reference points, 0x2048 transition snap|blend,dpl_SetLodStressperf governor,dpl_SetInstanceForceLOD). RESEARCH TECHNIQUE (reusable):LIBDPL.LIBis an OMF library with full symbol names — parse THEADR/PUBDEF records (python ~40 lines) to map everydpl_*entry point; the 1995 DOSMUNGA_L4HAL source is the game-side caller reference;dsys/PFBIZTAG.Hnames 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.texChannel
→ BgfDrawBatch.texChannel → L4D3D 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):
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).- D3DX
DrawSubsetattribute-table scan (~44µs/call × ~1400). Our BGF triangles are DOUBLE-SIDED (each face emitted twice) →GenerateAdjacencydegenerate →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 —SetAttributeTableEXPLICITLY (no adjacency at all)- per-op
bgfStartIndex/bgfPrimCountin L4DRAWOP and a directDrawIndexedPrimitivepath 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.
- per-op
- 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<=0never culled). GateBT_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 binary — Emitter::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, == binaryFUN_00424b60): resolves a named skeleton node via the PUBLIC engine pathGetSegment(CString)→EntitySegment::GetJointIndex()→JointSubsystem:: GetJoint(i)(JMOVER.h/SEGMENT.h/JOINT.h, all inmunga_engine.lib) — never touches the protectedEntitySegment::jointPointer. The Torso ctor @004b6b0c + Gyro ctor @004b3778 inline this; hoisted as the reuse point (gyro un-stub will call it too).Torso::ResolveJointforwards toowner->ResolveJoint(out-of-line — needs complete Mech).Torso::PushTwist(Joint*,Scalar)(==FUN_004b66b4/FUN_004b67ecinner block): dispatch onJoint::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 retypedvoid*→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 (drivecurrentElevation0x1e4, clamp vertTop/Bottom), @0x200/@0x204 = twist left/right (drivecurrentTwist0x1d8, clamp horiz Left/Right), @0x208 = recenter; analog axes @0x1F0 = twist, @0x1F4 = elevation (the draft misusedelevationHalfBottom@0x22C and omitted @0x1F4). Settle test usestargetTwist(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:
TorsoSimulationANDTorsoCopySimulationcomputecurrentTwistbut never write the joints;WriteJoints(@004b66b4) is called ONLY byResetToInitialState;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, soUpdateJoints()is now called at the end of both Torso(Copy)Simulation — the resolved equivalent at the same cadence. Verification is env-gatedBT_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 : MechSubsystem— NOT HeatableSubsystem. The Watcher branch is SEPARATE from the workingPoweredSubsystem : HeatSink : HeatableSubsystem : MechSubsystemheat leaves; the two share ONLYMechSubsystem. 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 NOTIsDerivedFrom(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;
MechSubsystemuntouched): 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-byteSubsystemConnection/ 8-byteHeatAlarmthe HeatSink branch uses. Introduced Watcher-localWatchedConnection(0xC) +WatcherGaugeAlarm(0x54) inheatfamily_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 shadowwatchedLink(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, engineSimulation:: lastPerformance/ForceUpdate()); ADDED the 4-byte_reserved0x270slot (+4) sorecenterActive@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, andHUDLayoutCheck(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, butoffsetof()on a PROTECTED object member needs afriend struct(namespace-scope offsetof → C2248). - Verified live (
BT_TORSO_LOG): the real Torso constructs withcurrentTwist@472 (0x1D8),sizeof=640 (0x280), no crash over 26s; heat converges to the identical steady state (pendingHeat0.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-linksinkSourceSubsystem+0x1D8now reads the livecurrentTwist(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@004b6b0creads enabled atparam_4+0x14C(line 5038) and gates joint resolution onenabled!=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 REALBLH.SKLjoints, widens the limits, and sweeps an analog twist demand. Verified (BT_TORSO_LOG):Mech::ResolveJointresolvedjointshakey2→type=4 (Ball, torso body) +jointtshadow→type=1 (HingeY, the skeleton's "apply torso twist to yaw" joint);PushTwistthen fires every frame withcurrentTwistsweeping cleanly0→+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/mechJointNodevoid*→Joint*.GyroLayoutCheckfriend locksexageration@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
JointAPI —GetJointType/GetRadians/SetRotation(Radian)[hinge]/GetEulerAngles/SetRotation(EulerAngles)[ball]/GetTranslation/SetTranslation[BallTranslation]; FUN_0041d11c = SetTranslation (writes*(joint+0xc)+ jointModified).ResolveJointforwards toMech::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 ofGyroscopeSimulation. - Verified live then REVERTED: the real gyro constructs, resolves REAL joints (
EyeJoint='jointlocal',MechJoint='jointeye', both BallTranslation type=5), andWriteMechJointFIRES every frame — but with garbage values (-4.31e8= 0xCDCDCDCD-as-float, and NaN) written tojointlocal(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 aseyeSpringConstant@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 theGyrostub (combat/heat un-regressed, TARGET DESTROYED). - ⚠ Cross-link landmine (mech.cpp): the gyro↔torso cross-link
((SubProxy*)gyro)->linkTarget = sinkSource+0x1D8writes throughSubProxy::linkTargetwhich 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_LOGdumps the layout + joint resolve + WriteMechJoint values for verification.