Files
BT411/docs/RECONCILE.md
arcattackandClaude Opus 4.8 7b7d465e5e Initial commit: bt411 -- standalone Windows BattleTech (Tesla 4.10 port)
Clean, self-contained extraction of the BattleTech-specific work from the
reverse-engineering workspace -- engine + game + content + build, with nothing
from Red Planet or the raw archive dumps. Builds green (Win32) and runs the
single-player drive->animate->target->fire->damage->destroy loop out of the box.

Layout:
  engine/   MUNGA + MUNGA_L4 shared 2007 engine, carrying our BT render/loader
            work (bgfload/L4D3D/L4VIDEO: BSL bit-slice decode, LOD/ground/shadow
            models) + image codec; the minimal rp/ headers the audio HAL needs
  game/     reconstructed BT logic + surviving-original BT source + fwd shims
            + WinMain launcher
  content/  full runtime tree (BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI)
  docs/     format specs + reconstruction ledgers
  reference/ raw Ghidra pseudocode (recon source-of-truth) + decomp exporter
  tools/    MP console emulator + map/resource scanners

One top-level CMake builds munga_engine lib + bt410_l4 game lib + btl4.exe.
All paths relativized (186 fwd shims + ~437 CMake abs paths -> repo-relative);
DXSDK is the one external, overridable via -DDXSDK. Verified: builds to a
byte-identical 2.27MB exe and runs combat (TARGET DESTROYED, 0 crashes) against
the bundled content.

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

113 KiB
Raw Permalink Blame History

BT compile-wave reconciliation notes

Cross-family header needs + link-time externs reported by family agents. Resolve AFTER all 7 families report (Stage 1 reconciliation), then again at link (Stage 3).

From controls/player (mechmppr, btplayer) — DONE, 0 errors

Cross-family header changes needed

  • mech.hpp (mech-core family): expose on Mechtonnage (+0x4bc), damageBiasFactor (+0x354), owningPlayer (+0x190, BTPlayer*). Currently reached via raw offset macros in btplayer.cpp.
  • ScenarioRole (engine SCNROLE.h): needs decrement of returnFromDeath (used SetReturnFromDeath(GetReturnFromDeath()-1)); no returnDelay field (role+0xf0) → roleReturnDelay stubbed to 0.
  • Player__VehicleDeadMessage: BT build carried killerName (+0x1c) the engine base lacks; read via offset.

Stubbed (no WinTesla analog) — revisit at integration

  • BTPlayer ctor role/mission-registry resolution: GetMissionRegistry() / GetRoleRegistry()->Lookup() / GetMission()->GetGameModel() have no WinTesla analog → stubbed; base-set scenarioRole stands.
  • ToggleVoiceAssist, Is_Destroyed (mechmppr)
  • Set_Alarm_Level (GaugeAlarm has no SetLevel in WinTesla)
  • Notify_Objective_Reached, StatusMessagePool
  • BTPlayerVTable, BTStatusMessageVTable, SelfDestructName, ResetDelay, TicksPerSecond

From gauges/HUD (btl4gaug, gau2, gau3, galm, hud) — DONE, 0 errors

Cross-family header changes needed

  • mech family: LWord BT_GetSegmentFlags(Mech*) — declared extern in hud.cpp (mirrors powersub's BT_GetMessageManager pattern); needs a real definition in the mech layer.
  • powersub/heat family: HUD inherited watchdog accessors stubbed to return 0 — bind to real inherited members once exposed: heat-state level @0x140, watched-voltage alarm level @0x198, heat-watcher flag @0x40.

Foundation fix worth doing properly

  • DebugStream is double-defined incompatibly: heat.hpp (std::ostream&) vs mechrecon.hpp (ReconStream). Gauge .cpps work around it by pre-defining BT_DEBUGSTREAM_DEFINED to suppress heat.hpp's variant. → settle on ONE definition in the shared foundation. (NOTE: a mechrecon.hpp exists — investigate.)

Stubbed (unrecovered .data) — revisit

  • Gauge resource-pool config (rates/modes/colours/image-names) not recovered from the optimised image → typed placeholder statics. HUD FindAttributeIndex shim (no engine analog).

From heat/power (heat, heatfamily_reslice, powersub, gnrator) — DONE, 0 errors

OWNS the subsystem base classes. Established the authoritative base virtual surface (HeatableSubsystem/ HeatSink/Condenser/Reservoir/PoweredSubsystem/Generator/Myomers/PowerWatcher/HeatWatcher) — see report: GetClassDerivations()/DefaultData 4-arg idiom; HeatAlarm/HeatFilter/SubsystemConnection wrappers in heat.hpp.

Cross-family header changes needed

  • mech family — provide on Mech (declared as extern prototypes BT_GetMessageManager/BT_IsBusLive/ BT_ClearMyomers(Mech*) in powersub.hpp so TUs link):
    • Mech::GetMessageManager() → subsystem message manager (owner+0x190)
    • Mech::IsBusLive() → power-bus-live flag (*(owner+0x190)+0x274)
    • Mech::ClearMyomers() → clears myomer back-pointer (owner+0x374)
    • Cockpit HUD gauge fields field_438/field_5b4 used by Condenser::MoveValve (stubbed null).

From btl4-app (btl4app/mssn/rdr/grnd/vid/pb/mppr, btdirect) — DONE (4 clean, 4 only cross-family residual)

btl4mssn/grnd/vid/direct = 0. btl4app=1, btl4mppr=2, btl4pb=3, btl4rdr=1 — ALL residuals are in mech.hpp / mechrecon.hpp (not owned). Will clear when mech family + mechrecon are reconciled.

Cross-family header changes needed

  • mech.hpp — add to class Mech (offsets from binary oracle):
    • void SetMappingSubsystem(Subsystem*); (btl4app.cpp:567)
    • void Reset(); + void MissionReviewReset(); (btl4pb.cpp:555)
    • Logical GetMissionReviewMode(); (reads mech+0x414, btl4mppr.cpp:366)
    • void SetTargetRange(Scalar); (writes mech+0x404, btl4mppr.cpp:407)
    • (non-blocking) egg fields vehicleColor/vehicleBadge/vehiclePatch (mech+0x848/0x844/0x84c)
  • mechrecon.hpp(89) — declares Random as a FUNCTION, collides with engine extern RandomGenerator Random; (RANDOM.h:40). RENAME/REMOVE it — sole residual blocking btl4rdr.cpp. Investigate what mechrecon.hpp is + who owns it (mentioned by gauges/HUD + btl4-app).

From mech-subsystems (gyro/torso/myomers/sensor/mechtech/searchlight/thermalsight/messmgr) — DONE, 0 errors

mechrecon.hpp confirmed = emergent SHARED COMPAT-SHIM header (provides ReconAlarm etc.); families lean on it. → CONSOLIDATE + settle ownership in reconciliation (also the Random-function bug above).

Cross-family header changes needed (locally shimmed; real homes are shared bases)

  • Mech/Entity/Subsystem: Word GetSegmentFlags() const (used by 6 files); Mech::ResolveJoint(const char*); segment-roster accessors; Mech::GetSegmentCount().
  • HeatSink: HeatState HeatStateLevel() const, Logical HeatModelOff() const, public heatEnergy accessor.
  • PoweredSubsystem: ElectricalState ElectricalStateLevel() const.
  • PowerWatcher: confirm ResetToInitialState()/HandleDeathMessage(Message&)/UpdateWatch()Simulation(Scalar)/WatchedVoltageLevel()/HostShutDown() signatures (now derives HeatableSubsystem).
  • Subsystem: GetExplosionID(); renderer/clock globals for messmgr explosion submission.
  • powersub.hpp: DELETE stale mislabeled Myomers; RegisteredClass: add MyomersClassID == 0xBC6 (enum still has the 0xBC3 mislabel = really Sensor).

From mech-core (mech/mech2/3/4/mechsub/mechdmg/dmgtable) — PARTIAL (4 of 7 clean)

CLEAN: mech2, mech4, mechsub, dmgtable. BLOCKED: mech.cpp, mech3.cpp, mechdmg.cpp. OWNS mechrecon.hpp (the shared compat shim) → fix the Random-function collision HERE. Added ~60 Mech members (gait/locomotion/clip/sim) to mech.hpp.

The big remaining integration task — THE SUBSYSTEM FACTORY

mech.cpp ctor + mech3.cpp CreateSubsystemStream/CreateSubsystemsStream/SubsystemDefaultData dispatch over ~21 sibling subsystem classes. Each needs, uniformly: static <X> CreateStreamedSubsystem(NotationFile*, const char*, const char*, SubsystemResourceBlob*, NotationFile*, const ResourceDirectories*, int) AND static SharedData DefaultData Classes: HeatSink, Reservoir, Condenser, PoweredSubsystem, Myomers, Generator, HUD, Searchlight, ThermalSight, Sensor, Gyroscope, Torso, Emitter, PPC, ProjectileWeapon, MissileLauncher, GaussRifle, AmmoBin, MechControlsMapper, MechTech, SubsystemMessageManager (+ mech.cpp-only: Cockpit, HeatSinkSource, Actuator, JumpJet, LegSubsystem, MechDisplay). → This is a dedicated follow-up pass AFTER all sibling headers are final (needs their headers included in mech.cpp/mech3.cpp + the uniform static signatures present). mech.cpp also has ~180 raw this[0xNN] pseudo-assignments to convert, and ChainOf<> default-ctor/SetOwner/iteration semantics.

  • mechdmg.cpp: mostly reducible (Mech__DamageZone members structureLevel/graphicState/flags + word accessor; Read→ReadBytes); + Generator cross-family dep (powersub) + engine skeleton-walk types (SegmentRecord/SegmentIterator/DZRef).

From weapons (mechweap/emitter/projweap/mislanch/missile/seeker/projtile/misthrst/ammobin) — DONE, 0 errors

No blocking cross-family needs (adapted to heat/powersub header changes live). Watch points:

  • mechweap.cpp calls PoweredSubsystem::GetClassDerivations() — must track if powersub reverts.
  • ammobin.hpp includes <heatfamily_reslice.hpp> (where HeatWatcher is actually defined), NOT <heatfamily.hpp> (forwards to heat.hpp, no HeatWatcher). Follow if HeatWatcher moves.
  • Added per-class X__UpdateRecord : Base::UpdateRecord; UpdateTargeting() now returns Logical.

============ WAVE COMPLETE: 36/43 clean. Remaining: 4 btl4-app (reconcile) + 3 mech-core (factory) ============

RECONCILIATION PASS 1 done → 40/43 clean

  • mechrecon.hpp Random() template → renamed RandomUnit() (collided w/ engine extern RandomGenerator Random); updated dmgtable.cpp 2 call sites. btl4rdr now 0.
  • mech.hpp: added Mech app/cockpit interface — SetMappingSubsystem(Subsystem*), GetMissionReviewMode(), SetTargetRange(Scalar), Reset(const Origin&, int mode), and enum ResetMode { MissionReviewReset=0 }. CORRECTION: MissionReviewReset is a MODE CONSTANT (0), not a method (call site proved it). → btl4app/mppr/pb all 0. (Definitions of these Mech methods still owed by mech.cpp at link time.)

REMAINING: mech.cpp, mech3.cpp, mechdmg.cpp (the subsystem factory) → dedicated agent.

RECONCILIATION PASS 2 + STAGE 2 PREP (overnight)

  • Factory agent #1 stalled (stream watchdog) with mech.cpp at 67 (from >100); mech3/mechdmg untouched. It regressed hud.cpp (0→1) via a ZeroVector it added to mechrecon.hpp:181 → FIXED by renaming hud.cpp's local ZeroVectorHudZeroVector (kept mechrecon's for the mech files). hud.cpp back to 0.
  • Resumed factory agent (a52bd3052af4ade1a) from transcript with file-by-file + verify-often guidance.
  • STAGE 2 (executable) staged, all non-conflicting:
    • btbuild/btl4main.cpp = WinMain launcher cloned from RP_L4/RPL4.CPP, BTL4Application path, BTL4.RES, 800x600 window, RP-only review/playback branch dropped. COMPILES CLEAN (0 errors).
    • btbuild/CMakeLists.txt now also defines exe target btl4 (WIN32): links bt410_l4 + munga_engine.lib (enginebuild/build/Debug) + OpenAL32/libsndfile-1 (trunk/lib) + d3d9/d3dx9/dinput8/dxguid/DxErr (DXSDK Lib/x86) + winmm/dbghelp/shell32/user32/gdi32/ole32. CMake CONFIGURES clean (Win32 Debug).
    • Version bytes hardcoded {4,1,0} — TODO verify vs BTL4.RES header at runtime.

symbols (Stage 3) → define/reconstruct or stub-with-flag → iterate to a linked exe → attempt boot.

====== ALL 43 RECONSTRUCTED MODULES COMPILE (factory done by stopped-but-finished agent) ======

- Surviving BT source not compiled in (BTMission/BTRegistry/BTL4ModeManager/Gauss/PPC/BTTeam/

BTScenarioRole/BTConsole) → FIXED: added BTMSSN/BTREG/BTTEAM/BTSCNRL/BTCNSL/GAUSS/PPC/

BTL4MODE/BTL4ARND .CPP to the bt410_l4 sources.

FULL BUILD #2: surviving files have 12 compile errors (410-era ClassDerivations→GetClassDerivations

STILL-UNDEFINED (buckets 3+4, expected after survivors compile) — provide real def or flagged stub:

Mech::{GetMissionReviewMode,SetTargetRange,SetMappingSubsystem,Reset,IsAirborne,RaiseStatusAlarm},

Mech__DamageZone::LoadCriticalSubsystems, MechSubsystem::{TakeDamage,OnAlarmChanged,HandleMessage},

Is_Destroyed, ToggleVoiceAssist, Generator::{ClassDerivations,ForceShortRecovery},

FrameTimeScale, allPresets, BTL4MethodDescription, BTL4AudioRenderer ctor, BTL4VideoRenderer ctor.

0 compile errors, 0 unresolved externals. Achieved via:

TASK 1 (12 compile errors in surviving 410-era files — all FIXED):

- GAUSS.CPP/PPC.CPP: ClassDerivations/DefaultData now pass ADDRESSES (&Emitter::ClassDerivations,

&X::ClassDerivations) — matches reconstructed emitter.cpp member-idiom (Derivation ctor now takes

Derivation*). Headers unchanged (still static Derivation ClassDerivations).

- BTTEAM.CPP/.HPP: migrated to GetClassDerivations()/GetAttributeIndex() accessor idiom (mirrors

engine TEAM.cpp); TestInstance uses *GetClassDerivations().

- BTL4ARND.CPP:77: JointedMover::ClassDerivations → *JointedMover::GetClassDerivations().

- BTMSSN.CPP:53: Exit(AbortExitCodeID) → Fail(...) (WinTesla engine has no free Exit(); Fail is the

fatal-abort macro already used in this file). FLAGGED.

- btscnrl.hpp AUTHORED (decomp/reconstructed/) — minimal BTScenarioRole : ScenarioRole (BTSCNRL.CPP

is an empty TU; nothing else references the class).

pulled btl4vid.obj fully in, exposing the unreconstructed BT renderable module + dpl2d + renderer

helpers), Wave C=6 (engine VideoComponent never implemented in WinTesla).

