Compare commits
190
Commits
+12
@@ -49,3 +49,15 @@ operator_secret.txt
|
||||
scratchpad/night*/*.log
|
||||
scratchpad/night*/*.rar
|
||||
scratchpad/night*/*.zip
|
||||
# tester-authored notes / lastrun dumps carry player handles + machine names
|
||||
# (deliberate exceptions: git add -f)
|
||||
scratchpad/night*/*notes*.txt
|
||||
scratchpad/night*/lastrun*.txt
|
||||
|
||||
# Bench artifacts in content/ (screenshots, session logs, generated eggs, deployed exe)
|
||||
# -- receipts stay on disk, out of `git status`. A new AUTHORED egg: git add -f.
|
||||
content/*.png
|
||||
content/*.log
|
||||
content/matchlog_*.txt
|
||||
content/btl4.exe
|
||||
content/*.EGG
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
|
||||
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
|
||||
`engine/ game/ content/ docs/ reference/ tools/ context/`.
|
||||
**Current front:** `btl4.exe` runs a full single-player loop; the gauge system is complete; the
|
||||
active work is reconstructing each subsystem's authentic behavior from the binary. Details +
|
||||
what's-next: `context/project-overview.md`, `context/open-questions.md`, recent git log.
|
||||
**Current front (2026-08-11):** core gameplay reconstruction is COMPLETE and the K/D chain is
|
||||
STRESS-PROVEN (kd4/kd8 benches: receipt-derived invariants exact at 4 and 8 nodes, gotcha §25
|
||||
closed the same-frame double-credit). This week's settled fronts: projectile flight clocks +
|
||||
live-target contact (#168), generator-out voice at the trip (the port's ONE content deviation,
|
||||
#154), annunciator lamp table + stale-flash heal (#135), ordered exit (#163), ram eligibility
|
||||
(#128). The decomp export was REBUILT 2026-08-06 (#60 closed: coverage 93.5% — cite `@ADDR`,
|
||||
NOT `part_0NN.c:LINE`). Active work: field verification (8-player night) + the remaining
|
||||
POLISH list (#156 fade, weapon-state presentation). Details + what's-next:
|
||||
`context/project-overview.md`, `context/open-questions.md`, recent git log.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,6 +65,7 @@ precise than anything you can infer.
|
||||
|---|---|
|
||||
| What the project is, engine, platform, goal | `context/project-overview.md` |
|
||||
| Missing BT source, decompilation strategy | `context/source-completeness.md` |
|
||||
| **Export DARK REGIONS / "is X in the decomp?"** — the gap census | `reference/decomp/GAP_CENSUS.md` (tools: `tools/gapcensus.py`, `tools/ghidra_reexport.sh`, `tools/gapdiff.py`; log: `phases/phase-04-gap-census.md`) |
|
||||
| The 4.10 literal-source manifest (per-TU function/size/address map) | `reference/BT410_SOURCE_MANIFEST.md` (tool: `tools/manifest410.py`; log: `phases/phase-03-bt410-source-manifest.md`) |
|
||||
| The WinTesla Windows port (renderer/audio/HAL) | `context/wintesla-port.md` |
|
||||
| Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
|
||||
@@ -78,6 +85,7 @@ precise than anything you can infer.
|
||||
| Multiplayer, replication, netcode | `context/multiplayer.md` |
|
||||
| **The OPERATOR CONSOLE + RELAY** — how to launch it, ports, seats, the round/launch lifecycle, re-arm | `context/operator-console.md` |
|
||||
| Running a session as sysop (operator-facing how-to) | `docs/OPERATOR_GUIDE.md` |
|
||||
| The tester-facing OPEN-ISSUES handout (for Discord) | `docs/OPEN_ISSUES_FOR_TESTERS.txt` — regenerate from the tracker + update the header date before each playtest |
|
||||
| Pod hardware, monitors, RIO, MFD surfaces, input remap (CONTROLS.MAP/XInput) | `context/pod-hardware.md` |
|
||||
| Glass cockpit desktop dev layer (BT_GLASS/BT_STEAM gates, PadRIO, miniconsole, Steam) | `context/glass-cockpit.md` |
|
||||
| Steam internet MP (the wire seam, identity tokens, lobby) | `context/steam-networking.md` |
|
||||
@@ -87,7 +95,11 @@ precise than anything you can infer.
|
||||
| Term / acronym definitions | `reference/glossary.yaml` |
|
||||
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
|
||||
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md` |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md`, `docs/AUDIO_FIDELITY.md`; multi-agent audit ledgers (2026-08-11): `docs/KD_CHAIN_AUDIT_20260811.md`, `docs/MISSION_END_AUDIT_20260811.md`, `docs/RAM_LEAK_AUDIT_20260811.md`, `docs/GEN_WARNING_AUDIT_20260811.md` |
|
||||
|
||||
**`docs/` split note:** most of `docs/` is AI-facing DETAIL (the ledgers above + `PROGRESS_LOG.md`);
|
||||
the HUMAN-facing subset is exactly `OPERATOR_GUIDE.md`, `OPEN_ISSUES_FOR_TESTERS.txt`, and
|
||||
`dist/RELEASE_NOTES_*` — write for the right audience per file.
|
||||
|
||||
---
|
||||
|
||||
@@ -150,7 +162,9 @@ bt411/
|
||||
│ └── <topic>.md # one concept each
|
||||
├── reference/
|
||||
│ ├── glossary.yaml # terms/acronyms
|
||||
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth)
|
||||
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth; REBUILT 2026-08-06,
|
||||
│ │ # 93.5% coverage. archive_2025export/ = the old export, kept
|
||||
│ │ # ONLY so old `part_0NN.c:LINE` citations resolve -- cite @ADDR)
|
||||
│ └── ghidra_scripts/ # the exporters
|
||||
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
|
||||
├── phases/ # restructuring / investigation logs
|
||||
|
||||
@@ -164,3 +164,31 @@ pad X action LookBehind
|
||||
pad Start action ModeCycle
|
||||
pad RightThumb action DisplayCycle
|
||||
pad Back action Valve
|
||||
|
||||
# COOLANT FLUSH -- HELD key dumps reservoir coolant into the loops
|
||||
# (Reservoir InjectCoolant, msg 4). The tank drains while held; with the
|
||||
# tank empty the loops burn coolant with no refill -- run hot long enough
|
||||
# and the bank drops below 5%, which ARMS the eject (Panic lights).
|
||||
key G action Flush
|
||||
|
||||
# PANIC / EJECT -- the punch-out (Mech msg 0x19). Only fires on a CRIPPLED
|
||||
# mech (no generators, coolant leaked below 5%, or leg-gimped novice) -- a
|
||||
# healthy mech refuses the button, so a stray press is a no-op.
|
||||
key Back action Eject
|
||||
pad LeftThumb action Eject
|
||||
|
||||
# PILOT KEYPAD (the pod's MFD program keypad, KeyboardPilot unit). While
|
||||
# PANIC mode is armed, ANY pilot-keypad key fires the eject (the binary's
|
||||
# keyboardGroup[KeyboardPilot].Add(0x200000, mech, 0x19) -- its ONLY armed
|
||||
# eject binding); un-armed, the keys feed program entry. Desktop numpad =
|
||||
# that keypad.
|
||||
key NumPad0 keypad pilot 0
|
||||
key NumPad1 keypad pilot 1
|
||||
key NumPad2 keypad pilot 2
|
||||
key NumPad3 keypad pilot 3
|
||||
key NumPad4 keypad pilot 4
|
||||
key NumPad5 keypad pilot 5
|
||||
key NumPad6 keypad pilot 6
|
||||
key NumPad7 keypad pilot 7
|
||||
key NumPad8 keypad pilot 8
|
||||
key NumPad9 keypad pilot 9
|
||||
|
||||
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
|
||||
}
|
||||
|
||||
#=======================================================================
|
||||
# Sunder V1
|
||||
# Denkou (was: Sunder V1)
|
||||
#=======================================================================
|
||||
Snd1Init
|
||||
{
|
||||
|
||||
@@ -69,6 +69,23 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
|
||||
⚠ **Keep the `if /I "%%P"=="btl4.exe"` guard** — parsing tasklist with `tokens=2` alone reads its
|
||||
*"INFO: No tasks are running"* line as a PID and spins forever with nothing running. Full
|
||||
investigation, including which windows exit vs merely hide: `phases/phase-12-orphan-processes.md`.
|
||||
- **Field-log anatomy — reading a steam night (#157 forensics, 2026-08-10) [T2].** A night's log is
|
||||
a CHAIN of `===== BT411 SESSION` blocks: menu stubs (`args=''`, "first process") alternating with
|
||||
glass generations (`-net 1501 -platform glass`, "relaunched generation"); one glass block = one
|
||||
drop, and boundaries are synchronized across every machine to ±2 s (mission-clock expiry relaunches
|
||||
all peers' menus in the same second). **Stub anatomy:** the steam lobby is CLICK-driven
|
||||
(`steamAction`, btl4fe.cpp), so a menu closed without a click logs exactly **4 boot lines** and
|
||||
exits 0 — a 4-line stub at end-of-night is a player going to bed, NOT a crash (all four machines
|
||||
ended night-14 with identical 4-line stubs at 00:31:2x). 7–8-line stubs = a lobby cycle
|
||||
(`[steamnet] up` + `[lobby] join` + map). `BTCrashFilter` is armed before the first log line and
|
||||
writes a `[crash]` block + module-relative stack on ANY unhandled exception — a silent log end
|
||||
means quit/kill/hang, never an unlogged AV. **`lastrun_<stem>.txt` caveats:** appends stop silently
|
||||
if the file gets locked (the `is_open` guard skips) — a lastrun ending mid-night while the log
|
||||
continues means the file was captured/locked during live troubleshooting, not that the chain died.
|
||||
And the bat sign-off ("The game has exited. If it closed unexpectedly, send…") prints after ANY
|
||||
chain end including a normal quit — it primes players to report quits as crashes; #157 ("did not
|
||||
exit cleanly") was exactly this echo. The arcade-authentic round-end (mission process exits →
|
||||
menu relaunch) itself reads as a crash to first-night players.
|
||||
- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits
|
||||
next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412
|
||||
keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's
|
||||
@@ -182,6 +199,18 @@ default (`-DBT_STEAM=ON` is the documented dev-checkout state). `BT_EXPIRE=ON` (
|
||||
14-day tester window; an expire-OFF zip is renamed `-noexpire` and warns. Verify by extracting the
|
||||
zip somewhere clean and booting it with no repo present — that is what catches a missing runtime DLL.
|
||||
|
||||
## Build ritual — the stale-link flake (bit 4+ times; MANDATORY for bench work)
|
||||
MSBuild often does NOT relink `btl4.exe` when only static-lib members changed — the bench then
|
||||
runs a STALE exe and the session burns hours on phantom results. Ritual for every code-change build:
|
||||
1. `rm -f build/Release/btl4.exe` FIRST (force the relink);
|
||||
2. build; a REAL relink prints the 20 known-benign `CreateStreamedSubsystem` LNK2019s (`/FORCE`
|
||||
baseline — see the top of this file); an up-to-date run prints none;
|
||||
3. STRING-VERIFY before benching: `python -c "d=open(r'build/Release/btl4.exe','rb').read();
|
||||
print(b'<A-NEW-LOG-MARKER>' in d)"` — pick a string your change added (NB: release strings
|
||||
carry LOG TEXT, not function names — check the .map for symbols);
|
||||
4. use ABSOLUTE paths in every bench command — `cd` leaks between compound commands (the cwd
|
||||
trap) and has broken builds/appends/copies repeatedly.
|
||||
|
||||
## Key Relationships
|
||||
- Base: [[wintesla-port]] (the engine build recipe).
|
||||
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
|
||||
|
||||
+42
-2
@@ -17,8 +17,21 @@ Verified in-game on all 8 mechs (Thor vs gameplay footage). [T2]
|
||||
|
||||
- One per mech (12: AVX BLX FIX JAX LOX MAX OWX RAX SNX STX THX VUX; model→skeleton table in
|
||||
BTL4.RES @~3232850: `madcat=mad`, `blkhawk=blh`, … — the INSIDE skeleton is the X-variant, so
|
||||
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh; the inside view loads
|
||||
EXACTLY ONE segment mesh (the `_cop`) — 25 others hidden. [T2]
|
||||
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh. [T2]
|
||||
- **The inside view is a PER-MECH AUTHORED SET, not the cop alone** (#91 decode, 2026-08-04;
|
||||
the earlier "EXACTLY ONE segment mesh" claim was madcat-derived and over-generalized —
|
||||
corrected [T1, decomp-grounded: the binary's renderable build `FUN_004cef28`
|
||||
(part_014.c:5077) sets skeleton type 4 (=A) for the inside build and its per-segment loop
|
||||
calls `FUN_00424084(segment, type)` (:5570) for EVERY segment — NULL name = no mesh, no
|
||||
torso-only filter, no cop special-case (only `siteeyepoint` diverts to the eye ctor
|
||||
:5560-64); engine side: no type fallback in `EntitySegment::GetVideoObjectName`,
|
||||
SEGMENT.cpp:97 — so the binary drew every authored type-A mesh, own-body pieces included;
|
||||
T2: live `[view]` roster logs]). Authored type-A rosters: **madcat / vulture / bhk1 = cop only; sunder /
|
||||
loki / avatar = cop + `<pfx>_tor` torso piece; thor = cop + `thx_tor` + `thx_msl` (the
|
||||
shoulder LRM pod!); owens = cop + BOTH complete legs (`owx_{l,r}{ule,dle,bto,fto}`) +
|
||||
`own_tshd`** — the Owens pilot saw their own legs walking; the Thor pilot saw their
|
||||
missile pod edge bob at the lower frame. `BT_HIDE_INSIDE_SEG=<substr>` hides a non-cop
|
||||
type-A mesh by name (diag, btl4vid.cpp ApplyViewSkeleton).
|
||||
- Material: `<pfx>skin:blakskn_dz_{u,r,l}torso_mtl` — DIFFUSE/AMBIENT (0,0,0) + RAMP_REF
|
||||
`softer`, **NO texture** (BLXSKIN.BMF is 238 bytes total; byte-verified). The 56 nonzero-UV
|
||||
verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
|
||||
@@ -230,6 +243,33 @@ the shell green), `BT_COP_DUMP` (per-batch punch/bbox), `BT_EYE_FWD=<f>` (eye po
|
||||
- Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
|
||||
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting)
|
||||
|
||||
## The #91 "black rectangle" (thor) — own-body inside meshes + the blakskn rule [T2]
|
||||
|
||||
Three testers (Oracle + RajelAran night 7; Ronin 2026-08-03: "moves diagonally 10→4 with the
|
||||
footsteps, at rest sits ON the lower frame bar, right side, half-in") reported a black
|
||||
rectangle in the Thor cockpit. Decoded 2026-08-04:
|
||||
- The rectangle is the **thor's own `thx_msl` missile-pod mount plate** — authored, authentic
|
||||
own-body geometry (see the roster above; "Summoner" = the `thr1` FE label, same THX canopy,
|
||||
so the cross-reference isolated nothing). It rides the walk-animated shoulder while the eye
|
||||
rides the gyro springs → the diagonal footstep-rhythm sway. Pixel-tracked across walk
|
||||
captures: ~29px wide, x/y oscillating together along the 10↔4 axis, bottom center-right,
|
||||
alternating with the gait [T2, scratchpad/night11/thorrect.sh].
|
||||
- **What WAS wrong:** the pilot-facing surfaces of these own-body meshes are authored with the
|
||||
same `<pfx>skin:blakskn_dz_*` interior-structure material as the canopy frame (texture-less,
|
||||
black diffuse, `softer` ramp lo 0.25 → hi 0.99), but the unlit frame-constant treatment was
|
||||
keyed on the `_cop` FILENAME — so the identical material rendered (0.13,0.12,0.15) on the
|
||||
canopy and PURE (0,0,0) on the pod/legs (`[matlog]`-verified: owx_cop blakskn vcol=FF211F26
|
||||
vs owx_lule blakskn vcol=FF000000). Fixed in bgfload.cpp: the constant now keys on
|
||||
`meshIsCop || material contains "skin:blakskn_dz_"`. `mechfx:blakskn_mtl` (tshd shadow
|
||||
quads) deliberately does NOT match. Verified: zero pure-black pixels in the lower view band
|
||||
post-fix, tab renders in the frame tone and blends with the bar at rest; owens legs read as
|
||||
coherent dark structure; canopy/terrain un-regressed. The textured pod/leg batches
|
||||
(THX/GEN/OWEN.BSL pages — bright, mean texel 0.55-0.74) face outboard and were never the
|
||||
visible surfaces.
|
||||
- Diags from the dig: `BT_MAT_LOG=<mesh-stem substr>` (bgfload per-batch material routing:
|
||||
resolved colour/texture/ramp/vcol), `BT_HIDE_INSIDE_SEG=<substr>`, `[view]` per-segment
|
||||
inside-roster log. The `softer` ramp resolves lo=(0.25,0.25,0.25) hi=(0.99,0.99,0.99) [T1].
|
||||
|
||||
## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated]
|
||||
User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y)
|
||||
into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
|
||||
|
||||
+339
-10
@@ -102,6 +102,50 @@ the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSe
|
||||
`targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's
|
||||
whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed
|
||||
lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray.
|
||||
**COMPLETED 2026-08-03 (#124, the zone-walk matrix): the pick is TRIANGLE-ACCURATE** [T2 matrix].
|
||||
The sphere approximation measurably mis-picked (aim dead-on dtorso -> picked rgun/ruleg) —
|
||||
`MechSegmentPick` runs a sphere PRE-FILTER then Moller-Trumbore nearest-hit over the threaded
|
||||
segments' posed meshes (CPU triangle cache from each object's own BGF buffers, `BTGetPickMesh`).
|
||||
**⚠ CORRECTED 2026-08-04 (field pushback from era players — they were RIGHT): the 08-03 zone rule
|
||||
("the struck segment's SKL dzone, ALWAYS" -> every aimed torso hit = CENTER TORSO) was WRONG.**
|
||||
The authentic rule: **the pick's zone is the struck PATCH's authored `dz_*` tag** — the art
|
||||
zone-tags the hull PER PANEL (`MAD_TOR.BGF` carries `dz_utorso`×36 / `dz_ltorso`×18 /
|
||||
`dz_rtorso`×18 / `dz_dtorso`×16 + all four rear panels + `dz_searchlight` across its patches
|
||||
[T1 bytes]); the dpl hit result kept GEOGROUP granularity (`dplHitGeoGroup` in the renderer's
|
||||
result block [T0]); and the binary's segment->zone map `@49db20` has NO runtime caller (sole
|
||||
caller = `CreateStreamedDamageZone`, load-time — raw call-scan [T1]), so no segment-level
|
||||
collapse mechanism even exists. Aimed fire in the pod stripped the exact hull panel under the
|
||||
reticle. The segment-level model looked airtight because LIMBS agree at both granularities (one
|
||||
zone per limb mesh) — the collapse only bit the hull, and Oracle's night-10 audit ("only LCT gets
|
||||
hits") was the BUG's fingerprint, not the pod's design. Port: `MechSegmentPick` attributes the
|
||||
struck triangle to its draw op (index-range) and takes the op's `.DZM`-bound zone (the #87
|
||||
armour-darkening bindings — the same authored patch->zone mapping that already paints those
|
||||
panels), segment dzone as the untagged-patch fallback; `ZoneAimPoint` aims hull zones at their
|
||||
patch CENTROIDS so the zone walker can exercise per-panel. Bench: [[test-harness]] §zone-walk
|
||||
(madcat variant, scratchpad/night11/zonewalk_madcat.sh).
|
||||
**MISS-MEANS-MISS (#131, 2026-08-05) [T2 both-direction bench]:** the pick answers ONLY for
|
||||
drawn geometry -- the any-object SPHERE fallback and the caller's whole-mech AABB fallback are
|
||||
gone (sphere only for a mesh the reader cannot parse -- none exist, counters clean; AABB only as
|
||||
the pre-tree replicant grace, `BTMechSegmentPick` returns 1/0/-1). They were port stand-ins the
|
||||
1995 card never had, and their signature regime was a LEVEL boresight over a SHORT mech: the
|
||||
blackhawk's mesh tops out below eye-ray height, so rays cleared every triangle while threading
|
||||
the fat cull spheres -- lock ring lit with the reticle above its head (night-12 field report,
|
||||
operator-witnessed on the sweep bench). Instruments: `BT_LOCK_SWEEP` (torso-pan lock envelope,
|
||||
operator-watchable), `BT_LOCK_ENVELOPE` (synthetic unit sweep), `[picksrc]`/`[pickbox]` source
|
||||
telemetry + objs/invFail/noTri localization. Locking is now strictly TIGHTER than 716-774; if
|
||||
era testers report the pods were more forgiving, Draco's "slight lock linger" memory is the
|
||||
sourced follow-up (tracked on #131).
|
||||
**TWIST-SIGN VERIFIED (2026-08-04) [T2 live]:** the frame adapter's inferred angular flip
|
||||
(`SelectSlice: theta -= TorsoHeading()`, vs the binary's `+=` before the z-reflection) is
|
||||
CORRECT — bench: target torso pinned 0/+140°/−140° (`BT_FORCE_TWIST`, sim-level
|
||||
`analogTwistAxis` hold in torso.cpp — the input-level pin is DEAD, live input rides the
|
||||
CONTROLS.MAP device push and Basic mode auto-centers), LRM salvos (missiles = the authentic
|
||||
cylinder consumers; the binary DROPPED zone-−1 beam damage). Slice picks track the
|
||||
physically-facing flank in both twist directions (positive twist = `horizontalLimitLeft` =
|
||||
CCW), deterministic; the wrong sign is cleanly excluded (would pick slice 7 where slice 1 is
|
||||
observed). Diag: `[slice]` log (live twist, thetaIn/thetaAdj, wedge index) + zone NAMES on
|
||||
`[dmgresolve]` (both under `BT_DMGTABLE_LOG`); bench `scratchpad/night11/twistsign.sh`.
|
||||
|
||||
**IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.**
|
||||
`BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object +
|
||||
`GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by
|
||||
@@ -155,6 +199,14 @@ Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the
|
||||
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
|
||||
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
|
||||
dist < (1 − hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
|
||||
⚠ `weaponRange` is **fire-control data only** (#168 audit [T1]): projectile FLIGHT is never
|
||||
range-capped — a plain shell (AFC) lives exactly 5.0 s (@4bddec) and a Missile lives
|
||||
BurnTime+10 s with a y<−1 kill-plane (@4bef78); FireWeapon @4bcc60 has no range gate, so an
|
||||
AFC50 (authored range 750) landing hits at 2000+ u is AUTHENTIC arcade behavior. Constants +
|
||||
the whole flight/expiry chain: [[decomp-reference]] §5 "Projectile/Missile flight & expiry".
|
||||
The same audit killed the pool's stale-point auto-hit (unguided contact was tested against
|
||||
the fire-time pick, so a locked AC shell could not miss a moving target — fixed 2026-08-11:
|
||||
the contact test now tracks the target's live position on every round kind).
|
||||
⚠ **`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT
|
||||
heatLoad** — the port originally computed `effectiveRange = (1 − heatLoad) × weaponRange`
|
||||
(mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in
|
||||
@@ -234,7 +286,7 @@ no-arc). The old hardwired lock, the ±30°-default cone, and the projectile pat
|
||||
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
|
||||
bug was fixed en route: every
|
||||
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
|
||||
the chosen view — the aim camera feed and the V toggle both depend on it).
|
||||
the chosen view — the aim camera feed and the view toggle (backtick since task #68 took 'V' for rear-view) both depend on it).
|
||||
|
||||
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
|
||||
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
|
||||
@@ -324,6 +376,34 @@ per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) a
|
||||
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
|
||||
offsets). [T2]
|
||||
|
||||
## "Mechs die with lightly damaged armor" — AUDITED, no port bug: it's AMMO COOK-OFF (2026-08-02) [T1 chain, T2 measured]
|
||||
Field complaint (night 9): mechs die while their panels read nearly pristine, so "the scaling
|
||||
must be off". A byte-level audit of the ENTIRE damage chain found **every link faithful**:
|
||||
zone stream reads (arcade base ctor `@0041df5c` == engine DAMAGE.cpp, incl. the JointedMover
|
||||
skip-5 branch); the BT ctor normalization loop (`@0049ce50` constants 1.0/1e-4/0.5 — and in
|
||||
SHIPPED CONTENT the raw cells are EXACTLY `1/armorPts` uniform across all 5 types, so the
|
||||
normalize is a no-op); `TakeDamage @0041e4e0` formula+clamps; the override `@0049c690`
|
||||
(redirect gate, vital/leg thresholds 1.0/0.5/0.0 `@0049c99c..a4`); `CriticalHit @0049ccc4`
|
||||
(fraction 0.5 `@0049ce48`, cap 1.0 `@0049ce4c`, the per-subsystem `applied<damagePercentage`
|
||||
cap); the cook-off chain (`stamp @004bc3fc` per-SALVO before the missile divide, `CookOff
|
||||
@004bd300` = ammoCount×amount, `DistributeCriticalHit @004ac274` = ÷(zones plugging the bin)
|
||||
then one DESIGNATED-zone TakeDamageMessage each).
|
||||
**The measured kill route** (death-kinetics bench, 7 deaths): 6 of 7 were **bay-fire
|
||||
detonations** — e.g. `AmmoBinLRM15 10 rounds × 50 = 500 (burst 1)` into the bin's ONE authored
|
||||
carrier zone (madcat bins are single-zone: LRM15_1=roster[26]→dz_ltorso, LRM15_2=[28]→dz_rtorso,
|
||||
AFC100=[21]→{larm,lgun}), which is VITAL (madcat authors **15 of 22 zones vital** — all torsos,
|
||||
legs, feet) → one application 0→1.0 → instant death, every other panel bright. The ÷1 is the
|
||||
CONTENT's authoring, not a port shortcut — the arithmetic is the binary's own. The one mech that
|
||||
died conventionally (300 applications) ended VISIBLY charred: 0.97/0.93/0.92/0.85/0.79 across 13
|
||||
zones — mid-fight darkening DOES accumulate when mechs live long enough.
|
||||
**Field logs confirm dominance**: Rajel's night-9 log alone has 12 bay-fire events (LRM10
|
||||
455-490 pts); ConnMan 10; the "salvo barely dented my armor" reporter was dying to their OWN
|
||||
cook-offs. **The real lever is HEAT** (bay fire arms at the bin heat-watcher's FAILURE level;
|
||||
the [[subsystems]] heat/#96 too-hot economy arms it constantly) plus player training: EJECT
|
||||
**hold** purges the burning bay (`DumpAmmo` → "bay fire EXTINGUISHED"); pod players were
|
||||
manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
|
||||
suppress the whole path ([[experience-levels]]).
|
||||
|
||||
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
||||
Three-layer story, measured live + decomp-verified:
|
||||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
||||
@@ -497,7 +577,9 @@ percent tables, MadCat) — see #73 on the tracker.
|
||||
## layers are now fixed and live-verified
|
||||
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the
|
||||
Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink,
|
||||
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState, 0x19 EjectPilot @0x49f854,
|
||||
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState (⚠ label/body mismatch:
|
||||
the 0x17 handler @0x49f674 randomizes position + graphicAlarm(2) + re-enables sim — not burning;
|
||||
possibly a shifted name row, cf. the factory ClassID mislabels — open-questions), 0x19 EjectPilot @0x49f854,
|
||||
0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**:
|
||||
`p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice
|
||||
never crits), rolled per BURST on the current zone (skip if the zone is already burning). The
|
||||
@@ -510,12 +592,121 @@ entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsyste
|
||||
@0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
|
||||
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel).
|
||||
Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1`
|
||||
— a full chain crit destroying a subsystem. Still deferred [T4→documented]: the `damageType==0`
|
||||
COLLISION divert (@0x4a0368 → `0x49ffcc`, its own distribution) and the **id-0x16 damage/kill
|
||||
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45:
|
||||
`{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly
|
||||
disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` +
|
||||
`[critroll]`), `BT_DMG_LOG`.
|
||||
— a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368 →
|
||||
`0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
|
||||
`BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
|
||||
|
||||
## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
|
||||
The rest of `Mech::TakeDamageMessageHandler` (@0x4a02f4-0x4a0890, dark-gap raw disasm) is now
|
||||
reconstructed: `BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), called from the
|
||||
handler at the binary's exact positions (mech.cpp). Retires `BTPostDamageScore`/`BTPostKillScore`.
|
||||
|
||||
**Entry sequence** [T1]: gyro bounce → death-edge latch `[ebp-0x10]` (was-destroyed at ENTRY —
|
||||
before the divert!) → `lastInflictingID` stamp (mech+0x43c ← msg inflictor) → shooter resolve
|
||||
(registry find → `shooter+0x190` = shooter's PLAYER; victim's = `mech+0x190`) → **collision divert
|
||||
JUMPS TO THE DEATH TAIL** (@0x4a0375 `jmp 0x4a07b5`, NOT a return — a wall/fall death still posts
|
||||
VehicleDead + blasts, but skips the score blocks: collision deaths credit no one). Our old early
|
||||
`return` here was a latent "wall-death strands the pilot" hazard, fixed with the move.
|
||||
|
||||
**The three id-0x16 reports** (all `Player::ScoreMessageID` 0x16, sizeof 0x3C — layout in
|
||||
btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
|
||||
- **A @0x4a04da, newly destroyed** → type 2 KillScore to the SHOOTER's player. Basis (+0x24) =
|
||||
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
|
||||
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
|
||||
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
|
||||
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED
|
||||
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY
|
||||
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into
|
||||
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry:
|
||||
dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the
|
||||
strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides
|
||||
**`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base
|
||||
dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in
|
||||
its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes
|
||||
`CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`.
|
||||
`ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees
|
||||
type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART
|
||||
(`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each
|
||||
self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored;
|
||||
benched 83 inflicted rows, awards 0.98–25.00, zero type-0 Verify rejections. [T1]
|
||||
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
|
||||
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
|
||||
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
|
||||
(gated `GetConsoleHost()` && !`suppressConsole` — which is why that field is named that).
|
||||
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
|
||||
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
|
||||
|
||||
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted
|
||||
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed
|
||||
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that
|
||||
followed from the dead-code misreading corrected in report B above, and retiring the credit IS the
|
||||
scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`)
|
||||
is the cross-check for every row and should be consulted before touching this path again:
|
||||
|
||||
| Points | Event |
|
||||
|---|---|
|
||||
| +1000 | Starting the game |
|
||||
| +1 | Each damage point scored on opponent's armor |
|
||||
| +10..+30 | Destroying an opponent's internal system |
|
||||
| +500 | Destroying an opponent's 'Mech |
|
||||
| −1 | Each self-inflicted point of armor damage |
|
||||
| −10..−30 | Knocking out one of your own internal systems |
|
||||
| −500 | Destroying your own 'Mech by an ammo explosion |
|
||||
| −1000 | Destroying your own 'Mech by ejecting |
|
||||
|
||||
⚠ **THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The
|
||||
binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL`
|
||||
and then does `param_1[0x9e] = 0` — **ungated**. So `+0x278` is a *console DELTA*, never a running
|
||||
total, and it does not matter which NODE computed a delta: every node's contribution is flushed
|
||||
stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly
|
||||
where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it.
|
||||
|
||||
**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge),
|
||||
`Player::CalcRanking()` and the replicated `Player__UpdateRecord` all read `+0x278` **on the owning
|
||||
node**. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the
|
||||
SHOOTER's player object there — a **REPLICANT** — and the credit is banked on the wrong machine,
|
||||
where the master's next update record overwrites it (benched: totals climb to ~35, snap back every
|
||||
few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0
|
||||
interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension
|
||||
fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base
|
||||
`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already
|
||||
credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the
|
||||
victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report
|
||||
does — do not ship the basis and recompute where it cannot be seen.
|
||||
|
||||
**The damage-bias term is VESTIGIAL — do not "finish" it** [T1, audit 2026-08-08]. The kill/inflicted
|
||||
formula's `(victimAvgZoneDamage@0x354 × damageBias + 1.0)` factor is **always 1.0** in the shipped
|
||||
binary. `mech+0x354` has exactly one writer — `Mech::Reset` (@0049fb74, part_012.c:14340), which
|
||||
computes `mean(zone+0x158)` across every damage zone *after* the zone heal has zeroed those cells —
|
||||
and exactly one reader, `CalcInflictedScore` (@004c052c). Nothing recomputes it during play, so it
|
||||
holds ~0 for the mech's whole life. `0x358`/`0x35c` are the same computation over subsystem zones
|
||||
and have **no reader at all**. The port's `MECH_DAMAGE_BIAS(m) → 0.0f` therefore reproduces the
|
||||
binary exactly; wiring it to live damage would look like completing a stub and would silently
|
||||
inflate every award (the chart-verified +1/point and +500/kill both assume 1.0).
|
||||
|
||||
⚠ **Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]:
|
||||
(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at
|
||||
game start** has no known implementation; (c) **−1000 eject / −500 ammo** would live in
|
||||
`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads
|
||||
(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors
|
||||
nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by
|
||||
hard-coding chart numbers; find where the binary sources them.
|
||||
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
|
||||
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
|
||||
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
|
||||
`freeForAll@0x250==0`); self-kill negates the whole award.
|
||||
|
||||
**The death tail** (@0x4a07b5, shared by the divert): gate = was-alive-at-entry && movementMode
|
||||
9|10 → **`Player::VehicleDeadMessage` id 0x17 to the mech's OWN player** — the respawn trigger,
|
||||
in the BT 0x38-byte extension `{deathCount -1, dropZone Null, +0x2c killed-by PLAYER EntityID,
|
||||
+0x34 kill zone}` (bench: `killedBy=2:1 zone=3` cross-node) — then the death Explosion (id 3,
|
||||
0x5C, model 0x31; port fires the authored death list from the transition instead, #42) and the
|
||||
#89 SplashDamage (re-verified after the reorder: 72 bursts at ~9u). The old mech4
|
||||
death-transition dispatch site (which was flagged [T3 sender-undecoded]) is retired — this IS
|
||||
that sender. The #55 NULL-playerLink fallback + DEAD_NOTIFY forensics moved into the bridge.
|
||||
Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 award=4.88`,
|
||||
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
|
||||
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
|
||||
|
||||
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
|
||||
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
|
||||
@@ -833,7 +1024,9 @@ stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the la
|
||||
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
|
||||
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
|
||||
|
||||
## Kill-score damage (task #60)
|
||||
## Kill-score damage (task #60) — ⚠ SUPERSEDED 2026-08-05 by §THE AUTHENTIC SCORE/DEATH REPORT
|
||||
## TAIL above (`BTPostKillScore` is RETIRED; the kill basis is now the applied damage TALLY +
|
||||
## the victim role's killBonus, posted by the victim's own TakeDamage handler). History below.
|
||||
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
|
||||
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
|
||||
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
|
||||
@@ -932,7 +1125,9 @@ player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the
|
||||
elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)`
|
||||
sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative
|
||||
`collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility =
|
||||
IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
|
||||
IsDerivedFrom HeatSinkBANK/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08 -- ⚠ 0x50e590
|
||||
is the AGGREGATE BANK's node, NOT the HeatSink base family: the family-wide reading was the
|
||||
#128 instant-leak bug, corrected 2026-08-11, full record docs/RAM_LEAK_AUDIT_20260811.md; all
|
||||
MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c —
|
||||
the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere
|
||||
(weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the
|
||||
@@ -965,6 +1160,60 @@ returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off
|
||||
arms left firing gun pods across every chassis (Conn Man's three-chassis audit).
|
||||
See reconstruction-gotchas #25.
|
||||
|
||||
## Zone-LEVEL replication -- the observer's picture (#87 root cause, 2026-08-03) [T1 decomp / T2 verified]
|
||||
|
||||
How an OBSERVER (replicant holder) learns a mech's zone damageLevels, end to end:
|
||||
|
||||
1. Damage messages do NOT echo locally: `Entity::Dispatch` on a REPLICANT
|
||||
forwards to the master's node and returns (ENTITY.cpp:244) -- there is no
|
||||
broadcast-bus application. The MASTER alone consumes `TakeDamage`.
|
||||
2. The master ships levels via zone UPDATE RECORDS
|
||||
(`DamageZone::Write/ReadUpdateRecord`, `DamageZoneUpdateModelBit`), gated by
|
||||
`ForceUpdate(DamageZoneUpdateModelFlag)`.
|
||||
3. **Who raises the flag (the binary's effect watcher `FUN_0042aa2c @0042aa2c`,
|
||||
the BT analog of the engine's `EntityEffectWatcher` -- which itself never
|
||||
runs for mechs: mech zones carry BAND DESCRIPTORS, the engine `ExplosionTable`
|
||||
stays NULL and `EntityEffectWatcher` is constructed nowhere in the image):**
|
||||
* level branch (zone changedFlags & 4): if the rise CROSSES a band-descriptor
|
||||
threshold (`FUN_0042a5f4` = `Mech__DamageZone::DescriptorCrossed`) AND the
|
||||
entity is a MASTER (`(entity+0x28 & 0xc) == 0`) -> `*(entity+0x18) |= 2`
|
||||
(= ForceUpdate(DamageZoneUpdateModelFlag)).
|
||||
* gstate branch (changedFlags & 8): ForceUpdate unconditionally (masters).
|
||||
So observers track a fight at **band-crossing granularity** -- not per hit.
|
||||
4. Consumption on the observer is passive: `ReadUpdateRecord` writes the level,
|
||||
the armour watcher (`TickArmourDamage`) re-reads zones every frame and pushes
|
||||
the draw-op tint; the doll gauges read `damageLevel*100` continuously.
|
||||
|
||||
**The port had dropped the level branch** -- mechdmg.cpp's band hub ForceUpdated
|
||||
only on `graphicState != Exists`, so every observer's copy sat at 0.0 until a
|
||||
zone DESTRUCTED: no enemy hull darkening, no observer-side doll movement, ever
|
||||
(the "no armour discoloration" night-10 report). Restored 2026-08-03
|
||||
(mechdmg.cpp band loop: `DescriptorCrossed(prev, level)` + master gate on both
|
||||
branches). Verified 2-node: A-side watcher pushed 114 level changes; every
|
||||
replicant peak matched the master's final level to 4 decimals (dtorso 0.9321
|
||||
both sides).
|
||||
|
||||
Render-path note (gotcha #23 discharged): the tint itself was pixel-proven with
|
||||
`BT_ARMOR_FORCE` A/B captures -- all-zones 1.0 renders the hull charcoal (0.1x
|
||||
floor), forced 0.4 reads visibly grey at close range on a light skin, so "no
|
||||
discoloration" was never the renderer. Solo/master-side perception is the
|
||||
authentic ECONOMY: 2-25 pt laser events vs 68-185 pt zone pools. The eye
|
||||
catches SNAPS, not creep: a single heavy hit (ERLG large laser / missile
|
||||
class, amt=25) jumps a 77-pt arm 0 -> 0.3247 in one frame (measured live) =
|
||||
the pod veterans' "a single missile leaves visible armor damage"; 2-pt ERS
|
||||
fire creeps ~1.6%/hit and stays visually silent for a dozen hits (the
|
||||
"legs never show" reports -- their levels peak ~0.08-0.13). The cockpit doll
|
||||
maps all 28 zones (L4GAUGE.CFG:4788-4814 cmArmor lines -- feet + lower legs
|
||||
included); nothing is unwired (orphan audit 2026-08-03: every drawn op with a
|
||||
material name is claimed by a zone).
|
||||
|
||||
Fidelity boundary [T1]: the HOST binary's response is LINEAR (watcher
|
||||
@004573e4/@00457784: 13 material floats lerped toward x0.1 by level, then
|
||||
"flush_material" to the division card). The CARD-side material->ramp->texel
|
||||
curve is i860 firmware absent from BTL4OPT.EXE -- see [[open-questions]]
|
||||
"i860 DIVISION-CARD firmware". If pod darkening was perceptually steeper at
|
||||
mid levels, that steepness lived there.
|
||||
|
||||
## The hit-location CYLINDER -- raw-bytes audit + chassis->table map (2026-08-02) [T1]
|
||||
|
||||
Full independent verification of the type-29 DamageLookupTable streams, parsed
|
||||
@@ -984,6 +1233,22 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
"Right*", W1/W2 = "Front*" -- with theta = atan2(z,x) from +X toward +Z, the
|
||||
mech-local FRONT is the **+Z arc (theta 45-135 deg)** and dead-ahead is the
|
||||
W1|W2 SEAM. (Corrects the earlier #92 comment that put frontal hits in W6.)
|
||||
* **⚠ PORT FRAME ADAPTER (#124, fixed 2026-08-03; corrected same day by the
|
||||
MUZZLE-ANCHOR probe)**: that +Z-forward (and +X-right, from the W0/W7
|
||||
"Right*" names) is the **1995 resolver frame**. OUR engine frame, measured
|
||||
with the mech's own asymmetric geometry as the anchor (`BT_ASPECT_TEST`:
|
||||
four world-cardinal self-impacts + one at each weapon's physical muzzle):
|
||||
**RIGHT = +X, same as 1995; FORWARD = −Z, flipped** — the LEFT missile pod
|
||||
sits at raw local x=−2.32, the RIGHT at +2.32, and `WorldToLocal` is a clean
|
||||
R(yaw) with no hidden terms (verified at yaws −30°/150°/40°). The frames
|
||||
differ by a **Z-NEGATION ONLY — a reflection** (the first commit's π
|
||||
rotation CROSSED the pods: the left muzzle resolved rtorso). `ResolveHit`
|
||||
negates local z; and because a reflection reverses angular direction, the
|
||||
torso-twist term in `SelectSlice` enters with the OPPOSITE sign (twist was 0
|
||||
in every probe — the twisted-torso verify is the tracked follow-up).
|
||||
Anchor acceptance: left pod→ltorso, right pod→right wedges, left/right arm
|
||||
muzzles→larm/rarm, nose→front cells, tail→rear cells, at a third yaw. The
|
||||
separate "one band LOW" Y-signature is #16 (pick-ray parallax), not this.
|
||||
Frontal unaimed foot-band hits therefore straddle the RightFoot/LeftFoot
|
||||
cells by impact-x sign, and the authored cross-bleed (20% opposite foot,
|
||||
10+10% opposite leg) is a data-true source of "hit the opposite leg" reports.
|
||||
@@ -996,6 +1261,70 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
|
||||
call 0x49ffcc; jmp out) [T1 disasm].
|
||||
|
||||
## The death transition is SYNCHRONOUS within the damage pass (2026-08-11) [T1/T2]
|
||||
The binary writes movementMode 9 INSIDE the lethal TakeDamage pass — its own death-tail gate
|
||||
(part_012.c:14761) reads 9||10 already flipped in the same call. The port deferred the write to
|
||||
the per-frame UpdateDeathState and a same-frame direct+splash lethal pair re-ran the whole death
|
||||
block (duplicate kill credit + duplicate VehicleDead) — caught by the 4-node bench, fixed by
|
||||
running the once-per-death transition inline at the death tail (1b6d247). Full class write-up:
|
||||
[[reconstruction-gotchas]] §25; stress verification: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26,
|
||||
SWALLOWED=0 both. The receiver-side dedup remains a TRIPWIRE only (1995 has none; +0x290 never
|
||||
written) — if it ever fires again, a new duplicate source exists upstream.
|
||||
|
||||
## Key Relationships
|
||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
||||
|
||||
|
||||
## The K/D-score AUTHORITY MODEL -- settled once and for all (2026-08-11, #162) [T1]
|
||||
|
||||
The 1995 chain has ONE emitter and NO receiver dedup; every counter is single-owner.
|
||||
Fixed twice piecemeal (#150 kills, #162 deaths) before the full audit settled it -- do
|
||||
not touch any single limb of this chain again without re-reading this section.
|
||||
|
||||
**Emitter (Mech::TakeDamageMessageHandler @0x4a0230 -- the victim's MASTER only;
|
||||
replicant-asserted part_012.c:14613):** entry latch `local_14` = destroyed-at-entry
|
||||
(modes 2|9, @0x49fb54). Three id-0x16 reports: **A** type-2 KILL -> shooter's player,
|
||||
alive->dead edge only, exclusive with B (basis = victim role's killBonus, @0x4a0506);
|
||||
**B** type-0 Inflicted -> shooter, non-lethal AND corpse hits (tally != 0); **C** type-1
|
||||
Received -> victim's own player, latch-INDEPENDENT (corpse hits included; basis =
|
||||
intended damage = bursts x amount). Death tail (gate `local_14==0 && mode 9|10`,
|
||||
@0x4a07b5): exactly ONE **VehicleDead** (id 0x17, deathCount=-1, killer player id) ->
|
||||
victim's own player, + explosion + delayed burn + splash (fresh TakeDamages, gate
|
||||
advancedDamageOn && !suppressConsole) + ForceUpdate. Collision (type-0 damage) diverts
|
||||
past ALL reports straight to the tail: a ram death counts + costs but credits no one.
|
||||
|
||||
**Consumer (BTPlayer):** 0x16 type-2 @0x4c02e4: CalcKillScore, killCount(+0x27c)++ dual
|
||||
(the phantom wrong-column partner -- keep byte-faithful), suicide fchs negation skips
|
||||
both increments. 0x17 @0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ then STRAIGHT-LINE
|
||||
the -500 cost (gate advancedDamageOn(+0x264) ONLY; -role->specialCaseDeathPenalty
|
||||
(+0x20) via DIRECT Player::ScoreMessageHandler base call -- never on the wire, never in
|
||||
a matchlog; the [deathcost] DEBUG line is its only receipt). **The receiver dedup gate
|
||||
+0x290 is NEVER written non-zero anywhere in BTL4OPT.EXE [T1 image byte-scan]** -- the
|
||||
binary's single-increment guarantee is purely the SENDER's one-shot edge. Its clear
|
||||
pair (+0x290=0, +0x258=0) lives in the DropZoneReply tail (FUN_004c012c,
|
||||
part_013.c:10519) -- the #81-era port put it in the VehicleDead arm by misattribution,
|
||||
disarming the port's defensive latch (corrected 2026-08-11).
|
||||
|
||||
**Replication:** the player update record carries currentScore(+0x1c8) +
|
||||
dropZoneLocation ONLY. killCount/deaths never cross the wire in 1995; each node's
|
||||
scoreboard columns are locally accumulated (the port's SBMIRROR is a deliberate
|
||||
deviation that makes boards consistent).
|
||||
|
||||
**Full evidence record** (every dispatch/arm/layout with decomp line cites, the field
|
||||
receipt A/B, and the fix archaeology): `docs/KD_CHAIN_AUDIT_20260811.md`.
|
||||
|
||||
**Port rules that keep it correct:** (0) **the entry latch is MODE-based (movementMode
|
||||
2||9 == FUN_0049fb54), NEVER the alarm-based IsMechDestroyed()** -- EjectPilot raises
|
||||
graphicAlarm to 10 BEFORE its killBonus self-damage, so an alarm-based latch reads a real
|
||||
punch-out as "already destroyed" and silently eats the suicide type-2 (the -499 arithmetic)
|
||||
while cook-off suicides still score; caught by the eject canary 2026-08-11, the same
|
||||
alarm-vs-mode trap #108 fixed for deathBlastArmed; (1) the report block AND the death tail
|
||||
are both master-gated + entry-latched (mech.cpp) -- replicant-side damage application is a
|
||||
visual-only deviation and must never emit; (2) advancedDamageOn comes from the mission
|
||||
egg per-pilot page -- every stock egg stamps `advancedDamage=1` on every page, and the
|
||||
FE egg writer now stamps the host's menu choice mission-wide (a memset-zero pilot
|
||||
record shipped penalty-off for entire steam nights, #162); (3) the bench for ANY change
|
||||
here is scratchpad/night15/kd_bench.sh -- five HARD invariants (deaths==increments,
|
||||
cost-per-death, credit-per-kill, zero swallowed, arithmetic witness); its predecessor's
|
||||
assertions were greps that matched nothing, which is how both halves shipped broken.
|
||||
|
||||
+313
-3
@@ -176,6 +176,22 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
||||
|
||||
---
|
||||
|
||||
### Master posture / crouch block (FUN_004a9b5c region, raw disasm 2026-08-05/06) [T1]
|
||||
- mech+0x3f8 `mapPosture` (0 none / 1 may-duck / 2 holding-squat) — selector @0x4a9f61-0x4aa007:
|
||||
gates = movementMode normal, simLive (player+0x25c, NOVICE lockout), leg alarm 0/4 → 1, leg
|
||||
alarm 1 → 2, myomer factor |x|>1e-4 (const @0x4ab16c).
|
||||
- mech+0x79c `myomerEffectiveness` — MAX over the myomer chain's speedEffect@0x31C, published by
|
||||
the mapper drive-scale block (mechmppr.cpp:990 == @0x4a9cf2-0x4a9da4); scales speedDemand,
|
||||
zeroes turnDemand at ~0 (the seek-4 freeze), and gates the crouch.
|
||||
- DuckRequest consumer @0x4aa011-0x4aa0af: duckState && squatCapable → SetLegAnimation(2 'sqd')
|
||||
/ (3 'squ'), ForceUpdate(8)+(1) (type-3 record ships legState → peers pose it), stability
|
||||
alarm 0/1, request consumed. Clips: animationClips[2]=sqd(i), [3]=squ(i) (BTL4.RES, all 8
|
||||
chassis; loader slots byte-verified vs @0x50d9c8 suffix pool).
|
||||
- Myomers cluster: Performance wrapper @004b8b9c → drive-heat integrator @004b8be3;
|
||||
AvailableOutput @004b8ac0 = gear clamp × QUADRATIC heat degrade (+0x114 vs +0x118/+0x11c band)
|
||||
× (1 − legZoneDamage); speedEffect = output / ownerBaseSpeed(+0x34C) — unclamped (gear-4
|
||||
supercharge ≈1.43).
|
||||
|
||||
## 4. Damage delivery
|
||||
|
||||
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch(&msg)`.
|
||||
@@ -183,6 +199,7 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
||||
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
|
||||
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
|
||||
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
|
||||
- **BT effect watcher (zone replication + band effects)** [T1, 2026-08-03]: ctor `FUN_0042a984 @0042a984` (hooks entity+0xbc, allocs oldLevel[damageZoneCount@+0x11c]); Execute `FUN_0042aa2c @0042aa2c` — per zone: flag&4 → `FUN_0042a664` DescriptorForLevel + `FUN_0042a5f4` DescriptorCrossed(old,new) → **crossed && master (`(entity+0x28 & 0xc)==0`) → `*(ushort*)(entity+0x18) |= 2`** (= `ForceUpdate(DamageZoneUpdateModelFlag)`, the zone-record send); flag&8 → `FUN_0042a6c4` DescriptorForGraphicState + the same master-gated ForceUpdate; descriptor → effect via `FUN_0043663c/FUN_004364e4` (renderer mgr @`DAT_004efc94+0x38`); changedFlags reset when (master && !pending-update) or replicant. Zone band table @ zone+0xd4; level @+0x158; gstate @+0x78. Engine `EntityEffectWatcher`/`ExplosionTable` are DEAD for mechs (table NULL, watcher never constructed — band descriptors replace them). Port: mechdmg.cpp band hub. See [[combat-damage]] §Zone-LEVEL replication.
|
||||
|
||||
---
|
||||
|
||||
@@ -236,6 +253,34 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
@004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30
|
||||
(generator client capacity @0x1e4/count @0x1e8).
|
||||
|
||||
### Projectile/Missile flight & expiry (#168 audit, 2026-08-11) [T1]
|
||||
- **No range cap exists anywhere in the flight chain.** WeaponRange feeds fire-control only
|
||||
(effectiveRange @0x328 = (1−hostZoneDamage)×WeaponRange — targeting/reticle "in range" call).
|
||||
FireWeapon @4bcc60 reads no +0x328: the trigger fires at any target distance.
|
||||
- **Plain Projectile (AFC shell) live integrator @4bddec** (master); expiry: age since
|
||||
spawn(+0x19c) > `_DAT_004be054` = **5.0 s** → FUN_0042061c kill. Dead-reckon divergence²
|
||||
vs predicted pos(+0x260, predictor FUN_00422060 via +0x254) > `_DAT_004be050` = 0.1 →
|
||||
updateModel(+0x18)|=1 (ForceUpdate, replication-dirty — NOT a kill).
|
||||
- **Missile live integrator @4bef78** (master); expiry: age > burnTime(+0x340, model rec
|
||||
+0x44) + `_DAT_004bf5ac` = **10.0 s** coast margin, OR pos.y < `_DAT_004bf5b0` = **−1.0**
|
||||
kill-plane → FUN_0042061c. Keepalive: no update for `_DAT_004bf594`=2.0 s → dirty bit.
|
||||
**Proximity fuse**: seeker rangeToTarget(+0x10C) < `_DAT_004bf5a4` = **4.0** → detonate on
|
||||
targetEntity without a geometry hit. Seeker drops a destroyed target (movementMode 2|9,
|
||||
FUN_0049fb54).
|
||||
- **Performance split** (Projectile ctor @4be1bc / Missile ctor @4bf5b4): instance
|
||||
(flags&0xC)==ReplicantInstance(4) → FUN_004221c0 dead-reckoning smoother (no physics);
|
||||
master → 4bddec/4bef78. (CLASSMAP's old authoritative/ghost labels were swapped —
|
||||
corrected 2026-08-11.)
|
||||
- **FUN_0042061c = Entity kill**: removes from world list + simulationFlags(+0x28) |= 0x2000|2.
|
||||
- All tick→seconds via `DAT_0052140c` = 28.0 (the frame-rate global).
|
||||
- Port consequences (both fixed in mech4.cpp, 2026-08-11): (a) the pool's old
|
||||
`p.range = len×1.3+60` distance-to-target cap was an invention — replaced by the
|
||||
authentic time caps; (b) the pool's contact sphere tested UNGUIDED rounds against
|
||||
the FIRE-TIME pick point and dispatched damage to the entity wherever it moved —
|
||||
a locked AC/Gauss could not miss a moving target (bench: 86/86 "hits" vs a
|
||||
circling mech). The pool now tracks the target's live position for the contact
|
||||
test on every round kind; only guided rounds steer.
|
||||
|
||||
---
|
||||
|
||||
### The CONFIG-MODE session chain (task #6) [T1]
|
||||
@@ -246,9 +291,59 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
|
||||
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
|
||||
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
|
||||
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674, 0x18
|
||||
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674 (⚠ body mismatch — randomizes position, graphicAlarm 2, sim re-arm; see open-questions), 0x18
|
||||
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
|
||||
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
|
||||
- **BTPlayer** (byte-scanned 2026-08-05, file 0x112dxx; 20-byte rows {id, namePtr, fn, 0, 0} — 6
|
||||
entries EXACTLY, matching the T0 PLAYER.h enum): **0x15 DropZoneReply@0x4bffd0, 0x16
|
||||
Score@0x4c02e4** (the type-1/2 switch — BT shadows the engine id with its override), **0x17
|
||||
VehicleDead@0x4c05c4, 0x18 MissionStarting@0x4bfbe8, 0x19 MissionEnding@0x4bfc20, 0x1a
|
||||
ScoreUpdate@0x4c02a8** (= Player::NextMessageID). **@0x4c0200 ("ScoreInflicted", type-0-only,
|
||||
Verify line 0x18b) is in NO entry — dead code in 1995.** The 1995 type-0 path therefore lands in
|
||||
@0x4c02e4's Verify arm and folds an UNINITIALIZED [ebp-0xc] into currentScore (real bug; port
|
||||
banks 0). @0x4c02e4 internals [T1 raw]: `+0x40==4` MissionEnding gate; registry-find msg+0x34;
|
||||
case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the
|
||||
console VTVDamaged post (ConsoleHost && !suppressConsole@0x258; **arg 6 = Round(AWARD)** — an
|
||||
ST0-arg __ftol @0x4dcd94 the decompiler rendered argless; the old "Now()" read was wrong); case
|
||||
2 = kill: `CalcKillScore@0x4c052c` × (senderTonnage/ownTonnage), self-kill `fchs`, dual
|
||||
`killCount@0x27c++` (shooter + victim's player — the phantom partner increment, masked by
|
||||
replication), StatusMessage{type 0 Destroyed, victim player, 6.0s} from pool @0x512f6c with
|
||||
vtable @0x513344, clear-target @0x4b04d8 via `playerVehicle+0x128` roster head when the dead
|
||||
mech == `objectiveMech@0x284`. `CalcKillScore@0x4c052c` [T1]: same-team (strcmp
|
||||
`teamName@0x20c`, gate `freeForAll@0x250==0`) → `-friendlyFirePenalty(role+0x14)`; else
|
||||
`victimMech->avgZoneDamage@0x354 × damageBias(role+0x18) + 1.0` (const @0x4c05c0); result ×
|
||||
`(basis + tally) × damageInflictedModifier(role+0xC)`. **ScenarioRole offsets** (Node base):
|
||||
+0xC inflictedMod, +0x10 receivedMod, +0x14 ffPenalty, +0x18 damageBias, +0x1C killBonus,
|
||||
+0x20 specialCaseDeathPenalty. **mech+0x354** = average zone damageLevel (computed with the
|
||||
subsystem average @0x358 by the telemetry refresher, part_012.c:9515). **Wire layouts**
|
||||
(static_assert-locked, btplayer.hpp): ScoreMessage 0x3C {+0x1c scoreAward=tally, +0x20 type,
|
||||
+0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID(=TakeDamageMessage+0x5c,
|
||||
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
|
||||
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
|
||||
— see [[combat-damage]].
|
||||
**#162 audit deltas (2026-08-11, full record docs/KD_CHAIN_AUDIT_20260811.md) [T1]:**
|
||||
VehicleDead@0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ dual — `inc [ebx+0x280]` victim
|
||||
@0x4c067a unconditional + `inc [edi+0x280]` killer's copy @0x4c0674 (skipped on self-kill);
|
||||
then STRAIGHT-LINE the death cost @0x4c07cd-0x4c0828: gate `advancedDamageOn(+0x264)` ONLY,
|
||||
amount `-specialCaseDeathPenalty(role+0x20)` via `fld/fchs` @0x4c07dd, applied by a DIRECT
|
||||
call to engine Player::ScoreMessageHandler @0x42da20 (never dispatched, never on the wire —
|
||||
no matchlog receipt exists; the port's `[deathcost]` DEBUG line is the only witness).
|
||||
**deathPending(+0x290) is NEVER written non-zero anywhere in BTL4OPT.EXE** (three writes,
|
||||
all zero-stores: ctor @0x4c0c07, respawn helper @0x4c01e5, DropZoneReply tail) — 1995 has NO
|
||||
receiver-side death dedup; the sender's one-shot edge is the whole guarantee. The clear pair
|
||||
`{+0x290=0, +0x258=0}` lives in **FUN_004c012c = DropZoneReply**'s tail (part_013.c:10519-23)
|
||||
— NOT the VehicleDead arm (the #81-era port misattribution, corrected 2026-08-11). Latch
|
||||
asymmetry: the entry latch (FUN_0049fb54) tests modes **2|9** while the death-tail gate
|
||||
accepts **9|10** (part_012.c:14761). **The mode-9 write is SYNCHRONOUS within the damage
|
||||
pass** [T1 — 14761's gate reads it already flipped in the same call]: the sender one-shot
|
||||
only holds because of this timing. The port deferred it to the per-frame UpdateDeathState
|
||||
and a same-frame direct+splash double-lethal re-ran the death block (duplicate kill credit
|
||||
— the 4-node retest catch, fixed 1b6d247 by running the transition inline at the death
|
||||
tail; gotcha §25). `advancedDamageOn(+0x264)` ← the mission egg's
|
||||
per-pilot `advancedDamage=` key (every stock egg stamps 1 on every page); Player update
|
||||
record replicates ONLY currentScore(+0x1c8) + dropZoneLocation — killCount/deaths never
|
||||
cross the 1995 wire (the port's #45 record extension mirrors them owner→replicant; benched
|
||||
convergent at 4 and 8 nodes, kd4_bench/kd8_stress).
|
||||
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
|
||||
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
|
||||
priority" — that's the emergent effect, not the mechanism):
|
||||
@@ -319,6 +414,182 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
|
||||
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
|
||||
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
|
||||
- **EJECT/PANIC cluster DECODED + WIRED 2026-08-02 [T1 raw disasm]:** `@0049f854`
|
||||
**Mech::EjectPilot** (table id 0x19; sat in the 0x49f854-0x49fa00 export gap) — gates
|
||||
{msg+0xc > 0, ejectPermitted@0x414, !IsDisabled@0049fb54}, console eject notice
|
||||
(`FUN_004c198c` ctor from ownerID@0x18c → network sender, code 5 — port tail),
|
||||
player `suppressConsole`@0x258 = 1 (the following death must not double-notify),
|
||||
`graphicAlarm → 10` (EJECT state; **kills via the ≥9 predicate** — IsMechDestroyed),
|
||||
self TakeDamageMessage {inflicting=SELF, zone −1, Explosive, amount =
|
||||
`ScenarioRole::killBonus` role+0x1c, burst 0}. Role layout byte-settled via the
|
||||
reader `@00429bec` dest offsets + ctor record copy: +0xc inflicted, +0x10 received,
|
||||
+0x14 ffPenalty, +0x18 damageBias, **+0x1c killBonus**, **+0x20
|
||||
SpecialCaseDeathPenalty (4.10-only key)**, +0x28 returnFromDeath. `KillBonus`
|
||||
authors NOWHERE in shipped content → charge 0, alarm does the killing — authentic.
|
||||
`@0049fa1c` **EvaluateEjectPermission** (writes @0x414; unexported caller): roster
|
||||
walk — 0xBBE bank coolant fraction (+0x12c/+0x128) < 0.05 (`@0049fb50`) | zero live
|
||||
Generators (0xBC1, +0x40 ≠ 1 && stateAlarm@0x210 ≠ 4) | live MechWeapons
|
||||
(0x511830; ProjectileWeapon family 0x5121a8 also needs weaponAlarm@0x364 ≠ 7 NoAmmo)
|
||||
< mech+0x448 (**no exported writer — zero, clause inert** [T3]) | leg-gimped
|
||||
(mode 3/4) novice (simLive 0). Port: mech.cpp handler+evaluator, bridges
|
||||
BTWeaponCountsForEject / BTGeneratorCountsForEject / BTHeatSinkBankCoolantFraction /
|
||||
BTPlayerEjectBookkeeping; input = binding-engine `Eject` action (Backspace /
|
||||
pad LeftThumb; BENCH `BT_EJECT_AT=<frame>`, `BT_KILL_SUBSYS` now comma-list).
|
||||
- **EJECT ARM CHAIN closed 2026-08-03 [T1 E8-scan / T2 lamp verified]:** the
|
||||
evaluator's "unexported caller" FOUND — one relative CALL from
|
||||
`0x004a9b6c` = **`FUN_004a9b5c` (the Mech MASTER PERFORMANCE) + 0x10: its
|
||||
FIRST act each frame is EvaluateEjectPermission** (masters only; the fn sits
|
||||
in the 004a977x-004ab188 export hole, located by E8 rel32 scan of the CODE
|
||||
section). Downstream: `@004d196c` L4MechControlsMapper::InterpretControls
|
||||
edge-detects @0x414 (cache @mapper+0x1a8) → Add/RemoveModeMask(**0x200000 =
|
||||
the PANIC-ARMED mode** — the old "missionReviewMode" reading of mech+0x414
|
||||
was a MISLABEL, swept 2026-08-03; the real review mode is the GLOBAL
|
||||
`DAT_004fd550`, btl4pb) → `MakeLinkedLamp(ButtonPanic 0x3d, 0x200000)`
|
||||
(`FUN_00476fc0`) lights the pod PANIC button; on glass the pad panel shades
|
||||
the miniconsole Panic button from the same PadRIO lamp state
|
||||
(L4PADPANEL:295, verified lit dark→bright on generator kill). NO gauge in
|
||||
the shipped L4GAUGE.CFG rides mode 0x200000 — the eject indication is the
|
||||
BUTTON LAMP, not an MFD element ("weapon-eng MFD eject lamp" reports = the
|
||||
weapon page's UNJAM/EJECT ammo-jam indicator, a different feature). Lamp is
|
||||
SOLID on arm (engine linked-lamp semantic); FLASH = the GaugeAlarm
|
||||
`SetAlertState` overlay (RIO::flashFast), not authored for panic. Port fix:
|
||||
per-frame `EvaluateEjectPermission()` at the top of Mech::PerformAndWatch
|
||||
(master-gated) + `GetEjectPermitted()` accessor; `BT_EJECT_LOG=1` logs the
|
||||
arm edges. Adjacent table fact: id 0x1a `DuckRequest@0049fa00` latches
|
||||
mech+0x398 = 1 — that latch has NO reader anywhere in the image (vestigial).
|
||||
- **THE TECHSTATUS / GAUGE-ALARM MODEL decoded 2026-08-04 [T1]:** the gauge-alarm
|
||||
"condition" = a **STATUS-FLAG BIT INDEX** (0..6, TechStatusTypeCount). MechTech's
|
||||
`TechnicalAssistance` edge-scans every monitored subsystem's `GetStatusFlags()`
|
||||
(vtable +0x30) and on a bit flip calls `ReportStatusSet/Cleared(sink, owner, sub,
|
||||
bitIndex, alarmModel)` → `GaugeAlarmManager::Activate(condition=bitIndex)` → the
|
||||
SHARED 'mechalrm' stream's items matching that condition flash their lamps
|
||||
(SetAlertState → RIO flashFast). The bit table, all byte-grounded overrides:
|
||||
**bit 0** structure ≥ StatusThreshold (@004ac18c) | **bit 1** structure >
|
||||
StatusFloor | **bit 2** coolantActive && HeatModelActive (CoolantLeaking) |
|
||||
**bit 3** heatAlarm ≠ 0 | **bit 4** linked AmmoBin cookOffArmed (**AmmoBurning**)
|
||||
| **bit 5** weaponAlarm == 5 (**Jammed**) | **bit 6** !HasVoltage (no usable
|
||||
voltage; `HasVoltage` = source stateAlarm==2 Ready && |outputVoltage| > ε,
|
||||
@004b0b5c). **Invite semantics:** conds 4/5 → gotoEngineering 0x80 + engEject
|
||||
0x85 = the AMMO purge/unjam invite (flashes the very key whose streamed function
|
||||
is EjectAmmo msg 0xB) — NEVER pilot eject; cond 6 → 0x80 + engBusMode 0x86 =
|
||||
the bus-switch invite; cond 2 → the coolant loop/placement lamps (#98). The
|
||||
PANIC LAMP (mode-linked, solid) is the SOLE pilot-eject indicator. **Electrical
|
||||
subtlety [T1, @0049c9a8 + FUN_004b1f7c]:** the crit distributor touches nothing
|
||||
electrical, and the generator sim has NO Ready-case recompute — a generator
|
||||
destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any
|
||||
transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)
|
||||
recomputes output via `(1 − damage) × rated` and zeroes it. stateAlarm 4's
|
||||
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
|
||||
destruction→state-4 bridge was removed as unfounded.
|
||||
- **Generator trip/recovery + the warning edge (#154 audit, 2026-08-11, all [T1] unless
|
||||
noted).** Trip: heatAlarm==FailureHeat(2) → stateAlarm 4, i.e. T > FailureTemperature;
|
||||
recovery gate (case-4 body @4b1f7c) = heatAlarm back to **NormalHeat(0)**, i.e.
|
||||
T ≤ DegradationTemperature — then 4→0 Starting → StartTime spin-up → 0→2 Ready.
|
||||
Authored generator temps (raw BTL4.RES): start=77, degradation=**1000**,
|
||||
failure=**2000** on all six vehicles — recovery at exactly HALF the trip line;
|
||||
manual p23 prints the half-way rule verbatim, and the heat bar's colored fill
|
||||
(VertTwoPartBar @4c48fc) exists exactly while T > 1000, so "red gone" == the
|
||||
recovery gate to the tick. NO hysteresis in the heatAlarm ladder (@4ad924).
|
||||
**The warning edge is authored backwards in the shipped content**: BTL4.RES
|
||||
streams exactly TWO AudioStateTriggers per generator on GeneratorState —
|
||||
(trigState=1 Idle, inv=0, Start) and (trigState=4 GeneratorOut, **inv=1**, Start,
|
||||
= fire on LEAVING 4). No enter-4 trigger exists (RES-wide census: 144/144 rows,
|
||||
triple-verified decode + live [audioedge] capture). Consequences, ALL live-benched
|
||||
(`scratchpad/night15/gen_edge_bench.sh`, receipts BT_AUDIO_EDGE/[audioedge] +
|
||||
BT_GEN_HEAT injection): thermal trip 2→4 SILENT; natural recovery 4→0 FIRES the
|
||||
warning; respawn-with-tripped-gen RTIS (4,0) FIRES it (ghost warning during
|
||||
respawn); crit-death fires enter-Idle one frame late; 4→1 (cooled while switched
|
||||
off) double-fires. The generators' inv+Start is the ONLY inverse-Start fault
|
||||
trigger in the game (census) — a 1995 authoring slip [T4 intent]. Port machinery
|
||||
is faithful end-to-end. **DELIBERATE DEVIATION shipped 2026-08-11 (user-approved):**
|
||||
the AudioStateTrigger ctor (AUDWTHR.cpp) flips the GeneratorState (trigState=4,
|
||||
inv=1) record to inv=0 at stream time — warning now fires at the TRIP, silent at
|
||||
recovery/respawn; `BT_GEN_WARN_SHIPPED=1` restores shipped behavior. This is the
|
||||
port's ONLY authored-content override (precedent: the self-hit score deviation).
|
||||
**The warning IS a VOICE — CORRECTED 2026-08-11 (a same-day wrong claim swept):**
|
||||
the Warnings01/AllWarning zones are the project's recorded voice material (Yip,
|
||||
original VWE) and the AudioControlSequences chain the zone-WORDS into spoken
|
||||
phrases — established by the #119 leak-voice work (commit 42b0691: "the warning
|
||||
is a phrase-sequenced voice patch (Warnings01 zones as notes)"). The generator
|
||||
sequence (notes 29/16/26 = zones z4/z0/z3) is the spoken "generator out" phrase —
|
||||
**ear-verified 2026-08-11** (zones played in sequence order, rate-patched copies) [T2]. An earlier revision of this entry claimed BT 4.10 shipped no speech and
|
||||
attributed era memories to Firestorm — WRONG: the claim rested on preset NAMES
|
||||
and was contradicted by our own git history. With the voice real, era memories
|
||||
of hearing it at the trip may be genuine (possibly a later floor-content
|
||||
revision fixing the same bit our deviation flips).
|
||||
⚠ Process lesson: before asserting an asset ABSENT, grep the project's own git
|
||||
log and prior tickets for the asset family (#119 was one `git log --grep=voice`
|
||||
away; six audit agents and the synthesizer all missed it).
|
||||
Voice-steal note: night-15 field logs show up to 932 channel steals/session — even
|
||||
fired Starts can be eaten at combat peak (L4AUDRND weight-steal).
|
||||
Diag hooks: BT_GEN_HEAT=<units/s> (+_DELAY/_SECS) injects into viewpoint
|
||||
GeneratorA (thermalMass=726000 raw units — rate ~1.5e8 for ~18-25 s trips it);
|
||||
BT_AUDIO_EDGE=1 prints every state-edge trigger delivery FIRED/no-fire.
|
||||
Related corrections: AmmoState 3 = EJECTING (an earlier lane's "cook-off enter-3"
|
||||
label was wrong — cook-off warning rides FireCountdownStarted matchers); manual
|
||||
p23 also answers #133: the MFD X = generator destroyed by CRIT, not overheat.
|
||||
- **THE CONDITION FLAGS ARE ROUTINE, NOT ALARMS — field-verified 2026-08-08 [T2]:** a
|
||||
`[techstat] <sub> condition <n> SET` is an *operating* flag ("this component is above
|
||||
nominal / browned out RIGHT NOW"), not a fault report. Census over a full Steam match
|
||||
(`steam_20260806_c_michael_XIAOLONG.log`): cond 3 **Overheating** fires 33× on LLaser_2,
|
||||
31× LLaser_1, 26× SRM4, 18× PPC_2 — every weapon trips it on each volley and clears it on
|
||||
cooldown; cond 6 **BadPower** fires 8× on Myomers, 3× each on SLaser_1/PPC_1/MLaser_1 as
|
||||
the bus browns out under simultaneous draw. **Every subsystem's SET/CLEARED counts are
|
||||
balanced** (GeneratorD 5/4, GeneratorA 8/7, Myomers 5/4, Condenser5 1/1 — the odd extra SET
|
||||
is only the log ending mid-heat). Nothing latches. **Never read a post-respawn `condition 3
|
||||
SET` as a reset failure** — that inference cost a full two-sided reset audit (#137 below).
|
||||
Diagnose a heat complaint from the SET/CLEARED *balance* and the throttle state, not from
|
||||
the presence of a SET.
|
||||
- **#137 "respawn came back with MYOMERS heat MAXED" = NOT A BUG, closed 2026-08-08 [T2]:**
|
||||
`Mech::Reset` is clean on both sides — bench: every roster subsystem incl. all six
|
||||
Condensers at `T=77 start=77`; field log: every live condition CLEARs 12–16 lines after each
|
||||
reset. The heat is real and immediate because **the mech respawns still under power**: at the
|
||||
reset the log reads `thr=1`, `cycleSpeed=14.6`, gait state 12 (running), and the myomers →
|
||||
Condenser5 → GeneratorD trip Overheating within ~1–2 s. `Mech::Reset`'s subsystem loop starts
|
||||
at **index 2** and the ControlsMapper is **index 0**, so the throttle is never reset — and the
|
||||
binary does the same, which is correct for a pod whose throttle is a PHYSICAL lever still
|
||||
under the pilot's hand. **Desktop caveat (real, and separate):** the glass bridge emulates
|
||||
that lever with a file-static ramp accumulator `sLever` (mech4.cpp:3250), zeroed ONLY by the
|
||||
X all-stop and a direction-crossing snap — so a pad/keyboard pilot, who is physically holding
|
||||
nothing, likewise respawns at speed with the lever state invisible. Port-layer question, not
|
||||
a heat bug.
|
||||
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
|
||||
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
|
||||
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
|
||||
`Player::ScoreMessageHandler` (`@0042da20` == PLAYER.cpp:138) a type-1 ScoreMessage
|
||||
with `scoreAward = −ScenarioRole::specialCaseDeathPenalty` (role+0x20, the 4.10-only
|
||||
key; restored to SCNROLE.h/.cpp — record slot [1] after KillBonus, fail-hard read).
|
||||
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
|
||||
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
|
||||
pod's death cost may never have displayed; our single-cell port shows it. Shipped
|
||||
content authors NO role keys, so the cost is 0 in the field. **CORRECTED 2026-08-07 —
|
||||
that sentence was wrong on both halves.** The role's scoring fields are not authored via
|
||||
notation keys at all: the ctor `@00429a9c` loads them from the role MODEL's GameModel
|
||||
record (type 0xf, 7 dwords — rec[0]=killBonus, rec[1]=specialCaseDeathPenalty,
|
||||
rec[2]=dmgRcvd, rec[3]=dmgInflctd, rec[4]=bias, rec[5]=friendlyFire,
|
||||
rec[6]=returnFromDeath); the notation keys are optional OVERRIDES. Shipped content
|
||||
authors `Role::Default` (model `dfltrole`) with **killBonus=500, deathPenalty=500,
|
||||
dmgInf=1, dmgRcv=0, bias=1, ff=1, return=1000** — the original manual's scoring chart
|
||||
verbatim (`reference/manual/scoring_chart.webp`). The cost read 0 only because
|
||||
`BTPlayer::scenarioRole` was never BOUND (the registry lookup sat commented out); wired
|
||||
2026-08-07 and the cost is verified APPLYING at 500. Other role+0x1c reader:
|
||||
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
|
||||
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
|
||||
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
|
||||
`slice = (till.ticks − this[4].ticks) / DAT_0052140c; this[4] = till;
|
||||
(*this[7])(this, slice)` then watchers (`FUN_0041c08c`) + WriteSimulationUpdate
|
||||
(`FUN_0041bd98`) — **VARIABLE-STEP, slice in real SECONDS**, line-for-line the
|
||||
WinTesla SIMULATE.cpp body. Corollaries: every Performance's `time_slice` is
|
||||
seconds in BOTH binary and port (no hidden tick/second unit gap); a dt-LESS
|
||||
per-frame term (myomer kinetic @4b8d18's middle term; the Mover gravity
|
||||
`-= **(mover+0x250)` @0x421e77) fires at the FRAME cadence = the tick rate,
|
||||
28 Hz nominal, sagging under load. Mover velocity is u/s (the replicant
|
||||
dead-reckoner @0x421f7c does `pos += vel × ticksΔ/DAT_0052140c`). The myomer
|
||||
heat term's ONE caller is the **unexported MyomersSimulation body
|
||||
@0x4b8b9a-0x4b8d0d** (E8-scan + raw disasm; the export gap hid it): calls
|
||||
@4b8d18 once per Perform, raw slice, gated `measuredVoltage > 0` only — plus
|
||||
a −0.011/Perform repair trickle at alarm 1 and `speedEffect(+0x31c) =
|
||||
AvailableOutput(min(measuredV, seek gear, cap)) / owner->runSpeedBase(+0x34c)`.
|
||||
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
|
||||
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
|
||||
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
|
||||
@@ -351,6 +622,20 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
@00444c80` (lamp map at renderer`+0x1c0c8`) → **`Lamp::SetAlertState @00444e64`** (the flash
|
||||
COUNTER at lamp+0x1C) → the L4 lamp flush `@00474e94` emits `0x37`/`0x13` = `flashFast` RIO
|
||||
states (== T0 L4LAMP.cpp:234-239) → `RIO::SetLamp` (binary `FUN_00476568`; cached wrapper
|
||||
- **#135 (2026-08-11): the GENERATOR special @004cc27c is `table_0051d070[generatorNumber]`**
|
||||
(sub+0x1E0, A=1..D=4, 1-based; bytes byte-read: {0x29, 0x1A,0x1B,0x1C,0x1D}; slot 5 is
|
||||
already string data — bounds-guard 1..4) [T1] — the SAME shape as the condenser special
|
||||
@004cc264 beside it. The port had mis-reconstructed it as an aux-screen placement probe
|
||||
(fields generators never fill) → the field-logged `NO LAMP RESOLVED (generator=1)` silent
|
||||
generator leak; benched fixed (`[lamp] 0x1a <- 0x37 (FLASHING)` on a GeneratorA leak).
|
||||
- **#135 stale-flash gotcha [T0]:** `Lamp::SetAlertState/SetState` notify the panel ONLY when
|
||||
the lamp's modeMask intersects the manager's current mask — an edge landing off-page (or in
|
||||
the one-frame mask race of a page transition) is silently dropped, and the panel value
|
||||
LATCHES the last-asserted flash (Oracle: "Loop 6 NEVER stopped flashing"; his log shows
|
||||
Condenser6 SET/CLEARED balanced 3/3 — the manager was right, the lamp was stale). Port fix:
|
||||
the skipped notify is remembered (`staleAlertNotify`) and delivered by the manager's
|
||||
active-lamp loop at the next in-mode frame (LAMP.cpp). Field SET/CLEARED balance + a stuck
|
||||
flash = this gotcha, not an alarm leak.
|
||||
`@00474d54`). T0-compiled already: LAMP.cpp / L4LAMP.cpp / GAUGALRM.cpp / RENDERER messages.
|
||||
**✅ ALL THREE PIECES BUILT same day (see [[open-questions]] for the full closure note).**
|
||||
Corrections pinned while building: the SENDER is **`MechTech::TechnicalAssistance` @004ad33c**
|
||||
@@ -409,6 +694,13 @@ default-ON (`'0'` disables).
|
||||
| Var | Effect |
|
||||
|---|---|
|
||||
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) |
|
||||
| `BT_BTNTEST=addr,on,off` / `BT_BTNTEST2=...` | scripted screen-button press/release cycles at poll counts (the REAL RIO seam; 2nd cycle for e.g. squat→rise) |
|
||||
| `BT_DUCK_LOG` | crouch diagnostics: RIO press mode-mask, SetLegAnimation re-arm tracer (+ra), 1 Hz joint probe, posture/squat events |
|
||||
| `BT_TREE_LOG` | dump the built render-tree topology (segment/joint/parent, SITE rows) at mech build |
|
||||
| `BT_SPOT_SELF` | bench-only: build the searchlight cone on the OWN-cockpit tree so `BT_CAM=face` can eyeball it solo |
|
||||
| `BT_CAM=face` | chase camera in FRONT looking back (the old animation view); `BT_CAM_Y/Z` offsets |
|
||||
| `BT_FOG_LOG` | log SetFogStyle transitions + fog page resolution (searchlight swap forensics) |
|
||||
| `BT_GIMP_SPEED` | override the gimp-gait demand scale (1.0 = authentic) |
|
||||
| `BT_SPAWN_ENEMY` | spawn a target mech 120u ahead along the spawn facing |
|
||||
| `BT_AUTOFIRE=1` | hold the trigger (headless walk→fire→death harness; supersedes the dead `BT_FORCE_FIRE` — btl4main.cpp:310 set `fireForced` once at startup, but mech4.cpp:1567 unconditionally overwrites it every frame) |
|
||||
| `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
|
||||
@@ -431,7 +723,7 @@ default-ON (`'0'` disables).
|
||||
| `BT_AIM="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness |
|
||||
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
|
||||
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
|
||||
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
|
||||
| `BT_START_INSIDE` | begin in the cockpit view (BACKTICK toggles; 'V' = the pod rear-view hold since #68 — the toggle skips bound keys, L4PADRIO.cpp:681) |
|
||||
| `BT_FORCE_MODEL=<name>` | force the player mech (`madcat`/`owens`/…; btl4mssn.cpp — per-mech cockpit bring-up) |
|
||||
| `BT_HIDE_COCKPIT` | hide the `*_cop` canopy shell (SHOWS by default — see [[cockpit-view]]) |
|
||||
| `BT_COP_*` / `BT_EYE_FWD=<f>` | cockpit canopy + eye diagnostics (`FRAME` (unlit frame colour override, default 0.13,0.12,0.15) / `SINGLE` (single-sided diag; double-sided is default) / `PLATES=1` (disable the punch stencil-cut kit) / `FLIP` / `DEBUG` / `DUMP`; eye forward-push probe) — [[cockpit-view]] |
|
||||
@@ -501,7 +793,9 @@ ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py`
|
||||
`reference/decomp/section_dump.txt`.
|
||||
|
||||
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
|
||||
`FUN_0041e4e0` (part_002.c:7341, reads the `material` key list per `[dz_*]` section); runtime
|
||||
address UNKNOWN — the old citation `FUN_0041e4e0` was WRONG (that function, part_002.c:7337,
|
||||
is `DamageZone::TakeDamage`: `level += amount×scale[type]`, byte-matches DAMAGE.cpp:379 —
|
||||
verified 2026-08-02, swept here + [[rendering]]); runtime
|
||||
consumer inside `BTL4VideoRenderer::MakeMechRenderables` **`FUN_004cef28`** (part_014.c:5190-5272);
|
||||
per-zone node ctor `FUN_00455c7c(...,0.04f)`; the per-material watcher
|
||||
**`FUN_004573e4(material, &zone->damageLevel, 0.1f)`** (part_007.c:9133, vtable `PTR_LAB_004f1730`,
|
||||
@@ -538,6 +832,22 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
|
||||
---
|
||||
|
||||
## 7. Tools
|
||||
- ⚠ **CITATION POLICY (since the 2026-08-06 re-export): cite `@ADDR`, never `part_0NN.c:LINE`.**
|
||||
Addresses are stable across exports; shard membership and line numbers are NOT. Pre-08-06
|
||||
part/line citations resolve only against `reference/decomp/archive_2025export/`.
|
||||
- ⚠ **Ghidra renders member access as int-ARRAY indices** (`param_1[0xfe]` == byte offset 0x3f8,
|
||||
`[0x1e7]` == 0x79c): grepping the export for an offset STRING misses every such site. Divide
|
||||
by 4 (or grep both forms) — this is a live instance of gotcha §20.
|
||||
- `tools/gapcensus.py [dir]` — the #60 dark-region census: index/export coverage, function-start
|
||||
discovery, TU attribution, repo-citation flags → `<dir>/GAP_CENSUS.md` + `gap_census.tsv`.
|
||||
Consult BEFORE any "absent from the export" claim (gotcha §20).
|
||||
- `tools/ghidra_reexport.sh [reprocess]` — headless re-export: Ghidra 12.1.2 + JDK 21 (installed
|
||||
in `%LOCALAPPDATA%\bt411-tools` beside DXSDK/cmake) run `reference/ghidra_scripts/ExportGaps.java`
|
||||
(ExportAll's output contract + the dark-region gap fill). ⚠ the runner MUST use 8.3 SHORT paths
|
||||
(`JOELAP~1`, `BT411-~1`): Ghidra's launcher .bat expands `%JAVA_HOME%` UNQUOTED and the user
|
||||
profile has a space. `reprocess` re-runs the script on the saved project (skips re-analysis).
|
||||
- `tools/gapdiff.py [old] [new]` — score two censused exports (coverage/dark deltas, which
|
||||
previously-dark addresses the KB already cites, celebrity check).
|
||||
|
||||
- `tools/disas2.py <VA> [len]` — capstone disassembly of BTL4OPT.EXE at a VA (recovers x87 math
|
||||
Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
|
||||
|
||||
@@ -42,7 +42,9 @@ then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both
|
||||
|
||||
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
|
||||
|
||||
- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam`
|
||||
- **+0x25c — "sim live", off only for novice.** Consumers: the CROUCH posture selector
|
||||
(master-perf @0x4a9f70: novice → mapPosture 0 → squat/rise refused — 2026-08-06,
|
||||
[[locomotion]] §CROUCH); ballistic jam roll `CheckForJam`
|
||||
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
|
||||
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
|
||||
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
|
||||
|
||||
+41
-1
@@ -11,7 +11,7 @@ open_questions:
|
||||
- "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)"
|
||||
- "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat"
|
||||
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
|
||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90, unreconstructed); plus 0x13 = Mech DuckRequest (crouch), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles -- see pod-hardware.md + docs/GLASS_COCKPIT.md 2026-07-20"
|
||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
|
||||
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
|
||||
---
|
||||
|
||||
@@ -414,6 +414,14 @@ Verified live: bay fire → lamp 0xD (the LRM's select button) flashes 0x37 + en
|
||||
on detonation/purge. Diagnostics: `BT_LAMP_LOG` → `[techstat]`/`[galarm]`/`[lamp]`. Details +
|
||||
the four load-bearing fixes en route: [[open-questions]] + [[decomp-reference]] §GaugeAlarm.
|
||||
|
||||
**These conditions are ROUTINE and SELF-CLEARING [T2, field-verified 2026-08-08].** A `SET` is an
|
||||
operating state, not a fault: every laser volley trips Overheating (cond 3) and clears it on
|
||||
cooldown (33× on one LLaser in a single match), and BadPower (cond 6) flickers whenever
|
||||
simultaneous draw browns the bus. Across a full match every subsystem's SET/CLEARED counts are
|
||||
balanced — nothing latches. So a lamp flashing after a respawn is the mech *operating*, not a
|
||||
failed reset; diagnose from the SET/CLEARED balance, never from a lone SET. Full census + the
|
||||
#137 post-mortem it settled: [[decomp-reference]] §TechStatus.
|
||||
|
||||
## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21)
|
||||
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
|
||||
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
|
||||
@@ -549,6 +557,38 @@ and every instrument is now live [T2]:**
|
||||
transcription color bug caught by a period reference screenshot, 2026-07-09; same for the
|
||||
bottom bowtie carets @4569-4570); pegs at 1200 with no target; the DISPLAYED range slides at
|
||||
**500 m/s** toward the true pick range (HudSimulation :5652 [T1]).
|
||||
**The HudSimulation tuning constants, read off .rdata 2026-08-08 [T1]** (`section_dump.txt`
|
||||
rows ` 4b7ec0 8be55dc3 0000403f 0000803f 0000c842` / ` 4b7ed0 00000000`) —
|
||||
`_DAT_004b7ec4` = **0.75f**, `_DAT_004b7ec8` = **1.0f**, `_DAT_004b7ecc` = **100.0f**,
|
||||
`_DAT_004b7ed0` = **0.0f**, `_DAT_004b7f90` = **0.0f**. ec4/ec8 are the fire-control **LOCK**
|
||||
limits (own HUD host zone < 0.75 damage, targeted zone < 1.0 — so a shot-up cockpit drops to
|
||||
"target held, no lock", and a dead zone can't be re-locked); ed0 is the shared **zero** in the
|
||||
range-slide `Abs()` idiom (the 500 is an immediate `0x43fa0000`, NOT a global). `hud.cpp` had
|
||||
carried all five as 0.0f/500.0f stand-ins under guessed names ("SegmentTempLimit … heat
|
||||
threshold for HUD page visibility" was neither heat nor page visibility) — corrected in place,
|
||||
with the live lock/slide implementation staying in mech4.cpp's targeting step, which had both
|
||||
thresholds right all along.
|
||||
**THE CARET CAN DIE FOR THE WHOLE SESSION — #147, fixed 2026-08-08 [T2 code, T3 field link]:**
|
||||
`sShownRange` (mech4's targeting step) is a **function-level static** — one cell per process,
|
||||
shared by every mech, carried across drops. **NaN is absorbing** in `step = trueRange −
|
||||
sShownRange; sShownRange += step`, and neither the producer's clamps nor
|
||||
`BTReticleRenderable::Draw`'s (`range < minRange` / `range > maxRange`) catch it — both
|
||||
comparisons are **false for NaN**. A poisoned value therefore reaches `AddPoint`/`ConcatMatrix`,
|
||||
and the caret + its bar become **degenerate geometry that stops rendering while the static tick
|
||||
marks keep drawing** — the exact reported symptom ("no range finder on this drop", ticks there,
|
||||
caret gone), sticky until relaunch. Fixed four ways: re-seed on mech change (not on respawn —
|
||||
that reuses the entity, and the binary doesn't reset the readout either), a producer NaN trap, a
|
||||
NaN-safe consumer clamp falling back to the 1200 peg, and — the reason no log could settle it —
|
||||
**`BT_RANGE_LOG` now prints the caret's actual input** (`[range] caret input shown=`). It never
|
||||
did before: `BT_RANGE_LOG` instrumented the PICK (#4) and `[target]`'s `range=` is a *separate*
|
||||
locally-recomputed Sqrt in the weapon-range check, so grepping field logs for NaN found nothing
|
||||
because the poisoned variable was never printed. Causal link to the field report is INFERENCE.
|
||||
**GAP — the 100 m RANGE BIAS is NOT reconstructed [T1 read, unimplemented]:** HudSimulation
|
||||
subtracts `_DAT_004b7ecc` (100.0f) from `RangeToTarget@0x1EC` **every frame while the timed
|
||||
flag @0x22C is set**, accumulating @0x21C by `time_slice` until it reaches @0x1D8, then clearing
|
||||
both. The port's targeting step (mech4.cpp) does the slide but never the bias, so whatever
|
||||
in-game state sets @0x22C currently produces no range offset. Trigger for @0x22C not yet
|
||||
identified — see [[open-questions]].
|
||||
**VERDICT (Gitea #4, 2026-07-20): the "range slides in/out crazily while walking" report is
|
||||
AUTHENTIC behavior, not a bug [T2 measured].** Per-frame `BT_RANGE_LOG` traces (mech4.cpp, with
|
||||
an independent Möller-Trumbore cross-check `BTGroundRayHitExact` in btvisgnd.cpp) on scripted
|
||||
|
||||
@@ -73,6 +73,7 @@ that one answer. Precedence, highest first:
|
||||
| Env | Layout | Buttons live in |
|
||||
|---|---|---|
|
||||
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
|
||||
| `BT_POD_SURFACES`=1 | **POD MODE** (2026-08-06): crop each panel window to its SURFACE (MFD 640×480, radar 480×640), drop the on-screen button banks + the Flight Controls pad, frameless. For REAL pod glass, where the buttons are physical — see [[pod-hardware]] §MFD PANELS. Per-window: `,bare` in `glass_layout.cfg`. | 6 bare surfaces |
|
||||
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
|
||||
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
|
||||
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
|
||||
@@ -155,6 +156,26 @@ want bare. From then on those windows are frameless **and pinned** — with no c
|
||||
nothing to drag them by, which is the point: the arrangement is finished. Delete the flag to get
|
||||
the frame back.
|
||||
|
||||
### `monitor:<name|index>` — bind a window to a DISPLAY, not to pixels (2026-08-06) [T2 live]
|
||||
|
||||
Instead of an `x,y,w,h` rect, a line may name a display device:
|
||||
|
||||
```
|
||||
Secondary / Radar=monitor:DISPLAY4,bare
|
||||
VGA Port A=monitor:DISPLAY2,bare
|
||||
```
|
||||
|
||||
The resolver (`ResolveMonitorSpec` + `MonScanProc`, `L4GLASSWIN`) matches the full device name OR
|
||||
its tail, so `monitor:DISPLAY4` works without typing `\\.\`; a bare integer is a 0-based index in
|
||||
enumeration order. The surface is CENTRED on that monitor. This is what makes a fixed rig
|
||||
survivable: **desktop rects move** when a display is re-detected, a USB graphics adapter
|
||||
re-enumerates or someone changes a resolution, and a pixel rect then silently puts a panel on the
|
||||
wrong glass — a device binding does not. Required for [[pod-hardware]] §ALPHA-MR, where a
|
||||
mis-bound window means a picture on the wrong CRT with no error anywhere.
|
||||
|
||||
`,bare` **implies frameless** (it used to need `,noframe` too, and a "bare" window came back
|
||||
656×519 WITH a caption — a real bug found on the cab).
|
||||
|
||||
Options are comma-separated after the numbers and **unknown ones are ignored**, so an older build
|
||||
reading a newer file loses the option but never the line — the `bindings.txt` grammar rule
|
||||
applied here.
|
||||
@@ -182,6 +203,15 @@ appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
|
||||
at 321,222. NB the plasma window blits directly every frame (`GetDC`+`StretchDIBits`), so unlike
|
||||
the panels it has no `WM_TIMER` focus-throttle to worry about.
|
||||
|
||||
**Turning it OFF: `L4PLASMA=NONE` (also `OFF`/`0`, 2026-08-06) [T2].** `L4GREND` creates a
|
||||
marquee whenever `L4PLASMA` is set at all (`SCREEN` → the desktop window, anything else → a real
|
||||
`PlasmaDisplay` on that serial port), and the GLASS profile force-defaults it to `SCREEN` — so on
|
||||
a rig with no marquee wired, leaving the variable unset is NOT enough; the profile fills it back
|
||||
in and a plasma window lands on the cab's glass. The `NONE`/`OFF`/`0` spelling nulls the string
|
||||
before the branch, so no external display is constructed at all. The boot banner reads
|
||||
`GLASS (PadRIO; plasma off [L4PLASMA])` when it takes, and the absence of the
|
||||
`[plasmawin] desktop plasma display up` line is the runtime proof.
|
||||
|
||||
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects;
|
||||
`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a
|
||||
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
|
||||
@@ -213,7 +243,10 @@ window.
|
||||
- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer
|
||||
enters into it.
|
||||
- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas
|
||||
letterboxed inside. Verified on a 3440×1440 ultrawide.
|
||||
letterboxed inside. Verified on a 3440×1440 ultrawide. **`BT_FIT=1` is the env spelling**
|
||||
(2026-08-06) — same flag, settable from `environ.ini`, so a fixed rig keeps its borderless main
|
||||
view however it is launched instead of depending on one launcher's command line. Targets
|
||||
`MONITOR_DEFAULTTOPRIMARY`, i.e. the PRIMARY display. [[pod-hardware]] §ALPHA-MR
|
||||
|
||||
⚠ **Ordering trap found live:** the letterbox flag was first set at device creation, but the
|
||||
first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the
|
||||
|
||||
@@ -273,6 +273,43 @@ number the peer Standing case walks on). Verified: arena circle replicant 218×s
|
||||
replicant, every `MechControlsMapper` demand cell except those explicitly re-derived
|
||||
(`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.**
|
||||
|
||||
**The #52 SEQUEL: the peer body-channel STANDING-LOCK (root-caused + fixed 2026-08-07) [T2].**
|
||||
Fixing (2) above closed an ACCIDENTAL escape hatch and the skate came back in a new shape. The
|
||||
port's body case 4 is an INSERTION (the task-#64 lockstep twin) sitting between case 0 and the
|
||||
advance group — but in the binary `case 4` is a **member of that advance group**
|
||||
(`FUN_004a5678` @004a5678: `case 2,3,`**`4`**`,5,8,…`, no turn block, no speed exit) [T1], so
|
||||
case 0's fallthrough is supposed to land on `Advance()`. The insertion intercepted it. On a
|
||||
REPLICANT that is fatal and not a race: case 0 arms walk iff `standSpeed < bodyTargetSpeed`, and
|
||||
the inserted block's exit tests `standSpeed < bspd` where `bspd` **IS** `bodyTargetSpeed` on a
|
||||
replicant — the *same expression*. Arm and reset therefore fire on the same frame, every frame,
|
||||
and a peer parked at Standing with a live replicated demand can never start cycling (reverse
|
||||
likewise: both sides test `< ZeroSpeed`). Before e91d447 the replicant branch read the dead mapper
|
||||
cell (0 forever) so the exit never fired and the fallthrough worked by accident. **Fix:** case 0
|
||||
`goto advance_body_normally` — the leg twin's own idiom (`goto advance_normally`, mech2.cpp) —
|
||||
restoring the binary's structure without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1`
|
||||
reverts. Measured: legacy 336 consecutive locked seconds with `bspd=39.2324 bts=39.2324` identical
|
||||
on every line; fixed 0 locks across every pass; the MASTER's body-Standing samples also fell 52→21
|
||||
(it was locking too, invisibly — mj=0 writes no joints, and its two tests read *different* cells so
|
||||
it only stalls in the window where they disagree). Turn-in-place re-verified under the fix (pivoter
|
||||
reached body state 4 ×9 / leg state 4 ×8 — armed in lockstep).
|
||||
|
||||
**Why a peer must be able to self-arm walking at all** (the load-bearing bit behind
|
||||
`mech4.cpp` "stand; case 0 walk-begins next tick"): the peer's body state is set directly from
|
||||
`record->legState` only on **type-3 edges** (`ReadUpdateRecord`), and entering Standing emits one
|
||||
while *leaving* it does not. So between gait-change records a replicant is REQUIRED to derive
|
||||
walking itself from the replicated `bodyTargetSpeed`. That is why the lock needs a mech holding a
|
||||
*steady* demand — a mech whose gait keeps changing keeps getting rescued by records, which is why
|
||||
free-walking and wall-jammed benches each reproduce only half the symptom. [T2]
|
||||
|
||||
⚠ **The field symptom link is [T3], not T2.** The Standing-lock is proven and proven removed; that
|
||||
it accounts for the night-13 episodes is inference (a locked peer has `bodyCycleSpeed==0` and never
|
||||
advances its clip, so locked + translating *is* the `[skate]` signature by construction) — but no
|
||||
bench caught the two together. The `[skate]` line now carries `bstate=`, so the next playtest
|
||||
settles it: episodes gone → confirmed; any survivor names its own state. **NB the night-12
|
||||
`skatebench` "reproductions" were a DETECTOR ARTIFACT** — the first detector build tested only
|
||||
`legCycleSpeed==0`, which is normal on a peer (the body channel poses it), so it fired on every
|
||||
healthy movement phase. Old-format lines (`legCycleSpeed=`, no `bodyCyc=`) are not evidence.
|
||||
|
||||
## Controls (`BT_REAL_CONTROLS`, default-on)
|
||||
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
|
||||
/ `turnDemand`. ⚠ **WndProc NEVER receives WM_KEYUP** (the engine's per-frame reader `GetMessage`s
|
||||
@@ -311,6 +348,48 @@ resolved with gait v5, no shadow code changed — though (a) may have contribute
|
||||
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
|
||||
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
|
||||
|
||||
## CROUCH -- COMPLETE 2026-08-06 [T1 decode / T2 full-cycle + MP benched]
|
||||
The manual's CROUCH button (streamed 0x13; glass key F4), end to end: DuckRequest (mech id 0x1a
|
||||
@0049fa00) latches duckState@0x398 -> the master posture block (mech4, from the dark master-perf
|
||||
@0x4a9cf0-0x4aa0af): posture selector @0x3f8 (mode 9/10/2/3/4 block, NOVICE simLive lockout,
|
||||
myomer-alive gate) -> standing: SetLegAnimation(2) ('sqd', squat down; LegClipFinished case-2
|
||||
PARKS the pose + leg alarm 1) / ducked: SetLegAnimation(3) ('squ', rise; case-3 alarm 0);
|
||||
stability alarm flips; ForceUpdate(8)+(1) ships the type-3 state record -- peers pose the squat
|
||||
for FREE (screenshot receipt: the crouched crimson madcat from the observer's cockpit,
|
||||
duckmpA_031). Clip data verified from BTL4.RES (root joint drops -2.22, knees +-1.14; squ/sqd
|
||||
are exact mirrors; interior 'squi/sqdi' variants ship for all 8 chassis). Full solo cycle
|
||||
benched (BT_BTNTEST + BT_BTNTEST2): squat -> hold (joints steady for minutes) -> rise ->
|
||||
standing zeros. Myomer factor: LIVE, not deferred -- the 2026-08-05 "feeder unreconstructed" claim was an
|
||||
export-gap-blind grep (the members are NAMED: speedEffect@0x31C, myomers.cpp; the chain walk +
|
||||
speedDemand scale + turn freeze = mechmppr.cpp:990, the 2026-07-31 seek audit, same bytes).
|
||||
mechmppr now publishes the chain MAX into mech->myomerEffectiveness (the binary's +0x79C home)
|
||||
so the crouch gate reads the live value: dead/overheated myomers (the Oracle seek-4 freeze)
|
||||
refuse the squat AND the rise.
|
||||
The pilot's EYE drops too -- the task-#15 bob channel (gBTEyeBobY <- jointlocal.y each frame)
|
||||
carries the parked -2.22 into DPLEyeRenderable (log receipt bob=-2.2187 through the hold; frame
|
||||
receipt duckcp_030 = grass-level view). A false "eye does not drop" residual was briefly filed
|
||||
2026-08-06 -- measurement error (canopy-dominant diff crops; the canopy drops WITH the eye, so
|
||||
the interior looks unchanged and only the through-window ground shifts). Bench traps for reruns: MP button delivery can miss if pressed
|
||||
during round-start jitter (press >= poll 900 after GO); diags all BT_DUCK_LOG-gated.
|
||||
|
||||
### CROUCH addendum -- the #60 re-export pass (2026-08-06) [T1]
|
||||
The rebuilt export (93.5% coverage) produced pseudocode for the master-perf region this block
|
||||
was hand-disassembled from, and it **confirms the transcription field for field**:
|
||||
`param_1[0xfe]`=mapPosture(0x3f8), `[0xe6]`=duckState(0x398), `[0x161]`=squatCapable,
|
||||
`[0x1e7]`=myomerEffectiveness(0x79c), `[0x10]`=movementMode, `player+0x25c` novice gate,
|
||||
`FUN_004a7fc4(2|3)`=SetLegAnimation sqd/squ, `FUN_004a4c54(8)+(1)`=ForceUpdate, `+0x131`
|
||||
stability alarm, duckState cleared after. (Ghidra renders these as int-ARRAY indices --
|
||||
`[0xfe]` == byte 0x3f8 -- which is why an offset-string grep of the export misses them; see
|
||||
[[reconstruction-gotchas]] §20.)
|
||||
|
||||
**It also caught a branch the raw pass missed** (the hand disassembly stopped at `0x4aa0af`):
|
||||
an **AIRBORNE AUTO-RISE** immediately after the duck consumer --
|
||||
`if ((movementMode == 3 || movementMode == 4) && legState == 1) -> SetLegAnimation(3) +
|
||||
ForceUpdate(8)+(1) + stability 1`. A mech that leaves the ground (jump/knockback) while PARKED
|
||||
in the squat is forced upright; unconditional on duckState/squatCapable, i.e. a safety
|
||||
un-crouch rather than a request. Implemented in mech4.cpp the same day; crouch re-benched
|
||||
un-regressed (squat still holds on flat ground, no spurious rises).
|
||||
|
||||
## Key Relationships
|
||||
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
|
||||
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
|
||||
|
||||
+28
-3
@@ -70,6 +70,13 @@ emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2]
|
||||
console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon,
|
||||
master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
|
||||
validity). [T2]
|
||||
- **Zone-level replication (#87, 2026-08-03):** an observer's copy of a mech learns zone damageLevels
|
||||
ONLY from zone update records, and the master sends those on BAND-THRESHOLD CROSSINGS (binary effect
|
||||
watcher `FUN_0042aa2c`: `DescriptorCrossed` + master gate → `ForceUpdate(DamageZoneUpdateModelFlag)`)
|
||||
plus every graphic-state change — never per hit. Damage messages do NOT echo locally (replicant
|
||||
`Dispatch` forwards to the master and returns). The port had only the gstate branch (observers saw
|
||||
0.0 until destruction); the level branch was restored + 2-node verified (replicant peaks == master
|
||||
finals to 4 decimals). Full mechanism: [[combat-damage]] §Zone-LEVEL replication. [T1/T2]
|
||||
- **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be
|
||||
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
|
||||
EVERY MakeMessage for pointer payloads. [T2]
|
||||
@@ -874,8 +881,13 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
REMAINING note is closed.
|
||||
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
|
||||
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
|
||||
harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is
|
||||
reconstructed (task #52 — see item 3b).
|
||||
harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
|
||||
report tail in the victim's TakeDamage handler — BTMechPostCombatReports, replacing
|
||||
BTPostKillScore; same cross-node reroute, see [[combat-damage]] §report tail). Respawn is
|
||||
reconstructed (task #52 — see item 3b; the VehicleDead sender moved to the same tail).
|
||||
CROUCH and the SEARCHLIGHT replicate with ZERO new wire code (2026-08-05/06): the squat rides
|
||||
the existing type-3 state record (legState) and the lamp rides subsystem record 0x14
|
||||
(lightState) — [[locomotion]] §CROUCH / [[rendering]] §SEARCHLIGHT.
|
||||
|
||||
## Key Relationships
|
||||
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
|
||||
@@ -895,4 +907,17 @@ RECONSTRUCTION STATE: coupled path is DEFAULT ON (mech4.cpp: s_drPos gait-couple
|
||||
|
||||
|
||||
## Peer motion: the "random shakiness" is single-box packet jitter, NOT the game (task #50, 2026-07-15) [T2]
|
||||
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
|
||||
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
|
||||
## K/D authority + the scoreboard mirror — STRESS-PROVEN at 4 and 8 nodes (2026-08-11) [T2]
|
||||
The scoring authority model (full map: [[combat-damage]] + `docs/KD_CHAIN_AUDIT_20260811.md`):
|
||||
the VICTIM's master emits everything on its death edge (kill credit to the shooter via
|
||||
replicant-Dispatch reroute, received penalty to itself, one VehicleDead); the killer's master
|
||||
increments `killCount` when the rerouted credit lands; **killCount/deathTally never crossed the
|
||||
1995 wire** — the port's #45 update-record extension mirrors them one-way owner→replicant
|
||||
(read guarded replicant-only, mixed-version length guard, 2s heartbeat re-dirty). Bystander
|
||||
nodes learn tallies ONLY through that mirror. Verified: `kd4_bench.sh` (bystander convergence +
|
||||
no-mirror-exceeds-authority, receipt-derived per player) and `kd8_stress.sh` (8-node all-vs-all
|
||||
melee, 26 deaths: 26/26/26/26 with zero duplicate-death swallows and zero `inst=R` deliveries).
|
||||
The same-frame double-credit this closed (direct+splash lethal pair racing the deferred death
|
||||
transition) is gotcha §25 — the death mode now flips synchronously inside the damage pass,
|
||||
matching part_012.c:14761.
|
||||
|
||||
+134
-19
@@ -22,6 +22,20 @@ authentic path scoped.
|
||||
further assets exist; the runtime `BTL4.RES` with 8 maps + full anim set is the master we have).
|
||||
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
|
||||
Win10+wrapper pod config. Status: OPEN.
|
||||
- **The i860 DIVISION-CARD firmware / DIV render docs (the material→pixel response curve).**
|
||||
Established 2026-08-03 (#87 dig): the HOST binary lerps 13 material colour floats linearly
|
||||
toward ×0.1 by zone damageLevel (watcher @004573e4/@00457784, 0.1 literal at the
|
||||
MakeMechRenderables call site) and then just **marshals the material to the card**
|
||||
(`FUN_0048d4d4` = "flush_material", dpl command opcode 100 — siblings flush_ramp/flush_texture).
|
||||
The card-side shading (how material colour selected/scaled the 256-row material ramps → final
|
||||
texels) is i860 firmware, NOT in BTL4OPT.EXE — so the PERCEPTUAL damage-darkening curve of the
|
||||
1995 pods cannot be recovered from this binary. Our port applies the host's linear factor at
|
||||
draw (TFACTOR modulate, L4D3D). Field testimony ("a single missile leaves visible armor
|
||||
damage") is arithmetically consistent with the linear curve — one 25-pt hit snaps a 68-90-pt
|
||||
zone by 0.28-0.37 (measured live: amt=25 → rarm 0→0.3247) — but if the card's ramp mapping was
|
||||
steeper than linear, mid-level creep damage read stronger on the pods than in the port.
|
||||
ASK: div-card firmware image, DIV/dpl render docs, or period screenshots showing a
|
||||
known-damage-level mech. Status: OPEN.
|
||||
- **Did the pod throttle quadrant have MECHANICAL detents** (how many / lever positions)? The
|
||||
software path is notch-free [T1, [[pod-hardware]]], but the gait SM's walk/run dead band
|
||||
([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
|
||||
@@ -210,16 +224,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) —
|
||||
most of the list had been reconstructed while the prose still called it dead. Check the code,
|
||||
not the census, before reconstructing anything here.
|
||||
- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets
|
||||
`duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two
|
||||
writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is
|
||||
a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState`
|
||||
on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So
|
||||
the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game
|
||||
ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in
|
||||
`content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it.
|
||||
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is
|
||||
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and
|
||||
- **❌ RETRACTED (2026-08-06): "`DuckState` has no CODE consumer, and that is authentic [T1]".**
|
||||
That verdict — and its corollary "no SQUAT clip survives; do not invent a crouch pose" — was
|
||||
EXPORT-GAP BLINDNESS ([[reconstruction-gotchas]] §20, incident 2), and the [T1] tag was
|
||||
unearned: "ZERO readers" was true of the *export*, not the binary. The reader is the master-perf
|
||||
posture/duck block in the un-exported region (@0x4aa011-0x4aa0af, raw disasm), and the squat
|
||||
clips DID ship — `<pfx>squ/sqd` + interior `squi/sqdi` for all 8 chassis live in **BTL4.RES**
|
||||
(the old claim searched decomp strings + loose `content/` files, never the RES TOC;
|
||||
`DuckServo01.wav` was the tell). CROUCH is now fully reconstructed — request latch → posture
|
||||
arbiter (novice + myomer + mode gates) → squat/hold/rise, MP-replicated on the type-3 record
|
||||
([[locomotion]] §CROUCH). The `bduck.pcc` legend databinding observation stands and still works.
|
||||
- **✅ Searchlight-driven fog swap — DONE 2026-08-05 (412053d/b75bb4a; [[rendering]] §SEARCHLIGHT).**
|
||||
The port's `TickSearchlight` (btl4vid) is the `PullFogRenderable` equivalent: LightOn →
|
||||
`SetFogStyle(searchLightOn/OffFogStyle)` with the binary's inverted-cache seed (authentic DARK
|
||||
night start), plus the external spot.bgf beam cone (btfx `brighten` additive veil) on the
|
||||
searchlight site joint, MP-replicated. Historical attribution notes kept below — the
|
||||
"ORIGINAL 1995 LATENT BUG" premise was ❌ RETRACTED 2026-07-25 (#61).** The arcade swaps fog between `fog=` (lights on) and
|
||||
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
|
||||
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
|
||||
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
|
||||
@@ -229,15 +249,10 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
|
||||
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
|
||||
carries no novice gate. All T1.]
|
||||
Searchlight's handler set is now WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
||||
`lightState 0→1`. So the sim needs **no repair** and the previous "DECISION: faithful to the buggy
|
||||
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a
|
||||
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside
|
||||
pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new
|
||||
`LightStatePtr()` accessor). `ControlsAllowLights()` is WIRED since issue #2 to the `player+0x25c`
|
||||
not-novice experience flag via the BTPlayerExperienceSimLive bridge ([[experience-levels]]).
|
||||
See [[rendering]] fog section. (The pre-#61 "verified inert live: BT_FOG_LOG zero `SetFogStyle(2/3)`"
|
||||
observation still holds — reason (1), the un-constructed renderable, remains.)
|
||||
Searchlight's handler set is WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
||||
`lightState 0→1`; the fog watcher + beam cone landed 2026-08-05 (see the header above), so no
|
||||
remaining work rides this entry. (NB the 1995 searchlight carries NO novice gate — that
|
||||
lockout is ThermalSight's; raw disasm @004b838c.)
|
||||
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
|
||||
FILED AS GITEA #65
|
||||
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
|
||||
@@ -694,6 +709,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
destroyed-subsystem dark-panel look.
|
||||
|
||||
## Locomotion / combat polish (non-gating)
|
||||
- **Myomer climb term is DEAD in the port: the gravity operand samples 0** (found in the #96
|
||||
cruise benches, `[myoheat] g=0` every line — `BTMechMyomerMotionSample` reads
|
||||
`GetEnvironment()->gravityConstant` and gets 0; the binary reads `**(mover+0x250)`, a POINTER
|
||||
the Mover holds to the live gravity cell). Harmless on flat ground (|vy|≈0 anyway) but the
|
||||
climb-work heat never accrues on slopes. Find where our Environment gravity actually lives
|
||||
and re-point the bridge. [T2 measured]
|
||||
- **Engine Mover gravity is dt-LESS per-frame in the binary** (`@0x421e77: vy -= **(+0x250)`,
|
||||
raw, once per Perform — same class as the myomer kinetic term). If our linked WinTesla
|
||||
MOVER.cpp kept that form, gravity acceleration in the port scales with OUR ~59Hz frame rate
|
||||
(≈2.1× the pod's 28) — jump arcs, falls, missile droop all stiffer than the pod. CHECK the
|
||||
WinTesla source's gravity line; if per-frame, it needs the same 28Hz reference-rate
|
||||
normalization the myomer kinetic term got. [T1 binary side / T4 port impact until read]
|
||||
- **Gait-noise phantom acceleration feeds the myomer accel term** (`[myoheat] a=4..33` at
|
||||
steady cruise — our per-frame velocity re-derivation jitters; the binary read the same
|
||||
`AccelerationLastFrame` cell off ITS OWN gait, noise level unknown). Bounded contributor
|
||||
(~25-40% of cruise heat-in); revisit only if the 28Hz-faithful feel is contested. [T3]
|
||||
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
|
||||
mech was leaning −8° into every walk because the authentic ctor clip-set gate
|
||||
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
||||
@@ -901,6 +932,40 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
|
||||
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
|
||||
|
||||
## Score/death report tail residuals (2026-08-05, #45/#134 reconstruction — the tail itself is DONE + benched, [[combat-damage]])
|
||||
- **Mech-table 0x17 "SetBurningState" label/body MISMATCH.** The recovered name row says
|
||||
Set/ClearBurningState for 0x17/0x18, but @0x49f674's body randomizes the mech's position from
|
||||
two scaled randoms, rebuilds localToWorld, sets graphicAlarm level 2, ForceUpdates and clears
|
||||
the not-simulated flag — nothing burns. Possibly a shifted name row (cf. the factory ClassID
|
||||
mislabels). Neither handler is reconstructed (nothing in the port sends mech-0x17/0x18);
|
||||
identify the real bodies before wiring. The DEATH-tail id-0x17 message that was once labeled
|
||||
"SetBurningState dispatch" is RESOLVED: it is Player::VehicleDeadMessage (player table, not mech).
|
||||
- **Does @0x4c05c4 (VehicleDead handler) CONSUME the BT extension fields** (+0x2c killed-by
|
||||
player, +0x34 kill zone)? The function is not in the export; our #52 reconstruction predates
|
||||
the fields. If it reads them, the likely consumer is a killed-by console post
|
||||
(ConsolePlayerMechDeathWithoutHonor / a killed-by line) + maybe an RP-style "DestroyedBy"
|
||||
status banner on the victim. The port SENDS them faithfully; raw-disasm the handler to close.
|
||||
- **Collision-death tail fallthrough is inspection-tier [T3]:** the divert now falls to the death
|
||||
tail (VehicleDead + blast) per @0x4a0375, but no live wall-death bench has run; field
|
||||
wall-deaths exercise it nightly (watch for DEAD_NOTIFY on collision deaths).
|
||||
- **The 1995 type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
|
||||
shipped binary folds an uninitialized stack float into the shooter's score on every non-lethal
|
||||
hit's type-0 report. Era scoreboards looked sane — worth understanding WHY (dispatch path may
|
||||
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
|
||||
deviates safely (award=0). Curiosity, not a blocker.
|
||||
|
||||
## HUD range-bias @0x22C — read but NOT reconstructed (2026-08-08) [T1 read, unimplemented]
|
||||
`HudSimulation` subtracts `_DAT_004b7ecc` = **100.0f** from `RangeToTarget@0x1EC` on **every frame
|
||||
that the flag @0x22C is non-zero**, while accumulating a timer @0x21C by `time_slice` until it
|
||||
reaches the limit @0x1D8, at which point BOTH the timer and the flag are cleared. So the authentic
|
||||
HUD has a timed **−100 m range offset** state that our targeting step (mech4.cpp — which does
|
||||
reconstruct the 500 m/s slide correctly) never applies.
|
||||
**Open:** what SETS @0x22C. Candidates not yet checked — a targeting-computer damage/jam effect, an
|
||||
ECM/spoof, or a weapon-lock transient. Found while correcting hud.cpp's stand-in constant block
|
||||
(the values are now byte-grounded: ec4 0.75 / ec8 1.0 / ecc 100.0 / ed0 0.0 / f90 0.0). Details:
|
||||
[[gauges-hud]] §Right ladder. Worth resolving before trusting any field report about the range
|
||||
readout being wrong — it is a real behavioural gap, not a cosmetic one.
|
||||
|
||||
## Rendering follow-ups (non-blocking)
|
||||
- ~~Per-pilot mech PAINT (color/badge/patch)~~ — **DONE 2026-07-17, verified live** (crimson MadCat +
|
||||
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
|
||||
@@ -967,6 +1032,25 @@ stability problems or massive lag spikes"*. Relevant to the carried cross-fire-s
|
||||
silent-node-crash opens: neither reproduced at 5 players this session. Not evidence of a fix
|
||||
(neither was addressed), but it bounds how often they occur.
|
||||
|
||||
## Post-re-export leads (2026-08-06, #60 closed — [[source-completeness]], `phases/phase-04-gap-census.md`)
|
||||
- **`@0x4c0904` is the MASTER BTPlayer Performance — our `@0x4c083c` attribution needs a
|
||||
re-check.** Newly lit by the re-export: it resolves the player's Team by name out of the
|
||||
`Teams` registry (error string "Team ... does not exist"), calls `@0x4c083c` and the engine
|
||||
`Player::PlayerSimulation` (`@0x42e100`), then at mission-end (state 4, past a threshold,
|
||||
once) posts "Sending EndMission" to the console host, and on a timer posts the periodic
|
||||
console SCORE update (`Round(currentScore)` — corroborating our hand-built scoreboard
|
||||
heartbeat). btplayer.cpp currently documents `@0x4c083c` as PlayerSimulation; read both and
|
||||
fix the attribution + fold in the team resolution (we have no Team-by-name resolve today).
|
||||
- **`0x480a2a-0x487ca0` (~9 KB, l4splr|btmssn boundary) stayed dark THROUGH the re-export** —
|
||||
the gap fill reached it with neither a call nor a data reference, which is itself a finding:
|
||||
dead code, data misparsed as code, or reachable only through a jump table. The earlier
|
||||
spot-check showed a real `enter 0x3c` function driven by a global-index dispatch table
|
||||
(`[0x4fef88]` → table `0x501588`), so "dead code" is unlikely. Byte-scan for the table's
|
||||
writers to find the entry path.
|
||||
- **~41 KB is still dark overall**, mostly munga/munga_l4 (we compile the real engine source, so
|
||||
low value). Current map: `reference/decomp/GAP_CENSUS.md`; re-run the pipeline after any
|
||||
future re-export.
|
||||
|
||||
## Key Relationships
|
||||
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
||||
- Gating master: [[source-completeness]] (the missing BT source).
|
||||
@@ -1079,3 +1163,34 @@ tuning from taste into arithmetic.
|
||||
Conn Man saw heat build-up in Standard while Rajel expected none there. See
|
||||
Gitea #116 and `experience-levels.md`.
|
||||
|
||||
|
||||
## Searchlight residuals (2026-08-05 — feature LIVE, [[rendering]] §SEARCHLIGHT)
|
||||
- **Cone look pass pending**: spot.bgf hangs on the site joint with the site's rest transform;
|
||||
the binary's per-case offset (local_2f0 at @004cef28 case 0xbd8) is believed to be the same
|
||||
site offset [T3] — eyeball the size/aim on madcat/loki at night, then clear this.
|
||||
- **BTL4VideoRenderer::LoadObject (@00498448) is a btstubs no-op** — the MechMarker beacon case
|
||||
(MakeEntityRenderables 0xBBA) silently gets NULL and has never drawn. The searchlight went
|
||||
around it (d3d_OBJECT::LoadObject direct). Reconstruct the wrapper or migrate the marker.
|
||||
- **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night
|
||||
pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing"
|
||||
on some maps, it's page authoring, not the mechanism.
|
||||
|
||||
|
||||
## Replicant-side TakeDamage delivery -- mechanism uncharacterized (2026-08-11, from the #162 audit) [T4]
|
||||
|
||||
Replicant mech copies demonstrably EXECUTE `Mech::TakeDamageMessageHandler` (matchlog `DMG
|
||||
inst=R` rows; the #150 bench observed replicant-side type-2 arms) -- yet every damage producer
|
||||
uses `Entity::Dispatch`, and ENTITY.cpp:235-272 reroutes a replicant target's dispatch to the
|
||||
wire WITHOUT local execution. Something delivers damage to the local replicant copy anyway
|
||||
(wire-seam double-delivery? interest/watcher path? a port-side mirror?), and the 2026-07-25
|
||||
corpus saw 0 of 18,818 DMG rows inst=R -- so the behavior APPEARED between then and 2026-08-09.
|
||||
The #162 fix GATES every scoring/death consequence of it (reports + death tail are master-only),
|
||||
so the scoreboard is safe regardless -- but the delivery mechanism itself is unmapped, and
|
||||
replicant-side execution may have OTHER un-audited side effects (zone/subsystem state divergence
|
||||
on peer copies beyond the intended visual application). To close: instrument the entry with the
|
||||
delivery path (who posted it, from which socket/queue), find the first build where inst=R rows
|
||||
appear, and decide whether the delivery is intended (visual-damage design) or a seam bug.
|
||||
DATAPOINT (2026-08-11, the #162 retest wave): the kd4/kd8 benches audit this explicitly (I8)
|
||||
and saw **0 inst=R DMG rows across three 4-node runs and one 8-node melee** (35 deaths total)
|
||||
on build ~900 -- the behavior did not reproduce on the bench rig; whatever produced the
|
||||
night-15 field rows needs field conditions (steam wire seam?) the LAN relay path lacks.
|
||||
|
||||
+385
-6
@@ -54,9 +54,10 @@ button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x
|
||||
the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D
|
||||
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
|
||||
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
|
||||
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **WIRED 2026-07-26**, the last handler in
|
||||
this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see
|
||||
[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16
|
||||
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **COMPLETE 2026-08-06**: handler (07-26) +
|
||||
the master-perf posture CONSUMER + squat/rise clips + MP replication ([[locomotion]] §CROUCH;
|
||||
glass key F4); the map legend's `bduck.pcc` widget still lights. (The old "no code consumer"
|
||||
verdict was export-gap blindness — [[reconstruction-gotchas]] §20) —, **0x28 → Mech `BalanceCoolant` (0x16
|
||||
@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
|
||||
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
|
||||
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
|
||||
@@ -183,6 +184,379 @@ All cockpit surfaces are bit-plane MASKS over ONE shared `SVGA16` pixelBuffer: `
|
||||
byte; `Heat`=0x4000, `Mfd2`=0x0400, `Comm`=0x8000, `Mfd1`=0x0100, `Mfd3`=0x1000; `Eng1-3` =
|
||||
engineering-mode alt planes; `overlay`=0x00C0 (shares the sec surface). See [[gauges-hud]]. [T2]
|
||||
|
||||
## ⭐ THE RGB SPLIT — how ONE VGA port drives THREE mono MFDs (decoded 2026-08-06) [T0 engine source + T1 authentic pod config]
|
||||
The five monochrome MFDs are NOT five video outputs. Each VGA port's **R, G and B analog lines
|
||||
are split to three separate monochrome monitors**, and the software puts a different MFD in each
|
||||
colour channel of one shared palettized framebuffer. Mechanism, end to end:
|
||||
|
||||
1. **Every surface is a bit-plane + a CHANNEL.** `content/GAUGE/L4GAUGE.CFG` (the authentic 1996
|
||||
pod config) configures each port as `configure(idx, port, rotation, bitMask, clut, COLOUR, palette)`:
|
||||
| port | panel | mask | clut | channel |
|
||||
|---|---|---|---|---|
|
||||
| `Comm` | upper right | 0x8000 | clut2 | **red** |
|
||||
| `Mfd2` (Engineering) | upper centre | 0x0400 | clut2 | **green** |
|
||||
| `Heat` | upper left | 0x4000 | clut2 | **blue** |
|
||||
| `Mfd1` | lower left | 0x0100 | clut1 | **red** |
|
||||
| `Mfd3` | lower right | 0x1000 | clut1 | **green** |
|
||||
| `sec` (+`overlay` 0x00C0) | secondary/radar | 0x003F | clut0 | **rgb** (full colour, rotation 270 — the physically ROTATED portrait CRT) |
|
||||
`Eng1/2/3` are the engineering-page twins of Mfd1/2/3: same monitor, second bit-plane, switched
|
||||
by `reconfigure(...)` giving one plane the channel and the other `blank`.
|
||||
2. **The channel assignment is literally a palette write.** `L4GraphicsPort::BuildSecondaryColor`
|
||||
(L4VB16.cpp) walks the palette entries owned by the port's bit group (`BitWrangler(byteMask,8)`)
|
||||
and writes ONE component: `RedChannel -> triplet->Red`, `GreenChannel -> ->Green`,
|
||||
`BlueChannel -> ->Blue`, `AllChannels -> the whole triplet`. `BlankColor` blanks the group
|
||||
(`BlankPalette()`), which is how a page swap silences the plane it replaces. The
|
||||
`*TransparentZero` variants skip colour 0 so zero reads as transparent for that group.
|
||||
3. **So the DAC output carries three independent pictures**, one per analog line, and the splitter
|
||||
hands each line to its own mono monitor. Three MFDs per VGA port; the pod's two MFD ports are
|
||||
the **1280x480 "horizontally spanned" surface** (2 x 640x480 halves) the Displays section
|
||||
describes — clut2 = the upper row (Comm/Mfd2/Heat), clut1 = the lower row (Mfd1/Mfd3, blue
|
||||
spare). The radar rides its own port in real colour.
|
||||
|
||||
**Why this matters for the port [T2]:** our modern path renders each surface as its own
|
||||
mono-tinted window on its own Windows display (see §MFD PANELS ON REAL HARDWARE), which is right
|
||||
when every panel has its own output. **On splitter-wired glass it is wrong** — three monitors
|
||||
would share one Windows display and each would show only its channel's share of a single tinted
|
||||
image.
|
||||
|
||||
**`BT_POD_RGB=1` — the CHANNEL-COMPOSITE mode (built + field-verified 2026-08-06) [T2].** It
|
||||
collapses the six panel windows into **three**: two VGA-port windows plus the radar. Each port
|
||||
window composites its group's planes into ONE 640×480 BGRA image, each member ORed into its own
|
||||
channel mask (`0x00FF0000` / `0x0000FF00` / `0x000000FF`), so the analog splitter downstream hands
|
||||
each mono CRT exactly its picture. Grouping follows `L4GAUGE.CFG`: *VGA Port A* = Heat + Comm +
|
||||
Mfd2, *VGA Port B* = Mfd1 + Mfd3; the radar keeps real colour on its own port.
|
||||
`SVGA16::ExpandPlaneToBGRA` does the per-plane extraction, and `BT_POD_RGB` implies
|
||||
`BT_POD_SURFACES`. Two companion gates came out of the bring-up: **`BT_POD_CHANMAP=<Port>=<colour>,…`**
|
||||
remaps a surface's colour line when a cab's harness disagrees with the 1995 config (it did — see
|
||||
§ALPHA-MR), and **`BT_POD_IDENT`** / **`BT_POD_CHANTEST`** paint identifying/cycling test patterns
|
||||
so you can tell which physical CRT is which without trusting the desktop layout.
|
||||
**SETTLED:** Nick's cart IS splitter-wired — its three 640×480 "displays" are VGA outputs, each
|
||||
feeding a three-monitor splitter, and the composite is what lit the glass correctly.
|
||||
|
||||
## MFD PANELS ON REAL HARDWARE — the bring-up path (2026-08-06) [T2 local / T4 on-pod]
|
||||
Nick's crash cart (pod hardware + Chrome Remote Desktop on a burner account) is the first chance
|
||||
to drive the real panels. **The 1995 display path is NOT the way in.** That rig spanned the five
|
||||
MFDs as ONE 1280×480 surface via **NVIDIA Horizontal Span, which every driver after XP dropped**;
|
||||
`SVGA16::BuildWindows` also wants an exclusive-fullscreen D3D device per adapter, which is
|
||||
fragile on modern drivers and over a remote session. Both are still in-tree and still the
|
||||
authentic reference — they are just not the bring-up route.
|
||||
|
||||
**The route is the glass per-display windows** ([[glass-cockpit]]): one window per surface, which
|
||||
maps 1:1 onto one physical panel per MFD, needs no special driver, and is already field-proven on
|
||||
desktops. Two pieces were added for the cab:
|
||||
- **`BT_POD_SURFACES=1` (pod surface mode, L4GLASSWIN)** — crops every window to its SURFACE
|
||||
(MFDs exactly 640×480, radar 480×640 portrait), drops the on-screen RIO button banks (the cab's
|
||||
buttons are PHYSICAL — drawing fake ones over a real panel is precisely wrong), goes frameless,
|
||||
and does not create the Flight Controls pad at all (6 windows, not 7). Per-window equivalent:
|
||||
append `,bare` to a line in `glass_layout.cfg` (mixed rigs).
|
||||
- **Placement receipts** — every window logs `[glasswin] '<title>' surface=<port> at X,Y WxH
|
||||
bare -> monitor \.\DISPLAYn (origin WxH, PRIMARY)`. On a cab nobody can see seven surfaces at
|
||||
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
|
||||
landed on the right glass.
|
||||
|
||||
### Boot-STABLE panel binding — `monitor:id:` (DEPLOYED + POD-VERIFIED 2026-08-10)
|
||||
**Live on ALPHA-MR since 2026-08-10** — mapping eye-verified over livestream, then the master
|
||||
`C:\bt411\glass_layout.cfg` flipped to the id form (old cfg saved as
|
||||
`glass_layout.cfg.displaynbak`) and the relaunch bound all three windows to the SAME rects with
|
||||
zero mismatch shouts [T2]. The shipped binding:
|
||||
```
|
||||
Secondary / Radar=monitor:id:Default_Monitor,bare # colour radar LCD, USB adapter
|
||||
VGA Port A=monitor:id:UID224795,bare # RAR0005: Heat + Comm + Mfd2 CRTs
|
||||
VGA Port B=monitor:id:UID200195,bare # RAR0005: Mfd1 + Mfd3 CRTs
|
||||
```
|
||||
⚠ **The radar LCD's USB display adapter presents NO EDID** — its only stable identity is the
|
||||
`Default_Monitor` placeholder (`…#Default_Monitor#…&UID256#`). Unique today; a second EDID-less
|
||||
panel would collide, and the per-boot `[glassid]` dump (BT_GLASS_IDS=1, now PERMANENT in
|
||||
`podprofile.ini`) plus the mismatch shout are the diagnosis tools. The Dell (`DEL4025`) is the
|
||||
PRIMARY main-view panel (BT_FIT owns it; not in the layout cfg) — the 08-08 note calling it a
|
||||
"1908FP" service monitor mis-guessed its role.
|
||||
**Windows renumbers displays.** Nick, after re-cabling + a reboot: *"the order changed … sometimes
|
||||
they change when one gets turned off and back on, at least how windows SEEs them, even if the
|
||||
visual desktop tool looks the same."* So the two original binding forms are both boot-fragile —
|
||||
`monitor:2` is an enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
|
||||
(Same trap next door in GameOS: its `-tmon` takes **DirectDraw device indices**, which are neither
|
||||
Windows monitor numbers nor stable, and a NULL-device merge shifts every index down by one on top
|
||||
— see Nick's `gos-displays.txt`.)
|
||||
|
||||
**The fix: bind to the panel's own hardware identity.** `EnumDisplayDevices` on a display's MONITOR
|
||||
child returns a DeviceID embedding the **EDID manufacturer + product code** and the connector
|
||||
instance — neither moves across a reboot or a power-cycle.
|
||||
|
||||
- **Discover:** run once on the pod with `BT_GLASS_IDS=1`. Every attached panel logs its
|
||||
`stable-id` **and a ready-to-paste `cfg form`**:
|
||||
```
|
||||
[glassid] index=0 device=\\.\DISPLAY1 PRIMARY rect=0,0 1920x1080
|
||||
stable-id = \\?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-…}
|
||||
cfg form = monitor:id:AUO10ED
|
||||
```
|
||||
The volatile identifiers print beside the stable one deliberately: run it twice across a
|
||||
power-cycle and `index`/`device` move while `stable-id` does not — that IS the proof.
|
||||
- **Bind:** `Heat MFD=monitor:id:AUO10ED,bare` in `glass_layout.cfg`.
|
||||
- **Identical panels** (the pod's mono MFDs are likely one model, so EDID codes collide): use a
|
||||
longer fragment from `stable-id` — the `UID…`/instance tail differs per connector, so
|
||||
`monitor:id:UID265988` picks exactly one.
|
||||
- **An `id:` that matches nothing WARNS and falls back** to computed placement:
|
||||
`[glasswin] monitor id 'X' matched NO attached panel`. Silence there would put a picture on the
|
||||
wrong glass and look exactly like the bug this form exists to prevent.
|
||||
- **Nothing changes by default.** No env, no `id:` → identical behaviour; `monitor:<name|index>`
|
||||
and raw `x,y` keep working. Playtester glass builds are untouched. [T2 verified on a 1-monitor
|
||||
dev box — discovery, binding, centring and the mismatch warning; **T3 on the pod**, which was
|
||||
offline: multi-panel disambiguation is unproven.]
|
||||
|
||||
**Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC):
|
||||
1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original
|
||||
panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that
|
||||
assigns the six surfaces to the non-primary monitors top-to-bottom/left-to-right, centred.
|
||||
2. Drop the cfg in the game's working directory; run with `BT_GLASS_PANELS=1
|
||||
BT_POD_SURFACES=1 BT_GLASS_LAYOUT=load` (`=save` to persist drags instead).
|
||||
3. Read the receipts; re-assign titles to monitors in the cfg until each picture is on its panel.
|
||||
Surface→panel roles: Heat MFD = upper left (coolant), Engineering = upper centre, Comm MFD =
|
||||
upper right (hot box), Left/Right Weapons = lower left/right, Secondary/Radar = the secondary
|
||||
screen (portrait). Main 3D view stays the game's own window on the main-view monitor.
|
||||
|
||||
**Known constraints / open on-pod questions:** whether the panels are attached to this PC at all
|
||||
(the probe answers it); whether Windows offers a 640×480 mode on them (if not, the surface renders
|
||||
at native size CENTRED, not scaled — a scale-to-fit option is the obvious follow-up); whether
|
||||
Chrome Remote Desktop holds the CONSOLE session (it should — an RDP session would get a virtual
|
||||
display and light nothing); and the RIO serial input, which is a separate task from the displays.
|
||||
|
||||
## ALPHA-MR — the verified cart config, FROZEN (2026-08-06) [T2, field-verified by eye]
|
||||
The bring-up above landed: real pictures on real pod glass, then user-confirmed correct
|
||||
("ok this is correct"). What that took, and where it now lives permanently.
|
||||
|
||||
**The rig.** Win10 IoT Enterprise LTSC on the cart; Intel HD630 + a "Trigger 6 External Graphics"
|
||||
USB adapter. Four console displays: **DISPLAY3 800×600 PRIMARY at 0,0** (main view), plus
|
||||
**DISPLAY4 @800,122**, **DISPLAY2 @1440,123**, **DISPLAY1 @2080,124** (each 640×480). Physical
|
||||
cab: five mono MFD CRTs (two left stacked, two right stacked, one centre loose on the desk) plus
|
||||
the colour radar LCD in the yellow-button frame.
|
||||
|
||||
**The mapping** (`glass_layout.cfg`, `monitor:<name>` binding — see [[glass-cockpit]]):
|
||||
```
|
||||
Secondary / Radar = monitor:DISPLAY4,bare # colour LCD, yellow-button frame
|
||||
VGA Port A = monitor:DISPLAY2,bare # RGB triple: Heat + Comm + Mfd2
|
||||
VGA Port B = monitor:DISPLAY1,bare # RGB triple: Mfd1 + Mfd3
|
||||
```
|
||||
Three windows, not six — under `BT_POD_RGB` each VGA-port window carries three MFDs on its three
|
||||
colour planes (§THE RGB SPLIT). Desktop-coordinate order says NOTHING about physical cab position:
|
||||
the first assignment, made by sorting monitors left-to-right, was wrong. Only eyes on the glass
|
||||
settle it.
|
||||
|
||||
**Two deviations from the 1995 config, both real, both this cab's wiring:**
|
||||
- **`BT_POD_CHANMAP=Comm=blue,Heat=red`** — kills/deaths and coolant loops came up on each
|
||||
other's panel. `L4GAUGE.CFG` has Comm on red and Heat on blue; THIS cab's splitter harness
|
||||
wires them the other way. The env override remaps a port's colour channel without touching the
|
||||
authentic config file. [T2]
|
||||
- **`BT_GAUGE_SEC_ROT=0`** — the radar here is a LANDSCAPE LCD, so the 640×480 source needs no
|
||||
rotation. The 1995 pod's 270° rotation existed because its secondary CRT was mounted PORTRAIT;
|
||||
it is a mounting fact, not a format fact. (En route: 180° looked plausible and was upside down.)
|
||||
|
||||
**Frozen in `content/environ.ini`**, not in a launcher — `scratchpad/pod/podprofile.ini` is the
|
||||
block, `scratchpad/pod/mergeprofile.ps1` merges it idempotently between markers. environ.ini is
|
||||
read before anything touches the environment ([[build-and-run]]), and the real environment still
|
||||
WINS, so a .bat can override any of it for a one-off. The cart's `runpod.bat` now sets only
|
||||
`BT_LOG`/`BT_GLASS_LOG` and the `-egg`; `runpod_env.bat` keeps the all-inline version as the
|
||||
fallback. Two gates were added for this:
|
||||
- **`BT_FIT=1`** — the env spelling of `-fit`, so the borderless main view survives however the
|
||||
game is started (shortcut, scheduled task, autostart) instead of riding on one launcher's
|
||||
command line. It fits `MONITOR_DEFAULTTOPRIMARY` = DISPLAY3, which is the main-view panel.
|
||||
- **`L4PLASMA=NONE`** (also `OFF`/`0`) — no marquee at all. Needed because the GLASS profile
|
||||
force-defaults `L4PLASMA=SCREEN`, so simply leaving it unset still puts a desktop plasma window
|
||||
on the pod's glass. The boot banner reports `plasma off [L4PLASMA]` when it takes.
|
||||
Verified on build 4.11.813 with the launcher carrying none of it: `[boot] environ.ini: 9
|
||||
setting(s) applied`, `[cockpit] -fit: borderless 800x600`, `[glasswin] radar rotation 0 (none)`,
|
||||
and all three surfaces on their intended `\\.\DISPLAYn`.
|
||||
|
||||
**Surviving a NEW BUILD, not just a reboot.** Both frozen files live INSIDE the versioned install
|
||||
(`content\environ.ini`, `content\glass_layout.cfg`) and NEITHER ships in the zip — environ.ini is
|
||||
generated on first run. So extracting a new build gives a cab that comes up wrong with no error
|
||||
anywhere. The masters therefore live at the stable `C:\bt411\` (`podprofile.ini`,
|
||||
`glass_layout.cfg`, `podkit.ps1`), and **`setup_pod.bat` pushes them into the newest `BT411_*`
|
||||
folder** — run it once after each extract. `runpod.bat` resolves the newest install and applies
|
||||
the kit itself before launching, so the remote path needs no hand-editing per build. Re-tuning the
|
||||
cab means editing the MASTER at `C:\bt411\`, not the install's copy: the apply overwrites it, on
|
||||
purpose, so there is one place to look when a panel moves. Repo copies: `scratchpad/pod/`.
|
||||
|
||||
**The tester launchers work on the cab unchanged (verified 2026-08-06) [T2].** `play_solo.bat`,
|
||||
`join.bat`, `join_lan.bat`, `play_steam.bat` and `joyconfig.bat` set `BT_PLATFORM=glass`,
|
||||
`BT_START_INSIDE=1`, `BT_DEV_GAUGES=1` plus their own mode flag — and **not one pod key** between
|
||||
them. So each inherits the whole rig from environ.ini without knowing the pod exists. This is the
|
||||
argument against a separate pod-only settings file: a second file would need every launcher to opt
|
||||
in, which is the shape of the "competing keymaps" bug that made glass the default profile.
|
||||
Observed: `play_solo.bat` on the cart logs `environ.ini: 7 setting(s) applied` — 7 not 9, because
|
||||
the bat sets `BT_PLATFORM` and `BT_START_INSIDE` itself and the real environment wins.
|
||||
Steam MP needs the **client installed and logged in** (AppID 480/Spacewar, so any account, no
|
||||
purchase); installed on the cart 2026-08-06. ⚠ **#68: a failed JOIN/HOST exits the process
|
||||
SILENTLY** — "Steam not running" and a real crash look identical to a player.
|
||||
|
||||
### The HARDWARE RIO is live on the cart (2026-08-06) [T2]
|
||||
`L4CONTROLS=RIO:COM1,KEYBOARD` in the frozen profile — the real cockpit board instead of PadRIO.
|
||||
The whole stock path above the seam (mapper, lamps, streamed `.CTL` mappings) runs unchanged; the
|
||||
109-mapping `L4` control table installs exactly as it does on a desktop. **This is independent of
|
||||
the platform profile** — the cab keeps the GLASS display stack (panels + RGB split) and only the
|
||||
input device changes, which is what the glass/PadRIO seam was built for. Do NOT reach for
|
||||
`BT_PLATFORM=pod` to get it.
|
||||
|
||||
- **Wiring:** `RIO:COMn` → `\\.\COMn` at **9600 8N1** (`L4SERIAL`); bare `RIO` defaults to COM1.
|
||||
Reads are non-blocking (`ReadIntervalTimeout=MAXDWORD`, totals 0), so an absent or dead board
|
||||
cannot hang the game — it just goes quiet.
|
||||
- **RIO and PAD are MUTUALLY EXCLUSIVE** — both assign `rioPointer` in the `L4CONTROLS` parser, so
|
||||
the last token wins. Selecting the hardware RIO turns the XInput pad OFF on that machine.
|
||||
`KEYBOARD` is safe alongside: it only sets `flags.keyboardExists` and never touches
|
||||
`primaryControlType`.
|
||||
- **The board's own self-test is the diagnostic.** `RIO::RIO` runs it (`perform_tests = True` by
|
||||
default) and writes `content\FAILURE.LOG` pages `RIOBoardErrors` / `RIODeadLamps` / `RIOErrors`,
|
||||
deleting them first so the file is always current. On the cart, reproducibly: **4 missing boards**
|
||||
(`Slot=3:Address=0`, `3:2`, `4:0`, `5:0`) and **16 dead lamps** (`AuxUpperRight1-8`,
|
||||
`AuxUpperCenter1`, `TeslaRelay1/3`, `PanicButton`, `IcomAmpEnableRelay`, `IcomIncRelay`,
|
||||
`FloorEntry`, `undefined_0x17`) — the crash cart is a PARTIAL cab, and those are the boards it
|
||||
does not have. **A specific 4-of-many inventory is itself the proof the link is good**: a dead
|
||||
serial line reports the whole address space missing, not four slots.
|
||||
- **Input reaching the mech** was seen live as `[ctrlmap] push stick x=0.0595238 y=0` with nobody
|
||||
at the cab — the physical stick sitting just outside the 5% deadband. NB when the stick is
|
||||
centred this reads `x=0`, which is indistinguishable from no data: a centred-stick zero proves
|
||||
nothing either way. ⚠ **Not yet checked by hand:** that stick/throttle/pedal travel and the
|
||||
physical buttons drive the mech correctly, and whether the cab's pots match the `Ranger`
|
||||
calibration baked into `RIO::RIO` (`JoystickX -96..96`, `JoystickY -97..108`, `Throttle 0..800`,
|
||||
pedals `0..470`). A mismatch shows up as drift or short travel, not as an error.
|
||||
- The boot banner names the resolved device — `GLASS (hardware RIO; plasma off [L4PLASMA])`. It
|
||||
used to hardcode "PadRIO", which is a lie on a wired cab and exactly the line you read to check.
|
||||
- ⚠ **THE COUPLING BUG this exposed (fixed 2026-08-06) [T2].** `BTGlassPanels_Create()` was called
|
||||
ONLY from the end of the **PadRIO constructor** — the panels began life as the frames around the
|
||||
on-screen RIO button banks, so "the buttons ride the device". Selecting the hardware RIO means
|
||||
PadRIO is never constructed, so **every MFD window silently failed to appear**: the mission came
|
||||
up on the main view with all the pod glass dark, and not one `[glasswin]` line in the log. The
|
||||
MFD panels are a DISPLAY concern, so creation moved to `LBE4ControlsManager` after the
|
||||
`L4CONTROLS` parse, where every device branch converges — plus a symmetric `BTGlassPanels_Destroy()`
|
||||
in its destructor, because `~PadRIO` tore them down and `~RIO` knows nothing about them (windows
|
||||
outliving the surfaces they blit would crash on the next mission cycle). Both calls are
|
||||
idempotent (`Create` returns on `gWinCount != 0`), so the PadRIO path is unchanged and simply
|
||||
arrives first.
|
||||
|
||||
**Working on the cart, remotely.** SSH over Tailscale lands in **session 0**, which has a dummy
|
||||
"WinDisc" display and CANNOT see or enumerate session 1's windows — GUI work must go through
|
||||
`schtasks /run /tn BT411Run` (task registered `/IT`), and window enumeration over SSH silently
|
||||
returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or the file is
|
||||
locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an
|
||||
otherwise-logging run is real evidence that code path did not execute.
|
||||
|
||||
### The remote runbook — READ THIS BEFORE IMPROVISING (2026-08-08)
|
||||
Every line below cost real time to rediscover on a live stream. Follow it in order.
|
||||
|
||||
**1. Connect.** `ssh bt411-pod` (alias in `~/.ssh/config`) → `bt411-pod.tail840fa4.ts.net`, user
|
||||
`user`, key `~/.ssh/bt411_pod`.
|
||||
⚠ **The key has existed since 2026-08-06 but ssh will NOT offer it without the config entry** — it
|
||||
only tries default names (`id_rsa`/`id_ed25519`). Without it you get
|
||||
`Permission denied (publickey,password,keyboard-interactive)`, which reads exactly like "auth was
|
||||
never set up". It was. Also: the Tailscale **node** KeyExpiry (2027-02-02) is what keeps the cab on
|
||||
the tailnet — it is **not** an SSH credential, and Tailscale SSH is **not** enabled on the pod (the
|
||||
peer advertises no `sshHostKeys`), so port 22 is the pod's own Windows OpenSSH.
|
||||
|
||||
**2. Absolute paths for System32 tools.** Over this SSH+cmd session, bare `taskkill` / `setx`
|
||||
return `The system cannot find the path specified.` and silently do nothing. Use
|
||||
`C:\Windows\System32\taskkill.exe`. (`tasklist`, `schtasks`, `dir`, `copy` happen to resolve.)
|
||||
|
||||
**3. Deploy — the SAME procedure a tester uses.** No pod-special exe drops.
|
||||
```
|
||||
python tools/mkdist.py # -> dist/BT411_4.11.NNN.zip
|
||||
scp dist/BT411_4.11.NNN.zip bt411-pod:C:/bt411/
|
||||
ssh bt411-pod "powershell -NoProfile -Command \"Expand-Archive C:\bt411\BT411_4.11.NNN.zip -DestinationPath C:\bt411 -Force\""
|
||||
ssh bt411-pod "powershell -NoProfile -ExecutionPolicy Bypass -File C:\bt411\podkit.ps1 -Content C:\bt411\BT411_4.11.NNN\content"
|
||||
```
|
||||
`runpod.bat` auto-resolves the **newest** `BT411_*` by date, so nothing else needs pointing.
|
||||
Local config is **never clobbered**: `mkdist.py` packs git-TRACKED content only, and
|
||||
`bindings.txt`/`environ.ini`/`glass_layout.cfg` are all gitignored. `podkit.ps1` pushes the frozen
|
||||
rig masters (`podprofile.ini`, `glass_layout.cfg`, `PODTEST.EGG`) from `C:\bt411\` into the install.
|
||||
⚠ **`PODTEST.EGG` is not in the repo** — only podkit carries it. If podkit fails, the launcher runs
|
||||
and *nothing appears*, with no error.
|
||||
|
||||
**4. Launch.** `schtasks /run /tn BT411Run` → runs `C:\bt411\runpod.bat` as `user`,
|
||||
LogonType=Interactive, so it lands in **session 1** and is visible on the panels. Log:
|
||||
`<install>\content\podrun.log`.
|
||||
|
||||
**5. ⚠ THE PODKIT INFINITE LOOP — the trap that ate an evening.** `podkit.ps1` line 30 was
|
||||
```powershell
|
||||
while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
|
||||
```
|
||||
When `$keep` trims to ONE blank line, `$keep.Count-2` is `-1` and PowerShell's `$keep[0..-1]`
|
||||
returns **two** elements instead of shrinking — infinite loop, RSS climbing past 60 MB. It fires
|
||||
whenever everything outside `environ.ini`'s marker block is blank, i.e. **any `environ.ini` that was
|
||||
already kitted** — exactly what you get carrying it forward from the previous install. Patched to
|
||||
`-gt 1` on the pod 2026-08-08 (`podkit.ps1.bak` is the original); **this fix is NOT in any repo**,
|
||||
so a restored/replaced podkit brings the bug back.
|
||||
*Signature:* a **blank cmd console** on the pod, task stuck `Status: Running`, **no `btl4.exe`, no
|
||||
`podrun.log`**. *The cascade:* each hung run holds `environ.ini`, so every later attempt blocks too —
|
||||
and over SSH your client times out while the REMOTE powershell keeps running, so "it returned
|
||||
instantly and did nothing" actually means "it is still hung". Recover with
|
||||
`taskkill /F /PID <pid>` (absolute path) on the session-1 `cmd`+`powershell` pair, then
|
||||
`schtasks /end /tn BT411Run` before re-running — a task already Running refuses `/run` with
|
||||
`2147946720` (`0x800710E0`, "operator refused the request").
|
||||
|
||||
**6. Panel identity (measured 2026-08-08, over SSH — WMI is session-independent so this works
|
||||
without a GUI):** `DISPLAY\RAR0005\…UID224795`, `DISPLAY\DEL4025\…UID249395` (DELL 1908FP),
|
||||
`DISPLAY\RAR0005\…UID200195`. **The two RAR panels share one EDID code and have blank serials**, so
|
||||
`monitor:id:RAR0005` is ambiguous — they must be bound by the per-connector form
|
||||
(`monitor:id:UID224795` / `monitor:id:UID200195`). **RESOLVED 2026-08-10:** the full map is
|
||||
UID224795 = VGA Port A (Heat+Comm+Mfd2), UID200195 = VGA Port B (Mfd1+Mfd3), `Default_Monitor`
|
||||
(EDID-less USB adapter, UID256) = the colour radar LCD, DEL4025 = the PRIMARY main-view panel
|
||||
(not a service monitor). The master cfg now ships the id form (see the binding section above);
|
||||
`glass_layout.cfg.displaynbak` preserves the old DISPLAYn cfg.
|
||||
|
||||
**7. ⚠ cmd redirect trap when scripting over SSH:** `echo VAR=1>> file` appends `VAR=` — cmd
|
||||
parses the digit-adjacent `1>>` as a stream-handle redirect and eats the value (and MSVC putenv
|
||||
treats an EMPTY value as UNSET, so the flag silently never arms). Space it (`echo VAR=1 >>file`,
|
||||
trailing-space payload) or use PowerShell `Add-Content`. Bit us arming BT_GLASS_IDS 2026-08-10.
|
||||
|
||||
**8. Tester-procedure deploy VERIFIED end-to-end (2026-08-10, build 876):** scp zip →
|
||||
`Expand-Archive` → podkit → `schtasks /run` came up clean on the first try — rig config survived,
|
||||
newest-install auto-resolve worked, no hand-edits. The cab genuinely upgrades like a tester box.
|
||||
|
||||
**9a. ⚠ THE RIO's "COM1" IS A USB-SERIAL ADAPTER — the axis-dropout / frozen-boot fault
|
||||
(2026-08-10, minidump-proven) [T1].** `\Device\VCP0` = an FTDI adapter (FTE7PGA5A); COM2 is a
|
||||
second FTDI (A995JTOYA); **two ghost Prolific PL2303 entries also claim "COM1"** from earlier
|
||||
plug-ins (clean them in Device Manager). When the adapter's **USB side** drops mid-operation:
|
||||
brief drop = analog polls go mute (the field "encoder dropouts", revived by hand-jostle /
|
||||
re-enumeration — NOT by button protocol); drop with a write in flight = the 1995 code's
|
||||
**unbounded `GetOverlappedResult` wait in `PCSerialPacket::SendPacket` parked the whole game at
|
||||
0 CPU forever** (three boots on 2026-08-10; stack: NtWaitForSingleObject ← GetOverlappedResult ←
|
||||
SendPacket ← RIO ctor). **Hardened in 4.11.881**: 500 ms bounded write + CancelIo + 3 s sick
|
||||
window (fast-drop, game keeps frame rate, keyboard fallback lives); `txTO=` in the `[rio]` health
|
||||
line counts the events — proven the same evening (`txTO=1 rRetry=4` on a boot that SURVIVED).
|
||||
USB **selective suspend disabled** system-wide on the cab the same day (powercfg) — prime suspect
|
||||
for the random-period idle dropouts. Hardware to-do: FTDI adapter into a REAR motherboard USB
|
||||
port, reseat its USB end. ⚠ A first "print-deadlock" diagnosis of the boot wedge was WRONG
|
||||
(confounded A/B) and is retracted — the dump is the authority.
|
||||
**Day-end state (2026-08-10 evening) + the pick-up plan.** The 881 soak measured the fault
|
||||
precisely: the board answers for **~13 s after each fresh game start** (254 replies at 20 Hz),
|
||||
then goes MUTE on analog for minutes while host→board writes keep completing (`req=3846 rep=254
|
||||
age=240s`, txTO frozen at 1, abandon=0). Direct hand-poll (`tools/probe_rio.ps1`, also at
|
||||
`C:\bt411\probe_rio.ps1` — raw COM1 CHECK/VERSION/ANALOG with correct [cmd][sum&0x7F] framing,
|
||||
DTR/RTS raised): **zero bytes**. USB re-enumeration of BOTH FTDI converters + immediate re-poll:
|
||||
**still zero** — which argues AGAINST the adapter (a wedged adapter RX clears on re-enum) and AT
|
||||
the board. ⚠ [T4 hypothesis for tomorrow]: 13 s ≈ when the mission render load lights up — a
|
||||
**power rail sagging under the cab's full load** would also explain the original mid-combat
|
||||
dropouts (explosion/load transients) and the button "revival" (load dips + harness jostle).
|
||||
Tomorrow, with hands: (1) loopback plug on the FTDI (TX→RX jumper, probe echoes = adapter fully
|
||||
exonerated); (2) 5 V/12 V at the RIO board while a mission loads (the 13 s correlation is the
|
||||
test); (3) swap in one of the spare Prolific adapters as cheap elimination; (4) rear-mobo USB
|
||||
port + ghost-COM cleanup regardless. ⚠ My STALL/RECOVER edge detector in 881 keys on
|
||||
request-age, which the 50 ms poll keeps resetting — it never fires; the `age=` field carries the
|
||||
real signal (fix the keying to reply-age at the next build).
|
||||
**Remote minidump procedure (worked over SSH, no tools install):** 32-bit dump of the wedged pid
|
||||
via `C:\Windows\SysWOW64\rundll32.exe C:\Windows\SysWOW64\comsvcs.dll, MiniDump <pid>
|
||||
C:\bt411\w.dmp full` → scp home → local x86 cdb (`where.exe cdb` resolves the bt411-tools copy)
|
||||
with `-y C:\git\bt411\dist` (the archived per-build PDBs) → `~*k`. A 64-bit comsvcs dump of the
|
||||
WOW64 process shows only 64-bit frames — use the SysWOW64 pair.
|
||||
|
||||
**9. ⚠ `schtasks /end` ORPHANS the game — the dead-RIO trap (2026-08-10):** `/end` kills the
|
||||
batch, but `runpod.bat` `start ""`s the exe DETACHED, so the old btl4.exe survives, keeps COM1
|
||||
open, and the NEXT launch boots with a dead cockpit board: `InitSerialPort() failed
|
||||
[GetLastError() = 5]` (access denied) → `RIO initialization failed!` → buttons dead while the
|
||||
banner still says "hardware RIO" (the banner is the PROFILE, not the init result). Diagnosis:
|
||||
`tasklist /FI "IMAGENAME eq btl4.exe"` showing ≥2 instances. FIXED at the root the same day:
|
||||
`runpod.bat` now `taskkill /F /IM btl4.exe`s FIRST (original saved as `runpod.bat.bak`), making
|
||||
the BT411Run task an idempotent restart — but any hand-rolled launcher on a future cab needs the
|
||||
same line, and a mid-mission `/run` now RESTARTS the game rather than failing quietly.
|
||||
|
||||
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
|
||||
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
|
||||
every checked control behavior:
|
||||
@@ -231,9 +605,14 @@ every checked control behavior:
|
||||
"Top Speed Gimped 40" also confirms the limp-gait speed cap as an authored spec. The port
|
||||
follows the BINARY (house rule); resurrecting manual-4.0 supercharge would be an opt-in
|
||||
deviation for the operator to decide.
|
||||
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen;
|
||||
`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist:
|
||||
EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech
|
||||
NEW LEADS (manual describes, port lacks input/UI): ~~CROUCH~~ ✅ **CROUCH COMPLETE 2026-08-06**
|
||||
([[locomotion]] §CROUCH), ~~EJECT~~ **EJECT WIRED
|
||||
2026-08-02** (core: `Mech::EjectPilotMessageHandler` id 0x19 @0049f854 + the crippled-mech
|
||||
permission evaluator @0049fa1c; input = binding-engine "Eject" action, Backspace / pad
|
||||
LeftThumb; the punch-out kills via graphicAlarm 10 ≥ 9 — KillBonus authors 0 in ALL shipped
|
||||
content, so the zero charge is authentic; tails: console relay notice, RIO 0x38 panic control,
|
||||
alarm-10 audio/canopy presentation, `SpecialCaseDeathPenalty` role+0x20 consumer), hot-box
|
||||
viewscreen framing (the deferred PNAME marker chain). Per-mech
|
||||
stat tables = a systematic cross-check source for our streamed subsystem resources.
|
||||
|
||||
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
|
||||
|
||||
+18
-13
@@ -39,19 +39,24 @@ sections in `docs/PROGRESS_LOG.md` cite old `C:/git/nick-games/...` paths — tr
|
||||
they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
|
||||
content → `content/`, raw decomp → `reference/decomp/`). [T2]
|
||||
|
||||
## Current state (2026-07)
|
||||
`btl4.exe` boots, renders the world + a skinned mech, and runs a full **drive → animate → target →
|
||||
fire → damage → destroy → respawn** single-player loop (task #52). **2-node MP is verified
|
||||
end-to-end** — replication, cross-pod targeting/damage/kill, beam visuals, replicant gait (tasks
|
||||
#46-#51, [[multiplayer]]). All 8 cockpit canopies are authentic + the horizontal-FOV fix (task #55,
|
||||
[[cockpit-view]]); the Gyroscope is live byte-exact with hit-bounce (task #56); the **gauge system
|
||||
is complete** ([[gauges-hud]]). The engine/renderer/HAL are done (WinTesla); AUDIO backend is now REAL
|
||||
(the repo's OpenAL32.dll + libsndfile-1.dll were both no-op STUBS — replaced with real OpenAL Soft +
|
||||
an in-tree WAV loader, 2026-07-15) and the 241-sample soundbank is cracked from AUDIO1/2.RES + loading;
|
||||
the ONLY remaining audio gap is game-triggering (no AudioEntities fire PlayNote yet), so gameplay is
|
||||
still silent apart from a proof-of-life hook (see [[wintesla-port]] Audio). The active work
|
||||
is reconstructing each BT subsystem's authentic behavior from the binary. Remaining: pod-LAN
|
||||
config, Mech-level update records, per-subsystem waves. [T2]
|
||||
## Current state (2026-08-06) — core gameplay reconstruction COMPLETE; polish phase
|
||||
Field-tested nightly at 4-8 pods over Steam (builds 4.11.622→801). Authentic + benched: the full
|
||||
SP+MP loop; per-panel mesh-true targeting/damage (+ crits, cylinder lottery, armour darkening);
|
||||
the 1995 SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
|
||||
death (blast/splash, wreck, burial) + respawn (full re-arm audit); locomotion (two-channel gait,
|
||||
gimp limp, knockdowns, CROUCH F4); the night kit (fog-swap SEARCHLIGHT F5 + beam cone); heat/
|
||||
power/myomers (incl. the seek-4 freeze); replication (masters/replicants, kill/death columns,
|
||||
ghost+skate field detectors); the Steam wire seam + build gate; the operator console/relay
|
||||
lifecycle; gauges/HUD; audio (real OpenAL backend + the AUDIO_FIDELITY trigger waves — footsteps/
|
||||
gait/alarms/impacts live in the field). [T2]
|
||||
**Infra ✅ 2026-08-06:** the #60 export gap census AND re-export shipped — coverage 87.3% →
|
||||
93.5%, dark code 90 → 41 KB, +205 functions, and the re-export independently confirmed the
|
||||
week's raw-disasm reconstructions ([[source-completeness]], `phases/phase-04-gap-census.md`).
|
||||
**Remaining (polish):** VehicleDead killed-by consumption (operator "X killed by Y" — the sender
|
||||
already ships the fields); burning-wreck handler (mech-0x17 label mismatch); `@0x4c0904` master
|
||||
BTPlayer Performance vs our `@0x4c083c` attribution; the id-0x16 type-0 curiosity; MechRIOMapper
|
||||
Keypress @004d2514; marker beacon (LoadObject wrapper stub); DIV firmware intersection routine
|
||||
(deep-cut); deferred ledger items in [[open-questions]]. [T2]
|
||||
|
||||
## Key Relationships
|
||||
- Full detail: `docs/PROGRESS_LOG.md`.
|
||||
|
||||
@@ -278,6 +278,12 @@ Suspect ANY reconstructed per-frame code with narrow equality/window tests or `x
|
||||
state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
|
||||
|
||||
## 13. Verification gotchas (don't fool yourself)
|
||||
- **Capture the viewpoint that can SEE the change.** The crouch "pose does not hold" and the
|
||||
false "eye does not drop" residual (2026-08-05/06) were BOTH capture errors: cockpit-view
|
||||
screenshots cannot show your own legs, and canopy-dominant diff crops mask eye-relative motion
|
||||
(the canopy drops WITH the eye — only the through-window ground shifts). Anchor pixel-diff
|
||||
crops on a region the effect MUST change, pair them with a state probe (joint values), and
|
||||
READ one frame with your own eyes before declaring a visual regression.
|
||||
|
||||
- **Lazy gauge build:** `GaugeRenderer::BuildConfigurationFile` runs LAZILY. A too-early process
|
||||
kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
|
||||
@@ -543,6 +549,34 @@ segment tables) — the same class of latent overflow.
|
||||
0xFEEEFEEE=freed).
|
||||
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
|
||||
|
||||
## 20. Export-gap blindness — absence in the EXPORT is not absence in the BINARY (4 incidents)
|
||||
The Ghidra export (`reference/decomp/`) has coverage gaps (#60), and BTL4.RES content never
|
||||
appears in it at all — so "no readers", "no caller", "no such clip/asset", and "unreconstructed"
|
||||
claims made by grepping the export or the port ALONE are structurally unsound. This class has
|
||||
produced four wrong conclusions, two of them [T1]-tagged at the time:
|
||||
1. **2026-07-25 searchlight "1995 latent bug"** — compared Searchlight's Performance against
|
||||
THERMALSIGHT's handler (wrong class) and invented a missing bridge; retracted (#61).
|
||||
2. **2026-07-2x `DuckState` "has no CODE consumer, authentic [T1]" + "no SQUAT clip survives"** —
|
||||
the consumer was the un-exported master-perf posture block (@0x4aa011), and the clips lived in
|
||||
BTL4.RES (`squ/sqd/squi/sqdi` × 8 chassis); the search covered decomp strings + loose files,
|
||||
never the RES TOC. Disproven by the CROUCH reconstruction (2026-08-06).
|
||||
3. **2026-07-31 morning: the myomer drive-scale DELETION** ("the binary has NO dynamic
|
||||
myomer→speed coupling") — the consumer was un-exported; restored same day by raw capstone.
|
||||
4. **2026-08-05 "the myomer factor FEEDER (@004b8be3) is unreconstructed"** — it had been fully
|
||||
reconstructed since 07-31 under NAMED members (`speedEffect`, `AvailableOutput`); the grep
|
||||
searched raw offsets. Cost: a duplicate multiply + a dead crouch gate until 08-06.
|
||||
**The rule — before claiming "X does not exist / is not reconstructed":**
|
||||
- byte-scan `content/BTL4OPT.EXE` for the offset/immediate (disp32 patterns), never just the export;
|
||||
- grep the port for NAMED members (check the .hpp for the offset's name) — offsets rot after promotion;
|
||||
- for content claims, walk the **BTL4.RES TOC** (`tools/resscan.py` pattern), not the loose tree;
|
||||
- check `reference/BT410_SOURCE_MANIFEST.md` + `game/reconstructed/CLASSMAP.md`;
|
||||
- and tag the claim's tier by the WEAKEST source consulted — an export-only sweep caps at [T4].
|
||||
The #60 gap census + RE-EXPORT is the systemic fix, and BOTH shipped 2026-08-06: the export
|
||||
now covers 93.5% of .text (dark code 90 KB → 41 KB) and `reference/decomp/GAP_CENSUS.md` maps
|
||||
what is still dark. **So the rule is cheap now: look the address up in GAP_CENSUS.md first.**
|
||||
If it is NOT in a dark region, the export is authoritative and "absent" means absent. If it IS,
|
||||
byte-scan before claiming anything. (Toolchain + method: `phases/phase-04-gap-census.md`.)
|
||||
|
||||
## Key Relationships
|
||||
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
|
||||
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
|
||||
@@ -795,3 +829,188 @@ grep its helper types for `return 0` bodies FIRST; (b) a bring-up shim must
|
||||
either Fail() loudly or log its own emptiness, never silently iterate nothing;
|
||||
(c) before shimming an engine-side structure, check whether the engine already
|
||||
has it -- the offsets in the decomp comment ARE the lookup key.
|
||||
|
||||
## 27. Drawing where the design says DON'T: under-surface buttons, shared planes, and inherited D3D state (#118, 2026-08-03)
|
||||
|
||||
Three MFD-corruption incidents in one evening, all mine, all the same class:
|
||||
adding a DRAW to a compositor whose layout invariants I had not read.
|
||||
|
||||
1. **The eject-wipe gauge** (BitMapInverseWipeScalar) drew into a weapon's ENG
|
||||
graphics port -- but the Eng1-3 planes SHARE composite dest cells with the
|
||||
weapon pages (`ckSlotOf` in L4VB16), so its mis-geometry scribbled striped
|
||||
garbage OVER the weapon MFDs whenever an eject cycle redrew it (= at
|
||||
overheat-jam onset, which the operator correctly correlated with "when the
|
||||
coolant warning starts").
|
||||
2. **The "flash overlay"** re-drew flashing buttons' full faces ON TOP of the
|
||||
surfaces. But the side buttons are BIG rects deliberately tucked UNDER the
|
||||
displays -- the protruding edge IS the lamp (the pod's backlit keys sat
|
||||
beside the CRT; there is no in-display face). A full-face redraw is wrong
|
||||
BY DESIGN: it covers gauge imagery with button faces.
|
||||
3. **The same overlay, drawn after `DrawDevSurface`, inherited its bound
|
||||
texture** -- an un-reset `CkFill` renders a TEXTURED quad sampling the
|
||||
gauge atlas: green diagonal-striped rectangles. (With the state reset the
|
||||
bug became honest: solid flashing faces covering the MFD -- which is how
|
||||
the operator's "the panel buttons took over the MFD" report finally
|
||||
identified the author.)
|
||||
|
||||
Rules: (a) before ADDING a draw to an existing render pass, read the pass's
|
||||
layering comments -- "UNDER the surfaces", "masks the part inside", "shares
|
||||
its sibling's dest cell" are INVARIANTS, not descriptions; (b) any D3D draw
|
||||
inserted after another subsystem's draws must re-assert its own texture/color
|
||||
state (CkColorState-equivalent) -- the previous draw's bindings are live;
|
||||
(c) when a visual corruption tracks an EVENT ("starts with the coolant
|
||||
warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
|
||||
state-change repaints -- before suspecting the event's logic; (d) the
|
||||
operator's screenshot is worth ten theories: the red-faces capture identified
|
||||
in one frame what three log-side hypotheses missed.
|
||||
|
||||
## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08)
|
||||
|
||||
`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when
|
||||
`segmentModified` is already set** — otherwise it hands back the cached matrix,
|
||||
and if the segment has no parent it can never recompute at all. The thing that
|
||||
sets that flag after a joint moves is `JointedMover::GetSegmentToWorld`
|
||||
(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the
|
||||
whole segment table marking every entry dirty, then clears the joint flag.
|
||||
|
||||
The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in
|
||||
`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld`
|
||||
instruction-for-instruction (`GetJointSubsystem` → `if (AreJointsModified())` →
|
||||
mark all → `ModifyJoints(False)` → `× localToWorld`). **So in the 1995 image
|
||||
every muzzle query performs the joints→segments refresh.** [T1]
|
||||
|
||||
Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(),
|
||||
mech->localToWorld)` — including one commented "the faithful FUN_004b9948".
|
||||
They skip the refresh and read whatever cache is present.
|
||||
|
||||
**Why it hid for a year:** the local mech is refreshed every frame anyway — the
|
||||
renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso
|
||||
pushes the joint. So master-side output is correct and solo testing is clean.
|
||||
A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on
|
||||
a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw`
|
||||
EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso
|
||||
was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from
|
||||
correctly replicated records. Twist arrived, joint moved, segment never
|
||||
re-derived.
|
||||
|
||||
Rules:
|
||||
(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call
|
||||
`GetSegmentToWorld` — it is the binary's own path and it does the refresh.
|
||||
(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting
|
||||
`ModifyJoints(True)` at the read site made the symptom go away and scored
|
||||
identically to the faithful fix — it was a stand-in that patched ONE
|
||||
consumer and left every other peer segment reader stale. The binary only
|
||||
ever *tests* that flag.
|
||||
(c) A cached-transform bug is **master/replicant asymmetric by construction**.
|
||||
If a geometry symptom is reported "peer POV only", suspect a cache that the
|
||||
local render pass refreshes for free — before suspecting replication. Here
|
||||
the replication was provably fine.
|
||||
(d) When a fix lands at a partial percentage, **check whether the failures are
|
||||
interleaved or a PREFIX** before calling it partial. These were a clean
|
||||
prefix that ended the moment the peer first had a non-zero twist to carry —
|
||||
i.e. the fix was complete and the remainder was correct behaviour. Reporting
|
||||
it as "64% fixed" was wrong.
|
||||
(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th
|
||||
call. With a master torso and a copy torso ticking 1:1, every 30th call is
|
||||
always the SAME instance — the probe showed only the local untwisted torso
|
||||
and hid the copy's writes entirely. Sample **per instance-kind** whenever
|
||||
master and replicant objects share a diagnostic. (See also §gotcha on
|
||||
process-wide statics serving the player's data as the replicant's.)
|
||||
|
||||
## 29. A peer mech does NOT tick before RunningMission — bench-only, and it fakes a replication bug (#148, 2026-08-08)
|
||||
|
||||
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls
|
||||
`PerformAndWatch` **only** when
|
||||
|
||||
```cpp
|
||||
application->GetApplicationState() == Application::RunningMission
|
||||
|| application->GetApplicationState() == Application::EndingMission
|
||||
|| IsPreRunnable()
|
||||
```
|
||||
|
||||
otherwise it just does `WriteSimulationUpdate`. `Entity::DefaultFlags` is
|
||||
`DynamicFlag|MasterInstance` — **no `PreRunFlag`**; only `Player` and `Director`
|
||||
add it in their DefaultFlags, and `Mech::Reset` sets it for a reset MASTER
|
||||
("a reset master must tick"). A **replicant mech never gets it.**
|
||||
|
||||
So during `LoadingMission` / `WaitingForLaunch` / `LaunchingMission` a peer mech
|
||||
performs **zero** subsystem ticks, no matter how much correctly-replicated data
|
||||
is arriving for it. Measured on the observer node:
|
||||
|
||||
```
|
||||
235 [perf-first] mech 3:161 master <- own mech, immediately
|
||||
402 [torso-rec-rx] <- peer's torso records start arriving
|
||||
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
|
||||
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
|
||||
2761 [ent-exec] state=5 <- RunningMission
|
||||
```
|
||||
|
||||
This is correct engine behaviour, **but it silently corrupts any bench that acts
|
||||
before the round starts.** `BT_AUTOFIRE`/`BT_GOTO` begin immediately, so early
|
||||
salvos measure a peer whose torso, gait and subsystems have never run — and the
|
||||
result reads exactly like a replication failure. It cost a full investigation
|
||||
(filed as #148) before the app-state trace showed the peer was simply not
|
||||
executing yet.
|
||||
|
||||
Rules:
|
||||
(a) **Judge a 2-node bench by PREFIX vs INTERLEAVED, never by raw percentage.**
|
||||
A clean leading run of failures that stops for good is almost always the
|
||||
pre-`RunningMission` window; interleaved failures are the real thing.
|
||||
(b) When a peer looks inert, check `[ent-exec] state=` before suspecting
|
||||
replication. States: `2` LoadingMission, `3` WaitingForLaunch,
|
||||
`4` LaunchingMission, `5` RunningMission.
|
||||
(c) Prefer benches that wait for `RunningMission` before acting — or slice the
|
||||
log at the transition — otherwise every peer-side metric carries this bias.
|
||||
(d) The receipts that make this legible: `BT_NET_TRACE` gives `[upd-repl]`
|
||||
(offered to the performer), `[ent-exec]` (state / preRun / instance) and
|
||||
`[perf-first]` (one-shot per mech: entity ID + instance at its FIRST
|
||||
performance). Anonymous per-frame receipts are useless in a 2-node log —
|
||||
**name the mech.**
|
||||
|
||||
## 30. A finite-difference cache the binary re-seeds at Reset — transcribe the WHOLE seed list (#137, 2026-08-09)
|
||||
|
||||
The binary `Mech::Reset @0049fb74` opens with more than the obvious origin writes: its SECOND
|
||||
instruction is `FUN_00408440(mech+0x58c, param_2)` — re-seeding the **previous-position memory**
|
||||
of the AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c) to the new origin. The port
|
||||
reconstructed the ring itself faithfully (ctor `part_012.c:9836`, derivative `:15169`) but its
|
||||
Reset never got that one line. Result: the first post-respawn sample computed
|
||||
`|newPos − prevPos| / dt` = **teleport distance / dt ≈ 1e5** into the velocity ring; the
|
||||
ring-mean derivative turned it into an acceleration spike (with an opposite-sign ECHO ~15 frames
|
||||
later as the sample rotated out of the mean); the myomer heat integrator's
|
||||
`termAccel = (1−accEff)·|v|·|a|·m·dt` turned THAT into a ~3e9 one-tick heat deposit; and the
|
||||
freshly-reset myomers snapped from 77 to ~9000 against failT=2000 — the #137 respawn freeze.
|
||||
|
||||
Rules:
|
||||
(a) **When the binary's Reset writes a cell you don't recognize, that write IS the spec.** The
|
||||
+0x58c re-seed looked like bookkeeping and was silently dropped; it was the only thing
|
||||
standing between a teleport and a position-derivative spike. Transcribe the whole zero/seed
|
||||
list, then map each cell — never the recognizable subset.
|
||||
(b) **Any prev-value cell backing a finite difference must be re-seeded at every discontinuity**
|
||||
(teleport, warp, respawn). If you add such a cache port-side, grep the binary's reset for its
|
||||
analog before assuming none exists.
|
||||
(c) **Derived state hides stale backing state.** `currentTemperature` sampled AT the reset read
|
||||
77 (clean) because RTIS wrote it — while the freeze arrived one frame later through
|
||||
`heatEnergy += pendingHeat` from a live producer. Probing the derived cell at the reset
|
||||
instant proves nothing about the producers; trace the WINDOW after, per producer.
|
||||
(d) The diagnosis chain that worked, for reuse: roster-wide state snapshot (at-death / at-reset /
|
||||
post-reset) → eliminate conduction by trapping flows with full operands → trap the remaining
|
||||
producer's deposits with operands → cross-reference the operand SHAPE (pure local −z,
|
||||
magnitude = distance/dt, echo at ring-length) against the writers. Each trap eliminated a
|
||||
theory the previous data had made plausible; three plausible theories died on operands.
|
||||
|
||||
## §25 — A same-pass edge latch sampling DEFERRED state: synchronous in the binary, next-tick in the port
|
||||
**(2026-08-11, the #162 4-node retest double-credit.)** The binary's death processing writes
|
||||
`movementMode 9` **inside the damage pass itself** — its own tail gate (part_012.c:14761) reads
|
||||
the mode already flipped in the same call. The port put the mode write in the per-frame
|
||||
`UpdateDeathState`, one tick later — and a comment at the death tail *asserted* the write was
|
||||
synchronous, so nobody looked. Consequence: TWO lethal deliveries of one salvo (direct + splash,
|
||||
2 ms apart, no sim tick between) each sampled `destroyedAtEntry = (mode==2||9)` as 0 and re-ran
|
||||
the whole death block — duplicate kill credit + duplicate VehicleDead. Invisible at 2 nodes
|
||||
(clean kills deliver ONE lethal hit); needs crowds + overlapping splash — the 8-player field
|
||||
shape. **Rule:** when a binary function's OWN later code observes state it wrote earlier in the
|
||||
same pass, the port's write must be equally synchronous — a per-frame deferral silently converts
|
||||
an edge latch into a race. Sibling of the alarm-vs-mode trap (#108/#162): both are "the latch
|
||||
sampled a cell whose write timing differs from the binary's". Fix shape: run the once-per-death
|
||||
transition inline at the destruction edge (mech.cpp death_tail → `UpdateDeathState(0)`), keep the
|
||||
per-frame caller for maintenance. Verified: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26, both
|
||||
SWALLOWED=0 (the receiver dedup tripwire now never fires — no duplicate is ever GENERATED).
|
||||
|
||||
+47
-1
@@ -335,7 +335,9 @@ the limb swap ("the actual enemy mech in external view is not showing darkened a
|
||||
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
|
||||
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
|
||||
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
|
||||
`destroyed`…`destroyedd` video variants); the res compiler is `FUN_0041e4e0`.
|
||||
`destroyed`…`destroyedd` video variants); the res-compiler address is UNKNOWN (the old
|
||||
`FUN_0041e4e0` citation was WRONG — that is `DamageZone::TakeDamage`; corrected 2026-08-02,
|
||||
swept with [[decomp-reference]]).
|
||||
- Compiled into BTL4.RES and parsed by **MUNGA's own `DamageZone` stream ctor** into
|
||||
`materialTable`, keyed by `EntitySegment::SkeletonType` (`engine/MUNGA/DAMAGE.cpp:311-336`,
|
||||
`GetMaterialList(skl_type)`) — real engine source [T0]. **This data was loaded and unused in the
|
||||
@@ -377,6 +379,50 @@ mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control
|
||||
The `damcolor_`/`gen*gry_` arm+gun names go unmatched until the DESTROYED limb variant loads —
|
||||
correct: those materials live on the `*D*.BGF` damaged meshes.
|
||||
|
||||
## SEARCHLIGHT -- the pod's night kit, RECONSTRUCTED 2026-08-05 [T1 decode, T2 both benches]
|
||||
The subsystem (searchlight.cpp: sim @4b841c, ToggleLamp @4b838c toggles requestedOn@0x1E0 --
|
||||
NO novice gate, that's ThermalSight's; lightState = requested && powered(4) && heat<2 && alive,
|
||||
published as the "LightOn" attribute + replicated as subsystem record 0x14) was already complete;
|
||||
the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8` [T1]:
|
||||
- **Cockpit = a FOG SWAP, not a light.** The @00456778/@00456814 watcher polls up to two LightOn
|
||||
attrs; either-on -> `SetFogStyle(searchLightOnFogStyle)` (the authored `fog=` set), off ->
|
||||
`searchLightOffFogStyle` (the `nosearchlightfog=` set). Both sets are authored per
|
||||
map/time/weather page in BTDPL.INI (arena_night 40/400 lit vs 5/400 dark; des_night 100/1100
|
||||
vs 30/700; arnitclear 90/1100 vs 5/1000). The 2007 engine kept the whole system under its real
|
||||
names (searchLightFog*/noSearchLightFog*, the FogStyle enum); the port completed the stubbed
|
||||
plane application (currentFogNear/Far now swap with the style) and transcribed the watcher into
|
||||
`TickSearchlight` -- including the ctor's INVERTED cache seed, whose first-tick "change"
|
||||
normalizes a night start onto the DARK set. Fidelity consequence: every prior build rendered
|
||||
the lit fog permanently (nothing called SetFogStyle(3)); authentic night starts darker.
|
||||
Glass key: F5 -> streamed button 0x14.
|
||||
- **PERCEIVED STRENGTH [T2 field eyeball 2026-08-05]: authentically SUBTLE.** With LINEAR fog the
|
||||
near-plane move barely changes close-range haze (at 30u the arena pair differs ~5%); the swap
|
||||
reads as a modest push-back of the night murk at mid/long range, NOT a flashlight. Geometry
|
||||
brightness comes from the page's ambient= + ramps and never changes with the lamp -- a close
|
||||
wall is identical lit/unlit. First eyeball matched exactly ("kinda sorta illuminates the
|
||||
area... didn't illuminate the wall" -- that IS the 1995 behavior). No illuminating dynamic
|
||||
light exists anywhere in BT's render path: ChildLightRenderable has ZERO instantiations
|
||||
engine-wide, and the FUN_0048d060 cluster (@4568f8/@456a0c/@456b4c/@4586d0) is light-POINT
|
||||
sprites (beacons/nav strobes, palette-tinted), not geometry lights. Set player expectations
|
||||
in the release notes accordingly.
|
||||
- **External = the spot.bgf beam-cone mesh** (VIDEO/GEO + per-time variants), hung as a child of
|
||||
the searchlight SITE joint (mount = subsystem `mountSegment`@0x1DC == resource segmentIndex;
|
||||
madcat 20, loki 21), shown/hidden per frame from the replicated LightOn (@0045612c watcher ->
|
||||
the SetDrawObj swap). Site segments now build geometry-less DCS children (posed + parentable --
|
||||
the 1995 graph gave every site a DCS node; our tree used to skip sites entirely; this also
|
||||
enables future site-hung effects). Wrecks go dark through the authentic gate (host shutdown
|
||||
forces lightState 0). Cross-node verified: B presses, A logs `[spot] cone SHOWN (seg 20)`.
|
||||
- Benches: `scratchpad/night12/searchfog.sh` (solo cockpit chain + red-fog end-to-end probe),
|
||||
`spotcone.sh` (2-node cone + replication). BEAM MATERIAL decoded 2026-08-06 (b75bb4a) [T1]:
|
||||
SPOT.BGF is a 7-vert cone ~50u forward and ~35° DOWN (a ground-pool lamp, not an air beam),
|
||||
verts tinted cyan-white; its material class `btfx:brighten.25` smuggles the ADDITIVE factor
|
||||
in DIFFUSE.r (0.25) with a warm emissive on the night page — the loader draws brighten*
|
||||
batches as an additive veil (dest += vertexRGB × factor, blend pass, unlit); tint compose
|
||||
(vertex cyan vs night emissive warm) [T3], flagged in the L4D3D draw branch. Look pass
|
||||
ACCEPTED by field eyeball 2026-08-06 (translucent beam reads correctly; solo rig
|
||||
BT_SPOT_SELF=1 + BT_CAM=face). Still open ([[open-questions]]): the stubbed LoadObject
|
||||
wrapper + dormant marker case, the subtle authored deltas.
|
||||
|
||||
## Key Relationships
|
||||
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
|
||||
- Renders on: [[pod-hardware]] (main 3D view).
|
||||
|
||||
@@ -94,6 +94,32 @@ its recorded line 400 exactly. [T1/T3 per the sidecar]
|
||||
Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
|
||||
behavior-equivalent **reconstruction**, NOT original source. [T2]
|
||||
|
||||
## THE GAP CENSUS + RE-EXPORT (2026-08-06, #60) — BOTH HALVES DONE [T1]
|
||||
**The export was REBUILT 2026-08-06** (Ghidra 12.1.2 headless + `ExportGaps.java`'s gap-fill
|
||||
pass): index coverage **87.3% → 93.5%**, dark real code **90.4 KB → 40.8 KB (54.8% recovered)**,
|
||||
game-side dark 53.1 → 21.1 KB, functions 6,267 → **6,472** (+205, zero decompile failures).
|
||||
Every historically-bitten dark function now has pseudocode — including `@0x4c05c4` VehicleDead,
|
||||
the absence that opened #60. The re-export CONFIRMED the crouch reconstruction field-for-field
|
||||
and revealed one branch the raw pass missed (airborne auto-rise, fixed same day).
|
||||
⚠ **The old export is archived at `reference/decomp/archive_2025export/`** — addresses are
|
||||
stable across both, but `part_0NN.c:LINE` citations only resolve against the archive. Cite
|
||||
`@ADDR`, not part/line. Pipeline: `tools/ghidra_reexport.sh` → `tools/gapcensus.py` →
|
||||
`tools/gapdiff.py`. Toolchain (JDK 21 + Ghidra 12.1.2) lives in `%LOCALAPPDATA%\bt411-tools`;
|
||||
the runner MUST use 8.3 short paths (Ghidra's .bat expands %JAVA_HOME% unquoted and the user
|
||||
profile has a space). Full log: `phases/phase-04-gap-census.md`.
|
||||
|
||||
### The original inventory (part 1, same day) [T1]
|
||||
`tools/gapcensus.py` → `reference/decomp/GAP_CENSUS.md` + `gap_census.tsv` (regenerate after
|
||||
any re-export; it censuses whatever dir you point it at). As found BEFORE the re-export: .text
|
||||
892 KB, index 87.3%, **428 dark regions = 90 KB of real code** (indexed-but-unexported = 0 —
|
||||
the gap class is purely "not indexed"), game-side dark ≈ 54 KB with 159 regions never touched.
|
||||
Current numbers are in the header above. Validation: all six historically-bitten dark
|
||||
addresses (VehicleDead, ToggleLamp, death tail, master-perf, myomer integrator, duck consumer)
|
||||
land inside census regions. Top uncharted leads + method: `phases/phase-04-gap-census.md`.
|
||||
**RULE (gotcha §20):** consult the census BEFORE any "absent from the export/binary" claim —
|
||||
if the address falls in a dark region, the claim needs byte-scan evidence, not export silence.
|
||||
(The second half — the re-export — landed the same day; see the header.)
|
||||
|
||||
## Key Relationships
|
||||
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]].
|
||||
- Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
|
||||
|
||||
+43
-1
@@ -41,6 +41,9 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
|
||||
conducts to the central sink via the linked-sink roster).
|
||||
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
|
||||
- ✅ **Searchlight VISUALS complete 2026-08-05** — cockpit fog swap + external spot.bgf beam
|
||||
cone, both MP-replicated; mountSegment@0x1DC identified (= resource segmentIndex, the cone's
|
||||
mount joint — was "commandedOn, role unidentified"). Full story: [[rendering]] §SEARCHLIGHT.
|
||||
- ✅ **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
|
||||
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
|
||||
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
|
||||
@@ -99,7 +102,10 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
(OwnerBaseSpeed reads the real base instead of the 1.0 stub).
|
||||
5. **Electrical sourcing — the dial's REAL stake is TORSO TWIST.** A gear's voltage must be
|
||||
<= the generator's measured output (`(1 − generator damage) × rated`, thermal breaker on
|
||||
FailureHeat; there is NO load model — demand never pulls the bus down), or the myomers leave
|
||||
FailureHeat with recovery gated on NormalHeat — trip at T>2000, re-arm at T≤1000, exactly
|
||||
the manual p23 "cooled down half way" rule; full trip/recovery/warning-edge audit in
|
||||
[[decomp-reference]] §Generator trip/recovery (#154); there is NO load model — demand
|
||||
never pulls the bus down), or the myomers leave
|
||||
Ready. The Torso is a PowerWatcher on the myomers: `TorsoSimulation` ZEROES the twist rate
|
||||
while the watched myomers are not Ready (halves it at DegradationHeat) — so an over-high gear
|
||||
on a damaged generator freezes the pilot's AIM until they downshift. On a healthy mech,
|
||||
@@ -296,3 +302,39 @@ the `BTGetSubsystemAuxScreen` bridge. See `docs/VEHICLE_SUBSYSTEMS.md` + [[gauge
|
||||
- Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]].
|
||||
- Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state).
|
||||
- Plan: `docs/SUBSYS_PLAN.md`.
|
||||
|
||||
## Myomer drive-heat calibration — VERIFIED FAITHFUL (2026-08-09, #137) [T1]
|
||||
The integrator `@004b8d18` accumulates into `pendingHeat@0x1C8`:
|
||||
`gear² × (1 + X) × [ (1−velEff)·|vy|·m·g·dt + (1−velEff)·work + (1−accEff)·|v|·|a|·m·dt ]`
|
||||
with `work = mass · |v|² · 0.5`. Its constants, read byte-exact from `.rdata`
|
||||
(`section_dump.txt` row ` 4b8ee0 5dc30000 0000003f 00000000 0000803f`):
|
||||
**`_DAT_004b8ee4` = 0.5f** (the kinetic ½), **`_DAT_004b8ee8` = 0.0f** (the `Abs()` idiom),
|
||||
**`_DAT_004b8eec` = 1.0f** (the `1 − efficiency` complements and the gear-ratio clamp floor).
|
||||
All three match what the port computes — the formula and its authored inputs
|
||||
(VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5, myomers linked
|
||||
Condenser5) are reconstructed correctly.
|
||||
|
||||
**The one deliberate deviation, and why it is the faithful choice.** The binary applies **no
|
||||
`time_slice`** to the kinetic term (`fVar5 * fVar1`) while the climb and accel terms both carry
|
||||
`param_2` — it is a per-frame energy add at the pod's **fixed ~28 Hz**. The port uses
|
||||
`work × (time_slice × 28)`, which is *identical* at 28 Hz (`dt·28 = 1.0`) but holds the same
|
||||
heat-per-SECOND at any frame rate. A literal transcription would add the full term once per
|
||||
frame, so at 170 fps it would inject ~6× the heat the pod ever did. `BT_MYO_HZ` overrides the
|
||||
reference rate for bracketing.
|
||||
|
||||
**Consequence for #137 — CORRECTED 2026-08-09, the paragraph that stood here was wrong.** The
|
||||
"players read 'respawned with heat maxed' as acceleration to top speed" claim did not survive
|
||||
the data: the deposits were e9-scale within 30 frames of the reset, physically impossible from
|
||||
motion input (~6.5e5/frame ceiling). The actual cause was a **dropped binary re-seed**: the
|
||||
binary Reset's second instruction (`FUN_00408440(mech+0x58c, origin)`) re-seeds the
|
||||
previous-position memory of the AccelerationLastFrame ring feed (+0x81c..+0x82c); the port
|
||||
reconstructed the ring but not the re-seed, so the first post-respawn sample computed
|
||||
TELEPORT-DISTANCE/dt (~1e5) into the velocity ring, the ring-mean derivative spiked
|
||||
`AccelerationLastFrame`, and `termAccel = (1-accEff)·|v|·|a|·m·dt` deposited ~3e9 into
|
||||
`pendingHeat` in one tick → myomers snapped from 77 to ~9000 (failT 2000) → speedEffect 0 →
|
||||
frozen until cooled. Fixed by restoring the re-seed (`accelPrevPos = origin.linearPosition` in
|
||||
Mech::Reset). The ~8% field rate was the |v| factor: only pilots whose throttle was still
|
||||
forward at the respawn (physical lever / HOTAS — exactly who reported it) had gait-republished
|
||||
speed in the spike frames. The calibration facts above (constants byte-exact, dt-normalised
|
||||
kinetic term) all STAND; the in-life governor (running hot at sustained top speed derates the
|
||||
myomers) is authentic and remains.
|
||||
|
||||
+83
-1
@@ -84,6 +84,56 @@ The vehicle sed must replace the WHOLE line (`s/^vehicle=.*/`), or you mint
|
||||
`vehicle=thr1bhk1` and the spawn FATALs. Chassis codes: ava1 bhk1 lok1/2 mad1/2
|
||||
own1 snd1 thr1 vul1 (own1 has NO arm zones — rack zones instead).
|
||||
|
||||
**Weapon/combat benches need `map=grass time=day`.** `MP.EGG` authors
|
||||
`map=cavern time=night`; a bench that copies it without the map sed (e.g. via
|
||||
`bt_expert_egg` alone — mp_double.sh has this gap) parks the GOTO-driven mechs
|
||||
against cavern rock and they shoot terrain for the whole window. Always sed the
|
||||
map/time on the COPY, single-node and two-node alike.
|
||||
|
||||
### THE ZONE-WALK MATRIX (`scratchpad/night10/zonewalk.sh`) — systematic
|
||||
### per-panel hit-location testing, operator-watchable (built for #124)
|
||||
The precision-targeting rig: **two nodes**. Node B (launched first, back
|
||||
window) is the TARGET — `BT_SPIN_SELF=15` rotates its own viewpoint mech in
|
||||
place (deg/s; the BT_SPAWN_AT write pattern, applied per frame) so every
|
||||
aspect passes the shooter's boresight; its window shows its own paper doll
|
||||
taking the hits (`BT_DMG_LOG=1` prints every `[dmghit]` with zone + level).
|
||||
Node A (foreground — it owns the pick focus) runs `BT_ZONE_WALK=<secs/zone>`:
|
||||
walks the target's damage zones in index order, resolves each zone's CARRIER
|
||||
SEGMENT's live world position (`BTResolveSegmentWorld` — the same resolver the
|
||||
attached smoke rides; zone→segment via the `BTMechZoneSegAndName` bridge),
|
||||
SERVOS the torso twist + aim elevation until the centered reticle's pick ray
|
||||
(`BTGetAimRay(0,0)`) points at the segment, then pulses the laser trigger 3×
|
||||
and advances. `[walk] ZONE/FIRE/HOLD` lines on A pair with `[dmghit]` on B.
|
||||
Launch: `bash scratchpad/night10/zonewalk.sh` (relay included; NO kill timer —
|
||||
the session stays up for observation; teardown = taskkill btl4).
|
||||
Node A also carries `BT_ARMOR_LOG=1` + `BT_SHOT_EVERY=300 BT_SHOT_PREFIX=zwA`
|
||||
(added for #87): the armour watcher's `[armor] ... level P -> Q` lines on A are
|
||||
the OBSERVER-side receipt that B's zone levels replicated (the walk is the
|
||||
standing zone-replication bench — A-side peaks must match B's `[dmghit]`
|
||||
finals), and the numbered captures are the pixel record of the enemy hull
|
||||
darkening. Digest: `python scratchpad/night10/digest_walk.py` (per-zone hits,
|
||||
amt/hit, level first→last on B; replicant peaks seen by A; zone-repl lines).
|
||||
Companion scalpels: `BT_ASPECT_TEST=1` (the zero-premise frame probe — 4
|
||||
world-cardinal self-impacts + one at each weapon's physical MUZZLE, the
|
||||
left/right anchors that caught the #124 reflection-vs-rotation error);
|
||||
`BT_AIM_SWEEP=<period>` (blind elevation sweep — superseded by the walker).
|
||||
Hard-won constraints baked into the walker (do not relearn these):
|
||||
- **Engage gate**: servo only when the aim ray is live AND range < 150 AND
|
||||
|bearing| < 1.1 rad; otherwise RELAX the twist toward 0 and hold the zone
|
||||
clock. Without it the servo winds the twist to its limit while the goto
|
||||
marches (limit-clamp fight = the mech visibly SHAKES) — and the gate must
|
||||
sit well above the goto's resting bearing error (~0.55 rad at STOP=90) or
|
||||
it deadlocks in HOLD.
|
||||
- **Yaw polarity**: the twist cell's angular sense is OPPOSITE atan2(x,−z)
|
||||
world yaw (operator-observed live: `+=` turned the torso AWAY — the second
|
||||
witness for the #124 SelectSlice twist-sign flip). The servo starts at −1
|
||||
and carries a growth WATCHDOG that self-flips if the error diverges.
|
||||
- **Damped servo**: gain 0.40, step cap 0.025 rad/frame, deadband — the aim
|
||||
ray lags the twist write by a frame; full-error correction oscillates.
|
||||
- **Approach port**: dropzones spawn cross-map; the walker one-shot teleports
|
||||
A to 100 u off the target (BT_SPAWN_AT pattern incl. the +500 y ground-snap
|
||||
lift) instead of minutes of marching.
|
||||
|
||||
### Two-node bench skeleton (the MP pattern)
|
||||
```bash
|
||||
bt_assert_player_env
|
||||
@@ -139,7 +189,39 @@ sleep <duration>; kill $relay; bt_kill_ours
|
||||
(one line, ungated) answer questions retroactively; per-frame traces stay
|
||||
gated.
|
||||
|
||||
## Bench-script gotchas (each has burned a session)
|
||||
- **Write benches CLEAN, never sed-derive a chain** — sed-derived copies silently dropped envs
|
||||
twice (the [replgimp] silence); export envs INSIDE the per-node subshell.
|
||||
- **Absolute paths everywhere; `cd /c/git/bt411` first** — the cwd trap (heredoc python + `cd
|
||||
scratchpad/nightN`) has broken later builds/appends with `fatal: pathspec`/`cannot stat`.
|
||||
- **No apostrophes/backticks through bash heredocs** — write a `.py` file and run it (quoting has
|
||||
mangled posted tracker comments and killed scripts mid-parse).
|
||||
- **Drain stale watchdogs before relaunching** — a leftover waiter's `taskkill` has killed a live
|
||||
bench mid-run.
|
||||
- **Scripted input goes through the REAL seam**: `BT_BTNTEST`/`BT_BTNTEST2` (RIO queue → mode-mask
|
||||
drain). In 2-node runs press at poll ≥900 — round-start jitter can eat earlier presses.
|
||||
- **Capture the viewpoint that can see the change** (gotcha #13 bullet): cockpit view can't show
|
||||
your own legs; pair screenshots with a state probe and READ a frame before concluding.
|
||||
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
|
||||
|
||||
## The standing bench suite (scratchpad/night15/, 2026-08-11 wave — all hard PASS/FAIL)
|
||||
| Bench | Asserts | Hooks it rides |
|
||||
|---|---|---|
|
||||
| `kd_bench.sh` (2-node) | K/D invariants I1-I5: deaths == PLAYER_DEAD == −500 costs == kill credits, no swallows | BT_AUTOFIRE + BT_AF_MISSILE, BT_GOTO, matchlog |
|
||||
| `kd4_bench.sh` (4-node) | I1-I8 **receipt-derived per player** + bystander mirror convergence + no-mirror-exceeds-authority + zero `inst=R` | same + SBMIRROR records |
|
||||
| `kd8_stress.sh` (8-node) | the same invariants under an all-vs-all melee (staggered salvo periods, one drop zone); `LOW STRESS` guard | same, 8 instances 1 core each |
|
||||
| `proj_ttl_bench.sh` | #168 flight clocks: probe fizzles at exactly 5.0s / burn+10s; field phase: authentic ttl stamps, none early, no stale-point hits | BT_PROJ_TEST probe + BT_PROJ_LOG |
|
||||
| `gen_edge_bench.sh` | #154 warning edge: 4/4 trigger fixups, trip edge FIRED, recovery + respawn silent | BT_GEN_HEAT injection + BT_AUDIO_EDGE |
|
||||
| `xfire_bench.sh` | #165 destroyed-weapon gate: pre-kill FIRED, post-kill only REFUSED, survivor unaffected | BT_KILL_SUBSYS + named `[emitter] FIRED` |
|
||||
| `eject_canary.sh` / `ram_bench.sh` / `relaunch_bench.sh` | eject suicide arithmetic / ram-rattle eligibility set / ordered-exit no-zombies | (night-15 fix wave) |
|
||||
|
||||
Multi-node rig notes: extend the egg by CLONING the 1602 pilot page (the kd4/kd8 scripts show
|
||||
the python pattern); **only zone names the MAP defines are valid dropzones** (undefined names
|
||||
pile everyone at a fallback — mechs spawn overlapping and the melee turns into splash-suicide
|
||||
chaos, which the receipt-derived invariants still score exactly, but it isn't the scenario you
|
||||
wrote); affinity masks 2-cores-each up to 4 nodes, 1-each at 8.
|
||||
|
||||
## Key Relationships
|
||||
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
|
||||
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
|
||||
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
# Generator-Out Warning Audit — #154 (2026-08-11)
|
||||
|
||||
> **Ledger** (full multi-agent evidence record; the curated digest lives in
|
||||
> `context/decomp-reference.md` §Generator trip/recovery + the warning edge).
|
||||
> Verdict chain: recovery threshold AUTHENTIC (trip 2000 / re-arm 1000 = the
|
||||
> manual p23 half-cooled rule, heat-bar red == the gate); the warning was
|
||||
> AUTHORED on the leave-GeneratorOut edge in shipped BTL4.RES (the game's only
|
||||
> inverse-Start fault trigger, 144-row census) — fixed as the port's one
|
||||
> deliberate content deviation (08fc87e, `BT_GEN_WARN_SHIPPED=1` reverts);
|
||||
> live-benched trip-FIRED/recovery-silent (gen_edge_bench.sh); phrase
|
||||
> ear-verified as Yip's spoken "generator out" (fef6fad).
|
||||
>
|
||||
> ⚠ RECORD CORRECTION (8d2f482): a same-day claim that BT 4.10 shipped no
|
||||
> speech (and that era memories were Firestorm's) was WRONG — the Warnings01
|
||||
> zones ARE the voice, phrase-sequenced (#119, commit 42b0691). The claim
|
||||
> below in lane text predates the correction; read it with that lens. Process
|
||||
> lesson recorded in memory: grep the project's own git log before asserting
|
||||
> an asset absent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — four investigation lanes (raw returns)
|
||||
|
||||
|
||||
########## LANE 0 ##########
|
||||
## #154 — OUR PORT's generator trip/recovery reconstruction (raw findings)
|
||||
|
||||
### 1. Trip + recovery logic and thresholds
|
||||
|
||||
The generator's thermal trip lives in `Generator::GeneratorSimulation` — C:/git/bt411/game/reconstructed/powersub.cpp:1295-1372, transcribed from @004b1f7c (raw: reference/decomp/all/part_013.c:9049-9107, re-read this session).
|
||||
|
||||
TRIP (powersub.cpp:1304-1312):
|
||||
```cpp
|
||||
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
|
||||
{
|
||||
...
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
|
||||
}
|
||||
```
|
||||
This is a HELD condition, not an edge: it re-asserts SetLevel(4) EVERY frame while heatAlarm==FailureHeat. Binary identical [T1]: part_013.c:9062-9064 `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);`.
|
||||
|
||||
RECOVERY (powersub.cpp:1340-1357):
|
||||
```cpp
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
if (generatorOn == 0) { stateAlarm.SetLevel(GeneratorIdle); } // 1
|
||||
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0
|
||||
}
|
||||
```
|
||||
Binary identical [T1]: part_013.c:9087-9097 (`param_1[0x61] == 0` gate, generatorOn at [0x75]). After the spin-up (`startTime <= startTimer`) it goes `SetLevel(GeneratorReady)` (2) and recomputes `outputVoltage = (1.0 - SourceLevel()) * ratedVoltage` (powersub.cpp:1316-1324).
|
||||
|
||||
The temperature thresholds behind heatAlarm (heat.cpp:851-862, @004ad924 [T1]): `T > failureTemperature → FailureHeat(2)`, `T > degradationTemperature → DegradationHeat(1)`, else `NormalHeat(0)`. Pure per-frame comparison, NO hysteresis. So: **trip at T > failureTemperature; recovery gate at T ≤ degradationTemperature** (all the way out of the yellow band, not just the red).
|
||||
|
||||
**The tester's era rule ("back online at HALF-cooled, when the red portion of the heat bar is gone") is SATISFIED by the port as built — no divergence.** The generator heat bar is a `VertTwoPartBar` fed `(currentTemp, degradeTemp, failTemp)` (btl4gau2.cpp:980-998); its Execute (btl4gaug.cpp:1321-1360, @004c48fc [T1]) tiles the bar up to the warn pixel `height*low/high` (low=degradationTemperature, high=failureTemperature) and paints `fillColor` only in the degradation..failure segment — i.e. the "red portion" IS exactly the T>degradationTemperature band, and it disappears exactly when the recovery gate opens. With the authored profile degradation=1000/failure=2000 (myomer record from BTL4.RES; generator values not separately confirmed [T3]) the warn line sits at literally HALF the bar. Oracle's 2nd/3rd tests ("came back after cooling halfway") behaviorally confirm the port already does this [T2-field].
|
||||
|
||||
### 2. stateAlarm writers, edges, and marks
|
||||
|
||||
Complete writer inventory of `Generator::stateAlarm` (@0x1FC, level cell @0x210):
|
||||
- powersub.cpp:1311 `SetLevel(4)` — thermal breaker, HELD (per-frame re-assert while FailureHeat). The edge-ification happens inside the alarm: `GaugeAlarm54::SetLevel` (heat.hpp:106) `{ levelB = level; if (n != level) { level = n; NotifyWatchers(); } }` — notify ONLY on change, oldState (levelB@0x10) updated UNCONDITIONALLY. Byte-matches the binary FUN_0041bbd8 [T1] (part_002.c:5512-5546: `+0x10 = +0x14` before the equality check, then the three watcher sockets @+0x18/+0x2c/+0x40 fire only on change) and the T0 engine `StateIndicator::SetState` (engine/MUNGA/SIMULATE.cpp:155-217, including the "old state changes even on no-op set" NOTE at :176-179).
|
||||
- powersub.cpp:1302 `SetLevel(1)` — held while `simulationState==1` (destroyed): a crit-destroyed generator is PINNED Idle forever (authentic).
|
||||
- powersub.cpp:1321/1335 `SetLevel(2)` — spin-up / short-recovery completion.
|
||||
- powersub.cpp:1346/1354 — the recovery arm above (4→1 or 4→0).
|
||||
- powersub.cpp:1392 `SetLevel(3)` — HandleMessage msg 4 short (@004b21d0 [T1]).
|
||||
- powersub.cpp:1212/1221 — ToggleGeneratorOnOff (@004b1ed0, raw re-read part_013.c:9016-9045 [T1]): OFF → SetLevel(1); ON → SetLevel(0) ONLY if heatAlarm==0 (hot ON-press changes NO state — see §4).
|
||||
- powersub.cpp:1269-1270 — ResetToInitialState (@004b215c [T1]): SetLevel(0) then SetLevel(2) — every respawn passes through Starting then Ready.
|
||||
- powersub.cpp:555 + :1564 — `ForceShortRecovery`/`PowerWatcher::HandleMessage` write `source->stateAlarm.SetLevel(3)` externally.
|
||||
- `MarkGeneratorOut()` (powersub.hpp:483) via `BTGeneratorMarkOut` (powersub.cpp:1787-1791): **ZERO live callers** — the ForceCriticalFailure call was removed 2026-08-03 (mechsub.cpp:290-297).
|
||||
|
||||
**Edge delivered to audio:** the trip produces a synchronous `StateChanged(prev→4)` on the trip frame. Mechanism: `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (powersub.cpp:1051) publishes the alarm; `AudioStateWatcher` registers on the alarm's +0x18 audio socket (engine/MUNGA/AUDWTHR.cpp:918, with the T3 bring-up guard :908-917 that a constructed GaugeAlarm54 passes); `NotifyWatchers` (heat.cpp:96-111) Execute()s it inside SetLevel; `SendNotificationOfChange` (AUDWTHR.cpp:933-945) reads GetOldState()/GetState() at +0x10/+0x14 — layout verified compatible: StateIndicator : Node : Plug : RegisteredClass gives header 0xC then stateCount/oldState/currentState @0xC/0x10/0x14 + three sockets @0x18/0x2C/0x40 = 0x54, identical to GaugeAlarm54 (heat.hpp:126-138). State watchers are NOT on the per-frame poll socket (only `AudioTriggerOf`/`AudioMatchOf` call `simulation->AddAudioWatcher(this)` — AUDWTHR.h:293/:770; `AudioStateWatcher` registers only on the indicator socket), so no spurious held-state notifications.
|
||||
|
||||
**No ForceUpdate/updateModel/SetDirty mark exists at either edge** — grep of powersub.cpp finds none; the binary @004b1f7c has none either [T1]. The notification is purely the alarm-socket synchronous path.
|
||||
|
||||
**So the port's plumbing puts the notification on the TRIP edge (X→4), and also emits 4→0 then 0→2 at recovery.** Which of those the "Generator Out" SPEECH keys on is decided by the authored `AudioStateTrigger` fields (`triggerState/inverseTrigger/exclude_state`, streamed — AUDWTHR.cpp:968-990; firing rule :1046-1105 [T0]): non-inverse fires on `new_state==triggerState`, inverse on `old_state==triggerState` — an inverse trigger on state 4 fires EXACTLY on the recovery edge, which matches Oracle's symptom precisely. The port cannot flip this itself: the alarm mechanics are byte-faithful. The decisive probe is the load-time `[statecfg]` dump (AUDWTHR.cpp:981-987, env BT_ATTRBIND_LOG) — it prints every authored GeneratorState trigger's trigState/inv/exclude. Until that capture, whether the wrong edge is (a) authored-inverse consumed correctly (pod-era authoring quirk), (b) the trip-edge trigger authored against an attribute the port publishes differently, or (c) a dead START half of a start/stop pair, is undetermined FROM THIS LANE.
|
||||
|
||||
### 3. Divergence check vs the knowledge base
|
||||
|
||||
- context/decomp-reference.md:470-477 ("no Ready-case recompute"; stateAlarm 4's producer = the thermal breaker in GeneratorSimulation; the 2026-08-03 destruction→state-4 bridge removed as unfounded): **port conforms** — no Ready-case recompute exists in the port either, `BTGeneratorMarkOut` is dead code as documented.
|
||||
- context/subsystems.md:103-105 ("(1 − generator damage) × rated, thermal breaker on FailureHeat; NO load model"): consistent with powersub.cpp:1322/1336/1398.
|
||||
- One STALE-ish note found: powersub.cpp:1052's comment on GeneratorOn ("static until a shutdown writer drives it to 0") — the shutdown writer EXISTS now (ToggleGeneratorOnOff, powersub.cpp:1214/1223); the comment predates #53. Cosmetic.
|
||||
- No divergence found between the port and @004b1f7c/@004b1ed0/@004b215c/@0041bbd8 — instruction-shape faithful on every path I compared [T1].
|
||||
|
||||
### 4. FailureHeat "permanent brick" family (open-questions)
|
||||
|
||||
The generator does NOT share a recompute path with the weapon FailureHeat family — three distinct recovery disciplines all reading the SAME producer (own HeatSink heatAlarm @+0x184, thresholds heat.cpp:851-861):
|
||||
1. **Emitters** (@004baa88, emitter.cpp:225-229, :426-434): continuous per-tick gate — ResetFiringState + currentLevel=0 while heatAlarm==2, auto-resumes as soon as heatAlarm drops to 1 (DegradationHeat). Weapons recover at "red gone"; the generator demands full NormalHeat — an intentional binary asymmetry [T1].
|
||||
2. **Launchers** (open-questions.md:1086-1102, projweap gate-1): a LATCH (weaponAlarm 7 roach-motel, re-asserted unconditionally), recovery ONLY out-of-band via the EjectAmmo tap (@004bb9b8) or reset — never by cooling. This is the documented "missile spam brick".
|
||||
3. **Generator**: a state-machine arm (case 4) that self-clears at NormalHeat — the OPPOSITE of a latch.
|
||||
|
||||
Family resemblance that could explain the once-seen "stuck offline forever" (did not repro): the generator DOES have latch-shaped states with no thermal recovery arm, and both are binary-authentic [T1]:
|
||||
- **The Idle park**: press generator OFF then ON while heatAlarm==1 (degradation band). The ON branch (@004b1ed0, part_013.c:9027-9033) sets startTimer=0 but does NOT touch stateAlarm when hot → parked at Idle(1) with generatorOn=1; case 1 has no restart arm; the FailureHeat re-trip doesn't fire (heat is only level 1). Recoverable only by another OFF/ON cycle after full cooling, or reset. Requires eng-page button presses — plausible for Oracle's first test.
|
||||
- **Crit-destroyed generator**: `simulationState==1` pins Idle every frame (powersub.cpp:1300-1303) — permanently out, authentically. If trip #1 coincided with a generator crit, "never came back" is correct behavior.
|
||||
- Non-button path: T parked in the degradation band forever keeps the breaker latched — but then the bar still shows red, contradicting "cooled off completely"; the bar and the alarm read the same currentTemperature, so this one is self-consistent and unlikely.
|
||||
|
||||
Bench hooks already in place: `[gen] X TRIPPED (T=...)` / `[gen] X RESTARTING (T=...)` under BT_HEAT_LOG (powersub.cpp:1308-1310, 1350-1352) give the exact trip/recovery timeline to line up against the audio.
|
||||
--- KEY FACTS ---
|
||||
* Trip: powersub.cpp:1304-1312 — stateAlarm.SetLevel(GeneratorOut=4) re-asserted EVERY frame while own heatAlarm==FailureHeat(2), i.e. currentTemperature > failureTemperature; binary-identical @004b1f7c (part_013.c:9062-9064) [T1]
|
||||
* Recovery: powersub.cpp:1340-1357 — requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature; then GeneratorStarting(0) -> spin-up startTime -> GeneratorReady(2) with outputVoltage=(1-SourceLevel())*ratedVoltage; binary-identical (part_013.c:9087-9097) [T1]
|
||||
* heatAlarm thresholds heat.cpp:851-862 (@004ad924): >failureT=FailureHeat(2), >degradationT=DegradationHeat(1), else NormalHeat(0); NO hysteresis [T1]
|
||||
* Era rule already satisfied: the generator heat bar (VertTwoPartBar, btl4gau2.cpp:980-998; Execute btl4gaug.cpp:1321-1360 @004c48fc) paints its fill ONLY in the degradationT..failureT segment — 'red portion gone' == T<=degradationT == the recovery gate; with authored deg=1000/fail=2000 the warn line is at half the bar [T1 code, T3 for generator-specific authored values]
|
||||
* Alarm edge semantics faithful: GaugeAlarm54::SetLevel (heat.hpp:106) == binary FUN_0041bbd8 (part_002.c:5512-5546) == engine StateIndicator::SetState (SIMULATE.cpp:155-217): oldState:=current unconditionally, notify watchers only on change [T0/T1]
|
||||
* Trip edge DOES notify audio synchronously in the port: StateChanged(prev->4) on the trip frame via the alarm's +0x18 audio socket (AUDWTHR.cpp:918, 933-945); recovery emits 4->0 then 0->2; layout GaugeAlarm54==StateIndicator verified (header 0xC, oldState@0x10, currentState@0x14, sockets 0x18/0x2C/0x40) [T0+T1]
|
||||
* No ForceUpdate/updateModel/SetDirty mark at either edge in port or binary; AudioStateWatcher/AudioStateTrigger are NOT on the per-frame poll socket (only AudioTriggerOf/AudioMatchOf call simulation->AddAudioWatcher — AUDWTHR.h:293/:770) [T0]
|
||||
* Which edge plays the voice is decided by authored AudioStateTrigger fields (triggerState/inverseTrigger/exclude_state, AUDWTHR.cpp:1046-1105): inverse trigger on state 4 fires exactly on the RECOVERY edge — matching Oracle's symptom; the [statecfg] dump under BT_ATTRBIND_LOG (AUDWTHR.cpp:981-987) prints every authored GeneratorState trigger config at load [T0 mechanism, T4 on which config is authored]
|
||||
* stateAlarm=4 writers: ONLY the thermal breaker; MarkGeneratorOut/BTGeneratorMarkOut (powersub.cpp:1787) has zero callers since 2026-08-03 (mechsub.cpp:290-297) [T2]
|
||||
* Stuck-offline candidates, all binary-authentic [T1]: (a) Idle park — generator ON pressed while heatAlarm==1: @004b1ed0 (part_013.c:9027-9033) sets no state when hot -> parked at Idle(1), no restart arm, FailureHeat re-trip cannot fire at level 1; (b) crit-destroyed generator pinned Idle every frame (powersub.cpp:1300-1303, simulationState==1)
|
||||
* FailureHeat family recompute paths are DISTINCT: emitter auto-resumes at heatAlarm<2 (@004baa88); launcher latches weaponAlarm 7, recovery only via EjectAmmo tap (@004bb9b8) or reset (open-questions.md:1086-1102); generator self-clears only at heatAlarm==0 — shared PRODUCER (own heatAlarm) but no shared recovery path [T1]
|
||||
* Weapons recover at DegradationHeat (red gone) but the generator requires NormalHeat — an intentional binary asymmetry, not a port bug [T1]
|
||||
* Bench hooks: BT_HEAT_LOG prints '[gen] X TRIPPED (T=..)' / '[gen] X RESTARTING (T=..)' (powersub.cpp:1308-1310, 1350-1352) for a trip/recovery-vs-audio timeline [T2]
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored AudioStateTrigger config for Generator.GeneratorState (triggerState/inverseTrigger/exclude_state values) is unknown from this lane — it lives in the 1995 audio stream; the BT_ATTRBIND_LOG [statecfg] load-time dump would settle which edge the 'Generator Out' speech keys on. Until then, whether the wrong edge is authored-inverse (STOP half of a pair firing as the only live trigger) or a dead trip-side binding is T4.
|
||||
* Whether the 'Generator Out' voice is even bound to Generator.GeneratorState vs GeneratorOn (a live x4 attr that does NOT change on thermal trip) vs a PoweredSubsystem electrical-state attribute the binary published but the port does not (port publishes only InputVoltage on PoweredSubsystem, powersub.cpp:100-104) — unverified.
|
||||
* Generator-specific authored DegradationTemperature/FailureTemperature values: only the myomer profile (1000/2000, BTL4.RES) is confirmed; generators are authored per-subsystem in content and could differ, which would shift where 'half-cooled' sits on the bar.
|
||||
* Whether Oracle's once-seen stuck generator was crit-destroyed (authentic permanent-out), the Idle park (requires OFF/ON presses while in the yellow band), or something else — not reproducible from code reading; needs the field log for that session.
|
||||
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:908-917) is assumed to PASS for GaugeAlarm54 sockets (constructed SChainOf vptr at +0x18); AUDIO_FIDELITY F8's 'all 118 state watchers bind real StateIndicators' supports this, but a per-generator [audiostate] skip line under BT_AUDIO_LOG in the FIELD build has not been ruled out this session.
|
||||
* Prime-time notification (PrimeWatcher -> SendNotificationOfChange at audio-page bind if the streamed sendNotificationOnChange flag is set) delivers StateChanged(0,2) at load — whether any authored trigger misfires on that is unchecked.
|
||||
|
||||
|
||||
########## LANE 1 ##########
|
||||
## The binary's Generator state machine (issue #154 lane: BTL4OPT.EXE simulation)
|
||||
|
||||
### Object map (Generator, classID 0xBC1, ctor @4b225c, vtable 0x50fd74)
|
||||
The generator's operational state machine is a 0x54-byte StateIndicator at gen+0x1FC (ctor `FUN_0041b9ec(this+0x1FC, 5)` = 5 levels; current level @0x210 = the "stateAlarm", previous level @0x20C) [T1]. Fields set by the ctor @4b225c: ratedVoltage@0x1D8←res+0xFC, outputVoltage@0x1DC, maxTapCount@0x1E4←res+0x100, startTime@0x1EC←res+0x104, startTimer@0x1F0, shortRecoveryTime@0x1F4←res+0x108, shortTimer@0x1F8, generatorOn@0x1D4=1, percentAvailable@0x1D0=1.0, generatorNumber@0x1E0 = last char of name − 0x40 (A=1..D=4). Initial state = SetState(2) Ready [T1]. States (2=Ready confirmed by HasVoltage @4b0b5c requiring source+0x210==2; 3=short-recovery and 1/4=off/out confirmed by the consumer-side dump @4b1224 which maps source 3→"Shorted", source 1||4→"GeneratorOff"): 0=Starting, 1=Off/Idle, 2=Ready, 3=ShortRecovery, 4=GeneratorOut.
|
||||
|
||||
The heat alarm is a SECOND, separate 3-level StateIndicator at gen+0x170 (level @0x184), inherited from HeatableSubsystem (ctor @4adda0: `FUN_0041b9ec(this+0x170, 3)`), with debug names @4ae050: 0="NormalHeat", 1="DegradationHeat", 2="FailureHeat" [T1].
|
||||
|
||||
### 1. THE THERMAL TRIP — exact write site
|
||||
`GeneratorSimulation` = FUN_004b1f7c(this, dt) (part_013.c:9049-9107). Its preamble, every tick, BEFORE the state switch:
|
||||
- base call FUN_004ad924 (HeatableSubsystem sim) recomputes temperature@0x114 = heatContent@0x158 / thermalMass@0x154 and drives the heat alarm with NO hysteresis: `if (T > FailureTemperature@0x11C) SetState(heatAlarm,2); else if (T > DegradationTemperature@0x118) SetState(heatAlarm,1); else SetState(heatAlarm,0)` (part_013.c:5638-5646) [T1]. The thresholds are authored content: HeatSink ctor @4adda0 loads 0x114←res+0xE4 StartingTemperature, 0x118←res+0xE8 DegradationTemperature, 0x11C←res+0xEC FailureTemperature (parse @4ae150) [T1].
|
||||
- then `if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064). **This is the ONLY producer of stateAlarm 4 in the binary** — I walked every SetState on the +0x1FC machine: ctor(2), ResetToInitialState @4b215c (0 then 2), toggle @4b1ed0 (0/1), sim @4b1f7c (1/4/2/2/1/0), HandleMessage msg-4 @4b21d0 (3), PoweredSubsystem-initiated short @4b11bc (3). No other site writes 4 [T1]. (The `SetState(+0x99,4)` calls in @4b0bd0/@4b0e6c are the CONSUMER PoweredSubsystem's own 5-state electrical machine @0x278 — 0=Starting,1=NoVoltage,2=Shorted,3=GeneratorOff,4=Ready per the string dump @4b1224 — do not confuse the two.)
|
||||
- Also in the preamble: `if (this+0x40 == 1) SetState(genState, 1)` — pins a +0x40==1 generator to Off every tick.
|
||||
- No gate on the trip: it fires at any experience level *mechanically*, but temperature can only rise when the heat model integrates (FUN_004ad7d4 gate = player+0x260, veteran+expert), so trips only occur when the heat model is on [T1].
|
||||
|
||||
### 2. THE RECOVERY — exact re-arm condition and threshold
|
||||
Case 4 body (part_013.c:9087-9097): output forced 0 every tick; `if (heatAlarm@0x184 == 0)`: if generatorOn@0x1D4==0 → SetState(1) Off; else → startTimer@0x1F0=0, SetState(0) **Starting** — then case 0 counts startTimer up and at startTime seconds does SetState(2) Ready + `outputVoltage = (1.0 − damageZone->damageLevel) × ratedVoltage` (constant @4b2154 byte-verified = 3F800000 = 1.0f in the exe's CODE section, file offset 0xB1F54).
|
||||
|
||||
**The re-arm threshold is NOT "half" of anything — there is no 0.5 multiplier anywhere in the generator path** (constants adjacent to the sim: @4b2154=1.0, @4b2158=0.0, @4b2258=1.0, @4b297c=0.0 — all byte-verified from BTL4OPT.EXE) [T1]. Recovery requires the heat alarm to return to **0 (NormalHeat), i.e. T ≤ DegradationTemperature** — alarm 1 (DegradationHeat) is NOT enough; the generator stays OUT through the whole degradation band. The hysteresis is structural: trip at T > FailureTemperature, re-arm at T ≤ DegradationTemperature, plus a full StartTime spin-up before voltage returns.
|
||||
|
||||
**The tester's "red portion of the heat bar gone" rule matches this EXACTLY in gauge terms**: the two-part temp bars normalize to FailureTemperature with the warn boundary at DegradationTemperature (warnPix = width·Degrade/Failure, value = CurrentTemperature — gauges-hud.md §TEMP/STATUS bar, @4c4170/@4c4724) [T1]. "Red gone" ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm condition. Whether that is numerically "half-cooled" depends on authored per-mech values; field notes (open-questions.md: generators equilibrate 1100–1500 K in the degradation band vs the ~2000 FailureHeat line) put Degrade/Failure ≈ 0.55 — approximately half [T3, authored temps not extracted from the archives].
|
||||
|
||||
### 3. WHICH EDGE the annunciator hears
|
||||
The stateAlarm machine IS a StateIndicator; SetState = FUN_0041bbd8: first line unconditionally copies current→old (+0x10←+0x14), and ONLY on an actual value change it stores the new state and synchronously fires all three watcher chains (+0x18 audio / +0x2C video / +0x40 gauge), each watcher's vtbl+0x14 (part_002.c:5510-5546) [T1]. So:
|
||||
- **stateAlarm 4 is written AT TRIP** (edge old=2→new=4 fires the audio chain the same tick T crosses FailureTemperature), **re-asserted every tick while out (edge-filtered no-ops, no re-fire), and REPLACED at recovery** by the edge 4→0 (Starting), followed StartTime seconds later by 0→2 (Ready). Nothing "clears" 4 separately; no write of 4 happens at recovery.
|
||||
- What SOUND plays on which edge is decided by the AUTHORED AudioStateTrigger record streamed from content, engine code AUDWTHR.cpp `AudioStateTrigger::StateChanged` [T0]: fires when `!inverseTrigger && new_state==triggerState` (ENTER edge) or `inverseTrigger && old_state==triggerState` (LEAVE edge), with optional exclude_state suppression. **Both keying modes exist in the engine.** A "Generator Out" sample keyed non-inverse on state 4 speaks at TRIP; one keyed inverse on 4 (or non-inverse on 0/2) speaks at RECOVERY. The binary/engine machinery emits both edges faithfully — if the field build speaks only at recovery, the discriminator is the authored trigger record (trigger_state / inverse_trigger / exclude_state for `Generator.GeneratorState`), which has NOT been dumped ([statecfg] via BT_ATTRBIND_LOG would capture it; no capture exists in scratchpad/docs). Note the port's GaugeAlarm54::SetLevel (heat.hpp:106) and StateIndicator layout (STATE.h: oldState@0x10/currentState@0x14) reproduce the binary semantics exactly, including the unconditional old←current copy — the port sim (powersub.cpp:1296 GeneratorSimulation) is a faithful transcription of @4b1f7c, so the mis-edged audio is NOT a divergence in the generator sim itself [T1].
|
||||
|
||||
### 4. "No Ready-case recompute" — CONFIRMED, with one refinement
|
||||
The switch in @4b1f7c has cases 0,1,3,4 and **NO case 2** — in Ready the sim touches nothing; outputVoltage is recomputed as `(1−damage)×rated` ONLY on the two Ready-ENTRY transitions (case 0 timeout @9069-9073, case 3 timeout @9081-9085) [T1]. Refinement: `Generator::HandleMessage` @4b21d0 ALSO recomputes `(1−damage)×rated` on ANY message id ≠ 4 (gated by player+0x274 ≠ 0, not-novice), and msg 4 (short) drives SetState(3)+shortTimer=0 when +0x40 ∈ {0,2} — so a message delivery is a third recompute path, but the crit distributor delivers damage without messaging the generator, so the decomp-reference claim stands for the destroyed-in-place case [T1].
|
||||
|
||||
### Bonus: a binary-authentic "stuck offline forever" candidate
|
||||
ToggleGeneratorOnOff @4b1ed0 (not-novice gate FUN_004ac9c8, press>0): toggling ON while the heat alarm ≠ 0 sets generatorOn=1 and startTimer=0 but deliberately does NOT SetState — it leaves the machine in whatever state it is in. If the pilot toggles a TRIPPED (state 4) generator OFF (→ state 1, generatorOn=0) and back ON while still hot (state stays 1, generatorOn=1), the machine is now in state 1 with generatorOn=1 — and case 1 has NO exit logic and NO thermal re-arm check. When the heat later cools to normal, nothing transitions: the generator stays offline indefinitely, recoverable only by another full off/on toggle pair after cooling, or by re-tripping to state 4 (whose case body honors generatorOn) [T1, pure case-walk — matches the tester's non-reproducing "stuck offline forever" one-off]. This is the same defect family as the "no Ready-case recompute" latch and the open-questions FailureHeat weapon brick (weaponAlarm 7 — a different machine, same alarm-latch pattern).
|
||||
--- KEY FACTS ---
|
||||
* stateAlarm 4 (GeneratorOut) has exactly ONE producer in the binary: the thermal breaker at the top of GeneratorSimulation @004b1f7c — `if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064); it is written AT TRIP, held (edge-filtered) while out, and replaced 4→0→2 at recovery — no write of 4 occurs on the recovery edge [T1]
|
||||
* Re-arm condition (case 4 body @004b1f7c, part_013.c:9087-9097): heat alarm must return to 0 = NormalHeat, i.e. currentTemperature ≤ DegradationTemperature (res+0xE8) — NOT half of FailureTemperature; no 0.5 constant exists in the path (sim constants @4b2154=1.0f, @4b2158=0.0f, @4b2258=1.0f byte-verified in BTL4OPT.EXE CODE section) [T1]
|
||||
* Recovery is two-stage: 4→0 Starting (startTimer reset), then after StartTime (res+0x104) seconds 0→2 Ready with outputVoltage = (1.0 − damageZone->damageLevel) × ratedVoltage; if generatorOn@0x1D4==0 at cool-down it goes 4→1 Off instead [T1]
|
||||
* The heat alarm producer FUN_004ad924 (part_013.c:5638-5646) has NO hysteresis: >FailureTemperature→2, >DegradationTemperature→1, else 0, every tick; thresholds loaded by HeatSink ctor @004adda0 from res+0xE4/0xE8/0xEC (StartingTemperature/DegradationTemperature/FailureTemperature, parse @004ae150); alarm names @004ae050: Normal/Degradation/FailureHeat [T1]
|
||||
* The tester's era rule maps exactly: the gauge temp bars place the warn/red boundary at DegradationTemperature over a 0..FailureTemperature scale (warnPix = width·low/high, gauges-hud.md, @004c4170/@004c4724), so 'red portion gone' ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm threshold [T1]; numerically ≈0.55·Failure from field equilibrium notes (1100–1500 K degradation band vs ~2000 FailureHeat) [T3]
|
||||
* The audio edge is decided by AUTHORED content, not code: engine AudioStateTrigger::StateChanged (AUDWTHR.cpp) fires on ENTER (non-inverse: new_state==triggerState) or LEAVE (inverse: old_state==triggerState) with optional exclude_state; the state machine (FUN_0041bbd8, part_002.c:5510) fires its audio/video/gauge chains synchronously on every level CHANGE, old←current copied unconditionally first [T0/T1]
|
||||
* The authored AudioStateTrigger record for Generator.GeneratorState (trigger_state/inverse_trigger/exclude_state) has never been dumped — no [statecfg] capture exists in scratchpad or docs; BT_ATTRBIND_LOG would print it at load [T2 gap]
|
||||
* 'No Ready-case recompute' CONFIRMED: the @004b1f7c switch has cases 0,1,3,4 and no case 2; recompute happens only on Ready-entry (case 0/3 timeouts) and on Generator::HandleMessage @004b21d0 for any message id ≠ 4 when player+0x274 ≠ 0 (not-novice); msg 4 = short → SetState(3)+shortTimer=0 when this+0x40 ∈ {0,2} [T1]
|
||||
* Binary-authentic stuck-offline latch found: ToggleGeneratorOnOff @004b1ed0 turning ON while heatAlarm ≠ 0 sets generatorOn=1 WITHOUT a state transition; from state 1 (Off) there is no thermal re-arm path, so off→on toggling a hot tripped generator strands it offline after cooling until another cold toggle pair or a fresh alarm-2 trip [T1 case-walk, matches the tester's one-off]
|
||||
* Generator toggle and short handling are novice-locked: FUN_004ac9c8 = (player+0x274 == 0); the heat-model integration gate is player+0x260 (veteran+expert) via FUN_004ad7d4, but the alarm thresholding runs ungated [T1]
|
||||
* Consumer view for cross-checks: PoweredSubsystem sim @004b0bd0 maps source stateAlarm 3→own state 2 'Shorted', 1||4→3 'GeneratorOff', 2→restart via 0 'Starting'; own electrical state names @004b1224: 0 Starting/1 NoVoltage/2 Shorted/3 GeneratorOff/4 Ready (machine @+0x264, level @0x278 — distinct from the generator's @0x1FC/0x210) [T1]
|
||||
* The port's generator machinery is a faithful transcription: powersub.cpp:1296 GeneratorSimulation mirrors @004b1f7c line-for-line, GaugeAlarm54::SetLevel (heat.hpp:106) mirrors FUN_0041bbd8 including the unconditional old←current copy, and engine StateIndicator layout (oldState@0x10/currentState@0x14) matches — the generator sim is NOT the source of the mis-edged audio [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored trigger_state/inverse_trigger/exclude_state for the Generator.GeneratorState AudioStateTrigger (and which SF2 preset/control it drives) is undumped content data — this alone determines whether 'Generator Out' speaks at trip or recovery in an authentic build; dump with BT_ATTRBIND_LOG=1 ([statecfg]/[trigcfg] lines) on a mech load
|
||||
* Whether the pod's SPOKEN 'Generator Out' warning even rides the AudioStateTrigger/SF2 path at all, versus a separate speech/annunciator system — not established in this lane
|
||||
* The semantics of gen+0x40 (port name simulationState): ==1 pins the machine to Off each tick and excludes the generator from eject-counting, but what writes 1 there is unlocated; decomp-reference's 'destroyed in place keeps stale Ready voltage' implies destruction does NOT set it — the two claims need reconciling
|
||||
* Actual authored Generator StartingTemperature/DegradationTemperature/FailureTemperature/StartTime values per mech are inside the compiled content archives (only the exe and L4GAUGE.CFG reference the key names as text); the ≈0.55 Degrade/Failure ratio is inferred from field equilibrium notes, not extracted values
|
||||
* Whether message id 4 ('short') is ever delivered to Generator::HandleMessage in normal play (vs the @004b11bc ForceShortRecovery direct-call path) — the message's producer was not traced
|
||||
* @004b2164 (Generator::DeathReset thunk cited by the port) sits in an export gap between @4b215c and @4b21d0 — shape assumed from the port comment, not read from pseudocode
|
||||
|
||||
|
||||
########## LANE 2 ##########
|
||||
LANE: the port's generator reconstruction (game/reconstructed/powersub.cpp + heat.cpp/hpp, engine AUDWTHR.cpp watchers).
|
||||
|
||||
== 1. Trip + recovery logic and thresholds ==
|
||||
|
||||
The generator thermal breaker lives in Generator::GeneratorSimulation, C:/git/bt411/game/reconstructed/powersub.cpp:1296-1372, a line-for-line transcription of the binary @004b1f7c (reference/decomp/all/part_013.c:9049-9107).
|
||||
|
||||
TRIP (powersub.cpp:1304-1312):
|
||||
```
|
||||
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
|
||||
{
|
||||
... BT_HEAT_LOG "[gen] <name> TRIPPED (T=...)" on the transition only ...
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4 -- called EVERY frame while FailureHeat holds
|
||||
}
|
||||
```
|
||||
Binary: `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);` (part_013.c:9062-9064) [T1 exact match].
|
||||
|
||||
RECOVERY (powersub.cpp:1340-1357):
|
||||
```
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
if (generatorOn == 0) stateAlarm.SetLevel(GeneratorIdle); // 1 -- STAYS DOWN until manual toggle
|
||||
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0 -> after StartTime secs -> SetLevel(2) Ready
|
||||
}
|
||||
```
|
||||
Binary: `case 4: if (param_1[0x61] == 0) { if (param_1[0x75]==0) SetLevel 1; else { [0x7c]=0; SetLevel 0; } }` (part_013.c:9087-9098) [T1 exact match].
|
||||
|
||||
The heat thresholds are NOT in the generator — they are the HeatSink heatAlarm drive at the tail of HeatSinkSimulation, heat.cpp:851-862: `T > failureTemperature -> FailureHeat(2); else T > degradationTemperature -> DegradationHeat(1); else NormalHeat(0)`. Binary @004ad924 tail is identical (`param_1[0x47] < T -> 2; param_1[0x46] < T -> 1; else 0`, temps @0x11C/@0x118) — NO hysteresis on either side [T1]. Both temperatures are authored per-subsystem resource fields ("DegradationTemperature"/"FailureTemperature"); their generator values live in the streamed .RES model data and were not dumped here.
|
||||
|
||||
CONSEQUENCE FOR THE ERA RULE ("back online when the red bar portion is gone / half-cooled"): the recovery gate is heatAlarm==0, i.e. T must fall BELOW degradationTemperature — the LOWER of the two thresholds, well below the trip point. The heat gauges bind CurrentTemperature with DegradationTemperature + FailureTemperature as zone markers (content/GAUGE/L4GAUGE.CFG:4434-4436 pattern), so if the bar's red zone starts at DegradationTemperature, "red gone" == the exact recovery gate and the port is ALREADY era-correct — which matches the tester's own runs 2 and 3 ("it did come back online after cooling halfway", scratchpad/night14/housekeep14.py:224-240). This is the binary's behavior byte-for-byte; there is nothing to change in the sim [T1 code, T3 for the red-zone==degradation mapping, unverified against the palette].
|
||||
|
||||
== 2. Where stateAlarm is written, and on which edge ==
|
||||
|
||||
All stateAlarm (alarm @0x1FC, level @0x210) writers in the port:
|
||||
- ctor powersub.cpp:1113 `SetLevel(GeneratorReady)` (2)
|
||||
- GeneratorSimulation trip :1311 `SetLevel(4)` — TRIP edge, re-asserted every held frame
|
||||
- GeneratorSimulation recovery :1346/:1354 `SetLevel(1)` or `SetLevel(0)`, then :1321 `SetLevel(2)` after StartTime — RECOVERY is TWO notify edges: 4->0, then 0->2
|
||||
- ToggleGeneratorOnOff :1212/:1221 (0 on healthy re-arm, 1 on switch-off)
|
||||
- ResetToInitialState :1269-1270 `SetLevel(0); SetLevel(2);` — every respawn fires a 0-then-2 notify pair (binary @004b215c does the same, part_013.c:9119-9120)
|
||||
- Generator::HandleMessage msg 4 :1392 `SetLevel(GeneratorShorted)` (3)
|
||||
- ForceShortRecovery (PoweredSubsystem) :555 and PowerWatcher::HandleMessage :1564 (both set 3)
|
||||
- MarkGeneratorOut (powersub.hpp:483) — documented UNUSED; the destruction bridge was removed as unfounded (powersub.hpp:471-481).
|
||||
|
||||
The notify mechanism: GaugeAlarm54::SetLevel (heat.hpp:106) `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState updated unconditionally FIRST, watchers fired ON CHANGE ONLY. Binary FUN_0041bbd8 (part_002.c:5512-5546) is semantically identical: `+0x10 = +0x14` unconditionally, then if-changed set +0x14 and Execute() every watcher on the three chains @+0x18/+0x2c/+0x40 [T1]. So the per-frame SetLevel(4) spam while tripped notifies exactly once, at the 2->4 trip edge.
|
||||
|
||||
THEREFORE: the port's sim DOES deliver a watcher notification on the TRIP edge (old=2, new=4), and delivers two more on recovery (4->0, 0->2). The generator sim is not the component putting the audio on the wrong edge. There is NO ForceUpdate/SetDirty/updateModel mark at either edge — grep of powersub.cpp + heat.cpp is empty, and the binary @004b1f7c contains nothing but the SetLevel calls and arithmetic; the watcher chains ARE the notification path [T1].
|
||||
|
||||
AUDIO WIRING (adjacent, for synthesis): GeneratorState is published as an attribute directly on stateAlarm (powersub.cpp:1051, ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)); the audio renderer binds authored AudioStateTriggers to it by name (context/wintesla-port.md:94, "audiostate skips 85 -> 0"). Trigger evaluation is engine T0 (engine/MUNGA/AUDWTHR.cpp:1046-1105): non-inverse fires when new_state==triggerState, INVERSE fires when old_state==triggerState (i.e. on LEAVING the state), with optional exclude pairs. Given the sim edges above, a sound heard ONLY at recovery means the live trigger fired on 4->0 or 0->2 — i.e. either an inverse trigger on state 4 ("leaving GeneratorOut") or a trigger on entering 0/2. Which of those is authored is in the streamed audio pages, NOT in the port code — undumped. BT_ATTRBIND_LOG=1 prints every AudioStateTrigger's `[statecfg] trigState/inv/ctl/excl` at bind time (AUDWTHR.cpp:981-987), and BT_HEAT_LOG prints "[gen] X TRIPPED"/"[gen] X RESTARTING" (powersub.cpp:1308-1311, 1350-1352); one capture correlating those against the heard sample settles the edge question definitively. Related suspicion [T4]: docs/AUDIO_FIDELITY.md F5/F6 document whole blocks of authored warning audio bound to dead attributes (ReportLeak is dead on GeneratorA-D among 19 subsystems, AUDIO_FIDELITY.md:402-411) — if the pod's trip-side warning rides one of those dead bindings, the trip would be silent in the port while a different, live authored cue (e.g. a spin-up/state sound at 4->0 or 0->2) is what the tester heard at recovery and labeled "the generator out warning".
|
||||
|
||||
== 3. Divergence vs the context claims ==
|
||||
|
||||
None found. context/decomp-reference.md:470-477 ("the generator sim has NO Ready-case recompute — a generator destroyed in place keeps stale Ready voltage until any transition recomputes output") is confirmed in both the port and the binary: the GeneratorSimulation switch has cases 0/1/3/4 only, no case 2 — outputVoltage is untouched while Ready [T1]. context/subsystems.md:103-105 ("(1 − generator damage) × rated, thermal breaker on FailureHeat, no load model") matches the transcription (the `(MaxOutputVoltage - SourceLevel()) * ratedVoltage` recomputes at :1322/:1336/:1398). The gnrator.cpp/gnrator.hpp pair is an alias note only — no second class, no code emitted (gnrator.cpp:10-24). One historical note: Generator::ResetToInitialState deliberately diverges from the surviving GNRATOR.TCP source (which zeroed outputVoltage) to follow the shipped binary @004b215c (outputVoltage = ratedVoltage) — documented at powersub.cpp:1238-1263 and docs/RESPAWN_REARM_PLAN.md:116 [T1].
|
||||
|
||||
== 4. FailureHeat "permanent brick" family (open-questions.md:1086-1102) ==
|
||||
|
||||
The generator does NOT share a recompute path with the weapon FailureHeat family — only the PREDICATE. All three families gate on the identical HeatSink heatAlarm level 2 at this+0x184 (open-questions.md:644-647, disasm-verified consumers), but their latch semantics differ:
|
||||
- Emitters (@004baa88): FailureHeat -> ResetFiringState + currentLevel=0 "until it cools" — SELF-RECOVERING.
|
||||
- Ballistic/missile launchers (@004bbd36 / projweap gate 1): FailureHeat -> weaponAlarm 7 (NoAmmo), re-asserted unconditionally inside the machine — a LATCH released only by an EjectAmmo tap or ResetToInitialState (the reproduced 19-rounds-left brick, open-questions.md:1086-1102).
|
||||
- Generator (@004b1f7c case 4): SELF-RECOVERING when heatAlarm returns to 0.
|
||||
So the missile roach-motel mechanism cannot produce the tester's once-seen "stuck offline forever" generator. Port-lane candidates for that non-reproducing report, both mechanisms [T1], scenario attribution [T4]: (a) the generatorOn==0 branch — if the pilot's generator toggle (RIO 0x1A-0x1D) had put generatorOn=0 (or was pressed while tripped: the OFF branch also zeroes coolantFlowScale, powersub.cpp:1219-1225, so the tripped generator loses its own coolant flow and cools only by conduction), a cooled generator lands in Idle(1) and NEVER self-restarts — authentic binary behavior requiring a manual toggle-ON; (b) a cooling stall — recovery needs T <= degradationTemperature (far below the trip point at failureTemperature); with coolant starved the last stretch can take a very long time, reading as "never" in one sitting while the heat BAR (which may visualize a different normalization) reads "cooled".
|
||||
--- KEY FACTS ---
|
||||
* Port trip gate: heatAlarm==FailureHeat(2) -> stateAlarm.SetLevel(GeneratorOut=4), powersub.cpp:1304-1312, re-asserted every frame while held; byte-matches binary @004b1f7c (part_013.c:9062-9064) [T1]
|
||||
* Port recovery gate: case GeneratorOut requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature (the LOWER threshold), then 4->0 (Starting) -> after authored StartTime -> 0->2 (Ready); powersub.cpp:1340-1357 + 1316-1324; byte-matches binary part_013.c:9087-9098 [T1]
|
||||
* Heat alarm thresholds have NO hysteresis: T>failureTemperature->2, T>degradationTemperature->1, else 0; heat.cpp:851-862 == binary @004ad924 tail (temps @0x118/@0x11C) [T1]
|
||||
* GaugeAlarm54::SetLevel (heat.hpp:106) notifies watchers ON CHANGE ONLY, oldState updated unconditionally first — semantically identical to binary FUN_0041bbd8 (part_002.c:5512-5546); the per-frame SetLevel(4) spam produces exactly ONE trip-edge notification (old=2,new=4) [T1]
|
||||
* The port sim DOES fire a watcher notification on the trip edge; a sound heard only at recovery means the bound AudioStateTrigger fired on 4->0 or 0->2 — either an authored INVERSE trigger on state 4 (fires on old_state==triggerState, engine T0 AUDWTHR.cpp:1079-1082) or a trigger on entering Starting/Ready; the authored trigger config is in the streamed audio pages, undumped [T1 mechanics, config unknown]
|
||||
* No ForceUpdate/SetDirty/updateModel mark at either edge in port (grep empty over powersub.cpp+heat.cpp) or in binary @004b1f7c — the alarm watcher chains are the only notification path [T1]
|
||||
* GeneratorState attribute is published directly on stateAlarm (powersub.cpp:1051); audio binding verified live ('audiostate skips 85->0', context/wintesla-port.md:94-99) [T2]
|
||||
* decomp-reference.md 'no Ready-case recompute' CONFIRMED: the GeneratorSimulation switch has no case 2 in port or binary; outputVoltage stays stale while Ready [T1]; no divergence found between port and context/decomp-reference.md or context/subsystems.md generator claims
|
||||
* Generator does NOT share the weapon FailureHeat recompute/latch path — only the heatAlarm==2 predicate (this+0x184). Emitters @004baa88 self-recover, ballistic/missile @004bbd36 latch weaponAlarm 7 until EjectAmmo/reset (the reproduced brick, open-questions.md:1086-1102), generator self-recovers at heatAlarm==0 [T1]
|
||||
* Stuck-offline-forever candidates (mechanisms T1, attribution T4): generatorOn==0 at cool-down lands Idle(1) which never self-restarts (manual toggle required, authentic); toggling a generator OFF also zeroes its coolantFlowScale (powersub.cpp:1221-1224) starving its own cooling; recovery needs cooling all the way below degradationTemperature, far under the trip point
|
||||
* ResetToInitialState fires a 0-then-2 SetLevel pair on every respawn (powersub.cpp:1269-1270 == binary @004b215c) — any audio trigger keyed on entering state 0 or 2 also fires at every respawn [T1]
|
||||
* Era-rule alignment: heat gauges bind DegradationTemperature+FailureTemperature as zone markers (L4GAUGE.CFG:4434-4436 pattern); if the red zone starts at DegradationTemperature, 'red gone' == the recovery gate exactly — matching the tester's runs 2-3 recovery at half-cooled [T3]
|
||||
* Diagnostics that settle the audio edge: BT_ATTRBIND_LOG=1 dumps every AudioStateTrigger's trigState/inverse/exclude at bind (AUDWTHR.cpp:981-987); BT_HEAT_LOG prints [gen] TRIPPED/RESTARTING transition lines (powersub.cpp:1308-1311,1350-1352)
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored AudioStateTrigger configuration for GeneratorState (trigger states, inverse flags, exclude pairs, control targets) is in the streamed audio pages of the mech model resources — not dumped; this single datum decides whether the recovery-edge sound is authored (inverse/state-0 trigger, pod would behave the same) or a port parsing/eval defect. Capture with BT_ATTRBIND_LOG=1.
|
||||
* Which sample the tester actually heard ('generator out warning') is unidentified — no GeneratorOut-named wav exists in audiopresets.cpp; it may be a Warnings01 klaxon split or another state cue misattributed. The trip-side warning may ride a DEAD authored binding (the AUDIO_FIDELITY F5/F6 family; ReportLeak is dead on GeneratorA-D) [T4].
|
||||
* Authored DegradationTemperature/FailureTemperature values for the generators (streamed .RES resources) not extracted; the 'red zone == degradation band' heat-bar mapping is inferred from the gauge attribute bindings, not verified against the palette rendering.
|
||||
* Whether the 1995 pod played the warning at the trip edge is era testimony only [T4]; nothing in the decomp examined here identifies the trip-side audio path.
|
||||
* The once-seen stuck-offline generator: neither candidate (generatorOn Idle landing, cooling stall) is confirmed against the tester's session — no log was captured for that run.
|
||||
|
||||
|
||||
########## LANE 3 ##########
|
||||
## #154 Generator Out warning — KB + field-evidence lane, raw findings
|
||||
|
||||
### 1. What the KB already claims (inventory)
|
||||
|
||||
**The generator state machine (binary-grounded):**
|
||||
- `stateAlarm` levels: 0=Starting, 1=Idle, 2=Ready/OnLine, 3=Shorted, 4=GeneratorOut. stateAlarm 4's producer = "the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03 destruction→state-4 bridge was removed as unfounded" — `context/decomp-reference.md:470-477` [T1].
|
||||
- "The generator sim has NO Ready-case recompute — a generator destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)" — `context/decomp-reference.md:472-475` [T1, @0049c9a8 + FUN_004b1f7c].
|
||||
- Offsets: `stateAlarm@0x1FC` (the 0x54 GaugeAlarm54 object base — `docs/HARD_PROBLEMS.md:251`, `docs/RESPAWN_REARM_PLAN.md:250`) vs `stateAlarm@0x210` in the eject clause (`decomp-reference.md:425`). These are CONSISTENT, not a conflict: 0x210 = 0x1FC + 0x14 = the alarm's `level` field (GaugeAlarm54 level@+0x14, `context/subsystems.md:31`). Flagging because it reads like a contradiction.
|
||||
- `Generator::ResetToInitialState @004b215c` does `stateAlarm.SetLevel(0)` then `SetLevel(2)` — "a forced *change*, so watchers re-latch" — `docs/RESPAWN_REARM_PLAN.md:172` [T1]. This 0→2 double edge fires at EVERY respawn/mission start.
|
||||
- Condition-flag bit table (the `[techstat]` receipts): bit0 structure≥StatusThreshold, bit1 structure>StatusFloor, bit2 CoolantLeaking, bit3 heatAlarm≠0 (Overheating), bit6 !HasVoltage (BadPower; HasVoltage = source stateAlarm==2 && |V|>ε @004b0b5c) — `decomp-reference.md:454-489` [T1]. Conditions are ROUTINE operating flags, not alarms; never read a lone SET as a latch [T2, field-verified 2026-08-08].
|
||||
- Audio binding: `Generator.GeneratorState`→stateAlarm registered as a real StateIndicator; "SetLevel fires them on change"; "generator/condenser/reservoir/ammo state sounds fire on transition" — `context/wintesla-port.md:87-99` [T2]. Port-side registration: `game/reconstructed/powersub.cpp:1051` `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` with comment "SetLevel fires audio".
|
||||
- `simulationFlags` bit 0 = engine DelayWatchersFlag — "setting it permanently skips the subsystem's ExecuteWatchers (audio watchers)" — `decomp-reference.md:248-250` [T0/T1]. (A watcher-suppression footgun adjacent to this family.)
|
||||
- Experience gates: "generators take damage/go offline (reroute power)" is VETERAN+; standard mode = "no generator outages", MechTech audio limited to the leg-damage warning — `context/experience-levels.md:76-81` [T1 primary source].
|
||||
|
||||
**What the KB does NOT contain:** any claim about WHICH state edge the "Generator Out" audio is authored on, any decode of the authored AudioStateTrigger state→note records for GeneratorState, and any claim about the recovery threshold's relation to the heat bar. The recovery threshold itself was undocumented in `context/` prose — I read it from the decomp directly (below).
|
||||
|
||||
### 2. Binary + authored-data ground truth (read this session)
|
||||
|
||||
- **`FUN_004b1f7c` (GeneratorSimulation), `reference/decomp/all/part_013.c:9053-9107` [T1]:**
|
||||
- Trip: `if (heatAlarm.level==2 /*FailureHeat*/) SetLevel(stateAlarm, 4)` — runs EVERY tick while at FailureHeat; edge-firing therefore depends entirely on GaugeAlarm54::SetLevel's fire-on-change contract.
|
||||
- Recovery: `case 4:` exits ONLY when `heatAlarm.level == 0` (NormalHeat): generatorOn==0 → SetLevel(1) Idle; else `startTimer=0; SetLevel(0)` Starting → spin-up `startTime` → `SetLevel(2)` Ready + voltage restore. So a tripped generator stays out through the ENTIRE FailureHeat+DegradationHeat band and returns only when T ≤ degradationTemperature.
|
||||
- **Authored thresholds, raw `content/BTL4.RES` dump [T1]:** GeneratorA-D (classID 0xBC1, 0x10C records): startingTemp=77, degradationTemp=1000, failureTemp=2000 (all six mech variants identical). heatAlarm banding has NO hysteresis: >2000→2, >1000→1, else 0 (`game/reconstructed/heat.cpp:851-862`, transcribed from @004ad924).
|
||||
- **Therefore the binary's recovery point is T ≤ 1000 = exactly HALF of the 2000 trip line.** Oracle's era rule ("gens return at HALF-cooled / red bar gone") is not a missing feature — it is the shipped binary's arithmetic, and the port (`powersub.cpp:1296-1372`) transcribes it faithfully. The threshold audit flagged in the handout should come back "already authentic," CONTINGENT on one unverified visual claim: that the cockpit heat bar paints its red band as the ≥degradationTemp region (gauge-coloring not audited this pass) [T3 for the bar mapping].
|
||||
- **Recovery emits TWO SetLevel edges (4→0, then 0→2 after spin-up); trip emits ONE (x→4). RTIS emits the same 0→2 pair at every respawn/mission start (@004b215c, part_013.c:9119-9120).** Any audio cue keyed to entering state 0 or state 2 will sound at recovery AND at every respawn/round start; a cue keyed to entering state 4 sounds at trip. This is the structural fingerprint the wrong-edge bug must sit on [T1 for the edge structure; the authored state→sound mapping itself is UNREAD — that is the code/data lane's job].
|
||||
|
||||
### 3. The 1995 manual (primary source, pages read this session)
|
||||
|
||||
- **p23 (GENERATORS / Generator Info Center), verbatim:** "If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way, which can take some time. If a generator is destroyed by a critical hit, an 'X' will appear in that generator's info center." — Oracle's era rule is PRINTED, word for word the half-way rule. Also feeds open era question #133 (the X = destroyed-by-crit, per the manual — not shutdown/overheat).
|
||||
- **p14 (MECHTECH / DAMAGE CONTROL):** the warning is listed among the spoken MechTech warnings: "*Warning, coolant leak … Warning, generator out … Warning, ammo bay fire*". MechTech audio warnings + smart buttons are veteran/expert-only; standard mode gets only the leg-damage message and "you are never required to switch generators".
|
||||
- **p19:** "when a generator overheats, it simply shuts down"; auto gen-mode cascade warning (all-auto + two dead gens can lock out sensors/myomers).
|
||||
- **p22:** "If your 'Mech stops moving, and the heat in your myomers is OK, check the generator; it may be the system that has overheated."
|
||||
- The warning is a VOICE line in 1995. **The port's SF2 banks contain no speech presets at all** (241 presets enumerated from `game/reconstructed/audiopresets.cpp`; the only warning-family presets are the `Warnings01` 8-way klaxon split and `AllWarning`). Whatever the tester hears as "Generator Out" in the port is a klaxon zone, not the spoken phrase — and where the original spoken phrases lived (not in AUDIO1/2.RES) is an unrecorded open question.
|
||||
|
||||
### 4. Field evidence (night15, 2026-08-10 steam logs, build 4.11.883)
|
||||
|
||||
- **The trip/recovery edges are FORENSICALLY INVISIBLE in field logs.** The only generator state receipts (`[gen] TRIPPED/RESTARTING`, powersub.cpp:1308-1352) are `BT_HEAT_LOG`-gated and appear 0 times in all six logs. No audio-warning receipts exist at all. #154's wrong-edge claim currently rests solely on Oracle's testimony + the manual; no log can confirm or refute it.
|
||||
- **Generator Overheating (cond 3) cycling is routine and balanced** — e.g. santo/MS-FIREFLY: ~30 GeneratorX cond-3 SETs with matching CLEAREDs; acaci/ZEUS GeneratorC 5 SET/4 CLEARED (log ends mid-heat). No latch anywhere = corroborates the stuck-forever non-repro.
|
||||
- **Probable real thermal trip captured (rajel/GAMERSLAB):** GeneratorB cond 3 SET :99453 (still set) → :103610-103625 ERSLaser_2 + ERPPC_2 cond 6 BadPower SET → cond 6 CLEARED :104109/:104273 → GeneratorB cond 3 CLEARED :104815 → re-SET :105025. **Note the ordering: the weapons regained voltage ~500-700 lines BEFORE the generator's Overheating flag cleared.** Under the binary model a same-generator recovery is impossible before heatAlarm→0, so this is either (a) manual/auto power rerouting to another generator (manual p14 procedure; the auto-hunt is live per `BT_POWER_DETACH_TEST` bench), or (b) an early-recovery defect. Logs cannot disambiguate (no per-weapon source receipts). Every other cond-6 cluster in the six logs traces to crit damage (gen cond 1+2 SET pairs = structure+coolant-leak flags, e.g. santo GeneratorD :107400/:107412) or to the mission-start spin-up wave (Dave :51669-52166: 11 subsystems BadPower while all gens run their startTime spin-up after load — clears in ~500 lines).
|
||||
- **The respawn reset clears gen conditions wholesale** (santo :89209-89210 GeneratorA cond 1+2 CLEARED inside the `[respawn] Mech::Reset` block) — and per @004b215c each respawn replays the 0→2 stateAlarm pair. If the port's warning cue is keyed to state 0 or 2, testers should ALSO hear it at every respawn/round start — a cheap discriminating question to put to Oracle.
|
||||
- **#135 adjacency receipt:** `[galarm] condition 2 code 0x80/0x87 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1)` — the generator-special lamp resolution path (@004cc27c power-family special) returns nothing for a leaking generator, so a generator coolant leak flashes NO annunciator. That is exactly Oracle's #135 ("coolant leak with NO flashing annunciators") signature on generators, in the current build's logs.
|
||||
|
||||
### 5. Prior-ticket / adjacent-defect map
|
||||
|
||||
- `docs/OPEN_ISSUES_FOR_TESTERS.txt:62-64` — #154 refined by Oracle: stuck-forever did NOT reproduce; era rule = half-cooled; "threshold audit due" (answer per §2: threshold already authentic).
|
||||
- #133 (open era question): the MFD X — manual p23 answers it: X = destroyed by critical hit.
|
||||
- The "missile spam trips FailureHeat permanent brick" (`context/open-questions.md:1086-1102`) is the WEAPON-side roach-motel: weaponAlarm 7 re-SetLevel(7) unconditionally (`decomp-reference.md:240-241`), a genuine latch. The GENERATOR breaker, by contrast, self-recovers by design. Different mechanism; only superficially the same family. If Oracle's once-seen "stuck offline forever" was real, the in-model candidates are: generatorOn==0 (state 4 → Idle requires a manual ToggleGeneratorOnOff; a pilot who punched the gen OFF while it was out stays dark forever — authentic), sustained load never letting T below 1000, or a coolant-leaked loop that can no longer shed heat at all.
|
||||
- #55/RESPAWN_REARM history: `Generator::RTIS` was once transcribed with `outputVoltage = 0` (stale GNRATOR.TCP) vs the binary's `= ratedVoltage` (`docs/RESPAWN_REARM_PLAN.md:116`) — a documented past hard-brick vector, since fixed (powersub.cpp now matches @004b215c).
|
||||
- git log has zero #154 commits — untouched territory.
|
||||
|
||||
### 6. Wrong-edge mechanism candidates (flagged for the code lane, all [T4] from this lane)
|
||||
1. The authored AudioStateTrigger record for `Generator.GeneratorState` keys the warning cue at state 0 (Starting) or 2 (Ready) rather than 4 — or the port misreads the authored state value when binding.
|
||||
2. The port's GaugeAlarm54 watcher passes the OLD level on change (off-by-one): a cue authored for level 4 then fires on the FIRST transition AFTER being at 4 — i.e. exactly at recovery. Symptom-exact.
|
||||
3. A Start/Stop inversion on a looped alarm cue (authored: start-on-enter-4 / stop-on-exit-4).
|
||||
Discriminator available without code: ask Oracle whether the same warning also sounds at respawn/round start (the RTIS 0→2 pair).
|
||||
--- KEY FACTS ---
|
||||
* Manual p23 verbatim: 'If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way' — Oracle's era rule is printed 1995 doctrine (reference/manual/Tesla40_BT_manual.pdf p23) [T1 primary source]
|
||||
* Binary recovery threshold: GeneratorSimulation @004b1f7c case 4 exits ONLY at heatAlarm==0 (NormalHeat), i.e. T <= degradationTemp (reference/decomp/all/part_013.c:9087-9097) [T1]
|
||||
* Authored generator thresholds from raw BTL4.RES: startT=77, degradationTemp=1000, failureTemp=2000 for GeneratorA-D on all 6 variants — recovery at 1000 = exactly HALF the 2000 trip line, so the half-cooled rule is already the shipped arithmetic and the port transcribes it (powersub.cpp:1296-1372) [T1]
|
||||
* Trip emits ONE stateAlarm edge (x->4); recovery emits TWO (4->0 Starting, then 0->2 Ready after startTime); Generator::ResetToInitialState @004b215c replays the same 0->2 pair at every respawn/mission start (part_013.c:9119-9120) [T1]
|
||||
* The 'Warning, generator out' line is a spoken MechTech warning in 1995 (manual p14), veteran/expert only; the port's SF2 banks contain ZERO speech presets (241 enumerated, audiopresets.cpp) — whatever the port plays is a Warnings01 klaxon zone, and the original voice samples' home is unrecorded [T1/T2]
|
||||
* Audio path on file: Generator.GeneratorState -> stateAlarm bound as a real StateIndicator, 'SetLevel fires them on change' (context/wintesla-port.md:87-99; powersub.cpp:1051) [T2]; the authored state->note mapping for GeneratorState has never been decoded — the KB is silent on which edge the warning is authored to
|
||||
* Field logs (night15, all 6): zero receipts for generator stateAlarm transitions or audio warnings — [gen] TRIPPED/RESTARTING is BT_HEAT_LOG-gated and absent; #154's wrong-edge claim is currently unfalsifiable from logs [T2]
|
||||
* Probable thermal trip in rajel/GAMERSLAB: GenB cond3 SET :99453 -> ERSLaser_2+ERPPC_2 cond6 BadPower :103610 -> cond6 CLEARED :104109/:104273 BEFORE GenB cond3 CLEARED :104815 — weapons regained power while the gen was still hot; auto/manual reroute vs early-recovery defect not distinguishable from the log [T2]
|
||||
* Generator cond-3 SET/CLEARED counts are balanced in every night15 log (no latch) — corroborates Oracle's stuck-forever non-repro [T2]
|
||||
* Stuck-forever in-model candidate: state 4 with generatorOn==0 drops to Idle(1) and stays until a manual ToggleGeneratorOnOff — a pilot who toggled the gen off while it was out stays dark forever, authentically (part_013.c:9089-9091) [T1]
|
||||
* The missile-spam FailureHeat brick (open-questions.md:1086) is the WEAPON weaponAlarm-7 roach-motel (re-SetLevel(7) unconditionally, decomp-reference.md:240-241) — a real latch, mechanically distinct from the self-recovering generator breaker [T1]
|
||||
* #135 adjacency captured live: [galarm] condition 2 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1) in night15 logs — a generator coolant leak flashes no annunciator [T2]
|
||||
* stateAlarm@0x1FC vs @0x210 in the KB is NOT a contradiction: 0x1FC = GaugeAlarm54 base, 0x210 = its level field (+0x14) [T1]
|
||||
* Manual p23 answers open era question #133: the MFD X = generator destroyed by critical hit (not shutdown/overheat) [T1 primary source]
|
||||
* heatAlarm banding has no hysteresis (>2000->2, >1000->1, else 0; heat.cpp:851-862 from @004ad924), so recovery timing is purely the cooling curve crossing 1000 [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* Whether the cockpit heat bar's RED band is painted over the >=degradationTemp region (required for 'red gone' == the 1000 recovery line); gauge coloring was not audited this pass [T3]
|
||||
* Which authored state value the 'Generator Out' audio cue is keyed to — the AudioStateTrigger state->note records for GeneratorState are undecoded; all three wrong-edge mechanism candidates (cue keyed to state 0/2, old-level off-by-one in the watcher, Start/Stop inversion of a looped cue) are [T4] pending the code/data lane
|
||||
* Where the 1995 spoken warning phrases physically live (not in AUDIO1/2.RES SF2 banks) and what sample the port actually plays for the tester-heard 'Generator Out' warning — no audio receipt exists in any field log
|
||||
* Whether rajel's :103610 BadPower window cleared via auto-hunt/manual reroute (authentic) or an early same-generator recovery (defect) — indistinguishable without per-weapon voltage-source receipts
|
||||
* Which night15 log user is Oracle (handles: Dave/rajel/acaci/Michael/santo/eleng) — could not map the callsign, so the trip he described may not be in these six logs at all
|
||||
* Whether Oracle also hears the warning at respawn/round start (the RTIS 0->2 SetLevel pair) — a cheap testimony discriminator between the state-0/2-keyed and old-level hypotheses that has not been asked
|
||||
* Whether the binary's GaugeAlarm54 change-detection suppresses the per-tick SetLevel(4) re-fires during sustained FailureHeat exactly as the port's does (KB says fire-on-change [T1/T2]; not re-verified against disasm this pass)
|
||||
|
||||
|
||||
########## LANE 4 ##########
|
||||
## ANNUNCIATOR/AUDIO LANE — how stateAlarm 4 becomes (or fails to become) the audible "Generator Out" warning
|
||||
|
||||
### 1. The mechanism: state audio is EDGE-FIRED from SetLevel, not polled
|
||||
|
||||
Chain (all verified in code + binary):
|
||||
|
||||
1. `Generator::stateAlarm` is a `GaugeAlarm54` (the binary's 0x54 alarm, FUN_0041b9ec) at Generator+0x1FC; it IS a StateIndicator: stateCount@+0x0c, oldState@+0x10, currentState@+0x14, three watcher SChains @+0x18/0x2c/0x40 (C:\git\bt411\game\reconstructed\heat.hpp:93-139) [T1].
|
||||
2. `GaugeAlarm54::SetLevel(n)` (heat.hpp:106): `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState is updated UNCONDITIONALLY, watchers fire only on a change. Byte-identical to the binary's Set_Alarm_Level @0041bbd8 (`*(p+0x10)=*(p+0x14)` unconditionally; on change, walk the three sockets calling vtbl+0x14 Execute) and to engine `StateIndicator::SetState` (C:\git\bt411\engine\MUNGA\SIMULATE.cpp:156-230) [T1/T0].
|
||||
3. `AudioStateWatcher` (C:\git\bt411\engine\MUNGA\AUDWTHR.cpp:886-919) registers DIRECTLY on the alarm's +0x18 audio socket (`Cast_Object(StateIndicator*,attributePointer)->AddAudioWatcher(this)`), NOT on any Simulation's audioWatcherSocket. So subsystem-state audio is synchronous with the SetLevel edge and never passes through `Simulation::ExecuteWatchers`. The binding resolves via `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (C:\git\bt411\game\reconstructed\powersub.cpp:1051); field log confirms real per-generator pointers, no pad redirects (`[attrbind] class=28 subsys=[GeneratorA] attr=[GeneratorState] -> ptr=052DC844`, C:\git\bt411\content\solo_20260729.log:39445) [T2].
|
||||
4. On a level change, `Execute()` (AttributeWatcherOf<StateIndicator>, C:\git\bt411\engine\MUNGA\WATCHER.h:265-327) compares its StateIndicator COPY (stateCount/oldState/currentState) to the live alarm, then `AudioStateWatcher::SendNotificationOfChange` calls `StateChanged(GetOldState(), GetState())`.
|
||||
5. `AudioStateTrigger::StateChanged` (AUDWTHR.cpp:1046-1105): fires `ReceiveControl(controlID, controlValue)` when `(!inverseTrigger && new_state==triggerState) || (inverseTrigger && old_state==triggerState)` — i.e. NON-INVERSE = "on ENTERING the state"; INVERSE = "on LEAVING the state". The binary's own StateChanged @0043eb40 (part_005.c:6960-6978, member map from ctor @0043e7d4: trig@+0x80, inv@+0x84, ctlID@+0x88, ctlVal@+0x8c, excl@+0x90/+0x94) is logic-identical, and the manifest attributes audwthr.cpp/.hpp code in the image to the surviving engine source (C:\git\bt411\reference\BT410_SOURCE_MANIFEST.md, "Engine/other TUs ... source survives") [T1].
|
||||
|
||||
### 2. THE CORE FINDING: the wrong-edge behavior is AUTHORED IN THE SHIPPED 1995 CONTENT, not a port defect
|
||||
|
||||
The shipped BTL4.RES streams exactly TWO AudioStateTriggers per generator on `GeneratorState`, decoded byte-exactly from C:\git\bt411\content\BTL4.RES @0x2be9a-0x2bf2a (GeneratorB records; A/C/D identical) and confirmed by the runtime dump (solo_20260729.log:39445-39448 etc.) [T1]:
|
||||
|
||||
- `trigState=1 (GeneratorIdle), inverse=0, control=Start(1)/0` — fires on ENTERING Idle.
|
||||
- `trigState=4 (GeneratorOut), inverse=1, control=Start(1)/0` — fires on LEAVING GeneratorOut.
|
||||
|
||||
There is NO trigger on entering state 4. Both triggers target the SAME non-looped warning sequence (comp=054135C0; `[seqcfg] ... looped=0 div=120 tempo=160 events=15`, notes 29/16/26 = Warnings01 zones z4/z0/z3 per C:\git\bt411\game\reconstructed\audiopresets.cpp:542-549), shared by all four generators. ControlID 1 = StartAudioControlID (C:\git\bt411\engine\MUNGA\AUDIO.h:81-95) [T1].
|
||||
|
||||
Cross-checked against the binary's GeneratorSimulation @004b1f7c (reference/decomp/all/part_013.c:9049-9110, matches C:\git\bt411\game\reconstructed\powersub.cpp:1296-1372 line for line) [T1]:
|
||||
|
||||
- THERMAL TRIP: heatAlarm==FailureHeat → `SetLevel(4)` from Ready(2) → StateChanged(2,4). Matches NO authored trigger → **the trip edge is silent, in the binary and in the port**.
|
||||
- RECOVERY: state 4 + heatAlarm==NormalHeat → `SetLevel(0)` (gen on, restart) → StateChanged(4,0) fires the inverse leave-4 trigger → **the warning sequence STARTS on the recovery edge**. (If the gen was switched off: 4→1 fires BOTH triggers — leave-4 AND enter-1.)
|
||||
- The when-it-DOES-play-at-"trip" cases: generator switched OFF LINE by the pilot (`ToggleGeneratorOnOff` → SetLevel(1), powersub.cpp:1221) and generator subsystem going destroyed/dead (`simulationState==1` → SetLevel(1) each tick, first transition fires) — both are enter-Idle, both play the warning at the moment of loss. Only the THERMAL breaker path (2→4) bypasses Idle and is silent until recovery.
|
||||
|
||||
So the port faithfully reproduces the shipped binary+content. Either the era pods really played the voice at thermal-recovery too (and the tester's memory conflates the destruction/switch-off cases, which DO play at loss), or the arcade floor ran a content revision whose GeneratorState triggers differ from this repo's BTL4.RES [T4 — cannot distinguish from the repo].
|
||||
|
||||
Authoring-intent evidence AGAINST the shipped config being intended: a census of ALL 118+ authored state triggers (solo_20260729.log statecfg dump) shows inverse triggers are otherwise used almost exclusively as `inv=1 ctl=2 (Stop)` loop-terminators (WeaponState 3-, 0-, AmmoState 3-leave → Stop). The generators' `inv=1 ctl=1 (Start)` rows are the ONLY subsystem-fault inverse-STARTs in the game (the sole other inverse-Start is Entity.AnimationState leave-1, a legitimate play-on-exit). A one-bit authoring slip (inverse_trigger=1 on a trigger meant as enter-4) fits the pattern; the era rule would then be restored by flipping that one authored flag in our stream/decode path — but that is a deviation from shipped content, flagged, not proposed [T1 census / T4 intent].
|
||||
|
||||
### 3. DelayWatchersFlag / updateModel — RULED OUT for this bug
|
||||
|
||||
- `AudioStateTrigger` never registers with `Simulation::AddAudioWatcher`; only `AudioTriggerOf`/`AudioMatchOf`/`AudioDeltaOf`/`AudioScaleOf` do (AUDWTHR.h:293, 770, 925, 535). `AreWatchersDelayed()` gates ONLY `Simulation::PerformAndWatch → ExecuteWatchers` (SIMULATE.cpp:459-479, mech4.cpp:8377) — the polled watchers. It CANNOT suppress one SetLevel edge and pass another; both trip and recovery notifications ride the same synchronous socket walk. [T0]
|
||||
- What a stuck DelayWatchersFlag on the Generator sim WOULD kill: the two `GeneratorOn` AudioLogicalTriggers (match 1/match 0 → Start ctl1/0.5 — the polled on/off cue) and the ReportLeak matchers, silently and permanently (the projweap/mislanch `simulationFlags|=1` trap, C:\git\bt411\game\reconstructed\projweap.cpp:461,1049). Nothing in the generator path sets it — the known setters are the weapon-family bugs already fixed, plus the btplayer.cpp:622-642 diagnostic. `updateModel|=1` (ForceUpdate) is the replication dirty mark only; no bearing on watchers. [T1]
|
||||
|
||||
### 4. The heat bar / "red portion gone" — the recovery gate ALREADY equals red-gone
|
||||
|
||||
- The generator panel's temperature bar is a `VertTwoPartBar` bound (value=CurrentTemperature, low=DegradationTemperature, high=FailureTemperature) — GeneratorCluster ctor @004c746c, C:\git\bt411\game\reconstructed\btl4gau2.cpp:980-998; config `GeneratorCluster(... sgentemp.pcc, 9, 11 ...)` at C:\git\bt411\content\GAUGE\L4GAUGE.CFG:5046 [T1].
|
||||
- `VertTwoPartBar::Execute` (@004c48fc, C:\git\bt411\game\reconstructed\btl4gaug.cpp:1318-1360): bar spans 0..FailureTemperature; tile bitmap below the warn line (DegradationTemperature), the FILL COLOR segment is drawn ONLY for [warnPix, valPix) — i.e. the colored ("red") portion exists exactly while CurrentTemperature > DegradationTemperature. "Red portion gone" ⇔ T ≤ DegradationTemperature [T1].
|
||||
- The recovery gate is `heatAlarm.GetLevel()==NormalHeat` (powersub.cpp:1342), and heatAlarm is a pure no-hysteresis threshold ladder recomputed every HeatSinkSimulation tick: T>failure→2, T>degradation→1, else 0 (C:\git\bt411\game\reconstructed\heat.cpp:851-862, @004ad924) [T1].
|
||||
- Therefore the generator leaves GeneratorOut on the EXACT tick the red portion of its bar disappears — the tester's era rule is already the implemented (and binary-authentic) threshold. The visible lag: leaving 4 enters Starting(0), voltage stays 0 for the authored StartTime spin-up before Ready(2) — so power (voltage bar/weapons) returns startTime seconds AFTER red-gone; that delay is authentic. Whether DegradationTemperature is numerically "half" of FailureTemperature is per-mech authored data in the streamed subsystem resources (not stored as text; not extracted) [T3].
|
||||
- No-hysteresis corollary: a generator that keeps making heat can duty-cycle across the degradation threshold (trip→cool→restart→re-trip), playing the warning once per RECOVERY — a repeating warning that never coincides with a trip, which plausibly amplified the tester's wrong-edge perception. The "stuck offline forever" non-repro is consistent: exit requires only T ≤ degradationTemperature with the sim ticking; a true brick needs dissipation to be structurally gone (the open-questions "missile-spam FailureHeat brick" entry is about heat delivery, not this audio path).
|
||||
|
||||
### 5. Cross-check: alarms that DO play on their trip edge, and what differs
|
||||
|
||||
Nothing differs in code — one shared `AudioStateTrigger::StateChanged` serves all; the difference is purely the authored trigger rows:
|
||||
- Condenser: `trigState=1 inv=0` AND `trigState=2 inv=0` → Start (ctl 1/4) — fires on ENTERING each fault state (trip edge). [T1, statecfg census]
|
||||
- AmmoBin: enter-3 Start + leave-3 Stop (loop pair, cook-off countdown), enter-4 Start, enter-5 Start — trip-edge Starts, inverse used only as Stop. [T1]
|
||||
- Reservoir: enter-1 Start. Weapons: enter-N Starts, inverse-Stop loop terminators. [T1]
|
||||
- Generator alone has its fault state (4) authored ONLY with an inverse (leaving) Start, and its enter-Start sits on Idle(1) instead of Out(4). [T1]
|
||||
--- KEY FACTS ---
|
||||
* Subsystem state audio is edge-fired synchronously from GaugeAlarm54::SetLevel/StateIndicator::SetState walking the alarm's own +0x18 audio socket (heat.hpp:106, heat.cpp:96-111, SIMULATE.cpp:156-230, binary @0041bbd8) — it never passes through Simulation::ExecuteWatchers [T1]
|
||||
* AudioStateTrigger semantics: non-inverse fires on ENTERING triggerState (new_state==trig); inverse fires on LEAVING it (old_state==trig) — engine source AUDWTHR.cpp:1079-1104 [T0], byte-confirmed in the binary @0043eb40 with member map from ctor @0043e7d4 [T1]
|
||||
* Shipped BTL4.RES authors exactly two GeneratorState triggers per generator: (state 1 Idle, inv=0, Start) and (state 4 GeneratorOut, inv=1, Start) — decoded from BTL4.RES bytes @0x2be9a-0x2bf2a and matching the runtime [statecfg] dump (content/solo_20260729.log:39445-39448); there is NO trigger on entering state 4 [T1]
|
||||
* Thermal trip (heatAlarm==FailureHeat -> SetLevel(4), Ready 2->4, @004b1f7c == powersub.cpp:1304-1312) matches no authored trigger -> trip edge is SILENT in both the port and the 1995 binary; recovery (4->0 or 4->1) fires the inverse leave-4 Start -> the reported wrong-edge behavior is the shipped content+engine behavior, not a port defect [T1]
|
||||
* The warning plays at the moment of LOSS only for the enter-Idle paths: pilot switch-off (SetLevel(1), powersub.cpp:1221) and subsystem-dead simulationState==1 (powersub.cpp:1302) [T1]
|
||||
* Both generator triggers on all four generators target one shared non-looped sequence (notes 29/16/26 = Warnings01 zones z4/z0/z3, audiopresets.cpp:542-549); controlID 1 = StartAudioControlID (AUDIO.h:83) [T1]
|
||||
* DelayWatchersFlag (simulationFlags bit 0) gates only polled watchers (ExecuteWatchers, SIMULATE.cpp:472-474); it cannot suppress one SetLevel edge and pass another; on a generator it would instead silently kill the polled GeneratorOn on/off matchers [T0/T1]
|
||||
* Authoring census: inverse triggers elsewhere are almost exclusively inv=1+Stop loop terminators; the generators' inv=1+Start on a fault state is unique among subsystem alarms — consistent with a one-flag authoring slip in the 1995 content [T1 census, T4 intent]
|
||||
* Generator heat bar (VertTwoPartBar @004c48fc, btl4gau2.cpp:980-998): colored fill exists only while CurrentTemperature > DegradationTemperature, so 'red portion gone' == T <= DegradationTemperature == exactly the existing recovery gate (heatAlarm NormalHeat, heat.cpp:851-862, no hysteresis) — the tester's era rule for the return threshold is already implemented; only the authored StartTime spin-up delays visible power return [T1]
|
||||
* heatAlarm has no hysteresis, so a heat-producing generator can duty-cycle trip/restart, replaying the warning once per RECOVERY [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* Whether the arcade-floor pods ran a content revision whose GeneratorState audio triggers differ from this repo's BTL4.RES — the era-rule claim (voice at thermal trip) contradicts the shipped authoring we have; cannot be settled from the repo [T4]
|
||||
* Sample identity of Warnings01 zones z4/z0/z3 (notes 29/16/26) — assumed to be the audible 'Generator Out' voice/klaxon material; not verified by listening [T3]
|
||||
* Authored DegradationTemperature/FailureTemperature values per mech (whether 'red gone' is numerically HALF-cooled) — baked into streamed subsystem resources, not extracted [T3]
|
||||
* Replicant-side behavior (whether a passenger/other pod hears the warning on the same edges) — out of this lane, untraced
|
||||
* No field log with BT_HEAT_LOG active captures an actual trip/recovery temperature pair, so the duty-cycle amplification of the wrong-edge perception is mechanism-derived, not field-observed [T3]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — adversarial verification (two skeptic lanes)
|
||||
|
||||
#### VERIFIER 0 ####
|
||||
ADVERSARIAL VERIFICATION OF #154 "two triggers, no enter-4" — ATTACK FAILED ON THE DECODE; PARTIAL HIT ON THE BEHAVIORAL CLAIM.
|
||||
|
||||
== 1. Read order: source vs binary vs port — NO DIVERGENCE [T1] ==
|
||||
T0 source (engine/MUNGA/AUDWTHR.cpp:974-979): AudioStateTrigger ctor reads triggerState, inverseTrigger, controlID, controlValue, excludeTransition, excludeState, after the base chain reads subsystem CString, attribute CString, sendNotificationOnChange (WATCHER.cpp:60,91,145), dump_value (WATCHER.h:212), audio_component ObjectID (AUDWTHR.h:151). All ints/enums are 4 bytes (STYLE.H:132,137; AUDIO.h:98 AudioControlValue=Scalar float); CString = len(4)+bytes+NUL (CSTR.cpp:353-356).
|
||||
Binary: ctor @0043e7d4 reads six consecutive 4-byte fields into +0x80..+0x94 after base @0043e738. Field MEANINGS (not just positions) are pinned by StateChanged @0043eb40: +0x80 is the state comparand, +0x84 the inverse boolean (selects old-vs-new comparison), +0x88/+0x8c go to ReceiveControl, +0x90/+0x94 the exclude pair, with the exclude-clause structure exactly mirroring the source. Writer @0043e8a4 emits name-list keys in the same order (trigger_state, inverse_trigger, control_ID, control_value, exclude_state).
|
||||
The one attack surface the prior analysis never checked — the CALLER's argument order — also holds: SendNotificationOfChange @0043e79c passes (watcher+0x30, watcher+0x34); the snapshot filler @0041bb88 copies indicator +0x0c/+0x10/+0x14 = stateCount/oldState/currentState, so arg1=oldState, arg2=newState, matching the source. Had the caller passed (new,old), the inverse trigger WOULD have fired on enter-4 (audible trip) — this was the only single-fault reinterpretation that could rescue the testers' expectation, and it is excluded by the binary's own code.
|
||||
|
||||
== 2. Independent byte re-decode (my parser, from the exe-verified layout, not the port's) ==
|
||||
Record A @0x02be8f: classID=0x1c, objID=0x033b @0x2be93, "GeneratorB" (len@0x2be97), "GeneratorState" (len@0x2bea6), sendNotif=1 @0x2beb9, dump=0 @0x2bebd, comp objID=0x032f @0x2bec1; trigger fields @0x2bec5: trigState=1 @0x2bec5, inverse=0 @0x2bec9, controlID=1(Start) @0x2becd, controlValue=0.0 @0x2bed1, exclTrans=0 @0x2bed5, exclState=0 @0x2bed9.
|
||||
Record B @0x02bedd: objID=0x033c, same strings, comp=0x032f; fields @0x2bf13: trigState=4 @0x2bf13, inverse=1 @0x2bf17, controlID=1(Start) @0x2bf1b, controlValue=0.0 @0x2bf1f, exclTrans=0 @0x2bf23, exclState=0 @0x2bf27 (record ends 0x2bf2b — the prior's cited window 0x2be9a-0x2bf2a is these two records).
|
||||
Structural proof of the layout: my strict parser recovered 2306 valid class-0x1c records file-wide with contiguous record chaining, sane enums, and printable strings — impossible under a shifted/swapped layout. RES-wide census: ALL 144 GeneratorState triggers (4 gens x 36 vehicle pages) are exactly the two shapes (1,inv0,Start) and (4,inv1,Start). No enter-4 anywhere, no Stop, no exclude. The prior analysis's decode is CORRECT.
|
||||
|
||||
== 3. State numbering — 0-based, verified against the binary sim and LIVE receipts ==
|
||||
The binary's generator sim FUN_004b1f7c writes raw levels through SetState @0041bbd8 with no translation: heatAlarm==2 -> SetLevel(4) (thermal breaker); switch cases 0/1/3/4; recovery from 4 -> SetLevel(0) (gen on) or SetLevel(1) (gen off). Authored trigState compares verbatim against these [T1, binary-internal, port-independent].
|
||||
Calibration: AmmoState authored set {enter-0 Start (feed whir), leave-0 Stop, enter-1 Start, enter-2 Start, enter-3 Start + leave-3 Stop, enter-4 Start/1, enter-5 Start/3} maps 1:1 onto ammobin.hpp levels 0=Feeding..5=Dumped; LIVE receipts in content/genedge.log: edge 0->3 fired enter-3 Start, edge 3->2 fired leave-3 Stop AND enter-2 Start — inverse semantics and 0-based numbering confirmed live. Condenser: authored enter-1/enter-2 Start/4 matches sim writes 1(degraded)/2(severe)/0(normal) (heatfamily_reslice.cpp:1443-1445).
|
||||
CORRECTION to the prior analysis: its "AmmoBin cook-off enter-3/leave-3" label is WRONG — AmmoState 3 is EJECTING (eject-hold countdown loop); the cook-off warning is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134). The calibration still works; the label must be fixed.
|
||||
If authored trigState were 1-based: (1,inv0)->enter-Starting(0), (4,inv1)->leave-Shorted(3) — STILL no enter-4. Swapped trig/inverse fields on record B (payload 4,1) -> fire-on-leaving-state-1 — still no enter-4, and it would garble hundreds of other records (AnimationState trig=23 would become inverse=23). The ONLY reinterpretation putting a Start on ENTER GeneratorOut(4) is flipping the inverse semantics (inv=1 = fire on new==trig), excluded at T1 by @0043eb40 + @0043e79c/@0041bb88 and by the live AmmoState receipts (flipped semantics would start the eject loop on RELEASE).
|
||||
|
||||
== 4. VERDICT: the decode is SOLID [T1]; the behavioral conclusion is UNDER-EVIDENCED ==
|
||||
The two-trigger/no-enter-4 authoring is now confirmed by three mutually independent routes (T0 source, binary pseudocode semantics, my own byte decode) plus a live semantics calibration. It is not a parser echo chamber anymore.
|
||||
HOWEVER, two things keep the prior CONCLUSION short of certain, and give the testers room to be right:
|
||||
(a) The live trip observation NEVER HAPPENED: content/genedge.log (the #154 bench, run 2026-08-11 06:30) contains ZERO [gen] TRIPPED/RESTARTING lines and zero GeneratorState 2->4 or 4->x [audioedge] edges — the BT_GEN_HEAT injection did not take effect (bench's own verdict: INCONCLUSIVE). "Warning plays on recovery" in the port is still a static prediction. The only generator edges captured live are 0->2 power-ups, which correctly fire nothing.
|
||||
(b) "Silent by authoring" does not mean "testers hear a consistent nothing at trip": the recovery paths are asymmetric (4->0 fires ONE Start; 4->1 fires TWO Starts — leave-4 AND enter-1 — on the SAME component 0x32f), the GeneratorOn logical match watchers fire Start/0.5 on BOTH edges ([matchcfg] receipts), the 38-watcher ReportLeak warning family is known-dead (AUDIO_FIDELITY.md F6), and genedge.log shows a real delivery quirk: old=3 new=3 same-state deliveries fired BOTH the Start and the Stop of one loop (root: SetState/SetLevel writes oldState unconditionally even on no-change — binary @0041bbd8:5520 does the same, and the polling compare @0041bba8 includes oldState — so it may be authentic, but the port's polling cadence vs the binary's is unverified). Any of these is a credible source of "wrong AND inconsistent" without the RES decode being wrong.
|
||||
To call it certain: re-run scratchpad/night15/gen_edge_bench.sh with the heat injection actually reaching the sim (verify the BT_GEN_HEAT gate: generatorNumber==1 && viewpoint entity; confirm the EGG's vehicle carries GeneratorA) and capture trip-edge no-fire + recovery-edge FIRED; identify what sample/sequence audio component 0x32f actually plays (warning klaxon vs generator spin-up whine — decides whether the authored intent was ever a "warning" at all); ideally, one original-cab/emulator recording of a thermal trip as behavioral ground truth.
|
||||
KEY FACTS:
|
||||
* Read order identical across T0 source (AUDWTHR.cpp:974-979), binary ctor @0043e7d4 (+0x80..+0x94), and the port; field meanings pinned semantically by @0043eb40 (StateChanged) and the writer @0043e8a4
|
||||
* Caller argument order verified: @0043e79c passes (oldState,newState) — snapshot filled by @0041bb88 from indicator +0x10/+0x14; a swapped order was the only decode fault that could make the trip audible, and it is excluded
|
||||
* Independent re-decode of BTL4.RES @0x2be8f/@0x2bedd: (trigState=1,inv=0,Start) and (trigState=4,inv=1,Start) on GeneratorB.GeneratorState, comp 0x32f — matches the prior analysis exactly; all field offsets stated (fields @0x2bec5 and @0x2bf13)
|
||||
* RES-wide census (my parser, 2306 class-0x1c records): all 144 GeneratorState triggers are the same two shapes; no enter-4 trigger exists anywhere in the file
|
||||
* State numbering is 0-based and raw: binary sim FUN_004b1f7c writes SetLevel 0/1/2/3/4 with 4=thermal breaker; recovery 4->0 (gen on) or 4->1 (gen off); no translation layer anywhere
|
||||
* Live calibration receipts (content/genedge.log): AmmoState 0->3 fired enter-3 Start, 3->2 fired leave-3 Stop + enter-2 Start — inverse semantics and numbering confirmed at runtime
|
||||
* PRIOR-ANALYSIS ERROR FOUND: AmmoState level 3 is Ejecting (eject-hold countdown), NOT cook-off; the cook-off klaxon is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134)
|
||||
* The #154 live bench never observed a trip: genedge.log (2026-08-11) has 0 TRIPPED/RESTARTING lines and no 2->4/4->x edges — BT_GEN_HEAT injection did not take effect; 'recovery fires the warning' is still static-only for the port
|
||||
* Candidate explanations for 'inconsistent' tester reports that do NOT require a decode error: asymmetric recovery (4->1 double-Start vs 4->0 single), GeneratorOn match watchers firing Start on both edges, dead ReportLeak warning family (AUDIO_FIDELITY.md F6), and live same-state deliveries (old=3 new=3) firing Start+Stop pairs due to the unconditional oldState write in SetState (@0041bbd8, mirrored by port GaugeAlarm54::SetLevel)
|
||||
UNCERTAINTIES:
|
||||
* Whether the port's recovery edge actually fires the sequence live (T2 missing): the gen_edge_bench must be re-run with BT_GEN_HEAT actually reaching GeneratorSimulation (the 2026-08-11 run shows zero [gen] lines — injection gate or vehicle/generator naming issue)
|
||||
* What audio component 0x32f (runtime 054135C0) actually plays — a warning sequence or the generator spin-up/idle sound; this decides whether 'warning plays on recovery' is even the right characterization of the authored intent
|
||||
* Whether the same-state (old==new) Start+Stop double-delivery seen live is authentic 1995 behavior at the same frequency — the binary has the identical unconditional oldState write and snapshot compare, but the port's polling cadence vs the binary's notify path is unverified
|
||||
* Whether the 1995 cab sounded anything else at thermal trip via non-AudioStateTrigger paths (heat-alarm audio, gauge alarm streams, cockpit MFD alerts) — outside this decode's scope but inside the testers' perception
|
||||
* GeneratorState level-name labels (0=Starting,1=Idle) are best-effort [T3] per powersub.hpp — the numbering is certain, the English names are not
|
||||
|
||||
#### VERIFIER 1 ####
|
||||
VERDICT: The prior analysis's AUTHORING DECODE SURVIVES verification, but its CONCLUSION ("thermal trip silent, warning on recovery, therefore behavior is consistent-by-authoring") is REFUTED on three grounds: (1) it was never runtime-verified — the #154 bench (scratchpad/night15/gen_edge_bench.sh -> content/genedge.log) contains ZERO "[gen] TRIPPED"/"RESTARTING" receipts, so no trip or recovery edge was ever observed live; the recovery-edge claim is code-derived [T1], not [T2]; (2) the enter-Idle trigger makes the SAME 3-tone sequence fire on at least five different game events with opposite meanings, four of them not mentioned by the prior analysis; (3) three field-measured port mechanisms make even correctly-fired warnings intermittent.
|
||||
|
||||
== 1. THE COMPLETE EVENT->SOUND TABLE (generator warning sequence = AudioControlSequence, 3 notes 29/16/26 = Warnings01_z4(loop)/z0/z3, ~3.25 s at div=120 tempo=160; ONE sequence component SHARED by all four generators A-D — solo_20260729.log:39452-39476, all four statecfg rows target comp 054135C0) ==
|
||||
Authored triggers per generator (verified in both logs, excl=0/0 so NO exclude-transition clause): T-A trigState=1 inv=0 ctl=Start/0 (fires on ANY entry to state 1) and T-B trigState=4 inv=1 ctl=Start/0 (fires on ANY exit from state 4). GeneratorState enum [T1, powersub.hpp:377-388]: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out.
|
||||
- Mission start / ResetToInitialState (SetLevel(0);SetLevel(2), powersub.cpp:1269-1270): edges (x,0),(0,2) -> SILENT (bench-confirmed: 4x "old=0 new=2 ... no-fire" in genedge.log).
|
||||
- THERMAL TRIP 2->4 (powersub.cpp:1341, per-frame while heatAlarm==FailureHeat): SILENT. Prior analysis correct on this edge. But outputVoltage collapses -> !HasVoltage status bit (stateAlarm==4 read, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering gauge invite CAN sound at trip [T3 — masterAlarm edges are live in the bench log but the specific generator condition->voice line was not traced]. Testers hearing a VOICE at trip and the TONE 10-60 s later at recovery = "timing feels wrong".
|
||||
- THERMAL RECOVERY 4->0 (generatorOn==1, powersub.cpp:1384): T-B FIRES — warning tone announces power RETURNING. One tone per generator recovery; four generators trip/recover STAGGERED, and each Start on the shared sequence STOPS and RESTARTS it from t=0 (AUDSEQ.cpp:409-412 StartSequence stops-if-running; StopSequence chases pending Stops, cutting the current note) -> stuttered/truncated warnings whose count varies with heat spread. A player riding the heat edge gets a periodic tone on EVERY trip/recover duty cycle — always at the recovery, never at the loss.
|
||||
- RECOVERY WHILE SWITCHED OFF 4->1 (powersub.cpp:1376): BOTH T-A and T-B fire — double Start same frame (second restarts the sequence).
|
||||
- PILOT TOGGLES GENERATOR OFF (ToggleGeneratorOnOff, powersub.cpp:1221, byte-matched to @004b1ed0): x->1, T-A FIRES — the "generator out" warning plays for a deliberate heat-management action (authentic, but a tester counts it as spurious). Toggle ON: SetLevel(0) only if heat healthy — silent; if heat-degraded, NO state write at all (silent, state stays at fault).
|
||||
- GENERATOR CRIT-DEATH: no direct SetLevel on the crit path (BTGeneratorMarkOut has ZERO callers — the powersub.cpp:483 method is documentation-only; hpp:471 says the ForceCriticalFailure bridge was removed), BUT GeneratorSimulation's pin `if (simulationState==1) SetLevel(GeneratorIdle)` (powersub.cpp:1330-1332, authentic @004b1f7c) fires T-A on the next tick: 2->1 (or 4->1 = DOUBLE fire if it died tripped). So a generator LOSS by crit DOES play the warning — one frame late — directly contradicting "loss is silent by authoring".
|
||||
- SHORT 2->3 and short-recovery 3->2 (powersub.cpp:1422,1365): SILENT both ways.
|
||||
- RESPAWN WITH A TRIPPED GENERATOR: ResetToInitialState SetLevel(0) delivers (4,0) -> T-B FIRES the warning right at respawn; dying un-tripped respawns silent. Inconsistent respawn audio depending on how you died. [T1 code-read, unbenched]
|
||||
- GeneratorOn POLLED MATCHERS (AudioLogicalTrigger, solo log: match=1 -> ctl1(Start)/0.5 and match=0 -> ctl1(Start)/0.5, per generator): generatorOn is init 1 and written ONLY by the toggle (powersub.cpp:1214,1222) and reset (=1). Thermal trip does NOT touch it. So: bind-time prime fires the match=1 Start (spawn hum — every AudioMatchOf subclass ctor calls PrimeWatcher, and AudioMatchOf::SendNotificationOfChange has no edge memory: it fires whenever notified AND equal, AUDWTHR.h:818-848); manual OFF fires the match=0 Start (a second sound stacked on the T-A warning). Target components unidentified (matchcfg does not print comp) — [T4 what they sound like].
|
||||
|
||||
== 2. INTERMITTENCY MECHANISMS (the "inconsistent" the testers report) ==
|
||||
A. VOICE STEAL / TRUE DROP AT COMBAT PEAK [T2, field-measured]: L4AUDRND.cpp:1287-1409 — on channel exhaustion the incoming Start steals only from LOWER-weight running sources (transient victims are STOPPED mid-play, sustained SUSPENDED); if nothing outranks, the Start is genuinely dropped (gBTAudioTrueDrops). The 2026-08-10 field logs show up to 932 steals and 22-30 acquire-failure prints per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log). Heat trips correlate exactly with peak firefight + heat klaxon + MechTech voices, so the 3-note warning's Starts land in the most contended window: any of its three notes can be stolen mid-sequence or dropped. AUDIO_FIDELITY.md F3 adds that steal/cull/ducking are DISTANCE-BLIND in the port (AL linear model), further randomizing which source loses.
|
||||
B. TRANSIENT-START VOLUME CULL: AUDREND.cpp:236-251 silently discards any Start on a transient source whose volume scale < LowAudioVolumeThreshold (0.3), and AUDREND.cpp:175-193 discards Starts on clipped transient sources entirely. Whether the cockpit warning target (seq tgt comp) is transient was not determined [T4]; if it is, this is a second silent-drop gate.
|
||||
C. BIND-TIME PRIME FIRES TRIGGERS [T2, bench-observed]: AudioStateTrigger ctor calls PrimeWatcher() (AUDWTHR.cpp:989) -> GrabCurrentValue -> StateChanged(alarm.oldState, alarm.currentState) at bind. Combined with the GaugeAlarm54::SetLevel oldState-clobber (heat.hpp:106 — `levelB = level` runs even when the level does NOT change, so any state that is re-set per frame reads old==new), a trigger bound while its alarm sits AT trigState fires BOTH polarities immediately: genedge.log lines 878/881 show "[audioedge] old=3 new=3 trig=3 inv=0 ... FIRED" AND "inv=1 ... FIRED" during a mid-run mech build. For generators this is benign at normal spawn (state 2) but fires the warning on any bind/rebuild that catches state 1 or 4.
|
||||
D. THE T3 BRING-UP GUARD CAN SILENTLY UNPLUG A GENERATOR'S STATE AUDIO: AUDWTHR.cpp:893-917 skips AddAudioWatcher when the bound alarm's +0x18 socket reads 0/0xCDCDCDCD — permanently silent for that subsystem, logged only under BT_AUDIO_LOG. GeneratorState binds with vtbl=00000000 (raw GaugeAlarm54 header) in every log; registration currently succeeds (edges were delivered in the bench), but the guard makes generator audio construction-order-dependent — a latent per-mech/per-build inconsistency [T3].
|
||||
E. DelayWatchersFlag: gates ONLY the polled watchers (Simulation::Simulate, SIMULATE.cpp:472; mech4.cpp:8377) — i.e. the GeneratorOn/ReportLeak matchers — NOT the state triggers (those are called directly from the alarm's NotifyWatchers, heat.cpp:96-111). RequestEncore sets it (SIMULATE.cpp:727) and only the encore path clears it (UPDATE.cpp:213-215). The historical bug (death path setting bit 0, permanently muting a player's polled audio) is fixed and canary-guarded (btplayer.cpp:635-643). No current generator-warning exposure, but a regression would kill the GeneratorOn sounds while leaving the state warning alive — a plausible past contributor to "inconsistent" reports depending on build vintage.
|
||||
|
||||
== 3. CROSS-POD / REPLICANT ==
|
||||
Replicant mechs DO build audio objects (interest teardown destroys them — context/multiplayer.md interest-teardown crash section [T2]), and subsystems DO tick on replicants when executable (#148 instance branch, mech4.cpp:8077-8104). Whether a replicant's Generator ever reaches state 1/4 locally depends on whether heat state feeds its sim — per-subsystem heat is not replicated (damage is master-side only per the #45 note), so remote generator warnings should not track the remote pilot's actual thermal state [T3]. Remote sources are also subject to the clip/volume gates, so at typical ranges they are inaudible. An observer/passenger riding a REPLICANT viewpoint would NOT hear that mech's authentic generator warnings (its master simulates elsewhere) [T3].
|
||||
|
||||
== 4. WHAT SURVIVES OF THE PRIOR ANALYSIS ==
|
||||
The RES decode itself is confirmed at runtime: exactly two AudioStateTriggers per generator, trigState=1/inv=0/Start and trigState=4/inv=1/Start, excl=0/0, both targeting the shared 3-note sequence (solo_20260729.log:39445+ and genedge.log both match). "The trip edge 2->4 fires nothing" is true [T1]. Everything downstream of that — "therefore silent-by-authoring at loss, warning on recovery, consistent" — is refuted: crit-loss sounds (via the sim-state pin), manual OFF sounds, respawn-after-tripped sounds, recoveries stutter-restart a shared sequence, and the delivery/rendering pipeline drops or truncates the sound under exactly the conditions in which trips happen. RECOMMENDED NEXT STEP: re-run gen_edge_bench (it never tripped — zero [gen] receipts; verify the BT_GEN_HEAT injection actually reaches the viewpoint Generator A and the build contains the injection block), and add a crit-death + toggle + respawn leg to capture the T-A firings live.
|
||||
KEY FACTS:
|
||||
* The #154 bench log content/genedge.log contains ZERO '[gen] TRIPPED' or 'RESTARTING' receipts — the thermal trip/recovery edge mapping was NEVER observed live; the prior conclusion is code-derived [T1], not runtime-verified
|
||||
* All four generators (A-D) share ONE warning sequence component (statecfg comp=054135C0 in solo_20260729.log:39452-39476); AudioControlSequence::StartSequence stops-and-restarts if already running (AUDSEQ.cpp:409-412), so staggered per-generator recoveries stutter/truncate the warning
|
||||
* GeneratorState enum: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out (powersub.hpp:377-388); authored triggers: enter-1 Start and leave-4 Start, no exclude clauses (excl=0/0)
|
||||
* The enter-Idle trigger fires the SAME warning on: generator crit-death (simulationState==1 pin, powersub.cpp:1330-1332), pilot toggling a generator OFF (powersub.cpp:1221), and recovery-while-off 4->1 (double-fire with the inverse trigger)
|
||||
* ResetToInitialState does SetLevel(0);SetLevel(2) (powersub.cpp:1269-1270): a mech that died with a tripped generator (state 4) fires the warning AT RESPAWN via the 4->0 edge; dying un-tripped respawns silent
|
||||
* GaugeAlarm54::SetLevel (heat.hpp:106) clobbers oldState unconditionally (levelB=level even when unchanged) and notifies only on change; per-frame same-level SetLevel therefore makes old==new at the alarm
|
||||
* Every concrete audio trigger ctor calls PrimeWatcher() at bind, which delivers StateChanged(old,cur) unconditionally; bench lines show old==new deliveries that FIRED both trigger polarities (genedge.log '[audioedge] old=3 new=3 trig=3 ... FIRED' for inv=0 AND inv=1) — a trigger bound while its alarm sits at trigState fires spuriously
|
||||
* Voice contention is live in current builds: 2026-08-10 field logs show up to 932 voice steals and 22-30 acquire failures per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log); the steal loop stops transient victims mid-play and true-drops unrankable Starts (L4AUDRND.cpp:1287-1409)
|
||||
* Transient Starts are silently discarded when clipped or below LowAudioVolumeThreshold=0.3 (AUDREND.cpp:175-251), and the cull is distance-blind (AUDIO_FIDELITY.md F3)
|
||||
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:893-917) silently skips watcher registration on an unconstructed alarm socket — construction-order-dependent permanent silence for that subsystem's state audio [T3 latent]
|
||||
* DelayWatchersFlag gates only POLLED watchers (GeneratorOn/ReportLeak matchers), never the alarm-socket state triggers; the historical death-path bit-0 bug is fixed and canary-guarded (btplayer.cpp:635-643)
|
||||
* GeneratorOn is written ONLY by the pilot toggle and reset — a thermal trip does NOT clear it; its two matchers both issue Start (ctl1/0.5): match=1 at bind/toggle-ON, match=0 at toggle-OFF
|
||||
* BTGeneratorMarkOut has zero call sites — generator crit-death does NOT SetLevel(4); the warning at crit-death comes from the simulationState==1 Idle pin one tick later
|
||||
* Thermal trip zeroes outputVoltage -> !HasVoltage status bit (stateAlarm==4, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering invite can sound AT TRIP while the tone sequence sounds at RECOVERY — a perception split for testers
|
||||
UNCERTAINTIES:
|
||||
* Why the bench never tripped: BT_GEN_HEAT=50000 for 25 s should exceed FailureTemperature — either the injection block wasn't in the running build, the viewpoint-entity/generatorNumber gate didn't match, or the run window missed; needs a re-run before ANY edge claim is called verified
|
||||
* Whether the warning sequence's target source (tgt comp, e.g. 0540FA18) is TransientAudioRenderType — determines exposure to the volume-cull and clip drop gates (AUDREND.cpp:175-251)
|
||||
* The exact source of the REPEATED old==new deliveries to the same comps in genedge.log (lines 814/978/1030): interpreted as bind-time primes of successive identical weapons sharing sequence comps during a mid-run mech build; not traced to completion — an unidentified re-prime/re-execute path would widen mechanism C to mid-life spurious warnings (including old=4,new=4 firing the warning DURING a trip)
|
||||
* What the GeneratorOn matchers' Starts (ctl1/0.5) actually sound like — [matchcfg] does not print the target component; needs a matchfire capture or RES decode of those pages
|
||||
* Whether a MechTech VOICE line specifically fires on the generator !HasVoltage condition at trip time in the port (masterAlarm is live, but the condition->voice mapping for generator-out was not traced) [T3]
|
||||
* Whether replicant Generator subsystems ever reach state 1/4 in MP (per-subsystem heat is believed master-side only) and whether their warning sequences route to spatialized sources or a center-channel path — the passenger/observer-seat audio experience is unaudited
|
||||
* Whether destroyed generators keep ticking GeneratorSimulation long enough for the simulationState==1 pin to deliver its edge in every case (DoNothingOnce/NeverExecute latching order at subsystem death)
|
||||
* Whether any encore/re-stream path rebinds audio pages mid-round (which would prime-fire triggers against in-state alarms); NotifyOfReplacementEntityCreation is Fail('under construction') so late re-creation may be impossible today
|
||||
|
||||
@@ -339,7 +339,7 @@ cavern.egg`. This is every option the original console exposed for a BattleTech
|
||||
- experience (4 [T1 game decode]): novice, standard, veteran, expert ← was hardcoded `expert`
|
||||
- vehicle (18, friendly→tag): Black Hawk=blkhawk, Loki=loki, Nova=bhk1, Mad Cat=madcat, Thor=thor,
|
||||
Owens=owens, Kotori=own1, Summoner=thr1, Hellbringer=lok1, Timber Wolf=mad1, Avatar=avatar,
|
||||
Satyr=ava1, Sunder=sunder, Sunder V1=snd1, Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
|
||||
Satyr=ava1, Sunder=sunder, Denkou=snd1 (#69), Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
|
||||
Zanin Neko=mad2 (the 18 ModelList names; matches the 2026-07-18 vehicle sweep)
|
||||
- color/camo (7, display→tag): White=White, Black=Black, Brown=Brown, Red=**Crimson**, Green=Green,
|
||||
Gray=**Grey**, Tan=Tan ← was White/Black/Crimson only
|
||||
|
||||
@@ -74,7 +74,7 @@ Ranked by impact. **Reverse thrust `0x3F` (key LALT / pad B) is FIXED** and conf
|
||||
| 3 | **pad LT / RT (turn pedals)** — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (`L4PADRIO.cpp:710-715` → `L4CTRL.cpp:1438-1446` → `btl4mppr.cpp:1337-1340` → `:1399 pedalsPosition`) but only *Standard/Veteran* read them (`mechmppr.cpp:1034`, `:1061`); the mapper boots in **Basic** (`mechmppr.cpp:361`) — and **the pad has no binding for `0x18` CycleControlMode**. Verified: `content/bindings.txt:74-89` binds only `0x40 0x3F 0x47 0x42 0x44 0x43 0x41`; no `0x18`, no `0x15` | **S** | `BT_MPPR_TRACE=1`, squeeze LT on a pad-only launch: `pedals=` moves, `turn=0`. Add `pad BACK button 0x18`, press it → `[mode] control mode -> 1`, then LT yaws |
|
||||
| 4 | **Fire dropout: releasing one alias of a held fire button** (`1`+`SPACE` on `0x40`; `3`+`LCTRL`+`RCTRL` on `0x47`; pad A/X on the same) | Two keys on one address = two wires to one button | Edge state is per **binding**, not per **address**: `previousKeyHeld[k]` is indexed by binding index and `EmitButton(addr, held)` fires on every per-binding edge (`engine/MUNGA_L4/L4PADRIO.cpp:580-595`; same shape in the pad loop `:665-685`). The release writes `-(addr+1)` (`L4CTRL.cpp:2635-2647`) and `ControlsUpdateManager::Update` then suppresses the redundant press, so fire stays **off** until the held key is released and re-pressed | **M** (shared plumbing — **defer**) | `BT_FIRE_LOG=1`: hold LCTRL (missiles firing), tap `3`, release `3` → fire stops with LCTRL still down |
|
||||
| 5 | **`0x1A`-`0x1D`** Generator A-D ON/OFF (4 radar-rail buttons) | Take a generator off line (heat/power management) | `Generator : HeatSink` (`powersub.hpp:320-322`) and **no `Generator::GetMessageHandlers()` exists** — `powersub.cpp:1014` resolves to HeatSink's set `{id 3 ToggleCooling}`, so streamed `msg 4` (`ctrlmap.log:44-47`) finds nothing. `ToggleGeneratorOnOff` @`004b1ed0` is unreconstructed (0 hits in `game/`) | **M** | Click `0x1A`: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
|
||||
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | **M** (reconstruct @`0049fa00`) | Click `0x13` on a stationary mech: expect the squat clip + `duckState 0→1` |
|
||||
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | ~~M~~ ✅ **COMPLETE** (handler 07-26; consumer + clips + MP 2026-08-06 — [[locomotion]] §CROUCH) | Verified: squat clip plays, holds, rises; peers replicate it |
|
||||
| 7 | **Torso recenter from the keyboard** (`0x42` is pad/mouse only) | Center a twisted torso — the manual's anti-disorientation control (p9) | `0x42` itself is **LIVE** (streamed `subsys 17 attr 14 -> +0x208` = `Torso::centerCommand`, consumed `torso.cpp:636-640`) but `content/bindings.txt:83,86` bind it only on `pad Y`/`DPAD_UP`. The port's keyboard path (`gBTTorsoRecenter`, `mech4.cpp:2875`) is fed from the zeroed `gBTInput` and its consumer sits inside the stood-down bridge (`mechmppr.cpp:854-863`, gate `:655-660`) | **S** | Twist with `E`, press `X`: `currentTwist` does not return to 0. Add `key X button 0x42` (two rows on one VK both fire — `L4PADRIO.cpp:580-629` iterates all bindings) |
|
||||
| 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 |
|
||||
| 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` |
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
# The K/D-score chain audit -- full findings (2026-08-11, ticket #162)
|
||||
|
||||
> The five-agent authority-model audit that settled the kill/death/score chain
|
||||
> once and for all. CURATED digest: `context/combat-damage.md` section "The
|
||||
> K/D-score AUTHORITY MODEL". This file is the complete evidence record --
|
||||
> every dispatch, handler arm, wire layout, replication fact, and field-receipt
|
||||
> correlation, with decomp line cites. Read it before touching ANY limb of the
|
||||
> scoring chain. The mandatory regression gate is scratchpad/night15/kd_bench.sh
|
||||
> (five hard invariants).
|
||||
>
|
||||
> Fixes shipped from this audit (4.11.885, commit 3545056): death tail
|
||||
> master-gated; #81 latch-clear moved to the binary's DropZoneReply site; steam
|
||||
> eggs stamp advancedDamage mission-wide; #38 badge/patch ride the lobby.
|
||||
|
||||
# Task 5 — #150 fix archaeology + bench coverage hole
|
||||
|
||||
## 1. Exactly what 46ff8dd changed (the #150 fix, 2026-08-10 10:30, "4.11.87x" era)
|
||||
|
||||
Commit `46ff8dd262b20a08334d44338e69fc686a6c7958` touched only `game/reconstructed/mech.cpp` (+28/-1) and added `scratchpad/night14/scorepair.sh` (new, 30 lines). All changes are inside `Mech::TakeDamageMessageHandler`.
|
||||
|
||||
**Before (pre-fix state of the report tail):**
|
||||
- The call to `BTMechPostCombatReports(this, message, damageTally, zoneDestroyed, reportZone, newly_destroyed)` ran **unconditionally** — on BOTH master and replicant instances of the victim mech (the port deliberately applies damage on replicants for local visual response, and the report tail rode along).
|
||||
- The `newly_destroyed` argument was computed as `deathBlastArmed && IsMechDestroyed()` — a movement-mode-derived flag, not an entry/exit edge.
|
||||
|
||||
**After (the two binary-cited gates):**
|
||||
1. **Master-only gate** (mech.cpp:1270 today): `if (GetInstance() != ReplicantInstance) { BTMechPostCombatReports(...); }`. Grounded in the binary's opening assertion `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)` [part_012.c:14613] — in 1995 the tail could only ever run on the victim's master. Local damage application on replicants stays.
|
||||
2. **destroyedAtEntry latch** (mech.cpp:1039 today): `const int destroyedAtEntry = IsMechDestroyed();` sampled **at handler entry**, and the last argument became `!destroyedAtEntry && IsMechDestroyed()` — the binary's `local_14` 0->1 edge across this handler call (part_012.c:14625-14629). A later salvo of the killing volley (arriving after the mech is already destroyed) takes the inflicted-only path.
|
||||
|
||||
**What the latch gates vs what it does NOT gate (verified against current mech.cpp):**
|
||||
- It gates **only the `newly_destroyed` argument**, i.e. block A (the type-2 KILL report to the shooter's player) inside `BTMechPostCombatReports` (btplayer.cpp, `if (newly_destroyed)` branch).
|
||||
- It does **NOT** gate block B (type-0 inflicted report) or block C (type-1 received report) — post-death salvos on the master still emit those.
|
||||
- It does **NOT** gate the **death_tail** at mech.cpp:1335-1343: `if (deathBlastArmed && IsMechDestroyed()) { BTMechPostVehicleDead(...); BTApplyDeathSplash(...); }` — this still uses the OLD pre-latch predicate, and **the master-only gate does not cover it either** (it sits outside the `GetInstance() != ReplicantInstance` block). `BTMechPostVehicleDead` is the dispatcher of the VehicleDead message whose `deathCount == -1` branch in `BTPlayer` does `++deathCount` / `++deathTally`, logs `PLAYER_DEAD`, and applies the -500 death cost (btplayer.cpp:582-736). **[Inference, flagged]** This ungated, un-latched VehicleDead dispatch is the natural suspect for #162's deathCount multi-increment — 46ff8dd hardened the score reports and left the death notification on the old predicate.
|
||||
|
||||
## 2. Other recent score/death/penalty changes in btplayer.cpp (last 3 weeks)
|
||||
|
||||
`git log` on `game/reconstructed/btplayer.cpp` (all within 2026-07-25 → 2026-08-08):
|
||||
|
||||
- **a52207d / 48d47ef (07-25, #45):** reclaimed the binary's DEATHS field (`deathTally` @+0x280) as the displayed/replicated counter, distinct from the engine's respawn-handshake `deathCount` (@+0x200, seeded -2); PLAYER_DEAD matchlog row logs BOTH (`deaths=` and `tally=`).
|
||||
- **a357dc4 (07-30, #81 ghost):** released the stuck `deathPending` death latch + stopped a duplicate VehicleDead. Note: `deathPending` (btplayer.cpp:546-559) is the ONLY dedup on the death cycle — a death arriving while a cycle is pending is SWALLOWED with a warning.
|
||||
- **1efe8ef (08-02, #118):** restored `SpecialCaseDeathPenalty` (role+0x20) and its consumer: every death with `advancedDamageOn` hands the ENGINE base `Player::ScoreMessageHandler` a type-1 award of `-penalty` via a **DIRECT base call** — deliberately bypassing BT dispatch, therefore **structurally invisible to the matchlog** (btplayer.cpp:726-736 today).
|
||||
- **91bd286 (08-05, #45/#134):** the big one — reconstructed the authentic report tail `BTMechPostCombatReports` (blocks A/B/C @0x4a02f4-0x4a0890) + `BTMechPostVehicleDead` (@0x4a07d4); retired the bring-up producers `BTPostDamageScore`/`BTPostKillScore`. This is what moved report emission into the victim's TakeDamage handler — the code 46ff8dd later had to gate.
|
||||
- **2772175 (08-07):** restored the type-0 Dispatch-override interceptor (`BTPlayer::ScoreInflictedMessageHandler` @004c0200) — per-hit inflicted credit was real 1995 behavior.
|
||||
- **e82f54c (08-07):** finding-only + a console-flush watermark; two recorded wrong turns.
|
||||
- **1324c81 (08-07):** inflicted credit lands on the OWNER's machine — Dispatch intercepts type 0 on a MASTER only; the type-0 arm of ScoreMessageHandler DELEGATES (wire deliveries go straight to the handler table, never the virtual Dispatch).
|
||||
- **b2498ca (08-07):** the delegating type-0 arm must `return`, not `break` — the fall-through clobbered `scoreAward` to 0 and ran the base handler again (a duplicate-row generator, then harmless-by-luck).
|
||||
- **a4bfb64 (08-07):** BOUND `scenarioRole` (was NULL forever — one commented-out lookup). This is what made deathPenalty=500 / killBonus=500 real; it also established that **-500 on an ordinary combat death is authentic** (gate = `advancedDamageOn` alone, @004c05c4 tail).
|
||||
- **e0b91df (08-07):** correction — the death cost was never missing; it is invisible to the matchlog because of the direct base call. Added the ungated `[deathcost] ... -> APPLYING/SKIPPED` DEBUG_STREAM receipt (btplayer.cpp:716-724). Explicit lesson recorded: "a value that cannot appear in the log you are reading is not evidence of absence."
|
||||
- **29b4d68 (08-07):** the +1000 MissionStarting seed grant. **5b7e481 (08-08):** mech+0x354 vestigial label, no behavior change.
|
||||
|
||||
## 3. The bench (scorepair.sh) — what it verified and what it could not see
|
||||
|
||||
`scratchpad/night14/scorepair.sh` (added by 46ff8dd): 2 nodes — B unarmed respawning target (player 3:1), A autofire missile killer that panic-ejects on a timer (player 2:1), relay via btconsole.py, 300 s. Its RESULT section is three checks:
|
||||
1. `grep -ac "Mech::Reset" sc_b.log` — a count of B's resets (proxy for "kills A should have earned").
|
||||
2. `grep -ah "SCORE\|KILL\|EJECT\|DEATH" matchlog_*.txt | head -25` — the score sequence.
|
||||
3. `grep -a "scorepost" sc_a.log / sc_b.log` — **"scorepost" matches NOTHING in the codebase** (grep over game/ + engine/ returns zero hits); these two checks were dead on arrival and always print empty.
|
||||
|
||||
**What the captured receipts actually show:**
|
||||
- `scratchpad/night14/sc.out` (02:56, pre-fix run) and `sc2.out` (10:28, the run cited in the commit): in BOTH, the `head -25` was exhausted by per-hit noise rows (type=0 award≈1.97 spam in sc.out; the victim node's `type=1 award=-0.00` spam in sc2.out) **before a single KILL / type=2 / DEATH row appeared**. The .out receipts therefore contain zero evidence about kills, deaths, or penalties. The commit's verified numbers ("kills 0->1->2, zero doubles, -900/-1050") match the raw matchlogs, so the verification was done by out-of-band manual matchlog reading, not by the bench's printed result.
|
||||
- **PLAYER_DEAD is matched by none of the bench's grep patterns** — "DEATH" does not substring-match "PLAYER_DEAD". The death counter was never surfaced, let alone compared against real deaths.
|
||||
- **`award=-500` can never appear in any matchlog** — the death cost is a direct `Player::ScoreMessageHandler` base call bypassing BTMatchLog (btplayer.cpp:712-715 says so explicitly; e0b91df is the commit that learned this). The only receipts are the `[deathcost]` DEBUG_STREAM lines in the node logs — which the bench never greps. (They DO exist in the post-fix run: `content/sc_a.log` has 4× `[deathcost] player 2:1 ... penalty=500 ... -> APPLYING`, `content/sc_b.log` has 2× for player 3:1 — so **at fix time the -500 was applying on both nodes**, provable only by artifacts the bench never printed.)
|
||||
|
||||
**The smoking gun in the pre-fix artifacts:** `scratchpad/night14/mlbak/matchlog_20260810_0250*.txt` (the run that reproduced #150) already contains the #162 death-side signature, in the same file as the double kill:
|
||||
```
|
||||
PLAYER_DEAD t=1827953 w=02:53:58.090 st=5 player=3:1 deaths=1 tally=1
|
||||
PLAYER_DEAD t=1827953 w=02:53:58.091 st=5 player=3:1 deaths=2 tally=2
|
||||
```
|
||||
— two deathCount increments 1 ms apart for ONE death, immediately adjacent to the double kill rows (`type=2 award=501.96 kills=1` / `award=505.88 kills=2`, also 1 ms apart) that #150 was chasing. Post-fix (10:24 run, `content/matchlog_20260810_102334_1912.txt`) PLAYER_DEAD rows read singly (deaths=1 then deaths=2, minutes apart), so in THIS bench topology the fix incidentally quieted the double too — but nothing asserted it, and no one looked at the death side of either artifact.
|
||||
|
||||
## 4. The precise coverage hole
|
||||
|
||||
1. **The bench asserted only the kill side, and only manually.** Its scripted receipts were (a) a respawn count on the victim, (b) a `head -25` matchlog slice that noise-truncated before any kill/death row in both captured runs, and (c) two greps for a log tag (`scorepost`) that does not exist. Nothing scripted compared type-2 count vs death count, PLAYER_DEAD count vs actual deaths, or score totals vs expected arithmetic.
|
||||
2. **PLAYER_DEAD was un-greppable by the pattern used** ("DEATH" ≠ "PLAYER_DEAD"), so death-count duplication — already present and visible in the pre-fix artifact — was structurally invisible to the bench.
|
||||
3. **The -500 penalty is invisible to the matchlog by design** (direct base-handler call), and the bench never grepped the `[deathcost]` DEBUG_STREAM receipts or computed total-arithmetic deltas — the only two ways to see it. A regression that silenced the penalty would print an identical bench RESULT.
|
||||
4. **The fix itself is asymmetric** and the bench could not detect the asymmetry: 46ff8dd's master-only gate + entry latch cover only `BTMechPostCombatReports` (mech.cpp:1270-1276); the death_tail's `BTMechPostVehicleDead` (mech.cpp:1335-1343) — the sole driver of `++deathCount`/`++deathTally`/PLAYER_DEAD and the death-cost block — kept the old `deathBlastArmed && IsMechDestroyed()` predicate, ungated by instance and un-latched. **[Inference]** That is the seam where #162's death-side breakage shipped: the kill side got both gates and a (manual) receipt; the death side got neither a gate nor a single line of bench assertion.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 1 — Binary truth: the report tail of `Mech::TakeDamageMessageHandler` @004a0230
|
||||
|
||||
Function: `FUN_004a0230` (`reference/decomp/all/part_012.c:14474-14837`, export tag `file=bt/mech.cpp`), bound as **Mech entity message id 0x12 TakeDamage** (`context/decomp-reference.md:265`). All claims below are [T1] read from this export unless flagged.
|
||||
|
||||
## 0. Entry protocol (context for the tail)
|
||||
|
||||
- **Replicant assert** — part_012.c:14613-14616: `(mech+0x28 & 0xc) == 4` → `Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)`. The handler body runs ONLY on the victim's master node.
|
||||
- **Gyro bounce** — :14617-14624 (`mech+0x528` → `FUN_004b2980(gyro, msg+0x2c damageType, msg+0x30 amount)`); no messages.
|
||||
- **THE LATCH** — :14625-14629: `local_14 = (FUN_0049fb54(mech) != 0)` = destroyed-at-ENTRY. `FUN_0049fb54` @0049fb54 (part_012.c:14174-14188) returns 1 iff `mech+0x40 (mode) == 2 || == 9`. **Note it does NOT count mode 10** (see §3 gate asymmetry).
|
||||
- **lastInflictingID stamp** — :14631: `mech+0x43c ← msg+0x1c` (inflictor EntityID).
|
||||
- **Party resolve** — :14630-14635: registry-find shooter entity by `msg+0x1c`; `local_1c = *(shooter+0x190)` = **SHOOTER's player**; `local_20 = *(mech+0x190)` = **VICTIM's own player**. Both resolved BEFORE the collision divert.
|
||||
- **COLLISION DIVERT** — :14636-14639: `msg+0x2c (damageType) == 0` → `FUN_0049ffcc(mech, &msg+0x2c)` then **`goto LAB_004a07b5`** — jumps straight to the death tail (binary `jmp @0x4a0375 → 0x4a07b5`, `context/combat-damage.md:598-601`). Ram/wall damage therefore emits **NO id-0x16 report of any type** but STILL posts VehicleDead/blast if it killed. `FUN_0049ffcc` @0049ffcc (part_012.c:14374-14430) applies scaled internal-subsystem damage (gated `victimPlayer+0x268 != 0`, random subsystem via `FUN_004ac07c`) and **sends no messages**.
|
||||
- **Damage loop** — :14655-14684: `local_28` (tally) += per-burst applied `msg+0x30` **plus** each critically-destroyed subsystem's value `*(subsys+0x108)` (:14669); `local_30` (vitalHit) = 1 when the struck zone's destroyed flag `zone[9]` goes 1 (:14674-14676); zone re-rolled per burst (:14680); loop breaks when bursts (`msg+0x58`) exhaust OR the mech becomes destroyed mid-volley (`FUN_0049fb54`, :14683). The do-while shape means **the first burst always applies, even on a corpse**, so tally can be nonzero when `local_14 != 0`.
|
||||
|
||||
All dispatches below are `(**(code**)(*player + 0xc))(…)` — vtbl slot 3 = `Entity::Dispatch`, which reroutes over the wire when the target player is a replicant on this node (engine/MUNGA/ENTITY.cpp:244-251 [T0], cited in `docs/KD_SCOREBOARD_PLAN.md:86`).
|
||||
|
||||
## 1. Wire layout of the id-0x16 report (ScoreMessage, sizeof 0x3C) [T1, static_assert-locked per decomp-reference.md:290-293]
|
||||
|
||||
`{+0x00 size 0x3C, +0x04 id 0x16, +0x08 =1, +0x0c destID null (DAT_00522524), +0x14 =0, +0x18 =0, +0x1c scoreAward, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 inflictingSubsystemID (=msg+0x5c, engine T0 name), +0x34 senderMechID}`
|
||||
|
||||
## 2. The report block — three mutually structured id-0x16 dispatches
|
||||
|
||||
### local_14 == 0 (ALIVE at entry) — part_012.c:14685-14709
|
||||
Re-tests `FUN_0049fb54` (:14686). If **destroyed NOW** (the alive→dead edge of THIS volley):
|
||||
|
||||
**Report A — type 2 KILL → SHOOTER's player** (:14688-14708; binary @0x4a04da, `combat-damage.md:605-608`; the KD plan calls this edge @0x4a052b-0x4a05ce, `KD_SCOREBOARD_PLAN.md:164`):
|
||||
- `+0x1c scoreAward` = applied tally `local_28` (:14693,14700)
|
||||
- `+0x20 type` = **2** (:14702)
|
||||
- `+0x24 basis` = `*(*(victimPlayer+0x208) + 0x1c)` (:14691) = the **VICTIM role's killBonus** (role+0x1c; = 500 in shipped `Role::Default`) — this is the `@0x4a0506` killBonus read (`decomp-reference.md:477-478`)
|
||||
- `+0x28 vitalHit` = `local_30`; `+0x2c zone` = `msg+0x24` (loop-ENTRY zone); `+0x30` = `msg+0x5c`; `+0x34 senderMechID` = **the VICTIM's own mech ID** (`mech+0x184`, :14692,14704)
|
||||
- Dispatch :14708 → `local_1c` = shooter's player. On the victim's node that object is a **replicant**, so Dispatch reroutes it over the wire to the killer's master — where `BTPlayer` 0x16 `Score@0x4c02e4` case 2 runs CalcKillScore × (senderTonnage/ownTonnage), `killCount@0x27c++` (dual — the 1995 phantom partner increment), self-kill `fchs` negation (`decomp-reference.md:280-284`).
|
||||
- Note: A and B are **exclusive** — a killing hit emits NO type-0.
|
||||
|
||||
If NOT destroyed now → `goto LAB_004a05d9` (falls into the same block the already-dead path uses):
|
||||
|
||||
**Report B — type 0 DamageInflicted → SHOOTER's player** (`LAB_004a05d9`, :14711-14734; @0x4a05d9):
|
||||
- Gate: `local_28 != 0.0f` (the constant `_DAT_004a0c14` is a 0.0f literal parked after function end)
|
||||
- `+0x1c scoreAward` = tally; `+0x20 type` = **0** (:14727); `+0x24 basis` = tally again (:14716,14728); `+0x28/2c/30` as above; `+0x34 senderMechID` = victim's mech ID (:14717,14729)
|
||||
- Dispatch :14733 → shooter's player. Receiver side: `BTPlayer::Dispatch` override (`@004bffa0`, vtbl @00513300 slot 3) splits type 0 off to `@004c0200 ScoreInflictedMessageHandler` before base dispatch (`combat-damage.md:609-623`).
|
||||
|
||||
### local_14 != 0 (ALREADY DESTROYED at entry)
|
||||
Enters directly at `LAB_004a05d9` → emits **Report B (type 0) if tally ≠ 0**. The believed "inflicted-only" is **half right**: the latch suppresses only the type-2 kill and the death tail — **Report C below still fires too**, because it sits OUTSIDE both branches.
|
||||
|
||||
### Report C — type 1 DamageReceived → VICTIM's OWN player (:14736-14759; @0x4a06c0) — **unconditional on the latch**
|
||||
- Gate: `0.0f < local_28` only (:14736). Fires on non-lethal hits, on the killing volley, AND on corpse-hits whose first burst applied.
|
||||
- `+0x1c scoreAward` = applied tally; `+0x20 type` = **1** (:14752); `+0x24 basis` = **INTENDED** damage = `msg+0x58 burstCount × msg+0x30 amount` (:14737,14753); `+0x28 vitalHit`; `+0x2c zone`; `+0x30 subsysID`; `+0x34 senderMechID` = **the SHOOTER's mech ID** (`msg+0x1c`, :14742,14754)
|
||||
- Dispatch :14758 → `local_20` = victim's own player (master, local delivery). Receiver: `Score@0x4c02e4` case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the console VTVDamaged post (`decomp-reference.md:276-279`).
|
||||
|
||||
## 3. The death tail — `LAB_004a07b5` (:14760-14835; @0x4a07b5; also reached by the collision divert)
|
||||
|
||||
**Gate (:14761): `local_14 == 0 && (mech+0x40 == 9 || mech+0x40 == 10)`** — was-alive-at-entry AND now in death mode 9|10 (`combat-damage.md:691` calls +0x40 movementMode).
|
||||
|
||||
⚠ **Latch asymmetry [T1 fact, T4 as bug mechanism]:** the latch (`FUN_0049fb54`) counts modes **2|9** but the tail gate accepts **9|10**. If a mech can sit at mode 10 at the ENTRY of a subsequent TakeDamage (e.g. the 1ms-later duplicate hits of the killing volley), `local_14` stays 0 and the ENTIRE death tail — including VehicleDead — **re-fires**. Whether mode 10 persists across invocations in the binary is not established here; in the port it depends on when the reconstruction advances the mode. This is the one structural hole in the binary's own dedup and maps exactly onto the #162 deathCount multi-increment shape.
|
||||
|
||||
Dispatches inside the tail, in order:
|
||||
|
||||
**(1) VehicleDead — id 0x17, BT-extended sizeof 0x38 → the VICTIM's OWN player** (:14762-14775):
|
||||
- `{+0x00 0x38, +0x04 0x17, +0x08 1, +0x0c destID null, +0x14 0, +0x18 0, +0x1c deathCount = -1 (0xffffffff, :14770), +0x20 dropZone EntityID = null, +0x28 = 0, +0x2c killed-by PLAYER EntityID = shooter-player's ID (local_1c+0x184 via `local_1c + 0x61` int* math, :14762,14773), +0x34 kill zone (msg+0x24, :14763,14774)}` — matches the locked layout at `decomp-reference.md:293-294`.
|
||||
- Dispatch :14775 → `local_20` (victim's own player, master → local). Receiver: **BTPlayer 0x17 `VehicleDead@0x4c05c4`** — increments the DEATHS cell `+0x280` on BOTH victim and killer players (`inc [edi+0x280]/[ebx+0x280]` @0x4c0670-0x4c067a) and, in its tail `@004c07cd-0x4c0828`, gated on `advancedDamageOn` (player+0x264), **direct-calls the ENGINE base `Player::ScoreMessageHandler` @0042da20 with a type-1 ScoreMessage whose `scoreAward = −ScenarioRole::specialCaseDeathPenalty` (role+0x20, 500 in `Role::Default`)** — the −500 death cost (`decomp-reference.md:458-478`, `KD_SCOREBOARD_PLAN.md:88`).
|
||||
- **This is the ONLY VehicleDead-class dispatch anywhere in FUN_004a0230**, and it is emitted at most once per handler invocation, solely under the was-alive latch + mode 9|10 gate. deathCount increment and −500 penalty share this single carrier: in the binary they cannot diverge (duplicate increments + missing penalty can only co-occur if the receiver path was split, which is a port-side property — inference, [T4] w.r.t. build 883).
|
||||
|
||||
**(2) Death explosion — engine message id 3, sizeof 0x5C — NOT dispatched, consumed locally** (:14776-14798):
|
||||
- `{+0x04 id 3, +0x1c model/class 0x31, +0x28 = mech+0x4c0, +0x2c = 0x100, +0x30..0x48 = mech position/orientation (mech+0x100..0x118), +0x4c and +0x54 = victim mech EntityID}` (:14779-14797)
|
||||
- Passed to `FUN_004302ec(&msg)` @004302ec (part_004.c:2362-2385): registry-finds the entity at msg+0x4c, `new`s a 0x1dc-byte object (ctor `FUN_00430180`, class data @DAT_004e6cc4) = the blast/Explosion entity, kept as `local_a0`. Nothing goes on the wire from here.
|
||||
|
||||
**(3) Delayed self-message — id 0x17 to the MECH, sizeof 0x1C — the respawn trigger, SCHEDULED not dispatched** (:14799-14811):
|
||||
- `{0x1c, 0x17, 1, destID null, 0, 0}` (:14799-14804). Receiver id-space is the MECH's: mech-table **0x17 = SetBurningState@0049f674**, whose body actually randomizes position + graphicAlarm 2 + sim re-arm — the respawn (`decomp-reference.md:266`, label/body mismatch).
|
||||
- Scheduled via `FUN_0041acbc(world+0x60, 3, mech, msg, when)` @0041acbc (part_002.c:4641-4658): allocates a scheduler node from pool 0x4e2fa4 (`FUN_0041a818(mech,msg,when)`) and inserts it in the world's delayed-delivery list. `when = *FUN_00414b60() (global clock, part_001.c:7125) + __ftol(mech+0x430)` — `FUN_004dcd94` is an ST0-arg `__ftol` the decompiler renders argless (`decomp-reference.md:278-279`); the float loaded is `mech+0x430` (:14805-14809), the respawn delay. **This is NOT a VehicleDead** despite sharing id 0x17 — per-receiver-class id spaces.
|
||||
- (Do not confuse with mech id **0x16** = BalanceCoolant — the player/mech 0x16 ids are unrelated.)
|
||||
|
||||
**(4) Roster sweep** (:14812-14817): iterator over the `mech+0x418` list (`FUN_004a4eab`/`FUN_004a4ee9`), calls vtbl+0x48 per item — no messages.
|
||||
|
||||
**(5) Splash damage — the one tail helper that DOES send more messages** (:14818-14832):
|
||||
- Gate: `victimPlayer+0x264 != 0 && victimPlayer+0x258 == 0` (:14819; +0x264 = advancedDamageOn per decomp-reference.md:460; the +0x258 slot is labeled suppressConsole in the VTVDamaged context — reuse here unverified [T4]).
|
||||
- Builds damage spec `local_274 {type=2, amount=mech+0x520, victim position, __ftol(...)}` (:14820-14828) and calls `FUN_0042fad0(explosionEntity local_a0, victimMechID, &spec, radius = mech+0x524)` (:14831).
|
||||
- `FUN_0042fad0` @0042fad0 (part_004.c:2059-2307) = the #89 SplashDamage: sweeps a ±radius box, and for each hit entity **dispatches a fresh id-0x12 TakeDamageMessage (size 100=0x64)** via `(**(code**)(*piVar3+0xc))(piVar3,&local_214)` (part_004.c:2276-2299), burst count = `max(1, __ftol(distance falloff))` (:2258-2261), inflictor ID stamped into the target's `+0x184`-relative slot from the passed mech ID (:2272-2275). Exclusions (:2203, :2226): the explosion entity itself, `*(explosion+0x1c8)` (its source — the dying mech, inferred from the ctor message carrying the mech ID [T4 on the identity]), and one under-recovered extra stack arg (`in_stack_00000024` — the decompiled 5-arg signature is short; [T4]). So the corpse does not re-splash itself, but every NEARBY mech's master re-enters this same handler with collateral damage during the killing volley.
|
||||
|
||||
**(6) ForceUpdate** (:14833): `FUN_004a4c54(mech, 1)` @004a4c54 (part_012.c:17180-17193): `mech+0x18 |= 1` (updateModel bit 0 = `Simulation::ForceUpdate`, corroborated `KD_SCOREBOARD_PLAN.md:131`); if destroyed, mask `& 0xfe03` still passes bit 0. Marks the dead mech for replication. No messages.
|
||||
|
||||
## 4. Emission matrix (per single handler invocation)
|
||||
|
||||
| Scenario at entry | type 0 → shooter | type 1 → victim | type 2 → shooter | VehicleDead 0x17 → victim's player | tail extras (blast/respawn-sched/splash) |
|
||||
|---|---|---|---|---|---|
|
||||
| alive, survives volley | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
|
||||
| alive → DEAD this volley | — | ✔ | ✔ (basis=victim killBonus) | ✔ (exactly once) | ✔ |
|
||||
| already destroyed (mode 2\|9) | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
|
||||
| collision (type 0 dmg), killed | — | — | — | ✔ (killed-by = rammer's player) | ✔ |
|
||||
| collision, survived | — | — | — | — | — |
|
||||
|
||||
Corrections to the task's priors: (a) the already-destroyed branch is **not** inflicted-only — the type-1 received report to the victim's own player also flows (Report C is outside both latch branches, gated only on tally>0); (b) the killing hit emits **no type-0** (A/B exclusive); (c) collision damage emits **no 0x16 at all** yet still runs the full death tail.
|
||||
|
||||
## 5. Relevance flags for #162 (inference, clearly marked [T4] against build 883)
|
||||
- deathCount++ and the −500 penalty ride the SAME single VehicleDead message in the binary; 8 increments + 0 penalties means the port's VehicleDead emission or its receiver diverged from this map, not the binary's report logic.
|
||||
- The only binary-native re-fire hole is the mode-10 latch gap (§3): destroyed-at-entry is tested as mode 2|9 while the tail fires on 9|10.
|
||||
- "Duplicate pairs" of damage awards is consistent with B+C both existing per hit (one to each party) and/or corpse-hits still emitting B+C — and splash generates additional id-0x12 volleys during a kill.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# FIELD RECEIPTS A/B — build 857 (night14) vs build 883 (night15), ticket #162
|
||||
|
||||
All paths below are absolute. Steam logs have no per-line timestamps (line numbers cited); matchlog receipts carry `t=` (ms tick) and `w=HH:MM:SS.mmm` wall clock. Receipt-format matchlogs were only collected from the ALIA machine on night15 (2 files) plus a dev-machine archive `scratchpad/night14/mlbak/` (builds 4.11.837–4.11.870, DESKTOP-AE1SU9U). The night14 field steam logs (build 857) contain NO receipt-format lines at all — no `SCORE t=`, no `PLAYER_DEAD` (0 matches in all four files); the 857 baseline evidence is the `[score]`/`[respawn]`/`[deathcost]` prose lines. [T2 throughout — all claims below are read directly from field logs]
|
||||
|
||||
## 1. BUILD-857 BASELINE (night14 steam logs)
|
||||
|
||||
**Multi-increment already existed in 857.** Clustering `[respawn] player X:1 death cycle START (death #N)` lines (same player, consecutive N, <200 lines apart) across the four logs:
|
||||
|
||||
| log | increments | real events | multiplicity |
|
||||
|---|---|---|---|
|
||||
| steam_20260808_a_dave_SCREECH.log | 9 | 6 | {1:4, 2:1, 3:1} |
|
||||
| steam_20260808_b_santo_MSFIREFLY.log | 7 | 6 | {1:5, 2:1} |
|
||||
| steam_20260808_c_michael_XIAOLONG.log | 11 | 9 | {1:7, 2:2} |
|
||||
| steam_20260808_e_rajel_GAMERSLAB.log | 13 | 9 | {1:7, 3:2} |
|
||||
| **TOTAL 857** | **40** | **30** | **{1:23, 2:4, 3:3}** |
|
||||
|
||||
**Every increment fired the full chain: KILL report + death cycle + deathcost.** KILL-report count == increment count in every log (9/9, 7/7, 11/11, 13/13) — this is the receipt-level shape of the 857 "kills doubled" bug that 46ff8dd later fixed.
|
||||
|
||||
**The penalty fired once per increment, gated on `advDmg`:** all 8 `-> APPLYING` lines have `advDmg=1`; all 32 `-> SKIPPED` have `advDmg=0`. Perfect 40/40 correlation. Verbatim triple-death, triple-penalty sample (dave log, C:/git/bt411/scratchpad/night14/steam_20260808_a_dave_SCREECH.log:59301-59317, one killing volley, scoreBefore stepping −500 each time — sequential application proof):
|
||||
|
||||
```
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=2.45198 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #2) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #2, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4788.47 -> APPLYING
|
||||
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43099 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #3) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #3, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4288.47 -> APPLYING
|
||||
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43232 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #4) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #4, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=3788.47 -> APPLYING
|
||||
```
|
||||
|
||||
Other verbatim samples: single APPLYING (michael log :82303-82307, `advDmg=1 scoreBefore=1513.08 -> APPLYING`); triple SKIPPED (rajel log :57467-57481, player 6:1, 3× `advDmg=0 scoreBefore=6858.66 -> SKIPPED`, note scoreBefore does NOT move — skipped means no deduction); double SKIPPED (santo log :41879-41889, player 4:1, 2× `scoreBefore=1264.11 -> SKIPPED`).
|
||||
|
||||
**Are there award=-500.00 SCORE receipt lines?** No — in ANY build. In the entire mlbak receipt archive (builds 837–870) plus both 883 matchlogs, `award=` is never −500; the penalty is applied OUTSIDE the id-0x16 SCORE receipt and shows only as a silent −500 drop in the running `total=` between consecutive receipts. E.g. mlbak/matchlog_20260807_235237_15576.txt (4.11.837): total 0.00 → −500.00 between lines 32→40 with all awards −0.00; mlbak/matchlog_20260809_165517_13216.txt (4.11.865) line ~172: total 1000.00 → 501.97 (= −500 + a 1.97 award). The type= on the surrounding lines is type=1 (DamageReceived) with award=-0.00 — the −500 never rides a typed report.
|
||||
|
||||
**−499 eject evidence:** not present in the four night14 field steam logs (greps for `-499`/eject-score hit only coordinates and weapon-jam EJECT lines). The documented evidence is docs/OPEN_ISSUES_FOR_TESTERS.txt:87 — `#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored as suicide + death penalty)` — i.e. −499 = suicide credit (+1) + death penalty (−500) [inference on the arithmetic; the −499 itself is a ledger claim, not in these logs].
|
||||
|
||||
## 2. BUILD-883 (night15)
|
||||
|
||||
**PLAYER_DEAD multiplicity — matchlog receipts (ALIA machine, both matches):**
|
||||
- matchlog_20260810_213656_20476.txt (player 6:1 = Elengil): 8 PLAYER_DEAD, **5 real deaths**, multiplicity per event: **2, 1, 1, 3, 1** (events at w=21:39:49.419, 21:41:17.884, 21:43:13.203, 21:44:36.445, 21:46:29.337). Matches ticket #162's "5 real deaths → 8 increments" exactly.
|
||||
- matchlog_20260810_223651_12120.txt (player 2:1, second match): 10 PLAYER_DEAD, **5 real deaths**, multiplicity: **1, 2, 2, 4, 1**.
|
||||
- DEAD_NOTIFY count == PLAYER_DEAD count in both (8/8, 10/10); every PLAYER_DEAD is immediately preceded by its own `DEAD_NOTIFY ... link=<same ptr>`.
|
||||
|
||||
**All-machine distribution** (six steam logs, death-cycle-START clustering, all players' ledgers): a:{1:4,2:2}, b:{1:16,2:6}, c:{1:9,2:3,4:1}, d:{1:11,2:8,3:5}, e:{1:8,2:6,3:1,4:2}, f:{1:14,2:6,3:1,6:1} → **TOTAL {1:62, 2:31, 3:7, 4:3, 6:1}** — 163 increments over 104 events; ~40% of deaths multi-count, up to 6×.
|
||||
|
||||
**award=-500 count: 0** across both matchlogs (1,326 SCORE lines). No negative award of any magnitude exists (the only negative-looking value is the literal `award=-0.00` on every type=1 line — 745 of them, all exactly −0.00). Also **no silent −500 total-drops**: the only two total-vs-award mismatches (ml1 lines 305, 1117) are the type=2 kill receipts, which print `total=` BEFORE adding the award (next receipt shows total jumped +512.00: 1471.83 → 1992.58 = 1471.83+512+8.75). So in 883 the penalty is gone from the receipts entirely.
|
||||
|
||||
**Deathcost is still evaluated once per increment but always skips:** across all six steam logs 163 `[deathcost]` lines: **162× `advDmg=0 -> SKIPPED`, 1× `advDmg=1 -> APPLYING`** (steam_20260810_a_Dave_SCREECH-PC.log:5383 — player 2:1's FIRST death of the first round, `scoreBefore=1000 -> APPLYING`; the same player's later deathcost lines :19317, :26970 are `advDmg=0 -> SKIPPED`). Contrast 857 where 2:1's advDmg stayed 1 across deaths #1–#4 mid-round (scoreBefore 2907…4788). Receipt-level verdict: **the −500 path is intact but its `advDmg` gate reads 0 on (almost) every death in 883**, where it read 1 persistently for the same player in 857. Eleng's own machine evaluated deathcost for 6:1 exactly 8 times (once per increment) — all SKIPPED.
|
||||
|
||||
## 3. ELENGIL CORRELATION (6:1, matchlog_20260810_213656_20476.txt)
|
||||
|
||||
**death#1 @ w=21:39:49.419 — 2 increments.** DMG victim=6:19 within ±3s: from **7:19**: 12.000 (lvl .8806), 13.000 (lvl .6195) at .180/.181, and 8.750 burst=3 at .558 (post-death); from **5:19**: 1.964/3.432/4.913 (lvl .8488→.9130) then **type=4 amt=14.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=1`, then **type=4 amt=15.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=2`, then `DEATH ... killer=5:19 killdmg=15.701` 46ms later. **2 increments = 2 lethal (lvl=1.0000) damage rounds**, both from ONE shooter (5:19). Distinct shooters in window = 2; peers = 8.
|
||||
|
||||
**death#4 @ w=21:44:36.445 — 3 increments.** Window: **8:19** lands 9 rounds .596–.866 (4×3.446, 2×5.889, 13.000, 3.500 burst=9, 1.500 burst=2 — none lethal); **5:19** lands 7 rounds: 1.977×2, 3.444 (lvl .9236→.9653), then **4.903 lvl=1.0000** → PLAYER_DEAD deaths=5, **15.717 lvl=1.0000** → deaths=6, **15.717 lvl=1.0000** → deaths=7, all at t=613221000 same ms, DEATH latch 53ms later. **3 increments = 3 lethal rounds, all from ONE shooter.**
|
||||
|
||||
**Verdict: increment multiplicity == the number of KILLING-WINDOW DAMAGE ROUNDS** (damage applications with lvl=1.0000 that land between the killing blow and the DEATH-transition latch ~30–50ms later). It is NOT the number of distinct shooters (death#1: 2 shooters in window, 2 increments from one; death#4: 2 shooters, 3 increments from one; ml2's 4-increment event at 22:45:40.768 has all 4 lethal rounds from the single shooter 8:19 while earlier lvl=1.0000 rounds from 6:19 and 9:19 at .323/.628 produced NO increment). It is NOT the peer count (8 peers; multiplicity observed 1–6, mode 1–2). Each in-flight round of the killing volley that applies before the wreck latch re-fires DEAD_NOTIFY→PLAYER_DEAD.
|
||||
|
||||
## 4. THE "DUPLICATE SCORE PAIRS" IN 883 RECEIPTS
|
||||
|
||||
Same-key (t + player + type + award) SCORE multiplicity histograms: ml1 type=0 {1:98, **2:63**}, type=1 {1:46, 2:28, 3:10, 4:20, 5:3, 6:19}; ml2 type=0 {1:115, **2:120**}, type=1 up to ×6. The nonzero-award duplicated groups are **all type=0 (DamageInflicted)**; pair members carry **IDENTICAL award and DIFFERENT, sequentially-advancing totals** — e.g. ml1 lines 45-48:
|
||||
|
||||
```
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1001.97 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1003.94 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1007.38 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1010.82 kills=0
|
||||
```
|
||||
|
||||
**BUT these type=0 pairs are not duplicates.** Each pair member maps 1:1 to a distinct weapon event: the volley above is FIRE wpn=25 amt=3.439 / wpn=26 amt=3.439 / wpn=27 amt=1.971 / wpn=28 amt=1.971 (twin-mounted lasers, ml1 lines 41-44), and the following `award=13.00` pair maps to PROJ wpn=22 + wpn=24 both amt=13.000 (lines 49-52). Aggregate: SCORE type=0 count ≤ hit-event count in both matchlogs (224 vs 242; 355 vs 408 — the excess hits are missile rounds paid as lump awards like 35.00=4×8.75). A build-865 control (mlbak/matchlog_20260809_165517_3316.txt lines 16-19: 2 FIRE → 2 identical 1.97 awards; 105 type=0 vs 108 hits) shows the identical pattern pre-883. **Verdict: the type=0 "duplicate pairs" are twin-weapon awards, present in old builds, correctly paid once per hit — not part of bug #162.** What IS genuinely duplicated per killing volley: the DMG/type=1 receipt (one per damage round, always award=-0.00, ×2–×6 same-ms) and DEAD_NOTIFY/PLAYER_DEAD (the real bug). Kill credit is exact in 883: `SCORE type=2 award=512.00` / `award=500.98` once per kill, SBMIRROR +1 steps (ml1 lines 305, 1117).
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 2 — Binary truth: 1995 player-side score/K-D handlers + replication
|
||||
|
||||
All addresses verified against the 2026-08-06 re-export (`reference/decomp/all/part_013.c`, `part_012.c`, `part_004.c`) and, where load-bearing, re-verified with raw capstone disasm of `content/BTL4OPT.EXE` (`tools/disas2.py`). Tier tags inline.
|
||||
|
||||
## 0. The handler table (who consumes what) [T1, byte-scanned table + export]
|
||||
|
||||
BTPlayer message table (20-byte rows, 6 entries — matches the T0 `PLAYER.h:235-240` enum `DropZoneReply=Entity::NextMessageID(0x15), Score(0x16), VehicleDead(0x17), MissionStarting(0x18), MissionEnding(0x19), NextMessageID(0x1a)`):
|
||||
- **0x15 DropZoneReply → `FUN_004bffd0`** (part_013.c:18814)
|
||||
- **0x16 Score → `FUN_004c02e4`** (part_013.c:18941) — the type-0/1/2 switch
|
||||
- **0x17 VehicleDead → `FUN_004c05c4`** (part_013.c:19066; formerly an export gap, now exported AND raw-disasm-confirmed this pass)
|
||||
- **0x1a ScoreUpdate → `FUN_004c02a8`** (part_013.c:18926)
|
||||
- `FUN_004c0200` "ScoreInflicted" (part_013.c:18896) is bound in NO table entry — **dead code in 1995** (confirms `context/decomp-reference.md:273-275`).
|
||||
|
||||
## 1. The id-0x16 Score handler `@0x4c02e4` — all three arms [T1]
|
||||
|
||||
Entry: replicant Verify (`(this+0x28 & 0xc)==4` → Verify print, btplayer.cpp line 0x1e9), then **MissionEnding gate `this+0x40 != 4`** (whole body skipped during mission end). `iVar3 = GetEntityPointer(msg->senderMechID @+0x34)`; `local_c = playerVehicle(+0x1fc)`.
|
||||
|
||||
**type 0 (DamageInflicted)** — part_013.c:18970-18973: nothing but `Verify` (line 0x296). `local_10` (the award local) is **never written**, and the shared tail (part_013.c:19015-19016) still runs: `msg->scoreAward(+0x1c) = local_10 /*UNINITIALIZED*/; FUN_0042da20(this,msg)` → **folds stack garbage into engine currentScore(+0x1c8)** — the shipped 1995 bug the KB documents (`decomp-reference.md:275-276`). No counter increments.
|
||||
|
||||
**type 1 (DamageReceived)** — part_013.c:18974-18991: only if `senderMech != own vehicle`: `award = (ownTonnage(+0x4bc)/senderTonnage) × CalcDamageReceivedScore(basis@msg+0x24)` where **`FUN_00429b94` (part_003.c:10156-10160) = `−(basis × role->damageReceivedModifier(+0x10))`** — a NEGATIVE (penalty). Then the console VTVDamaged post (code 5; gated ConsoleHost && `!suppressConsole(+0x258)`). **If senderMech == own vehicle (self-inflicted), `local_10` stays uninitialized and the tail still folds it** — same uninit-fold class as type 0 (NEW this pass). No counter increments. dfltrole `dmgRcv=0` ⇒ award 0 in shipped content.
|
||||
|
||||
**type 2 (Kill)** — part_013.c:18992-19013:
|
||||
- `award = CalcKillScore(this, &basis@+0x24, victimMech, tally@+0x1c) × (victimTonnage/ownTonnage)`.
|
||||
- **`CalcKillScore @0x4c052c`** (part_013.c:19027-19060): team game (`freeForAll(+0x250)==0`) + same `teamName(+0x20c)` → `local_8 = −friendlyFirePenalty(role+0x14)`; else `local_8 = victimMech->avgZoneDamage(+0x354) × damageBias(role+0x18) + 1.0 (@0x4c05c0)`; returns `(basis + tally) × damageInflictedModifier(role+0xC) × local_8`.
|
||||
- **Suicide (`victimMech == own vehicle`): `local_10 = -local_10` (the `fchs`) and NO killCount increment.**
|
||||
- Else — **the dual killCount++, exact context** (part_013.c:18998-19001; `inc [+0x27c]` @0x4c0397/@0x4c03a3):
|
||||
```c
|
||||
else {
|
||||
*(int *)(param_1 + 0x27c) = *(int *)(param_1 + 0x27c) + 1; // shooter (this) killCount++
|
||||
piVar1 = (int *)(*(int *)(iVar3 + 400) + 0x27c); // victimMech+0x190 -> victim's player
|
||||
*piVar1 = *piVar1 + 1; // PHANTOM: victim's player killCount++ (wrong column)
|
||||
}
|
||||
```
|
||||
On the shooter's node the second object is the **victim's replicant player copy** — never corrected in 1995 (no replication, §4).
|
||||
- StatusMessage `{type 0 "Destroyed", victim player, 6.0s}` (pool @0x512f6c, vtable @0x513344) added to this player; if `victimMech == objectiveMech(+0x284)` → clear-target `FUN_004b04d8` via own vehicle's roster head (+0x128).
|
||||
- Tail (all types): `msg->scoreAward = award; FUN_0042da20(this,msg)` → `currentScore(+0x1c8) += award; playerVehicle->RespondToScoreMessage(msg)` (part_004.c:449-450 ≡ T0 `engine/MUNGA/PLAYER.cpp:143,150`).
|
||||
|
||||
**killCount(+0x27c) is written NOWHERE else in the image** — only the type-2 arm.
|
||||
|
||||
## 2. WHERE deathCount++ lives and WHERE the deathPenalty is subtracted [T1, raw disasm this pass]
|
||||
|
||||
Both live in **`BTPlayer::VehicleDeadMessageHandler @0x4c05c4`**, in the **`msg->deathCount(+0x1c) == -1` arm** — one straight-line block, no exit between them:
|
||||
|
||||
- Entry gates: `this+0x40 != 4` (MissionEnding); `msg+0x1c == -1` else → engine base `FUN_0042db80` (drop-zone hunt); **`deathPending(+0x290) == 0`** guard @0x4c05f7.
|
||||
- `edi = GetEntityPointer(msg+0x2c)` = the **killer's PLAYER** (the BT 0x38-byte extension field).
|
||||
- StatusMessage `{type 1 "KILLED BY", killer player, 6.0s}` @0x4c062d-0x4c0668.
|
||||
- **The deaths increment @0x4c0670-0x4c067f:**
|
||||
```
|
||||
0x4c0670: cmp ebx, edi ; victim(this) vs killer player
|
||||
0x4c0672: je 0x4c067a ; self-kill: skip the partner bump
|
||||
0x4c0674: inc dword ptr [edi + 0x280] ; killer's player deaths++ (wrong-column slip; on the victim's node this is the killer's REPLICANT copy)
|
||||
0x4c067a: inc dword ptr [ebx + 0x280] ; victim's deaths++ (the real DEATHS cell) — UNCONDITIONAL
|
||||
```
|
||||
- Console death notice (code 5, `FUN_004c18cc`) if `!suppressConsole(+0x258)` && console present.
|
||||
- Revenge lock `objectiveMech(+0x284) = killerPlayer->playerVehicle(+0x1fc)` @0x4c06dd.
|
||||
- Death sweep `FUN_0049fe0c(playerVehicle, 0)` @0x4c06e9 (DeathShutdown arg 0).
|
||||
- Lives branch on `role(+0x208)->returnFromDeath(+0x28)`:
|
||||
- `> 0`: re-post the SAME VehicleDead to **SELF** at Now + **5.0 s** (@0x4c0830) with `msg->deathCount ← this->deathCount(+0x200)` (raw disasm recovered `mov esi,ebx` @0x4c0731 — the Post target is the player, matching the export).
|
||||
- `< 1` (out of lives): post `{size 0x1c, id 0x18}` to the **VEHICLE** at Now + **10.0 s** (@0x4c0838) — in the Mech id space 0x18 = ClearBurningState; no respawn.
|
||||
- **THE DEATH PENALTY @0x4c07cd-0x4c0825** — BOTH branches converge here (`jmp 0x4c07cd` @0x4c0754):
|
||||
```
|
||||
0x4c07cd: mov ecx, [ebx + 0x264] ; advancedDamageOn — THE ONLY GATE
|
||||
0x4c07d5: je 0x4c0828 ; off -> no penalty
|
||||
0x4c07d7: mov eax, [ebx + 0x208] ; scenarioRole
|
||||
0x4c07dd: fld dword ptr [eax + 0x20] ; specialCaseDeathPenalty (dfltrole = 500)
|
||||
0x4c07e0: fchs ; negate
|
||||
... build a BARE 0x20-byte engine ScoreMessage {id 0x16, scoreAward@+0x1c = -penalty} (NO type field — the msg ends at 0x20)
|
||||
0x4c0820: call 0x42da20 ; DIRECT call to ENGINE Player::ScoreMessageHandler
|
||||
```
|
||||
So the −500: **victim's master node, inside the id-0x17 VehicleDead(-1) arm, gated ONLY on `advancedDamageOn(+0x264)`, amount = `role+0x20` read live, applied by a DIRECT call** (no Dispatch → no reroute, no BT type switch, no Verify) into `currentScore(+0x1c8)` + `playerVehicle->RespondToScoreMessage`. Confirms `decomp-reference.md:458-478` (#118) and adds: **the penalty also applies on the out-of-lives path.**
|
||||
|
||||
**The engine respawn counter is a DIFFERENT field:** `Player::deathCount(+0x200)` (ctor seed −2, `PLAYER.cpp:777`; zeroed at first spawn, part_013.c:18834) increments ONLY in **`FUN_004c012c`** (part_013.c:18872, the DropZoneReply respawn helper) — once per completed respawn cycle, alongside `msg->deathCount` restamp, `role->returnFromDeath(+0x28)--`, **`ForceUpdate` (`or word [ebx+0x18],1`)**, sim-state SetLevel(1)=DropZoneAcquired, respawn-location choose (advancedDamageOn → the ORIGINAL drop @DAT_00524b38, else the assigned zone), a delayed self re-post (the 2-pass latch), and **clearing `+0x290` and `suppressConsole(+0x258)`** (part_013.c:18887-18888).
|
||||
|
||||
### NEW [T1, whole-image byte scan]: the `deathPending(+0x290)` guard is NEVER SET in 1995
|
||||
Every code-section instruction touching disp32 0x290: `push 0x290` @0x429294 (an immediate, not a member); zero-writes @0x4b77fd (different object), **@0x4c01e5 (`FUN_004c012c`: = 0)**, **@0x4c0c07 (BTPlayer ctor: = 0)**; the lone READ @0x4c05f9 (the handler guard). **No instruction ever writes it non-zero.** The 1995 receiver has NO dedup — single increment/single penalty is guaranteed solely by the SENDER emitting exactly one VehicleDead per death (§3). Implication for #162: in the binary, deaths++ (@0x4c067a) and the penalty (@0x4c07cd) are one straight-line block — "deaths multi-increments while the penalty never applies" is impossible in a faithful transcription; it requires (i) `advancedDamageOn==0`, (ii) an unbound/zero role (+0x20==0 — the pre-2026-08-07 port state), or (iii) a port-side early exit between the two.
|
||||
|
||||
## 3. The sender: `Mech::TakeDamageMessageHandler @0x4a0230` report tail [T1] (part_012.c:14474-14837)
|
||||
|
||||
Runs ONLY on the **victim mech's MASTER node** (replicant Verify at entry, line 0x3da; field: 0/18818 DMG rows inst=R). Per killing/damaging hit:
|
||||
1. **Destroyed-at-entry latch: `local_14 = FUN_0049fb54(this)` (IsDisabled) at ENTRY** — a hit landing on an already-dead mech can never re-send kill/VehicleDead.
|
||||
2. `lastInflictingID(+0x43c) ← msg->inflictingEntityID(+0x1c)` — every hit, last-writer-wins.
|
||||
3. **`damageType(msg+0x2c) == 0` (Collision) → `FUN_0049ffcc` and `goto` the death tail — SKIPS ALL THREE id-0x16 reports.** 1995 ram kills award NO kill score and NO killCount; they still produce VehicleDead → deaths + penalty (state-9 timing inside FUN_0049ffcc not read this pass, [T3]).
|
||||
4. Burst loop applies damage; `local_28` accumulates applied damage + crit contributions; the loop breaks when the mech goes disabled mid-burst.
|
||||
5. Reports, all ScoreMessage id 0x16 size 0x3C `{+0x1c tally, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID, +0x34 senderMechID}`:
|
||||
- **KILL EDGE** (alive at entry && disabled after) part_012.c:14685-14708: **type 2 → Dispatch to the SHOOTER's owning player** (`*(shooterMech+0x190)`; a replicant locally → `Entity::Dispatch` reroutes to the shooter's master). `tally = local_28` (applied damage of the volley), **`basis = the VICTIM's own `role->killBonus(+0x1c)`** (the @0x4a0506 read; dfltrole 500), `senderMechID = the victim mech's own ID(+0x184)`.
|
||||
- **Else** (still alive, or wreck-shooting), if `local_28 != 0` (part_012.c:14710-14734): **type 0 → the SHOOTER's player** (tally = basis = local_28) — lands in the 1995 dead/uninit arm (§1).
|
||||
- If `local_28 > 0` (part_012.c:14736-14758, independent — ALSO fires on the kill edge): **type 1 → the VICTIM's OWN player** (local master → synchronous). `tally = local_28`, `basis = burstCount(+0x58) × damageAmount(+0x30)` (nominal volley), `senderMechID = the inflicting entity (msg+0x1c)`.
|
||||
6. **DEATH TAIL** (part_012.c:14760-14835): gate `local_14 == 0 && (this+0x40 == 9 || == 10)` (10 = eject) — the one-shot alive→dead edge. Builds **BT VehicleDeadMessage `{size 0x38, id 0x17, deathCount(+0x1c) = -1, dropZoneID = Null, +0x28 = 0, +0x2c = the killer's PLAYER EntityID (`shooterPlayer+0x184`), +0x34 = kill zone}`** → **Dispatch to the VICTIM's OWN player** (synchronous, master). Then: death-effect broadcast (id 3, 0x5c), a delayed SetBurningState (id 0x17, 0x1c) to the mech itself (delay = `mech+0x430`), watcher sweep, console damage-effect notify (advancedDamageOn && !suppressConsole), `FUN_004a4c54(this,1)`. **Exactly one VehicleDead per death, sender-side guaranteed.**
|
||||
|
||||
## 4. Replication: what rides the Player update record [T0 + T1]
|
||||
|
||||
- `Player::WriteUpdateRecord` (`PLAYER.cpp:645-661` ≡ binary `FUN_0042e2e4`, part_004.c:859-867): `Entity::WriteUpdateRecord`, `recordLength = 0x3c`, **`record+0x2c = currentScore(+0x1c8)`, `record+0x30 = dropZoneLocation(+0x1d0)`** — nothing else. `ReadUpdateRecord` (`PLAYER.cpp:631-641` ≡ `FUN_0042e2ac`) applies the same two.
|
||||
- **BTPlayer does NOT override** — vtable @0x513300 slots 6/7 = 0x42e2ac/0x42e2e4, the engine's (KD plan §1 [T1]).
|
||||
- Therefore in 1995: **`currentScore(+0x1c8)` is the ONLY replicated scoring field.** `killCount(+0x27c)`, `deaths(+0x280)`, respawn `deathCount(+0x200)`, and the bank `+0x278` NEVER cross the wire; each is authoritative (and only meaningful) on the single node where its increment ran. Bystanders show 0/0 K/D for everyone all mission; the two wrong-column partner increments (on replicant copies) are locally visible and never corrected.
|
||||
- `Player::CalcRanking` (`PLAYER.cpp:542-626` ≡ `FUN_0042e1ac`): per-node sort of the local "Players" group **by `currentScore`** → `playerRanking(+0x1cc)`/`playerHighlighted(+0x1c4)`. Runs in Player/CameraShip simulation each frame.
|
||||
- **The scoreboard PilotList `@0x4cabd0`** (part_014.c:7656-7657) draws per row exactly `(float)[player+0x27c]` (KILLS) and `(float)[player+0x280]` (DEATHS) — no score cell in this widget.
|
||||
- **`+0x278` is a pending-score BANK, not the score** (NEW precision): sole writer = the dead `@0x4c0200` (`+0x278 += tonnageRatio × CalcKillScore(...)`, self-negated); the flusher `FUN_004c083c` (part_013.c:19164-19192, called from the BTPlayer master performance `FUN_004c0904`) every **10.0 s** (@0x4c08fc) self-Dispatches a `{0x20, id 0x1a, award = +0x278}` ScoreUpdate then zeroes the bank; handler `@0x4c02a8` = Verify-master + engine fold. Design = replicant-side observed-score banking with reroute-to-owner; dead in 1995 because the only writer is unbound. The periodic console score post (every 15.0 s @0x4c0bc0) reads **+0x1c8** (`param_1[0x72]`, part_013.c:19274).
|
||||
|
||||
## 5. THE 1995 FLOW — one death event (shooter S kills victim V, weapon damage, advanced damage ON)
|
||||
|
||||
On **V's node** (V master), synchronously inside the killing `TakeDamageMessage` (id 0x12) processing:
|
||||
1. `V.mech.lastInflictingID ← S.mech` ; damage applied; tally accumulated.
|
||||
2. **[out] ScoreMessage 0x16 type 2** → dispatched at S.player-replicant → rerouted to S's node (`ENTITY.cpp:244-251`).
|
||||
3. **[sync] ScoreMessage 0x16 type 1** → V.player (master): `V.currentScore(+0x1c8) += (Vt/St) × −(basis × rcvMod)` (0 with dfltrole); console VTVDamaged.
|
||||
4. **[sync] VehicleDead 0x17 (deathCount=−1, killer=S.player, zone)** → V.player (master):
|
||||
a. "KILLED BY" ticker; b. `S.player-replicant.deaths(+0x280)++` (wrong column, local-only); c. **`V.deaths(+0x280)++`** ← THE death count; d. console death notice (unless suppressConsole — eject sets it); e. `V.objectiveMech = S.vehicle`; f. DeathShutdown(0) sweep; g. lives>0 → repost VehicleDead(deathCount=+0x200) to self @ +5 s; h. **`V.currentScore(+0x1c8) −= role.specialCaseDeathPenalty(500)`** — direct engine call, advancedDamageOn-gated. No ForceUpdate anywhere in this block.
|
||||
|
||||
On **S's node**, when the rerouted type-2 executes on S.player (master):
|
||||
5. `award = (killBonus + tally) × dmgInfMod × (V.avgZoneDmg × bias + 1) × (Vt/St)` [or `−ffPenalty` same-team; negated + no counters on suicide]; **`S.killCount(+0x27c)++`**; **`V.player-replicant.killCount(+0x27c)++`** (phantom, local-only); "DESTROYED" ticker; revenge clear-target; `S.currentScore(+0x1c8) += award`.
|
||||
|
||||
Respawn (V's node): +5 s repost → engine `FUN_0042db80` (gate `msg->deathCount == +0x200 && simState != DropZoneAcquired`): closest non-"win" DropZone, `AssignDropZone{reply 0x15, deathCount}` → DropZone dispatches **DropZoneReply 0x15** → `FUN_004bffd0`: stale gate (`msg+0x38 == +0x200`), MissionEnding gate; pass 1 (state≠1) → **`FUN_004c012c`: `deathCount(+0x200)++`, `returnFromDeath--`, ForceUpdate, state←1, location choose, clear +0x290/+0x258, delayed self-repost**; pass 2 (state==1) → state←2, **`Mech::Reset(vehicle, loc, 1)` (`FUN_0049fb74`)**.
|
||||
|
||||
Convergence: peers learn ONLY `currentScore` (and position) from the Player update record; K/D never converge in 1995.
|
||||
|
||||
## Authority table (1995)
|
||||
|
||||
| field | offset | sole writer(s) | node | replicates |
|
||||
|---|---|---|---|---|
|
||||
| currentScore | +0x1c8 | engine `@0x42da20` (BT 0x16 tail all types; death penalty direct; 0x1a flush) | wherever the owning handler runs (master after reroute) | **YES** (only one) |
|
||||
| killCount | +0x27c | 0x16 type-2 arm only (@0x4c0397 + phantom @0x4c03a3) | shooter's master node | no |
|
||||
| deaths | +0x280 | 0x17 −1 arm only (@0x4c067a + wrong-column @0x4c0674) | victim's master node | no |
|
||||
| deathCount (respawn id) | +0x200 | ctor −2; first-spawn =0; `@0x4c012c` ++ | victim's master | no |
|
||||
| score bank | +0x278 | dead `@0x4c0200`; flushed/zeroed `@0x4c083c` | — | no |
|
||||
| deathPending | +0x290 | **never set**; zeroed ctor + `@0x4c01e5` | — | no |
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 3 — The as-built scoring/death chain at HEAD (build 883, post-46ff8dd)
|
||||
|
||||
## 1. Producer: `Mech::TakeDamageMessageHandler` (game/reconstructed/mech.cpp:1031-1344)
|
||||
|
||||
Handler anatomy, in execution order:
|
||||
|
||||
| Step | Site | Notes |
|
||||
|---|---|---|
|
||||
| `destroyedAtEntry = IsMechDestroyed()` | mech.cpp:1039 | #150 entry latch (added in 46ff8dd), sampled at handler ENTRY; `IsMechDestroyed()` = `graphicAlarm >= 9` (mech.hpp:1145, impl mech4.cpp:2215) |
|
||||
| gyro bounce, lastInflictingID/Damage, HUD threat | mech.cpp:1063-1087 | no score effect |
|
||||
| `deathBlastArmed = !(MovementMode()==9 \|\| MovementMode()==10)` | mech.cpp:1119-1120 | the OTHER once-latch, movementMode-based — arms the death tail |
|
||||
| COLLISION DIVERT (`damageType==0`) | mech.cpp:1142-1146 | `DistributeCollisionDamage` then `goto death_tail` — **bypasses the report tail entirely** (a collision death posts VehicleDead but NO 0x16 reports; credits no one — documented authentic, mech.cpp:1130-1140) |
|
||||
| zone/burst application loop | mech.cpp:1193-1241 | accumulates `damageTally` (applied damage + crit bonuses, :1216/:1227), `zoneDestroyed` (:1229) |
|
||||
| **REPORT TAIL master-gate** | mech.cpp:1270 | `if (GetInstance() != ReplicantInstance)` → `BTMechPostCombatReports(this, msg, damageTally, zoneDestroyed, reportZone, !destroyedAtEntry && IsMechDestroyed())` (:1272-1275). The kill flag = the 0→1 edge of `IsMechDestroyed()` across THIS call |
|
||||
| matchlog `DMG` receipt | mech.cpp:1283-1304 | prints `inst=M/R` (:1294) — runs on BOTH instances, ungated |
|
||||
| **DEATH TAIL** | mech.cpp:1335-1343 | `if (deathBlastArmed && IsMechDestroyed())` → `BTMechPostVehicleDead(...)` (:1339) + `BTApplyDeathSplash(this)` (:1342) |
|
||||
|
||||
**The critical asymmetry (46ff8dd scope):** the commit gated ONLY the 0x16 report tail (mech.cpp:1270) and only the report tail got the `destroyedAtEntry` edge-latch. The DEATH TAIL is (a) **not instance-gated** — it runs on replicant victim copies too — and (b) latched on `deathBlastArmed` (movementMode), **not** on `destroyedAtEntry`. On a replicant, movementMode cannot flip to 9/10 until the owner's death update record round-trips (replicants reach mode 9 only via replicated simulationState — mech4.cpp:2721-2725), while `graphicAlarm` IS raised to 9 locally by the damage application itself (mechdmg.cpp:495, :497, :548, :710, :798 — leg-gone/vital-zone/vital-subsystem/mech-kill writers that run wherever damage is applied). So inside the round-trip window **every additional salvo of the killing volley re-enters the death tail on each peer's replicant copy**: `deathBlastArmed`=1 (mode still 1) && `IsMechDestroyed()`=1 → another VehicleDead + another death splash, per salvo, per peer. This is byte-for-byte the same mechanism 46ff8dd proved for the duplicated kill reports ("two type-2 arms 1ms apart, victim inst=R" — commit message) — the fix covered the reports and left the tail.
|
||||
|
||||
## 2. `BTMechPostCombatReports` (game/reconstructed/btplayer.cpp:2574-2691) — every dispatch
|
||||
|
||||
Resolution: `shooter` from registry by `msg->inflictingEntity` (:2595), `shooter_player = shooter mech playerLink` (:2598), `victim_player = victim playerLink` (:2600).
|
||||
|
||||
| Block | Guard | Message | Destination | kill-flag use |
|
||||
|---|---|---|---|---|
|
||||
| **A** kill (:2602-2644) | `if (newly_destroyed)` && `shooter_player != 0` | ScoreMessage id 0x16 **type 2**, scoreAward=damageTally, damageAmount=victim role killBonus (:2612-2626) | `shooter_player->Dispatch(&kill)` (:2627) — replicant on the victim's node → Entity::Dispatch reroutes to killer's host (ENTITY.cpp:244-251) | **consumed** — sole gate. killer==victim IS dispatched (eject/suicide; handler negates) :2608-2610. Else-arm: matchlog `NOCREDIT` (:2633-2644) |
|
||||
| **B** inflicted (:2646-2667) | `else if (damage_tally != 0 && shooter_player != 0)` | id 0x16 **type 0**, award & basis = tally (:2658-2665) | `shooter_player->Dispatch(&inflicted)` (:2666) | **consumed via the else** — the killing hit posts NO inflicted report (kill replaces it) |
|
||||
| **C** received (:2669-2690) | `if (damage_tally > 0 && victim_player != 0)` — NOT an else | id 0x16 **type 1**, award=tally, damageAmount=INTENDED (burstCount×amount) (:2678-2688), senderMechID=INFLICTOR | `victim_player->Dispatch(&received)` (:2689) — master on the victim's own node → local | **ignored** — runs after A too, kills included (binary behavior, :2672-2674) |
|
||||
|
||||
All three sit INSIDE the mech.cpp:1270 master-gate. Per-TakeDamage cadence: B+C once per non-lethal hit, A+C once on the killing hit, B+C again on every post-mortem salvo that still tallies damage on the wreck (destroyedAtEntry=1 → newly=0 → the "inflicted-only path", mech.cpp:1038).
|
||||
|
||||
## 3. `BTMechPostVehicleDead` (btplayer.cpp:2702-2758)
|
||||
|
||||
Builds `BTPlayer::VehicleDeadMessage` id 0x17, 0x38 bytes, killed_by = shooter PLAYER EntityID, killZone (:2748-2752); engine-base `deathCount` ctor-defaults to **-1** (engine/MUNGA/PLAYER.h:111,120; btplayer.hpp:183) → every dispatch takes the consumer's "-1 immediate death notification" arm. Owner resolved via playerLink with the #55 mission-player fallback (:2711-2723); `DEAD_NOTIFY` matchlog (:2724). `owner->Dispatch(&vehicle_dead)` (:2753) — on a peer node `owner` is a replicant player → reroute to the owning host (ENTITY.cpp:244-251). **No instance gate, no once-per-death latch of its own** — it fires as often as the death tail fires (see §1 asymmetry).
|
||||
|
||||
`BTApplyDeathSplash` (mech4.cpp:1382-1428): gates = victim player's `advancedDamageOn` && !`suppressConsole` (:1399; punch-outs never blast), radius/damage authored per chassis; dispatches a type-2 TakeDamage (inflictor = the DYING mech, :1337 via BTSplashCore :1426) to every live mech in radius via `e->Dispatch(&td)` (mech4.cpp:1335-1338). Because the death tail runs on every node's copy, a bystander's master can receive the SAME death blast once locally (its own node's replicant-victim tail) and once over the wire (the victim-master's tail, rerouted) — **each application then posts its own Block-C received award: a structural producer of "damage awards in duplicate pairs"** [T3 inference from as-built structure; not yet log-confirmed].
|
||||
|
||||
## 4. Consumers (btplayer.cpp) — everything that mutates score/killCount/deathCount
|
||||
|
||||
Handler table: btplayer.cpp:346-356 (DropZoneReply, VehicleDead, Score, ScoreInflicted, ScoreUpdate, MissionStarting, MissionEnding).
|
||||
|
||||
### 4a. `BTPlayer::Dispatch` type-0 interceptor (:891-917)
|
||||
Local id-0x16 type-0 dispatches, `GetInstance()==MasterInstance` only (:911) → `ScoreInflictedMessageHandler`; otherwise falls to `Player::Dispatch` (replicant → wire reroute). Wire deliveries NEVER pass through this vtable — they land in the handler table via Receive (:1092-1105).
|
||||
|
||||
### 4b. `ScoreInflictedMessageHandler` (:963-1022) — type 0
|
||||
Verify-asserts type==0 (:971-980); drops if `our_mech==0` (:994); `currentScore += (tonnage ratio) * award`, negated if target==self (:1001-1012); `ForceUpdate()` (:1013); **matchlog receipt `SCORE ... type=0`** (:1018-1021). No instance guard at all.
|
||||
|
||||
### 4c. `ScoreUpdateMessageHandler` (:1032-1046)
|
||||
Replicant check is `Verify(False, ...)` (:1036) then falls through to the base handler (:1045).
|
||||
|
||||
### 4d. `ScoreMessageHandler` (:1057-1279) — the 0x16 type-1/2 consumer
|
||||
- **Replicant guard is a no-op at runtime**: `Verify(False, ...)` (:1061-1067) — and `Verify` compiles to NOTHING: no `DEBUG_LEVEL` is defined anywhere in the build (grep of CMakeLists), so engine/MUNGA/STYLE.H:10-14 defaults DEBUG_LEVEL=0 → DEBUGOFF.h:42 `#define Verify(c)` (empty). Same for :973 and :1036. **If a 0x16 ever reaches a replicant player copy, the handler executes fully on it.** Delivery-side, `EntityManager::ReceiveNetworkPacket` (engine/MUNGA/NTTMGR.cpp:107-149) posts an inbound wire message to whatever LOCAL copy matches the entityID — **no instance check** — so nothing structural prevents that.
|
||||
- MissionEndingState drop (:1073-1076).
|
||||
- **type 0** (:1086-1115): delegates to ScoreInflictedMessageHandler and RETURNS (wire-rerouted inflicted reports land here; :1114-1115).
|
||||
- **type 1** DamageReceived (:1117-1169): award = tonnage-ratio × `scenarioRole->CalcDamageReceivedScore(msg->damageAmount)` — guards sender/our mech non-null, sender≠self, role bound (:1125-1131); self-hit → award stays 0 (deliberate deviation, :1132-1136); console VTVDamaged side-message (:1149-1167).
|
||||
- **type 2** Kill (:1171-1241): award = tonnage-ratio × `CalcInflictedScore(msg->damageAmount, sender_mech, msg->scoreAward)` (:1188-1190); `sender==our_mech → award = -award` (suicide/eject, :1192-1195 — the previous build's measured **-499**); else `++killCount` (:1204) **and `++sender_owner->killCount`** — the binary's wrong-column slip reproduced on the VICTIM's (replicant) player copy (:1206, banner :2376-2389); status ticker (:1219-1228); objective notify (:1234-1240).
|
||||
- Common tail: `message->scoreAward = award` (:1252); **matchlog `SCORE ... type=<1|2>`** (:1257-1262); `currentScore += award` — via direct add if vehicleless (:1264-1268) else `Player::ScoreMessageHandler` (:1270); `ForceUpdate()` (:1278).
|
||||
|
||||
### 4e. `VehicleDeadMessageHandler` (:418-771) — the deathCount writer
|
||||
- MissionEndingState swallow (:426-439).
|
||||
- `deathCount != -1` branch (:441-539): the +5s re-post / -2 probe → drop-zone hunt via engine base (:537). Moot if mech alive again (:452-462).
|
||||
- **`deathCount == -1` arm** (:541-771), the death notification. Dedup gate `if (deathPending != 0) return` (:546-558) — **but `deathPending` is SET at :559 and CLEARED at :768 inside the SAME synchronous invocation** (the #81 fix moved the clear here, banner :739-767). A queued/wire duplicate arriving even 1ms later sails through. Per accepted -1 message: `++deathCount` (:582), `message->deathCount = deathCount` (:583), `++deathTally` (:592), **matchlog `PLAYER_DEAD`** (:601-602), life debit (:606-610), ForceUpdate (:634), `killerName` (:673), +5s respawn re-post (:690-695), **THE DEATH COST** (below), latch clear (:768), `suppressConsole=0` (:770). **No instance guard anywhere in this handler.**
|
||||
|
||||
### 4f. The -500 deathPenalty — the ONLY site (:698-736)
|
||||
```
|
||||
if (advancedDamageOn && scenarioRole != 0) // :726
|
||||
ScoreMessage death_cost(ScoreInflictedMessageID /*0x16*/, ..., type 1,
|
||||
-scenarioRole->GetSpecialCaseDeathPenalty(), 0.0f, EntityID::Null); // :728-734
|
||||
Player::ScoreMessageHandler(&death_cost); // :735 DIRECT base call
|
||||
```
|
||||
Properties: applied via a DIRECT engine-base call (PLAYER.cpp `currentScore += scoreAward`) — **never dispatched, never on the wire, produces NO `SCORE` matchlog receipt** (only the running total moves; acknowledged at :712-715). The one witness is the **ungated `[deathcost]` DEBUG line** (:716-724) printing `advDmg=/role=/penalty=/scoreBefore=/APPLYING|SKIPPED`. Gates: `advancedDamageOn` — assigned ONLY in the ctor's MASTER branch from `bt_mission->AdvancedDamageOn()` (:2137-2138); on a replicant player copy it is **never assigned** (ctor does not zero it; :1944-1947 zero only console/score/deathPending) [T3: indeterminate there]. `scenarioRole` — bound for both instances with a `Role::Default` fallback (:2066-2084).
|
||||
|
||||
### 4g. `DropZoneReplyMessageHandler` (:1743-1920)
|
||||
`deathCount = 0` on vehicle create (:1798); stale-reply discard when `message->deathCount != deathCount` (:1835-1867 — duplicate deaths that bump deathCount between the re-post and the reply strand the respawn here); `RESPAWN` matchlog (:1906).
|
||||
|
||||
### 4h. Scoreboard replication (:2391-2477)
|
||||
`WriteUpdateRecord` ships killCount/deathTally (:2415-2416); `ReadUpdateRecord` mirrors onto replicants only (:2450-2458), `SBMIRROR` receipt (:2467). **`deathCount` (the respawn-handshake identity) is NOT replicated — only deathTally is** — so replicant-side deathCount writes are never corrected.
|
||||
|
||||
## 5. `MechDeathHandler` (game/reconstructed/mechdmg.cpp:1208-1363+)
|
||||
Nothing score- or count-adjacent: skate detector (:1233-1313), ghost detector (:1315-1342), replicant un-wreck on the mode-9/2 exit edge (:1344-1360). The death-relevant writes in mechdmg.cpp are the `graphicAlarm.SetLevel(9)` sites (:495, :497, :548, :710, :798) which flip `IsMechDestroyed()` wherever damage is applied — including on replicant copies — which is what re-arms the ungated death tail (§1).
|
||||
|
||||
## 6. Every site that can run MORE THAN ONCE per death (vs a once-per-event model)
|
||||
|
||||
1. **Death tail on replicant victim copies** (mech.cpp:1335-1343): ungated by instance, latched on movementMode which is frozen on replicants for the whole death-record round-trip → **one VehicleDead + one death splash per salvo per peer node**. The exact #150 mechanism, un-fixed for the tail. → explains deathCount 5→8, duplicates 1ms apart / same-ms ×N (each duplicate reroutes to the victim's master and is counted).
|
||||
2. **VehicleDeadMessageHandler -1 arm has no effective dedup** (btplayer.cpp:546/559/768): the latch is raised and lowered inside one synchronous body, so every queued duplicate increments deathCount/deathTally, debits a life, posts another +5s re-post, and (when its gates pass) applies another death cost.
|
||||
3. **Death splash multi-application** (mech4.cpp:1382-1428 via mech.cpp:1342): fires from the master's tail AND from every peer replicant's tail (×salvos) → bystander masters take the blast ≥2× → **paired type-1 damage awards** (§3) [T3].
|
||||
4. **Post-mortem salvos on the wreck**: each still posts Block B+C (mech.cpp:1038 "inflicted-only path"; btplayer.cpp:2646-2690) — by design, but it means received penalties continue after death.
|
||||
5. **Replicant execution of the 0x16 consumers is unguarded at runtime**: `Verify` is compiled empty (DEBUG_LEVEL=0 → DEBUGOFF.h:42), VehicleDeadMessageHandler has no guard at all, and NTTMGR.cpp:107-149 delivers wire messages to the local copy by entityID with no instance check.
|
||||
6. **`++sender_owner->killCount` on the victim's replicant player copy** (btplayer.cpp:1206) — bounded by the SBMIRROR correction (banner :2376-2389), kills currently read exact in the field.
|
||||
|
||||
## 7. Why the -500 can read "never applies" — as-built observations (mechanism NOT proven here)
|
||||
The penalty has exactly one producer (§4f) and it is receipt-less: no matchlog row, no wire message — a duplicate-VehicleDead storm shows up as PLAYER_DEAD rows while the penalty leaves only the `[deathcost]` line and the raw total. As-built, the arm CANNOT be reached without also incrementing deathCount (:582 precedes :726 in the same arm), so "deathCount multi-increments AND penalty never applies" requires the :726 gate to fail on every accepted -1 — i.e. field `[deathcost]` lines reading `advDmg=0` or `role=NULL -> SKIPPED`, or the -1 arms executing on player copies whose `advancedDamageOn` was never assigned (§4f). The previous build's -499 eject proof exercised the **type-2 suicide negation** (btplayer.cpp:1192-1195) plus the death cost (46ff8dd message: "1000 - ~999 - 500 = -499"), so the discriminator for build 883 is the ungated `[deathcost]` line and the `[exp] master player ... advDamage=` ctor line (:2141-2145) in the field logs.
|
||||
|
||||
## 8. Cross-node routing spine (for the graph)
|
||||
- `Entity::Dispatch` on a replicant reroutes to the owner host and does NOT execute locally (engine/MUNGA/ENTITY.cpp:235-272).
|
||||
- Wire arrival: `EntityManager::ReceiveNetworkPacket` posts to the local copy by entityID, handler table via Receive — bypassing the `BTPlayer::Dispatch` type-0 interceptor (btplayer.cpp:1092-1105) and any instance discipline (NTTMGR.cpp:107-149).
|
||||
- Matchlog receipt map: `DMG` mech.cpp:1290 · `SCORE type=0` btplayer.cpp:1018 · `SCORE type=1/2` :1258 · `PLAYER_DEAD` :601 · `DEAD_NOTIFY` :2724 · `NOCREDIT` :2637 · `SBMIRROR` :2467 · `RESPAWN` :1906 · `PROJ` mech4.cpp:2001 · `SPLASH` mech4.cpp:1349. The death cost has NO tag.
|
||||
@@ -404,3 +404,28 @@ design change for its own pass. `killCount`/`deathTally` are still zeroed only i
|
||||
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
|
||||
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
|
||||
reproduced; it is merely no longer visible).
|
||||
|
||||
## Addendum 2026-08-05 — the AUTHENTIC report tail lands (#45/#134)
|
||||
The kill/damage report sender is no longer the BTPostDamageScore/BTPostKillScore stand-in pair:
|
||||
`Mech::TakeDamageMessageHandler`'s dark-gap tail (@0x4a02f4-0x4a0890) is reconstructed as
|
||||
`BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), dispatching the binary's three
|
||||
id-0x16 reports (kill to shooter / type-0 wire-fidelity / received to victim) and the BT-extended
|
||||
0x38 VehicleDead {killed-by player, kill zone} from the victim's handler at the binary's exact
|
||||
positions. Consequences for this plan:
|
||||
- KILLS credit path unchanged in shape (victim node -> replicant-player Dispatch reroute ->
|
||||
killer's master ++killCount) — re-benched cross-node (scorekill.sh: kills=1, award=4.88).
|
||||
- Suicides now dispatch and the handler negates the award (#134 panic penalty live;
|
||||
scoreself.sh: type=2 award=-39.00 kills=0).
|
||||
- Per-hit INFLICTED credit is REMOVED — it never existed in 1995 (@0x4c0200 is bound in no
|
||||
handler-table entry; scoring = kill awards + received penalties + death costs). SCORE column
|
||||
behavior changes accordingly; KILLS/DEATHS columns unaffected.
|
||||
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
|
||||
replication-masked. The type-1 received report also fixes the console VTVDamaged
|
||||
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
|
||||
|
||||
|
||||
## 2026-08-11 -- SETTLED
|
||||
|
||||
The chain was audited end-to-end and fixed at the authority model (ticket #162, build 4.11.885). The definitive references now live in
|
||||
`context/combat-damage.md` (curated model) and `docs/KD_CHAIN_AUDIT_20260811.md`
|
||||
(complete evidence). This plan file is historical from here on.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# The mission-end / exit-hang audit -- full findings (2026-08-11, ticket #163)
|
||||
|
||||
> Four-agent audit of the round-end freeze + hang-on-exit family. VERDICT:
|
||||
> the mission-end ladder is CLEAN on every machine (StopMission never even
|
||||
> fires in steam fields -- each pod self-ends on its local clock); the bug
|
||||
> lives in the unlogged exit window: bare ExitProcess with no SteamAPI_Shutdown
|
||||
> anywhere in the codebase -> intermittent DLL-detach deadlock -> the dead
|
||||
> process's fullscreen frame squats on screen while its menu child hangs in
|
||||
> SteamAPI_Init against the wedged sibling (field-captured: ZEUS 22:12:38).
|
||||
> Fix shipped same day: BTSteamNet_ShutdownAll + ordered exit (flush, steam
|
||||
> down BEFORE child spawn, hide windows, TerminateProcess) at every exit path.
|
||||
> Side-yield: #156's fade root cause (POVStartEndRenderable ctor is an empty
|
||||
> stub, btstubs.cpp:339) + the complete binary end-of-mission ladder.
|
||||
|
||||
# #163 — Task 2: The end-of-mission ladder as built, every stall-capable site flagged
|
||||
|
||||
Stall-capable sites are tagged **[S#]**. Evidence tiers: [T0]=our engine source read directly; claims about runtime behavior of third-party code marked [T4].
|
||||
|
||||
## Stage A — the HOST marshal (game/glass/btl4console.cpp)
|
||||
|
||||
**Arming.** btl4main.cpp:1322-1330 — if `BT_FE_EGG` is set, `BTLocalConsole_Start(fe_egg, BT_FE_PODS, atoi(BT_FE_SECS))` runs. For Steam hosting the menu sets `BT_STEAM_NET=1`, `BT_FE_MYFAKE`, `BT_FE_STEAMMAP` and *falls into the marshal-armed default* (btl4main.cpp:1284-1296). `BTLocalConsole_Start` (btl4console.cpp:588-606) copies the args into `marshalState` and `CreateThread(MarshalThread)`; the handle is closed immediately (:604) — **the marshal thread is never joined by anything**.
|
||||
|
||||
**MarshalThread** (btl4console.cpp:434-586), step by step:
|
||||
1. Read the egg (:440-446). Failure → `MarshalLog("cannot read egg...")` and the thread just returns — the pods sit at WaitingForEgg forever **[S0]** (not tonight's shape, but a stall).
|
||||
2. Connect every pod in `BT_FE_PODS` order, **self first**, sequentially (:452-482): `ConnectWithRetry(host, port, 60)` (:388-432). The Steam branch calls `BTSteamNet_Connect` which **blocks up to 30 s per attempt** (L4STEAMNET.cpp:595-609) — worst case 60×~31 s ≈ 31 min per pod **[S1]**. Any pod that never answers → `BTFE_RelaunchSelfAndExit("")` — the whole host process aborts the start (:473-476).
|
||||
3. Egg → every pod (:492-497) via `SendEggChunks` (:334-363). The Winsock branch uses **blocking** `send()` — the marshal's raw sockets are never set FIONBIO (only TCP_NODELAY, :423-425) **[S2]**. Errors surface only as a `-1` chunk count in the log; no retry.
|
||||
4. `Sleep(20s)`; RunMission #1 to all; `Sleep(4s)`; RunMission #2 to all (:500-512). Send results **ignored**.
|
||||
5. **The mission clock** (:519-570): `mission_end = GetTickCount() + missionSeconds*1000`; loop drains and discards pod→console traffic (select() for real sockets, a nonblocking `BTSteamNet_Recv` pass for Steam pseudo-sockets :559-569). Note: a Steam connection that dies mid-mission is **invisible** here — `closedByPeer` makes Recv return 0/err and the loop just moves on; nothing logs or repairs it.
|
||||
6. **StopMission at expiry** (:572-576):
|
||||
```
|
||||
MarshalLog("mission clock expired -- StopMission to all pods");
|
||||
for (int p = 0; p < pod_count; ++p)
|
||||
SendApplicationMessage(pods[p], StopMissionMessageID);
|
||||
```
|
||||
`SendApplicationMessage` (:369-382) writes one 28-byte packet (header clientID=4/ApplicationClientID, msg id 6, ReliableFlag). **One shot per pod. No retry. No acknowledge. The return value is ignored. There is no per-pod success/failure log** — only the single "clock expired" line **[S3 — the silent-drop site]**. Over the Steam seam, `BTSteamNet_Send` (L4STEAMNET.cpp:424-442) returns SOCKET_ERROR if the connection is `closedByPeer` (WSAECONNRESET) or if `SendMessageToConnection` fails (WSAEWOULDBLOCK) — all swallowed. Over raw TCP the blocking `send` could also wedge the loop between pods so *later pods in podList never get their stop* **[S4]** (needs a full send buffer; unlikely for 28 bytes).
|
||||
7. `Sleep(MissionEndGraceSeconds=8s)` (:577), `MarshalClose` every pod (:578-581), `BTFE_RelaunchSelfAndExit("")` (:583-584) → Stage D. During the 8 s grace the marshal **no longer drains** pod traffic (pods push an immediate console score update in EndingMission — btplayer.cpp:1361-1366).
|
||||
8. **Host-side race** **[S5]**: the host's *own* pod (self, first in podList) receives StopMission over its loop link, finishes its fade in ~3-4 s, and the MAIN thread reaches the BT_FE_LOOP relaunch (btl4main.cpp:1669-1674) → `ExitProcess` — *while the marshal thread is still inside its 8 s grace sleep*. Whichever thread reaches `ExitProcess(0)` first kills the other mid-flight; if the main thread is slow (long Shutdown) both can pass `CreateProcessW` → two menu children. Nothing serializes the two relaunch paths.
|
||||
|
||||
## Stage B — the receiving pod: StopMission → RunMissions returns
|
||||
|
||||
**Delivery.** The 28-byte packet is routed by `networkManager->RoutePacket()`, pumped at least once per frame from `Application::ExecuteBackgroundTask` (APP.cpp:771-781; in non-Running states it routes greedily). Steam-carried console bytes are drained lazily by the seam's recv (L4STEAMNET pump is called from the seam entries).
|
||||
|
||||
**Handler chain**: `BTL4Application::StopMissionMessageHandler` (game/reconstructed/btl4app.cpp:765-781, @004d3a94 — plasma display off) → `L4Application::` (L4APP.cpp:693-732 — `PilotIllumination(True)` + a LightsOut self-post at +30 s) → `Application::` (APP.cpp:1706-1778):
|
||||
- `gBTMissionStoppedByConsole = 1` (:1723-1726); if state==RunningMission: `gBTRoundCompleted=1`, launch pends voided (:1733-1739).
|
||||
- switch (:1746-1777): `StoppingMission` → ignore; `EndingMission`/`AbortingMission` → `Stop()`; **default** → `SetState(EndingMission)`, `networkManager->Mode(ReliableMode)` (:1762), dispatch `Player::MissionEndingMessage` to `GetMissionPlayer()` (:1759-1769); **if the mission player is NULL → `Stop()` immediately** (:1771-1774).
|
||||
|
||||
**The fade.** `Player::MissionEndingMessageHandler` (engine/MUNGA/PLAYER.cpp:106-113): `fadeTimeRemaining = 3.0f; ForceUpdate(); SetSimulationState(MissionEndingState)`. **BTPlayer has NO override** — `MESSAGE_ENTRY(BTPlayer, MissionEnding)` (btplayer.cpp:355) resolves to the inherited Player handler; the "fade ForceUpdate site" named in the task brief is PLAYER.cpp:110.
|
||||
|
||||
**EVERY condition required to progress from EndingMission to `Stop()`** — the countdown lives ONLY in `Player::ManageApplicationStatus` (PLAYER.cpp:408-476; the MissionEndingState arm :444-472 counts `fadeTimeRemaining -= time_slice` and, at ≤0 with app state EndingMission, dispatches `StopMissionMessage(NullExitCodeID)` back at the application :451-456):
|
||||
1. A mission player must exist (else Stop() already ran — faster, not a stall).
|
||||
2. `ManageApplicationStatus` is reached ONLY from the player's active Performance: `Player::PlayerSimulation` (PLAYER.cpp:505, called from `BTPlayer::PlayerSimulation` btplayer.cpp:1329) or `CameraShipSimulation` (PLAYER.cpp:486). **If the mission player's Performance is `HuntForDropZone` (PLAYER.cpp:768 — no MAS call) or `DoNothingOnce` (set at PLAYER.cpp:380 once a dropzone answered the initial hunt; `DoNothingOnce` = `NeverExecute()`, SIMULATE.cpp:484-488 — the entity STOPS EXECUTING ENTIRELY), the fade NEVER counts down and the app sits in EndingMission forever** **[S6 — hard in-engine stall]**. This window exists from mission start until `DropZoneReply` creates the vehicle and sets PlayerSimulation + `AlwaysExecute()` (btplayer.cpp:1771/1780/1784). After that, the reset-based respawn keeps the same Performance through death, so a dead-at-mission-end pilot still fades normally.
|
||||
3. Fade 3 s of simulated time must elapse; then the re-dispatched StopMission lands in the `EndingMission` case → `Stop()` (APP.cpp:1751-1754) → `executeFrames = False` + `SetState(StoppingMission)` (APP.cpp:818-820).
|
||||
- There are **no queue-drain gates, no all-entities gates, no peer gates, and no other timers** in this leg.
|
||||
|
||||
**Backstop worth knowing**: the solo-clock shim (APP.cpp:674-695) is **ACTIVE in Steam matches** — its only gate is `getenv("BT_RELAY") == 0`, and glass/steam mode does not set BT_RELAY; the glass egg writes `length=` (btl4fe.cpp:277) → `Mission::gameLength > 0` (MISSION.cpp:336-337). So a pod that never receives the marshal's StopMission still self-posts one when its own clock (started at RunningMission entry, APP.cpp:1559) expires. A *lost* StopMission alone therefore should not strand a pod in RunningMission [T0 code-read; runtime unverified].
|
||||
|
||||
**RunMissions returning** (engine/MUNGA/APPMGR.cpp:39-273): next frame `ExecuteForeground` returns False (APP.cpp:516-519) → APPMGR calls `application->Shutdown()` (APPMGR.cpp:77-91). `Application::Shutdown` (APP.cpp:828-927): gauge/video/audio `Shutdown()`, viewpoint delete, `interestManager->Shutdown()`, `hostManager->Shutdown()`, `networkManager->Shutdown()` — L4NetworkManager::Shutdown (L4NET.CPP:1296-1411) closes every non-console host via `CloseConnection` = `shutdown(SD_BOTH)` + `BTNetClose` (L4NET.CPP:4658-4659), all nonblocking (engine sockets are FIONBIO throughout, e.g. L4NET.CPP:4554-4558) — deletes the mission, and **returns False** (APP.cpp:925). App is moved to the ended list; the next loop pass finds no apps → `Terminate()` (APP.cpp:934+ — deletes renderers/managers; D3D/DirectSound teardown lives here **[S7, T4]**) → `delete application` → **RunMissions returns** (APPMGR.cpp:128-138). Alternate exit: WM_QUIT (window destroyed) → Terminate + return (APPMGR.cpp:102-115).
|
||||
|
||||
## Stage C — after RunMissions returns (game/btl4main.cpp:1618-1684)
|
||||
|
||||
- :1619 `"[boot] RunMissions returned (mission loop exited)."` — **the key forensic marker** separating a Stage-B stall from a Stage-C/D hang.
|
||||
- :1623-1626 `BTProjectilesClearAll()` — static pool scrub, memory-only.
|
||||
- :1632-1635 `BTRelayUploadMatchLog()` — **relay-only**: gated on `s_relayGameAddrCached` (L4NET.CPP:707-708), a **no-op on Steam nights**. When it does run: bounded 3 s nonblocking connect (L4NET.CPP:738-753), but then an **unbounded blocking SendAll** (L4NET.CPP:770) **[S8, relay only]**.
|
||||
- :1645-1660 BT_RELAY rejoin — not taken under Steam.
|
||||
- :1669-1674 `BT_FE_LOOP` set (it always is on menu-launched missions, btl4main.cpp:1248) → `BTFE_RelaunchSelfAndExit("")`.
|
||||
|
||||
## Stage D — the exit/relaunch choke point (btl4console.cpp:156-276)
|
||||
|
||||
1. `gBTUserRequestedExit` (WM_CLOSE stamp, btl4main.cpp:164-172) → `ExitProcess(0)` directly (:171-181).
|
||||
2. Storm damper: generation <15 s → `Sleep(5000)` (:193-202).
|
||||
3. Menu relaunch clears BT_FE_EGG/PODS/SECS/LOOP from the env (:211-217).
|
||||
4. `CreateProcessW` of the menu child (:259-274). **A CreateProcessW failure is completely silent — no log, no retry — and control still falls to ExitProcess: window closes, no menu appears** **[S9]**.
|
||||
5. `ExitProcess(0)` (:275) — **the exit-hang site [S10]**. ExitProcess terminates all other threads (the marshal, or the main thread if the marshal got here first) then runs DLL_PROCESS_DETACH serially under the loader lock. **`SteamAPI_Shutdown()` is never called anywhere in the tree** (grep hits only the SDK header, extern/steamworks_sdk_164/public/steam/steam_api.h:107) — so steam_api64/steamclient are detached with live SDR connections and their service threads hard-terminated; a detach-time deadlock there is the standing hypothesis for "BTL4 hang on exit, hosting" [T4 — inference, matches the host being the machine that hosts the most Steam connections]. D3D/driver DLL detach is the sibling candidate [T4].
|
||||
6. Because every glass-path exit is `ExitProcess`, **CRT atexit/static dtors are skipped** — the RIO dtor's bounded 5 s flush (L4RIO.cpp:1160-1184) and `PCSerialPacket::ShutdownRxThread`'s **unbounded** `WaitForSingleObject(hRxThread, INFINITE)` (L4PCSPAK.cpp:204) **[S11]** only run on the plain `return Exit_Code` path (btl4main.cpp:1683, taken only when BT_FE_LOOP is unset) — and only in serial-RIO builds; glass uses PadRIO which owns no thread (no CreateThread in L4PADRIO.cpp).
|
||||
|
||||
## Stage E — is there any all-pods handshake at mission end? NO.
|
||||
|
||||
- Start side has three gates: the egg acknowledge (`AcknowledgeEggFileMessage`, L4NET.H:276/424-433), the READY notify (APP.cpp:1466-1470), and the connection gate (L4NET.CPP:1957 comment). **The end has none**: no end-acknowledge message exists (grep across engine/), the marshal reads nothing back after StopMission, and pod teardown never waits on a peer.
|
||||
- `HostDisconnected` during EndingMission: GameMachineHostType arm just decrements the count and closes (L4NET.CPP:1672-1677) — no state change, no wait. Console loss mid-mission in glass/steam (non-relay, scene presented): the ConsoleHostType arm recreates the console listener and **continues the mission** (L4NET.CPP:1686-1753) — the relay-mode 15 s graceful self-stop (RelayGameDown, L4NET.CPP:2458-2472) does NOT apply outside BT_RELAY.
|
||||
|
||||
## Cross-cutting observations relevant to the incidents
|
||||
|
||||
- **Thread-safety hole [S12]**: `BTSteamNet_Pump` (SteamAPI_RunCallbacks + unlocked `connections[].ring` writes, L4STEAMNET.cpp:244-306) is invoked from BOTH the marshal thread (via BTSteamNet_Send/Recv/Connect — btl4console.cpp:119-124, 564) and the game thread (seam entries; L4STEAMNET.h:34 documents "game thread"). No lock anywhere. Concurrent RunCallbacks + ring index races are possible on the HOST only (the only process with a marshal) [T0 for the code paths; consequences T4].
|
||||
- **Frozen-view interpretation** [inference]: a stalled EndingMission does NOT freeze the picture — ExecuteForeground has no EndingMission early-out, the update manager keeps simulating and the renderers keep presenting (APP.cpp:560-565 only overlays pre-run states). A literally static final frame with no menu means the frame loop *stopped* while the window lived: i.e., RunMissions returned (or Terminate/Shutdown hung) and the process then wedged in Stage C/D — most plausibly at ExitProcess [S10] or silent CreateProcessW failure [S9]. Log triage for the frozen machines: `"[boot] RunMissions returned"` present → Stage C/D hang; absent but `StopMission`/plasma-off lines present → Stage B stall ([S6]/[S7]); no stop lines at all → the marshal's send was silently dropped ([S3]) and the solo-clock backstop should then have fired ~launch-skew seconds later — if it didn't, check `length=` in the session egg.
|
||||
|
||||
## Stall-site index
|
||||
| # | Site | File:line | Bound |
|
||||
|---|------|-----------|-------|
|
||||
| S0 | egg unreadable → marshal thread exits, pods stranded | btl4console.cpp:440-446 | forever |
|
||||
| S1 | ConnectWithRetry, Steam branch | btl4console.cpp:388-408 + L4STEAMNET.cpp:595-609 | ~31 min/pod |
|
||||
| S2 | blocking egg/TCP sends (marshal sockets never nonblocking) | btl4console.cpp:334-363, 409-431 | OS send timeout |
|
||||
| S3 | StopMission one-shot, errors swallowed, no per-pod log | btl4console.cpp:572-576, 369-382; L4STEAMNET.cpp:424-442 | n/a (silent drop) |
|
||||
| S4 | blocking StopMission send can starve later pods | btl4console.cpp:573-576 | TCP buffer dependent |
|
||||
| S5 | main-thread relaunch races marshal's 8 s grace; dual relaunch | btl4main.cpp:1669-1674 vs btl4console.cpp:577-584 | race |
|
||||
| S6 | fade counts only in PlayerSimulation; DoNothingOnce=NeverExecute strands EndingMission | PLAYER.cpp:380, 444-472; SIMULATE.cpp:484-488 | forever |
|
||||
| S7 | Terminate/Shutdown renderer teardown (D3D/DSound) | APP.cpp:838-865, 944-973 | unbounded [T4] |
|
||||
| S8 | matchlog upload blocking SendAll (relay only) | L4NET.CPP:770 | TCP dependent |
|
||||
| S9 | CreateProcessW failure silent, no menu | btl4console.cpp:259-274 | n/a |
|
||||
| S10 | ExitProcess w/ live SteamAPI (no SteamAPI_Shutdown anywhere), DLL detach | btl4console.cpp:275; steam_api.h:107 | unbounded [T4] |
|
||||
| S11 | PCSerialPacket join INFINITE (static-dtor path only, non-glass) | L4PCSPAK.cpp:204 | unbounded |
|
||||
| S12 | unsynchronized two-thread Steam pump on the host | L4STEAMNET.cpp:244-306; btl4console.cpp:119-124 | corruption risk |
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Task 4 — Steam console-link delivery of StopMission (ticket #163)
|
||||
|
||||
## 1. The exact delivery chain for StopMission under Steam
|
||||
|
||||
**Dial timing — at GO, once, never again.** The host's mission process arms the marshal thread at boot (`game/btl4main.cpp:1322-1329` → `BTLocalConsole_Start`, `game/glass/btl4console.cpp:588-606`). `MarshalThread` dials EVERY podList entry up front, serially, before anything else: `ConnectWithRetry(host, port, 60)` at `btl4console.cpp:470`. For a fake-IP entry the Steam branch (`btl4console.cpp:391-408`) calls `BTSteamNet_Connect` up to 60 times, 1 s apart; each attempt itself blocks up to 30 s in the SDR handshake wait (`engine/MUNGA_L4/L4STEAMNET.cpp:595-609`) — worst case ~31 min of near-silence before the abort at `btl4console.cpp:473-476` (which aborts the mission start for EVERYONE via relaunch). [T1 — read from our own source]
|
||||
|
||||
**Token resolution.** `BTSteamNet_Connect` maps the token IP to a SteamID64 via the `tokens[]` table (`L4STEAMNET.cpp:556-568`), loaded ONCE per process at `BTSteamNet_Install` from env `BT_FE_STEAMMAP` (`L4STEAMNET.cpp:354-384`); port 1501 selects P2P virtual channel 0 = console (`L4STEAMNET.cpp:569`, `TokenConsolePort` at `:67`). The connection is a `ConnectP2P` handle wrapped in a pseudo-SOCKET (`0x5EA0xxxx`, `L4STEAMNET.cpp:93-97`).
|
||||
|
||||
**Send path — RunMission and StopMission ride the SAME connection.** Egg chunks (`SendEggChunks`, `btl4console.cpp:334-363`), RunMission #1 (+20 s), RunMission #2 (+4 s), then the marshal holds the clock (`:519-570`) draining pod→console bytes, then StopMission at clock expiry (`:572-576`), 8 s grace, then `MarshalClose` all pods (`:577-581`) and relaunch (`:583-584`). There is **no re-dial, no ack, no health check anywhere** — StopMission depends on the connection dialed at GO still being alive ~10 min later. Wire message: 28-byte packet, clientID=4 (ApplicationClientID), messageID=6 (`StopMissionMessageID`, `btl4console.cpp:75`, per `APP.h:383`), sent via `BTSteamNet_Send` → `SendMessageToConnection(..., k_nSteamNetworkingSend_ReliableNoNagle)` (`L4STEAMNET.cpp:433-435`).
|
||||
|
||||
## 2. Every silent-failure point in that chain
|
||||
|
||||
1. **Send results are discarded.** `SendApplicationMessage` returns -1 on failure but the return is ignored at all three call sites (`btl4console.cpp:503, 509, 575`). A failed StopMission produces NO log line; the marshal logs only the aggregate "StopMission to all pods" (`:572`) BEFORE sending. [T1]
|
||||
2. **Send-on-dead-connection is an error only if the death was already noticed.** `BTSteamNet_Send` returns SOCKET_ERROR/WSAECONNRESET only when `closedByPeer` was set by the status callback (`L4STEAMNET.cpp:428-431`), and SOCKET_ERROR/WSAEWOULDBLOCK when `SendMessageToConnection` fails (`:436-440`) — both swallowed by (1). Worse, a reliable send on a dying-but-not-yet-flagged connection returns `k_EResultOK`, buffers the bytes, and they are dropped when the connection is later closed — **undetectable even if (1) were fixed**. [T4 — SDK semantics, not exercised in a bench]
|
||||
3. **The marshal never learns a console link died mid-match.** The clock-hold drain loop treats Steam recv()==0 (dead: `L4STEAMNET.cpp:459-462`) identically to -1/no-data (`while (BTSteamNet_Recv(...) > 0)`, `btl4console.cpp:564`); the real-socket path likewise ignores `recv`==0 (`:550`). No close callback reaches the marshal; the only trace is the engine-side `[steamnet] connection N closed (…)` line printed from the status callback during a pump (`L4STEAMNET.cpp:219-231`). No reconnect logic exists. [T1]
|
||||
4. **Mesh/Steam pods have NO console-loss StopMission fallback.** The 15 s self-stop on console loss is RELAY-ONLY (`RelayGameDown`, `engine/MUNGA_L4/L4NET.CPP:2456-2472`). In mesh/steam mode a `ConsoleHostType` disconnect just destroys and re-listens the console host (`L4NET.CPP:1686-1752`, `CreateConsoleHost` at `:885-1023`) — a pod that loses its console link mid-match plays a **forever-mission**: peers time out and freeze as stale replicants — literally "a frozen view of how the game ended" with no menu transition. [T1 code + T2 field, below]
|
||||
5. **Pump concurrency hazard (host only).** `BTSteamNet_Pump` mutates the global `connections[]`/rings and runs `SteamAPI_RunCallbacks` with zero locking (`L4STEAMNET.cpp:244-306`), and on the HOST it is called concurrently from the marshal thread (via Send/Recv/Connect, e.g. `btl4console.cpp:564`) and the main game thread (via the L4NET seam, `L4NET.CPP:48-96`). Only the host has this exposure. [T1 for the race's existence; T3 for consequences]
|
||||
6. **Accept is channel-blind.** `BTSteamNet_Accept` ignores which listener polled and dequeues either channel (`L4STEAMNET.cpp:476-501`); the console-host accept path trusts "whatever connects to a console host" (`L4NET.CPP:3876-3894`). Benign so far, but a game-channel dial can be adopted as the console. [T1, no field hits]
|
||||
7. **Second-round state:** the connection table is per-process (fresh each round — every round is a new process), and mission children get fresh `BT_FE_MYFAKE`/`BT_FE_STEAMMAP` at GO (`btl4main.cpp:1279-1287`). But a MENU relaunch clears only `BT_FE_EGG/PODS/SECS/LOOP` (`btl4console.cpp:211-217`) — menu children carry the PREVIOUS round's token map (visible in the field: relaunched menus log "N roster token(s) incl. self" at boot). The real cross-round asymmetry found is **zombie processes**: a round-N process wedged at exit stays Steam-online into round N+1 (see acaci's ghost lobby seat, below). [T1/T2]
|
||||
|
||||
**Receiving side (pod).** `CreateConsoleHost` listens on 1501 (`L4NET.CPP:962-993`) — under Steam the marshal's dial arrives via the P2P channel-0 listen socket, is accepted inside the status callback (`L4STEAMNET.cpp:189-203`), queued, and dequeued by the console host's `BTNetAccept` poll (`L4NET.CPP:3814-3821`); the console host goes OnLine (`:1593-1595`, logs "Connected to ConsoleHost at <token .1>:1501"). Marshal-side death is detected only by `BTNetRecv` returning 0 (or WSAECONNRESET→0) during `CheckBuffers` (`L4NET.CPP:3989-4019`). At mission end the pod does NOT close the console link — teardown explicitly keeps the console host (`L4NET.CPP:1367-1370`); the MARSHAL closes it 8 s after StopMission (`btl4console.cpp:577-581`), which the pod handles as a normal console disconnect + re-listen. So there is no close-while-the-other-side-needs-it on the pod side. [T1]
|
||||
|
||||
## 3. Field evidence (scratchpad/night15, six logs; frozen = santo/MS-FIREFLY, acaci/ZEUS; Lynx has NO log)
|
||||
|
||||
**StopMission WAS delivered to every logged pod in both incident rounds.** Every mission session on santo ends with the full clean tail `[boot] RunMissions returned` + `[fe] mission over -- relaunching the menu` (14/14, e.g. santo:57956-57957 for the round ending 21:40:50 — the 21:41 incident round — and santo:201693 for the round ending 22:47:21 — the 22:48 incident round). Michael (d:153225-153226) and Dave (a: tail before 218096) likewise ended the 22:47 round cleanly. **No `[steamnet]` close, no ConsoleHost disconnect, no PEER_DOWN anomaly at either incident round-end on any logged machine.** The freeze on the logged machines happened AFTER the last log line of a clean in-process mission end — i.e., in the relaunch/exit seam, NOT in Stop delivery. [T2]
|
||||
|
||||
**But genuine console-link death happened that night — twice — and its signature matches the reported symptom exactly.** Mid-round, all pods lost BOTH links to the host token (.1) with `[steamnet] connection N closed (Timeout; remote problem. Rx age server 12-21s ...)` followed by `Disconnected from ConsoleHost at 169.254.77.1:1501`: eleng f:22366-22372 (+ a second episode f:50374-50455) and the santo(e:35887-35922)/rajel(b:20340-20416) round (same round; rajel's clock is -3 h). After the loss the pod keeps simulating with no console and no fallback (finding 4): peers ghost out (`[ghost] replicant 5:29 has received NO update records for 601 frames`, f:~22434) and the mission never ends — each player eventually closed the window by hand (`[marshal] window closed by the user -- exiting for real`, e:36012, f:22510). The "Timeout; remote problem" reason means the HOST process went silent mid-round (crash/wedge, not graceful close) — the host is the machine with the marshal-thread pump race (finding 5). [T2; the race attribution is T3]
|
||||
|
||||
**The frozen machines' actual signatures:**
|
||||
- **santo ~21:41:** round ended cleanly 21:40:50 (e:57957); the menu child (pid 28112, e:57959-57966) booted fully, auto-joined the lobby within 1 s, and received GO normally at 21:43:10 (mission child pid 31404, `relaunched generation`, e:57967). The 2m20s hole between a clean end and the next GO, with a healthy-but-invisible menu, fits a wedged mission-parent window sitting frozen on top of the live menu (the foreground-handoff problem this code already documents, `btl4console.cpp:262-271`). [T2 logs; interpretation T3]
|
||||
- **acaci 22:48:** ZEUS's log goes SILENT at 22:12:38 — the post-round menu child (pid 29344, c:213897-213900) printed exactly 3 boot lines and never reached `[steamnet] up`, which for a relaunched child is unconditional ~1 s after boot (`BT_STEAM_NET` is inherited; boot-time install at `btl4main.cpp:1306-1316`). It hung at/before `SteamAPI_Init` (`L4STEAMNET.cpp:337`). acaci never played again (file mtime 23:18, nothing after 22:12:38) — **acaci was NOT in the 22:37-22:47 round at all**; yet acaci's SteamID (76561198064247614) still appears as token .2 in the 22:47 lobby map on santo/Michael (e:201702, d:153233) — a ghost lobby seat held by the wedged process. [T2; "hung at SteamAPI_Init" is T3 — the alternative, a user-kill inside the ~1 s boot window, is implausible given the same machine repeated it]
|
||||
- The same 3-line pre-SteamAPI_Init signature ends EVERY machine's log (Dave 22:47:22 — the exact incident round end, a:218096-218099; eleng 21:47:21; rajel 20:25:37; Michael 23:25:36; santo 23:25:37) and appears mid-night followed by ~1-2 min manual-restart gaps (Dave 20:31:52→20:32:40 a:31170; acaci 21:07:11→21:09:13 c:60457). The relaunch/exit seam wedges routinely; nobody calls `SteamAPI_Shutdown` anywhere (grep: SDK headers only), and every round-end exits via `ExitProcess` from `BTFE_RelaunchSelfAndExit` (`btl4console.cpp:275`) — on the HOST from the marshal thread while the main thread is mid-teardown (`btl4console.cpp:583-584`), matching Lynx's "hang on exit, hosting". [T2 signatures; ExitProcess/DLL-detach mechanism is T4]
|
||||
|
||||
## 4. Bottom line for #163
|
||||
|
||||
The StopMission delivery chain is a single unmonitored 10-minute-old connection per pod with at least four fully silent failure modes (send-result discarded; error mapped then discarded; reliable-buffer drop; dead-vs-empty indistinguishable in the drain loop) and no mesh-mode fallback on the pod — a real design hole, and the night's two host-death rounds show exactly what its failure looks like (orphaned forever-mission, frozen peers). **However, for the two ticketed incidents the field logs exonerate delivery:** every logged pod received Stop and ended its mission in-process; the freeze and the exit hang live in the round-end relaunch/exit seam (ExitProcess without Steam shutdown; menu children hanging pre-SteamAPI_Init; ghost lobby seats from wedged processes), on which the marshal's host-side thread-kill-at-clock-expiry piles extra risk for the host machine.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# #163 Field Forensics — night15 (2026-08-10, build 4.11.883)
|
||||
|
||||
## 0. Ground work: clock alignment and identity map (all T2 unless noted)
|
||||
|
||||
Wall clocks were aligned by matching identical `[lobby] join ... map [...]` strings and identical session-cadence across machines (per instructions, not by raw wall clock):
|
||||
|
||||
| Machine | User | Steam ID | Clock offset vs ET | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| MS-FIREFLY | santo | 76561198049064449 | **= ET** | own token, santo log line 15 |
|
||||
| SCREECH-PC | Dave | 76561198022091594 | = ET | sessions 21:30:18/21:40:54… match santo; hosted the 19:40 2-pod round (Dave 766) whose lobby shows .1=…022091594 (eleng line 9) |
|
||||
| XIAOLONG | Michael | 76561198020775163 | = ET | sessions 21:43:10/21:53:46 match santo; hosted the 23:15 round (Michael 209577) whose map has .1=…020775163 (santo 255102) |
|
||||
| DESKTOP-QR9VPJQ | Lynx | 76561198659597127 | = ET | matchlog B HDR (his machine) is exactly +1:00:00 vs ALIA's matchlog A of the same events |
|
||||
| ALIA | eleng | 76561198147980449 | **ET − 1:00:00** | glass sessions 20:30:17/20:43:10/…/21:36:48 = santo's 21:30:17/…/22:36:48 to the second |
|
||||
| ZEUS | acaci | 76561198064247614 | ET − 1:00:13 (≈ALIA−13s) | ZEUS 21:36:35→21:47:11 pairs with ALIA 21:36:48→21:47:21 |
|
||||
| GAMERSLAB | rajel | 76561197970523304 | **ET − 3:00:00** | rajel 18:40:50/18:43:10/18:53:46… = ALIA 20:40:50/20:43:10/20:53:47 exactly −2h (⇒ −3h vs ET, matching the "~3h behind" note) |
|
||||
| (no log) | ? | 76561197976435895, 76561197995508393 | — | extra players, day logs not collected |
|
||||
|
||||
**CRITICAL CORRECTION TO THE TICKET'S PREMISE [T1]:** `matchlog_20260810_213656_20476.txt` (ALIA, local 21:36) and `matchlog_20260810_223651_12120.txt` (DESKTOP-QR9VPJQ, local 22:36) are **the SAME mission** — the 22:36 ET round — recorded on two machines whose clocks differ by exactly 1h. Proof: identical MISSION run instant (A line 22: `MISSION … w=21:37:21.041 st=4 run` vs B line 22: `w=22:37:20.047`), identical terminal events (A line ~1319: `DEATH victim=2:20 … pos=-27.5,0.0,144.2` + final `SBMIRROR player=8:1 kills=4 deaths=2 wasKills=3 wasDeaths=2`; B lines 1816-1818: same death at `pos=-27.6,0.0,144.2`, same SBMIRROR). Matchlogs are written per-machine (each pod keeps one), confirmed by dev-bench pairs in `scratchpad/night15/mlbak/` (two matchlogs per bench round). There is **no matchlog for the 21:41-incident round** in the staged set. Lynx DID host the 22:36 round (host token .1=…659597127 in the round's map, ALIA line 172106) — and in fact hosted **every round from ET 21:08 through 22:47**, including BOTH incident rounds (santo maps at 36020, 46238, 57965, 68589, 101519, 124366, 156700 all show .1=…659597127).
|
||||
|
||||
## 1. The mission-end ladder as it appears in these logs
|
||||
|
||||
There are **no** 'EndingMission'/'StopMission'/state-name lines in this build's day logs. The observed healthy end shape (every clean round, every machine) is:
|
||||
|
||||
```
|
||||
[mission] solo game clock expired (600s|1200s) -- ending the mission
|
||||
[score] gauge read / ~RankAndScore: I think my score is N
|
||||
[lamp] 0xNN <- 0x0 (×41, cockpit lamps off)
|
||||
SVGA16::~SVGA16: pixel management statistics + 4 lines
|
||||
[glasswin] destroy entry #1 windows=0
|
||||
[glasswin] destroy entry #2 windows=0
|
||||
[boot] RunMissions returned (mission loop exited).
|
||||
[fe] mission over -- relaunching the menu <=== LAST LINE the process ever logs
|
||||
===== BT411 SESSION ... ===== (the relaunched MENU process, new pid)
|
||||
```
|
||||
(e.g. santo 57904→57959). ~50-60 lines, wall time ≤3s (the menu header lands the same/next second as the computed 600s expiry). After `[fe] mission over` the process calls `BTFE_RelaunchSelfAndExit("")` → `CreateProcessW` + `ExitProcess(0)` (game/btl4main.cpp:1671-1673, game/glass/btl4console.cpp:157-275) — **everything after the `[fe]` line is unlogged by construction**.
|
||||
|
||||
## 2. Incident A — ~21:41 ET, santo (MS-FIREFLY) frozen
|
||||
|
||||
**Round:** ET 21:30→21:40:50, Lynx-hosted, 6 players (santo 46238 map: Lynx.1, eleng.2, acaci.3, santo.4, Dave.5, rajel.6), 600s clock. Michael not in it.
|
||||
|
||||
**santo's end sequence is textbook-clean and on time [T1]:**
|
||||
- 57887-57903: normal in-mission ticks (santo idle: `speedDemand=0`, alive — targeting active; the last nearby deaths are replicants 7:4 at 54956 and 6:4 at 57832, NOT santo).
|
||||
- 57904 `[mission] solo game clock expired (600s)` → 57906 `~RankAndScore: I think my score is 2686` → lamps → 57948 `SVGA16::~SVGA16` → 57954-5 glasswin destroys → 57956 `RunMissions returned` → 57957 `[fe] mission over -- relaunching the menu` → **57959 new MENU session at 21:40:50** (same second as everyone else, below). The log never goes silent mid-ladder and never keeps ticking past the end. (No `[glassperf]` beats exist on this machine — the tag appears only in Dave's and Michael's logs.)
|
||||
- **Recovery:** the relaunched menu was alive immediately — 57963-57965 it joined the NEXT round's lobby (token .7); santo was playing again at **21:43:10 ET** (57967), ≈2m20s after the freeze report. No manual cold restart visible.
|
||||
|
||||
**Same-round healthy comparison (identical ladder shapes, same wall second):**
|
||||
| machine | EXPIRED line | menu-header line | menu local time (→ET) |
|
||||
|---|---|---|---|
|
||||
| santo (frozen) | 57904 | 57959 | 21:40:50 |
|
||||
| eleng | 73366 | 73424 | 20:40:50 (21:40:50) |
|
||||
| acaci | 11986 | 12045 | 20:40:37 (≈21:40:50) |
|
||||
| Dave | 75044 | 75126 | 21:40:54 |
|
||||
| rajel | 44983 | 45041 | 18:40:50 (21:40:50) |
|
||||
|
||||
**Conclusion [inference, flagged]:** the freeze left NO trace in santo's day log. Everything up to and including the menu relaunch executed on time; the only unlogged window is the old glass process's `ExitProcess(0)` path after `[fe]`. The "frozen view of how the game ended" is consistent with the OLD process hanging after spawning the menu — its last rendered frame stays on the fullscreen/plasma window on top while the new menu opens BEHIND (the build even logs `[marshal] foreground handoff denied -- the next window may open behind` for this Z-order hazard, e.g. santo 46239; emit site btl4console.cpp:267-271).
|
||||
|
||||
## 3. Incident B — ~22:48 ET, acaci (ZEUS) + Lynx (host) frozen
|
||||
|
||||
**Round:** ET 22:36:51→22:47:2x, Lynx-hosted, 8 players (map ALIA 172106: Lynx.1, acaci.2, …976435895.3, santo.4, eleng.5, Michael.6, Dave.7, rajel.8), GO at 22:37:20.0 (matchlog B line 22), 600s ⇒ nominal expiry 22:47:20 ET.
|
||||
|
||||
**acaci (ZEUS) end sequence [T1]** — clean, but with the night's most interesting receipt:
|
||||
- 159680 `[wreck] replicant 2:20 entered wreck state … at (-27.6352,144.223)` — **Lynx's mech dying at 22:47:15.6**, the same death both matchlogs end on.
|
||||
- **159890 `[steamnet] connection 1 closed (closed)` + 159892 `Disconnected from GameMachineHost at 169.254.77.1:1502` — the HOST (Lynx) dropped ZEUS's game link BEFORE ZEUS's own clock expired** (ZEUS's 600s clock ran ~3-10s later than the pack because its RunMission receipt timing; it was the last machine still in-mission).
|
||||
- 159894 `EXPIRED (600s)` → 159909/159911 second disconnect (`169.254.77.5:1502` = eleng, tearing down in parallel) → 159970 `RunMissions returned` → 159971 `[fe]` → **159973 menu session 21:47:11 local (≈22:47:24 ET)**.
|
||||
- **Recovery:** menu joined the 22:52-round lobby immediately (159979); acaci played the 22:52 ET round (159981, glass 21:52:09 local) — recovered ≤5 min; quit for the night cleanly at 23:14:36 ET (`lastrun_steam_acaci_ZEUS_8.txt` line 29: `[2026-08-10 22:14:36] clean exit: player quit from the menu`; his day log's final 3-line menu stub at 213897 is the systemic buffered-tail loss described in §6, not a wedge).
|
||||
|
||||
**Same-round healthy machines:** Michael 153171→153227 (menu 22:47:20), santo 201637→201695 (22:47:21), rajel 192406→192463 (19:47:21=22:47:21), eleng 216583→216639 (21:47:21=22:47:21), Dave 218029→218096 (22:47:22). All within 0-4s of nominal expiry, identical ladders. eleng then quit for the night at 22:47:48 ET (lastrun line 47) — unaffected.
|
||||
|
||||
**Lynx (host, no day log):** his own matchlog (B) stops at 1818 `SBMIRROR … w=22:47:15.805` — his own death (`PLAYER_DEAD … player=2:1 deaths=10` at 22:47:15.642, B line ~1813), **5.6s before expiry, respawn never recorded** — and contains no mission-end receipt. He reported "BTL4 hang on exit, hosting" + Task Manager; he is absent from both later lobbies (22:52 map santo 201701; 23:15 map santo 255102) — **never returned**. The pre-expiry link close seen on ZEUS (`(closed)`, i.e. orderly close, not timeout) proves Lynx's process DID start its teardown (its clock expired first); the hang happened later in his exit path [inference].
|
||||
|
||||
## 4. Did StopMission ever arrive? (Task 4)
|
||||
|
||||
**No — on any machine, at any round, all night [T1]:**
|
||||
- `grep StopMission` over all six day logs (≈90MB): **0 hits**.
|
||||
- Every mission end on every machine is the LOCAL fallback: `[mission] solo game clock expired (Ns) -- ending the mission` (14 on santo, 10 on eleng, 8 on acaci, etc.).
|
||||
- The marshal's only sends all night are launches: `[marshal] RunMission #1 sent to N pod(s)` / `RunMission #2 sent -- mission running; clock Ns` (Dave 766/899, 13401/13588, 16085/16289, 23146/23304, 32151/32319, 50684/50831; rajel 19145/19269; Michael 615/733, 209577/209723). No `[marshal] StopMission` line ever.
|
||||
- Consequence: rounds end as N independent local expiries skewed by RunMission-delivery jitter (0-13s spread observed), and the host tears its links down whenever ITS clock fires — peers see `Disconnected from GameMachineHost … :1502` pre-expiry when their clock is late (ZEUS 159890 in incident B; also Dave 118701 & 141778, Michael 13749, eleng 60403 & 84435 — all Lynx-hosted rounds).
|
||||
- Corroborating shape from earlier the same night: when a HOST vanished mid-round (Dave closed his hosting window at 20:21:27 and 21:06:28 ET), peers did NOT transition — they sat in-mission until SteamNetworkingSockets timed out (`Timeout; remote problem. Rx age server 11-21s`: rajel 20340-20416, santo 35887-35922, eleng 22366-22440 & 50374-50455) and each user had to close the window (`RunMissions returned` + `[marshal] window closed by the user`). There is no host-loss or console-driven end path under Steam in the field [T1].
|
||||
|
||||
## 5. Matchlog tails vs bench baseline (Task 3)
|
||||
|
||||
- Field matchlogs (both machines, incident-B round) end mid-combat with NO end receipt: A ends `SBMIRROR … w=21:47:16.821`, B ends `SBMIRROR … w=22:47:15.805`. Neither contains any `st=6`/stop/`PEER_DOWN` record; the only MISSION line each is `st=4 run`.
|
||||
- Dev-bench matchlogs (mlbak/, builds 874-885) normally end with **`PEER_DOWN t=… host=1 type=3`** (the console/relay link closing at teardown) — e.g. matchlog_20260811_005558_2240.txt last line, matchlog_20260810_102334_9548.txt last line.
|
||||
- So both field matchlogs lost their teardown tail. Since ALIA demonstrably transitioned cleanly, the missing tail is NOT diagnostic of the hang; the likely mechanism is the `ExitProcess(0)` relaunch path discarding the matchlog's unflushed stdio buffer (btl4console.cpp:275) [T3].
|
||||
|
||||
## 6. Systemic log-tail artifact (don't misread it)
|
||||
|
||||
Every collected day log ends with a fresh MENU session header + exactly 3 `[boot]` lines and nothing more (Dave 22:47:22, eleng 21:47:21, rajel 20:25:37, Michael 23:25:36, acaci 22:12:38, santo 23:25:37). The lastrun files prove these menus ran fine and exited cleanly minutes later (ZEUS quit 22:14:36 local; ALIA quit 21:47:48 local; santo's final sessions rotated into `steam_20260810.1.log` — not collected; Dave's post-22:47 activity, including hosting the 22:52 ET round per map token .1=…022091594, is likewise missing from the collected file). The `[boot]` lines carry explicit `std::flush` (btl4main.cpp:1605-1619); the later `[steamnet]`/`[lobby]` lines evidently don't survive the quit path's buffer loss. Truncated final menu stub ≠ wedge.
|
||||
|
||||
## 7. Healthy baseline round (Task 1 control): ET 22:22:36 → 22:33:11, Lynx-hosted, 8 players
|
||||
|
||||
| machine | glass session (local) | EXPIRED line | menu line | menu local (→ET) |
|
||||
|---|---|---|---|---|
|
||||
| santo | 22:22:36 (124368) | 156637 | 156694 | 22:33:11 |
|
||||
| Michael | 22:22:36 (74169) | 107656 | 107712 | 22:33:10 |
|
||||
| Dave | 22:22:37 (141879) | 173829 | 173890 | 22:33:11 |
|
||||
| eleng | 21:22:36 (140157) | 172042 | 172100 | 21:33:11 (22:33:11) |
|
||||
| acaci | 21:22:23 (83362) | 115098 | 115152 | 21:32:57 (≈22:33:10) |
|
||||
| rajel | 19:22:36 (113680) | 146649 | 146702 | 19:33:11 (22:33:11) |
|
||||
|
||||
Identical ~55-line ladders, all six menus stamped within 1s. The incident-round ladders of the frozen machines are line-for-line indistinguishable from this baseline.
|
||||
|
||||
## 8. Synthesis (clearly marked inference where noted)
|
||||
|
||||
1. [T1] The in-log mission-end ladder COMPLETED normally and on time on every frozen machine; the freeze lives entirely in the unlogged post-`[fe]` exit path (`BTFE_RelaunchSelfAndExit` → `ExitProcess(0)`, btl4console.cpp:275).
|
||||
2. [Inference] "Frozen view of the ended mission" = the old glass process hung inside `ExitProcess` (classic DLL-detach/terminated-thread deadlock territory: SNS threads, D3D, the plasma-window thread) with its last frame still on screen, while the already-spawned menu opened BEHIND it (`AllowSetForegroundWindow` hazard logged at btl4console.cpp:269). Task Manager kill of the OLD pid clears it — exactly Lynx's report.
|
||||
3. [T1] Both incidents happened in Lynx-hosted rounds; Lynx's own machine hung at exit in incident B while hosting (7 console links + relay + marshal to unwind). In incident B, Lynx's teardown began BEFORE the last peer's expiry (orderly `(closed)` on ZEUS 159890) and then hung.
|
||||
4. [T1] StopMission is not part of this build's field behavior — every end is the local solo clock; the marshal only launches. Host death mid-round strands every peer in-mission until manual window close (three occurrences 20:08-21:07 ET).
|
||||
5. [T2] Lynx died in-game 5.6s before the clock and his respawn never appears before the end; acaci and santo were alive at their freezes — a death-at-end race is NOT the common factor across the three frozen instances.
|
||||
6. Recovery: santo ≈2m20s (auto-relaunch worked; next round 21:43:10 ET); acaci ≈5 min (next round 22:52 ET, quit cleanly 23:14:36 ET); Lynx: killed via Task Manager, never rejoined.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Ticket #163 / #156 — TASK 3: The binary's end-of-mission truth
|
||||
|
||||
Scope: the 1995 `BTL4OPT.EXE` end ladder, read from `reference/decomp/all/part_*.c` + the T0 WinTesla MUNGA source the port compiles against, with constants byte-read from the image (`scratchpad/rdva.py`) and one raw disasm (`tools/disas2.py`). Everything is [T1] (decompiled + cross-checked against T0/raw bytes) unless tagged.
|
||||
|
||||
## 0. Headline answer
|
||||
|
||||
**The 1995 end sequence is EVENT → TIME → EVENT, and the only timed leg runs inside the mission player's own simulation tick.** Console `StopMission` (event) puts the app in `EndingMission` (state 6) and dispatches `MissionEnding` to the mission player; the player then counts a **3.0-second** `fadeTimeRemaining` down **in player-Performance time** (`ManageApplicationStatus` @0x42df8c); at ≤0 it self-dispatches a **second `StopMissionMessage`** (event) which, arriving at state 6, calls `Application::Stop()` @0x44e6c0 → `executeFrames=0` → the mission loop unwinds. **Nothing in the ladder waits on the visual fade** — the fog fade renderable (@0x45447c) is a passive video watcher. **The ladder CAN stall in exactly two binary-grounded ways:** (a) StopMission #1 never arrives — the mission runs forever (1995's console owned the clock; the pod never stops itself — `context/multiplayer.md:639-641`); (b) the mission player's Performance never executes while in `MissionEndingState` — then `fadeTimeRemaining` never decrements, StopMission #2 is never dispatched, and the app parks in `EndingMission` FOREVER. Once the countdown completes, StopMission #2 is a **direct synchronous `application->Dispatch`** (PLAYER.cpp:454-455; @0x42df8c line 740) — it cannot be lost in transit.
|
||||
|
||||
## 1. The Application state machine's EndingMission arm (binary addresses)
|
||||
|
||||
State enum (T0 `engine/MUNGA/APP.h:188-202`): Initializing=0, WaitingForEgg=1, LoadingMission=2, WaitingForLaunch=3, LaunchingMission=4, RunningMission=5, **EndingMission=6**, StoppingMission=7, Suspending=8, Resuming=9, Aborting=10, CreatingMission=11. App state read as `*(app+0x88)`; the `StateIndicator` sits at app+0x74 with current state at indicator+0x14, and `SetState` = `FUN_0041bbd8` @0x41bbd8 (part_002.c:5510) — which also **pings three watcher chains** (indicator+0x18/+0x2c/+0x40, vtbl+0x14 notify) — this is how the fade renderable learns of state flips.
|
||||
|
||||
Binary Application TU (part_007.c:3090-3810):
|
||||
- **`FUN_0044eeb4` @0x44eeb4 = `Application::StopMissionMessageHandler`** (part_007.c:3698-3737): state 7 → ignore; state 6 or 10 → `Stop()` (@0x44e6c0) — this is the SECOND arrival; default → `SetState(6)`, and if `missionPlayer(app+0x14)` non-null: `networkManager->Mode(0=ReliableMode)` (vtbl+0x30 on app+0x20, part_007.c:3721 == T0 APP.cpp:1762) then dispatch **`Player::MissionEndingMessage {size 0x1c, id 0x19}`** to the player; if NO mission player → `Stop()` directly. Matches T0 APP.cpp:1705-1778 exactly.
|
||||
- **`FUN_0044ef4c` @0x44ef4c = AbortMissionMessageHandler** — same shape, `SetState(10)`, no ReliableMode switch.
|
||||
- **`FUN_0044e6c0` @0x44e6c0 = `Application::Stop()`**: `executeFrames(app+0x70)=0`; flush DEBUG stream; `SetState(7)`. (T0 APP.cpp:797-821.)
|
||||
- **`FUN_0044e488` @0x44e488 = `Application::ExecuteForeground`** (part_007.c:3138-3176): returns 0 immediately when `executeFrames==0`; at state 5 recomputes `secondsRemainingInGame(app+0x58) = mission->length(+0xe0) − (Now−gameStarted(+0x5c))/tps`; final return is `executeFrames && !Exit_Code` where **Exit_Code = `DAT_004efc98`**. **There is NO pod-side action when the clock hits 0** — app+0x58 is display-only; the stop was the console's job.
|
||||
- **`FUN_0044e6e8` @0x44e6e8 = base `Application::Shutdown`** (part_007.c:3239-3278): gauge/video/audio renderer `Shutdown+UnlinkFromEntity` (+0x4c/+0x48/+0x44), delete viewpointEntity (+0x6c), interestManager/hostManager/networkManager shutdown, delete `currentMission(+0xC8)`, then **`executeFrames=1; SetState(1=WaitingForEgg); return 0`**.
|
||||
- Other rungs for contrast: CheckLoad `@0x44ebec` (2→3, and if **no console host** (`FUN_00429078(app+0x2c)`) self-launches RunMission — the console-less path), RunMission `@0x44ecdc` (3→4 + dispatch **MissionStarting id 0x18** to player; 4→5 + `gameStarted=Now()`; else Fail @APP.CPP:0x609 — the port's launch/load-race crash site), Suspend `@0x44ed88` (5→8 + dispatch id 0x19 — suspend ALSO rides the player MissionEnding fade), Resume `@0x44ee10`, KeyCommand `@0x44efd8` ('&'=0x26 → `Exit_Code=1` + `Stop()`).
|
||||
|
||||
**There is NO app-side timer in the end arm.** The only end-sequence time constant anywhere is the player's 3.0 s fade (plus the fade renderable's cosmetic 1.0 s, §3). No mission-review display period exists at the app level; the ranking window is a separate per-player display driver (§below).
|
||||
|
||||
**Ranking-window behavior at end** — `FUN_0042eb38` @0x42eb38 (part_004.c:1387-1426, called from PlayerSimulation/CameraShipSimulation): at states **6/7 the window flag (player+0x224) is forced 0 (hidden)**; while `secondsRemainingInGame > 30.0` (`_DAT_0042ec2c`=30.0, byte-read) it runs a periodic show/hide machine (+0x228/+0x22c/+0x230/+0x234); at ≤30 s it pins the flag to 1 (solid on). So the authentic end was: standings solid for the final 30 s, then StopMission → **ranking hidden + 1 s fade to black**. (The camera-seat DIRECTOR.cpp:107 additionally shows its ranking during EndingMission — different display, camera seat only.)
|
||||
|
||||
**Crucial executability fact [T0]:** `Entity::Execute` (ENTITY.cpp:556-558, quoted in `context/reconstruction-gotchas.md §29:920-934`) calls `PerformAndWatch` only in `RunningMission || EndingMission || IsPreRunnable()` — so the world (and the player's fade countdown) KEEPS TICKING during state 6, and stops at state 7. A screen showing a *frozen last frame* therefore means the app got PAST state 6 (Stop() ran, `executeFrames=0`, renderers no longer execute) but the process never finished Shutdown/relaunch — whereas a player *stuck in state 6* would see a live, still-simulating world that never returns to menu. (Relevance flag for #163 triage, [T4 inference]: post-`RunMissions` in the modern port comes the matchlog-upload dial + relaunch (`context/multiplayer.md:110`, `:541-545`) — a blocking network call there matches "frozen final frame + hang on exit".)
|
||||
|
||||
## 2. The Player handlers @0x4bfc20 / @0x4bfbe8
|
||||
|
||||
Base handlers (part_004.c:409-430):
|
||||
- **`FUN_0042d9c0` @0x42d9c0 = base `Player::MissionStartingMessageHandler`**: `fadeTimeRemaining(+0x1F4) = 3.0f`; `SetSimulationState(3=MissionStartingState)`.
|
||||
- **`FUN_0042d9e0` @0x42d9e0 = base `Player::MissionEndingMessageHandler`**: `fadeTimeRemaining = 3.0f`; **`ForceUpdate` (`+0x18 |= 1`)** — this ships the final player update record (score) under the just-switched reliable mode; `SetSimulationState(4=MissionEndingState)`. Neither touches the Performance pointer or `simulationFlags`.
|
||||
|
||||
**BT overrides** (part_013.c:18556-18588; raw disasm of @0x4bfc20 via `tools/disas2.py` this session):
|
||||
- **`FUN_004bfbe8` @0x4bfbe8 = BTPlayer::MissionStarting**: base @0x42d9c0, then `if (app->state==4 /*LaunchingMission*/ && !(this+0x29 & 0x40 /*NonScoringPlayerBit*/)) currentScore(+0x1c8) = 1000.0f` — the manual's "+1000 starting the game" seed (port: `game/reconstructed/btplayer.cpp:788-806`).
|
||||
- **`FUN_004bfc20` @0x4bfc20 = BTPlayer::MissionEnding** — full body:
|
||||
1. base @0x42d9e0 (fade 3.0 + ForceUpdate + state 4);
|
||||
2. **`this+0x288 −= Round(15.0f × DAT_0052140c + 0.5)`** — recovered from raw bytes @0x4bfc3b-0x4bfc52 (`fld 15.0f @0x4bfca4; fmul [0x52140c]; fadd 0.5f @0x4bfca8; call __ftol@0x4dcd94; sub [ebx+0x288],eax`): it **rewinds the 15-second console score-total clock by exactly one period**, so the next PlayerSimulation tick immediately pushes the FINAL total score to the console (see §5). The old export dropped the x87 operand (`FUN_004dcd94()` rendered argless — the §19 gotcha); now pinned.
|
||||
3. `if (app->state == 8 /*SuspendingMission*/ && playerVehicle(+0x1fc) IsDerivedFrom Mech (tag 0x50bdb4))` → `FUN_0049fb74(mech, &DAT_00524b38, 1)` (= `Mech::Reset`, per the KB respawn chain — `context/decomp-reference.md` "Respawn SIM path"; the argument blob `DAT_00524b38` semantics unverified [T4]) + `ForceUpdate`. So the operator SUSPEND path resets the vehicle; the normal state-6 path does NOT.
|
||||
- **No score finalization, no fade rendering, no waiting happens in this handler** — it is fire-and-forget bookkeeping; the actual fade/countdown/exit all run elsewhere.
|
||||
|
||||
**The countdown** — **`FUN_0042df8c` @0x42df8c = `Player::ManageApplicationStatus`** (part_004.c:706-752; == T0 PLAYER.cpp:408-476): state 3: `fade −= dt`; at ≤0 (`_DAT_0042e0dc`=0.0) → state 2 (VehicleTranslocated) + dispatch RunMission (app state 4) / Resume (state 9). **State 4 (MissionEnding): `fade −= dt`; at ≤0 → app state 6 → dispatch `StopMissionMessage(exit 0)` (ctor @0x44f5d0); state 8 → SuspendMission; state 10 → AbortMission.** Called every tick from `Player::PlayerSimulation` @0x42e100 and `Player::CameraShipSimulation` @0x42e0e0 (part_004.c:756-781) — both flavors complete the ladder. (KB nit: `btplayer.cpp:1329` cites base PlayerSimulation as `FUN_0042e168`; @0x42e168 is only the vehicle-position-copy tail — the full base is @0x42e100.)
|
||||
|
||||
**When can the countdown not run?** `Simulation::DoNothingOnce` = `NeverExecute()` = `simulationFlags |= DontExecuteFlag` (SIMULATE.cpp:484-488, SIMULATE.h:167-206). In the 1995 binary the master player is parked on DoNothingOnce only **pre-first-spawn** (`Player::HuntForDropZone` @0x42ddcc sets the pmf `PTR_FUN_004e67fc` at part_004.c:669 once the drop zone is found, until DropZoneReply re-arms). The BT death cycle does NOT park it: **`FUN_004c05c4` @0x4c05c4 = BTPlayer::VehicleDead** (part_013.c:19064-19156) contains **no SetPerformance/simulationFlags writes** — a mid-mission-dead player still ticks PlayerSimulation, so a StopMission arriving during a death window still completes the fade in 1995. Its first line is the state-4 gate (`this+0x40==4 → return`): once MissionEnding, all death traffic is swallowed. Also in the -1 arm: deaths dual-increment (+0x280), console **MechKilled id 8** via ctor @0x4c18cc gated `suppressConsole(+0x258)==0`, objectiveMech(+0x284)=killer's vehicle, lives check `role(+0x208)->returnFromDeath(+0x28) < 1` → post Mech id 0x18 (ClearBurningState) to the wreck at +10 s and NO respawn re-post; else re-post VehicleDead to self at +5.0 s (`@0x4c0830`=5.0f, byte-read); then the straight-line death cost gated `advancedDamageOn(+0x264)` via direct call to base ScoreMessageHandler @0x42da20.
|
||||
|
||||
## 3. THE FADE (ticket #156)
|
||||
|
||||
**Mechanism: a fog-color/fog-range animation on the main view — `POVStartEndRenderable`** (T0 `engine/MUNGA_L4/L4VIDRND.cpp:2126-2394`, binary ctor **`FUN_00454394` @0x454394**, Execute **`FUN_0045447c` @0x45447c**, part_007.c:8468-8606). Not a palette fade, not a gauge overlay, not the warp machinery.
|
||||
|
||||
- **Built for the LOCAL (master) player only** by the video builder **`FUN_004d0774` @0x4d0774** (part_014.c:10673-10693): classID 0xBDA (BTPlayer): replicant (`(entity+0x28 & 0xc)==4`) → translocation-warp renderable @0x458d2c; **master → alloc 0x50 → `FUN_00454394(entity, Watcher=2, mainView, mainZone, deathZone, trigger, fogRGB, fogNear/Far, 3, 4)`**. The trigger = the player's **SimulationState attribute** (`FUN_0041bf44(entity,1)`), i.e. the same `StateIndicator` the MissionStarting/Ending handlers write; start state **3** = MissionStartingState, end state **4** = MissionEndingState.
|
||||
- **State machine** (@0x45447c; myState at +0x1c, timer +0x3c, trigger state read at `trigger+0x14`): 0 WaitForStart → on trigger==3: fog snapped to WHITE (1,1,1) with near/far 0.01/0.05 (screen fully fogged = white flash), AddDynamicRenderable; 1 FlashScreen: hold **0.1 s** (`@0x454740`); 2 FadeIn: lerp fog color/ranges back over **1.0 s** (`@0x454744`); 3 MissionRunning: wait for trigger==4 → 4 FadeOut: fog color × percent-left → black, ranges collapse to 0.01/0.05 (`@0x45474c/0x454750`), over **1.0 s in the binary** — note the WinTesla source's `FADE_OUT_TIME (0.5f)` (L4VIDRND.cpp:2150) is a later edit; the shipped 1995 constant is 1.0 (the same `@0x454744` is reused as the fade-out duration at part_007.c:8584); at expiry → back to state 0 + RemoveDynamicRenderable.
|
||||
- **What advances it:** the renderer's frame clock (`GetCurrentFrameTime`), NOT the player sim — once state 4 is set it completes even if the player sim is dormant. **What would happen if it never completed: nothing.** The ladder never reads the renderable; `ManageApplicationStatus` is the sole gatekeeper. The fade-out (1.0 s) simply fits inside the 3.0 s window; the pod then sat on a black screen ~2 s before teardown.
|
||||
- **Why the port has no fade (#156 root cause):** the builder call exists (`game/reconstructed/btl4vid.cpp:311-315`, states 3/4 passed), but **`BTPOVStartEndRenderable`'s ctor is an empty stub — `game/reconstructed/btstubs.cpp:339-342`** — it never registers with the video renderer or the state dial. The white mission-start flash is missing for the same reason. All the plumbing it needs (SetState watcher ping @0x41bbd8, fog style/limits on the D3D renderer) already exists in the port.
|
||||
|
||||
## 4. RunMissions exit + what the 1995 pod did next
|
||||
|
||||
- **Exit condition** (T0 `engine/MUNGA/APPMGR.cpp:39-161`): `RunMissions` loops foreground/packet-route/background passes; when `application->ExecuteForeground(...)` returns False (binary: `executeFrames==0 || Exit_Code(DAT_004efc98)` — @0x44e488) it calls `application->Shutdown(n)`; **Shutdown returning False removes the app from `runningApplications`, and with no apps left RunMissions Terminates them and RETURNS.**
|
||||
- **1995: the pod did NOT exit** — the L4 (pod) application overrides Shutdown: **`FUN_0047c560` @0x47c560** (part_010.c:2805-2820) = base Shutdown @0x44e6e8 (which re-arms `executeFrames=1` + `SetState(WaitingForEgg)`) then **`return (Exit_Code==0 && app+0xd4==0) ? 1 : 0`** — i.e. the app STAYS in the manager and the pod parks in WaitingForEgg for the console's next egg (the between-rounds attract wait). RunMissions only returned on the '&' keystroke (Exit_Code, @0x44efd8) or the `app+0xd4` shutdown latch (set by the two-phase handler **@0x47c2c4** — first receipt latches +0xd4 and re-posts, second receipt tears down; a console-initiated pod shutdown [T3 on its exact message id]).
|
||||
- The modern build diverges deliberately: T0 APP.cpp:922-926 replaced the original `return !Exit_Code` with a hard `return False` (`#if 0` preserved in-source), and `engine/MUNGA_L4/L4APP.cpp` has **no** Shutdown override — so every mission end exits RunMissions, then btl4main uploads the matchlog and BT_FE_LOOP relaunches the menu (`context/multiplayer.md:110,541-545`).
|
||||
|
||||
## 5. The end-of-mission pod↔console handshake
|
||||
|
||||
All pod→console, fire-and-forget over the console link (clientID 5 = ConsoleClientID); **the ladder never waits for any console reply**. Names from the surviving original `game/original/BT/BTCNSL.CPP` (cnslmsgs.cpp) + T0 `engine/MUNGA/CONSOLE.h:11-18`:
|
||||
|
||||
| When | Message {size, id} | Binary ctor / site |
|
||||
|---|---|---|
|
||||
| Every 10 s AND immediately once app state == **6** | score **DELTA** flush: self-dispatch ScoreUpdate {0x20, 0x1a} → the +0x278 delta cell is sent then **zeroed** | **`FUN_004c083c` @0x4c083c** (part_013.c:19160-19192): `10.0(@0x4c08fc) <= (this+0x10 − this+0x28c)/tps \|\| app+0x88==6`; port analog `btplayer.cpp:1361-1427` |
|
||||
| Every **15 s** while state 5 or 6; forced immediately at MissionEnding by the @0x4bfc20 clock rewind | **ConsolePlayerMechScoreUpdate** (running TOTAL, from +0x1c8) {0x14, id 9} | ctor **@0x4c18f4**; send site part_013.c:19269-19279 (`_DAT_004c0bc0`=15.0, clock at +0x288) |
|
||||
| Once, when `fadeTimeRemaining ≤ 1.5` in MissionEndingState (i.e. **halfway through the 3 s fade**, while the link is guaranteed still up), one-shot latch +0x254 | state 6: **ConsoleApplicationEndMissionMessage {0x14, id 7} {host, Round(score)}** (added 10/05/95 per BTCNSL.CPP:10); otherwise (abort/suspend): **ConsoleApplicationAbortMissionMessage {0x10, id 0xb}** | **BTPlayer::PlayerSimulation `FUN_004c0904` @0x4c0904**, block part_013.c:19251-19268, Tell string `"Sending EndMission..."` @0x5132d7; ctors @0x42998c / @0x4299b4 |
|
||||
| On death (any time) | **ConsolePlayerMechKilled** {0x14, id 8} {victim, killer} | ctor @0x4c18cc, sent from VehicleDead @0x4c05c4 (part_013.c:19110-19114), suppressed after eject via +0x258 |
|
||||
| On eject | DeathWithoutHonor (id 5) | `FUN_004c198c` (KB: decomp-reference EJECT cluster) |
|
||||
| (team variants) | TeamScoreUpdate {0x14, 0xd} @0x4c191c; TeamEndMission {0x1c, 0xe} @0x4299d8 | |
|
||||
|
||||
Plus two non-console end actions: **`networkManager->Mode(ReliableMode)`** at StopMission #1 (@0x44eeb4/T0 APP.cpp:1762) and the base MissionEnding **ForceUpdate** shipping the final player record to peers. Also note part_013.c:19280-19282: while state 5 **or 6**, the player record is re-dirtied (`+0x18 |= 1`) whenever none has shipped for 1.0 s (`_DAT_004c0bc4`) — a 1995 1 Hz player-record keepalive that keeps running through the ending window.
|
||||
|
||||
## 6. Stall matrix (the ticket's question, binary-grounded)
|
||||
|
||||
| Leg | Driven by | Can it stall? |
|
||||
|---|---|---|
|
||||
| StopMission #1 → state 6 | console event over the console link | YES — if it never arrives the mission runs forever (1995 design; modern relay adds the +15 s RelayGameDown fallback, multiplayer.md:519-521). World stays LIVE (not frozen). |
|
||||
| state 6 → StopMission #2 | **3.0 s of MISSION-PLAYER SIM TIME** (@0x42df8c) | YES — iff the mission player's Performance stops executing while in MissionEndingState (DontExecuteFlag latched, or its Performance never calls ManageApplicationStatus). In 1995 the only such window is pre-first-spawn (HuntForDropZone→DoNothingOnce, part_004.c:669). Result: app parks in state 6 forever — world keeps ticking (ENTITY.cpp:556-558), ranking window hidden (@0x42eb38), never returns to menu. |
|
||||
| StopMission #2 → Stop() | direct synchronous Dispatch | NO (cannot be lost). |
|
||||
| Stop() → RunMissions return | next foreground pass: ExecuteForeground→0, then Shutdown (synchronous renderer/network/mission teardown @0x44e6e8) | Only if a Shutdown callee BLOCKS (e.g. a network shutdown on a dead transport). From Stop() onward the screen is a FROZEN LAST FRAME (renderers no longer execute) — the shape #163 describes. |
|
||||
| visual fade | renderer frame clock, passive watcher | irrelevant — nothing waits on it; in the port it's a stub anyway (btstubs.cpp:339-342). |
|
||||
@@ -0,0 +1,135 @@
|
||||
=============================================================================
|
||||
BT411 -- ALL OPEN TRACKER ISSUES (59), SORTED FOR PLAYTESTERS
|
||||
Updated 2026-08-10 (evening) -- after Oracle's full annotated sweep, which
|
||||
closed 35 issues. Thank you Oracle: that is what this list is for.
|
||||
=============================================================================
|
||||
HOW TO REPORT: drop a line in Discord with the issue number and PASS /
|
||||
FAIL / what you saw. Screenshots and rough times help; logs get collected
|
||||
as usual.
|
||||
=============================================================================
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
A. FIXED -- PLEASE VERIFY (headliners first: new fixes fielded THIS build)
|
||||
-----------------------------------------------------------------------------
|
||||
#149 Glass cockpit stutter / hitching ** HEADLINER **
|
||||
First fielded build with the fix. Glass users -- especially Oracle:
|
||||
mission load and play should feel smooth again (repaints now skip
|
||||
windows whose gauges did not change).
|
||||
#150 Double kill credit (one kill counted twice) ** HEADLINER **
|
||||
First fielded build with the fix (868's double-kills were pre-fix).
|
||||
TEST: kill someone with a missile volley -- scoreboard +1, not +2.
|
||||
#69 "Sunder V1" renamed to DENKOU
|
||||
TEST: vehicle picker shows Denkou -- era witnesses, confirm spelling.
|
||||
#71 Closing the Plasma window lost it until restart
|
||||
TEST: X the plasma window -- it minimizes to the taskbar; click restores.
|
||||
#25 Gamepad turn profile (stick X could not turn the mech)
|
||||
TEST: RajelAran -- bind an axis to "Turn" in content\bindings.txt.
|
||||
#27 Autocannon/Gauss dealt the wrong damage type
|
||||
Oracle asked for a RETEST before closing: ballistics should feel/score
|
||||
like solid shot (no blast splash).
|
||||
#38 Mech colors reverted on respawn
|
||||
TEST: custom paint, die, respawn -- colors stay.
|
||||
#39 Controls were live before the game screen appeared
|
||||
TEST: mash fire during load -- nothing happens until the scene shows.
|
||||
#41 (David/Auric) launcher killed before it could run -- forensics armed
|
||||
TEST: if join.bat ever fails again, send content\lastrun_join.txt.
|
||||
#56 MFD buttons drift away from where they are drawn
|
||||
TEST: alt-tab / focus changes mid-round -- buttons stay aligned.
|
||||
#147 Range caret vanished for the session (ticks stayed)
|
||||
Monitoring: if it happens, the log traps the culprit -- just report it.
|
||||
#158 (Sauron) could not move after respawn until throttle cycled
|
||||
TEST when it happens: does cycling the throttle free you?
|
||||
YES -> the #146 release mechanic (expected). NO -> map + spawn spot.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
B. OPEN -- HELP US REPRODUCE / NARROW (what to note when it happens)
|
||||
-----------------------------------------------------------------------------
|
||||
#24 Controller hat/look view sticks (recovers after wiggling)
|
||||
#37 Mech torso appears BACKWARDS to other players (transient)
|
||||
#74 8BitDo gamepad works solo, dead in Steam MP (VexUbiquity)
|
||||
#102 Owens: full-throttle run -> hold reverse = animation/audio hang
|
||||
REPRO known (exterior cam, savannah); self-clears on a torso twist.
|
||||
#125 Peers do not see the FIRST destroyed-zone graphic (2nd hit shows it)
|
||||
#126 Peer weapon fire invisible AND silent from some positions
|
||||
#128 Ramming terrain = INSTANT coolant leak -- Oracle re-confirms STILL LIVE
|
||||
#130 Vulture "skating" (gliding without walk animation)
|
||||
#132 (Conn Man) persistent yellow line on the HUD in every mech
|
||||
#135 Coolant leak with NO flashing annunciators -- Oracle re-confirms LIVE
|
||||
#136 A SHUT leaking loop starts/stops leaking with COMPONENT toggles
|
||||
(Oracle marked "Clarify" -- one more description pass requested.)
|
||||
#143 One panel hit -> smoke erupts from MULTIPLE places at once
|
||||
#153 Basic-mode elevation limit only applies after cycling modes (fix pending)
|
||||
#154 Generator Out warning fires on the WRONG EDGE (at recovery, not trip)
|
||||
Refined by Oracle: the stuck-forever gen did NOT reproduce; era rule =
|
||||
gens return at HALF-cooled (red bar portion gone). Threshold audit due.
|
||||
#155 PANIC lit: Eng + Weapon MFD banks dead; Score/Radar/Coolant stay live
|
||||
(Oracle mapped the exact bank split; #50 closed into this ticket.
|
||||
Flicker half = authentic. Next: is the bank capture authentic too?)
|
||||
#159 (Conn Man) lag during drops -- missiles / fire exploding in empty space
|
||||
NOTE: shooter + target + rough time; match receipts measure the rest.
|
||||
#160 Pod cab: RIO axis dropouts + boot wedges (hardware investigation)
|
||||
Cab-side only. Bounded-write hardening is in this build; the cage
|
||||
hardware work continues with Nick.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
C. STILL-OPEN ERA QUESTIONS
|
||||
-----------------------------------------------------------------------------
|
||||
#103 Mech-to-mech RAM: armor damage or internals only? (Oracle: don't know;
|
||||
Lynx / Ronin / Draco -- your call. Binary does internals-only.)
|
||||
#133 Generator X-out on the MFD: Oracle recalls an X "under certain
|
||||
circumstances" -- WHICH circumstances? (shutdown / overheat / destroyed)
|
||||
ALSO (#152 rider): did Mid/Adv auto-recentre the torso in the pod, or only
|
||||
Basic? (Oracle: don't know. Binary says: no auto-recentre.)
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
D. DESIGN CALLS -- group opinions wanted
|
||||
-----------------------------------------------------------------------------
|
||||
#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored
|
||||
as suicide + death penalty). Keep authentic, or house-rule a flat cost?
|
||||
#23 Non-glass screen layout: Oracle proposes widescreen-aware sizing --
|
||||
world view wider + taller, moved up, allowed to overlap the radar.
|
||||
Opinions welcome before anyone prototypes it.
|
||||
#77 Resizable Secondary/Radar window (touch-panel cockpits). Want it?
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
E. KNOWN GAPS -- confirmed and on the list, no need to re-report
|
||||
-----------------------------------------------------------------------------
|
||||
#49 Radar/map does not draw the arena WALL geometry
|
||||
#76 Glass layout save/restore rework (some windows not saved)
|
||||
#83 INCOMING MISSILE alarm not implemented yet
|
||||
#90 Flames/smoke from serious damage: less than the original (tuning)
|
||||
#104 Mad Dog "NARC 15" label: NARC postdates our 4.10 binary (Oracle: added
|
||||
in a late 4.x release; zero NARC in our decomp) -- content rename due.
|
||||
#105 Sensor damage effects: Oracle's era spec captured (HUD flicker/loss,
|
||||
radar loss) -- implementation pending.
|
||||
#111 Peers see a dying mech's blue warp bubble (should see explosion only)
|
||||
#112 AFC rounds shotgun across zones (should land as one projectile)
|
||||
#113 LRM burn/hit state lasts too long
|
||||
#115 Hot box / target select not connected (Oracle era note: hotbox was a
|
||||
STANDARD-mode feature; absent in Expert -- gating to reconcile)
|
||||
#121 Sensor HEAT has no consequences yet
|
||||
#123 Searchlight: the visible LIGHT + night-fog swap still to come
|
||||
#145 IR "predator vision": decode done, display not implemented
|
||||
#156 Fade-to-black at mission end not happening
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
F. INTERNAL ENGINEERING BACKLOG (nothing to test)
|
||||
-----------------------------------------------------------------------------
|
||||
#14 Glass panel unreconstructed button handler backlog (census)
|
||||
#17 Cavern upper-butte halves lack a collision stream (authenticity TBD)
|
||||
#21 Weapon recharge arc dark after eng-page use (symptom trail -> #62)
|
||||
#28 Confirm the vital-subsystem-crit death path can fire (machinery in)
|
||||
#30 Weapon "unavailable" (level-7) display verification + accessor cleanup
|
||||
#63 PlayerStatus reads raw 1995 offsets (databinding trap, dormant)
|
||||
#64 damageZone shadow field (half-fixed; consequences disarmed)
|
||||
#65 HandleMessage non-virtual across 10 classes (latent dispatch trap)
|
||||
#72 BTLoadPump swallows WM_QUIT during load (latent)
|
||||
#107 Destroyed HeatSink annunciates nothing (no lamp for condition 0)
|
||||
#117 KB note: per-chassis cockpit bounce was deliberate (Ronin) -- FYI
|
||||
|
||||
=============================================================================
|
||||
End of list. 59 open issues total:
|
||||
12 verify (2 headliners), 17 reproduce, 3 era questions, 3 design calls,
|
||||
14 known gaps, 11 internal.
|
||||
35 issues closed 2026-08-10 on Oracle's annotated sweep.
|
||||
=============================================================================
|
||||
@@ -0,0 +1,204 @@
|
||||
# The terrain-ram coolant-leak audit -- full findings (2026-08-11, ticket #128)
|
||||
|
||||
> Four-agent audit of the collision->damage->leak chain. VERDICT: pricing,
|
||||
> gate, scaling, free floor and sub-hit math are all byte-faithful; the ONE
|
||||
> divergence is ELIGIBILITY -- the port read GUID 0x50e590 as the whole
|
||||
> HeatSink base family (making myomers/condensers/reservoirs rattle-eligible,
|
||||
> every one coolant-bearing -> instant leak per over-floor ram), while the
|
||||
> binary's node is the AGGREGATE BANK only: eligible set {HeatSinkBank 0.30,
|
||||
> Gyroscope 0.35, Torso 0.25}, 10% of rolls land nowhere, myomers' authored
|
||||
> 0.35 weight is DEAD DATA. Gyro/Torso cannot leak; a 1995 ram is gyro/torso
|
||||
> damage with at worst a bounded bank drip. Also established: the 0.5-pt free
|
||||
> floor eats ALL scrape taps (the per-frame [crashdmg] spam is log noise, not
|
||||
> damage -- now sampled), and the +0x268 advancedDamage gate is honored
|
||||
> faithfully (night-15's 'leaks' were combat-caused; the collision leak only
|
||||
> ever fired where advDmg=1 -- and #162's egg fix would have armed it for
|
||||
> every steam match, making this fix urgent). Fixed in mech.cpp
|
||||
> (IsDerivedFrom("HeatSinkBank")) same day; bench ram_bench.sh.
|
||||
|
||||
TICKET #128 — PORT AS-BUILT CHAIN (terrain ram/scrape -> instant coolant leak)
|
||||
|
||||
=========================================================================
|
||||
STAGE 1 — THE EMITTER (physical collision -> TakeDamage dispatch)
|
||||
=========================================================================
|
||||
The [crashdmg] print lives in Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8526. Full path:
|
||||
|
||||
1a. Per frame, the master mech (only movers with a collisionAssistant run the collision half — replicants bail at mech4.cpp:8427-8428) fetches contacts: GetCurrentCollisions() (mech4.cpp:8431), then Mover::ProcessCollisionList(cols, dt, old_position, &dmg) (mech4.cpp:8450-8452). `Damage dmg` is stack-constructed: Damage::Damage (engine/MUNGA/DAMAGE.cpp:35-45) sets damageAmount=0, burstCount=1; **damageType is NOT initialized by the ctor** — it is stamped Damage::CollisionDamageType(0) inside Mover::ProcessCollisionList at engine/MUNGA/MOVER.cpp:1228, and the accumulated `total_damage` across the frame's contacts is written at MOVER.cpp:1350.
|
||||
|
||||
1b. Per contact, the engine calls the virtual Mech::ProcessCollision (mech4.cpp:8754-8883, binary @004abb40). It restores frameEntryWorldVelocity first (mech4.cpp:8777 — PORT ADDITION, "COLLISION-DAMAGE ECONOMY GUARD": prevents multi-solid frames compounding StaticBounce reflections; commented as fixing 4x-40x over-pricing), runs the BoxedSolid resolver, then prices via Mover::StaticBounce (mech4.cpp:8796-8797 -> engine/MUNGA/MOVER.cpp:1432-1528).
|
||||
|
||||
1c. THE AMOUNT FORMULA (engine T0, MOVER.cpp:1527): `return -0.0005 * (vn*vp) * moverMass`, where impact = worldLinearVelocity·normal; damage is 0 if impact > 0 (separating, MOVER.cpp:1465-1469). vn/vp are the reflected normal/parallel components scaled by (1+elasticity). Constants: 0.0005 (MOVER.cpp:1527); minimumBounceSpeed only ZEROES elasticity when -impact <= bounce_min*dt (MOVER.cpp:1470-1473) — **there is NO minimum-speed gate on the damage amount itself; any closing contact prices > 0**. Measured economy (context/combat-damage.md:413-415): raw ≈ 600·v² for a head-on mech (59221 @ |v|=9.94; moverMass is tonnage-scale 60000-90000).
|
||||
|
||||
1d. OWNER CLASSIFICATION (mech4.cpp:8815-8866): a Mover owner -> mech-vs-mech dispatch via BTDispatchCollisionDamage, edge-gated by ramLastVictim/ramContactLinger 0.35s (mech4.cpp:8837-8846 — PORT ADDITION, explicit one-bump-one-hit; the binary's edge was implicit in its bounce). A CulturalIcon owner -> crunch dispatch + 0.00123f walk-through sentinel. **Plain UnscalableTerrain owners (ground/hills/cliffs/canyon walls) match NEITHER branch — their StaticBounce damage stands with NO edge gate whatsoever** (comment mech4.cpp:8656-8658).
|
||||
|
||||
1e. THE TERRAIN CRASH RESPONSE (mech4.cpp:8485-8537): if dmg.damageAmount > 0.0f, the frame is fully rejected (velocity zeroed 8487, position restored 8488), [crashdmg] prints (8526-8531, always-on), and an Entity::TakeDamageMessage{inflictor=SELF, zone=-1, dmg VERBATIM} is dispatched at OUR OWN mech (8532-8536, binary @4aa984-4aaab4). Stamps: damageType=0 (from MOVER.cpp:1228), burstCount=1 (Damage ctor), amount=raw StaticBounce kinetic price.
|
||||
|
||||
1f. FIRE RATE: per frame while scraping. The freshBlock hysteresis was deliberately REMOVED (pricing audit 2026-07-31; comment mech4.cpp:8560-8565). The only natural pacing is the knockdown: iv2 > 40 (i.e. |v| > ~6.3 u/s, const @0x4ab184, mech4.cpp:8540) binds the bmp clip whose cycleSpeed 0 collapses velocity. **Below iv2=40 there is no knockdown, so a sustained scrape with a closing normal component re-prices and re-dispatches EVERY FRAME at full drive speed** (the gait re-derives velocity each frame — context/combat-damage.md:427-431 documents exactly this hazard for the mech-vs-mech case, which got the ramContactLinger fix; terrain never did).
|
||||
|
||||
=========================================================================
|
||||
STAGE 2 — THE DISTRIBUTOR (Mech::DistributeCollisionDamage)
|
||||
=========================================================================
|
||||
Divert site: mech.cpp:1160-1164 — `if (message->damageData.damageType == 0) { DistributeCollisionDamage(&message->damageData); goto death_tail; }` (binary @0x4a0368; never reaches the zone/armor loop; jumps to the death tail, not return — mech.cpp:1149-1158).
|
||||
|
||||
Full body mech.cpp:980-1028 (binary FUN_0049ffcc):
|
||||
- GATE (mech.cpp:982-985): `player = GetPlayerLink()` (mech+0x190); return if null or !BTPlayerAdvancedDamageOn(player). The bridge (btplayer.cpp:2201-2204) reads **advancedDamageOn2 = the SECOND cell this[0x9a] = +0x268** — it DOES reproduce the binary's +0x268 gate (@0x49ffde) [T1]. Both cells are stamped together at btplayer.cpp:2131 from btMission->advancedDamageOn (mission+0xf0, the egg "advancedDamage" technician flag). NOTE [inference, T3]: commit 3545056 ("steam eggs stamp the host's advancedDamage mission-wide — penalty was silently off for every steam match") means this gate only recently began PASSING in steam matches — plausibly why two testers suddenly report the leak as new.
|
||||
- SCALE (mech.cpp:987-990): `scale = ((2000/moverMass) / (100·0.27777779)²) / (1 − elasticity²)` — constants match binary (tbyte @0x4a0148 = 1/3.6, 2000f @0x4a0154; mass +0x20c, elasticity +0x244). ≈ 4.5e-5 at 60t/e=0.2 (measured, context/combat-damage.md:1124). Note the mass CANCELS against StaticBounce's ·mass, so scaled amount ≈ (vn·vp)/(771.6·(1−e²)) — mass-independent.
|
||||
- FREE FLOOR (mech.cpp:991-992): scaled < 0.5f -> return (taps free; @0x4a003c). **This floor IS the effective "minimum speed": measured 2.55 pts at 9.6 u/s -> the floor clears at |v| ≈ 4.25 u/s head-on.** Between ~4.3 and ~6.3 u/s a scrape prices 0.5-1.2 pts per frame with no knockdown pacing (see 1f).
|
||||
- SUB-HITS (mech.cpp:994-997): n = Round(amount·2), min 1; each sub-hit = amount/n (~0.5 pts each).
|
||||
- SELECTION (mech.cpp:1007-1025): per sub-hit, one [0,1) roll walks the full subsystem roster accumulating collisionCriticalHitWeight (+0x10C, authored per subsystem resource "CollisionCriticalHitWeight", mechsub.cpp:200/652); eligibility = IsDerivedFrom HeatSink-family(GUID 0x50e590) / Gyroscope(0x50fdc0) / Torso(0x510b08). Measured weights (scratchpad/SESSION_HANDOFF_20260801.md:39): HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35. Un-won rolls land nowhere (weights un-normalized — faithful). Winner takes the sub-hit via MechSubsystem::ApplyDamageAndMeasure (mechsub.cpp:459-481, binary @0x4ac07c = the audited FUN_004ac07c) -> MechSubsystem::TakeDamage (mechsub.cpp:513-538, @0x4ac0bc) -> the subsystem's PRIVATE DamageZone::TakeDamage: `damageLevel += amount × damageScale[0]` (engine DAMAGE.cpp:391), scale = points-normalized from authored damageTypePoints/weaponDamagePoints (mechsub.cpp:220-237).
|
||||
- Subsystems per event: up to n (one per sub-hit); a full-run terrain crash (~2.6 pts) = n=5 sub-hits, so up to 5 subsystem rattles per impact.
|
||||
|
||||
MASTER GATING: **DistributeCollisionDamage is NOT master-gated.** The #150 gate (mech.cpp:1288) covers only the score-report tail and the #162 gate (mech.cpp:1372-1373) only the death tail; the type-0 divert at mech.cpp:1160 runs BEFORE both, on any instance. The port deliberately applies damage on replicants for visual response (comment mech.cpp:1276-1282; binary asserted replicants OUT of this handler entirely, part_012.c:14613-14616). So the shooter-side ram dispatch at the victim's local REPLICANT (BTDispatchCollisionDamage, mech4.cpp:8845; victim->Dispatch, mech4.cpp:8717 — also streamed cross-pod to the master per task #47, messmgr.cpp:348) runs the divert on the replicant too, subject only to the replicant's playerLink/advancedDamageOn2 gate. The TERRAIN self-crash emitter itself is master-only (replicants have no collisionAssistant, mech4.cpp:8427), so #128's chain is master-side.
|
||||
|
||||
=========================================================================
|
||||
STAGE 3 — THE LEAK ONSET (subsystem damage -> coolant leak)
|
||||
=========================================================================
|
||||
HeatSink::UpdateCoolant, game/reconstructed/heat.cpp:1061-1118 (binary @004adbf8):
|
||||
- `coolantDraw = ownZone->damageLevel × heatLoad` (heat.cpp:1078-1080; reads the subsystem's OWN private DamageZone.damageLevel — the exact cell the rattle sub-hit raised).
|
||||
- Floors/thresholds (heat.cpp:70-80, byte-verified): draw < 0.0025 -> zeroed (_DAT_0050e3d8); coolantActive flips ON at draw > 0.003 (_DAT_0050e3d4), OFF below 0.0025 — hysteresis (heat.cpp:1110-1117).
|
||||
- coolantActive IS the leak: it feeds the ReportLeak attribute (heat.cpp:489, all 19 authored leak watchers -> the "warning: coolant leak" voice) and coolantDraw is published as CoolantMassLeakRate (heat.cpp:481) -> the cockpit LeakGauge (btl4gau2.cpp:984/1008). Coolant then drains at coolantDraw/s with central-tank top-up via DrawCoolant (heat.cpp:1095-1105).
|
||||
- heatLoad = 15-sample filtered average of 0.002×temperature×coolantLevel, clamped [0,1] (heat.cpp:70-72, 886-926); typical running value ~0.6 (radiatedHeat ~300 band comment heat.cpp:60-61).
|
||||
- THE THRESHOLD: leak springs when damageLevel > 0.003/heatLoad ≈ 0.005 at typical load. A single 0.5-pt sub-hit raises the private zone by 0.5×damageScale[0] ≈ 0.5/points — for any plausible authored points (10-50) that is 0.01-0.05, i.e. **an ORDER OF MAGNITUDE above the leak threshold. One free-floor-clearing sub-hit landing on ANY heat-family subsystem = instant audible leak, deterministically.** And since Myomers is heat-family (weight 0.35) alongside HeatSinkBank/Condensers/Generator/Reservoir, most of the eligible weight IS heat-family; with n=5 sub-hits at run speed, at least one landing heat-family is near-certain — matching "every single time".
|
||||
- Design intent note: heat.cpp:1070-1072 states the AUTHENTIC behavior — draw rises only as the heat subsystem takes battle damage; there is no repair, so the leak persists once lit (clears only when heatLoad drops enough to cross the 0.0025 OFF hysteresis — the "warning coo-- war--" oscillation, heat.cpp:876-879).
|
||||
|
||||
=========================================================================
|
||||
GUESSES / STAND-INS / PORT DEVIATIONS in the chain (admitted in comments)
|
||||
=========================================================================
|
||||
1. mech4.cpp:8868-8873 [T3]: InitialHit(1) vs Slide(2) contact-state split — the two collision floats @0x240/0x244 are fieldAt() STUBS returning 0; port always accumulates InitialHit. (Audio-only; not damage.)
|
||||
2. mech4.cpp:8777 + 8436-8441: frameEntryWorldVelocity restore — PORT ADDITION (economy guard), not in the binary (its ground was a heightfield probe, never a collision-list entry).
|
||||
3. mech4.cpp:8837-8846: ramContactLinger 0.35s one-bump gate — PORT ADDITION, mech-vs-mech ONLY; the binary's edge was implicit in its bounce separation. **Terrain self-damage got no equivalent gate — the per-frame scrape pricing is the port's own emergent behavior**, bounded in the binary by its velocity-reversing bounce.
|
||||
4. mech4.cpp:8566-8567: knockdown mid-clip rebind guard — admitted "cheap belt-and-braces port addition"; the binary has no rebind guard.
|
||||
5. mech4.cpp:8508-8510 TODO: throttleState = fall-surface material not forwarded (binary (**[mech+0x2f8])+0x24) — payload nicety, unmapped.
|
||||
6. mech.cpp:984: `player == 0` null guard is a port guard (binary reads +0x268 through the link; the audit says the link is always live there).
|
||||
7. mech.cpp:1086-1089 [T3]: lastInflictingID write site is a reconstruction guess (binary write site not located).
|
||||
8. Replicant damage application (mech.cpp:1276-1282) — deliberate port deviation from the 1995 replicant assertion; consequence: the divert/distributor can run on replicant copies (see master-gating above).
|
||||
9. Cross-pod TakeDamage streaming (task #47, messmgr.cpp:348) — port addition; in the 1995 pod, ram damage was network-inert (context/combat-damage.md:416-419).
|
||||
10. Subsystem private-zone damageScale values are authored content (res+0x30/+0x44, mechsub.cpp:220-237) — I did not extract the per-subsystem numbers; the "0.01-0.05 per 0.5-pt hit" figure is inference from the points normalization, not a measured value.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Task 4 — Reconstruction archaeology of the collision→coolant-leak chain (#128)
|
||||
|
||||
## A. TIMELINE — how the chain reached its current shape
|
||||
|
||||
**2026-07-05 (7b7d465, initial commit).** WinTesla engine baseline. The engine already contained the collision PRICING: `Mover::StaticBounce` returns `-0.0005 * (vn·vp) * moverMass` (C:/git/bt411/engine/MUNGA/MOVER.cpp:1527) and `Mover::ProcessCollisionList` stamps `damage->damageType = Damage::CollisionDamageType` (MOVER.cpp:1228) and accumulates the total into the Damage record. The engine RETURNS the Damage to the caller — it never dispatches it. So the pricing formula is ENGINE truth [T0], not a port invention, and the crash-response FORWARDING is game-side policy.
|
||||
|
||||
**2026-07-08 (2af401e) — first collision damage dispatch.** `BTDispatchCollisionDamage` added (mech-vs-mech + cultural-icon crunch), unblocked by the STEP-6 cylinder hit-location work. Terrain explicitly did NOT damage: "Terrain (walls/hills) matches neither branch, so it still BLOCKS without damage (faithful to the binary)" — a claim later falsified (the terrain self-damage was hiding in an export gap). The self-TakeDamage on a blocking hit was left as a marked "DEFERRED" note in the response policy (mech4.cpp ProcessCollision header, still visible at mech4.cpp:8660-8664). Era symptom: "grinding has no effect" was structurally true — wall crashes cost nothing.
|
||||
|
||||
**2026-07-12 (bb795e2) — the ram one-shot + first guard.** First live MP wave found StaticBounce reflections compounding 4-40x across multi-solid frames ("a walking bump one-shot a pristine mech for 112,375 pts"). Fix: `frameEntryWorldVelocity` per-contact restore (port guard for an engine-facility drift, gotcha #16) + `Mech::Reset` motion zeroing. Both are port additions [T3-justified], still in place (mech4.cpp:8436-8441).
|
||||
|
||||
**2026-07-13 (065c114) — the ×1e-3 normalization [T3, admitted guess].** "Port normalizes x1e-3 to the point economy [T3]" — an INVENTED scale patched in because raw kinetic amounts (~59,000 pts @10 m/s, moverMass ~60-90k) were hitting the victim's ARMOR zones directly (the divert did not exist yet). Also added `ramLastVictim`/`ramContactLinger` contact-edge (one-bump-one-hit, 0.35s re-arm) — a port invention [T3] compensating for the port's kinematic gait velocity never reading as "separating" the way the binary's bounce-reversed physics velocity did. The contact edge SURVIVES today (mech4.cpp:8444-8449).
|
||||
|
||||
**2026-07-30 (9c83a46, #82 ROOT FIX) — terrain crash SELF-damage recovered.** The wall-grind "knockdown storm / skating" report decomposed; the missing piece was recovered from the EXPORT GAP by raw disasm (scratchpad/night6/gap_4a9770.txt, binary @4aa89f-4aaab4): fallDirection = worldToLocal(damageForce), fallScalar = -(fallDirection·localVelocity), and a self-dispatched `TakeDamageMessage{0x64, zone=-1, engine Damage verbatim}`. This closed the old DEFERRED note — terrain crashes now hurt, per the 1995 binary [T1 raw disasm]. The `[crashdmg]` log line was added here as the pricing arbiter ("run 14 field report: 'tapped a wall and died instantly', so the forwarded amount is suspect vs binary" — mech4.cpp:8523-8531). Also FALSIFIED en route: the action-request-flags drive-suppressor theory. A port-only mid-clip rebind guard kept as "belt-and-braces" (mech4.cpp:8560-8567).
|
||||
|
||||
**2026-07-30 (14ff351, #83) — the COLLISION DIVERT, hours later.** The self-damage immediately exposed its missing second half: raw kinetic figures were falling into the WEAPON zone/armor loop ("tapped a wall and died instantly"). Recovered @0x4a0368 (hub) → FUN_0049ffcc (distributor, also an export-gap fn, raw disasm scratchpad/night6/gap_49ffcc.txt): gate on victimPlayer advancedDamage copy 2 (+0x268, @0x49ffde); scale = (2000/moverMass)/(100km/h)^2/(1-e²) (tbyte 1/3.6 @0x4a0148, 2000f @0x4a0154); <0.5 FREE (@0x4a015c); Round(2×) sub-hits on random HeatSink-family/Gyro/Torso subsystems via cumulative collisionCriticalHitWeight (+0x10C) and ApplyDamageAndMeasure (@0x4ac07c). All byte-anchored [T1] (mech.cpp:960-1028). Bridges (BTHeatSinkFamily/BTGyroscopeFamily/BTTorsoFamily, BTPlayerAdvancedDamageOn) added same commit.
|
||||
|
||||
**2026-07-31 (37dd7f9) — collision-pricing audit, two REMOVALS of port inventions.** (A) The 0.4s `gBlockCooldown` contact-hysteresis (a port stand-in) DELETED — it had suppressed the knockdown while contact held, letting the drive re-slam at full price every frame → the night-7 SAURON grind death (110 priced frames @|v|~34.7). Binary needs no guard (crash threshold @0x4ab178 = 0.0 read from the exe). (B) The ×1e-3 normalization DELETED — it double-normalized against the divert's ~4e-5 scale, making victim-side rams a no-op. Raw dispatch restored [T1 @part_012:15324]. Bench: scratchpad/night7/mp_rampricing.sh — 150s wall push = 0 rattle 0 deaths; 9.6 m/s ram = 2.55 pts rattle. KB swept: the "moverMass ≈1.3e6" figure corrected to measured 60-90k tonnage scale (an earlier mis-attribution).
|
||||
|
||||
**2026-07-31 (a8a0042, #88) — coolant leaks BECOME POSSIBLE.** Until this commit a leak was STRUCTURALLY IMPOSSIBLE: the MechSubsystem ctor filled only its re-declared SHADOW damageZone (#64, gotcha #1), so `HeatSink::UpdateCoolant`'s engine-member read was NULL forever. Fix aliased the engine base member; from this build on, `coolantDraw = zoneDamage × heatLoad` prices live. Same commit documents the authored collision-rattle weights extracted from BTL4.RES [T1]: HeatSinkBank 0.30 / Gyro 0.35 / Torso 0.25 / Myomers 0.35, Condenser+Reservoir 0 for collisions. NOTE the coupling: #83 (rattle) and #88 (leaks) went live ONE DAY apart — the terrain-ram→leak behavior could not have been observed before 2026-07-31 builds.
|
||||
|
||||
**2026-07-31 (839b040) — night-8: the divert declared "matches the field".** KB records: "RAM damage: the binary's internal-only collision divert MATCHES what players observe (gyro/myomer damage, coolant leak, NO armor damage, grinding inert). May be working as intended; awaiting a player ruling. Gitea #103." (context/open-questions.md:1023-1028). I.e. ram→coolant-leak was seen on night-8 and DISPOSED AS AUTHENTIC-SHAPED, with only the armor question left open.
|
||||
|
||||
**2026-08-02 (night-10, build 716) — #128 filed.** "Ramming terrain = INSTANT coolant leak (4 for 4 that night). NOTE: still 100%? speed at impact; which terrain." (first tester handout, git show bf04e1c:docs/OPEN_ISSUES_FOR_TESTERS.txt:126-127). #125-#130 appear as "carried" in scratchpad/SESSION_HANDOFF_20260803.md:51. **No commit has ever addressed #128** (git -S/--grep sweeps find only the two handout commits).
|
||||
|
||||
**2026-08-05 (91bd286) — divert tail corrected.** The #83 block had shipped with an early `return`; the binary actually JUMPS TO THE DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5) — fixed to `goto death_tail` so a rattle death still posts VehicleDead; no score reports on this path (mech.cpp:1149-1164).
|
||||
|
||||
**2026-08-10 (night-15) — Oracle re-confirms:** "#128 Fail - Happens every single time I collide with terrain at speed, needs to be at some mimimum speed. Confirmed." (scratchpad/night15/OPEN_ISSUES_20260810_commentary2.txt:151). Handout regenerated (40c3594) with "#128 ... Oracle re-confirms STILL LIVE".
|
||||
|
||||
**2026-08-11 (3545056, #162) — advancedDamage stamping.** "Steam eggs stamp the host's advancedDamage mission-wide (penalty was silently off for every steam match)". Night-14 steam logs show advDmg MIXED across players (docs/KD_CHAIN_AUDIT_20260811.md:194: 8 APPLYING all advDmg=1, 32 SKIPPED all advDmg=0). Since the rattle gate is the SECOND advancedDamage cell (+0x268), set from the same mission egg copy (btplayer.cpp:2128-2132: `advancedDamageOn = advancedDamageOn2 = bt_mission->AdvancedDamageOn()`), the collision rattle was per-node-egg-dependent in steam matches before build 885 and is now uniformly the host's setting.
|
||||
|
||||
## B. Provenance verdict per element
|
||||
|
||||
- **Pricing ([crashdmg] amount):** ENGINE (WinTesla MOVER.cpp StaticBounce), shipped 1995 — NOT invented [T0]. The [crashdmg] fields (mass, |v|, force) are diagnostics only.
|
||||
- **Terrain crash→TakeDamage forwarding:** 1995 BT game code @4aa89f-4aaab4, recovered by raw disasm 2026-07-30 [T1]. OUR addition history: absent (pre-07-30) → present raw. The engine never forwarded; the game always did.
|
||||
- **The divert + DistributeCollisionDamage:** byte-anchored reconstruction [T1], constants read from the exe.
|
||||
- **Rattle target weights:** authored data (BTL4.RES) [T1].
|
||||
- **Leak pricing (`coolantDraw = zoneDamage × heatLoad`, floor 0.0025 / ON 0.003):** byte-verified constants (_DAT_0050e3d8/_DAT_0050e3d4, heat.cpp:63-80); an earlier 1e-4 misread made them fire 25-30x too eagerly (task #9 correction).
|
||||
- **Port inventions still live [T3]:** frameEntryWorldVelocity restore; ramLastVictim/ramContactLinger 0.35s contact edge; the mid-clip bmp rebind guard; the collisionAssistant null guard. Port inventions REMOVED: ×1e-3 normalization, gBlockCooldown.
|
||||
- **Known port drift touching the leak:** the heatLoad smoothing window is frame-rate dependent (28 Hz pod vs ~59 fps port halves the window → heatLoad 2x twitchier, heat.cpp:872-880) — documented as the #119 leak-voice stutter cause; also the port's impact |v| comes from the kinematic gait, not binary mover physics (the whole reason the contact-edge guards exist).
|
||||
|
||||
## C. Prior unresolved observations this audit should explain
|
||||
|
||||
1. **#103 (night-8, open era question):** ram → gyro/myomer damage + myomer coolant leak, no armor — filed as possibly-authentic, "awaiting a player ruling" on armor. #128 is the terrain-side escalation of the same mechanism; the night-8 disposition ("matches the divert, may be WAI") is the reason nobody treated the leak itself as a bug for ten days.
|
||||
2. **The determinism testers report is SHAPED like the reconstruction:** ≥0.5 scaled pts (the "minimum speed" testers observe — ~18.5 u/s at 65t per 37dd7f9's message) → guaranteed ≥1 sub-hit → 0.3+0.35 cumulative weight on heat-family targets → any zone damage × running heatLoad ≥ 0.0025 → leak flag. Whether the 1995 pod also leaked on EVERY over-floor terrain crash is the open question — no ledger records a 1995-side check of leak frequency, only of the divert's structure.
|
||||
3. **#135 (leak with NO flashing annunciators) and #97 (three-level leak severity, we only ever produce the lowest)** — both still open, both plausibly entangled with the leak-magnitude side of #128.
|
||||
4. **The steam advancedDamage inconsistency:** before 4.11.885 the +0x268 gate varied per node in steam matches; testers could legitimately disagree about whether terrain rams did anything at all. Post-885 it is the host's egg mission-wide — if the host egg authors advancedDamage ON, every player rattles/leaks.
|
||||
5. **Un-won roll fidelity:** the distributor deliberately does NOT normalize weights (mech.cpp:976-977 "faithful: the binary does not normalize") — with only heat-family+gyro+torso eligible, a sub-hit can land NOWHERE; nobody has verified the eligible-roster cumulative weight against the binary's actual roster order, which affects how often the rattle hits a heat-family (leaking) member vs fizzling.
|
||||
|
||||
==============================================================================
|
||||
|
||||
## Ticket #128 Task 3 — Field receipts from night-14 (build 4.11.857, 4 machines) and night-15 (build 4.11.883, 6 machines) steam logs
|
||||
|
||||
**Method note:** logs have no per-line timestamps; times were reconstructed from `[rstat] frames=N` anchors at 0.017 s/frame (validated against `[drive] dt=0.017`), linearly interpolated by line number (accuracy ~±1 s). Leak receipt = `[techstat] <sub> condition 2 SET` (condition 2 = CoolantLeaking per game/reconstructed/btl4galm.cpp:116) plus first-seen `[galm-item] cond=2` per subsystem. Scripts: scratchpad crash_analysis{,2,3}.py (session scratchpad dir).
|
||||
|
||||
### (1) Distribution
|
||||
Night 14: **8,528** [crashdmg] events (dave 805, santo 1,065, michael 4,351, rajel 2,307). Night 15: **30,715** (Dave 856, rajel 6,468, acaci 9,378, Michael 9,680, santo 2,765, eleng 1,568). Amounts are wildly bimodal: **~98% of events are amount<2,000** (median ~250–530, p95 ~1.1–1.8k) at scrape speeds |v| 3–6.5; a thin tail of discrete rams: amount 10k–145k at |v| 16–60. n14 amt≥10k: 47 events (0.55%); n15: ~145 (0.47%). |v| shows hard structure: scraping saturates at |v|≈6.4–6.5; a dense cluster at |v|=34.3–34.9 (full-throttle wall rams, esp. rajel: 20+ rams all at 34.x); outliers 40–60 (falls/jump landings — inference). mass field is the mover's own mass (35k–90k, varies per mech pick), never the partner's.
|
||||
|
||||
### (2) Correlation + empirical threshold — THE KEY RESULT
|
||||
The collision→instant-leak signature exists **only in advDamage=1 missions** and **only for discrete high-speed rams**, not scrapes:
|
||||
- **Night 14, advDamage=1, amount≥10k: 7 events → 6/7 (86%) followed by an internal-subsystem `[techstat]` receipt within ≤1 s; 4/7 (57%) an explicit CoolantLeaking (cond 2) SET at +0.2–0.4 s.** Michael's 3 rams (14.8k/26.5k/30k raw, |v| 24.4–43.6): ALL THREE → `Myomers condition 2 SET` (coolant leak) at +0.2–0.4 s. Dave's: 76k ram → Gyroscope+HeatSink DMG at +0.0/+0.3 s; 33k ram → Myomers LEAK at +0.3 s. The struck subsystems (Myomers/HeatSink/Gyroscope/Condenser) are exactly the DistributeCollisionDamage lottery families — this is the rattle firing, receipt-latency "instant" [T2 from field receipts].
|
||||
- **advDamage=0 (n14 40 events amt≥10k + n15 ~145): ~2/185 (~1%) show a ≤1 s receipt**, and those are ambiguous (one is ERSLaser_1, not a rattle-family sub — concurrent weapon fire). All other follow-ups sit at +1.3–9.9 s, i.e. ambient combat damage.
|
||||
- **Empirical threshold:** no event below |v|≈15.5 ever priced ≥8k raw; the smallest ram that sprang a leak was raw=14,811 @ |v|=24.4 (65t). Scrape events (|v|≤6.5) never exceeded ~3.1k raw. So the field threshold ≈ **raw amount ~15k ⇔ |v| in the low-to-mid 20s** — matching Oracle's "needs some minimum speed" and consistent with the 0.5-pt free floor of the (2000/mass)/(27.78)² pricing (raw floor ≈ 0.19×mass = 9.6k–17k depending on tonnage/elasticity) [inference from mech.cpp:987-991 + data].
|
||||
- 5-s burst-level correlation over ALL bursts (including scrapes) is only 5–22% (n14) and 0–6% (n15) — the tester "every single time" is about solid rams under advDamage=1, NOT scrapes. Baseline: leaks are common from ordinary weapon fire — 85–100% of cond-2 SETs have NO crashdmg in the prior 5 s (n14: 144/161 orphaned; n15: ~124/142).
|
||||
|
||||
### (3) Cadence — scraping is priced per frame
|
||||
Yes, repeat-fire: consecutive-line [crashdmg] runs of 38–120 events at **25–82 events/s** (frame rate ≈59 → up to ~1.4 pricings per rendered frame). Confirms the santo observation: santo n14 worst run = 50 events in 1.1 s (45/s) totaling 35,601 raw; dave n14 = 50 in 0.6 s (82/s), 51,230 raw; rajel n15 = 94 in 2.2 s, 121,335 raw. Cluster-level (≤2 s gaps): worst = rajel n15, 1,829 events over 43.3 s totaling 1.77M raw. Each per-frame event is individually tiny (med ~300 raw ≈ 0.011 pt after scaling at 70t) → every one falls under the 0.5-pt free floor, so a scrape run costs ZERO through the rattle even when gated on — but the run's raw SUM (25k–121k) would rattle 1–4+ sub-hits if it were priced once as a single impact. The per-frame re-pricing therefore currently under-damages scrapes rather than over-damaging them (through this path).
|
||||
|
||||
### (4) advDamage=0 cross-check — THE GATE HOLDS
|
||||
Night-15 receipts: advDamage=0 for effectively every mission with crash traffic (Dave 11×0/2×1 early, acaci's lone =1 receipt superseded by =0 600 lines later before any ram, Michael's =1 was pre-mission line 56; rajel/santo/eleng all 0). Under those adv=0 receipts, **the instant collision→leak signature is ABSENT** (0–1 borderline in ~145 rattle-eligible rams, 0.7%). The signature appears exactly and only where [exp] advDamage=1 (dave/michael night 14). **Field receipts therefore show the port's rattle path HONORING the +0x268 gate — the leak does NOT come from a gate bypass.** Corollary: the testers who reported "every single time" were almost certainly dave/michael in the n14 advDamage=1 missions (5 and 3 such receipts respectively), or in non-steam sessions outside this evidence set.
|
||||
|
||||
### (5) Wall vs mech-ram
|
||||
Not distinguishable from these receipts: [crashdmg] carries no partner identity and mass= is always the logger's own mover mass. Indirect only: the |v|=34.x pile-up is top ground speed into static geometry; |v| 40–60 outliers imply falls/DFA. No receipt-visible difference in leak outcome by |v| class beyond the amount threshold.
|
||||
|
||||
==============================================================================
|
||||
|
||||
TICKET #128 — BINARY TRUTH: THE COMPLETE 1995 COLLISION-DAMAGE CHAIN
|
||||
|
||||
=== STAGE 1: THE EMITTER (who dispatches, with what amount) ===
|
||||
|
||||
Pricing is ENGINE code we compile against [T0], dispatch is BT binary code [T1].
|
||||
|
||||
1a. Per-contact pricing — Mover::StaticBounce (C:/git/bt411/engine/MUNGA/MOVER.cpp:1433-1528) [T0]:
|
||||
- impact = worldLinearVelocity · surfaceNormal (the NORMAL component only; grazing scrapes contribute little).
|
||||
- impact > 0 (moving away) -> return 0. No other speed gate on damage: minimumBounceSpeed (streamed Mover attr "MinimumBounceSpeed") only zeroes *elasticity for slow hits (MOVER.cpp:1470-1473) — it kills the rebound, NOT the damage.
|
||||
- Return value (MOVER.cpp:1527): -0.0005·(vn·vp)·moverMass, which algebraically reduces to raw = 0.0005 · moverMass · (1−e²) · impact² — the kinetic-energy loss in kilojoules (mass in kg, velocity m/s; the DynamicBounce comment at MOVER.cpp:1569 confirms the kJ intent). This is the amount [crashdmg] prints.
|
||||
- Mover::ProcessCollision (MOVER.cpp:1365-1414) calls StaticBounce per contact; Mover::ProcessCollisionList (MOVER.cpp:1214-1360) SUMS raw damage across the frame's contacts (total_damage, :1283/:1350), sets damageType = Damage::CollisionDamageType (= 0, :1228) and damageForce = post−pre velocity delta (:1351).
|
||||
|
||||
1b. Dispatch — the binary's ground+collide response block (port Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8383-8537; binary @4aa630-@4aab5f, recovered byte-by-byte from the export gap, raw disasm scratchpad/night6/gap_4a9770.txt) [T1]:
|
||||
- crushable-icon contact (0.00123f sentinel) -> gyro crunch only (@4aa7ce-4aa871), NO TakeDamage.
|
||||
- blocking contact with damageAmount > 0 -> full frame rejection (velocity zeroed, position restored) + @4aa984-4aaab4 builds TakeDamageMessage{id 0x64, zone=-1, the collision Damage VERBATIM} and Dispatches it at the mech ITSELF. There is NO minimum-amount/speed gate at the emitter: ANY nonzero closing contact dispatches. (iv2 > 40 (u/s)² @0x4ab184 gates only the knockdown/bump ANIMATION ≈ 22.8 km/h.)
|
||||
|
||||
=== STAGE 2: THE DISTRIBUTOR — FUN_0049ffcc @0049ffcc (part_012.c:14374-14430) [T1, decomp + raw disasm scratchpad/night6/gap_49ffcc.txt] ===
|
||||
|
||||
Reached ONLY via the hub Mech::TakeDamageMessageHandler @0x4a0230: part_012.c:14636-14638 — `if (damage.damageType == 0) { FUN_0049ffcc(mech, &damage); goto LAB_004a07b5; }` — the goto jumps past the zone/armor loop entirely. Collision damage NEVER touches zone armor, in either gate state.
|
||||
|
||||
2a. THE GATE (@0x49ffde): `if (*(victimMech+0x190 /*owning BTPlayer*/ +0x268) == 0) return;` — the SECOND advancedDamage cell (egg/technician "splash/collision damage" setting). Gate OFF = TRULY NOTHING: the entire function body is inside this if; combined with the hub's goto, a collision then costs zero armor, zero subsystems, zero anything. [T1]
|
||||
|
||||
2b. THE SCALING (@0x49ffff-0x4a0030): amount *= ((2000/moverMass) / ((100·(1/3.6))²)) / (1 − elasticity²). Constants from raw disasm: tbyte @0x4a0148 = 0.2777778 (1/3.6, km/h→m/s), 2000f @0x4a0154, 1.0f @0x4a0158, mass @mover+0x20c, elasticity @mover+0x244. Multiplied against the raw kJ from Stage 1, EVERYTHING CANCELS: scaled = 0.0005·m·(1−e²)·v_n² × 2000/(m·771.605·(1−e²)) = (v_n/27.778)² — the squared ratio of normal impact speed to 100 km/h, independent of mech mass and elasticity [T1 algebra on T0+T1 inputs].
|
||||
|
||||
2c. THE THRESHOLD (@0x4a003c-0x4a0045): scaled < 0.5f (@0x4a015c) -> return, FREE. => minimum damaging impact: v_n ≥ 27.778·√0.5 = 19.64 m/s ≈ 70.7 km/h closing normal speed, for EVERY mech. Walking scrapes (tangential, or slow) are free by construction.
|
||||
|
||||
2d. SUB-HIT COUNT (@0x4a0051): n = _ftol(scaled × 2.0f (@0x4a0160)) — FUN_004dcd94 is the CRT _ftol, not a helper (Ghidra shows ROUND(ST0); MSVC _ftol truncates — minor rounding uncertainty, n identical at all half-integer boundaries). amount /= n (@0x4a0065). With truncation each sub-hit is 0.5-1.0 points (n=1 for scaled∈[0.5,1), n=2 at 100 km/h, n=3 at ~140 km/h, n=6 at 182 km/h supercharge).
|
||||
|
||||
2e. TARGET SELECTION (the loop @0x4a006b-0x4a0121; NOT FUN_004ac07c): per sub-hit, roll = FUN_00408050() = rand()·(1/32768) ∈ [0,1) (part_000.c:7930); walk the subsystem roster (count mech+0x124, array mech+0x128) in ROSTER ORDER; an entry is ELIGIBLE iff FUN_0041a1a4(its derivation chain, TAG) matches one of THREE tags (part_012.c:14403-14410): 0x50e590, 0x50fdc0, 0x510b08; accumulate eligible entries' collisionCriticalHitWeight (+0x10C, streamed res+0xDC "CollisionCriticalHitWeight"); first entry whose cumulative ≥ roll receives the sub-hit via FUN_004ac07c and the walk breaks. Weights are UN-NORMALIZED: an un-won roll lands NOWHERE (faithful).
|
||||
|
||||
2f. ELIGIBILITY — the tags [T1, with a KB-internal contradiction I must flag]: FUN_0041a1a4 (part_002.c:3843) matches by Derivation-object POINTER IDENTITY walked up the parent chain. 0x50fdc0 = Gyroscope's node (DefaultData 0x50fdb0+0x10), 0x510b08 = Torso's node (DefaultData 0x510af8+0x10). 0x50e590 = the HeatSinkBank/AggregateHeatSink (0xBBE) node — NOT the HeatSink base family: decomp-reference.md rows 55+87 (task #57 correction) say so, and it is logically forced by part_013.c:5864 — the HeatSink base ctor itself tests 0x50e590 to let ONLY the bank skip the sink link-attach (heat.cpp:620-628 task #9, live-verified: treating it broader blocked condenser→bank conduction and caused the heat runaway). So the BINARY's eligible set = {HeatSinkBank, Gyroscope, Torso} — authored weights 0.30/0.35/0.25 (Σ=0.90; 10% of rolls land nowhere). The authored Myomer weight 0.35 is DEAD DATA for collisions (myomers are PoweredSubsystem-derived, no gyro/torso/bank tag in their chain; the other +0x10C-like path, zone crits @0049ccc4, uses the ZONE's crit-entry list weights instead). ⚠ CONTRADICTION: game/reconstructed/mech.cpp:1004-1018 (DistributeCollisionDamage) and context/combat-damage.md:1120 read 0x50e590 as the whole HeatSink FAMILY (BTHeatSinkFamily() returns HeatSink::GetClassDerivations(), heat.cpp:1447-1450) — that broadens eligibility to condenser/reservoir/generator/sensor/MYOMERS/weapons, i.e., nearly the entire coolant-bearing branch. This is a prime #128 root-cause candidate (see verdict below).
|
||||
|
||||
2g. THE APPLICATOR — FUN_004ac07c (part_013.c:4274-4282) [T1]: NOT a picker. It snapshots the target subsystem's PRIVATE zone damageLevel (this[0x38]+0x158, the Subsystem::damageZone @+0xE0), calls the subsystem's TakeDamage virtual (vtbl+0x24) with the (scaled, divided) Damage, returns the delta. The MechSubsystem vtbl+0x24 analog FUN_004ac0bc (part_013.c:4290): zone->TakeDamage (zone vtbl+0x18; DamageZone::TakeDamage @0041e4e0 == DAMAGE.cpp:391: damageLevel += amount × damageScale[type]) then if level ≥ 1.0 (_DAT_004ac140) -> failure states (Destroyed). The private zone's damageScale[0] (collision) is the authored damageTypePoints normalized as scale = 1/(points·defaultArmorPoints) (mechsub.cpp:221-237, ArmourNumerator=1.0 @0x4ac860) — exact authored bank values not pinned [T3].
|
||||
|
||||
=== STAGE 3: THE LEAK ONSET — HeatSink::UpdateCoolant FUN_004adbf8 (part_013.c:5733) [T1 decomp, constants T2] ===
|
||||
|
||||
Run per-frame from HeatSink::Update for every HeatSink-DERIVED subsystem only (CLASSMAP.md tree: HeatSink -> Condenser/Reservoir/HeatSinkBank/Generator/PoweredSubsystem -> Sensor/Myomers/MechWeapon...). Gyroscope and Torso are HeatWatcher->PowerWatcher-derived (CLASSMAP.md:34-98) — they have NO coolant members and CANNOT leak.
|
||||
- coolantDraw(+0x130) = ownZoneDamage (this[0x38]+0x158) × heatLoad (+0x120, the SMOOTHED radiated-heat running average, attr "NormalizedPressure");
|
||||
- draw < 0.0025 (_DAT_0050e3d8) -> zeroed (the floor);
|
||||
- the draw drains own coolantLevel(+0x12C) × dt, replenished by DrawCoolant (vtbl+0x38, @0x4add00) recursing up linkedSinks × coolantFlowScale(+0x15C) to the Reservoir (@0x4af3b0) — the cockpit coolant bar visibly drops;
|
||||
- HYSTERESIS coolantActive(+0x138): OFF->ON when draw > 0.003 (_DAT_0050e3d4); ON->OFF when draw < 0.0025. coolantActive IS the ReportLeak attribute ridden by the 19 authored leak watchers (the 3-note warning) and the "CoolantLeaking" TechStatusType (@50df17).
|
||||
- LEAK CONDITION: zoneDamage × heatLoad > 0.003. One 0.5-1.0-pt rattle sub-hit gives zoneDamage = amount × damageScale[0]; with any working heat load (live port measurements saw myomer heatLoad ≈ 0.014 at idle-ish work: 0.5×0.014 = 0.007 > 0.003) a SINGLE sub-hit plausibly crosses the threshold immediately — the leak is instant once a coolant-bearing subsystem is rattled at all [formula T1; magnitude T3 pending authored bank points].
|
||||
|
||||
=== THE EXPLICIT ANSWERS ===
|
||||
|
||||
(a) When does 1995 dispatch damage at all? The self-TakeDamage(type 0) is dispatched on ANY blocking contact with nonzero closing normal speed (no emitter threshold). But it has EFFECT only when the distributor's scaled amount (v_n/27.778 m/s)² ≥ 0.5, i.e. closing NORMAL speed ≥ 19.64 m/s ≈ 70.7 km/h — mass- and elasticity-independent (exact cancellation). Below that every scrape/tap is free; gate (+0x268) OFF makes ALL collisions free. Mech envelope: stat-sheet speeds 40 (gimped)/143 (normal)/182 (supercharged) kph (context/pod-hardware.md:589-598) — so a run-speed head-on ram crosses easily, a walk or graze never does.
|
||||
|
||||
(b) When does 1995 first spring a coolant leak? Requires ALL of: (i) advancedDamage(+0x268) ON; (ii) v_n ≥ 70.7 km/h (≥1 sub-hit); (iii) the lottery landing on the HeatSinkBank — the ONLY coolant-bearing eligible class — probability 0.30 per sub-hit (gyro 0.35 and torso 0.25 rattles cannot leak); (iv) resulting bank zoneDamage × bank heatLoad > 0.003 (plausibly crossed by one sub-hit on any warm mech). Per-ram leak probability ≈ 1−0.7ⁿ: ~30% at 71 km/h (n=1), ~51% at 100 km/h (n=2), ~66% at 140 km/h (n=3), ~88% at 182 km/h (n=6). 1995 is NEVER "every single time".
|
||||
|
||||
VERDICT for #128 (inference from the above): the deterministic instant leak the testers report cannot be authentic 1995 behavior at any speed. The port's DistributeCollisionDamage (mech.cpp:1016) broadened 0x50e590 from bank-only to the whole HeatSink family — so myomers (weight 0.35 EACH, several per mech) and other coolant-bearing subsystems enter the lottery, the cumulative weight saturates, and nearly every rattle sub-hit lands on something that runs UpdateCoolant -> leak virtually every qualifying ram. Compounding: the steam eggs now stamp advancedDamage mission-wide (commit 3545056), turning the gate ON for matches where it was silently OFF before, and the run-14 note (mech4.cpp:8523-8525) already flags the forwarded [crashdmg] amount as suspect vs binary.
|
||||
+9
-2
@@ -14,8 +14,15 @@ Resolve AFTER all 7 families report (Stage 1 reconciliation), then again at link
|
||||
- **Player__VehicleDeadMessage**: BT build carried `killerName` (+0x1c) the engine base lacks; read via offset.
|
||||
|
||||
### Stubbed (no WinTesla analog) — revisit at integration
|
||||
- BTPlayer ctor role/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
|
||||
`GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
|
||||
- ~~BTPlayer ctor role-registry resolution~~ — **RESOLVED 2026-08-07.** The claim "no WinTesla
|
||||
analog → stubbed; base-set `scenarioRole` stands" was wrong twice over: the base ctor sets
|
||||
`scenarioRole` to **NULL** (PLAYER.cpp:680), so nothing "stood"; and the analog exists —
|
||||
`Mission::GetScenarioRole(name)` (MISSION.h:162) walks the same `scenarioRoleChain` that
|
||||
`BTL4Mission` fills via `AddScenarioRole()` when it parses the role pages. A NULL role zeroed
|
||||
EVERY scoring value (a kill scored 4.88 instead of 505.88, the eject charge read 0, the death
|
||||
cost was skipped). Now bound, with a `Role::Default` fallback and a BOUND/NULL receipt.
|
||||
`GetMission()->GetGameModel()` (the freeForAll compare) stays genuinely stubbed — the shipped
|
||||
code discarded its result anyway.
|
||||
|
||||
### Link-time externs to be PROVIDED by owning modules (Stage 3)
|
||||
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
|
||||
|
||||
@@ -254,4 +254,27 @@ Keep all three unconditional (matchlog only arms on `-net`, `matchlog.cpp:66-76`
|
||||
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on death — the wreck stays (P5 teardown crash).
|
||||
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
|
||||
9. **`AmmoBin +0x228`** is written to 0 by `@004bd26c` alongside `feedTimer`/`cookOffArmed` and is the object's last dword (`sizeof == 0x22C`); our `ammobin.hpp:270` calls it `reserved // unused`. It is a live per-feed latch — identify it from `AmmoBinSimulation @004bd394` / `FeedAmmo @004bd4f4` before shipping the refill move, or copy the write blind and flag it [T3].
|
||||
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
||||
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
||||
---
|
||||
|
||||
# ADDENDUM 2026-08-04 — the full-reset audit (operator request): valves, gensel, MFD modes, spawn smoke
|
||||
|
||||
Steps 0-5 above all landed via #55 and are byte-consistent (re-audited today).
|
||||
Four operator/field reports were then grounded and dispositioned:
|
||||
|
||||
| report | binary ground truth | disposition |
|
||||
|---|---|---|
|
||||
| coolant valves survive respawn | `@004ae534` (MISSING from the Ghidra export -- raw disasm): chains **HeatSink** `0x4ad760` (coolant refill RUNS), then arg-gated `valveState=1` + `massScale=refrigerationFactor`; `Mech::Reset` tail (`call 0x49f788`) re-runs RecomputeCondenserValves | **PORT GAP -> FIXED** (heat.cpp authentic body + mech4.cpp Reset tail call). Bench: detent 5 -> death -> `[respawn] Condenser1 valve detent 5 -> 1` |
|
||||
| generator switching survives | `@004b215c` sets `generatorOn=1` (landed, #55); `@004b0e6c` only RESOLVES the source link -- **the weapon->generator taps are never rewritten** | gens re-light = already correct; **taps persisting = AUTHENTIC** |
|
||||
| MFD display mode / control mode survive | mapper vtables `0050f45c` + `0051e440` slots 8-11 read from the shipped exe = plain root bodies -- **displayMode/controlMode have NO reset anywhere** | **AUTHENTIC persist** -- do not "fix" |
|
||||
| #129 fresh spawns smoke briefly | the pod's respawn ran the per-entity effect cleanup `@004d0c14` | **PORT GAP -> FIXED**: only the master's Mech::Reset ran the cleanup; the REPLICANT un-wreck edge (mechdmg.cpp MechDeathHandler::Tick) rebuilt without it, so the observer's last 10s plume window rode the teleport (peers-only symptom). BTStopEntityPfx now runs on the un-wreck edge |
|
||||
|
||||
Bench: `scratchpad/night11/respawnreset.sh` (BT_VALVE_TEST press -> BT_MP_FORCE_DMG
|
||||
kill -> read `[valve]`/`[respawn]` on A, plume/un-wreck ordering on B).
|
||||
|
||||
## Addendum 2026-08-05 (#45 report tail): the VehicleDead SENDER moved
|
||||
The death-transition dispatch site (mech4) is retired: the decoded sender is the TakeDamage
|
||||
handler's death tail (@0x4a07d4-0x4a0890), now BTMechPostVehicleDead (btplayer.cpp) with the
|
||||
BT 0x38-byte extension {killed-by player, kill zone}. The #55 NULL-playerLink fallback + the
|
||||
DEAD_NOTIFY forensics moved into it intact; the #81 single-dispatch rule is unchanged. The
|
||||
flow diagram above predates this -- read BTPostKillScore rows as historical (retired 08-05).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include "munga.h"
|
||||
#pragma hdrstop
|
||||
|
||||
@@ -978,6 +979,31 @@ AudioStateTrigger::AudioStateTrigger(
|
||||
MemoryStream_Read(stream, &excludeTransition);
|
||||
MemoryStream_Read(stream, &excludeState);
|
||||
|
||||
// #154 DELIBERATE DEVIATION from shipped 1995 content (the ONLY authored-
|
||||
// content override in the port -- revert with BT_GEN_WARN_SHIPPED=1).
|
||||
// BTL4.RES authors the generator-out warning as (trigState=4, inverse=1,
|
||||
// Start) = fire on LEAVING GeneratorOut -- so a thermal trip was SILENT and
|
||||
// the klaxon played at RECOVERY/respawn instead (live-benched,
|
||||
// gen_edge_bench.sh; bytes @0x2bf13, decode triple-verified). Every other
|
||||
// fault alarm in the game is authored enter-state, inverse is otherwise
|
||||
// only a Stop terminator (144-row census), and the manual (p14/p23) lists
|
||||
// this as a loss-time warning: a one-bit 1995 authoring slip. Flip it to
|
||||
// enter-4 so the warning speaks at the TRIP, stays silent at recovery and
|
||||
// at the respawn reset's 4->0 edge. (Testers' "voice at trip" memories are
|
||||
// Firestorm's speech system -- BT 4.10 shipped no speech samples at all.)
|
||||
{
|
||||
extern char g_lastBoundAttrName[64];
|
||||
if (triggerState == 4 && inverseTrigger != 0
|
||||
&& strcmp(g_lastBoundAttrName, "GeneratorState") == 0
|
||||
&& getenv("BT_GEN_WARN_SHIPPED") == 0)
|
||||
{
|
||||
inverseTrigger = 0;
|
||||
if (getenv("BT_ATTRBIND_LOG"))
|
||||
DEBUG_STREAM << "[statecfg] #154 fixup: GeneratorState leave-4"
|
||||
" Start -> enter-4 Start\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
if (getenv("BT_ATTRBIND_LOG")) { static int s_stc=0; if (s_stc++<120)
|
||||
DEBUG_STREAM << "[statecfg] attrPtr=" << (void*)attributePointer
|
||||
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
|
||||
@@ -1076,10 +1102,21 @@ void
|
||||
//
|
||||
// Check for the trigger state
|
||||
//
|
||||
if (
|
||||
(!inverseTrigger && new_state == triggerState) ||
|
||||
(inverseTrigger && old_state == triggerState)
|
||||
)
|
||||
const int bt_fires =
|
||||
((!inverseTrigger && new_state == triggerState) ||
|
||||
(inverseTrigger && old_state == triggerState));
|
||||
// #154 diag (BT_AUDIO_EDGE=1): EVERY state-edge delivery to EVERY authored
|
||||
// trigger, fired or not -- state edges are rare, so this is low-volume.
|
||||
// Correlate with the [gen] TRIPPED/RESTARTING lines to observe which edge
|
||||
// actually starts a warning sequence.
|
||||
if (getenv("BT_AUDIO_EDGE"))
|
||||
DEBUG_STREAM << "[audioedge] old=" << old_state << " new=" << new_state
|
||||
<< " trig=" << triggerState << " inv=" << (int)inverseTrigger
|
||||
<< " ctl=" << (int)controlID << "/" << controlValue
|
||||
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
|
||||
<< (bt_fires ? " FIRED" : " no-fire")
|
||||
<< "\n" << std::flush;
|
||||
if (bt_fires)
|
||||
{
|
||||
Check(&audioComponentSocket);
|
||||
Check(audioComponentSocket.GetCurrent());
|
||||
|
||||
@@ -117,6 +117,16 @@ EntitySegment*
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// #149 perf telemetry (BT_PERF_LOG; counters cost two increments when unset).
|
||||
// gBTSegWDirty counts the EXPENSIVE arm -- the mark-every-segment pass that
|
||||
// invalidates the whole table -- separately from plain calls, because the #141
|
||||
// sweep put this accessor on per-frame weapon/beam paths and the open question
|
||||
// is whether that multiplied the dirty-pass rate (each pass forces the next
|
||||
// query of EVERY segment to re-derive its parent chain).
|
||||
int gBTSegWCalls = 0;
|
||||
int gBTSegWDirty = 0;
|
||||
double gBTSegWMs = 0.0;
|
||||
|
||||
//
|
||||
void
|
||||
JointedMover::GetSegmentToWorld(
|
||||
@@ -125,6 +135,13 @@ void
|
||||
)
|
||||
{
|
||||
Check(this);
|
||||
// DEFAULT ON (operator, 2026-08-09): every player's session log should
|
||||
// carry this -- cross-machine comparison is the point. BT_PERF_LOG=0
|
||||
// opts out. Cost: ~2 QPC reads per call, microseconds per second.
|
||||
static const int sPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
||||
LARGE_INTEGER t0, t1, fq;
|
||||
++gBTSegWCalls;
|
||||
if (sPerf) QueryPerformanceCounter(&t0);
|
||||
JointSubsystem *joints = GetJointSubsystem();
|
||||
Check(joints);
|
||||
|
||||
@@ -135,6 +152,7 @@ void
|
||||
//
|
||||
if (joints->AreJointsModified())
|
||||
{
|
||||
++gBTSegWDirty;
|
||||
EntitySegment::SegmentTableIterator iterator(segmentTable);
|
||||
EntitySegment *current_segment;
|
||||
while( (current_segment = iterator.ReadAndNext() ) != NULL)
|
||||
@@ -153,6 +171,12 @@ void
|
||||
my_segment.GetSegmentToEntity(),
|
||||
localToWorld
|
||||
);
|
||||
if (sPerf)
|
||||
{
|
||||
QueryPerformanceCounter(&t1);
|
||||
QueryPerformanceFrequency(&fq);
|
||||
gBTSegWMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ void
|
||||
while ((lamp_pointer=i.ReadAndNext()) != NULL)
|
||||
{
|
||||
Check(lamp_pointer);
|
||||
// #135: an edge that arrived while this lamp's page was not current
|
||||
// (or during the one-frame mask race of a page transition) skipped its
|
||||
// NotifyOfStateChange -- the panel value latches the last asserted
|
||||
// flash, so a missed CLEAR left loop buttons flashing forever. The
|
||||
// lamp is on the ACTIVE list here, so its mode is current: deliver
|
||||
// the missed notification now.
|
||||
if (lamp_pointer->staleAlertNotify)
|
||||
{
|
||||
lamp_pointer->staleAlertNotify = False;
|
||||
lamp_pointer->NotifyOfStateChange();
|
||||
}
|
||||
lamp_pointer->Update();
|
||||
}
|
||||
Check_Fpu();
|
||||
@@ -248,6 +259,7 @@ Lamp::Lamp(
|
||||
previousState = LampStateUndefined;
|
||||
manager = lamp_manager;
|
||||
alertActive = 0;
|
||||
staleAlertNotify = False; // #135
|
||||
|
||||
manager->AddLamp(this);
|
||||
Check_Fpu();
|
||||
@@ -281,6 +293,10 @@ void
|
||||
{
|
||||
NotifyOfStateChange();
|
||||
}
|
||||
else
|
||||
{
|
||||
staleAlertNotify = True; // #135: deliver at next in-mode Update
|
||||
}
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -311,6 +327,10 @@ void
|
||||
{
|
||||
NotifyOfStateChange();
|
||||
}
|
||||
else
|
||||
{
|
||||
staleAlertNotify = True; // #135: deliver at next in-mode Update
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ protected:
|
||||
*manager;
|
||||
int
|
||||
alertActive;
|
||||
// #135: an alert-state EDGE whose NotifyOfStateChange was skipped by the
|
||||
// mode gate (page not current, or the one-frame mask race during a page
|
||||
// transition). The panel value latches the last asserted flash until the
|
||||
// next assert, so a skipped clear-edge left loop buttons flashing forever
|
||||
// (field: Oracle night-15, "Loop 6 NEVER stopped"). Delivered by Update()
|
||||
// at the lamp's next in-mode frame.
|
||||
Logical
|
||||
staleAlertNotify;
|
||||
};
|
||||
|
||||
//#########################################################################
|
||||
|
||||
@@ -48,15 +48,39 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
||||
resourceAddress;
|
||||
|
||||
killBonus = player_data->killBonus;
|
||||
specialCaseDeathPenalty = player_data->specialCaseDeathPenalty; // BT 4.10 (restored)
|
||||
returnFromDeath = player_data->returnFromDeath;
|
||||
damageReceivedModifier = player_data->damageReceivedModifier;
|
||||
damageInflictedModifier = player_data->damageInflictedModifier;
|
||||
damageBias = player_data->damageBias;
|
||||
friendlyFirePenalty = player_data->friendlyFirePenalty;
|
||||
player_res_des->Unlock();
|
||||
|
||||
// SCORE PROVENANCE (ungated, 2026-08-07). Every unmatched row of the
|
||||
// original manual's scoring chart -- +500 a kill, -1000 an eject, -500
|
||||
// an ammo death -- is sourced HERE, from the role's GameModel record
|
||||
// (type 0xf, 7 dwords; the binary's ctor @00429a9c copies rec[0..6]).
|
||||
// A field log showed `charge=0 (role killBonus)`, and the two ways that
|
||||
// happens -- record authored zero, or resource lookup missed -- are
|
||||
// indistinguishable in release because the miss path's Warn/Tell compile
|
||||
// out (DEBUGOFF.h). One line per role settles it.
|
||||
DEBUG_STREAM << "[role] '" << (const char *)role_name
|
||||
<< "' model='" << (const char *)model_file
|
||||
<< "' killBonus=" << (float)killBonus
|
||||
<< " deathPenalty=" << (float)specialCaseDeathPenalty
|
||||
<< " dmgInf=" << (float)damageInflictedModifier
|
||||
<< " dmgRcv=" << (float)damageReceivedModifier
|
||||
<< " bias=" << (float)damageBias
|
||||
<< " ff=" << (float)friendlyFirePenalty
|
||||
<< " return=" << (int)returnFromDeath
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[role] '" << (const char *)role_name
|
||||
<< "' model='" << (const char *)model_file
|
||||
<< "' -- RESOURCE NOT FOUND, all score values default to 0"
|
||||
<< "\n" << std::flush;
|
||||
Tell(role_name);
|
||||
Warn(" does not exists in resource! ");
|
||||
damageReceivedModifier = 0.0f;
|
||||
@@ -64,6 +88,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
||||
damageBias = 0.0f;
|
||||
friendlyFirePenalty = 0.0f;
|
||||
killBonus = 0.0f;
|
||||
specialCaseDeathPenalty = 0.0f; // BT 4.10 (restored)
|
||||
returnFromDeath = 0;
|
||||
}
|
||||
}
|
||||
@@ -151,6 +176,20 @@ Dump_And_Die:
|
||||
return -1;
|
||||
}
|
||||
|
||||
// BT 4.10: read right after KillBonus, fail-hard like every other key
|
||||
// (binary role reader @00429bec, dest = record slot [1]).
|
||||
if(
|
||||
!model_file->GetEntry(
|
||||
"gamedata",
|
||||
"SpecialCaseDeathPenalty",
|
||||
&local_model->specialCaseDeathPenalty
|
||||
)
|
||||
)
|
||||
{
|
||||
std::cerr << model_name << "Missing SpecialCaseDeathPenalty" << std::endl;
|
||||
goto Dump_And_Die;
|
||||
}
|
||||
|
||||
if(
|
||||
!model_file->GetEntry(
|
||||
"gamedata",
|
||||
|
||||
+13
-1
@@ -9,7 +9,12 @@ class NotationFile;
|
||||
|
||||
struct ScenarioRole__ModelResource
|
||||
{
|
||||
Scalar killBonus, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
||||
// BT 4.10 carries specialCaseDeathPenalty as record slot [1], right after
|
||||
// killBonus (role reader @00429bec dest offsets; the 2007 WinTesla source
|
||||
// dropped the BT-only field -- restored 2026-08-02, the DefaultRendererRate
|
||||
// pattern). Consumed by the BT death handler tail (@004c07cd): every death
|
||||
// with advancedDamageOn costs -specialCaseDeathPenalty score.
|
||||
Scalar killBonus, specialCaseDeathPenalty, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
||||
int returnFromDeath;
|
||||
};
|
||||
|
||||
@@ -20,6 +25,7 @@ public:
|
||||
|
||||
protected:
|
||||
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
|
||||
Scalar specialCaseDeathPenalty; // BT 4.10 role+0x20 (restored 2026-08-02)
|
||||
CString roleName;
|
||||
|
||||
int returnFromDeath;
|
||||
@@ -61,6 +67,12 @@ public:
|
||||
return killBonus;
|
||||
}
|
||||
|
||||
Scalar GetSpecialCaseDeathPenalty() const // BT 4.10 role+0x20
|
||||
{
|
||||
Check(this);
|
||||
return specialCaseDeathPenalty;
|
||||
}
|
||||
|
||||
CString GetRoleName() const
|
||||
{
|
||||
Check(this);
|
||||
|
||||
@@ -96,6 +96,19 @@ AttributeWatcher::AttributeWatcher(
|
||||
|
||||
attributePointer = simulation->GetAttributePointer(attribute_name);
|
||||
|
||||
// The bound attribute's NAME, visible to derived ctors (the stream layout
|
||||
// puts the trigger fields AFTER the base bind, so a derived ctor that needs
|
||||
// to know WHICH attribute it just bound -- the #154 GeneratorState fixup --
|
||||
// reads it from here). Valid only during construction.
|
||||
{
|
||||
extern char g_lastBoundAttrName[64];
|
||||
const char *an = (const char *)attribute_name;
|
||||
int i = 0;
|
||||
for (; an != 0 && an[i] != '\0' && i < 63; ++i)
|
||||
g_lastBoundAttrName[i] = an[i];
|
||||
g_lastBoundAttrName[i] = '\0';
|
||||
}
|
||||
|
||||
if (getenv("BT_ATTRBIND_LOG"))
|
||||
{
|
||||
extern int g_curAudioWatcherClass;
|
||||
|
||||
@@ -23,6 +23,7 @@ ALuint *g_buffers;
|
||||
int g_numBuffers;
|
||||
const char *g_bufferNames[512]; // bufferInd -> sample file (diag: the live-playing dump)
|
||||
int g_curAudioWatcherClass = 0;
|
||||
char g_lastBoundAttrName[64] = {0}; // AttributeWatcher bind name, for derived-ctor fixups (#154)
|
||||
|
||||
//
|
||||
// Minimal canonical-PCM WAV reader. The repo's libsndfile-1.dll is a STUB
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include "dxutils.h"
|
||||
#ifdef BT_GLASS
|
||||
#include "l4padrio.h"
|
||||
#ifdef BT_GLASS
|
||||
#include "l4glasswin.h" // the per-display MFD windows -- created below for
|
||||
// EITHER RIO (they used to ride the PadRIO ctor)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
//
|
||||
@@ -865,6 +869,29 @@ LBE4ControlsManager::LBE4ControlsManager():
|
||||
while (temp[0] != '\0');
|
||||
}
|
||||
|
||||
//--------------------------------------------------
|
||||
// The per-display MFD windows (2026-08-06)
|
||||
//--------------------------------------------------
|
||||
// These used to be created ONLY at the end of the PadRIO constructor,
|
||||
// because they began life as the frames around the on-screen RIO button
|
||||
// banks -- "the buttons ride the device". On a real cab that coupling is
|
||||
// backwards: selecting the HARDWARE RIO means PadRIO is never built, so
|
||||
// the MFD panels never appeared at all. Found on the crash cart, where
|
||||
// the mission came up on the main view with every MFD dark.
|
||||
//
|
||||
// The windows are a DISPLAY concern, so create them here -- after the
|
||||
// L4CONTROLS parse, where every device branch converges -- and let them
|
||||
// exist whichever RIO won. In pod surface mode there are no on-screen
|
||||
// buttons on them at all. Create() is idempotent (`gWinCount != 0`
|
||||
// returns), so the PadRIO call site stays valid and simply arrives first.
|
||||
//
|
||||
#ifdef BT_GLASS
|
||||
if (rioPointer != NULL && BTGlassPanelsActive())
|
||||
{
|
||||
BTGlassPanels_Create();
|
||||
}
|
||||
#endif
|
||||
|
||||
//--------------------------------------------------
|
||||
// Clear all lamps (and reset analog I/O)
|
||||
//--------------------------------------------------
|
||||
@@ -1089,6 +1116,28 @@ LBE4ControlsManager::~LBE4ControlsManager()
|
||||
rioPointer = NULL;
|
||||
}
|
||||
|
||||
#ifdef BT_GLASS
|
||||
//
|
||||
// Symmetric with the Create above (2026-08-06). The HARDWARE RIO's dtor
|
||||
// knows nothing about the panels, and windows that outlive the surfaces
|
||||
// they blit are a crash waiting for the next mission cycle.
|
||||
//
|
||||
// CORRECTION (2026-08-07, #140): the original comment here claimed this was
|
||||
// "a no-op" on the PadRIO path because ~PadRIO tears the panels down itself.
|
||||
// It was not. ~PadRIO runs first (delete rioPointer, above), zeroing the
|
||||
// window list -- and BTGlassPanels_Destroy unconditionally ran SaveLayout
|
||||
// BEFORE looking at whether anything was left, so this second call rewrote
|
||||
// glass_layout.cfg with every MFD and radar line missing. Only the plasma
|
||||
// window survived, because external windows cached a last-known rect and the
|
||||
// per-display windows did not. That is the regression testers hit on the
|
||||
// desktop the same day the pod panels were wired up; the pod itself was
|
||||
// unaffected (no PadRIO, so only ONE destroy, and it runs BT_GLASS_LAYOUT=
|
||||
// load anyway). Fixed on both sides in L4GLASSWIN: the geometry is now
|
||||
// remembered across teardown, and the teardown save is guarded.
|
||||
//
|
||||
BTGlassPanels_Destroy();
|
||||
#endif
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -2624,12 +2673,28 @@ void
|
||||
Verify(rio_event.Data.Unit < ButtonCount);
|
||||
temp = rio_event.Data.Unit + 1;
|
||||
|
||||
// DIAG (BT_DUCK_LOG): the mode mask a press dispatches under -- the
|
||||
// crouch-button (0x13) MP-delivery hunt (mode-gated streamed rows).
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] RIO press unit=0x" << std::hex
|
||||
<< (int)rio_event.Data.Unit << " modeMask=0x"
|
||||
<< (unsigned)mode_mask << std::dec << "\n" << std::flush;
|
||||
|
||||
Check(&buttonGroup[rio_event.Data.Unit]);
|
||||
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
|
||||
//----------------------------------------
|
||||
// Save 'pressed' mode mask for 'release'
|
||||
//----------------------------------------
|
||||
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask;
|
||||
|
||||
// (NO eng-page eject-key hook: REMOVED 2026-08-03 for strict binary
|
||||
// fidelity. The soft keys 0x0B/0x23/0x03 never pilot-eject in the
|
||||
// binary -- every page routes them to authored functions (weapon eng
|
||||
// pages: EjectAmmo 0xB -- a hook here HIJACKED the unjam while armed,
|
||||
// self-destructing a pilot trying to clear a jam). The flashing
|
||||
// engEject lamp is the INVITE; the authored press is the pilot KEYPAD
|
||||
// bank / PANIC key, both live on the desktop.)
|
||||
|
||||
break;
|
||||
|
||||
case RIO::ButtonReleasedEvent:
|
||||
@@ -2670,6 +2735,16 @@ void
|
||||
);
|
||||
|
||||
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]);
|
||||
{
|
||||
static int s_klog = -1;
|
||||
if (s_klog < 0) s_klog = (getenv("BT_PAD_LOG") != 0) ? 1 : 0;
|
||||
if (s_klog)
|
||||
DEBUG_STREAM << "[lbe4key] unit "
|
||||
<< (int)rio_event.Data.Keyboard.Unit
|
||||
<< " key " << (int)new_key
|
||||
<< " mode 0x" << std::hex << (unsigned)mode_mask
|
||||
<< std::dec << "\n" << std::flush;
|
||||
}
|
||||
keyboardGroup[rio_event.Data.Keyboard.Unit].
|
||||
ForceUpdate(&new_key, mode_mask);
|
||||
|
||||
|
||||
@@ -443,8 +443,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
|
||||
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
|
||||
// stay in the sky pass.
|
||||
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj;
|
||||
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp;
|
||||
// BTFX brighten veil (spot cone): route to the blend pass like the
|
||||
// vertex-alpha cards; the draw-state branch in DrawMesh adds
|
||||
// dest += emissive x factor instead of SRCALPHA/INVSRCALPHA.
|
||||
const bool brightenOp = data.batches[i].brightenFactor > 0.0f && !isSkyObj;
|
||||
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp || brightenOp;
|
||||
object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp;
|
||||
object->mDrawOps[i].brightenAlpha = brightenOp ? data.batches[i].brightenFactor : 0.0f;
|
||||
object->mDrawOps[i].drawAsDecal = false;
|
||||
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
|
||||
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
|
||||
@@ -1279,7 +1284,9 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
// (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
|
||||
// above routed it there; ALPHABLEND is on and z-write off pass-wide).
|
||||
DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0;
|
||||
const bool vtxAlphaCard = (drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND);
|
||||
const bool brightenCard = (drawOp->brightenAlpha > 0.0f && pass == PASS_ALPHABLEND);
|
||||
const bool vtxAlphaCard =
|
||||
((drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND) || brightenCard);
|
||||
if (vtxAlphaCard)
|
||||
{
|
||||
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
|
||||
@@ -1292,13 +1299,35 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
|
||||
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
|
||||
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
|
||||
mDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
|
||||
mDevice->SetRenderState(D3DRS_DESTBLEND,
|
||||
brightenCard ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA);
|
||||
if (brightenCard)
|
||||
{
|
||||
// BTFX brighten veil: dest += vertexRGB x factor. SPOT.BGF's
|
||||
// verts author the beam tint (cyan-white 0.12/0.98/1.0, alpha
|
||||
// 1.0 -- no fade gradient); the factor rides TFACTOR alpha.
|
||||
// [T3 tint compose]: the night material ALSO authors emissive
|
||||
// (0.9,0.4,0.2 warm) -- whether the DIV board tinted the veil
|
||||
// by vertex, emissive, or their product is unconfirmed; vertex
|
||||
// (a white-blue searchlight) matches the authored geometry
|
||||
// gradient model. Flip here if era-look evidence says warm.
|
||||
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR,
|
||||
D3DCOLOR_COLORVALUE(1.0f, 1.0f, 1.0f, drawOp->brightenAlpha));
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TFACTOR);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
|
||||
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
|
||||
}
|
||||
}
|
||||
|
||||
const bool copCut = (drawOp->copRole == 2 && iOp > 0
|
||||
|
||||
+1060
-30
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,23 @@ L4GaugeRenderer::L4GaugeRenderer(bool windowed, int *secondaryIndex, int *aux1In
|
||||
char
|
||||
*plasma_string = getenv("L4PLASMA");
|
||||
|
||||
//
|
||||
// L4PLASMA=NONE / OFF / 0 -- no plasma marquee at all (2026-08-06).
|
||||
// A real cab drives the 128x32 marquee over serial; a rebuilt cart may
|
||||
// not have one wired, and the GLASS profile force-defaults L4PLASMA to
|
||||
// SCREEN, which puts a desktop plasma window on the pod's glass where
|
||||
// nothing should be. Leaving the variable UNSET is not an option
|
||||
// there (the profile fills it in), so give "off" an explicit spelling.
|
||||
//
|
||||
if (plasma_string != NULL
|
||||
&& (_stricmp(plasma_string, "NONE") == 0
|
||||
|| _stricmp(plasma_string, "OFF") == 0
|
||||
|| strcmp(plasma_string, "0") == 0))
|
||||
{
|
||||
Tell("Plasma display disabled (L4PLASMA=none)\n");
|
||||
plasma_string = NULL;
|
||||
}
|
||||
|
||||
if (plasma_string != NULL)
|
||||
{
|
||||
//Win32 Serial support: ADB 06/30/07
|
||||
|
||||
@@ -36,6 +36,7 @@ int gBTPadViewToggleEdges = 0;
|
||||
// desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame.
|
||||
//
|
||||
int gBTReverseHeld = 0;
|
||||
int gBTTorsoCenterHeld = 0; // button 0x42 hold (#152; same seam as 0x3F)
|
||||
|
||||
//
|
||||
// The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed
|
||||
@@ -458,6 +459,17 @@ void
|
||||
// via SetScreenButton), so the desktop bridge can honour the button exactly
|
||||
// like the pod's RIO board did.
|
||||
//
|
||||
// TORSO CENTER (pod button 0x42, 'the shipped .RES name' -- UP arrow via
|
||||
// bindings.txt). Same chokepoint pattern as 0x3F below: publish the HOLD
|
||||
// state so the mapper's unified recenter writer (#152) can honour it on
|
||||
// every rig. Before this, no RIO/glass path reached centerCommand at all
|
||||
// -- bench: two scripted 0x42 holds, ctrCmd=0 throughout.
|
||||
if (address == 0x42)
|
||||
{
|
||||
extern int gBTTorsoCenterHeld;
|
||||
gBTTorsoCenterHeld = pressed ? 1 : 0;
|
||||
}
|
||||
|
||||
if (address == 0x3F)
|
||||
{
|
||||
gBTReverseHeld = pressed ? 1 : 0;
|
||||
@@ -466,6 +478,21 @@ void
|
||||
<< (pressed ? "HELD" : "released") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// PANIC (0x3D) -> pilot-keypad key. The binary's ONLY pilot-eject input
|
||||
// is keyboardGroup[KeyboardPilot].Add(mode 0x200000, mech, msg 0x19)
|
||||
// (FUN_004d266c; exhaustive scan: no other 0x200000 consumer). The
|
||||
// guarded PANIC key has no button-space consumer anywhere in the image,
|
||||
// so on the pod it reported through the pilot keypad matrix -- this is
|
||||
// that wire.
|
||||
if (address == 0x3D && pressed)
|
||||
EmitKeypad(LBE4ControlsManager::KeyboardPilot, 0);
|
||||
|
||||
// (The ENG-PAGE EJECT KEY intent-completion lives in the game-thread RIO
|
||||
// drain -- LBE4ControlsManager::ProcessRIOEvent, ButtonPressedEvent --
|
||||
// where mode_mask is safely in hand. A first cut queried the mode
|
||||
// manager HERE, on the window-click thread, and crashed on the unbound
|
||||
// TU global (field-caught 2026-08-03).)
|
||||
|
||||
RIOEvent event;
|
||||
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
|
||||
event.Data.Unit = address;
|
||||
@@ -479,6 +506,9 @@ void
|
||||
event.Type = KeyEvent;
|
||||
event.Data.Keyboard.Unit = unit;
|
||||
event.Data.Keyboard.Key = key;
|
||||
if (getenv("BT_PAD_LOG"))
|
||||
DEBUG_STREAM << "[padkey] unit " << unit << " key " << key
|
||||
<< " queued\n" << std::flush;
|
||||
PushEvent(event);
|
||||
}
|
||||
|
||||
@@ -524,6 +554,33 @@ void
|
||||
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
// BT_BTNTEST2="addr,pressPoll,releasePoll": an optional SECOND
|
||||
// scripted cycle (e.g. crouch then rise) on the same poll clock.
|
||||
static int s_bt2Addr = -2, s_bt2On = 0, s_bt2Off = 0, s_bt2State = 0;
|
||||
if (s_bt2Addr == -2)
|
||||
{
|
||||
s_bt2Addr = -1;
|
||||
const char *e2 = getenv("BT_BTNTEST2");
|
||||
if (e2 != NULL)
|
||||
sscanf(e2, "%i,%i,%i", &s_bt2Addr, &s_bt2On, &s_bt2Off);
|
||||
}
|
||||
if (s_bt2Addr >= 0)
|
||||
{
|
||||
if (s_bt2State == 0 && s_poll >= s_bt2On)
|
||||
{
|
||||
s_bt2State = 1;
|
||||
EmitButton(s_bt2Addr, 1);
|
||||
DEBUG_STREAM << "[btntest] PRESS2 0x" << std::hex << s_bt2Addr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
else if (s_bt2State == 1 && s_poll >= s_bt2Off)
|
||||
{
|
||||
s_bt2State = 2;
|
||||
EmitButton(s_bt2Addr, 0);
|
||||
DEBUG_STREAM << "[btntest] RELEASE2 0x" << std::hex << s_bt2Addr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,20 @@ int PCSerialPacket::ReceivePacket(BYTE *destPtr) // returns len (zero if none)
|
||||
// Returns:
|
||||
// void
|
||||
//-------------------------------------------------------------------------
|
||||
//
|
||||
// Serial-write health (see the bounded wait below): after a write timeout
|
||||
// the link is SICK for 3 s and writes fast-drop -- one stall, not one per
|
||||
// poll. BTPcspakTxTimeouts feeds the [rio] telemetry.
|
||||
//
|
||||
static unsigned gPcspakTxTimeout = 0;
|
||||
static DWORD gPcspakTxSickUntil = 0;
|
||||
|
||||
extern "C" unsigned
|
||||
BTPcspakTxTimeouts()
|
||||
{
|
||||
return gPcspakTxTimeout;
|
||||
}
|
||||
|
||||
void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
{
|
||||
//--------------------------------------------------------------
|
||||
@@ -418,6 +432,11 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
if(!enabled) //if disabled, don't even try
|
||||
return;
|
||||
|
||||
if (gPcspakTxSickUntil != 0 && GetTickCount() < gPcspakTxSickUntil)
|
||||
{
|
||||
return; // adapter sick: drop, don't stall
|
||||
}
|
||||
|
||||
BYTE cmd = *srcPtr; //get the command byte
|
||||
cmd &= 0x7F; //remove MSB
|
||||
int length = txLengthPtr[cmd]; //get the length in al
|
||||
@@ -459,11 +478,37 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
return;
|
||||
}
|
||||
}
|
||||
DWORD bytesWritten;
|
||||
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, TRUE);
|
||||
if(bytesWritten != length + 1)
|
||||
//
|
||||
// BOUNDED completion wait (2026-08-10, ALPHA-MR field minidump). The
|
||||
// RIO's "COM1" is a USB-serial adapter; when its USB link drops with a
|
||||
// write in flight the overlapped write NEVER completes, and the original
|
||||
// GetOverlappedResult(..., TRUE) parked the whole game at 0 CPU forever
|
||||
// (cdb-proven: NtWaitForSingleObject <- GetOverlappedResult <-
|
||||
// SendPacket <- RIO ctor). Now: 500 ms bound (9600 baud needs ~21 ms),
|
||||
// CancelIo on expiry, and a 3 s SICK window that fast-drops writes so a
|
||||
// dead adapter costs one stall, not one per 50 ms poll -- the game stays
|
||||
// at frame rate with a degraded RIO and the keyboard fallback alive.
|
||||
// gPcspakTxTimeout feeds the [rio] health line (txTO=).
|
||||
//
|
||||
DWORD bytesWritten = 0;
|
||||
DWORD wait_result = WaitForSingleObject(overlapSend.hEvent, 500);
|
||||
if (wait_result == WAIT_OBJECT_0)
|
||||
{
|
||||
DEBUG_STREAM << "Not all data was written to serial port!\n" << std::flush;
|
||||
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, FALSE);
|
||||
gPcspakTxSickUntil = 0;
|
||||
if (bytesWritten != (DWORD)(length + 1))
|
||||
{
|
||||
DEBUG_STREAM << "Not all data was written to serial port!\n"
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No print here -- serial-path discipline; the counter surfaces in
|
||||
// the [rio] health line (txTO=) from the glass-tick printer.
|
||||
CancelIo(hComm);
|
||||
++gPcspakTxTimeout;
|
||||
gPcspakTxSickUntil = GetTickCount() + 3000;
|
||||
}
|
||||
delete buffer;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ static LRESULT CALLBACK
|
||||
return 0;
|
||||
|
||||
case WM_CLOSE:
|
||||
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
|
||||
// Minimize, never hide (#71): SW_HIDE left no way back short of a
|
||||
// restart. A minimized window keeps its taskbar button (both framed
|
||||
// and ,noframe styles), so the player can recover the display. The
|
||||
// renderer still owns the window either way.
|
||||
ShowWindow(window, SW_MINIMIZE);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
@@ -50,6 +54,22 @@ PlasmaWindow::PlasmaWindow():
|
||||
}
|
||||
blitBuffer = new unsigned long[plasmaWidth * plasmaHeight];
|
||||
memset(blitBuffer, 0, plasmaWidth * plasmaHeight * sizeof(unsigned long));
|
||||
|
||||
//
|
||||
// INITIALIZE THE DISPLAY AREA (2026-08-10). PixelMap8(w,h) allocates its
|
||||
// pixel bytes UNCLEARED (GRAPH2D.cpp: `new Byte[w*h]`, no memset), so the
|
||||
// 128x32 plasma buffer starts as heap garbage. The real pod never showed
|
||||
// it: the serial PlasmaDisplay transfers only CHANGED lines (changedLine[]
|
||||
// is zeroed by the base ctor), so the garbage never left the buffer and
|
||||
// the hardware marquee sat at its power-on blank. This desktop window
|
||||
// blits the WHOLE buffer every frame, so the junk was visible from boot
|
||||
// until the gauge renderer first drew the callsigns. Clear to index 0
|
||||
// (the near-black background) -- the window comes up blank like the
|
||||
// hardware did.
|
||||
//
|
||||
if (pixelBuffer != NULL && pixelBuffer->Data.MapPointer != NULL)
|
||||
memset(pixelBuffer->Data.MapPointer, 0, plasmaWidth * plasmaHeight);
|
||||
|
||||
DEBUG_STREAM << "[plasmawin] desktop plasma display up (scale x"
|
||||
<< scale << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1240,105 @@ RIO::~RIO()
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//
|
||||
// [rio] telemetry state (see the tracking block in GetNextEvent). File-scope
|
||||
// statics on purpose: one hardware RIO per process, no header/layout churn --
|
||||
// and the PRINTER (BTRioHealthReport, called from the glass tick) reads ONLY
|
||||
// these statics, never RIO members, so it needs no instance and touches
|
||||
// nothing the serial layer locks.
|
||||
//
|
||||
static int gRioPresent = 0;
|
||||
static unsigned gRioReqCount = 0;
|
||||
static unsigned gRioRepCount = 0;
|
||||
static unsigned gRioBtnCount = 0;
|
||||
static int gRioReqPending = 0;
|
||||
static DWORD gRioLastReqTick = 0;
|
||||
static DWORD gRioLastReplyTick = 0;
|
||||
static int gRioStallActive = 0;
|
||||
static DWORD gRioStallStart = 0;
|
||||
static unsigned gRioStallBtnBase = 0;
|
||||
static unsigned gRioStallCount = 0;
|
||||
static DWORD gRioLastStallMs = 0;
|
||||
static unsigned gRioLastStallBtns = 0;
|
||||
static unsigned gRioLineErrMirror = 0;
|
||||
static unsigned gRioOverrunMirror = 0;
|
||||
static unsigned gRioAbandonMirror = 0;
|
||||
static unsigned gRioRRetryMirror = 0;
|
||||
static unsigned gRioRAbandonMirror = 0;
|
||||
static unsigned gRioRFullMirror = 0;
|
||||
|
||||
extern "C" unsigned BTPcspakTxTimeouts(); // L4PCSPAK.cpp: bounded-write stat
|
||||
|
||||
//
|
||||
// The printer half -- called from BTGlassPanels_Tick beside the [glassperf]
|
||||
// report (a stream site that prints every second without incident), NEVER
|
||||
// from the serial path. Edge lines land at the tick's ~1 s granularity,
|
||||
// ample for the multi-second field dropouts this exists to catch.
|
||||
// BT_RIO_LOG=0 opts out; silent when no hardware RIO ever came up.
|
||||
//
|
||||
extern "C" void
|
||||
BTRioHealthReport()
|
||||
{
|
||||
static int sLog = -1;
|
||||
static DWORD sLastLine = 0;
|
||||
static unsigned sSeenStalls = 0;
|
||||
static int sSawActive = 0;
|
||||
|
||||
if (!gRioPresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (sLog < 0)
|
||||
{
|
||||
const char *e = getenv("BT_RIO_LOG");
|
||||
sLog = (e != 0 && *e == '0') ? 0 : 1;
|
||||
}
|
||||
if (!sLog)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DWORD now = GetTickCount();
|
||||
if (gRioStallActive && !sSawActive)
|
||||
{
|
||||
sSawActive = 1;
|
||||
DEBUG_STREAM << "[rio] STALL analog silent "
|
||||
<< (now - gRioStallStart) << "ms and counting req="
|
||||
<< gRioReqCount << " rep=" << gRioRepCount
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
else if (!gRioStallActive && sSawActive)
|
||||
{
|
||||
sSawActive = 0;
|
||||
}
|
||||
if (sSeenStalls != gRioStallCount)
|
||||
{
|
||||
sSeenStalls = gRioStallCount;
|
||||
DEBUG_STREAM << "[rio] RECOVER stall#" << gRioStallCount
|
||||
<< " lasted " << gRioLastStallMs
|
||||
<< "ms btnDuring=" << gRioLastStallBtns
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
if (now - sLastLine >= 5000)
|
||||
{
|
||||
sLastLine = now;
|
||||
DEBUG_STREAM << "[rio] req=" << gRioReqCount
|
||||
<< " rep=" << gRioRepCount
|
||||
<< " btn=" << gRioBtnCount
|
||||
<< " age=" << (gRioLastReplyTick != 0
|
||||
? (now - gRioLastReplyTick) : 0)
|
||||
<< "ms stalls=" << gRioStallCount
|
||||
<< " txTO=" << BTPcspakTxTimeouts()
|
||||
<< " lineErr=" << gRioLineErrMirror
|
||||
<< " overrun=" << gRioOverrunMirror
|
||||
<< " abandon=" << gRioAbandonMirror
|
||||
<< " rRetry=" << gRioRRetryMirror
|
||||
<< " rAbandon=" << gRioRAbandonMirror
|
||||
<< " rFull=" << gRioRFullMirror
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
Logical
|
||||
RIO::TestInstance() const
|
||||
{
|
||||
@@ -1264,6 +1363,44 @@ Logical
|
||||
Logical reply, looping;
|
||||
Word errors;
|
||||
|
||||
//
|
||||
// [rio] link-health TRACKING -- pure arithmetic, no I/O here by design.
|
||||
// CORRECTED HISTORY (2026-08-10): the boot wedges first blamed on prints
|
||||
// from this path were actually the USB-serial adapter dropping its link
|
||||
// with a write in flight -- the unbounded GetOverlappedResult in
|
||||
// PCSerialPacket::SendPacket parked the game (minidump-proven; the
|
||||
// print-deadlock A/B was confounded by the adapter behaving that boot).
|
||||
// The count-here / print-from-the-glass-tick split stays anyway: it
|
||||
// keeps this hot polled path I/O-free and the printer at one known
|
||||
// stream site (BTRioHealthReport below, beside [glassperf]).
|
||||
//
|
||||
if (operational)
|
||||
{
|
||||
DWORD now = GetTickCount();
|
||||
// #160 telemetry fix: the detector used to key on REQUEST age -- but the
|
||||
// poll loop sends a fresh request every ~50ms regardless of replies, so
|
||||
// request-age never reached 500ms and the STALL line could never fire
|
||||
// (the field logs' age= field carried the signal instead). Key on the
|
||||
// REPLY drought: requests outstanding AND no AnalogReply for 500ms
|
||||
// since the last good one. Requires one good reply first (boot-mute
|
||||
// shows in the req/rep counters, not here).
|
||||
if (!gRioStallActive && gRioReqPending
|
||||
&& gRioLastReplyTick != 0
|
||||
&& now - gRioLastReplyTick > 500)
|
||||
{
|
||||
gRioStallActive = 1;
|
||||
gRioStallStart = gRioLastReplyTick;
|
||||
gRioStallBtnBase = gRioBtnCount;
|
||||
}
|
||||
gRioPresent = 1;
|
||||
gRioLineErrMirror = (unsigned) lineErrorCount;
|
||||
gRioOverrunMirror = (unsigned) overrunCount;
|
||||
gRioAbandonMirror = (unsigned) abandonCount;
|
||||
gRioRRetryMirror = (unsigned) remoteRetryCount;
|
||||
gRioRAbandonMirror = (unsigned) remoteAbandonCount;
|
||||
gRioRFullMirror = (unsigned) remoteFullBufferCount;
|
||||
}
|
||||
|
||||
//PCSPAKState(this);
|
||||
//
|
||||
//cout << flush;
|
||||
@@ -1377,6 +1514,20 @@ Logical
|
||||
break;
|
||||
|
||||
case AnalogReply:
|
||||
++gRioRepCount;
|
||||
gRioReqPending = 0;
|
||||
gRioLastReplyTick = GetTickCount();
|
||||
if (gRioStallActive)
|
||||
{
|
||||
// The reply that ended the drought -- record only; the
|
||||
// glass-tick printer emits the RECOVER line. btnDuring
|
||||
// is the field theory's verdict: >0 on every recover =
|
||||
// button traffic resyncs the link; 0 = it self-heals.
|
||||
gRioStallActive = 0;
|
||||
++gRioStallCount;
|
||||
gRioLastStallMs = gRioLastReplyTick - gRioStallStart;
|
||||
gRioLastStallBtns = gRioBtnCount - gRioStallBtnBase;
|
||||
}
|
||||
destination->Type = RIO::AnalogEvent;
|
||||
//
|
||||
// NOTE: no data is sent in this packet.
|
||||
@@ -1423,11 +1574,13 @@ Logical
|
||||
break;
|
||||
|
||||
case ButtonPressed:
|
||||
++gRioBtnCount;
|
||||
destination->Type = RIO::ButtonPressedEvent;
|
||||
destination->Data.Unit = receive_buffer[1];
|
||||
break;
|
||||
|
||||
case ButtonReleased:
|
||||
++gRioBtnCount;
|
||||
destination->Type = RIO::ButtonReleasedEvent;
|
||||
destination->Data.Unit = receive_buffer[1];
|
||||
break;
|
||||
@@ -1568,6 +1721,9 @@ void
|
||||
if (operational && !TestModeActive)
|
||||
{
|
||||
SendPacket((Byte *) request_analog_string);
|
||||
++gRioReqCount;
|
||||
gRioReqPending = 1;
|
||||
gRioLastReqTick = GetTickCount();
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -632,3 +632,49 @@ unsigned long long
|
||||
}
|
||||
return SteamUser()->GetSteamID().ConvertToUint64();
|
||||
}
|
||||
|
||||
//
|
||||
// #163 -- the ORDERED transport shutdown for process exit (2026-08-11).
|
||||
//
|
||||
// The port never called SteamAPI_Shutdown(): every round-end died via bare
|
||||
// ExitProcess with live SNS connections and Steam callback threads, and the
|
||||
// DLL_PROCESS_DETACH path intermittently DEADLOCKED. When it did, the old
|
||||
// process froze holding its last fullscreen frame ("frozen view of how the
|
||||
// game ended") while its already-spawned menu child hung inside
|
||||
// SteamAPI_Init against the wedged sibling's client session -- field-
|
||||
// captured on ZEUS at 22:12:38 (a 3-line child boot stub that never reached
|
||||
// the unconditional [steamnet] up line). Closing every connection and
|
||||
// shutting the API down BEFORE the child is spawned hands it a clean Steam
|
||||
// client and leaves detach nothing to strangle. Callers then die via
|
||||
// TerminateProcess, which skips DLL detach entirely.
|
||||
//
|
||||
void
|
||||
BTSteamNet_ShutdownAll()
|
||||
{
|
||||
if (!steamActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ISteamNetworkingSockets *sockets = SteamNetworkingSockets();
|
||||
for (int i = 0; i < MaxConnections; ++i)
|
||||
{
|
||||
if (connections[i].inUse
|
||||
&& connections[i].connection != k_HSteamNetConnection_Invalid)
|
||||
{
|
||||
sockets->CloseConnection(connections[i].connection, 0,
|
||||
"round over", false);
|
||||
}
|
||||
connections[i].inUse = 0;
|
||||
}
|
||||
for (int p = 0; p < FakePortCount; ++p)
|
||||
{
|
||||
if (listenSockets[p] != k_HSteamListenSocket_Invalid)
|
||||
{
|
||||
sockets->CloseListenSocket(listenSockets[p]);
|
||||
listenSockets[p] = k_HSteamListenSocket_Invalid;
|
||||
}
|
||||
}
|
||||
SteamAPI_RunCallbacks(); // let the closes flush client-side
|
||||
steamActive = 0;
|
||||
SteamAPI_Shutdown();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ int
|
||||
BTSteamNet_Active();
|
||||
void
|
||||
BTSteamNet_Pump(); // callbacks + rx drain (game thread)
|
||||
void
|
||||
BTSteamNet_ShutdownAll(); // #163: ordered transport death for
|
||||
// process exit -- close every
|
||||
// connection, then SteamAPI_Shutdown
|
||||
|
||||
//
|
||||
// The wire-seam surface (BTNet* wrappers in L4NET.CPP + the marshal).
|
||||
|
||||
@@ -692,6 +692,28 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
||||
device->DrawPrimitiveUP(D3DPT_TRIANGLEFAN, 2, quad, sizeof(InsetVert));
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS dirty-skip: FNV-1a over the shared pixelBuffer masked to `mask` -- the bits
|
||||
// one glass window can show. The glass repaint pump compares this per window and
|
||||
// re-blits only the ones whose plane changed (L4GLASSWIN BTGlassPanels_Tick). Full
|
||||
// pass (no stride) so a single-word gauge change is never missed; ~640*480 cheap
|
||||
// integer ops, run at most once per window per ~16 Hz pump.
|
||||
//===========================================================================//
|
||||
unsigned long SVGA16::PlaneChecksum(int mask) const
|
||||
{
|
||||
int w = pixelBuffer.Data.Size.x;
|
||||
int h = pixelBuffer.Data.Size.y;
|
||||
const Word *p = pixelBuffer.Data.MapPointer;
|
||||
if (p == NULL || w <= 0 || h <= 0)
|
||||
return 0;
|
||||
unsigned long sum = 2166136261UL; // FNV-1a offset basis
|
||||
Word m = (Word)mask;
|
||||
int n = w * h;
|
||||
for (int i = 0; i < n; ++i)
|
||||
sum = (sum ^ (unsigned long)(p[i] & m)) * 16777619UL;
|
||||
return sum;
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS per-display windows -- the CPU (no-D3D) analog of DrawDevSurface: expand
|
||||
// one bit-plane of the shared gauge pixelBuffer into a 32-bit BGRA image that the
|
||||
@@ -739,6 +761,34 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
||||
return;
|
||||
}
|
||||
|
||||
// 180 degrees (rotate 2): dimensions UNCHANGED -- the case a landscape
|
||||
// replacement panel needs when the 1995 glass was a portrait CRT mounted
|
||||
// the other way up. Added 2026-08-06 from the crash cart, whose radar is a
|
||||
// landscape LCD: 90 CW left the picture portrait and overhanging its
|
||||
// monitor, and a further 90 CW is this.
|
||||
if (rotateQuadrant == 2)
|
||||
{
|
||||
for (int oy = 0; oy < h; oy++)
|
||||
{
|
||||
unsigned long *d = dst + oy * w;
|
||||
for (int ox = 0; ox < w; ox++)
|
||||
{
|
||||
Word s = base[(h - 1 - oy) * w + (w - 1 - ox)];
|
||||
if (monoTint < 0)
|
||||
{
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||
d[ox] = ((unsigned long)pe->Red << 16) |
|
||||
((unsigned long)pe->Green << 8) | (unsigned long)pe->Blue;
|
||||
}
|
||||
else
|
||||
d[ox] = (s & mask) ? (unsigned long)monoTint : 0UL;
|
||||
}
|
||||
}
|
||||
if (outW) *outW = w;
|
||||
if (outH) *outH = h;
|
||||
return;
|
||||
}
|
||||
|
||||
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
|
||||
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
|
||||
int ow = h, oh = w;
|
||||
@@ -1180,6 +1230,15 @@ void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device)
|
||||
}
|
||||
}
|
||||
|
||||
// (NO full-face "flash overlay" here -- twice field-broken 2026-08-03.
|
||||
// The side buttons are BIG rects deliberately tucked UNDER the surfaces;
|
||||
// step 2 already animates their lamp shades, and the protruding edge IS
|
||||
// the port's lamp light (the pod's backlit keys sat beside the CRT --
|
||||
// there is no in-display face to light). An on-top redraw either covers
|
||||
// the MFD imagery with flashing faces, or -- drawn without resetting the
|
||||
// texture state DrawDevSurface leaves bound -- smears the gauge atlas as
|
||||
// striped garbage (operator captures: green stripes, then red faces).)
|
||||
|
||||
// 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
|
||||
for (int i = 0; i < L.buttonCount; i++)
|
||||
{
|
||||
@@ -1293,7 +1352,7 @@ void BTCockpitMouseUp(void)
|
||||
// that turn fires only ONCE PER FULL GAUGE CYCLE (foreground -> background -> copy).
|
||||
// The cycle only advances to the next foreground turn after the THROTTLED background
|
||||
// gauge sweep drains the whole active-instrument list -- and that background task
|
||||
// starves under load (issue #45: "every cockpit instrument freezes at its last paint
|
||||
// starves under load (field reports: "every cockpit instrument freezes at its last paint
|
||||
// while the underlying values AND fps stay perfectly healthy"). So on a busy MP
|
||||
// mission the lamp sweep ran ~1x/second: the lit buttons froze / flashes stalled while
|
||||
// the 3D view (a separate per-frame foreground render) stayed smooth -- exactly the
|
||||
|
||||
@@ -419,6 +419,12 @@ public:
|
||||
// dwords; the image is written TOP-DOWN. *outW/*outH receive the produced size.
|
||||
void ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotateQuadrant,
|
||||
unsigned long *dst, int *outW, int *outH);
|
||||
|
||||
// GLASS dirty-skip (L4GLASSWIN, 2026-08-09): FNV-1a checksum of the shared
|
||||
// gauge pixelBuffer, masked to the bits a given window can show. Lets the
|
||||
// glass repaint pump re-blit ONLY the windows whose plane actually changed
|
||||
// (idle MFDs / static panels skip; the sweeping radar keeps updating).
|
||||
unsigned long PlaneChecksum(int mask) const;
|
||||
};
|
||||
|
||||
//########################################################################
|
||||
|
||||
@@ -4361,6 +4361,12 @@ void
|
||||
fogFar = searchLightFogFar;
|
||||
if(fogUpdating)
|
||||
{
|
||||
// BT (searchlight, 2026-08-05): the stubbed dpl_SetViewFog below also
|
||||
// carried the PLANES; the per-frame FOGSTART/FOGEND application reads
|
||||
// currentFogNear/Far, so the swap must land there too (the color line
|
||||
// alone left the fog DISTANCES stuck at load values).
|
||||
currentFogNear = fogNear;
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||
// dpl_SetViewFog(
|
||||
// dplMainView,
|
||||
@@ -4381,6 +4387,8 @@ void
|
||||
fogFar = noSearchLightFogFar;
|
||||
if(fogUpdating)
|
||||
{
|
||||
currentFogNear = fogNear; // see the On case
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||
// dpl_SetViewFog(
|
||||
// dplMainView,
|
||||
@@ -8433,6 +8441,107 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
lastFrameTime = mTargetRenderTime;
|
||||
currentFrameTime = Now();
|
||||
|
||||
// #156 -- the MISSION START/END FADE (POVStartEndRenderable, ctor @00454394,
|
||||
// Execute @0045447c; the port class is armed by MakeEntityRenderables'
|
||||
// BTPlayer master case via BTMissionFadeArm). The 1995 renderable drives
|
||||
// the POV zone fog through a 5-phase machine keyed on the local player's
|
||||
// SimulationState; constants byte-read from BTL4OPT.EXE @0x454740-0x454750:
|
||||
// flash hold 0.1s, fades 1.0s, plane floors near 0.01 / far 0.05. Phases:
|
||||
// 0 armed -> (MissionStarting) 1 WHITE FLASH (fog 1,1,1 @ 0.01/0.05) ->
|
||||
// 2 FADE-IN (white -> live style; planes floor -> authored) -> 3 running ->
|
||||
// (MissionEnding) 4 FADE-OUT (color x local -> BLACK, planes close) ->
|
||||
// re-arm. The binary re-reads the LIVE fog each frame mid-fade (style
|
||||
// swaps track); we read the STYLE members (fogRed/G/B, fogNear/Far), never
|
||||
// currentFogNear/Far which we are overriding. Placed at the frame head:
|
||||
// the Clear below samples FOGCOLOR, so the fade owns the WHOLE frame
|
||||
// including sky/void. Receipts: [fade] phase lines under BT_FADE_LOG or
|
||||
// BT_FOG_LOG.
|
||||
{
|
||||
extern void *gBTFadeIndicator; // BTMissionFadeArm (btl4vid.cpp)
|
||||
extern int gBTFadeStart, gBTFadeEnd;
|
||||
static int s_phase = 0;
|
||||
static float s_T = 0.0f;
|
||||
if (gBTFadeIndicator != 0)
|
||||
{
|
||||
const float kFlash = 0.1f, kFade = 1.0f; // @0x454740 / @0x454744
|
||||
const float kNearFloor = 0.01f, kFarFloor = 0.05f; // @0x454750 / @0x45474c
|
||||
int st = ((StateIndicator *)gBTFadeIndicator)->GetState();
|
||||
float fdt = (float)dT;
|
||||
if (fdt < 0.0f) fdt = 0.0f; else if (fdt > 0.1f) fdt = 0.1f;
|
||||
float r = -1.0f, g = 0.0f, b = 0.0f, fn = 0.0f, ff = 0.0f;
|
||||
switch (s_phase)
|
||||
{
|
||||
case 0:
|
||||
if (st == gBTFadeStart) { s_phase = 1; s_T = 0.0f; }
|
||||
break;
|
||||
case 1:
|
||||
r = 1.0f; g = 1.0f; b = 1.0f; fn = kNearFloor; ff = kFarFloor;
|
||||
s_T += fdt;
|
||||
if (s_T >= kFlash) { s_phase = 2; s_T = 0.0f; }
|
||||
break;
|
||||
case 2:
|
||||
{
|
||||
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
|
||||
float el = kFade - local;
|
||||
r = (1.0f - fogRed) * local + fogRed;
|
||||
g = (1.0f - fogGreen) * local + fogGreen;
|
||||
b = (1.0f - fogBlue) * local + fogBlue;
|
||||
fn = fogNear * el + kNearFloor;
|
||||
ff = fogFar * el + kFarFloor;
|
||||
s_T += fdt;
|
||||
if (local <= 0.0f) { s_phase = 3; r = -1.0f; } // fog back to the style
|
||||
break;
|
||||
}
|
||||
case 3:
|
||||
if (st == gBTFadeEnd) { s_phase = 4; s_T = 0.0f; }
|
||||
break;
|
||||
case 4:
|
||||
{
|
||||
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
|
||||
r = fogRed * local;
|
||||
g = fogGreen * local;
|
||||
b = fogBlue * local;
|
||||
fn = fogNear * local + kNearFloor;
|
||||
ff = fogFar * local + kFarFloor;
|
||||
s_T += fdt;
|
||||
if (local <= 0.0f) { s_phase = 0; } // re-armed; final frame stays black
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (r >= 0.0f)
|
||||
{
|
||||
currentFogNear = fn;
|
||||
currentFogFar = ff;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR,
|
||||
D3DCOLOR_XRGB((int)(255 * r), (int)(255 * g), (int)(255 * b)));
|
||||
mDevice->SetRenderState(D3DRS_FOGENABLE, TRUE);
|
||||
}
|
||||
else if (s_phase == 3 || s_phase == 0)
|
||||
{
|
||||
// Out of the fade: restore the style planes once so a fade that
|
||||
// ended mid-style leaves the world exactly on its authored fog.
|
||||
static int s_prev = -1;
|
||||
if (s_prev != s_phase)
|
||||
{
|
||||
currentFogNear = fogNear;
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR,
|
||||
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
|
||||
(int)(255 * fogBlue)));
|
||||
}
|
||||
s_prev = s_phase;
|
||||
}
|
||||
static int s_lastLogged = -1;
|
||||
if (s_phase != s_lastLogged)
|
||||
{
|
||||
s_lastLogged = s_phase;
|
||||
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[fade] phase " << s_phase
|
||||
<< " (simState=" << st << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DIAG (turn-hitch hunt): time the render phases -- draw CPU vs Present
|
||||
// (GPU-queue block). Logged on slow frames + 1 Hz stats.
|
||||
LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0);
|
||||
@@ -9026,6 +9135,20 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames)
|
||||
<< "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP
|
||||
<< " batches=" << gNumBatches << " culled=" << gBTNumCulled << "\n" << std::flush;
|
||||
// #149: segment-refresh telemetry on the same cadence (BT_PERF_LOG).
|
||||
// calls = GetSegmentToWorld entries; dirty = the mark-every-segment
|
||||
// invalidation passes (the expensive arm the #141 sweep may have
|
||||
// multiplied); ms = time inside the accessor for the whole window.
|
||||
{
|
||||
static const int sSegPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
||||
extern int gBTSegWCalls, gBTSegWDirty;
|
||||
extern double gBTSegWMs;
|
||||
if (sSegPerf)
|
||||
DEBUG_STREAM << "[segperf] calls=" << gBTSegWCalls
|
||||
<< " dirty=" << gBTSegWDirty
|
||||
<< " ms=" << gBTSegWMs << "\n" << std::flush;
|
||||
gBTSegWCalls = 0; gBTSegWDirty = 0; gBTSegWMs = 0.0;
|
||||
}
|
||||
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,12 @@ struct MatInfo {
|
||||
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
|
||||
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
|
||||
bool hasEmissive = false;
|
||||
// BTFX "brighten" class (SPOT.BGF searchlight beam cone): the material
|
||||
// smuggles its ADDITIVE factor in DIFFUSE.r (brighten.25 authors diffuse
|
||||
// {0.25,0,0}) and its glow colour in EMISSIVE ({0.9,0.4,0.2} on the night
|
||||
// page). 0 = not a brighten material; >0 = draw as an additive veil,
|
||||
// dest += emissive x factor (the 1995 DIV brighten semantics).
|
||||
float brightenFactor = 0.0f;
|
||||
float emissive[3] = {0,0,0};
|
||||
};
|
||||
|
||||
@@ -495,12 +501,14 @@ struct MaterialResolver {
|
||||
std::string name, texName, rampName;
|
||||
MatInfo info;
|
||||
bool haveColor = false;
|
||||
float rawDiffuseR = -1.0f; // brighten-class factor carrier
|
||||
for (const Chunk& ch : c.children) {
|
||||
if (ch.id == TAG_NAME) name = chunkStr(ch);
|
||||
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
|
||||
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
|
||||
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
|
||||
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
|
||||
rawDiffuseR = rdF32(ch.data);
|
||||
haveColor = true;
|
||||
info.hasDiffuse = true;
|
||||
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor
|
||||
@@ -520,6 +528,12 @@ struct MaterialResolver {
|
||||
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
|
||||
}
|
||||
}
|
||||
// BTFX brighten class: name-gated so ordinary materials keep
|
||||
// their diffuse semantics untouched (the factor rides only in
|
||||
// "brighten*" records -- brighten.25/.5/.75 in btfx.bmf).
|
||||
if (name.compare(0, 8, "brighten") == 0 && rawDiffuseR > 0.0f)
|
||||
info.brightenFactor = rawDiffuseR;
|
||||
|
||||
// Resolve the ramp reference to its low/high colours: this
|
||||
// file's own definitions first, then the cross-library
|
||||
// registry (mech skins reference 'softer' defined elsewhere).
|
||||
@@ -640,10 +654,12 @@ struct Builder {
|
||||
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
|
||||
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
|
||||
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
|
||||
std::string meshStem; // lowercased load stem (BT_MAT_LOG diag)
|
||||
std::map<const void*, int> copRoleMap; // punch-kit roles per PMESH chunk: 1=mask 2=hull 3=skip (see TAG_PATCH)
|
||||
int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
|
||||
bool currentHasEmissive = false;
|
||||
float currentEmissive[3] = {0,0,0};
|
||||
float currentBrighten = 0.0f; // btfx brighten-class additive factor
|
||||
|
||||
// per-vertex scratch (normals accumulated from forward triangles only)
|
||||
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
|
||||
@@ -778,7 +794,19 @@ struct Builder {
|
||||
// value is not recoverable from the code (texture-less ramp = no texel
|
||||
// luminance to index); default (0.13,0.12,0.15) [T3] matches the dark
|
||||
// near-black frame in pod gameplay footage. BT_COP_FRAME="r g b" overrides.
|
||||
if (useRamp && currentTex.empty() && meshIsCop) {
|
||||
// The same interior-structure treatment applies to the blakskn SKIN
|
||||
// family on ANY mesh, not just *_cop shells: the inside skeletons
|
||||
// author pilot-facing surfaces of the OWN-BODY meshes (thor thx_msl
|
||||
// missile pod, owens owx_* legs -- issue #91) with the identical
|
||||
// "<pfx>skin:blakskn_dz_*" material the canopy frame uses, and the
|
||||
// board shaded them all the same way. Keying on the filename alone
|
||||
// left those surfaces vcol=pure-black -- the reported "black
|
||||
// rectangle" was the thor pod's blakskn mount plate at RGB(0,0,0)
|
||||
// instead of the frame constant. mechfx:blakskn_mtl (the tshd
|
||||
// shadow quads) must NOT match -- key on the skin-lib "_dz_" form.
|
||||
const bool blaksknSkin =
|
||||
currentMatName.find("skin:blakskn_dz_") != std::string::npos;
|
||||
if (useRamp && currentTex.empty() && (meshIsCop || blaksknSkin)) {
|
||||
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
|
||||
vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
|
||||
}
|
||||
@@ -913,6 +941,7 @@ struct Builder {
|
||||
batch.shadowMat = currentShadowMat;
|
||||
batch.hasEmissive = currentHasEmissive;
|
||||
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
|
||||
batch.brightenFactor = currentBrighten; // btfx additive veil (spot cone)
|
||||
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
|
||||
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
|
||||
// PUNCH-cutout overlay + an inner shell, plane separations
|
||||
@@ -939,6 +968,28 @@ struct Builder {
|
||||
else
|
||||
batch.lodBias = 0.0f;
|
||||
mesh->batches.push_back(batch);
|
||||
// #91 diag: BT_MAT_LOG=<substr> dumps each batch of a matching mesh
|
||||
// stem -- the material routing decision (ramp vs lit vs emissive,
|
||||
// resolved colour, texture) for the inside-view black-mesh hunt.
|
||||
{
|
||||
static const char* s_matLog = getenv("BT_MAT_LOG");
|
||||
if (s_matLog && *s_matLog
|
||||
&& meshStem.find(s_matLog) != std::string::npos)
|
||||
fprintf(stderr, "[matlog] %s mat=%s color=%08X hasDiff=%d "
|
||||
"tex='%s' ch=%d hasRamp=%d useRamp=%d norms=%d "
|
||||
"emis=%d vcol=%08X tris=%u\n",
|
||||
meshStem.c_str(), currentMatName.c_str(), currentColor,
|
||||
(int)currentHasDiffuse, currentTex.c_str(),
|
||||
currentTexChannel, (int)currentHasRamp, (int)useRamp,
|
||||
(int)hasNormals(vtag), (int)pureEmissive, vcol,
|
||||
idxCount / 3);
|
||||
if (s_matLog && *s_matLog
|
||||
&& meshStem.find(s_matLog) != std::string::npos)
|
||||
fprintf(stderr, "[matlog] ramp lo=(%.3f,%.3f,%.3f) "
|
||||
"hi=(%.3f,%.3f,%.3f)\n",
|
||||
currentRampLo[0], currentRampLo[1], currentRampLo[2],
|
||||
currentRampHi[0], currentRampHi[1], currentRampHi[2]);
|
||||
}
|
||||
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
|
||||
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
|
||||
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
|
||||
@@ -1216,8 +1267,9 @@ struct Builder {
|
||||
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
|
||||
currentHasEmissive = info.hasEmissive;
|
||||
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
|
||||
currentBrighten = info.brightenFactor;
|
||||
}
|
||||
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; }
|
||||
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; currentBrighten = 0.0f; }
|
||||
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
|
||||
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
|
||||
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
|
||||
@@ -1386,6 +1438,7 @@ bool LoadBgfFile(const std::string& name, BgfData& out) {
|
||||
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
|
||||
// these meshes only.
|
||||
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
|
||||
b.meshStem = stemLower(name);
|
||||
b.res = &res;
|
||||
for (const Chunk& c : roots) b.collect(c);
|
||||
b.finish();
|
||||
|
||||
@@ -44,6 +44,12 @@ struct BgfDrawBatch {
|
||||
// alpha). The verts keep their authored RGBA gradient and the draw routes
|
||||
// to the alpha-blend pass, unlit, colour = texture x vertex gradient.
|
||||
bool vertexAlpha = false;
|
||||
// BTFX "brighten" ADDITIVE VEIL (the searchlight SPOT.BGF beam cone): the
|
||||
// material class authors its factor in DIFFUSE.r and its glow colour in
|
||||
// EMISSIVE; the batch draws in the blend pass as dest += emissive x factor
|
||||
// (1995 DIV brighten semantics; the record also authors fog IMMUNE +
|
||||
// DRAWLAST, both satisfied by the blend pass). 0 = not a brighten batch.
|
||||
float brightenFactor = 0.0f;
|
||||
// TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
|
||||
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
|
||||
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
|
||||
|
||||
@@ -109,6 +109,11 @@ struct L4DRAWOP
|
||||
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
|
||||
// per-vertex fade (SRCALPHA/INVSRCALPHA).
|
||||
bool vertexAlphaBlend;
|
||||
// BTFX BRIGHTEN VEIL (the searchlight SPOT.BGF cone): additive brighten of
|
||||
// the framebuffer, dest += material.Emissive x this factor (the class
|
||||
// authors the factor in DIFFUSE.r, the glow colour in EMISSIVE). Drawn in
|
||||
// the alpha-blend pass, unlit, fog-immune by authoring. 0 = off.
|
||||
float brightenAlpha;
|
||||
// COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
|
||||
// 1=aperture MASK (drawn stencil-only, paired with the following hull op),
|
||||
// 2=HULL (drawn stencil-rejected under the mask = window cutouts).
|
||||
|
||||
@@ -31,6 +31,12 @@ void
|
||||
void
|
||||
BTGlassPanels_Destroy();
|
||||
|
||||
// BT_GLASS_IDS=1 -- log every attached panel's BOOT-STABLE hardware identity
|
||||
// plus a ready-to-paste `monitor:id:<fragment>` line for glass_layout.cfg.
|
||||
// Called from BTGlassPanels_Create; no-op unless the env is set.
|
||||
void
|
||||
BTGlassDumpMonitorIds();
|
||||
|
||||
//
|
||||
// Per-frame repaint pump. Call once per frame from the main render loop so the
|
||||
// per-display windows' lamp flash keeps animating even when they are in the
|
||||
|
||||
+84
-1
@@ -234,6 +234,36 @@ static LONG WINAPI
|
||||
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
|
||||
}
|
||||
|
||||
//
|
||||
// lastrun_<stem>.txt clean-exit markers (#157, 2026-08-10): the launch record
|
||||
// only ever said "exe reached WinMain", so a night that ended with the player
|
||||
// closing the menu was indistinguishable from a silent death -- and the bat's
|
||||
// sign-off taught players to report normal quits as crashes. The deliberate
|
||||
// exit paths stamp the record themselves now, so the file answers "crash or
|
||||
// quit?" on its own. Name captured at breadcrumb time (cwd is content\ by
|
||||
// then -- the same file the bats bracket).
|
||||
//
|
||||
static char gBTLastrunName[MAX_PATH] = "";
|
||||
|
||||
void
|
||||
BTLastrunNote(const char *note)
|
||||
{
|
||||
if (gBTLastrunName[0] == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SYSTEMTIME lt;
|
||||
GetLocalTime(<);
|
||||
std::ofstream f(gBTLastrunName, std::ios::out | std::ios::app);
|
||||
if (f.is_open())
|
||||
{
|
||||
char stamp[64];
|
||||
wsprintfA(stamp, "[%04d-%02d-%02d %02d:%02d:%02d] ",
|
||||
lt.wYear, lt.wMonth, lt.wDay, lt.wHour, lt.wMinute, lt.wSecond);
|
||||
f << stamp << note << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
|
||||
// per line; the real environment always WINS, so a launcher .bat or a shell
|
||||
@@ -407,6 +437,32 @@ static const char *gBTCwdFixNote = NULL;
|
||||
//
|
||||
int gBTFitDisplay = 0;
|
||||
|
||||
//
|
||||
// L4PLASMA=NONE/OFF/0 means "no marquee at all" (L4GREND.cpp reads the same
|
||||
// spellings). Only used to keep the boot banner honest.
|
||||
//
|
||||
static bool BTPlasmaDisabled(void)
|
||||
{
|
||||
const char *p = getenv("L4PLASMA");
|
||||
return p != NULL && (_stricmp(p, "NONE") == 0 ||
|
||||
_stricmp(p, "OFF") == 0 ||
|
||||
strcmp(p, "0") == 0);
|
||||
}
|
||||
|
||||
//
|
||||
// Which control device the GLASS profile actually resolved to. The banner
|
||||
// used to hardcode "PadRIO", which is a lie on a cab wired to the real board
|
||||
// -- and precisely the log line someone reads to check that (2026-08-06).
|
||||
//
|
||||
static const char *BTControlsSummary(void)
|
||||
{
|
||||
const char *c = getenv("L4CONTROLS");
|
||||
if (c == NULL) return "PadRIO";
|
||||
if (_strnicmp(c, "RIO", 3) == 0) return "hardware RIO";
|
||||
if (_strnicmp(c, "PAD", 3) == 0) return "PadRIO";
|
||||
return c;
|
||||
}
|
||||
|
||||
static void BTEnsureContentDirectory(void)
|
||||
{
|
||||
if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES)
|
||||
@@ -807,6 +863,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
GetComputerNameA(machine, &machine_len);
|
||||
char lastrun_name[MAX_PATH];
|
||||
wsprintfA(lastrun_name, "lastrun_%s.txt", logStem);
|
||||
lstrcpynA(gBTLastrunName, lastrun_name, MAX_PATH);
|
||||
std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app);
|
||||
if (breadcrumb.is_open())
|
||||
{
|
||||
@@ -848,6 +905,18 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
{
|
||||
gBTFitDisplay = 1;
|
||||
}
|
||||
//
|
||||
// BT_FIT=1 -- the env spelling of -fit, so a POD can freeze it in
|
||||
// content\environ.ini alongside the rest of its rig config instead of
|
||||
// depending on every launcher passing the flag (2026-08-06, crash cart:
|
||||
// the main view has to fill its 800x600 panel on every boot, however the
|
||||
// game gets started -- shortcut, autostart or scheduled task).
|
||||
//
|
||||
{
|
||||
const char *fitEnv = getenv("BT_FIT");
|
||||
if (fitEnv != NULL && fitEnv[0] != '\0' && fitEnv[0] != '0')
|
||||
gBTFitDisplay = 1;
|
||||
}
|
||||
|
||||
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
|
||||
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
|
||||
@@ -1145,8 +1214,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
std::cout << "[boot] platform profile: "
|
||||
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
|
||||
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
|
||||
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
|
||||
: gBTPlatformGlass ? "GLASS"
|
||||
: "DEV (single window + keyboard)");
|
||||
if (gBTPlatformGlass && !fe_menu_mode)
|
||||
std::cout << " (" << BTControlsSummary()
|
||||
<< (BTPlasmaDisabled() ? "; plasma off [L4PLASMA])" : " + plasma window)");
|
||||
if (!fe_menu_mode)
|
||||
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
|
||||
std::cout << std::endl << std::flush;
|
||||
@@ -1168,6 +1240,17 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
BTFeLaunchSpec fe_spec;
|
||||
if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone)
|
||||
{
|
||||
BTLastrunNote("clean exit: player quit from the menu");
|
||||
#ifdef BT_STEAM
|
||||
// #163: the menu keeps the Steam transport up for the lobby;
|
||||
// returning through the CRT would run DLL detach with live
|
||||
// Steam threads -- the same intermittent deadlock the
|
||||
// round-end relaunch hit. Tear it down deliberately first.
|
||||
{
|
||||
extern void BTSteamNet_ShutdownAll();
|
||||
BTSteamNet_ShutdownAll();
|
||||
}
|
||||
#endif
|
||||
return 0; // quit from the menu
|
||||
}
|
||||
char fe_arguments[192];
|
||||
|
||||
@@ -94,6 +94,7 @@ static MarshalState marshalState;
|
||||
//
|
||||
#ifdef BT_STEAM
|
||||
extern int BTSteamNet_IsFakeAddress(unsigned long internet_address_be);
|
||||
extern void BTSteamNet_ShutdownAll(); // #163: ordered exit
|
||||
extern SOCKET BTSteamNet_Connect(unsigned long internet_address_be, int remote_port);
|
||||
extern int BTSteamNet_Owns(SOCKET wire_socket);
|
||||
extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length);
|
||||
@@ -153,6 +154,48 @@ static unsigned long
|
||||
return gBTBootTick != 0 ? gBTBootTick : GetTickCount();
|
||||
}
|
||||
|
||||
//
|
||||
// #163 -- ORDERLY DEATH (2026-08-11). Bare ExitProcess ran DLL detach with
|
||||
// live Steam threads and intermittently deadlocked: the dying process kept
|
||||
// its last fullscreen frame on screen ("frozen view of how the game ended")
|
||||
// while the healthy menu child it had already spawned hung in SteamAPI_Init
|
||||
// against the wedged sibling -- both round-end freeze reports AND the
|
||||
// hang-on-exit reports, one mechanism (the HOST worst: this exit runs on
|
||||
// the MARSHAL thread while the main thread is mid-teardown). The order:
|
||||
// flush the forensics, tear the Steam transport down, hide every window
|
||||
// this process owns (a residual wedge must never squat on the screen), then
|
||||
// TerminateProcess -- which skips DLL detach entirely, making the deadlock
|
||||
// structurally unreachable. The 1995 pod never exited between rounds at
|
||||
// all (@0x47c560 parks in WaitingForEgg), so exit is pure port territory --
|
||||
// no binary semantic to preserve.
|
||||
//
|
||||
static BOOL CALLBACK
|
||||
HideProcessWindow(HWND window, LPARAM pid)
|
||||
{
|
||||
DWORD owner = 0;
|
||||
GetWindowThreadProcessId(window, &owner);
|
||||
if (owner == (DWORD)pid)
|
||||
{
|
||||
ShowWindow(window, SW_HIDE);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
BTOrderlyDie(void)
|
||||
{
|
||||
{
|
||||
extern void BTMatchLogClose();
|
||||
BTMatchLogClose(); // complete receipts on disk (the
|
||||
} // field matchlogs lost their tails)
|
||||
fflush(NULL);
|
||||
#ifdef BT_STEAM
|
||||
BTSteamNet_ShutdownAll();
|
||||
#endif
|
||||
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
}
|
||||
|
||||
void
|
||||
BTFE_RelaunchSelfAndExit(const char *arguments)
|
||||
{
|
||||
@@ -174,7 +217,9 @@ void
|
||||
{
|
||||
MarshalLog("window closed by the user -- exiting for real "
|
||||
"(no relaunch)");
|
||||
ExitProcess(0);
|
||||
extern void BTLastrunNote(const char *);
|
||||
BTLastrunNote("clean exit: player closed the game window");
|
||||
BTOrderlyDie(); // #163: never bare ExitProcess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +294,18 @@ void
|
||||
}
|
||||
command_line[n] = 0;
|
||||
|
||||
// #163 step (a): forensics flushed + the Steam transport DOWN before the
|
||||
// child exists -- it must find a clean Steam client (the field capture:
|
||||
// a menu child hung in SteamAPI_Init against its wedged sibling).
|
||||
{
|
||||
extern void BTMatchLogClose();
|
||||
BTMatchLogClose();
|
||||
}
|
||||
fflush(NULL);
|
||||
#ifdef BT_STEAM
|
||||
BTSteamNet_ShutdownAll();
|
||||
#endif
|
||||
|
||||
STARTUPINFOW startup;
|
||||
PROCESS_INFORMATION process;
|
||||
memset(&startup, 0, sizeof(startup));
|
||||
@@ -257,10 +314,22 @@ void
|
||||
if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE,
|
||||
0, NULL, NULL, &startup, &process))
|
||||
{
|
||||
// Hand the child our foreground right BEFORE we die (#157): Windows
|
||||
// refuses SetForegroundWindow to a process spawned by an exiting
|
||||
// background parent, so the round-end menu could open BEHIND the
|
||||
// desktop clutter -- which is how a normal between-rounds relaunch
|
||||
// read as "the game crashed" from the cockpit seat.
|
||||
if (!AllowSetForegroundWindow(process.dwProcessId))
|
||||
{
|
||||
MarshalLog("foreground handoff denied -- the next window may "
|
||||
"open behind");
|
||||
}
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
}
|
||||
ExitProcess(0);
|
||||
// #163 step (b): vanish, then die without touching DLL detach.
|
||||
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
|
||||
+18
-1
@@ -412,7 +412,7 @@ static const CatalogEntry kVehicles[] =
|
||||
{ "avatar", "Avatar" },
|
||||
{ "ava1", "Satyr" },
|
||||
{ "sunder", "Sunder" },
|
||||
{ "snd1", "Sunder V1" },
|
||||
{ "snd1", "Denkou" }, // #69: the 4.x-era name (two era witnesses)
|
||||
{ "vulture", "Vulture" },
|
||||
{ "vul1", "Mad Dog" },
|
||||
{ "lok2", "Mischief" },
|
||||
@@ -1200,6 +1200,7 @@ int
|
||||
{
|
||||
BTLobbyRoster roster;
|
||||
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
||||
roster.memberCount == 0)
|
||||
@@ -1207,6 +1208,15 @@ int
|
||||
return 1; // cancelled / Steam unavailable
|
||||
}
|
||||
spec->mode = BTFeLaunchHostSteam;
|
||||
// #162: capture the host's ADV. DAMAGE menu choice BEFORE the roster
|
||||
// loop -- `self` aliases mission.pilots[0], which the loop overwrites
|
||||
// with the first lobby member. advancedDamage is a MISSION-level
|
||||
// setting (every stock 1995 egg stamps the same value on every pilot
|
||||
// page); leaving it unset here shipped advancedDamage=0 for EVERY
|
||||
// pilot of EVERY steam match, which silently disabled the -500 death
|
||||
// cost and the death splash all night (night-15 [exp] receipts:
|
||||
// advDamage=0 on 24 of 26 group-mission spawns).
|
||||
const int host_adv_damage = self.advancedDamage;
|
||||
mission.pilotCount = 0;
|
||||
sprintf(spec->podList, "127.0.0.1:%d", console_port);
|
||||
for (int m = 0; m < roster.memberCount && m < 8; ++m)
|
||||
@@ -1226,6 +1236,12 @@ int
|
||||
// but now only for clients that never published a choice.
|
||||
strncpy(pilot.experience, member.experience,
|
||||
sizeof(pilot.experience) - 1);
|
||||
// #38: badge + patch ride the lobby the same way (absent = the
|
||||
// writer's VGL/Red fallback, which is exactly what every non-host
|
||||
// player got all night while these fields were never carried).
|
||||
strncpy(pilot.badge, member.badge, sizeof(pilot.badge) - 1);
|
||||
strncpy(pilot.patch, member.patch, sizeof(pilot.patch) - 1);
|
||||
pilot.advancedDamage = host_adv_damage; // #162: mission-level
|
||||
sprintf(pilot.address, "%s:%d",
|
||||
member.fakeAddress, member.gamePort);
|
||||
if (!member.isSelf)
|
||||
@@ -1245,6 +1261,7 @@ int
|
||||
if (menu.steamAction == 2)
|
||||
{
|
||||
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
||||
{
|
||||
|
||||
+182
-13
@@ -13,6 +13,7 @@
|
||||
#pragma pack(push, 8)
|
||||
#include "steam/steam_api.h"
|
||||
#include "steam/isteammatchmaking.h"
|
||||
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
|
||||
#pragma pack(pop)
|
||||
|
||||
//
|
||||
@@ -48,6 +49,50 @@ static void
|
||||
}
|
||||
#include <stdarg.h>
|
||||
|
||||
//
|
||||
// A rejection the PLAYER must see goes through LobbyNotice: the same text
|
||||
// lands in the day log (newlines flattened so the line stays greppable)
|
||||
// AND in a blocking message box. These paths are synchronous FE flows --
|
||||
// a bare return here QUITS the exe, and #68 taught us a player bounced
|
||||
// with only a log line reports "the game just closed".
|
||||
//
|
||||
static void
|
||||
LobbyNotice(const char *format, ...)
|
||||
{
|
||||
char text[512];
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
_vsnprintf(text, sizeof(text) - 1, format, arguments);
|
||||
text[sizeof(text) - 1] = 0;
|
||||
va_end(arguments);
|
||||
{
|
||||
char flat[512];
|
||||
int n = 0;
|
||||
for (const char *s = text; *s && n < 510; ++s)
|
||||
{
|
||||
if (*s == '\n')
|
||||
{
|
||||
if (n > 0 && flat[n - 1] == ' ')
|
||||
continue;
|
||||
flat[n++] = ' ';
|
||||
}
|
||||
else
|
||||
{
|
||||
flat[n++] = *s;
|
||||
}
|
||||
}
|
||||
flat[n] = 0;
|
||||
LobbyLog("NOTICE: %s", flat);
|
||||
}
|
||||
WCHAR wide[512];
|
||||
int n = 0;
|
||||
for (const char *s = text; *s && n < 511; ++s)
|
||||
wide[n++] = (WCHAR)*s;
|
||||
wide[n] = 0;
|
||||
MessageBoxW(NULL, wide, L"BATTLETECH -- STEAM LOBBY",
|
||||
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
// Synchronous Steam call-result helper (manual polling -- no callback
|
||||
// template machinery in this C-style TU).
|
||||
@@ -84,7 +129,7 @@ static CSteamID currentLobby;
|
||||
|
||||
static void
|
||||
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience)
|
||||
const char *experience, const char *badge, const char *patch)
|
||||
{
|
||||
//
|
||||
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||
@@ -95,6 +140,15 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
// #38: badge + patch complete the paint identity (the egg's fourth and
|
||||
// fifth pilot fields) -- without them every joiner rendered VGL/Red.
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "bd", badge);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "pt", patch);
|
||||
// BUILD GATE, member half: publish our exact build so the HOST can
|
||||
// reject mismatches at GO time. This is what catches OLD exes -- they
|
||||
// predate the joiner-side lobby filter, but they can't fake a "bv" key
|
||||
// they never set.
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "bv", BT_VERSION_STRING);
|
||||
}
|
||||
|
||||
static int
|
||||
@@ -128,6 +182,16 @@ static int
|
||||
if (xp != NULL)
|
||||
strncpy(out->experience, xp, sizeof(out->experience) - 1);
|
||||
}
|
||||
{
|
||||
// #38: badge + patch, same tolerance -- an older client that never
|
||||
// published them keeps the old VGL/Red fallback, nobody else does.
|
||||
const char *bd = matchmaking->GetLobbyMemberData(lobby, member, "bd");
|
||||
if (bd != NULL)
|
||||
strncpy(out->badge, bd, sizeof(out->badge) - 1);
|
||||
const char *pt = matchmaking->GetLobbyMemberData(lobby, member, "pt");
|
||||
if (pt != NULL)
|
||||
strncpy(out->patch, pt, sizeof(out->patch) - 1);
|
||||
}
|
||||
out->isSelf = (member == SteamUser()->GetSteamID());
|
||||
if (out->name[0] == 0)
|
||||
{
|
||||
@@ -143,8 +207,15 @@ static int
|
||||
|
||||
static int roomIsHost = 0;
|
||||
static int roomResult = 0; // 0 pending, 1 go, -1 cancel
|
||||
static RECT roomLaunchRect = { 140, 250, 420, 296 };
|
||||
static RECT roomLeaveRect = { 140, 306, 300, 336 };
|
||||
// #169: the buttons sit BELOW the full 8-slot roster (rows y=60..268), and the
|
||||
// window is sized by CLIENT rect (AdjustWindowRect at creation) -- the old
|
||||
// 560x360 was the OUTER size, so the caption bar ate the bottom ~47px and the
|
||||
// LEAVE button (bottom=336) drew half-clipped, while rosters past 6 members
|
||||
// plowed into the fixed LAUNCH frame (Lynx's 8-player clutter shot).
|
||||
static RECT roomLaunchRect = { 140, 280, 420, 326 };
|
||||
static RECT roomLeaveRect = { 140, 336, 300, 366 };
|
||||
static const int kRoomClientW = 560;
|
||||
static const int kRoomClientH = 382; // LEAVE bottom 366 + 16 margin
|
||||
|
||||
static void
|
||||
PaintRoom(HWND window)
|
||||
@@ -186,10 +257,19 @@ static void
|
||||
int n = 0;
|
||||
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
|
||||
name[n] = 0;
|
||||
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
|
||||
// BUILD GATE: flag mismatched members in the roster so the host
|
||||
// sees WHO won't launch before pressing GO (the log alone left
|
||||
// the operator guessing on field nights).
|
||||
const char *bv = matchmaking->GetLobbyMemberData(currentLobby,
|
||||
matchmaking->GetLobbyMemberByIndex(currentLobby, i), "bv");
|
||||
int build_ok = (bv != NULL && strcmp(bv, BT_VERSION_STRING) == 0);
|
||||
wsprintfW(line, L" %d. %-16s %hs, %hs%s%s", i + 1, name,
|
||||
member.vehicle[0] ? member.vehicle : "mech",
|
||||
member.color[0] ? member.color : "-",
|
||||
member.isSelf ? L" (you)" : L"");
|
||||
member.isSelf ? L" (you)" : L"",
|
||||
// #169: keep the tag short -- the long "-- WILL NOT LAUNCH"
|
||||
// tail clipped at the 536px row edge on long name+vehicle rows.
|
||||
build_ok ? L"" : L" [WRONG BUILD]");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -291,10 +371,12 @@ static int
|
||||
window_class.lpszClassName = L"BTLobbyRoomWnd";
|
||||
RegisterClassW(&window_class);
|
||||
|
||||
RECT outer = { 0, 0, kRoomClientW, kRoomClientH }; // #169: size by CLIENT rect
|
||||
AdjustWindowRect(&outer, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, FALSE);
|
||||
HWND window = CreateWindowW(
|
||||
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
|
||||
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
|
||||
160, 160, 560, 360,
|
||||
160, 160, outer.right - outer.left, outer.bottom - outer.top,
|
||||
NULL, NULL, GetModuleHandleW(NULL), NULL);
|
||||
if (window == NULL)
|
||||
{
|
||||
@@ -322,7 +404,7 @@ static int
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
@@ -346,7 +428,13 @@ int
|
||||
}
|
||||
currentLobby = created.m_ulSteamIDLobby;
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
// BUILD GATE: stamp the host's exact build on the lobby. Joiners filter
|
||||
// on equality (below) and verify after entry -- a mixed-build lobby
|
||||
// silently corrupts raw-struct replication (the night-9 ghost confound:
|
||||
// one stale-zip player desyncs one connection's stream). Same build or
|
||||
// no entry.
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
if (RunRoom(1) != 0)
|
||||
@@ -371,6 +459,25 @@ int
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!member.isSelf)
|
||||
{
|
||||
// BUILD GATE, host half: a member on a different zip (or an
|
||||
// exe old enough to have no "bv" key at all) gets NO token.
|
||||
// Omitted from btl4map, their client -- old builds included,
|
||||
// the code predates the gate -- hits its own "the host's map
|
||||
// is missing us" path and fails the join cleanly instead of
|
||||
// desyncing the session (#108's stale-zip confound).
|
||||
const char *bv = matchmaking->GetLobbyMemberData(
|
||||
currentLobby, CSteamID(member.steamID), "bv");
|
||||
if (bv == NULL || strcmp(bv, BT_VERSION_STRING) != 0)
|
||||
{
|
||||
LobbyLog("host: REJECT %s at GO -- their build [%s], "
|
||||
"ours %s (no token minted; their join will fail)",
|
||||
member.name, (bv && *bv) ? bv : "pre-gate/unknown",
|
||||
BT_VERSION_STRING);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((pass == 0) == (member.isSelf != 0))
|
||||
{
|
||||
roster_out->members[roster_out->memberCount++] = member;
|
||||
@@ -414,19 +521,26 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
{
|
||||
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
||||
{
|
||||
LobbyLog("join: Steam transport unavailable");
|
||||
LobbyNotice("STEAM UNAVAILABLE\n\n"
|
||||
"Could not reach Steam.\n"
|
||||
"Is Steam running and logged in?");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4", "1", k_ELobbyComparisonEqual);
|
||||
// BUILD GATE: only lobbies stamped with OUR exact build are visible. A
|
||||
// stale-zip player finds nothing -- and the log names the build so the
|
||||
// "no lobby found" report is self-diagnosing (#68's silent-exit lesson).
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4ver", BT_VERSION_STRING, k_ELobbyComparisonEqual);
|
||||
SteamAPICall_t call = matchmaking->RequestLobbyList();
|
||||
LobbyMatchList_t match_list;
|
||||
memset(&match_list, 0, sizeof(match_list));
|
||||
@@ -434,7 +548,36 @@ int
|
||||
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
|
||||
match_list.m_nLobbiesMatching == 0)
|
||||
{
|
||||
LobbyLog("join: no btl4 lobby found");
|
||||
//
|
||||
// Nothing on OUR build. Probe once WITHOUT the version filter so
|
||||
// the rejection is specific: "the host is on a different zip"
|
||||
// beats "no lobby" when one is in fact up. Lobby data rides back
|
||||
// with the list result, readable without joining. (Filters only
|
||||
// apply to the next request, so the probe re-adds the game key.)
|
||||
//
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4", "1", k_ELobbyComparisonEqual);
|
||||
call = matchmaking->RequestLobbyList();
|
||||
memset(&match_list, 0, sizeof(match_list));
|
||||
if (WaitApiCall(call, &match_list, sizeof(match_list),
|
||||
LobbyMatchList_t::k_iCallback, 8000) == 0 &&
|
||||
match_list.m_nLobbiesMatching > 0)
|
||||
{
|
||||
const char *host_ver = matchmaking->GetLobbyData(
|
||||
matchmaking->GetLobbyByIndex(0), "btl4ver");
|
||||
LobbyNotice("BUILD MISMATCH\n\n"
|
||||
"A lobby is up, but the host runs build %s\n"
|
||||
"and this machine runs build %s.\n\n"
|
||||
"Everyone must run the same zip to play together.",
|
||||
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||
BT_VERSION_STRING);
|
||||
}
|
||||
else
|
||||
{
|
||||
LobbyNotice("NO LOBBY FOUND\n\n"
|
||||
"No BattleTech lobby is up right now.\n"
|
||||
"(This machine runs build %s.)", BT_VERSION_STRING);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -450,7 +593,25 @@ int
|
||||
return -1;
|
||||
}
|
||||
currentLobby = lobby;
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
// BUILD GATE belt-and-suspenders: the list filter covers discovery, but
|
||||
// verify the entered lobby too (covers invite/direct joins and any
|
||||
// filter drift). Mismatch = leave loudly, never play version-skewed.
|
||||
{
|
||||
const char *host_ver = matchmaking->GetLobbyData(currentLobby, "btl4ver");
|
||||
if (host_ver == NULL || strcmp(host_ver, BT_VERSION_STRING) != 0)
|
||||
{
|
||||
matchmaking->LeaveLobby(currentLobby);
|
||||
currentLobby = CSteamID();
|
||||
LobbyNotice("BUILD MISMATCH\n\n"
|
||||
"The host runs build %s\n"
|
||||
"and this machine runs build %s.\n\n"
|
||||
"Everyone must run the same zip to play together.",
|
||||
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||
BT_VERSION_STRING);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
int result = RunRoom(0);
|
||||
@@ -487,7 +648,15 @@ int
|
||||
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
|
||||
if (my_token_out[0] == 0)
|
||||
{
|
||||
result = -1; // the host's map is missing us
|
||||
// The host launched without us -- no seat in the mission map.
|
||||
// (Same-build clients can only hit this via a full lobby or a
|
||||
// publish race now; version skew is gated before entry.)
|
||||
LobbyNotice("LEFT BEHIND\n\n"
|
||||
"The host launched the mission without this machine\n"
|
||||
"(no seat in the mission map).\n\n"
|
||||
"Rejoin on the next launch. If this repeats, compare\n"
|
||||
"builds: this machine runs %s.", BT_VERSION_STRING);
|
||||
result = -1;
|
||||
}
|
||||
}
|
||||
SteamMatchmaking()->LeaveLobby(currentLobby);
|
||||
|
||||
@@ -34,6 +34,9 @@ struct BTLobbyMember
|
||||
// without it on the wire, the host's
|
||||
// egg authored every JOINER as the
|
||||
// WriteEgg "veteran" fallback
|
||||
char badge[16]; // #38: same class as experience --
|
||||
char patch[16]; // absent on the wire, every joiner
|
||||
// spawned VGL-badged with a Red patch
|
||||
int isSelf;
|
||||
unsigned long long steamID;
|
||||
};
|
||||
@@ -51,7 +54,7 @@ struct BTLobbyRoster
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
@@ -63,6 +66,6 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
|
||||
@@ -398,10 +398,29 @@ Missile flight-entity cluster (spawned by MissileLauncher::FireWeapon @4bcc60, s
|
||||
members (best-effort, past Entity base): modelUpdateContext@0x250(+bound update PTR_FUN_005129dc@0x254), world@0x300, currentSegment@0x304,
|
||||
segmentIndex@0x308(desc+0x84), sourceEntity@0x30C(desc+0x88), sourceWeapon@0x310(desc+0x8C), launchPosition@0x314(desc+0x90),
|
||||
launchVelocity@0x320(desc+0x9C), aimDirection@0x32C(desc+0xA8), modelIndex@0x338(desc+0xB4), damageRecord@0x33C(desc+0xB8).
|
||||
Performance variants PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
|
||||
Performance variants (CORRECTED 2026-08-11, was swapped): ctor @4be1bc picks by instance —
|
||||
(flags&0xC)==ReplicantInstance(4, ENTITY.h:318 [T0]) → PTR_LAB_005129e8 → FUN_004221c0 = the REPLICANT
|
||||
dead-reckoning smoother (blends toward predicted state, no physics/damage); else (master/local) →
|
||||
PTR_LAB_005129f4 → FUN_004bddec = the LIVE plain-Projectile integrator (was "past window" in the old
|
||||
export; present in the 2026-08-06 rebuild). Same split in the Missile ctor: 512da0→4221c0 (replicant),
|
||||
512dac→4bef78 (master integrator).
|
||||
Plain-Projectile flight/expiry @4bddec [T1]: integrate → world/entity collide FUN_0042291c (shooter
|
||||
excluded via @4be058) → hit: detonate+damage FUN_004be078; no-hit: dead-reckon predictor
|
||||
FUN_00422060 via +0x254 refreshes predicted pos +0x260, divergence² > _DAT_004be050=0.1 sets
|
||||
+0x18|=1 (replication-dirty, NOT kill); tail: age since spawn(+0x19c) > _DAT_004be054=5.0s →
|
||||
FUN_0042061c kill (remove + flags|=0x2000|2). NO WeaponRange / distance cap on flight — a shell
|
||||
flies exactly 5.0s (#168: the authored range=750 is fire-control data only; FireWeapon @4bcc60
|
||||
reads no effectiveRange +0x328 — the trigger has no range gate).
|
||||
└─ Missile vtable 00512f2c ctor @4bf5b4 dtor @4bf890 alloc @4bf8bc (size 0x368) [missile.cpp]
|
||||
MoveAndCollide @4bef78 (age/guide/integrate/collide; collision FUN_0042291c, detonate FUN_004be078
|
||||
streamed ClassID 0x5C), WriteUpdateRecord @4bef4c (slot7, tag 0x78).
|
||||
Expiry @4bef78 tail [T1]: age since spawn(+0x19c) > burnTime(+0x340, model rec +0x44) +
|
||||
_DAT_004bf5ac=10.0s coast margin, OR pos.y(+0x104) < _DAT_004bf5b0=-1.0 kill-plane → FUN_0042061c
|
||||
kill. Also: no replication update for _DAT_004bf594=2.0s → +0x18|=1 (dirty keepalive);
|
||||
dead-reckon divergence² > _DAT_004bf5a8=0.1 → +0x18|=1. Seeker target DROPPED when the victim's
|
||||
movementMode ∈ {2,9} = destroyed (FUN_0049fb54). PROXIMITY FUSE: seeker rangeToTarget(+0x10C) <
|
||||
_DAT_004bf5a4=4.0 → treat targetEntity as the collision victim (detonate) even without a geometry
|
||||
hit. LRM "range" is therefore physics (speed × lifetime), not a gate.
|
||||
Hosts 2 subsystems: this[0x49]=2 @0x124, roster @0x128 → {Seeker, MissileThruster}.
|
||||
enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort):
|
||||
lifetime@0x340 ageFraction@0x344 thrustScale@0x348 targetConn@0x34C targetOffset@0x350
|
||||
|
||||
@@ -82,6 +82,7 @@ enum BTActionID
|
||||
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
|
||||
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
|
||||
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
|
||||
BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
|
||||
BTActCount
|
||||
};
|
||||
|
||||
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
|
||||
{"Reconnect", BTActReconnect},
|
||||
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
|
||||
{"Mfd3Cycle", BTActMfd3Cycle},
|
||||
{"Eject", BTActEject},
|
||||
{0, 0}
|
||||
};
|
||||
|
||||
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
|
||||
"key L action Mfd3Cycle\n"
|
||||
"key V action ViewToggle\n"
|
||||
"key B action LookBehind\n"
|
||||
"key Back action Eject\n"
|
||||
"pad LeftThumb action Eject\n"
|
||||
"key F5 action Generator1\n"
|
||||
"key F6 action Generator2\n"
|
||||
"key F7 action Generator3\n"
|
||||
@@ -991,6 +995,7 @@ void
|
||||
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
|
||||
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
|
||||
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
|
||||
case BTActEject: next.eject = 1; break;
|
||||
case BTActGenerator1: next.genSel = 4; break;
|
||||
case BTActGenerator2: next.genSel = 5; break;
|
||||
case BTActGenerator3: next.genSel = 6; break;
|
||||
|
||||
@@ -55,6 +55,7 @@ struct BTInputState
|
||||
int valve;
|
||||
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
|
||||
int configHold;
|
||||
int eject; // PANIC/EJECT punch-out (level; mech4 edge-dispatches Mech 0x19)
|
||||
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
|
||||
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
|
||||
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
|
||||
|
||||
@@ -170,7 +170,13 @@ static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0
|
||||
// no 0x29) -- overlapping but distinct lamps. Slot 6 (0x29) is also
|
||||
// DAT_0051d070[0], the first per-placement lamp: the two tables abut.
|
||||
static const int kBTCondenserLamp[7] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A, 0x29 };
|
||||
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
|
||||
// #135 CORRECTED: these are the GENERATOR leak lamps -- the binary's table at
|
||||
// 0x51d070 (@004cc27c), indexed by generatorNumber 1-BASED (A=1..D=4; slot 0
|
||||
// overlaps the condenser table's last entry and is unused; slot 5 is already
|
||||
// string bytes in the image, hence the bounds guard). The old name/comment
|
||||
// ("auxScreenPlacement") described a mis-reconstruction that indexed this
|
||||
// table with PoweredSubsystem aux fields generators never fill.
|
||||
static const int kBTGeneratorLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D };
|
||||
|
||||
//
|
||||
// @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank.
|
||||
@@ -276,6 +282,14 @@ void
|
||||
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
|
||||
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code));
|
||||
|
||||
if (BTLampLog())
|
||||
DEBUG_STREAM << "[galm-item] sub='"
|
||||
<< (the_subsystem ? (const char *)the_subsystem->GetName() : "?")
|
||||
<< "' cond=" << (int)the_condition
|
||||
<< " itemCond=" << item_condition
|
||||
<< " lampCode=0x" << std::hex << lamp_code << std::dec
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if ((int)the_condition != item_condition)
|
||||
{
|
||||
return;
|
||||
@@ -327,10 +341,15 @@ void
|
||||
{
|
||||
if (the_condition == 2) // CoolantLeaking
|
||||
{
|
||||
int screen = 0, placement = -1;
|
||||
BTGetSubsystemAuxScreen(the_subsystem, &screen, &placement, 0);
|
||||
if (placement >= 0 && placement < 5)
|
||||
lamp_id = kBTPlacementLamp[placement];
|
||||
// #135 FIX: the binary's generator special @004cc27c indexes the
|
||||
// 0x51d070 table by generatorNumber (sub+0x1E0, A=1..D=4) -- NOT by
|
||||
// any aux-screen placement (generators never fill those fields; the
|
||||
// old probe left placement=-1 and a leaking generator annunciated
|
||||
// NOTHING, the field-logged 'NO LAMP RESOLVED (generator=1)').
|
||||
extern int BTGeneratorNumber(::Subsystem *sub); // powersub.cpp
|
||||
int n = BTGeneratorNumber(the_subsystem);
|
||||
if (n >= 1 && n <= 4)
|
||||
lamp_id = kBTGeneratorLamp[n];
|
||||
}
|
||||
}
|
||||
else if (lamp_code < 0x80)
|
||||
|
||||
@@ -2020,11 +2020,23 @@ BallisticWeaponCluster::BallisticWeaponCluster(
|
||||
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
|
||||
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
|
||||
|
||||
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
|
||||
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
|
||||
// declared/reconstructed in btl4gaug (only the non-Scalar BitMapInverseWipe);
|
||||
// tracked NULL until it lands.
|
||||
ejectWipe = NULL; // @0x44
|
||||
// ejectWipe (BitMapInverseWipeScalar @004c61c8): the round-EJECT progress
|
||||
// sweep -- bteejtm.pcc as the weapon's PercentOfEject (@0x3F8) runs 0..1.
|
||||
// Binary call (part_014.c:2312): FUN_004c61c8(., rate, eng_mode,
|
||||
// renderer, ownerID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||
// weapon+0x3f8, name). RE-WIRED 2026-08-03 after the draw op was
|
||||
// properly settled: vtbl+0x58 = DrawBitMapOpaque, NOT DrawBitMap -- the
|
||||
// mis-guess was the striped-MFD corruption (gotcha #27; the wipe's
|
||||
// Execute in btl4gaug now carries the byte-exact two-span transcription).
|
||||
{
|
||||
extern Scalar *BTWeaponPercentOfEjectPtr(void *weapon);
|
||||
Scalar *ejectPct = BTWeaponPercentOfEjectPtr(subsystem_in);
|
||||
ejectWipe = (ejectPct != NULL)
|
||||
? new BitMapInverseWipeScalar(ChildRate(), eng_mode, renderer_in,
|
||||
owner_ID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||
ejectPct, "BitMapInverseWipeScalar")
|
||||
: NULL; // @0x44
|
||||
}
|
||||
}
|
||||
|
||||
BallisticWeaponCluster::~BallisticWeaponCluster()
|
||||
|
||||
@@ -1744,10 +1744,27 @@ Logical
|
||||
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
|
||||
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
|
||||
{
|
||||
DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
|
||||
// WAS DebugStream -- the no-op ReconStream (gotcha: use DEBUG_STREAM).
|
||||
// A missing strip therefore failed COMPLETELY SILENTLY, which is
|
||||
// exactly the state #142 was stuck in: the crouch symbol never drew and
|
||||
// nothing anywhere said why.
|
||||
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt: MISSING IMAGE '"
|
||||
<< p[2].data.string << "' -- element not created\n" << std::flush;
|
||||
return False;
|
||||
}
|
||||
warehouse->pixelMap8Bin.Release(p[2].data.string); // FUN_00442e51
|
||||
|
||||
// #142 receipt (ungated, one line per element): does this strip exist, and
|
||||
// did its integer attribute actually RESOLVE? A NULL attributePointer
|
||||
// leaves the connection reading nothing, so the strip pins to frame 0 and
|
||||
// looks like "no animation at all" -- indistinguishable, from outside, from
|
||||
// a missing image or an unbuilt page.
|
||||
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt '" << p[2].data.string
|
||||
<< "' frames=" << p[3].data.integer << "x" << p[4].data.integer
|
||||
<< " port=" << display_port_index
|
||||
<< " at(" << position.x << "," << position.y << ")"
|
||||
<< " attr=" << (p[5].data.attributePointer != 0 ? "BOUND" : "NULL !!")
|
||||
<< "\n" << std::flush;
|
||||
return True;
|
||||
}
|
||||
|
||||
@@ -2152,6 +2169,114 @@ void BitMapInverseWipe::Execute()
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
// BitMapInverseWipeScalar @004c61c8 ctor / @004c5fb8 Execute
|
||||
//###########################################################################
|
||||
//
|
||||
// The weapon-eng round-EJECT progress sweep (bteejtm.pcc <- PercentOfEject).
|
||||
// Base ctor @004c5e84 interns the image and captures colours + the bitmap
|
||||
// geometry; the @004c61c8 tail adds the Scalar value-watcher (FUN_00474855
|
||||
// into this+0xA8) -- our GaugeConnectionDirectOf<Scalar> is that watcher.
|
||||
//
|
||||
BitMapInverseWipeScalar::BitMapInverseWipeScalar(
|
||||
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
||||
int owner_ID, int graphics_port_number, int x, int y, const char *image,
|
||||
int color_a, int color_b, Scalar *value_pointer,
|
||||
const char *identification_string
|
||||
):
|
||||
GraphicGauge(rate, mode_mask, renderer_in, owner_ID, graphics_port_number,
|
||||
identification_string)
|
||||
{
|
||||
localView.SetOrigin(x, y);
|
||||
imageName = new char[strlen(image) + 1];
|
||||
strcpy(imageName, image);
|
||||
colorA = color_a;
|
||||
colorB = color_b;
|
||||
value = 0.0f;
|
||||
previousLevel = -1;
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer_in->warehousePointer;
|
||||
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||
if (bmp == NULL)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
width = bmp->Data.Size.x;
|
||||
height = bmp->Data.Size.y;
|
||||
}
|
||||
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &value, value_pointer));
|
||||
}
|
||||
|
||||
BitMapInverseWipeScalar::~BitMapInverseWipeScalar()
|
||||
{
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||
warehouse->bitMapBin.Release(imageName);
|
||||
delete[] imageName;
|
||||
imageName = NULL;
|
||||
}
|
||||
|
||||
Logical BitMapInverseWipeScalar::TestInstance() const { return GraphicGauge::TestInstance(); }
|
||||
|
||||
void BitMapInverseWipeScalar::BecameActive() // @004c5fa4
|
||||
{
|
||||
previousLevel = -1;
|
||||
}
|
||||
|
||||
//
|
||||
// @004c5fb8 -- Execute. level = round(value x width) clamped to [0, width]
|
||||
// (the x87 form Ghidra drops -- fild width / fmul value, gotcha #19), and on
|
||||
// a change draw the two source-column spans OPAQUE with INVERTED colour
|
||||
// pairs -- the literal "inverse wipe".
|
||||
//
|
||||
// SETTLED 2026-08-03 (the MFD-corruption arc): the binary's draw call is
|
||||
// vtbl+0x58 = GraphicsView::DrawBitMapOpaque(background, rotation, bitmap,
|
||||
// sx1, sy1, sx2, sy2) [T0 GRAPH2D.h slot 22 -- the NEXT virtual after
|
||||
// DrawBitMap at +0x54; verified by counting the declaration order:
|
||||
// +0x18 SetColor, +0x24 MoveToAbsolute both land exactly]. The earlier
|
||||
// reconstruction guessed DrawBitMap and fed it a colour as "rotation" --
|
||||
// the striped-garbage smear the operator captured. Byte-exact spans:
|
||||
// level != 0: MoveTo(0,0); SetColor(colorA);
|
||||
// DrawBitMapOpaque(colorB, 0, bmp, 0, 0, level, height);
|
||||
// ++level; // inclusive-bounds step
|
||||
// level < w: MoveTo(level,0); SetColor(colorB);
|
||||
// DrawBitMapOpaque(colorA, 0, bmp, level, 0, width, height);
|
||||
//
|
||||
void BitMapInverseWipeScalar::Execute()
|
||||
{
|
||||
Scalar scaled = value * (Scalar)width;
|
||||
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
|
||||
if (level < 0) level = 0;
|
||||
if (level > width) level = width;
|
||||
|
||||
if (level != previousLevel)
|
||||
{
|
||||
previousLevel = level;
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||
if (bmp != NULL)
|
||||
{
|
||||
int split = level;
|
||||
if (split != 0)
|
||||
{
|
||||
localView.MoveToAbsolute(0, 0);
|
||||
localView.SetColor(colorA);
|
||||
localView.DrawBitMapOpaque(colorB, 0, bmp, 0, 0, split, height);
|
||||
++split; // the binary's iVar2++
|
||||
}
|
||||
if (split < width)
|
||||
{
|
||||
localView.MoveToAbsolute(split, 0);
|
||||
localView.SetColor(colorB);
|
||||
localView.DrawBitMapOpaque(colorA, 0, bmp, split, 0, width, height);
|
||||
}
|
||||
warehouse->bitMapBin.Release(imageName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// HeadingPointer @004c554c Make / @004c562c ctor
|
||||
|
||||
@@ -541,6 +541,39 @@
|
||||
Scalar value; // @0xB4 this[0x2D] (connection)
|
||||
};
|
||||
|
||||
//#######################################################################
|
||||
// BitMapInverseWipeScalar @004c61c8 (vtable 0x518a14; base ctor @004c5e84,
|
||||
// Execute @004c5fb8, BecameActive @004c5fa4). A bitmap COLUMN SWEEP whose
|
||||
// level tracks a live Scalar 0..1 (the ctor's trailing Scalar* rides a
|
||||
// value-watcher into this+0xA8): columns [0..level] draw fg colorA over
|
||||
// bg colorB, the remainder draws the INVERSE -- the weapon-engineering
|
||||
// panel's round-EJECT progress graphic (bteejtm.pcc, watching the
|
||||
// weapon's PercentOfEject @0x3F8). Reconstructed 2026-08-03 (#118: the
|
||||
// operator saw the eject slot flash with NO graphic -- this was the
|
||||
// tracked-NULL bring-up stub in BallisticWeaponCluster).
|
||||
//#######################################################################
|
||||
class BitMapInverseWipeScalar :
|
||||
public GraphicGauge
|
||||
{
|
||||
public:
|
||||
BitMapInverseWipeScalar( // @004c61c8
|
||||
GaugeRate, ModeMask, L4GaugeRenderer *, int owner_ID,
|
||||
int graphics_port_number, int x, int y, const char *image,
|
||||
int color_a, int color_b, Scalar *value_pointer, const char *);
|
||||
~BitMapInverseWipeScalar();
|
||||
Logical TestInstance() const;
|
||||
void BecameActive(); // @004c5fa4 (previousLevel = -1)
|
||||
void Execute(); // @004c5fb8 (two-span inverse sweep)
|
||||
protected:
|
||||
char *imageName; // @0x90
|
||||
int colorA; // @0x94
|
||||
int colorB; // @0x98
|
||||
int width; // @0x9C bitmap width (columns)
|
||||
int height; // @0xA0 bitmap height
|
||||
int previousLevel; // @0xA4
|
||||
Scalar value; // @0xA8 (connection: the 0..1 progress)
|
||||
};
|
||||
|
||||
//#######################################################################
|
||||
// SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
|
||||
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
// Function -> method map:
|
||||
// L4MechControlsMapper ----------------------------- vtable @0051e440
|
||||
// @004d17ac ctor @004d1814 dtor
|
||||
// @004d196c InterpretControls (target-range ramp + review-mode watch)
|
||||
// @004d196c InterpretControls (target-range ramp + panic-arm watch)
|
||||
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
|
||||
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
|
||||
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
|
||||
@@ -91,7 +91,7 @@
|
||||
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
|
||||
//
|
||||
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 missionReviewMode.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 ejectPermitted.
|
||||
//
|
||||
|
||||
#include <bt.hpp>
|
||||
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
//
|
||||
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
|
||||
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
|
||||
// review-mode watcher.
|
||||
// panic-arm watcher (ejectPermitted edge).
|
||||
//
|
||||
L4MechControlsMapper::L4MechControlsMapper(
|
||||
Mech *owner,
|
||||
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
)
|
||||
{
|
||||
(void)class_ID;
|
||||
previousMissionReviewMode = 0; // this[0x6a] @0x1a8
|
||||
previousEjectPermitted = 0; // this[0x6a] @0x1a8
|
||||
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
|
||||
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
|
||||
Check_Fpu();
|
||||
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
|
||||
// 1. snap the throttle to a full-throttle detent,
|
||||
// 2. (re)build the pilot roster,
|
||||
// 3. watch the Mech's mission-review flag and toggle the corresponding mode
|
||||
// bit (0x200000) on the application mode manager,
|
||||
// 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
|
||||
// refreshed each frame by the master performance, FUN_004a9b5c+0x10 ->
|
||||
// @0049fa1c) and toggle the PANIC-ARMED mode bit (0x200000) on the
|
||||
// application mode manager. That mode bit is what lights the pod's
|
||||
// physical PANIC button (MakeLinkedLamp, btl4mppr RIO ctor) and any
|
||||
// gauge elements carrying the mode in their ModeMask. [Corrected
|
||||
// 2026-08-03: this cell was mislabeled "mission-review mode" -- the
|
||||
// real review mode is the GLOBAL DAT_004fd550 (btl4pb), not mech+0x414.]
|
||||
// 4. slew the smoothed target-range exponent toward the panel demand and
|
||||
// push the resulting range (250 * 2^exponent) onto the Mech,
|
||||
// 5. delegate to MechControlsMapper::InterpretControls for the actual
|
||||
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
BuildPilotArray(); // FUN_004b0600
|
||||
|
||||
//
|
||||
// (3) Mission-review mode change watcher.
|
||||
// (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
|
||||
//
|
||||
Mech *mech = GetMech();
|
||||
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414
|
||||
if (review_mode != previousMissionReviewMode) // @0x1a8
|
||||
int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
|
||||
if (eject_armed != previousEjectPermitted) // @0x1a8
|
||||
{
|
||||
previousMissionReviewMode = review_mode;
|
||||
previousEjectPermitted = eject_armed;
|
||||
|
||||
BTL4ModeManager *mode_manager =
|
||||
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
|
||||
// ModeManager API: Add/RemoveModeMask save the old mask into
|
||||
// previousModeMask (the recovered "savedMode = currentMode" step).
|
||||
if (review_mode == 0)
|
||||
if (eject_armed == 0)
|
||||
{
|
||||
mode_manager->RemoveModeMask(0x200000);
|
||||
}
|
||||
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
{
|
||||
mode_manager->AddModeMask(0x200000);
|
||||
}
|
||||
if (getenv("BT_EJECT_LOG"))
|
||||
DEBUG_STREAM << "[eject] panic-arm mode "
|
||||
<< (eject_armed ? "ON" : "off") << " ("
|
||||
<< mech->GetEntityID() << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -74,8 +74,17 @@
|
||||
//
|
||||
public:
|
||||
enum {
|
||||
KeypressMessageID = MechControlsMapper::NextMessageID,
|
||||
StringMatchMessageID,
|
||||
// PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
|
||||
// straight into the group Adds; corrected 2026-08-03). The chain
|
||||
// arithmetic (MechControlsMapper::NextMessageID) lands on 0x17 --
|
||||
// TWO mapper ids between 0x16 ToggleVoiceAssist and 0x19 are
|
||||
// unreconstructed -- and the mis-numbered Keypress send was
|
||||
// SILENTLY swallowed by the Mech (no 0x17 handler), which is why
|
||||
// the armed pilot-keypad press never ejected. Mech::EjectPilot is
|
||||
// id 0x19 on the MECH's receiver: the panic-mode keypad binding
|
||||
// sends THIS id to the OWNER, and the collision is the design.
|
||||
KeypressMessageID = 0x19,
|
||||
StringMatchMessageID, // 0x1a (unregistered; stringManager route)
|
||||
NextMessageID
|
||||
};
|
||||
|
||||
@@ -203,7 +212,9 @@
|
||||
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
|
||||
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
|
||||
int
|
||||
previousMissionReviewMode; // @0x1a8 last-seen mech mission-review flag
|
||||
previousEjectPermitted; // @0x1a8 last-seen mech ejectPermitted (@0x414) --
|
||||
// the panic-arm edge detector (was mislabeled
|
||||
// "mission-review flag"; corrected 2026-08-03)
|
||||
|
||||
//
|
||||
// Per-platform input staging slots (filled by the derived ctor's
|
||||
@@ -305,8 +316,12 @@
|
||||
// was previously self-consistent at the wrong value (0x2a), so it
|
||||
// worked by accident -- now it matches the binary.
|
||||
// (Faithful-fidelity note: the binary RIO table also carries its OWN
|
||||
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared
|
||||
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.)
|
||||
// Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
|
||||
// the binary's 0x19 (the old chain arithmetic landed on 0x17, and the
|
||||
// panic-mode pilot-keypad send was silently swallowed by the Mech --
|
||||
// no 0x17 handler -- so the armed eject press never fired). Remaining
|
||||
// refinement: the ctor registers the shared L4 Keypress BODY
|
||||
// (@004d1bf0); the RIO-specific body is @004d2514.)
|
||||
//
|
||||
enum {
|
||||
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
|
||||
|
||||
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
|
||||
|
||||
//
|
||||
// Controls only matter in interactive ("scrub") review mode.
|
||||
// The binary reads the GLOBAL review-mode cell DAT_004fd550 here --
|
||||
// NOT mech+0x414 (that cell is ejectPermitted; the old
|
||||
// GetMissionReviewMode() call was a mislabel, corrected 2026-08-03).
|
||||
// The port never enters scrub mode, so the global stays 0.
|
||||
//
|
||||
if (GetMissionReviewMode() == 2) // DAT_004fd550
|
||||
extern int gMissionReviewMode; // DAT_004fd550 (btstubs)
|
||||
if (gMissionReviewMode == 2)
|
||||
{
|
||||
Check(controlsManager);
|
||||
controlsManager->Execute(); // vtbl+0x18
|
||||
|
||||
+652
-16
@@ -170,6 +170,104 @@ void
|
||||
// localToWorld in its ctor -- no explicit AddDynamicRenderable here
|
||||
// (unlike the 1996 VideoComponent path).
|
||||
(void)mech_root;
|
||||
|
||||
//
|
||||
// SEARCHLIGHT pass (2026-08-05) -- the roster walk's `case 0xbd8`
|
||||
// (@004cef28, raw pseudocode part_014). The binary, per searchlight
|
||||
// subsystem: resolve the "LightOn" attribute (error print
|
||||
// BTL4VID.CPP:0x941 when absent); COCKPIT build (view 1) -> stash up
|
||||
// to TWO attrs for the fog-swap watcher (@00456778); EXTERNAL build
|
||||
// -> load "spot.bgf" (warn if missing), hang it as a child of the
|
||||
// mount joint and bind visibility to the attribute (@0045612c). Our
|
||||
// tree builder does not walk the roster, so the probe bridge
|
||||
// (searchlight.cpp) supplies {attr, mount segment} and the two
|
||||
// watcher Execute bodies live in TickSearchlight.
|
||||
//
|
||||
{
|
||||
extern int BTMechSearchlightProbe(void *, int **, int *);
|
||||
int *attrs[2];
|
||||
int segs[2];
|
||||
int count = BTMechSearchlightProbe((void *)entity, attrs, segs);
|
||||
std::map<Entity*, MechRenderTree>::iterator ti =
|
||||
mMechRenderTrees.find(entity);
|
||||
if (count > 0 && ti != mMechRenderTrees.end())
|
||||
{
|
||||
MechRenderTree &tree = ti->second;
|
||||
tree.searchLightCount = 0;
|
||||
tree.searchIsCockpit = (view_type == 1);
|
||||
for (int sl = 0; sl < count; ++sl)
|
||||
{
|
||||
MechRenderTree::SearchLight &light =
|
||||
tree.searchLight[tree.searchLightCount];
|
||||
light.cone = NULL;
|
||||
light.coneObj = NULL;
|
||||
light.lightOn = attrs[sl];
|
||||
light.shown = 0;
|
||||
light.mountSeg = segs[sl];
|
||||
// the binary's inverted seed (@00456778: [9]/[10] =
|
||||
// (*attr == 0)) -- guarantees the FIRST tick "changes"
|
||||
// and normalizes the fog to the real lamp state, which
|
||||
// is how a night mission starts on nosearchlightfog.
|
||||
tree.searchFogCache[tree.searchLightCount] =
|
||||
(*attrs[sl] == 0);
|
||||
// BT_SPOT_SELF=1 (bench-only): build the cone on the
|
||||
// OWN-cockpit tree too, so the V-chase camera can
|
||||
// eyeball it solo. Authentic builds never cone the
|
||||
// cockpit tree (the 1995 view-1 branch stashes attrs
|
||||
// only) -- keep this env off outside look benches.
|
||||
if (!tree.searchIsCockpit || getenv("BT_SPOT_SELF"))
|
||||
{
|
||||
// NB: the renderer-level LoadObject wrapper (@00498448) is
|
||||
// still a btstubs no-op; load through the d3d route the
|
||||
// tree builder itself uses (extension REQUIRED there).
|
||||
d3d_OBJECT *spot = d3d_OBJECT::LoadObject(
|
||||
GetDevice(), (char *)"spot.bgf"); // @0051d6c0
|
||||
if (spot == NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[spot] Couldn't locate spot.bgf "
|
||||
"for mech\n" << std::flush; // @0051d6c9
|
||||
}
|
||||
else
|
||||
{
|
||||
std::map<int, HierarchicalDrawComponent*>::iterator si =
|
||||
tree.segRenderable.find(segs[sl]);
|
||||
HierarchicalDrawComponent *parent =
|
||||
(si != tree.segRenderable.end()) ? si->second : NULL;
|
||||
if (parent == NULL)
|
||||
{
|
||||
// slot-space mismatch would land here -- keep the
|
||||
// receipt loud (see the bench).
|
||||
DEBUG_STREAM << "[spot] mount segment " << segs[sl]
|
||||
<< " has no joint renderable -- cone skipped; slots:";
|
||||
for (std::map<int, HierarchicalDrawComponent*>::iterator di =
|
||||
tree.segRenderable.begin();
|
||||
di != tree.segRenderable.end(); ++di)
|
||||
DEBUG_STREAM << " " << di->first;
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
LinearMatrix identity(True);
|
||||
dpl_ISECT_MODE gSpotIsectMode; // stub type (binary: mode 1)
|
||||
light.cone = new DPLStaticChildRenderable(
|
||||
entity, false /* main zone */, spot,
|
||||
gSpotIsectMode, 0 /* mask 0: never pickable */,
|
||||
identity, parent);
|
||||
light.cone->SetDrawObj(NULL); // hidden until LightOn
|
||||
light.coneObj = spot;
|
||||
}
|
||||
}
|
||||
}
|
||||
++tree.searchLightCount;
|
||||
}
|
||||
DEBUG_STREAM << "[spot] searchlight pass: " << count
|
||||
<< " light(s), view=" << (int)view_type
|
||||
<< " mountSeg=" << segs[0]
|
||||
<< (tree.searchIsCockpit ? " (cockpit: fog watcher)"
|
||||
: " (external: spot cone)")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -401,7 +499,8 @@ HierarchicalDrawComponent*
|
||||
// bring-up diagnostics (counts geometry actually loaded vs. requested)
|
||||
int dbg_obj_requested = 0, dbg_obj_loaded = 0, dbg_eye = 0;
|
||||
DEBUG_STREAM << "[BTrender] MakeMechRenderables: " << segment_count
|
||||
<< " segments, view=" << (int)type << "\n" << std::flush;
|
||||
<< " segments, view=" << (int)type
|
||||
<< " entity=" << entity->GetEntityID() << "\n" << std::flush;
|
||||
|
||||
JointSubsystem *joint_subsystem = jointed_mover->GetJointSubsystem(); // [0x31c]
|
||||
|
||||
@@ -459,6 +558,11 @@ HierarchicalDrawComponent*
|
||||
//
|
||||
if (segment->IsSiteSegment() /* [0x10] */ != 0)
|
||||
{
|
||||
if (getenv("BT_TREE_LOG"))
|
||||
DEBUG_STREAM << "[tree] SITE seg=" << segment->GetIndex()
|
||||
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
||||
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
||||
<< "\n" << std::flush;
|
||||
// The authentic COCKPIT EYEPOINT. Built for the true inside view
|
||||
// AND for the player's chase build (buildDebugChaseCamera), so the
|
||||
// V-key toggle can switch to it -- the pod's only view was this
|
||||
@@ -491,6 +595,22 @@ HierarchicalDrawComponent*
|
||||
mCamera = mEyeCockpit;
|
||||
dbg_eye = 1;
|
||||
}
|
||||
//
|
||||
// SEARCHLIGHT (2026-08-05): sites are ATTACHMENT joints, and the
|
||||
// 1995 render graph gave every one a DCS node -- that node is what
|
||||
// the subsystem-visual walk parents to (the spot cone hangs on the
|
||||
// searchlight site, @004cef28 case 0xbd8). Build the geometry-less
|
||||
// child so site slots are posed + parentable; nothing draws for the
|
||||
// site itself.
|
||||
//
|
||||
{
|
||||
dpl_ISECT_MODE site_isect;
|
||||
HierarchicalDrawComponent *site_child = new DPLStaticChildRenderable(
|
||||
entity, inDeathZone, NULL /* no geometry */,
|
||||
site_isect, 0, offset_matrix, parent_DCS);
|
||||
dcs_array[segment->GetIndex()] = site_child;
|
||||
render_tree.segRenderable[segment->GetIndex()] = site_child;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -563,6 +683,14 @@ HierarchicalDrawComponent*
|
||||
// the JointSubsystem's joint table.
|
||||
//
|
||||
int segment_slot = segment->GetIndex() /* [0xcc] */;
|
||||
// DIAG (BT_TREE_LOG): the built joint topology -- segment slot, name,
|
||||
// joint index/type, parent -- the crouch root-drop chain hunt.
|
||||
if (getenv("BT_TREE_LOG"))
|
||||
DEBUG_STREAM << "[tree] seg=" << segment_slot
|
||||
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
||||
<< " joint=" << segment->GetJointIndex()
|
||||
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
||||
<< "\n" << std::flush;
|
||||
Joint *this_joint = NULL;
|
||||
Joint::JointType joint_type;
|
||||
if (segment->GetJointIndex() /* [0xc0] */ == -1)
|
||||
@@ -884,7 +1012,8 @@ void
|
||||
if (swapped != 0 || getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
|
||||
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
|
||||
<< checked << " checked)\n" << std::flush;
|
||||
<< checked << " checked) entity="
|
||||
<< entity->GetEntityID() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
@@ -1171,13 +1300,15 @@ void
|
||||
if (hulk != NULL && hulk->GetVertCount() == 0)
|
||||
{
|
||||
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
||||
<< "' is an EMPTY placeholder -> gendbr.bgf fallback\n" << std::flush;
|
||||
<< "' is an EMPTY placeholder -> gendbr.bgf fallback (entity="
|
||||
<< victim->GetEntityID() << ")\n" << std::flush;
|
||||
hulk = NULL;
|
||||
}
|
||||
if (hulk == NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
||||
<< "' missing -> gendbr.bgf fallback\n" << std::flush;
|
||||
<< "' missing -> gendbr.bgf fallback (entity="
|
||||
<< victim->GetEntityID() << ")\n" << std::flush;
|
||||
hulk = d3d_OBJECT::LoadObject(GetDevice(), "gendbr.bgf");
|
||||
}
|
||||
|
||||
@@ -1451,9 +1582,36 @@ void
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
DEBUG_STREAM << "[armor] entity " << (void *)entity << " skel="
|
||||
<< tree.skeletonType << ": " << tree.dmgBinds.size()
|
||||
<< " material binding(s)\n" << std::flush;
|
||||
// ORPHAN inventory: drawn ops NO zone claimed. These panels never
|
||||
// darken -- if the big hull materials land here (a .DZM name the mesh
|
||||
// doesn't carry, or per-zone material instancing collapsed at load),
|
||||
// the mech reads "no darkening" live even while every bound op logs
|
||||
// perfectly (the #87 visibility gap).
|
||||
for (std::map<int, MechRenderTree::SegPick>::iterator si = tree.segPick.begin();
|
||||
si != tree.segPick.end(); ++si)
|
||||
{
|
||||
d3d_OBJECT *obj = si->second.obj;
|
||||
if (obj == NULL)
|
||||
continue;
|
||||
for (int op = 0; op < obj->GetDrawOpCount(); ++op)
|
||||
{
|
||||
L4DRAWOP *draw_op = obj->GetDrawOp(op);
|
||||
if (draw_op->dzMatName[0] == '\0')
|
||||
continue;
|
||||
int claimed = 0;
|
||||
for (size_t b = 0; b < tree.dmgBinds.size() && !claimed; ++b)
|
||||
if (tree.dmgBinds[b].obj == obj && tree.dmgBinds[b].op == op)
|
||||
claimed = 1;
|
||||
if (!claimed)
|
||||
DEBUG_STREAM << "[armor] ORPHAN op seg=" << si->first
|
||||
<< " '" << draw_op->dzMatName << "'\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1514,6 +1672,63 @@ void
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
//#############################################################################
|
||||
// TickSearchlight -- the two 1995 searchlight watchers, transcribed
|
||||
//#############################################################################
|
||||
//
|
||||
// External cone (@0045612c, Execute @004561d8): poll the subsystem's LightOn
|
||||
// attribute; on change, set the spot instance visible iff the value matches
|
||||
// (match value 1) and flush. Our equivalent of SetInstanceOn/Flush is the
|
||||
// SetDrawObj object swap (the wreck-reveal mechanism).
|
||||
//
|
||||
// Cockpit fog (@00456778, Execute @00456814): poll BOTH stashed attrs; when
|
||||
// either changed, either-on -> SetFogStyle(searchLightOnFogStyle) else
|
||||
// SetFogStyle(searchLightOffFogStyle). The ctor's INVERTED caches make the
|
||||
// first poll always fire, normalizing a night start onto nosearchlightfog --
|
||||
// the authentic dark (fog= is the LIT set; see BTDPL.INI per-page authoring).
|
||||
//
|
||||
void
|
||||
BTL4VideoRenderer::TickSearchlight(Entity *mech)
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator ti = mMechRenderTrees.find(mech);
|
||||
if (ti == mMechRenderTrees.end() || ti->second.searchLightCount <= 0)
|
||||
return;
|
||||
MechRenderTree &tree = ti->second;
|
||||
|
||||
int any_on = 0, changed = 0;
|
||||
for (int sl = 0; sl < tree.searchLightCount; ++sl)
|
||||
{
|
||||
MechRenderTree::SearchLight &light = tree.searchLight[sl];
|
||||
if (light.lightOn == NULL)
|
||||
continue;
|
||||
const int cur = (*light.lightOn != 0);
|
||||
|
||||
if (light.cone != NULL && cur != light.shown) // @004561d8
|
||||
{
|
||||
light.cone->SetDrawObj(cur ? light.coneObj : NULL);
|
||||
light.shown = cur;
|
||||
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[spot] cone " << (cur ? "SHOWN" : "HIDDEN")
|
||||
<< " (seg " << light.mountSeg << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
if (cur != tree.searchFogCache[sl]) // @00456814
|
||||
changed = 1;
|
||||
tree.searchFogCache[sl] = cur;
|
||||
any_on |= cur;
|
||||
}
|
||||
|
||||
if (tree.searchIsCockpit && changed)
|
||||
{
|
||||
SetFogStyle(any_on ? searchLightOnFogStyle : searchLightOffFogStyle);
|
||||
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[spot] cockpit fog -> "
|
||||
<< (any_on ? "searchLightOn" : "searchLightOff") << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Sim-side bridge (per-mech, every frame, from Mech::PerformAndWatch).
|
||||
//
|
||||
@@ -1526,6 +1741,7 @@ void BTArmourDamageTick(Entity *mech)
|
||||
if (renderer == NULL)
|
||||
return;
|
||||
renderer->TickArmourDamage(mech);
|
||||
renderer->TickSearchlight(mech); // the searchlight watchers ride the same beat
|
||||
}
|
||||
|
||||
|
||||
@@ -1545,10 +1761,106 @@ int BTWreckSinkTick(Entity *victim, float dt)
|
||||
|
||||
|
||||
//
|
||||
// #73 -- the aimed PER-PART pick (see the header note). Ray-vs-sphere over
|
||||
// the per-segment draw objects recorded at tree build; world centers come
|
||||
// through the draw-cached mLocalToWorld (updated every drawn frame -- the
|
||||
// target being aimed at is on screen, so at most one frame stale).
|
||||
// #124 -- the pick's TRIANGLE cache. The 1995 pick was a dpl scene
|
||||
// intersection against the DRAWN GEOMETRY (the division card cast from the
|
||||
// view); the port's sphere approximation measurably mis-picked (the zone-walk
|
||||
// matrix: aim dead-on dtorso -> picked rgun/ruleg -- gun/limb spheres thread
|
||||
// the ray before the torso from most angles, and its own comments admitted "a
|
||||
// foot can be unhittable behind its own knee"). Restore the authentic
|
||||
// semantic: nearest RAY-TRIANGLE hit across the candidate segments' posed
|
||||
// meshes. Positions are read ONCE per d3d_OBJECT from its own BGF buffers
|
||||
// (managed pool, lockable) and cached CPU-side; the per-frame cost is a
|
||||
// sphere pre-filter + Moller-Trumbore over the few threaded segments.
|
||||
//
|
||||
struct BTPickMesh
|
||||
{
|
||||
std::vector<float> pos; // xyz per vertex
|
||||
std::vector<unsigned int> idx; // triangle list
|
||||
int ok;
|
||||
};
|
||||
static std::map<d3d_OBJECT *, BTPickMesh> gBTPickMeshes;
|
||||
|
||||
static BTPickMesh *
|
||||
BTGetPickMesh(d3d_OBJECT *obj)
|
||||
{
|
||||
std::map<d3d_OBJECT *, BTPickMesh>::iterator mi = gBTPickMeshes.find(obj);
|
||||
if (mi != gBTPickMeshes.end())
|
||||
return mi->second.ok ? &mi->second : 0;
|
||||
|
||||
BTPickMesh &pm = gBTPickMeshes[obj];
|
||||
pm.ok = 0;
|
||||
if (obj->mBgfVB == 0 || obj->mBgfIB == 0 || obj->mBgfStride < 12)
|
||||
return 0;
|
||||
|
||||
D3DINDEXBUFFER_DESC ibd;
|
||||
if (FAILED(obj->mBgfIB->GetDesc(&ibd)))
|
||||
return 0;
|
||||
int idx32 = (ibd.Format == D3DFMT_INDEX32);
|
||||
unsigned int nIdx = ibd.Size / (idx32 ? 4 : 2);
|
||||
|
||||
void *vp = 0, *ip = 0;
|
||||
if (FAILED(obj->mBgfVB->Lock(0, 0, &vp, D3DLOCK_READONLY)))
|
||||
return 0;
|
||||
if (FAILED(obj->mBgfIB->Lock(0, 0, &ip, D3DLOCK_READONLY)))
|
||||
{
|
||||
obj->mBgfVB->Unlock();
|
||||
return 0;
|
||||
}
|
||||
pm.pos.resize((size_t)obj->mBgfNumVerts * 3);
|
||||
const unsigned char *vb = (const unsigned char *)vp;
|
||||
for (UINT v = 0; v < obj->mBgfNumVerts; ++v)
|
||||
{
|
||||
const float *p = (const float *)(vb + (size_t)v * obj->mBgfStride);
|
||||
pm.pos[v*3+0] = p[0]; // position-first vertex layout (the BGF
|
||||
pm.pos[v*3+1] = p[1]; // loader's own decl; the cull sphere was
|
||||
pm.pos[v*3+2] = p[2]; // computed from these same floats at load)
|
||||
}
|
||||
pm.idx.resize(nIdx);
|
||||
if (idx32)
|
||||
{
|
||||
const unsigned int *s = (const unsigned int *)ip;
|
||||
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
||||
}
|
||||
else
|
||||
{
|
||||
const unsigned short *s = (const unsigned short *)ip;
|
||||
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
||||
}
|
||||
obj->mBgfIB->Unlock();
|
||||
obj->mBgfVB->Unlock();
|
||||
pm.ok = (pm.idx.size() >= 3 && pm.pos.size() >= 9);
|
||||
return pm.ok ? &pm : 0;
|
||||
}
|
||||
|
||||
// Moller-Trumbore, both-sided (the pod's dpl geometry has no consistent
|
||||
// winding guarantee across ported BGF pieces). Returns t >= 0 or -1.
|
||||
static float
|
||||
BTRayTri(const float o[3], const float d[3],
|
||||
const float *a, const float *b, const float *c)
|
||||
{
|
||||
float e1[3] = { b[0]-a[0], b[1]-a[1], b[2]-a[2] };
|
||||
float e2[3] = { c[0]-a[0], c[1]-a[1], c[2]-a[2] };
|
||||
float pv[3] = { d[1]*e2[2]-d[2]*e2[1], d[2]*e2[0]-d[0]*e2[2], d[0]*e2[1]-d[1]*e2[0] };
|
||||
float det = e1[0]*pv[0] + e1[1]*pv[1] + e1[2]*pv[2];
|
||||
if (det > -1e-8f && det < 1e-8f) return -1.0f;
|
||||
float inv = 1.0f / det;
|
||||
float tv[3] = { o[0]-a[0], o[1]-a[1], o[2]-a[2] };
|
||||
float u = (tv[0]*pv[0] + tv[1]*pv[1] + tv[2]*pv[2]) * inv;
|
||||
if (u < 0.0f || u > 1.0f) return -1.0f;
|
||||
float qv[3] = { tv[1]*e1[2]-tv[2]*e1[1], tv[2]*e1[0]-tv[0]*e1[2], tv[0]*e1[1]-tv[1]*e1[0] };
|
||||
float v = (d[0]*qv[0] + d[1]*qv[1] + d[2]*qv[2]) * inv;
|
||||
if (v < 0.0f || u + v > 1.0f) return -1.0f;
|
||||
float t = (e2[0]*qv[0] + e2[1]*qv[1] + e2[2]*qv[2]) * inv;
|
||||
return (t >= 0.0f) ? t : -1.0f;
|
||||
}
|
||||
|
||||
//
|
||||
// #73 -- the aimed PER-PART pick (see the header note). #124: now a true
|
||||
// DRAWN-GEOMETRY intersection -- sphere pre-filter, then nearest ray-triangle
|
||||
// hit across the threaded segments' posed meshes (the 1995 division-card
|
||||
// semantic). The old smallest-sphere selection survives only as the fallback
|
||||
// when no triangle anywhere is struck (grazing edge shots). World transforms
|
||||
// come through the draw-cached mLocalToWorld (at most one frame stale).
|
||||
//
|
||||
int
|
||||
BTL4VideoRenderer::MechSegmentPick(
|
||||
@@ -1561,7 +1873,8 @@ int
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
return -1; // #131: NO TREE (unbuilt replicant / wreck) -- the caller may
|
||||
// box-test; distinct from 0 = a true drawn-geometry MISS.
|
||||
|
||||
// Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
|
||||
// SMALLEST radius wins (normalized-distance tie-break). Neither nearest-
|
||||
@@ -1573,12 +1886,35 @@ int
|
||||
// envelope; smallest-pierced picks the most specific part on the aim line,
|
||||
// and the torso wins only when no limb is threaded -- which is the per-part
|
||||
// semantic the 1995 mesh intersection produced.
|
||||
float bestR = 1e30f; // primary key: sphere radius (ascending)
|
||||
float bestScore = 1.0f; // tie-break: normalized perpendicular d2/r2
|
||||
float bestR = 1e30f; // sphere-FALLBACK key: radius (ascending)
|
||||
float bestScore = 1.0f; // sphere tie-break: normalized d2/r2
|
||||
float bestT = max_range;
|
||||
int bestZone = -1;
|
||||
int hitAny = 0;
|
||||
|
||||
// triangle-accurate primary: nearest surface hit across all segments
|
||||
float triBestT = max_range;
|
||||
int triBestZone = -1;
|
||||
int triBestSeg = -1;
|
||||
int triHit = 0;
|
||||
d3d_OBJECT *triBestObj = 0; // #124-PATCH: winning object + tri
|
||||
size_t triBestK = 0; // (triangle ordinal in its IB)
|
||||
int dbgTriObjs = 0, dbgInvFail = 0, dbgNoTri = 0; // #131 telemetry
|
||||
|
||||
// #124 CORRECTED (2026-08-04, field pushback vindicated): the pick's zone
|
||||
// is the struck PATCH's authored dz_* tag, with the segment's SKL dzone as
|
||||
// the fallback for untagged patches. The earlier "struck segment's dzone,
|
||||
// ALWAYS" model collapsed the hull to dtorso -- but the art zone-tags the
|
||||
// hull PER PANEL (MAD_TOR carries dz_utorso/ltorso/rtorso/dtorso + all four
|
||||
// rear panels + searchlight across its patches), the dpl hit result kept
|
||||
// GEOGROUP granularity (dplHitInstance/DCS/GeoGroup/Geometry), and the
|
||||
// binary's segment->zone map (@49db20) has NO runtime caller (sole caller =
|
||||
// CreateStreamedDamageZone, load-time) -- there was nothing to do the
|
||||
// segment-level collapse with. Era players' "I stripped the left torso in
|
||||
// the pod" stands. The triangle -> patch attribution below reuses the .DZM
|
||||
// zone->material bindings (#87's armour darkening -- the same authored
|
||||
// mapping that already paints those panels per-zone).
|
||||
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
@@ -1609,6 +1945,77 @@ int
|
||||
if (t < 0.0f || t >= max_range)
|
||||
continue;
|
||||
|
||||
// ---- TRIANGLE TEST (#124): the sphere only nominates ----
|
||||
BTPickMesh *pm = BTGetPickMesh(obj);
|
||||
if (pm == 0 && getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_nt = 0;
|
||||
if (s_nt++ < 40)
|
||||
DEBUG_STREAM << "[picktri] seg=" << sp->first << " zone="
|
||||
<< sp->second.zone << " NO TRIS (vb=" << (void *)obj->mBgfVB
|
||||
<< " ib=" << (void *)obj->mBgfIB
|
||||
<< " stride=" << obj->mBgfStride << ")\n" << std::flush;
|
||||
}
|
||||
if (pm == 0)
|
||||
++dbgNoTri;
|
||||
if (pm != 0)
|
||||
{
|
||||
++dbgTriObjs;
|
||||
// ray into object-local space (affine inverse; segment poses are
|
||||
// rigid, so local t == world t after direction normalization is
|
||||
// preserved by construction below)
|
||||
D3DXMATRIX w2l;
|
||||
if (D3DXMatrixInverse(&w2l, 0, &l2w) == 0)
|
||||
++dbgInvFail;
|
||||
if (D3DXMatrixInverse(&w2l, 0, &l2w) != 0)
|
||||
{
|
||||
D3DXVECTOR3 lo, ld;
|
||||
D3DXVECTOR3 wo(ray_start[0], ray_start[1], ray_start[2]);
|
||||
D3DXVECTOR3 wd(ray_dir[0], ray_dir[1], ray_dir[2]);
|
||||
D3DXVec3TransformCoord(&lo, &wo, &w2l);
|
||||
D3DXVec3TransformNormal(&ld, &wd, &w2l);
|
||||
float o3[3] = { lo.x, lo.y, lo.z };
|
||||
float d3[3] = { ld.x, ld.y, ld.z };
|
||||
const float *P = &pm->pos[0];
|
||||
size_t nv = pm->pos.size() / 3;
|
||||
for (size_t k = 0; k + 2 < pm->idx.size(); k += 3)
|
||||
{
|
||||
unsigned int i0 = pm->idx[k], i1 = pm->idx[k+1], i2 = pm->idx[k+2];
|
||||
if (i0 >= nv || i1 >= nv || i2 >= nv)
|
||||
continue;
|
||||
float tt = BTRayTri(o3, d3, P + i0*3, P + i1*3, P + i2*3);
|
||||
if (tt >= 0.0f && tt < triBestT)
|
||||
{
|
||||
// world-space t of the local hit (handles any scale)
|
||||
D3DXVECTOR3 lh(o3[0]+d3[0]*tt, o3[1]+d3[1]*tt, o3[2]+d3[2]*tt);
|
||||
D3DXVECTOR3 wh;
|
||||
D3DXVec3TransformCoord(&wh, &lh, &l2w);
|
||||
float wt = (wh.x - ray_start[0]) * ray_dir[0]
|
||||
+ (wh.y - ray_start[1]) * ray_dir[1]
|
||||
+ (wh.z - ray_start[2]) * ray_dir[2];
|
||||
if (wt >= 0.0f && wt < triBestT)
|
||||
{
|
||||
triBestT = wt;
|
||||
triBestZone = sp->second.zone; // segment dzone (fallback)
|
||||
triBestSeg = sp->first;
|
||||
triBestObj = obj;
|
||||
triBestK = k; // first index of the tri
|
||||
triHit = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sphere FALLBACK bookkeeping -- #131 MISS-MEANS-MISS: only an
|
||||
// object whose drawn mesh is UNREADABLE (pm == 0) may answer by
|
||||
// sphere. A readable mesh the ray misses is a MISS. The old
|
||||
// any-object sphere halo produced lock-without-mech: level rays over
|
||||
// the squat blackhawk read tri=0 / sphereFB=168 on the sweep bench,
|
||||
// and the operator watched the reticle pass over its head with the
|
||||
// ring lit. The 1995 card cast against the DRAWN geometry -- no halo.
|
||||
if (pm != 0)
|
||||
continue;
|
||||
float score = d2 / r2; // 0 = dead-center thread
|
||||
if (r > bestR
|
||||
|| (r == bestR && score >= bestScore))
|
||||
@@ -1621,6 +2028,58 @@ int
|
||||
hitAny = 1;
|
||||
}
|
||||
|
||||
// #131 telemetry: how did this pick answer? (TRI = drawn-geometry hit,
|
||||
// the authentic semantic; SPHERE = the fallback answered with no triangle
|
||||
// struck -- the false-lock candidate.) Throttled under BT_PICK_LOG.
|
||||
// objs/inv/noTri localize a dead triangle pass: objs = sphere-nominated
|
||||
// objects entering the tri test, inv = SILENT skips from a failed
|
||||
// world-matrix inverse (degenerate l2w), noTri = pick-mesh read failures.
|
||||
if (getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_src[3] = {0,0,0}; // [0]=tri [1]=sphere [2]=miss
|
||||
static int s_objs = 0, s_inv = 0, s_noTri = 0;
|
||||
static int s_rep = 0;
|
||||
s_objs += dbgTriObjs; s_inv += dbgInvFail; s_noTri += dbgNoTri;
|
||||
++s_src[triHit ? 0 : (hitAny ? 1 : 2)];
|
||||
if ((++s_rep % 240) == 0)
|
||||
DEBUG_STREAM << "[picksrc] tri=" << s_src[0]
|
||||
<< " sphereFB=" << s_src[1] << " miss=" << s_src[2]
|
||||
<< " objs=" << s_objs << " invFail=" << s_inv
|
||||
<< " noTri=" << s_noTri
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
if (triHit)
|
||||
{
|
||||
bestT = triBestT;
|
||||
bestZone = triBestZone; // segment dzone unless a patch tags it
|
||||
hitAny = 1;
|
||||
// #124-PATCH: attribute the struck triangle to its DRAW OP (each op is
|
||||
// an index RANGE in the same IB the pick mesh copied) and take the
|
||||
// op's .DZM-bound zone -- the authored dz_* panel. dmgBinds is the
|
||||
// #87 armour mapping: {obj, op, zone} for every material a zone paints.
|
||||
if (triBestObj != 0)
|
||||
{
|
||||
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
||||
for (size_t b = 0; b < binds.size(); ++b)
|
||||
{
|
||||
if (binds[b].obj != triBestObj)
|
||||
continue;
|
||||
L4DRAWOP *op = triBestObj->GetDrawOp(binds[b].op);
|
||||
if (op == 0 || op->bgfPrimCount <= 0)
|
||||
continue;
|
||||
size_t first = (size_t)op->bgfStartIndex;
|
||||
size_t count = (size_t)op->bgfPrimCount * 3;
|
||||
if (triBestK >= first && triBestK < first + count)
|
||||
{
|
||||
bestZone = binds[b].zone; // the struck PANEL's zone
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(void)triBestSeg;
|
||||
}
|
||||
|
||||
// #92 probe: which spheres did the ray actually THREAD, and which won?
|
||||
// "smallest radius wins" means a big sphere can never beat a small one that
|
||||
// the ray also grazes -- so a foot can be unhittable behind its own knee.
|
||||
@@ -1681,7 +2140,9 @@ int
|
||||
static int s_pl = 0;
|
||||
if ((s_pl++ % 60) == 0)
|
||||
DEBUG_STREAM << "[pickwin] zone=" << bestZone
|
||||
<< " score=" << bestScore << " t=" << bestT << "\n" << std::flush;
|
||||
<< " score=" << bestScore << " t=" << bestT
|
||||
<< " tri=" << triHit << " triSeg=" << triBestSeg
|
||||
<< " triZone=" << triBestZone << "\n" << std::flush;
|
||||
}
|
||||
|
||||
hit_out[0] = ray_start[0] + bestT * ray_dir[0];
|
||||
@@ -1692,11 +2153,102 @@ int
|
||||
}
|
||||
|
||||
//
|
||||
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
||||
// the wreck swap below).
|
||||
// #124 zone walker: the zone's visual aim anchor (see the hpp note).
|
||||
//
|
||||
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
||||
float max_range, float hit_out[3], int *zone_out)
|
||||
int
|
||||
BTL4VideoRenderer::ZoneAimPoint(Entity *mech, int zone, float out3[3])
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
|
||||
// #124-PATCH: a zone with .DZM-bound panel patches aims at the PATCH
|
||||
// CENTROID (the authored dz_* panel -- upper/left/right/rear torso are
|
||||
// distinct patch clusters on ONE torso object, so the old cull-center
|
||||
// answer aimed every hull zone at the same chest point). Centroid =
|
||||
// average of the bound ops' triangle vertices, world-transformed.
|
||||
{
|
||||
double acc[3] = { 0, 0, 0 };
|
||||
long nAcc = 0;
|
||||
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
||||
for (size_t b = 0; b < binds.size(); ++b)
|
||||
{
|
||||
if (binds[b].zone != zone || binds[b].obj == NULL)
|
||||
continue;
|
||||
d3d_OBJECT *obj = binds[b].obj;
|
||||
L4DRAWOP *op = obj->GetDrawOp(binds[b].op);
|
||||
if (op == 0 || op->bgfPrimCount <= 0)
|
||||
continue;
|
||||
BTPickMesh *pm = BTGetPickMesh(obj);
|
||||
if (pm == 0)
|
||||
continue;
|
||||
D3DXMATRIX l2w = obj->GetLocalToWorld();
|
||||
size_t first = (size_t)op->bgfStartIndex;
|
||||
size_t last = first + (size_t)op->bgfPrimCount * 3;
|
||||
if (last > pm->idx.size())
|
||||
continue;
|
||||
for (size_t k = first; k < last; ++k)
|
||||
{
|
||||
unsigned int vi = pm->idx[k];
|
||||
if ((size_t)vi * 3 + 2 >= pm->pos.size())
|
||||
continue;
|
||||
D3DXVECTOR3 lp(pm->pos[vi*3], pm->pos[vi*3+1], pm->pos[vi*3+2]);
|
||||
D3DXVECTOR3 wp;
|
||||
D3DXVec3TransformCoord(&wp, &lp, &l2w);
|
||||
acc[0] += wp.x; acc[1] += wp.y; acc[2] += wp.z;
|
||||
++nAcc;
|
||||
}
|
||||
}
|
||||
if (nAcc > 0)
|
||||
{
|
||||
out3[0] = (float)(acc[0] / nAcc);
|
||||
out3[1] = (float)(acc[1] / nAcc);
|
||||
out3[2] = (float)(acc[2] / nAcc);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
d3d_OBJECT *best = 0;
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
if (sp->second.zone != zone || sp->second.obj == NULL)
|
||||
continue;
|
||||
if (best == 0 || sp->second.obj->mCullRadius > best->mCullRadius)
|
||||
best = sp->second.obj;
|
||||
}
|
||||
if (best == 0 || best->mCullRadius <= 0.0f)
|
||||
return 0;
|
||||
D3DXMATRIX l2w = best->GetLocalToWorld();
|
||||
D3DXVECTOR3 cw;
|
||||
D3DXVec3TransformCoord(&cw, &best->mCullCenter, &l2w);
|
||||
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The carrier-SEGMENT variant: anchor at segPick[seg]'s cull center (the
|
||||
// piece's visual middle). For hull-family zones no object claims the zone,
|
||||
// and the zone table's carrier is the hull joint whose ORIGIN sits in the
|
||||
// crotch gap -- rays aimed there sail between the legs to the terrain
|
||||
// sentinel. The cull center is the chest.
|
||||
int
|
||||
BTL4VideoRenderer::SegAimPoint(Entity *mech, int seg, float out3[3])
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp = it->second.segPick.find(seg);
|
||||
if (sp == it->second.segPick.end() || sp->second.obj == NULL
|
||||
|| sp->second.obj->mCullRadius <= 0.0f)
|
||||
return 0;
|
||||
D3DXMATRIX l2w = sp->second.obj->GetLocalToWorld();
|
||||
D3DXVECTOR3 cw;
|
||||
D3DXVec3TransformCoord(&cw, &sp->second.obj->mCullCenter, &l2w);
|
||||
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int BTMechSegAimPoint(void *mech, int seg, float out3[3])
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return 0;
|
||||
@@ -1704,6 +2256,35 @@ int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return 0;
|
||||
return renderer->SegAimPoint((Entity *)mech, seg, out3);
|
||||
}
|
||||
|
||||
int BTMechZoneAimPoint(void *mech, int zone, float out3[3])
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return 0;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return 0;
|
||||
return renderer->ZoneAimPoint((Entity *)mech, zone, out3);
|
||||
}
|
||||
|
||||
//
|
||||
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
||||
// the wreck swap below).
|
||||
//
|
||||
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
||||
float max_range, float hit_out[3], int *zone_out)
|
||||
{
|
||||
// #131 contract: 1 = drawn-geometry hit, 0 = TRUE MISS (mesh tested),
|
||||
// -1 = no renderer/tree (caller may box-test as pre-tree grace).
|
||||
if (mech == NULL || application == NULL)
|
||||
return -1;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return -1;
|
||||
return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir,
|
||||
max_range, hit_out, zone_out);
|
||||
}
|
||||
@@ -2041,6 +2622,14 @@ void
|
||||
{
|
||||
// the range caret translate, from the live target range
|
||||
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
|
||||
// #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH
|
||||
// false for NaN, so the old pair let a poisoned value straight through into
|
||||
// AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its
|
||||
// bar silently STOP DRAWING while the static ticks remain. Test for NaN
|
||||
// first (x == x is false only for NaN) and fall back to the binary's
|
||||
// no-target default rather than rendering nothing.
|
||||
if (!(range == range))
|
||||
range = maxRange; // 1200: the authentic no-target peg
|
||||
if (range < minRange) range = minRange;
|
||||
if (range > maxRange) range = maxRange;
|
||||
Scalar frac = (range - minRange) / (maxRange - minRange);
|
||||
@@ -2916,6 +3505,16 @@ int
|
||||
if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL
|
||||
&& getenv("BT_HIDE_COCKPIT"))
|
||||
nm = NULL;
|
||||
// #91 attribution diag: BT_HIDE_INSIDE_SEG=<substr> hides any OTHER
|
||||
// inside-view (type-A) mesh whose name contains the substring -- the
|
||||
// thor authors THREE type-A meshes (cop + thx_tor + thx_msl) and one
|
||||
// of the extras is the reported black rectangle.
|
||||
{
|
||||
const char *hide = getenv("BT_HIDE_INSIDE_SEG");
|
||||
if (inside && nm != NULL && hide && *hide
|
||||
&& strstr((const char *)*nm, hide) != NULL)
|
||||
nm = NULL;
|
||||
}
|
||||
d3d_OBJECT *obj = NULL;
|
||||
if (nm != NULL)
|
||||
{
|
||||
@@ -2947,6 +3546,14 @@ int
|
||||
else
|
||||
render_tree.segPick.erase(slot);
|
||||
if (obj) ++shown; else ++hidden;
|
||||
// Inside view: NAME what renders. The KB says the inside view is the
|
||||
// _cop alone; any OTHER type-A mesh here is a black-material stowaway
|
||||
// (the #91 rectangle investigation) -- keep the roster visible.
|
||||
if (inside && obj != NULL)
|
||||
DEBUG_STREAM << "[view] shown seg " << slot << ": "
|
||||
<< (const char *)segment->GetName()
|
||||
<< " mesh " << (nm ? (const char *)*nm : "?")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
// This reloaded every segment mesh, so every armour-damage binding is stale.
|
||||
BindArmourDamage(viewpoint, render_tree);
|
||||
@@ -3080,6 +3687,35 @@ namespace {
|
||||
|
||||
extern void BTSetWorldDead(int dead); // L4VIDRND.cpp bridge -> l4_application->SetIsDead
|
||||
|
||||
//===========================================================================//
|
||||
// #156 -- the mission start/end fade arming seam. The phase machine itself
|
||||
// runs at the renderer frame head (L4VIDEO.cpp, beside the FOGCOLOR clear);
|
||||
// these globals are what MakeEntityRenderables' BTPlayer master case arms
|
||||
// through the now-real BTPOVStartEndRenderable ctor below.
|
||||
//===========================================================================//
|
||||
void *gBTFadeIndicator = 0;
|
||||
int gBTFadeStart = 3, gBTFadeEnd = 4;
|
||||
|
||||
// The real ctor (was a btstubs.cpp inert stub -- the #156 root cause: rounds
|
||||
// hard-cut instead of fading). Binary ctor @00454394 stores the indicator +
|
||||
// the authored fog params and registers on the view's watcher list; the port
|
||||
// keeps the object inert (house pattern, like the translocation renderable)
|
||||
// and arms the frame-head machine instead. The fog params ride the renderer
|
||||
// members live, exactly as the binary re-reads them per fade frame.
|
||||
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
|
||||
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *,
|
||||
StateIndicator *sim_state,
|
||||
float, float, float, float, float, int start_state, int end_state)
|
||||
: BTRenderableBase(entity)
|
||||
{
|
||||
gBTFadeIndicator = (void *)sim_state;
|
||||
gBTFadeStart = start_state;
|
||||
gBTFadeEnd = end_state;
|
||||
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[fade] armed (states " << start_state << "/"
|
||||
<< end_state << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
// The renderable objects the entity tree builds for the translocation wiring are
|
||||
// inert now (the warp is the self-contained one-shot below); the ctor/dtor just
|
||||
// satisfy MakeEntityRenderables.
|
||||
|
||||
@@ -721,6 +721,24 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
|
||||
std::vector<DmgBind> dmgBinds;
|
||||
std::map<int, int> segGState; // slot -> last applied graphic state
|
||||
// SEARCHLIGHT (2026-08-05, MakeMechRenderables case 0xbd8 @004cef28):
|
||||
// external view = a spot.bgf cone child on the mount joint, shown/
|
||||
// hidden from the subsystem's "LightOn" attribute (the @0045612c
|
||||
// watcher, transcribed into TickSearchlight); cockpit view = the fog
|
||||
// swap (@00456778/@00456814 -> SetFogStyle searchLightOn/Off).
|
||||
struct SearchLight
|
||||
{
|
||||
DPLStaticChildRenderable *cone; // external only; NULL on cockpit builds
|
||||
d3d_OBJECT *coneObj; // spot.bgf (held while hidden)
|
||||
int *lightOn; // the subsystem's LightOn attribute
|
||||
int shown; // cone visibility last applied
|
||||
int mountSeg; // resource segmentIndex (subsys +0x1DC)
|
||||
};
|
||||
SearchLight searchLight[2]; // the binary keeps 2 slots
|
||||
int searchLightCount;
|
||||
int searchIsCockpit; // built with view_type 1
|
||||
int searchFogCache[2]; // seeded INVERTED (@00456778
|
||||
// [9]/[10]) -> first tick syncs
|
||||
char paintSerno; // the %serno% this mech was BUILT with
|
||||
// (0 = none) -- ApplyViewSkeleton re-parses
|
||||
// segment BGFs, so the paint substitutions
|
||||
@@ -740,6 +758,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
int
|
||||
TickWreck(Entity *victim, float dt);
|
||||
|
||||
//
|
||||
// SEARCHLIGHT per-frame drive (2026-08-05): the transcription of the two
|
||||
// 1995 watcher renderables -- cone visibility from "LightOn" (@0045612c
|
||||
// Execute @004561d8) and the cockpit fog swap (@00456778 Execute
|
||||
// @00456814). Rides the same sim bridge as TickArmourDamage.
|
||||
//
|
||||
void
|
||||
TickSearchlight(Entity *mech);
|
||||
|
||||
//
|
||||
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
|
||||
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
|
||||
@@ -797,6 +824,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
const float ray_dir[3], float max_range,
|
||||
float hit_out[3], int *zone_out);
|
||||
|
||||
// #124 zone walker: a zone's VISUAL aim point -- its largest pick
|
||||
// object's cull-center in world (what a player aims at; the segment
|
||||
// ORIGIN sits at the joint and makes lower segments strike the part
|
||||
// above). 0 when the zone has no pick geometry.
|
||||
int
|
||||
ZoneAimPoint(Entity *mech, int zone, float out3[3]);
|
||||
int
|
||||
SegAimPoint(Entity *mech, int seg, float out3[3]);
|
||||
|
||||
protected:
|
||||
//
|
||||
// Renderer-manager overrides
|
||||
|
||||
+722
-180
File diff suppressed because it is too large
Load Diff
+121
-22
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
|
||||
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
|
||||
// score types describe mech combat instead of score zones.
|
||||
//
|
||||
// Observed message layout (param_2 in the handlers):
|
||||
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
|
||||
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
|
||||
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
|
||||
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
|
||||
// were guesses from RP's smaller message, which stops at +0x28):
|
||||
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
|
||||
// damage tally here; the receiving handler
|
||||
// overwrites it with the computed award before
|
||||
// delegating to Player::ScoreMessageHandler
|
||||
// +0x20 scoreType (int)
|
||||
// +0x24 damageAmount (Scalar) raw damage / point quantity
|
||||
// +0x28 pointSenderHi (EntityID word)
|
||||
// +0x2c pointSenderLo (EntityID word)
|
||||
// +0x30 auxID (EntityID / host word)
|
||||
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
|
||||
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
|
||||
// type 1 = intended (burstCount x amount),
|
||||
// type 2 = the VICTIM role's killBonus (role+0x1c)
|
||||
// +0x28 vitalHit (int) a zone reached BurningState during
|
||||
// this message's applications (loop local_2c)
|
||||
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
|
||||
// the INITIAL resolve -- not the last burst's)
|
||||
// +0x30 subsysID (int) inflictingSubsystemID passed through
|
||||
// from the TakeDamageMessage (+0x5c)
|
||||
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
|
||||
// type 1: the INFLICTING mech
|
||||
//
|
||||
class BTPlayer__ScoreMessage:
|
||||
public Player::ScoreMessage
|
||||
@@ -72,11 +84,24 @@ class DropZone__ReplyMessage;
|
||||
public:
|
||||
//
|
||||
// Kind of scoring event. Recovered from the branch selector at
|
||||
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
|
||||
// inflicted-damage handler at @004c0200.
|
||||
// @004c02e4 (this->scoreType, message+0x20).
|
||||
//
|
||||
// CORRECTED 2026-08-07. This note used to read "type 0 has NO scoring
|
||||
// arm in the binary ... @004c0200 appears in no handler-table entry ...
|
||||
// per-hit inflicted credit never existed", and build 787 deleted the
|
||||
// credit on that basis. The byte-scan was right that no TABLE entry
|
||||
// binds @004c0200 and wrong to conclude it is unreachable: BTPlayer
|
||||
// overrides Dispatch (vtable @00513300 slot 3 = FUN_004bffa0), which
|
||||
// splits type 0 off BEFORE base dispatch and calls it directly. That
|
||||
// is also WHY @004c02e4 Verify-rejects type 0 -- the interceptor
|
||||
// guarantees it never arrives there. @004c0200 names itself
|
||||
// "BTPlayer::ScoreInflictedMessageHandler" in its own Verify string,
|
||||
// and the original manual's scoring chart independently confirms what
|
||||
// it computes: "+1 each damage point scored on opponent's armor",
|
||||
// "-1 each self-inflicted point".
|
||||
//
|
||||
enum ScoreType {
|
||||
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
|
||||
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above)
|
||||
DamageReceivedScore = 1, // I took damage
|
||||
KillScore = 2 // I destroyed / was destroyed
|
||||
};
|
||||
@@ -85,21 +110,19 @@ class DropZone__ReplyMessage;
|
||||
scoreType; // +0x20
|
||||
|
||||
Scalar
|
||||
damageAmount; // +0x24
|
||||
damageAmount; // +0x24 scoring basis (see layout table)
|
||||
|
||||
//
|
||||
// Point-source / auxiliary handles carried alongside the damage record
|
||||
// (read by ScoreMessageHandler when building the console feed message).
|
||||
//
|
||||
int
|
||||
pointSenderHi; // +0x28
|
||||
vitalHit; // +0x28 zone entered BurningState this message
|
||||
|
||||
int
|
||||
pointSenderLo; // +0x2c
|
||||
zoneIndex; // +0x2c struck damage zone
|
||||
|
||||
int
|
||||
auxID; // +0x30
|
||||
subsysID; // +0x30 inflicting weapon subsystem
|
||||
|
||||
EntityID
|
||||
senderMechID; // +0x34 inflicting mech (point sender)
|
||||
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
|
||||
|
||||
BTPlayer__ScoreMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
@@ -107,15 +130,63 @@ class DropZone__ReplyMessage;
|
||||
int score_type,
|
||||
Scalar score_award,
|
||||
Scalar damage_amount,
|
||||
const EntityID &sender_mech_ID
|
||||
const EntityID &sender_mech_ID,
|
||||
int vital_hit = 0,
|
||||
int zone_index = 0,
|
||||
int subsys_ID = 0
|
||||
):
|
||||
Player::ScoreMessage(message_ID, length, score_award),
|
||||
scoreType(score_type),
|
||||
damageAmount(damage_amount),
|
||||
vitalHit(vital_hit),
|
||||
zoneIndex(zone_index),
|
||||
subsysID(subsys_ID),
|
||||
senderMechID(sender_mech_ID)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//################# BTPlayer::VehicleDeadMessage ######################
|
||||
//###########################################################################
|
||||
//
|
||||
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
|
||||
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
|
||||
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
|
||||
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
|
||||
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
|
||||
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
|
||||
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
|
||||
// dispatches it to the dying mech's own player -- the respawn-cycle
|
||||
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
|
||||
// handler consumes the two extension fields is still undecoded (the
|
||||
// function is not in the export); the sender carries them faithfully.
|
||||
//
|
||||
class BTPlayer__VehicleDeadMessage:
|
||||
public Player::VehicleDeadMessage
|
||||
{
|
||||
public:
|
||||
int
|
||||
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
|
||||
|
||||
EntityID
|
||||
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
|
||||
|
||||
int
|
||||
killZone; // +0x34 zone the killing damage struck
|
||||
|
||||
BTPlayer__VehicleDeadMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
size_t length,
|
||||
const EntityID &killed_by_player_ID,
|
||||
int kill_zone
|
||||
):
|
||||
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
|
||||
reserved28(0),
|
||||
killedByPlayerID(killed_by_player_ID),
|
||||
killZone(kill_zone)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//##################### BTPlayer::MakeMessage #########################
|
||||
//###########################################################################
|
||||
@@ -202,8 +273,9 @@ class DropZone__ReplyMessage;
|
||||
// Player::ScoreMessage.)
|
||||
//
|
||||
public:
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Message Support
|
||||
@@ -224,6 +296,23 @@ class DropZone__ReplyMessage;
|
||||
private:
|
||||
static const HandlerEntry MessageHandlerEntries[];
|
||||
|
||||
public:
|
||||
//
|
||||
// @004bffa0 -- the DISPATCH OVERRIDE (vtable @00513300 slot 3). The
|
||||
// binary uses ONE message id (0x16) for all three score reports and
|
||||
// splits type 0 off HERE, before base dispatch:
|
||||
//
|
||||
// if (msg->id == 0x16 && msg->type == 0) ScoreInflicted(msg);
|
||||
// else base dispatch;
|
||||
//
|
||||
// which is why ScoreMessageHandler's own type-0 arm can Verify-reject:
|
||||
// the interceptor guarantees type 0 never reaches it. Without this
|
||||
// override every inflicted report lands in the rejecting arm and scores
|
||||
// nothing -- see the note in btplayer.cpp.
|
||||
//
|
||||
virtual void
|
||||
Dispatch(Receiver::Message *what); // @004bffa0
|
||||
|
||||
protected:
|
||||
static MessageHandlerSet& GetMessageHandlers();
|
||||
|
||||
@@ -238,6 +327,14 @@ class DropZone__ReplyMessage;
|
||||
void
|
||||
ScoreMessageHandler(ScoreMessage *message); // @004c02e4
|
||||
|
||||
//
|
||||
// @004bfbe8 -- BT's MissionStarting override. Seeds the starting score
|
||||
// ("+1000 Starting the game", original manual scoring chart). The base
|
||||
// Player handler does the fade-in; BT adds the grant.
|
||||
//
|
||||
void
|
||||
MissionStartingMessageHandler(Entity::Message *message); // @004bfbe8
|
||||
|
||||
//
|
||||
// @004bffd0 -- the spawn / respawn handshake. When the drop zone
|
||||
// replies with our spawn location we create (or reset) the player's
|
||||
@@ -437,6 +534,8 @@ class DropZone__ReplyMessage;
|
||||
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
|
||||
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
|
||||
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
|
||||
friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
|
||||
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
|
||||
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
|
||||
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
|
||||
// compiled object is `deathPending` (the respawn latch). These bridges
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
|
||||
// //
|
||||
// == RUNTIME BRING-UP WORKLIST (replace these) == //
|
||||
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / //
|
||||
// Mech::IsAirborne / SetTargetRange / //
|
||||
// SetMappingSubsystem / RaiseStatusAlarm //
|
||||
// Mech__DamageZone::LoadCriticalSubsystems //
|
||||
// MechSubsystem::TakeDamage / OnAlarmChanged //
|
||||
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
|
||||
// Mech method stubs.
|
||||
//===========================================================================//
|
||||
|
||||
// TODO(bring-up): reads mech+0x414 (mission-review playback flag).
|
||||
int Mech::GetMissionReviewMode()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
|
||||
// mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
|
||||
// mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
|
||||
// the port never enters scrub/review, so it stays 0.
|
||||
int gMissionReviewMode = 0;
|
||||
|
||||
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
|
||||
int Mech::IsAirborne()
|
||||
@@ -336,10 +336,9 @@ BTMarkerWatcherRenderable::BTMarkerWatcherRenderable(
|
||||
// BTTranslocationRenderable (the "blue warp" translocation sphere) is now a real
|
||||
// reconstruction in btl4vid.cpp (task #52) -- no longer a stub here.
|
||||
|
||||
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
|
||||
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *, StateIndicator *,
|
||||
float, float, float, float, float, int, int)
|
||||
: BTRenderableBase(entity) {}
|
||||
// BTPOVStartEndRenderable (the mission start/end fade) is now a real
|
||||
// reconstruction: the arming ctor lives in btl4vid.cpp and the 5-phase fog
|
||||
// machine at the renderer frame head (L4VIDEO.cpp) -- #156.
|
||||
|
||||
BTTracerEffectRenderable::BTTracerEffectRenderable(
|
||||
Entity *entity, int, void *, int, dpl_ZONE *, dpl_DCS *, LinearMatrix *)
|
||||
|
||||
@@ -175,9 +175,14 @@ DamageZonePercentTable *
|
||||
{
|
||||
if (sliceCount <= 0) return 0;
|
||||
|
||||
Scalar thetaIn = theta;
|
||||
if (rotateWithTorso) // binary this[3] != 0
|
||||
{
|
||||
theta += owner->TorsoHeading(); // torso+0x1d8
|
||||
// #124 frame adapter, angular half: theta arrives in the 1995 frame
|
||||
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
|
||||
// -- enters with the OPPOSITE sign (a reflection reverses angular
|
||||
// direction). Binary form: theta += torso+0x1d8.
|
||||
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
|
||||
if (theta >= TwoPi) theta -= TwoPi;
|
||||
if (theta < 0.0f) theta += TwoPi;
|
||||
}
|
||||
@@ -185,6 +190,19 @@ DamageZonePercentTable *
|
||||
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
|
||||
if (index < 0) index = 0;
|
||||
if (index > sliceCount - 1) index = sliceCount - 1;
|
||||
|
||||
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
|
||||
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
static int s_sl = 0;
|
||||
if (s_sl++ < 400)
|
||||
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
|
||||
<< " twist=" << (float)owner->TorsoHeading()
|
||||
<< " thetaIn=" << (float)thetaIn
|
||||
<< " thetaAdj=" << (float)theta
|
||||
<< " -> slice " << index << "/" << sliceCount << std::endl;
|
||||
}
|
||||
return slices[index];
|
||||
}
|
||||
|
||||
@@ -277,6 +295,24 @@ int
|
||||
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
|
||||
if (heightRef <= 0.0f) return -1;
|
||||
|
||||
// PORT FRAME ADAPTER (#124, corrected 2026-08-03 by the MUZZLE-ANCHOR
|
||||
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
|
||||
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
|
||||
// slice-name data]. Our engine frame, measured with the mech's own
|
||||
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
|
||||
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
|
||||
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
|
||||
// (drive-code convention, "forward = -Z at heading 0"). The frames
|
||||
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
|
||||
// rotation first committed (that crossed the pods: left muzzle resolved
|
||||
// rtorso). y (the band axis) untouched.
|
||||
//
|
||||
// A reflection reverses the angular walk direction, so the OTHER angular
|
||||
// input -- the torso-twist term SelectSlice adds -- must flip sign with
|
||||
// it (applied there, same adapter). Twist was 0 in every probe; the
|
||||
// twisted-torso verify is the follow-up on #124.
|
||||
local.z = -local.z;
|
||||
|
||||
// --- height -> layer (penetration depth) ---
|
||||
Scalar depth = local.y; // binary local_2c
|
||||
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
|
||||
@@ -305,12 +341,22 @@ int
|
||||
{
|
||||
static int s_rh = 0;
|
||||
if (s_rh++ < 400)
|
||||
DEBUG_STREAM << "[dmgresolve] localY=" << local.y
|
||||
{
|
||||
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
|
||||
int *seg_out, const char **name_out);
|
||||
int seg = -1; const char *zname = 0;
|
||||
if (resolved >= 0)
|
||||
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
|
||||
DEBUG_STREAM << "[dmgresolve] impact=(" << impact.x << ","
|
||||
<< impact.y << "," << impact.z << ")"
|
||||
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
|
||||
<< " heightRef=" << heightRef
|
||||
<< " frac=" << (depth / heightRef)
|
||||
<< " layer=" << layerIndex << "/" << layerCount
|
||||
<< " theta=" << theta
|
||||
<< " -> zone " << resolved << std::endl;
|
||||
<< " -> zone " << resolved
|
||||
<< " '" << (zname ? zname : "?") << "'" << std::endl;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,14 @@ void
|
||||
// BRING-UP verify (rate-limited): prove the real fire path executes + feeds the heat
|
||||
// sim (heatPortion -> pendingHeat -> flows to the central sink -> mech temp climbs).
|
||||
static int s_fireLog = 0;
|
||||
if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
|
||||
// #165 bench: EVERY discharge, NAMED, under BT_DMG_LOG (the old unnamed
|
||||
// 1-in-19 sample could not answer "which weapon fired" -- the #110 nit);
|
||||
// the rate-limited unnamed line stays for ambient runs without the env.
|
||||
if (getenv("BT_DMG_LOG"))
|
||||
DEBUG_STREAM << "[emitter] FIRED '" << (GetName() ? GetName() : "?")
|
||||
<< "' damage=" << damagePortion << " heat=" << heatPortion
|
||||
<< "\n" << std::flush;
|
||||
else if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
|
||||
DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion
|
||||
<< " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush;
|
||||
|
||||
|
||||
+226
-19
@@ -359,22 +359,40 @@ Condenser::~Condenser()
|
||||
#endif
|
||||
|
||||
//###########################################################################
|
||||
// ResetToInitialState -- Condenser (HEAT.TCP)
|
||||
// ResetToInitialState -- Condenser
|
||||
//
|
||||
// RE-TRANSCRIBED FROM THE BINARY (respawn-reset audit 2026-08-04; the function
|
||||
// is MISSING from the Ghidra export -- disassembled raw, tools/disas2.py
|
||||
// 0x4ae534 0x60). The old body chained HeatableSubsystem (temp+heatLoad only,
|
||||
// per the stale HEAT.TCP shard) -- the binary chains HEATSINK (call 0x4ad760),
|
||||
// so the coolant refill DOES run for a condenser; then, RESPAWN-side only:
|
||||
//
|
||||
// test esi, esi ; je ... -> if (powered) {
|
||||
// [this+0x1d0] = 1 -> valveState = 1 (default detent --
|
||||
// the pod RESETS every coolant valve)
|
||||
// eax = [this+0x1d8]; [0x160] = eax -> massScale = refrigerationFactor
|
||||
// (restore the refrigeration output) }
|
||||
//
|
||||
// Mech::Reset's tail then re-runs BTRecomputeCondenserValves (@0049f788), so
|
||||
// the flow fractions rebuild from the reset detents (mech4.cpp). This is the
|
||||
// operator-reported "coolant valves survive a respawn" gap [was T3, now T1].
|
||||
//
|
||||
void
|
||||
Condenser::ResetToInitialState(Logical /*powered*/)
|
||||
Condenser::ResetToInitialState(Logical powered)
|
||||
{
|
||||
HeatableSubsystem::ResetToInitialState(True);
|
||||
HeatSink::ResetToInitialState(powered); // call 0x4ad760
|
||||
if (powered)
|
||||
{
|
||||
if (getenv("BT_DEATH_LOG") && valveState != 1)
|
||||
DEBUG_STREAM << "[respawn] " << GetName() << " valve detent "
|
||||
<< valveState << " -> 1 (authentic @004ae534 restore)\n" << std::flush;
|
||||
valveState = 1; // @0x1D0 = 1
|
||||
massScale = refrigerationFactor; // @0x160 = @0x1D8
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser -- a coolant
|
||||
// loop's valve. NOTE the body above chains HeatableSubsystem's (temperature +
|
||||
// heatLoad) and so does NOT run HeatSink's coolant refill, even though Condenser
|
||||
// derives from HeatSink. That mirrors the existing reconstruction; whether the
|
||||
// binary's Condenser reset also restores the VALVE SETTING is not established from
|
||||
// the decomp yet, so valve detents may still persist across a respawn.
|
||||
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
|
||||
//
|
||||
void
|
||||
Condenser::DeathReset(int reset_command)
|
||||
@@ -777,7 +795,7 @@ void
|
||||
{
|
||||
heatEnergy += pendingHeat;
|
||||
currentTemperature = heatEnergy / thermalMass;
|
||||
UpdateHeatLoad();
|
||||
UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
|
||||
|
||||
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
|
||||
// the old shared static timer aliased to whichever instance crossed the
|
||||
@@ -850,8 +868,22 @@ void
|
||||
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
|
||||
// the running-average filter to produce the smoothed heatLoad reading.
|
||||
//
|
||||
// POD-CADENCE SAMPLING (#119, 2026-08-02). The binary adds ONE sample per
|
||||
// Perform -- a dt-less per-frame term (the myomer-kinetic class, FUN_0041c018
|
||||
// proves Performs ride the frame rate). 15 samples at the pod's 28 Hz = a
|
||||
// 0.536 s smoothing window; sampled at the port's ~59 fps the window halves
|
||||
// and heatLoad turns 2x as twitchy -- which doubles the cadence of the
|
||||
// ReportLeak relaxation oscillation at a drained tank (draw = damage x
|
||||
// heatLoad hunting across the authored 0.0025/0.003 hysteresis band), and
|
||||
// THAT is the rapidly-clipping "warning coo-- war--" leak voice Oracle
|
||||
// reproduced twice in 716. Sim calls pass their slice and samples accrue at
|
||||
// 28 Hz on any machine; ctor/reset prime calls (negative dt) sample
|
||||
// unconditionally. Per-instance clock lives in a static map (the census
|
||||
// precedent above) because the heat-family layouts are factory-size-locked
|
||||
// (sizeof(Myomers) == 0x358 exact) -- no new members.
|
||||
//
|
||||
void
|
||||
HeatSink::UpdateHeatLoad()
|
||||
HeatSink::UpdateHeatLoad(Scalar time_slice)
|
||||
{
|
||||
radiatedHeat = currentTemperature * coolantLevel;
|
||||
|
||||
@@ -865,7 +897,31 @@ void
|
||||
sample = HeatLoadMaximum;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
if (time_slice >= 0.0f)
|
||||
{
|
||||
static std::map<const void *, Scalar> s_filterClock;
|
||||
Scalar &clock = s_filterClock[this];
|
||||
clock += time_slice;
|
||||
const Scalar kPodTick = 1.0f / 28.0f; // [T1] pod Perform cadence
|
||||
if (clock < kPodTick)
|
||||
{
|
||||
return; // heatLoad holds the last average
|
||||
}
|
||||
int catchUp = 0;
|
||||
while (clock >= kPodTick && ++catchUp <= 15) // >15 samples saturate the window
|
||||
{
|
||||
clock -= kPodTick;
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
}
|
||||
if (catchUp > 15)
|
||||
{
|
||||
clock = 0.0f; // hitch: window already saturated
|
||||
}
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
return;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4 (prime call)
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
}
|
||||
|
||||
@@ -891,6 +947,26 @@ void
|
||||
if (other != 0 && coolantAvailable != 0)
|
||||
{
|
||||
Scalar flow = ComputeHeatFlow(other, time_slice); // FUN_004ad9ec
|
||||
// #137 FLOW TRAP (BT_HEAT_LOG): the myomers gains ~2e9 of energy within
|
||||
// 30 frames of a respawn while every partner reads T=77 -- and the only
|
||||
// writers into its pendingHeat are its (dead-at-spawn) integrator and
|
||||
// THIS line. Any e6-scale single flow is the bug caught in the act;
|
||||
// print the complete operand set so the arithmetic can be re-run by
|
||||
// hand instead of guessed at.
|
||||
if (getenv("BT_HEAT_LOG") != 0 && (flow > 1.0e6f || flow < -1.0e6f))
|
||||
{
|
||||
DEBUG_STREAM << "[heatflow] " << (GetName() ? GetName() : "?")
|
||||
<< " -> " << (other->GetName() ? other->GetName() : "?")
|
||||
<< " flow=" << flow << " dt=" << time_slice
|
||||
<< " | this: T=" << currentTemperature << " E=" << heatEnergy
|
||||
<< " pend=" << pendingHeat << " m=" << thermalMass
|
||||
<< " mScale=" << massScale << " k=" << thermalConductance
|
||||
<< " lvl=" << coolantLevel << " cap=" << thermalCapacity
|
||||
<< " fScale=" << coolantFlowScale
|
||||
<< " | other: T=" << other->currentTemperature
|
||||
<< " E=" << other->heatEnergy << " pend=" << other->pendingHeat
|
||||
<< " m=" << other->thermalMass << "\n" << std::flush;
|
||||
}
|
||||
other->pendingHeat += flow;
|
||||
pendingHeat -= flow;
|
||||
BalanceCoolant(time_slice); // FUN_004ada94
|
||||
@@ -1363,12 +1439,143 @@ int BTCondenserNumber(::Subsystem *sub)
|
||||
|
||||
|
||||
//
|
||||
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||
// roster members against this family's derivation chain (binary GUID 0x50e590).
|
||||
// Lives here because HeatSink is a complete type only in this TU.
|
||||
// The #83-era BTHeatSinkFamily bridge that lived here is GONE (#128,
|
||||
// 2026-08-11): it returned the whole HeatSink base family for the
|
||||
// collision distributor's 0x50e590 test, but 0x50e590 is the AGGREGATE
|
||||
// BANK's node (0xBBE "HeatSinkBank") -- the same task-#9 identity the
|
||||
// link-attach guard above proved live (heat.cpp:627). The distributor
|
||||
// now uses the string-name derivation test directly (mech.cpp), so no
|
||||
// complete-type bridge is needed.
|
||||
//
|
||||
Derivation *
|
||||
BTHeatSinkFamily()
|
||||
|
||||
//===========================================================================//
|
||||
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
|
||||
// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
|
||||
// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
|
||||
// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
|
||||
// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
|
||||
// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
|
||||
// bank, else 1 with *out filled.
|
||||
//===========================================================================//
|
||||
int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
|
||||
{
|
||||
return HeatSink::GetClassDerivations();
|
||||
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
|
||||
return 0;
|
||||
HeatSink *bank = (HeatSink *)sub;
|
||||
*out = bank->CoolantFractionOf();
|
||||
return 1;
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTReportHeatAtReset -- #137 forensic (ungated when BT_HEAT_LOG is set).
|
||||
//
|
||||
// The [heat-t] census runs on a 5-second per-instance timer, which is far too
|
||||
// coarse to answer the question #137 actually poses: "respawn came back with
|
||||
// MYOMERS heat MAXED". Is the temperature high BECAUSE the reset did not
|
||||
// clear it, or because it climbs again within the first second? Those need a
|
||||
// sample taken AT the reset, which is what this is. Called from Mech::Reset
|
||||
// immediately after the subsystem sweep, so every heat-bearing subsystem
|
||||
// reports the temperature the reset actually left it at.
|
||||
//===========================================================================//
|
||||
void BTReportHeatAtReset(void *mech_v)
|
||||
{
|
||||
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
|
||||
return;
|
||||
Entity *mech = (Entity *)mech_v;
|
||||
const int count = mech->GetSubsystemCount();
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = mech->GetSubsystem(i);
|
||||
if (s == 0)
|
||||
continue;
|
||||
// UNFILTERED first: the earlier pass filtered on IsDerivedFrom(HeatSink)
|
||||
// and reported no Condensers. That test rides a hand-built Derivation
|
||||
// chain, so a false negative there is indistinguishable from "not in the
|
||||
// roster" -- name every roster entry and say whether the test passed.
|
||||
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
|
||||
{
|
||||
DEBUG_STREAM << "[heat-reset] roster[" << i << "] "
|
||||
<< (s->GetName() ? s->GetName() : "?")
|
||||
<< " (not HeatSink-derived)" << "\n" << std::flush;
|
||||
continue;
|
||||
}
|
||||
HeatableSubsystem *sink = (HeatableSubsystem *)s;
|
||||
DEBUG_STREAM << "[heat-reset] " << (s->GetName() ? s->GetName() : "?")
|
||||
<< " T=" << (float)sink->currentTemperature
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTReportMyomerFreeze -- #137 forensic (BT_HEAT_LOG), the POST-reset trace.
|
||||
//
|
||||
// BTReportHeatAtReset samples AT the reset and showed every subsystem at
|
||||
// T == startingTemperature, which is what made the reset path look innocent and
|
||||
// got #137 wrongly closed. The field then proved the freeze is real (5 of 61
|
||||
// respawns: throttle up, speedDemand pinned at 0) -- so the interesting window
|
||||
// is the frames immediately AFTER the reset, which nothing was sampling.
|
||||
//
|
||||
// Three explanations survive the decomp read and only data separates them:
|
||||
// (a) RESET DIDN'T TAKE -> temp is high right after the reset
|
||||
// (b) STALE CACHE -> temp is at start but speedEffect is still 0
|
||||
// (HeatSink::RTIS @004ad760 writes only bytes
|
||||
// 0x114/0x12C/0x130/0x134/0x138/0x158/0x15C --
|
||||
// it does NOT touch Myomers::speedEffect @0x31C,
|
||||
// and Myomers::RTIS @004b8aa4 only chains to the
|
||||
// PoweredSubsystem one, so the pre-death value
|
||||
// survives until the myomers next ticks)
|
||||
// (c) INSTANT RE-HEAT -> temp starts at start and climbs back at once
|
||||
//
|
||||
// Prints per myomers: temperature, its own speedEffect, and the mech-level MAX
|
||||
// the mover actually multiplies by. Freeze == that MAX at 0.
|
||||
//===========================================================================//
|
||||
void BTReportMyomerFreeze(void *mech_v, const char *when)
|
||||
{
|
||||
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
|
||||
return;
|
||||
Entity *mech = (Entity *)mech_v;
|
||||
extern Scalar BTMyomersSpeedEffectOf(void *subsystem);
|
||||
const int count = mech->GetSubsystemCount();
|
||||
Scalar best = -1.0f;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = mech->GetSubsystem(i);
|
||||
if (s == 0)
|
||||
continue;
|
||||
// ROSTER-WIDE now (2026-08-09): the myomers-only version proved the
|
||||
// myomers is being COOKED FROM OUTSIDE -- +2.3e9 of energy arrives in
|
||||
// <=30 frames while its own integrator reads near-zero v (our Reset
|
||||
// zeroes localVelocity, matching the binary's +0x1c4 zero-fill, so
|
||||
// termKinetic/termAccel are dead at spawn). ConductHeat flow is
|
||||
// bounded by deltaT, so an e9 slug through it demands a NEIGHBOUR at
|
||||
// extreme temperature. Print EVERY heat-bearing subsystem's T plus
|
||||
// the two cells T is actually derived from (heatEnergy @0x158,
|
||||
// pendingHeat @0x1C8) to NAME that neighbour.
|
||||
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
|
||||
continue;
|
||||
HeatableSubsystem *h = (HeatableSubsystem *)s;
|
||||
// Every Heatable-positive roster member in this game IS a HeatSink
|
||||
// (the Watcher branch -- Torso/HUD/Gyro -- rides HeatWatcher, which is
|
||||
// not HeatableSubsystem-derived), so the downcast for E/pend is safe.
|
||||
// Deliberately NOT IsDerivedFrom(HeatSink): that hand-built chain
|
||||
// returns false for Condenser (the night-13 trap).
|
||||
HeatSink *hs = (HeatSink *)s;
|
||||
Scalar f = BTMyomersSpeedEffectOf(s); // >= 0 only for a Myomers
|
||||
if (f > best) best = f;
|
||||
DEBUG_STREAM << "[myofreeze] " << when << " " << (s->GetName() ? s->GetName() : "?")
|
||||
<< " T=" << h->currentTemperature
|
||||
<< " E=" << hs->heatEnergy
|
||||
<< " pend=" << hs->pendingHeat
|
||||
<< " fail=" << h->failureTemperature;
|
||||
if (f >= 0.0f)
|
||||
{
|
||||
DEBUG_STREAM << " speedEffect=" << f
|
||||
<< ((f <= 1.0e-4f && h->currentTemperature < h->failureTemperature)
|
||||
? " <<<< STALE (cold but zero)" : "");
|
||||
}
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
if (best >= 0.0f)
|
||||
DEBUG_STREAM << "[myofreeze] " << when << " CHAIN MAX=" << best
|
||||
<< (best <= 1.0e-4f ? " <<<< FROZEN" : "") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
@@ -512,8 +512,11 @@ inline int
|
||||
// Internal model helpers
|
||||
//
|
||||
public:
|
||||
// time_slice >= 0: sample the filter at the POD's 28 Hz cadence (#119
|
||||
// -- the per-Perform filter is frame-rate-dependent, the myomer-kinetic
|
||||
// class); negative (the ctor/reset prime calls) samples unconditionally.
|
||||
void
|
||||
UpdateHeatLoad(); // @004ad7f0
|
||||
UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
|
||||
void
|
||||
ClearHeatFilter(); // @004ad884
|
||||
void
|
||||
@@ -567,6 +570,11 @@ inline int
|
||||
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
|
||||
// leak gauge's full-scale divisor (#97)
|
||||
Scalar LeakGaugeFullScale() const { return filterDecay; }
|
||||
// Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
|
||||
// (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
|
||||
// the guard only shields a malformed stream from a NaN.
|
||||
Scalar CoolantFractionOf() const
|
||||
{ return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
|
||||
Scalar thermalMass; // @0x154 resource +0xF4
|
||||
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
|
||||
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
|
||||
|
||||
@@ -1233,7 +1233,7 @@ void
|
||||
}
|
||||
heatEnergy += pendingHeat; // [0x56] += [0x72]
|
||||
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
|
||||
UpdateHeatLoad(); // FUN_004ad7f0
|
||||
UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
|
||||
pendingHeat = 0.0f;
|
||||
|
||||
Scalar target = 300.0f
|
||||
|
||||
+57
-10
@@ -52,15 +52,41 @@ struct HUDLayoutCheck
|
||||
};
|
||||
|
||||
//
|
||||
// Tuning constants observed as read-only float globals adjacent to the
|
||||
// HudSimulation body (.rdata, recovered from section_dump.txt).
|
||||
// Tuning constants read as read-only float globals adjacent to the
|
||||
// HudSimulation body. The VALUES BELOW ARE THE BINARY'S, read straight off
|
||||
// the .rdata rows in reference/decomp/section_dump.txt [T1]:
|
||||
// 4b7ec0 8be55dc3 0000403f 0000803f 0000c842
|
||||
// 4b7ed0 00000000
|
||||
// -> ec4 = 0.75f ec8 = 1.0f ecc = 100.0f ed0 = 0.0f
|
||||
//
|
||||
// CORRECTED 2026-08-08. Every entry here used to be a 0.0f/500.0f STAND-IN
|
||||
// under a guessed name, and the names described the wrong mechanism: there is
|
||||
// no "heat threshold for HUD page visibility" at 0x4b7ec4 -- ec4/ec8 are the
|
||||
// fire-control LOCK damage limits, and "MaxTorsoSlew = 500.0f" at ed0 read the
|
||||
// range-slide Abs() idiom backwards (ed0 is the ZERO; 500.0 is an immediate).
|
||||
// The LIVE implementation of the lock rule and the range slide is mech4.cpp's
|
||||
// targeting step, which had both thresholds right all along. These stay so
|
||||
// the addresses resolve to the truth for the next reader.
|
||||
//
|
||||
static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c
|
||||
static const Scalar SegmentTempLimit = 0.0f; // _DAT_004b7ec4 (heat threshold for HUD page visibility)
|
||||
static const Scalar TargetTempLimit = 0.0f; // _DAT_004b7ec8
|
||||
static const Scalar RangeBias = 0.0f; // _DAT_004b7ecc
|
||||
static const Scalar MaxTorsoSlew = 500.0f; // _DAT_004b7ed0
|
||||
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
|
||||
// LOCK gate (a): your OWN HUD's host zone must be BELOW 75% damage, else
|
||||
// fire-control lock is lost -- the `_DAT_004b7ec4 <= ownZone->damageLevel`
|
||||
// arm of HudSimulation. A shot-up cockpit drops to "target held, no lock".
|
||||
static const Scalar LockOwnZoneDamageLimit = 0.75f; // _DAT_004b7ec4
|
||||
// LOCK gate (b): the TARGETED zone must be below 1.0 damage (a whole-mech
|
||||
// target checks zone 0), so a dead zone cannot be re-locked.
|
||||
static const Scalar LockTargetZoneDamageLimit = 1.0f; // _DAT_004b7ec8
|
||||
// Subtracted from RangeToTarget (@0x1EC) every frame while the timed flag
|
||||
// @0x22C is set (timer @0x21C accumulates to @0x1D8, then both clear).
|
||||
// NOT reconstructed in the port's targeting step -- tracked, not implemented.
|
||||
static const Scalar RangeBias = 100.0f; // _DAT_004b7ecc
|
||||
// The shared ZERO: the right-hand side of the Abs() idiom on the range slide
|
||||
// (`dt * 500.0 <= 0.0` picks the sign) and of an `== 0.0f` test at @0x28C.
|
||||
// The 500 m/s slide RATE is an immediate literal (0x43fa0000), never this.
|
||||
static const Scalar HudZero = 0.0f; // _DAT_004b7ed0
|
||||
// The decay FLOOR for horizontalTorsoOffset (@0x294) in FUN_004b7ed4. The
|
||||
// decay RATE is the object's own @0x298, not a constant. (Value verified.)
|
||||
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
|
||||
|
||||
//
|
||||
// Cross-family helper (definition lives in the mech game layer; declared here
|
||||
@@ -322,9 +348,13 @@ Logical
|
||||
// (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no
|
||||
// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676).
|
||||
// 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the
|
||||
// commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to
|
||||
// +/- horizontalLimit (@0x29C), then written to the graphic at
|
||||
// mech +0x36C. The flicker helper (@004b7ed4) damps the settle.
|
||||
// commanded torso heading, clamped to +/- horizontalLimit (@0x29C), then
|
||||
// written to the graphic at mech +0x36C. The flicker helper (@004b7ed4)
|
||||
// damps the settle: it decays @0x294 toward ZERO at the object's own
|
||||
// @0x298 (horizontalMovementPerSecond) x time_slice. (CORRECTED
|
||||
// 2026-08-08: this used to read "at up to MaxTorsoSlew (500/sec)" -- the
|
||||
// rate is that per-object field, and there is no 500 constant here. The
|
||||
// 500 m/s belongs to the RANGE slide in step 5, as an immediate.)
|
||||
//
|
||||
void
|
||||
HUD::HudSimulation(Scalar time_slice)
|
||||
@@ -475,3 +505,20 @@ void
|
||||
{
|
||||
ResetToInitialState(reset_command != 0); // @004b77bc
|
||||
}
|
||||
|
||||
//
|
||||
// BTSetHudFlickerActive -- complete-type bridge for the CONTROL-MODE switch
|
||||
// (mechmppr.cpp treats the mech's subsystems as opaque pointers, so it cannot
|
||||
// touch HUD members directly; same pattern as torso.cpp's BTGetTorsoTwistAddr).
|
||||
//
|
||||
// @004afbe0's BASIC arm ends in `*(mech+0x5b4 + 0x2a0) = 1` -- mech+0x5b4 is the
|
||||
// HUD subsystem cache and +0x2A0 is flickerActive. Basic mode re-centres the
|
||||
// torso, so the HUD horizon is kicked into its settle animation to follow it
|
||||
// (UpdateFlicker @004b7ed4 decays horizontalTorsoOffset and reports whether it
|
||||
// is still moving). The port never made this call.
|
||||
//
|
||||
void BTSetHudFlickerActive(Subsystem *hud)
|
||||
{
|
||||
if (hud != 0)
|
||||
((HUD *)hud)->SetFlickerActive(1);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,12 @@
|
||||
// Simulation Support
|
||||
//
|
||||
public:
|
||||
// @0x2A0 -- raised by the CONTROL-MODE switch's BASIC arm
|
||||
// (`*(mech+0x5b4 + 0x2a0) = 1`, @004afbe0) so the HUD horizon re-settles
|
||||
// with the torso that Basic just re-centred. Reached from mechmppr via
|
||||
// hud.cpp's BTSetHudFlickerActive bridge (that TU sees Subsystem*, not HUD).
|
||||
void SetFlickerActive(int on) { Check(this); flickerActive = on; }
|
||||
|
||||
typedef void
|
||||
(HUD::*Performance)(Scalar time_slice);
|
||||
|
||||
|
||||
+365
-23
@@ -466,6 +466,7 @@ const Receiver::HandlerEntry
|
||||
MESSAGE_ENTRY(Mech, PlayerLink),
|
||||
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
|
||||
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
|
||||
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
|
||||
};
|
||||
|
||||
//
|
||||
@@ -509,8 +510,156 @@ void
|
||||
{
|
||||
return;
|
||||
}
|
||||
duckState = 1;
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
||||
// #142: duckState is the POSTURE the cockpit's crouch-symbol animation
|
||||
// reads -- L4GAUGE.CFG:5001 binds attribute 0x37 to a THREE-frame
|
||||
// bduck.pcc strip, confirmed on screen as a duck animation:
|
||||
// 0 = standing 1 = moving between 2 = crouched
|
||||
//
|
||||
// A bare 1 here is therefore exactly right, and is what the binary writes:
|
||||
// it means "in transition", which is both the request AND the middle frame.
|
||||
// The consumer (mech4.cpp) reads the parked leg alarm to decide DIRECTION
|
||||
// -- parked means the pending move is a rise, not parked means a squat --
|
||||
// and settles duckState to 0 or 2 when the clip finishes. No separate
|
||||
// request cell, no toggle, no divergence from @0049fa00.
|
||||
//
|
||||
// (An earlier revision toggled 0<->1 here. That produced a two-pose snap,
|
||||
// which is what the cockpit reported as "it lights up and sticks, no
|
||||
// animation": frame 2 was never reachable.)
|
||||
duckState = 1; // show the MIDDLE frame at once (the binary's write)
|
||||
duckRequest = 1; // and remember that a move is pending (#142)
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1 (in transition)"
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
|
||||
// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
|
||||
// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
|
||||
// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
|
||||
// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
|
||||
// additionally need weaponAlarm != 7 = NoAmmo)
|
||||
// then permitted = weapons < ejectMinWeapons || generators == 0
|
||||
// || coolant < 0.05 (@0049fb50)
|
||||
// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
|
||||
// The binary refreshes this per frame from an UNEXPORTED caller; the port
|
||||
// evaluates on demand (the handler + any future panic-lamp consumer).
|
||||
//
|
||||
int
|
||||
Mech::EvaluateEjectPermission()
|
||||
{
|
||||
extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
|
||||
extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
|
||||
extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
|
||||
|
||||
int liveWeapons = 0;
|
||||
int liveGenerators = 0;
|
||||
Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
|
||||
|
||||
for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
|
||||
{
|
||||
Subsystem *s = (Subsystem *)subsystemArray[i];
|
||||
if (s == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Scalar frac;
|
||||
if (BTHeatSinkBankCoolantFraction(s, &frac))
|
||||
{
|
||||
coolantFrac = frac;
|
||||
}
|
||||
else
|
||||
{
|
||||
int g = BTGeneratorCountsForEject(s);
|
||||
if (g >= 0)
|
||||
{
|
||||
liveGenerators += g;
|
||||
}
|
||||
else
|
||||
{
|
||||
int w = BTWeaponCountsForEject(s);
|
||||
if (w >= 0)
|
||||
{
|
||||
liveWeapons += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
|
||||
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
|
||||
int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
|
||||
|
||||
// (A short-lived coolant-clause HYSTERESIS was tried and REMOVED
|
||||
// 2026-08-03, same night, for strict fidelity: the binary's clause is
|
||||
// this plain `< 0.05` compare, evaluated per frame by the master
|
||||
// performance (FUN_004a9b5c+0x10) -- the ARCADE ran exactly this, so a
|
||||
// bank fraction hovering at the threshold flapped the panic-arm mode at
|
||||
// frame rate there too. The flap is cosmetic churn (mode mask + lamp;
|
||||
// hundreds of transitions ran crash-free on the bench) and was NOT the
|
||||
// operator's audio tick -- that was the autofire scalpel spamming the
|
||||
// per-pull jam click, no game bug. Keep the authentic compare.)
|
||||
ejectPermitted =
|
||||
(liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
|
||||
|| (liveGenerators == 0)
|
||||
|| (coolantFrac < 0.05f) // _DAT_0049fb50
|
||||
|| (gimped && noviceSim);
|
||||
return ejectPermitted;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
|
||||
// See the header comment (mech.hpp) for the byte-level story.
|
||||
//
|
||||
void
|
||||
Mech::EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message)
|
||||
{
|
||||
if (message->dataContents <= 0) // press only (msg+0xc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (mech not crippled enough)" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (disabled: movementMode "
|
||||
<< MovementMode() << ")" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
|
||||
// from ownerID @0x18c) and send it to the console host. CORE: forensic
|
||||
// log; the relay wire is the tracked tail. The suppressConsole latch set
|
||||
// below is the authentic other half (the following DEATH must not also
|
||||
// notify the console).
|
||||
extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
|
||||
void *player = GetPlayerLink(); // suppressConsole=1,
|
||||
Scalar charge = (player != 0) // returns role killBonus
|
||||
? BTPlayerEjectBookkeeping(player) : 0.0f;
|
||||
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " PUNCH-OUT: charge=" << charge
|
||||
<< " (role killBonus)" << std::endl << std::flush;
|
||||
|
||||
graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
|
||||
|
||||
Damage dmg; // FUN_0041db7c (Damage::Damage)
|
||||
dmg.damageType = (Enumeration)2; // Explosive
|
||||
dmg.damageAmount = charge; // role+0x1c (killBonus)
|
||||
dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
|
||||
dmg.burstCount = 1; // binary writes 0; our victim-side
|
||||
// guard clamps 0->1 -- same outcome
|
||||
Entity::TakeDamageMessage take_damage(
|
||||
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
|
||||
GetEntityID(), // inflicting = SELF (this+0x184)
|
||||
-1, // unaimed -> cylinder resolves
|
||||
dmg,
|
||||
-1); // inflictingSubsystemID = -1
|
||||
Dispatch(&take_damage); // vtbl+0xC self-dispatch
|
||||
}
|
||||
|
||||
Receiver::MessageHandlerSet
|
||||
@@ -852,7 +1001,18 @@ void
|
||||
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
|
||||
<< std::endl << std::flush;
|
||||
|
||||
extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
|
||||
// #128 CORRECTION (2026-08-11): 0x50e590 is the AGGREGATE BANK's node
|
||||
// (0xBBE "HeatSinkBank"), NOT the HeatSink base family -- the identity
|
||||
// task #9 proved live for the link-attach guard (heat.cpp:627, same
|
||||
// binary test FUN_0041a1a4 vs 0x50e590). The old family-wide test made
|
||||
// condensers, reservoirs, generators, sensors, weapons and MYOMERS
|
||||
// rattle-eligible -- every one coolant-bearing, so a single over-floor
|
||||
// ram sub-hit sprang an instant leak ("every single time"; Michael's
|
||||
// three field rams all logged 'Myomers condition 2 SET', a subsystem
|
||||
// the 1995 tag test can never select -- its authored 0.35 collision
|
||||
// weight is DEAD DATA in the binary). Authentic eligible set:
|
||||
// {HeatSinkBank, Gyroscope, Torso}. Only the bank carries coolant, so
|
||||
// a 1995 ram is gyro/torso damage with at worst a bounded bank drip.
|
||||
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
|
||||
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
|
||||
for (int i = 0; i < n; ++i) // @0x4a006b
|
||||
@@ -864,7 +1024,7 @@ void
|
||||
Subsystem *s = GetSubsystem(b); // roster @+0x128
|
||||
if (s == 0)
|
||||
continue;
|
||||
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
|
||||
if (!s->IsDerivedFrom("HeatSinkBank") // @0x4a0092 (0x50e590, the BANK)
|
||||
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
|
||||
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
|
||||
continue;
|
||||
@@ -883,6 +1043,30 @@ void
|
||||
{
|
||||
Check(message);
|
||||
|
||||
// #150 -- the binary's ONCE-PER-DEATH latch (local_14 @0x4a0230 body,
|
||||
// part_012.c:14625-14629). The kill report posts only on the 0->1 edge
|
||||
// (!destroyedAtEntry && destroyed-now).
|
||||
// CORRECTED by the #162 audit: a later salvo of the killing volley is NOT
|
||||
// "inflicted-only" -- it emits type-0 Inflicted (shooter) AND type-1
|
||||
// Received (victim, latch-independent, part_012.c:14736-14759); what the
|
||||
// latch suppresses is the type-2 kill and the death tail. Full map:
|
||||
// docs/KD_CHAIN_AUDIT_20260811.md.
|
||||
//
|
||||
// ⚠ PREDICATE (eject canary, 2026-08-11): the binary's local_14 samples
|
||||
// FUN_0049fb54 = movementMode 2||9 (part_012.c:14174-14188) -- the MODE,
|
||||
// not the alarm. The first cut here used the alarm-based
|
||||
// IsMechDestroyed(), and the EJECT arm broke exactly the way #108's
|
||||
// deathBlastArmed once did: EjectPilot raises graphicAlarm to 10 BEFORE
|
||||
// dispatching its killBonus self-damage, so the handler entered reading
|
||||
// "already destroyed" and the SUICIDE type-2 was suppressed -- a real
|
||||
// punch-out charged the -500 cost but never the -(basis+tally) award,
|
||||
// while ammo cook-off suicides (no alarm pre-write) scored correctly.
|
||||
// Mode 2|9 is untouched by the eject's alarm write, so the mode form
|
||||
// latches post-death volley rounds exactly as before AND lets the eject's
|
||||
// suicide report through -- byte-faithful to the 1995 predicate.
|
||||
const int destroyedAtEntry =
|
||||
(MovementMode() == 2 || MovementMode() == 9); // FUN_0049fb54
|
||||
|
||||
// MP DIAGNOSTIC (task #47): confirm the handler runs on the OWNING MASTER
|
||||
// for a cross-pod (network-delivered) TakeDamage + what state it carries.
|
||||
if (getenv("BT_MP_NET"))
|
||||
@@ -931,24 +1115,64 @@ void
|
||||
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
||||
}
|
||||
|
||||
//
|
||||
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
|
||||
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
|
||||
// arms the whole death tail -- kill report, VehicleDead, death blast.
|
||||
// Capturing it after the divert (where #89 first placed it) meant a
|
||||
// COLLISION death could never arm the tail.
|
||||
//
|
||||
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the
|
||||
// graphic alarm. FIXED 2026-08-07 -- this substitution was the eject-ghost.
|
||||
//
|
||||
// The binary tests movementMode 9|10 here; the port used IsMechDestroyed()
|
||||
// (graphicAlarm >= 9) and justified it with "the death transition sets mode
|
||||
// 9 synchronously with the structural flag on every path through here, so
|
||||
// the edges coincide". That is true of every DAMAGE path and false of the
|
||||
// one that matters: Mech::EjectPilotMessageHandler raises graphicAlarm to
|
||||
// 10 (the EJECT state) BEFORE dispatching its self-damage, while
|
||||
// movementMode is still 1. So on an eject the handler entered already
|
||||
// reading "destroyed", the latch never armed, and the whole death tail was
|
||||
// skipped -- no VehicleDead, which IS the respawn trigger.
|
||||
//
|
||||
// Consequences, all three reported from the field on the same night:
|
||||
// * the ejecting player never respawns ("panic button, didn't respawn");
|
||||
// * the peer wrecks the mech and never un-wrecks it, because the un-wreck
|
||||
// rides the master's respawn -> the permanent EJECT GHOST (#108);
|
||||
// * the eject scores only its -500 self-damage: no negated kill award and
|
||||
// no -500 death cost, because both live in the tail that never ran --
|
||||
// which is why the manual chart's "-1000 ejecting" never materialised.
|
||||
//
|
||||
// MovementMode 9|10 is untouched by the eject's alarm write, so the latch
|
||||
// now arms on the eject exactly as it does on a combat death.
|
||||
const int deathBlastArmed =
|
||||
!(MovementMode() == 9 || MovementMode() == 10); // [ebp-0x10], inverted (@0x4a0303)
|
||||
//
|
||||
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
|
||||
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
|
||||
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
|
||||
// leak into them.
|
||||
//
|
||||
int reportZone = message->damageZone;
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
|
||||
//
|
||||
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
|
||||
// type 0 == the mover's collision damage (what the crash response policy
|
||||
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc)
|
||||
// and the handler is done. This was the missing half of the crash
|
||||
// self-damage reconstruction -- without the divert, the raw kinetic-energy
|
||||
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the
|
||||
// WEAPON loop and a single bump killed the mech (run-14 field report).
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
|
||||
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
|
||||
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
|
||||
// the rattle still posts VehicleDead (the respawn trigger) and still
|
||||
// blasts. The early `return` this block shipped with was a latent
|
||||
// "wall-death strands the pilot" hazard. No score reports on this path --
|
||||
// the jump bypasses them; a collision death credits no one. Authentic.
|
||||
//
|
||||
if (message->damageData.damageType == 0)
|
||||
{
|
||||
DistributeCollisionDamage(&message->damageData);
|
||||
Check_Fpu();
|
||||
return;
|
||||
goto death_tail;
|
||||
}
|
||||
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
// #92: when this gate fails the zone keeps whatever the message carried --
|
||||
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
@@ -973,6 +1197,7 @@ void
|
||||
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
|
||||
//
|
||||
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
|
||||
// REPLACES the engine-base single application. Three things the base
|
||||
@@ -1044,14 +1269,40 @@ void
|
||||
}
|
||||
message->damageZone = zoneIndex; // matchlog sees the LAST zone
|
||||
|
||||
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
|
||||
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
|
||||
// inflicting subsystem, victim name} -- to the shooter's player (with a
|
||||
// kill-flagged variant when this damage NEWLY disabled the mech) and to
|
||||
// the victim's player. That is the authentic stats plumbing (#45); the
|
||||
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
|
||||
(void)zoneDestroyed;
|
||||
(void)damageTally;
|
||||
//
|
||||
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
|
||||
// the kill / inflicted / received score reports to the shooter's and
|
||||
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
|
||||
// TU, per the databinding rule); it receives the APPLIED tally, the
|
||||
// vital-wreck flag, and the loop-ENTRY zone. This retires
|
||||
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
|
||||
//
|
||||
// #150 DOUBLE-KILL FIX, two binary-grounded gates (2026-08-10):
|
||||
//
|
||||
// (1) MASTER ONLY. The binary OPENS this handler with the assertion
|
||||
// `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedam...",
|
||||
// MECH.CPP, 0x3da)` (part_012.c:14613-14616) -- in 1995 a replicant
|
||||
// executing TakeDamage was a BUG, so the report tail could only ever
|
||||
// run on the victim's master, once. This port deliberately applies
|
||||
// damage on replicants too (local visual responsiveness), so the tail
|
||||
// ALSO ran there -- and the replicant's movement mode can't flip to
|
||||
// wreck until the death record round-trips, so a multi-round killing
|
||||
// volley posted a kill report PER ROUND inside that window. Measured:
|
||||
// one death -> two type-2 arms 1ms apart, kills 0->2 (the field's
|
||||
// "one kill counted twice", #150). Gate the REPORTS to the master;
|
||||
// the local damage application stays.
|
||||
//
|
||||
// (2) ENTRY LATCH. The binary's newly-destroyed test is the 0->1 edge of
|
||||
// IsMechDestroyed() across THIS handler call (local_14), not the
|
||||
// movement-mode-derived deathBlastArmed -- on the master both agree,
|
||||
// but the binary's form is the spec.
|
||||
if (GetInstance() != ReplicantInstance)
|
||||
{
|
||||
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
|
||||
BTMechPostCombatReports((void *)this, (void *)message,
|
||||
(float)damageTally, zoneDestroyed, reportZone,
|
||||
!destroyedAtEntry && IsMechDestroyed());
|
||||
}
|
||||
}
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
||||
@@ -1080,6 +1331,81 @@ void
|
||||
(float)message->damageData.impactPoint.y,
|
||||
(float)message->damageData.impactPoint.z);
|
||||
}
|
||||
|
||||
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
|
||||
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
|
||||
// is dead(9)/eject(10) now. Sequence:
|
||||
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
|
||||
// carrying killed-by player + kill zone) -> the mech's OWN player:
|
||||
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
|
||||
// previously read this build as "SetBurningState (mech id 0x17)" --
|
||||
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
|
||||
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
|
||||
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
|
||||
// this is the decoded sender the mech4 transition site's
|
||||
// [T3 sender-undecoded] flag was waiting for.
|
||||
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
|
||||
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
|
||||
// death list from the death transition instead (#42 history): same
|
||||
// once-per-death edge, not double-spawned.
|
||||
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
|
||||
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
|
||||
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
|
||||
// impact=mech origin,
|
||||
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
|
||||
// radius=deathSplashRadius@0x524, excluded=the dying mech;
|
||||
// falloff bursts/dist^1.25 per victim inside the shared core.
|
||||
// 4. ForceUpdate(1).
|
||||
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
|
||||
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, and
|
||||
// sets mode 9 SYNCHRONOUSLY inside this same damage pass (its own tail
|
||||
// gate part_012.c:14761 reads mode 9||10 already flipped). An earlier
|
||||
// comment here claimed our transition was synchronous too -- it was NOT
|
||||
// (UpdateDeathState ran per-frame), and the 4-node retest caught the
|
||||
// window: TWO lethal deliveries in one event drain (direct + splash of
|
||||
// the same salvo, 2ms apart, no sim tick between) each read
|
||||
// destroyedAtEntry=0 and re-ran the whole death block -- duplicate kill
|
||||
// credit to the shooter (kills 1->2 in 2ms) and a duplicate VehicleDead
|
||||
// (the receiver dedup swallowed the death cycle but the kill report had
|
||||
// already been posted). The transition is now invoked synchronously
|
||||
// below, closing the window exactly the way the binary does.
|
||||
death_tail:
|
||||
// #162 (2026-08-11) -- MASTER ONLY: the discipline #150 restored for the
|
||||
// score reports, now applied to the death tail it forgot. In 1995 this
|
||||
// ENTIRE handler was replicant-asserted (part_012.c:14613), so the tail
|
||||
// could only run on the victim's master: ONE VehicleDead per death -- and
|
||||
// the receiver needs no dedup, because the binary's deathPending gate
|
||||
// (+0x290) is never written non-zero anywhere in BTL4OPT.EXE [T1 image
|
||||
// byte-scan]; the sender's one-shot edge IS the 1995 guarantee. Our port
|
||||
// deliberately applies damage on replicant copies for visual response,
|
||||
// and on those copies movementMode does not flip to 9 until the owner's
|
||||
// type-6 death record round-trips -- so deathBlastArmed stayed armed
|
||||
// there, and EVERY salvo of the killing volley re-fired this tail on
|
||||
// EVERY peer: a duplicate VehicleDead per salvo per node, rerouted to the
|
||||
// victim's master (night-15 receipts: 5 real deaths -> 8 PLAYER_DEAD,
|
||||
// deaths=1,2 one ms apart, x3 same-millisecond; multiplicity tracks the
|
||||
// killing-window ROUNDS, not shooters or peers), plus a duplicate death
|
||||
// splash per peer. Peers' death visuals ride the type-6 record path
|
||||
// (UpdateDeathState wreck/explosion), never this tail -- gating it here
|
||||
// removes only the phantom emissions the binary never made.
|
||||
if (deathBlastArmed && IsMechDestroyed()
|
||||
&& GetInstance() != ReplicantInstance)
|
||||
{
|
||||
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||
|
||||
extern void BTApplyDeathSplash(void *mech_v);
|
||||
BTApplyDeathSplash((void *)this);
|
||||
|
||||
// Run the once-per-death transition NOW (mode 9 + type-6 ForceUpdate +
|
||||
// death effects), not next tick -- the binary flips mode inside this
|
||||
// same pass, which is the entire same-frame double-delivery latch: the
|
||||
// NEXT lethal message of this volley reads destroyedAtEntry=1 and
|
||||
// deathBlastArmed=0, so it can neither re-credit the kill nor re-post
|
||||
// VehicleDead. dt=0: the transition branch has no time dependence; the
|
||||
// per-frame caller takes the wreck-maintenance branch from here on.
|
||||
UpdateDeathState(0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -1249,6 +1575,8 @@ Mech::Mech(
|
||||
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
|
||||
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
|
||||
rearFiring = 0; // (task #68) ORed from the weapons below
|
||||
ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
|
||||
ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
|
||||
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
|
||||
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
|
||||
// is retired; distanceToMissile (init below) owns the slot's meaning.
|
||||
@@ -1348,6 +1676,7 @@ Mech::Mech(
|
||||
radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position...
|
||||
radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin)
|
||||
duckState = 0; // not crouching
|
||||
duckRequest = 0; // no pending duck request (#142)
|
||||
// (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX
|
||||
// (part_012.c:9446-9447; FLT_MAX = "no missile" far default)
|
||||
incomingLock = 0;
|
||||
@@ -1764,8 +2093,11 @@ Mech::Mech(
|
||||
|
||||
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
|
||||
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
|
||||
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
|
||||
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
|
||||
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
|
||||
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
|
||||
// members -- the Wword bank is
|
||||
// GLOBAL and clobbered per-mech
|
||||
// values chassis-to-chassis)
|
||||
Wword(0x195) = Wword(0x196) = 0;
|
||||
Wword(0xfd) = 0;
|
||||
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
|
||||
@@ -1919,6 +2251,9 @@ Mech::Mech(
|
||||
ramLastVictim = 0; // ram contact-edge state
|
||||
ramContactLinger = 0.0f;
|
||||
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
||||
mapPosture = 0; // binary @0x3f8 (the ctor zero-block @0x4b3d18 zeroes the band)
|
||||
myomerEffectiveness = 1.0f; // binary @0x79c: 0-init + per-frame MAX; the port holds the
|
||||
// healthy 1.0 until the Myomers Performance feeder lands [T3]
|
||||
if (GroundReal() && GetCollisionVolumeCount() > 0
|
||||
&& collisionTemplate != 0 && collisionVolume != 0)
|
||||
{
|
||||
@@ -2290,6 +2625,13 @@ Logical
|
||||
void
|
||||
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
||||
{
|
||||
// #108 ghost forensics: stamp the staleness detector on every applied
|
||||
// record (replicants only -- the detector lives on the death handler).
|
||||
if (GetInstance() == ReplicantInstance)
|
||||
{
|
||||
extern void BTMechNoteUpdateApplied(void *mech_v);
|
||||
BTMechNoteUpdateApplied((void *)this);
|
||||
}
|
||||
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
||||
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
||||
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
||||
|
||||
@@ -357,7 +357,11 @@ struct ShotDescriptor
|
||||
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
|
||||
|
||||
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
|
||||
Logical GetMissionReviewMode(); // reads this+0x414 (btl4mppr.cpp:366)
|
||||
// ejectPermitted (@0x414) accessor -- the panic-arm watcher's read
|
||||
// (btl4mppr InterpretControls @004d196c). [The old GetMissionReviewMode
|
||||
// here was a mislabel of the same cell; the real review mode is the
|
||||
// GLOBAL DAT_004fd550 (btl4pb.cpp). Corrected 2026-08-03.]
|
||||
int GetEjectPermitted() const { return ejectPermitted; }
|
||||
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
|
||||
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
|
||||
|
||||
@@ -650,6 +654,12 @@ public:
|
||||
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
||||
// after the layout-locked fields so nothing shifts.
|
||||
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
|
||||
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
|
||||
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
|
||||
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
|
||||
// were silently clobbered to the last-loaded chassis).
|
||||
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
|
||||
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
|
||||
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
|
||||
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
|
||||
|
||||
@@ -739,6 +749,15 @@ protected:
|
||||
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
|
||||
// mech carries a rear arsenal". The look-back view arms those weapons.
|
||||
int rearFiring; // @0x410 this[0x104]
|
||||
int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
|
||||
// 1 = crippled enough that the PANIC/EJECT
|
||||
// punch-out is allowed (binary refreshes per
|
||||
// frame from an unexported caller; the port
|
||||
// evaluates on demand in the 0x19 handler)
|
||||
int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
|
||||
// clause. NO writer in the exported decomp --
|
||||
// zero-filled in the binary, so the clause is
|
||||
// inert there too [T3]; init 0 to match.
|
||||
// (task #68) the authored look-view angles (model record +0x50..0x5c,
|
||||
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
|
||||
// parked in the Wword scratch bank). Consumed by the look-state
|
||||
@@ -945,7 +964,15 @@ protected:
|
||||
Scalar radarRange; // 0x2f RadarRange (scale/max)
|
||||
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
|
||||
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
|
||||
int duckState; // 0x37 DuckState (crouch posture)
|
||||
int duckState; // 0x37 DuckState (crouch POSTURE the cockpit
|
||||
// strip draws: 0 stand, 1 moving, 2 crouched)
|
||||
// PORT-ONLY (#142): the pending duck REQUEST, kept separate from the
|
||||
// posture above. duckState cannot carry both -- settling it to the
|
||||
// real posture destroys the request, and the consumer then re-issues
|
||||
// the opposite direction on the very frame the clip parks (benched:
|
||||
// 69 squat/rise transitions from 2 presses). Not a binary field; it
|
||||
// is appended, never read by offset.
|
||||
int duckRequest;
|
||||
// (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary
|
||||
// Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match
|
||||
// ==1 Start / ==0 Stop of the looped beeper), DistanceToMissile id 56
|
||||
@@ -1013,6 +1040,17 @@ protected:
|
||||
Scalar gimpStrideLength; // @0x350
|
||||
AlarmIndicator legStateAlarm; // @0x39c
|
||||
int legAnimationState; // @0x3b0
|
||||
int mapPosture; // binary @0x3f8 -- the map-legend/duck posture arbiter
|
||||
// (0 none / 1 may-duck / 2 holding-squat), computed per
|
||||
// master frame (FUN_004a9b5c @0x4a9f61-0x4aa007; the
|
||||
// CROUCH reconstruction, 2026-08-05)
|
||||
Scalar myomerEffectiveness; // binary @0x79c -- the master-perf myomer chain MAX
|
||||
// (speedEffect@0x31C over the myomer subsystems),
|
||||
// published LIVE each mapper update by mechmppr.cpp's
|
||||
// drive-scale block (the 2026-07-31 seek-audit recon of
|
||||
// the same @0x4a9cf2-0x4a9da4 bytes). Consumed by the
|
||||
// crouch posture gate (mech4). Ctor 1.0 = pre-first-
|
||||
// update default only.
|
||||
Scalar gimpSpeedMax; // @0x52c
|
||||
Scalar standSpeed; // @0x530
|
||||
Scalar walkStrideLength; // @0x534
|
||||
@@ -1106,6 +1144,9 @@ protected:
|
||||
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
|
||||
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
|
||||
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
|
||||
// --- #108 ghost-forensics accessors (PORT; the fields are protected) ---
|
||||
void *DeathHandlerPtr() { return (void *)deathHandler; }
|
||||
int WreckBuried() const { return collisionVolumeCount == 0; }
|
||||
|
||||
// --- clip loaders (mech3) -------------------------------------------
|
||||
ResourceDescription::ResourceID *
|
||||
@@ -1237,6 +1278,29 @@ protected:
|
||||
enum { DuckRequestMessageID = 0x1a };
|
||||
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
|
||||
// @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
|
||||
// EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
|
||||
// 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
|
||||
// ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
|
||||
// console of the eject (FUN_004c198c ctor -> network sender; port core
|
||||
// LOGS it, the relay wire is the tail) and latch the player's
|
||||
// suppressConsole (+0x258) so the death that follows doesn't double-
|
||||
// notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
|
||||
// then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
|
||||
// Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
|
||||
// byte-verified via the role reader @00429bec dest offsets + the ctor
|
||||
// record copy), burst 0 (binary; our victim guard clamps to 1 --
|
||||
// identical outcome: the charge is lethal)}. The punch-out IS a
|
||||
// death: wreck, respawn cycle and replication all ride the normal
|
||||
// damage chain.
|
||||
enum { EjectPilotMessageID = 0x19 };
|
||||
void EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
// @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
|
||||
// roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
|
||||
// generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
|
||||
// (leg-gimped state 3/4 AND novice, player simLive == 0).
|
||||
int EvaluateEjectPermission();
|
||||
|
||||
// --- damage-routing support (mechdmg / mech4) -----------------------
|
||||
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
|
||||
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
|
||||
|
||||
@@ -220,6 +220,16 @@ enum MechAnimationState
|
||||
void
|
||||
Mech::SetLegAnimation(int state)
|
||||
{
|
||||
// DIAG (BT_DUCK_LOG): every leg re-arm with the caller's return address --
|
||||
// the crouch-clobber hunt (who re-arms after the squat parks?).
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
{
|
||||
char dbuf[96];
|
||||
sprintf(dbuf, "[duck] SetLegAnimation(%d) ra=btl4+0x%lx", state,
|
||||
(unsigned long)_ReturnAddress()
|
||||
- (unsigned long)GetModuleHandleA(0));
|
||||
DEBUG_STREAM << dbuf << std::endl << std::flush;
|
||||
}
|
||||
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
|
||||
animationClips[state], // *(this+0x5cc + state*4)
|
||||
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
|
||||
@@ -520,6 +530,8 @@ Scalar
|
||||
return 0.0f;
|
||||
// slot10 state 2 (0x4a6b37): SetLevel(1).
|
||||
case 2:
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] squat clip parked (case 2 -> leg alarm 1)\n" << std::flush;
|
||||
m->legStateAlarm.SetLevel(1); return 0.0f;
|
||||
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
|
||||
case 3: case 4: case 8: case 9: case 20: case 21:
|
||||
@@ -1234,6 +1246,10 @@ Scalar
|
||||
Mech::AdvanceBodyAnimation(Scalar time_slice, int loop)
|
||||
{
|
||||
Scalar distance = 0.0f;
|
||||
// #52 probe (BT_BODY_SM_LOG): case 0 and the inserted turn block below run
|
||||
// in the SAME invocation, so a plain local proves the arm->reset pair --
|
||||
// no cross-frame state, no per-mech bookkeeping.
|
||||
int armedFromStanding = 0;
|
||||
|
||||
// In the binary `bodyAnimationState`@0x728 IS `bodyStateAlarm`'s level (one field);
|
||||
// the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)`
|
||||
@@ -1285,7 +1301,35 @@ Scalar
|
||||
}
|
||||
SetBodyAnimation(0x10);
|
||||
}
|
||||
// FALLTHROUGH
|
||||
// FALLTHROUGH -- into the ADVANCE GROUP, which is where the binary
|
||||
// sends it. #52 SKATE ROOT CAUSE (2026-08-07): in FUN_004a5678 case 4
|
||||
// is a MEMBER of the advance list (case 2,3,*4*,5,8,...), so a state
|
||||
// just armed away from Standing lands on Advance(). The port's turn
|
||||
// block below is an INSERTION (task #64 lockstep twin) and, sitting
|
||||
// between case 0 and the advance group, it intercepted that fallthrough.
|
||||
// On a REPLICANT that is fatal and not a race: case 0 arms walk iff
|
||||
// `standSpeed < bodyTargetSpeed`, and the inserted block's exit tests
|
||||
// `standSpeed < bspd` where bspd IS bodyTargetSpeed for a replicant --
|
||||
// the SAME expression. Arm and reset therefore fire on the same frame,
|
||||
// every frame, and a peer parked at Standing with a live replicated
|
||||
// demand can never start cycling (reverse likewise: both sides test
|
||||
// `< ZeroSpeed`). It cycles again only when a record sets the state
|
||||
// directly (ReadUpdateRecord, mech.cpp) -- the observed self-recovery.
|
||||
// Masters escape because their two tests read DIFFERENT cells
|
||||
// (bodyTargetSpeed = last-sent vs the live mapper speedDemand) and
|
||||
// because the body channel is mj=0 there, so its stall is invisible.
|
||||
// Introduced by e91d447 (#82): before it the replicant branch read the
|
||||
// dead mapper cell (0 forever), so the exit never fired and this
|
||||
// fallthrough worked BY ACCIDENT. Fixing the dead cell closed the
|
||||
// accidental escape hatch and the trn-lock skate came back as a
|
||||
// Standing-lock skate. BT_NO_BODY_FALLTHRU=1 restores the old path.
|
||||
armedFromStanding = (int)bodyStateAlarm.GetLevel();
|
||||
{
|
||||
static const int s_bodyFallthru = getenv("BT_NO_BODY_FALLTHRU") ? 0 : 1;
|
||||
if (s_bodyFallthru)
|
||||
goto advance_body_normally;
|
||||
}
|
||||
// FALLTHROUGH (legacy path only)
|
||||
|
||||
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
|
||||
// The body channel runs trn in LOCKSTEP with the leg: armed together at
|
||||
@@ -1312,6 +1356,23 @@ Scalar
|
||||
: (bm != 0) ? bm->speedDemand : 0.0f;
|
||||
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
|
||||
{
|
||||
// #52 probe: when this fires on a state case 0 JUST armed, the
|
||||
// mech is being pushed straight back to Standing on the same
|
||||
// frame it tried to leave it -- the Standing-lock. On a
|
||||
// replicant `bspd` IS the same cell case 0 tested, so the pair
|
||||
// is unconditional, not a race.
|
||||
if (armedFromStanding != 0 && getenv("BT_BODY_SM_LOG"))
|
||||
{
|
||||
static float s_bsm = 0.0f; s_bsm += time_slice;
|
||||
if (s_bsm >= 1.0f) { s_bsm = 0.0f;
|
||||
DEBUG_STREAM << "[bodySM] " << (GetInstance() == ReplicantInstance
|
||||
? "REPLICANT " : "master ")
|
||||
<< GetEntityID() << " case0 armed " << armedFromStanding
|
||||
<< " -> turn-block RESET to Standing bspd=" << (float)bspd
|
||||
<< " bts=" << (float)bodyTargetSpeed
|
||||
<< " standSpeed=" << (float)standSpeed
|
||||
<< " (STANDING-LOCK)\n" << std::flush; }
|
||||
}
|
||||
bodyStateAlarm.SetLevel(0);
|
||||
ForceUpdate(8);
|
||||
distance = 0.0f;
|
||||
@@ -1335,6 +1396,7 @@ Scalar
|
||||
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
|
||||
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
|
||||
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
|
||||
advance_body_normally: // case 0's fallthrough target (leg twin: advance_normally)
|
||||
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
|
||||
time_slice * globalTimeScale * idleStrideScale, loop);
|
||||
bodyCycleSpeed = distance / time_slice; // this+0x6b8
|
||||
|
||||
+1475
-116
File diff suppressed because it is too large
Load Diff
@@ -1193,8 +1193,21 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind
|
||||
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
|
||||
extern void BTRemakeMechModel(Entity *entity);
|
||||
|
||||
//
|
||||
// #108 bridge: Mech::ReadUpdateRecord (mech.cpp, incomplete-type TU for the
|
||||
// handler) stamps the ghost detector through this.
|
||||
//
|
||||
void
|
||||
BTMechNoteUpdateApplied(void *mech_v)
|
||||
{
|
||||
Mech *m = (Mech *)mech_v;
|
||||
if (m != 0 && m->DeathHandlerPtr() != 0)
|
||||
((MechDeathHandler *)m->DeathHandlerPtr())->NoteUpdateApplied();
|
||||
}
|
||||
|
||||
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
|
||||
: owner(mech), prevMode(0)
|
||||
: owner(mech), prevMode(0), ghostFrames(0), ghostLogged(0),
|
||||
skatePrevX(0.0f), skatePrevZ(0.0f), skateFrames(0), skateLogged(0)
|
||||
{
|
||||
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
|
||||
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
|
||||
@@ -1217,6 +1230,117 @@ void
|
||||
if (owner == 0)
|
||||
return;
|
||||
|
||||
// #52 SKATE DETECTOR (PORT, ungated -- the night-12 forensic): a REPLICANT
|
||||
// whose position advances while its LEG CHANNEL is not cycling is the
|
||||
// field "skating" signature. One line per episode, carrying the three
|
||||
// inputs that split the failure: bodyTargetSpeed (the replicated commanded
|
||||
// speed the leg SM feeds from -- ~0 means the RECORD side starved the
|
||||
// gait), legCycleSpeed (~0 with a live demand means the SM is STUCK), and
|
||||
// IsMechDestroyed (1 means the gait feed block was SKIPPED -- the stale
|
||||
// post-respawn flag suspect). ~90 frames of sustained skate to trip; the
|
||||
// threshold 0.08 u/frame ~ 2.4 u/s at 30 fps, well above reckoner jitter.
|
||||
if (owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
const float px = (float)owner->localOrigin.linearPosition.x;
|
||||
const float pz = (float)owner->localOrigin.linearPosition.z;
|
||||
const float dx = px - skatePrevX, dz = pz - skatePrevZ;
|
||||
const float step = sqrtf(dx * dx + dz * dz);
|
||||
skatePrevX = px; skatePrevZ = pz;
|
||||
// CHANNEL-AGNOSTIC (corrected same-day): the peer's joints are posed by
|
||||
// the BODY channel in the current authentic architecture (mech4
|
||||
// s_peerLegCh=0 -> AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is
|
||||
// NORMAL there -- the first detector build false-fired on every healthy
|
||||
// movement phase. Skating = moving with BOTH channels idle.
|
||||
const float cyc = fabsf((float)owner->legCycleSpeed)
|
||||
+ fabsf((float)owner->bodyCycleSpeed);
|
||||
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
|
||||
&& cyc < 0.05f) ? 1 : 0;
|
||||
// #52 A/B probe (BT_BODY_SM_LOG): the POSITIVE half of the Standing-lock
|
||||
// evidence. The [skate] line only speaks once a lock has ALSO produced
|
||||
// 90 sustained moving frames; this says every second what a moving peer's
|
||||
// body channel is actually doing. Locked: bstate=0, bodyCyc=0. Healthy:
|
||||
// bstate in the walk/run family with a live cycle.
|
||||
if (step > 0.08f && step < 5.0f && getenv("BT_BODY_SM_LOG"))
|
||||
{
|
||||
static float s_pg = 0.0f; s_pg += 1.0f / 30.0f;
|
||||
if (s_pg >= 1.0f) { s_pg = 0.0f;
|
||||
DEBUG_STREAM << "[peergait] replicant " << owner->GetEntityID()
|
||||
<< " step=" << step
|
||||
<< " bstate=" << (int)owner->bodyAnimationState
|
||||
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
|
||||
<< " bts=" << (float)owner->bodyTargetSpeed
|
||||
<< (movingNoLegs ? " <-- IDLE CHANNELS" : "")
|
||||
<< "\n" << std::flush; }
|
||||
}
|
||||
if (movingNoLegs)
|
||||
{
|
||||
if (++skateFrames > 90 && !skateLogged)
|
||||
{
|
||||
skateLogged = 1;
|
||||
// bstate = the peer's BODY animation state (@0x728), the channel
|
||||
// that poses a replicant (s_peerLegCh=0). Added 2026-08-07 after
|
||||
// night 13: the field lines proved "moving with both channels
|
||||
// idle" but not WHICH state it was idling in, and the answer
|
||||
// (0 = Standing, pinned) is the whole diagnosis -- see #52.
|
||||
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
|
||||
<< " SKATING: " << skateFrames << " frames moving ("
|
||||
<< step << " u/frame) with legCyc="
|
||||
<< (float)owner->legCycleSpeed
|
||||
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
|
||||
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
|
||||
<< " bstate=" << (int)owner->bodyAnimationState
|
||||
<< " destroyed=" << (int)owner->IsMechDestroyed()
|
||||
<< " mode=" << (int)owner->MovementMode()
|
||||
<< " at (" << px << "," << pz << ")\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f "
|
||||
"cmdSpd=%.2f bstate=%d destroyed=%d mode=%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
skateFrames, step, cyc,
|
||||
(float)owner->bodyTargetSpeed,
|
||||
(int)owner->bodyAnimationState,
|
||||
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
|
||||
px, pz);
|
||||
}
|
||||
}
|
||||
else if (skateFrames > 0)
|
||||
{
|
||||
if (skateLogged)
|
||||
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
|
||||
<< " recovered after " << skateFrames << " frames\n" << std::flush;
|
||||
skateFrames = 0; skateLogged = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// #108 GHOST DETECTOR (PORT, ungated -- the night-9 forensic): a visible
|
||||
// REPLICANT that stops receiving update records is exactly what "everyone
|
||||
// shoots thin air" looks like from the other side. ReadUpdateRecord
|
||||
// stamps arrivals (NoteUpdateApplied); ~20s of silence on an unburied
|
||||
// replicant logs ONE [ghost] line per episode with the identity + last
|
||||
// position, then re-arms when records resume. Frame-counted (Tick has no
|
||||
// dt); 600 frames ~ 10-20s at field rates.
|
||||
if (owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
++ghostFrames;
|
||||
if (ghostFrames > 600 && !ghostLogged
|
||||
&& !owner->WreckBuried()) // buried wrecks are expected-silent
|
||||
{
|
||||
ghostLogged = 1;
|
||||
DEBUG_STREAM << "[ghost] replicant " << owner->GetEntityID()
|
||||
<< " has received NO update records for " << ghostFrames
|
||||
<< " frames (mode " << (int)owner->MovementMode()
|
||||
<< ") last pos (" << owner->localOrigin.linearPosition.x
|
||||
<< "," << owner->localOrigin.linearPosition.z
|
||||
<< ") -- stale-replicant / ghost signature (#108)\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("GHOST", "mech=%d:%d frames=%d mode=%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
ghostFrames, (int)owner->MovementMode(),
|
||||
(float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
}
|
||||
}
|
||||
|
||||
// REPLICANT UN-WRECK (#94): fire on the DEATH-STATE EXIT edge of the
|
||||
// replicated SimulationState dial (MovementMode 9/2 are the death modes;
|
||||
// they never revert except through Mech::Reset, whose SetMovementMode(0)
|
||||
@@ -1234,20 +1358,55 @@ void
|
||||
const bool nowDead = (mode == 2 || mode == 9);
|
||||
const bool prevDead = (oldMode == 2 || oldMode == 9);
|
||||
prevMode = mode;
|
||||
// #108 GHOST CENSUS, the ENTER edge (ungated, 2026-08-07). The
|
||||
// un-wreck receipt below has had no partner, so counting ghosts in a
|
||||
// field log meant pairing it against
|
||||
// "[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback" --
|
||||
// which is a MISSING-ASSET WARNING, not a death: it only prints for
|
||||
// chassis whose wreck model is absent. Night 13's census therefore
|
||||
// found ONE ghost while testers reported many, and there was no way to
|
||||
// tell a real count from a chassis accident. This line is emitted for
|
||||
// EVERY replicant that enters the wreck state, so a log's ghost count
|
||||
// is exactly (wreck-enters minus un-wrecks) per entity.
|
||||
if (!prevDead && nowDead
|
||||
&& owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
DEBUG_STREAM << "[wreck] replicant " << owner->GetEntityID()
|
||||
<< " entered wreck state (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("WRECK", "mech=%d:%d mode=%d->%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
oldMode, mode,
|
||||
(float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
}
|
||||
if (prevDead && !nowDead
|
||||
&& owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
extern void BTRebuildMechModel(Entity *entity);
|
||||
extern void BTStartWarpEffect(float x, float y, float z);
|
||||
// #129 (fresh spawns smoking, peers' screens only): the wreck plume
|
||||
// is re-armed LOCALLY on every node (entity-attached, 10s windows),
|
||||
// and only the master's Mech::Reset ran the @004d0c14 effect
|
||||
// cleanup -- the observer's last window rode the teleport and kept
|
||||
// puffing on the fresh mech for up to ~16s. Kill the entity's
|
||||
// effects on the un-wreck edge, same as the master path.
|
||||
extern void BTStopEntityPfx(void *entity_v);
|
||||
BTStopEntityPfx((void *)owner);
|
||||
BTRebuildMechModel((Entity *)owner);
|
||||
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.y,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
// #108 forensics: UNGATED -- one line per peer respawn; a field
|
||||
// night's ghost triage needs these without asking players for envs.
|
||||
DEBUG_STREAM << "[respawn] replicant " << owner->GetEntityID()
|
||||
<< " un-wrecked + warp (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,6 +1434,20 @@ void
|
||||
continue;
|
||||
lastLevel[i] = level;
|
||||
|
||||
// AUTHENTIC (FUN_0042aa2c @0042aa2c, the binary's effect-watcher level
|
||||
// branch): when the rise CROSSES a band-descriptor threshold
|
||||
// (FUN_0042a5f4) and this node owns the master, raise the entity's
|
||||
// damage-zone update flag (*(entity+0x18) |= 2, master-gated on
|
||||
// (entity+0x28 & 0xc) == 0) -- the zone update records ship this
|
||||
// zone's damageLevel to every observer, so replicant dolls/hull tint
|
||||
// track the fight at band granularity. This send was MISSING from
|
||||
// the port (only the graphic-state branch below raised it): masters
|
||||
// consumed damage correctly while every observer's copy sat at 0.0
|
||||
// until destruction (the "no discoloration in MP" report, #87).
|
||||
if (zone->DescriptorCrossed(prev, level)
|
||||
&& owner->GetInstance() == Entity::MasterInstance)
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
|
||||
// AUTHENTIC GATE (decomp re-verified 2026-07-12, workflow): the binary
|
||||
// fires the CURRENT band descriptor's effect whenever the zone's
|
||||
// damage-CHANGED flag is set (+0xb8 & 4, set by DamageZone::TakeDamage,
|
||||
@@ -1326,7 +1499,12 @@ void
|
||||
// The single-threaded frame loop makes the immediate swap safe.
|
||||
if (d->graphicState != DamageZone::ExistsGraphicState)
|
||||
{
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
// Master-gated like the binary's gstate branch (@0042aa2c:
|
||||
// (entity+0x28 & 0xc) == 0 guards the |= 2); the mesh swap
|
||||
// runs on EVERY instance -- an observer node executes this
|
||||
// same path when the replicated level/gstate lands.
|
||||
if (owner->GetInstance() == Entity::MasterInstance)
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
|
||||
}
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
@@ -1438,3 +1616,22 @@ void BTAmmoExplosionFanOut(
|
||||
((Mech *)owner_mech)->AmmoExplosionFanOut(
|
||||
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
|
||||
}
|
||||
|
||||
//
|
||||
// #124 ZONE WALKER bridge: a zone's carrier SEGMENT index + name, complete-
|
||||
// type TU (Mech__DamageZone). Replicant-safe -- the zone table streams on
|
||||
// every instance, so the SHOOTER can enumerate its target's zones locally.
|
||||
// Returns 0 past the end (the walker wraps on that).
|
||||
//
|
||||
int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out)
|
||||
{
|
||||
Mech *m = (Mech *)mech_v;
|
||||
if (m == 0 || zone_idx < 0 || zone_idx >= m->damageZoneCount)
|
||||
return 0;
|
||||
Mech__DamageZone *dz = m->Zone(zone_idx);
|
||||
if (dz == 0)
|
||||
return 0;
|
||||
*seg_out = dz->EffectSegmentIndex(); // public accessor (@0x194)
|
||||
*name_out = (const char *)dz->damageZoneName; // engine base, public
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -363,6 +363,11 @@ class MechDeathHandler
|
||||
MechDeathHandler(Mech *mech); // @0042a984
|
||||
~MechDeathHandler(); // @0042a9f4
|
||||
void Tick(); // @0042aa2c (the Performance)
|
||||
// #108 forensics (PORT, ungated): the replicant staleness detector.
|
||||
// Mech::ReadUpdateRecord stamps every applied record; Tick counts
|
||||
// frames since -- a visible replicant starved of records IS the ghost
|
||||
// signature, logged once per episode with id + age + last position.
|
||||
void NoteUpdateApplied() { ghostFrames = 0; ghostLogged = 0; }
|
||||
|
||||
private:
|
||||
Mech *owner; // @0x14
|
||||
@@ -378,6 +383,14 @@ class MechDeathHandler
|
||||
// authentic un-wreck rides the type-0 graphic-state hook
|
||||
// (Mech::ReadUpdateRecord case 0), which our port has not reconstructed.
|
||||
int prevMode;
|
||||
// #108 ghost detector state (PORT)
|
||||
int ghostFrames;
|
||||
int ghostLogged;
|
||||
// #52 skate detector state (PORT): position advancing while the leg
|
||||
// channel is not cycling = the field "skating" signature.
|
||||
float skatePrevX, skatePrevZ;
|
||||
int skateFrames;
|
||||
int skateLogged;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
+142
-27
@@ -508,25 +508,69 @@ void
|
||||
}
|
||||
NotifyOfControlModeChange(controlMode); // vtable+0x48
|
||||
|
||||
// TYPED torso reconfiguration (2026-07-13): the raw block this
|
||||
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
|
||||
// straight onto OUR compiled Torso -- the databinding trap: garbage
|
||||
// writes into whatever members live there in this build. The
|
||||
// observable semantics via named members: Basic clears the analog
|
||||
// axes and recenters (the sim's centerCommand -> Recenter); the
|
||||
// assisted modes just free the torso (the sim clamps to the authored
|
||||
// limits on its own).
|
||||
// TYPED torso reconfiguration. The raw block this replaces wrote the
|
||||
// BINARY's offsets straight onto OUR compiled Torso (the databinding
|
||||
// trap); the typed rewrite that followed then got the SEMANTICS wrong in
|
||||
// three ways. Corrected 2026-08-08 against @004afbe0, which is a
|
||||
// complete spec:
|
||||
//
|
||||
// iVar1 = mech+0x438 (TORSO) iVar2 = mech+0x5b4 (HUD)
|
||||
// if (mode == 0) { // BASIC
|
||||
// *(iVar1 + 0x1f0) = 0; // analogTwistAxis
|
||||
// *(iVar1 + 0x274) = 1; // recenterActive
|
||||
// *(iVar1 + 0x220) = *(iVar1 + 0x228); // vertLimitTop
|
||||
// *(iVar1 + 0x224) = *(iVar1 + 0x22c); // vertLimitBottom
|
||||
// *(iVar2 + 0x2a0) = 1; // HUD flickerActive
|
||||
// } else if (mode - 1U < 2) { // STANDARD/VETERAN
|
||||
// *(iVar1 + 0x220) = *(iVar1 + 0x230);
|
||||
// *(iVar1 + 0x224) = *(iVar1 + 0x234);
|
||||
// }
|
||||
//
|
||||
// (1) THE BUG Sauron hit. Basic set `centerCommand` (@0x208) via
|
||||
// CommandRecenter(). That is the HELD-BUTTON cell: TorsoSimulation
|
||||
// re-arms recenterActive from it EVERY frame it is non-zero, and only
|
||||
// the input path clears it -- and a MODE SWITCH has no button release
|
||||
// to follow. So one visit to Basic pinned it at 1 forever, the torso
|
||||
// re-centred every frame, and the digital twist commands (processed
|
||||
// BEFORE the centerCommand block) were overridden as fast as they were
|
||||
// applied. Cycling Standard -> Veteran -> (wraps through BASIC) ->
|
||||
// Standard is enough to trigger it, which is exactly the reported
|
||||
// "toggled to advanced and back, lost torso control". The binary sets
|
||||
// recenterActive (@0x274) directly: a ONE-SHOT that self-clears on
|
||||
// settle (`recenterActive = Recenter(dt)`) and is cancelled by any
|
||||
// twist input.
|
||||
// (2) The ELEVATION LIMIT SWAP was missing entirely. Two authored pairs
|
||||
// exist -- BASIC @0x228/@0x22C (full top, HALF bottom) vs
|
||||
// STANDARD/VETERAN @0x230/@0x234 (the full pair) -- and all four were
|
||||
// ctor-written and never read by anything. So Basic never restricted
|
||||
// downward travel and the assisted modes never restored it.
|
||||
// (3) Basic also raises the HUD's flickerActive (@0x2A0) so the horizon
|
||||
// re-settles with the torso. Not ported.
|
||||
// Also: the binary zeroes ONLY analogTwistAxis (@0x1F0). The extra
|
||||
// SetAnalogElevationAxis(0) was invented; removed.
|
||||
Mech *mech = GetMech();
|
||||
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
|
||||
if (torso != 0)
|
||||
{
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
torso->SetAnalogTwistAxis(0.0f);
|
||||
torso->SetAnalogElevationAxis(0.0f);
|
||||
torso->CommandRecenter();
|
||||
torso->SetAnalogTwistAxis(0.0f); // @0x1F0
|
||||
// BT_LEGACY_MODE_RECENTER=1 restores the defective pre-2026-08-08
|
||||
// behaviour (the sticky centerCommand) for A/B measurement.
|
||||
static const int s_legacyRecenter =
|
||||
getenv("BT_LEGACY_MODE_RECENTER") ? 1 : 0;
|
||||
if (s_legacyRecenter)
|
||||
torso->CommandRecenter(); // @0x208 STICKY -- the bug
|
||||
else
|
||||
torso->BeginRecenterOnce(); // @0x274 (NOT centerCommand)
|
||||
torso->ApplyBasicElevationLimits(); // @0x220/@0x224 <- @0x228/@0x22C
|
||||
extern void BTSetHudFlickerActive(Subsystem *hud);
|
||||
BTSetHudFlickerActive(mech->GetHudSubsystem()); // HUD @0x2A0 = 1
|
||||
}
|
||||
else // StandardMode / VeteranMode -- `mode - 1U < 2` in the binary
|
||||
{
|
||||
torso->ApplyAssistedElevationLimits(); // @0x220/@0x224 <- @0x230/@0x234
|
||||
}
|
||||
// Standard/Veteran: nothing to force -- the sim's limits govern.
|
||||
}
|
||||
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
|
||||
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
|
||||
@@ -660,6 +704,58 @@ void
|
||||
// after the push, immediately before interpretation -- making the keyboard
|
||||
// authoritative on the dev box. Interpretation below stays 100% authentic.
|
||||
//
|
||||
// BENCH (BT_MODECYCLE_EVERY=<n>): cycle the control mode every n
|
||||
// InterpretControls calls, driving the SAME body the 'M' key and the pod
|
||||
// console button (key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
|
||||
// it) drive. Dev-only; default off.
|
||||
//
|
||||
// ⚠ DELIBERATELY OUTSIDE the key-bridge block below. The ONLY caller of
|
||||
// ClearRecenterCommand() lives inside that block, so forcing BT_KEY_BRIDGE=1
|
||||
// to make this hook run would ALSO switch on the one thing that clears
|
||||
// centerCommand -- masking the very bug under test. That is exactly how the
|
||||
// first run of modecycle.sh came back clean. Keeping the hook out here lets
|
||||
// the bench reproduce the RIO-present (glass/PadRIO) configuration, where the
|
||||
// bridge is OFF and nothing clears the cell.
|
||||
{
|
||||
static const char *s_mcEvery = getenv("BT_MODECYCLE_EVERY");
|
||||
if (s_mcEvery != 0)
|
||||
{
|
||||
static int s_mcN = 0;
|
||||
int period = atoi(s_mcEvery);
|
||||
if (period < 1) period = 300;
|
||||
if (++s_mcN % period == 0)
|
||||
CycleControlModeNow();
|
||||
}
|
||||
}
|
||||
// (#152) TORSO-CENTER -- the ONE writer of the torso's centerCommand
|
||||
// (@0x208, HELD-button semantics: writer asserts while held, clears on
|
||||
// release; TorsoSimulation re-arms recenterActive from it each frame).
|
||||
// Sources OR'd here, deliberately OUTSIDE the key-bridge gate:
|
||||
// * torsoCenter (@0x154) -- this mapper's databound "TorsoCenter" cell,
|
||||
// the streamed pod-button route (button 0x42);
|
||||
// * gBTTorsoRecenter -- the desktop 'X' one-frame pulse (mech4 key poll).
|
||||
// The old writer lived INSIDE the key-bridge block, so on any rig with a
|
||||
// RIO/PadRIO present (glass + the pod -- the bridge is off there) NO path
|
||||
// could reach centerCommand: with the stuck-cell phantom auto-recentre
|
||||
// fixed, those players had no way to recentre the torso in Std/Vet at all
|
||||
// (Oracle's #152 report). Single-writer here also means the two sources
|
||||
// can never stomp each other's clear.
|
||||
{
|
||||
Mech *rcMech = GetMech();
|
||||
Torso *rcTorso = (rcMech != 0) ? (Torso *)rcMech->GetTorsoSubsystem() : 0;
|
||||
if (rcTorso != 0)
|
||||
{
|
||||
int hold = (torsoCenter != 0);
|
||||
extern int gBTTorsoRecenter;
|
||||
extern int gBTTorsoCenterHeld; // pod button 0x42 (L4PADRIO chokepoint)
|
||||
if (gBTTorsoCenterHeld) hold = 1;
|
||||
if (gBTTorsoRecenter) { gBTTorsoRecenter = 0; hold = 1; }
|
||||
if (hold)
|
||||
rcTorso->CommandRecenter();
|
||||
else
|
||||
rcTorso->ClearRecenterCommand();
|
||||
}
|
||||
}
|
||||
{
|
||||
// STAND-DOWN (glass-cockpit step 2c): BT_KEY_BRIDGE unset = AUTO --
|
||||
// the bridge runs only when NO live cockpit device (serial RIO /
|
||||
@@ -840,6 +936,15 @@ void
|
||||
extern float gBTElevAxis;
|
||||
stickPosition.y = gBTElevAxis;
|
||||
}
|
||||
// #124 twist-sign bench probe: is the axis routed, and in what mode?
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
{
|
||||
static int s_mp = 0;
|
||||
if ((s_mp++ % 60) == 0 && s_mp < 1200)
|
||||
DEBUG_STREAM << "[mppr] mode=" << (int)controlMode
|
||||
<< " twistAxis=" << gBTTwistAxis
|
||||
<< " keyTurn=" << key_turn << "\n" << std::flush;
|
||||
}
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
stickPosition.x = key_turn;
|
||||
@@ -862,21 +967,12 @@ void
|
||||
// centerCommand is a pod BUTTON state, so the writer clears
|
||||
// it while unpressed (Basic's own path re-asserts every
|
||||
// frame; this branch owns it in Standard/Veteran).
|
||||
{
|
||||
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
|
||||
if (rcTorso != 0)
|
||||
{
|
||||
if (gBTTorsoRecenter)
|
||||
{
|
||||
gBTTorsoRecenter = 0;
|
||||
rcTorso->CommandRecenter();
|
||||
}
|
||||
else
|
||||
{
|
||||
rcTorso->ClearRecenterCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
// (#152) the recenter writer moved OUT of this key-bridge
|
||||
// block to the unified consumer below -- inside here it was
|
||||
// DEAD on every RIO/glass rig (bridge off), which left those
|
||||
// players with no torso-centre control at all once the
|
||||
// stuck-cell phantom auto-recentre was fixed. Bench: two
|
||||
// scripted 0x42 holds on the RIO path, ctrCmd=0 throughout.
|
||||
}
|
||||
}
|
||||
// (stickPosition.y no longer zeroed here -- the bridge above
|
||||
@@ -1009,6 +1105,25 @@ void
|
||||
if (myomers > 0)
|
||||
{
|
||||
speedDemand *= drive; // @0x4a9d63: in-place demand scale
|
||||
// The binary keeps the chain MAX in mech+0x79C (@0x4a9d00-0x4a9d39)
|
||||
// -- publish it so the CROUCH gate (mech4 posture block, 2026-08-06)
|
||||
// reads the same live factor (dead/overheated myomers cannot squat
|
||||
// or rise; the posture selector tests |factor| <= 1e-4).
|
||||
mech->myomerEffectiveness = drive;
|
||||
// #137 POST-RESET TRACE. Mech::Reset arms gBTMyoTrace; sample here,
|
||||
// where the mover's actual multiplier is formed, for a few seconds
|
||||
// after a respawn. This is the window nothing was watching -- the
|
||||
// at-reset sample always looks clean.
|
||||
{
|
||||
extern int gBTMyoTrace;
|
||||
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
|
||||
if (gBTMyoTrace > 0)
|
||||
{
|
||||
--gBTMyoTrace;
|
||||
if ((gBTMyoTrace % 30) == 0)
|
||||
BTReportMyomerFreeze((void *)mech, "post-reset");
|
||||
}
|
||||
}
|
||||
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
|
||||
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
|
||||
}
|
||||
|
||||
@@ -287,6 +287,14 @@ void MechSubsystem::ForceCriticalFailure()
|
||||
{
|
||||
((DamageZone *)damageZone)->SetDamageZoneState(1); // zone+0x10
|
||||
}
|
||||
// (A MarkGeneratorOut call lived here 2026-08-03 for a few hours --
|
||||
// REMOVED same night when the alarm dig completed: the gauge-alarm
|
||||
// conditions are STATUS-FLAG BITS scanned by MechTech, not stateAlarm
|
||||
// levels. Generator death raises bit 6 (!HasVoltage) AUTHENTICALLY ->
|
||||
// the gotoEngineering + engBusMode invite; conditions 4/5 (engEject)
|
||||
// are the WEAPON's AmmoBurning/Jammed bits -- the ammo-purge invite,
|
||||
// never a pilot-eject one. stateAlarm 4's real producer remains
|
||||
// unfound (export holes) and nothing observable needs it.)
|
||||
}
|
||||
|
||||
// issue #22 (PORT): the fresh-mech respawn repair -- the exact inverse of the
|
||||
|
||||
@@ -341,6 +341,13 @@ class Damage;
|
||||
public:
|
||||
static Logical
|
||||
LookupStatusType(const char *name, int *out_value);
|
||||
|
||||
// EJECT-evaluator support (@0049fa1c bridges): the both-cells
|
||||
// destroyed test (gotcha #22 -- the binary's ONE cell @0x40 is split
|
||||
// into statusAlarm + simulationState here, so every cross-family
|
||||
// destroyed check must read BOTH).
|
||||
int SimulationStateCell() const { return simulationState; }
|
||||
int StatusAlarmLevel() { return statusAlarm.GetLevel(); }
|
||||
protected:
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -197,6 +197,15 @@ MechTech::MechTech(
|
||||
wooHooMinimumDuration = model->wooHooMinimumDuration; // res +0x34 -> this+0xF4
|
||||
wooHooDurationRange = model->wooHooDurationRange; // res +0x38 -> this+0xF8
|
||||
wooHooChance = model->wooHooChance; // res +0x3C -> this+0xFC
|
||||
// DIAG (#119 leak-voice dig): the AUTHORED anti-spam latch tuning.
|
||||
// No armer for the wooHoo latch exists anywhere in the flat export --
|
||||
// if the authored chance is 0 the latch was dormant in 4.10 too and
|
||||
// the repeating warning voice under multiple leaks is AUTHENTIC.
|
||||
if (getenv("BT_LAMP_LOG"))
|
||||
DEBUG_STREAM << "[techstat] wooHoo authored: minDur="
|
||||
<< wooHooMinimumDuration << " range=" << wooHooDurationRange
|
||||
<< " chance=" << wooHooChance
|
||||
<< " alarmModel=" << alarmModel << "\n" << std::flush;
|
||||
|
||||
//
|
||||
// Walk the owner's subsystem roster and attach a monitor to every
|
||||
|
||||
@@ -740,15 +740,12 @@ void
|
||||
target->Dispatch(&message); // no manager: direct (bring-up)
|
||||
}
|
||||
|
||||
// PORT bookkeeping (score lived in the retired mech4 fire block): credit
|
||||
// the delivered amount to the shooter-side score exactly as before.
|
||||
extern void BTPostDamageScore(Entity *victim, Scalar damage);
|
||||
extern Logical BTIsRegisteredMech(Entity *e);
|
||||
if (BTIsRegisteredMech(target)
|
||||
&& !((Mech *)target)->IsMechDestroyed())
|
||||
{
|
||||
BTPostDamageScore(target, damageData.damageAmount);
|
||||
}
|
||||
// SCORE: handled by the victim's own TakeDamage handler now -- the
|
||||
// dispatch above lands in the authentic report tail (#45), which posts
|
||||
// the kill/received reports itself. The shooter-side BTPostDamageScore
|
||||
// call that lived here was RETIRED 2026-08-05 (per-hit inflicted credit
|
||||
// never existed in the 1995 pod; kills + received-damage penalties only).
|
||||
extern Logical BTIsRegisteredMech(Entity *e); // still read by the matchlog below
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
|
||||
// submission -- pairs with the victim's DMG application line. mech=1
|
||||
@@ -994,3 +991,13 @@ void BTWeaponSetViewFireEnable(Subsystem *sub, int enable)
|
||||
if (sub != 0 && sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
((MechWeapon *)sub)->SetViewFireEnable(enable);
|
||||
}
|
||||
|
||||
// #124 aspect probe: a weapon's physical muzzle WORLD position (the pod's own
|
||||
// asymmetric geometry as a left/right anchor). -1-family returns 0.
|
||||
int BTWeaponMuzzleWorld(Subsystem *sub, Point3D *out)
|
||||
{
|
||||
if (sub == 0 || out == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
return 0;
|
||||
((MechWeapon *)sub)->MuzzlePoint(*out); // the public beam-render alias
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ class CockpitHud;
|
||||
int PipExtendedRange() const { return pipExtendedRange; }
|
||||
void *WithinRangePtr() { return &targetWithinRange; }
|
||||
void *WeaponAlarmPtr() { return &weaponAlarm; }
|
||||
int WeaponAlarmLevel() { return weaponAlarm.GetLevel(); } // @0x364 (eject evaluator: 7 = NoAmmo)
|
||||
void *SimulationStatePtr() { return &simulationState; } // attr 1 ("SimulationState" -- damage state; 1 = Destroyed)
|
||||
Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); LIVE on BOTH families
|
||||
// (projectile weapons write it via slot 17 @004b9c9c — Gitea #12)
|
||||
|
||||
@@ -20,9 +20,23 @@
|
||||
// excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx
|
||||
// Entity engine vtable slots
|
||||
//
|
||||
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0):
|
||||
// 0x40000000 = 2.0f 0x38d1b717 = 1.0e-4f 0x40800000 = 4.0f
|
||||
// 0x3dcccccd = 0.1f 0x41200000 = 10.0f 0xbf800000 = -1.0f
|
||||
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0).
|
||||
// ⚠ RE-DECODED 2026-08-11 (#168 audit, byte-read + full @4bef78 walk) -- the
|
||||
// original decode below misattributed most of these. Verified semantics:
|
||||
// _DAT_004bf594 = 2.0f replication KEEPALIVE: no update sent for 2.0s
|
||||
// -> updateModel(+0x18)|=1 (ForceUpdate), NOT a death
|
||||
// _DAT_004bf598 = 0.0f per-axis drag-coefficient sign pick (rel-vel vs wind)
|
||||
// _DAT_004bf59c/a0 = ∓1e-4 signed-square deadband in the DRAG term
|
||||
// _DAT_004bf5a4 = 4.0f PROXIMITY FUSE: seeker rangeToTarget(+0x10C) < 4.0
|
||||
// -> detonate on targetEntity without a geometry hit
|
||||
// _DAT_004bf5a8 = 0.1f dead-reckon divergence² (vs predictor FUN_00422060
|
||||
// pos +0x260) -> replication-dirty
|
||||
// _DAT_004bf5ac = 10.0f COAST MARGIN: expiry at age > burnTime(+0x340)+10.0s
|
||||
// _DAT_004bf5b0 = -1.0f kill-plane: pos.y < -1 -> FUN_0042061c retire
|
||||
// The steering/lifetime code below still reflects the OLD decode -- this TU is
|
||||
// DORMANT (nothing constructs Missile; the mech4.cpp pool is the live path) --
|
||||
// rework it against context/decomp-reference.md §5 "Projectile/Missile flight
|
||||
// & expiry" before ever wiring it live.
|
||||
//
|
||||
// Helper-function name mapping:
|
||||
// FUN_004be1bc Projectile base constructor (vtable @00512a5c)
|
||||
@@ -53,12 +67,14 @@
|
||||
//
|
||||
// MoveAndCollide tuning constants (read-only globals in the decomp).
|
||||
//
|
||||
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 age-ratio dead threshold
|
||||
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf598/9c/a0 steering deadband
|
||||
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4
|
||||
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8
|
||||
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac/a8 min squared-speed before fizzle
|
||||
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0
|
||||
// ⚠ names below carry the OLD (wrong) decode -- see the corrected table in the
|
||||
// header comment; kept only so the dormant skeleton still compiles unchanged.
|
||||
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 actually: replication keepalive (s)
|
||||
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf59c/a0 actually: drag signed-square deadband
|
||||
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4 actually: proximity-fuse radius (u)
|
||||
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8 actually: dead-reckon divergence²
|
||||
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac actually: coast margin (s) past burnTime
|
||||
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0 kill-plane y (this one was right)
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
|
||||
@@ -762,35 +762,36 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
// exists because the binary's per-tick value depends on the pod's frame
|
||||
// rate, which our port does not run at.
|
||||
//
|
||||
// THE CLOCK ORIGIN, traced 2026-08-02 (was the open question here):
|
||||
// the DOS binary keeps its engine frame rate in the global DAT_0052140c,
|
||||
// set once at startup --
|
||||
// 0x401ace: mov [0x52140c], 0x41E00000 = 28.0f (nominal)
|
||||
// 0x401ada: mov [0x52140c], 0x4191A6E0 = 18.2065f (BIOS-tick fallback)
|
||||
// -- and consumed by ~90 fmul/fdiv sites across the image as THE
|
||||
// per-frame<->per-second conversion. The DOS main passes it straight into
|
||||
// the ApplicationManager ctor (0x401189: push [0x52140c]). So the pod's
|
||||
// nominal Performance cadence was 28 Hz, byte-proven. [T1]
|
||||
// THE CLOCK -- CLOSED 2026-08-02, all [T1] from bytes:
|
||||
// * DAT_0052140c = SystemClock ticksPerSecond, 28.0 nominal (0x401ace) /
|
||||
// 18.2065 BIOS fallback (0x401ada).
|
||||
// * The arcade Simulation::PerformAndWatch is FUN_0041c018:
|
||||
// slice = (till.ticks - lastPerformance.ticks) / DAT_0052140c;
|
||||
// lastPerformance = till; (*performance)(this, slice);
|
||||
// -- VARIABLE-STEP, slice in real SECONDS, line-for-line the WinTesla
|
||||
// SIMULATE.cpp body. Every dt-carrying term is frame-rate independent
|
||||
// in BOTH the binary and the port; no unit conversion is missing.
|
||||
// * This term's ONE caller is the unexported MyomersSimulation body
|
||||
// (0x4b8b9a-0x4b8d0d, raw disasm 2026-08-02 -- the export gap hid it):
|
||||
// it calls 0x4b8d18 once per Perform with the RAW slice, gated only on
|
||||
// measuredVoltage > 0. So the kinetic energy pulse fires once per
|
||||
// FRAME, and the pod's frame cadence is its tick rate: 28 Hz when
|
||||
// holding the deadline, sagging under load (fewer pulses/s -- an
|
||||
// EMERGENT hardware mercy, not a constant in the code).
|
||||
//
|
||||
// CALIBRATION FACTOR 0.5 -- the pod's EFFECTIVE rate under load, now
|
||||
// corroborated from three directions (2026-08-02):
|
||||
// * Oracle (pod veteran), asked if the frame rate was rock solid:
|
||||
// "I think I could do as well with a flipbook"; heavy fights "would
|
||||
// turn to a slide show".
|
||||
// * Lynx (original-era): "RP had a target frame rate of 30FPS. It
|
||||
// wasn't hard coded or locked. BT never had a locked frame rate in
|
||||
// 1st release testing... coming from 3.0, where 20FPS was a miracle."
|
||||
// * The engine's own pacer DESIGN: end_of_frame = Now() + frameDuration
|
||||
// with background fill and NO overrun catch-up (APPMGR.cpp, T0) --
|
||||
// "target 28 but allowed to slip" is the construction, and the
|
||||
// 18.2065 BIOS fallback anticipated degraded timing.
|
||||
// Corollary: a choking 486 generated LESS heat/s exactly during the
|
||||
// biggest fights -- veteran memory of seek-4 endurance encodes the LOADED
|
||||
// rate, so bracketing by their consensus is the correct instrument.
|
||||
// Effective default 28 * 0.5 = 14 Hz [T2 multi-witness]; BT_MYO_HZ=<hz>
|
||||
// overrides absolutely for bracketing.
|
||||
// The faithful reference is therefore the binary's own nominal cadence,
|
||||
// 28 Hz, full stop. The old 0.5 "loaded-rate" calibration [T3 witness
|
||||
// bracket] is RETIRED: it modeled the sag as a constant, which is wrong in
|
||||
// exactly the reported scenario (open-field cruising is a LIGHT scene --
|
||||
// the pod held ~28 there). Measured at 28 Hz (cruise benches 2026-08-02):
|
||||
// recommended gear equilibrates ~1450 (soft 20% output governor inside the
|
||||
// authored degradation band, weapons unaffected, zero jams); supercharge
|
||||
// ~1770 (deep governor, the manual's own warning). That IS the authored
|
||||
// system: degradeT=1000 is a governor onset, failT=2000 the cliff, and the
|
||||
// pod's coolant-loop controls (flush/balance, Myomers on loop 5) are the
|
||||
// player's lever. BT_MYO_HZ=<hz> remains as the bracketing override.
|
||||
static const Scalar kPodFrameHz = 28.0f; // byte-proven nominal [T1]
|
||||
static const Scalar kSeekHeatCalib = 0.5f; // Oracle bracket 2026-08-02 [T3]
|
||||
static const Scalar kSeekHeatCalib = 1.0f; // faithful (0.5 bracket retired 2026-08-02)
|
||||
static Scalar s_hz = -1.0f;
|
||||
if (s_hz < 0.0f)
|
||||
{
|
||||
@@ -800,7 +801,7 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
DEBUG_STREAM << "[myoheat] kinetic-term reference rate: " << s_hz
|
||||
<< " Hz (pod nominal 28 [T1] x calib " << kSeekHeatCalib
|
||||
<< " [T3]" << (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
|
||||
<< (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
|
||||
}
|
||||
|
||||
Scalar termClimb = velComplement * vy * mass * gravity * time_slice;
|
||||
@@ -808,6 +809,31 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
|
||||
Scalar gain = ratio * ratio * damageGain;
|
||||
|
||||
// #137 DEPOSIT TRAP (BT_HEAT_LOG): conduction into the myomers is measured
|
||||
// ZERO post-respawn (the [heatflow] trap), weapons deposit into themselves,
|
||||
// so THIS add is the only writer left that can carry the observed one-shot
|
||||
// slug (9e7..2.3e9, varying per respawn). The [myoheat] receipt is
|
||||
// time-sampled and would miss a 1-2 frame spike; this prints EVERY add
|
||||
// over 1e6 with the full operand set, unconditionally.
|
||||
{
|
||||
Scalar deposit = gain * (termClimb + termKinetic + termAccel);
|
||||
if (getenv("BT_HEAT_LOG") != 0 && (deposit > 1.0e6f || deposit < -1.0e6f))
|
||||
{
|
||||
// WHOSE mech (master vs the peer's replicant shares this log!) and
|
||||
// the acceleration COMPONENTS (pure-y = gravity accumulation on the
|
||||
// wreck; planar = teleport/warp-derived).
|
||||
Mech *om = (Mech *)owner;
|
||||
const Vector3D &av = om->localAcceleration.linearMotion;
|
||||
DEBUG_STREAM << "[myodep] mech=" << om->GetEntityID()
|
||||
<< (om->GetInstance() == Entity::ReplicantInstance ? " REPL" : " mstr")
|
||||
<< " deposit=" << deposit
|
||||
<< " v=" << velMag << " a=" << accMag
|
||||
<< " aXYZ=(" << av.x << "," << av.y << "," << av.z << ")"
|
||||
<< " vy=" << vy << " dt=" << time_slice << " gain=" << gain
|
||||
<< " climb=" << termClimb << " kinetic=" << termKinetic
|
||||
<< " accel=" << termAccel << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel);
|
||||
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
|
||||
@@ -1295,6 +1295,49 @@ void
|
||||
void
|
||||
Generator::GeneratorSimulation(Scalar time_slice)
|
||||
{
|
||||
// DEV: BT_GEN_HEAT=<kJ/s> pumps heat into the VIEWPOINT mech's Generator A
|
||||
// for a bounded window (BT_GEN_HEAT_DELAY=<s> lead-in, default 10;
|
||||
// BT_GEN_HEAT_SECS=<s> injection length, default 20) -- the deterministic
|
||||
// thermal-trip bench for #154: drive T past FailureTemperature, stop, let
|
||||
// it cool through DegradationTemperature, and correlate the [gen]
|
||||
// TRIPPED/RESTARTING receipts against the [audioedge] receipts to observe
|
||||
// which stateAlarm edge actually starts the warning sequence.
|
||||
{
|
||||
static const char *s_ghEnv = getenv("BT_GEN_HEAT");
|
||||
if (s_ghEnv != 0)
|
||||
{
|
||||
static int s_ghSaid = 0;
|
||||
if (s_ghSaid < 8)
|
||||
{
|
||||
++s_ghSaid;
|
||||
DEBUG_STREAM << "[genheat] gate: gen#" << generatorNumber
|
||||
<< " owner=" << (void *)owner
|
||||
<< " viewpoint=" << (void *)application->GetViewpointEntity()
|
||||
<< " T=" << currentTemperature
|
||||
<< " mass=" << thermalMass << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (s_ghEnv != 0 && generatorNumber == 1
|
||||
&& (Entity *)owner == application->GetViewpointEntity())
|
||||
{
|
||||
static float s_ghClock = 0.0f;
|
||||
static float s_ghDelay = -1.0f, s_ghSecs = -1.0f;
|
||||
if (s_ghDelay < 0.0f)
|
||||
{
|
||||
const char *d = getenv("BT_GEN_HEAT_DELAY");
|
||||
const char *s = getenv("BT_GEN_HEAT_SECS");
|
||||
s_ghDelay = (d != 0 && *d != '\0') ? (float)atof(d) : 10.0f;
|
||||
s_ghSecs = (s != 0 && *s != '\0') ? (float)atof(s) : 20.0f;
|
||||
}
|
||||
s_ghClock += time_slice;
|
||||
if (s_ghClock >= s_ghDelay && s_ghClock < s_ghDelay + s_ghSecs)
|
||||
{
|
||||
heatEnergy += (Scalar)atof(s_ghEnv) * time_slice;
|
||||
currentTemperature = heatEnergy / thermalMass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HeatSink::HeatSinkSimulation(time_slice); // FUN_004ad924
|
||||
|
||||
if (simulationState == 1) // this[0x10] @0x40
|
||||
@@ -1305,10 +1348,10 @@ void
|
||||
{
|
||||
// The THERMAL BREAKER: output collapses to 0 until the generator cools
|
||||
// past NormalHeat and re-spins (task #11 diag below shows the duty cycle).
|
||||
if (stateAlarm.GetLevel() != GeneratorRecovered && getenv("BT_HEAT_LOG"))
|
||||
if (stateAlarm.GetLevel() != GeneratorOut && getenv("BT_HEAT_LOG"))
|
||||
DEBUG_STREAM << "[gen] " << GetName() << " TRIPPED (T="
|
||||
<< currentTemperature << ")" << std::endl;
|
||||
stateAlarm.SetLevel(GeneratorRecovered); // SetLevel 4
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
|
||||
}
|
||||
|
||||
switch (stateAlarm.GetLevel()) // this[0x84] @0x210
|
||||
@@ -1337,7 +1380,7 @@ void
|
||||
}
|
||||
break;
|
||||
|
||||
case GeneratorRecovered: // 4
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
@@ -1778,3 +1821,41 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
|
||||
{
|
||||
return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0;
|
||||
}
|
||||
|
||||
// #135 bridge: the binary's generator lamp special @004cc27c is
|
||||
// `table_0051d070[*(int*)(sub+0x1E0)]` -- indexed by generatorNumber (A=1..D=4),
|
||||
// the same shape as the condenser special beside it. Expose the number so the
|
||||
// alarm-lamp TU can do the authentic lookup (it had been probing the
|
||||
// PoweredSubsystem aux-screen fields, which generators never fill -- the
|
||||
// field-logged 'NO LAMP RESOLVED (generator=1)' silent leak).
|
||||
int BTGeneratorNumber(::Subsystem *sub)
|
||||
{
|
||||
return BTSubsystemIsGenerator(sub) ? ((Generator *)sub)->generatorNumber : -1;
|
||||
}
|
||||
|
||||
// Destruction -> OUT display state (see Generator::MarkGeneratorOut, the [T3
|
||||
// inferred link]): a destroyed generator must reach stateAlarm 4 or the
|
||||
// authored gotoEngineering/engEject FLASH invite (alarm conditions 4/5) never
|
||||
// fires and the eject evaluator's state clause never sees it. Called from
|
||||
// MechSubsystem::ForceCriticalFailure (the one crit-death chokepoint).
|
||||
void BTGeneratorMarkOut(::Subsystem *sub)
|
||||
{
|
||||
if (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations()))
|
||||
((Generator *)sub)->MarkGeneratorOut();
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTGeneratorCountsForEject -- complete-type bridge for the EJECT-permission
|
||||
// evaluator (Mech::EvaluateEjectPermission @0049fa1c). Binary clause: classID
|
||||
// 0xBC1, not destroyed (+0x40 != 1; both-cells here per gotcha #22), and
|
||||
// stateAlarm level (@0x210) != 4. Returns -1 = not a generator, else 0/1.
|
||||
//===========================================================================//
|
||||
int BTGeneratorCountsForEject(Subsystem *sub)
|
||||
{
|
||||
if (sub == 0 || sub->GetClassID() != RegisteredClass::GeneratorClassID) // +4 == 0xbc1
|
||||
return -1;
|
||||
Generator *g = (Generator *)sub;
|
||||
if (g->SimulationStateCell() == 1 || g->StatusAlarmLevel() == 1)
|
||||
return 0; // destroyed
|
||||
return (g->GeneratorStateOf() != 4) ? 1 : 0; // stateAlarm @0x210 != 4
|
||||
}
|
||||
|
||||
@@ -379,7 +379,12 @@ class Generator;
|
||||
GeneratorIdle = 1, // off / no output
|
||||
GeneratorReady = 2, // producing RatedVoltage
|
||||
GeneratorShorted = 3, // shorted; shortTimer -> shortRecoveryTime
|
||||
GeneratorRecovered = 4 // TODO: confirm 4th/5th level names
|
||||
GeneratorOut = 4 // generator DEAD/OUT: the eject evaluator counts
|
||||
// stateAlarm==4 as not-live [T1 @0049fa1c], and the
|
||||
// authored gauge-alarm streams flash gotoEngineering +
|
||||
// engEject on conditions 4/5 [T1 content dump
|
||||
// 2026-08-03] -- the "punch out now" invite.
|
||||
// (Old name "GeneratorRecovered" was a placeholder.)
|
||||
};
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -462,6 +467,20 @@ class Generator;
|
||||
// Source-state queries used by PoweredSubsystem / PowerWatcher.
|
||||
int
|
||||
GeneratorStateOf() { return stateAlarm.GetLevel(); } // source+0x210
|
||||
|
||||
// UNUSED -- and now PROVEN unnecessary (2026-08-04 dig): stateAlarm 4
|
||||
// (GeneratorOut) is the THERMAL BREAKER state, produced by
|
||||
// GeneratorSimulation itself (heatAlarm==FailureHeat -> SetLevel 4,
|
||||
// byte-matched vs FUN_004b1f7c) -- never by destruction. The gauge
|
||||
// alarms scan STATUS-FLAG BITS via MechTech (bit 6 = !HasVoltage);
|
||||
// a generator destroyed IN PLACE keeps its stale Ready voltage in
|
||||
// the BINARY TOO (the crit distributor @0049c9a8 touches nothing
|
||||
// electrical) until any transition recomputes output through the
|
||||
// (1 - damage) formula. A short-lived ForceCriticalFailure bridge
|
||||
// to this method was removed as unfounded; keep the method only as
|
||||
// documentation of the state's meaning.
|
||||
void
|
||||
MarkGeneratorOut() { stateAlarm.SetLevel(GeneratorOut); }
|
||||
Scalar
|
||||
MeasuredVoltage() { return outputVoltage; } // source+0x1dc
|
||||
Scalar
|
||||
|
||||
@@ -268,6 +268,17 @@ void *
|
||||
? (void *)((ProjectileWeapon *)weapon)->ammoBinLink.Resolve() : 0;
|
||||
}
|
||||
|
||||
// Eject-wipe gauge bridge (#118): the BallisticWeaponCluster's
|
||||
// BitMapInverseWipeScalar watches the weapon's PercentOfEject (@0x3F8, the
|
||||
// binary passes param_8+0x3f8 straight into the @004c61c8 ctor). Complete-
|
||||
// type TU per the databinding rule -- never raw-offset a compiled object.
|
||||
Scalar *
|
||||
BTWeaponPercentOfEjectPtr(void *weapon)
|
||||
{
|
||||
return (weapon != 0)
|
||||
? &((ProjectileWeapon *)weapon)->percentOfEject : 0;
|
||||
}
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// Construction / Destruction
|
||||
@@ -568,6 +579,18 @@ void
|
||||
|
||||
AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c)
|
||||
|
||||
// #166 forensics (ALWAYS on -- ejects are rare): every entry, with the
|
||||
// state that decides which branch runs. Oracle's field jam (bin parked
|
||||
// at Ejecting(3), rounds aboard, countdown dead) can only come from a
|
||||
// press/release sequence this line will capture -- the prime suspect is
|
||||
// a press whose bin-resolve fails falling through to the release block.
|
||||
DEBUG_STREAM << "[weap] " << GetName() << " EJECT msg data=" << message->dataContents
|
||||
<< " bin=" << (void *)bin
|
||||
<< " ejectState=" << ejectState
|
||||
<< " binState=" << (bin != 0 ? bin->GetAmmoState() : -1)
|
||||
<< " rounds=" << (bin != 0 ? bin->GetAmmoCount() : -1)
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if (message->dataContents > 0 && bin != 0) // PRESS @4bb9d0/4bb9e9
|
||||
{
|
||||
bin->SetAmmoState(AmmoBin::Ejecting); // SetLevel(bin+0x194, 3)
|
||||
@@ -1242,3 +1265,25 @@ Subsystem *CreateProjectileWeaponSubsystem(Mech *owner, int id, void *seg)
|
||||
return (Subsystem *) new (Memory::Allocate(0x448))
|
||||
ProjectileWeapon(owner, id, (ProjectileWeapon::SubsystemResource *)seg, ProjectileWeapon::DefaultData);
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTWeaponCountsForEject -- complete-type bridge for the EJECT-permission
|
||||
// evaluator (Mech::EvaluateEjectPermission, @0049fa1c; issue: eject/panic).
|
||||
// The binary's weapon clause: MechWeapon-derived (0x511830), not destroyed,
|
||||
// and -- for the ProjectileWeapon family only (0x5121a8) -- weaponAlarm
|
||||
// (@0x364) != 7 (NoAmmo): a dry ballistic rack is not a fighting weapon.
|
||||
// Destroyed is the BOTH-CELLS test (gotcha #22: statusAlarm level + the
|
||||
// port's split simulationState). Returns -1 = not a weapon, else 0/1.
|
||||
//===========================================================================//
|
||||
int BTWeaponCountsForEject(Subsystem *sub)
|
||||
{
|
||||
if (sub == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
return -1;
|
||||
MechWeapon *w = (MechWeapon *)sub;
|
||||
if (w->SimulationStateCell() == 1 || w->StatusAlarmLevel() == 1)
|
||||
return 0; // destroyed (both cells)
|
||||
if (sub->IsDerivedFrom(ProjectileWeapon::ClassDerivations)
|
||||
&& ((ProjectileWeapon *)sub)->WeaponAlarmLevel() == 7)
|
||||
return 0; // NoAmmo (@0x364 == 7)
|
||||
return 1;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user