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>
This commit is contained in:
arcattack
2026-07-05 21:03:40 -05:00
co-authored by Claude Opus 4.8
commit 7b7d465e5e
4192 changed files with 604371 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
# Asset Pipeline — Textures, Materials & Gauges
Companion to `BGF_FORMAT.md`. Covers how a model's surface resolves to pixels, and the cockpit
gauge/HUD formats. Verified by hexdump against real content; header cites are `file:line` under
`…/CODE/RP/MUNGA_L4/libDPL/`.
## Format inventory (CONTENT/BT)
| Ext | Count | Kind | Role |
|---|---|---|---|
| `.bgf` | 1275 | `DIV-BIZ2` FILETYPE=0 | 3D geometry (see BGF_FORMAT.md) |
| `.bmf` | 2222 | `DIV-BIZ2` FILETYPE=1 | material + texture **libraries** (no pixels) |
| `.vtx` | 5 | `DIV-VTX2` | raw RGB texel image |
| `.tga` | 8 | Truevision TGA2 type-2 | 24-bit truecolor texel image |
| `.sgi` | 0 | (loader exists) | RGBA texel image (none shipped) |
| `.bsl` | 3 | `DIV-BSL2` | bit-slice / multi-image + mip container |
| `.pcc` | 434 | ZSoft PCX, 8-bit RLE | cockpit HUD/gauge **raster** bitmaps |
| `.gim` | 311 | ASCII INI | MFD/radar **vector** line-gauges (NOT raster) |
| `.gat` | 3 | ASCII INI | gauge color/attribute table |
Layout: `VIDEO/GEO/*.bgf`, `VIDEO/MAT/*.bmf`, `VIDEO/TEX/*.vtx,*.bsl` (+ `TEX/BUILD/*.tga`),
`VIDEO/BUILD/*` staging, `GAUGE/*.gim,*.gat,*.pcc`, `MODELS/*.mod` (INI tying bgf+gim+sld).
## Name-resolution chain (verified)
```
BGF geogroup SV_F_MATERIAL "library:material"
→ open library.bmf
→ MATERIAL chunk whose NAME == "material"
→ its MATERIAL_TEXTURE (0x0021): [u8 type=2 NAMED][texture-name string]
→ TEXTURE chunk whose NAME == that texture-name
→ its TEXTURE_MAP (0x0011): image basename (no ext)
→ loader appends ext, searches texmap path → .vtx/.tga/.sgi/.bsl
```
`dpfCreateMaterialName(extPath,library,mname)` `PFILE.H:821`.
## .BMF chunk layout (same TLV grammar as BGF; tags in `PFBIZTAG.H:43-62`)
**TEXTURE `0x0010`** (`dpfTEXTURE`, `__PFILE.H:120-137`): NAME `0x2008` (string); TEXTURE_MAP `0x0011`
(basename string); MINIFY `0x0012`/MAGNIFY `0x0013` (mip/filter enums, `PFILE.H:155-162`); ALPHA `0x0014`
{blend/cut/blendcut}; WRAP_U/V `0x0015/16` {repeat/clamp/select}; DETAIL `0x0017`; BITSLICE `0x0018` (u8 → .bsl slice).
**MATERIAL `0x0020`** (`dpfMATERIAL`, `__PFILE.H:152-169`): NAME `0x2008`; MATERIAL_TEXTURE `0x0021`
[`u8 type`(2=named)+name]; AMBIENT `0x0023`/DIFFUSE `0x0024` = 3×f32 RGB (verified); SPECULAR `0x0025`/
EMISSIVE `0x0026`/OPACITY `0x0027` (optional, inferred 3-4 f32); RAMP `0x0028` (ramp-name string).
**RAMP `0x0030`**: NAME + RAMP_DATA `0x0031` (float color data).
## Pixel formats (normalize all to 32-bit RGBA at load)
- **.VTX (`DIV-VTX2`):** TLV; size tag `0x2062` = two int32 (w,h, e.g. 128×128); body = **uncompressed RGB
3 B/px**, top-to-bottom. Type enum `dpiBSLTYPE` (`PIMAGE.H:104-116`): MONO0-5/BILINEAR/RGB/RGBA.
- **.TGA:** standard type-2, 24-bit BGR, TGA2 footer. `dpl_tgaRead`.
- **.SGI:** RGBA (`dpl_sgiRead`; none shipped).
- **.BSL (`DIV-BSL2`): DECODED — a BIT-SLICED multi-image container** (corpus-verified on all 66 archive
BSLs; reference = the shipping WinTesla loader `DivLoader/VGCDivLoader.cpp:323-410`
`LoadBSLFile`/`getBSLData` + Division `dsys/PIMAGE.H` `dpiBSLTYPE` + the content build scripts
`img2vtx.exe -b -mN <tga>`). Header (int32 LE): w `@0x08`, h `@0x0C`, depth `@0x10` (bits per slice,
always 4), tableBytes `@0x14` (directory length counted from the nEntries field; imageDataOffset =
`0x18 + tableBytes`), nEntries `@0x18`; directory entries `{int32 recLen; int32 sliceType; char
name[recLen-4]}`**entry names are authoring records only, read-and-discarded at runtime**; then
`w*h` little-endian 32-bit texel WORDS. Byte 0 of each word = pad (structurally unused, 0 in all 66
files); the other 3 bytes = SIX independent **4-bit GRAYSCALE slices**, nibble PAIR-SWAPPED (even slice
= HIGH nibble): slice0=bits12-15, 1=8-11, 2=20-23, 3=16-19, 4=28-31, 5=24-27 (`shift=(c+((c+1)%2)*2)*4`,
value returned `<<4` = 0x00..0xF0). sliceType 7 = **RGB444** (r=s5,g=s4,b=s3), 8 = **RGBA4444** (+a=s2);
both coexist with mono slices in one file (BDAM/BEXP/BDET). Slice SELECTION = the BMF TEXTURE record's
`BITSLICE` tag `0x18` (u8; **absent = slice 0**) — e.g. BLHSKIN.BMF blkhwk1_tex=absent(0),
blkhwk2/3/4_tex=01/02/03, all mapping image basename `blkhwk` → BLKHWK.BSL. Mono slices are colorized
downstream by material RAMP (+ diffuse tint on neutral ramps) — all mech skins, vehicle atlases
(BASEV = 6 gray sheets, NOT truecolor), logos, effects grit. ⚠ The previous "base image = trailing
w*h*bpp, pixel order [pad,R,G,B]" claim was WRONG — it overlaid 2-3 different gray slices as RGB
channels (the rainbow "graffiti" mech-skin bug); it survives as the `BT_BSL=0` diagnostic fallback in
`port/src/image.cpp decodeBSL`.
## Cockpit gauges
- **.GIM** = ASCII INI, **vector** top-down line drawing for MFD/radar: `[lods] level0=3000…`,
`[vertices] v0=x,y,z`, `[level0] linelist=<group> v0 v1 v2…`. First token = color/attr group resolved
via `.gat`. Feeds the `dpl2d_*` 2D line path. Verified `AB01_GA.GIM`.
- **.GAT** = ASCII INI: `[colors]`,`[groups] a_bld=amber1…` → group→palette-index. Verified `GAUGE.GAT`.
- **.PCC** = ZSoft **PCX**, 8-bit RLE, 256-color palette (palette after `0x0C` EOF marker); 640×480-class.
The actual HUD raster bitmaps → need 8-bit-palette → RGBA expansion. Verified `ADPAL.PCC`.
## ⚠ Dependencies / open items
1. **The retail texel raster set is NOT in this archive** — only the BUILD/logo subset of `.vtx/.tga/.bsl`
ships. The 2222 `.bmf` reference maps that aren't here. **Need the full texture content from Nick**, or
the renderer must tolerate missing maps and fall back to material DIFFUSE color.
2. **No source for the `.gim/.gat/.pcc` loaders** — inside the closed `LIBDPL.LIB`. `.pcc`(PCX) and
`.gim/.gat`(INI) are standard/simple. `.bsl` and `.vtx` are now FULLY decoded (see Pixel formats above;
the shipping `DivLoader/VGCDivLoader.cpp` turned out to carry the reference reader for both).
+110
View File
@@ -0,0 +1,110 @@
# BGF (`DIV-BIZ2`) Model Format — Parsing Spec
Reverse-engineered for the BattleTech pod port (replacing the closed `libDPL`/Division-IG
renderer). Verified by parsing **all 1275 `.BGF` files** in `CONTENT/` with a prototype
parser — every file closes to the exact byte. Header authority cited inline; source headers
under `…/CODE/RP/MUNGA_L4/libDPL/`.
## 1. Header & global encoding
- **Magic:** 8 bytes ASCII `DIV-BIZ2` (`__PFILE.H:60-61` `dpfB2ZIDstr`). Siblings: `DIV-BMF2`
(binary material), `DIV-VIZ2`/`DIV-VMF2` (ascii).
- **Endian:** little-endian (`FILELIB.H:55`). All ints/tags + IEEE-754 **float32** (not double,
`PFILE.H:127-130`).
- **No directory.** After the magic, a flat-to-walk **nested chunk (TLV) tree**. First chunk is
always `HEADER (0x0003)`, last always `BIZ_DONE (0x0005)`.
- Content written by format v2.8 (lib 2.07).
## 2. Chunk grammar (TLV tree)
```
Chunk { uint16 tagword; (uint8|uint16) len; byte payload[len]; }
```
- **Tag id = `tagword & 0x2fff`.** Namespaces: `0x00xx` = structural/DEF, `0x20xx` = attribute/CON
(all leaves). Validity macro `dpfValidTag` (`PFBIZTAG.H:134-137`).
- **Length width flag = `tagword >> 14`:** `0` → 1-byte len; `1` (`0x4000`) → 2-byte LE len;
`0x8000` (4-byte) reserved, unused in this content. (Census: 1-byte 38890, 2-byte 8525, 4-byte 0.)
- **Containers** (payload = child chunks): `HEADER, BOUND, OBJECT, LOD, PATCH, PMESH, SPHERE_LIST`
(+ spec'd MATERIAL/TEXTURE/RAMP/POLYGON/TRISTRIP/POLYSTRIP/LINE/TEXT). Everything else is a leaf.
### Tag IDs actually used by this game (full table in `PFBIZTAG.H`)
Structural `0x00xx`: `0003 HEADER`, `0005 BIZ_DONE`, `0040 OBJECT`, `0041 LOD`, `0042 PATCH(=GEOGROUP)`,
`0046 PMESH` (**only mesh type used**), `0047 CONNECTION_LIST`, `004d PCONN_LIST`, `0048/0049 SPHERE_LIST/SPHERE`,
`0070/0071/0072 BOUND/BBOX/BSPHERE`, vertex blocks `0080 XYZ`, `0081 XYZ_N`, `0082 XYZ_RGBA`,
`0088 XYZ_UV`, `0089 XYZ_N_UV`, `008A XYZ_RGBA_UV`.
Attribute `0x20xx`: `2002 VERSION`, `2003 DATE`, `2004 TIME`, `2005 SCALE`, `2007 FILETYPE`(0=geom/1=mtl),
`2008 *_NAME`, `2009 UNIT`(1=metre), `2030/2031 SV_F/B_MATERIAL`, `2034 SV_DECAL`, `2035 SV_FACETED`,
`2036 SV_VERTEX`, `2037 SV_SPECIAL` (articulation/part names — see §6), `2046 LOD_DISTANCE`(2×f32 in,out),
`2048 LOD_TRANSITION`.
## 3. Geometry
**Vertex blocks** — interleaved packed float32, count = `len / stride`:
| tag | fields | stride |
|---|---|---|
|0080 XYZ|pos[3]|12|
|0081 XYZ_N|pos[3],n[3]|24|
|0082 XYZ_RGBA|pos[3],rgba[4]|28|
|0088 XYZ_UV|pos[3],uv[2]|20 ← dominant (2595 blocks)|
|0089 XYZ_N_UV|pos[3],n[3],uv[2]|32|
|008A XYZ_RGBA_UV|pos[3],rgba[4],uv[2]|36|
Positions = 3 floats, units per `UNIT` (metres). Flags map 1:1 to `dpl_VERTEX_TYPE` (`DPLTYPES.H:189-199`).
**Indices** (inside a `PMESH`, after its vertex block):
- **`PCONN_LIST` (0x004d)** — `uint8 pointsPerFace` (4 = quads is typical; **6 = hexagons occur**,
e.g. CALPB) then `int32 index[]` (0-based into this geometry's vertex block).
`nFaces = ((len-1)/4)/ppf`; fan-triangulate each face.
- **`CONNECTION_LIST` (0x0047)** — a **flat triangle list** (3 indices per face, no leading byte;
corpus: all 3488 CONN chunks have `n%3==0`).
-**CONN and PCONN are NOT alternatives — a single PMESH can carry BOTH** (quads/hexes in PCONN
*plus* plain triangles in CONN): 370 of 841 pod GEO models mix them in one pmesh. A loader that
prefers PCONN and skips CONN silently drops those triangles (found via the calliope turret base
`calpb`: 78 PCONN tris + 24 CONN tris — the missing base panels). Process both, always.
> **D3D9 note:** faces are **quads** → triangulate each quad `[a b c d]` → `[a b c][a c d]` at load.
## 4. Materials & textures (NOT in the BGF)
- Geometry refs material via parent `PATCH`'s `SV_F_MATERIAL`/`SV_B_MATERIAL` leaf:
`uint8 mattype` (`PFILE.H:169-170`; 1=named) + NUL-terminated `"library:material"` string,
e.g. `basev:shadow_mtl`.
- The part before `:` = a **`.BMF` material-library** file (`basev``BASEV.BMF`). BMF uses the
**identical chunk grammar** (magic `DIV-BMF2`/`DIV-BIZ2`, `FILETYPE=1`, `MATERIAL`/`TEXTURE` chunks) —
so the same parser reads it.
- Texture **pixels** live in further files named by the BMF's `TEXTURE_MAP` (loaders
`dpl_vtxRead/sgiRead/tgaRead`, `DPLUTILS.H:210-223`). **Load chain: BGF → BMF → image.**
(Open: this archive has 2222 `.BMF` but few `.vtx/.sgi/.tga` — confirm where pixels actually live.)
## 5. Hierarchy → dpl model
```
HEADER
BOUND { BSPHERE, BBOX }
OBJECT → dpl_OBJECT
SV_VERTEX
LOD → dpl_LOD (LOD_DISTANCE in/out; may be absent = 1 implicit LOD)
PATCH → dpl_GEOGROUP (SV_F/B_MATERIAL, SV_FACETED, SV_DECAL, SV_SPECIAL)
PMESH → dpl_GEOMETRY
VERTEX_xxx
PCONN_LIST / CONNECTION_LIST
BIZ_DONE
```
`PATCH == GEOGROUP` (`VCELTYPE.H:481-492`). Multi-LOD objects (1947 LODs/1275 files) are
distance-switched; blend per `LOD_TRANSITION`/runtime `dpl_MORPH_MODE`.
## 6. Articulation / damage — name-driven, not geometric
No morph-target/joint chunk exists. Articulation & damage key off **`SV_SPECIAL` (0x2037)** string
tokens on geogroups, e.g. `dz_hip`, `dz_utorso`, `dz_larm`, `dz_rgun`, `dz_lfoot`, `dz_searchlight`,
and behaviour tokens `PUNCH`, `BLINK`, `WIREFRAME`, `ADDITIVE_LODS`, `GEOMETRIZE 0x8000001a`.
`dpl_FlushDCSArticulations`/`dpl_MorphObject`/`dpl_Damagize` match these names to attach DCS joints /
morph / hide-swap on damage. **Loader must expose each geogroup's special string verbatim.**
## 7. Open items (need a known-good render or more samples)
1. ~~`PCONN_LIST` vs `CONNECTION_LIST` semantics~~ **RESOLVED (twice-corrected — see §"Indices"):**
`PCONN_LIST` = many faces with a leading points-per-face byte (4 or 6); `CONNECTION_LIST` =
a FLAT TRIANGLE LIST (the earlier "ONE polygon per chunk" reading was wrong — task #20), and
the two **coexist in one PMESH** (the earlier "prefer PCONN" reading dropped CONN's triangles —
the turret-base missing-panels bug). The `*_LP` "light-point/FX" objects (materials `btfx:*`)
carry only `SPHERE_LIST`/`POINT_LIST` geometry — no triangle mesh — and render as sprites
later, not solids. Loader result on this corpus: **621/663 triangle meshes load; 42 are
point/sphere FX.**
2. Winding order (CW/CCW) + `SV_FACETED` ↔ backface culling — confirm visually.
3. Full `SV_SPECIAL` token grammar (multi-word commands).
4. `SPHERE`/`SPHERE_LIST` per-sphere radius source.
5. 4-byte length variant (`0x8000`) — unused here, handle defensively.
+187
View File
@@ -0,0 +1,187 @@
# BattleTech (Tesla 4 / rel 4.10) — Source Status & Reconstruction
**Purpose:** brief for a meeting with an original BT programmer. What source we have, what was
missing, where each piece came from, and how we're patching the gaps to get BT running on modern
Windows. **The most useful thing you can tell us is at the bottom (§7).**
**One-line status:** the port now **compiles, links, and boots to a window**, and is running into the
mission/spawn simulation. The engine/renderer/audio came from an existing Windows port (Red Planet);
the BT *game logic* was lost and is being **reconstructed from the shipped binary** + the surviving
headers + Red Planet's parallel code.
---
## 1. The situation in one picture
| Layer | Have it? | Source |
|---|---|---|
| **MUNGA engine + L4 HAL** (renderer, mover, math, scene, net, audio) | ✅ complete | The **Windows port** ("WinTesla", Red Planet 4.11 source) — already bypasses the Division IG board with Direct3D 9 (`L4D3D`) and replaces HMI SOS audio with OpenAL. |
| **BT headers** (`.hpp`: class layouts, vtables, signatures) | ⚠️ **partial** | ~14 survived in the BT source archive (`410srczipped`); the **core ~28 (`mech`, `heat`, `powersub`, weapons, `btplayer`, `hud`, gauges…) were lost** — see §3. |
| **BT game logic** (`mech`, subsystems, weapons, HUD, app…) `.cpp` | ❌ **LOST** | Reconstructed from the shipped binary `BTL4OPT.EXE` (decompiled). |
| **Deployed content** (models, skeletons, skins, animations, missions/eggs) | ✅ present | The pod machine disk image. |
Where a header survived it's an *answer key* for class layout/vtable order (then the binary just
supplies the bodies). Where it didn't (the core mech/weapon/player classes), the layout itself is
inferred from the binary's vtables + `this+offset` usage, cross-checked against the Red Planet analog —
a harder reconstruction, and the part most worth validating against original source if any exists.
---
## 2. Where everything lives (the four archives, in the repo root)
| Archive | What it is | Key contents |
|---|---|---|
| `410srczipped.zip` | **BT source tree** | All `.hpp` headers, geometry/material defs, **428 animations**, and a *partial* set of `.cpp`. Build manifests `CODE/BT/BT/BT.MAK` + `CODE/BT/BT_L4/BTL4.MAK` (the authoritative file lists). |
| `Tesla4NovellTechPC-*.zip` | Pod **technician/test PC** image | The compiled game `btrel410.exe`, Division VPX board diagnostics, shared textures. |
| `Tesla4PodPCNovell-*.zip` | The pod **game machine** image | The full **deployed content tree** at `…/CopyofNovellDisk/REL410/BT/` — per-mech skeletons/skins, models, textures, gauges, **and the mission eggs** (`TEST.EGG`, `LAST.EGG`). Also a copy of the game binary. |
| `Elsewhen RP411 Source-*.zip` | **The Windows port** ("WinTesla", VS2008) | Modernized engine: `WinTesla.sln`, MUNGA + MUNGA_L4 (incl. `L4D3D.cpp` = IG→Direct3D), OpenAL/libsndfile audio, and the **complete Red Planet game logic**. The solution references a `BT410_L4` target **but the BT game source folder is absent** — confirming BT was mid-port and its code isn't here. |
The shipped binary `btrel410.exe` is a PKZIP self-extractor; unwrapped it yields **`BTL4OPT.EXE`**
(1.24 MB, clean PE32) — that's our decompilation + behavioral oracle.
---
## 3. What was missing, precisely (from `BT.MAK` + `BTL4.MAK` = 53 modules)
Every module in the original build manifests is accounted for as follows:
### 3a. Survived as real source (~10) — used as-is
From `CODE/BT/BT/` and `CODE/BT/BT_L4/`:
`BTMSSN` (mission), `BTREG` (registry), `BTTEAM`, `BTSCNRL` (scenario role), `BTCNSL` (console),
`BTTOOL`, `GAUSS` (Gauss rifle), `PPC`, `BTL4ARND` (arena), `BTL4MODE` (mode manager).
### 3b. Lost → reconstructed from the binary (~43 modules)
The heart of the game. **Two cases, depending on whether the header also survived:**
**(i) Header AND `.cpp` lost → both reconstructed** (class layout/vtable inferred from the binary +
RP analog — these are the ones whose layout is *our inference*, not ground truth):
`mech`, `mech2`, `mech3`, `mech4`, `mechsub`, `dmgtable`, `heat`, `heatfamily_reslice`, `powersub`,
`gnrator`, `gyro`, `torso`, `myomers`, `searchlight`, `thermalsight`, `mechweap`, `emitter`,
`projweap`, `missile`, `projtile`, `ammobin`, `btplayer`, `mechmppr`, `btl4vid`, `btl4mppr`, `hud`,
`btl4gaug`, `btl4gau2`, `btl4gau3`.
**(ii) `.hpp` survived, only `.cpp` lost → implementation reconstructed against the real header**
(lower risk — layout is ground truth):
`mechdmg`, `mechtech`, `sensor`, `messmgr`, `mislanch`, `misthrst`, `seeker`, `btl4app`, `btl4mssn`,
`btl4rdr`, `btl4grnd`, `btl4pb`, `btl4galm`, `btdirect`.
(Surviving headers with no reconstruction needed — interface-only or their `.cpp` also survived:
`bt`, `btl4`, `btl4ver`, `btmssn`, `btreg`, `btteam`, `bttool`, `gauss`, `ppc`, `btl4arnd`,
`btl4mode`, `testbt`, `turret`.)
### 3c. Genuinely unaccounted-for (4) — **please confirm with us (§7)**
| Module | Our handling | Question for you |
|---|---|---|
| `btl4` (the `WinMain`/main app) | **Recreated** as `btl4main.cpp`, cloned from RP's launcher `RPL4.CPP`. | Is the real BT main app meaningfully different from RP's? |
| `path` | **Not reconstructed.** Not referenced on the current code path. | What is `path.cpp` — AI pathfinding? Is it needed for single-player bots? |
| `mechbld` | Assumed **offline tool** (mech/model builder), not runtime. | Correct? |
| `btl4tool` | Assumed **offline tool**, not runtime. | Correct? |
### 3d. Missing files, organized by the directory you'd look in
This is the concrete "check your backup drive" list. `BOTH` = `.cpp` and `.hpp` both gone;
`.cpp only` = header survived; `.hpp only` = implementation survived.
**`CODE/BT/BT/` (game logic)**
| File (module) | What's missing |
|---|---|
| `mech`, `mech2`, `mech3`, `mech4`, `mechsub` | **BOTH** |
| `heat`, `powersub`, `gnrator` | **BOTH** |
| `mechweap`, `emitter`, `projweap`, `missile`, `projtile`, `ammobin` | **BOTH** |
| `gyro`, `torso`, `myomers`, `hud`, `dmgtable` | **BOTH** |
| `btplayer`, `mechmppr` | **BOTH** |
| `path` | **BOTH** (AI pathfinding — see §3c) |
| `mechdmg`, `mechtech`, `sensor`, `messmgr`, `mislanch`, `misthrst`, `seeker`, `btdirect` | `.cpp only` |
| `btcnsl` | `.hpp only` |
**`CODE/BT/BT_L4/` (app / cockpit / L4 integration)**
| File (module) | What's missing |
|---|---|
| `btl4vid`, `btl4gaug`, `btl4gau2`, `btl4gau3`, `btl4mppr` | **BOTH** |
| `mechbld`, `btl4tool` | **BOTH** (assumed offline tools — see §3c) |
| `btl4` (main app), `btl4app`, `btl4mssn`, `btl4rdr`, `btl4grnd`, `btl4pb`, `btl4galm` | `.cpp only` |
**Present and intact (no help needed):** `CODE/BT/BT/`: `btmssn`, `btreg`, `btteam`, `bttool`,
`gauss`, `ppc`, `turret.hpp`, `bt.hpp` · `CODE/BT/BT_L4/`: `btl4arnd`, `btl4mode`, `btl4.hpp`,
`btl4ver.hpp`, `testbt.hpp`.
---
## 4. How the BT game logic is being reconstructed
`BTL4OPT.EXE` is a near-ideal target: class names, `Class::Method` strings, and **original source
paths in asserts** (`d:\tesla_bt\bt\mech.cpp`) are left in the binary. Per module:
1. Locate the module's function cluster in the decompiled output (asserts + address contiguity).
2. Map vtable slots → methods (vtable order = declaration order, from the surviving header).
3. Map `this+0xNN` offsets → named members (from the header layout).
4. Rewrite the Ghidra pseudo-C into compilable C++, **cross-checking the Red Planet analog** for
idioms — `VTV``Mech`, `VTVMPPR``mechmppr`, `RPPLAYER`/`BLOCKER``btplayer`, `WEAPSYS`≈weapons,
`RPL4*`≈the `BT_L4` app layer.
5. Verify behavior against the binary as an oracle.
Result is a **behavior-equivalent reconstruction**, not the original text. Reconstructed files carry a
header banner citing the originating `@ADDR` and the cross-references used.
---
## 5. How it's assembled & patched into a running build
- **Engine:** the WinTesla MUNGA + MUNGA_L4 (193 files) compiles green to `munga_engine.lib`
(modern MSVC, Win32, targeting the legacy DirectX SDK June 2010 for `d3dx9`/`dinput`).
- **BT game lib (`bt410_l4`):** the 43 reconstructed `.cpp` + the ~10 surviving BT `.cpp` + a small
`btstubs.cpp` (see below) + a header-forwarding shim (the DOS-era code uses `.hpp` include names;
WinTesla renamed engine headers to `.h`).
- **Launcher:** `btl4main.cpp` (`WinMain`, cloned from `RPL4.CPP`), drives `BTL4Application`.
- **Link:** `bt410_l4` + `munga_engine.lib` + OpenAL/libsndfile + D3D9/d3dx9/dinput8 → **`btl4.exe`**.
### The patch points we know about (the bring-up worklist)
These are deliberately isolated and flagged `// TODO(bring-up)`:
- **`btstubs.cpp`** — first-link placeholders for: a handful of engine data globals not pulled from the
static lib (`allPresets`, `FrameTimeScale`); the BT renderable pipeline (`BT*Renderable`); the 2D HUD
layer (`dpl2d_*`, which libDPL provided and `L4D3D` hasn't yet re-implemented); a few `Mech`/subsystem
accessors. Each is inert until given a real body.
- **Two stubbed video data files** — `VIDEO\REPLACEMATS.tbl` / `MATREPLACETABLE.tbl` (WinTesla-era
material-substitution tables, absent from the 1995 content) created empty so video init proceeds.
- **A few reconstructed `BTPlayer`/`Mech` methods** still missing real bodies (currently being
recovered, in dependency order driven by what the running game asks for next).
---
## 6. Current runtime state (live)
`btl4.exe` boots cleanly: opens `BTL4.RES`, parses the `-egg TEST.EGG` mission (BattleTech / cavern /
freeforall / night), creates the application, initializes the D3D video renderer, **opens its window**,
and enters the mission simulation. The **player-spawn pipeline now executes end-to-end**
`HuntForDropZone → DropZone reply → BTPlayer::CreatePlayerVehicle → MakeAndLinkViewpointEntity →
Mech::Make → Mech::Mech` (all reconstructed from the binary + RP analog this session).
**Current frontier = the `Mech` object-layout + resource-streaming reconstruction.** The Mech
constructor runs but doesn't yet *stream* its model/subsystems/skeleton: in the reconstruction the
resource layer (`ResourceFind`/`ResourceStream`) is still a no-op placeholder, and ~145 raw
`this[0xNN]` field accesses in `mech.cpp` are backed by a shared scratch bank instead of named members.
This is the single biggest remaining reconstruction task — and, notably, it's **exactly the module
whose original `.hpp` *and* `.cpp` were both lost** (`mech`, see §3d). Recovering the real `mech.*`
would short-circuit this entire pass.
---
## 7. What would help most from you
1. **The real BT game source** — if a backup/dev drive has `CODE/BT/BT/*.cpp` (the lost `mech`,
subsystems, weapons, HUD, app), it would replace the entire reconstruction. Even a partial set
shrinks the work dramatically.
2. **`path.cpp`** — what is it (AI pathfinding?), and is it needed for single-player bot missions?
3. **`mechbld` / `btl4tool`** — confirm these are offline tools, not runtime modules.
4. The **mission/egg flow** for standalone (non-networked) play — anything special about how a pod
launched a single mission vs. the multiplayer console handshake.
5. The **2D HUD / `dpl2d_*` layer** — how the cockpit gauges/radar were drawn, since that's the part of
libDPL the Direct3D port hasn't reproduced yet.
6. Confirmation that the pod disk image is the **complete content master**, and whether the full
walk/run **animation set** exists beyond what shipped in the source archive.
---
*Reference docs in this repo: `CLAUDE.md` (overall project), `btbuild/RECONCILE.md` (the detailed
reconstruction + bring-up ledger), `decomp/README.md` (decompilation method).*
+199
View File
@@ -0,0 +1,199 @@
# BT Port — Hardest-Problems Front-Load Plan (scope-hardest-problems workflow + verification)
Goal: tackle the hardest, highest-LEVERAGE problems first so the rest of the port gets easier.
6 candidates were scoped by independent deep-dive agents, then the load-bearing claims were VERIFIED
against the code (several agent claims + several CLAUDE.md facts turned out wrong — see Verification).
## Ranked front-load sequence (post-verification)
| # | Problem | Verdict | Why here |
|---|---------|---------|----------|
| **1** | **P5 — Entity lifecycle / collision teardown** ★ | do-first | Hardest (4) + highest leverage (4) + hard prereq for multiplayer & multi-entity combat & real hit detection. Forces Entity/Mover base-region layout correctness that ALSO de-risks P3. |
| **2** | **P3 — Locomotion cutover** (SequenceController → world transform) | do-early (adjacent to P5) | The gait pipeline is ALREADY reconstructed in source (mech2/3/4.cpp: legAnimation@0x65c, bodyAnimation@0x6bc SequenceControllers) — this is a CUTOVER from the procedural-slide stand-in, not greenfield. Shares P5's Mech/Mover layout de-risk. Produces the speed/turn-demand consumer P2 & P6 need. |
| 3 | **P2 — Authentic input** (MechControlsMapper + RIO) | do-later | Downstream of P3 (mapper output is inert without the locomotion consumer). Software plumbing = days once P3 lands; physical RIO wiring defers to a Phase-8 session with Nick. |
| 4 | **P6 — Multiplayer** (integrate existing TCP stack) | do-later | Terminal integration. The hard part is DONE (L4NET = 3446-line WinSock TCP; master/replicant core complete). Gated on P3 + P5 + subsystem waves. Do a 2-instance smoke test early to de-risk; save fidelity for last. |
| 5 | **P1 — dpl2d reticle/PIP overlay** | opportunistic | Easy + isolated + additive (btl4vid.cpp already calls dpl2d_Circle for the PIP). Slot in whenever a visible aiming win is wanted; blocks nothing. |
| — | **P4 — Build /FORCE cleanup** | optional cosmetic | ⚠ NOT a front-load enabler. The agent's premise was REFUTED (see Verification): /FORCE hides ZERO runtime symbols. Pure cosmetic; do opportunistically, not first. |
**Prerequisite gates:** P5 gates P6 · P3 gates P2 & P6 · P5 and P3 share a hidden prereq = **Entity/Mover
base-region field-layout correctness** (do that audit once, up front, both benefit).
## Verification results (adversarial — several claims corrected)
-**P5 root cause corrected:** CLAUDE.md said "collision solids were never built." VERIFIED WRONG —
`Mover::Mover` (RP/MUNGA/MOVER.cpp:1756) allocates `collisionLists = new BoxedSolidCollisionList[2]`
UNCONDITIONALLY; `~Mover`:2050 deletes it. So the crash is a **clobber / dangling / teardown-order** bug.
-**P6 corrected:** CLAUDE.md §8 said "reimplement over UDP." VERIFIED WRONG — L4NET.CPP is 3446 lines of
WinSock **TCP** (`SOCK_STREAM`×4, `ReliableMode`, zero `SOCK_DGRAM`). Already reimplemented, not greenfield.
-**P4 claim REFUTED:** agent claimed `/FORCE` swallows ~10-15 genuine runtime unresolveds (e.g.
`Mech::WorldToLocal`). The linker emits EXACTLY 40 unresolveds = 20 `DefaultData` + 20
`CreateStreamedSubsystem` (dead offline factory), **zero runtime symbols**. `WorldToLocal` resolves via the
engine lib. CLAUDE.md's "dead offline-factory, cleanup TODO" characterization was correct. P4 is cosmetic.
-**P3 confirmed less-greenfield:** mech2.cpp carries the full SequenceController gait reconstruction → cutover.
-**P1 confirmed small/isolated:** dpl2d gap ≈ the reticle/PIP vector overlay only (btl4vid.cpp:563+ already
calls dpl2d_NewDisplayList/Begin/Circle for the PIP; weapon beams are a separate 3D-renderables module;
gauges/MFDs/radar are the separate L4GAUGE path — MUNGA_L4/L4GAUGE.cpp — already ported, OFF in BT).
## P5 — ⚠ AUDIT PIVOT: it is a TEARDOWN-SEQUENCE bug, NOT a base-region stomp (verified)
The base-region audit **disproved the base-region-stomp hypothesis** and redirected P5:
- **The enemy's engine base region is FULLY VALID at death** (`BT_ENABLE_TEARDOWN` dump): `collisionLists@0x2e4
=0D8C8C04`, `segmentTable@0x2f0=00872CE8` (segmentCount `51`), `jointSubsystem@0x30c=00872D68` all valid;
`lastCollisionList`/`collisionAssistant` NULL (fine). Nothing corrupts it during the enemy's life.
- **Every raw-offset base-region stomp is DEAD CODE.** `Mech::Simulate` (collision-cluster reads/writes +
telemetry overflow past sizeof) and `FeedHeat*Gauge` (writes through `+0x2ec`) are DEFINED but NEVER
CALLED (grep-confirmed; mech4.cpp:858 "our drivable override bypasses the unsafe Mech::Simulate"). So the
`0x2d4-0x2f0` stomps and the `0x7e0-0x828` over-sizeof writes do not run — they are not the cause.
- **The crash is in the teardown SEQUENCE** (`FryDeathRow → ~Mech → ~JointedMover → ~Mover`): cdb_dmg5 shows
`collisionLists`'s array already **freed** (`0xdd` cookie / `count=0xdddddddb`) by the time `~Mover`'s
`delete[]` runs — a **double-free / destruction-order** bug (crash site varies run-to-run: `~Mover`
collisionLists, or `~JointedMover` deleting a `0x0ccd1210` value). ~Mech (reconstructed) runs BEFORE the
engine base dtors, so the prime suspect is a Mech member dtor / the enemy's minimal-spawn collision setup
freeing (or aliasing) a base resource that the engine base dtor then frees again.
**Latent finding (real but not the crash cause):** compiled `sizeof(Mech)=0x638` < binary `0x854`, and
`Mech::Make` allocates the compiled size; the code that would write `0x7e0-0x828` past it is the dead
`Simulate`, so no live overflow today — but if that code is ever revived, the Mech must first be padded to 0x854.
**✅ DOUBLE-FREE PINNED (trace done):** NOT collisionLists — it's the **skeleton SEGMENT teardown**.
- `collisionLists` is LIVE at `~Mech` entry (`*cl` not `0xDD`) — ruling out the collision path.
- Real crash stack: `~JointedMover` (JMOVER.cpp:436 `SegmentTableIterator(segmentTable).DeletePlugs()`) →
`SocketIterator::DeletePlugs` (SOCKET.cpp:157-161 `delete plug`) → `EntitySegment` scalar-deleting-dtor →
`_free_base` → **`STATUS_HEAP_CORRUPTION` (0xC0000374)** with **`0xFEEE` freed-fill** present. So one of the
enemy's **51 EntitySegments is freed twice**.
- Mechanism: `DeletePlugs(defeat_release_node=1)` (SOCKET.cpp:151-154) NULLs the socket's release node and
force-`delete`s every plug, BYPASSING the ref-count release path. If the enemy's segments are ref-counted/
shared (owned by the skeleton resource, or shallow-aliased by the minimal `Mech::Make` spawn), the normal
release already freed them → `DeletePlugs` double-frees. (Earlier `~Mover` collisionLists `0xDD` crash was
the same heap corruption surfacing at a different free — the segment double-free is the root.)
**Fix-trace (deep, still open):** segment-deletion trace (cdb bp on `EntitySegment::~scalar-deleting-dtor`)
shows `DeletePlugs` deletes segment #1 OK (`0d3d3f30`), then crashes on segment #2 (`0d3d5200`) —
`STATUS_HEAP_CORRUPTION` freeing its block. Only 2 of 51 deleted; **segment #2's block is invalid** and its
scalar-dtor never fired earlier, so it was freed via ANOTHER path before `~JointedMover`. Ruled out:
- `EntitySegment::~EntitySegment` (SEGMENT.cpp:116) only DeletePlugs its OWN child-index/damage/video plugs
(integers) — it does NOT free sibling segments, so deleting #1 didn't free #2.
- The reconstructed `~Mech` body (mech.cpp:952-1008) does NOT explicitly free `subsystemArray` or segments.
- No live overflow past `sizeof(Mech)=0x638` (the over-sizeof writes are all in dead `Simulate`).
**Leading hypothesis — DUAL segment/joint DeletePlugs ownership:** the `JointSubsystem` dtor (JOINT.cpp:499-505)
`DeletePlugs()`es its **`jointTable`**, and `~JointedMover` (JMOVER.cpp:436) `DeletePlugs()`es **`segmentTable`**;
the JMOVER `#if 0` comment ("deleted by the entity subsystemArray[jointSubsystem]") flags the ownership overlap.
If the enemy's minimal `Mech::Make` spawn puts the SAME segments in both tables (or sets release-node/ref-counts
differently than a normal entity), both force-delete them → the second is the double-free.
**✅ DECISIVE TEST RUN (spawn-vs-rp-creation workflow + cdb):**
- **Dual-ownership hypothesis DISPROVEN.** Engine source confirms `segmentTable` (owns EntitySegments,
`(this,True)`) and `jointTable` (owns Joints, `(NULL,False)`) are DISJOINT — `DeletePlugs` on one never
touches the other (JMOVER.cpp:171/310, JOINT.cpp:501-504, SEGMENT.cpp:116-126). So there is no
segment/joint double-delete.
- **Double-destruction DISPROVEN.** cdb bp on `JointedMover::~JointedMover` shows it runs EXACTLY ONCE for
the enemy (`~JM this=0d04b998` == the teardown-log enemy this). Single teardown.
- **=> It is HEAP CORRUPTION of an individual segment block (Hypothesis B), during a SINGLE teardown.**
`segmentTable.DeletePlugs` frees segment #1 OK, then segment #2's block header is invalid
(`RtlValidateHeap ... Invalid address`). So a live write during the enemy's life corrupts one segment's
block. **The spawn-lifecycle fix (Registry::MakeEntity / BecomeInteresting) will NOT fix this** (the
workflow's own gate: "entered once => steps 1-5 do not fix it").
- **Source still unpinned, but these are RULED OUT:** the dead-`Simulate` raw-offset stomps (never called);
double-destruction; subsystem writes past `sizeof(Mech)=0x638`; the segmentTable POINTER (valid at death).
What remains: a live heap-corruptor of a segment block (candidate: the damage path mechdmg.cpp:628 which
indexes segments while the enemy takes its 8 hits) — and it may not even be enemy-specific.
**⭐ P5 PREMISE WAS WRONG — dead mechs are SUPPOSED to stay (verified).** In the real game a killed mech does
NOT vanish: RP's death path `VTV::DeathShutdown` (VTV.cpp:1681-1691) loops subsystems calling `DeathShutdown`
(the vehicle shuts down but is NEVER removed); `CondemnToDeathRow`/entity-removal in RP is used ONLY for
transient objects (RIVET.cpp projectiles, DEMOPACK.cpp cleanup), never for a combat kill. BT death is a
STATE/VISUAL transition — `SetGraphicState(DestroyedGraphicState)` (mechdmg.cpp:355) + a death animation
(`deathAnimationLatched@0x650`, mech.hpp:505) + death effect/splash. So the mech becomes a wrecked HULK and
stays. ⇒ **Our current wreck-stays behavior IS the faithful one; `DestroyEntityMessage`-on-death is NOT a
real behavior and should never be enabled.** The teardown crash is an artifact of forcing a removal the
original never does (behind `BT_ENABLE_TEARDOWN`). There is NOTHING to fix here for faithfulness. The real
future "death" work is the OPPOSITE of removal: reconstruct the DeathShutdown sequence + collapse animation
+ destroyed skin — none of which touch the crashing teardown path.
**RECOMMENDATION: CLOSE P5 (not just park).** The wreck-stays death (explosion + stop-targeting) ships and
combat is 100% unaffected; the crash only exists behind `BT_ENABLE_TEARDOWN`. Pinning the live segment-corruptor needs
either gflags **PageHeap** (elevation — catches the overflow AT the write) or a `ba w4` drill on a segment
block, i.e. another deep session for a cosmetic "wreck vanishes" win. Better to spend the effort on P3
(locomotion cutover) and revisit this opportunistically (e.g. run once under PageHeap when elevation is available).
**(superseded) earlier next step:** hardware write-breakpoint (`ba w4`) on segment #2's block to catch the FIRST free
(which table/dtor frees it) — capture its address at spawn, set the bp, run to the free. That names the exact
first-owner and the fix (make that table release-not-delete, or de-duplicate the segment registration).
**Pragmatic alternative:** the wreck-stays behavior (explosion + stop-targeting) already ships and combat is
unaffected; full `DestroyEntityMessage` removal can stay deferred behind `BT_ENABLE_TEARDOWN` until the
segment-ownership model is reconciled (a bounded but genuinely deep engine-archaeology task).
---
### (superseded) earlier hypothesis — base-region stomp
Reproduced under cdb behind `BT_ENABLE_TEARDOWN=1` (mech4.cpp, default OFF, parallel to the working stand-in).
**Findings (all verified, not inferred):**
- The documented cause "collision solids never built" is **WRONG**. `Mover::Mover` (MOVER.cpp:1756) allocates
`collisionLists = new BoxedSolidCollisionList[2]` unconditionally; the `[teardown]` log shows the enemy's
`collisionLists@0x2e4 = 0x0CCBCF8C` (a valid heap ptr) at death.
- `collisionLists` sits at **Mover+0x2e4** (from `dt btl4!Mover`), inside the engine collision cluster
`collisionVolumeCount@0x2d4 · collisionVolume@0x2d8 · collisionTemplate@0x2dc · containedByNode@0x2e0 ·
collisionLists@0x2e4 · lastCollisionList@0x2e8 · collisionAssistant@0x2ec`. `sizeof(Mover)=0x2f0`,
`JointedMover=0x318`, `Mech=0x638`.
- The reconstructed Mech's **declared** fields for these are safely relocated (`netOrientation@0x3ec`,
`arrivalTime@0x500`, `torsoAimTarget@0x3e0`) — BUT `Mech::Simulate`/terrain/heat code in mech4.cpp still
uses **stale RAW binary offsets** `this+0x2d4 / +0x2e0 / +0x2e8 / +0x2ec / +0x2f0` that land ON the engine
collision cluster. It READS them (garbage) and DEREF-WRITES through them (e.g. mech4.cpp:1020
`*(this+0x2ec)+0xc = heatCapacity` writes through the engine `collisionAssistant` ptr). `physicsBody/
groundRef/groundCell` aren't even declared members — they exist ONLY as these raw offsets.
- Result: the engine base region is corrupted during the enemy's life; teardown crashes at **varying**
base-member sites (cdb_dmg5: `~Mover`:2050 `delete[] collisionLists`, count cookie `0xdddddddb`; this run:
`~JointedMover`:444 deleting a `0x0ccd1210` uninit member). It's the **base-region layout divergence**
between the 1995 BT binary (raw offsets) and the 2007 RP411 engine base — the shared P3/P5 prerequisite.
**Remaining P5 fix (the real work):**
1. **Base-region audit of mech*.cpp raw offsets:** enumerate every `this+0xNN` for `0xNN < 0x318` (engine
base), map each against the `dt btl4!Mover`/`JointedMover` layout, and convert stomping accesses to the
correct engine field or the relocated declared member. The `0x2d4-0x2f0` set is the known-bad start;
audit the whole base range (there are likely more, given `Mech::Simulate` is raw-offset-dense).
2. **Complete the minimal spawn** so the enemy's engine base members are all initialized (no `0xCD`).
3. **Route death through `DestroyEntityMessage → FryDeathRow`** once teardown is clean (env flag → default).
Effort: the audit is mechanical but broad (Simulate/terrain/heat are raw-offset-dense) — days-to-weeks.
Do it behind `BT_ENABLE_TEARDOWN` until green, since the shipped combat loop works by bypassing teardown.
## ✅ CLOSED — the BGF-load heap corruption (`bld08.bgf` / `Builder::~Builder` AV)
**Symptom:** mid-mission `LoadBgfFile("bld08.bgf")` → `Builder::~Builder` → `vector<float>` teardown → AV
inside `operator delete` (ntdll `RtlpFreeHeap` dereferencing `0xDDDDDDDD`), position-dependent (one combat
run crashed; walk-only and differently-routed runs were clean).
**Root cause (reconstruction TYPE CONFUSION, found by a 4-agent workflow + forensics):**
`HeatSink`'s ctor resolved its linked sink via `owner->GetSegment(heatSinkIndex)` — the Nth skeleton
**EntitySegment** (288 bytes compiled) — cast to `Subsystem*`/`HeatSink*`. The binary (`@004adda0`,
part_012.c:16999) reads **`owner->subsystemArray[heatSinkIndex]`** (the subsystem ROSTER @0x128,
bounds-checked vs subsystemCount @0x124, null-guarded before `linkedSinks.Add`). Through the bogus pointer,
every per-frame `ConductHeat` wrote `other->pendingHeat` at compiled offset 388 = **100 bytes past the
288-byte EntitySegment heap block** (and `BalanceCoolant` wrote `coolantLevel` +20 past) — thousands of
4-byte OOB writes during sustained fire, smashing NT free-list metadata adjacent to the mech's segments.
The BGF loader's big vector alloc/free churn merely DETECTED it later. Sibling bug: `PoweredSubsystem`'s
`voltageSourceIndex` (res+0xFC; raw part_013.c:1198 = the same roster lookup) was also GetSegment-resolved,
so `AttachToVoltageSource` wrote `currentTapCount` 136 bytes past the segment block at every mech spawn.
**Exoneration sweep (all CONFIRMED):** the loader itself — an exact Python mirror over all 879 content BGFs,
zero anomalies; the crashed 0x768 block = exactly the 474-float MSVC growth capacity for bld08's 472 verts
(healthy vector); every hard-coded placement-new alloc ≥ compiled sizeof (probe-compiled); Mech spawns use
`sizeof(Mech)` (immune to growth); Explosion churn is pure engine code; `Mech::Simulate` over-sizeof writes
are dead code; LoadLocomotionClips postdates the crash (timeline via /tmp mtimes) and its
`keyframeData[keyframeCount]` read is binary-faithful.
**Fix (heat.cpp + powersub.cpp):** both resolutions replaced with the binary's roster lookup via the public
`owner->GetSubsystemCount()`/`GetSubsystem(i)` (pre-checked so the engine Verify never fires; roster is
pre-zeroed so forward refs read NULL = the binary's "missing" warn path). Powersub's else-gate also fixed to
OWNER flags per raw. Payoff: the heat link now reaches a REAL sink (`heatEnergy=1.34e+07`, was ~0).
**Verification:** (1) `BT_HEAPCHECK=1` (new runtime gate, btl4main.cpp: `_CRTDBG_CHECK_ALWAYS_DF` whole-heap
validation on every alloc/free) through 100+ shots — ZERO detections (pre-fix, the first ConductHeat write
would trip it). (2) The im2 scenario re-run fast: **3601 shots + 10.6 km walked** — no AV. (3) `BT_PROBE_BGF`
(new: direct-load models at boot; `=ALL` sweeps every GEO model) — bld08 clean 25×.
**Durable lesson (systemic checklist entry):** an owner offset `+0x128` in subsystem raw decomp is the
subsystem ROSTER (`subsystemArray`), NOT the segment table — audit every `GetSegment(int)` call in
reconstructed subsystem ctors (only these two existed; both fixed).
+362
View File
@@ -0,0 +1,362 @@
# P3 — Locomotion Cutover (procedural slide → animation-driven gait)
Plan from the `p3-locomotion-cutover-scope` workflow (4 maps, source-verified). Goal: make BT movement
ANIMATION-DRIVEN (the walk/run clip's `[RootTranslation].z` IS the speed; feet plant by construction),
replacing the bring-up procedural slide + disconnected `gBodyAnim`.
## Ground truth (verified)
The real two-channel gait is reconstructed but **bracketed by no-op stubs on BOTH ends**, so it can't just
be "called on":
- **INPUT stubs:** `legAnimation@0x65c` / `bodyAnimation@0x6bc` are `ReconSeq` (mech.hpp:508-509);
`ReconSeq::Advance()` returns 0, `SelectSequence`/`Reset` no-op, `keyframeData==NULL` (mechrecon.hpp:225-236).
- **OUTPUT stub:** `Matrix34 == ReconMatrix` no-op (mechrecon.hpp:460); its `SetRotation/FromQuaternion` write
nothing → `IntegrateMotion`'s world-step (mech4.cpp:206-285) commits nothing even with nonzero input.
- `IntegrateMotion` is REAL logic (not a stub) but dead (input 0, output stub); its only caller `Mech::Simulate`
(mech4.cpp:307) has 0 call sites. The live tick is bring-up `Mech::PerformAndWatch` (mech4.cpp:549).
- `LoadLocomotionClips` (mech3.cpp:325) would CRASH today (reads `keyframeData[..]` on the NULL stub).
- **The lever that already works:** `gBodyAnim->Animate(dt,True)` (mech4.cpp) runs the REAL engine
`AnimationInstance::Animate` (JMOVER.cpp:1474-1700), cycling joints AND returning the clip's per-frame
root-translation distance — which the stand-in threw away. That is the smallest safe cutover.
## Steps (each independently build+test; mech keeps moving throughout)
- **✅ STEP 1 — DONE (commit below): forward travel animation-driven via the working `AnimationInstance`.**
Use the `adv` that `gBodyAnim->Animate(dt,True)` returns as the forward step (`localOrigin.linearPosition
+= facing * adv`); removed the `kDriveMaxSpeed * throttle * dt` slide (mech4.cpp). VERIFIED: mech walks at
the clip's authentic ~60 u/s (was a guessed 30), FORWARD (Z), feet plant (travel==stride); combat +
damage un-regressed; no crash.
- **✅ STEP 2 — DONE: gait clip selection by throttle** (walk/run/reverse). `|throttle| >= 0.5` → run
(`blhrrl` id 904), else walk (`blhwwl` id 898); throttle sign → direction (negative backs up); SetAnimation
only on gait change (tracked by `gCurrentGait`). Added a forced-throttle VALUE (`BT_FORCE_THROTTLE=<v>`,
btl4main.cpp) for headless testing. VERIFIED: run ~60 u/s, walk ~22 u/s (walk is ~1/3 of run — authentic
per-clip speeds, NOT a scalar of one guess), reverse backs up (pos.z increases); combat un-regressed (enemy
DESTROYED at walk speed, 8 hits). NOTE: the blh set has no dedicated reverse CYCLE clip (stand/walk/run +
transitions only) → reverse plays the forward clip and drives the body backward (bring-up; real reverse in STEP 7).
- **STEP 3 — back the embedded controllers with real `AnimationInstance`** (`ReconSeq``AnimationInstance`,
0x60 bytes each at 0x65c/0x6bc; adapters SelectSequence→SetAnimation, Advance→Animate, Reset, keyframe
accessors). Infrastructure only; live path unchanged.
- **STEP 4 — call `LoadLocomotionClips(model)` at model build** (mech3.cpp:325) to populate
`animationClips[]`/`namedClip[]` + stride caps (`standSpeed`, `walkStrideLength`, ...). Safe after Step 3.
- **STEP 5 — bring-up feed for `movementMode@0x40` + `bodyTargetSpeed@0x6b4` + `commandedSpeed`** from
`gBTDrive.throttle` (the real `MechControlsMapper::InterpretControls` is deferred — reads unsafe App offsets).
- **STEP 6 — back `Matrix34`/`ReconMatrix` with the real engine affine matrix + base-region audit** of
`IntegrateMotion`'s transform offsets (`+0x260` dual-use pos/quat suspected mislabel; `+0x100` labeled
maxSpeed but used as `localToWorld`) vs `part_012.c`, anchored from `damageZoneCount@0x11c`.
- **STEP 7 — THE FULL CUTOVER:** run the real two-channel gait (`AdvanceLegAnimation`/`AdvanceBodyAnimation`
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
`gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait.
**Deferred polish (post-core):** airborne/fall gaits (AdvanceBody/LegAnimationAirborne), torso-twist-to-target
(Torso is FULLY reconstructed — TorsoSimulation twist/elevation @004b5cf0 + WriteJoints; needs wiring), gyro sway.
**First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds,
feet plant) on the already-proven engine path, with near-zero risk. Steps 3-7 are the deeper "authentic
two-channel gait" and can follow once the milestone is banked.
---
## ⭐ BASE-REGION RECONCILIATION (the STEP-6 audit — ground truth, the shared P3/P5/gyro de-risk)
This is THE root cause behind the P5 teardown crash, the gyro `+4` cross-link landmine, the dead `Mech::Simulate`
stomps, AND why `IntegrateMotion` can't be revived: the reconstructed `IntegrateMotion`/`Simulate` (mech4.cpp) use
**1995 raw binary offsets** (`this+0xNN`) that, in the 2007 RP411 engine base layout, land ON live engine fields.
**Ground-truth compiled layout (cdb `dt btl4!Mover` / `!JointedMover` / `!Mech`, from btl4.pdb):**
```
engine Mover/JointedMover: reconstructed Mech own region:
+0x0C8 localToWorld (LinearMatrix) +0x3D4 torsoAimCurrent
+0x0F8 localOrigin (Origin) +0x3E0 torsoAimTarget
+0x114 damageZoneCount +0x11C subsystemCount +0x3EC netOrientation (EulerAngles)
+0x124 updateOrigin (Origin) +0x458 movementMode +0x45C movementFlags
+0x194 creationTime (Time) +0x468 airborneSelect +0x46C forwardCycleRate
+0x250 projectedOrigin (Origin) +0x4A4 groundCycleRate +0x4A8 airborneCycleRate
+0x26C previousOrigin (Origin) +0x4C8 deathAnimationLatched
+0x288 projectedVelocity (Motion) +0x4D4 legAnimation +0x4E0 bodyAnimation (ReconSeq)
+0x2A0 updateAcceleration (Motion) +0x500 arrivalTime +0x504 simTime +0x508 spinRate
+0x2B8 updateVelocity (Motion) +0x598 motionEventName +0x5A4 motionEventArmed
+0x2D0 nextUpdate (Time)
+0x2D4..0x2EC collision cluster (Count/Volume/Template/containedByNode/Lists/last/Assistant)
+0x2F0 segmentTable +0x308 segmentCount +0x30C jointSubsystem (JointedMover ends 0x318)
```
**The stomp table — every `IntegrateMotion` raw offset vs the engine field it corrupts, and the correct target:**
| raw offset (1995) | intended 1995 field | 2007 engine field it STOMPS | correct target (declared member) |
|---|---|---|---|
| `this+0x100` | motion payload A | `localOrigin` (0xF8-0x114) | (relocate) `motionPayloadA` |
| `this+0x12c` | motion payload B | `updateOrigin` (0x124-0x140) | (relocate) `motionPayloadB` |
| `this+0x260` | motionDelta (Quat) | `projectedOrigin` (0x250-0x26c) | **declare** `motionDelta` |
| `this+0x26c` | worldPose (Quat) | `previousOrigin` (0x26c-0x288) | **declare** `worldPose` |
| `this+0x298`/`0x29c` | angular accum | `projectedVelocity` (0x288-0x2a0) | **declare** `angularAccum` |
| `this+0x2a4` | torsoAimTarget | `updateAcceleration` (0x2a0-0x2b8) | ✅ existing `torsoAimTarget`@0x3E0 |
| `this+0x2d4` | netOrientation | `collisionVolumeCount` | ✅ existing `netOrientation`@0x3EC |
| `this+0x598`/`0x5a4` | motionEventName/Armed | (past base, in Mech region) | ✅ existing `motionEventName`/`motionEventArmed` |
So `torsoAimTarget`, `netOrientation`, `motionEventName/Armed`, `arrivalTime`, `simTime`, `spinRate`, `movementMode/
Flags`, cycle rates ARE already relocated as declared Mech members — but `IntegrateMotion` still reads the RAW
offsets for them AND for the 3 un-declared motion fields (`motionDelta`/`worldPose`/`angularAccum`) + the 2 motion
payloads. Reviving `IntegrateMotion` as-is would corrupt `projectedOrigin`/`previousOrigin`/`projectedVelocity`/
`updateAcceleration`/the collision cluster → exactly the P5 teardown heap corruption.
## STEP-6/7 attack (revised, concrete)
1. **Lock the ground truth** (compile-time): `friend struct MechBaseLayoutCheck` with `static_assert(offsetof(...))`
on the engine-base fields (localOrigin@0xF8, projectedOrigin@0x250, previousOrigin@0x26c, collision cluster
@0x2d4-0x2ec, segmentTable@0x2f0) + the relocated members (netOrientation@0x3EC, torsoAimTarget@0x3E0,
arrivalTime@0x500, spinRate@0x508). Any future raw-offset stomp then fails the build.
2. **Declare the 3 missing motion members** (`motionDelta`/`worldPose` Quaternion, `angularAccum` Vector3D) +
the 2 motion payloads in the Mech's OWN region (offset > 0x318), so nothing lands on the engine base.
3. **Convert every `IntegrateMotion` raw `this+0xNN` (0xNN < 0x318) to the declared member** per the table above.
Same pass for the dead `Simulate` (so it's revivable) — this is the base-region audit HARD_PROBLEMS wanted.
4. **Back the 3 stub families with real engine types:** `ReconMatrix``AffineMatrix` ops (Identity/FromQuaternion/
transform-vector); `ReconQuat*`→real `Quaternion`; `ReconSeq`→real `AnimationInstance` (the two-channel gait
input, 0x60 bytes each @legAnimation/bodyAnimation).
5. **Revive `IntegrateMotion` in `PerformAndWatch`** (the cutover), retiring the Step-1/2 stand-in translation.
Verify: mech walks via the real gait, no engine-base stomp, no teardown crash, combat/heat un-regressed.
Payoff: this ALSO fixes the gyro cross-link (write to the correct relocated field, not gyro+4), makes the dead
`Simulate` revivable, and removes the P5 teardown corruptor — the single highest-leverage de-risk in the port.
### PROGRESS (this pass)
-**STEP 1 — layout locked:** `MechBaseLayoutCheck` (mech4.cpp, friend of Mech) static_asserts the engine-base
offsets (localOrigin@0xF8, projectedOrigin@0x250, previousOrigin@0x26c, collision cluster@0x2d4-0x2ec,
collisionLists@0x2e4, segmentTable@0x2f0) + the relocated members (torsoAimTarget@0x3E0, netOrientation@0x3EC,
arrivalTime@0x500, spinRate@0x508). All pass — the ground truth is proven in code; any stomp now fails the build.
-**STEP 2/3 (partial) — declared the missing motion members** (mech.hpp, appended so no locked offset shifts):
`motionDelta`/`worldPose`/`worldPoseBase`/`angularAccum` (Quaternion) + `motionSourceA/B`/`motionEventVector`
(Vector3D). **Converted the LIVE-PATH functions** `IntegrateMotion` (@004ab1c8) and `DeadReckonPose` (@004ab188)
— every raw `this+0xNN` (<0x318) now uses the declared member (0x260→motionDelta, 0x26c→worldPose, 0x298→
angularAccum, 0x100/0x12c→motionSourceA/B, 0x598→motionEventVector, 0x2a4→torsoAimTarget, 0x2d4→netOrientation,
0x138→worldPoseBase). Build green, combat un-regressed (TARGET DESTROYED, 0 crashes). The dead `Simulate`
(@004ab430) still holds raw offsets — DEFERRED (off the cutover path; convert when/if reviving it).
-**STEP 4 (partial) — `ReconMatrix` backed** with the real engine `AffineMatrix` (mechrecon.hpp): `Identity`
`BuildIdentity()`, `FromQuaternion`/`SetRotation``operator=(const Quaternion&)`. SAFE: its only live caller
(IntegrateMotion) uses a LOCAL `bodyFrame`; other callers are the dead Simulate.
-**STEP 4 — `ReconQuat` backed** (mechrecon.hpp): `ReconQuatIdentity``Quaternion::Identity`,
`ReconQuatIntegrate``Quaternion::Add(source, Vector3D delta)` (the exact FUN_00409f58 integrate). SAFE: all its
callers are dead (Simulate + the dead-until-cutover IntegrateMotion/DeadReckonPose). Also relocated the last
live-adjacent raw offset: the `this+0x1dc` "aim/torso" clear → a declared `aimRate` (Quaternion) member. (NOTE: I
earlier miscalled mech4.cpp:447 a LIVE PerformAndWatch call — it is actually in the dead `Simulate` telemetry tail
(<0x551 where PerformAndWatch starts), so ReconQuat had no live caller after all.) Build green, combat un-regressed.
-**STEP 4 remaining — the GAIT (`ReconSeq`) is the hard final piece, NOT a drop-in.** Verified: the leg/body
controllers use `SelectSequence`/`Advance`/`Reset` (FUN_004277a8/0042790c/004283b8) — a BT-specific **SequenceController**
keyframe player that **does NOT exist in the RP411 engine** (grep of MUNGA found no SelectSequence/Sequence/Controller).
So "ReconSeq→AnimationInstance" (the STEP-3 note) is inaccurate. TWO approaches, a real fork:
(A) **Reconstruct the SequenceController** from the binary (FUN_004277a8/0042790c/004283b8 + its keyframe-table
struct) and keep the authentic two-channel gait (AdvanceLeg/BodyAnimation + IntegrateMotion). Most faithful;
biggest. NOTE: retyping legAnimation/bodyAnimation to the full controller GROWS the Mech (they are ~0xC now vs
the binary's 0x60 each), shifting the relocated members after them (arrivalTime/spinRate) — update those
MechBaseLayoutCheck locks (they are by-name relocations, safe to move; the engine-base locks must NOT move).
(B) **Rewrite AdvanceLeg/BodyAnimation onto the engine `AnimationInstance`** (which STEP 1-2 already proved can
play the gait clips). Less faithful to the exact controller, reuses the engine, no SequenceController rebuild.
Also still a stub: **`FUN_00408744`** (mechrecon.hpp:391) — the world-step transform (transform angularAccum by the
bodyFrame matrix) IntegrateMotion needs; back it (AffineMatrix·Vector3D) as part of the cutover.
- **STEP 7 (revive) is unchanged** but gated on the gait decision above.
---
## ⭐ SequenceController RECONSTRUCTION SPEC (chosen path A — from the binary, part_003.c)
The BT gait controller (embedded `legAnimation`@0x65c / `bodyAnimation`@0x6bc, 0x60 bytes each). It is a BT-specific
keyframe animation player (NOT in the RP411 engine). Full decomp read: ctor @00427768, SelectSequence @004277a8,
Advance @0042790c, Reset @004283b8, dtor @004278d4. It parses the SAME animation-clip resource format the engine
`AnimationInstance` uses and writes joints through the SAME `Joint` API already backed for the gyro/torso.
**Object layout (0x60; offsets are byte offsets into the controller):**
```
+0x00 vtable (Plug base, FUN_00415e90 ctor) +0x34 rootTranslation[] table (0xC/frame; .z@+8 = stride)
+0x0c (base refcount region) +0x38 jointSubsystem (resolved from owner Mech +0x31c)
+0x14 frameCount (resource hdr[0]) +0x3c currentFrame (int)
+0x18 jointCount (resource hdr[1]) +0x40 owner (Mech*)
+0x1c jointIndices[] (jointCount ints) +0x44 clipResource (ref-counted handle)
+0x20 hdr+2 (metadata ptr) +0x48 finishedCallback (Mech method ptr)
+0x24 frameTimes[] (frameCount floats) +0x4c cbArg2 +0x50 cbArg3
+0x28 keyframeBase +0x2c keyframeCursor +0x54 currentTime (Scalar)
```
**Methods (behaviour + engine mapping):**
- **ctor(mech):** Plug base; store owner@0x40; `jointSubsystem@0x38 = mech->GetJointSubsystem()` (binary resolves
mech+0x31c); clipResource@0x44 = 0.
- **SelectSequence(clipID, cb, a2, a3):** store cb@0x48/a2@0x4c/a3@0x50; reset currentFrame@0x3c + currentTime@0x54;
release old clipResource; `FindResourceDescription(clipID)` → clipResource@0x44; parse the clip: frameCount@0x14,
jointCount@0x18, jointIndices@0x1c, frameTimes@0x24, keyframe pointers@0x28/0x2c; compute the per-frame keyframe
stride@0x34 by summing joint DOF sizes (hinge<3 →8B, ball==4 →0xC, balltrans==5 →0x18).
- **Advance(dt, moveJoints) -> Scalar distance:** `t = currentTime + dt`. Walk keyframes while `frameTimes[cur] <= t`:
for each joint (jointSubsystem->GetJoint(jointIndices[i])) snap to the keyframe pose (hinge→SetRotation(Radian),
ball→SetRotation(EulerAngles), balltrans→ + SetTranslation) IF moveJoints; accumulate `distance += (frameTimes[cur]
- currentTime) * rootTranslation[cur].z`; advance cur/currentTime. If clip ended (cur==frameCount): call the
finishedCallback (owner->*cb)(clipResource, carryover, moveJoints) and add its distance. Else interpolate the
partial frame (ratio = (t - currentTime)/(frameTimes[cur]-currentTime)); per joint LERP/SLERP between keyframes
(FUN_00409390 slerp / FUN_00408848 translation-lerp / FUN_00408dd4 hinge-lerp) and write; accumulate the partial
distance. Return distance. Engine helper map: joint iter→`JointSubsystem::GetJoint`; GetEulerAngles=FUN_0041cfa0;
SetRotation(Radian)=FUN_0041d0a8; SetRotation(EulerAngles)=FUN_0041d020; SetTranslation=FUN_0041d11c;
SetHinge/direct=FUN_0041cfc8; close-enough=FUN_00408d78(radian)/FUN_004091f4(euler)/FUN_004084fc(point);
slerp=FUN_00409390; fabsf=FUN_004dcd00.
- **Reset(loop):** reset each joint to a default pose (identity quat + default euler/translation) via the per-type set.
- **dtor:** reinstall vtable + release clipResource.
**Wiring:** replace the `ReconSeq` stub (mechrecon.hpp) with this class (keep a `typedef ... ReconSeq` so mech2's
`legAnimation.SelectSequence/Advance/Reset` calls are unchanged); retype is automatic. The Mech GROWS (0xC→0x60 per
controller = +0xA8) → move + update the by-name locks after them (arrivalTime/simTime/spinRate); engine-base locks
UNCHANGED. The Mech ctor must construct legAnimation/bodyAnimation with `this` (owner). Then STEP 7 cutover.
### ✅ BUILT (seqctl.cpp) — the SequenceController is reconstructed, linked, integrated (inert until the cutover)
- `SequenceController` class in mechrecon.hpp (extends the old ReconSeq: keeps keyframeCount/keyframeTimes/keyframeData
accessors for mech3; adds the real fields; `typedef SequenceController ReconSeq`). `seqctl.cpp` implements Init
(ctor logic — resolves `owner->GetJointSubsystem()`), SelectSequence (find via `application->GetResourceFile()->
SearchList(clip_id, 16)` + Lock + parse frameCount/jointCount/jointIndices/frameTimes/pose-base/rootTranslations),
Advance (snap full keyframes + interpolate the partial frame via the engine Joint API, accumulate the
rootTranslation.z distance, loop at end-of-clip), Reset (neutral pose per joint type), dtor (Unlock the clip).
`Hinge`==8B confirmed (`{int axisNumber; Radian rotationAmount}`) → keyframe hinge snap `SetHinge(*(Hinge*)cursor)`
is byte-faithful. Joint I/O reuses the same engine API the gyro/torso use (GetJointType/GetEulerAngles/GetTranslation
/ SetHinge/SetRotation(Radian|EulerAngles)/SetTranslation).
- Added seqctl.cpp to btbuild/CMakeLists.txt. Full build GREEN; combat un-regressed (TARGET DESTROYED, 0 crashes).
Inert for now: its callers (mech2 AdvanceLeg/BodyAnimation via IntegrateMotion, mech3 LoadLocomotionClips) are all
DEAD until the cutover, so the retype + link changes no live behavior.
- **Two known simplifications to revisit at the cutover (STEP 7):** (a) end-of-clip currently loops directly (reset to
frame 0) instead of invoking the stored member-fn-ptr finished-callback @0x48 — faithful in INTENT (that callback is
Mech::OnBodyAnimFinished's re-arm), refine if a non-looping callback is needed; (b) partial-frame rotation uses a
component LERP of the euler/hinge (the binary FUN_00409390 slerps) — visually identical for small gait deltas, upgrade
to slerp if needed.
- **Remaining for STEP 7 cutover (task 13):** (1) Mech ctor must call `legAnimation.Init(this)` + `bodyAnimation.Init(this)`
(currently default-constructed with jointSubsystem=0 — fine while inert, REQUIRED before use); (2) call
`LoadLocomotionClips(model)` at model build to populate the clips; (3) back the `FUN_00408744` world-step transform;
(4) revive `IntegrateMotion` in `PerformAndWatch`, retiring the STEP-1/2 stand-in. First point where it all
runtime-verifies together.
### ✅ STEP 7 CUTOVER — DONE & VERIFIED LIVE (gated `BT_GAIT_CUTOVER=1`; the real controller drives locomotion)
Rather than reviving the FULL `IntegrateMotion` (leg+body channels + world-step + DeadReckonPose, whose position-apply
lives in the dead `Simulate`/`MoveAndCollide`) in one leap, the cutover swaps the STEP-1/2 forward-step SOURCE to the
real `SequenceController` on the PROVEN position path (`localOrigin += facing*adv`). This runtime-verifies the
reconstructed controller with minimal risk and is the meaningful milestone (the authentic gait controller drives the
mech). Implemented + verified:
- **(1) DONE** — Mech ctor (`mech.cpp`, after the body-load `ResolveJoint("jointlocal")`, where `jointSubsystem` is
valid) calls `legAnimation.Init(this)` + `bodyAnimation.Init(this)`. Runs always (harmless when the cutover is off).
- **cutover branch** — `PerformAndWatch` (mech4.cpp), gated `static int s_gaitCutover = getenv("BT_GAIT_CUTOVER")`.
On a gait CHANGE it `bodyAnimation.SelectSequence(ResolveAnimationClip("blh",suffix), (void*)1 /*loop*/, 0, 0)`; each
frame `adv = bodyAnimation.Advance(dt,1)` (the real controller animates the skeleton AND returns the clip forward
step) → `localOrigin.linearPosition += facing*adv*dir`. STEP-1/2 `gBodyAnim` path preserved as the `else` (default).
- **KEY BUG (found via cdb av in `ResourceFile::SearchList+0x77`, RESOURCE.cpp:498):** `SelectSequence` fetched the
clip with `rf->SearchList(clip_id, 16)` — but `SearchList(list_id,type)` treats its arg as a resource LIST (reads the
resource's data as an ID array + iterates) → it walked clip 904's animation bytes as garbage resource IDs → crash.
The `clip_id` from `ResolveAnimationClip` is already a DIRECT resource ID → fixed to `rf->FindResourceDescription(clip_id)`
+ `Lock`, which mirrors the engine's own clip load `AnimationInstance::SetAnimation` (JMOVER.cpp:1406) EXACTLY (same
header layout too: hdr[0]=frameCount, hdr[1]=jointCount, hdr[2]=footStepThreshold skipped, hdr[3]=jointIndices).
- **Verified live under cdb** (`BT_FORCE_THROTTLE=1 BT_GAIT_CUTOVER=1`): `[gait] SequenceController -> blhrrl id=904
(run)`, mech walks (adv≈1.07, matching the STEP-1/2 baseline; pos advances), 0 crashes. Combat regression
(`+BT_SPAWN_ENEMY +BT_FORCE_FIRE`): identical to a same-harness BASELINE (no-cutover) run — both fire/heat/damage the
same (`FIRED #1..#141`, `structure` climbs 0.133/hit) and both walk PAST the stationary dummy before 8 hits (no
DESTROY in-window) — i.e. NOT a regression, just harness geometry. `SequenceController::Advance` runs live for the
first time with no crash.
- **DECOMP FACT (`Advance@0042790c` raw, part_003.c:6722):** distance is LUMPY BY DESIGN — whole keyframes add
`(frameTimes[cur]-currentTime)*rootTrans[cur].z` (matches seqctl.cpp:217); the partial-frame else-branch (6821)
interpolates joints but adds NO distance; end-of-clip (6814) CALLS the finished-callback `@0x48` recursively (passing
the carryover time) and folds its return into distance. Smoothness in the real game comes from VELOCITY integration
in `IntegrateMotion`, not per-Advance — so applying `adv` directly to position (this cutover) is inherently lumpy;
that's expected, faithful to the distance fn, and superseded once the velocity path lands.
- **STILL GATED (not default) pending fidelity refinements:** (a) end-of-clip `carryover*stride[last]` in place of the
recursive finished-callback `@0x48` (source of the occasional adv spike at loop boundaries — needs `OnBodyAnimFinished`
wired as the real member-fn-ptr callback that re-arms + recursively continues `Advance`); (b) component-LERP → slerp
(`FUN_00409390`); (c) the deep win — the FULL `IntegrateMotion` velocity path (leg+body via mech2 `AdvanceLeg/
BodyAnimation`, the `FUN_00408744` world-step, `DeadReckonPose`) which smooths the lumpy step and enables MP dead-reckon.
### ✅ STEP 7 DEEPER — the AUTHENTIC world-step (real velocity model) drives locomotion LIVE
Took the "harder path": replaced the cutover's direct position slide with the real `IntegrateMotion`
motion-tail (`@004ab1c8`), so the mech now moves through the authentic velocity→rotate→integrate model.
- **`FUN_00408744` BACKED** (mechrecon.hpp; was a no-op template stub): the world-step matrix×vector
(part_000.c:8331, `out[i]=Σ_j v[j]·M(i,j)`). Backed via the engine `AffineMatrix::GetFromAxis` column
basis — `out = v.x·colX + v.y·colY + v.z·colZ` reproduces the raw row-dot EXACTLY, and it is the SAME
`GetFromAxis` convention the drive facing uses, so sign-consistent. Only real caller = mech4.cpp:304
(gyro only name-drops it in comments).
- **KEY MAPPING (verified):** the 1995 motion transform `{ Point3D @0x260; Quaternion @0x26c }` IS the
engine `localOrigin` — `Origin = { Point3D linearPosition; Quaternion angularPosition }` (ORIGIN.h:15);
raw `FUN_0040ab44` builds the matrix from BOTH halves (rot from 0x26c, translation from 0x260). So the
motion state maps directly onto `localOrigin`, NOT the parallel `motionDelta`/`worldPose` placeholders.
- **Cutover branch now (mech4.cpp):** `adv = bodyAnimation.Advance` → `localVel = {0,0,-adv/dt}` →
`Matrix34::FromQuaternion(orient, localOrigin.angularPosition)` → `FUN_00408744(worldVel, localVel,
orient)` → `localOrigin.linearPosition += worldVel*dt`. == `facing*adv`, through the real machinery.
- **Latent bug fixed** in the (still-dead) full `IntegrateMotion`: it set only `spinRate`@0x508 and left
`angularAccum[2]` (velocity.z the world-step reads) stale → now sets `angularAccum[2] = -adv/dt`.
- **Verified:** walk-only `BT_GAIT_CUTOVER=1` walks straight fwd ~60 u/s, 0 crashes; combat fire/damage
identical to STEP-1/2. ⚠ A combat run faulted in the ENGINE BGF loader (`bld08.bgf`, `Builder::~Builder`)
walking into a building's range — pre-existing heap fragility, position-dependent (STEP-1/2 fired 600+
clean), NOT the world-step (zero heap ops). Track separately.
### Remaining full-`IntegrateMotion` work (the rest of the harder path)
1. **`AdvanceBodyAnimation` gait STATE MACHINE** (mech2.cpp:506, real — stand→walk→run transitions via
`SetBodyAnimation(state)` → `animationClips[state]`) in place of the inline `ResolveAnimationClip`+
`SelectSequence`. Needs #2.
2. **`LoadLocomotionClips`** (mech3.cpp:326, real) called at model build (Mech ctor) to populate
`namedClip[]`/`animationClips[]` + gait-speed caps (standSpeed/walkStride/reverseSpeedMax/gimp…) —
measured via `legAnimation.SelectSequence` + `keyframeData` (now that the SequenceController is real).
OPEN: reconcile `namedClip[]`@0x5e0 vs `animationClips[]`@0x5cc + the state→clip index mapping.
3. **Orientation INTEGRATION** — the real turn is angular-rate integration into `localOrigin.angular
Position` (raw `FUN_00409f58(0x26c, 0x26c, angRate*dt)`), driven by control input, not the bring-up
`gDriveHeading`. (Bring-up keeps `gDriveHeading` until the controls mapper is un-bypassed.)
4. **Velocity STORAGE + `DeadReckonPose`** — store the world velocity (raw @0x298) for MP dead-reckon;
`DeadReckonPose(fraction)` projects the render pose forward between sim ticks (the true render smoothing).
5. Then call the real `Mech::IntegrateMotion` (retargeted to `localOrigin`) from the cutover, retiring the
inline world-step.
### ✅ STEP 2 (steps 1-2 of the full IntegrateMotion) — LoadLocomotionClips + the gait STATE MACHINE drive the walk LIVE
Gated `BT_GAIT_SM` (requires `BT_GAIT_CUTOVER`). The real `Mech::AdvanceBodyAnimation` now drives locomotion.
- **Step 1 — `LoadLocomotionClips`** called in the Mech ctor (gated), completes cleanly for the Blackhawk:
resolves the full gait set + measures the speed caps (verified `walkStride=22.02`, `standSpeed=6.83`).
**RECONCILIATION FIX:** `namedClip[]`@0x5e0 and `animationClips[]`@0x5cc were declared as SEPARATE arrays
but in the binary are ONE (`namedClip[i]==animationClips[i+5]`, 0x5e0==0x5cc+0x14) -> the loader's writes
weren't visible to `SetBodyAnimation`/`MeasureClipStride`. Fixed: `namedClip` is a pointer aliased to
`&animationClips[5]` (ctor). Same class of bug as the field-shadowing systemic issue.
- **Step 2 — `AdvanceBodyAnimation` state machine** drives the walk: case 6/7 slews `bodyCycleSpeed` toward
`bodyTargetSpeed` with the loaded caps, Advances `animationClips[6]=wwr`, returns the cycle distance ->
world-step. **FIX:** `bodyAnimationState`@0x728 == `bodyStateAlarm.level` (also declared-separate) -> re-synced
from the alarm at the top of AdvanceBodyAnimation so `SetBodyAnimation`'s level reaches the switch.
- Verified live: mech walks forward (z 1600->-975), `adv~0.35-0.42` (authentic walk cadence, < run's 1.07),
clip loops its 15 keyframes, 0 crashes.
- **Bring-up substitute (MARKED, mech2.cpp):** `SetBodyAnimation` passes a non-null loop sentinel `(void*)1`
where the binary passes `PTR_LAB_0050d6fc` = the real body finished-callback (gait TRANSITION stand->walk->run
+ leg alternation, @0x48). Without a non-null cb the SequenceController leaves the cycle stuck at its last
keyframe (`adv=0`). Reconstruct `PTR_LAB_0050d6fc` for authentic transitions.
### Remaining for the FULL gait (after steps 1-2)
1. **Reconstruct `PTR_LAB_0050d6fc`** (+ the leg `PTR_LAB_0050d6f0`) -- the finished-callback that transitions
gait states (stand->walk->run->reverse) + alternates legs. Replaces the `(void*)1` loop sentinel.
2. **LEG channel** `AdvanceLegAnimation` for visible leg stepping (body channel already drives world motion).
3. **Gait SELECTION** from throttle -> `bodyTargetSpeed`/`movementMode` (currently forces walk state 6);
wire walk/run/reverse via the real commanded speed (the `MechControlsMapper` is still bypassed).
4. Then orientation integration + velocity storage + DeadReckonPose (the later IntegrateMotion pieces).
### ✅ TRANSITION CALLBACK — reconstructed + wired (walk/run leg alternation + walk->run transition)
The real gait finished-callback (was a `(void*)1` loop sentinel).
- **Resolved the .data fn ptr by PE-parsing** BTL4OPT.EXE: `PTR_LAB_0050d6fc`@0x50d6fc -> body cb **0x4a6d8c**;
`PTR_LAB_0050d6f0`@0x50d6f0 -> leg cb **0x4a6928**; cbArg2/3 = 0. Neither in the assert-anchored decomp, so
**disassembled with capstone** + decoded the jump table (byte idx @0x4a6de9, dword targets @0x4a6e0a; 33 states
-> 10 handlers). Reusable: `/tmp/readptr.py` (PE VA->DWORD) + `/tmp/disas.py`, `/tmp/jt.py`, `/tmp/dh*.py`.
- **Reconstructed `Mech::BodyClipFinished` (= FUN_004a6d8c)** byte-for-byte (mech2.cpp): dispatch on
bodyAnimationState, compare bodyTargetSpeed to the caps (standSpeed/walkStrideLength/reverseSpeedMax), pick the
next state, `SetBodyAnimation(next)` (re-binds with THIS callback) + `bodyAnimation.Advance(carryover-derived)`
via the shared tail `Mech::BodyTransition`. Walk 6<->7, run 12<->13, stop->8/9, walk->run 6->11->12.
- **Plumbing:** `SequenceController::Advance` now CALLS `cbCode` as `Scalar(*)(Mech*,unsigned,Scalar,int)` at
end-of-clip (matches the binary `**(code**)(this+0x48)`), folding the returned distance. `SetBodyAnimation`
passes `&Mech::BodyClipFinished`; the inline cutover passes `&Mech::LoopBodyClip` (bring-up loop).
- **Verified live (cdb):** walk alternates 6<->7 (adv~0.42), run alternates 12<->13 (adv~0.5-0.85), walk->run
transition fires (6->11->12), pos advances, 0 crashes; inline cutover (LoopBodyClip) + combat un-regressed.
- **Fixed a bring-up gap:** `reverseSpeedMax2`@0x7a0 (run bodyCycleSpeed clamp) unset by LoadLocomotionClips ->
0xCDCDCDCD -> run exploded; set to reverseStrideLength (case-12 divisor -> ratio~1).
- **Remaining:** LEG callback FUN_004a6928 (state@0x3b0 + motion source@0x128); airborne FUN_004a6344; gimp
handlers (16-19; targets 0x70b2/0x7161 undecoded); gait SELECTION from real throttle (mapper still bypassed).
### ✅ PILLAR A COMPLETE — leg channel + orientation + velocity storage + DEFAULT-ON
- **LEG finished-callback** FUN_004a6928 (== PTR_LAB_0050d6f0) reconstructed (`Mech::LegClipFinished`,
mech2.cpp): jump table @0x4a69aa decoded (same 33-state shape as the body's); compares the LIVE mapper
speedDemand (*(subsystemArray[0])+0x128 -> typed mirror controlsMapper), slews legCycleSpeed@0x348,
re-arms via SetLegAnimation; gimp alternates 0x12<->0x13 with |ratio| (gimpStrideLength stored negative).
- **Two-channel split LIVE** (real-controls): leg = joints from live demand, body = motion only
(Advance(dt, 0)). Natural Standing entry verified: legState 0 -> 5 -> 11 -> 13<->12 @ 61.5 u/s.
- **RAW FIX: Standing case INVERTED in BOTH channels** (raw case 0: standSpeed < commanded -> walk(5);
commanded < 0 -> reverse(0x10); else stand). Also: AdvanceLegAnimation controlSource double-deref AV ->
controlsMapper->speedDemand; legAnimationState==legStateAlarm.level re-sync.
- **Orientation**: yaw rate composed via Quaternion::Add (FUN_00409f58 form) -- spawn orientation kept
authentic (turn-rate constant still bring-up).
- **Velocity storage every frame**: worldLinearVelocity + localVelocity (fwd -Z + yaw rate) == the
Mover::WriteUpdateRecord publish set = the MP dead-reckon writer data.
- **DEFAULTS FLIPPED** (BTEnvOn): BT_GAIT_CUTOVER / BT_GAIT_SM / BT_COLLISION / BT_REAL_CONTROLS default
ON; "=0" opts back to the bring-up paths (verified). BT_SPAWN_ENEMY now places the dummy along the
spawn FACING (the authentic orientation exposed the old fixed-offset assumption).
- Verified: env-free default run = full authentic chain, combat damage -> 0.933, 0 crashes; all =0
fallbacks work. P3 is CLOSED for multiplayer purposes (the update-writer data is maintained).
+1555
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# BT Port — Resource-Layout Audit (resource-layout-audit workflow, 33 agents, adversarially confirmed)
> **✅ STATUS: ALL 8 BUGS FIXED & VERIFIED** (commits 802a7a6 A, 7de66ad B, 975a397 Gyro+Searchlight).
> Each layout locked with compile-time `static_assert(offsetof/sizeof)` guards; full build green; combat +
> heat-flow un-regressed. The plan below is retained as the record of what was found and done.
**Verdict:** 24 `*__SubsystemResource` structs audited → **8 confirmed bugs** (all high-severity,
review-upheld, none refuted), 16 clean. All 8 root to **2 broken base resources** (`HeatWatcher`,
`PowerWatcher`) + **2 struct-specific field defects** (`Gyroscope`, `Searchlight`).
Same class as the heat-resource bug fixed earlier (§10d): a resource struct that doesn't mirror its
class hierarchy reads every field from the wrong offset — SILENT garbage (non-fatal). These 6 Watcher-
family subsystems (AmmoBin, Gyroscope, Searchlight, ThermalSight, Torso, HUD) currently TICK on garbage
resource data. Combat is unaffected (mech/weapons/damage/heat don't depend on them) — it bites when
WAVE 4-8 consumes gyro/torso motion, sensor/searchlight/thermalsight readouts, and ammo.
## Raw-decomp verification (done this session — the fix is bigger than "one-liner")
- `HeatWatcher::CreateStreamedSubsystem` = `FUN_004aec54` chains to `FUN_004ac9ec` (the **MechSubsystem**
parse, fills 0x30..0xE4 — armorByFacing/videoObjectName/alarmModel/... verified) then adds only
`DegradationTemperature@0xE8` + `FailureTemperature@0xEC`. Record ends **0xF0** (MechSubsystem + 3).
- `HeatWatcher` ctor `@4aeb40` chains to `FUN_004ac644` (MechSubsystem-level base ctor, shared with the
HeatSink ctor) and reads only `res+0xe4/0xe8/0xec` — NO thermal-mass/heatSinkIndex reads. So HeatWatcher
is genuinely **MechSubsystem-based**, not a full HeatableSubsystem.
- `PowerWatcher` ctor chains to `FUN_004aeb40` = **HeatWatcher's ctor**`PowerWatcher : HeatWatcher`
(powersub.hpp already comments "real base is HeatWatcher"). Adds `MinVoltagePercent@0xF0`, record ends **0xF4**.
- The reconstruction implemented BOTH classes as `: public HeatableSubsystem` (an approximation, with the
real base in `// FUN_...` comments) → the resource was made HeatableSubsystem-based (0xFC) → every
Watcher field slid +0x18 (HeatWatcher) / +0xC (PowerWatcher). Fixing the resource base alone will NOT
compile (the ctor passes the resource to the HeatableSubsystem base ctor) → class+ctor+parser must change together.
## Fix plan (bases before children; each verified by offset logging `(char*)&res->f - (char*)res`)
1. **Root cause A — HeatWatcher → MechSubsystem** (heatfamily_reslice.hpp/.cpp):
- class `HeatWatcher : public MechSubsystem` (was HeatableSubsystem)
- resource `HeatWatcher__SubsystemResource : public MechSubsystem__SubsystemResource` (was HeatableSubsystem::SubsystemResource); keep `int watchedSubsystem;`(0xE4) `Scalar degradationTemperature;`(0xE8) `Scalar failureTemperature;`(0xEC) → ends **0xF0**
- ctor chains `MechSubsystem(...)`; parser chains the MechSubsystem parse (FUN_004ac9ec)
- de-shadow any fields HeatWatcher inherited from HeatableSubsystem it no longer has
- **Fixes HeatWatcher + AmmoBin** (child; fields realign to 0xF0/0xF4/0xF8/0xFC/0x100, size 0x104). Target stamp: HeatWatcher **0xF0**, AmmoBin **0x104**.
2. **Root cause B — PowerWatcher → HeatWatcher** (powersub.hpp/.cpp; depends on A):
- class `PowerWatcher : public HeatWatcher`; resource `... : public HeatWatcher::SubsystemResource`, keep `Scalar minVoltagePercent;`(0xF0) → ends **0xF4** (do NOT pad — would break the 0xF4 stamp)
- powersub.hpp must `#include` the HeatWatcher resource (`heatfamily_reslice.hpp`)
- ctor chains `HeatWatcher(...)`; parser chains HeatWatcher parse (FUN_004aec54, WatchedSubsystem/Degradation/Failure keys)
- **Fixes PowerWatcher + HUD + ThermalSight + Torso** (children realign, no child edits). Target stamps: PowerWatcher **0xF4**, Torso **0x158**.
3. **Gyroscope** (gyro.hpp; after B): add `Scalar field_f4;`(0xF4, ctor `FUN_004b3778` reads it, init -1.0f — best-effort name) BEFORE `exageration`(0xF8). Then fields align through 0x21C.
4. **Searchlight** (searchlight.hpp:75 + searchlight.cpp:96; independent, after B): delete `int segmentDefault;`
(compiles OOB at sizeof(PowerWatcher)); the binary reads `resource+0x28` = inherited `segmentIndex`
change searchlight.cpp:96 to `commandedOn = subsystem_resource->segmentIndex;`.
**Clean / no action (16):** Emitter, HeatableSubsystem (correctly 0xFC — do NOT touch; HeatSink/Generator/
PoweredSubsystem/Reservoir/Condenser depend on it), Condenser, Reservoir, MechSubsystem, MechTech,
MechWeapon, SubsystemMessageManager, MissileLauncher, MissileThruster, Myomers, PoweredSubsystem,
Generator, ProjectileWeapon, Seeker, Sensor.
**Rejected proposal:** aliasing HeatWatcher fields onto the 0xFC HeatableSubsystem base via accessors/union
— leaves sizeof=0xFC, contradicts the 0xF0 stamp, cascades the wrong size into PowerWatcher. Use the re-base.
**Verify after each step:** offset logging vs stamps (HeatWatcher 0xF0, PowerWatcher 0xF4, AmmoBin 0x104,
Torso 0x158, Gyroscope ends 0x21C); build green; combat/heat/torso un-regressed.
+218
View File
@@ -0,0 +1,218 @@
I have verified the factory switch (mech.cpp 556-741), the stub block (145-181), the corrected ClassID map (CLASSMAP.md), and the heat base re-declarations (heat.hpp 168-242 vs mechsub.hpp 120-255). The findings are corroborated by the source. Here is the unified spec.
---
# UN-STUB THE BATTLETECH SUBSYSTEM ROSTER — SYNTHESIS PLAN
Ground truth used throughout: **the ctor address in each factory case's `// FUN_004xxxxx` comment, reconciled via `CLASSMAP.md`** — NOT the `case <Name>ClassID` enum label, which is systematically mislabeled. The numeric classID is correct; the label and stub-struct name are wrong.
## Master case → real-class table (authoritative; from mech.cpp 567-689 + CLASSMAP)
| factory case (line) | classID | ctor | alloc | current stub | **REAL class** | file (status) | special action |
|---|---|---|---|---|---|---|---|
| CockpitClassID (569) | 0xBBD | 4ae568 | 0x230 | `Cockpit` | **Condenser** | heatfamily_reslice (DONE) | — |
| SensorClassID (574) | 0xBBE | 4ae8d0 | 0x1e4 | `Sensor` | **HeatSink aggregate/bank** | heatfamily_reslice (`#if 0` skeleton) | `sensorSubsystem=arr[id]` (0x1f7) = the heat bank, NOT a sensor |
| CondenserClassID (580) | 0xBC0 | 4af408 | 0x230 | `Condenser` | **Reservoir** | heatfamily_reslice (DONE) | — |
| GeneratorClassID (585) | 0xBC1 | 4b225c | 0x250 | `Generator` | **Generator** | powersub.cpp (DONE) | — |
| PoweredSubsystemClassID (590) | 0xBC2 | 4b0f74 | 0x31c | `PoweredSubsystem` | **PoweredSubsystem** | powersub.cpp (DONE) | — |
| MyomersClassID (595) | 0xBC3 | 4b1d18 | 0x328 | `Myomers` | **Sensor** | sensor.cpp (DONE) | — |
| GyroClassID (600) | 0xBC4 | 4b3778 | 0x3d0 | `Gyro` | **Gyroscope** | gyro.cpp (DONE) | `gyroSubsystem=arr[id]` (0x14a) |
| SinkSourceClassID (606) | 0xBC5 | 4b6b0c | 0x280 | `HeatSinkSource` | **Torso** | torso.cpp (DONE) | `sinkSourceSubsystem=arr[id]` (0x10e) |
| ActuatorClassID (612) | 0xBC6 | 4b8fec | 0x358 | `Actuator` | **Myomers** | myomers.cpp (DONE) | — |
| WeaponEmitterClassID (617) | 0xBC8 | 4bb120 | 0x478 | `EmitterWeapon` | **Emitter** | emitter.cpp (DONE) | `++weaponCount` |
| JumpJetClassID (623) | 0xBCB | 4bd5c4 | 0x22c | `JumpJet` | **AmmoBin** | ammobin.cpp (DONE) | — |
| MechWeaponClassID (628) | 0xBCD | 4bc3fc | 0x448 | `MechWeapon` | **ProjectileWeapon** | projweap.cpp (DONE) | `++weaponCount` |
| MissileWeaponClassID (634) | 0xBCE | **4bdcb4** | 0x484 | `MissileLauncher` | **UNRESOLVED** (no CLASSMAP entry) | — | `++weaponCount`**BLOCKED** |
| BallisticWeaponClassID (640) | 0xBD0 | 4bcff0 | 0x44c | `BallisticWeapon` | **MissileLauncher** | mislanch.cpp (DONE) | `++weaponCount` |
| MechControlsMapperID (646) | 0xBD3 | 49bca4 | 0x130 | `MechControlsMapper` | **SubsystemMessageManager** | messmgr.cpp (DONE) | `controlsMapper=arr[id]` (0x10d) — verify, see Risk 4 |
| GaussWeaponClassID (652) | 0xBD4 | 4bb888 | 0x478 | `GaussRifle` | **PPC** (Emitter subclass) | emitter.cpp bridge | `++weaponCount` |
| MechTechClassID (658) | 0xBD6 | 4b7f94 | 0x2a4 | **MechTech (real, MIS-WIRED)** | **HUD** | hud.cpp (DONE) | `mechTechSubsystem=arr[id]` (0x16d) — see Risk 4 |
| LegSubsystemClassID (664) | 0xBD8 | 4b84dc | 0x238 | `LegSubsystem` | **Searchlight** | searchlight.cpp (DONE) | — |
| HeatableClassID (669) | 0xBDC | 4ad228 | 0x140 | `HeatableSubsystem` (**ptr discarded — bug**) | **MechTech** | mechtech.cpp (DONE) | must `arr[id]=…` + cache mechTech |
| DisplayClassID (679) | 0xBDE | 4b8718 | 0x234 | `MechDisplay` | **ThermalSight** | thermalsight.cpp (DONE) | — |
There is **NO real Cockpit / Sensor(@0xBBE) / Actuator / JumpJet / BallisticWeapon / LegSubsystem / MechDisplay class** — every one of those stub names is a mislabel for a different, already-reconstructed class.
Two confirmed wiring bugs visible in the source: (a) **case 0xBDC builds `HeatableSubsystem` and never stores the pointer** (line 676 has no `subsystemArray[id] =`); (b) **the real `MechTech` class is instantiated at case 0xBD6** (line 660, alloc 0x2a4 = HUD's size) — MechTech and HUD are swapped.
---
## 1. THE STUB → REAL SWAP RECIPE (general)
**Architecture decision (applies to every family): use a per-family factory-bridge function, do NOT `#include` the real subsystem headers into mech.cpp.** mech.cpp defines local bring-up stubs whose names collide with the real classes (`Condenser`, `Generator`, `PoweredSubsystem`, `Myomers`, `MechWeapon`, `MissileLauncher`, …). Including the real headers there is a redefinition storm. Instead:
**Per real class, in its own `.cpp` (which already includes its real header):**
```cpp
Subsystem *
Create<Class>Subsystem(Mech *owner, int id, void *seg)
{
Check(sizeof(<Class>) <= SIZE); // SIZE = the factory alloc literal (binary's true size)
return (Subsystem *) new (Memory::Allocate(SIZE))
<Class>(owner, id, (<Class>::SubsystemResource *)seg
/*, <Class>::DefaultData [, extra real ctor params] */);
}
```
- `SIZE` = the exact `Memory::Allocate(0x…)` literal already in the factory case (these are the binary's real allocation sizes — keep them).
- The `Check(sizeof <= SIZE)` guard catches a placement-new overrun, which is the source of the intermittent ~1-in-6 startup heap-corruption crash (base ctor writes `vitalSubsystemIndex@0x110` → object must be ≥ 0x114).
- The trailing `<Class>::DefaultData` matches the real ctor signature `(Mech*, int, SubsystemResource*, SharedData& = DefaultData [, …])`. Where the factory already passes `…::DefaultData`/`,0,0`, preserve those real tail params.
**In mech.cpp:**
```cpp
extern Subsystem *Create<Class>Subsystem(Mech *, int, void *); // near the forward-decls
...
case <Name>ClassID: // keep the numeric classID; the LABEL stays as-is
subsystemArray[id] = Create<Class>Subsystem(this, id, seg);
/* preserve ++weaponCount and/or the special cache (sensorSubsystem=…/gyroSubsystem=…) */
break;
```
Then delete the now-dead `RECON_SUBSYS(<StubName>);` line for that class. Leave a `RECON_SUBSYS` only where no reconstruction exists yet (the 0xBCE @4bdcb4 case).
### The 4 systemic checks — apply to EVERY real class before/while swapping
1. **Shadowing (re-declared base fields).** Grep the class + its bases for fields that already exist in the engine `Subsystem`/`Simulation` chain or in the reconstructed BT base (`MechSubsystem`/`HeatableSubsystem`/`HeatSink`/`PoweredSubsystem`): `owner`/`hostEntity` (→ `Subsystem::owningEntity`, use a `GetEntity()` cast), `damageZone` (→ engine `Subsystem::damageZone`), `simulationState`/`destroyed` (→ `MechSubsystem::simulationState`), `simulationFlags`/`flags`/`statusFlags`, `subsystemID`/`subsystemId2`, `subsystemName`, `lastPerformance`/`lastUpdate`/`updateModel`, `sharedData`, `classID`. **Delete the re-declaration; use the inherited member/accessor.** A shadow both (a) leaves the engine's value unread (the recon copy is `0xCDCDCDCD`) and (b) lands at the wrong compiled offset. **NOT a shadow (leave as is):** each class's own `typedef … Performance;` + `SetPerformance(Performance)` (deliberately writes the inherited `activePerformance`), its own `typedef … SubsystemResource;`, and `static …DefaultData`. After de-shadowing, confirm `sizeof(Class) ≤ alloc SIZE`.
2. **`Wword(N)` trap.** `mechrecon.hpp:192` `Wword(i)` is a global scratch bank (`bank[i&0x3ff]`), NOT `this+i*4`. Grep the class body for `Wword(` used as object/pointer access → replace with the named inherited member or an engine accessor (e.g. owner via `(Mech*)GetOwningSimulation()`). The heat/gyro/sensor/torso/myomers/powersub bodies are already clean here — keep them that way; `Wword` use is a mech.cpp-only concern.
3. **MessageHandler chaining.** Each class's handler set must chain to its parent: `Receiver::MessageHandlerSet X(Parent::GetMessageHandlers());`. An empty/parentless set makes `Receiver::Receive` find no handler → inherited messages (TakeDamage, InjectCoolant, ToggleLamp, Jammed, …) silently dropped. Gyroscope/Sensor/Myomers and all heat classes currently define `GetClassDerivations` but an empty/missing `GetMessageHandlers` — fix on swap.
4. **Performance install + validity.** Confirm the real ctor calls `SetPerformance(&Class::XxxSimulation)` **under the live-primary gate** (`(owner->simulationFlags & 0xC)==0`, some also require `& 0x100`). Without it, `activePerformance` stays `&Simulation::DoNothingOnce`, which on its first tick calls `NeverExecute()` → sets `DontExecuteFlag` → the subsystem is skipped forever (this is exactly why a stub "ticks once then goes silent"). Entity-validity for message delivery rides the mech's validity (already handled in bring-up).
**Tick is already wired** — no new plumbing. `mech4.cpp Mech::PerformAndWatch` (~825-871) walks `subsystemArray[0..subsystemCount-1]`, and for each `s->IsNonReplicantExecutable()` calls `s->PerformAndWatch``Simulation::PerformAndWatch` (SIMULATE.cpp:448) → `Perform(slice)``(this->*activePerformance)(slice)`. Installing `activePerformance` (check #4) is the entire difference between a stub and a running subsystem.
**Build/verify each swap:** `cmake --build C:/git/nick-games/btbuild/build --config Debug`; single file `C:/git/nick-games/btbuild/compile1.cmd <file>.cpp`. Watch the `[tick] first frame: dispatched to N … of M present` log — N should climb toward M (minus the bypassed `controlsMapper`) as stubs become real.
---
## 2. RECOMMENDED ORDER (rationale + dependencies + blocked)
The whole roster sits on the BT base chain `engine Subsystem → MechSubsystem → HeatableSubsystem → HeatSink → {PoweredSubsystem, Generator, Condenser, Reservoir, Bank}` and `PoweredSubsystem → {PowerWatcher → Gyro/Torso/HUD/Searchlight/ThermalSight, MechWeapon → Emitter/PPC, ProjectileWeapon → MissileLauncher}`. So the **heat base re-base is the universal prerequisite** — it must be correct before any leaf is trustworthy. This aligns with the user mandate (heat first).
- **WAVE 1 — Heat base re-base + de-shadow (PREREQUISITE, no factory change).** Reconcile `mechsub.hpp``heat.hpp`; make `HeatableSubsystem : MechSubsystem`; delete shadow fields; fix the thermal offsets. Compile `heat.cpp`, `heatfamily_reslice.cpp`, `mechsub.cpp` green. **Unblocks every family.**
- **WAVE 2 — HEAT family + the MechTech/HUD wiring fix (user: "heat first").** Un-stub Condenser (0xBBD), Reservoir (0xBC0), aggregate sink (0xBBE). Fold in the MechTech↔HUD swap (0xBDC→MechTech with store + cache; 0xBD6→HUD) because the 0xBDC case currently instantiates the heat base and discards the pointer — it has to change here anyway, and it validates the one already-real class in its correct slot. Verify heat builds/dissipates.
- **WAVE 3 — Energy weapons (user: "emitter/PPC second").**
- **3a (dependency):** power bus — Generator (0xBC1) + PoweredSubsystem (0xBC2). Low-risk, foundational; energy weapons need it to charge (`GetVoltageState()==4`).
- **3b:** Emitter (0xBC8) + PPC (0xBD4). Construct+tick+fire underneath; **keep the explosion stand-in for the visual** (beam renderable deferred — see §4).
- **WAVE 4 — Standalone readouts (low risk, DONE classes):** Sensor (0xBC3), Searchlight (0xBD8), ThermalSight (0xBDE), AmmoBin (0xBCB). Pure power/heat-gated readouts; run standalone.
- **WAVE 5 — Torso (0xBC5): ✅ DONE (live, binary-exact layout, zero regression).** The joint I/O
(`Mech::ResolveJoint`/`Torso::PushTwist` + corrected `TorsoSimulation` + per-frame `UpdateJoints()`) AND the
base-chain re-base are complete. Hierarchy is `Torso : PowerWatcher : HeatWatcher : MechSubsystem` (a SEPARATE
branch from the HeatSink leaves — shares only MechSubsystem, so the +156 fix stays in the Watcher branch). Fix:
Watcher-local `WatchedConnection`(0xC) + `WatcherGaugeAlarm`(0x54) in heatfamily_reslice.hpp (never resize the
shared `SubsystemConnection`/`HeatAlarm`); grew HeatWatcher (+84) + PowerWatcher (+72, shadow watchedLink deleted);
Torso shim fields deleted + 0x270 slot added → `currentTwist`@0x1D8, `sizeof==0x280`, compile-time locked
(`TorsoLayoutCheck`/`HUDLayoutCheck` + Watcher-base asserts). Real Torso un-stubbed at 0xBC5; verified no crash,
heat/combat un-regressed (TARGET DESTROYED after 8 hits). Full detail: CLAUDE.md §10d "WAVE-4 Torso base-chain".
NOTE: the torso doesn't visibly twist for the Blackhawk because its 0xBC5 record has `TorsoHorizontalEnabled=0`
(a FAITHFUL result — the binary ctor @004b6b0c gates on it identically); visible twist needs a twist-enabled mech.
**Gyroscope (0xBC4) — layout+joint-I/O DONE, un-stub DEFERRED (reverted to stub).** Shim-delete + accessor-redirect +
node-I/O (backed by the real engine Joint API, incl. FUN_0041d11c=SetTranslation) + ResolveJoint reuse + WriteEye/
MechJoint wiring are done and locked (`GyroLayoutCheck`: exageration@0x1D8, sizeof<=0x3D0). Verified live: gyro
constructs, resolves real joints (jointlocal/jointeye), WriteMechJoint fires — but with GARBAGE (0xCDCDCDCD/NaN)
because the ctor field-init + integrator field-MAPPING are incomplete/incorrect (binary @004b3778: springConstant@0x1E8,
dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root
joint). Remaining = a full ctor+integrator reconstruction from @004b3778 (bigger than the torso). Full detail: CLAUDE.md
§10d "WAVE-5 GYROSCOPE". Also: the mech.cpp gyro↔torso cross-link write is broken (SubProxy::linkTarget→gyro+4) — commented out.
- **WAVE 6 — Mover-coupled:** Myomers (0xBC6). Routes `SpeedEffect` into the Mech mover (`Mech+0x128`), writes `maxSpeed@0x7A0`, loops the myomer list `@0x1EB`. Verify those Mech offsets are valid before enabling (Risk 7), else no-op/wild writes.
- **WAVE 7 — Projectile/missile weapons:** ProjectileWeapon (0xBCD), MissileLauncher (0xBD0). **PARTIALLY BLOCKED:** case 0xBCE calls `@4bdcb4`, which has no CLASSMAP entry — decompile it first and resolve the 0xBCD/0xBCE/0xBD0 label tangle (Risk 1) before swapping these.
- **WAVE 8 — Message hub:** SubsystemMessageManager (0xBD3). Real, but **verify the cache-slot/tick-bypass interaction** (Risk 4) before enabling its tick.
**DEFER / DO NOT ENABLE:**
- **MechControlsMapper tick** — the real mapper is installed at roster slot 0 by `btl4app::MakeViewpointEntity` (not by this switch); ticking it calls `InterpretControls→FillPilotArray`, reading undocumented WinTesla Application offsets (`application+0x6c→+0x190→pilot+0x1e0`) → **access violation** (mech4.cpp:854-866 deliberately bypasses it). Keep bypassed; this is a Phase-8 input-remap task.
- **0xBCE @4bdcb4** — leave its `RECON_SUBSYS(MissileLauncher)` stub until decompiled.
---
## 3. HEAT FAMILY — detailed ordered edit list
### Step 1 — heat.hpp re-base + de-shadow (PREREQUISITE; do this before any factory edit)
All in `C:/git/nick-games/decomp/reconstructed/heat.hpp` (+ matching touch-ups in `heat.cpp` / `heatfamily_reslice.cpp`). The collisions are confirmed: `mechsub.hpp` already owns `Mech *owner` (line 134), `int simulationState;//@0x40` (251), `ReconDamageZone *damageZone;//@0xE0` (252), `Mech *hostEntity;//@0xE8` (254), `int subsystemId2;//@0xEC` (255), plus `GetStatusFlags@4ac144 / HandleMessage@4ac0bc / ResetToInitialState@4ac1d4 / PrintState@4ac8c0` virtuals — while `heat.hpp` re-declares all of these on top of `: public Subsystem`.
1. **Base change.** heat.hpp:33 — ensure `#include <mechsub.hpp>`. heat.hpp:168-169 — `class HeatableSubsystem: public Subsystem`**`: public MechSubsystem`**.
2. **Delete the shadow fields** (heat.hpp:234-238):
- `Mech *owner` → inherited `MechSubsystem::owner` (or a `Mech* GetEntity(){return (Mech*)Subsystem::GetEntity();}` accessor, matching surviving MECHTECH.HPP).
- `LWord flags` / `LWord statusFlags` (both @0x28) → engine `Simulation::simulationFlags`. Retarget `flags |= 8/2` (Condenser/Reservoir) to `simulationFlags`.
- `LWord statusBits` (@0x60) → engine dirty word; Reservoir `statusBits |= 1``ForceUpdate()`.
- `int destroyed` (@0x40) → `MechSubsystem::simulationState` (DestroyedState); `IsDamaged()` reads it.
3. **Thermal offsets** (heat.hpp:240-241 + HeatSink). CLASSMAP:472 — the `0x114-0x184` block belongs to HeatableSubsystem: keep `currentTemperature@0x114`, **move `degradationTemperature@0x118` / `failureTemperature@0x11C` UP from HeatSink into HeatableSubsystem** between `currentTemperature` and `heatLoad`, so `heatLoad` lands at `@0x120` (today it compiles to 0x118).
4. **Virtual surface** (heat.hpp:192-201) now **overrides** MechSubsystem's real slots — do not add parallel slots. Match the binary signature (watch `MechSubsystem::ResetToInitialState(Logical powered)` vs heat's `ResetToInitialState()`).
5. **HeatSink overlap words** (~heat.hpp:382-412): `valveState@0x1D0`/`field_1d0@0x1D0` and `refrigerationOutput@0x160`/`massScale@0x160` are the SAME binary word reused by subclasses — declare each once in the base it belongs to so subclasses extend rather than re-append.
6. **Handler chains** (systemic #3): define `GetMessageHandlers()` for each heat class to chain to its parent (`Receiver::MessageHandlerSet(HeatableSubsystem::GetMessageHandlers())`, etc.). Today only `GetClassDerivations` is defined → `HeatSink::HandleMessage(1)` and `Reservoir` InjectCoolant are silently dropped.
Verify: `compile1.cmd heat.cpp` and `compile1.cmd heatfamily_reslice.cpp` green; `sizeof(Condenser) ≤ 0x230`, `sizeof(Reservoir) ≤ 0x230`.
### Step 2 — factory bridges (in heatfamily_reslice.cpp)
Add `CreateCondenserSubsystem` (SIZE 0x230), `CreateReservoirSubsystem` (SIZE 0x230), `CreateHeatSinkBankSubsystem` (SIZE 0x1e4) per the §1 template (Condenser/Reservoir take `…::DefaultData`).
### Step 3 — mech.cpp factory edits
- `extern` the three bridges near the forward-decls.
- **Case 0xBBD** (CockpitClassID, 569-572): `subsystemArray[id] = CreateCondenserSubsystem(this, id, seg);`
- **Case 0xBC0** (CondenserClassID, 580-583): `subsystemArray[id] = CreateReservoirSubsystem(this, id, seg);`
- **Case 0xBBE** (SensorClassID, 574-578): `subsystemArray[id] = CreateHeatSinkBankSubsystem(this, id, seg); sensorSubsystem = subsystemArray[id];`**keep the cache** (slot 0x1f7 is the heat bank, per CLASSMAP:455-458; the name "sensor" is a mech.cpp mis-guess).
- **0xBBE interim (still real, not a stand-in):** the aggregate is a `#if 0` skeleton (Performance @4ae73c, 351 bytes, unfilled). Until reconstructed, have `CreateHeatSinkBankSubsystem` instantiate the real **HeatSink** base (alloc 0x1e4) so the master gate + `HeatSinkSimulation` run. This sink is the central heat engine that relaxes stored heat toward the ~300K ambient — without it the mech has no heat dissipation (Risk 8).
- Delete `RECON_SUBSYS(Cockpit)` (161), `RECON_SUBSYS(Sensor)` (162), `RECON_SUBSYS(Condenser)` (163). Leave the others for later waves.
### Step 4 — MechTech ↔ HUD un-swap (folded into this wave)
- Add `CreateHUDSubsystem` (hud.cpp, SIZE 0x2a4) and `CreateMechTechSubsystem` (mechtech.cpp, SIZE 0x140 — ≥ real 0x104 and ≥ the 0x114 base-ctor write).
- **Case 0xBD6** (MechTechClassID, 658-662): build **HUD**`subsystemArray[id] = CreateHUDSubsystem(this, id, seg);`.
- **Case 0xBDC** (HeatableClassID, 669-677): build **MechTech**`subsystemArray[id] = CreateMechTechSubsystem(this, id, seg); mechTechSubsystem = subsystemArray[id];` (this fixes the discarded-pointer bug).
- The `mechTechSubsystem` cache (slot 0x16d) — see Risk 4 for whether it caches MechTech or HUD; default to caching the real MechTech at 0xBDC.
### Step 5 — internal heat linkage (depends on the bank existing)
The HeatSink ctor resolves `heatSinkIndex` and `linkedSinks.Add(master)`; Reservoir ctor calls `link->Attach(this)` and scales by `link->field_1d0`. These need the 0xBBE master sink (Step 3) present so `linkedSinks.Resolve()` is non-null; the null-master guards (heat.cpp:595/646/693) keep it safe-but-inert otherwise. The mech.cpp:700-704 gyro↔`+0x1d8` link is **Torso, not heat** — leave it for Wave 5.
### Verify heat build/dissipate
1. In each heat ctor, after the master-gate block, log when the gate arms (`name << " heat gate armed"`). No "armed" line for the 0xBBE sink ⇒ the gate/`owner->simulationFlags` path is wrong.
2. In `HeatSink::HeatSinkSimulation` (heat.cpp:516), gated to the viewpoint mech, log per tick: `currentTemperature, heatEnergy, pendingHeat, coolantLevel, heatLoad, coolantActive`.
3. With `BT_FORCE_FIRE=1 BT_SPAWN_ENEMY=1`: weapons deposit into `pendingHeat``currentTemperature`/`heatEnergy` climb, `heatLoad` (15-sample average) ramps, `coolantActive`→1 and `coolantLevel` drains; on cease-fire the aggregate relaxes `currentTemperature` toward ~300K and `coolantLevel` recovers via `Reservoir::DrawCoolant`. Alarm transitions log via `HeatSink::PrintState` (Normal/Degradation/Failure).
4. **Offset proof:** the cockpit Heat gauge (`btl4gaug` Heat connection @4c3720 reads `currentTemperature@0x114`, CLASSMAP:260) must track the logged temperature. Garbage/`0xCDCDCDCD` ⇒ the MechSubsystem re-base/offsets are still off.
---
## 4. EMITTER / PPC WEAPON — detailed ordered edit list (make ONE real beam weapon fire)
Do **heat first** (E5 below depends on it). All offsets per CLASSMAP:476-484.
**E1 — Bridge (emitter.cpp).** Add `CreateEmitterSubsystem(Mech*, int, void* seg)``new (Memory::Allocate(0x478)) Emitter(owner, id, (Emitter::SubsystemResource*)seg, Emitter::DefaultData);`. Use it for **both** 0xBC8 and 0xBD4 — PPC is an Emitter subclass whose `FireWeapon@4bb878` just tail-calls `Emitter::FireWeapon@4bace8`, and the renderer keys off the *streamed* classID, so behavior is identical now. (A `struct PPC : Emitter {…}` for vtable/classID fidelity can be added later.)
**E2 — ctor null-deref + uninit (emitter.cpp ~653-680).** `FUN_00417ab4` (voltage-source resolver) returns 0 until the bus is real, then `src->ratedVoltage` and `TrackSeekVoltage`'s `src->voltage` deref null = construction-time crash. Add `energyCoefficient = 1.0f;` (@0x454) **before** the electrical branch (it is read by `FireWeapon` at line 160 but assigned only inside that branch). Resolve `src` once; gate the whole electrical block on `src != 0 && src->ratedVoltage > 0.0f`; in the else path keep `seekVoltage[i]` as the raw resource fractions, dividing by `v` only when `v > 0`.
**E3 — inert targeting (mechweap.cpp/.hpp).** `HasActiveTarget()` (mechweap.hpp:182) returns a never-set local `hasTarget` → fire gate permanently false. Read the owner instead (mech4.cpp already writes `mech+0x388` = MECH_TARGET_ENTITY):
```cpp
Logical MechWeapon::HasActiveTarget() const {
Mech *owner = (Mech *)GetOwningSimulation();
return (owner && *(Entity **)((char *)owner + 0x388)) ? True : False;
}
```
Have `GetTargetPosition` read owner `+0x37c`; drop the `hasTarget` member (systemic #1).
**E4 — Loaded→Firing gate reachable (emitter.cpp:259).** `firingArmed` (aliases `MechWeapon::recoil@0x3E8`) is uninitialized and written by nothing → the `if (firingArmed && HasActiveTarget())` gate reads garbage. Initialize it `True` in the ctor / `ResetToInitialState` for bring-up, pending its real semantic (Risk 3).
**E5 — heat self-load (emitter.hpp:283).** `heatAccumulator` is declared as an *Emitter* member → lands ~0x3f0+, NOT the inherited HeatableSubsystem accumulator `@0x1c8`, so `FireWeapon`'s `heatAccumulator += heatPortion` never reaches the heat sim. Delete the Emitter-local copy; write the inherited `@0x1c8` accumulator. **Depends on the heat module (Wave 1/2) — this is why heat is first.**
**E6 — mech.cpp factory.** `extern Subsystem *CreateEmitterSubsystem(Mech*,int,void*);`. Replace **case 0xBC8** (617-621) and **case 0xBD4** (652-656): `subsystemArray[id] = CreateEmitterSubsystem(this, id, seg); ++weaponCount; break;`. Delete `RECON_SUBSYS(EmitterWeapon)` (170) and `RECON_SUBSYS(GaussRifle)` (174). The Emitter ctor already installs `activePerformance = EmitterSimulation` (emitter.cpp:627), so the roster loop ticks it every frame for the authoritative mech — no extra plumbing.
**E7 — power bus (Wave 3a, required for the real fire path).** While Generator/PoweredSubsystem are stubs, `GetVoltageState()` never returns 4 → `currentLevel` never charges → the weapon never reaches `Loaded`. Faithful path: un-stub **Generator (0xBC1)** and **PoweredSubsystem (0xBC2)** so `FUN_00417ab4` resolves a real `VoltageSource`. Bring-up shortcut (still drives the real state machine): force-charge — `currentLevel = seekVoltage[idx]; weaponAlarm → Loaded`.
**E8 — trigger wiring (mech4.cpp ~707-736).** The mapper is bypassed, so nothing writes `fireImpulse@0x31c` and `CheckFireEdge()` never sees a rising edge. Where `gBTDrive.fire` is handled, locate the emitter in `subsystemArray` (classID 0xBC8/0xBD4) and set its `fireImpulse > 0` for the frame (small setter/friend on the weapon). This is the faithful replacement for the explosion stand-in's input read.
**What blocks a VISIBLE shot (and the honest interim).** `btl4vid.cpp::MakeMechRenderables` dispatches `0xBC8 Emitter beam` / `0xBD4 PPC beam` (CLASSMAP:334) but **defers the beam renderable to the unported `dpl2d_` 2D display-list layer** (btl4vid.cpp:244-245, 452-458). So a perfectly-firing Emitter draws nothing. **Honest interim: keep the mech4.cpp explosion stand-in (707-736) for the visual** while the real Emitter mechanics (charge → `Loaded``CheckFireEdge``Firing``FireWeapon` splits `damagePortion`/`heatPortion`, sets `dischargeTimer`/`beamFlag`/`targetEntity`) run underneath. The shot is real; only its rendering is borrowed until the `dpl2d_` beam lands. (Net: E1/E2/E6 = construct+tick safely; E3/E4/E5 + E7 = actually fire and self-heat; E8 = trigger; the `dpl2d_` beam = finally visible.)
Net dependency: **E5 needs heat (Waves 1-2); the real-fire path needs the power bus (E7 = Wave 3a); the visible beam needs `dpl2d_` (out of scope — keep the stand-in).**
---
## 5. RISKS / UNKNOWNS — and how to settle each at the binary
1. **0xBCE @4bdcb4 unresolved (blocks Wave 7).** No CLASSMAP/recon at that address; sits between AmmoBin (4bd5c4) and MissileLauncher (4bcff0). **Settle:** run `tools/ghidra_scripts/ExportBTSource.java` on `4bdcb4` (and its CSS/vtable); it is most likely a ballistic-autocannon variant of ProjectileWeapon. Reconcile the 0xBCD(ProjectileWeapon)/0xBCE(?)/0xBD0(MissileLauncher) label tangle before swapping any of the three. Leave `RECON_SUBSYS(MissileLauncher)` at 0xBCE until done.
2. **HeatableSubsystem base: MechSubsystem vs Subsystem.** `mechsub.hpp` and `heat.hpp` are overlapping reconstructions of the same cluster (vtable 0x50e210, classID 0xBBB, dtor 0x4ac868, model 0xE4). **Settle:** confirm `sizeof(MechSubsystem) == 0x114` and that `currentTemperature` begins exactly at 0x114 (HeatSink temp init @4adda0). Recommended resolution (Wave 1): `HeatableSubsystem : MechSubsystem`, one DamageZone built by the MechSubsystem resource ctor (@4ac644). Cross-check via the Heat gauge offset proof (§3 verify #4).
3. **`firingArmed` real semantic (@0x3E8, aliases MechWeapon::recoil).** **Settle:** decompile around the `Loaded→Firing` gate and the fire-group dispatch to find what writes `this+0x3e8` in the binary (a weapon-selected/enabled flag from the controls path). Until then init `True` for bring-up (E4).
4. **Special-cache slots — re-derive each member's TRUE meaning.** The Wword index is ground truth for *which Mech member* is written, but the names are mislabeled: `sensorSubsystem`(0x1f7)=heat bank; `mechTechSubsystem`(0x16d)=MechTech-or-HUD; `controlsMapper`(0x10d)=mapper-or-message-manager; `gyroSubsystem`(0x14a)=gyro; `sinkSourceSubsystem`(0x10e)=torso. **Settle:** read which Mech offset the binary writes at each slot and who consumes it. **Critical for SubsystemMessageManager (0xBD3, Wave 8):** verify slot 0x10d is NOT the same member `mech4.cpp` bypasses in the tick (`if (s == controlsMapper) continue;`) — if the message manager lands in the bypassed slot it will never tick. And confirm whether the HUD (0xBD6) needs its own cache distinct from `mechTechSubsystem`.
5. **MechControlsMapper tick AV (confirmed).** `InterpretControls→FillPilotArray` reads `application+0x6c→+0x190→pilot+0x1e0` (`DAT_004efc94`) → wild pilot pointer → AV (mechmppr.cpp:46). **Settle:** only as a Phase-8 input-remap task with the real WinTesla `Application` layout. Until then keep it bypassed; the factory's 0xBD3 case is the *message manager*, not this mapper.
6. **Gyro/Torso joint writes — TORSO SIDE RESOLVED.** ~~Stubbed no-ops needing a Skeleton/DCS API.~~ The
real engine already exposes it: `JointedMover::GetSegment(CString)``EntitySegment::GetJointIndex()`
`JointSubsystem::GetJoint(i)``Joint::SetRotation(Radian|EulerAngles)` (all PUBLIC, in `munga_engine.lib`).
Torso's `ResolveJoint`/`PushTwist` are reconstructed against this (DONE). Gyro's `WriteEyeJoint`/`WriteMechJoint`/
`ResolveJoint` should reuse the same hoisted `Mech::ResolveJoint` + the joint-write dispatch (follow-up). Not a
separate DCS task — the write path is engine-native. (Torso's live wiring is still gated on the WAVE-5 base
re-base, not on this.)
7. **Myomers mover coupling (Wave 6).** `ConnectToMover` routes `SpeedEffect@0x31C` into `**(Mech+0x128)`; `RegisterMaxOutput` writes `maxSpeed@0x7A0` and loops the myomer list `@0x1EB`. **Settle:** verify those Mech offsets exist/are valid in the reconstructed Mech before enabling, else no-op/wild writes. Also note `powersub.hpp` still declares a STALE `Myomers` (the old Sensor mislabel) — `#include <myomers.hpp>` first and watch for the name collision at link.
8. **Aggregate heat sink (0xBBE) unfinished.** Performance @4ae73c (351 bytes) is a `#if 0` skeleton; it's the load-bearing central heat engine. **Settle:** decompile `4ae8d0`/`4ae73c`, finish `AggregateHeatSink`, give it `GetClassDerivations`/`DefaultData`. Interim = real `HeatSink` base at 0xBBE (real, not a stand-in) so the master gate + `HeatSinkSimulation` run.
9. **Placement-new overrun (general).** After de-shadowing, `sizeof(Class)` can exceed the factory alloc SIZE → heap corruption (the 1-in-6 crash). **Settle:** the `Check(sizeof(<Class>) <= SIZE)` guard in every bridge (§1) makes this a hard, immediate assert instead of an intermittent corruption.
Key files (all under `C:/git/nick-games/decomp/reconstructed/`): `mech.cpp` (factory 556-741, stubs 145-181, enum mech.hpp:253-272), `mech4.cpp` (tick 825-871, target slots/explosion 707-736), `heat.hpp`/`heat.cpp`/`heatfamily_reslice.{hpp,cpp}`, `mechsub.hpp`, `mechtech.{cpp,hpp}`, `hud.cpp`, `emitter.{cpp,hpp}`, `mechweap.{cpp,hpp}`, `powersub.{cpp,hpp}`, `sensor/gyro/torso/myomers/searchlight/thermalsight/ammobin/projweap/mislanch/messmgr.cpp`, `CLASSMAP.md` (authoritative). Engine bases: `C:/git/nick-games/elsewhen_extracted/Elsewhen RP411 Source/trunk/MUNGA/{SUBSYSTM,SIMULATE,RECEIVER,VDATA}.h`, `SIMULATE.cpp` (PerformAndWatch @448), `ENTITY.cpp` (roster tick @698).
+63
View File
@@ -0,0 +1,63 @@
# Parallel "Deepen the Systems" Wave — ready-to-fire plan
**Status:** STAGED. Do NOT launch until the TRIGGER below is met. Authored while the camera/visibility
pass is still running.
## TRIGGER (fire the wave only when all true)
1. A mech renders on screen (camera pass done).
2. There is a world/environment to be in (terrain/cavern renders — Tier 1).
3. The player mech moves/turns under control input with a following camera (Tier 2 — basic piloting).
Why wait: parallel reconstruction is only **compile-verified**; correctness shows up at **runtime**, and
we've repeatedly hit runtime-only bugs (vtable artifact, shadowed `subsystemArray`, null scenarioRole).
A pilotable build is the **test harness** that lets the serial integration pass validate the wave's output.
Firing earlier just piles up plausible-but-wrong code with nothing to check it against.
## SERIAL pre-work that must finish first (NOT part of the wave)
- **Tier 1 — world/terrain render:** fix `DPLRenderer::MakeEntityRenderables` `/RTC1` uninit bug
(`L4VIDEO.cpp:5211`) + load the cavern environment so non-mech entities draw. (btl4grnd.cpp + engine.)
- **Tier 2 — locomotion:** wire control input → mech per-frame mover/Animate consumer → chase camera.
This touches `mech.cpp` + `mechmppr.cpp` (shared cores) so it's serial, done before the wave.
These two are the critical path to "pilotable" and must not run concurrently with the wave (shared-file
contention on mech.cpp / btl4vid.cpp / camera).
## THE WAVE — parallel agents, partitioned by DISJOINT file ownership
Same model as the compile wave that worked (7 families, no collisions). Each agent: reconstruct the REAL
per-frame behavior of its subsystems (the streamed objects currently have only minimal `MechSubsystem`
base behavior) from the **binary oracle** (`decomp/recovered/all/part_*.c`, `vtables.tsv`, `functions_index.tsv`)
+ the **RP analog** (`...\trunk\RP\`, `WEAPSYS`/`VTV*`) + surviving headers. Verify by COMPILE
(`compile1.cmd <file>`), match the oracle, and flag cross-family needs (don't edit headers you don't own).
| # | Agent (track) | Owns (`decomp/reconstructed/`) | Reconstruct |
|---|---|---|---|
| 1 | **Heat/power** | heat.cpp/hpp, powersub.cpp/hpp, gnrator.cpp/hpp | HeatSink heat-flow/coolant per-frame, HeatableSubsystem thresholds, PoweredSubsystem voltage bus, Generator output. (Owns the subsystem BASE classes — coordinate first.) |
| 2 | **Energy weapons** | mechweap.cpp/hpp, emitter.cpp/hpp (+ survived GAUSS.CPP/PPC.CPP) | MechWeapon fire/cooldown/heat-cost, Emitter beam (PPC/Gauss) FireWeapon (vtable slot 18). |
| 3 | **Projectile/missile weapons** | projweap, mislanch, missile, seeker, projtile, misthrst, ammobin (.cpp/.hpp) | ProjectileWeapon/MissileLauncher fire, Missile/Seeker flight + homing, AmmoBin counts, MissileThruster. |
| 4 | **Sensors/targeting/tech** | sensor.cpp/hpp, mechtech.cpp/hpp | Sensor target acquisition/lock, MechTech repair/diagnostics. |
| 5 | **Movement subsystems** | gyro.cpp/hpp, torso.cpp/hpp, myomers.cpp/hpp | Gyro stability, Torso twist/aim, Myomers actuation. |
| 6 | **Damage/messaging** | mechdmg.cpp/hpp, dmgtable.cpp/hpp, messmgr.cpp/hpp | DamageZone TakeDamage → structure/armor, damage-table rolls, SubsystemMessageManager dispatch. |
| 7 | **HUD / 2D track** (independent, HIGHEST risk) | hud.cpp/hpp, btl4gaug/gau2/gau3/galm (.cpp/.hpp) + a NEW `dpl2d` over-D3D layer | The `dpl2d_*` 2D layer (no D3D equivalent exists — from-scratch over the D3D device) + gauge widgets reading real subsystem state + `.gim/.gat/.pcc` cockpit assets. The one genuine unknown; give it the most room. |
(Track 8 — world/terrain — is the SERIAL Tier-1 pre-work above, not a wave slot.)
## Ownership / contention rules (critical — agents run concurrently, no git worktrees)
- Edit ONLY your owned `.cpp` + `.hpp`. Need a base-class change in a header you don't own → REPORT it
("CROSS-FAMILY NEEDS"), don't edit it.
- Heat/power (agent 1) owns the subsystem base classes weapons/sensors/movement derive from → if its base
signatures change, let it settle first OR have it publish the final base surface up front.
- Do NOT touch `mech.cpp`/`mech.hpp`/`btl4vid.cpp` (cores + render) — those are serial-integration territory.
- Each agent runs builds against the SAME build dir → builds are serialized by the OS; that's fine for
`compile1.cmd` single-file checks (preferred) but don't run full `cmake --build` concurrently.
## After the wave: SERIAL integration pass
One bring-up loop (debugger-driven) runs the pilotable build with the deepened systems active and validates
behavior at runtime — fix interaction bugs, wire the cross-family needs the wave reported, confirm
weapons/heat/damage/HUD actually function together. This is where the parallel reconstruction gets its
correctness check. THEN: Tier 5 (enemy AI / `path.cpp` reconstruction) + mission objectives/scoring.
## Dispatch checklist (when triggered)
- [ ] Confirm pilotable (3 trigger conditions).
- [ ] Re-verify build GREEN + all 43 modules compile (baseline).
- [ ] Launch agents 16 (combat) as one parallel batch; launch agent 7 (HUD) as its own long-running track.
- [ ] Collect reports → reconcile CROSS-FAMILY NEEDS → serial integration pass.
- [ ] `path.cpp` / enemy AI is its own pass after combat is validated.