REAL fixes (co-located, not stubs):

- mechdmg.cpp:358 Generator::ClassDerivations → *Generator::GetClassDerivations() (idiom).

- btl4grnd.cpp: defined BTL4MethodDescription[] = { &BTL4ChainToPrevious } (minimal real table).

- btl4vid.cpp: defined BTL4VideoRenderer ctor/dtor/TestInstance (ctor adapts old IG calibration

args → DPLRenderer(GetActiveWindow(),800,600,false,...); FLAGGED window/size).

FLAGGED STUBS (new file decomp/reconstructed/btstubs.cpp; added to bt410_l4 in CMakeLists):

data globals: FrameTimeScale(=1.0f), allPresets[2][100], StatusMessagePool, BTPlayerVTable,

BTStatusMessageVTable.

free fns: Is_Destroyed, ToggleVoiceAssist, Set_Alarm_Level, Notify_Objective_Reached.

Mech: GetMissionReviewMode, IsAirborne, SetTargetRange, SetMappingSubsystem, RaiseStatusAlarm.

Mech__DamageZone::LoadCriticalSubsystems; MechSubsystem::TakeDamage/OnAlarmChanged;

Generator::ForceShortRecovery.

BT_GetMessageManager/BT_IsBusLive/BT_ClearMyomers (Mech<->subsystem bridge).

dpl2d_* (NewDisplayList/Begin/SetColor/Circle/Push/PopMatrix/MoveTo/End/Compile) +

dpl_SetMaterialNameCallback (libDPL 2D layer never ported to L4D3D).

BTL4VideoRenderer::GetScene/LoadObject/AttachToEyeDCS/AddDynamicRenderable/LoadMissionImplementation.

ENTIRE BT RENDERABLE MODULE (unreconstructed): BTWorldContainer/Root/DCSObject/Hinge/BallJoint/

BallTranslateJoint/Eye/DeathEffect/MarkerWatcher/Translocation/POVStartEnd/Tracer/EmitterBeam/

PPCBeam/LightConnection ctors (chain to BTRenderableBase) + BTReticleRenderable ctor/dtor/Execute.

ENGINE VideoComponent ctor/dtor/Add/Connect/ReceiveControl/Execute (declared in L4VIDRND.h but

1995 header-defined non-inline globals/free-fns (operator==(sockaddr_in&), EnumAxes/JoysticksCallback,

gOpNames, gReplacementData) compiled into multiple TUs. Identical defs → linker keeps first.

⚠ RUNTIME BRING-UP WORKLIST = every stub above (all carry TODO(bring-up) comments; grep btstubs.cpp).

stubs get real bodies (from BTL4OPT.EXE oracle / RP analog). First boot will likely reach init/menu

but not render a mech. NEXT: attempt boot; reconstruct the BT renderable module + dpl2d-over-D3D.

MISSING-FILE LEDGER (from BT.MAK+BTL4.MAK = 53 modules): ~10 survived, ~43 reconstructed,

mechbld(offline tool), btl4tool(offline tool). Nothing runtime-critical lost except possibly path.

============ STAGE 4: IT BOOTS. btl4.exe runs to a stable window. ============

FULL BUILD: 0 errors, 0 unresolved → btl4.exe (1.85MB PE32) LINKS. Boot smoke-test (run/ dir: exe + stub OpenAL32.dll/libsndfile-1.dll + x86 debug CRT; cwd = pod content tree tesla4pod_extracted/.../REL410/BT; arg -egg TEST.EGG):

PROGRESS LADDER (each was a real fix):

  1. exit 3, "no source for egg" → wired L4Application::ParseCommandLine + ParseToken in btl4main.cpp (egg via -egg <file>; single-user mode = no real net needed).
  2. exit 3, "btl4.res v1.1.0.6 obsolete" → version check compares our version[i] vs file versionArray[i+1] (skips format byte). File=1.1.0.6 → set version[3]={1,0,6}. PASSES.
  3. NOW: boots → window "BattleTech" (800x600) appears → resources OK → ApplicationManager OK → BTL4Application constructs OK → BLOCKS inside ApplicationManager::StartApplication (idle, CPU~0, no crash, stable 16s+). Waiting on a mission/network state-machine event the inert stubs don't produce.

RUN RECIPE (for morning / interactive)

exe: C:\git\nick-games\btbuild\run\btl4.exe (+ stub DLLs + debug CRT already there) cwd: C:\git\nick-games\tesla4pod_extracted\Tesla4PodPCNovell\CopyofNovellDisk\REL410\BT arg: -egg TEST.EGG (a BattleTech/cavern/freeforall/night mission) log: btl4.log in the cwd (has the [boot] markers from btl4main.cpp).

NEXT (bring-up, best done interactively + with a debugger):

  • Trace why StartApplication blocks: the mission/net state machine (InitializingState→WaitingForEgg→ LoadingMission→...) likely waits for a step a STUB no-ops. Prime suspects = the btstubs.cpp list (BTRenderable, dpl2d_, BTL4VideoRenderer::Load*/GetScene, network/host local-establishment).
  • Replace stubs with real bodies from the BTL4OPT.EXE oracle / RP analog, in dependency order of the boot state machine. Each real body lets the state machine advance one step.
  • Audio is stubbed (no real OpenAL/libsndfile DLLs) — fine for now; swap real DLLs later.

============ STAGE 4 BRING-UP (debugger-driven, 2026-06-30) ============

Method: run under cdb (x86), catch fault/abort, fix, repeat. Run recipe unchanged (run\btl4.exe, cwd=pod BT dir, -egg TEST.EGG; symbols via -y run\ + btl4.pdb).

PROGRESS LADDER (each a real fix; all advance the boot):

  1. HANG in video init: DPLRenderer(=D3D)->loadTables fread(NULL) -> CRT assert MessageBox. loadTables reads VIDEO\REPLACEMATS.tbl + VIDEO\MATREPLACETABLE.tbl (WinTesla-era, absent). FIX: created empty (numMats=0, 4 zero bytes) tables in pod VIDEO. -> video init passes, monitor detection runs (windowed mode), StartApplication returns, enters RunMissions.
  2. AV in mission load: BTPlayer ctor -> ScenarioRole::GetReturnFromDeath on NULL (role lookup stubbed/commented at btplayer.cpp:630). FIX: null-guard btplayer.cpp:644 (default index 2).
  3. AV in frame loop: Entity::Execute->PerformAndWatch called NULL fn ptr; the BTPlayer's vtable was overwritten with the zeroed stub BTPlayerVTable. ROOT: btplayer.cpp:568/698 had decompiler artifact *(void**)this = &BTPlayerVTable (+447 status msg). FIX: removed those 3 artifacts (C++ ctor sets the real vtable). -> D3D WINDOW APPEARS, game simulates.
  4. abort() in frame loop: Player::HuntForDropZone (spawn logic) -> DropZone reply -> base Player::DropZoneReplyMessageHandler = Fail("...base player class!"). ROOT: BTPlayer never defines DropZoneReplyMessageHandler, so MESSAGE_ENTRY(BTPlayer,DropZoneReply) resolves to the inherited base Fail-stub. <-- CURRENT FRONTIER.

NEXT: reconstruct BTPlayer::DropZoneReplyMessageHandler (the spawn handshake: place mech at dropzone + create player vehicle) from the BTL4OPT.EXE oracle. Likely a small CLUSTER of missing BTPlayer/Mech gameplay overrides will follow (now genuine game-logic reconstruction, not mechanical link fixes).

============ STAGE 4 BRING-UP (cont., 2026-06-30 session 2) ============

The SPAWN PIPELINE is now reconstructed end-to-end. Boot advances from the DropZoneReply abort all the way INTO Mech construction. PROGRESS LADDER (each a real fix): 5. abort() Player::DropZoneReplyMessageHandler base Fail. ROOT: BTPlayer had no override. FIX (REAL reconstruction): reconstructed the whole spawn handshake - - BTPlayer::DropZoneReplyMessageHandler <- binary oracle FUN_004bffd0 (handler table @00512fd8, msg id 0x15; confirmed VehicleDead=FUN_004c05c4 NOT 004c012c, and the real 6-entry table: DropZoneReply/VehicleDead/Score/ScoreUpdate/MissionStarting/MissionEnding). !playerVehicle -> CreatePlayerVehicle+InitializePlayerLink+SetPerformance(by classID: Mech 0xbb9 / CameraShip 0x45) +AlwaysExecute+deathCount=0; else-same-death -> MissionEnding ignore / !DropZoneAcquired -> VehicleDead(reset); then mech->Reset(dropZoneLocation,True). - BTPlayer::CreatePlayerVehicle(Origin) <- vtable slot +0x40 (FUN_004bfcac, not in the assert-anchored decomp) modelled on RPPlayer::CreatePlayerVehicle: base CreatePlayerVehicle (camera ships) then make a Mech via MakeAndLinkViewpointEntity from playerMission->GetGameModel() ModelList resource, names = GetBadgeName/GetColorName/((BTMission*))->GetPatchName. - BTPlayer::InitializePlayerLink <- FUN_004bfee0 / RPPlayer analog (PlayerLink dispatch). - Mech::MakeMessage ctor (mech.hpp) <- mirror of VTV__MakeMessage entity_ID form + 3 name strings. - Mech::Reset(const Origin&,int) (btstubs.cpp) <- MINIMAL of FUN_0049fb74: set localOrigin/ localToWorld + ForceUpdate. TODO(bring-up): full ~50-field state + per-subsystem reset deferred. RESULT: HuntForDropZone->AssignDropZone->DropZoneReply->CreatePlayerVehicle->MakeAndLinkViewpoint-> BTL4Application::MakeViewpointEntity->Mech::Make->Mech::Mech runs. Spawn pipeline FUNCTIONAL. 6. AV mech.cpp:407 (Mech ctor) deref Wword(0xbb)+0xc, Wword(0xbb)=0 (engine segment record). FIX: guarded the NULL deref (heatLevel default 0). TODO(bring-up) - real value needs the segment record wired (see frontier below). 7. AV mech.cpp:591 (Mech ctor) gyro<->sinkSource cross-link wrote through gyroSubsystem=0xcdcdcdcd. ROOT (REAL BUG): the ctor's subsystem-pointer zero-inits at mech.cpp:359-361 targeted the Wword SCRATCH BANK (Wword(0x14a/0x10e/0x16d)=0) instead of the real members, so gyroSubsystem/ sinkSourceSubsystem/mechTechSubsystem were never zeroed -> debug-fill passed the !=0 guard. FIX (REAL): zero the real members gyroSubsystem/sinkSourceSubsystem/mechTechSubsystem/sensorSubsystem (matches binary @9873-9875). NOT a guard - a genuine init bug. 8. AV mech.cpp:641 (Mech ctor Pass 2) model = ResourceFind(Wword(0x6f),0xf) -> NULL -> model->base deref. <-- CURRENT FRONTIER (ROOT, structural).

THE FRONTIER = the Mech object-layout / resource-streaming "factory" (deferred big task)

ROOT CAUSE, definitively localised: in decomp/reconstructed/mechrecon.hpp the resource-streaming layer is a set of NO-OP placeholder shims:

  • ResourceFind(...) -> Recon() (mechrecon.hpp:86) [stubs ResourceFile::Find FUN_00407064]
  • ResourceStream (mechrecon.hpp:117-119) = template-ctor no-op; SegmentCount()=0 -> 0 subsystems
  • ResourceFindByName, LoadSkeleton, WorldTransform, ResourceManagerHas = same no-op shims
  • Wword(i) (mechrecon.hpp:188) = a single shared static BTVal bank[0x400] SCRATCH bank: every raw this[0xNN] in mech.cpp reads/writes a throwaway slot, NOT real object memory. ~145 sites in mech.cpp. CONSEQUENCE: the Mech ctor compiles and runs but never streams its model (segment 0xf), subsystem list (0x11), damage zones (0x14) or skeleton; model is NULL and the model-resource handle Wword(0x6f) (set by the BASE ctor in the binary, an inherited field) is 0. So NO functional mech can be constructed. Every further mech-ctor fault (407, 591, 641, ...) is one symptom of this same root - guarding them one by one yields only a hollow, non-functional mech. This is exactly the "THE SUBSYSTEM FACTORY" + "~180 raw this[0xNN]" item flagged for a dedicated pass (see CLAUDE.md mech-core / Roadmap Track 2). NEXT (dedicated factory pass, NOT a fault-grind): (a) map the ~145 Wword(0xNN) sites + the segment-record fields (0xba/0xbb) and model handle (0x6f) to REAL Mech/base members at the documented offsets; (b) implement ResourceFind/ResourceStream/ResourceFindByName against the real engine ResourceFile::Find over the BT .res segment format (0x11/0xf/0x14/0x1e/0x1d); (c) verify the ModelResource/SubsystemSegment field layouts vs the binary; (d) then the per-class subsystem factory (CreateStreamedSubsystem/DefaultData) actually instantiates subsystems. Oracle = Mech ctor FUN_004a1674 (part_012.c @9696, 5690 bytes). Files touched this session: decomp/reconstructed/btplayer.{hpp,cpp}, mech.hpp (MakeMessage ctor), mech.cpp (lines ~359-361 real zero-init, ~406 heat guard), btstubs.cpp (Mech::Reset). Build GREEN, 0 errs.

============ STAGE 5: MECH STREAMS + CONSTRUCTS (resource-streaming pass, 2026-06-30 sess 3) ============

GOAL of this pass: replace the no-op resource-streaming shims with REAL engine streaming + map the raw object-layout fields to real members, then grind faults until the Mech is fully constructed. RESULT: the Mech now constructs end-to-end from real streamed resources, the spawn pipeline + control-mapping binding complete, and the game advances into mission-entity RENDER setup.

FAULT LADDER CLEARED (each = real reconstruction unless noted): 8. mech.cpp:641 model = ResourceFind(...) == NULL (THE documented frontier). ROOT: mechrecon ResourceFind/ResourceStream were no-op shims (SegmentCount()=0). FIX (REAL streaming): added MechFindResource() = application->GetResourceFile()->SearchList(creation_message->resourceID, type) -- the exact engine idiom the JointedMover base ctor + RP/VTV use. Rewrote BOTH passes: - Pass 1 (subsystems, segment 0x11 SubsystemModelStreamResourceType): MemoryStream over the locked resource; header int = subsystem count; per-blob loop reads Subsystem::SubsystemResource via GetPointer()/AdvancePointer(subsystemModelSize). (mirrors VTV::VTV @1379-1583) - Pass 2 (model, segment 0xf GameModelResourceType): model = res->resourceAddress (kept locked). Wword(0x6f) handle -> creation_message->resourceID (authoritative; base ctor already streamed the skeleton from it). -> model non-NULL, subsystem stream real. 9. mech.cpp:729 (float)(this + 0x15d) = ... AV. ROOT (decompiler artifact): typed-pointer arithmetic on Mech* (this + 0x15d == this + 0x15d*sizeof(Mech) == +0x89028). FIX: route to the Wword(0x15d/0x15e) scratch bank (the decomp intent is word slot this[0x15d], an unnamed pose angle). 10. OBJECT-LAYOUT BUG (the big one): mech.hpp re-declared int subsystemCount; Subsystem **subsystems; -- these SHADOWED the inherited Entity base members subsystemCount / subsystemArray that the engine reads in Entity::GetSimulation/GetSubsystem. The ctor streamed into the shadow members, so the engine never saw the roster (GetSimulation returned NULL). FIX (REAL layout mapping): removed the shadow decls; renamed all mech.cpp subsystems -> inherited subsystemArray; subsystemCount now resolves to the Entity base field. this[0x49]/this[0x4a] === Entity::subsystemCount/subsystemArray. 11. MechTech::MechTech (mechtech.cpp:213) AV walking owner->GetSubsystem(0..count): array TAIL was uninitialised heap garbage (loop only zeroed the current slot). FIX: zero the WHOLE subsystemArray right after allocation (every not-yet-filled slot must read NULL for roster scans). 12. MechTech roster walk AV: the streamed roster entries were HOLLOW stubs (RECON_SUBSYS = empty ReconSubsystemStub, no vtable) -> sub->IsDerivedFrom() faulted. FIX (REAL base wiring): made ReconSubsystemStub DERIVE FROM the real MechSubsystem base (proven ctor -- MechTech uses it), so every streamed entry is now a genuine registered, vtable-valid Subsystem WITH a DamageZone. -> Mech::Mech now RETURNS (full construction: model + 31 streamed subsystems + MechTech walk). 13. btl4app.cpp:554 Fail("Mech has no controls mapping"): primaryControlType==None on a dev box (no RIO/Thrustmaster joystick). FIX (bring-up): default case falls back to MechRIOMapper (the pod's real L4 device) instead of Fail. FLAGGED -- keyboard/gamepad remap is a pod Phase-8 task. 14. mechmppr.cpp:327 (MechControlsMapper ctor) AV: torso-recenter reads a ptr at the BINARY byte offset owner+0x438 then derefs it -- invalid because the RECONSTRUCTED Mech layout != binary's 0x854. FIX (bring-up GUARD): skipped (the Mech ctor already identity-inits the torso aim, so it starts centered). FLAGGED -- re-enable via a named accessor once cross-module layout is mapped. 15. L4CTRL CreateStreamedMappings -> GetSimulation(0) == NULL -> GetAttributePointer on NULL. ROOT: the streamed control-mapping resource binds DirectMappings to subsystemID 0 (the ControlsMapper slot, cf. VTV::ControlsMapperSubsystem==0), but Mech::SetMappingSubsystem was a no-op STUB, so subsystemArray[0] stayed NULL. FIX (REAL, mirrors VTV::SetMappingSubsystem): SetMappingSubsystem now installs the mapper into subsystemArray[0] (+ mirrors controlsMapper). -> control binding runs. 16. btplayer.cpp:225 VehicleDeadMessageHandler AV: scenarioRole->GetReturnFromDeath() on a NULL role (scoring role not looked up for the minimal TEST.EGG; btplayer.cpp:815 deferred). FIX (guard, consistent w/ the existing :644 null-role handling): only debit a life when scenarioRole != 0.

END STATE

The Mech is fully constructed from streamed resources and spawned; controls bind; the mission loads entities and enters the RENDER path. Boot now reaches: "Entity 1:NN classNN couldn't figure out how to MakeEntityRenderables" (the stubbed BT renderable module) then AV at btl4vid.cpp:808 BTL4VideoRenderer::SetupMaterialSubstitutionList -> NotationFile::GetEntry -> NotePage::FindNote. CAUSE: the renderer looks up the mech's egg color/badge/patch via entity->GetAttributePointer("VehicleColor"/"VehicleBadge"/"VehiclePatch") -- a FLAGGED cross-family gap (those egg strings aren't in the attribute index; the call returns garbage that FindNote then derefs). This is the BT video-renderer / material-substitution module (btl4vid.cpp, NOT a mech-construction file) -- the "ideally renders" frontier, a separate reconstruction task.

REMAINING STUBS / GUARDS introduced or relied on this pass (bring-up worklist):

  • mech.cpp RECON_SUBSYS subsystems: now real MechSubsystem-derived objects but with MINIMAL class behaviour (Cockpit/Sensor/Generator/Gyro/Myomers/weapons/... all share the base body). Per-class reconstruction (use the existing sensor.cpp/gyro.cpp/gnrator.cpp/powersub.cpp/emitter.cpp/... real classes in the switch) is the next refinement. 6 classes have NO real recon at all (Cockpit, Actuator, JumpJet, LegSubsystem, HeatSinkSource, MechDisplay).
  • mech.cpp Pass 3 (damage-zone segment 0x14) + crit-subsystem (0x1e) + standing-anim (0x1d): still use the mechrecon shim proxies (damageZoneCount scratch==0 -> loops inert). Not yet real-streamed.
  • mech.cpp model field reads (Pass 2): write mostly to the Wword scratch bank; the Mech__ModelResource struct offsets vs the on-disk GameModel record are unverified (reads are buffer-safe, values unused on the critical path). The capability sub-rosters (heatable/powered/damageable) stay EMPTY because the SubProxy::IsDerivedFrom shim returns 0 (no per-frame consumer reached yet).
  • mechmppr.cpp:327 torso-recenter: GUARDED/skipped (layout-dependent raw offset).
  • btl4app.cpp default controls case: MechRIOMapper fallback (no-joystick dev box).
  • btplayer.cpp:225 scenarioRole debit: NULL-guarded (no scoring role in TEST.EGG).
  • btstubs.cpp SetMappingSubsystem: now REAL (slot 0 install). Remaining btstubs stubs unchanged.

NEXT (for whoever continues): the render path. btl4vid.cpp SetupMaterialSubstitutionList needs the

mech's egg color/badge/patch (resourceNameA/B/C, set in the Mech ctor from the MakeMessage) exposed to the renderer -- either via the attribute index ("VehicleColor"/...) or a named Mech accessor -- and the VEHICLE table entries to match. Then the BT renderable module (BTWorldContainer/DCSObject/... ctors, currently btstubs) must be reconstructed for MakeEntityRenderables to actually build geometry.

============ STAGE 6: RENDER PATH RECONSTRUCTED — mech tree builds + presents (black: asset gap) ============

GOAL: clear the render-path faults until a frame draws (mech in cavern) or reach the deepest render fault. RESULT: the BT world-render path is reconstructed and runs end-to-end to a STABLE PRESENTING FRAME with no crash; the mech's 51-segment renderable tree + cockpit camera build correctly. The frame is currently BLACK — honest status: nothing visibly draws — but the cause is now precisely localised to a CONTENT (asset-pipeline) gap, NOT a render-code fault. Files touched: decomp/reconstructed/btl4vid.{cpp,hpp}.

FAULT LADDER CLEARED (each a real fix): R1. btl4vid.cpp:808 SetupMaterialSubstitutionList AV (strcmp NULL) — GetEntry("color",egg_color,..) with egg_color from GetAttributePointer("VehicleColor") returns garbage; NotePage::FindNote deref-crashed. FIX (mirrors RP RPL4VID.cpp:1562 which tolerates a missing colour/badge): the egg color/badge/patch reads are NULL-guarded and a table miss is no longer fatal (was Fail("Exiting")) — veh_color/badge/patch stay NULL and the %placeholder% substitution drops to default materials. TODO(bring-up): re-wire the real egg names (Mech::resourceNameA/B/C @0x844/0x848/0x84c) via a named accessor once the Mech layout is mapped; the attribute-index path is not wired. R2. ARCHITECTURE PIVOT — the recovered MakeMechRenderables built a 1996 pre-DPL renderable hierarchy (BTRootRenderable/BTHingeRenderable/... parenting on raw dpl_DCS*) on top of VideoComponent, which WinTesla never implemented (it was superseded by VideoRenderable; btstubs.cpp had to stub VideoComponent + every BTRenderable just to link → all inert → "couldn't MakeEntityRenderables"). FIX (the real work): REWROTE BTL4VideoRenderer::MakeMechRenderables to build the tree with the engine's WORKING D3D renderables — RootRenderable / HingeRenderable / BallJointRenderable / BallTranslateJointRenderable / DPLStaticChildRenderable / DPLEyeRenderable (MUNGA_L4/L4VIDRND) — exactly as RP's port does (RPL4VideoRenderer::MakeJointedMoverRenderables / RecurseSKLFile). These self-register (RootRenderable→AddRenderable; VideoRenderable→AddDynamicRenderable), build their own DCS, parent on the PARENT RENDERABLE (HierarchicalDrawComponent, not a dpl_DCS*), and hook the root to entity->localToWorld. Walks the EntitySegment table; per segment loads geometry via d3d_OBJECT::LoadObject(GetDevice(), ".bgf"), picks Hinge/Ball/BallTranslate/Static by joint type, and builds the cockpit camera (DPLEyeRenderable→mCamera) at the "siteeyepoint" site. MakeMechRenderables return type VideoComponent* → HierarchicalDrawComponent* (.hpp updated); the caller no longer double-registers via the AddDynamicRenderable stub. The BT*Renderable stubs in btstubs.cpp are now DEAD CODE for the body (kept for link; the 2D reticle path still references the BTReticleRenderable stub but that block is deferred — see below). R3. _RTC_UninitUse at L4VIDEO.cpp:5211 (DPLRenderer::MakeEntityRenderables) — exposed when the default (non-mech) case was routed to DPLRenderer::MakeEntityRenderables. ROOT (pre-existing ENGINE port bug): this_instance is used uninitialised (the DPL-stubbed engine left its assignment commented out) and /RTC1 traps it. FIX (faithful + safe): the default case calls the generic base VideoRenderer::MakeEntityRenderables (FUN_0045e744) — which is exactly what the shipped BT renderer did (it deferred to the grandparent "couldn't figure out how to MakeEntityRenderables" handler, NOT the DPL prop builder). Non-mech props/terrain pieces therefore log + draw nothing for now. TODO(bring-up): route world props through DPLRenderer::MakeEntityRenderables once that engine uninitialised-this_instance gap is fixed (out of scope here — shared engine source).

END STATE / VERIFIED BY DIAGNOSTICS (btl4.log, flagged [BTrender] lines): "[BTrender] MakeMechRenderables: 51 segments, view=1" <- render path runs, inside (cockpit) view "[BTrender] no mesh for 'blx_cop.bgf' (expects VIDEO*.x)" <- geometry load FAILS "[BTrender] mech tree built: meshes 0/1 loaded, eye=1" <- tree + camera built OK; 0 meshes loaded Window: 804x600 "BattleTech", stable, ALIVE@20s+, no crash, captured frame = solid black.

DEEPEST RENDER FAULT REACHED = ASSET PIPELINE GAP (not code): d3d_OBJECT::LoadObject (MUNGA_L4/L4D3D.cpp:47) loads VIDEO\<name>.x via D3DXLoadMeshFromXA — i.e. the WinTesla D3D renderer consumes Direct3D .x meshes produced by the port's MeshExp exporter. The BT pod content tree ships 879 .bgf (DIV-BIZ2) files and ZERO .x files (VIDEO\ + VIDEO\GEO). So EVERY segment mesh resolves to NULL → renderables are built with a NULL graphicalObject → nothing is drawn → black clear. Inside (cockpit) view requests only the cockpit shell (blx_cop); the 50 body segments have no inside-view object name (correct — you're inside the mech). To actually SEE geometry the .bgf models must be exported to .x into VIDEO\ (the MeshExp/ASSET_PIPELINE task), and/or use the OUTSIDE view to load the full body. Terrain/cavern likewise needs either the prop path (R3 TODO) or environment build.

