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>
1892 lines
184 KiB
Markdown
1892 lines
184 KiB
Markdown
# Virtual World Pod Games — Port Project (BattleTech / Red Planet)
|
||
|
||
> ⚠️ **REPO ORIENTATION (bt411):** This is the **clean standalone bt411 repo** — start with
|
||
> `README.md` for the current build/run and the actual tree layout (`engine/ game/ content/
|
||
> docs/ reference/ tools/`). The historical/progressive sections below were written in the
|
||
> original RE workspace and still cite absolute `C:/git/nick-games/...` paths and its scattered
|
||
> layout (`decomp/reconstructed/`, `elsewhen_extracted/.../MUNGA_L4/`, `btbuild/`, `enginebuild/`,
|
||
> the pod content tree); those map into this repo as: reconstructed BT → `game/reconstructed/`,
|
||
> the engine → `engine/MUNGA{,_L4}/`, content → `content/`, raw decomp → `reference/decomp/`.
|
||
> Treat those paths as history; the build of record is the top-level `CMakeLists.txt` + `README.md`.
|
||
> Everything else below (decomp findings, reconstruction history, gotchas) is accurate and durable.
|
||
|
||
Knowledge base + roadmap for porting Virtual World Entertainment's arcade **pod** games
|
||
(Tesla platform, release 4.10, ~1995–96) to run on modern Windows and, ultimately, the actual
|
||
arcade pod hardware. Primary target: **BattleTech (BT)**; Red Planet (RP) shares the engine.
|
||
|
||
> Companion docs: `docs/BGF_FORMAT.md` (model format), `docs/ASSET_PIPELINE.md` (textures/materials/
|
||
> gauges). The working port code is under `port/` (see "Port codebase" below).
|
||
|
||
> **📌 This is a PROGRESSIVE CONTEXT system — keep it current.** This file is the single, durable source
|
||
> of project context across sessions. **Whenever you learn something new and non-obvious about the
|
||
> project — a decompilation finding, a build/runtime gotcha, a reconstruction insight, an offset/format
|
||
> detail, a decision or its rationale, a dead end to avoid — ADD it here** (in the most relevant section,
|
||
> or §10 for combat bring-up / reconstruction findings) as part of the same task, not as an afterthought.
|
||
> Update existing statements when reality changes them rather than letting the doc drift. Goal: a later
|
||
> session (or a fresh agent) can read this file and have the full working context with nothing lost to
|
||
> chat history. Durable project facts → here; per-session/cross-cutting reminders → the `memory/` files
|
||
> (indexed in `MEMORY.md`); detailed running findings → `btbuild/RECONCILE.md`.
|
||
|
||
---
|
||
|
||
## 1. What this is
|
||
|
||
- Two games share one engine: **BT (BattleTech)** and **RP (Red Planet)**. Engine = **MUNGA**;
|
||
hardware abstraction layer = **L4** (`MUNGA_L4`). Game logic = `CODE/BT`, `CODE/RP/RP`.
|
||
- Original platform: **32-bit DOS** under a **DPMI32** extender, built with **Borland C++ 5** +
|
||
**TASM32**. Ran on **Novell DOS** pods.
|
||
- 3D was rendered by a proprietary **Division "IG" image-generator board (dual EISA)**, driven by a
|
||
closed-source library **`libDPL.lib`** (ships as binary only, + `VREND*.BTL/.MNG` board microcode).
|
||
**The port replaces this** with a `dpl_*` shim on a modern GPU. (Nick: "we'll just yoink that out.")
|
||
- Pods now run **Windows 10** with modern-ish GPUs + a Voodoo/Glide wrapper. BT was abandoned;
|
||
MechWarrior 4 was ported for the current pods. This project revives BT.
|
||
|
||
## 2. People / goals
|
||
|
||
- **arcattack** (user): doing the port. Has NVIDIA 3060 + A6000 dev GPUs.
|
||
- **Nick**: owns the pods + the software license (porting is authorized). Runs the pod hardware.
|
||
- Goal: run on (a) a modern dev box and (b) the fixed pod hardware. Multiplayer (pod-to-pod) wanted.
|
||
|
||
## 3. Pod hardware (fixed target)
|
||
|
||
- **2 video cards → 7 monitors**: main 3D view **800×600**; radar **640×480**; five monochrome MFDs
|
||
driven as one **1280×480** horizontally-spanned surface.
|
||
- Requires **old NVIDIA drivers** for the MFD horizontal spanning — a hard constraint that bounds the
|
||
graphics API. → **Target Direct3D 9** (lowest common denominator that runs on old-driver pods AND
|
||
modern cards; one maintainable codepath). NOT Vulkan/D3D11+. CUDA is irrelevant (compute, not display).
|
||
- Cockpit I/O ("RIO") = joystick X/Y, throttle, pedals, buttons over **serial COM** (`L4RIO`,
|
||
`L4SERIAL`). Must be remapped to keyboard/gamepad on a dev box; real wiring is a pod bring-up task.
|
||
|
||
## 4. Where the content lives (3 archives, in repo root)
|
||
|
||
The deployed game data was **never in source control** — it lived on the pod machines. Spread across:
|
||
|
||
| Archive | What it is | Has |
|
||
|---|---|---|
|
||
| `410srczipped.zip` | **Source code** (36MB) | all `.cpp/.hpp`, geometry `.bgf`, material defs `.bmf`, **animations `.ani`** (428), a few sample textures/skeletons. NO per-mech skins/skeletons. |
|
||
| `Tesla4NovellTechPC-*.zip` | pod **technician/test PC** (77MB) | game binary `btrel410.exe` (in `vgl_labs/archive/`), Division **VPX board diagnostic tools** (`vwetest/vpx/`), shared generic textures, Novell DOS. |
|
||
| `Tesla4PodPCNovell-*.zip` | the pod **game machine** (92MB) | **the full deployed tree** at `…/CopyofNovellDisk/REL410/` (BT+RP): per-mech **skeletons** (`AVA.SKL` etc., 124), **skins** (`AVATAR.BSL` etc.), 1143 models, 1624 material libs, textures. |
|
||
| `Elsewhen RP411 Source-*.zip` | **the WINDOWS PORT** ("WinTesla", VS2008) ⭐ | modernized source: `WinTesla.sln`; MUNGA(150 cpp)+MUNGA_L4(43); **`L4D3D.cpp` = IG-board→Direct3D renderer bypass DONE**; **OpenAL+libsndfile = SOS audio replaced**; full **Red Planet** game logic (`VTV/VTVMPPR/WEAPSYS/...`); asset exporters (`MeshExp/TextureExp`); `DivLoader`. Solution wires both `RP_L4` AND a `BT410_L4` target — **but the `BT410_L4` folder + all BT game source are ABSENT** (only the .sln reference remains → BT was being ported, code not included). Extracted: `elsewhen_extracted/`. |
|
||
|
||
Extracted to: `410srczipped_extracted/`, `tesla4_extracted/`, `tesla4pod_extracted/`.
|
||
**Content root for the port** = `tesla4pod_extracted/Tesla4PodPCNovell/CopyofNovellDisk/REL410/`
|
||
(use the pod tree for skeleton/skins/geometry; **animations** come from the source tree's
|
||
`410srczipped_extracted/.../CONTENT/BT/ANIMS/`).
|
||
|
||
Still likely missing / ask Nick: confirmation this is the complete content master.
|
||
(UPDATE: the walk/run animation set is NOT missing after all — `CONTENT/BT/ANIMS/` has the full per-mech
|
||
set, e.g. `BLHRRL/BLHRRR/BLHWWLI/BLHWWRI...`, and they are compiled into `BTL4.RES` — the game plays
|
||
`blhrrl`=904. The earlier "only AVABMP present" worry was looking at an incomplete slice.)
|
||
|
||
### 4a. LEVELS / MAPS — what's loadable (content audit)
|
||
The mission is an INI `.EGG` (`[mission] map= scenario= time= weather=`). The engine resolves `map=<name>`
|
||
from `BTL4.RES` (`MISSION.cpp:344 FindResourceDescription(name, MakeMessageStreamResourceType)`; a missing
|
||
name → null-deref crash at `:355`). **The runtime `BTL4.RES` (pod tree == `decomp/BTL4.RES`, byte-identical,
|
||
3.23 MB) contains 8 loadable maps:** `cavern` (the default night cave, enclosed/no sky), `grass` (grassland),
|
||
`rav` (ravine), `polar3`/`polar4` (snow), `arena1`/`arena2`, `dbase`. **Switch levels by editing a COPY of
|
||
`TEST.EGG`** (e.g. `map=grass` + `time=day`) and running `-egg <that>.egg` — verified: `grass` boots + the
|
||
mech walks, 0 crashes (`GRASSDAY.EGG` created). ⚠ Names are `arena1`/`polar3`/`rav` (with the suffix), NOT
|
||
bare `arena`/`polar`; `map=burnt`/`frstrm`/`des` are NOT in this RES (they crash) — those are lab/other-build
|
||
maps whose `.map` SOURCE exists in `CONTENT/BT/MAPS/` (`DES.MAP` = a sky+buttes desert) but would need
|
||
compiling into a RES via the DOS `btl4tool.exe` (`BUILDRES.BAT`, manifest `CONTENT/BT/BTL4.BLD`) — a
|
||
separate content-build sub-project. **The full content SOURCE tree** `410srczipped_extracted/.../CONTENT/BT/`
|
||
is comprehensive (17 `.map`, 636 `MODELS/`, 219 `SOLIDS/` collision, 331 `ANIMS/`, 1958 `VIDEO/` textures,
|
||
207 `AUDIO/`, 404 `GAUGE/`) — i.e. the assets to build ANY map exist; the runtime tree is the already-built
|
||
subset. No consolidation needed to test the 8 maps. ⚠ Collision is OFF on ALL maps until `MoveAndCollide`
|
||
is reconstructed (the mech phases through terrain/objects) — the `.sld` collision solids ARE present in the
|
||
RES + `SOLIDS/`, so it is a code-revival task, not a content gap.
|
||
|
||
## 5. Asset formats (all reverse-engineered; details in docs/)
|
||
|
||
- **`.MOD`** — INI mech/object definition: skeleton, skins, collision, gauges, physics, animations, class.
|
||
- **`.BGF`** — `DIV-BIZ2` little-endian nested-TLV geometry. OBJECT→LOD→PATCH(=geogroup)→PMESH;
|
||
interleaved float32 verts; **quad faces (triangulate!)**; materials by `library:material` name;
|
||
damage/articulation are **name-driven via `SV_SPECIAL` `dz_*` tokens**, not stored geometry.
|
||
- **`.BMF`** — `DIV-BIZ2` (FILETYPE=1) material+texture **library** (no pixels). Material→DIFFUSE color
|
||
+ `MATERIAL_TEXTURE`→texture→`TEXTURE_MAP` image basename.
|
||
- Texture **pixels**: **`.tga`** (Truevision), **`.vtx`** (`DIV-VTX2`, trailing RGB), **`.bsl`**
|
||
(`DIV-BSL2`: a **BIT-SLICED container** — w*h 32-bit texel words holding SIX independent 4-bit GRAYSCALE
|
||
sub-images (nibble pair-swapped; byte 0 pad) or RGB444/RGBA4444 (sliceType 7/8); the BMF TEXTURE tag
|
||
`0x18 BITSLICE` selects the slice, absent = 0. The old "[pad,R,G,B] confirmed" note was WRONG — full
|
||
spec in `docs/ASSET_PIPELINE.md` §Pixel formats; reference reader `DivLoader/VGCDivLoader.cpp:323-410`).
|
||
- **`.SKL`** — INI joint skeleton: `[joint] parent=, Type=(hingex/y/z|ball|balltranslate), Object=part.bgf,
|
||
dzone=, tranx/y/z, pitch/yaw/roll`. Build a DCS tree; neutral pose ≈ translations (pyr ≈ 0).
|
||
- **`.ANI`** — INI keyframe animation: `[HEADER] framecount/framerate(30)/skeletonfile`, `[Time]`
|
||
frameN→seconds, `[JointType]` per-joint DOF, `[frameN]` joint→`rx ry rz` radians (balltranslate→translate).
|
||
Walk anims have **no baked root translation** → forward motion is procedural/physics (see §7).
|
||
- **`.GIM`** (vector MFD/radar line maps) + **`.GAT`** (color table) + **`.PCC`** (ZSoft PCX 8-bit gauge
|
||
rasters) — the cockpit HUD assets. **`.DZM`** = INI damage-zone→material map (mech skin states).
|
||
- **`.SLD`** collision solids, **`.DMG`/`.TBL`** damage zones/tables, **`.SUB`** subsystems, **`.CTL`** control maps.
|
||
|
||
## 5a. ⚠ SOURCE COMPLETENESS — the gating blocker
|
||
|
||
The archive has the **engine and all headers, but NOT the game's implementation code**. Verified across
|
||
all three zips:
|
||
- **MUNGA engine** (shared): ~183 `.cpp` — largely complete (renderer, `MOVER/JMOVER`, math, scene).
|
||
- **All game `.hpp` headers** present (every class/interface defined).
|
||
- **BT game logic `.cpp`: MISSING** — `mech`, `mech2/3/4`, `mechsub`, `mechmppr`, `mechtech`, `heat`,
|
||
`sensor`, `gyro`, `torso`, `myomers`, `hud`, `mechweap`, `powersub`, `btplayer`, and the **main app
|
||
`btl4`** are all absent. Only 10 BT `.cpp` are present (BTCNSL/BTMSSN/BTREG/BTSCNRL/BTTEAM/BTTOOL +
|
||
weapons GAUSS/PPC + BTL4ARND/BTL4MODE). RP game logic is similarly gutted (5 `.cpp`).
|
||
- Compiled **`btrel410.exe`** exists (binary only, in the TechPC zip).
|
||
- **RP can't backfill BT:** RP's game source is gutted the same way (only `RPCNSL/RPMSSN/RPTOOL/VTVPWR/
|
||
VTVSUB` + L4 glue); none of the missing BT files are under RP. Only shared *engine* game-logic is
|
||
present (`MUNGA\DAMAGE.CPP`, `MUNGA\MOVER.CPP`). The per-game source for BOTH titles is missing — the
|
||
archive captured the engine fully but only a partial subset of game source → a complete repo likely
|
||
exists elsewhere (Nick).
|
||
|
||
**Consequence:** Route A (recompile/port original source) is **NOT possible as-is** — the code that *is*
|
||
the game isn't here. Paths forward: (1) **get the missing `.cpp` from Nick** [gating — top priority];
|
||
(2) run `btrel410.exe` by **emulating the VPX/IG board** in DOSBox (deep RE project; no source needed);
|
||
(3) **decompile `btrel410.exe` + reconstruct from headers** (Plan B, analyzed below); (4) rewrite game
|
||
logic from headers blind (worst). Do not start the shim/port build until the source situation is resolved.
|
||
|
||
**`btrel410.exe` (UNWRAPPED — see `decomp/`):** it's a **PKZIP self-extractor** (`unzip` lists 324 files).
|
||
Inside is the real game binary **`BTL4OPT.EXE` (1.24 MB, clean uncompressed PE32, Phar Lap TNT `32rtm`
|
||
stub)** + `BTL4.RES`, `NETNUB.EXE`, TNT extender, IG microcode, full content. Extracted to `decomp/`.
|
||
`BTL4OPT.EXE` is a near-ideal decompile target: class names, `Class::Method` strings, and **source-file
|
||
paths in asserts** (`d:\tesla_bt\bt\mech.cpp`) are left in — confirming the missing modules (`mech`,
|
||
`mech2/4`, `mechmppr`, `mechweap`, `heat`, `powersub`, `btplayer`, `btl4app/gaug/mssn/rdr/vid`, …) ARE in
|
||
the binary. No COFF symbol table (naming comes from those strings). Mined: `decomp/src_files_in_binary.txt`
|
||
(78 files compiled in), `decomp/methods_in_binary.txt` (151 methods).
|
||
|
||
**Decompilation — UNDERWAY (Plan B in progress).** JDK21 + **Ghidra 12.1.2 installed** under `tools/`
|
||
(portable). `tools/ghidra_scripts/ExportBTSource.java` run headless on `BTL4OPT.EXE` → **real decompiled
|
||
C recovered** to `decomp/recovered/` for heat/mech/mech2/mechmppr/mechweap/powersub/btplayer/btmssn/
|
||
btl4app/gaug/mssn/rdr/vid (proven: heat.cpp HeatSink ctor w/ real constants+asserts). Current pass =
|
||
assert-anchored only (few funcs/file). Next: decompile ALL funcs + recover vtables → map to header classes
|
||
for COMPLETE modules → reconstruct to compilable C++ (header + RP analog + binary oracle) → WinTesla
|
||
`BT410_L4` → build/verify. This is mechanized now but the reconstruction volume (~40 files) is large.
|
||
|
||
**PILOT DONE — `heat` reconstructed** (`decomp/reconstructed/heat.{hpp,cpp}`). From the 46-func cluster:
|
||
~17 confidently reconstructed (the real heat logic — ctor, ResetToInitialState, heat-flow/coolant
|
||
formulas, status/message handlers, resource parse), 5 correctly identified as inherited base methods
|
||
(excluded), ~24 template/glue folded into members; Condenser bodies fell just outside the captured
|
||
window (needs a re-slice). Recovered the full field layout (offset→named member), HeatSink vtable slot
|
||
map, and resource struct. Output is faithful, named, VWE-styled C++ — a strong draft a human finishes
|
||
(won't compile clean yet). **Honest effort: ~0.5–1.5 hr/module → ~30–50 agent-hours + human review for
|
||
~40 modules, with a long tail** (modules with no surviving `.TCP`/`.HPP` fragment, or split across decomp
|
||
windows, cost more). Process upgrades before scaling: per-class function lists (not address clusters),
|
||
complete vtable rows, and recover `.data` float constants (the biggest "couldn't" bucket = tuning values).
|
||
|
||
**Decompilation feasibility (why viable):** the **headers are an answer key** (exact
|
||
class layouts, vtable order, signatures) and the **MUNGA engine source (~183 cpp) anchors ~half the
|
||
binary** by pattern-matching — and scope is bounded to only the ~15 missing modules. Stages: unwrap
|
||
(PKLITE→TNT image→strip zip) → load Ghidra/IDA → compile engine to auto-identify known funcs → decompile
|
||
missing modules method-by-method (vtable-located, header-typed) → rewrite into compilable C++. Result =
|
||
behavior-equivalent **reconstruction** (binary usable as an oracle to verify), NOT original source;
|
||
~months of expert RE. Pilot before committing: unwrap, prove anchoring, decompile one module (e.g. `heat`).
|
||
|
||
## 5b. ⭐ The Windows port (WinTesla) changes the plan
|
||
|
||
The `Elsewhen RP411` archive is a **working Windows port of the engine** (`elsewhen_extracted/.../trunk/`,
|
||
`WinTesla.sln`). It means the hardest infrastructure is **already solved** and need not be rebuilt:
|
||
- **Renderer bypass: DONE** (`MUNGA_L4/L4D3D.cpp` + `DXUtils` → Direct3D; libDPL/IG board removed — no
|
||
`libdpl.lib` in the tree). Our from-scratch `port/` viewer is now a *reference/sandbox*, not the path.
|
||
- **Audio: DONE** (OpenAL + libsndfile replacing HMI SOS).
|
||
- **L4 HAL on Windows + VS build system: DONE**; **Red Planet game logic: COMPLETE & buildable**.
|
||
|
||
So **Red Planet is (at/near) playable today** — build `WinTesla.sln` (VS2008 → upgrade to current VS).
|
||
And for **BattleTech**: everything below the game layer (engine, renderer, audio, HAL, build) already
|
||
exists here and is shared. The ONLY thing BT still needs is its **game-logic source** (mech/subsystems/
|
||
HUD/app), which is confirmed gone (§5a) → must be **reconstructed**, now with THREE strong anchors:
|
||
(1) all BT headers, (2) the working WinTesla engine to compile against, (3) **Red Planet's parallel
|
||
implementations as a live reference** (`VTV`≈mech, `VTVMPPR`≈mechmppr, `WEAPSYS`≈mech weapons, `RP_L4`
|
||
app≈`BT_L4` app) — plus `btrel410.exe` as a behavioral oracle.
|
||
|
||
## 6. Port codebase (`port/`)
|
||
|
||
Standalone **D3D9 model/animation viewer** — proves the renderer bypass + asset pipeline. **Not the game.**
|
||
|
||
- Build: `cmake -S port -B port/build -G "Visual Studio 16 2019" -A x64 -DCMAKE_GENERATOR_INSTANCE="C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools"` then `cmake --build port/build --config Debug`.
|
||
- **Toolchain gotchas:** the VS2019 *Community* install on this box is broken — must use the *BuildTools*
|
||
instance via `CMAKE_GENERATOR_INSTANCE`. Don't name a header `math.h` (shadows `<math.h>`; ours is `vmath.h`).
|
||
- Files: `vmath.h` (math), `bgf.{h,cpp}` (model + material/texture resolution), `image.{h,cpp}` (tga/vtx/bsl
|
||
decode), `scene.{h,cpp}` (retained scene graph: Object/DCS/Instance), `skeleton.{h,cpp}` (.skl→DCS),
|
||
`anim.{h,cpp}` (.ani player), `main.cpp` (Win32+D3D9), `tools/bgftest.cpp` (headless loader test).
|
||
- Run: `btviewer.exe <model.bgf | skeleton.skl> [animation.ani]`. Keys: `W` wire · `C` cull · `T` textures ·
|
||
`P` pose · `-`/`+` walk speed · `Space` pause · `Esc`.
|
||
- **Working today:** load any mech (geometry + real BMF/BSL skin), assemble from its `.SKL` skeleton,
|
||
play real `.ANI` animations, walk across a ground grid with procedural root motion. Verified on Avatar
|
||
+ Raptor. Walk speed currently tuned by hand (being derived from source — see §7).
|
||
|
||
## 7. Open technical notes
|
||
|
||
- **Walk speed / locomotion (SOLVED):** locomotion is **animation-driven**, NOT physics-derived. Each
|
||
`.ANI` has a `[RootTranslation]` section (per keyframe `x y z`) where **`.z` = forward root SPEED in
|
||
world units/sec** (meters/s); `[KeyJointPos].y` = vertical bob; `y` ≈ constant root height. The engine
|
||
(`RP\MUNGA\JMOVER.CPP` `AnimationInstance::Animate` ~1497-1723) integrates `movement += Δt·rootTrans.z`
|
||
and moves the body that far — so feet plant by construction (no separate stride constant; scale k=1).
|
||
Idle anims have z=0 (no travel); walks carry their speed (e.g. RAPWWR z≈20). The viewer reads this:
|
||
`g_walkPos += dt * forwardSpeedAt(anim, animTime)`. The `.MOD` physics (MoverMass/drag/MaxAcceleration)
|
||
governed thrust *vehicles* (RP hovercraft `VTVPWR`, camera ship) + collisions, NOT walk gait. NOTE: the
|
||
BT walking-mech mover/mapper code is NOT in the source dump (BT here is weapons-only; the mech consumer
|
||
of Animate() is absent) — but the per-clip speed is fully recoverable from the `.ANI` data above.
|
||
**✅ P3 LOCOMOTION CUTOVER — STEP 1 DONE (`btbuild/P3_LOCOMOTION.md`).** btl4's live drive (mech4.cpp
|
||
`PerformAndWatch`) is now ANIMATION-DRIVEN: the engine `AnimationInstance::Animate(dt,True)` return `adv`
|
||
(the clip's per-frame `[RootTranslation]` distance) drives the forward step (`localOrigin.linearPosition
|
||
+= facing * adv`), replacing the hand-tuned `kDriveMaxSpeed` slide. Verified: mech walks at the clip's
|
||
AUTHENTIC ~60 u/s (Blackhawk run; the guess was 30), forward, feet plant (travel==stride), combat+damage
|
||
un-regressed. NOTE: the reconstructed two-channel gait (mech2.cpp `AdvanceLeg/BodyAnimation`, `IntegrateMotion`)
|
||
is bracketed by no-op stubs (`ReconSeq` controllers → Advance()=0; `Matrix34`=`ReconMatrix` no-op), so the
|
||
full gait (STEP 7) needs those backed by real engine `AnimationInstance`/affine-matrix first. Plan +
|
||
7 steps in `btbuild/P3_LOCOMOTION.md`; the smallest milestone (STEPs 1-2, real per-clip speeds on the
|
||
proven engine path) is the near-zero-risk win.
|
||
**✅ P3 STEP 7 CUTOVER — the real gait controller drives locomotion LIVE (gated `BT_GAIT_CUTOVER=1`; verified).**
|
||
The BT-specific `SequenceController` (`decomp/reconstructed/seqctl.cpp`, reconstructed from binary
|
||
`SelectSequence@004277a8` + `Advance@0042790c`) is now reconstructed, built, and wired: the Mech ctor
|
||
`Init`s `legAnimation`/`bodyAnimation`, and `PerformAndWatch` (mech4.cpp, cutover branch) drives the
|
||
forward step from `bodyAnimation.Advance(dt,1)` (the real controller: parses the clip, animates the
|
||
skeleton joints via the engine `Joint` API `SetHinge/SetRotation/SetTranslation`, returns the
|
||
root-translation forward step) instead of the STEP-1/2 free-standing `AnimationInstance`. Verified live
|
||
under cdb: `[gait] SequenceController -> blhrrl id=904 (run)`, mech walks (adv≈1.07, matching baseline),
|
||
0 crashes; combat is byte-for-byte un-regressed vs a same-harness baseline run (both fire/damage
|
||
identically). **KEY BUG FIXED:** clip fetch was `SearchList(clip_id,16)` (which treats its arg as a
|
||
resource LIST → walks the clip bytes as garbage IDs → av); the clip_id from `ResolveAnimationClip` is a
|
||
DIRECT resource ID → use `FindResourceDescription(clip_id)`+`Lock`, mirroring the engine's own
|
||
`AnimationInstance::SetAnimation` (JMOVER.cpp:1406) EXACTLY. **DECOMP FACT — CORRECTED (re-verified vs `Advance@0042790c`
|
||
raw, part_003.c:6878):** the earlier "lumpy distance" claim here was WRONG — the binary's partial-frame
|
||
branch DOES accrue `(t-currentTime)*stride[currentFrame]` (distance is CONTINUOUS, matching seqctl.cpp:298),
|
||
and `IntegrateMotion` (@004ab1c8:14997) integrates `localVelocity.z = -adv/dt` directly with NO smoothing.
|
||
**FOOT-PLANT FACT (raw FUN_004ab430:15076):** the binary advances the BODY controller with `move_joints=1` —
|
||
the DISPLAYED joints and the travel distance come from the SAME Advance (same clip/phase), so feet plant by
|
||
construction; the leg channel (also hardcoded `move_joints=1`, FUN_004a5028:12006) runs FIRST and its joint
|
||
writes are overwritten. Port matched in commit 01312e8 (body channel `Advance(dt,1)`); the same commit also
|
||
added a ground-velocity smoothing (averaging adv/dt across keyframe spans) that is NOT in the binary — a
|
||
deliberate deviation; if residual micro foot-slip is ever chased, that smoothing is the first suspect.
|
||
Cutover is kept GATED (not default) pending two flagged fidelity refinements: (a) end-of-clip uses
|
||
`carryover*stride[last]` in place of the binary's recursive finished-callback `@0x48` (the source of the
|
||
occasional adv spike at clip-loop boundaries); (b) partial-frame rotation uses component-LERP vs the
|
||
binary's slerp (`FUN_00409390`). The deeper full-`IntegrateMotion` velocity path (leg+body channels +
|
||
world-step transform + `DeadReckonPose`, smoothing the lumpy step) is the remaining STEP-7 work.
|
||
**✅ STEP 7 — the AUTHENTIC world-step (velocity model) now drives the mech LIVE (under `BT_GAIT_CUTOVER`).**
|
||
The cutover no longer slides the position directly (`localOrigin += facing*adv`); it runs the real
|
||
`IntegrateMotion` motion-tail (`@004ab1c8`): `localVelocity = {0,0,-adv/dt}` (forward = local −Z) →
|
||
rotate into world by the body orientation via the now-**backed `FUN_00408744`** (part_000.c:8331, a
|
||
matrix×vector; backed with the engine `AffineMatrix::GetFromAxis` column basis — the same convention the
|
||
drive facing uses, so it is sign-consistent) → `localOrigin.linearPosition += worldVelocity*dt`. Result
|
||
== `facing*adv`, but through the authentic velocity→rotate→integrate machinery (runs `FUN_00408744` live).
|
||
**KEY DECOMP FACT (verified):** the 1995 motion transform `{ Point3D @0x260; Quaternion @0x26c }` **IS the
|
||
engine `localOrigin`** — `Origin = { Point3D linearPosition; Quaternion angularPosition }` (ORIGIN.h:15),
|
||
and raw `FUN_0040ab44` builds the matrix from BOTH halves (rotation from 0x26c, translation from 0x260).
|
||
So `IntegrateMotion`'s motion state maps DIRECTLY onto `localOrigin` (NOT the parallel `motionDelta`/
|
||
`worldPose` members the earlier draft declared — those were placeholders). Also fixed a latent
|
||
`IntegrateMotion` bug (unexercised while the fn was dead): it wrote only the separate `spinRate`@0x508 and
|
||
left `angularAccum[2]` (the velocity.z the world-step reads) stale → now sets it. Verified live under cdb:
|
||
walk-only (`BT_GAIT_CUTOVER=1`) walks straight forward at the authentic ~60 u/s, **0 crashes**; combat
|
||
fire/damage identical to STEP-1/2. **Remaining full-`IntegrateMotion` work:** the `AdvanceBodyAnimation`
|
||
gait STATE MACHINE (stand→walk→run transitions) + `LoadLocomotionClips` (real, mech3.cpp:326 — populates
|
||
`namedClip[]`/gait-speed caps via `legAnimation.SelectSequence`) in place of the inline clip-select;
|
||
orientation INTEGRATION (turn via angular rate, not the bring-up `gDriveHeading`); and velocity STORAGE
|
||
for MP dead-reckon. ⚠ **PRE-EXISTING crash observed (NOT locomotion):** a combat run walked into a
|
||
building's interest range and faulted in the ENGINE BGF loader (`LoadBgfFile("bld08.bgf")` →
|
||
`Builder::~Builder` `vector<float>` teardown → bad `operator delete`, bgfload.cpp:491) — a heap
|
||
corruption in model loading, hit position-dependently (the STEP-1/2 run with a different spawn fired 600+
|
||
shots clean). My world-step does zero heap ops + walk-only is clean, so it is not the cause; likely the
|
||
known enemy-spawn over-`sizeof` or a fragile BGF Builder for certain building assets. Track separately.
|
||
**✅ STEP 7 steps 1-2 — `LoadLocomotionClips` + the real gait STATE MACHINE drive the walk LIVE (`BT_GAIT_SM`).**
|
||
(1) **`LoadLocomotionClips`** (mech3.cpp:326, real) is now called in the Mech ctor (gated `BT_GAIT_CUTOVER`)
|
||
and completes cleanly for the Blackhawk — it resolves the full gait clip set (`swr/wwr/wwl/rrr/rrl/...`) +
|
||
measures the speed caps via `legAnimation.SelectSequence`+`keyframeData` (verified real: `walkStride=22.02`,
|
||
`standSpeed=6.83`). **KEY RECONCILIATION:** `namedClip[]`@0x5e0 and `animationClips[]`@0x5cc were declared as
|
||
SEPARATE arrays but in the binary are ONE (`namedClip[i]==animationClips[i+5]`, 0x5e0==0x5cc+0x14) — so
|
||
`LoadLocomotionClips`' writes weren't visible to `SetBodyAnimation`/`MeasureClipStride`. Fixed: `namedClip`
|
||
is now a pointer aliased to `&animationClips[5]` in the ctor (same storage). (2) **`AdvanceBodyAnimation`
|
||
state machine** (mech2.cpp, real) now drives the walk under `BT_GAIT_SM` (case 6/7: slew `bodyCycleSpeed`
|
||
toward `bodyTargetSpeed` with the loaded caps → `bodyAnimation.Advance` the `wwr` clip → world-step). Fixed
|
||
**`bodyAnimationState`@0x728 == `bodyStateAlarm.level`** (also declared-separate; re-synced from the alarm each
|
||
frame so `SetBodyAnimation`'s level change reaches the switch). Verified live: mech walks forward (z 1600→−975),
|
||
`adv≈0.35-0.42` at the authentic walk cadence (correctly slower than run's 1.07), clip loops through its 15
|
||
keyframes, 0 crashes. **Bring-up substitute (marked):** `SetBodyAnimation` passes a non-null loop sentinel
|
||
`(void*)1` where the binary passes `PTR_LAB_0050d6fc` — the real body finished-callback (gait-state
|
||
TRANSITION stand→walk→run + leg alternation, @0x48), NOT yet reconstructed; without it the cycle clip would
|
||
stick at its last keyframe (`adv=0`).
|
||
**✅ STEP 7 — the REAL gait TRANSITION CALLBACK reconstructed + wired (walk/run leg alternation + walk→run).**
|
||
Replaced the `(void*)1` sentinel with the real `FUN_004a6d8c` = `Mech::BodyClipFinished`. RESOLVED the `.data`
|
||
fn pointer by PE-parsing (`0x50d6fc`→body cb `0x4a6d8c`; `0x50d6f0`→leg cb `0x4a6928`) — not in the decomp, so
|
||
DISASSEMBLED with capstone + decoded the jump table (`@0x4a6e0a`; 33 states→10 handlers). Reconstructed
|
||
byte-for-byte: at end-of-clip it dispatches on `bodyAnimationState`, compares `bodyTargetSpeed` to the loaded
|
||
caps (`standSpeed`/`walkStrideLength`/`reverseSpeedMax`), picks the next state, re-arms it (`SetBodyAnimation`,
|
||
which re-binds the clip WITH this same callback), and recursively advances the carryover (shared tail
|
||
`0x4a6e66`/`0x4a6ed1`/`0x4a7001`). Walk alternates 6↔7, run 12↔13, stop→8/9, walk→run 6→11→12. **Plumbing:**
|
||
`SequenceController::Advance` now CALLS `cbCode` as `Scalar(*)(Mech*,unsigned,Scalar,int)` at end-of-clip
|
||
(matching the binary's `**(code**)(this+0x48)`), folding the returned distance; inline cutover uses
|
||
`Mech::LoopBodyClip`. **Verified live:** walk 6↔7 (adv≈0.42), run 12↔13 (adv≈0.5-0.85), walk→run fires, pos
|
||
advances, 0 crashes; inline cutover + combat un-regressed. **Bring-up gap fixed:** `reverseSpeedMax2`@0x7a0
|
||
(run bodyCycleSpeed clamp, AdvanceBodyAnimation case 12/13) is unset by LoadLocomotionClips → read `0xCDCDCDCD`
|
||
→ run exploded (`adv=-7e6`); set to `reverseStrideLength` (the case-12 divisor → ratio≈1). **DECOMP TECHNIQUE:**
|
||
resolve a Ghidra `PTR_LAB_xxxx` .data fn ptr by PE-parsing the DWORD at its VA, then capstone-disassemble the
|
||
target (`/tmp/readptr.py`+`disas.py`) — for any callback/vtable slot the assert-anchored decomp didn't export.
|
||
**Remaining:** the LEG callback `FUN_004a6928` (leg alternation, state@0x3b0 + motion source@0x128); airborne
|
||
`FUN_004a6344`; gimp handlers (states 16-19, targets `0x70b2`/`0x7161` undecoded → fall back to stand); gait
|
||
SELECTION from real throttle (`MechControlsMapper` still bypassed; SM forces walk-6 then the callback transitions).
|
||
- **✅ MoveAndCollide — the mech now COLLIDES with world solids (engine Mover pipeline; gated `BT_COLLISION`).**
|
||
The bring-up drive wrote `localOrigin.linearPosition` directly with no collision, so the mech phased through
|
||
terrain/objects + floated. KEY FINDING: the 2007 RP411 engine models **terrain AS `BoxedSolid` collision
|
||
volumes** (`TERRAIN.h`: `Terrain` = an `Entity` with `collisionVolumes`), so terrain-follow AND object
|
||
collision go through ONE path — the engine `Mover` collision pipeline (`MOVER.cpp`), NOT the 1995 heightfield
|
||
query (`FUN_0040e5f0`, which the dead `Simulate` used). Wired into `PerformAndWatch` (mech4.cpp) after the
|
||
drive's world-step: snapshot the pre-move pos → set `worldLinearVelocity` from the move delta →
|
||
`MoveCollisionVolume()` (reposition the volume to `localToWorld`) → `GetCurrentCollisions()` (gather
|
||
overlapping solids) → `ProcessCollisionList(cols, dt, oldPos, &damage)` (the engine resolves each penetration
|
||
via `ProcessCollision`→`StaticBounce`, WRITING `localOrigin.linearPosition` to push the mech out;
|
||
MOVER.cpp:1162/1200/1217). All methods are public on `Mover`; guarded on `GetCollisionVolumeCount()>0` so it
|
||
is a no-op for volume-less mechs. **Verified live:** in the cavern the mech walks flat (Y=−0.35), hits a
|
||
building solid at z≈150 and is pushed out once (x+7, y+2.9) over ~6 frames then continues stably (Y holds,
|
||
no drift/fly-away); combat un-regressed (structure→0.533, 0 crashes, 109 collide events while fighting).
|
||
**✅ GRAVITY / terrain-settle — DONE (same `BT_COLLISION` block; tunable `BT_GRAVITY`, default 20 u/s).**
|
||
Collision alone only pushes OUT of solids (no downward force → a floating mech never came down). Added a
|
||
per-frame downward Y (`localOrigin.y -= kGravityRate*dt`) BEFORE the collision resolve, so gravity presses
|
||
the mech down + the collision holds it at the terrain surface (equilibrium ON the ground). SAFETY NET: a
|
||
FLOOR CLAMP at the spawn/ground Y (captured on first frame) — on a map with NO terrain solid the mech rests
|
||
at ground level instead of falling forever; on real terrain (solids) the collision catches it above the floor
|
||
so it follows hills/valleys. **Verified live:** grass — mech settles at Y=0 (clamp; flat grass has no ground
|
||
solid) and climbs a terrain feature to Y=4.68; cavern — settles at the floor Y=−0.35, stable while walking,
|
||
combat un-regressed (structure→0.933), 0 crashes. So the mech now STANDS + walks on the ground + climbs
|
||
terrain + collides with objects. **Remaining refinements:** (b) wall-blocking vs climb — the engine resolves
|
||
along the surface normal, so a wall-top contact pushes UP (climb) rather than blocking forward; may need
|
||
game-tuning. (c) collision damage is computed by `ProcessCollisionList` but not applied here (bring-up). Kept
|
||
GATED (`BT_COLLISION`) pending (b) + confirmation on more maps.
|
||
- **✅✅ THE AUTHENTIC 1995 GROUND MODEL — DECODED, IMPLEMENTED, DEFAULT-ON (task #15 CLOSED for ground;
|
||
`BT_GROUND_REAL`, =0 falls back to the gravity+push baseline above).** Product of the 10-agent
|
||
`ground-model-decode` workflow (6 mappers over binary/1995-source/2007-engine/postmortem/content + synthesis
|
||
+ 3 adversarial verifiers, all SOUND). **THE MODEL (binary FUN_004a9b5c = the MASTER per-frame performance,
|
||
PTR_LAB_0050c0f4, decoded from raw asm — the "undefined 5.6KB gap" in mech4's window; FUN_004ab430 is the
|
||
REPLICANT interior only, NOT "Simulate"):** there is **NO GRAVITY anywhere in the mech** (accelerations
|
||
zeroed every frame @4a9cb6). Vertical position comes exclusively from an ABSOLUTE per-frame ground snap:
|
||
(1) ctor lifts `collisionTemplate->minY` by 5% of volume X-width (BLH: 2.0+0.37969=2.37969 — the probe
|
||
height AND the wall-vs-floor separation); (2) per frame: `MoveCollisionVolume()` (rebuilds localToWorld,
|
||
places volume, re-finds `containedByNode` in the zone's **BoundingBoxTree** — BOXTREE.cpp, identical
|
||
1995→2007, NOT a heightfield); (3) probe `q = origin + (0, lift, 0)` →
|
||
`BoundingBoxTreeNode::FindBoundingBoxUnder(q, &h)` (h = distance down to the highest solid top; −1 = miss);
|
||
(4) snap gate `h > 1e-4` (@0x4ab16c): `origin.y -= (h − lift)` ⇒ y = surfaceY EXACTLY (up-slope bounded by
|
||
the lift window = implicit step allowance; walk-offs drop instantly; **MISS ⇒ NOTHING** — y holds, no
|
||
runaway possible); (5) `GetCurrentCollisions` → `ProcessCollisionList` → per-contact **`Mech::ProcessCollision`
|
||
override (FUN_004abb40, vtbl+0x3c** — the fn the old recon misread as "ResolveWeaponImpact"): BoxedSolid
|
||
resolver → StaticBounce → owner classification — Mover owner = separating-gate + (deferred) mech damage;
|
||
CulturalIcon owner without `StoppingCollisionVolume` (flags&0x8000) = **crushable, damage=0.00123f sentinel**;
|
||
plain UnscalableTerrain (cliffs/canyon walls/most buildings) matches neither branch; (6) response policy:
|
||
sentinel = move STANDS (restore post-snap saves); damage>0 = **FULL FRAME REJECTION** (velocity zeroed,
|
||
origin=start-of-frame — walls block by rejection, never slide/climb); impactVel²>40 = crash-anim trigger
|
||
(SetLegAnimation(0x20) — deferred, logged). Implementation: `Mech::AuthenticGroundAndCollide` (mech4.cpp)
|
||
called for the player (post-drive) AND every non-player master; ctor half in mech.cpp; shared gate
|
||
`GroundReal()` (mechrecon.hpp). **KEY FIXES/FACTS:** (a) FUN_0040e36c/FUN_0040e5f0 are
|
||
`BoundingBoxTreeNode::FindSmallestNodeContainingColumn`/`FindBoundingBoxUnder` (BOXTREE.CPP:503/867), ALIVE
|
||
in the linked 2007 engine — the "Terrain::CellAt/HeightAt heightfield" labels were wrong (the cause of both
|
||
reverted attempts: v2 assigned the DISTANCE h as an absolute height → the y=13301 ladder). (b) binary
|
||
0x2e8/0x2ec/0x2f0/0x2f8 = ENGINE Mover `collisionVolume`/`collisionTemplate`/`containedByNode`/
|
||
`lastCollisionList` (NOT game-layer "physicsBody/groundRef/groundCell"). (c) grass DOES have a ground slab
|
||
(gr100_cv Block Y[−50,0]) — the old "flat grass has no ground solid" note was wrong. (d) **a master mech
|
||
needs a CollisionAssistant** (GetCurrentCollisions iterates it UNCHECKED, MOVER.cpp:894): only the viewpoint
|
||
mech gets one (MakeViewpointEntity); the BT_SPAWN_ENEMY dummy now `StartCollisionAssistant()`s at spawn +
|
||
the block has a defensive assistant guard (in the real game exactly ONE master exists per pod, so the
|
||
invariant held by construction). (e) BT SOLIDS ship ZERO tiles (only Block/YCyl/Cone/Ramp/Wedge, all
|
||
FindDistanceBelowBounded clamped ≥0) → h is never negative in BT. **VERIFIED (render-side + telemetry):**
|
||
grass census lift=0.379688/minY=2.37969 exact, y=0 EXACT on the slab; arena 65s anti-ladder soak y pinned 0,
|
||
wall BLOCKs hold (no creep); dbase two full hill climbs (y→24/y→31) + exact floor return −0.453125;
|
||
screenshots standing/climbing; combat DESTROYED after the same ~8 hits; baseline (=0) byte-identical; 7-min
|
||
BT_HEAPCHECK soak clean. **EXPECTED-AUTHENTIC (do not "fix"):** sticky-ledge frame rejections walking off
|
||
2.4-5.9u terraces; crushable props pass through WITH crunch; canyon backdrops (rav pwin*)/arena detail have
|
||
no solids = pass-through; over void y HOLDS (no fall).
|
||
**✅ CRASH/KNOCKDOWN LIVE (commit 6452ab7):** wall impacts above iv2>40 (~6.3 u/s) bind the `bmp`
|
||
bump/stagger clip — `SetLegAnimation(0x20)` + `RequestActionFlags(1)` + `(0x20)` (binary @4aaae2;
|
||
FUN_004a4c54 == RequestActionFlags, NOT "ForceUpdate"). ALIASING FIX (namedClip bug class): `gimpBaseClip`
|
||
@0x64c IS `animationClips[0x20]` (0x5cc+32*4) — bmp now loads into slot 32; the clip table is zeroed in
|
||
the ctor. Recovery = LegClipFinished case 32 → SetLevel(0) (already reconstructed). BRING-UP GUARD
|
||
(marked): no rebind while legState==0x20 — the bmp clip's ~6.5 u/s root motion re-contacts the wall and
|
||
the `actionRequestFlags` consumers (likely the stagger drive-suppressor) are unreconstructed; without the
|
||
guard the clip restarted every frame. Verified: knockdown → play out → stand → walk → re-knockdown at ~3s
|
||
cadence at a butte wall; mid-stagger screenshot ON the surface; combat clean. **Why leg-burial appeared at
|
||
walls before this:** frame rejection freezes pre-snap Y while grinding (the snap is undone every rejected
|
||
frame) — the knockdown is what prevented sustained grinding in the original. Related content facts
|
||
(measured, tools/mapscan.py + BGF triangle sampling): dune mounds visual≈solid (median 0.2u, p90 1.1u);
|
||
buttes deviate ±5u typical / ±15-25u at cliff faces — authentic 1995 coarseness. The transform chain
|
||
(quat→matrix identical formulas both paths, row-vector convention, ToD3DMatrix raw copy) was
|
||
audit-verified — no visual-vs-solid placement bug exists.
|
||
**✅ KNOCKDOWN RE-TRIGGER STUTTER — FIXED (user: "leg animation glitchy/stuttery when I run into an obstacle,
|
||
fixes if I fully stop"; reproduced + spec-confirmed).** Running INTO a blocking solid (a wall, a steep terrain
|
||
flank, OR the spawned dummy MECH — not slope-specific) at speed made the leg animation flip-flop between the
|
||
stagger clip (legState 0x20) and walk. Repro (drive into the dummy on grass, `BT_GROUND_LOG`): the FIRST hit
|
||
at run speed is a genuine knockdown, but then the mech is pressed against the obstacle with throttle held, so
|
||
every recovery frame it steps ~6.9 u/s back into it and the `iv2>40` (=6.32 u/s) knockdown RE-FIRES → rapid
|
||
freeze↔walk = the stutter (BLOCK=319 / **CRASH=107** in 22 s; leg flip-flopping 5↔32). Full stop clears it
|
||
(velocity→0→iv2<40). **The ground-model spec (scratchpad `gd_spec.md`, the hand-decoded master perf
|
||
`FUN_004a9b5c` @4aaae2) shows the binary's mitigation: `localVelocity = {0,0,−adv/dt}` is ZEROED while crashed
|
||
(@4aa3d3), so a knocked-down mech can't keep advancing into the obstacle** — our reconstruction re-derived
|
||
localVelocity from the position delta and never zeroed it, and re-armed the knockdown every fresh block. TWO
|
||
fixes (mech4.cpp): (1) **crash motion suppression** — while `legState==0x20`, zero `worldLinearVelocity` +
|
||
`localVelocity.linearMotion` (spec-faithful; no mid-stagger creep, no re-detect during the clip); (2)
|
||
**contact-hysteresis knockdown gate** — the knockdown fires only on a FRESH strike, where "fresh" = the mech
|
||
has been OUT of blocking contact for `kBlockHysteresis` (0.4 s). A 1-frame edge (`!gWasBlocked`) was tried
|
||
FIRST and FAILED for the user: a GLANCING / sliding contact alternates blocked↔not-blocked (the frame
|
||
rejection pushes the mech out each blocked frame), which flickers a 1-frame edge back to "fresh" and
|
||
re-knocks-down — that intermittent case is exactly what a head-on force-throttle repro never hits (it shows a
|
||
clean 1 bind, hiding the bug). The 0.4 s `gBlockCooldown` window bridges those gaps: any sustained OR
|
||
intermittent press just BLOCKS (runs/walks in place), only a real separate-then-re-approach re-arms it.
|
||
`gBlockCooldown` is a single global gated to the VIEWPOINT mech (the stationary dummy + MP masters also call
|
||
`AuthenticGroundAndCollide`, so they keep the original per-fresh-block behavior and never touch the shared
|
||
state). **Verified across cases:** dune-beeline **21→1**, head-on dummy **→1**, terrain-crossing (BLOCK=497)
|
||
**→1**; still blocks (no phase-through); combat un-regressed (TARGET DESTROYED, 0 crashes / 0 heap); normal
|
||
walking unaffected (suppression only triggers on legState 0x20). ⚠ DIAGNOSTIC LESSON: a head-on repro gives
|
||
1 bind even with the brittle edge gate — the bug only shows on INTERMITTENT contact (glancing / sliding /
|
||
rough-terrain), so reproduce with an angled or terrain-crossing approach, not a straight ram. NOTE: holding
|
||
full-throttle into a wall still yields ONE knockdown then a clean block — a real player releasing throttle
|
||
sees just the single stagger.
|
||
**✅ TWO-CHANNEL GAIT DESYNC — FIXED (the ACTUAL "legs stay glitchy after a bump until full stop" bug).**
|
||
The knockdown re-trigger work above reduced the crash spam but the user reported the legs stayed glitchy
|
||
AFTER moving away, only clearing on a full stop. ROOT CAUSE (measured, not guessed): the real-controls gait
|
||
runs TWO animation channels — the BODY channel draws the displayed legs (`AdvanceBodyAnimation(dt,1)`, runs
|
||
last, wins the joint writes) while the LEG channel drives the mech's TRAVEL (`localAdv = legAdv`). In normal
|
||
walking they run in EXACT lockstep (the `[sync]` log: `advSum == legSum` frame-for-frame). The knockdown
|
||
staggered ONLY the leg channel (`SetLegAnimation(0x20)`), freezing TRAVEL while the body channel kept
|
||
animating the DISPLAY -> the two clocks split PERMANENTLY (measured `advSum=229` vs `legSum=112`: the
|
||
displayed legs animate ~2× the real travel = foot-slip) and never reconverged until a full stop reset both to
|
||
Standing. **FIX (one line):** the knockdown now staggers BOTH channels — `SetBodyAnimation(0x20)` alongside
|
||
`SetLegAnimation(0x20)` — so display + travel freeze and recover from the SAME bmp clip IN PHASE
|
||
(`BodyClipFinished` case 32 recovers the body, mech2.cpp:308, mirroring the leg). **Verified:** `advSum ==
|
||
legSum` stays EXACT through+after the bump (was 229 vs 112); mech still BLOCKS the obstacle (BLOCK=328, motion
|
||
stays on the leg channel so no tunnel-through); combat un-regressed (DESTROYED, 0 crashes). NOTE: the
|
||
now-correct behavior is a FULL STOP + stagger on hard contact (authentic: frame-rejection + the iv2>40 crash
|
||
anim); the old "run through / run in place" was the desync bug, not original behavior.
|
||
**✅✅ v4 — DESYNC CLASS KILLED FOR GOOD: travel now comes from the BODY channel (the SAME Advance that
|
||
writes the displayed joints) — BINARY-FAITHFUL (raw FUN_004ab430:15076 advances the body with move_joints=1
|
||
and takes travel from it).** The stagger-both-channels fix (above) killed the KNOCKDOWN trigger, but the
|
||
desync CLASS resurfaced on the Mad Cat + the analog throttle lever: the lever sweeps the demand THROUGH the
|
||
walk/run boundaries, and each channel's end-of-clip callback samples the demand at a different instant — near
|
||
a threshold they pick DIFFERENT next states → permanent phase split → the foot-slip again. v4 sources
|
||
`localAdv = adv` (body) so display == travel BY CONSTRUCTION — no two-machine agreement needed, ever. The
|
||
earlier "tunnels through obstacles" objection to exactly this change is OBSOLETE: it predated
|
||
`SetBodyAnimation(0x20)` — with the knockdown staggering the body channel too, a hard impact freezes travel
|
||
correctly. RE-VERIFIED: head-on run-speed ram into the dummy = pinned at its face for seconds (BLOCK=801,
|
||
1 knockdown, NO tunnel-through, then a normal grazing slide around the corner); combat DESTROYED on BOTH
|
||
BLH and Mad Cat, 0 crashes. The leg channel keeps running as the binary's local sim (its joint writes land
|
||
first and are overwritten by the body's). The full `actionRequestFlags`(1/0x20) drive-suppressor +
|
||
crashed(0x650) lifecycle remain unreconstructed (master-perf ASM gap); the edge gate is the faithful
|
||
stand-in until they land.
|
||
**✅ RESIDUAL-SINK ALIGNMENT AUDIT — quantified; verdict = authentic coarseness, NOT a bug or a mesh error
|
||
(dbase; scratchpad audit scripts).** After the CONN trilist fix, the user still noticed "sink in a bit" in
|
||
some spots and asked for a triangle-error audit. TWO sweeps run: (1) **GEOMETRY** — all 877 pod GEO BGFs /
|
||
127,171 tris: **0** CONN chunks with n%3≠0 (fan-bug corpus-complete), **0** out-of-bounds indices; the only
|
||
52 anomalies are **zero-area (repeated-index) no-op** tris, ALL in props/vehicles (AB01/ARBAN/BULLET/MSLBASE/
|
||
TK1/WL*), NONE in terrain → no mesh defect causes sinking. (2) **VISUAL-vs-COLLISION deviation** — per dbase
|
||
terrain model, sampled each visual vertex's Y vs the analytic `.SLD` surface (Cone apex-up / YCyl / Block) at
|
||
its XZ; worst clip in the walkable band (Y<15): **mesas (buttea-e) 2–3.5u**, **dunes (dhillg1-3) 0–3.1u**,
|
||
**flat pad (calpb) 1.6u** (⇒ NO global snap offset — the probe/lift is correct), **buildings (bld06/bld08)
|
||
12–14u = the outliers**. Butte "overall" 23u is on the VERTICAL CLIFF FACES (unwalkable, the stepped-cylinder
|
||
cliff approximation — faithful). **KEY per-feature facts:** dbase terrain is placed by class-42 instances
|
||
(NOT the class-94 vehicle props); mesa collision = a STACK of YCyls + a base Cone whose footprint is ~5–22u
|
||
narrower than the visual skirt (the small skirt clip). **Building collision = separate COLUMN/WALL Blocks with
|
||
GAPS** (bld08=26 blocks, bld06=7), and the visual has floors/overhangs spanning the gaps → a mech walks into a
|
||
gap / under an overhang where there's no solid and its legs clip the visual (THIS is the user's "some things
|
||
are solid, some aren't" + the biggest residual sink). ALL authentic 1995 coarse collision — the original pods
|
||
clipped identically; the only "fix" (gap-filling collision on buildings) is an intentional content DEVIATION,
|
||
not a port correction → left as-is by default.
|
||
**DEFERRED (bound together, marked in code):** collisionTemporaryState zero @4aa741 + ProcessCollision state
|
||
tail; 0x4a4/0x4a8/0x4b4 caches (material/local normal/approach speed); the TakeDamageMessage dispatches
|
||
(zone=−1 dropped by the engine handler today + inflictor global DAT_0050b9ac unmapped); SetLegAnimation(0x20)
|
||
crash anim; gyro crunch feed; duck maxY swap wiring; replicant-path snap (MP — gate `h != −1`, part_012.c:
|
||
15144-15167, the 0x4ab9d8 wrapper). Full spec + verdicts: scratchpad gd_spec.md/gd_verdicts.md (session);
|
||
the workflow reports are the durable citations.
|
||
- **Renderer dependency** is isolated to 3 HAL files in the original (`L4VIDEO/L4VIDPER/L4VIDRND` call `dpl_*`).
|
||
The full `dpl_*`/`dpl2d_*` API surface the game uses is cataloged (see chat history / shim contract).
|
||
|
||
---
|
||
|
||
## 8. Roadmap: viewer → playable
|
||
|
||
**REVISED after finding the WinTesla Windows port (§5b).** The original "build a shim + port the engine"
|
||
plan is largely **already done** in WinTesla. New plan:
|
||
|
||
- **Track 1 — Red Planet build (integration spike measured the gap):** `WinTesla.sln` is **VS2005**
|
||
(Format 9 / vcproj v8), **225 source files**, and hard-depends on the **legacy DirectX SDK (Jun 2010)** —
|
||
it includes `<d3dx9.h>`, `<d3dx9math.h>`, `<dinput.h>`, `<dxerr.h>`, all REMOVED from the modern Windows
|
||
SDK (only `d3d9.h` remains). OpenAL32/libsndfile libs are in `trunk/lib`. So building RP is a real
|
||
**toolchain-modernization sub-project**, not a recompile: (1) ~~obtain legacy DXSDK~~ **DONE — installed at `C:\Program Files (x86)\Microsoft DirectX SDK
|
||
(June 2010)\` (Include + Lib\x86 present: d3dx9/dinput/dxerr/dxguid).** Note: the installer threw
|
||
**S1023** (harmless — caused by a newer VC++2010 redist v10.0.40219 already present blocking the bundled
|
||
10.0.30319; the SDK files install before that failing step, so they survive — just dismiss the error), (2) drive the 225 files via CMake (relative `..\munga\` includes → compile each from
|
||
its project dir or add all dirs to the include path) or upgrade the vcprojs, (3) fix VS2005→VS2019
|
||
C++/header drift across the tree, (4) link d3dx9/dinput8/dxguid/dxerr + OpenAL/libsndfile. Bounded but a
|
||
dedicated session; best done where the DXSDK can be installed normally. Validates the platform + yields a
|
||
playable RP. **BT integration = same build + drop in the reconstructed `decomp/reconstructed/` modules.**
|
||
|
||
**✅ ENGINE COMPILES GREEN** (`C:\git\nick-games\enginebuild\`, CMake/MSVC Win32) — the shared
|
||
`Munga_L4` 193-file engine+HAL builds to `munga_engine.lib` (0 errors). Recipe (4298→0 errors):
|
||
(a) include path = **DXSDK Include + `enginebuild/shim` ONLY** — do NOT add MUNGA dirs (they shadow
|
||
`<time.h>` → 3840 errors); rely on the code's relative/quoted includes. (b) defines: `WIN32 _WINDOWS
|
||
UNICODE _UNICODE NOMINMAX _CRT_SECURE_NO_DEPRECATE _CRT_SECURE_NO_WARNINGS _SILENCE_STDEXT_HASH_DEPRECATION_WARNINGS
|
||
_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS`. (c) opts `/permissive /W0 /EHsc /bigobj /MP`. (d) source edits:
|
||
`MUNGA/CAMMGR.cpp` `std::ios.in`→`std::ios::in` (2007 typo); add `Time(const Time&)` copy ctor to
|
||
`MUNGA/time.h`. (e) `enginebuild/shim/atlbase.h`+`atlconv.h` = minimal ATL `USES_CONVERSION`/`W2A` shim.
|
||
Build Unicode (matches original — wide literals → `...W` APIs).
|
||
- **Track 2 — BattleTech:** reuse the WinTesla engine (shared MUNGA/MUNGA_L4/L4D3D/audio/build); add a
|
||
`BT410_L4` + BT game library by **reconstructing the missing BT `.cpp`** (mech, subsystems, HUD, app)
|
||
from: BT headers + RP's parallel code (§5b) + `btrel410.exe` oracle + pod content. This is the real
|
||
remaining work for BT — bounded to game logic, since the engine/renderer/HAL/audio are done.
|
||
**→ NOW LIVE (§10):** `btl4.exe` boots + renders + runs a drive→animate→target→fire→damage→destroy
|
||
single-player loop; the active work is reconstructing each subsystem's authentic behavior (damage zones
|
||
first, then weapon/heat/sensor subsystems — they are still `RECON_SUBSYS` stubs). See §10 for the
|
||
build/run/debug recipe, the no-stand-ins reconstruction method, and the per-subsystem decomp findings.
|
||
|
||
Old roadmap below is superseded for Phases 1–3 (engine/renderer/HAL/audio = done in WinTesla); Phases
|
||
4–8 still apply to BT's game layer + pod bring-up. The from-scratch `libDPL` shim is **no longer needed**
|
||
(WinTesla replaced libDPL with L4D3D outright).
|
||
|
||
(Historical plan — keep for reference of remaining game-layer/pod work:)
|
||
|
||
- **Phase 0 — Foundation ✅ (done):** asset formats cracked + documented; D3D9 viewer renders skinned,
|
||
skeleton-assembled, animated mechs. Renderer bypass proven feasible.
|
||
- **Phase 1 — `libDPL` shim as a real API:** turn the viewer renderer into a drop-in lib exposing the
|
||
actual `dpl_*` (3D) + `dpl2d_*` (2D) C functions the game calls, backed by D3D9. *Linchpin: once the API
|
||
matches, the game's own rendering code compiles unchanged.* Risk: matching semantics (fog, LOD, materials,
|
||
`dpl_SectPixel` picking, 2D display lists, morph/damage/articulation).
|
||
- **Phase 2 — Compile the game code (modern toolchain):** get `MUNGA` + `BT` C++ building on MSVC/Clang —
|
||
modernize Borland-isms, replace `.asm`, stub the `L4` layer — linked against the shim. Goal: it *builds*.
|
||
Risk: pervasive non-standard 1995 C++; DOS/DPMI assumptions.
|
||
- **Phase 3 — L4 HAL on SDL/Win32:** replace `L4` video-out, input, timing, file I/O. Goal: the game first
|
||
*runs* — boots to menu / loads a mission.
|
||
- **Phase 4 — Cockpit (HUD/controls):** `dpl2d_` HUD + `.pcc`/`.gim` gauges + radar; map RIO cockpit
|
||
controls API → keyboard/gamepad; data-driven multi-surface output (dev windows vs pod's 7 monitors).
|
||
- **Phase 5 — Gameplay loop:** one single-player mission end to end — drive, fire, heat, damage, AI bots.
|
||
→ **playable (single player)**.
|
||
- **Phase 6 — Audio:** HMI "SOS" sound engine → modern mixer (SDL_audio / similar).
|
||
- **Phase 7 — Multiplayer:** ⚠ **CORRECTED (scope-hardest workflow, verified):** NOT "reimplement over UDP" —
|
||
the WinTesla port ALREADY replaced the DOS `NETNUB` driver with a **3446-line WinSock2 TCP** reimplementation
|
||
(`MUNGA_L4/L4NET.CPP`, `SOCK_STREAM`/`ReliableMode`, zero `SOCK_DGRAM`) and the master/replicant distributed-
|
||
sim core (InterestManager/Entity/HostManager) is complete. So multiplayer = **integrate + smoke-test that
|
||
existing TCP stack**, gated on P3 (locomotion update writer) + P5 (entity create/destroy lifecycle) + subsystem
|
||
WAVEs. Unknown: whether that TCP path has ever run successfully even in RP (a send-size bug at `L4NET.CPP:1853`).
|
||
This is problem **P6** in `btbuild/HARD_PROBLEMS.md`.
|
||
**⭐ P6 SMOKE TEST PASSED — TWO INSTANCES SHARE A WORLD (cross-pod entity replication live).** The full
|
||
chain works on one box: console egg delivery → TCP mesh → synchronized mission start → bidirectional
|
||
entity replication (each instance renders its own mech + the peer's REPLICANT; `[BTrender] mech tree
|
||
built` ×2 on both sides; 0 crashes). HOW IT WORKS (workflow-mapped + verified): (1) `-net <port>` CLI arg
|
||
= networked mode; the pod listens on <port> for a CONSOLE and boots `ConsoleOnly`; game mesh port =
|
||
<port>+1. Solo (no `-net`) never touches WinSock. (2) The mission egg's `[pilots]` section IS the session
|
||
roster (`pilot=ip[:port]` + a per-address page with hostType=0/name/vehicle/dropzone...); each pod
|
||
self-identifies by local-IP+game-port match, CONNECTS to pilots listed before it, LISTENS for those after
|
||
— a deterministic full mesh; mission loads when all peers connect ("All connections completed!").
|
||
(3) The CONSOLE (the operator station — ABSENT from every archive) delivers the egg as chunked
|
||
`ReceiveEggFileMessage` packets; **`tools/btconsole.py` is our console emulator** (wire spec in its
|
||
docstring, constants probed from the build via `BT_NET_PROBE=1`). ⚠ `NotationFile::ReadText` expects
|
||
NUL-SEPARATED LINES, not raw file text (NOTATION.cpp:1043 walks strchr(buf,0)+1) — the tool converts.
|
||
(4) One-box recipe: instance A `-egg MP.EGG -net 1501 BT_LOG=mp_a.log BT_AFFINITY=0x1`, instance B
|
||
`-net 1601 BT_LOG=mp_b.log BT_AFFINITY=0x2` (new env vars: per-instance log + CPU pin), then
|
||
`python tools/btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601`. MP.EGG/MP1.EGG (2-pilot loopback /
|
||
1-pilot networked) live in `btbuild/` (copy to the pod BT dir). Egg entries MUST carry explicit :port on
|
||
one box (else both instances self-identify as the same pilot). (5) **WIRE-FORMAT BUG CLASS FOUND+FIXED:
|
||
MakeMessages replicate RAW over TCP, so string payload must be INLINE** — the reconstructed
|
||
`BTPlayer::MakeMessage` carried `const char*` pointers (fine solo, garbage cross-pod: the first remote
|
||
BTPlayer replication crashed in `CString(roleName)`); fixed to inline `char[0x40]` at the binary offsets
|
||
(+0x50 teamName/+0x90 roleName, static_assert-locked, size 0xD0). CHECK EVERY MakeMessage for pointer
|
||
payloads before it replicates (swept: no others currently). (6) The rumored L4NET.CPP:1853 send-size bug
|
||
is DEAD CODE (`#if MESSAGE_BUFFERING` == enum True == 0 to the preprocessor); live send paths are correct.
|
||
**⭐⭐ MOVEMENT REPLICATION COMPLETE — A's mech WALKS ON B'S SCREEN (replicant tracks the master to
|
||
~1.5u through dead-reckoning).** The full authentic mission-start ladder now runs: console egg →
|
||
CreatingMission → (event-queue quiet timeout, APP.cpp:667 `QUIET_TIME_OUT` 3s) → LoadingMission →
|
||
WaitingForLaunch → **console sends `RunMissionMessage` ×2** (the operator's LAUNCH button;
|
||
`btconsole.py` sends them after a 20s settle + 4s step; ID=5, clientID=4, probed) → LaunchingMission →
|
||
**RunningMission (state 5)**. SIX bugs/gaps found+fixed to get here (each verified live):
|
||
(1) Mech ctor now `SetDeadReckoner(&Mover::AcceleratedDeadReckoner)` (the VTV pattern);
|
||
(2) REPLICANT MOTION: `Mech::PerformAndWatch` runs `DeadReckon(dt)` (engine lerp toward
|
||
projectedOrigin) for `GetInstance()==ReplicantInstance` instead of the drive;
|
||
(3) MASTER EMISSION: the VTV error-threshold block (self-predict via own deadReckoner; `ForceUpdate()`
|
||
on posErr²>0.04 / quat-w<0.997 / 2s heartbeat) — without it masters NEVER emit update records;
|
||
(4) emission GATED on `RunningMission` — pre-launch update spam kept the PEER's CreatingMission
|
||
event-queue from ever going quiet (a cross-pod deadlock);
|
||
(5) the console must send the LAUNCH — **even solo had been running entirely on the
|
||
`IsPreRunnable()` player-escape in a pre-launch state, forever** (nothing posts RunMissionMessage
|
||
locally); solo still works that way (unchanged);
|
||
(6) **REPLICANT VALIDITY: an invalid entity defers EVERY message as an event forever**
|
||
(`Entity::Receive`/`Dispatch`, the '07 edit defers all) — replicants never get the master's
|
||
MakeReady/CheckLoad handshake (the interest layer is partial), so their network updates never applied
|
||
AND the deferred-event churn also blocked the quiet-timeout. FIX: `Mech::Make`/`BTPlayer::Make`
|
||
`SetValidFlag()` replicant instances at creation (same fix the spawned test dummy needed).
|
||
**Debug tooling added (env `BT_NET_TRACE`, permanent, in the ENGINE tree):** `[net-tx]`/`[net-rx]`
|
||
(L4NET send/receive), `[net-upd]` (update entity lookup), `[upd-repl]` (replicant scheduling),
|
||
`[ent-exec]` (Execute state ladder, 1-per-240 frames) — plus `[emit]`/`[repl]`/`[mech-exec]` in
|
||
mech4.cpp under `BT_REPL_LOG`. Update stream measured: ~60 Hz × 144-byte records per moving master.
|
||
**NEXT (P6 phase 4):** cross-pod combat (target a REPLICANT + damage routed to the owning master —
|
||
Entity::Dispatch on a replicant already reroutes to the master via SendMessage), interactive 2-window
|
||
driving, replicant gait animation (replicants don't animate joints yet — derive from replicated
|
||
velocity), then the pod-LAN config (real IPs, both `-net 1501`, bare-IP pilot entries).
|
||
- **Phase 8 — Pod bring-up (with Nick):** 7-monitor layout, old-driver compatibility, RIO cockpit I/O
|
||
wiring, on-pod testing.
|
||
|
||
**Alternate route considered:** run original `btrel410.exe` under DOSBox — **blocked**: it needs the
|
||
VPX/IG board DOSBox can't emulate; bypassing it = the shim work anyway.
|
||
|
||
## 9. Things to get from Nick
|
||
|
||
- **#1 (gating): the BattleTech game SOURCE CODE** — the missing implementation `.cpp` (mech, subsystems,
|
||
mapper, HUD, main app; see §5a). Without it the source port can't proceed. Likely on a backup/dev
|
||
drive/repo separate from these machine images.
|
||
- Confirmation the PodPC image is the complete content master (or where the rest is). NOTE: the runtime
|
||
`BTL4.RES` already has 8 maps + the full mech anim set (§4a); genuinely-absent items are the lab/other-build
|
||
maps (des/burnt/frstrm — source-only) and possibly a fuller content build. Likely low priority now.
|
||
- A working session on pod specifics: the 7-monitor driver setup, the RIO cockpit I/O protocol, and how
|
||
the current Win10+wrapper pods are configured (for Phase 8).
|
||
|
||
---
|
||
|
||
## 10. Combat bring-up — `btl4.exe` runs a single-player loop ⭐ (this is the live front)
|
||
|
||
The reconstructed BT (`C:\git\nick-games\btbuild` → `run\btl4.exe`) now boots, renders the cavern world
|
||
+ a skinned Blackhawk, and runs a **drive → animate → target → fire → damage → destroy** loop. The
|
||
remaining work is making each link *authentic* (reconstructed from the binary), not bringing the link up.
|
||
Companion ledgers: `btbuild/RECONCILE.md` (running findings), `docs/BT_SOURCE_STATUS.md` (missing-file
|
||
map), memory files `bt-combat-bringup`, `reconstruction-no-standins`.
|
||
|
||
### 10a. Build / run / debug recipe
|
||
- **Build:** `cmake --build C:\git\nick-games\btbuild\build --config Debug` (CMake/MSVC Win32, VS2019
|
||
**BuildTools** instance). Links `bt410_l4` (reconstructed BT) + `munga_engine.lib` (enginebuild) +
|
||
DXSDK d3d9/d3dx9/dinput8 + OpenAL/libsndfile. Linker uses `/FORCE` (tolerates header-defined globals
|
||
+ dead offline-factory unresolved externals — see CMakeLists comment; a cleanup TODO).
|
||
- **Single-file check** before a full build: `cmd /c C:\git\nick-games\btbuild\compile1.cmd <file>.cpp`
|
||
(green = compiles). NOTE: compile-green ≠ link-green — a fn that pulls `mech3.obj` exposes the dead
|
||
factory unresolveds (why `/FORCE`).
|
||
- **Run:** copy `build\Debug\btl4.exe`+`.pdb` to `btbuild\run\`, run from the **pod BT dir**
|
||
`tesla4pod_extracted\Tesla4PodPCNovell\CopyofNovellDisk\REL410\BT` with `-egg TEST.EGG`. Logs to
|
||
`btl4.log` in that dir (grep `[anim]/[drive]/[target]/[fire]/[damage]/[boot]` markers). cwd MUST be the
|
||
pod BT dir (see `loadTables` gotcha below).
|
||
- **⭐ THE AUTHENTIC STACK IS NOW DEFAULT-ON** (`BTEnvOn`, mechrecon.hpp): `BT_GAIT_CUTOVER`,
|
||
`BT_GAIT_SM`, `BT_COLLISION`, `BT_REAL_CONTROLS` all default ON — set `=0` to fall back to the
|
||
historic bring-up path (e.g. `BT_GAIT_SM=0`). Verified: an env-free run gets the full authentic chain
|
||
(mapper speed/turn demands → two-channel gait → collision+gravity) with combat/damage intact, and each
|
||
`=0` fallback still works.
|
||
- **Bring-up env vars** (DEBUG/headless; default OFF): `BT_FORCE_THROTTLE=1` (auto-walk fwd, no key),
|
||
`BT_SPAWN_ENEMY=1` (spawn a target mech 120u ahead ALONG THE SPAWN FACING — facing-aware since the
|
||
real-controls drive keeps the authentic spawn orientation), `BT_FORCE_FIRE=1` (auto-fire on cooldown),
|
||
`BT_ASSERT_TO_DEBUGGER=1` (route CRT asserts to the debugger instead of a modal box), `BT_HEAPCHECK=1`
|
||
(whole-heap validation on every alloc/free — heap-corruption hunts), `BT_PROBE_BGF=<name|ALL>` (+
|
||
`BT_PROBE_N`) (boot-time BGF loader probe, exits after). Interactive:
|
||
**WASD** drive, **Space/Ctrl** fire, **X** all-stop. Gated/marked in `btl4main.cpp` + `mech4.cpp`.
|
||
**⭐ VIRTUAL CONTROLS (dev keyboard → the pod's analog inputs; mech4.cpp drive block):** the pod's
|
||
throttle was an ABSOLUTE analog LEVER + an analog turn stick — momentary 0/1 keys can't express either.
|
||
After the perf fix took the game 10fps→60fps, every key tap landed as a frame-perfect FULL demand (at
|
||
10fps most taps aliased away between sim ticks, which had LOOKED like fine control) → "controls too
|
||
sensitive, tap-back reverses instead of stopping". mech4 integrates dt-scaled: **W/S sweep a PERSISTENT
|
||
throttle lever** (tap ≈ ±0.08 step, full sweep ~1.4s; a lever below standSpeed = the mech stands, authentic)
|
||
with a **DETENT at zero** (braking from forward stops AT 0 and latches until the key is released — no
|
||
accidental reverse), **X = all stop**; **A/D deflect a momentary stick** (~0.4s to full, auto-centers) so a
|
||
tap = a gentle nudge. `gBTDrive.throttle/turn` are the integrator OUTPUTS; the mapper bridge +
|
||
forced-throttle harness consume them unchanged (BT_FORCE_THROTTLE bypasses the lever).
|
||
**⚠⚠ KEY-INPUT GOTCHA — the WndProc NEVER RECEIVES WM_KEYUP:** the engine's per-frame keyboard reader
|
||
(`L4CTRL.cpp:1506`) `PeekMessage(PM_NOREMOVE)` + `GetMessage()`s every `WM_KEYUP`/`WM_SYSKEYUP`/`WM_CHAR`
|
||
OUT of the queue for its key-command channel — only KEYDOWNs reach the launcher WndProc, so ANY
|
||
message-based key-state tracking latches keys on forever (this was ALSO true of the old 0/1 scheme: its
|
||
throttle/turn/fire never cleared on release; the old "feel" was that quirk + 10fps aliasing). The
|
||
integrator therefore POLLS `GetAsyncKeyState` per frame (via GetProcAddress, no headers needed), gated on
|
||
the game window being FOREGROUND (`GetForegroundWindow` pid == ours) so background typing doesn't drive
|
||
the mech. **`BT_KEY_NOFOCUS=1`** = test hook accepting keys without focus (SendInput automation from a
|
||
background shell can't grant real foreground; `scratchpad/realkey.ps1` injects real keys for harness runs);
|
||
**`BT_KEY_LOG=1`** = 1 Hz `[vctl]` key/lever/stick state. BTDriveInput struct is duplicated in
|
||
btl4main/mech4/mechmppr/emitter — keep in sync (the emitter copy had already silently drifted).
|
||
**⭐ Levels:** default test EGG is now `DEV.EGG` (`map=grass` + `time=day`, outdoor); the RES has 8 maps
|
||
(`cavern`/`grass`/`rav`/`polar3`/`polar4`/`arena1`/`arena2`/`dbase`) — swap via a copied EGG's `map=`
|
||
field (§4a).
|
||
**⭐ MECH SELECTION IS EGG DATA — the code path is mech-agnostic (VERIFIED with a second mech).** The
|
||
player vehicle = the egg's pilot page `vehicle=<name>` (DEV.EGG: `bhk1`), resolved via
|
||
`playerMission->GetGameModel()` → `FindResourceDescription(name, ModelListResourceType)` (btplayer.cpp
|
||
`CreatePlayerVehicle`). RES type-18 names incl.: `bhk1/blkhawk ava1/avatar mad1/mad2/madcat lok1/lok2/loki
|
||
thor/thr1 vul1/vulture owens/own1 snd1 sunder strk`. **`MECH2.EGG` (pod BT dir) = DEV.EGG with
|
||
`vehicle=madcat` — the Mad Cat booted, rendered, walked and fought ON FIRST TRY with ZERO code changes:**
|
||
own clips loaded (walkStride 18.5 vs BLH 22.0 — real per-mech data measured), 22 damage zones (BLH 20),
|
||
30-subsystem roster constructed, gait/collision/shadow/heat live, combat → TARGET DESTROYED, 0 crashes.
|
||
**The per-mech worklist that surfaced (all known warts, nothing new):** (1) the visual-fire muzzles fell
|
||
back to the centre point (the gun-port site names are hardcoded BLH.SKL names — the real fix is per-weapon
|
||
`.SUB` `segmentReference` muzzle resolution, already how `Emitter::GetMuzzlePoint` works); (2) roster 32 vs
|
||
30 present = its LRM racks hit the still-stubbed missile factory cases (0xBCE/0xBD0 — SUBSYS_PLAN WAVEs,
|
||
per weapon FAMILY not per mech); (3) beam colour still the BLH red (per-weapon `VideoObjectName` pending
|
||
weapon enumeration). Everything else is foundational.
|
||
- **cdb (x86):** `"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"`. Pattern for a faulting
|
||
stack (set cwd to the pod BT dir first): `-g -c ".lines;sxe av;g;kp 24;q"` with `BT_ASSERT_TO_DEBUGGER=1`.
|
||
The debug CRT marks fresh heap **0xCDCDCDCD** (uninitialized) and freed **0xFEEEFEEE** — invaluable for
|
||
"was this ever constructed?" questions. gflags PageHeap (needs elevation) caught the earlier subsystem
|
||
heap overruns.
|
||
|
||
### 10a-bis. Where the BT build's inputs live (NOT consolidated — a "virtual assembly")
|
||
The build is pinned (no ad-hoc archive searching), but spans 6 locations + runtime content; all paths are
|
||
**absolute** (not relocatable). Compiled into `btl4.exe`: (1) **`decomp/reconstructed/`** — reconstructed
|
||
BT source (the bulk); (2) **`410srczipped_extracted/410SRC/.../CODE/BT/{BT,BT_L4}`** — ~9 *surviving
|
||
original* BT `.cpp` (`BTMSSN/BTREG/BTTEAM/BTSCNRL/BTCNSL/GAUSS/PPC/BTL4MODE/BTL4ARND`) + BT header include
|
||
dirs; (3) **`btbuild/`** — launcher `btl4main.cpp` + CMake + `run/` + `compile1.cmd`; (4) **`enginebuild/`**
|
||
— prebuilt `munga_engine.lib` + `shim/` headers. The **engine is referenced twice**, both rooted in the
|
||
archive: (5) **`elsewhen_extracted/.../MUNGA/`** is compiled into `munga_engine.lib` (via `enginebuild`)
|
||
AND its **headers** are reached at compile time through **~188 `btbuild/fwd/*.hpp` forwarding shims** (each
|
||
just `#include "C:/git/.../elsewhen_extracted/.../MUNGA/<NAME>.h"`) — this is the glue letting reconstructed
|
||
code do `#include <EXPLODE.hpp>` without copying engine headers. External: DXSDK June 2010 + OpenAL/
|
||
libsndfile. At **runtime** the exe needs cwd = **`tesla4pod_extracted/.../REL410/BT/`** (content: `BTL4.RES`,
|
||
models, `TEST.EGG`, `VIDEO\\REPLACEMATS.tbl`). Reference-only (not built): `decomp/recovered/all/part_*.c`
|
||
(raw pseudocode = source of truth). Consolidating into one relocatable tree (copy engine headers + content,
|
||
relativize paths) is a possible future cleanup, not done.
|
||
|
||
### 10b. Reconstruction workflow (the method) — **RULE: no stand-ins**
|
||
The loop for each feature: (1) read the RAW decomp `decomp/recovered/all/part_*.c` for the relevant
|
||
`FUN_xxxx`; (2) map `FUN_`/`DAT_`/`this+0xNN` to engine symbols using BT headers + the WinTesla MUNGA
|
||
source + `decomp/reconstructed/CLASSMAP.md` + RP's parallel code; (3) write the **real** reconstruction
|
||
into `decomp/reconstructed/*.cpp`; (4) `compile1.cmd` then full build; (5) run env-gated + read `btl4.log`;
|
||
(6) cdb on any crash. **Never write stand-in/placeholder code for an apparent "gap"** — the full game
|
||
logic IS in the pseudocode (the binary ran the game); a "gap" is just a reconstruction stub not yet
|
||
filled (e.g. `ResourceStream`/`MemStreamX` no-op proxies, an uninitialised field). Said by the user:
|
||
"there are no gaps, just work to be done." Bring-up scaffolding (the env-var paths, the explosion-for-beam,
|
||
a player-gated drive) is **clearly marked and meant to be replaced** by the real reconstructed system,
|
||
never to substitute for reading the decomp. For exhaustive multi-function decomp analysis, fan out a
|
||
read-only **Workflow** (understanding phase), then implement hands-on so each change is visible.
|
||
|
||
### 10c. Decompilation insights (per subsystem — durable facts)
|
||
- **Resource lookup is by NAME or by ID.** `ResourceFile::FindResourceDescription(name, type, index=-1)`
|
||
(raw `FUN_00406ff8` = find-by-type-and-name; `FUN_00407064` = `SearchList(resourceID, type)`). Resource
|
||
**type enum**: SkeletonStream, **DamageZoneStream**, ModelList, **AnimationResourceType==16 (0x10)**,
|
||
damage-zone segment list **type 0x14**, critical-subsystem segment **0x1e**. Animation clip names in
|
||
`BTL4.RES` are **lowercase** (`blhrrl`=904, `blhrrr`=905, `blhwwli`=910, `blhrrli`=916). `Mech::
|
||
ResolveAnimationClip(prefix,suffix)` builds the name and calls FindResourceDescription.
|
||
- **Animation (engine `MUNGA/JMOVER.cpp`):** `AnimationInstance(JointedMover*)` then `SetAnimation(
|
||
ResourceID, finishedCallback)` (Lock()s the clip, parses `frameCount/jointCount/jointIndices/frameStart/
|
||
keyFrames/rootTranslations` from `resourceAddress`). `Animate(dt, move_joints=True)` writes each joint's
|
||
DCS per keyframe. **At end-of-clip it calls `(moverToAnimate->*finishedCallback)(...)` (line ~1592) — a
|
||
NULL callback crashes.** Loop a gait via a **non-virtual** `Mech::OnBodyAnimFinished` re-arming the clip,
|
||
passed `reinterpret_cast<JointedMover::AnimationCallback>` (Mech derives from JointedMover; the member-
|
||
ptr cast is how the original stores a Mech method in the JointedMover-typed slot).
|
||
- **`loadTables` gotcha (`MUNGA_L4/L4VIDEO.cpp:849`):** `fopen("VIDEO\\REPLACEMATS.tbl","rb")` is **relative
|
||
to cwd with no null-check** → fread on NULL if cwd isn't the pod BT dir. (RP material-substitution table.)
|
||
- **⚠ `application`/`l4_application` is NULL outside the frame loop** — `ApplicationManager` (APPMGR.cpp:73)
|
||
REASSIGNS the global `application` while iterating the running-app chain each frame and leaves it NULL
|
||
between frames. Any code running at Windows message-pump time (WndProc handlers, timers) reads NULL even
|
||
while the game runs. Pattern: record the input in a global at message time; consume/apply it inside a
|
||
per-frame path where `this` is the engine object (how the window-resize aspect fix works, L4VIDEO.cpp
|
||
`gWindowAspect` → per-frame dirty-check rebuild; commit 06683cf).
|
||
- **Entity spawn:** `Mech::Make(MakeMessage*)` (`FUN_004a2d48`) allocates `sizeof(Mech)=0x854` + runs the
|
||
ctor; the base `Entity` ctor self-registers it into the world (renders + ticks). `Registry::MakeEntity`
|
||
dispatches per-class `makeHandler` (BT registry maps `MechClassID=0xBB9 → Mech::Make`). Unique IDs via
|
||
`HostManager::MakeUniqueEntityID()`. **The mission system is NETWORKED and BT is PvP-ONLY — it never had
|
||
NPCs/AI** (verified, not a recovery gap): `BTREG.cpp`'s entity registry knows only `Mech`/`Projectile`/
|
||
`Missile`/`BTPlayer`/`BTTeam`; the COMPLETE BT header set has no Bot/Brain/AI class; `BTL4OPT.EXE` has no
|
||
AI strings; every mech is driven by a `BTPlayer` (or a `BTCameraDirector` for the "camera"/spectator game
|
||
model), players grouped into `BTTeam`s. Opponents were always other human pods over the net (`NETNUB`/
|
||
`L4NET`). The AI entities `Crusher`/`Blocker`/`Runner` are **Red Planet** classes (`RP/`), NOT BT and NOT
|
||
engine — reference only for *how the engine supports AI if we ever ADD it* (that would be new dev, not a
|
||
port). ⇒ A solo mission has no enemy by design; the env-gated `BT_SPAWN_ENEMY` mech is a **test dummy**
|
||
(uncontrolled `Mech`), not a game feature. The authentic opponent path is multiplayer (Phase 7).
|
||
- **`Mech::PerformAndWatch` runs for EVERY mech** → gate player-only logic on
|
||
`this == application->GetViewpointEntity()` (else a spawned enemy is driven by player input / shares the
|
||
player's AnimationInstance).
|
||
- **Targeting (mech offsets verified vs `part_013.c`):** `mech+0x37c` = `Point3D` target world pos (range/
|
||
aim source), `mech+0x388` = `Entity*` target (**the field `HasActiveTarget()` / `Emitter::FireWeapon`
|
||
gate on**), `mech+0x38c` = `int` targeted sub-zone (-1 = whole). Weapons cache `hasTarget/targetPoint/
|
||
muzzlePoint`, refreshed each frame from the mech's target by the mech's targeting update.
|
||
- **Firing:** `Emitter::FireWeapon` (`emitter.cpp`, real) builds a muzzle→target beam but **weapon-effect
|
||
renderables (beams/tracers) are NOT built in the port** (`btl4vid.cpp:455`) → nothing draws. **Explosions
|
||
DO render** (`L4VIDEO.cpp:5356`), so a shot stands in as `Explosion::Make(MakeMessage*)` at the target
|
||
(effect resource **"explode" = id 13**). Weapon ClassIDs (CLASSMAP, note the factory enum mislabels):
|
||
EmitterWeapon 0xBC8, MechWeapon 0xBCD, Missile 0xBCE, Ballistic 0xBD0, **PPC 0xBD4** (factory calls it
|
||
"GaussRifle"), MechTech 0xBD6. Mech ctor offsets: `damageZoneCount @0x11c (param[0x47])`,
|
||
`damageZones @0x120 (param[0x48])`, `resourceID @0x1bc (param[0x6f])`.
|
||
- **Damage delivery:** `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` →
|
||
`target->Dispatch(&msg)`. **Base `Entity::TakeDamageMessageHandler` IGNORES `zone==-1`**; the comment
|
||
(`ENTITY3.h`) says "Mechs use cylinder lookup table" for an unaimed hit — **that Mech override is NOT yet
|
||
reconstructed**, so send a VALID zone index for now. `class Damage{ damageType(enum Collision/Ballistic/
|
||
Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`.
|
||
`DestroyEntityMessage(id, size)` removes an entity. Engine `Explosion` also splash-damages nearby
|
||
entities via a collision query (fragile) — direct Dispatch to the known target is what we do.
|
||
- **✅ Damage zones — REAL damage model reconstructed & working** (replaced the health-model stand-in).
|
||
The chain: aimed `TakeDamageMessage` → engine `Entity::TakeDamageMessageHandler` → `Mech__DamageZone::
|
||
TakeDamage` → engine base armor model (`damageLevel += damageAmount * damageScale[type]`) → accumulates
|
||
to 1.0 (zone destroyed) → graphic-state/death branch → death detected. Verified: `structure` climbs
|
||
0.133/hit, vital zone destroyed → `*** TARGET DESTROYED ***`, stable. Build mechanics: engine `Entity`
|
||
ctor (`ENTITY.cpp:961-987`) reads `damageZoneCount` from the `DamageZoneStreamResourceType` (type 0x14)
|
||
resource and allocates the array (entries left `0xCDCDCDCD`); the **Mech ctor populates that inherited
|
||
array** (mech.cpp Pass-3): `MechFindResource(resourceID, 0x14)` → `Lock` → `DynamicMemoryStream(addr,
|
||
size, initial_offset=4)` (skips the count word the base consumed) → `for z<damageZoneCount: new
|
||
(Memory::Allocate(0x1b8)) Mech__DamageZone(this,z,&stream)` → `SetLODParentPointers` → crit subsystems
|
||
(type 0x1e) + `MechDeathHandler`. The engine `DamageZone` ctor (`DAMAGE.cpp:200`, shared MUNGA == binary
|
||
`FUN_0041df5c`) reads name/effect-sites/`defaultArmorPoints`/`damageScale[5]`/materials — so stream
|
||
alignment is exact.
|
||
|
||
**⚠ THE SYSTEMIC BUG (check this first on every reconstructed class): re-declared engine base fields.**
|
||
The reconstructed BT classes re-declared fields that already exist in their WinTesla engine base, at the
|
||
*binary's* offsets. Two failures result: (a) the re-declaration **shadows** the engine field (the engine
|
||
ctor writes the base field; the reconstruction reads its own uninitialised copy = `0xCDCDCDCD`), and
|
||
(b) compiled as a subclass of the engine base, the copy lands at a **different offset** than the binary
|
||
assumed. Fixed instances this session — `Mech`: `damageZoneCount`/`damageZones` (shadowed `Entity`'s →
|
||
zones never built); `Mech__DamageZone`: `structureLevel`→engine `damageLevel`, `damageScale[5]`/
|
||
`defaultArmorPoints` (raw `Wword(0x51)`/`Wword(0x50)` offsets → inherited members), `graphicState`/
|
||
`graphicStateAlarm`/`flags`/`state`→engine `StateIndicator`s `damageZoneState`/`damageZoneGraphicState`
|
||
(+ `SetDamageZoneState`/`SetGraphicState`/`GetGraphicState` accessors; the reconstructed `SetGraphicState`
|
||
override was byte-identical to the base → removed). Use the inherited member/accessor, never a raw
|
||
`Wword(N)`/re-declared copy, for any field the engine base owns. (`subsystemCount`/`subsystemArray` were
|
||
already fixed this way earlier — same lesson.) **`Wword(int i)` is itself a TRAP**: `mechrecon.hpp:192`
|
||
defines it as `static BTVal bank[0x400]; return bank[i&0x3ff]` — a global scratch bank, NOT `this+i*4`.
|
||
Any `Wword(N)` used for object access reads/writes garbage (e.g. `(Mech*)Wword(3)` for a zone's owner →
|
||
use `GetOwningSimulation()`).
|
||
|
||
**⚠ SIBLING SYSTEMIC BUG — resource-struct layout mismatch (check when RESOURCE fields read garbage).**
|
||
The `*__SubsystemResource` structs are overlaid on **pre-built 256-byte records loaded verbatim** at the
|
||
binary's fixed offsets, so OUR struct MUST match the binary byte-for-byte or every member read is wrong
|
||
(and the failure is *silent* — garbage that happens to be non-fatal, like a heatSinkIndex reading `10.0f`).
|
||
Two ways it breaks: (a) **wrong resource inheritance base** — the resource must mirror the CLASS hierarchy
|
||
(`HeatableSubsystem:MechSubsystem` → resource must inherit `MechSubsystem__SubsystemResource` (0xE4), not
|
||
`Subsystem::SubsystemResource` (0x30)); a skipped base slides every field low. (b) **under-sized fields** —
|
||
e.g. `alarmModel` at res+0xCC is a 12-byte record field but was typed as a 4-byte `ResourceID` (`typedef
|
||
int`) → 8-byte gap. DIAGNOSE by logging compiled offsets (`(char*)&res->field - (char*)res`) vs the binary's
|
||
known offsets and dumping raw record bytes as int+float; anchor the true field offsets from the raw decomp
|
||
of the resource parser (`CreateStreamedSubsystem`/@004ac9ec — `param_4+0xNN` reads). See §10d heat-flow.
|
||
|
||
**Two more non-field fixes were needed for damage to flow:** (1) **message-handler chaining** — a
|
||
reconstructed class's `MessageHandlers` set must be built chained to the parent's (`Receiver::Message
|
||
HandlerSet(Entity::GetMessageHandlers())`); an empty default-ctor set has NO parent chain, so
|
||
`Receiver::Receive` finds no handler and **every inherited message (TakeDamage, etc.) is silently
|
||
dropped**. (2) **entity validity** — `Entity::Dispatch` only delivers synchronously (`Receiver::Receive`)
|
||
for a VALID master; an invalid entity `Post`s the message as a deferred event that never fires. A
|
||
manually-spawned entity (no `MakeViewpointEntity`/CheckLoad handshake) must call `SetValidFlag()` itself.
|
||
|
||
**Remaining damage gaps (scoped, non-blocking):** EnergyDamageType(4) shorts attached generators via
|
||
`criticalSubsystems[]->subsystemPlug.Resolve()` → needs the REAL subsystem roster (still `RECON_SUBSYS`
|
||
stubs) → use ExplosiveDamageType meanwhile (correct for the explosion weapon anyway). Per-impact aim
|
||
needs the cylinder lookup (deferred STEP 6 — `Mech::TakeDamageMessageHandler` override `FUN_004a0230` +
|
||
`FUN_0049ed0c`); meanwhile aim a vital zone directly. `IsDisabled()` (`FUN_0049fb54`) reconstructed
|
||
(reads `movementMode==2||9`), but `movementMode` is set by the bypassed gait/death-transition → bring-up
|
||
death reads the real zone state directly (a `vitalDamageZone` with `damageLevel>=1.0`). Removing the
|
||
wreck (`DestroyEntityMessage`) crashes on teardown — death = explosion + stop-targeting for now.
|
||
⚠ **ROOT-CAUSE PIVOT (P5 — full writeup `btbuild/HARD_PROBLEMS.md`; repro `BT_ENABLE_TEARDOWN=1`):** it is
|
||
a **TEARDOWN-SEQUENCE double-free, NOT a base-region stomp** (two earlier theories DISPROVEN). (a) "solids
|
||
never built" — WRONG (`Mover::Mover`:1756 allocs `collisionLists` unconditionally). (b) "reconstructed Mech
|
||
raw offsets stomp the engine collision cluster" — ALSO WRONG: those stomps (`Mech::Simulate` collision/
|
||
terrain/telemetry, `FeedHeat*Gauge`) are **DEAD CODE, never called** (grep-confirmed). The `BT_ENABLE_TEARDOWN`
|
||
dump proves the enemy's engine base is **FULLY VALID at death** (`collisionLists@0x2e4`, `segmentTable@0x2f0`
|
||
=51 segs, `jointSubsystem@0x30c` all valid). The crash is in the destruction SEQUENCE (`FryDeathRow → ~Mech
|
||
→ ~JointedMover → ~Mover`): cdb shows `collisionLists`'s array already **freed** (`0xdd`/`count=0xdddddddb`)
|
||
by the time `~Mover` `delete[]`s it → a double-free. `~Mech` (reconstructed) runs before the engine base
|
||
dtors → prime suspect is a Mech member dtor / the minimal-spawn collision-or-death-row setup freeing (or
|
||
aliasing) a base resource the engine dtor frees again. ✅ **PINNED (trace done):** it's the **skeleton
|
||
SEGMENT teardown double-free**, not collisionLists. `~JointedMover` (JMOVER.cpp:436
|
||
`SegmentTableIterator(segmentTable).DeletePlugs()`) → `SocketIterator::DeletePlugs` (SOCKET.cpp:157-161
|
||
`delete plug`) force-`delete`s each of the enemy's 51 `EntitySegment`s with `defeat_release_node=1`
|
||
(bypassing the ref-count release path); one is **already freed** → `STATUS_HEAP_CORRUPTION 0xC0000374` +
|
||
`0xFEEE` fill. ⭐ **P5 CLOSED — the whole premise was WRONG (verified):** a killed mech does NOT disappear
|
||
in the real game, so `DestroyEntityMessage`-on-death should NEVER be issued. RP death = `VTV::DeathShutdown`
|
||
(VTV.cpp:1681 — subsystems shut down, vehicle STAYS); `CondemnToDeathRow`/removal is ONLY for transient
|
||
objects (RIVET projectiles, DEMOPACK cleanup). BT death = a STATE transition (`SetGraphicState(Destroyed
|
||
GraphicState)` mechdmg.cpp:355 + death anim `deathAnimationLatched@0x650` + effect/splash) → the mech becomes
|
||
a persistent WRECK. So the **wreck-stays behavior we already ship IS faithful**; the teardown crash is an
|
||
artifact of forcing a removal the original never does (only reachable via `BT_ENABLE_TEARDOWN`). NOTHING to
|
||
fix. Real future death work = reconstruct DeathShutdown + collapse anim + destroyed skin (does NOT touch the
|
||
teardown path). Later trace also showed it's heap corruption in a SINGLE teardown, not the double-free the
|
||
line above hypothesized. Full trail: `btbuild/HARD_PROBLEMS.md`.
|
||
⭐ **P5 TRULY CLOSED — ROOT CAUSE FOUND & FIXED (the whole trail above was chasing run-2 of a DOUBLED
|
||
dtor chain).** `Mech::~Mech` (mech.cpp:1106) contained an EXPLICIT `JointedMover::~JointedMover();` —
|
||
the Ghidra decomp shows the binary's `FUN_00425550(this,0)` tail call, which IS the compiler-emitted
|
||
implicit base-dtor; reconstructing it as a source statement ran the whole `~JointedMover → ~Mover →
|
||
~Entity` chain TWICE per Mech. Run 2 re-`delete[]`d `Mover::collisionLists` (count word read from the
|
||
0xDD freed fill = the observed `0xdddddddb`) and re-ran `DeletePlugs` over the destroyed segment table
|
||
(= the P5 "EntitySegment already freed" 0xC0000374). ONE bug = BOTH crashes (death-row teardown AND
|
||
app exit — every window close crashed once entities existed; symptom timing varied with heap reuse,
|
||
which is why idle-close sometimes survived and why the earlier traces looked contradictory; the old
|
||
ENTRY/EXIT probes bracketed the EXPLICIT call, misreading run-1 as "a member dtor freed it").
|
||
**⚠ RECONSTRUCTION RULE (add to the systemic checklist):** in a decompiled DESTRUCTOR, the trailing
|
||
member-dtor calls (`FUN_xxx(this+N, 2)`), the base-dtor call (`FUN_xxx(this, 0)`), and the
|
||
`(flags&1) && operator delete(this)` tail are COMPILER EPILOGUE GLUE — reconstruct only the dtor body
|
||
ABOVE them; C++ re-emits member+base destruction implicitly at the closing brace. Explicit member-dtor
|
||
calls were also removed from ~Mech (were benign only because the Recon proxies are no-op) and the same
|
||
pattern documented out of `Mech__DamageZone`'s dtor (mechdmg.cpp). VERIFIED: interactive play → window
|
||
close under `BT_HEAPCHECK=1` = clean exit; combat kill + `BT_ENABLE_TEARDOWN=1` forced entity teardown
|
||
= `TARGET DESTROYED`, teardown completes, sim continues, clean close — 0 crashes across the matrix.
|
||
**App exit no longer crashes; entity removal (`DestroyEntityMessage`) is usable again** (relevant to MP
|
||
disconnect cleanup). Repro harness kept: `BT_FORCE_SECONDS=<n>` (release forced throttle after n sim-s)
|
||
+ `BT_FORCE_TURN=<t>` (hold steering) in mech4.cpp — the tap-release/turn paths no soak had exercised.
|
||
(Forensics: a 14-agent adversarial workflow; secondary REAL-but-dormant hazard found on the way:
|
||
`AddCollisionToList` BNDGBOX.cpp:389 has no live bounds check — `Verify` compiles out at DEBUG_LEVEL=0 —
|
||
so >10 simultaneous collision contacts from >1 source overflow the 10-slot list; **NOW FIXED** — a live
|
||
clamp in `AddCollisionToList` (BNDGBOX.cpp, engine tree) drops overflow contacts instead of writing past
|
||
the block. Trigger scenario was real: the user spawned INSIDE a building solid on grass (mass simultaneous
|
||
contacts + gravity pressing into terrain boxes) → silent AV mid-play, where the CRT assert dialog ITSELF
|
||
crashed while formatting (why no popup appeared). Verified: full combat+teardown+close matrix under
|
||
`BT_HEAPCHECK=1` = 0 crashes, clean exit. Spawn-inside-solid placement itself is a separate tuning item.)
|
||
(Latent, non-causal: compiled `sizeof(Mech)=0x638` < binary `0x854`; the over-sizeof writes `0x7e0-0x828`
|
||
live only in the dead Simulate, so no live overflow — but pad to 0x854 before reviving that code.)
|
||
|
||
### 10d. Reconstructed modules wired into `bt410_l4` (state)
|
||
~43 reconstructed `.cpp` compile+link. Mech runtime (`mech.cpp/2/3/4`, `mechdmg`, `mechsub`, `mechtech`),
|
||
heat (`heat`), weapons (`emitter`/`mechweap`/`mislanch`/`missile`/`projweap`/`projtile`/`gauss`/`ppc`),
|
||
subsystems (`gyro`/`sensor`/`gnrator`/`powersub`/`myomers`/`torso`/...), app/HUD (`btl4app`/`btl4vid`/
|
||
`btl4mppr`/`btl4mssn`/`btplayer`/`hud`/`dpl2d`). **Subsystem instances are `RECON_SUBSYS` STUBS — the
|
||
un-stub wave is IN PROGRESS** (full per-family plan: `btbuild/SUBSYS_PLAN.md`, from the `bt-subsystem-recon`
|
||
workflow). KEY FINDING: the **real subsystem classes mostly already exist** (heat→`heatfamily_reslice`,
|
||
weapons→`emitter`/`projweap`/`mislanch`, gyro/sensor/power/torso/myomers/searchlight/thermalsight/ammobin
|
||
all reconstructed); the work is *wiring* them in + the 4 systemic checks (shadowing, `Wword` trap, handler
|
||
chain, validity). **The factory case LABELS are systematically MISLABELED** — the real class is identified
|
||
by the `// FUN_004xxxxx` ctor-address comment reconciled via `CLASSMAP.md`, NOT the `case <Name>ClassID`
|
||
label. Master map (case→real): 0xBBD→Condenser, 0xBBE→HeatSink-bank (cache slot=heat bank, not "sensor"),
|
||
0xBC0→Reservoir, 0xBC1→Generator, 0xBC2→PoweredSubsystem, 0xBC3→Sensor, 0xBC4→Gyroscope, 0xBC5→Torso,
|
||
0xBC6→Myomers, 0xBC8→Emitter, 0xBCB→AmmoBin, 0xBCD→ProjectileWeapon, 0xBCE→UNRESOLVED (decompile `4bdcb4`),
|
||
0xBD0→MissileLauncher, 0xBD3→SubsystemMessageManager, 0xBD4→PPC(Emitter), 0xBD6→HUD, 0xBDC→MechTech,
|
||
0xBD8→Searchlight, 0xBDE→ThermalSight. Two bugs to fix: case 0xBDC discards the ptr (no `subsystemArray[id]=`);
|
||
MechTech(real)+HUD are SWAPPED (MechTech wrongly built at 0xBD6). **Swap recipe:** per-class `Create<Class>
|
||
Subsystem` bridge fn in the class's own `.cpp` (do NOT `#include` real subsystem headers into mech.cpp —
|
||
the local stubs collide) + `extern` it in mech.cpp; keep the alloc SIZE + `++weaponCount`/special-cache.
|
||
**Order:** WAVE 1 heat base re-base (`HeatableSubsystem : Subsystem`→`: MechSubsystem`, delete shadow
|
||
fields owner/flags/statusFlags/statusBits/destroyed, fix thermal offsets) = universal prerequisite; then
|
||
heat family + MechTech/HUD fix; then power bus + Emitter/PPC; then standalone readouts; gyro/torso/myomers
|
||
later. DEFER: `MechControlsMapper` tick (reads unddocumented App offsets → AV, mech4 bypasses it); 0xBCE.
|
||
**✅ WAVE 1 DONE** — `HeatableSubsystem` re-based onto `MechSubsystem`; shadow fields removed; the cascade
|
||
fixed across heat.cpp/heatfamily_reslice/mechsub + powersub/hud/mechweap (the de-shadow propagates to EVERY
|
||
heat/powered subclass: `statusFlags`→`simulationFlags`, `destroyed`→`simulationState`, `statusBits`→
|
||
`ForceUpdate()`, and `ResetToInitialState()`→`(Logical powered)` on every override). Full build GREEN, exe
|
||
runs, combat (zones/anim/damage→death) un-regressed. **DEEPER FINDING: the shadowing is more systemic than
|
||
the plan estimated — `MechSubsystem` ITSELF shadows the engine `Subsystem::damageZone` (mechsub.hpp:252
|
||
`ReconDamageZone *damageZone` shadows `SUBSYSTM.h:159 DamageZone *damageZone`), exposed once MechWeapon's
|
||
chain ran through MechSubsystem. Worked around by qualifying `this->Subsystem::damageZone` (mechweap.cpp);
|
||
the proper fix = remove the `MechSubsystem::damageZone` shim shadow (cascades to mechsub.cpp) — a follow-up.
|
||
Two include fixes also needed: `heat.hpp` now `#include <mechsub.hpp>`; `mechrecon.hpp` now `#include
|
||
<ROTATION.hpp>` (self-sufficient Quaternion, for myomers's include order). Next = WAVE 2 (heat factory
|
||
wiring + MechTech/HUD un-swap) per `btbuild/SUBSYS_PLAN.md`.**
|
||
**✅ WAVE 2 DONE** — real heat family + HUD + MechTech wired into the factory. Swap mechanism: a per-class
|
||
`Create<Class>Subsystem(Mech*,int,void*)` BRIDGE fn appended to each real class's own `.cpp` (heat.cpp:
|
||
Condenser 0x230 + HeatSink-bank 0x1e4; heatfamily_reslice.cpp: Reservoir 0x230; hud.cpp: HUD 0x2a4;
|
||
mechtech.cpp: MechTech 0x140), `extern`-declared + called from the mislabel-correct factory cases in
|
||
mech.cpp (0xBBD→Condenser, 0xBBE→HeatSink-bank+cache, 0xBC0→Reservoir, 0xBD6→HUD, 0xBDC→MechTech+store+cache
|
||
— fixing the MechTech/HUD swap + the discarded-ptr bug). RESULT: **30 of 31 subsystems now tick their real
|
||
per-frame Performance** (1 bypassed = controls mapper), up from MechTech-only; build green, 16s stable,
|
||
combat un-regressed. RUNTIME FIX: `HUD::HUD` read the torso source at raw `owner+0x438` (garbage in the
|
||
recon Mech layout) → defaulted the torso/range readouts to 0 (HudSimulation refreshes per-frame). NEW
|
||
SYSTEMIC PATTERN for the remaining waves: subsystem ctors read the owner Mech at RAW binary offsets
|
||
(`*(int*)(owner+0xNN)`) that are wrong in the recon Mech → guard/zero + per-frame refresh, or wire a real
|
||
Mech accessor. Heat self-load from firing (temperature climb) needs WAVE 3 (real Emitter writing the
|
||
inherited heat accumulator). Next = WAVE 3: power bus (Generator/PoweredSubsystem) + Emitter/PPC weapon.
|
||
**✅ WAVE 3 (power bus + Emitter) — CONSTRUCT+TICK done; full fire-path pending.** 3a: bridges for Generator
|
||
(0xBC1, 0x250) + PoweredSubsystem (0xBC2, 0x31c) in powersub.cpp → factory wired → power bus real. 3b:
|
||
`CreateEmitterSubsystem` (0x478) in emitter.cpp, wired for BOTH 0xBC8 (Emitter) AND 0xBD4 (PPC = Emitter
|
||
subclass, same FireWeapon) → real Emitter constructs + ticks its `EmitterSimulation` state machine. Runtime
|
||
fix (E2): `Emitter::TrackSeekVoltage` (emitter.cpp:311) derefs a null voltage source (the source plug at raw
|
||
`this+0x1d0` isn't registry-linked in bring-up) → guarded `if (src==0 || dtScale==0) return` (no charge that
|
||
frame). All real subsystems now construct + tick; build green, combat un-regressed.
|
||
**✅ WAVE 3 fire-path (E3-E8) DONE — the real Emitter fires end-to-end** (verified: repeated `[emitter]
|
||
FIRED damage=.. heat=0.49 pendingHeat=0.49`, 17s stable, combat intact). Implemented: E3 `MechWeapon::
|
||
HasActiveTarget()`/`GetTargetPosition` read the owner's target slots (`owner+0x388`/`+0x37c` via the
|
||
inherited `MechSubsystem::owner`) not the never-set `hasTarget`; E4 `firingArmed=1` in the Emitter ctor; E5
|
||
deleted the Emitter-local `heatAccumulator` SHADOW, `FireWeapon` now writes the inherited `HeatSink::
|
||
pendingHeat`@0x1c8 (the heat-sim input); E7 force-charge in EmitterSimulation Loading (the source plug at
|
||
raw `this+0x1d0` never resolves -> `currentLevel=seekVoltage[idx]` -> outputVoltage 1.0 -> Loaded; also
|
||
pre-init the seek table in the ctor so it's valid when the ctor src is null); E8 trigger via a per-FRAME
|
||
`int gBTWeaponTrigger` pulsed by mech4 (controls mapper bypassed) -> CheckFireEdge sees clean edges.
|
||
**ROOT-CAUSE BUG fixed along the way (benefits ALL subsystems): the MechSubsystem ctor set `hostEntity`
|
||
but never set `owner`** (mechsub.cpp) -> every subsystem's `owner` was garbage once heat de-shadowed; now
|
||
set in both MechSubsystem ctors. Also: the Mech ctor now zeroes the target slots (+0x37c/+0x388/+0x38c) so
|
||
an un-targeted mech (spawned enemy) reads "no target" (else its weapons fire at a garbage pointer).
|
||
**✅ HEAT-FLOW LINKAGE CONNECTED — weapon heat now conducts to the central sink** (verified: `[heat]
|
||
conduct self.E=.. -> other.E`, sustained fire `FIRED #1..#21+`, combat intact, no crash). THREE fixes,
|
||
each faithful to the decomp (no stand-ins):
|
||
1. **EmitterSimulation ran a no-op stub** `FUN_004b0bd0` for the "PoweredSubsystem step" -> the emitter
|
||
never ran its heat sim. Replaced with the REAL `PoweredSubsystem::PoweredSubsystemSimulation`
|
||
(@004b0bd0, chains to `HeatSink::HeatSinkSimulation` = absorb pendingHeat + `ConductHeat`). Emitter is
|
||
`Emitter:MechWeapon:PoweredSubsystem:HeatSink`, so the qualified base call is valid. (emitter.cpp)
|
||
2. **Linked-sink Add-gate read the wrong flags.** Raw decomp of the HeatSink ctor @004adda0 gates BOTH
|
||
the Performance-install AND the `linkedSinks.Add` on `param_2+0x28` = `owner->simulationFlags`, NOT
|
||
the per-segment `subsystem_resource->subsystemFlags` (which stream 0 -> dead gate -> no links). The
|
||
reconstruction had only half-fixed this (Performance gate on owner, Add-gate still on resource).
|
||
Fixed the Add-gate to owner flags too. (heat.cpp) The link TARGET is still per-subsystem `heatSinkIndex`.
|
||
3. **⚠ NEW SYSTEMIC BUG — resource-struct layout mismatch (a sibling of the field-shadowing bug).**
|
||
`HeatableSubsystem__SubsystemResource` inherited `Subsystem::SubsystemResource` (ends 0x30) instead of
|
||
`MechSubsystem__SubsystemResource` (ends 0xE4) -- the class hierarchy is `HeatableSubsystem:MechSubsystem`
|
||
so the RESOURCE must mirror it. Result: every heat field slid 0xB4 low; `heatSinkIndex` read a
|
||
neighbouring float (`10.0f`=1/thermalMass) -> `GetSegment` OOB -> no link, and thermalMass/startTemp
|
||
were garbage. Fixed the base. Then an 8-byte residual gap: `alarmModel` was typed as a 4-byte `ResourceID`
|
||
(`typedef int`), but the RECORD reserves a 12-byte field at 0xCC (raw `@004ac9ec` parses it via
|
||
FUN_00408944 as 3 floats, with `PrintSimulationState` at 0xD8). Added `int _alarmModelReserved[2]`.
|
||
Now `startTemp=0xE4 thermalMass=0xF4 heatSinkIndex=0xF8` EXACTLY match the binary; heatSinkIndex reads
|
||
real indices (5,2,4,..), thermalMass real values (1.39e6..), links resolve, ConductHeat flows.
|
||
**DIAGNOSTIC TECHNIQUE (reusable for any resource-layout doubt):** log compiled member offsets via
|
||
`(char*)&res->field - (char*)res` and compare to the binary's known offsets; dump raw record bytes as
|
||
int+float side-by-side to see the true on-disk layout (records are pre-built 256-byte blobs loaded verbatim
|
||
at fixed offsets, so OUR struct MUST match the binary or every member read is wrong).
|
||
**NOTE on the payoff:** with the REAL data, `currentTemperature` stays ~77 (startTemp) because `thermalMass`
|
||
is genuinely huge (~1e6) vs ~0.24 heat/shot; the visible metric that responds to fire is `heatLoad` (the
|
||
smoothed radiated-heat reading), which rises/decays with firing. Not a bug -- authentic data behavior.
|
||
(The "linked `other` sink reads heatEnergy~0" follow-up is RESOLVED — the link target was WRONG; see the
|
||
heap-corruption root cause below.)
|
||
**✅ THE BGF-LOAD HEAP CORRUPTION — ROOT-CAUSED & FIXED (was: `LoadBgfFile("bld08.bgf")` → `Builder::~Builder`
|
||
→ AV in `operator delete`, position-dependent).** A 4-agent workflow + hands-on forensics converged:
|
||
- **The loader was INNOCENT** — an exact Python mirror of `bgfload.cpp` swept all 879 content BGFs with zero
|
||
anomalies; every Builder write is bounds-guarded; and the crashed 0x768 block is exactly the correct
|
||
474-float capacity for bld08's 472 verts (MSVC 1.5× growth ...316→474). The AV was ntdll's free-list walk
|
||
reading `0xDDDDDDDD` (the CRT freed-fill) inside the ADJACENT block's NT heap metadata — prior corruption,
|
||
merely DETECTED at the loader's teardown. `BT_PROBE_BGF=<name|ALL>` + `BT_PROBE_N` (btl4main.cpp) now
|
||
direct-loads models at boot for regression (bld08 clean 25×; bld08_lp is a bound-only file, benign FAIL).
|
||
- **THE CULPRIT — a TYPE CONFUSION in the heat/power linkage (reconstruction bug):** `HeatSink`'s ctor
|
||
resolved its linked sink via `owner->GetSegment(heatSinkIndex)` — the Nth skeleton **EntitySegment**
|
||
(288 bytes) — cast to `Subsystem*`. The binary (`@004adda0`, part_012.c:16999) instead reads
|
||
**`owner->subsystemArray[heatSinkIndex]`** (the SUBSYSTEM ROSTER @0x128, bounds-checked vs
|
||
subsystemCount@0x124, null-guarded). Through the bogus pointer, every per-frame `ConductHeat` wrote
|
||
`other->pendingHeat` at offset 388 = **100 bytes past the 288-byte block**, and `BalanceCoolant` wrote
|
||
`coolantLevel` 20 bytes past — thousands of 4-byte OOB heap writes during sustained fire → smashed NT
|
||
free-list links next to the mech's EntitySegments → the next big alloc/free churn (a lazy building load)
|
||
AV'd. **Sibling bug in `PoweredSubsystem`** (raw part_013.c:1198): `voltageSourceIndex` (res+0xFC) is
|
||
ALSO a roster index; the GetSegment draft made `AttachToVoltageSource` write `currentTapCount` 136 bytes
|
||
past the segment block at every mech spawn. BOTH fixed to the roster lookup via the public
|
||
`owner->GetSubsystemCount()/GetSubsystem(i)` (+ the powersub else-gate fixed to OWNER flags per raw).
|
||
Verified: sustained combat under `BT_HEAPCHECK=1` (`_CRTDBG_CHECK_ALWAYS_DF`, whole-heap validation on
|
||
every alloc/free — now a runtime env gate in btl4main.cpp) = 100+ shots, ZERO detections; the heat link
|
||
now reads a REAL sink (`heatEnergy=1.34e+07`, was ~0 through the segment). **LESSON (add to the systemic
|
||
checklist): a `+0x128`-style owner offset in subsystem code is the subsystem ROSTER (`subsystemArray`),
|
||
NOT the segment table — check every `GetSegment(int)` call in reconstructed subsystem ctors.**
|
||
- Bonus findings from the audit (non-corrupting, tracked): `keyframeData[keyframeCount]` in
|
||
LoadLocomotionClips is BINARY-FAITHFUL (the raw does the same read; the value is stable/in-bounds in real
|
||
clip data — do NOT "fix" to [count-1]); crashClipA/B/C+gimpBaseClip are separate members here but live
|
||
INSIDE `animationClips[]` in the binary ([3]/[2]/[4]/[32]) — a wrong-clip fidelity gap for crash/gimp gait
|
||
states when those go live; `Mech::CreateModelResource` alloc 200 < sizeof 356 is a LATENT overflow in dead
|
||
offline-tool code; Torso 640==0x280 alloc is an EXACT fit (zero headroom — any Torso/Watcher growth must
|
||
bump the factory alloc); StatusMessagePool->New() at btplayer.cpp:460 derefs a NULL stub pool (latent);
|
||
mechdmg.cpp:289 leaks IntegerPlugs into a no-op DZIndexTable::Add.
|
||
**✅ RESOURCE-LAYOUT AUDIT — DONE (full report: `btbuild/RESOURCE_AUDIT.md`; commits 802a7a6/7de66ad/975a397).**
|
||
A 33-agent adversarial audit of all 24 `*__SubsystemResource` structs found **8 confirmed layout bugs** (same
|
||
silent-garbage class as the heat fix), all rooting to 2 broken base resources (`HeatWatcher`, `PowerWatcher`)
|
||
+ 2 field defects (`Gyroscope`, `Searchlight`). Raw-decomp-verified + FIXED: **HeatWatcher re-based onto
|
||
MechSubsystem** (record 0xF0; class+resource+ctor+parser, AmmoBin child follows) and **PowerWatcher re-based
|
||
onto HeatWatcher** (record 0xF4; HUD/ThermalSight/Torso children follow) — the reconstruction had approximated
|
||
BOTH as `: public HeatableSubsystem`, sliding every Watcher field low. Gyroscope got its unread +0xF4 record
|
||
slot (exageration→0xF8); Searchlight now reads the inherited `segmentIndex`@0x28 (was a bogus appended field).
|
||
Combat + heat-flow un-regressed. **KEY PRACTICE ESTABLISHED: lock every resource-struct layout with
|
||
`static_assert(offsetof(...)==0xNN)` + `static_assert(sizeof(...)==0xNN)`** against the binary's known offsets —
|
||
compile-time proof that can never silently regress (far better than a runtime log; the test mech may not even
|
||
instantiate the subsystem). Cascade notes: re-basing a Watcher class off HeatableSubsystem un-shadows its
|
||
`degradation/failureTemperature`; child `HandleMessage`/`Simulation` delegates that called `HeatableSubsystem::`
|
||
must redirect to the real base (`MechSubsystem::HandleMessage`, `HeatWatcher::ResetToInitialState`); a re-based
|
||
class's header needs a guarded `#include` of its new base's header (powersub.hpp now includes heatfamily_reslice.hpp).
|
||
|
||
**✅ MechControlsMapper REVIVED — the real input-interpretation tick runs LIVE (gated `BT_REAL_CONTROLS=1`).**
|
||
The fully-reconstructed mapper family (mechmppr.cpp `MechControlsMapper` @004afbe0-@004b08c0; btl4mppr.cpp
|
||
`L4MechControlsMapper`/`MechThrustmasterMapper`/`MechRIOMapper` @004d11e8-@004d27f8) was already built +
|
||
constructed at runtime (btl4app `MakeViewpointEntity` builds a `MechRIOMapper` into roster slot 0 via
|
||
`SetMappingSubsystem`); the ONLY bypass was the tick being skipped (mech4.cpp roster walk) because of raw-offset
|
||
bugs. FIXED (a 4-agent workflow mapped the binary/API/state first): (1) **wild typed-pointer arithmetic** in
|
||
`InterpretControls` (`*(Scalar*)(mech + 0x34c)` on `Mech*` = +0x34c*sizeof(Mech) — THE AV) → all owner reads
|
||
now reconciled members: `mech+0x34c→reverseStrideLength` (naming caveat: it is the TOP/run speed the throttle
|
||
scales — CONFIRMED by data: speedDemand=61.5 == the authentic run speed), `0x534→walkStrideLength`,
|
||
`0x5c0→forwardThrottleScale` (NEW member; writer not in decomp, ctor=1.0), `0x438→sinkSourceSubsystem` (real
|
||
binary-exact Torso: `SetAnalogTwist/ElevationAxis`@0x1F0/0x1F4, `GetHorizontalEnabled`@0x250),
|
||
`0x5b4→hudSubsystem`; (2) **the 0x5b4 cache was MISWIRED**: raw factory (part_012.c:10155-10164) case 0xbd6
|
||
(HUD, alloc 0x2a4) writes `param_1[0x16d]` — case 0xbdc (MechTech, binary alloc 0x104!) has NO cache; our
|
||
factory had it on MechTech (renamed member `mechTechSubsystem`→`hudSubsystem`, cache moved to the HUD case;
|
||
MechTech's 0x104 alloc could never hold the mapper's `+0x28c` write); (3) **HUD `flickerPhase`@0x28C renamed
|
||
`freeAimSlew`** — it IS the mapper's free-aim/reticle-slew output (was init-only placeholder); (4)
|
||
**`FillPilotArray`'s wild app reads** (`application+0x6c→station+0x190` — the OTHER AV) → the native
|
||
`application->GetMissionPlayer()` + `GetEntityID()` (the same drift-fix btl4mppr's SendFakeButtonEvent uses);
|
||
(5) the look/eyepoint COMMIT deferred (marked): its binary offsets collide with declared members
|
||
(`mechName`@0x360, `stateFlags`@0x410, `poweredSubsystems`@0x7bc) — arbitrate before enabling; no port consumer.
|
||
**Wiring (mech4.cpp, gated):** an INPUT BRIDGE writes the mapper's published input attributes from WASD/
|
||
env exactly as a streamed Direct `.CTL` mapping would (the dev box has no RIO/Thrustmaster; interpretation
|
||
stays 100% authentic), the roster walk un-skips the mapper, the SM consumes `bodyTargetSpeed=|speedDemand|`
|
||
and the drive heading uses `turnDemand` (mode-shaped steering with the authentic speed-vs-turn clamps).
|
||
**Verified live:** `[mppr] thr=1 → speedDemand=61.501` → SM walk→run at the commanded speed; full tick chain
|
||
(RIO→L4→main + BuildPilotArray) clean; baseline (env off) combat un-regressed (FIRED #221); heapcheck run
|
||
with real controls + combat = 61+ shots, zero detections. **Controls plumbing facts (from the workflow):**
|
||
the engine is a PUSH model — `LBE4ControlsManager` groups (`scalarGroup/joystickGroup/keyboardGroup/
|
||
buttonGroup`) are fed by ALL devices (RIO serial on the pod, DirectInput on dev) and mapper members register
|
||
via `group[elem].Add(mask,&member,this)`; `L4CONTROLS=KEYBOARD` gives NO analog axes (key-RELEASE events
|
||
only) — so a real stick needs `L4CONTROLS=DIJOYSTICK`/`THRUSTMASTER`, or the bridge. **Remaining mapper
|
||
work:** fire-trigger through the mapper (streamed `.CTL` event mappings → weapon demand; gBTWeaponTrigger
|
||
still the bring-up), look/eyepoint offset arbitration, control-mode cycling (message handlers exist),
|
||
`ControlsMapper`-vs-`SubsystemMessageManager` untangle at factory 0xBD3 (binary 0xBD3 = messmgr; the mapper's
|
||
canonical home is roster slot 0 — our 0xBD3 case builds a base mapper as a stand-in).
|
||
|
||
**✅ PILLAR A — THE MOTION MODEL IS COMPLETE & DEFAULT-ON (locomotion P3 closed for MP purposes).**
|
||
(1) **LEG callback reconstructed** (`FUN_004a6928` == `PTR_LAB_0050d6f0`, PE-parse + capstone jump table
|
||
@0x4a69aa): `Mech::LegClipFinished` — same 33-state shape as the body's but compares the LIVE mapper
|
||
`speedDemand` (binary: `*(subsystemArray[0])+0x128`; typed mirror `controlsMapper`), slews `legCycleSpeed`
|
||
@0x348, re-arms via `SetLegAnimation`, gimp alternates 0x12↔0x13 with |ratio| (negative gimp stride).
|
||
(2) **The two-channel split is LIVE** (under real-controls): the LEG channel writes the skeleton JOINTS
|
||
from the live demand, the BODY channel is pure motion (`Advance(dt, move_joints=0)`) — the binary's
|
||
leg=local-sim / body=displayed-motion model. The leg enters NATURALLY from Standing: verified progression
|
||
legState 0→5 (stand→walk) →11 (walk→run) →13↔12 alternation converging on the commanded 61.5 u/s.
|
||
**KEY RAW FIX: the Standing case was INVERTED in BOTH channels** (recon had `commanded < standSpeed →
|
||
walk`; raw FUN_004a5028/FUN_004a5678 case 0 is `standSpeed < commanded → SetAnimation(5)`, `<0 → reverse
|
||
0x10`, else stand) — without the fix a commanded mech never left Standing. Also fixed: `AdvanceLegAnimation`
|
||
double-deref'd the never-initialized `controlSource` alias (AV) → reads `controlsMapper->speedDemand`
|
||
(null → 0 = idle); `legAnimationState`@0x3b0 == `legStateAlarm.level` re-synced (same as body).
|
||
(3) **Orientation** now composes the yaw RATE into `localOrigin.angularPosition` via the engine integrate
|
||
op (`Quaternion::Add` == FUN_00409f58) instead of rebuilding from a scalar heading — the mech keeps its
|
||
authentic spawn orientation (rate CONSTANT still bring-up; the per-mech turn-rate param is deferred).
|
||
(4) **Velocity storage every frame** (was collision-gated): `worldLinearVelocity` from the frame delta +
|
||
`localVelocity` (linear fwd −Z + yaw angular) — the exact fields `Mover::WriteUpdateRecord` publishes =
|
||
the MP dead-reckon update-writer data. `DeadReckonPose` was already reconstructed; consuming it is MP-side.
|
||
(5) **Defaults flipped** (see §10a). Consequence of the authentic spawn orientation: `BT_SPAWN_ENEMY` now
|
||
places the dummy along the spawn FACING (btplayer.cpp; the fixed z-offset assumed the old forced heading
|
||
and the mech walked away → "no damage" red herring). Verified default-stack combat: structure→0.933.
|
||
**Remaining locomotion polish (non-gating):** authentic turn-rate constant; body-callback gimp handlers
|
||
(targets 0x70b2/0x7161 undecoded); airborne callbacks (`FUN_004a6344`/`FUN_004a7970`); wall-block-vs-climb
|
||
tuning; collision damage application; the eyepoint/look commit (mapper).
|
||
|
||
**✅ SHADOW — the AUTHENTIC 1995 model decoded + implemented, render-verified (task #20, commit b38e94b).**
|
||
BT 4.10's shadow is NOT a projection: it is the flat **`*_tshd.bgf` proxy** drawn as ordinary geometry,
|
||
posed by **`jointshadow`** (balltranslate under ROOT; SKL comment "apply terrain angle to pitch and roll")
|
||
and its child **`jointtshadow`** (hingey; "apply torso twist to yaw", Torso record key
|
||
`TorsoHorizontalShadowJoint`). Configured by the model record's **`ShadowJointName` @0xB4** (≤19 chars,
|
||
"Unspecified"=none), resolved in the Mech ctor (part_012.c:10285-10310 → **`this[0x10b]` @0x42c** = the
|
||
shadow Joint — the earlier recon misread this block as a death-effect lookup; now `Mech::shadowJointNode`).
|
||
`dpl_SetGeometryShadowVolume` exists in libDPL headers but has NO surviving game callers. Port implementation:
|
||
(1) `d3d_OBJECT::mIsShadow` + a dedicated DrawMesh path — blend-pass only, SRCALPHA/INVSRCALPHA, no Z-write,
|
||
constant TFACTOR `0x8C000000` (~55% black), all touched states saved/restored exactly; btl4vid tags `tshd`
|
||
objects at load AND sets each drawOp `alphaTest=true` (**pass scheduling fact:** `HierarchicalDrawComponent::
|
||
Execute` L4VIDRND.cpp:149 routes objects into PASS lists per-drawOp on alphaTest/drawAsDecal/drawAsSky —
|
||
an object with all-false drawOps only ever reaches PASS_OPAQUE). (2) `Mech::UpdateShadowJoint(normal)`
|
||
writes pitch/roll to jointshadow per-frame. **✅ SHADOW TERRAIN-TILT NOW LIVE (was the flat-up placeholder;
|
||
default-on, gate `BT_SHADOW_TILT=0` restores flat).** The user reported the shadow DISAPPEARS on elevation +
|
||
the feet look sunk. Root cause: mech4.cpp fed `UpdateShadowJoint` a hardcoded flat-up normal, so on a slope
|
||
the flat `*_tshd` quad was buried in the hillside and Z-culled (it draws Z-tested, no Z-write, in
|
||
PASS_ALPHABLEND) → gone; and the missing ground-contact cue made the feet READ as sunk even though the origin
|
||
is measurably ON the surface (alignment probe at the mech's climb positions: visual−origin = 0.05–2.7u, i.e.
|
||
NOT a placement bug — the snap is correct). Fix (mech4.cpp shadow block): now that the ground decode (#15) is
|
||
live, sample the collision surface via `GetMoverCollisionRoot()->FindBoundingBoxUnder` at the mech's XZ + two
|
||
world-axis offsets (D=12u), build the world normal from the height gradient `(-dH/dx,1,-dH/dz)`, rotate it into
|
||
the mech-local frame by `-gDriveHeading` about Y (mech is upright, yaw only), and pass it in — jointshadow's
|
||
SKL comment is literally "apply terrain angle to pitch and roll". A probe miss (mech at its collision node's
|
||
edge) falls back to flat. **Render-verified:** on a butte-flank slope the shadow now LIES ON the ground
|
||
(tilted, follows the slope) and anchors the feet; pre-fix the same steep mid-climb showed NO shadow; combat
|
||
un-regressed (TARGET DESTROYED after 8 hits), 0 crashes / 0 heap detections under BT_HEAPCHECK. NOTE the probe
|
||
uses the mech's `containedByNode` so far offsets can miss (→ flat fallback); a residual uphill-foot dip on
|
||
steep slopes is the authentic upright-biped geometry + coarse-collision (0–2.7u), not a placement bug. (3)
|
||
Torso yaw→jointtshadow was already wired (Torso::UpdateJoints; data-disabled on the BLH record, byte-faithful).
|
||
**✅ THE "MECH SINKS INTO INCLINES + SHADOW DISAPPEARS" BUG — ROOT-CAUSED & FIXED (shadow DEPTH BIAS,
|
||
L4D3D.cpp mIsShadow block; BT_SHADOW_BIAS=<float> tunes, =0 restores plain Z-test).** The user reported feet
|
||
sinking + shadow vanishing on ANY moderate incline (original vids show neither). Systematic elimination:
|
||
(a) flat ground placement EXACT — dg100's instance Y is −0.453125 (map dumps printing "−0" hid it); visual
|
||
floor AND slab top both at −0.4531 = the mech's resting Y, zero offset; (b) snap faithful (origin = solid
|
||
surfaceY, verified decode); (c) LOD selection correct — BGF LODs are FINEST-FIRST (dhillg1 136→47 tris) and
|
||
the loader takes the first; ⚠ DG100 the desert-floor model is PERFECTLY FLAT (all verts Y=0) — the rolling
|
||
desert relief is the giant dhillg mounds (r≈950); (d) LOD0-exact content gap (earlier merged-LOD measurements
|
||
were inflated): mound visual runs 0–2.1u ABOVE the collision cones on slopes (median 0–0.5, p90 1.2–1.5) =
|
||
ankle-to-shin foot clip at worst patches — authentic, the binary had the same. **THE REAL RENDER DIVERGENCE:
|
||
the shadow proxy drew with a plain Z-TEST, so wherever the visual rose even ~0.3u over the quad the WHOLE
|
||
shadow was buried+culled — and without its ground-contact cue the small authentic foot-clip reads as "the
|
||
mech is sinking".** The IG board drew ground shadows decal-style (depth-biased coplanar geometry — the classic
|
||
blob-shadow technique; the shadow FLICKER in pod footage is that bias fighting at edges). FIX: the mIsShadow
|
||
draw now sets `D3DRS_DEPTHBIAS` (default −0.004 normalized ≈ 2–4u at chase range) + `SLOPESCALEDEPTHBIAS −1`,
|
||
saved/restored exactly. **Render-verified:** standing mid-slope at the WORST measured gap spot (606,621 on
|
||
dhillg2) = shadow fully present lying on the slope + feet visible planted (was: shadow gone, feet "buried");
|
||
moderate inclines while walking ✓; flat ground crisp, no bleed; combat un-regressed (DESTROYED after 8 hits,
|
||
0 heap detections). Note the residual ≤2.1u foot-clip at worst patches is authentic content coarseness — with
|
||
the shadow anchoring the mech it reads as standing in soft sand, matching the vids. (A whole-mech terrain-tilt
|
||
experiment was tried and REJECTED — the 1995 mech is upright-only; do not tilt the body.) `BT_CAM_Y`/`BT_CAM_Z`
|
||
(btl4vid.cpp) override the debug chase-camera offset — diagnostic for separating camera occlusion from real
|
||
geometry clipping.
|
||
**✅✅ VISUAL-GROUND CONFORM — the residual foot-clip is now GONE too (btvisgnd.cpp, PORT ADDITION,
|
||
presentation-only; gate `BT_VISUAL_GROUND` default ON).** The user (rightly) rejected "authentic ≤2.1u clip":
|
||
the resolution to "how did the devs let this slide" is **the 1995 game never rendered this view** — it was
|
||
cockpit-only (you can't see your own feet; other mechs are 100+m away = subpixel), so the artists never needed
|
||
visual-solid agreement; OUR port adds a close external camera, so the port also owns making it correct. The fix
|
||
samples the ACTUAL rendered terrain triangles under the mech and lifts the RENDER matrix to the visible surface:
|
||
at mission load, walk the map's MakeMessage stream (`mission->GetMapID()` → `FindResourceDescription` → Lock;
|
||
class-42 instances, skip includes/sky/profile/rwin backdrops), load each terrain model's LOD0 triangles via the
|
||
ENGINE `LoadBgfFile` (bgfload.h — game-side callable; indices are double-sided, 6 per tri, take first 3), cache
|
||
per-model + per-instance world XZ AABBs; per frame `BTVisualGroundLift(x,y,z)` = highest triangle surface at the
|
||
mech XZ within a LOCAL band [y−1, y+3] (band keeps a butte-base query on the skirt, not the mesa top; clamp
|
||
[−1,+3]) → `localToWorld(3,1) += lift` at the PerformAndWatch tail (mech4.cpp). **`localOrigin` is NEVER touched
|
||
— collision/aim/damage/dead-reckoning stay on the authentic solid model; next frame's drive rebuilds localToWorld,
|
||
so the lift is per-frame render-only.** Zero tuning constants — exact content geometry both ways (lift up where
|
||
visual bulges over the solid, settle down where it dips). **Verified:** flat desert lift≈0 (float epsilon —
|
||
confirms both sampler and the flat-exactness finding); full mound crossing lift tracks ±0.02..0.79u continuously;
|
||
standing + walking mid-mound = feet ON the visible sand, shadow present (the user's exact repro case); combat
|
||
un-regressed (DESTROYED after 8 hits), 0 crashes / 0 heap detections. NOTE: masters only (replicant render-conform
|
||
is an MP follow-up); needs cwd = pod BT dir (LoadBgfFile indexes VIDEO/ relative — already a runtime requirement). **⚠ ENGINE-CLASS GOTCHA
|
||
(add to the checklist):** a NEW member added to a 2007 engine class (d3d_OBJECT etc.) MUST be initialized
|
||
in EVERY ctor init-list — the debug heap fills fresh allocs 0xCDCDCDCD, so an uninitialized flag reads
|
||
TRUE (here: every object in the world drew as translucent-black "shadow" → near-black scene); and any
|
||
state the shadow/special draw path sets on the D3D device must be save/restored exactly (a leaked
|
||
`LIGHTING FALSE` + `ALPHAARG1=TFACTOR` also darkened the whole frame). Render-verified via PrintWindow
|
||
A/B on DBASE: baseline opaque black blob → dark translucent silhouette, terrain/feet visible through it.
|
||
|
||
**✅ BACKFACE CULLING RESTORED (task #20, commit a2ed21a; `BT_CULL` env cw/ccw/off, default CW).** The
|
||
bring-up `D3DCULL_NONE` drew interior/back faces the original culled — the "dark wedge" shapes in the
|
||
distance were the INSIDES of dune/butte meshes, and standing inside a mound visual looked like being
|
||
sealed in rock. CW is the correct winding for our BGF quad triangulation `[a b c][a c d]` (CCW/none
|
||
selectable for debugging). Render-verified on dbase; combat clean. **CONTENT FACT (the "mound swallow"
|
||
investigation):** dbase's dune-mound groups (`dhillg*`) have CRESCENT-RIDGE visuals with HOLLOW interiors
|
||
but solid FULL CONES (hillg2_c.sld: 2 cones r=200/250 h=36/60) — where the visual exists it matches the
|
||
solid within ~1u (verified by triangle-exact BGF parsing vs the analytic cone), but a mech can walk
|
||
through a ridge crest into the hollow and ride the invisible cone — AUTHENTIC 1995 coarse collision, not
|
||
a port bug. Butte cliff tiers verified BLOCKING (beeline repro: 403 frame rejections, zero penetration).
|
||
Analysis tooling now permanent: `[contact]` telemetry in Mech::ProcessCollision, an INSIDE-SOLID streak
|
||
detector (BT_GROUND_LOG), `BT_GOTO="x z"` beeline harness (mech4 computes → mapper bridge consumes; the
|
||
turn-sign/convergence is rough — debug only), and the RES/map/solid scanners preserved in the scratchpad
|
||
(`resscan.py`/`mapscan.py` — worth moving to tools/).
|
||
|
||
**✅✅ THE AUTHENTIC DPL ENVIRONMENT IS NOW HONORED — day sky + sun + haze + draw distance (task #20,
|
||
commits 5b615e7 + 9ca9a48).** The user compared the desert level to pod footage — ours was dark/muddy with
|
||
a wrong sky; the fix transformed it to match. ROOT CAUSE: the env pipeline (`DPLReadEnvironment` →
|
||
`DPLReadINIPage`, reading **`BTDPL.INI`** in the pod BT dir — set via `L4DPLCFG`) **already ran and
|
||
correctly resolved** the map+time via compare/branch (`main → dpl_defaults → des_day → dsdayclear` for
|
||
dbase/day), setting the AUTHENTIC **clip=2100**, **fog=600/2050** (0.46,0.46,0.68 haze), **ambient=0.45 0.4
|
||
0.45**, and a **directional sun** (1.10,0.9,1.00 @ −50,10,0) — but THREE bring-up stubs clobbered it:
|
||
(1) `EnsureValidProjection` (the mission-load safety net, btstubs.cpp) forced clip far=1300, killed fog
|
||
(`currentFogNear/Far=1e9`+FOGENABLE FALSE), set WHITE ambient → now a FALLBACK that only overrides when the
|
||
env failed (`viewAngle<=0 || clipFar<=clipNear`); when valid it just re-asserts the env projection.
|
||
(2) the per-frame lighting block re-set `D3DRS_AMBIENT` to gray `0x404040` (~0.25) every frame → now
|
||
re-asserts the captured `mEnvAmbient` (from the map's `ambient=`); `BT_AMBIENT=<hex>` still overrides.
|
||
(3) `mCloudRed/Green/Blue`+`mCloudEmit*` were UNINITIALIZED in the ctor (0xCDCDCDCD garbage into the sky
|
||
material multiply) → init 1.0/0.0. **THE SKY:** the sky is a `*sky.bgf` dome (dbase=`dsky`, grass/arena=
|
||
`sky`) authored with a cloud TEXTURE (`btfx:dsky_mtl`) + white vertex colours. The original drew it in a
|
||
dedicated fullbright **PASS_SKY** (D3DRS_LIGHTING FALSE + extended fog, already present from earlier task-#20
|
||
work), but the BGF loader route hardcoded `drawAsSky=false` (the `.sky` sidecar the `.x` path keys on doesn't
|
||
exist), so the dome fell into PASS_OPAQUE → LIT (half-dark) + FOGGED → the dark navy dome. FIX:
|
||
`LoadObjectBGF` tags any `*sky*` object `drawAsSky=true` → PASS_SKY → the cloud texture shows fullbright.
|
||
**Render-verified dbase A/B:** dark muddy + navy dome → bright warm sand + blue cloudy sky + soft horizon
|
||
haze + distant mesas (2100 far). Combat clean. **KEY DEBUG TECHNIQUE:** the env parser's `DPLReadINIPage
|
||
processing <page>` + `Clip/Fog/Ambient/Light` banners are gated on `[main] debug=` — copy `BTDPL.INI` →
|
||
`*DBG.INI` with `debug=True`, point `L4DPLCFG` at it, and the log shows every page visited + value set.
|
||
**STILL STUBBED (follow-ups):** the `objectpath`/`materialpath`/`texmappath` day/night PRIORITY is commented
|
||
out (`dpl_AddToObjectFilePath` calls) — the BGF loader (`bgfload.cpp`) recursively indexes the whole `VIDEO/`
|
||
tree **first-match-wins**, which happens to pick `DAY/` (alphabetically first) for day missions, so day is
|
||
accidentally correct; NIGHT/morning/evening missions would get the wrong (day) sky+textures, and the grass
|
||
map renders pale pink/purple terrain (a wrong-time or wrong material picked). Proper fix = reimplement the
|
||
path priority so the loader prefers the env's objectpath/materialpath dirs in order.
|
||
**◐ DAY/NIGHT PATH PRIORITY — infrastructure done, materials gated (commit 90a8d92).** `bgfload.cpp` now ranks
|
||
every index candidate by the INI's search dirs (most-specific-first) and keeps the best per stem;
|
||
`SetVideoPathPriority(cacheKey, dirs)` (bgf/bmf/img) is fed the accumulated objectpath/materialpath/texmappath
|
||
lists from `DPLReadEnvironment`. **BGF priority (geometry) is ON; BMF/IMG (materials/textures) are GATED
|
||
`BT_MATPRI=1`, default OFF** — a naive "prefer mat\day" over-applies to the SHARED desert terrain materials and
|
||
washes the warm day terrain to GRAY (render-verified regression on the user's dbase/day). The sky's day/night is
|
||
itself MATERIAL-driven (`btfx:dsky_mtl` in mat\day|night), so a correct night sky needs BMF priority — which
|
||
trades against the day terrain; resolving cleanly needs the terrain-material model understood (why mat\day grays
|
||
the desert — likely a shared lib whose mat\day variant differs, or a resolution-failure gray fallback). **⚠⚠
|
||
CRITICAL GOTCHA — `/FORCE` turns an unresolved symbol into a runtime crash, NOT a link error.** `SetVideoPathPriority`
|
||
was first defined INSIDE bgfload.cpp's anonymous namespace → internal linkage → `L4VIDEO.obj`'s call was
|
||
unresolved → the BT game link (uses `/FORCE`) silently stubbed it → a call jumped to `__ImageBase`
|
||
(`0x009d0000`), a DETERMINISTIC AV in `LoadMissionImplementation` that looked like heap corruption. Fix: define
|
||
it OUTSIDE the anonymous namespace. **When a `/FORCE`-linked build crashes with a garbage call target near the
|
||
image base, grep the link log for `unresolved external` — the "successful" build is lying.** Also: `BTL4VideoRenderer`
|
||
is allocated by the GAME (btl4app.cpp:242) as a `DPLRenderer` subclass, so ANY member added to `DPLRenderer`
|
||
(L4VIDEO.h) needs the game's btl4app/btstubs/btl4vid objects recompiled — CMake header-dep tracking through the
|
||
`btbuild/fwd/*` shims is unreliable; delete stale `.obj`s if layout-mismatch corruption appears.
|
||
|
||
**✅✅ RENDER FIDELITY — the dbase desert now matches the pod footage (task #20; a 10-agent measured
|
||
render-fidelity workflow found 6 defects, 3 fixed = the "big three").** The user compared our dbase to
|
||
authentic footage (`C:/git/original.PNG`): ours was harsh/saturated/faceted; the original is soft, pale,
|
||
low-contrast. Root causes (measured, not guessed):
|
||
1. **⭐ Mountains "inside-out/faceted" = MALFORMED GEOMETRY, not lighting (commit d189371).** `BGF_FORMAT.md`
|
||
wrongly documented **`CONNECTION_LIST` (tag 0x0047) as "one polygon per chunk, fan-triangulate"**. It is
|
||
actually a **FLAT TRIANGLE LIST** (3 indices/face). Fan-triangulating the whole chunk as one N-gon turned
|
||
buttec's 115 real triangles into 341 fan-garbage with smeared UVs + cancelled normals. FIX: group-of-3
|
||
walk in the `!gotPconn` branch (bgfload.cpp buildPmesh + port/src/bgf.cpp). **Corpus-verified: ALL 3488
|
||
CONN chunks in the pod GEO tree have n%3==0.** bgftest BUTTEC.BGF: 341→115 tris. Gated BT_TERRAIN_TRILIST.
|
||
2. **Horizon seam = flat sky plane truncated by the world projection (commit 2434bbc).** DSKY is a FLAT plane
|
||
at Y~110 (±6000), NOT a dome. The world far=2100 projection truncated it above the true horizon → the gap
|
||
showed the fog-colour Clear as a hard lavender band; the old sky-pass fog (near×3/far×6) left the rim
|
||
unfogged. FIX: sky-pass gets its own far=9000 projection + the SAME fog as the world (drop ×3/×6), BOTH
|
||
restored after the sky loop (mandatory — the alpha/particle/HUD passes need the world projection). Gated
|
||
BT_SKY_FAR.
|
||
3. **Sky "two-tone navy, nothing like clouds" = MODULATE vs SCREEN (commit 2a8aa37).** The cloud art (bintA)
|
||
is a GRAYSCALE intensity map; the sky colour is the material tint (0.3,0.5,1.0) baked into the vertex
|
||
diffuse. MODULATE (texel×tint) → saturated navy. The original SCREENed toward white:
|
||
`screen(D,T)=T+D·(1-T)=lerp(D,white,T)` → bright texels near-white, dark → pale tint. FIX: one texture
|
||
stage `D3DTOP_LERP(Arg0=TEXTURE, Arg1=TFACTOR=white, Arg2=DIFFUSE)`, states saved/restored around the sky
|
||
draw. Measured `screen(0.3,0.5,1.0,0.85)=(228,236,255)` ≈ original top `(229,230,255)`. Gated BT_SKY_SCREEN.
|
||
**THE AUTHENTIC IG-BOARD SHADING MODEL (from libDPL DPLTYPES.H:189-199):** shading is selected PER-GEOMETRY by
|
||
vertex type — no-normal geometry (XYZ_UV / XYZ_RGBA_UV: mesas/ground/sky/buildings/mech, all with WHITE baked
|
||
verts) is drawn UNLIT with the texture colorized by the material's 2-endpoint RAMP (`dpl_SetMaterialRamp`,
|
||
texture luminance indexes a low→high gradient — rock (0.25,0.21,0.16)→(0.8,0.5,0.4) = warm tan); only ~150
|
||
vehicle/missile files (XYZ_N_UV) carry real normals and were lit by the single map light.
|
||
4. **⭐ RAMP colorize + day/night material priority — DONE (commits d5c155b, cc0055a); the ground/mountain
|
||
colour match + sky whites.** The ground looked PALER than the mountains with a seam because they use
|
||
different material libs: the mountains' `ravinerock_mtl` has a warm diffuse (0.781,0.525,0.353) so they
|
||
render warm, but the ground's `grass_mtl` has NO diffuse — it relied ENTIRELY on its colour RAMP, which we
|
||
didn't apply → gray placeholder = the pale ground. The authentic IG board colours terrain by the material's
|
||
**2-endpoint RAMP** (`dpl_SetMaterialRamp`): texture luminance indexes a low→high gradient. Implemented:
|
||
`bgfload` parses the ramp def (0x30 name + 0x31 6-floats) + the material's ramp ref (0x28), threads
|
||
rampLo/Hi onto `BgfDrawBatch`→drawOp; `L4D3D` bakes `lerp(rampLo,rampHi,texLum)` into the texture at load
|
||
and draws ramped batches with a **WHITE material + WHITE vertex colour** (so the ramp colour shows untinted
|
||
— critical: a leftover tint MODULATEs the ramp again). **DAY/NIGHT MATERIAL PRIORITY (bmf/img) is now
|
||
DEFAULT ON**: the mat\day libs carry the AUTHENTIC warm ramps (rock 0.25,0.21,0.16→0.8,0.5,0.4 = warm tan;
|
||
grass →warm brown); first-match had picked the generic GEO/*.BMF whose ramps are GRAY (0.8,0.75,0.75) —
|
||
which only looked warm before because the vertex tint covered for it. The earlier "BT_MATPRI grays the
|
||
terrain" regression is GONE precisely because the ramp is now applied. **SKY via its own ramp**: dsky_mtl
|
||
has a 'sky' ramp (0,0,0.6→0.99,0.99,0.99) → bright cloud texels → 0.99 near-white = the "whites" the user
|
||
wanted; `BT_SKY_SCREEN` (the pre-ramp heuristic) now default OFF. **Render-verified ALL DEFAULTS**: dbase/day
|
||
= pale pastel sky + soft white clouds + uniform warm tan ground matching the warm mesas = the pod footage;
|
||
dbase/night = dark twilight + warm-lit red mesas; grass = warm brown; combat un-regressed. Gates:
|
||
BT_RAMP / BT_MATPRI / BT_SKY_SCREEN.
|
||
5. **UNLIT no-normal geometry (STEP 5) — NOT done, and no longer needed.** Since STEP 1 fixed the geometry +
|
||
the ramp gives correct colour, the mesas/terrain look natural lit; the residual faceting is gone. Deferred
|
||
(⚠ per-drawOp NOT per-object — APC/HUMMER mix XYZ_N_UV + XYZ_UV; ⚠ 0xCD ctor-init gotcha; ⚠ would change the
|
||
mech to unlit). Full spec: scratchpad rf_plan.md/rf_verdicts.md; the workflow reports are the durable cites.
|
||
6. **⭐ RAMP over-applied to LIT vehicle geometry → "whitish-tan blobs" — FIXED (bgfload.cpp `hasNormals` gate).**
|
||
The user saw the map's scattered vehicles (hummer/semi/dozer/apc…) render as uniform dusty-white blobs. ROOT
|
||
CAUSE: the vehicles' shared `basev` material library (`VIDEO/MAT/BASEV.BMF`) gives every material a REAL
|
||
diffuse colour (bv1_mtl tan 0.66,0.47,0.30; bv1gry gray 0.41; bv5dkgrn green; bv5red; bv1ylw; blk) PLUS a
|
||
ramp ref (tag 0x28) to the `cdusty` ramp (0.33,0.27,0.22→0.90,0.85,0.80 = dark-brown→near-white) over the
|
||
shared atlas `BASEV.BSL`. Our task-#20 ramp path set `batch.hasRamp` purely from the material, with **no
|
||
vertex-type gate** → it ramped the vehicles too, baking cdusty over BASEV.BSL + forcing a WHITE material →
|
||
every vehicle washed to the same dusty-white, killing its diffuse colour. But the authentic IG model
|
||
(DPLTYPES.H) selects shading BY VERTEX TYPE: **normal-bearing geometry (vehicles/missiles, `XYZ_N_UV`/
|
||
`XYZ_N`) is LIT and shows diffuse×texture; the RAMP is a NO-NORMAL feature only** (terrain/mesas/sky/
|
||
buildings/mech). Vehicles carry `XYZ_N_UV` (SEMI = 400 XYZ_N_UV + 248 XYZ_RGBA_UV). FIX: `bool hasNormals
|
||
(id)` (0x81/0x89) + `useRamp = currentHasRamp && !hasNormals(pmeshVtag)` per-PMESH in `buildPmesh` — replaces
|
||
`currentHasRamp` for the vertex colour + `batch.hasRamp`/`batch.color`. Now normal-bearing batches keep their
|
||
diffuse colour + BASEV.BSL texture (colored trucks); no-normal batches (incl. the vehicles' own no-normal
|
||
detail parts) still ramp. Per-PMESH so a mixed-vertex-type model splits correctly. **Verified A/B (zoomed
|
||
distant-vehicle crop):** before = gray/white blobs → after = gray trucks + tan/brown cargo + brown container,
|
||
distinct materials; terrain ramp un-regressed; combat un-regressed (TARGET DESTROYED after 8 hits); no crash /
|
||
0 heap detections under BT_HEAPCHECK. NOTE: not separately env-gated — it's a correctness fix inside the
|
||
existing BT_RAMP system (BT_RAMP=0 still disables all ramping in L4D3D). **CORRECTION to an in-chat claim:
|
||
the vehicles are ALREADY per-light shaded once un-ramped — the loader is NOT missing normals.** `buildPmesh`
|
||
pushes 0 into nx/ny/nz only as INITIALISATION; `emitTri` then accumulates the forward face normal into each
|
||
vertex (bgfload.cpp:442-444) and the finalise pass normalizes them (`:597-601`) = smooth per-vertex normals
|
||
for ALL geometry. Lighting is globally ON whenever the map ships a light: `DPLReadINIPage` parses the env
|
||
page's `light` entry (BTDPL.INI, e.g. dbase/day `dsdayclear`) into `sceneLight[]` as a `D3DLIGHT_DIRECTIONAL`
|
||
sun (`L4VIDEO.cpp:3086` SetLight+LightEnable), and the frame setup (`:6196-6233`) sets `D3DRS_LIGHTING TRUE` +
|
||
`NORMALIZENORMALS` + `COLORVERTEX` + diffuse/ambient from vertex COLOR1 when `sceneLightCount>0`. Non-ramp
|
||
batches get material Diffuse=bv color, Emissive=0 (L4D3D.cpp:303-310), so a vehicle renders
|
||
`texture × [ambient·color + sun·(N·L)·color]` = real directional shading (render-verified: a gray tanker
|
||
shows a bright sun-lit top, dark shadowed side faces, smooth gradients — a flat render would be uniform).
|
||
The ramp-white was the ONLY thing suppressing it; no separate lighting work was needed. (STEP 5 — making
|
||
no-normal geometry authentically UNLIT — is the OPPOSITE change and still "not needed": global lighting on
|
||
the ramped terrain/mesas/mech looks correct vs the footage.)
|
||
|
||
**✅ WEAPON FIRE IS NOW VISIBLE — the muzzle→hit BEAM renders + fires on the trigger, target or not.**
|
||
The user pressed SPACE and saw nothing: (1) the whole fire path was gated `if (gEnemyMech != 0)` (the
|
||
BT_SPAWN_ENEMY test dummy) → in solo there was NO target so the trigger ran no fire code; (2) even against
|
||
the dummy the only visual was an Explosion at the target — the `dpl_*` weapon-beam renderable was never
|
||
ported. FIX (two parts): **(a) a self-contained additive-quad BEAM renderer in the engine** —
|
||
`BTPushBeam(from,to,color,ttl,width)` queues a world segment, `BTDrawBeams(dev,view,dt)` (L4VIDEO.cpp,
|
||
called in the alpha pass so beams are Z-occluded by terrain) billboards each as a camera-facing additive
|
||
quad and fades it; no content needed (fills the unported dpl_ layer). **(b) a visual-fire block in
|
||
`Mech::PerformAndWatch` (mech4.cpp) that runs ALWAYS** — computes the muzzle (origin + kMuzzleHeight 7u /
|
||
kMuzzleForward 3u) + the mech's −Z forward, picks the aim = the locked enemy (torso) OR the crosshair
|
||
raycast to terrain (`BTGroundRayHit`, btvisgnd.cpp, else muzzle+fwd*range), and on the trigger (own cooldown
|
||
`gBeamCooldown`) pushes the bolt = **red-orange glow (w6) + white-hot core (w1.6) + muzzle flash** and (solo)
|
||
an impact Explosion at the hit. The existing enemy-damage block is UNCHANGED and still runs below (beam →
|
||
"target" while locked, then "free" after the kill). **Render-verified:** solo desert — a bright white-cored
|
||
laser fires forward to the aim point; combat — `[fire] BEAM -> target` while the dummy is locked, structure
|
||
0.8→1.0, **TARGET DESTROYED after 8 hits**, then "free" beams post-kill; 0 crashes / 0 heap detections under
|
||
BT_HEAPCHECK. SPACE/Ctrl fire the beam interactively (btl4main VK_SPACE→gBTDrive.fire).
|
||
**✅ AUTHENTIC MUZZLES — the beams now fire from the mech's REAL gun-port SITE segments (arm guns).** The
|
||
BLH.SKL has arm-gun joints `jointrgun`/`jointlgun` with muzzle site segments `siter/lu/dgunport` +
|
||
`sitel/lu/dgunport` (upper/down/back ports per arm). The fire block (mech4.cpp) resolves them by name each
|
||
frame — `GetSegment(CString("siterugunport"))` → `EntitySegment::GetSegmentToEntity()` (segment→entity
|
||
LinearMatrix) → `AffineMatrix::Multiply(segToEntity, localToWorld)` → `Point3D = matrix` (extracts the W_Axis
|
||
translation) = the world muzzle — and fires one bolt per resolved port, all converging on the aim point (falls
|
||
back to a centre muzzle if none resolve). **Verified:** muzzle logged at (−2.6,+4.4,+2.7) off the origin =
|
||
the arm gun, NOT the centre; render shows TWO beams emerging from the left+right arm guns in a converging V,
|
||
with muzzle flashes at each gun, tracking the arms; combat un-regressed (DESTROYED after 8 hits), 0 crashes /
|
||
0 heap detections. The beams are now RED LASER (glow 0x00E01808 + white-hot core), authentic for the all-laser BLH (Nova).
|
||
**◐ TIE-TO-REAL-FIRE — TRIED, REVERTED (over-saturates; needs a beam throttle).** The authentic next step is
|
||
driving the beam from each weapon's OWN fire: `Emitter::FireWeapon` has `owner` (Mech*), `muzzlePoint`,
|
||
`targetPoint`, `dischargeTime`, and `damageData.damageType` all in scope, so pushing `BTPushBeam` there
|
||
(muzzle from `owner->GetSegment(segmentReference)`→segmentToEntity×localToWorld, coloured by damageType:
|
||
Laser red / Energy blue / Ballistic amber) gives real rate + heat + per-type colour, and it BUILT + ran
|
||
(combat: `[emitter] FIRED heat=0.239`, DESTROYED-in-8). BUT the BLH is a ~12-laser ALPHA STRIKE and
|
||
`FireWeapon` runs ~16×/s across the roster; a persistent (`dischargeTime`) additive beam pushed per fire
|
||
ACCUMULATES into a white screen-flood at point-blank. So `Emitter::FireWeapon` no longer draws (kept the
|
||
mech4 controlled 0.8s salvo). To revive: throttle to ONE beam per weapon per discharge (push only on the
|
||
fire EDGE, `dischargeTimer` just reset) + short TTL (no per-frame stacking) + dim per-beam / alpha-blend the
|
||
glow so N overlapping beams don't saturate. `simulationFlags & 0x8` is NOT a safe weapon discriminator
|
||
(bit 3 is in the engine host/replicant `flags & 0xc` mask) — enumerate weapons via the factory class IDs,
|
||
not the flag. **✅ FIRE RATE — grounded on the recovered weapon data (was a 0.8s bring-up guess).** The user noticed the
|
||
laser cadence looked too slow vs the pod videos — correct, 0.8s was arbitrary. The real laser params
|
||
(`ERMLASER.SUB`/`ERLLASER.SUB`/... — the BLH is ER-medium): **`DischargeTime=0.2`** (beam-on), `WeaponRange=500`,
|
||
`DamageAmount=3.5`, `PipColor=1.0 0.0 0.0` (RED, confirms the laser colour), `RechargeRate` scales with size
|
||
(ER-S 1.0 / ER-M 2.0 / ER-L 4.0 / PPC 5.0). Applied: beam-on = `kBeamOnTime=0.2` (DischargeTime, recovered
|
||
hard value); cadence `kFireCooldown=0.3` (0.2 + short recharge) → ~62% beam duty cycle, snappy rapid fire vs
|
||
the old ~15%. **✅✅ RECHARGE CADENCE — FULLY DECODED from the binary (RechargeRate IS the recharge in seconds).** PE-parsed
|
||
the charge-curve constants from BTL4OPT.EXE (`.data` at the `_DAT_` VAs, read as the x87 80-bit `float10` the
|
||
decomp uses, NOT 32-bit float): **C1=`_DAT_004bb3c4`=1.0, C2=`_DAT_004bb3b8`=1e-4, `_DAT_004bb3b4`=0.5** (the
|
||
energyCoefficient ½). The Emitter ctor (raw part_013.c:7899-7906) computes `energyCoefficient = energyTotal/
|
||
(0.5·seekV²)` and `energyRampTime = rechargeRate / (−VoltageCurve(C1−C2·seekV)) / energyCoefficient`; the
|
||
per-frame charge (`FUN_004b0d50` dtScale × `TrackSeekVoltage`) is an exponential approach toward the source
|
||
voltage with time-constant τ = dtScale·energyCoefficient, and the fire threshold is `seekVoltage[rec] =
|
||
0.8·RatedVoltage`. **The constants make it cancel exactly:** seekV = 0.8·RatedVoltage, RatedVoltage
|
||
(GENRATOR.SUB) = 1e4 → seekV = 8000 → curve arg = `1 − 1e-4·8000` = 0.2 → `VoltageCurve=ln` (must be, for
|
||
`energyRampTime>0`) → `−ln(0.2) = ln5 = 1.609`; and the exponential charge from 0 to 0.8·srcV also takes
|
||
`ln(1/0.2) = ln5`. The two ln5 factors cancel ⇒ **recharge time = RechargeRate seconds exactly** (ER-S 1.0 /
|
||
ER-M 2.0 / ER-L 4.0 / PPC 5.0). So one laser's full cycle = `DischargeTime + RechargeRate` = **0.2 + 2.0 =
|
||
2.2s** for the BLH's ER-mediums — much SLOWER per weapon than any guess; the fast on-screen fire is the ~12
|
||
lasers firing STAGGERED (chain), aggregate ≈ 12/2.2 ≈ 5/s. Applied (mech4.cpp): `kPortRecharge=2.2`, fire ONE
|
||
gun port per stagger tick (`kPortRecharge/nMuz`) rotating through the 6 arm ports (added siter/lbgunport) →
|
||
a rapid alternating single-beam stream, each port honouring the 2.2s recharge (render-verified: single red
|
||
bolt per frame, rotating ports; combat DESTROYED after 8 hits, 0 crashes / 0 heap). ⚠ ASSUMPTION: that the
|
||
game STAGGERS rather than synced-volleys (a synced alpha strike would flash every 2.2s = slow, contradicting
|
||
the pod footage) — the per-weapon 2.2s is decoded-exact; the stagger *pattern* + exact weapon→port count is
|
||
the estimate (needs the weapon enumeration via factory class IDs, not simulationFlags).
|
||
**✅✅ BEAM APPEARANCE — DECODED from the assets (the "gritty" look is a real scrolling texture, NOT invented).**
|
||
The user noted the lasers looked too clean. Decoded the real beam: the weapon resource names its beam model
|
||
(`VideoObjectName=ermlaser.bgf`, `GraphicLength=2000`; `beamLengthRatio=dist/2000` scales it). `ermlaser.bgf`
|
||
= a thin TUBE (89 verts, Z 0..−2000) with UVs tiled ~8× down its length, material `btfx:beamwhite_mtl` (core)
|
||
+ `btfx:beamred_mtl` (glow). The GRIT is a SCROLLING TEXTURE: **`VIDEO/MAT/DAY/BTFX.VMF` (DIV-VIZ2 source)**
|
||
defines `TEXTURE(NAME="beamwhite_scr_tex"; SPECIAL="SCROLL 0.0 0.0 0.10 9.5") { MAP {"bexp"} }` — i.e. the
|
||
core samples the **`bexp` image = `VIDEO/TEX/BEXP.BSL`** (a 128×64 chaotic red/yellow/green noise) scrolled
|
||
FAST along the beam (u=0.10, v=9.5), ramp-colourised by `softer` (0.25→0.99) to a white core; the glow is
|
||
solid red (`fiery` ramp). ⚠ **I FIRST WRONGLY SAID THE TEXTURE WAS MISSING** — it is present; I'd missed the
|
||
`MAP{}` directive in the `.VMF` (the `.BMF` only names the texture, the `.VMF` binds it to the image + carries
|
||
the SCROLL). **THE `.VMF` IS THE KEY EFFECTS ASSET** (scroll speeds for beams/muzzle/smoke/sky: beamwhite v=9.5,
|
||
beamyellow v=−5.3, beamgreen v=−1.3, muzinner/muzouter, firesmoke, sky). Beam COLOR is per-weapon via the beam
|
||
model: ER lasers→ermlaser(RED), standard lasers→l/m/slaser(YELLOW), PPC→ppc.bgf(BLUE), AC/Gauss→ballistic
|
||
(no beam), missiles→projectiles. Implemented (L4VIDEO.cpp BTDrawBeams; gate BT_BEAM_TEX): load BEXP.BSL once,
|
||
ramp its luminance by `softer` → grayscale grit texture, MODULATE the beam colour by it, tile U down the
|
||
length + scroll (u=0.10/s, v=9.5/s) → the streaming gritty beam. Render-verified: the beam shows banded/streaky
|
||
grit (was a clean gradient); combat un-regressed (DESTROYED after 8), 0 crashes / 0 heap detections.
|
||
**✅ THE REAL `ermlaser.bgf` TUBE NOW RENDERS (replaced the camera-facing billboard quad; gate `BT_BEAM_TUBE`,
|
||
default ON).** `BTDrawBeams` (L4VIDEO.cpp) loads the tube geometry ONCE (`LoadBgfFile("ermlaser.bgf")` → static
|
||
`s_tubeVB`/`s_tubeIB`, x,y,z,u,v per vert) and per beam builds a world transform `Scale(w,w,beamLen/2000) ×
|
||
Rotate(local −Z → muzzle→target dir) × Translate(muzzle)` — the tube's native Z 0..−2000 maps onto the shot,
|
||
its baked UVs (U 0..7.73 tiled down the length) carry the scrolling `bexp` grit via a `D3DTS_TEXTURE0`
|
||
transform (`D3DTTFF_COUNT2`, `_31=uScroll _32=vScroll`), beam colour from `D3DRS_TEXTUREFACTOR` (the tube verts
|
||
carry no diffuse — FVF `XYZ|TEX1`, COLORARG2/ALPHAARG1 = `TFACTOR`; the billboard path stays `XYZ|DIFFUSE|TEX1`
|
||
with `DIFFUSE`, kept as the `BT_BEAM_TUBE=0` fallback). Native tube is ~0.3u wide so a width scale is applied:
|
||
`BT_BEAM_WIDTH` (default **3.0** → ~0.9u; the width scaling ONLY affects the tube path, which is how a run was
|
||
confirmed to use the tube not the billboard). All touched state saved/restored (TEXTUREFACTOR,
|
||
TEXTURETRANSFORMFLAGS, D3DTS_TEXTURE0). **Render-verified** (dbase/grass, `BT_FORCE_FIRE`+`BT_SPAWN_ENEMY`): a
|
||
clean 3D volumetric bolt from the arm-gun muzzle to the target, staggered single-port fire, scene un-regressed,
|
||
7+ shots, 0 crashes. ⚠ **TEST-HARNESS GOTCHA (cost an hour — not a game bug):** the env gates check
|
||
`getenv(name)` for NON-NULL, so **`$env:BT_HEAPCHECK=""` (empty string) still ENABLES heapcheck** — every run
|
||
after I first set it crawled through `CrtCheckMemory` on each string alloc in the material `scanDir` (O(n²)
|
||
during mission BGF indexing) and looked like a mission-load HANG at `[zonebuild]` (`Responding:False`). cdb on
|
||
the "hung" process showed the main thread live in `ucrtbased!CrtCheckMemory` ← `scanDir`/`bmfIndex` ←
|
||
`LoadBgfFile` ← `DPLReadINIPage`, not stuck. FIX: `Remove-Item Env:\BT_HEAPCHECK` to actually clear it (setting
|
||
`=""` does not). Applies to ALL `BTEnvOn`-style gates.
|
||
**✅✅ AUTHENTIC DISTANCE LODs — "first LOD only" was WRONG; the arena "structures not rendering fully" bug
|
||
(bgfload.cpp + L4D3D + L4VIDEO).** The user found arena1 structures rendering as floating fragments (poles/
|
||
letters/rails with no massing). Triage ruled out the CONN fix (A/B: 28 vs 29 tris on AR03, identical) and
|
||
load failures (none). ROOT CAUSE: **each BGF LOD chunk carries a `0x2046` header = [near..far) VIEWING BAND**
|
||
(AD01: [0..57.7]=close-up 3D logo letters, [57.7..231]=full billboard, [231..1155]=far; AB07: near band = a
|
||
sliver+pole DETAIL SUBSET, [115..1155] = the whole building) — the IG board selected the LOD whose band
|
||
contains the camera distance. Our loader always took the FIRST LOD; for classic reduce-LODs (calpb 152→16
|
||
verts, dhillg, buttes) first==finest so it LOOKED right, but the arena's composite structures author their
|
||
massing in the FAR bands → only near-detail fragments drew. FIX: the loader collects EVERY LOD tagging
|
||
batches with the band (BgfDrawBatch/L4DRAWOP lodNear/lodFar); DrawMesh selects per draw-op by
|
||
|object − camera| (camera published per frame via `d3d_OBJECT::SetCameraPosition`, eye = −t·Rᵀ from the view
|
||
matrix). **CONSOLIDATION IS NOW DEFAULT OFF (`BT_CONSOL=1` re-enables)** — merged ops combine many instances
|
||
+ LODs of one material, so per-op bands can't survive the merge (a consolidated run would draw EVERY LOD
|
||
simultaneously). Frustum culling + HWVP carry the cost — measured FASTER than consolidation: dbase combat
|
||
avg 1.05ms, 79 batches (distant objects draw their small far-LODs). VERIFIED: arena renders a coherent
|
||
scene (continuous perimeter wall, full buildings, no floaters); dbase terrain/buttes unchanged; combat
|
||
TARGET DESTROYED after 8 hits, 0 crashes. Viewer: `BT_LOD_INDEX=n` inspects a specific LOD. FOLLOW-UP: a
|
||
few PINK horizon patches on dbase = far-LOD materials that never resolved before (their libs never loaded);
|
||
minor. ⚠ LESSON: "first LOD" was validated only on reduce-style models — a format assumption verified on a
|
||
subset can still be wrong for another authoring style; the 0x2046 header was readable all along.
|
||
**⚠⚠ LOD-DISTANCE BUGS FIXED THE NEXT SESSION (user: "mech disappearing/reappearing with viewing angle",
|
||
then "scenery still blinks"):** (1) the CAMERA EXTRACTION from the view matrix was TRANSPOSED — the D3D
|
||
row-vector LookAt stores the camera basis in COLUMNS (xaxis=(_11,_21,_31); eye.x = −(t·col1) =
|
||
−(_41·_11+_42·_12+_43·_13)); using ROWS gave a pseudo-position that VARIED WITH THE VIEW ANGLE, so LOD
|
||
bands popped in/out as the camera rotated (structures AND the mech blinked). (2) the METRIC IS THE INSTANCE
|
||
ORIGIN distance — a bounding-sphere-SURFACE metric (tried briefly, blamed for a floor vanish that was
|
||
really bug #1) subtracts each component's OWN radius, smearing the COORDINATED band handoffs of composite
|
||
structures (ADWLK1 ends [..115] exactly where AB07's far band [115..] takes over) by 20–50u per piece →
|
||
holes/doubles the orbiting chase camera swept pieces in and out of = the scenery blinking. Floor slabs'
|
||
single bands are wide (ctarmac [0..1155], dtarmac [0..1039], afloor [0..577]) — origin distance keeps them
|
||
visible across the play space; LODing out beyond that range (far outside the arena) is authentic.
|
||
`BT_CULL_LOG=1` dumps big culled objects (bounds sanity). Verified: 10-frame full-rotation sweep = mech +
|
||
floor + scenery stable (2 frames camera-inside-a-wall, expected); dbase combat DESTROYED after 8 hits.
|
||
⚠ LESSON: changing two things at once (camera fix + metric) attributed the floor vanish to the wrong one —
|
||
the sphere metric was never needed.
|
||
**⚠⚠ RUNTIME LOD SELECTION — REVERTED TO EXPERIMENTAL (`BT_LODSEL=1`); DEFAULT = SHIPPING-ENGINE BEHAVIOR.**
|
||
Even with the correct camera + origin metric + the authentic mech-eyepoint reference (fed via `BTSetLodEye`,
|
||
kept wired), runtime band selection still broke arena content (floor vanished when the mech wandered >577u
|
||
from `afloor`'s origin — impossible in the real game, so the 1995 semantics for huge grounds involve
|
||
something not yet decoded: per-LOD reference points (`dpfGetLODReference`/`dpl_SetObjectLodHotSpot` exist in
|
||
the libDPL API!), the unparsed class-0 arena map records, or ground-type exemptions). **DEFINITIVE REFERENCE
|
||
FOUND: `DivLoader/VGCDivLoader.cpp parseLOD` — the SHIPPING 2007 engine keeps ONLY the nearest-band LOD
|
||
(`if (in < 5.0f) parse = true`) and does NO runtime LOD selection at all** — i.e. our original "first LOD"
|
||
behavior IS what Red Planet shipped with; the arena-composite fragments exist in the 2007 engine too.
|
||
DEFAULT restored to that (first LOD + consolidation ON; `BT_LODSEL=1` switches to the experimental full-LOD
|
||
path and auto-disables consolidation). TO FINISH PROPERLY: decompile BTL4OPT.EXE's own LOD path (the 1995
|
||
software renderer's selection incl. hot-spots) instead of guessing — then the arena backdrop can be made
|
||
authentic. All the plumbing (bands parsed+tagged, per-op DrawMesh test, eyepoint feed) is in place and gated.
|
||
**✅✅ ADDITIVE_LODS DECODED — the arena "fragment structures" FIXED, authentically (default ON, gate
|
||
`BT_ADDLOD=0`).** The follow-up research cracked it WITHOUT decompiling the binary: **the 1995
|
||
`MUNGA_L4/L4VIDEO.CPP` `TestSpecialCallBack` (~line 486) reads the OBJECT-level SV_SPECIAL (0x2037) token
|
||
`ADDITIVE_LODS` from the BGF itself and calls `dpl_SetObjectAdditiveLODs`** (DPL_VPX.H). **135 pod BGFs
|
||
carry the token** — ALL arena structures (AB*/AD*/AR*/AW*), the PGN calliope-turret family, buildings
|
||
(HUT/TWR/BNK/JAX/FT/HN), vehicle props (TRK*), and EVERY `*D` mech damage model. Corpus sweep (841 GEO
|
||
BGFs): additive objects author their LODs as a clean PARTITION of [0..outmax) with COMPLEMENTARY geometry
|
||
(AW01: LOD1 = upper structure, LOD2 = base — different regions, not nested representations) — so
|
||
replacement-selection is provably WRONG for them (one complementary piece at any distance) and
|
||
first-LOD-only shows just the near-detail piece = the "floating fragments". **THE RULE: additive object →
|
||
at eye distance d, EVERY LOD with `d < OutDist` draws (InDist ignored; near detail ADDS onto the coarser
|
||
massing and drops first as you retreat).** One corpus quirk: MSLG.BGF has an inverted band → clamp
|
||
max(in,out). Implementation: `bgfload.cpp` TAG_OBJECT parses 0x2037 → additive objects collect ALL LODs,
|
||
each batch banded [0..OutDist); non-additive keep shipping first-LOD-only (mech/terrain/afloor → zero
|
||
regression risk); `L4VIDEO.cpp RecurseStaticObject` EXCLUDES banded objects from static consolidation
|
||
(merged runs can't band-gate) — they render individually via the per-op DrawMesh distance test (eye =
|
||
the `BTSetLodEye` mech feed). **Verified:** arena1 spawn A/B — `BT_ADDLOD=0` reproduces the user's exact
|
||
floating-panels bug AT THE SPAWN (the spawn sits inside a dropzone gate whose massing never drew!),
|
||
default ON = the complete bay/gate + walkway, coherent structures at all ranges; consol srcMeshes 255→152
|
||
(103 additive instances excluded); grass combat TARGET DESTROYED, 0 crashes; cavern + dbase identical to
|
||
baseline. NOTE: the arena spawn's debug CHASE camera now starts inside the (correctly solid) bay wall —
|
||
the pod's cockpit camera sat at the mech's head so the original never saw this; drive forward to exit.
|
||
**✅✅✅ THE FULL LOD DECODE — √3 RANGES + ORIGIN METRIC + PUNCH CUTOUTS (the 6-agent `lod-blink-decode`
|
||
workflow; closes the arena "blinks with movement" + "noisy unstable surfaces" reports).**
|
||
1. **⭐ THE √3 RANGE DECODE (corpus-exact):** every stored 0x2046 band value is the AUTHORED euclidean
|
||
distance ÷ √3 — 92% of the corpus' 3263 nonzero band slots are EXACTLY nice-number/√3 to 6 significant
|
||
figures, with hand-conversion TYPOS as proof (BUTTEB 2000.73 for 2020.73=3500/√3; MSLG's inverted band
|
||
= the author typing 10 for 100). afloor 577.35 = authored **1000** (the historic floor-vanish
|
||
explained); ADWLK1 115.47 = 200; the arena massing 1154.7 = **2000** ≈ des_day clip 2100. Comparing
|
||
plain euclidean eye distance against stored values made EVERY piece switch at 57.7% of its authored
|
||
range — inside the arena's clear air (fog 200→1250, clip 1300 [ardayclear]) = the position-dependent
|
||
blinking. FIX: `bgfload.cpp` multiplies parsed bands by √3 (`kLodRangeScale=1.7320508`). With it, NO
|
||
massing pops in-play (2000 ≥ the arena's max reachable 1972; 54 of 104 popping instances — ALL
|
||
walls/buildings/gates — stop popping; the remaining are authored detail dropouts at 400-900u, 19-66%
|
||
fogged). A disjoint 7.45% RAW-integer band population exists (one prop-family artist skipped the
|
||
conversion — GD/RB/WL/SW/SB/TK1 etc), 100% NON-additive → unaffected today.
|
||
2. **METRIC = the instance-transformed OBJECT HOT SPOT (default = origin), ONE shared d for all LODs of
|
||
an object** (`s_dplobject.lod_hot_spot`; InDist doesn't even survive to the runtime — the board keeps
|
||
only `lod_ranges[16]` switch-out floats, which STRUCTURALLY confirms the cumulative rule). The
|
||
bounding-sphere-SURFACE metric (previous commit) was UNAUTHENTIC — per-piece radius offsets desync
|
||
sibling bands (the OpenFlight "use previous slant range" contract: siblings must range against the
|
||
SAME point). Reverted to origin distance in DrawMesh.
|
||
3. **✅ PUNCH cutouts (dpl_Punchize) implemented** — PATCH-level SV_SPECIAL `PUNCH` (1995
|
||
TestSpecialCallBack L4VIDEO.CPP:563-569) marks cutout geogroups (arena scaffold layers AR01-04,
|
||
gratings): black texels = HOLES. The port drew them SOLID, shingled 0.05-0.5u over the wall massing =
|
||
near-coplanar depth shimmer in motion (the "noisy unstable surfaces"). Now: loader tags batches
|
||
(bgfload punch flag) → texture loads with alpha-0 on near-black texels → DrawMesh alpha-TESTS punch
|
||
ops in the opaque pass (ALPHAREF 0x80, z-write preserved, states restored). Gate `BT_PUNCH=0`.
|
||
Render-verified: the catwalk rails/barriers draw as see-through lattice.
|
||
**Corpus classification (the same workflow):** additive LODs are overwhelmingly COMPLEMENTARY
|
||
(families: detail+massing 54, progressive layered series 49 [AR01 = trim→scaffold→scaffold+panels→
|
||
SOLID WALL, disjoint layers, correct under cumulative], single-detail 9, mixed 23); real coplanar
|
||
DOUBLED overlap only in props (TRK1/3/4 grille detail flush on the body) + mech damage arms — those
|
||
carry `GEOMETRIZE 0x8000xxxx` hints (decal/priority, unimplemented lead), authentic-coexisting on the
|
||
board too, left as-is.
|
||
**Other decoded facts:** map class-0 records = INCLUDE directives (`mid==3` + type-14 target;
|
||
arena1→{arenall→cavern, artrucks} — the arena sits INSIDE the cavern map; class-48 = named dropzones);
|
||
NO 0x2047/0x2048 exists anywhere in the corpus; Division dVS has no additive concept (VPX-local
|
||
extension); OpenFlight LOD semantics (OGC 19-065) match the tag set 1:1 (reference-point ranging,
|
||
transition ranges) — the reference for future replacement-LOD work.
|
||
**Diagnostics kept (permanent):** `BT_LOD_LOG=1` → `[lodband]` per-model banded-op census at load,
|
||
`[lodeye]` eye-feed trace, `[lodflip]` per-op band-visibility FLIP events with model name + band + d
|
||
(how the oscillation was caught: awbox1 flipping IN 3× at d≈461 vs edge 461.88).
|
||
Verified: arena traverse = no massing flips (only authored detail handoffs at 400-900u under fog);
|
||
walk-out renders banners + see-through rails + complete walls; grass combat TARGET DESTROYED, 0
|
||
crashes; dbase/cavern identical; avg frame 4.5ms.
|
||
**✅✅ THE COPLANAR-SHELL DECODE — the "extremely unstable walls" (noise/diagonal/dot patterns crawling
|
||
with movement, stable parked) ROOT-CAUSED + FIXED (submission-order depth bias; commit bcc9210).**
|
||
Diagnosis chain (each step eliminated a hypothesis): two stills at one position = pixel-identical
|
||
(pattern is a pure function of camera position); layer-isolation imaging (`BT_ONLY_LOD=k`) showed the
|
||
dot lattice INSIDE A SINGLE LOD layer (not layer-vs-layer); `BT_PUNCH=0` left it unchanged; analytic
|
||
geometry (AR02 LOD2) found the truth: **the content layers EXACTLY-COPLANAR full shells inside one LOD**
|
||
— a solid concrete shell + a coplanar PUNCH-cutout overlay + an inner shell, plane separations measured
|
||
0.000–0.003u. The Division board composited coplanar polygons DETERMINISTICALLY (exact per-polygon plane
|
||
equations → identical depth → later-submitted wins via less-equal); D3D9 interpolates depth from
|
||
vertices, so two tessellations of one plane differ by rounding PER PIXEL = crawling interference at ANY
|
||
distance (near-plane precision, per-LOD bias, punch alpha all irrelevant — hence the failed attempts).
|
||
**FIX = reproduce the board's rule:** every batch of an additive object is depth-biased ~4 LSB of D24
|
||
CLOSER per submission ordinal (file order = authored order; `2.5e-7` NDC/step, ordinal capped 20,
|
||
`BT_LAYER_BIAS` overrides) — coplanar contests resolve to the overlay patch at all distances.
|
||
Verified by bias on/off A/B at a teleport-reproduced viewpoint. **Also: punch cutout mips are built
|
||
MANUALLY with alpha RE-BINARIZED per level** (box-filtered alpha hovers at the test threshold → texels
|
||
flicker as movement shifts the mip level). **And `BT_LOD_SCALE` is BLANKET again (commit 1bea626):**
|
||
the outermost-only restriction guarded against extending shingled detail into z-fight range — obsolete
|
||
under the bias — so inner detail (wall-top caps, authored 800) now holds to 2000 = zero wall-detail pops
|
||
in-arena (traverse flip census 104 → 7, remaining = two small pieces at 375–500u under fog).
|
||
**HARNESSES (permanent):** `BT_SPAWN_AT="x z [hdg]"` teleports the player on the first driven frame for
|
||
exact-viewpoint A/B repro — MUST set the orientation QUATERNION (`localOrigin.angularPosition =
|
||
EulerAngles(0,h,0)`), not just the scalar `gDriveHeading` mirror (the chase camera derives from the
|
||
quat; without it the view keeps the random dropzone's facing). Teleporting INTO a dune can leave the
|
||
mech sunk (ground probe from inside a solid) — cosmetic, walking recovers. `BT_ONLY_LOD=k` = additive
|
||
layer-isolation imaging. ⚠ **GOTCHA (cost a loop): `bgfload.cpp` builds into the ENGINE lib** — a
|
||
game-only rebuild silently ships the stale loader; grep the exe for a new string literal when in doubt.
|
||
Remaining authentic-LOD work: replacement selection for NON-additive multi-LOD models (detail pop at
|
||
range — needs the board's hot-spot/reference semantics; leads catalogued: `s_dplobject.lod_ranges[16]/
|
||
lod_hot_spot/lod_fade_range` in DPL_PRIV.H, per-LOD 0x2047 reference points, 0x2048 transition snap|blend,
|
||
`dpl_SetLodStress` perf governor, `dpl_SetInstanceForceLOD`). **RESEARCH TECHNIQUE (reusable):
|
||
`LIBDPL.LIB` is an OMF library with full symbol names — parse THEADR/PUBDEF records (python ~40 lines) to
|
||
map every `dpl_*` entry point; the 1995 DOS `MUNGA_L4` HAL source is the game-side caller reference;
|
||
`dsys/PFBIZTAG.H` names every BGF tag (0x2046=LOD_DISTANCE, 0x2047=LOD_REFERENCE, 0x2048=LOD_TRANSITION,
|
||
0x2037=SV_SPECIAL).**
|
||
|
||
**✅✅ THE RAINBOW/GRAFFITI MECH SKINS — THE BSL BIT-SLICE FORMAT DECODED + FIXED (user: "are the mechs
|
||
colored accurately? they all seem to have a rainbow/graffiti motif" — they were not; every mech now renders
|
||
its authentic gray-metal paint).** A 4-agent workflow (BSL corpus census / format ground truth / our-loader
|
||
audit / BMF material spec) + hands-on. ROOT CAUSE: `.BSL` is NOT one RGBA image — it is a **bit-sliced
|
||
container**: w*h 32-bit texel words, byte 0 pad, the other 3 bytes = SIX independent **4-bit grayscale
|
||
sub-images** packed 2-per-byte with the nibble pair SWAPPED (even slice = HIGH nibble;
|
||
`shift=(c+((c+1)%2)*2)*4`), plus truecolor sliceTypes 7=RGB444/8=RGBA4444 (r=s5,g=s4,b=s3,a=s2) that COEXIST
|
||
with mono slices in one file. BLKHWK.BSL holds the Blackhawk's 4 skin sheets this way; our decoder read
|
||
bytes[1..3] as RGB = 2-3 different gray sheets overlaid as colour channels = the graffiti. **Slice selection
|
||
= the BMF TEXTURE record tag `0x18 BITSLICE` (u8, absent=0)** — the entry NAMES in the BSL directory are
|
||
authoring records, read-and-discarded at runtime (proof: BASEV.BMF `bexp9_tex`→slice 8 vs entry named
|
||
`bexp99`). Definitive reference found: **the shipping 2007 loader `DivLoader/VGCDivLoader.cpp:323-410`
|
||
(`LoadBSLFile`/`getBSLData`)** + Division `dsys/PIMAGE.H dpiBSLTYPE` + content build scripts (`img2vtx.exe
|
||
-b -m0..-m5 <tga>`); corpus-verified on all 66 archive BSLs (GEN.BSL's 6 entries can't fit any byte-plane
|
||
model — the smoking gun). FIXES: (1) `port/src/image.cpp decodeBSL` rewritten to slice decode (one file
|
||
serves engine+viewer; `BT_BSL=0` = legacy fallback); (2) `bgfload.cpp` parses tag 0x18 → `MatInfo.texChannel`
|
||
→ `BgfDrawBatch.texChannel` → `L4D3D decodeImage(tp, channel)`; (3) **cross-library RAMP registry** — every
|
||
mech-skin material references ramp `softer` (neutral 0.25→0.99) which is defined only in OTHER libs
|
||
(BTARENA/BTFX/BTPOLAR/BTVEH); per-file lookup left mech skins un-ramped forever; on first miss the loader now
|
||
sweeps the indexed BMFs once into a global registry (`BT_RAMP_XLIB=0` disables); (4) **RAMP TINT RULE** —
|
||
the content pins the (binary-only) board combine from both sides: dsky_mtl has diffuse (0.3,0.5,1.0) + the
|
||
COLOURED 'sky' ramp yet renders footage-white ⇒ diffuse must NOT modulate coloured ramps; the mech variants
|
||
(gen2medgry vs gen3drkgry etc.) share texture+NEUTRAL 'softer' ramp and differ ONLY by diffuse ⇒ diffuse MUST
|
||
tint neutral ramps. Rule: explicit diffuse + NEUTRAL ramp ⇒ tint; coloured ramp ⇒ white (`BT_RAMP_TINT=0`
|
||
restores always-white). Terrain/sky pixels UNCHANGED by construction; (5) truecolor slices (≥6, e.g.
|
||
damcolor's bdam8 damage sheet) bypass the ramp; (6) beam grit (L4VIDEO BTDrawBeams inline decode) now samples
|
||
slice 0 (bexp1 — beamwhite_scr_tex has no 0x18 tag); (7) viewer mirrored + its texture cache re-keyed
|
||
`path#channel` (4 textures share BLKHWK.BSL). **Render-verified:** grass + close-up = coherent gray-metal
|
||
Blackhawk with crisp panel/vent detail, dark charcoal accent zones, mauve `gen*a` leg tints (arena shot),
|
||
green lgo6 leg-logo edge visible — the BMF-spec oracle look ("gray panel-detailed mech", per-zone) exactly;
|
||
dbase terrain/sky/vehicles byte-identical to the tuned look; arena walls/punch gate clean, 0 lodflips; combat
|
||
under BT_HEAPCHECK = TARGET DESTROYED after 8 hits (exact baseline), 0 crashes / 0 heap detections. Docs
|
||
corrected (ASSET_PIPELINE.md §Pixel formats, CLAUDE.md §5). FOLLOW-UPS: RGBA4444 alpha (TREE.BSL cutout) is
|
||
decoded but not yet alpha-tested outside punch batches; VTX/TGA paths untouched; the DZM damage-state
|
||
material swap (THOR stores damage skins as extra slices thor12d/thor34d) still pending damage-graphic-state
|
||
work.
|
||
|
||
**✅ WRECK FLOOR-TILE FLICKER — FIXED (baked ground-shadow models + consolidated-world recession; commits
|
||
15fc70d, 7d5071a).** The user reported the floor tile under arena wreckage flickering exactly like the wall
|
||
panels (pattern changes with movement, freezes parked — the coplanar-shell signature, and visible in original
|
||
demo vids too; user chose to fix). TWO coplanar fighters found: (1) **baked ground-shadow MODELS** — the
|
||
`<prop>S.BGF` naming convention (MECHMOVS/COMS/ARMRS/COOLS = shadow companions to wreck props): flat quads of
|
||
`basev:shadow_mtl` at y≈0.1. Detection = all-shadow-material model (`shadowMat` per batch in bgfload; material
|
||
name contains "shadow") → routed through the mech-shadow pipeline (`SetIsShadow`: translucent dark, depth-biased,
|
||
no z-write) instead of opaque. (2) The ACTUAL fighter for the tile: **MECHMOVR's own ground plates at y=0.000
|
||
vs the consolidated arena floor** (cross-object coplanarity the per-object submission bias can't order) →
|
||
**consolidated static world is now recessed `+7.5e-7` NDC (~3 LSB)** (ConsolidateSingleObject op copy), so
|
||
individually-drawn objects (entity props, additive structures, the mech) win floor-plane ties deterministically.
|
||
Verified at the user's parked repro spot. Shadow-material models are also EXCLUDED from static consolidation
|
||
(GetIsShadow → banded), same as banded additive objects.
|
||
|
||
**✅ POLAR MAPS VERIFIED — two "bugs" that are AUTHENTIC + the emissive decode (commit cfbe6d2).** polar3/
|
||
polar4 boot + render + fight clean. (1) **No mech shadow on polar = AUTHORED**: `GEO/POLAR/*_TSHD.BGF` are
|
||
188-byte STUBS (deliberate shadow disable on snow; only RAP_TSHD is real) — chased through burial/tilt/opaque-
|
||
color probes first (BT_SHADOW_BIAS/BT_SHADOW_TILT/BT_SHADOW_COLOR all no-effect) before checking the FILES.
|
||
Check the content before debugging the pipeline. (2) **Pink ice-mound perimeter = AUTHENTIC**: the `pint`
|
||
material family (`MAT/DAY/BTPOLAR.BMF` pintBIceEmit_mtl: diffuse BLACK + emissive 0.676/0.614/0.718 + ramp
|
||
'icy') = pink-tinted self-lit ice. This surfaced that **EMISSIVE (tag 0x26) wasn't parsed** — now threaded
|
||
through MatInfo→BgfDrawBatch (`hasEmissive`/`emissive[3]`); PURE-emissive materials (diffuse black + emissive
|
||
set) render as an UNLIT glow: vertex color forced BLACK (the engine drives lit diffuse from vertex COLOR1) +
|
||
`m.Emissive = batch.emissive` — tex × emissive, no sun/ambient. ⚠ pure-emissive test is `(color&0xFFFFFF)==0`;
|
||
a material with BOTH diffuse and emissive keeps the lit path.
|
||
|
||
**✅ ALL 8 RES MAPS BOOT + RENDER (map sweep complete):** cavern, grass, rav, polar3, polar4, arena1, arena2,
|
||
dbase — each verified live (screenshot + log: 0 crashes, 0 lodflips). Eggs for every map now exist in the pod
|
||
BT dir (POLAR3/POLAR4/RAV/ARENA1M/ARENA2/DBASE/MECH2/DEV/TEST...). Arena2 note: the flat brown band between
|
||
wall-top and sky is the cavern backdrop (arena maps sit inside the cavern map via class-0 includes) — believed
|
||
authored; re-check only if it misbehaves in motion.
|
||
|
||
**✅✅ CONN+PCONN COEXIST — 370 of 841 MODELS WERE MISSING TRIANGLES (the "turret base missing panels"
|
||
bug; bgfload.cpp + port/src/bgf.cpp).** The user spotted the calliope turret (`calp` gun + `calpb` base
|
||
pad, on dbase AND cavern) rendering with gaps in the base and confirmed vs pod videos. Chain that found it:
|
||
loader-mirror (bgfcheck.py) said calpb loads "clean" at 78 tris → the standalone port VIEWER showed the same
|
||
gaps (ruling out the new consolidation/cull work) → dumping the chunk TREE showed calpb LOD0's PMESH carries
|
||
**BOTH** `PCONN_LIST` chunks (37 quads + 1 hexagon = 78 tris; note **ppf=6 hexagons exist**) **AND** a
|
||
`CONNECTION_LIST` chunk (24 plain triangles = the base panels) — and `buildPmesh` processed CONN only
|
||
`if (!gotPconn)`. **CONN and PCONN are NOT alternatives — a pmesh carries quads/hexes in PCONN plus loose
|
||
triangles in CONN.** Corpus scan: **370/841 GEO models** have mixed pmeshes (incl. AVA mech parts, buildings,
|
||
vehicles) — all silently missing their CONN triangles until now. FIX: process both, always (both loaders);
|
||
probe verified calpb 78→102 tris (+24 = exact); viewer A/B shows the base gaps filled; combat regression
|
||
clean. BGF_FORMAT.md §Indices + Open-Items updated (second correction to the CONN story: first "one polygon"
|
||
→ flat trilist (task #20), now "either/or" → BOTH). ⚠ LESSON: a "loads clean, all indices in bounds" audit
|
||
can still be dropping whole chunks — audit what's NOT consumed, not just what is.
|
||
|
||
**✅✅ THE "TURN HITCH" / 10FPS BASELINE — ROOT-CAUSED & FIXED (~50-80× render speedup; user: "game
|
||
occasionally hangs/stutters... definitely somehow related to turning").** Instrumentation chain that found it
|
||
([spike] frame-time log → [loadobj] load timer → cdb stack sampling → [rstat] draw/present split → per-draw
|
||
section timers): the hitches happened with AND without input (the user's own repro log: 3 of 6 spikes at
|
||
turn=0) — turning only made them VISIBLE (at 10fps a 400ms frame while holding a turn snaps the view through
|
||
4 frames of rotation = reads as a freeze; going straight the same stall just pauses the scenery). THE REAL
|
||
PROBLEM was the render baseline: ~107ms CPU per frame (Present <1ms, GPU IDLE). THREE stacked causes, all
|
||
fixed (engine tree, git add -f):
|
||
1. **`D3DCREATE_SOFTWARE_VERTEXPROCESSING` (L4VIDEO.cpp:2023) — THE ROOT CAUSE.** Every vertex of every
|
||
draw was transformed + LIT + fogged on the CPU. The first CreateDevice attempt used SOFTWARE (the error
|
||
message below it says "Couldn't create HARDWARE..." — the fallback was always intended); now HARDWARE
|
||
first, software fallback kept. This alone: dense-view frames 490ms → ~3ms. Also why the RelWithDebInfo
|
||
build was EQUALLY slow (the D3D software pipeline is external optimized code either way).
|
||
2. **D3DX `DrawSubset` attribute-table scan (~44µs/call × ~1400).** Our BGF triangles are DOUBLE-SIDED
|
||
(each face emitted twice) → `GenerateAdjacency` degenerate → `OptimizeInplace(ATTRSORT)` failed SILENTLY
|
||
→ no attribute table → DrawSubset scanned the whole attribute buffer per call; on the huge CONSOLIDATED
|
||
static meshes ~354µs/call = the 490ms stretches whenever a merged map chunk entered the view. FIX: the
|
||
batches are already contiguous face-sorted ranges — `SetAttributeTable` EXPLICITLY (no adjacency at all)
|
||
+ per-op `bgfStartIndex/bgfPrimCount` in L4DRAWOP and a direct `DrawIndexedPrimitive` path in DrawMesh
|
||
(DrawSubset kept as fallback for non-BGF meshes). ⚠ `D3DXConcatenateMeshes` (static-world consolidation)
|
||
REQUIRES an attribute table on its INPUTS — removing OptimizeInplace without SetAttributeTable made it
|
||
fail hr=0x8876086c and the WHOLE TERRAIN silently skipped ("206 static meshes") — the mech floated in
|
||
fog-colored void. ConsolidateSingleObject now sorts faces by op itself (no D3DX optimize) + sets the
|
||
table + direct-draw ranges.
|
||
3. **No frustum culling** (the 1995 IG board clipped in hardware; the port drew EVERYTHING every frame).
|
||
Added bounding-sphere culling: model-space sphere computed at load (mesh ctor), per-frame frustum from
|
||
view × mProjectionMatrix (NOT a GetTransform read-back — device state between frames holds pass-specific
|
||
matrices and the planes silently degenerate), 4 side planes + FAR plane (near skipped for pass-projection
|
||
differences; sky pass never culled; `mCullRadius<=0` never culled). Gate `BT_CULL_FRUSTUM` (default ON).
|
||
**Measured: dbase circling 107ms/2729 batches → 1.2-2.8ms; grass combat avg 4.5-8.7ms; spikes 8→~1
|
||
(residual = first-view managed-buffer upload). Verified: full scene renders (terrain/mesas/sky/mech/
|
||
shadow/beams), combat DESTROYED, 0 crashes.** Diagnostic infra kept (cheap): `[spike]` (mech4, frame>0.2s),
|
||
`[loadobj]` (L4D3D, load>5ms), `[rslow]`/`[rstat]` (L4VIDEO, 1Hz frame/batch/cull stats). Also gated the
|
||
weapon-aim terrain raycast (mech4) to trigger-down/discharge-active frames (was marching SampleBand over
|
||
every map instance EVERY frame). ⚠ LESSON: the user plays the DEBUG build — but the 10fps was NOT the debug
|
||
build (RelWithDebInfo identical); always split draw-vs-present and check the DEVICE flags before blaming
|
||
the compiler. ⚠ D3DXComputeBoundingSphere/SetAttributeTable/etc: any NEW d3d_OBJECT member must be inited
|
||
in BOTH ctors (0xCD fill reads as garbage bounds → everything culled).
|
||
|
||
**✅ FIRING ARC — no more shooting the enemy out of your back; the arc is DATA-DRIVEN from the torso.**
|
||
The bring-up fire path aimed the beam AND applied damage at the locked target regardless of where the mech
|
||
pointed (turning away fired lasers backward THROUGH the mech at the enemy behind). **DECOMP FINDING: there is
|
||
NO firing-arc check in the binary** — `Emitter::FireWeapon` (@004bb6d4) fires whenever `HasActiveTarget()`
|
||
(entity+0x388) and builds a straight muzzle→target beam, and the weapon `.SUB` resources carry **NO arc field**
|
||
(ERMLASER.SUB = range/damage/recharge/heat only). The 1995 game got away with this because it was **cockpit-
|
||
only**: a beam from a front muzzle to a target behind you passes behind the camera — invisible to the pilot
|
||
(same reason the foot-clip never mattered). Our external camera exposes it, so the port owns the fix. The
|
||
AUTHENTIC per-mech weapon traverse is the **TORSO twist range** (what actually lets a mech point its guns off
|
||
dead-ahead): `Torso::GetHorizontalReach()` = the wider of the software limits `horizontalLimitLeft/Right`
|
||
(res +0x100/+0xFC), or **0 when `TorsoHorizontalEnabled=0`** (the Blackhawk → guns lined up with the mech
|
||
facing). Wired: `Mech::GetHorizontalFiringReach()` (mechmppr.cpp — the TU that safely knows the full `Torso`
|
||
type; mech.cpp/mech4.cpp must not include subsystem headers, stub-collision) reads the cached torso @0x438;
|
||
the mech4 fire block gates BOTH the visual beam aim AND the real Emitter trigger + explosion/damage on
|
||
`targetInArc` = angle-to-target ≤ (BASE aim tolerance + torso reach). Base tolerance = `BT_FIRE_ARC` degrees
|
||
(default 30) — the reticle/convergence box, a labeled PORT presentation parameter (the cockpit view never
|
||
needed one); the torso reach is the authentic data part (0 for BLH, widens the arc for a twist-capable mech).
|
||
In-arc: the beam converges on the target and the shot hits; out-of-arc: the guns fire straight ahead and
|
||
nothing is hit. **Verified live** (`[target]` log shows `torsoReach=0rad` for the BLH → arc = base 30°):
|
||
circling — enemy sweeping out of arc → beams go `-> free` (forward), no hit; facing → `-> target` converge +
|
||
damage; walking in to close range → **TARGET DESTROYED after 8 hits** (un-regressed). ⚠ `kWeaponRange=100`
|
||
(mech4.cpp) is a stale BRING-UP damage-gate threshold (the real ERMLASER `WeaponRange=500`); the enemy spawns
|
||
at 120u so a STATIONARY facing test sits just outside it (visual beam fires, no damage until you close to
|
||
<100u) — cosmetic bring-up wart, not the arc. **Remaining:** wire the real torso ACTIVE-TRACK (the torso
|
||
twists toward the target within its limits, so a twist mech's guns visibly follow) — currently the reach only
|
||
WIDENS the hit test; the joints don't move in combat (torso sim dormant, §Torso).
|
||
**✅ BEAM ORIGIN TRACKS THE LIVE MUZZLE during the discharge (you can't run out from under your own beam).**
|
||
The user saw the beam's near end lag behind the mech when running — the bring-up pushed ONE world-space beam at
|
||
fire time with a 0.2s life, so the mech moved out from under the frozen segment. **DECOMP: the real Emitter
|
||
re-anchors the beam every frame.** `Emitter::FireWeapon` (`FUN_004bace8`) computes the muzzle LIVE via
|
||
`GetMuzzlePoint` (`FUN_004b9948` — resolves the gun's muzzle SEGMENT current world position from `owner+0x300`
|
||
segment table, ref `+0xdc`) and **does NOT store the muzzle** on the emitter (only the aim orientation @0x107
|
||
and the target point @0xf2 are saved); then `EmitterSimulation` case 0 (Firing) calls `ContinueDischarge`
|
||
(`FUN_004baa20`) EVERY frame while `dischargeTimer>0`, re-sending the beam keepalive. Since the muzzle isn't
|
||
stored, the display re-obtains it live each frame → the beam ORIGIN follows the gun as the mech moves (invisible
|
||
in the 1995 cockpit-only view; our chase cam exposes the lag). Reproduced in the mech4 visual: a single active
|
||
discharge is tracked (`s_dischargePort`/`s_dischargeRemain`, one at a time since beam-on 0.2s < stagger 0.37s);
|
||
on the trigger edge it STARTS the discharge, then a per-frame KEEPALIVE redraws glow+core+flash from
|
||
`muzzles[port]` (recomputed from the gun segments every frame) to the live aim with a one-frame ttl (exactly one
|
||
beam alive/frame = same brightness, re-anchored to the moving gun). **Verified:** running forward while firing,
|
||
the beam emanates from the arm gun (near end at the gun, not behind); combat un-regressed (TARGET DESTROYED
|
||
after 8 hits, 0 crashes) while the mech walked the whole time.
|
||
**⚠ FOLLOW-ON (colour/grit washout from the run-out fix):** re-pushing the beam every frame at a one-frame
|
||
ttl made `f = ttl/maxTtl = 1.0` CONSTANT (the old 0.2s ttl FADED, so it was only briefly at full brightness).
|
||
Three full-bright overlapping tubes (red glow + white core + white flash) at the SAME `s_tubeWidth` then
|
||
additively saturated to a solid WHITE cylinder — the red and the scrolling bexp grit vanished. Root cause was
|
||
TWO things: (1) the tube path ignored per-beam width (all beams drew at the global `s_tubeWidth`), so the
|
||
white core covered the FULL beam width instead of just the centre; (2) too much additive brightness. FIX:
|
||
the tube now scales by **`b.width * s_tubeWidth`** (per-beam width honoured; `BT_BEAM_WIDTH` is now a global
|
||
MULTIPLIER, default 1.0), and the keepalive pushes just TWO dimmer beams — a WIDE dim red glow (`0x00C81404`,
|
||
width 3.0 → carries the ER-laser colour + grit) + a THIN warm core (`0x00A07868`, width 0.9 → hot centre, no
|
||
white-out) — dropping the always-on white flash. Result: a steady red bolt with a bright core and visible
|
||
scrolling grit (render-verified, 24 beams, 0 crashes). A laser is steady-on (not fading), so constant
|
||
brightness is correct — the bug was saturation, not the lack of fade.
|
||
**Remaining (real weapon fidelity):** per-weapon color from the beam model (blue PPC, yellow standard lasers)
|
||
once weapon enumeration is wired (factory class IDs, not simulationFlags); the throttled real-fire beam (drive
|
||
off Emitter::FireWeapon, heat); damage by the aimed zone (cylinder lookup, STEP 6).
|
||
Remaining subsystem families (gyro/torso/myomers motion, sensor/searchlight/thermalsight readouts,
|
||
projectile/missile weapons 0xBCD/0xBCE/0xBD0) per SUBSYS_PLAN.md WAVEs 4-8.
|
||
|
||
**✅ TORSO twist/elevation aim — joint-I/O + simulation RECONSTRUCTED (dormant; blocked on the WAVE-4 un-stub).**
|
||
The torso's aim→skeleton path is now fully reconstructed and builds green with zero combat regression, but is
|
||
NOT yet live because the Torso subsystem itself is still a `HeatSinkSource` RECON_SUBSYS stub at factory case
|
||
0xBC5 (its real class is **Torso**, ctor @004b6b0c — the "SinkSource" label is the usual mislabel). What was done:
|
||
- **`Mech::ResolveJoint(name)→Joint*`** (mech.cpp, == binary `FUN_00424b60`): resolves a named skeleton node
|
||
via the PUBLIC engine path `GetSegment(CString)` → `EntitySegment::GetJointIndex()` → `JointSubsystem::
|
||
GetJoint(i)` (JMOVER.h/SEGMENT.h/JOINT.h, all in `munga_engine.lib`) — never touches the protected
|
||
`EntitySegment::jointPointer`. The Torso ctor @004b6b0c + Gyro ctor @004b3778 inline this; hoisted as the reuse
|
||
point (gyro un-stub will call it too). `Torso::ResolveJoint` forwards to `owner->ResolveJoint` (out-of-line —
|
||
needs complete Mech).
|
||
- **`Torso::PushTwist(Joint*,Scalar)`** (== `FUN_004b66b4`/`FUN_004b67ec` inner block): dispatch on
|
||
`Joint::GetJointType()` — hinge (0..2)→`SetRotation(Radian(twist))` (`FUN_0041d0a8`); ball (4..5)→
|
||
`GetEulerAngles()` keep pitch[0]/roll[2], **yaw[1]←twist**, `SetRotation(EulerAngles)` (`FUN_0041cfa0`/
|
||
`FUN_0041d020`). Nodes retyped `void*`→`Joint*`.
|
||
- **`TorsoSimulation` @004b5cf0 corrected to match the raw decomp** (part_013.c 4471-4693): the earlier draft had
|
||
the command roles SWAPPED — verified true roles: **@0x1F8/@0x1FC = elevate up/down** (drive `currentElevation`
|
||
0x1e4, clamp vertTop/Bottom), **@0x200/@0x204 = twist left/right** (drive `currentTwist` 0x1d8, clamp horiz
|
||
Left/Right), @0x208 = recenter; **analog axes @0x1F0 = twist, @0x1F4 = elevation** (the draft misused
|
||
`elevationHalfBottom`@0x22C and omitted @0x1F4). Settle test uses `targetTwist`(0x218) + the OLD twistRate
|
||
snapshot; limit latches are **edge-triggered** with @0x260/0x264=twist L/R, @0x258/0x25C=elev top/bottom
|
||
(the draft had these swapped/non-edge). `ResetToInitialState` @004b5bf8 likewise made faithful (clears all 4
|
||
latches + analog axes + aim state).
|
||
- **Per-frame skeleton write wired.** KEY DECOMP FACT: `TorsoSimulation` AND `TorsoCopySimulation` compute
|
||
`currentTwist` but **never write the joints**; `WriteJoints` (@004b66b4) is called ONLY by `ResetToInitialState`;
|
||
`UpdateJoints` (@004b67ec, the out-param-free twin) has **ZERO direct callers** — the per-frame torso→skeleton
|
||
write was dispatched by the engine's generic joint pass (indirect/virtual). This port ticks the torso's own
|
||
Performance, so `UpdateJoints()` is now called at the end of both Torso(Copy)Simulation — the resolved
|
||
equivalent at the same cadence. Verification is env-gated `BT_TORSO_LOG` (ctor resolve + layout dump; PushTwist
|
||
fire log).
|
||
|
||
**✅ WAVE-4 Torso base-chain RE-BASE + un-stub — DONE (real Torso is live, binary-exact layout, zero regression).**
|
||
The Torso is now built by the real `Torso` class at factory case 0xBC5 (`CreateTorsoSubsystem`), no longer a stub.
|
||
- **The corrected hierarchy** (verified from headers + a 6-agent decomp-mapping workflow): `Torso : PowerWatcher :
|
||
HeatWatcher : MechSubsystem` — **NOT** HeatableSubsystem. The Watcher branch is SEPARATE from the working
|
||
`PoweredSubsystem : HeatSink : HeatableSubsystem : MechSubsystem` heat leaves; the two share ONLY `MechSubsystem`.
|
||
So the 156-byte deficit lives entirely in the Watcher branch and re-basing it CANNOT touch the working heat leaves.
|
||
(Corollary, verified: Torso is **NOT** `IsDerivedFrom(HeatSink)` → it never joins the heat-conduction roster, so
|
||
there was never a heat-roster-corruption risk; the earlier note to the contrary was wrong.)
|
||
- **The +156 fix (all in the Watcher branch; `MechSubsystem` untouched):** the binary's HeatWatcher/PowerWatcher use
|
||
a **0xC-byte** connection (`watchedLink`) and a **0x54-byte** gauge alarm (`heatAlarm`/`watchdogAlarm` = a full
|
||
GaugeAlarm), NOT the 4-byte `SubsystemConnection` / 8-byte `HeatAlarm` the HeatSink branch uses. Introduced
|
||
Watcher-**local** `WatchedConnection`(0xC) + `WatcherGaugeAlarm`(0x54) in `heatfamily_reslice.hpp` (NOT heat.hpp,
|
||
so the shared types the working leaves depend on are never resized). HeatWatcher: `watchedLink`→0xC (+8),
|
||
`heatAlarm`→0x54 (+76) = +84 → own ends 0x180. PowerWatcher: DELETE the shadow `watchedLink` (the binary has ONE,
|
||
inherited@0x114) (−4), `watchdogAlarm`→0x54 (+76) = +72 → `minVoltage`@0x180, `watchdogAlarm`@0x184, own ends 0x1D8.
|
||
- **Torso own block:** DELETED the 7 CROSS-FAMILY shim backing fields (segmentFlags/heatModelOff/heatStateLevel/
|
||
electricalState/creationTime/currentTime/movedFlag, −28) and their accessors now read the REAL inherited base
|
||
(`heatAlarm.GetLevel()`, `watchdogAlarm.GetLevel()`, `MechSubsystem::simulationState`, engine `Simulation::
|
||
lastPerformance`/`ForceUpdate()`); ADDED the 4-byte `_reserved0x270` slot (+4) so `recenterActive`@0x274 /
|
||
joints@0x278/0x27C match the binary. Net −24 → own block 0xA8, **`currentTwist`@0x1D8, `sizeof(Torso)==0x280`**.
|
||
- **Compile-time LOCKS** (the practice from the resource audit, now applied to OBJECT layout — protected fields need a
|
||
`friend struct XLayoutCheck`): `TorsoLayoutCheck` (currentTwist==0x1D8, sizeof==0x280, +0x1E4/0x1F0/0x1F4/0x250/
|
||
0x274/0x27C), HeatWatcher (watchedLink@0x114, heatAlarm@0x12C, sizeof==0x180), PowerWatcher (minVoltage@0x180,
|
||
watchdogAlarm@0x184, sizeof==0x1D8), `WatcherGaugeAlarm`==0x54 / `WatchedConnection`==0xC, and **`HUDLayoutCheck`**
|
||
(HUD::flickerRate@0x1D8 — HUD is the only OTHER live Watcher-branch class; the lock proves the re-base fixes HUD's
|
||
own torso+0x1D8 read too). All pass. **KEY: `sizeof()` static_asserts work at namespace scope, but `offsetof()` on a
|
||
PROTECTED object member needs a `friend struct` (namespace-scope offsetof → C2248).**
|
||
- **Verified live** (`BT_TORSO_LOG`): the real Torso constructs with `currentTwist@472 (0x1D8)`, `sizeof=640 (0x280)`,
|
||
no crash over 26s; heat converges to the identical steady state (`pendingHeat` 0.239, temp 77, heatLoad plateaus —
|
||
the transient −0.265 on shot #1 was benign startup-timing variance); full combat loop intact (**TARGET DESTROYED
|
||
after 8 hits** at walk speed, matching the prior baseline). The gyro cross-link `sinkSourceSubsystem+0x1D8` now
|
||
reads the live `currentTwist` (was stub garbage).
|
||
- **Why the torso still doesn't visibly twist for the Blackhawk (a FAITHFUL result, NOT a bug):** the Blackhawk's
|
||
0xBC5 record has **`TorsoHorizontalEnabled=0`** + empty joint names + `hRotPerSec=0` (dumped raw; the record IS a
|
||
valid Torso record — classID=0xBC5, modelSize=0x158). Our resource struct is CONFIRMED correct — the binary ctor
|
||
`@004b6b0c` reads enabled at `param_4+0x14C` (line 5038) and gates joint resolution on `enabled!=0` (line 5039),
|
||
joints at `+0x10C`/`+0x12C`, exactly our layout. So the ORIGINAL binary ALSO skips torso joints for this mech; our
|
||
behavior is byte-faithful. Visible twist needs a **twist-enabled mech** (a record with enabled=1 + real joint
|
||
names), or the clearly-marked bring-up force-enable below.
|
||
- **✅ FULL PATH PROVEN end-to-end via `BT_FORCE_TORSO` (bring-up demo; default OFF; NOT faithful).** Since the
|
||
Blackhawk record disables the torso, a gated demo (`BT_FORCE_TORSO=1`, torso.cpp ctor + TorsoSimulation) force-
|
||
enables the torso, resolves the REAL `BLH.SKL` joints, widens the limits, and sweeps an analog twist demand. Verified
|
||
(`BT_TORSO_LOG`): `Mech::ResolveJoint` resolved **`jointshakey2`→type=4 (Ball, torso body)** + **`jointtshadow`→type=1
|
||
(HingeY, the skeleton's "apply torso twist to yaw" joint)**; `PushTwist` then fires every frame with `currentTwist`
|
||
sweeping cleanly `0→+0.7` (clamped at limit) → reverse → `-0.7` → back — i.e. the WHOLE reconstructed chain
|
||
(analog axis → TorsoSimulation integrate → clamp → UpdateJoints → PushTwist → `Joint::SetRotation`, ball branch
|
||
yaw←twist) runs correctly; no crash over 14s. The only unconfirmed link is the render pass consuming the joint
|
||
(needs an eyeball). The env-gated demo (+`BT_FORCE_TORSO_JOINT=<name>` to pick the main joint) stays as an on-demand
|
||
verifier; faithful behavior is untouched when unset. BLH.SKL torso joints for reference: `jointshakey2` (ball,
|
||
blh_tor.bgf, under jointhip) = torso body; `jointtshadow` (hingey, under jointshadow) = ground-shadow twist.
|
||
- **Follow-on for the sibling un-stubs (Gyro/Searchlight/ThermalSight):** they inherit PowerWatcher, so after this
|
||
re-base their own fields also begin at 0x1D8, but they STILL carry the same CROSS-FAMILY shim fields → each must get
|
||
the same shim-delete + accessor-redirect (like Torso) BEFORE its un-stub, or wiring it will over-size the object.
|
||
|
||
**◐ WAVE-5 GYROSCOPE (0xBC4) — layout + joint-I/O DONE; un-stub DEFERRED (ctor/integrator reconstruction incomplete).**
|
||
The gyro plumbing is reconstructed and the build is GREEN with the gyro back on its stub (reverted — see why below); the
|
||
infra is dormant + ready in gyro.hpp/gyro.cpp:
|
||
- **Re-base + layout (DONE, locked):** `Gyroscope : PowerWatcher : HeatWatcher : MechSubsystem` (Watcher branch). Deleted
|
||
its 4 CROSS-FAMILY shim fields (segmentFlags/heatModelOff/heatStateLevel/electricalState) + redirected the 3 used
|
||
accessors to the real inherited base (heatAlarm/watchdogAlarm/simulationState), retyped eyeJointNode/mechJointNode
|
||
`void*`→`Joint*`. `GyroLayoutCheck` friend locks `exageration@0x1D8` + `sizeof<=0x3D0` (both pass live).
|
||
- **Joint I/O (DONE, real):** the 8 node-helper stubs (NodeType/NodeScalar/SetNodeScalar/NodeVector/SetNodeVector/
|
||
NodeRotationEquals/SetNodeRotation/NodeVectorEquals) now call the real engine `Joint` API — `GetJointType`/`GetRadians`/
|
||
`SetRotation(Radian)` [hinge]/`GetEulerAngles`/`SetRotation(EulerAngles)` [ball]/`GetTranslation`/`SetTranslation`
|
||
[BallTranslation]; **FUN_0041d11c = SetTranslation** (writes `*(joint+0xc)` + jointModified). `ResolveJoint` forwards to
|
||
`Mech::ResolveJoint`. WriteEyeJoint/WriteMechJoint (no direct caller in the decomp, like the torso's UpdateJoints —
|
||
dispatched by the engine joint pass) are wired at the end of `GyroscopeSimulation`.
|
||
- **Verified live then REVERTED:** the real gyro constructs, resolves REAL joints (`EyeJoint='jointlocal'`,
|
||
`MechJoint='jointeye'`, both BallTranslation type=5), and `WriteMechJoint` FIRES every frame — but with **garbage
|
||
values** (`-4.31e8` = 0xCDCDCDCD-as-float, and NaN) written to `jointlocal` (the ROOT joint). Root cause: the gyro's
|
||
ctor field-init + integrator field-MAPPING are incomplete/incorrect in the reconstruction. The binary ctor @004b3778
|
||
puts **springConstant@0x1E8, dampingConstant@0x1F4, posSpring@0x218, negSpring@0x224, rotation springs @0x2C0/0x2CC/
|
||
0x2F0/0x2FC**, but the recon mislabels several (e.g. assigns springConstant to eyeOrientation@0x1DC; the field the
|
||
integrator reads as `eyeSpringConstant`@0x1F4 is really dampingConstant) and leaves the integrator accumulators
|
||
(eyeAccel/eyeForce/eyeWork/bodyOrientation/bodyAccel/bodyForce/bodyWork/eyeVelocity/limits) UNINITIALISED. Writing NaN
|
||
to the root joint is unshippable, so 0xBC4 was reverted to the `Gyro` stub (combat/heat un-regressed, TARGET DESTROYED).
|
||
- **⚠ Cross-link landmine (mech.cpp):** the gyro↔torso cross-link `((SubProxy*)gyro)->linkTarget = sinkSource+0x1D8`
|
||
writes through `SubProxy::linkTarget` which sits at **gyro+4** (an engine base field), not the real gyro torso-link
|
||
offset — harmless vs the stub, would STOMP the live gyro base. Commented out; needs the real gyro torso-link field.
|
||
- **Remaining work (the real gyro un-stub):** faithfully reconstruct the ctor field-init + the two integrators
|
||
(IntegrateEyeJoint @004b2ec0 / IntegrateBody @004b30ec) and the field LAYOUT/labels from @004b3778 (the recon has a
|
||
~196-byte layout gap + uncertain field map). This is a full subsystem-simulation reconstruction (bigger than the
|
||
torso), not a quick un-stub — do NOT guess the integrator mapping (no stand-ins). Env `BT_GYRO_LOG` dumps the layout +
|
||
joint resolve + WriteMechJoint values for verification.
|