REMAINING STUBS/GUARDS in the render path (bring-up worklist):

  • SetupMaterialSubstitutionList: egg color/badge/patch forced NULL (default materials). TODO: wire real Mech::resourceNameA/B/C.
  • Inside-view targeting reticle (BTReticleRenderable + AddWeapon pips) and per-subsystem weapon/effect renderables (PPC/emitter beams, projectile tracers, coolant, searchlight) NOT built — they depend on the unported dpl2d_ 2D layer (btstubs.cpp, all inert) + the BT effect renderables (btstubs.cpp). These are the HUD/weapons render bring-up; not needed for the mech body.
  • dpl2d_* (9 fns) + dpl_SetMaterialNameCallback: still inert stubs (btstubs.cpp).
  • Non-mech entity default → VideoRenderer base (no-op + log); props/terrain not rendered (R3 TODO).
  • [BTrender] DEBUG_STREAM diagnostics left in MakeMechRenderables (low volume; remove at cleanup).

================================================================================ PASS: .BGF MESH LOADER (close the asset-pipeline gap so meshes actually load)

PROBLEM (from prior pass): d3d_OBJECT::LoadObject loaded VIDEO<name>.x via D3DXLoadMeshFromXA, but the pod ships 879 original .bgf (DIV-BIZ2) models and ZERO .x -> every segment mesh resolved NULL -> "meshes 0/1 loaded" -> black frame.

FIX (engine mesh loader now reads .bgf natively):

  • NEW MUNGA_L4/bgfload.{h,cpp}: C++14 port of the proven port/src/bgf.cpp loader. Parses DIV-BIZ2 (OBJECT->LOD->PATCH->PMESH; quads triangulated; per-PATCH SV_SPECIAL material name), resolves .bmf material libs + texture image basenames, produces L4VERTEX-compatible verts (XYZ|NORMAL|DIFFUSE|TEX1) + 32-bit index list
    • per-material draw batches. Directory scanning uses Win32 FindFirstFile (NOT ) so it builds under the engine's C++14 target. Asset resolution: recursively indexes VIDEO\ (covers VIDEO\GEO, VIDEO\GEO<theme>, VIDEO\MAT...), basename(lower)->path; LoadObject("blx_cop.bgf") -> VIDEO\GEO\BLX_COP.BGF. Geometry is emitted DOUBLE-SIDED (both windings) so it is visible regardless of the engine's global D3DCULL_CW (no .x exporter survives to confirm winding); smooth normals computed from the forward winding only.
  • L4D3D.cpp: new static d3d_OBJECT::LoadObjectBGF — builds an ID3DXMesh (D3DXCreateMeshFVF, MANAGED|32BIT, L4VERTEX_FVF), fills VB/IB, writes one attribute id per face = batch index, GenerateAdjacency + OptimizeInplace(ATTRSORT) to build the attribute table, then constructs the mesh-backed d3d_OBJECT and fills mDrawOps per batch: material Diffuse(+Ambient) from the resolved .bmf color, and a decoded texture (port/src/image.cpp decodeImage -> D3DFMT_A8R8G8B8 managed tex). LoadObject now falls through to LoadObjectBGF when D3DXLoadMeshFromXA fails (the .x path is kept as a fallback), before LoadSpheres.
  • port/src/image.cpp (tga/vtx/bsl decoder, C++14-clean) added to the engine build; enginebuild/CMakeLists.txt: + bgfload.cpp, + image.cpp, + port/src include dir (no header-name collisions with the engine; verified).

VERIFIED (btl4.log, flagged [BTrender]/[BTdraw] lines): "[BTrender] mech tree built: meshes 1/1 loaded, eye=1" <- inside view: cockpit shell blx_cop.bgf now LOADS (was 0/1). N>0 achieved. Forced-outside experiment (skeletonType_N, reverted): "meshes 12/12 loaded" -> loader handles the full mech body too (12 segments w/ geometry, 0 failures). [BTdraw] diagnostic (added then removed): opaqueList=1, batches=9, every frame -> the cockpit mesh REACHES the opaque pass list and issues 9 DrawSubset calls per frame. The load->D3D-buffer->rasterize pipeline works end to end.

FRAME STATE: still BLACK in the default inside view (captured the BattleTech client region via screen grab; solid black even with materials forced fully Emissive and with the full body loaded). So this is NOT a loader/lighting problem -- the geometry IS rasterized but projects outside the eyepoint camera's frustum. ROOT (out of scope here, R2/R3 render-tree + camera): the inside view places the camera at the cockpit "siteeyepoint"; the player's own cockpit shell / body sit at/behind that eyepoint, and there is NO world to see ahead (terrain not built; other entities still hit the default VideoRenderer base = "couldn't figure out how to MakeEntityRenderables", R3 TODO). Original BT drew the world through the canopy + a 2D gauge HUD (dpl2d, unported). So black is expected for the current port state -- a VISIBLE mech needs either an external/chase camera looking at the body, or routing enemy JointedMovers through MakeMechRenderables so a mech renders ahead. Neither is the asset pipeline; both are render-tree/camera work.

HONEST MILESTONE READ: the asset-pipeline gap is CLOSED (meshes load: 1/1 cockpit, 12/12 body; rasterized 9 batches/frame). The window is still black because the only loaded geometry is the player's own shell behind the eyepoint with no world ahead -- a camera/world-render gap, not a mesh gap. Per the strict milestone ("window shows geometry, not black"), the on-screen mech is NOT yet achieved.

REMAINING GAPS (next pass, in priority order):

  • Camera/visibility: add an external view (or capture an enemy mech) so loaded body geometry lands in the frustum; verify winding (currently double-sided as insurance).
  • Textures: most BT materials resolve to .bmf diffuse COLORS; only ~47 .vtx/.bsl exist in the tree, so most batches are flat-colored (no texture). Texture coverage + the egg color/badge/patch substitution (SetupMaterialSubstitutionList, still NULL-forced) remain for a faithful skin.
  • World/props/terrain rendering (R3) + 2D cockpit HUD (dpl2d_, btstubs inert).

MECH VISIBLE ON SCREEN — first rendered BattleMech (external chase camera)

WIN: btbuild/run/frame.png shows a flat-shaded Avatar BattleMech (torso, both arms + gun pods, both legs + feet/toes, hip) centered in the BattleTech window. 12/12 body meshes assembled and rasterized.

What was wrong (diagnosis chain)

  1. The default player view is insideEntity -> MakeMechRenderables built the INSIDE skeleton (SkeletonType_A) and put the camera AT the cockpit eyepoint; no body geometry, nothing ahead -> black.
  2. Forcing the OUTSIDE body + adding an external camera still gave black. Instrumented DPLEyeRenderable::Execute (L4VIDRND.cpp): camera WAS correct — ~29 units in front of the mech (mech localToWorld vs camera pos), look-dir within ~5deg of the mech centre.
  3. Instrumented the opaque pass: the 12 mech body meshes WERE submitted (DrawSubset) with correct world matrices clustered at the mech's world position. Still black. CULLMODE=NONE did not help -> not backface culling.
  4. Root cause: logged the device transforms at draw time — the PROJECTION matrix was uninitialised garbage (every entry ~ -4.3e8). BTL4VideoRenderer::LoadMissionImplementation was an empty stub (btstubs.cpp), so DPLReadEnvironment never ran and DPLRenderer::mProjectionMatrix (a private member) was never built -> all geometry clipped. Compounded by D3DXMatrixPerspectiveFovLH mismatching the D3DXMatrixLookAtRH view.

Changes made

  • decomp/reconstructed/btl4vid.cpp MakeMechRenderables:
    • For the player's own mech (originally insideEntity) force the OUTSIDE body build (loads all 12 body meshes) and, after the tree is built, install a fixed external chase camera: DPLEyeRenderable parented to this_root with a hand-built LinearMatrix offset (camera at mech-local (0,9,-28), looking at (0,6,0); +Z=look, +Y=up, W=pos).
  • MUNGA_L4/L4VIDEO.cpp:
    • DPLReadINIPage projection: PerspectiveFovLH -> PerspectiveFovRH (match the RH view), dropped the mProjectionMatrix(0,0)*=-1 X-flip.
    • Added DPLRenderer::EnsureValidProjection() (decl in L4VIDEO.h) — builds a valid RH projection from the private members + bright ambient + fog off.
    • Opaque pass: CULLMODE=NONE and LIGHTING=FALSE (flat-shaded so the mech shows without scene lights).
  • decomp/reconstructed/btstubs.cpp: BTL4VideoRenderer::LoadMissionImplementation now calls EnsureValidProjection() instead of being empty.
  • Temporary diagnostics still present (gated to first few calls): [EYE], [DRAW], [VP] logging in L4VIDRND.cpp / L4D3D.cpp / L4VIDEO.cpp.

Remaining work for a proper external view

  • The current camera is a hard-coded DEBUG chase cam forced on the player mech. A real spectator/chase view-mode should be a toggle wired through BTL4Application (view mode), not a forced override inside MakeMechRenderables.
  • Mech sits slightly low/small; tune offset (raise target, pull closer) or frame from the mech bounding box.
  • EnsureValidProjection + opaque-pass LIGHTING=FALSE/CULLMODE=NONE are debug shortcuts. Proper fix: implement the real LoadMissionImplementation (call DPLReadEnvironment) so the per-mission viewangle/clip/fog/ambient/lights/paths from BTDPL.INI are honoured, then restore lighting + correct cull winding (and verify the RH projection against RP).
  • Strip the [EYE]/[DRAW]/[VP] diagnostics once stable.
  • Intermittent: ~1 run in 2 crashes during the segment loop before "mech tree built" (pre-existing reconstructed-code instability, unrelated to the camera/projection fix).

============ STARTUP-CRASH FIX (the intermittent ~1-in-6 heap corruption) ============

Symptom: exit 0xC0000374 (STATUS_HEAP_CORRUPTION), intermittent (2/12 runs), in the Mech ctor subsystem loop. Diagnosis: debugger masked it (corruption, not AV); nailed via gflags PageHeap (/p /enable btl4.exe /full) which faults at the exact bad access. ROOT (two object-layout overflows in mech.cpp's subsystem construction):

  1. mech.cpp:654 HeatableSubsystem allocated only 0x104 bytes, but the shared MechSubsystem base ctor WRITES this[0x44] (byte 0x110, vitalSubsystemIndex) -> 16-byte write past the allocation = heap corruption. FIX: bumped to 0x140 (base extent is 0x114; all other subsystems were already

    =0x130). THIS was the corruption. (PageHeap caught it at mechsub.cpp:161.)

  2. mech.cpp:497 the subsystem-resource MemoryStream wrapped the .res segment at exact size; the base ctor reads each record to res+0xe0, overrunning the buffer for short/last records (controls mapper). FIX: stream over a zero-padded (+0x100) copy. (Read-overflow; PageHeap caught at mechsub.cpp:165.) VERIFIED: PageHeap off, 30/30 runs clean, 0 crashes (was ~17%). Crash fixed. RESIDUAL (NOT the crash; latent over-reads PageHeap flags but that read valid adjacent heap in normal runs, e.g. mech.cpp:741 reading +0x108): object-layout debt -> fold into the Mech layout reconstruction. Tooling note: gflags needs an ELEVATED prompt; user enabled/disabled. btl4main.cpp has a dormant BT_HEAPCHECK #ifdef (CRT heap validation) for future hunts.

============ WORLD/TERRAIN VISIBLE — mech now stands in the cavern(des) world ============

WIN: btbuild/run/world.png shows the player mech standing on TEXTURED DESERT TERRAIN (the cavern->des_day environment) with a horizon + midground structures, instead of the black void. 3/3 stability runs clean, no crash. Engine + game both build green.

Diagnosis chain

  1. Set L4DPLCFG=BTDPL.INI + wired the real env load (see changes) -> the DPL per-entity builder began processing world props (bld08_lp, dish_lp, mslr_LP, ctar/dtar/calp_lp) but the run then crashed (0xC0000005) in DPLRenderer::ConsolidateSingleObject (L4VIDEO.cpp:2138), mov eax,[edx] edx=0.
  2. ROOT: the pod ships .bgf, loaded by d3d_OBJECT::LoadObjectBGF with D3DXMESH_32BIT (L4D3D.cpp:212). ConsolidateStaticObjects (the post-load static-mesh merge) called D3DXConcatenateMeshes with only D3DXMESH_MANAGED (16-bit output) -> concatenating 32-bit-index source meshes into a 16-bit output FAILS, leaving outMesh==NULL, then outMesh->GetNumFaces() dereferenced NULL. RP never hit this (its world loads from .x as 16-bit). This static-consolidation path had NEVER run for BT before, because the world default-case was previously a no-op (no static objects existed to consolidate).

Changes made (all in owned files)

  • btbuild/btl4main.cpp: added putenv("L4DPLCFG=BTDPL.INI") (alongside the other env defaults) so DPLReadEnvironment reads BT's real renderer config (paths/clip/fog/lights/ ambient/background/cached shapes), not dpldflt.ini. (RP set RPDPL.INI.)
  • decomp/reconstructed/btstubs.cpp BTL4VideoRenderer::LoadMissionImplementation: now calls DPLRenderer::LoadMissionImplementation(mission) (-> DPLReadEnvironment + LoadNameBitmaps, exactly like RPL4VideoRenderer::LoadMissionImplementation), then EnsureValidProjection() as a safety net (mProjectionMatrix is private here; this guarantees a valid RH projection + fog off so the mech/terrain are not clipped/fogged). Was previously an empty stub that only called EnsureValidProjection -> env never loaded.
  • decomp/reconstructed/btl4vid.cpp MakeEntityRenderables default case: route non-mech entities (terrain/props/landmarks) to DPLRenderer::MakeEntityRenderables (the DPL prop builder) instead of the no-op VideoRenderer::MakeEntityRenderables grandparent -- this is what RP's default does (RPL4VID.cpp:1436) and is what actually loads world .bgf into the scene.
  • MUNGA_L4/L4VIDEO.cpp:
    • ConsolidateSingleObject: pass D3DXMESH_MANAGED | D3DXMESH_32BIT to D3DXConcatenateMeshes (match the 32-bit source meshes); init outMesh=NULL and return NULL on failure with a log line instead of dereferencing NULL. Both callers in ConsolidateStaticObjects now skip a NULL result. This was the crash fix.
    • MakeEntityRenderables CulturalIcon/Landmark arm: initialise dpl_INSTANCE *this_instance = NULL (the per-object instance assignment is DPL-stubbed/commented out) so the /RTC1 uninitialised-variable check can't fault if a CulturalIcon/Landmark entity is built.

What renders / what doesn't

  • RENDERS: desert ground/terrain (textured), horizon, midground structures, the player mech positioned on the terrain at world ~(275,-0.35,48). Static terrain meshes consolidate cleanly (no D3DXConcat-skip log).
  • NOTE: BT/VIDEO/GEO/CAVERN/ is EMPTY in this content tree; cavern branches to des_day in BTDPL.INI, so the "world" is the desert/arena prop set in VIDEO/GEO/ (buildings, dishes, missile launchers, targets) -- which the recursive .bgf index finds and loads.
  • STILL FAILS (harmless): 28 *_LP.bgf (low-poly distant-LOD variants: bld08_lp/dish_lp/ mslr_lp/ctar_lp/dtar_lp/calp_lp). Their high-poly counterparts (BLD08/DISH/MSLR/CALP) ARE present and load; the _LP files are tiny (216-750B) valid DIV-BIZ2 that the bgf parser (bgfload.cpp) rejects -- this is the pre-existing BGF-loader gap (CLAUDE: 621/663 load), NOT a terrain-path regression. Fixing the LOD parse would add the distant-LOD billboards.

Remaining / next

  • Capture gotcha (for future sessions): the D3D9 window is windowed; PrintWindow returns BLACK (can't read the D3D backbuffer) and plain CopyFromScreen grabs whatever occludes it. WORKS: SetWindowPos(h, HWND_TOPMOST, 0,0,820,640, SWP_SHOWWINDOW) then CopyFromScreen at the client rect -> real frame.
  • The chase camera + EnsureValidProjection/opaque-pass LIGHTING=FALSE/CULLMODE=NONE are still the debug shortcuts from last pass; now that the env loads, the INI lights/fog/ambient could be honoured (restore lighting + correct cull winding) once verified against RP.
  • Fix the _LP BGF parse (bgfload.cpp) to add distant-LOD props; wire the dpl2d_ HUD.

Tier 2 — DRIVABLE mech (locomotion + camera follow) [this session]

WIN: the player mech walks under control and the chase camera follows. Verified with forced throttle (headless): position log Z 493.156 -> 160.363 (monotonic, ~30 u/s at full throttle), and the per-render [EYE] Execute entityL2W.t tracks the moving mech each frame (camera follows by construction). Two warm screenshots (run/walk_t1.png, run/walk_t2.png) show the mech centered while the background structures shift. Default (no-throttle) run is stable: mech spawns idle, no drift, no crash.

Root cause: mech rendered but never SIMULATED

The mech rendered every frame (EYE/DRAW) but its localToWorld was static. The reconstructed per-frame tick (Mech::Simulate via activePerformance, set from a Recon->null member-fn-ptr, and full of this+0xNN byte-offset-as-index bugs) is unsafe and was never reached. Two engine gates also block a fresh master entity from being executed:

  1. Entity::Execute only calls PerformAndWatch when the app is in RunningMission/EndingMission or the entity IsPreRunnable() (ENTITY.cpp ~549). The bring-up never reaches RunningMission.
  2. UpdateManager::Execute skips a master unless IsInteresting() (interestCount!=0) && IsNonReplicantExecutable() (UPDATE.cpp:148).

What was changed (4 files; engine untouched)

  • decomp/reconstructed/mech.hpp / mech4.cpp: added Mech::PerformAndWatch(const Time&, MemoryStream*) — an override of the engine virtual Simulation::PerformAndWatch (the director calls it every frame on every entity; stasis/subsystem/activePerformance machinery lives in the base Mover::PerformAndWatch, which we deliberately bypass). It reads the drive input, integrates the engine-base localOrigin (turn -> yaw about world +Y; forward = mech-local -Z from the rebuilt matrix; advance in the ground plane preserving Y) and rebuilds localToWorld — the matrix the render tree AND the chase camera (btl4vid.cpp) are bound to. Bypasses the unsafe Simulate/subsystem chain (that's a later step). Logs world pos at 1 Hz while moving. Tuning: 30 u/s full throttle, 1.2 rad/s full turn (hand-picked; the animation .ani [RootTranslation] clip-speed path is future work).
  • decomp/reconstructed/btstubs.cpp: Mech::Reset (spawn hook) now sets SetPreRunFlag() + forces interestCount = 1 so the update manager ticks the mech regardless of mission-run state. Added Mech::IsDisabled() stub (return False) — was declared but undefined; the new vtable pulls mech2/mech4 into the link which referenced it.
  • btbuild/btl4main.cpp: keyboard drive input wired in WndProc (WASD / arrow keys -> gBTDrive.throttle/turn). gBTDrive.forced is a DEBUG toggle (env BT_FORCE_THROTTLE, default OFF) used only for headless verification (full-ahead, no keypress).

Notes / caveats (flagged)

  • Single local-player assumption: heading is a file-static in mech4.cpp. AI/ multiplayer mechs would need per-entity state (out of scope; Phase 7/8).
  • Idle (no input) the mech re-asserts yaw=0 each frame, overriding any egg spawn facing (minor; documented). Position is preserved.
  • The bypass skips collision + the real animation-driven gait/SequenceController clips. Next: drive commandedSpeed through the controls-mapper subsystem and integrate the .ani [RootTranslation].z per-clip speed (the established animation-driven locomotion model) instead of a flat tuning constant.
  • Render is slow in the Debug build (~0.6 fps on the big desert scene); the simulation runs real-time (dt0.017). Capture gotcha unchanged: grab a WARM frame (>=8 s) — early frames are black (backbuffer not presented yet).

Wave: per-frame SUBSYSTEM SIMULATION TICK wired into Mech::PerformAndWatch

Goal: turn the drivable mech into a live combat harness by ticking the mech's subsystem roster every frame, WITHOUT calling the unsafe reconstructed Mech::Simulate (raw this+0xNN-as-index bugs, null activePerformance).

What was wired (how the tick reaches the subsystems)

Mirrored the shipped engine's Entity::PerformAndWatch roster loop (MUNGA/ENTITY.cpp ~698-790; RP's VTV ticks its roster the identical way). In Mech::PerformAndWatch (mech4.cpp), AFTER the existing drive/movement integration and BEFORE WriteSimulationUpdate, we now walk the inherited Entity roster subsystemArray[0..subsystemCount-1] and, for each non-null subsystem, call the engine virtual subsystem->PerformAndWatch(till, update_stream). That base (Simulation::PerformAndWatch, SIMULATE.cpp:448) computes the subsystem's OWN time slice from its own lastPerformance and dispatches activePerformance — the reconstructed <Class>::<Class>Simulation method each subsystem's ctor installed via SetPerformance (heat/gyro/sensor/power/weapons/ …). We deliberately do NOT call Mech::Simulate. Guards: null check + IsNonReplicantExecutable() (mirrors Entity).

Per-frame confirmation (verified live, headless, BT_FORCE_THROTTLE=1)

btl4.log over a 30 s run (cwd = pod REL410\BT, -egg TEST.EGG):

  • [tick] first frame: dispatched to 30 executable subsystem(s) of 30 present / roster 33 → the dispatch genuinely reaches all 30 real subsystems on frame 1.
  • [tick] subsystems simulated: 1 (executable) of 30 present / roster 33 (1 Hz, steady). After frame 1 the subsystems still on the engine default DoNothingOnce latch NeverExecute (DontExecuteFlag) on their first call and drop out of IsNonReplicantExecutable()faithful engine behavior. The persistent N=1 is the single subsystem with an always-executable real Performance. As the upcoming per-subsystem wave deepens each subsystem (its ctor calls SetPerformance(realMethod)), it automatically stays executable and the live count climbs — the tick path is already wired to carry it.
  • Driving NOT regressed: [drive] pos=... advances on Z at ~30 u/s·dt each frame.
  • Stability NOT regressed: full 30 s run, no fault (re-verified under cdb).

Roster shape (from the run)

roster 33 = slot 0 (controls mapper, skipped — see below) + slot 1 (resolved voltage bus) + 30 streamed subsystems + (slots that streamed NULL). 30 present, all ticked frame 1.

One subsystem we could NOT safely reach (flagged, skipped by design)

The controls-mapping subsystem (MechRIOMapper/MechControlsMapper, installed at roster slot 0 by Mech::SetMappingSubsystem, mirrored to the controlsMapper member). Initial wiring crashed here on the FIRST tick: MechControlsMapper::FillPilotArray (mechmppr.cpp:779) does pilotIDs[0] = *(int*)(local_pilot+0x1e0) where local_pilot is read through UNDOCUMENTED WinTesla Application byte offsets (application+0x6c → +0x190) that don't exist in the port → wild pointer → 0xC0000005 (its own null guards can't catch garbage-non-null). This is an engine gap, not a stub. Resolution: skip subsystem == controlsMapper in the tick loop. Justified — in bring-up the player drives the mech DIRECTLY via gBTDrive in this same override, so the input mapper is bypassed by design (the "bring-up RIO fallback" path). Reaching it safely = pod-bring-up / input-remap work (CLAUDE.md Phase 8). All OTHER subsystems tick normally.

Gait bonus (animation-driven speed): SKIPPED (flagged)

Not landed — not quick. The flat 30 u/s kDriveMaxSpeed slide remains. Wiring .ani [RootTranslation].z per-clip speed (as port/ viewer does: g_walkPos += dt*forwardSpeedAt(anim,t)) needs either (a) independently loading the mech's walk .ani and exposing its root-translation track, or (b) calling the reconstructed Mech::AdvanceBodyAnimation (which lives inside the bypassed, still-unsafe Simulate/IntegrateMotion chain). Both are a dedicated step, not a quick add. Deferred to the locomotion/gait wave.

Files changed (4; engine untouched)

  • decomp/reconstructed/mech4.cpp: added the subsystem-roster tick loop + #include <subsystm.hpp>; controls-mapper skip; 1 Hz + one-shot first-frame tick logs; new file-statics gTickLogAccum, gTickFirstLogged.
  • decomp/reconstructed/mech.hpp, mechsub.*, btstubs.cpp: not modified (the roster + controlsMapper member + IsNonReplicantExecutable were all already present/sufficient).

Build: bt410_l4 + btl4.exe green (only the pre-existing /FORCE:MULTIPLE LNK4006/4088 warnings). Engine lib untouched.

COMBAT WAVE — agent 4/7 (sensor, mechtech) — DONE, both compile-clean (exit 0)

Per-frame TICK behavior verified against binary oracle + surviving headers. Both subsystems install a real activePerformance in their ctor, so neither falls back to DoNothingOnce (they tick every frame, not just frame 1).

Sensor (classID 0xBC3, ctor @004b1d18, vtable @0050fb0c)

  • Behavior reconstructed: SensorSimulation(Scalar) @004b1c4c — faithful to oracle (part_013.c:1823). Runs PoweredSubsystem::PoweredSubsystemSimulation first, then derives the radar-effectiveness readout: radarPercent = 1.0 - heatMaster.heatEnergy; zeroed + selfTest cleared when thermal model off; badVoltage/blind when electrical state != Ready; heat-state alarm trims radar (Normal→ok, Degradation→×0.5, Failure→0). This IS the whole of the Sensor subsystem's per-frame work in the binary.
  • NOTE on "target sweep/acquisition/lock-on": confirmed NOT in this class. Oracle FUN_004b1c4c does only the radar-effectiveness model above; target acquisition/lock/range lives in the HUD/radar brick (other family), not the Sensor subsystem. Did not invent it.
  • SetPerformance wired: ctor calls SetPerformance(&Sensor::SensorSimulation) gated on (GetSegmentFlags() & 0xC) != 4 (damaged-copy segments don't tick) — matches oracle param_1[7]=PTR_FUN_0050fa94. Also sets has-performance instance flag bit 0x8.

MechTech (classID 0xBDC, ctor @004ad228, vtable @0050e3a0)

  • Behavior reconstructed: TechnicalAssistance(Scalar) @004ad33c — faithful to oracle (part_012.c:16435). Iterates subsystemMonitors; per monitor reads watched MechSubsystem::GetStatusFlags() (vt+0x30), loops the 7 TechStatusTypes, clears expired wooHoo latches, edge-detects each status bit, and posts cleared/set cockpit messages on change (set carries alarmModel). Monitor roster is built in the ctor over damageable segments (segment bit 0x8). Faithful to oracle ctor flag gate (entity+0x28 & 0xC) == 0.
  • SetPerformance wired: ctor calls SetPerformance(&MechTech::TechnicalAssistance) for live primaries; copies/templates flag dormant (this[10] |= 2) and do NOT install a Performance — matches oracle (MechTech sets no has-performance bit, just the perf pointer).

CROSS-FAMILY NEEDS (locally shimmed; real homes are shared bases — unchanged from Stage 1)

  • HeatSink (heat family): HeatStateLevel(), HeatModelOff(), heatEnergy accessor — Sensor backs these locally (heatStateLevel/heatModelOff/heatEnergy members) pending real inherited accessors. SensorSimulation reads them; base sim must write them once exposed.
  • PoweredSubsystem (power family): ElectricalStateLevel() — backed locally (electricalState).
  • Mech/Entity/Subsystem (mech family): GetSegmentFlags(), GetSubsystemCount(), GetSubsystem(i), MechSubsystem::damageZone/GetStatusFlags(), Entity::simulationFlags. Currently satisfied via the shared mechrecon.hpp compat shim + entity accessors.

Stubs (cross-family, revisit at integration — unchanged from Stage 1)

  • MechTech cockpit message dispatch: StatusMessageSink() returns 0, ReportStatusCleared/ReportStatusSet are no-ops. Real impl routes through *(theApplication+0x38) + HUD message builders (FUN_004366b8/00436688/004364e4) in the btl4gaug/app brick (not this family). Edge-detection + latch-clear in TechnicalAssistance are live; only the message emission is stubbed.
  • MechTech::GetTime() returns Now(); oracle compared the subsystem's cached current-time field (this+0x10). Behaviorally equivalent for latch expiry; revisit if a per-frame cached clock is wired.

From combat/energy-weapons (mechweap, emitter; +surviving PPC/GAUSS) — DONE, 0 errors

OWNS the energy/beam weapon family: MechWeapon (abstract base), Emitter (charge/discharge beam base), PPC + GaussRifle (surviving real source, : public Emitter). compile1.cmd mechweap.cpp + emitter.cpp both exit 0.

Per-weapon fire/heat behavior + SetPerformance wiring

  • MechWeapon (base) — abstract; FireWeapon() is the "should not be fired" assert stub (matches oracle @004b95ec / RP GenericGun). No own performance: ctor @004b99a8 only chains to PoweredSubsystem

    • installs vtable PTR_FUN_00511d2c (verified in oracle — does NOT write this[7..9]). Per-frame helpers live (UpdateTargeting heat-degrades effectiveRange = (1-heatLoad)*weaponRange; CheckFireEdge rising-edge on fireImpulse; recharge/heat-cost fields parsed from resource). Relies on a derived class to install the tick → correct, since only Emitter/ProjectileWeapon are instantiable.
  • Emitter (energy base) — FIXED the missing tick wiring. The ctor @004bb120 was mis-reconstructed as choosing a "fire message template" (SetFireMessage). The binary oracle proves this[7..9] is the Simulation::activePerformance member-pointer (same slot every sibling fills via SetPerformance) and the ctor installs ONE of two performances, resolved from section_dump:

    • default : member-ptr @00511e84 = 88 aa 4b 00 -> 0x004baa88 = EmitterSimulation
    • external model (flags&0xC==4): @00511e90 = d0 a8 4b 00 -> 0x004ba8d0 = ServiceDischarge Replaced the stub with SetPerformance(&Emitter::EmitterSimulation) / &Emitter::ServiceDischarge. Without this the chained PoweredSubsystem ctor's PoweredSubsystemSimulation would tick instead and the Loading->Loaded->Firing charge/fire machine would never run (would drop out via DoNothing-style after frame 1). EmitterSimulation (the full state machine), FireWeapon (slot 18, vtables.tsv 005120b8 col3 = 4bace8 — confirmed: E=½·currentLevel²·energyCoefficient split by damageFraction into damage + self-heat, beam build muzzle->target, pip), TrackSeekVoltage (charge integrate + I²R heat back into source), ServiceDischarge / ContinueDischarge / ResetFiringState / ComputeOutputVoltage / ReadyToDischarge all reconstructed and live. Removed the dead SetFireMessage stand-in + the two fire-template statics.
  • PPC (surviving source, unchanged) — ctor chains to Emitter ctor → inherits EmitterSimulation tick. FireWeapon() tail-calls Emitter::FireWeapon() (the post-return block is #if 0 dead code in the original). Fires + charges per frame via the inherited Emitter machine. No edit needed.

  • GaussRifle (surviving source, unchanged) — ctor chains to Emitter ctor → inherits EmitterSimulation tick. FireWeapon() override sets outputVoltage = 0.0f (drain). Declares GaussRifleSimulation + SetPerformance in GAUSS.HPP but the ctor never calls them → intentionally uses the inherited Emitter performance (faithful to shipped GAUSS.CPP). No edit needed.

CROSS-FAMILY NEEDS

  • messaging (messmgr/SubsystemMessageManager): Emitter beam emit + keepalive use Subsystem::SendMessage(template,a,b) (FUN_0041c350) with message templates PTR_LAB_00511e6c/e78 (beam-keepalive) and the fire message. ServiceDischarge/ContinueDischarge/FireWeapon currently stub the actual SendMessage call (templates retained as statics). Bind to the real message manager at integration.
  • heat/power (heat.hpp/powersub.hpp — NOT edited): Emitter charge + self-heat depend on the linked VoltageSource (Generator). FUN_00417ab4 (follow SharedData link to the voltage source) is stubbed → TrackSeekVoltage / ReadyToDischarge / ctor seekVoltage-scaling (×source->ratedVoltage) and energyCoefficient derivation need the real Generator link. FUN_004ad7d4 (HeatableSubsystem online?) stubbed to 1 and the self-heat dump (heatAccumulator += heatPortion) needs to feed the inherited HeatableSubsystem load owned by the heat family. EMITTER.TCP confirms intended coupling (Condenser/Generator/PowerBar feed the Emitter).
  • hud: DrawWeaponPip AddElement into the cockpit HUD manager is stubbed in mechweap.cpp (gated on pipSegment != -1) — wire to the HUD module's AddElement (FUN_0049b6d8).
  • FUN_004ad7d4, FUN_0049fb54, FUN_004b0bd0 (PoweredSubsystem step), FUN_004b0d50 (dt scale), FUN_004ac9c8, FUN_00417ab4, FUN_00421070, ResolveSource, VoltageCurve, CopyColor — local stand-ins.
  • Firing-physics tuning constants _DAT_004bafbc (kDamageScale=1.0f) and _DAT_004bb3b8 (folded into VoltageCurve()→1.0f) are double-precision operands the optimised image left non-finite in the dump → left symbolic; a human should re-derive from the FPU stream for exact damage/recharge tuning.

Agent 6/7 — damage / messaging (mechdmg, dmgtable, messmgr)

All three modules were already substantially reconstructed (prior pass); this pass DEEPENED the damage math against the binary oracle + the WinTesla engine DAMAGE.cpp/.h and re-verified each compiles clean single-file via compile1.cmd. mechdmg.cpp / dmgtable.cpp / messmgr.cpp → 0 errors.

Damage model reconstructed (armor -> structure -> crit)

  • Base armour model confirmed & documented (DamageZone::TakeDamage @0041e4e0, oracle part_002.c; identical to the WinTesla engine MUNGA/DAMAGE.cpp body we link against). There is no separate armour-then-structure pool: a single damageLevel [0,1] accumulates damageLevel += damage.damageAmount * damageScale[damage.damageType], is clamped to [0,1], sets the DamageLevelChanged flag (|=4), and at >= 1.0 marks the zone destroyed (BurningState, DestroyedGraphicState, flag |=8). Documented verbatim in Mech__DamageZone::TakeDamage.
  • Key correctness fix in the ctor: the 5 scalars at words 0x51..0x55 were mislabelled "per-facing armour". The oracle base TakeDamage indexes them this[0x51 + damage.damageType], so they are damageScale[Damage::DamageTypeCount==5] indexed by damage TYPE (Collision/Ballistic/Explosive/Laser/Energy), NOT by facing. Relabelled the field, loop var (facing->type, bound < Damage::DamageTypeCount) and comments. Normalisation math unchanged (1/(scale*count); leg zones ×0.5) — verified against oracle FUN_0049ce50.
  • Leg-failure threshold fix: the non-standing / still-grounded branch of Mech__DamageZone::TakeDamage compares structure against _DAT_0049c9a0 == 0.5 (partial leg-failure graphic: right->level 4, left->level 3), not StructureMax (1.0). Was using 1.0; now uses new LegHalfStructure = 0.5f. The standing/walking branch correctly keeps 1.0 -> level 9 (fall). Verified vs oracle FUN_0049c690.
  • Critical-hit roll verified (CriticalHit @0049ccc4): half the damage amount (×0.5, clamped to 1.0) flows through the normal TakeDamage, then a single uniform draw walks the cumulative criticalWeight list (roll <= cum/criticalWeightSum) to pick one MechCriticalSubsystem, applying ApplyDamage and accumulating damagePercentageUsed (capped at damagePercentage). Reconstruction matches oracle field offsets (+0x10 pct, +0x14 pctUsed, +0x18 weight) exactly.
  • DMGTABLE roll logic verified (dmgtable.cpp): three-level resolver DamageLookupTable(layers/penetration depth) -> PieSlice(angular sector, optionally torso-relative) -> DamageZonePercentTable(weighted zone list summing to 1.0). SelectZone() = single uniform draw against ascending cumulative %, returns first zone whose cumulative exceeds the draw. Constants confirmed (1.0 sum target, 2*PI wrap, 1e-4 sort epsilon). No change needed.

Message dispatch (SubsystemMessageManager) verified

  • AddDamageMessage (@0049b6d8) re-verified against oracle: first-of-frame wins for entityHit (+0xE4), impactPoint (+0xEC, gated on AlmostEqual-to-origin), damageZoneIndex (+0xE8); each message also queues a 0x14-byte DamageInformation Plug (damageType=msg+0x2C, subsystemID=msg+0x5C) into the damageInformation chain keyed by damage amount (msg+0x30). Field offsets match exactly.
  • ConsolidateAndSendDamage (Performance @0049b784): walks queued damage, resolves each damaged subsystem from the owner roster, queues a per-zone weapon-explosion ResourceID (dedup via chain Find), then flushes via SendQueuedExplosions (staggered 0.1s/explosion) and clears the common record. Logic intact.

CROSS-FAMILY NEEDS

  • renderer (btl4vid/L4D3D — NOT edited): messmgr::SubmitExplosion is stubbed. Original built an explosion render command (ClassID 0x5C sub-type 3, FUN_004336f0) and submitted it to the global IG graphics pipe (theGraphicsPipe->Submit, FUN_0041acbc). WinTesla replaced the IG pipe with L4D3D and the submission entry point is not in these headers — wire to the L4D3D explosion/effect submit at integration, using commonDamageInformation.impactPoint + rendererCompensateTime.
  • subsystem base (subsystm.hpp — NOT edited): messmgr::ResolveExplosionID should read the per-subsystem explosion id off the engine Subsystem (subsystem+0x3E4, Subsystem::GetExplosionID()). The WinTesla Subsystem doesn't expose it yet; currently returns the manager's own terrainHitExplosionID as a stand-in. Add the accessor to the base, then bind.
  • mech roster (mech.cpp / mech3.cpp — NOT edited): ConsolidateAndSendDamage relies on owner->GetSubsystem(id) (mech+0x128 roster). Confirm the reconstructed Mech exposes that accessor with the same id->slot indexing the message manager assumes.
  • damage notify message (net/entity — NOT edited): the consolidated TakeDamage/notify message build (ClassID 0x13, vtable @0050b984, target field +0x184) in ConsolidateAndSendDamage is left as a recovered note — the message ctor (FUN_00419e18) and target wiring are net/renderer-side and absent from headers.

Random / stubs remaining

  • RandomUnit() (mechrecon.hpp) returns 0 — the unit-random ([0,1)) used by the CriticalHit weighted roll, DamageZonePercentTable::SelectZone, LODDamageRouter hysteresis and DamageLookupTable degenerate-angle fallback. With it pinned at 0 every weighted roll selects the FIRST entry and LOD always re-uses the same child. Must be backed by the real engine RNG (FUN_00408050) before damage distribution is correct. Flagged here as the single highest-impact bring-up item for this family.
  • mechdmg local stand-ins (TheTime/TicksPerSecond clock, SegTable/SegmentIterator segment-tree walk, SubProxy2 subsystem facets, NoteX2/RFileX2 offline notation readers) and the dmgtable ReconTable/ ReconTableIter/notation proxies are behaviour-neutral compile shims — bind to the real engine clock / EntitySegment tree / Subsystem / NotationFile + TableOf at integration.
  • LODReuseHysteresis = 0.82f (_DAT_0049c514) is a TODO: the exact reuse-probability operand wasn't cleanly recoverable from the pseudo-C; behaves as a high "stay on the same spot" bias. Re-derive from the FPU constant stream for exact LOD damage clustering.

COMBAT WAVE — agent 1/7 (heat/power) [heat.cpp/.hpp, powersub.cpp/.hpp, gnrator.cpp/.hpp]

Status: all three files compile clean (compile1.cmd → 0 error C). The per-frame TICK is wired for every master subsystem in this family (SetPerformance installed in ctor; engine Simulation::PerformAndWatchPerform(this->*activePerformance) persists it, no auto-reset — verified against RP MUNGA/SIMULATE.HPP:117-182).

Per-class per-frame behavior + SetPerformance wiring

  • HeatSinkHeatSinkSimulation (@004ad924): heatEnergy += pendingHeat; currentTemp = heatEnergy/thermalMass; UpdateHeatLoad (radiatedHeat = currentTempcoolant, scaled+clamped+ 15-sample filtered); ConductHeat→ComputeHeatFlow (conductive exchange w/ linked sink via expf decay) + BalanceCoolant; UpdateCoolant (draw=master.heatEnergyheatLoad, top-up via DrawCoolant, draw state machine); degradation/failure alarm. WIRED: ctor installs SetPerformance(&HeatSink::HeatSinkSimulation) for master segments (flags 0x100, copy-bits 0). Faithful to FUN_004ad924/7f0/8ac/9ec/da94/dbf8 in heat_cluster.c.
  • PoweredSubsystemPoweredSubsystemSimulation (@004b0bd0): runs HeatSink sim, resolves voltage bus from powering Generator, advances electrical state machine (Starting/NoVoltage/Shorted/GeneratorOff/Ready) off generator state + startTimer, AutoConnect scan of GeneratorClassID segments. WIRED: ctor installs it for masters. Voltage state per frame is live.
  • GeneratorGeneratorSimulation (@004b1f7c): spin-up (startTimer→startTime), short recovery (shortTimer→shortRecoveryTime), idle/recovered transitions, outputVoltage = (1 - sourceLevel)*ratedVoltage, percentVoltageAvailable clamped [0,1]. WIRED: ctor installs it for masters. ALSO folded in the one recovered method the powersub cluster omitted: Generator::ResetToInitialState() (verbatim from GNRATOR.TCP — chains HeatableSubsystem::ResetToInitialState, outputVoltage=0), declared in powersub.hpp, defined in powersub.cpp.
  • Myomers / PowerWatcher (in powersub) — already wired (MyomersSimulation, PowerWatcher::Simulation) in their ctors; left as-is.
  • HeatableSubsystem (base) — owns the damageable-subsystem virtual surface; base edits kept additive (Generator::ResetToInitialState chains it). No regressions to derived families.
  • gnrator.cpp/.hpp — alias TUs (Generator lives in powersub); the recovered method they documented is now folded into powersub. Left as alias-only, no code emitted.

Constants RECOVERED from the oracle (section_dump.txt raw bytes; corrected placeholders)

  • _DAT_004ad870 HeatLoadScale: 0.002f (was 1.0f placeholder) — 80-bit extended 3b df 4f 8d 97 6e 12 83 f6 3f = 1.023992 × 2^-9. Real: normalises radiatedHeat (~300) into the [0,1] heatLoad band; the 1.0f placeholder would have pinned heatLoad at the 1.0 clamp.
  • _DAT_004ad87c=0.0f, _DAT_004ad880=1.0f, _DAT_004ada90=1.0f — confirmed correct.
  • _DAT_004adbf4 HeatEqualizeEpsilon & _DAT_004adcfc CoolantDrawEpsilon: 1.0e-4f (0x38d1b717) — was 0.0005f JM_CLOSE_ENOUGH placeholder. Now exact.
  • Power constants verified already-correct from bytes: VoltageEpsilon 1.0e-4f (@4b0bcc), RatedVoltageRef 1.0f (@4b1d10), DegradedDriveScale 0.5f (@4b1d14), MinOutputVoltage 0.0f (@4b2158), MaxOutputVoltage 1.0f (@4b2154/4b2258).

CROSS-FAMILY NEEDS

  • BT_GetMessageManager(Mech*), BT_IsBusLive(Mech*), BT_ClearMyomers(Mech*) (declared in powersub.hpp, definitions belong in the mech family) — used by PoweredSubsystem/Generator/Myomers message handlers. Mech family must provide these (owner+0x190 message manager, +0x274 bus-live flag, +0x374 myomer back-ptr).
  • Mech::GetSegment(int) / EntitySegment segment roster — relied on for voltage/heat-sink linkage resolution and the AutoConnect generator scan. Owned by mech family.

Remaining stubs / not deepened (out of my 3 DEEPEN targets)

  • Condenser (RefrigerationSimulation @4ae4d8) — body lives in heatfamily_reslice.cpp (not my file). Condenser currently inherits HeatSink ctor → ticks via HeatSinkSimulation (live, not DoNothingOnce), but does NOT install RefrigerationSimulation. Deferred to whoever owns heatfamily_reslice.cpp; flagged so refrigeration output isn't yet per-frame.
  • PowerWatcher MinVoltageScale (_DAT_004b1924) left at 1.0f — appears to be a double in the dump and is non-load-bearing (PowerWatcher only delegates per frame); not re-derived.
  • MyomersSimulation SourceLevel/voltage-curve details depend on cross-family Mech back-ptrs above.

COMBAT WAVE — agent 5/7: movement subsystems (gyro / torso / myomers)

Goal: real per-frame behavior + SetPerformance(&<Class>::<Class>Simulation) so each subsystem ticks through Mech::PerformAndWatch → subsystem->PerformAndWatch → Simulation::PerformAndWatch → activePerformance (instead of the default DoNothingOnce that drops out after frame 1).

ROOT-CAUSE FOUND (all three were silently NOT ticking as the master)

The binary registers the per-tick Performance in each ctor by storing the pointer-to-member into activePerformance (this[7..9]) under the live-MASTER guard (segFlags & 0xC)==0 && (segFlags & 0x100). Proven identical shape in all three ctors:

  • Gyro @004b3778: this[7..9] = PTR_FUN_0050fe08 (GyroscopeSimulation), guard bit 0x100.
  • Torso @004b6b0c: master → PTR_FUN_00510c10 (TorsoSimulation), else → PTR_FUN_00510c1c (TorsoCopySimulation); this[0x93]=isDamagedCopy.
  • Myomers @004b8fec: this[7..9] = PTR_LAB_00511620 (MyomersSimulation), guard bit 0x100. The earlier reconstructions (a) tested the wrong bit (SegmentLiveFlag 0x01 instead of MasterHeatSinkFlag 0x100) AND (b) primed the local segmentFlags shim to 0 → guard always false. Net effect before this pass: Gyro never registered its sim; Torso registered the slaved COPY sim (no command processing); Myomers mislabeled the store a "graphic-state hook" and omitted registration entirely. All three would have fallen back to DoNothingOnce.

Gyroscope (gyro.cpp/.hpp) — balance / tip-over

  • Behavior: per-frame GyroscopeSimulation verified faithful to @004b275c — PowerWatcher watch update, idle-sway ramp toward (impaired? percentageOnDestruction : percentageOnNormal)+swayBias at rotationPerSecond·dt with overshoot clamp + [min,max] noise band, then IntegrateEyeJoint (@004b2ec0) + IntegrateBody (@004b30ec) spring-dampers. (Sim integrates only; the binary does NOT write the joints in-sim — WriteEyeJoint/WriteMechJoint are driven from the render/animate pass.)
  • SetPerformance wired: guard fixed to MasterHeatSinkFlag (0x100); segmentFlags primed to live- master so GyroscopeSimulation registers. Compile-clean (exit 0).

Torso (torso.cpp/.hpp) — twist (yaw) + elevation (pitch) aim

  • Behavior: TorsoSimulation verified faithful to @004b5cf0 — watch update; effective rates gated by power/heat (Degradation halves, Failure/not-Ready/HeatModelOff zero); button-accel ramp; per-axis command integration (rotate L/R, elevate U/D) clamped to software limits; center/recenter; velocity
    • settle/dirty-bit; statusFlags limit latches. TorsoCopySimulation (@004b65f8) retained for the copy path.
  • SetPerformance wired: guard fixed to MasterHeatSinkFlag (0x100); segmentFlags primed to master so the command-processing TorsoSimulation registers (was registering TorsoCopySimulation). Compile-clean (exit 0).

Myomers (myomers.cpp/.hpp, classID 0xBC6) — artificial-muscle drive/actuation

  • Behavior: MyomersSimulation verified faithful to @004b8d18 — advanced-damage gate (FUN_004ad7d4), then `pendingHeat(@0x1C8) += ratio² · (1+damage) · [(1-accelEff)·|vy|·k·m·dt + (1-velEff)·(k·|v|²·½)
    • (1-accelEff)·|v|·|a|·k·dt]` (k=motionGain@0x20c, m=*mass@0x250). Drive helpers AvailableOutput/ RegisterMaxOutput/GetSpeedReading/HasAdequateVoltage already faithful.
  • SetPerformance wired: ADDED the missing registration in the ctor (added a local segmentFlags Word shim + SegmentCopyMask/MasterHeatSinkFlag enum to myomers.hpp), primed to live-master, guard (flags&0xC)==0 && (flags&0x100) per @004b8fec, → SetPerformance(&Myomers::MyomersSimulation). Compile-clean (exit 0).

CROSS-FAMILY NEEDS (movement subsystems)

  • Real segment flags (owner+0x28, read by all three ctors): the live-master vs damaged-copy decision and thus which Performance is installed must come from the owning Mech segment, not the locally-primed segmentFlags shim. Mech family must set these on the subsystem/segment before construction (master = 0x100 set, copy-mask 0xC clear). Until then all three are primed to the master value so the master sims tick.
  • Gyro skeleton-joint I/O: eyeJointNode/mechJointNode resolution + the node get/set-transform API (FUN_0041cfa0/0041d020/0041d0a8/0041d11c) and Mech::ResolveJoint(name) are stubbed (NodeType/ NodeScalar/SetNode*). WriteEyeJoint/WriteMechJoint integrate values are inert until the Skeleton/JointedMover node API is reachable from the subsystem.
  • Torso: Mech::ResolveJoint, skeleton-node scalar writer (PushTwist), Simulation dirty/replicate bit (SetMovedFlag), and PowerWatcher base virtuals (WatcherResetToInitialState/ WatcherHandleDeathMessage/WatcherUpdateWatch, FUN_004b1804/179c/181c) are stubbed — owned by Mech / Simulation / PowerWatcher families.
  • Myomers: owner-Mech accessors (base/top speed @0x34C/0x7A0, velocity/drive vectors @0x1C4/0x82C, motionGain @0x20C, mass @0x250, advanced-damage gate FUN_004ad7d4), JointedMover roster Attach/Detach/Release (mover vtable +0x38/+0x3C/+0x44, fed &speedEffect@0x31C), and this segment's DamageZone structureLevel @0x158 are all backed by neutral local shims. The drive/heat formulas are intact but read placeholder zeros until the Mech/JointedMover/DamageZone accessors land.

Stubs / not deepened

  • Gyro UpdateAnimationNoise (@004b357c) procedural-noise generator: structure recovered, node injection bodies left as branch stubs (best-effort; not on the per-frame tick path).
  • Gyro WriteEyeJoint/WriteMechJoint quantise-write logic faithful but inert pending the skeleton-node API (above).
  • Myomers ToggleSeekVoltage (@004b8a48) command handler: not in the captured shards (untagged gap); cycles currentSeekVoltageIndex within [min,max] — left as a documented stub.

COMBAT WAVE — agent 3/7: projectile / missile weapons

Edited only the 7 assigned module pairs. All 7 compile clean (compile1.cmd, EXIT=0). The per-frame TICK is now wired end-to-end for the launcher and the flight entity.

Per class

  • ProjectileWeapon (projweap.cpp/.hpp) — behavior DEEPENED + tick wiring FIXED.

    • Bug fix (matters for the live tick): the ctor's Performance-install gate read subsystem_resource->subsystemFlags; the oracle (FUN_004bc3fc @8375) reads the owner's flags *(param_2+0x28) = owner->simulationFlags (&0xC==0 && &0x100) — the same authoritative-mech gate the working AmmoBin uses. Corrected so launchers actually arm their Performance on the bring-up mech instead of dropping out as DoNothingOnce.
    • ProjectileWeaponSimulation (was an empty stub; body genuinely unrecovered @004bbd04) reconstructed from the recovered helpers (DrawFiringCharge @004bbc78, UpdateEject @004bbb50, CheckForJam @004bbfcc, ComputeTimeOfFlight @004bc06c) + the RP analog RivetGun::RivetGunSimulation (WEAPSYS.cpp): recharge recoil, advance eject, edge-trigger via CheckFireEdge(), a Jammed/NoAmmo/ready state machine, jam roll, then FireWeapon() through the vtable.
    • FireWeapon (slot 18, stub @004bc104) reconstructed: ConsumeRound() (→ AmmoBin::FeedAmmo @004bd4f4), start recoil cooldown, GetMuzzlePoint + Projectile::New. New shared helpers ConsumeRound() / ReadyToFire().
    • SetPerformance(&ProjectileWeapon::ProjectileWeaponSimulation) — wired (ctor).
  • MissileLauncher (mislanch.cpp/.hpp) — FireWeapon (override @004bcc60) reconstructed: ConsumeRound(), recoil cooldown, then spawn missileCount guided Missile::New entities per salvo (damage already pre-split per-missile in the ctor). Ticks via the inherited ProjectileWeaponSimulation; its vtable FireWeapon override is dispatched from there.

  • Missile (missile.cpp/.hpp) — flight-entity tick wiring ADDED.

    • SetPerformance(&Missile::MoveAndCollide) installed in the ctor (was MISSING — the Projectile base ctor only installed the unguided base pass, so a spawned missile would not have homed). This is the "flight entity simulates" wiring.
    • MoveAndCollide now explicitly drives its hosted subsystems each frame (seeker->FindTarget(dt), thruster->MissileThrusterSimulation(dt)) — the behavior the shipped binary folds inline — before the steering/integrate/ collide pass.
  • Seeker (seeker.cpp/.hpp) — LeadTarget/FindTarget homing already reconstructed; now actually RUN each frame from Missile::MoveAndCollide. Faithful to the binary, the Seeker installs no custom Performance (its vtable is all engine slots; its work is folded into the host missile), so it is driven by the missile rather than a standalone SetPerformance. No change needed.

  • Projectile (projtile.cpp/.hpp) — base integrate/collide pass already present; SetPerformance(&Projectile::MoveAndCollide) already installed by the ctor. No change.

  • MissileThruster (misthrst.cpp/.hpp) — burn/steer sim already present; SetPerformance(&MissileThruster::MissileThrusterSimulation) already installed by the ctor, and now also invoked per frame by the host missile. No change.

  • AmmoBin (ammobin.cpp/.hpp) — feed (FeedAmmo @004bd4f4), DumpAmmo @004bd588 and CookOff @004bd300 already reconstructed; AmmoBinSimulation Performance already installed by the ctor under the correct owner->simulationFlags gate. Verified the feed/dump identities against the oracle (FeedAmmo dispenses one round Loaded→Feeding; DumpAmmo empties the bin). No change.

CROSS-FAMILY NEEDS

  • MechWeapon base (agent 2): the fire/sim path relies on recoil, rechargeRate, weaponAlarm (SetLevel/GetState), CheckFireEdge(), UpdateTargeting(), GetMuzzlePoint(), GetTargetPosition(), damageData, effectiveRange, rangeToTarget. All present in mechweap.hpp today; if any are renamed/retyped, my two FireWeapon paths + ProjectileWeaponSimulation need a matching touch-up.
  • Mech (mech family): the AmmoBin roster lookup (OwnerSubsystemCount/ OwnerSubsystem) and the cockpit "live-fire" flag (LiveFireEnabled) are still isolated stubs in projweap.cpp — the real Mech subsystem-roster + controls query must wire them so the launcher resolves its bin and the jam roll honors the live-fire control.
  • Entity / world (engine): Projectile/Missile world-segment registration, motion integration (IntegrateMotion/AdvanceModel), CollisionQuery (FUN_0042291c) and detonation spawn (FUN_004be078, ClassID 0x5C) remain structural stubs in projtile.cpp/missile.cpp — engine-side, not weapon logic. Spawned entities also need the entity-manager registration that ticks their installed Performance (engine-side).

Stubs still standing (flagged, not blocking the tick)

  • projweap.cpp: GetStatusFlags (slot 12 @004bbf88) and UpdateWeaponState (slot 16 @004bbc20) — bodies genuinely unrecovered; left deferring to base / benign. OwnerSubsystem* / LiveFireEnabled cross-family stubs (above).
  • missile.cpp: GetForward, ResolveTargetConnection, LaunchAimOffset, ResolveModel, SpawnDetonation — engine/model plumbing stubs.
  • projtile.cpp: InitSegmentLink/ResolveWorld/IntegrateMotion/ CollisionQuery/Retire etc. — Entity-engine stubs.

WAVE agent 7/7 — HUD / 2D cockpit layer (dpl2d_ over D3D9 + heat gauge feed)

What was done

  1. dpl2d_ 2D vector display-list layer reimplemented over Direct3D 9. Moved the 9 inert dpl2d_* stubs OUT of btstubs.cpp into a NEW file decomp/reconstructed/dpl2d.cpp (+ dpl2d.hpp), added to the bt410_l4 CMake source list. The recorder calls (dpl2d_NewDisplayList/Begin/SetColor/ Circle/PushMatrix/PopMatrix/MoveTo/End/Compile — prototypes in btl4vid.hpp) now build a real in-memory command list (colour, filled disc, outline ring, translation-matrix stack + pen). A real dpl2d_ExecuteList(list, device) executor rasterises a compiled list as pre-transformed 2D primitives (D3DFVF_XYZRHW|D3DFVF_DIFFUSE, mirroring engine L4VERTEX_2D/_2D_FVF): filled circle = D3DPT_TRIANGLEFAN, outline = D3DPT_LINESTRIP, 32-seg tessellation, alpha-blend on, z/lighting/fog off, full render-state save/restore, normalised-[0,1]→viewport-pixel mapping. This matches exactly what the reconstructed reticle/PIP builder in btl4vid.cpp (lines ~563-616) issues (dpl2d_Circle(list,x,y,0.012f,1) filled blip + 0.014f,0 dark ring).

  2. Heat gauge wired to real subsystem state. Implemented HeatConnection::Update() (the per-frame data feed, orig "Transfer" @004c3720) in btl4gaug.cpp. It reads the live HeatableSubsystem::currentTemperature (@0x114) of the gauge's bound heat subsystem and writes the colour index that ColorMapper::Execute pushes into the hardware palette slot (no subsystem / destroyed → 100/full tint, else Round(currentTemperature)). Update() is the real base virtual (GaugeConnection::Update, gauge.h) the gauge framework calls each frame — so the cockpit heat tint art is now driven by actual heat state.

2D primitives implemented

Coloured filled circle, coloured outline circle, translation matrix stack (Push/Pop), pen move — over D3D9 via DrawPrimitiveUP with pre-transformed verts. (The port's dpl2d_ API surface is the small reticle/PIP subset; the full libDPL board API — polylines/polygons/clip regions/AddSetMatrix etc. — is NOT used by the reconstructed code and was not reimplemented.)

Gauges wired to real state

  • ColorMapperHeatHeatableSubsystem::currentTemperature (DONE, live). The ColorMapper draw path itself uses the engine's real L4 gauge raster layer (Video16BitBuffered/SVGA16/L4GraphicsPort in MUNGA_L4/L4VB16.cpp), which is already fully implemented over D3D9 in WinTesla (rasters to a PixelMap16 then uploads changed lines to a D3D texture) — so this path genuinely draws.

Compile-clean status

All 7 owned files compile clean (exit 0) via compile1.cmd: hud.cpp btl4gaug.cpp btl4gau2.cpp btl4gau3.cpp btl4galm.cpp btstubs.cpp dpl2d.cpp.

Honest "draws vs compiles" assessment

  • Gauge raster path: REAL (engine L4VB16 D3D path is implemented). The heat gauge's data feed is now live and correct.
  • dpl2d_ reticle: records + has a real D3D9 executor, but NOT yet on-screen. No caller invokes dpl2d_ExecuteList yet — BTReticleRenderable::Execute() (btstubs.cpp) is still empty and btl4vid.cpp builds the PIP lists but never executes them. Hooking execution into the per-frame world render is the last mile and lives in btl4vid.cpp / the reticle renderable (NOT my files).
  • ColorMapperHeat::Make() uses placeholder .data resource constants (empty palette names, port 0, colour slot) — the gauge-config resource pool (l4gauge.cfg / DAT_005150xx) was not recovered from the optimised image, so the heat gauge constructs degenerately at runtime until those constants are supplied. Data FEED is correct; the gauge's art binding is the gap.
  • Other gauge widgets in btl4gaug/gau2/gau3 (bars, OneOfSeveral, HeadingPointer, arcs) remain comment-summaries (no code bodies); they only fail at link if the config references them (currently not constructed).
  • btl4galm.cpp (alarm/lamp stream virtuals) is serialization glue — no live gauge to wire; left as the existing best-effort reconstruction.
  • hud.cpp is the HUD subsystem (torso-horizon gimbal logic), not a 2D drawing surface — left as-is (already reconstructed).

CROSS-FAMILY NEEDS

  1. Reticle execution hookup (btl4vid.cpp / BTReticleRenderable — NOT my files): wire dpl2d_ExecuteList(pipDisplayListA[i]/B[i], renderer->GetDevice()) into the world-view per-frame render (after the 3D scene) so the recorded reticle/ PIP display lists actually draw. DPLRenderer::GetDevice() (L4VIDEO.h) returns the main-view device; dpl2d_ExecuteList is declared in decomp/reconstructed/ dpl2d.hpp.
  2. Gauge-config resource pool (l4gauge.cfg / the DAT_005150xx constants for ColorMapperHeat::Make): needed so the heat gauge binds real palette names / port / colour slot. Likely recoverable from the pod content tree or a deeper .data slice (binary oracle). Owner: whoever owns gauge-config / content.
  3. Heat temperature→percent scaling (flagged in btl4gaug.cpp): the recovered feed rounds currentTemperature (seeded 300.0) straight into a 0..255 index; the exact ramp scaling should be reconciled against the original palette ramp. Touches heat subsystem semantics (heat.cpp owned by another agent) — read-only here; no edit made to heat.* .

INTEGRATION PASS (post-7-agent wave) — subsystem tick reconcile

TL;DR

  • Tasks 1/2/3 landed; build GREEN, stable (3+ clean runs), driving intact.
  • Tick count stayed 1 -> 1. Root cause is NOT the gate-source variance the brief described. The player mech's subsystem roster is built from local RECON_SUBSYS stub classes (mech.cpp:145-181), not the reconstructed real classes. The real classes (heat.cpp::HeatSink, gyro.cpp::Gyroscope, powersub.cpp::Generator/ PoweredSubsystem, sensor.cpp::Sensor, ...) are compiled but never instantiated, so their (now-reconciled) gates never run.

What actually gates the tick (empirically verified, not theory)

The base Simulation ctor installs activePerformance = &DoNothingOnce (MUNGA/SIMULATE.cpp:352). DoNothingOnce runs once then calls NeverExecute() (sets DontExecuteFlag 0x2), so a subsystem that never SetPerformance-s a real sim drops out after frame 1. Frame-1 log = 30 executable, steady-state = 1: i.e. 29 subsystems entered with DoNothingOnce and self-disabled.

A one-time per-slot roster dump (temp diag, since removed) showed the lone survivor is slot 18 'HUD' = the real MechTech (mech.cpp:643), whose gate (entity->simulationFlags and 0xC)==0 reads the OWNER's flags and passes. Every other slot (HeatSink, Reservoir, Condenser1-6, GeneratorA-D, Avionics, Myomers, Gyroscope, Torso, Searchlight, ThermalSight, PPC_1/2, ERMLaser_1-3, AmmoBinSRM6, SRM6, MessageManager) is a RECON_SUBSYS/local-struct stub -> DoNothingOnce -> exec=0. (The brief's claim that AmmoBin is the survivor is incorrect; AmmoBin isn't instantiated as the real class -- the 'AmmoBinSRM6_*' slots are stubs.)

Binary-oracle finding on the gate (Task 1, done correctly)

decomp/recovered/all/part_.c shows the canonical subsystem master-gate ~17x: ((uint*)(param_2+0x28) and 0xc)==0 && ((uint)(param_2+0x28) and 0x100)!=0 where param_2 is the owner (Mech*), so owner->simulationFlags (owner+0x28) is the authoritative source -- matching the working AmmoBin/projweap path. The reconstruction had drifted to per-file sources (subsystem_resource->subsystemFlags [streams 0], GetSegmentFlags() shim, primed-local segmentFlags, entity->simulationFlags without 0x100). Aligned all real-class SetPerformance master gates to owner->simulationFlags: heat.cpp (HeatSink), powersub.cpp (PoweredSubsystem, Generator, PowerWatcher), gyro.cpp, torso.cpp, myomers.cpp, sensor.cpp. (mechtech.cpp already reads entity->simulationFlags = owner -> left as-is; it's the one that works.) Heat/power linked-sink/voltage-source logic left on per-segment flags (that linkage is genuinely per-segment, and leaving it keeps the master-missing Verify() paths dormant). These edits are correct and oracle-matched but LATENT -- they only take effect once the real classes are instantiated (see blocker below).

Task 2 -- mech-core accessors (btstubs.cpp)

The reconstructed Mech is NOT byte-exact (named members + a Wword scratch bank), so the shipped raw offsets (+0x190 msg-mgr, *(+0x190)+0x274 bus, +0x374 myomers) do NOT address the right members; a raw read would deref garbage. Done against intent, deref-safe:

  • BT_IsBusLive -> True (running player mech is a powered master; un-gates the power-dependent paths in powersub when wired). REAL change (was False).
  • BT_GetSegmentFlags(Mech*) -> defined = owner->simulationFlags. This symbol was declared in hud.cpp but had NO definition -- it only linked via /FORCE (undefined return if ever called). Now resolves the dangling extern and feeds the HUD/ segment gate the same authoritative source. REAL fix.
  • BT_GetMessageManager -> still 0 (the real MessageManager is a roster slot but can't be located without the byte-exact layout; a bogus non-null would be worse -- callers deref). BT_ClearMyomers -> no-op (no known-offset member). Documented.
  • Mech::GetSegmentFlags() / EntitySegment roster (owner+0x28) / GetExplosionID (+0x3E4): NOT implemented -- no safe byte-exact source in the reconstructed Mech; latent like the rest. Flagged.

Task 3 -- RandomUnit backed by real RNG (DONE, verified)

mechrecon.hpp: RandomUnit() was return 0 (pinned every damage/crit/zone roll to entry 0). Now #include <RANDOM.hpp> (fwd -> engine RANDOM.h) and returns (Scalar)Random -- RandomGenerator::operator Scalar() in [0,1), matching FUN_00408050. Variadic tail + Scalar return keep dmgtable call sites unchanged. Verified with a temp 1 Hz log (since removed): printed varying triples e.g. 0.00662 0.1477 0.2346 / 0.2919 0.1782 0.3686 / 0.0590 0.0847 0.4086 across runs. This genuinely de-pins damage/crit/zone selection -- but its consumers (dmgtable via weapon impacts) are exercised only when the stub weapons fire, so the combat effect is latent until the weapon roster is real.

THE REAL BLOCKER to N->~30 (honest)

Replacing the RECON_SUBSYS stubs (mech.cpp:145-181 + the switch @550-672) with the real reconstructed classes is the genuine remaining work. It was NOT done in this pass because it cannot be landed stably piecemeal:

  1. Two parallel ClassID schemes. The streamed segments use the switch's 0xBBD-0xBDE IDs; the real classes stamp a different family (heat.cpp HeatSinkClassID, heatfamily Condenser/Reservoir/HeatWatcherClassID, powersub, ...). The class<-> resource mapping must be reconciled first or a class gets constructed over a resource blob meant for another (garbage fields).
  2. Unverified per-class alloc sizes. The switch hand-codes Memory::Allocate sizes per class; the real objects are larger (e.g. HeatSink/AmmoBin reach >=0x224). Under-allocation -> heap corruption (this family already caused the "~1-in-6 startup crash", mechsub.cpp:165 note).
  3. Real ctors Verify()-assert on segment links. HeatSink @004adda0 asserts "Bad subsystem resource ->heatSink" if owner->GetSegment(heatSinkIndex) is null; PoweredSubsystem @004b0f74 asserts on GetSegment(voltageSourceIndex). These depend on the streamed indices landing at the right resource offsets in our (approximate, +0x100-padded) stream.
  4. Interdependence. Even a single real class wired alone won't show useful computation: HeatSink's HeatSinkSimulation does currentTemperature = heatEnergy/thermalMass but pendingHeat stays 0 unless the heat sources (Generator/weapons -- all stubs) feed it. The roster must be made real together to compute real combat state.

Ordered safe path (next pass): (a) reconcile the streamed ClassID -> real-class table; (b) recover each real class's exact object size from the binary and size the allocations; (c) confirm owner->GetSegment(index) returns valid skeleton segments for the streamed indices (it's the real engine JointedMover::GetSegment(int), so likely yes) and that resource fields sit at the expected offsets; (d) wire ONE family at a time (heat first -- it's the pilot), build+run, watch for the asserts, keep only if green+stable; (e) repeat. The gate alignment (Task 1) and accessors (Task 2) above are the prerequisites already in place, so each newly-real class will tick the moment it's instantiated.

What genuinely runs today (no overclaim)

  • Driving: real ([drive] pos advances every second; stable).
  • 1 real subsystem ticks: MechTech::TechnicalAssistance every frame (the [tick] ... 1 (executable) of 30). The other 29 are stubs decaying to DoNothingOnce -- they tick frame 1 only, then opt out (by design of the stub).
  • RNG: real and verified (Task 3). Bus-live / segment-flags accessors: real values now, but their consumers are stubs -> latent.
  • Stability: 3+ headless runs to timeout, no asserts, no faults, gflags PageHeap OFF.
  • Build: GREEN (bt410_l4.lib + btl4.exe), links under the pre-existing /FORCE (now with one fewer unresolved extern -- BT_GetSegmentFlags resolved).

============ HEAT-FAMILY PILOT (instantiate real heat classes) — 2026-06-30 ============

GOAL: make the mech construct the REAL heat-family subsystem classes (heat.cpp/heat.hpp/ heatfamily_reslice: HeatSink/HeatableSubsystem/Condenser/Reservoir/HeatWatcher) instead of the inert RECON_SUBSYS stubs, proving the instantiation+tick pattern for the other families. RESULT: REVERTED to known-good (all stubs). A real Condenser DOES instantiate and its per-frame Performance DOES tick (the gate/mechanism works) — but it then crashes in the heat SIMULATION on a missing inter-sink linkage that cannot be fixed from the allowed files. Build is back GREEN, 3/3 runs stable, tick back to 1/30, driving intact. This is the conservative outcome the brief sanctions ("if a class can't be made stable, keep the stub, document, report").

#1 finding — the factory ClassID enum NAMES (mech.hpp:253-273) are MISLABELED.

The streamed ClassID VALUES are right, but the human-readable enum names + the class each switch case constructs are guesses that DISAGREE with the authoritative CLASSMAP.md. Cross-referencing the ctor address each case cites against CLASSMAP gives the TRUE class. Authoritative map (streamed ID -> ctor -> REAL class // current switch label): 0xBBD @4ae568 -> Condenser // labelled CockpitClassID (builds stub Cockpit) 0xBBE @4ae8d0 -> HeatSink bank (agg) // labelled SensorClassID (builds stub Sensor; ALSO // wrongly sets sensorSubsystem — pre-existing mislabel) 0xBC0 @4af408 -> Reservoir // labelled CondenserClassID (builds stub Condenser) 0xBC1 @4b225c -> Generator // GeneratorClassID (correct) 0xBC2 @4b0f74 -> PoweredSubsystem // correct 0xBC3 @4b1d18 -> Sensor // labelled MyomersClassID 0xBC4 @4b3778 -> Gyroscope // correct 0xBC5 @4b6b0c -> Torso // labelled SinkSourceClassID 0xBC6 @4b8fec -> Myomers // labelled ActuatorClassID 0xBC8 @4bb120 -> Emitter // WeaponEmitterClassID (correct) 0xBCB @4bd5c4 -> AmmoBin // labelled JumpJetClassID 0xBCD @4bc3fc -> ProjectileWeapon // MechWeaponClassID (correct family) 0xBD0 @4bcff0 -> MissileLauncher // labelled BallisticWeaponClassID 0xBD3 @49bca4 -> SubsystemMessageManager // labelled MechControlsMapperID 0xBD4 @4bb888 -> PPC // labelled GaussWeaponClassID 0xBD6 @4b7f94 -> HUD // labelled MechTechClassID — and the case builds the REAL // MechTech class over it (size 0x2a4 == HUD size, so safe); // THIS is the lone steady-state tick survivor. 0xBD8 @4b84dc -> Searchlight // labelled LegSubsystemClassID 0xBDC @4ad228 -> MechTech // labelled HeatableClassID (builds stub HeatableSubsystem, // and that case does NOT even assign subsystemArray[id]) 0xBDE @4b8718 -> ThermalSight // labelled DisplayClassID ACTION for the next pass: re-label this enum + re-target every switch case to the CLASSMAP class BEFORE wiring any real class — a class built over the wrong streamed blob = garbage fields. (I did NOT renumber the enum this pass to avoid churn/regression; documented here instead.)

Heat-family streamed in TEST.EGG (from the roster dump): Condenser x6 (0xBBD), Reservoir x1

(0xBC0), "HeatSink" x1 (0xBBE aggregate). The 0xBBE aggregate has NO reconstructed class (only prose in heatfamily_reslice.hpp), so it is not instantiable. That leaves Condenser and Reservoir.

Assert/crash analysis (heat.cpp, read-only):

  • Condenser is the only ctor-safe heat class. HeatSink ctor @004adda0 (heat.cpp:415-428) resolves a linked sink via owner->GetSegment(heatSinkIndex) and Verify-aborts ("Bad subsystem resource ->heatSink") if null — BUT it SKIPS that whole block when IsDerivedFrom(Condenser) (true during a Condenser ctor iff shared_data == Condenser::DefaultData). So Condenser never hits the segment assert.
  • Reservoir runs the segment block (it is HeatSink-derived, not Condenser) -> Verify-abort risk if heatSinkIndex doesn't resolve; PLUS its own master branch (heatfamily_reslice.cpp:446-448) does link->Attach(this) with no null guard; PLUS btl4vid MakeMechRenderables walks the roster touching ClassID 0xBC0 (Reservoir) at BINARY offsets (reconstructed layout != binary) — three independent risks. Not attempted past the Condenser result.

What I actually did + the debugger-confirmed blocker:

Wired the REAL Condenser into case 0xBBD: new (Memory::Allocate(sizeof(Condenser))) Condenser(this, id,(Condenser::SubsystemResource*)seg, Condenser::DefaultData) (used C++ sizeof(Condenser) for the alloc — the reconstructed object size, guaranteed to cover every member the ctor writes; NO binary hand-size, NO under-alloc). Added #include <heat.hpp>; renamed the two colliding local stubs (Condenser->ReservoirStub, HeatableSubsystem->HeatableStub) so heat.hpp's real names win.

  • Build: GREEN. Construction: SUCCEEDED — all 6 Condensers built, mech spawned (no ctor assert; the Condenser skip-path held).
  • The master gate DID arm (owner->simulationFlags has &0xC==0 AND &0x100 — the Task-1 gate reconcile is correct): the Condenser installed HeatSink::HeatSinkSimulation and it TICKED frame 1.
  • CRASH (0xC0000005) on frame 1, cdb stack (PageHeap off): btl4!HeatSink::UpdateCoolant+0x35 heat.cpp:693 btl4!HeatSink::HeatSinkSimulation+0xa4 heat.cpp:537<-531 btl4!Simulation::Perform / PerformAndWatch btl4!Mech::PerformAndWatch mech4.cpp:554 ROOT: heat.cpp:692-693 `HeatSink *master = linkedSinks.Resolve(); coolantDraw = master->heatEnergy
    • heatLoad;` — linkedSinks is EMPTY for a Condenser (its HeatSink ctor skipped the linkage block), so master==NULL and master->heatEnergy derefs null. NB the sibling methods ConductHeat (heat.cpp:595) and BalanceCoolant (heat.cpp:646) DO null-guard linkedSinks.Resolve(); UpdateCoolant does NOT — a reconstruction omission (the shipped binary @4adbf8 must guard, since real condensers ran). Even the "correct" Condenser sim (RefrigerationSimulation @4ae4d8, which the reconstructed Condenser ctor fails to install) derefs master the same way (heatfamily_reslice.cpp:125-127) -> same crash.

Why it can't be made stable from the allowed files (mech.cpp/mech.hpp + heat.hpp/powersub.hpp):

The fix is EITHER (a) a one-line null-master guard in heat.cpp::UpdateCoolant (mirroring its own ConductHeat/BalanceCoolant guards) — but heat.cpp is outside the allowed edit set and UpdateCoolant's body can't be patched from heat.hpp; OR (b) give the Condenser a VALID linked master heat sink — which requires the 0xBBE master heat-sink bank (no reconstructed class) to be real AND the inter-sink linkage to be wired, i.e. the whole heat family at once. Fabricating a self/peer link to dodge the null deref would produce meaningless heat numbers (the exact "garbage" the brief warns against). This is precisely the interdependence blocker already called out in "THE REAL BLOCKER" point 4 above — now proven with a debugger trace rather than predicted.

Before/after: tick stayed 1 (executable) of 30 — the Condenser tick CRASHED rather than surviving;

reverted -> 1/30 baseline restored. Stability after revert: 3/3 runs alive to 14s, bad-pattern count 0, [drive] pos advances every frame. Build GREEN (mech.cpp only recompiled). NO files left modified.

Reverted-to-stub: Condenser (0xBBD) — blocker = heat.cpp::UpdateCoolant null-master deref +

absent inter-sink linkage (interdependence). Reservoir (0xBC0) and HeatSink bank (0xBBE) — not attempted (0xBBE has no class; Reservoir carries 3 independent crash risks).

EXACT path forward for a (necessarily family-wide) heat pass:

  1. Re-label the SubsystemClassID enum + re-target every switch case to the CLASSMAP class (table above).
  2. Reconstruct the 0xBBE master HeatSink-bank class (heatfamily_reslice.hpp documents it: parses HeatSinkCount/AmbientTemperature, ctor @4ae8d0) so a real master exists.
  3. Wire the heat inter-sink linkage in the Mech ctor (after the subsystem loop, like the existing gyro<->sinkSource cross-link @ mech.cpp ~683): link each Condenser/Reservoir's linkedSinks to the master bank. (linkedSinks is public on HeatSink — reachable from mech.cpp.)
  4. Add the missing null-master guard to heat.cpp::UpdateCoolant (and install Condenser's real RefrigerationSimulation in its ctor) — REQUIRES heat.cpp edits (out of scope for this pilot's file set).
  5. THEN instantiate the heat family together (Condenser+Reservoir+bank) and verify the tick rises by their count without the UpdateCoolant fault.