Compare commits
281
Commits
+18
@@ -43,3 +43,21 @@ matchlog_*.txt
|
||||
|
||||
# Operator control-channel secret -- NEVER commit (plaintext shared credential)
|
||||
operator_secret.txt
|
||||
|
||||
# Player field logs: contain Steam IDs, machine names and usernames -- never commit.
|
||||
# (bench .sh/.py under scratchpad/night*/ stay committable.)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +54,7 @@ precise than anything you can infer.
|
||||
3. **RULE: no stand-ins.** The full logic IS in the pseudocode; a "gap" is an unfilled stub, not a
|
||||
hole. Never write placeholder logic — read the decomp. Bring-up scaffolding is marked + temporary.
|
||||
4. **Verify honestly.** `static_assert`-lock layouts; run env-gated; read `content\<stem>_YYYYMMDD.log`; cdb on crashes.
|
||||
**Verify against the FIELD COMPOSITION, not a constructed proxy** — benches + the verified bar: `context/test-harness.md`.
|
||||
Tag claims with the evidence tier; flag T3/T4.
|
||||
5. **Persist insights.** A genuinely new finding → add it to the right `context/*.md` (+ the `docs/`
|
||||
ledger for detail), with an evidence tier. Keep the knowledge base current — this is a mandate,
|
||||
@@ -58,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 |
|
||||
@@ -65,6 +73,7 @@ precise than anything you can infer.
|
||||
| BGF geometry, LODs, CONN/PCONN, ramps | `context/bgf-format.md` |
|
||||
| A layout/linkage/databinding BUG | `context/reconstruction-gotchas.md` |
|
||||
| The reconstruction method / workflow | `context/reconstruction-method.md` |
|
||||
| **Running a BENCH / verifying a fix** — harness contract, 2-node pattern, what counts as VERIFIED | `context/test-harness.md` |
|
||||
| Walking, gait, ground model, collision | `context/locomotion.md` |
|
||||
| Subsystems, the factory, heat/weapons/power | `context/subsystems.md` |
|
||||
| Damage zones, targeting, firing, death | `context/combat-damage.md` |
|
||||
@@ -76,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` |
|
||||
@@ -85,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` |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -148,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
|
||||
{
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Red
|
||||
color=Crimson
|
||||
[largebitmap]
|
||||
bitmap=BitMap::Large::Aeolus
|
||||
[BitMap::Large::Aeolus]
|
||||
|
||||
+64
-246
@@ -1,8 +1,8 @@
|
||||
[mission]
|
||||
adventure=BattleTech
|
||||
map=arena1
|
||||
map=cavern
|
||||
scenario=freeforall
|
||||
time=day
|
||||
time=night
|
||||
weather=clear
|
||||
temperature=27
|
||||
length=600
|
||||
@@ -156,206 +156,95 @@ x=128
|
||||
y=32
|
||||
width=8
|
||||
[pilots]
|
||||
pilot=10.99.0.1:1502
|
||||
pilot=10.99.0.2:1502
|
||||
pilot=10.99.0.3:1502
|
||||
pilot=10.99.0.4:1502
|
||||
[10.99.0.1:1502]
|
||||
pilot=127.0.0.1:1502
|
||||
pilot=127.0.0.1:1602
|
||||
pilot=127.0.0.1:1702
|
||||
pilot=127.0.0.1:1802
|
||||
[127.0.0.1:1502]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=ALPHA
|
||||
name=Aeolus
|
||||
bitmapindex=1
|
||||
experience=expert
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=madcat
|
||||
vehicle=bhk1
|
||||
vehicleValue=1000
|
||||
color=White
|
||||
[10.99.0.2:1502]
|
||||
[127.0.0.1:1602]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=BRAVO
|
||||
name=Boreas
|
||||
bitmapindex=2
|
||||
experience=expert
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=thor
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Crimson
|
||||
[10.99.0.3:1502]
|
||||
[127.0.0.1:1702]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=CHARLIE
|
||||
name=Caicias
|
||||
bitmapindex=3
|
||||
experience=expert
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=vulture
|
||||
vehicle=bhk1
|
||||
vehicleValue=1000
|
||||
color=Grey
|
||||
[127.0.0.1:1802]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Deimos
|
||||
bitmapindex=4
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Green
|
||||
[10.99.0.4:1502]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=DELTA
|
||||
bitmapindex=4
|
||||
experience=expert
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=avatar
|
||||
vehicleValue=1000
|
||||
color=Tan
|
||||
[largebitmap]
|
||||
bitmap=BitMap::Large::ALPHA
|
||||
bitmap=BitMap::Large::BRAVO
|
||||
bitmap=BitMap::Large::CHARLIE
|
||||
bitmap=BitMap::Large::DELTA
|
||||
[BitMap::Large::ALPHA]
|
||||
bitmap=BitMap::Large::Aeolus
|
||||
[BitMap::Large::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000001F803C001FFE07807807E000000
|
||||
bitmap=000001FC03C001FFF07807807F000000
|
||||
bitmap=000001FC03C001FFF87807807F000000
|
||||
bitmap=000003FC03C001FFFC780780FF000000
|
||||
bitmap=000003DE03C001E07C780780F7800000
|
||||
bitmap=000003DE03C001E03C780780F7800000
|
||||
bitmap=000007DE03C001E03C780781F7800000
|
||||
bitmap=0000078F03C001E07C780781E3C00000
|
||||
bitmap=0000078F03C001FFFC7FFF81E3C00000
|
||||
bitmap=00000F8F03C001FFF87FFF83E3C00000
|
||||
bitmap=00000F0783C001FFF07FFF83C1E00000
|
||||
bitmap=00000F0783C001FFC07FFF83C1E00000
|
||||
bitmap=00001FFF83C001E000780787FFE00000
|
||||
bitmap=00001FFFC3C001E000780787FFF00000
|
||||
bitmap=00001FFFC3C001E000780787FFF00000
|
||||
bitmap=00003E03C3C001E00078078F80F00000
|
||||
bitmap=00003C01E3FFF1E00078078F00780000
|
||||
bitmap=00003C01E3FFF1E00078078F00780000
|
||||
bitmap=00007C01E3FFF1E00078079F00780000
|
||||
bitmap=00007800F3FFF1E00078079E003C0000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[BitMap::Large::BRAVO]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00003FFC07FFC007E0F001E03F800000
|
||||
bitmap=00003FFE07FFE007F07803C0FFE00000
|
||||
bitmap=00003FFF07FFF007F07803C1FFF00000
|
||||
bitmap=00003FFF87FFF80FF07803C3FFF80000
|
||||
bitmap=00003C0F8780F80F783C0787E0FC0000
|
||||
bitmap=00003C078780780F783C0787803C0000
|
||||
bitmap=00003C078780781F783C078F803E0000
|
||||
bitmap=00003C0F0780F81E3C1E0F0F001E0000
|
||||
bitmap=00003FFF07FFF01E3C1E0F0F001E0000
|
||||
bitmap=00003FFE07FFF03E3C1E0F0F001E0000
|
||||
bitmap=00003FFF07FFE03C1E0E0E0F001E0000
|
||||
bitmap=00003FFF87FF803C1E0F1E0F001E0000
|
||||
bitmap=00003C07C787C07FFE0F1E0F001E0000
|
||||
bitmap=00003C03C783E07FFF071C0F803E0000
|
||||
bitmap=00003C03C781E07FFF07BC07C07C0000
|
||||
bitmap=00003C07C781F0F80F07BC07E0FC0000
|
||||
bitmap=00003FFFC780F8F00783B803FFF80000
|
||||
bitmap=00003FFF878078F00783F801FFF00000
|
||||
bitmap=00003FFF07807DF00781F000FFE00000
|
||||
bitmap=00003FFE07803FE003C1F0003F800000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[BitMap::Large::CHARLIE]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=007F00F00F00FC01FFF01E000F0FFFF0
|
||||
bitmap=01FFC0F00F00FE01FFF81E000F0FFFF0
|
||||
bitmap=03FFE0F00F00FE01FFFC1E000F0FFFF0
|
||||
bitmap=07FFF0F00F01FE01FFFE1E000F0FFFF0
|
||||
bitmap=0FC1F0F00F01EF01E03E1E000F0F0000
|
||||
bitmap=0F00F8F00F01EF01E01E1E000F0F0000
|
||||
bitmap=1F0060F00F03EF01E01E1E000F0F0000
|
||||
bitmap=1E0000F00F03C781E03E1E000F0F0000
|
||||
bitmap=1E0000FFFF03C781FFFC1E000F0FFFE0
|
||||
bitmap=1E0000FFFF07C781FFFC1E000F0FFFE0
|
||||
bitmap=1E0000FFFF0783C1FFF81E000F0FFFE0
|
||||
bitmap=1E0000FFFF0783C1FFE01E000F0FFFE0
|
||||
bitmap=1E0060F00F0FFFC1E1F01E000F0F0000
|
||||
bitmap=1F00F8F00F0FFFE1E0F81E000F0F0000
|
||||
bitmap=0F00F8F00F0FFFE1E0781E000F0F0000
|
||||
bitmap=0FC3F0F00F1F01E1E07C1E000F0F0000
|
||||
bitmap=07FFF0F00F1E00F1E03E1FFF8F0FFFF0
|
||||
bitmap=03FFE0F00F1E00F1E01E1FFF8F0FFFF0
|
||||
bitmap=01FFC0F00F3E00F1E01F1FFF8F0FFFF0
|
||||
bitmap=007F00F00F3C0079E00F9FFF8F0FFFF0
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[BitMap::Large::DELTA]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000007FF807FFF8F003FFFC1F8000000
|
||||
bitmap=000007FFE07FFF8F003FFFC1FC000000
|
||||
bitmap=000007FFF07FFF8F003FFFC1FC000000
|
||||
bitmap=000007FFF07FFF8F003FFFC3FC000000
|
||||
bitmap=00000780F878000F0000F003DE000000
|
||||
bitmap=000007807878000F0000F003DE000000
|
||||
bitmap=000007807C78000F0000F007DE000000
|
||||
bitmap=000007803C78000F0000F0078F000000
|
||||
bitmap=000007803C7FFF0F0000F0078F000000
|
||||
bitmap=000007803C7FFF0F0000F00F8F000000
|
||||
bitmap=000007803C7FFF0F0000F00F07800000
|
||||
bitmap=000007803C7FFF0F0000F00F07800000
|
||||
bitmap=000007803C78000F0000F01FFF800000
|
||||
bitmap=000007807C78000F0000F01FFFC00000
|
||||
bitmap=000007807878000F0000F01FFFC00000
|
||||
bitmap=00000780F878000F0000F03E03C00000
|
||||
bitmap=000007FFF07FFF8FFFC0F03C01E00000
|
||||
bitmap=000007FFF07FFF8FFFC0F03C01E00000
|
||||
bitmap=000007FFE07FFF8FFFC0F07C01E00000
|
||||
bitmap=000007FF807FFF8FFFC0F07800F00000
|
||||
bitmap=000003FE0000000001E0000000000000
|
||||
bitmap=000007FF0000000001E0000000000000
|
||||
bitmap=00000F8F8000000001E0000000000000
|
||||
bitmap=00000F078000000001E0000000000000
|
||||
bitmap=00000F0781FF00FF81E0F0781FC00000
|
||||
bitmap=00000F0783FF81FFC1E0F0783FE00000
|
||||
bitmap=00000F0787C7C3E3E1E0F0783C700000
|
||||
bitmap=00000F078783C3C1E1E0F07878300000
|
||||
bitmap=00000F078783C3C1E1E0F07878000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787C000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787F000000
|
||||
bitmap=00000F0787FFC3C1E1E0F0783FC00000
|
||||
bitmap=00000F0787FFC3C1E1E0F0781FE00000
|
||||
bitmap=00000F07878003C1E1E0F07807F00000
|
||||
bitmap=00000F07878003C1E1E0F07801F00000
|
||||
bitmap=00000F07878003C1E1E0F07800F00000
|
||||
bitmap=00000F078780C3C1E1E0F07860F00000
|
||||
bitmap=00000F0787C1C3E3E1E078F871E00000
|
||||
bitmap=00000F0783FF81FFC1E07FF83FE00000
|
||||
bitmap=00000F0781FF00FF81E03F781FC00000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
@@ -366,87 +255,16 @@ x=128
|
||||
y=32
|
||||
width=8
|
||||
[smallbitmap]
|
||||
bitmap=BitMap::Small::ALPHA
|
||||
bitmap=BitMap::Small::BRAVO
|
||||
bitmap=BitMap::Small::CHARLIE
|
||||
bitmap=BitMap::Small::DELTA
|
||||
[BitMap::Small::ALPHA]
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=00038C1F98C70000
|
||||
bitmap=00038C18D8C70000
|
||||
bitmap=0006CC18D8CD8000
|
||||
bitmap=0006CC18D8CD8000
|
||||
bitmap=0006CC18DFCD8000
|
||||
bitmap=0006CC1F98CD8000
|
||||
bitmap=000FEC1818DFC000
|
||||
bitmap=000C6C1818D8C000
|
||||
bitmap=000C6FD818D8C000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
[BitMap::Small::BRAVO]
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=000FCFC3B0678000
|
||||
bitmap=000C6C6398CCC000
|
||||
bitmap=000C6C66D8D86000
|
||||
bitmap=000C6C66D8D86000
|
||||
bitmap=000FCFC6CD986000
|
||||
bitmap=000C6CC6CD986000
|
||||
bitmap=000C6C6FED986000
|
||||
bitmap=000C6C6C670CC000
|
||||
bitmap=000FCC3C67078000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
[BitMap::Small::CHARLIE]
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=00798C71F9837E00
|
||||
bitmap=00CD8C718D836000
|
||||
bitmap=01818CD98D836000
|
||||
bitmap=01818CD98D836000
|
||||
bitmap=0181FCD9F9837E00
|
||||
bitmap=01818CD999836000
|
||||
bitmap=01818DFD8D836000
|
||||
bitmap=00CD8D8D8D836000
|
||||
bitmap=00798D8D87FB7E00
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
[BitMap::Small::DELTA]
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0003E3F61F9C0000
|
||||
bitmap=00033306061C0000
|
||||
bitmap=00031B0606360000
|
||||
bitmap=00031B0606360000
|
||||
bitmap=00031BF606360000
|
||||
bitmap=00031B0606360000
|
||||
bitmap=00031B06067F0000
|
||||
bitmap=0003330606630000
|
||||
bitmap=0003E3F7E6630000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=0000000000000000
|
||||
bitmap=BitMap::Small::Aeolus
|
||||
[BitMap::Small::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=0000000000000000003E00000C000000
|
||||
bitmap=006300000C00000000631F0F8CC63C00
|
||||
bitmap=00633198CCC66200007F3198CCC66000
|
||||
bitmap=00633F98CCC6780000633018CCC61E00
|
||||
bitmap=00633018CCC6060000633098CCC64600
|
||||
bitmap=00631F0F8C7E3C000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
[mission]
|
||||
adventure=BattleTech
|
||||
map=grass
|
||||
scenario=freeforall
|
||||
time=night
|
||||
weather=clear
|
||||
temperature=27
|
||||
length=600
|
||||
[ordinals]
|
||||
bitmap=Ordinal::BitMap::1
|
||||
bitmap=Ordinal::BitMap::2
|
||||
bitmap=Ordinal::BitMap::3
|
||||
bitmap=Ordinal::BitMap::4
|
||||
[Ordinal::BitMap::1]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000038000003C000001FF00000000F00
|
||||
bitmap=0000F8000003C000003FF80000000F00
|
||||
bitmap=0001F8000003C00000707C0000000F00
|
||||
bitmap=0001F8000003C00000603C0000000F00
|
||||
bitmap=00007801FC0FF00000003C3DF807FF00
|
||||
bitmap=00007803FE0FF00000003C3FFC0FFF00
|
||||
bitmap=00007803C703C00000003C3E3C1F0F00
|
||||
bitmap=000078078303C0000000783C1E1E0F00
|
||||
bitmap=000078078003C0000000783C1E1E0F00
|
||||
bitmap=00007807C003C0000000F03C1E1E0F00
|
||||
bitmap=00007807F003C0000001E03C1E1E0F00
|
||||
bitmap=00007803FC03C0000003C03C1E1E0F00
|
||||
bitmap=00007801FE03C0000007803C1E1E0F00
|
||||
bitmap=000078007F03C000000F003C1E1E0F00
|
||||
bitmap=000078001F03C000001E003C1E1E0F00
|
||||
bitmap=000078000F03C000003C003C1E1E0F00
|
||||
bitmap=000078060F03C0000078003C1E1E0F00
|
||||
bitmap=000078071E03E0000078003C1E1F1F00
|
||||
bitmap=00007803FE01F000007FFC3C1E0FFF00
|
||||
bitmap=00007801FC00F000007FFC3C1E07EF00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::2]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF0000003C0000003C03C0780000
|
||||
bitmap=001FFF0000003C0000007C03C0780000
|
||||
bitmap=00000E0000003C000000FC03C0780000
|
||||
bitmap=00003C0000003C000001BC03C0780000
|
||||
bitmap=0000700F3E1FFC000003BC0FF07BF000
|
||||
bitmap=0001E00F7E3FFC0000073C0FF07FF800
|
||||
bitmap=0003800FFE7C3C0000063C03C07C7800
|
||||
bitmap=0007F80FFE783C00000C3C03C0783C00
|
||||
bitmap=0007FE0F80783C0000183C03C0783C00
|
||||
bitmap=00001E0F00783C0000383C03C0783C00
|
||||
bitmap=00000F0F00783C0000703C03C0783C00
|
||||
bitmap=00000F0F00783C00007FFF03C0783C00
|
||||
bitmap=00000F0F00783C00007FFF03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00000F0F00783C0000003C03C0783C00
|
||||
bitmap=00180F0F00783C0000003C03C0783C00
|
||||
bitmap=001C1F0F007C7C0000003C03E0783C00
|
||||
bitmap=000FFE0F003FFC0000003C01F0783C00
|
||||
bitmap=0007FC0F001FBC0000003C00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::3]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF03C07800000000FC03C0780000
|
||||
bitmap=001FFF03C07800000003FC03C0780000
|
||||
bitmap=001E0003C07800000007C003C0780000
|
||||
bitmap=001E0003C0780000000F0003C0780000
|
||||
bitmap=001E000FF07BF000000F000FF07BF000
|
||||
bitmap=001E000FF07FF800001E000FF07FF800
|
||||
bitmap=001E0003C07C7800001E0003C07C7800
|
||||
bitmap=001FFC03C0783C00001EFC03C0783C00
|
||||
bitmap=001FFE03C0783C00001FFE03C0783C00
|
||||
bitmap=00001F03C0783C00001F1F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00000F03C0783C00001E0F03C0783C00
|
||||
bitmap=00180F03C0783C00001E0F03C0783C00
|
||||
bitmap=001C1F03E0783C00001F1F03E0783C00
|
||||
bitmap=000FFE01F0783C00000FFE01F0783C00
|
||||
bitmap=0007FC00F0783C000007FC00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[Ordinal::BitMap::4]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=001FFF03C07800000007FC03C0780000
|
||||
bitmap=001FFF03C0780000000FFE03C0780000
|
||||
bitmap=00000F03C0780000001F1F03C0780000
|
||||
bitmap=00000F03C0780000001E0F03C0780000
|
||||
bitmap=00000F0FF07BF000001E0F0FF07BF000
|
||||
bitmap=00001F0FF07FF800001E0F0FF07FF800
|
||||
bitmap=00001E03C07C7800001E0F03C07C7800
|
||||
bitmap=00003E03C0783C00001E0F03C0783C00
|
||||
bitmap=00003C03C0783C00000F1E03C0783C00
|
||||
bitmap=00003C03C0783C000007FC03C0783C00
|
||||
bitmap=00007803C0783C000007FC03C0783C00
|
||||
bitmap=00007803C0783C00000F1E03C0783C00
|
||||
bitmap=00007803C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003C0783C00001E0F03C0783C00
|
||||
bitmap=0000F003E0783C00000F1E03E0783C00
|
||||
bitmap=0000F001F0783C00000FFE01F0783C00
|
||||
bitmap=0000F000F0783C000007FC00F0783C00
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[pilots]
|
||||
pilot=127.0.0.1:1502
|
||||
pilot=127.0.0.1:1602
|
||||
pilot=127.0.0.1:1702
|
||||
pilot=127.0.0.1:1802
|
||||
[127.0.0.1:1502]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Aeolus
|
||||
bitmapindex=1
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=bhk1
|
||||
vehicleValue=1000
|
||||
color=White
|
||||
[127.0.0.1:1602]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Boreas
|
||||
bitmapindex=2
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Crimson
|
||||
[127.0.0.1:1702]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Caicias
|
||||
bitmapindex=3
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=bhk1
|
||||
vehicleValue=1000
|
||||
color=Grey
|
||||
[127.0.0.1:1802]
|
||||
hostType=0
|
||||
advancedDamage=1
|
||||
loadzones=1
|
||||
name=Deimos
|
||||
bitmapindex=4
|
||||
experience=novice
|
||||
badge=VGL
|
||||
patch=Yellow
|
||||
role=Role::Default
|
||||
dropzone=one
|
||||
vehicle=ava1
|
||||
vehicleValue=1000
|
||||
color=Green
|
||||
[largebitmap]
|
||||
bitmap=BitMap::Large::Aeolus
|
||||
[BitMap::Large::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=000003FE0000000001E0000000000000
|
||||
bitmap=000007FF0000000001E0000000000000
|
||||
bitmap=00000F8F8000000001E0000000000000
|
||||
bitmap=00000F078000000001E0000000000000
|
||||
bitmap=00000F0781FF00FF81E0F0781FC00000
|
||||
bitmap=00000F0783FF81FFC1E0F0783FE00000
|
||||
bitmap=00000F0787C7C3E3E1E0F0783C700000
|
||||
bitmap=00000F078783C3C1E1E0F07878300000
|
||||
bitmap=00000F078783C3C1E1E0F07878000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787C000000
|
||||
bitmap=00000FFF8783C3C1E1E0F0787F000000
|
||||
bitmap=00000F0787FFC3C1E1E0F0783FC00000
|
||||
bitmap=00000F0787FFC3C1E1E0F0781FE00000
|
||||
bitmap=00000F07878003C1E1E0F07807F00000
|
||||
bitmap=00000F07878003C1E1E0F07801F00000
|
||||
bitmap=00000F07878003C1E1E0F07800F00000
|
||||
bitmap=00000F078780C3C1E1E0F07860F00000
|
||||
bitmap=00000F0787C1C3E3E1E078F871E00000
|
||||
bitmap=00000F0783FF81FFC1E07FF83FE00000
|
||||
bitmap=00000F0781FF00FF81E03F781FC00000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=128
|
||||
y=32
|
||||
width=8
|
||||
[smallbitmap]
|
||||
bitmap=BitMap::Small::Aeolus
|
||||
[BitMap::Small::Aeolus]
|
||||
bitmap=00000000000000000000000000000000
|
||||
bitmap=0000000000000000003E00000C000000
|
||||
bitmap=006300000C00000000631F0F8CC63C00
|
||||
bitmap=00633198CCC66200007F3198CCC66000
|
||||
bitmap=00633F98CCC6780000633018CCC61E00
|
||||
bitmap=00633018CCC6060000633098CCC64600
|
||||
bitmap=00631F0F8C7E3C000000000000000000
|
||||
bitmap=00000000000000000000000000000000
|
||||
x=64
|
||||
y=16
|
||||
width=4
|
||||
[Role::Default]
|
||||
model=dfltrole
|
||||
[Role::NoReturn]
|
||||
model=noretun
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -48,7 +48,11 @@ spec is `docs/ASSET_PIPELINE.md`. Full list: `docs/PROGRESS_LOG.md §5`.
|
||||
## Collision / damage / subsystems
|
||||
- **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes;
|
||||
see [[locomotion]] ground model + [[combat-damage]]. [T1]
|
||||
- **`.DZM`** = INI damage-zone→material map (mech skin damage states). **`.DMG`/`.TBL`** damage
|
||||
- **`.DZM`** = INI damage-zone→material map (mech skin damage states) — `[dz_<zone>]` sections each
|
||||
listing `material=<lib>:<name>_mtl`. One file per SKIN VARIANT (`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/
|
||||
`dzmb`/`dzmc`/`dzmd`). This is the ARMOUR-DARKENING registry: the renderer lerps every listed
|
||||
material toward 0.1× as that zone's `damageLevel` runs 0→1. Loaded by MUNGA's `DamageZone` stream
|
||||
ctor into `materialTable` (`GetMaterialList(skl_type)`); full chain in [[rendering]]. [T1] **`.DMG`/`.TBL`** damage
|
||||
zones/tables, **`.SUB`** subsystems (weapon params — DischargeTime/RechargeRate/WeaponRange),
|
||||
**`.CTL`** control maps. [T1]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -79,7 +96,12 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
|
||||
in `docs/DIST_LAYOUT_PLAN.md` — PROPOSED, not implemented, awaiting a decision between the
|
||||
authors.** The cwd guard stays either way (it protects the developer tree, where the split is
|
||||
real and permanent).
|
||||
- Interactive: **WASD** drive; weapon groups (keyboard, task #43) **1/Space** = lasers, **2** =
|
||||
- Interactive: ⚠ under the GLASS profile (`content\bindings.txt`, the default since the cyd
|
||||
merge) drive is the **1995 throttle-lever scheme**, NOT WASD: **SHIFT** = throttle up (lever
|
||||
STICKS where you leave it), **CTRL** = throttle down, **ALT** = reverse thrust, **X** =
|
||||
all-stop, **BACKTICK** = view toggle. ("WASD drive" was the pre-glass dev profile — telling a
|
||||
glass user W is the confirmed way to watch a mech stand perfectly still, 2026-07-29.)
|
||||
Weapon groups (keyboard, task #43) **1/Space** = lasers, **2** =
|
||||
PPCs, **3/Ctrl** = missiles; **X** all-stop; **V** cockpit/chase view. Default egg = `DEV.EGG`
|
||||
(map=grass, time=day). Swap mech via the egg's `vehicle=` — **ALL 18 ModelList names
|
||||
CERTIFIED playable (2026-07-18 vehicle sweep)**: the canonical 8 (avatar, bhk1/blkhawk,
|
||||
@@ -87,6 +109,45 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
|
||||
mad1/mad2, own1, snd1, thr1, vul1); each boots a solo mission, spawns, animates, no
|
||||
crash. The code path is mech-agnostic. [T2]
|
||||
|
||||
## Local benches MUST launch like a player (bench parity, 2026-07-30)
|
||||
|
||||
> Parity is one rule of the harness. The full harness contract, the two-node
|
||||
> pattern, and the VERIFICATION DOCTRINE (field composition over constructed
|
||||
> proxies) live in [[test-harness]] — read that before writing any bench.
|
||||
|
||||
Every shipped launcher (`play_solo.bat`, `join*.bat`, `play_steam.bat`) sets the SAME three
|
||||
things, and a local bench that omits them is not testing the game the field plays:
|
||||
|
||||
set BT_PLATFORM=glass set BT_START_INSIDE=1 set BT_DEV_GAUGES=1
|
||||
|
||||
plus a front-end flag (`BT_FE_SOLO` / `BT_FE_JOIN`), `cd content`, and NO `-res`.
|
||||
**`BT_START_INSIDE=1` is the big one** — without it a bench opens in the EXTERNAL CHASE camera
|
||||
while every player starts in the COCKPIT. Benches ran chase-only for weeks; the first
|
||||
first-person look at one was misread as a broken HUD [T2].
|
||||
**Experience:** every SHIPPED egg is `experience=expert`. A `novice` bench egg silences the whole
|
||||
heat model, crits and jams (see [[experience-levels]]) — bench combat then is not field combat.
|
||||
Only the aimed-leg gimp bench may use novice (expert crits the aimed leg and the mech
|
||||
turns-but-never-moves); it must say so out loud.
|
||||
**Affinity:** pin each node to TWO logical processors (`0x03/0x0C/0x30/0xC0`), not one. Disjoint
|
||||
core sets keep single-box packet delivery even (the peer-shakiness fix, [[multiplayer]]); ONE LP
|
||||
per node starves the gauge executive and fakes a "the comms panel never counts" reading [T2].
|
||||
|
||||
`scratchpad/night6/bench_common.sh` owns this contract (`bt_player_env` / `bt_launch` /
|
||||
`bt_expert_egg` / `bt_novice_egg`, and `bt_assert_player_env` warns if the shipped bat drifts).
|
||||
The 4-node benches (`mp4_panel.sh` respawn+scoreboard, `mp4_cross.sh` real cross-fire kills,
|
||||
`mp4_limp.sh` gait replication) all source it. A `-net` node renders NOTHING until
|
||||
`tools/btconsole.py` starts the mission — a solo launch is the quick visual check.
|
||||
|
||||
## Headless frame capture — `BT_SHOT_EVERY` (2026-07-30)
|
||||
|
||||
`BT_SHOT_EVERY=<n>` dumps the game's own backbuffer every n frames to
|
||||
`<BT_SHOT_PREFIX|shot>_NNN.png` (btl4vid.cpp, top of `BTDrawTranslocationSpheres` — the per-frame
|
||||
alpha-pass hook; it MUST stay above that function's phase-0 early-out). This is how rendering is
|
||||
diagnosed without foregrounding a window, and it is the ONLY capture method to use: OS
|
||||
screen-capture grabs whatever is actually on the user's screen (a foreground-lock failure once
|
||||
photographed the user's browser instead of the game) and is off-limits.
|
||||
Frames land mid-alpha-pass, so a process killed during a write leaves ONE truncated PNG — skip it.
|
||||
|
||||
## Debug (cdb x86)
|
||||
`"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"`. Pattern for a faulting stack (cwd
|
||||
= content\): `-g -c ".lines;sxe av;g;kp 24;q"` with `BT_ASSERT_TO_DEBUGGER=1`. Debug CRT fills fresh
|
||||
@@ -138,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.
|
||||
|
||||
+72
-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]
|
||||
@@ -88,6 +101,36 @@ inverse(eyeWorld)`. No LookAt anywhere.
|
||||
DPLEyeRenderable::Execute (order `Orient × R`, `D3DXMatrixInverse` on the live path — note the
|
||||
ctor also holds a copy of this code but its view write is DEAD; patch the Execute).
|
||||
|
||||
## ⚠ The canopy looks "obscured" at wide viewports — it is the ASPECT, not the canopy [T2]
|
||||
|
||||
`BTFovYFromHorizontal` (L4VIDEO.cpp:127) pins the HORIZONTAL fov at the authentic 60° and derives
|
||||
the vertical from the LIVE aspect — correct at the pod's 4:3 (60°×46.8°), but the cockpit-surround
|
||||
layout hands it a very wide, short world view and the vertical field collapses:
|
||||
|
||||
| viewport | aspect | vertical FOV |
|
||||
|---|---|---|
|
||||
| pod 800×600 | 1.33 | **46.8°** |
|
||||
| surround default 900×500 | 1.80 | 35.6° |
|
||||
| surround on a 1080p screen 900×440 | 2.05 | **31.5°** |
|
||||
|
||||
The surround height-clamps the view to fit the work area (`btl4main.cpp` ~1404: 900×500 → 900×440
|
||||
on 1080p), so the smaller the screen the worse it gets. The canopy is FIXED world geometry, so a
|
||||
1.5× vertical squeeze makes it eat 1.5× more of the view: measured on bhk1 at 900×440 the frame
|
||||
covers **39% of the viewport — 9.5% of the upper half but 68.6% of the LOWER half**, i.e. the dash
|
||||
rides up over the horizon and hides the ground where enemy mechs stand (user-reported as "can't
|
||||
see to shoot"). At a true 4:3 world view the same build/mech/map renders a correct big-window
|
||||
cockpit. Left/right skew is 6% (symmetric — nothing is tilted).
|
||||
**Not a regression:** the shipped 4.11.643 binary reports identical geometry (`view 900x440
|
||||
canvas 1452x999`), and the gauge-executive change is measurably innocent (canopy coverage 57–59%
|
||||
with it on vs 60–61% off — scene variation).
|
||||
**Verification method:** `BT_COP_PLATES=1` (kit off) gives a 100%-solid viewport vs 39% with the
|
||||
stencil cut live — that is how to prove the punch kit is running. Canopy coverage = per-pixel diff
|
||||
of a normal frame against a `BT_HIDE_COCKPIT=1` frame; a raw grey-pixel count also catches sky.
|
||||
**Open decision (nothing changed yet):** cap the aspect used for fovY (wide windows would show
|
||||
MORE horizontally instead of cropping vertical — identical to the pod at 4:3), or make the
|
||||
surround's view taller (clamp width, costing surround width), or bench first-person work in the
|
||||
dock layout (`BT_DEV_GAUGES_DOCK=1 -res 640 480`).
|
||||
|
||||
## Status per mech + the gyro (task #56 — gyro now LIVE)
|
||||
|
||||
- **Madcat (MAX_COP): verified good on pure defaults** — dark domed frame (top arch +
|
||||
@@ -200,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
|
||||
|
||||
+609
-31
@@ -69,6 +69,103 @@ player-only logic on `this == application->GetViewpointEntity()` (else a spawned
|
||||
player input). [T1]
|
||||
|
||||
## Targeting (mech offsets, [[decomp-reference]] §3)
|
||||
**STRUCTURAL DISCOVERY (2026-07-29, the #73 pick dig): the target block is an embedded engine
|
||||
`Reticle` at `mech+0x36c`** [T0 layout-verified]. `engine/MUNGA/RETICLE.h` lays out exactly:
|
||||
`reticlePosition@0x36c` (Vector2D), `reticleState@0x374`, `pickPointingOn@0x378`,
|
||||
**`rayIntersection@0x37c`**, **`targetEntity@0x388`**, **`targetDamageZone@0x38c`**,
|
||||
`reticleElementMask@0x390`. This is why every "who writes 0x388" scan came back empty — writers
|
||||
carry `&mech->reticle` (an `lea [mech+0x36c]`) and use small reticle-relative offsets.
|
||||
`ENTITY3.h:131` states the model in T0 words: *"For BattleTech, damage zones are only valid via
|
||||
reticle based weapons."* The engine `Reticle` is a **passive container** (ctor + resource parse
|
||||
only — no pick math); `HudSimulation @0x4b7830` opens with `lea esi,[owner+0x36c]` and READS the
|
||||
pick (range caret from `rayIntersection`, the designator transform) and slews `reticlePosition`
|
||||
via `@0x4b7ed4`, but does not write the pick either. **The pick WRITER is BT code, still
|
||||
unlocated** [T4 candidates]: the un-exported stretch `0x4a1674-0x4a2d48` (touches the reticle at
|
||||
`0x4a16a5` — the Mech::Make-called init — and `0x4a1f93`, `0x4a294a/54/61` state+pickPointing+base
|
||||
together near entry `0x4a2971`, which is called from `0x494483`), the two unidentified Mech vtable
|
||||
overrides `+0x18=0x4a122c` / `+0x1c=0x4a0c2c` (both big switch functions), and lone sites
|
||||
`0x4877a4` (byte read via the mission entity table `0x5015c8`), `0x4b0097`, `0x45fb14-24`.
|
||||
**RESOLVED (same dig, deeper): there IS no software pick writer — the 1995 pick was a DPL SCENE
|
||||
INTERSECTION** [T0 + T1 converging]. Evidence: (a) the WinTesla renderer still carries the result
|
||||
members `dplHitInstance/dplHitDCS/dplHitGeoGroup/dplHitGeometry` + `vehicleReticle` (L4VIDEO.cpp
|
||||
ctor, all NULL-init); (b) the stubbed 1995-era renderable constructors each took
|
||||
**`dpl_isect_mode_obj` ("type of intersections to do on this object") + an intersection MASK** —
|
||||
per-renderable intersection configuration against the scene; (c) Auric's account ("the pod's
|
||||
division card cast from the view"); (d) VGL Lynx's LOD warning reads as firsthand knowledge — the
|
||||
ray tested the DRAWN geometry, i.e. the active LOD's meshes; (e) exhaustive scans: nothing in the
|
||||
binary or the pseudocode ever writes `rayIntersection/targetEntity/targetDamageZone` — game code
|
||||
only reads, initializes (the Mech ctor @0x4a1674 constructs the embedded Reticle), gates
|
||||
(`FUN_004afd10`, the look-state machine, toggles `pickPointingOn` per view with the π rear case),
|
||||
and SUBMITS the reticle to the board for drawing (`FUN_00460a7c` packages +0x37c/+0x390 into dpl).
|
||||
⇒ **Aimed fire in 1995 had PER-PART precision**: scene ray → struck triangle on the current LOD →
|
||||
the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSegmentIndex@49db20`) =
|
||||
`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
|
||||
JMOVER.cpp:290) are recorded in `MechRenderTree::segPick`; the pick ray-tests the per-segment
|
||||
bounding spheres (`mCullCenter/mCullRadius`, world via the draw-cached `mLocalToWorld` — ≤1 frame
|
||||
stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST radius wins**
|
||||
(normalized `d²/r²` tie-break). Both simpler rules were measured failing identically: the torso
|
||||
mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always
|
||||
faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs
|
||||
it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line
|
||||
— the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick
|
||||
per candidate (box `PickRayHit` + zone −1 → the unaimed lottery survives only as fallback:
|
||||
no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` +
|
||||
`targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone.
|
||||
Bench (L/C/R sweep, dummy at 8u): aim-left = **36/41 hits on zone 2 = `jointlshoulder`** with
|
||||
thread score 0.30 (was a 6-way lottery spray); envelope grazes log score ≈0.99 and credit the
|
||||
torso. The MadCat segment→zone map is authored and rich (shoulders 2/9, guns 6/17, hip 1, legs
|
||||
3/5/8/10/16/19, torso 0). Known approximations for field verification: sphere bounds (not
|
||||
per-triangle), the torso-envelope graze credits the torso where the pod's mesh test would miss
|
||||
into air. Diag: `BT_PICK_LOG` (`[segpick]` the map at build, `[pickwin]` zone/score/t per pick).
|
||||
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
|
||||
`mech+0x38c` = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
|
||||
refreshed each frame. [T1]
|
||||
@@ -102,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
|
||||
@@ -181,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
|
||||
@@ -271,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
|
||||
@@ -279,17 +412,26 @@ Three-layer story, measured live + decomp-verified:
|
||||
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
|
||||
`@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps,
|
||||
BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is
|
||||
NOT in this formula — zone damage IGNORES it** [T1]; one Damage message = one `amount×scale`
|
||||
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the
|
||||
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the
|
||||
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓
|
||||
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000
|
||||
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e).
|
||||
The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local
|
||||
REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and proceeds) —
|
||||
**ram damage was network-inert in the pod**. Our task-#47 replicant forwarding (required
|
||||
for MP weapon damage) surfaced it as a one-shot. Port: `BTDispatchCollisionDamage`
|
||||
normalizes ×1e-3 to the armor-point economy (bump = points, charge = tens) [T3].
|
||||
NOT in this formula — `TakeDamage` itself IGNORES it** [T1]. ⚠ But one Damage message is **NOT**
|
||||
one application: `Mech::TakeDamageMessageHandler` calls `TakeDamage` **`burstCount` times**,
|
||||
re-rolling the struck zone per burst (@0x4a0423-0x4a04d8; `mech.cpp`, task #80). `burstCount` =
|
||||
number of applications, and it is load-bearing (missile cluster count, splash falloff, gyro
|
||||
bounce). A cluster missile lands its damageAmount once **per connecting missile** — the count is
|
||||
rolled at impact, `Random(n) + n/4` clamped to `n`. See the corrected SALVO note below. ✓
|
||||
2. **StaticBounce prices rams at ~600×v² raw** (measured: 59221@9.94, 66239@9.59, 398@0.81;
|
||||
`[collide-tx]` logs mass/e — mech moverMass is the tonnage-scale 60000-90000, NOT the old
|
||||
"authored ≈1.3e6" mis-attribution; the ~24·v²·0.0005·mass factor is the head-on (vn·vp)
|
||||
algebra). The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed
|
||||
on the local REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and
|
||||
proceeds) — **ram damage was network-inert in the pod**. Our task-#47 replicant forwarding
|
||||
surfaced it as a one-shot, and a ×1e-3 normalization was patched in (2026-07-12 [T3]).
|
||||
**That normalization is GONE (pricing audit 2026-07-31): the #83 type-0 divert is the
|
||||
authentic normalizer** — the victim's TakeDamage hub routes collision damage into
|
||||
`DistributeCollisionDamage` (scale ≈ 3.9-4.5e-5), so with the 0.001 still applied no
|
||||
physically possible ram could clear the divert's 0.5-pt free floor (rams were a no-op
|
||||
against the victim). Raw restored [T1]: a 9.6 m/s ram = 2.55 pts of internal rattle on the
|
||||
victim (bench-measured, `scratchpad/night7/mp_rampricing.sh`); pressed follow-up contact
|
||||
prices under the floor = free.
|
||||
3. **The contact EDGE**: the binary's bounce reversed the mover's velocity so the frame after
|
||||
a bump read as separating; our gait re-derives velocity per frame (still closing while
|
||||
pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn
|
||||
@@ -299,6 +441,34 @@ Band effects: `MechDeathHandler::Tick` fires the CURRENT band descriptor on any
|
||||
(the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/
|
||||
burns per hit; the earlier crossing-only gate was over-tight (the metronome was SHKWAVE).
|
||||
|
||||
## Missile THRUST -- FIXED (2026-07-31, issue #84) [T1 values / T2 verified]
|
||||
The binary Missile hosts a **MissileThruster** subsystem: the round leaves the rack at the
|
||||
launcher's authored MuzzleVelocity (SRM eject 100 u/s flat; LRM 30 u/s up-tilted) and then
|
||||
ACCELERATES while BurnTime remains. Authored values (BTL4.RES type-15 missile model records
|
||||
+0x44/+0x48, reached from the AmmoBin's `ammoModelFile` through the type-1 MODELLIST
|
||||
indirection): **SRM 2.5s @ 600 u/s² (turn 120°/s) · LRM 10s @ 300 (turn 60°/s, climb 50) ·
|
||||
Streak 3s @ 300 (turn 360°/s) · NARC 10s @ 300**; SplashRadius 30 for all (the port's constant
|
||||
was right); authored drag ~0.001 = negligible. The port pool flew at CONSTANT eject speed --
|
||||
LRMs 10× slower than authored -- which was the whole of the field "missiles are slow" report,
|
||||
and the "explosion before the missiles arrive" perception (the salvo-lead detonates while the
|
||||
slow spread rounds straggle). Fixed: `BTMissileThrustOf` (mech4.cpp) lazily caches the RES
|
||||
thruster table (ModelList ids aliased to their type-15 member); both launch paths (master fire
|
||||
+ the replicant salvo mirror) pass burn/accel into the pool, whose advance integrates
|
||||
`speed += accel·dt` while burning. Verified live: Streak impacts at 277 u/s (was 100).
|
||||
Note: the Black Hawk's "SRM6" fires **strk** (Streak) ammo per its bin -- the ammo model, not
|
||||
the launcher name, decides the flight profile.
|
||||
|
||||
## Destroyed weapons kept FIRING -- FIXED (2026-07-31, issue #86) [T1/T2]
|
||||
The fire gates (`ProjectileWeaponSimulation` gate 1 @4bbd36, `EmitterSimulation` hard-failure
|
||||
@4baab9) test `subsystem+0x40` for Destroyed(1). In the binary that offset IS the status alarm's
|
||||
level cell; the port splits it into `statusAlarm` + a never-written `int simulationState`, so the
|
||||
gates read a dead cell -- a weapon on a destroyed mount showed its X on the MFD and kept firing
|
||||
and SCORING (night-7: all three testers, with screenshots of a missile leaving a destroyed pod).
|
||||
Fixed by reading BOTH cells in both gates; the full pattern is [[reconstruction-gotchas]] §22.
|
||||
Verified A/B in one run (BT_KILL_SUBSYS bench hook -> ForceCriticalFailure, the same call the
|
||||
zone crit-cascade makes): before the kill both SRM6 launchers fired 2 salvos each; after, the
|
||||
destroyed launcher fired ZERO (gate log `destroyed=1`) while its twin kept firing.
|
||||
|
||||
## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2]
|
||||
Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType
|
||||
histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**,
|
||||
@@ -359,20 +529,203 @@ death). ALL EIGHT proxy-view sites in mechsub.cpp swept to the engine view
|
||||
`GetStatusFlags` (read the vptr as a float → always "intact") and `ApplyDamageAndMeasure` (the
|
||||
crit cascade's damage-measure read garbage). Gotcha §5 (alias fields), new archetype.
|
||||
|
||||
## ⚠ A subsystem's PRIVATE zone cannot be damaged via `TakeDamage` (2026-07-28) [T0]
|
||||
Each `MechSubsystem` owns a private `DamageZone` at `+0xE0`, built by the **2-arg trivial ctor**
|
||||
`new DamageZone(this, 0)`. That ctor (`engine/MUNGA/DAMAGE.cpp:187-190`) zeroes **all five**
|
||||
`damageScale[]` entries, `Reset()` never touches them, and the only other writer in the tree is
|
||||
`Mech__DamageZone` — the mech's *streamed* zones (`mechdmg.cpp:246-253`), a different class. Since
|
||||
`DamageZone::TakeDamage` is `damageLevel += damageAmount * damageScale[damageType]`, **any**
|
||||
`TakeDamage` against a subsystem's own zone is arithmetically a no-op, whatever the amount or type.
|
||||
Verified by experiment: a zone seeded to 0.6 and fed −0.011f for ~1500 ticks never moved.
|
||||
The crit path works because it **bypasses this entirely** and writes `damageLevel` directly
|
||||
(`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`; `ForceCriticalFailure` sets the state).
|
||||
Practical consequence: if you are reconstructing something that "damages a subsystem", route it the
|
||||
way the crit path does — a `TakeDamage` call there will silently do nothing. This is also why
|
||||
Myomers' Performance `@004b8bb9` (an un-powered self-repair) is dead code in the 1995 binary itself:
|
||||
see [[subsystems]] WAVE 6.
|
||||
## ⚠ CORRECTED (2026-07-29, #80): subsystem private zones ARE damageable — the port was writing
|
||||
## their scales through the WRONG LAYOUT
|
||||
The 2026-07-28 verdict here ("a subsystem's private zone cannot be damaged via `TakeDamage` — in
|
||||
the original too") was **wrong about the original**, and the experiment that "confirmed" it was
|
||||
measuring a PORT bug. The truth [T1, raw disasm @0x4ac7bb + live-verified]:
|
||||
- The binary's `MechSubsystem` resource ctor **initializes the private zone's armour**:
|
||||
`defaultArmorPoints@0x140` ← resource `WeaponDamagePoints` (+0x44, REQUIRED key) and
|
||||
`damageScale[5]@0x144` ← the five per-damage-TYPE keys
|
||||
`Collision/Ballistic/Explosive/Laser/EnergyDamagePoints` (+0x30), normalized
|
||||
`1/(scale·armorPoints)` exactly like the mech zones (without their extra ×0.5).
|
||||
- The PORT had this copy — but wrote it through the **`ReconDamageZone` proxy**, whose
|
||||
`structureReference/armour[]` sit at struct offsets **+4/+8**, not the binary's +0x140/+0x144.
|
||||
The floats landed on the engine object's header and the real scales stayed at the engine ctor's
|
||||
zeros — hence the frozen-damage experiment. Classic databinding trap, now on the engine side.
|
||||
FIXED: the ctor writes the engine's NAMED members (layout-parity holds — `Mech__DamageZone`
|
||||
locks its derived fields from 0x160 up).
|
||||
- The port's CSS also never parsed the seven keys (now parsed; two are required with the binary's
|
||||
own error strings).
|
||||
Consequences: `MechSubsystem::TakeDamage @0x4ac0bc` (now real, was a stub) accumulates authored
|
||||
per-type damage on subsystems; crits land and destroy subsystems; and **the Myomers un-powered
|
||||
self-repair `@004b8bb9` was LIVE in 1995 and is live in the port now** — the earlier "dead code in
|
||||
the original too" note in [[subsystems]] WAVE 6 is superseded. `DistributeCriticalHit`'s direct
|
||||
`damageLevel = 1.0f` pin remains the ammo-explosion path, not the general mechanism.
|
||||
|
||||
## ⚠ ZONE SELECTION IS A WEIGHTED LOTTERY — pixel-precise limb damage does not exist (2026-07-29) [T1]
|
||||
Issue #73 ("fired only at the left arm, damage credited elsewhere, no crits") is **largely the
|
||||
authored 1995 model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream
|
||||
format): impact point → `WorldToLocal` → **height layer** (`floor(layerCount·y/heightRef)`) →
|
||||
**pie slice** (`atan2(local.z, local.x)`, optionally rotated by live torso twist —
|
||||
`rotateWithTorso` per layer) → `DamageZonePercentTable::SelectZone()` = **`RandomUnit()` against
|
||||
cumulative percent thresholds** (`@0x49de14`). Each slice sprays damage across an authored
|
||||
*distribution* of zones — aiming at a limb at best biases WHICH SLICE you strike; the zone within
|
||||
it is dice. Measured live (L/C/R aim sweep at 8u, `BT_DMG_LOG`): 24-46 hits per aim point spread
|
||||
across 6+ zones each, with the distribution *shifting* by aim — the model working as designed.
|
||||
Consequences for triage: reports of "damage landed somewhere I didn't aim" need this base rate
|
||||
before being called bugs; crit expectations from aimed fire are likewise probabilistic.
|
||||
**Residual genuine question [T3]:** the shooter's pick point comes from `Mech::PickRayHit` = a
|
||||
**whole-mech AABB slab test** (a PORT stand-in — the binary's 0x37c/0x388/0x38c writer is in the
|
||||
un-exported gap, never decompiled). Theta computed from a flat box FACE clusters toward the
|
||||
facing slices, so flank slices may be under-reachable from frontal shots vs. the pod (which
|
||||
plausibly intersected the mech's *cylinder* — the damage table is literally cylindrical).
|
||||
Deciding whether that distortion is material needs a per-hit theta probe + a wheel dump (slices ×
|
||||
percent tables, MadCat) — see #73 on the tracker.
|
||||
|
||||
## ✅ CRITS RECONSTRUCTED (2026-07-29, #80) — the section below records the GAP as found; all three
|
||||
## 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 (⚠ 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
|
||||
handler's application loop (binary @0x4a0423-0x4a04d8, now in `mech.cpp`): per burst — crit roll →
|
||||
`CriticalHit @0049ccc4` (replaces the zone application; routes half through armour internally,
|
||||
tally += the subsystem's `CriticalHitScoreBonus`) else `zone->TakeDamage`; then **re-run the
|
||||
cylinder lottery from the impact point for the next burst** (multi-burst damage sprays), stopping
|
||||
early once the mech is disabled. The engine base's single-application (which ignored `burstCount`
|
||||
entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsystem::TakeDamage
|
||||
@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. 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`
|
||||
(half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by
|
||||
`damagePercentage`) — but **nothing in the port calls it**. Raw byte-scan of the binary: exactly ONE
|
||||
call site, `@0x4a0461`, inside the **un-exported gap** (no decomp covers `0x4a03xx-0x4a05xx` — the
|
||||
same dark region as the `0x37c/0x388/0x38c` pick writer), so the crit TRIGGER CONDITIONS (per-hit
|
||||
chance? threshold crossing?) are unknown and unreconstructed. Second layer:
|
||||
`MechSubsystem::TakeDamage` is an **empty bring-up stub** (`btstubs.cpp:179`), so even a wired
|
||||
caller would measure a delta of 0 through `ApplyDamageAndMeasure` (and the weapon-family overrides
|
||||
chain to the engine base whose private-zone write is the zero-`damageScale` no-op — see the
|
||||
subsystem-zone finding above). Net: **the Critical view lights ONLY on zone destruction
|
||||
(`SendSubsystemDamage`, direct pin) or ammo cook-off (`DistributeCriticalHit`, direct pin), never
|
||||
from accumulating fire.** The DISPLAY is fine — the paper doll shows ZONE ARMOR, the Critical panel
|
||||
shows SUBSYSTEM crits (different data by design); the panel's near-permanent emptiness is the gap.
|
||||
Field signature (night 6, Conn Man): "Armor panel damage. Critical damage display did not show any
|
||||
crits." **#28 (vital-subsystem-crit death path) is very likely blocked by the same missing caller.**
|
||||
Recovery: raw-disasm the `@0x4a04xx` container (#60-class gap work) for the trigger; dump
|
||||
MechSubsystem vtable `0050e210` slot `+0x24` for the real TakeDamage body; wire both. Tracker: #80.
|
||||
|
||||
## Damage delivery + the real damage model
|
||||
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch`.
|
||||
@@ -671,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
|
||||
@@ -721,10 +1076,23 @@ cross-pod for a replicant victim on its own.
|
||||
|
||||
**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.**
|
||||
**KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is
|
||||
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** So ONE arcade cluster
|
||||
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit
|
||||
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math
|
||||
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). The port
|
||||
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.**
|
||||
⚠ **CORRECTION (2026-08-01, #95): do NOT generalise that into "burstCount is cosmetic for zone
|
||||
damage" — the earlier wording here said exactly that and it is WRONG.** `TakeDamage` ignores it;
|
||||
the **CALLER honours it**. `Mech::TakeDamageMessageHandler` (@0x4a0423-0x4a04d8, reconstructed in
|
||||
`mech.cpp` under task #80) loops `burstCount` times, calling `TakeDamage` once per burst and
|
||||
**re-rolling the struck zone each iteration** (@0x4a04b9) — so a burst SPRAYS across zones.
|
||||
`burstCount` is therefore "number of applications", and it is load-bearing for missiles (cluster
|
||||
count), splash (distance falloff) and the gyro bounce alike.
|
||||
So ONE arcade cluster Missile per trigger (damageAmount = authored/missileCount, burstCount =
|
||||
missileCount) delivers its salvo through that loop, not in a single application. **How many of the
|
||||
cluster connect is ROLLED at impact** in `Missile::Perform` right before dispatch
|
||||
(part_013.c:10082): `b = Random(n) + n/4`, clamped to `n` — between a quarter of the salvo and all
|
||||
of it. **And the arcade Missile dispatches DIRECTLY at the struck entity** (`FUN_004be078`:
|
||||
`param_2->Dispatch(&msg)`) — it does NOT route through the shooter's `SubsystemMessageManager`,
|
||||
whose consolidation would drop `burstCount` (`DamageInformation` carries only damageType +
|
||||
subsystemID). Routing a projectile through the manager therefore silently deletes the cluster
|
||||
count — that was the #95 bug, and it also produced the #84 double explosion. The port
|
||||
re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62
|
||||
damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the
|
||||
splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix
|
||||
@@ -747,6 +1115,216 @@ its decomp writers (4668/10512) read as a per-frame state toggle, not a clean co
|
||||
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
|
||||
enable. See [[open-questions]].
|
||||
|
||||
## Collision damage — the type-0 DIVERT + rattle distributor (#83, 2026-07-30) [T1 disasm/T2]
|
||||
`Mech::TakeDamageMessageHandler` diverts `damageType==0` (@0x4a0368) into
|
||||
`DistributeCollisionDamage` (FUN_0049ffcc, export gap, raw disasm `scratchpad/night6/
|
||||
gap_49ffcc.txt`): collision damage **never reaches the zone/armor loop**. Gate: the owning
|
||||
player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the manual's
|
||||
"splash/collision damage" technician setting. Pricing: `raw × (2000/moverMass)/(100·(1/3.6))²/
|
||||
(1−elasticity²)` (constants @0x4a0148=1/3.6 tbyte, 2000f, 0.5f threshold; mass@+0x20c,
|
||||
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 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
|
||||
same way. Measured on bhk1 (mass 60000, e=0.2): scale 4.5e-5; a 94k spawn-slam = 4.2 rattle
|
||||
points; taps free. `[colldmg]` (BT_DMG_LOG) + `[crashdmg]` (always-on, rare) are the probes.
|
||||
|
||||
## The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified]
|
||||
|
||||
`Mech__DamageZone::RecurseSegmentTable @0049cad4`: when a zone reaches damageLevel
|
||||
1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT
|
||||
TREE (mech+0x300, the engine `JointedMover::segmentTable` [T0]):
|
||||
* `destroySiblingsOnDestruction` (streamed, Wword 0x68) -> every other zone on
|
||||
the SAME segment (`EntitySegment::damageZoneTable`, seg+0xD0) recurses and is
|
||||
set graphic state 2 (**Gone**);
|
||||
* `descendOnDestruction` (Wword 0x67) -> every zone on every CHILD segment
|
||||
(`childIndexTable` seg+0xE8 -> child's damageZoneTable) recurses -- **the arm
|
||||
takes the gun pod with it**. Each recursed zone's `SendSubsystemDamage
|
||||
@0049c9a8` pushes the unused crit allotment and `ForceCriticalFailure`s any
|
||||
subsystem driven to 1.0 -- which the #86 fire gates then refuse.
|
||||
Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything
|
||||
else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin +
|
||||
Condenser6 force-failed, `AFC100 -> NoAmmo (gate1): destroyed=1`; dz_larm -> PPC
|
||||
force-failed, `[emitter] 'PPC' fire REFUSED (destroyed=1)` x4998. Torso weapons
|
||||
unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade
|
||||
(no guard in the binary's child loop; only the sibling loop checks graphic state
|
||||
!= 1) -- authentic, idempotent in effect.
|
||||
|
||||
⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators
|
||||
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
|
||||
STRAIGHT from BTL4.RES bytes with `scratchpad/night9/res29_scan.py` (no port
|
||||
code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
|
||||
* **18 streams, 8 distinct by content** (copies 3,3,2,2,2,2,2,2). All 7-layer.
|
||||
* **Slice counts are authored, not fixed 8**: Black Hawk band 6 and the
|
||||
Vulture/Battlemaster band 4 are **7-wedge** rings; everything else 8.
|
||||
* rotateWithTorso: layers 3-6 on six tables; **all-zero on Black Hawk + Owens**.
|
||||
* Chassis -> content hash (live-dump matched, dump-identical rounding):
|
||||
ava1->1b9958b4, bhk1->5150f823, lok1->b0a0e692, **mad1->c02bfc7a**,
|
||||
own1->f0f03c87, snd1->0e1a9ca0, thr1->0163740f, vul1->739b1a6b.
|
||||
The two same-zone-set "Avatar/MadCat" tables ARE distinguishable by loader:
|
||||
**the Mad Cat rides one (c02bfc7a), the Avatar the other (1b9958b4)**.
|
||||
* **Wedge orientation, from the AUTHORED slice names** [T1 data]: W0/W7 =
|
||||
"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.
|
||||
* Resolver mechanics re-confirmed from the decomp (@0049eb54/@0049e678/
|
||||
@0049de14): WorldToLocal; local y CLAMPED to [0, heightRef] BEFORE scaling
|
||||
(heightRef = live collision cylinder, owner+0x2ec->+0xc); layer floor+clamp;
|
||||
atan2(z,x) +2pi wrap, on-axis -> random angle; rotateWithTorso adds the live
|
||||
torso heading (torso+0x1d8) BEFORE slicing; uniform roll vs ascending
|
||||
cumulatives, -1 fallthrough. **Collision damage (type 0) NEVER reaches the
|
||||
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.
|
||||
|
||||
+371
-2
@@ -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 |
|
||||
@@ -427,10 +719,11 @@ default-ON (`'0'` disables).
|
||||
| `BT_SECTOR_LOG` | SectorDisplay Make/Execute |
|
||||
| `BT_NET_TRACE` | `[net-tx]/[net-rx]/[net-upd]` MP tracing |
|
||||
| `BT_DEV_GAUGES` | render the 6 pod MFD surfaces in a separate dev window |
|
||||
| `BT_GLASS_LAMP_SWEEP` | `0` = restore authentic once-per-gauge-cycle cockpit-lamp updates; default (on) sweeps the lamps EVERY frame in the dev/glass composite path so the lit buttons track the sim even when the throttled background gauge sweep starves under MP load (`L4VB16.cpp` `BTGlassSweepLamps`; the "lighting slow/nonexistent" fix — glass-only, pod serial cadence untouched) |
|
||||
| `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]] |
|
||||
@@ -473,6 +766,14 @@ default-ON (`'0'` disables).
|
||||
| `BT_SELF_DAMAGE=<dps>` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you |
|
||||
| `BT_POWER_DETACH_TEST=<name\|1>` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach |
|
||||
| `BT_AUDIO_SOURCES=<n>` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] |
|
||||
| `BT_SELF_DAMAGE_ZONE=<n>` | aim the BT_SELF_DAMAGE harness at an explicit zone (-1/unset = the lottery) -- ramps one zone deterministically past thresholds (#78 bench) |
|
||||
| `BT_ARMOR_LOG` | mech ARMOUR-DARKENING trace (#87): per-zone `.DZM` list -> draw-op binding counts at tree build, any `unmatched` material name, and every change of a bound zone's level with the resulting brightness |
|
||||
| `BT_ARMOR_FORCE=<0..1>` | pin EVERY bound armour zone to a fixed damage level, so the same scene can be rendered pristine and fully-damaged and diffed pixel-wise (`scratchpad/night7/armor_ab.sh`) |
|
||||
| `BT_DRIVE_LOG` | the demand-site drive scale: `[drive] n/drive/mm/dmd/mech` ~1Hz (drive = myomers speedEffect x gimp; mm = the gimp level via BTMechGimpLevel) |
|
||||
| `BT_GIMP_SPEED=<0..1>` | EXTRA gimp demand multiplier (default 1.0 = authentic since 2026-07-30: the real slowdown is the gimp gait machines' clamp to the wg clip speed — see [[locomotion]] §visible limp; the old 0.5 T3 stand-in is retired) |
|
||||
| `BT_PICK_LOG` | #73 aimed-pick diagnostics: `[segpick]` the segment→zone map at tree build (index, name, zone, sphere), `[pickwin]` the winning zone/score/t per pick (score ~0 = threading the part core, ~1 = envelope graze) |
|
||||
| `BT_CRIT_LOG` | #80 crit diagnostics: `[subarmor]` per-subsystem armour/scales/critBonus at ctor (proves the resource keys parsed + the zone got REAL scales), `[critroll]` per landed crit (zone, subsystem, its resulting own-zone level). NB the type-0x1e loader's `[crit]` tag is a different, older log |
|
||||
| `BT_DEVICELOST_TEST=<frame>[,crashrepro]` | #35 bench hook. `<frame>` forces the D3D9 DEVICELOST branch at that render frame (+600/+1200 = 3 cycles), driving the REAL `BTResetLostDevice` recovery. `,crashrepro` runs the field null-teardown shape (double `ParticleEngine::Destroy`) — pre-fix this reproduced the field crash byte-for-byte (`Destroy +0x11`, `target=0x0`); post-fix it must log `SURVIVED`. See [[wintesla-port]] §Device-loss |
|
||||
|
||||
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
|
||||
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
|
||||
@@ -491,6 +792,22 @@ recoil @4bc136-4bc19c (damage>3 → (0,0.6,−1.5) × damage/16; `gyroRumbleTime
|
||||
ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over
|
||||
`reference/decomp/section_dump.txt`.
|
||||
|
||||
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
|
||||
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`,
|
||||
232 bytes) with Perform **`FUN_00457784`** (lerp constant `@0x4579a4` = 1.0; damaged factor
|
||||
`0x3dcccccd` = 0.1). Material accessors: `dpl_GetMaterialAmbient` `FUN_0048baf4` /
|
||||
`Set` `FUN_0048ba98`, `Emissive` `FUN_0048bbb0`/`FUN_0048bb54`, `Diffuse` `FUN_0048bc6c`/
|
||||
`FUN_0048bc10`, `Specular`(4) `FUN_0048bd2c`/`FUN_0048bccc`, `Opacity` `FUN_0048bdf0` (READ ONLY --
|
||||
lerped but never pushed), flush `FUN_0048d4d4`; `dpl_SetMaterialRamp` `@0x48bfb0` (writes
|
||||
`material+0x1c`; ONE caller, load-time only). Material lookup by name `FUN_00497678`. The UNUSED
|
||||
sibling path: `dpl_Damagize` `FUN_004902b0` (tokens `{0x92,0x05,0x04}`), `SV_SPECIAL DAMAGE`
|
||||
dispatch @0x4599ae -- **zero shipped BGFs carry a DAMAGE token**.
|
||||
|
||||
**PUNCH firmware decode** [T1] (the definitive mechanism, 2026-07-11): cmd 0x20 =
|
||||
vr_damage_action (VR_PROT.H enum); the i860 firmware content/VREND.MNG (code @0xf0400000)
|
||||
handler 'damageize' @0xf040f6f8 writes the token triple onto the geogroup's first THREE
|
||||
@@ -515,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
|
||||
@@ -526,6 +859,42 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
|
||||
|
||||
---
|
||||
|
||||
## THE ENGINE FRAME-RATE GLOBAL — `DAT_0052140c` = 28.0f (2026-08-02) [T1]
|
||||
|
||||
The DOS binary's per-frame<->per-second conversion scalar, set ONCE at startup:
|
||||
|
||||
0x401ace: mov [0x52140c], 0x41E00000 ; 28.0f (nominal rate)
|
||||
0x401ada: mov [0x52140c], 0x4191A6E0 ; 18.2065f (BIOS-tick fallback, PIT/65536)
|
||||
|
||||
and passed straight into the ApplicationManager ctor by the DOS main
|
||||
(0x401189: `push [0x52140c]`). ~90 code sites `fmul`/`fdiv` this global —
|
||||
whenever the decomp shows an unexplained multiply/divide by `_DAT_0052140c`,
|
||||
it is per-second <-> per-frame conversion at the pod's 28 Hz.
|
||||
|
||||
**The AUDIO/EFFECT FRAME CLOCK is this same rate.** `AudioTime::
|
||||
Seconds_To_Frames` in the image is `fmul [0x52140c]; fadd 0.5; round` (e.g.
|
||||
@0x42c611, @0x42dd86 — the 0x42xxxx audio-layer cluster of the ~90 consumers),
|
||||
so every authored audio duration/delay counts frames at 28. WinTesla's
|
||||
`DefaultRendererRate` said **30** — all audio timing ran ~7% fast until
|
||||
corrected (2026-08-02). Oracle's coupled report ("prolonged smoke and
|
||||
explosion including sound") was the lead: effect visuals and audio share this
|
||||
clock family.
|
||||
|
||||
**So the pod's nominal simulation cadence was 28 fps, not the i860 board's
|
||||
30 Hz — and it was UNLOCKED.** Corroborated testimony (2026-08-02): Oracle —
|
||||
the pod rate was "flipbook" territory, heavy fights "a slide show"; Lynx
|
||||
(original era) — "BT never had a locked frame rate in 1st release testing",
|
||||
RP targeted 30, Tesla 3.0 managed ~20 on a good day. The engine pacer design
|
||||
agrees: fixed budget, background fill, NO overrun catch-up (APPMGR.cpp T0) —
|
||||
target 28, allowed to slip. Consequence: any per-tick dt-LESS accumulator ran
|
||||
SLOWER during heavy combat on real pods; per-tick behaviors must be calibrated
|
||||
against the loaded effective rate (veteran bracketing), not the constant. Every "per tick with no dt" accumulation in the binary (the myomer
|
||||
kinetic heat term; candidate: particle trail density) was calibrated against
|
||||
28 nominal — and less under load (the 18.2 fallback existing at all says slow
|
||||
paths were expected; the EFFECTIVE in-pod rate is an open question, see
|
||||
[[open-questions]]). Our port's frame pacer note in btl4main.cpp claimed 30
|
||||
as "the pod's authentic rate" — corrected to 28.
|
||||
|
||||
## Key Relationships
|
||||
- Feeds: [[subsystems]] (factory + ClassIDs), [[combat-damage]] (damage delivery), [[gauges-hud]] (attribute binding).
|
||||
- Verified against: [[reconstruction-method]] (the decomp loop), [[reconstruction-gotchas]] (why raw offsets fail).
|
||||
|
||||
@@ -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
|
||||
|
||||
+114
-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)"
|
||||
---
|
||||
|
||||
@@ -111,6 +111,21 @@ viewport), and `gWindowAspect` = the view rect's on-screen aspect (`BTWorldAspec
|
||||
the work area). Green tint tunable via `BT_COCKPIT_TINT=RRGGBB` (default `0x27E8`). Labels are a
|
||||
lazy GDI-baked MANAGED atlas (survives device reset). Renders in ALL builds; only the PadRIO
|
||||
click/lamp seam is BT_GLASS-gated. Full detail: `docs/GAUGE_COMPOSITE.md`.
|
||||
- ⚠ **Cockpit-lamp LATENCY under MP load (2026-08-04, glass playtest fix) [T2 runtime-verified].**
|
||||
The lit cockpit buttons are drawn every frame (`BTDrawCockpitPanels`, reading `PadRIO::GetLampState`),
|
||||
but the lamp STATE sweep that fills that store — `LampManager::Update` → `AssertNewLampValue` →
|
||||
`SetLamp` — authentically rides `GaugeRenderer::ExecuteForeground`, which fires only **once per full
|
||||
gauge cycle** (foreground→background→copy). The cycle can't advance to the next foreground turn
|
||||
until the THROTTLED background gauge sweep drains the ~140-instrument active list, and that
|
||||
background task starves under load (issue #45 — "instruments freeze while fps stays healthy"). So on
|
||||
a busy MP mission the lamp sweep ran ~1×/s: **buttons froze / flashes stalled while the 3D view (a
|
||||
separate per-frame foreground render) stayed smooth** — the "lighting slow or nonexistent" reports
|
||||
(all testers on glass surround). Fix: `BTGlassSweepLamps()` runs the lamp sweep EVERY frame on the
|
||||
dev/glass composite path (`L4VB16.cpp`, in `BTDrawGaugeInset`) — cheap (deduped pushes, ~72 lamps),
|
||||
decoupled from the gauge cycle. The pod never enters this path (real gauge hardware), so its
|
||||
bandwidth-paced serial lamp cadence is untouched. Kill switch `BT_GLASS_LAMP_SWEEP=0`. NB the RIO
|
||||
serial/lamp stack itself is byte-identical to pod-proven Red Planet (`L4LAMP.cpp`/`L4SERIAL.cpp`
|
||||
diffed) — the defect was purely the glass update CADENCE, not the lamp wiring.
|
||||
|
||||
## MP gauge-window FREEZE = dangling bindings + permanent SEH disable (Gitea #12, 2026-07-19) [T2 log-convicted]
|
||||
The live-MP "dev-gauges window froze entirely mid-session" incident (issue #12) is NOT a
|
||||
@@ -399,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
|
||||
@@ -534,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
|
||||
@@ -682,6 +737,64 @@ BitMap-strip lamp drew `SetColor(0)` + `DrawBitMapOpaque(0,…)` = invisible. Hi
|
||||
Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base),
|
||||
not `BecameActive` — vtable-diffed.
|
||||
|
||||
## The gauge EXECUTIVE — why panels froze in MP, and the fix (#45 root, 2026-07-30) [T0 engine / T2 verified]
|
||||
Instruments repaint via the BACKGROUND task pump (`APPMGR.cpp RunMissions`): after sim+render, the
|
||||
loop pumps `BackgroundTasks::Execute()` (ONE task per pump, ~7 tasks round-robin: net, events,
|
||||
audio, GAUGES, …) **only until the frame deadline** — the gauge renderer's turn advances **one
|
||||
gauge** of the active list (`ProcessOneActiveGauge`, ~140 active), and the 16-bit `GaugeRate`
|
||||
mask advances once per FULL sweep (so "rate D = every 4th frame" is really every 4th SWEEP).
|
||||
On a busy MP mission the foreground eats the whole frame budget → 1 pump/frame → a full
|
||||
instrument rotation took MINUTES at perfectly healthy fps: comms-panel K/D stuck at 0, recharge
|
||||
tickers frozen — while the underlying tallies (and their replication) were exactly right. This
|
||||
is the real root of the field "panels don't update in MP" (#45's display half).
|
||||
**Fix (both env-tunable):** `BT_BG_MIN` (APPMGR.cpp, default 32, 0=authentic) guarantees a
|
||||
minimum pump floor per frame; `BT_GAUGE_BATCH` (GAUGREND.cpp, default 32, 1=authentic) advances
|
||||
a batch of gauges per gauge turn (a visit is only a rate-mask check unless due). Verified
|
||||
4-node: sweeps 0.006/s → **18-20/s**, PilotList ~10 Exec/s, all four panels tracked every death
|
||||
within ~1s, bg cost 2-4 ms/frame. Diagnostics: `BT_PERF=1` → `[perf] … bgTasks/gaugeTurns/
|
||||
sweeps/active` 1 Hz (APPMGR.cpp); `[score] panel DRAW` edge log (btl4gau3.cpp, BT_SCORE_LOG).
|
||||
|
||||
## The COOLANT-LEAK ALARM audio chain — fully built; the shared-Stop is AUTHENTIC (#99, 2026-08-01) [T1 disasm-verified]
|
||||
|
||||
The audible leak warning is **implemented and works end to end**; an earlier note
|
||||
claiming it was "genuinely unbuilt" was wrong (asserted without checking).
|
||||
|
||||
**The chain**, traced live (all probes under `BT_ATTRBIND_LOG`):
|
||||
`HeatSink::coolantActive` (published as the `ReportLeak` attribute) changes 0->1
|
||||
-> `AttributeWatcherOf<Logical>::Execute` sees it (`[watchpoll] CHANGE`)
|
||||
-> `AudioMatchOf<Logical>::SendNotificationOfChange` fires (`[matchfire] val=1 -> ctl 1`)
|
||||
-> `AudioControlSequence::ReceiveControl(StartAudioControlID)` -> `StartSequence`
|
||||
-> an `AudioIdleWatcher` ticks it each frame (`IdleAudioControlID` -> `RunSequence`)
|
||||
-> `[seqsend]` emits the authored pattern: two chirps then an ~8s sustained tone, looped.
|
||||
|
||||
**The authored wiring (BTL4.RES): 19 subsystems, ONE shared alarm sequence.**
|
||||
`Condenser1-6, GeneratorA-D, Myomers, PPC_1/2, ERMLaser_1-3, SRM6_1/2, Avionics`
|
||||
each bind TWO `AudioLogicalTrigger`s to their own `ReportLeak` — match 1 -> ctl 1
|
||||
(Start), match 0 -> ctl 2 (Stop) — and every one of them drives the SAME sequence.
|
||||
|
||||
**Consequence, and it is AUTHENTIC:** any single subsystem clearing its leak sends
|
||||
an unconditional Stop, silencing the alarm even while others still leak; the
|
||||
still-leaking ones never re-assert because their flag has not *changed*. Reported
|
||||
from the field as a bug ("was sounding, resolved a leak, but there was another").
|
||||
**Do NOT 'fix' it** — verified in the arcade image, not merely inferred from the
|
||||
engine source lineage:
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| same MUNGA sources compiled in | `d: esla_bt\munga\AUDSEQ.CPP` / `AUDWTHR.CPP` strings present |
|
||||
| object factory | `@00466184` `case 0x4b` -> alloc **0x5C** -> ctor `@0043ccec` |
|
||||
| vtable | `0x4ead48` |
|
||||
| `ReceiveControl` | `@0043d3a4` — switch on control IDs **1 / 2 / 9 / 12** = Start / Stop / Idle / Tempo (exactly our enum) |
|
||||
| Stop case | `@0043d3ca` -> unconditional `call StopSequence @0043d2d4` |
|
||||
| `StopSequence` | tests only its OWN `isRunning` (`+0x28`), then the Chase loop — **no refcount, no awareness of other leak sources** |
|
||||
|
||||
So the arcade had the identical structure. Silence in 4.11.674 was a different
|
||||
cause: the OpenAL source pool was exhausted in every player session (see the audio
|
||||
pooling fix) — an alarm that cannot acquire a source is silent.
|
||||
|
||||
⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0
|
||||
pooled=0`), so the control chain is verified but final playback is not.
|
||||
|
||||
## Key Relationships
|
||||
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
|
||||
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
|
||||
|
||||
@@ -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.
|
||||
@@ -169,6 +190,28 @@ Verified live: two flagged windows came up caption-less while the other five kep
|
||||
still dispatched on the frameless radar (`CLICK 'Secondary / Radar' addr=0x18`), and a
|
||||
finished-drag save round-tripped both flags back into the file.
|
||||
|
||||
**The plasma window is in the list too (2026-08-04) [T2 round-trip-verified].** The desktop plasma
|
||||
display (`L4PLASMAWIN`, title `BattleTech - Plasma`) is created by a DIFFERENT TU than the
|
||||
per-display panels, so it can't be a `gWins[]` entry. Instead it registers with a small
|
||||
extern-window registry in `L4GLASSWIN` (`BTGlassLayout_RegisterExtern`), and on creation it reads
|
||||
its saved rect + `,noframe` via `BTGlassLayout_QueryWindow` (BEFORE sizing — `WS_POPUP`'s frame
|
||||
extent differs). `SaveLayout` then writes the plasma's line alongside the panels (with a
|
||||
last-known-rect cache so a teardown before the save still preserves its line), and the plasma
|
||||
WndProc calls `BTGlassLayout_Save` on `WM_EXITSIZEMOVE`/teardown. So `BattleTech - Plasma=x,y,w,h`
|
||||
appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
|
||||
`BattleTech - Plasma=321,222,…`; a reload with `,noframe` brought it up `WS_POPUP` (no `WS_CAPTION`)
|
||||
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
|
||||
@@ -200,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
|
||||
|
||||
+243
-5
@@ -206,11 +206,109 @@ master mech needs a **CollisionAssistant** (GetCurrentCollisions iterates it unc
|
||||
ship ZERO tiles (h never negative). [T2]
|
||||
|
||||
## Knockdown / crash + the desync/stutter fixes
|
||||
Wall impacts iv²>40 (~6.3 u/s) bind the `bmp` stagger clip. Two documented bug fixes: (a) the
|
||||
knockdown must stagger BOTH channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display
|
||||
+ travel split permanently (foot-slip); (b) a contact-hysteresis gate (0.4 s `gBlockCooldown`)
|
||||
stops the knockdown re-firing on sustained/glancing contact. `localVelocity` is zeroed while crashed
|
||||
(spec-faithful) so a knocked mech can't keep advancing. [T2]
|
||||
Wall impacts iv²>40 (~6.3 u/s) bind the `bmp` stagger clip. The knockdown must stagger BOTH
|
||||
channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display + travel split permanently
|
||||
(foot-slip). `localVelocity` is zeroed while crashed (spec-faithful) so a knocked mech can't keep
|
||||
advancing. **The 0.4 s `gBlockCooldown` contact-hysteresis is GONE (pricing audit 2026-07-31)**: it
|
||||
held the knockdown off for as long as contact persisted, letting the drive pay full-commanded-speed
|
||||
crash self-damage EVERY FRAME — a healthy mech dead in ~2-3 s of grinding (the night-7 SAURON ram
|
||||
death). The binary needs no such gate: the clip zeroes the drive, and a pressed mech re-triggers
|
||||
the knockdown at v>6.3 long before its grind can price anything (the type-0 divert's 0.5-pt floor
|
||||
needs v>~18.5 at 65 t) — wall grinding = free taps + ~1.2 s-paced staggers, only fast ARRIVALS pay.
|
||||
Bench: 150 s full-throttle wall push = 125 paced binds, 4,767 free-tap frames, 0 rattle, 0 deaths
|
||||
(`scratchpad/night7/mp_rampricing.sh`). The mid-clip rebind guard (`legState != 0x20`) stays. [T2]
|
||||
|
||||
**⚠ The WALL-GRIND KNOCKDOWN STORM = the surviving "#82 peers see skating" — ROOT-CAUSED + FIXED
|
||||
(2026-07-30) [T1 disasm / T2 verified].** Each stagger bind also broadcasts `ForceUpdate(0x20)` —
|
||||
the **type-5 KNOCKDOWN record** — and the replicant handler (`mech.cpp` case 5) plays the bmp clip
|
||||
AND zeroes `projectedVelocity`. A gimped mech held against a wall re-crashes each time its gg cycle
|
||||
rebuilds speed (the gg ceiling is authentically the CLIP'S NATURAL SPEED — bhk1 ≈ 14.9 u/s, ratio
|
||||
divisor + accel clamp @0x544/0x548, loader formula byte-matched — so 14.9² ≈ 222 ≫ the 40.0 crash
|
||||
threshold @0x4ab184): 26 broadcasts in one bench run, every peer's replicant in knockdown/recovery
|
||||
churn — gliding + trn leg-lifts = "skating". 4-config A/B (`scratchpad/night6/mp_gimpAB.sh`,
|
||||
`[animind]` inst=1 verdict): grass straight/circle clean (198 gimp entries; **17 car crunches
|
||||
during it — crushable icons are the `0.00123f` sentinel path, gyro kick only, NO broadcast**);
|
||||
arena1 single strike recovers (196); arena1 wall-grind = 0 gimp entries.
|
||||
**What the port was missing is the crash SELF-DAMAGE *and its own pricing path*** (two halves;
|
||||
the first alone one-shot mechs on wall taps — corrected same day): the blocking branch (export
|
||||
gap @4aa89f-4aaab4, `scratchpad/night6/gap_4a9770.txt`) fills `fallDirection`=worldToLocal(
|
||||
dmg.damageForce) (FUN_0040879c = Mᵀv, un-normalized), `fallScalar`=−(fallDirection·localVelocity
|
||||
.linearMotion), then **Dispatches `TakeDamageMessage{0x64, zone=−1, the collision Damage
|
||||
verbatim}` at its own mech** (was the long-standing "DEFERRED" note; mech4.cpp). CRUCIALLY the
|
||||
TakeDamage hub then **DIVERTS damageType 0 (@0x4a0368 → FUN_0049ffcc, gap disasm
|
||||
`gap_49ffcc.txt`) — collision damage NEVER touches zone armor.** It is the manual's
|
||||
"splash/collision damage" technician setting: gated on the player's advancedDamage copy
|
||||
(+0x268), priced `raw × (2000/mass)/(100/3.6)²/(1−elasticity²)` (a 60-ton mech: ~4.5e-5 — a
|
||||
wall tap prices ~0.05, and <0.5 is FREE), then split into 0.5-point RATTLE CRITS applied to
|
||||
random internal subsystems (HeatSink family/Gyroscope/Torso, drawn by
|
||||
`collisionCriticalHitWeight`@0x10C). See [[combat-damage]]. Verified: wall-grinding no longer
|
||||
kills or heals the limper (0 deaths, 27 rattle events, mech stays gimped) and the peer's
|
||||
replicant re-enters the gimp cycle between bounded staggers; grass circling unchanged (198).
|
||||
**Falsified en route (do not revive):** (a) "the stagger's action-request flags feed a drive
|
||||
suppressor" — image-wide + full-gap sweep shows `word[this+0x18]` is read ONLY by the net record
|
||||
emitter; (b) "the bmp clip's ~6.5 u/s root motion presses back into the wall" — measured `adv=0
|
||||
cycleSpeed=0` during state 0x20; the re-strike velocity is the REBUILT gg cycle; (c) "the gimp cap
|
||||
is ±5.8 (so √40 sits just above it)" — that number is `gimpStrideLength`@0x350 (the BACK-cycle
|
||||
figure, printed under a misleading label since renamed `ggCapL/R`); field @0x52c (`gimpSpeedMax`)
|
||||
has NO binary consumer. Advance caps, loader fills, crunch path and crash branch are all verified
|
||||
byte-faithful — the self-damage dispatch was the only gap. Replication detail in [[multiplayer]].
|
||||
|
||||
**The LAST #82 layer: the peer body-channel TRN-LOCK (root-caused + fixed 2026-07-30) [T2].**
|
||||
After the storm fix, a CIRCLING limper still skated on peers (user repro): post-knockdown recovery
|
||||
lands the peer at body-stand; the port's peer turn-step arming block (mech4.cpp `PerformAndWatch`,
|
||||
needed because the peer no longer runs the leg channel that cross-arms trn) saw
|
||||
`replMppr->turnDemand` (circling → always turning) and armed body state 4 — and NOTHING could exit:
|
||||
(a) body case 4's speed exit read the LOCAL mapper's `speedDemand`, a **dead cell on a replicant**
|
||||
(nothing writes it → 0 forever); (b) turning never stops for a circler. Peer pinned in trn
|
||||
(advance-only churn = leg-lifty skating) while `bodyTargetSpeed` held the replicated demand
|
||||
(~24 u/s full). Measured via `[replgimp]` (`bts=24.6 standSpeed=6.83 bodyState=4` in 168/171
|
||||
samples) and a `_ReturnAddress` trap on `SetAnimationState(4)` (`[trntrap]`, BT_TRNTRAP → linker
|
||||
map: ALL 60 hits from PerformAndWatch = the port's own arm, NOT the type-3 reader — the master
|
||||
broadcasts clean gimp states). Fix pair, mirroring the leg twin's authentic precedence
|
||||
(part_012.c:12013 — the Standing speed test outranks the turn test): (1) mech4 peer arm gated on
|
||||
`!wantsWalk` (`standSpeed < bodyTargetSpeed || bts < 0`), exit trn when wantsWalk; (2) mech2 body
|
||||
case 4 `bspd` reads `bodyTargetSpeed` on ReplicantInstance (the replicated demand — the same
|
||||
number the peer Standing case walks on). Verified: arena circle replicant 218×state-24/0×state-4
|
||||
(was 116×trn churn), grass circle 235-clean, knockdowns bounded, 0 deaths. **Lesson: on a
|
||||
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`
|
||||
@@ -250,6 +348,146 @@ 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).
|
||||
|
||||
## Myomer drive + the GIMP (limp) chain — COMPLETE 2026-07-30 (#75/#78)
|
||||
The speed-demand site (`mechmppr.cpp` InterpretControls) applies the **drive scale**:
|
||||
`speedDemand *= myomers.speedEffect`. The myomers factor is the wrapper's live 0..1
|
||||
output (gear/thermal/1−damage, via `BTMyomersDriveOf`); the authentic coupling attached
|
||||
`&speedEffect` into the mover's feed roster, which the 2007 engine lacks — the multiplication at
|
||||
the demand site is the port equivalent. The GIMP chain: `mechdmg` raises **graphicAlarm 3 (left)
|
||||
/ 4 (right)** when a leg zone (the `LegDamageZone`-flagged zones; MadCat: 3/5/8 left, 10/16/19
|
||||
right) crosses `LegHalfStructure` (0.5), and **reverse input is refused** ("reverse disabled",
|
||||
`[gimp]` log — and the binary agrees: the gimp gait machines have NO standing→reverse entry).
|
||||
⚠ Cross-TU reads of the gimp level MUST use `BTMechGimpLevel` (mechdmg.cpp) — see
|
||||
[[reconstruction-gotchas]] §23 (the AlarmIndicator typedef split). Harness:
|
||||
`BT_SELF_DAMAGE_ZONE=<n>` ramps one zone deterministically; `BT_DRIVE_LOG` prints
|
||||
`[drive] n/drive/mm/dmd/mech`.
|
||||
|
||||
### The VISIBLE limp — the gimp gait machines (reconstructed + bench-verified 2026-07-30) [T2]
|
||||
Bench (madcat, novice, `BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_ZONE=16 BT_SELF_DAMAGE_TICKS=2`,
|
||||
`BT_GOTO` walk): right-leg crossing → alarm 4 → body enters 0x17/wgr FROM A LEFT STEP
|
||||
(phase-correct) → both channels settle in 0x19/ggl and hold it 8k+ frames at the gimp cadence
|
||||
cap (cycle 14.77 = the wgr entry stride, vs 18.5 walk / 22+ run) while raw demand stays 50 —
|
||||
the slowdown IS the gait machine, no demand multiply. Two revive-bugs fixed en route: see
|
||||
[[reconstruction-gotchas]] §24.
|
||||
The binary's limp was hiding behind a **"jump-jet" misread**: the port had `FUN_004a5bf8` /
|
||||
`FUN_004a71f4` reconstructed as `AdvanceBody/LegAnimationAirborne`, gated on
|
||||
`(MovementMode()==3||4) && jumpCapable@0x580` and thought DEAD (mechs never jump). Truth
|
||||
[T1, raw part_012.c]: **mech+0x40 there is the graphicAlarm level (3=left gimp, 4=right)**
|
||||
and **+0x580 is `hasGimpClips`** — set by the conditional loader block that probes `wgl` and
|
||||
loads clip slots 22-27 (`wgl wgr ggr ggl gsl gsr`, +0x624..0x638) plus four measurements:
|
||||
`gimpLeft/RightSpeedMax`@0x53c/0x540 (the wg entry clips' final keyframe strides) and
|
||||
`gimpLeft/RightStrideLength`@0x544/0x548 (MeasureClipStride over the gg cycles). The five
|
||||
`jump*`-named members were renamed accordingly (mech.hpp; there is no jump clip set).
|
||||
Machinery (all four reconstructed in mech2.cpp, gates in mech4.cpp):
|
||||
- **Drivers** `AdvanceBodyAnimationGimp`@004a5bf8 / `AdvanceLegAnimationGimp`@004a71f4 —
|
||||
selected per-frame when `(gimpLevel 3|4) && hasGimpClips`; clamp the demand (body:
|
||||
`bodyTargetSpeed`; leg: the LIVE mapper `speedDemand`, written back) to the gimped side's
|
||||
speed cap and advance the gg cycles at gimp cadence. **This clamp IS the authentic gimp
|
||||
slowdown** — ground speed is clip travel, so the limp cadence bounds it; the earlier T3
|
||||
`×0.5` stand-in in mechmppr is retired (`BT_GIMP_SPEED` remains as an override, default 1.0).
|
||||
- **Finished-callbacks** `GimpBodyClipFinished`@004a6344 / `GimpLegClipFinished`@004a7970 —
|
||||
branched to from the tops of the normal `Body/LegClipFinished`. **Phase-correct entry**:
|
||||
left-gimp enters 0x16/wgl only from a RIGHT step (walk-R cases 5/6/0xe), right-gimp enters
|
||||
0x17/wgr from a LEFT step (7/0xf) — the machine forces one extra normal step if mid-wrong-foot.
|
||||
Cycles: left = 0x18 (ggr clip, stride @0x544), right = 0x19 (ggl, @0x548); exits through the gs
|
||||
transitions 0x1a/0x1b when demand dies. No reverse entry exists while gimped.
|
||||
**Turn-in-place while gimped** (field find, `95cf49d`): the binary's trn dispatcher
|
||||
(`FUN_004a9b5c`, master perf) runs OUTSIDE the driver selection, so it arms the turn step for
|
||||
gimped mechs too — the port relocated it into the NORMAL leg driver's Standing case, which
|
||||
stops running when the gimp driver takes over ("rotating statue"). The arming is now mirrored
|
||||
in `AdvanceLegAnimationGimp`. No gimp-turn clip exists — a limping mech step-turns with the
|
||||
normal `trn` clip [T1: the RES limp set is only wg/gg/gs].
|
||||
**Gimp audio — the "REVERSE DISABLED" voice IS REAL and now plays** (`1671b7d`; the earlier
|
||||
"no voice exists" verdict here was WRONG — user/old-timer pushback found it). The authored mech
|
||||
audio has **state watchers on `Entity.SimulationState == 3/4`** (the binary's one-cell mech+0x40
|
||||
= the gimp level) that start a sequence playing notes 29, 16, **40** — two klaxon hits then
|
||||
Warnings01 zone 8 (`Warnings01_z7.wav`, key 40-41) = the spoken "reverse disabled". It never
|
||||
fired in the port because of the SAME cell split that hid the limp (graphicAlarm vs engine
|
||||
simulationState, gotcha #23) — the audio watches the half the gimp system never wrote. Fixed:
|
||||
mechdmg mirrors gimp 3/4 into `SetSimulationState` at the leg-half crossing (guarded vs
|
||||
disabled/fall/dead), and `BT_GIMP_SAFE_BASE_READ` stops pre-gimp replication records from
|
||||
stomping the cell (gimp is monotonic per life). Verified: `SetupPatch bank2 patch113 note=40`.
|
||||
Layered under it: the `Entity.AnimationState` triggers on limp entries 22/23 + stops 26/27
|
||||
(+ walk-stands 8/9) play **EngineShiftRev01** (downshift foley), velocity-gained — a separate
|
||||
working layer (earlier mis-ID'd here as "the" limp audio). The earlier "watcher rebind"
|
||||
hazard was a bench artifact (the harness gimped the mech before the audio object loaded —
|
||||
impossible in play). ⚠ Open: an unidentified RAW writer (bypasses SetState; indicator-level
|
||||
trap never fired while the value changed) resets the sim cell between damage events — under a
|
||||
1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play
|
||||
damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family
|
||||
write or a struct copy spanning it).
|
||||
### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29)
|
||||
Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a
|
||||
normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]:
|
||||
- The binary's one `mech+0x40` **is** `Simulation::simulationState`, which
|
||||
`Simulation::Write/ReadUpdateRecord` puts in **every update record header** — so a peer's
|
||||
replicant learned the gimp level on every packet and its self-simulated gait limped with no
|
||||
extra plumbing. There is no gimp-specific replication anywhere in the binary because none is
|
||||
needed.
|
||||
- A replicant NEVER re-derives the level: zone damage reaches it as a DamageZone update record
|
||||
(`Entity::ReadDamageUpdateRecord` → `DamageZone::ReadUpdateRecord` writes `damageLevel` straight
|
||||
from the wire), which never runs `Mech__DamageZone::TakeDamage` — where the leg-threshold
|
||||
evaluation and the `graphicAlarm` 3/4 write live. ⚠ And zone levels replicate only when the
|
||||
**explosion table's** damage tier is crossed (`EXPTBL.cpp:512-530`
|
||||
`CrossedDamageLevelThreshold`) — coarse and lagging (measured: peer at 0.428 while the master
|
||||
was at 0.857). **Never infer damage state on a peer from replicated zone levels.**
|
||||
- The port's own `Mech::PerformAndWatch` wrote `SetMovementMode(1)` **every frame** ("ground,
|
||||
non-death, non-airborne"), which erased the gimp level from that cell once it was mirrored in
|
||||
for the voice — so the wire carried 1 and peers walked. Found with the scoped `[simstomp]` trap
|
||||
(`g_btGimpWatchMech` + module-relative return address → `symcrash.py`).
|
||||
Fix: that per-frame write now writes the authoritative level (`gimped ? 3/4 : 1`) via the
|
||||
alarm-only bridge `BTMechGimpAlarmLevel` (never the cell it feeds, so a respawn-cleared alarm
|
||||
can't re-latch a stale gimp). Verified two-node (`scratchpad/night6/mp_skate.sh`): the observer's
|
||||
replicant receives `sim=4` and its gait runs `23 → 25` (wgr entry → ggl limp cycle). This also
|
||||
made the voice fire exactly once per onset instead of restarting on every damage event, and
|
||||
re-enabled the authentic leg-destroyed→fall gate (`mechdmg` MovementMode 3||4 [T1 task #60]),
|
||||
which had been dead while the cell was pinned at 1.
|
||||
Open: the load-time per-clip callbacks `PTR_LAB_0050d738/744` on the wg clips (uncarved LABs,
|
||||
inert `Recon` placeholders in the port — likely footstep/cycle events, transitions work without
|
||||
them).
|
||||
|
||||
+46
-5
@@ -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]
|
||||
@@ -282,6 +289,18 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
|
||||
NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn:
|
||||
`Force(0x1f)` burst snaps the replicant to the drop zone + un-wrecks it. Walking replicant
|
||||
un-regressed (run states 10→12, legCycle tracking, with type-3 records flowing). Solo clean.
|
||||
- **The replicant UN-WRECK trigger is the DEATH-STATE EXIT edge, not a zone edge (#94 fix,
|
||||
2026-07-31) [T2]**: `MechDeathHandler::Tick` (mechdmg.cpp) tracks `prevMode` and fires
|
||||
`BTRebuildMechModel` + `BTStartWarpEffect` when the replicated `MovementMode()` LEAVES the death
|
||||
modes {2,9} (only `Mech::Reset`'s `SetMovementMode(0)` + `ForceUpdate(0x1f)` ever does; the state
|
||||
rides every record header). The previous trigger — a damage-zone falling-edge latch
|
||||
(`wasWrecked`, `prev>=1.0 -> <1.0`) — was BLIND to ZONE-LESS deaths: the #83 collision damage
|
||||
prices as internal rattle and never moves a zone, so a fresh mech killed by ramming left the
|
||||
latch unarmed and the peer's wreck could never un-wreck — the live respawned mech drove around
|
||||
wearing the hulk (night-7 field report: SAURON's Loki after the full-speed ram; both peers
|
||||
screenshotted it). Bench (`scratchpad/night7/mp_zoneless.sh`, `BT_SELF_DAMAGE_TYPE=collision`
|
||||
dispatches type-0 through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked
|
||||
(`mode 9->1`), zero zone-damage rises on the observer; explosive regression leg clean.
|
||||
- Telemetry: `[mrec-tx]/[mrec-rx]` per non-pose record (BT_REPL_LOG). Note the type-1 rx line
|
||||
prints a bogus simState (the damage record's own layout at +0xC) — cosmetic.
|
||||
- New by-name members: `poseSyncLatch@0x77c` (dead-reckon re-base latch, consumed by
|
||||
@@ -676,7 +695,9 @@ Only the relay host needs port forwarding. Verified: scripted protocol suite in
|
||||
2-node mission launched + stopped purely over the socket.
|
||||
|
||||
## The interest-teardown audio crash (2026-07-24) [T2 stack-captured + fixed]
|
||||
THE layout-shifting release-only crash (weeks of field flakes, #35's prime suspect) finally
|
||||
THE layout-shifting release-only crash (weeks of field flakes; was #35's prime suspect until
|
||||
2026-07-29 CLEARED it — #35 turned out to be the ParticleEngine device-reset null-deref, root-caused
|
||||
from 8 symbolized field stacks and fixed; see [[wintesla-port]] §Device-loss) finally
|
||||
hit a crash-filter build: `InterestManager::OrphanInterestOrigin → RemoveUninterestingEntity
|
||||
→ DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV
|
||||
reading NULL+0x38: the authentic burning-mech death-silence check dereferences
|
||||
@@ -800,7 +821,9 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
|
||||
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. PEER WARP IS WIRED + VISIBLE
|
||||
(`160b78e`): an observer sees a peer's un-wreck warp via the world-anchored `BTStartWarpEffect`
|
||||
(mechdmg.cpp:1074, `ReplicantInstance`-gated; `simulationState` rides every record header).
|
||||
(`MechDeathHandler::Tick`, `ReplicantInstance`-gated; since the #94 fix it fires on the
|
||||
death-state EXIT edge of the replicated `MovementMode` — cause-agnostic, so zone-less
|
||||
collision deaths warp too; `simulationState` rides every record header).
|
||||
REMAINING [T3, non-gating]: the peer sphere is anchored to the peer's WORLD position, not the
|
||||
peer's authentic `DropZoneLocation` (that attribute isn't replicated) — a fidelity refinement.
|
||||
Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged
|
||||
@@ -858,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]]
|
||||
@@ -879,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.
|
||||
|
||||
+284
-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 —
|
||||
@@ -161,6 +175,32 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
|
||||
|
||||
## Deferred subsystems / feeds (authentic path scoped, marked in code)
|
||||
- **~~Myomer seek "SUPERCHARGE"~~ — ✅ RESOLVED same day: the players' testimony (Oracle:
|
||||
seek-4 humanoid = 182 kph, overheat = freeze) prompted a re-audit that found the coupling in
|
||||
the un-exported master-perf gap (@0x4a9cf2). The manual was right, 4.10 HAS supercharge, and
|
||||
the port now implements it byte-grounded ([[subsystems]] WAVE 6 correction). LESSON: a
|
||||
"no callers in the decomp" claim is only as good as the export coverage — sweep the raw image
|
||||
for the operand pattern before declaring a data path dead ([[reconstruction-gotchas]]).
|
||||
Original entry below for the record:** The 1995 manual (p20/p22 + stat sheets) says seek changes TOP SPEED (gear 4 =
|
||||
"Supercharge", expert-only; Loki/Thor print Normal 143 / Super Charged 182 / Gimped 40 kph).
|
||||
The 4.10 binary provably has NO runtime myomer→speed coupling (full data-flow closure [T1]:
|
||||
demand fn @004afd10 all branches, all speedDemand writers, AvailableOutput's 2 callers, the
|
||||
virtual slot-0x3C sweep, the single SpeedEffect attr ref, the 0x7A0 writer set — see
|
||||
phases/phase-13). Printed ratio 1.27 vs 4.10's vestigial gear ratio 1.43 → documented
|
||||
4.0→4.10 drift. The operator is asking SAURON/Oracle whether THEIR pod-era mechs sped up at
|
||||
seek 4. If yes and wanted: resurrecting supercharge is an OPT-IN deviation — the plumbing
|
||||
(gear ratio, 0x7A0 cap, ratio² heat, heatFactor overheat-to-zero) all exists; it needs one
|
||||
authorized multiplication in the demand path. [[subsystems]] [[pod-hardware]]
|
||||
- **Environment gravity unwired — the myomer CLIMB-heat term is inert (found 2026-07-31, #85).**
|
||||
`Mover::localEnvironment` (MOVER.h:271) is declared and never populated anywhere in the port;
|
||||
`GetEnvironment()->gravityConstant` is the named analog of the 1995 Mover's gravity POINTER at
|
||||
`+0x250` (`FUN_00421e2c` does `vy -= **(+0x250)` per tick), which the myomer drive-heat
|
||||
integrator uses for its `m·g·|vy|·dt` climb-work term. The bridge (`BTMechMyomerMotionSample`)
|
||||
null-guards it, so climbing currently generates no myomer heat (flat maps: moot; cavern slopes:
|
||||
a missing cost). EnvironmentZone resources (RES type 23) exist in BTL4.RES — wiring them (or at
|
||||
least a constant-gravity Environment on mover birth) revives the term. Also relevant to any
|
||||
future airborne/jump physics (`ApplyAirResistanceAndGravity` reads the same member).
|
||||
[[subsystems]]
|
||||
- **Revolving-door Phase 2 (true mid-mission drop-in) — PLANNED, gated on a spike**
|
||||
(`docs/REVOLVING_DOOR_PLAN.md`, 2026-07-24). Open questions before any engine work:
|
||||
(a) can an `Entity::MakeMessage` be REGENERATED post-birth (serializing current state), or
|
||||
@@ -184,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**
|
||||
@@ -203,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/
|
||||
@@ -668,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
|
||||
@@ -761,6 +818,25 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
numbers no longer point at that code).
|
||||
|
||||
## Multiplayer (Phase 7 / P6)
|
||||
- **#82 wall-grind storm — CLOSED 2026-07-30** (the "stagger drive-suppressor" theory that
|
||||
briefly lived here was falsified: `word[this+0x18]` has no consumer but the record emitter).
|
||||
The real missing piece was the crash SELF-DAMAGE dispatch (binary @4aa984-4aaab4), now
|
||||
reconstructed — see [[locomotion]] §Knockdown. Residue actually still open: the crash's
|
||||
`throttleState = fall-surface material` fill (binary `(**[mech+0x2f8])+0x24`; payload nicety
|
||||
for the type-5 reader's surface-specific fall handling — needs the assistant-record map).
|
||||
|
||||
- **Cross-fire bench opens (2026-07-30, first real-weapons 4-node runs).** (a) COMBAT STALL: all
|
||||
firing and dying ceased ~3 min in (run 2, throttled `BT_AF_PERIOD=7`) with everyone alive,
|
||||
subsystems healthy, and fps fine — engagement/heat state needs a logging pass (`BT_GOTO_LOG`
|
||||
+ heat probes); run 1's max-spam stall was the documented FailureHeat all-weapons brick.
|
||||
(b) NODE CRASH: one instance died silently mid-run (no SEH line, no WER record; last log line
|
||||
= audio census; yesterday's WER archive holds 3× `OpenAL32.dll` abort `0x40000015` and WER
|
||||
dedups repeats — suspect the audio-pool thread). procdump harness now wired into
|
||||
`scratchpad/night6/mp4_cross.sh`; did NOT recur under throttled fire. (c) VERIFIED GOOD: 4
|
||||
deaths ↔ 4 kills credited to the right shooters (incl. a mutual kill), `NOCREDIT`=0, cross-
|
||||
machine damage applied on victims' masters with correct attribution; one kill credit arrived
|
||||
17 s late (`SCORE type=2`) — the deferred ScoreMessage reroute path, watch it.
|
||||
|
||||
- **Gitea #12 MP incident (2026-07-19) — root causes found; BOTH FIXES LANDED 2026-07-19
|
||||
(awaiting the human MP death-and-survive verification).** Findings [T1/T2 — see the #12
|
||||
comments + `scratchpad/incident_2157/`]:
|
||||
@@ -856,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
|
||||
@@ -879,6 +989,67 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
## Content build sub-project (low priority)
|
||||
- Lab/other-build maps (`des`/`burnt`/`frstrm`) are source-only (`.map` in CONTENT/BT/MAPS/); would
|
||||
need compiling into a RES via the DOS `btl4tool.exe`. The 8 RES maps cover testing.
|
||||
- **Offline subsystem-authoring dispatch is un-linked (known, cold, 2026-07-30).** mech3.cpp's
|
||||
`Mech::CreateSubsystemStream` dispatches to 20 per-class `CreateStreamedSubsystem` stubs whose
|
||||
guessed signatures (`void*`/`int`) don't match the real modules (nested `<X>::SubsystemResource*`
|
||||
+ trailing `ResourceFile*`, e.g. mechtech.cpp:407) — all 20 are /FORCE-silenced unresolved
|
||||
externals. No callers exist, so it cannot crash today; before wiring any authoring tool through
|
||||
it, convert each to a per-module bridge fn in a complete-type TU (gotchas §6 stub-typedef
|
||||
corollary). The parallel `DefaultData` half of the table WAS the same trap and is fixed
|
||||
(`Simulation__SharedData`).
|
||||
|
||||
## Night-8 playtest intelligence (2026-07-31, build 4.11.674) [T2 player testimony]
|
||||
|
||||
**SENSOR PANEL — the model, from a player who ran the cabinets** (VGL Lynx; the last major system
|
||||
without one; Gitea #105). Sensor damage degrades, in descending confidence: (1) the **HUD flickers**
|
||||
— corroborated by period gameplay footage, and the distinctive tell to match; (2) **radar/map**
|
||||
degraded or lost; (3) the **searchlight** (cf. #61, where Searchlight/ThermalSight ToggleLamp were
|
||||
dead handlers); (4) the **hot box / thermal** suspected but unconfirmable — that feature is not
|
||||
functional in our build. Next step is to find the sensor subsystem's damage consumers in the binary
|
||||
and check which display paths it actually gates, rather than implementing from the description.
|
||||
|
||||
**COOLANT LEAK severity is a THREE-level display** (Oracle + Draco, Gitea #97): one/two/three
|
||||
triangles, top level draining the reservoir fast, with an audible alarm. We only ever produce the
|
||||
lowest. Our leak rate is a continuous scalar (`ownZone->damageLevel x heatLoad`, floor 0.0025,
|
||||
ON hysteresis 0.003) — so either the banding into three display levels is missing, or the magnitude
|
||||
never leaves the bottom band. Check whether severity is separately authored before deriving it.
|
||||
|
||||
**MYOMER heat calibration is CHASSIS-RELATIVE, not absolute** (Oracle, Gitea #96): a **Thor should
|
||||
sustain seek 4 more or less indefinitely** on its heatsink count, while a light chicken-walker
|
||||
overheats in well under a minute. This is the acceptance test for the drive-heat coefficient — the
|
||||
spread should fall out of (drive heat / authored sink capacity) on its own, so a uniform "too hot"
|
||||
across ALL chassis indicts the coefficient rather than the sinks.
|
||||
|
||||
**RAM damage: the binary's internal-only divert MATCHES what players see, and may be correct**
|
||||
(Gitea #103). Oracle reported ram causing gyro + myomer damage and a myomer coolant leak but **no
|
||||
armor damage**, with grinding inert — exactly `Mech::DistributeCollisionDamage`'s type-0 divert
|
||||
(internals weighted Myomers/Gyro 0.35, HeatSinkBank 0.30, Torso 0.25; 0.5-pt free-tap floor).
|
||||
Awaiting a player ruling on whether a hard ram ever damaged ARMOR in the original before treating
|
||||
the absence as a defect.
|
||||
|
||||
**MP STABILITY datapoint** (RajelAran): a **5-player match with no egregious issues** — *"no
|
||||
stability problems or massive lag spikes"*. Relevant to the carried cross-fire-stall and
|
||||
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).
|
||||
@@ -929,3 +1100,97 @@ heat / FailureTemperature scaling is too aggressive, or the roach-motel (no reco
|
||||
falls) is the wrong reading. NEXT: audit the SRM6 heatPerShot vs FailureTemperature vs the mech's
|
||||
dissipation, and whether the binary's gate-1 FailureHeat is a LATCH or clears when heat drops.
|
||||
Trace it live with `BT_AMMO_LOG=1` (+ `BT_PROJ_LOG=1` for per-tick heat/recoil).
|
||||
|
||||
## Night-9 playtest intelligence (build 4.11.693, 2026-08-01)
|
||||
|
||||
**A MEMBER OF THE ORIGINAL TEAM IS NOW TESTING.** "Ronin" (log user `torak`,
|
||||
machine TOWER_OF_TERROR) worked on the 1995 game and has played the pods
|
||||
recently, including in Japan. He is a **primary source** — treat his comparisons
|
||||
against the pods as high-value evidence, above reconstruction inference. Two
|
||||
contributions already:
|
||||
* **Per-chassis cockpit BOUNCE was deliberate** [T1, testimony]: "all of the
|
||||
chassis have different cockpit 'bounce' — this was made so to help give the
|
||||
'mechs some individuality". If our port applies one shared bounce curve, a
|
||||
deliberate identity cue is missing. (Gitea #117; `jointshakey` is the likely
|
||||
carrier — its pick sphere radius already differs per chassis.)
|
||||
* **NARC may never have been implemented** [T3, recollection]: the loadout data
|
||||
shipped but he does not recall the weapon being built. Squares with what we
|
||||
see — an authored record that resolves with no distinct behaviour to
|
||||
reconstruct. Mike L. could confirm. (Gitea #104.)
|
||||
|
||||
**AUDIO #32, CORRECTED DIAGNOSIS (2026-08-02): saturation during combat, NOT
|
||||
retention** [T2 — census-measured]. An earlier version of this entry claimed the
|
||||
pool "fills and never returns a source"; that was read off the FAILURE line, where
|
||||
`free=0` is true by definition (a third instance of the counter-sampling trap).
|
||||
The 30-second census in the same logs disproves it: `free` returns to ~227-230
|
||||
between bursts and `reuses` climbs ~20/s all session — the pool cycles fine, and
|
||||
release-on-stop exists and works (the engine's steal loop:
|
||||
AudioSourceStop/SuspendMaintenance -> ReleaseChannels -> ReleaseSourceSet).
|
||||
* The real regime: during firefights CONCURRENT demand exceeds the 240 cap and
|
||||
the priority steal loop services each new sound by killing an old one,
|
||||
thousands of times per combat session. Idle standing demand is only ~13.
|
||||
* The raw `ACQUIRE FAILED` line count (6.8k-19k/log) is NOISE — the steal loop
|
||||
retries after every failed attempt, so lines pile up per event and most
|
||||
events still play via a steal. It is now rate-limited (1/30s) and the census
|
||||
carries the true metrics: `steals=` and `drops=` (drops = the steal loop ran
|
||||
dry and the sound NEVER played), plus a per-class drop histogram.
|
||||
* The saturating requester is class 1005 = **Static3DPatchSource, 4 voices** —
|
||||
world-placed effect sounds, i.e. EXPLOSIONS/impacts. Demand tracks the
|
||||
number of SHOOTERS (9-mech one-shooter bench peaks at 130 with 0 fails; a
|
||||
6-shooter field lobby pins 240). **#84's double detonation doubles exactly
|
||||
this class on observer nodes** — fix #84 first, then re-read the field
|
||||
census before touching the audio budget.
|
||||
* `BT_AUDIO_SOURCES=<n>` now raises the POOL cap too, not just the AL context
|
||||
budget (it previously did only the latter, making the experiment
|
||||
impossible). If post-#84 logs still saturate, that is the field experiment
|
||||
— with frame time measured, since more concurrent voices = more mixing CPU.
|
||||
|
||||
**Reading the missile impact line.** `[projectile] IMPACT damage=X ... burst=N`
|
||||
prints X = **per missile**; the delivered total is `X * N`. A tester reading X
|
||||
alone will report LRMs as doing ~3.3 damage when the salvo is landing up to 65.
|
||||
Field-verified across 382 impacts: LRM20 3.25 x 5..20, LRM15 3.33 x 3..15,
|
||||
LRM10 3.50 x 2..10 — burst ranges matching the binary's `Random(n) + n/4` roll
|
||||
exactly.
|
||||
|
||||
**Weapon damage values are published** in the original manuals (Oracle: "that is
|
||||
published ... Manuals had that info and it was accurate") — but NOT in the 4.0
|
||||
manual we have archived (confirmed by Lynx). A later manual, or the wayback
|
||||
capture of the original site's final build notes, would give hard per-weapon
|
||||
numbers to test the authored data against. Worth chasing: it converts weapon
|
||||
tuning from taste into arithmetic.
|
||||
|
||||
**Standard vs Expert heat is unsettled** and it contaminates every heat report:
|
||||
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.
|
||||
|
||||
+408
-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:
|
||||
@@ -208,9 +582,37 @@ every checked control behavior:
|
||||
side button columns + **EJECT button** beside the joystick + per-mech COOLANT LOOP tables
|
||||
(weapons+generators per loop 1-6) and stat sheets (tonnage/armor/reservoir liters/heat
|
||||
sinks/top speeds incl. "Super Charged").
|
||||
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
|
||||
- **MYOMER SEEK / "SUPER CHARGE" — ⚠ the "4.0→4.10 drift" verdict below was WRONG and is
|
||||
RETRACTED (same day).** 4.10 HAS the seek→speed coupling — in the un-exported master-perf
|
||||
gap (@0x4a9cf2: `speedDemand *= max myomer speedEffect`, turn-freeze at dead drive; see
|
||||
[[subsystems]] WAVE 6 correction). The manual is ACCURATE for 4.10; Oracle's pod memory
|
||||
(seek-4 humanoid = 182 kph = the printed figure) confirmed it and prompted the re-audit.
|
||||
The paragraph below is retained as the record of the error (its BINARY facts — the two
|
||||
exported AvailableOutput callers, the single attr ref — were correct but incomplete:
|
||||
export-gap functions are invisible to decomp text sweeps).
|
||||
~~ORIGINAL (WRONG) VERDICT:~~
|
||||
Manual p20/p22: seek "translates to a lower or higher top speed", gear 4 is named
|
||||
**Supercharge** (screenshot caption "Seek Level Is Set to Supercharge"), expert-only ("in
|
||||
standard simulation mode, you are not able to supercharge"), and "overheated myomers can
|
||||
reduce your 'Mech's speed to zero". Stat sheets print THREE top speeds (Loki/Thor: Normal
|
||||
143 / **Super Charged 182** / **Gimped 40** kph). The 4.10 BINARY dropped the runtime
|
||||
coupling entirely: the mapper demand @004afd10 has no myomer/seek term, `AvailableOutput`
|
||||
has exactly two callers (assembly cap + graph), and the SpeedEffect attribute string has one
|
||||
reference (its own table row). Even the printed ratio disagrees with 4.10's authored gears
|
||||
(182/143 = 1.27 vs 0.9999/0.7 = 1.43) — the gear table and the assembly cap are vestiges.
|
||||
In 4.10 the seek dial's live effects are heat (ratio² ≥ 1), the generator brown-out
|
||||
threshold (whose real stake is TORSO TWIST — see [[subsystems]] WAVE 6), and the ENG graph.
|
||||
"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~~ ✅ **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`.
|
||||
|
||||
@@ -121,6 +121,17 @@ mislanch.cpp's extern but not projweap.cpp's → first AUTOCANNON shot AV'd). **
|
||||
bridge-signature change, `grep -rn "extern .*<name>"` and update every declaration; then grep
|
||||
the fresh link output for the symbol name** — the pre-existing LNK2019 wall camouflages new
|
||||
entries if you only eyeball it.
|
||||
**Stub-typedef corollary (mech3 tool path, 2026-07-30):** a LOCAL stub TU that re-declares a
|
||||
sibling class's STATIC MEMBER with the wrong typedef mangles to a **ghost symbol** — mech3.cpp
|
||||
declared every `<Subsystem>::DefaultData` as `Mech::SharedData` (= `Entity__SharedData`) while
|
||||
the real definitions are the inherited `Simulation__SharedData` (in THIS engine `Entity` derives
|
||||
from `Simulation`; `Entity__SharedData : public Simulation::SharedData`, ENTITY3.h:9) → ~20
|
||||
unresolved externals, all /FORCE-silenced, all cold (offline authoring dispatch has no callers).
|
||||
Fixed for the DefaultData statics; the `CreateStreamedSubsystem` stub SIGNATURES are still wrong
|
||||
(real ones take the class's NESTED `SubsystemResource*` + trailing `ResourceFile*`) and stay
|
||||
unresolved-by-design until bridged per-module ([[open-questions]]). **Rule: when a "benign" LNK
|
||||
wall exists, `tail` on the build output hides the fleet — always grep the FULL log; and verify a
|
||||
stub's member TYPE against `dumpbin /symbols` of the defining obj, not against what looks right.**
|
||||
**Duplicate-GLOBAL corollary (glass per-display windows, 2026-07-20):** a global DEFINED in two
|
||||
libs (the 1995 headers declare free globals without `inline`/`extern`, so `application`,
|
||||
`ghWnd`, … exist in BOTH `munga_engine` and `bt410_l4`) links under `/FORCE:MULTIPLE` with
|
||||
@@ -267,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
|
||||
@@ -532,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).
|
||||
@@ -623,3 +668,349 @@ trusting what it is called — the reconstruction's names are reconstructions to
|
||||
|
||||
**Sweep note:** the same misnomer sits in all three headers; renaming is safe (no binary meaning
|
||||
attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately.
|
||||
|
||||
## 23. `AlarmIndicator` is a DIFFERENT TYPE per header family — Mech's layout diverges across TUs (2026-07-30)
|
||||
|
||||
Found chasing #78: `mechdmg.cpp` wrote `mech->graphicAlarm.SetLevel(4)` and read 4 back;
|
||||
`mechmppr.cpp` read **0** from the *same object, same expression, same frame family*. Root cause:
|
||||
|
||||
```cpp
|
||||
mech.hpp:67 typedef ReconAlarm AlarmIndicator; // 4 bytes {unsigned level}
|
||||
heat.hpp:52 typedef GaugeAlarm AlarmIndicator; // 0x54 bytes (the real binary alarm)
|
||||
```
|
||||
|
||||
`Mech::graphicAlarm` is declared `AlarmIndicator` — so a TU's include ORDER decides which type
|
||||
that member is, and **every Mech member after it shifts by 0x50 between the two families**. A
|
||||
write through one family's layout is invisible to a read through the other. This is the shadow
|
||||
trap operating at the TYPEDEF level, where no compiler error can catch it (each TU only ever sees
|
||||
one definition).
|
||||
|
||||
**Rule:** any cross-TU read/write of a Mech member declared past `graphicAlarm` must go through a
|
||||
bridge compiled in a KNOWN TU (`BTMechGimpLevel` in mechdmg.cpp is the pattern) until the typedef
|
||||
split is audited and unified. The audit itself — which TUs resolve `AlarmIndicator` to which
|
||||
type, and which member traffic crosses families — is an open work item; the same split-brain also
|
||||
explains why `MovementMode()` (the engine `simulationState`) and the KB's "movementMode IS the
|
||||
graphicAlarm level" (task #1) never actually met in the port: they are two different cells, both
|
||||
alive, written by different subsystems.
|
||||
|
||||
## 24. Reviving DEAD reconstructed code replays every port-glue fix it predates (2026-07-30)
|
||||
The gimp gait drivers (`AdvanceBody/LegAnimationGimp`, ex-"Airborne") sat reconstructed-but-dead
|
||||
for weeks while their gate read the wrong cell (gotcha #23's split-brain). The moment the gate
|
||||
was fixed and they ran for the first time, they reproduced — within seconds of engagement — TWO
|
||||
bugs the LIVE drivers had each been cured of long before:
|
||||
1. **the raw `*(this->controlSource)` mapper read** (`controlSource@0x128` is never wired in the
|
||||
port; the live drivers were converted to `MappingMapper()` — the dead one crashed at
|
||||
`AdvanceLegAnimationGimp+0x16`, null deref, first live engagement);
|
||||
2. **the missing alarm→member state re-sync** (`bodyAnimationState = bodyStateAlarm.GetLevel()`;
|
||||
the binary has ONE cell, the recon has two — the ground drivers re-sync at :831/:1181; the
|
||||
dead driver didn't, so the state member froze at its pre-gimp value and the machine pinned in
|
||||
one run state for 8000+ frames).
|
||||
**Rule:** before wiring a long-dead reconstructed function into a live path, diff it against its
|
||||
LIVE sibling for the port-glue idioms (mapper access via `MappingMapper()`, alarm→member
|
||||
re-syncs, null guards, `BTEnvOn` gates). The binary-faithful parts transplant cleanly; it is the
|
||||
RECON-side glue that will be missing, because every glue fix landed only where code was running.
|
||||
|
||||
## 25. A SPLIT cell breaks REPLICATION silently — and per-frame writers erase the half you added (2026-07-29)
|
||||
The gimp-level saga's second act (#82). When the port carries one binary cell as TWO members, the
|
||||
damage isn't only cross-TU reads (gotcha #23) — it is **which half rides the wire**:
|
||||
- `Simulation::simulationState` is replicated in EVERY update record header
|
||||
(`Simulation::Write/ReadUpdateRecord`). Any binary state living in `mech+0x40` therefore
|
||||
replicated for free. The port's parallel member (`graphicAlarm`) replicates **nowhere**, so every
|
||||
behavior a peer derives from it silently becomes master-only — visible as "peers see something
|
||||
different" bugs (here: a limping mech that skated on every other pod).
|
||||
- Worse, the engine cell usually already has a **per-frame writer** with a narrower idea of what it
|
||||
means (`Mech::PerformAndWatch`: `SetMovementMode(1)` = "ground, non-death, non-airborne"). The
|
||||
moment you mirror extra semantics into it, that writer erases them 60×/second — and the symptom
|
||||
is not a stuck value but a 1→N→1 oscillation whose edges retrigger anything watching (the
|
||||
warning-voice sequence restarted on every damage tick and never reached its spoken note).
|
||||
**Rules:**
|
||||
1. Before mirroring into an engine cell, `grep` for its per-frame writers and teach them the new
|
||||
value (write `gimped ? 3/4 : 1`, not a blind `1`).
|
||||
2. Read the authority, never the cell you feed — an alarm-only bridge (`BTMechGimpAlarmLevel`)
|
||||
keeps a respawn-cleared alarm from re-latching stale state out of the cell.
|
||||
3. Don't "fix" a stomp by patching incoming records: on a replicant the master's records ARE the
|
||||
authority, and pinning a local value against them makes state stick forever (that draft would
|
||||
have kept peers limping through a respawn). Find the writer instead.
|
||||
**Tool:** scope the trap. `StateIndicator`/`Simulation` state traps drown in subsystem churn —
|
||||
filter to one watched object (`g_btGimpWatchMech`) and print the caller module-relative
|
||||
(`btl4+0x…`) so `tools/symcrash.py` names it. That turned a two-hour guess into one line.
|
||||
|
||||
|
||||
## §21 — Export-gap blindness: "no callers in the decomp" is NOT "no callers in the binary"
|
||||
**(2026-07-31, the myomer seek retraction — the costliest wrong verdict to date.)** The Ghidra
|
||||
export has GAPS (gitea #60): whole functions absent from `reference/decomp/all/*.c` — the
|
||||
master-perf region 0x4a9770-0x4ab188, the 0x4a03xx crit caller, @004b838c, and others. A text
|
||||
sweep of the decomp for callers/readers of a symbol **cannot see a consumer that lives in a
|
||||
gap**. The myomer `speedEffect@0x31C` demand feed was declared dead on exactly such a sweep
|
||||
("two AvailableOutput callers, one attribute reference, no raw readers") and an already-correct
|
||||
port mechanism was deleted on that basis — while the real consumer sat un-exported at
|
||||
@0x4a9cf2 (`speedDemand *= max myomer speedEffect` + the dead-drive turn freeze). Field
|
||||
testimony (Oracle: pod seek-4 = 182 kph, the manual's printed figure) forced the re-audit.
|
||||
**RULE: before declaring a data path dead, sweep the RAW IMAGE for the operand pattern** —
|
||||
capstone over the CODE section for the addressing form (e.g. FPU reads of `[reg+0x31C]`, SIB
|
||||
`[reg+reg*4+0x330]`). Minutes of scanning; it found in one pass what three decomp sweeps
|
||||
missed. Corollary: when the decomp and a PRIMARY SOURCE (the manual, a pod veteran) disagree,
|
||||
treat the disagreement as a hole in YOUR evidence first, not in theirs.
|
||||
|
||||
|
||||
## §22 — The SPLIT CELL: one binary offset, two port members (only one gets written)
|
||||
**(2026-07-31, gitea #86 "destroyed weapons keep firing".)** The binary's weapon fire gates test
|
||||
`subsystem+0x40`. In the 1995 layout that offset is *inside* the embedded status alarm
|
||||
(`statusAlarm@0x2C` + the indicator's level at `+0x14` = `0x40`) — **one cell**, written by
|
||||
`ForceCriticalFailure` when a zone's crit-cascade kills the subsystem. The port models the same
|
||||
address as TWO independent members: `AlarmIndicator statusAlarm` **and** a plain
|
||||
`int simulationState@0x40`. Every destruction path writes the ALARM (so the MFD correctly draws
|
||||
its X, the paper doll correctly greys the mount) while the fire gates read the plain int — which
|
||||
nothing ever writes. Result: a weapon on a blown-off arm shows destroyed on every panel and
|
||||
keeps firing and scoring, on the shooter's screen and on peers' (all three night-7 testers).
|
||||
**Detection smell:** a diagnostic that prints the gate's own inputs and shows a state flag
|
||||
reading 0 while the UI bound to "the same" state shows destroyed. (`[ammo] NoAmmo (gate1):
|
||||
destroyed=0` on a mech with an X'd-out launcher was the tell — it sat in the logs for weeks.)
|
||||
**Rule:** when a binary offset falls inside an embedded object in OUR layout, do not mirror it as
|
||||
a sibling scalar — read it through the object that owns it, or (if a duplicate member already
|
||||
exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed
|
||||
base field): same failure shape — two cells where the binary has one, and the readers pick the
|
||||
dead one.
|
||||
|
||||
## §23 — Scaling `D3DMATERIAL9` does NOTHING for BGF geometry (the vertex-colour diffuse source)
|
||||
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The 1995 armour damage darkens a
|
||||
mech by scaling its MATERIALS' colour terms (`dpl_SetMaterialAmbient/Emissive/Diffuse/Specular`).
|
||||
The obvious port translation — scale the draw op's `D3DMATERIAL9` before `SetMaterial` — renders
|
||||
**byte-identically**: measured **0 changed pixels** at a full 0.1x scale.
|
||||
**Why:** D3D9 defaults `D3DRS_DIFFUSEMATERIALSOURCE` to **`D3DMCS_COLOR1`** (and `D3DRS_COLORVERTEX`
|
||||
to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted.
|
||||
Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is
|
||||
unlit and coloured by vertex/ramp), so for essentially all world geometry `SetMaterial` is inert
|
||||
for colour. The material only matters for lit, normal-bearing meshes.
|
||||
**Fix pattern:** modulate the FINAL fragment instead — `D3DRS_TEXTUREFACTOR` + a
|
||||
`MODULATE(CURRENT, TFACTOR)` on texture **stage 1** (unused in `d3d_OBJECT::Draw`), restored to
|
||||
`D3DTOP_DISABLE` after the op. That darkens the result whatever the diffuse source was, and
|
||||
equally covers the ramp-baked-texture path and pure-emissive batches.
|
||||
**Detection smell:** a colour change that logs perfectly at the source and produces zero visible
|
||||
difference. Diff two runs pixel-wise against a control region before believing a colour path
|
||||
works — "the log says 0.1x" is not evidence that anything reached the screen.
|
||||
|
||||
## §24 — A verified fact, OVER-GENERALISED, becomes a wrong design premise
|
||||
**(2026-08-01, gitea #95 "missiles land 3 points instead of 50".)** The KB carried this, tagged
|
||||
**[T1]**: *"`DamageZone::TakeDamage` is `damageLevel += amount*scale` and IGNORES `burstCount` — so
|
||||
`burstCount` is cosmetic for zone damage."* The first clause is **true and byte-verified**
|
||||
(`@0041e4e0`). The second is an **inference** that was written down beside it, inherited the [T1]
|
||||
tag, and then justified a design decision (task #62's salvo-lead model) that silently divided every
|
||||
missile salvo by its missile count for months.
|
||||
**What was actually true:** `TakeDamage` ignores `burstCount`; the **CALLER**
|
||||
(`Mech::TakeDamageMessageHandler` @0x4a0423-0x4a04d8) honours it by calling `TakeDamage` that many
|
||||
times, re-rolling the zone per burst. The consumer was one stack frame up — the same blind spot
|
||||
shape as §21 (export-gap blindness), but with the evidence *present* and simply not followed
|
||||
outward.
|
||||
**Detection smell:** a "cosmetic"/"unused"/"vestigial" claim about a field that other code still
|
||||
computes carefully. Here the binary randomises `burstCount` at impact (`Random(n) + n/4`) and the
|
||||
splash code derives it from a distance falloff — nobody spends instructions rolling a decorative
|
||||
value. **If a field is called dead, ask who still writes it, and why.**
|
||||
**Rule:** tag the VERIFIED clause, not the paragraph. An inference sitting next to a [T1] fact is
|
||||
still [T4] — split them, or the next reader (including you) will build on the guess.
|
||||
|
||||
## 26. The SILENT-STUB walk: empty iterators make a faithful-looking mechanism a no-op (#110)
|
||||
|
||||
`RecurseSegmentTable` was "reconstructed" against local shim types whose
|
||||
iterators unconditionally returned NULL (`SegmentIterator::Next() { return 0; }`,
|
||||
`SegTableX::operator[] { return 0; }`). Every line of the walk mirrored the
|
||||
binary -- and none of it did anything: the destruction cascade fired, logged,
|
||||
"descended", and touched no other zone. The symptom (gun pods surviving their
|
||||
blown-off arms) was field-reported for weeks while the code READ correct.
|
||||
|
||||
The trap: a stub that returns EMPTY (rather than asserting/crashing) converts a
|
||||
reconstruction into silent fiction, and the no-stand-ins rule cannot catch it by
|
||||
reading the call site -- the stand-in hides in the TYPES. The engine already had
|
||||
the real structures (`EntitySegment::damageZoneTable` / `childIndexTable`, T0,
|
||||
byte-matching the decomp's seg+0xD0/+0xE8) -- the stub was never necessary.
|
||||
|
||||
Rules: (a) when a reconstructed mechanism runs but nothing downstream changes,
|
||||
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).
|
||||
|
||||
@@ -58,4 +58,5 @@ the ground-model decode (10 agents), the alarm-unification (8), the gauge-widget
|
||||
|
||||
## Key Relationships
|
||||
- Bug classes: [[reconstruction-gotchas]] (check FIRST). Reference data: [[decomp-reference]].
|
||||
- Verification: [[test-harness]] — the bench contract + what counts as VERIFIED (field composition, not proxies).
|
||||
- Why it's needed: [[source-completeness]].
|
||||
|
||||
+108
-4
@@ -220,8 +220,13 @@ i860 firmware** `content/VREND.MNG` (same decode workflow as the task-#55 'damag
|
||||
- Remaining [T3]: sfx cone shape (v_bias as up-bias), size ×0.5 + 1.25m textured floor +
|
||||
hot-phase occlusion 0.45..0.9, burst scatter ×0.2; trail
|
||||
density is frame-rate-dependent (2/frame @60fps = 2× pod density); .PFX 'repeats' unconsumed
|
||||
(all shipped files author 1); replicant visual salvo chases the stale fire-time aim point
|
||||
(no re-lead without a target handle).
|
||||
(all shipped files author 1); replicant visual salvo FIXED (2026-08-02, #84): the
|
||||
update record now carries the locked target's EntityID; the mirror resolves it
|
||||
locally (HostManager::GetEntityPointer, engine T0) and homes at the LIVE
|
||||
replicant, as the binary's GHOST missiles did (CLASSMAP: Projectile
|
||||
PTR_LAB_005129e8 authoritative / 005129f4 ghost performances) -- peers now see
|
||||
detonations at the true position. Point fire / local-only targets (wrecks,
|
||||
hostID -1) still fly to the frozen aim, which is correct for them.
|
||||
|
||||
## Weapon beams (the unported dpl_* layer)
|
||||
The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`/`BTDrawBeams`
|
||||
@@ -255,8 +260,10 @@ streamed via INTEREST.cpp; ARENA1 = 124 icons, each with a damage zone, `Explosi
|
||||
TakeDamageMessageHandler` → zone `damageLevel ≥ 1` → **Explosion entity** (Crunch variant for
|
||||
collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes
|
||||
DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch
|
||||
(`mech4.cpp ProcessCollision` → `BTDispatchCollisionDamage`, ×0.001 ram economy) reaches icons
|
||||
(≈19 walking-speed bumps kill the spawn-yard truck). What was BROKEN (the "no state change"
|
||||
(`mech4.cpp ProcessCollision` → `BTDispatchCollisionDamage`, RAW since the 2026-07-31 pricing
|
||||
audit — the ×0.001 era needed ≈19 walking-speed bumps to kill the spawn-yard truck; raw is the
|
||||
binary's own dispatch [T1], so a mech contact now crunches a truck-scale icon outright, per the
|
||||
authored icon armor). What was BROKEN (the "no state change"
|
||||
report): the 2007 port fully stubbed **`StateInstanceSwitchRenderable`** ("STUBBED: DPL RB
|
||||
1/14/07") — the intact→rubble visual swap NEVER fired. Revived D3D9-native: the switch now
|
||||
controls the draw component via the **`SetDrawObj` in-place drawable swap** (the mech
|
||||
@@ -319,6 +326,103 @@ Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object
|
||||
flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested
|
||||
cross-pod).
|
||||
|
||||
## Mech ARMOUR DARKENING (the .DZM material-damage system) — RECONSTRUCTED 2026-07-31 (Gitea #87) [T2]
|
||||
A mech's armour panels **darken as their damage zone takes damage** — a per-zone, per-material
|
||||
colour ramp-down, NOT a texture swap, a filter, or geometry. Players reported the port showing only
|
||||
the limb swap ("the actual enemy mech in external view is not showing darkened armor panels").
|
||||
|
||||
**The 1995 chain** [T1]:
|
||||
- **`.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 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
|
||||
port all along**; only the consumer was missing.
|
||||
- **`BTL4VideoRenderer::MakeMechRenderables` (`FUN_004cef28`)** walks the zones and builds, per
|
||||
(zone, material), a 232-byte watcher **`FUN_004573e4(material, &zone->damageLevel, 0.1f)`**
|
||||
(a 60-byte per-zone parent first, factor 0.04). Error string: *"BTL4VideoRenderer: Material name
|
||||
<X> could not be found"* @0x51d6f8.
|
||||
- The watcher snapshots the material's **16 authored colour floats** — Ambient(3), Emissive(3),
|
||||
Diffuse(3), Specular+shininess(4), Opacity(3) — precomputes a DAMAGED set = **pristine x 0.1**
|
||||
(`0x3dcccccd`), and **`FUN_00457784`** re-pushes `lerp(pristine, damaged, damageLevel)` whenever
|
||||
the level changes (constant `@0x4579a4` = **1.0**). So a zone at full damage renders at **10%**
|
||||
of authored brightness, continuously interpolated.
|
||||
- **Faithful quirk:** Opacity is read and lerped but **never written back** — only 13 of the 16
|
||||
values are pushed (`dpl_GetMaterialOpacity` has no matching Set call in the watcher).
|
||||
|
||||
**NOT the mechanism** [T2]: the engine also has `SV_SPECIAL DAMAGE <material>` /
|
||||
**`dpl_Damagize` `FUN_004902b0`** (punch's sibling — same VPX cmd 0x20, tokens
|
||||
`f_t_dam(0x92)/sca(0x05)/undamage(0x04)`, dispatch @0x4599ae; error *"SPECIAL GEOGROUP DAMAGE
|
||||
couldn't find material"*). A scan of **all 879 shipped BGFs finds ZERO `DAMAGE` tokens** — the path
|
||||
exists in the engine but no shipped asset uses it. Don't chase it.
|
||||
|
||||
**The port** (`game/reconstructed/btl4vid.cpp` `BindArmourDamage`/`TickArmourDamage`,
|
||||
`engine/MUNGA_L4/L4D3D.cpp`): batches now carry their AUTHORED (pre-substitution) material name
|
||||
(`BgfDrawBatch::matName` -> `L4DRAWOP::dzMatName`); binding resolves the .DZM lists onto real draw
|
||||
ops once per tree build, and the per-frame tick (driven from `Mech::PerformAndWatch`, EVERY mech)
|
||||
copies `zone->damageLevel` onto them. Two hard-won details:
|
||||
- **Key the material list on `viewSkeleton`, not the build `skeletonType`.** Each skeleton variant
|
||||
is painted from its own skin library (Black Hawk N = `blhskin:`, X = `blxskin:`) and
|
||||
`ApplyViewSkeleton` reloads every segment mesh; keying on the build skeleton matched a list
|
||||
belonging to geometry that is not on screen -> **zero bindings, no darkening**.
|
||||
- **Scaling the `D3DMATERIAL9` is inert** — see gotcha **§23** (`DIFFUSEMATERIALSOURCE` defaults to
|
||||
vertex colour). The darkening is applied as a `TEXTUREFACTOR` modulate on texture stage 1.
|
||||
Rebinding runs after every geometry swap (`RemakeEntityRenderables`, `ApplyViewSkeleton`), which
|
||||
also keeps `segPick` (the #73 aimed pick) off discarded meshes.
|
||||
Bench: `scratchpad/night7/armor_darken.sh` (self/enemy/ext legs) + `armor_ab.sh` (forced-level
|
||||
pixel A/B). Gates `BT_ARMOR_LOG` / `BT_ARMOR_FORCE=<0..1>`. Verified: 57 bindings on the enemy
|
||||
mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control 0.98x at level 1.
|
||||
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.
|
||||
|
||||
+141
-13
@@ -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`
|
||||
@@ -66,7 +69,72 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
change needs its own verification pass).
|
||||
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
|
||||
faithfully no visible twist).
|
||||
- **WAVE 6** — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling).
|
||||
- **WAVE 6 — ✅ MYOMER SYSTEM COMPLETE (2026-07-31). The "mover cutover" concept is DEAD — it
|
||||
was built on a misattribution.** `@004b9550/@004b95b8` (the "ConnectToMover/DisconnectFromMover"
|
||||
bodies) are **MechWeapon::ConfigureMappables/ChooseButton** — their `+0x31C` is the weapon
|
||||
TriggerState and `**(mech+0x128)` is the CONTROLS MAPPER's button roster (proven by the
|
||||
MECHWEAP.CPP assert string + the trigger-edge detector @004b9608 adjacent).
|
||||
**⚠ SAME-DAY CORRECTION (the seek audit): the binary DOES have a dynamic myomer→speed feed —
|
||||
it lives in the un-exported master-perf gap** (@0x4a9cf2-0x4a9da4, raw capstone; the same gap
|
||||
that hid the #93 crash block). Per frame: MAX `speedEffect@0x31C` over the myomer chain
|
||||
(+0x7AC) → `mech+0x79C` → **`mapper->speedDemand@0x128 *= it`**, and at |drive| ≤ 1e-4
|
||||
(@0x4ab16c) the mapper's `turnDemand@0x12C` is ZEROED — dead/overheated myomers freeze speed
|
||||
AND turn. The morning's "no feed" verdict was export-gap blindness (text sweeps cannot see
|
||||
un-exported functions; the decisive tool was a raw-image capstone scan for FPU reads of
|
||||
+0x31C). Field truth agrees: Oracle (pod veteran) — seek-4 humanoids ran 182 kph (the
|
||||
manual's printed Super Charged figure), overheat = "freezing up". The port restored the
|
||||
demand scale byte-grounded (mechmppr.cpp): MAX over the chain, UNCLAMPED (gear 4 rides ~1.43×
|
||||
demand into the RegisterMaxOutput cap = SUPERCHARGE), turn-freeze included; damage slows
|
||||
(speedEffect carries 1−damage — gitea #75's original premise was RIGHT).
|
||||
The rest of the myomer system, all live in the port:
|
||||
1. **Heat** — the drive-heat integrator (#85, below).
|
||||
2. **Vital death** — myomers are the ONLY `vital=1` subsystem in the authored records
|
||||
(BTL4.RES `res+0x48` scan): destroying them KILLS the mech (the #80 vital-crit path). This
|
||||
is also the collision ram-death mechanism (myomers are a 0.35-weight rattle target).
|
||||
3. **The assembly cap** — `RegisterMaxOutput @004b8ef0`: `mech+0x7A0 = max(cap,
|
||||
AvailableOutput(topGear))` = base × ~1.4284, the gait rise-clamp both channels bound
|
||||
against (part_012:12103/12334). Port: each myomer registers per tick (idempotent max();
|
||||
the 0x358 layout lock leaves no latch room) — state-identical to once-at-assembly. The
|
||||
old per-frame `reverseSpeedMax2 = reverseStrideLength` heal (which would have clobbered
|
||||
it) is garbage-guarded only now. `Mech+0x34C` (base) = port `reverseStrideLength`,
|
||||
`Mech+0x7A0` (cap) = port `reverseSpeedMax2` — both documented misnomers.
|
||||
4. **The ENG-page power curve** — SeekVoltageResponse, now at the correct absolute scale
|
||||
(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 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,
|
||||
gears 1-3 are literally identical (heat ratio floors at 1, no speed coupling); gear 4 =
|
||||
double heat + a tighter dropout threshold for nothing but the taller graph — a trap.
|
||||
(A `speedDemand *= speedEffect` multiplier was removed in the morning commit and RESTORED,
|
||||
byte-grounded and improved, the same day — see the correction above. Verified live: healthy
|
||||
drive=1.0, collision-rattled myomers drive 0.89→0.74 with demand scaling in lockstep.)
|
||||
**✅ MYOMER DRIVE HEAT LIVE (#85, 2026-07-31) [T2].** The five Mech motion operands of the
|
||||
drive-heat integrator `@004b8d18` were `return 0.0f` cross-family shims, so the (fully
|
||||
reconstructed) integrator accumulated `ratio² · damageGain · 0` every tick — myomers ice cold at
|
||||
any seek (Oracle's night-7 panel-confirmed report). Operands re-grounded from the raw disasm +
|
||||
the decomp and mapped to named members via the complete-TU bridge `BTMechMyomerMotionSample`
|
||||
(mech4.cpp): `+0x1C4` vec = `localVelocity.linearMotion`; `+0x82C` vec =
|
||||
`localAcceleration.linearMotion` (the authentic 15-ring smoothed AccelerationLastFrame);
|
||||
`+0x20C` = `moverMass` (the collision divert's cell — the old "motion gain" label was wrong);
|
||||
`*(+0x250)` = the environment **gravity pointer** (`FUN_00421e2c` does `vy -= **(+0x250)`/tick).
|
||||
Physics: `heat += ratio²·(1+dmg)·[0.005·½mv² + 0.2·m·g·|vy|·dt + 0.2·m|v||a|·dt]` — kinetic work
|
||||
+ climb power + acceleration power. The binary `fabs`es v.y (the port's old draft didn't).
|
||||
**Authored tuning extracted from BTL4.RES** (all 18 mech variants share one record):
|
||||
VelocityEfficiency=0.995, AccelerationEfficiency=0.8, seek gears 0.3/0.5/0.7/0.9999 rec idx 2;
|
||||
myomer thermal profile startT=77 / degradation=1000 / failure=2000 / conductance=1.9e5 /
|
||||
thermalMass=2.5e5. Live-verified (solo arena, BT_MYO_LOG): standstill ≈ 0 generation, hard
|
||||
circling drove T 77→1225 (past degradation) and the coolant loop pulled it back to ~520
|
||||
equilibrium on slowing. ⚠ Two carried caveats: (1) the kinetic work term has **no dt** in the
|
||||
binary (per-tick accumulate, a 1995 fixed-frame assumption) → heat/s scales mildly with frame
|
||||
rate; kept verbatim, field-calibrate against pod veterans. (2) the climb term is wired but
|
||||
inert: `Mover::localEnvironment` is never populated in the port (EnvironmentZone res type 23
|
||||
unwired), so g reads 0 — see [[open-questions]].
|
||||
⚠ **The registered Performance is the WRAPPER @004b8b9c, not the inner integrator @004b8d18**
|
||||
[T1, fixed 2026-07-28]. The ctor @004b8fec stores `[0x511620]` into activePerformance, and that
|
||||
pointer resolves to `0x4b8b9c`, whose first instruction is `call 0x4b0bd0` =
|
||||
@@ -92,18 +160,16 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**.
|
||||
Verified live: healthy `outV=10000 speed=1`, un-powered `outV=0 speed=0`, and 96/96 torso samples at
|
||||
`elec=4` across two death/respawn cycles.
|
||||
⚠ **`@004b8bb9` is DEAD CODE — in the original too** [T0]. It builds a `Damage`
|
||||
(type=`Explosive`, amount=`0xbc343958`≈-0.011f, impactPoint=`owner+0x100`, burst=1) and calls the
|
||||
**zone's** `TakeDamage` (zone vtable `+0x18`, *not* the subsystem's `+0x24`). Reading it as
|
||||
"an un-powered myomer heals" is the evident intent, but it can never fire: a subsystem's private zone
|
||||
comes from the 2-arg `new DamageZone(this,0)`, and `engine/MUNGA/DAMAGE.cpp:187-190` zeroes **all five**
|
||||
`damageScale[]` entries; `Reset()` never touches them and the only other writer is `Mech__DamageZone`
|
||||
(the mech's *streamed* zones — `mechdmg.cpp:246-253`). Since `TakeDamage` is
|
||||
`damageLevel += amount * damageScale[type]`, the sum is always `+= amount * 0`. Confirmed by
|
||||
experiment: seeded to 0.6 and held at NoVoltage ~1500 ticks, `damageLevel` never moved.
|
||||
Reconstructed and deliberately NOT "fixed" — inventing a working repair would be made-up behavior.
|
||||
**General rule:** you cannot damage a subsystem's own zone through `DamageZone::TakeDamage`; the crit
|
||||
path writes `damageLevel` directly (`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`).
|
||||
✅ **CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in 1995 and
|
||||
now in the port.** The 2026-07-28 "dead code in the original too" verdict here was wrong about the
|
||||
original: the frozen-at-0.6 experiment was measuring a PORT bug (the subsystem ctor wrote the
|
||||
zone's armour/scales through the `ReconDamageZone` proxy at struct offsets +4/+8 instead of the
|
||||
engine members at +0x140/+0x144, so the real `damageScale[]` stayed zero). The binary ctor
|
||||
(`@0x4ac7bb`) initializes them from the resource keys `WeaponDamagePoints` + the five per-type
|
||||
`...DamagePoints`; the port now does the same through the engine's named members, and the CSS
|
||||
parses the keys. An un-powered, not-yet-destroyed myomer heals at `0.011 × damageScale[Explosive]`
|
||||
per tick. The retracted "cannot damage a subsystem's own zone" rule is superseded — see
|
||||
[[combat-damage]] §CORRECTED for the full mechanism (this also revived the crit sink, #80).
|
||||
Also un-stubbed here: `Myomers::DamageStructureLevel()` returned a hardcoded `0.0f`, which pinned
|
||||
`AvailableOutput`'s `(1 - damage)` factor at 1 — a shot-up myomer drove exactly as well as a fresh
|
||||
one. Now routed to the base bridge `GetSubsystemDamageLevel()`; measured `dmg=0.6 → speed=0.4`.
|
||||
@@ -147,6 +213,32 @@ its electrical state. The full chain, byte-verified [T1]:
|
||||
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
|
||||
`SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]].
|
||||
|
||||
## Coolant LEAKS (Gitea #88 fix, 2026-07-31) — the #64 shadow was the single break [T2]
|
||||
`HeatSink::UpdateCoolant` (@004adbf8) prices the damage-driven leak: `coolantDraw =
|
||||
ownZone->damageLevel × heatLoad`, floor 0.0025, and the `coolantActive@0x138` hysteresis
|
||||
(ON >0.003) IS the `ReportLeak` attribute the 19 authored leak watchers (3-note warning) ride.
|
||||
The read is the **qualified ENGINE member** `Subsystem::damageZone@0xE0` — which the port's
|
||||
MechSubsystem ctor never assigned (it filled only its re-declared shadow, gitea #64 / gotcha #1),
|
||||
so `zoneDamage` pinned 0 and a leak was **structurally impossible** regardless of damage. Fixed by
|
||||
ALIASING the engine base member to the same zone in both MechSubsystem ctors (mechsub.cpp) — every
|
||||
shadow reader is untouched (same object), the engine base `Subsystem::TakeDamage` null-AV is
|
||||
disarmed, and the #64 full de-shadow sweep remains the long-term cleanup. Verified live: collision
|
||||
rattle and weapon crits (`[critroll]`) both drive `[cool]` leak lines (draw ≈ dmg×heatLoad, level
|
||||
draining, hysteresis arming). **Authored damage routing** (BTL4.RES): collision rattle targets
|
||||
HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35 — Condenser + Reservoir are authored 0
|
||||
for collisions; weapon crits select via the ZONE's crit-entry list.
|
||||
**The central pull-through is ALSO live (same day):** `HeatSink::DrawCoolant` was a `return 0`
|
||||
stub; the real base @0x4add00 (disassembled) RECURSES UP the sink linkage — `return
|
||||
resolve(linkedSinks)->DrawCoolant(requested × coolantFlowScale@0x15C)` — terminating at the
|
||||
Reservoir's supply (@0x4af3b0, already faithfully ported as `Reservoir::DrawCoolant`: clamp to
|
||||
[0, coolantLevel], drain, return granted). Binary terminal hop = the bank's slot-14 override
|
||||
@0x4ae8b0 resolving its reservoir connection @0x1D8 (the port's `helper` member — NOT vestigial);
|
||||
the port terminates equivalently via the Reservoir-ctor Attach into the bank's `linkedSinks` +
|
||||
the Reservoir override (the extra bank hop scales by its ctor-default flowScale 1.0 — a no-op).
|
||||
Verified live (`[resdraw]`): a destroyed myomer's leak pulled the central Reservoir tank
|
||||
6.0 → 5.58 over ~30 s — **`Reservoir/CoolantMass`, the cockpit coolant BAR, now visibly drops
|
||||
as a damaged mech bleeds coolant**, and DeathReset refills it on respawn.
|
||||
|
||||
## The coolant FLUSH (Gitea #7, 2026-07-19) — Reservoir InjectCoolant end-to-end [T2 live-verified]
|
||||
The manual-p24 coolant button (coolant MFD top-right; punch or HOLD): message id **4 "InjectCoolant"**
|
||||
on the Reservoir (handler table @**0x50e680**, one entry → @4aee70 — same per-receiver id space as the
|
||||
@@ -210,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.
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
id: test-harness
|
||||
title: "The Test Harness — how benches run, and what counts as verified"
|
||||
status: living
|
||||
source_sections: "scratchpad/night6/bench_common.sh (the contract); build-and-run.md §bench-parity; nights 6-9 bench scripts; the #86/#110 verification failure"
|
||||
related_topics: [build-and-run, reconstruction-method, multiplayer, reconstruction-gotchas, experience-levels]
|
||||
key_terms: [bench, harness, field-composition, scalpel]
|
||||
open_questions: []
|
||||
---
|
||||
# The Test Harness — how benches run, and what counts as verified
|
||||
|
||||
Two subjects, deliberately in one file: the MACHINERY (how to launch and read a
|
||||
bench) and the DOCTRINE (what a bench must show before a fix may be called
|
||||
fixed). The doctrine exists because the machinery was used wrong more than once.
|
||||
|
||||
## THE DOCTRINE: verify the FIELD COMPOSITION, not a constructed proxy
|
||||
|
||||
**The archetype failure (#86 → #110).** "Weapons on damaged arms keep firing"
|
||||
was fixed and verified by destroying the WEAPON directly (`BT_KILL_SUBSYS`).
|
||||
The field scenario was the ARM ZONE dying while the weapon stayed healthy — a
|
||||
path the bench never took. The claim "fixed" stood for two playtests while the
|
||||
players kept hitting the bug; the actual defect (a stubbed cascade walk,
|
||||
gotcha #26) sat exactly in the gap between the proxy and the field. **A fix
|
||||
verified only against a reproduction you constructed is not verified.**
|
||||
|
||||
Rules, in order of application:
|
||||
|
||||
1. **Reproduce the reported scenario first** — the player's composition, not a
|
||||
convenient adjacent one. If the report is "peer's arm shot off in MP", the
|
||||
proof is a two-node run where PEER FIRE kills the arm (`mp_armgrind.sh`),
|
||||
not a solo self-damage run.
|
||||
2. **Scalpels are for isolation, not for verdicts.** Deterministic hooks
|
||||
(`BT_SELF_DAMAGE_ZONE`, `BT_KILL_SUBSYS`, `BT_FORCE_SEEK`, `BT_ARMOR_FORCE`)
|
||||
are the right way to LOCATE a defect. Before a scalpel result supports a
|
||||
"fixed" claim, prove PATH IDENTITY to the field composition (e.g. the
|
||||
self-damage harness dispatches a real `Entity::TakeDamageMessage` — the
|
||||
same message + handler a peer round takes — so the scalpel shares every
|
||||
line from the handler down; verified 2026-08-02, and then the field
|
||||
composition was STILL run).
|
||||
3. **If the symptom is multiplayer, the proof is two nodes.** Master-side
|
||||
correctness says nothing about what a peer sees; replication is its own
|
||||
layer (records, edge-detects, instance gates). #94, #84 and #110 each had a
|
||||
peer-side half invisible to any solo bench.
|
||||
4. **If the symptom is visual, the proof is pixels** — `BT_SHOT_EVERY` frames
|
||||
diffed against a control region, not a log line saying the value changed
|
||||
(gotcha #23: a colour change that logs perfectly and renders nothing).
|
||||
5. **Coverage claims need a sweep, not an instance.** "Works for every weapon
|
||||
type / chassis" = enumerate the axis and measure each point (all 3 fire
|
||||
gates through a zone death; all 8 chassis' authored cascade flags —
|
||||
`zonesweep.sh`). Per-chassis behaviour usually lives in AUTHORED DATA, so
|
||||
fixed code on one chassis proves nothing about the others.
|
||||
6. **Chase every anomaly in a passing run to ground.** The Owens rack cascade
|
||||
also killing the sensor suite looked like a walk bug; it was authored
|
||||
skeleton data (the mast is a child of the rack). A pass with an
|
||||
unexplained extra effect is not yet a pass.
|
||||
|
||||
## THE MACHINERY
|
||||
|
||||
### The contract: `scratchpad/night6/bench_common.sh`
|
||||
The single source of truth for launching a node THE WAY A PLAYER'S SHORTCUT
|
||||
DOES — read the file, its comments carry the incident history. Summary:
|
||||
- `bt_player_env` — `BT_PLATFORM=glass`, `BT_START_INSIDE=1` (cockpit, not
|
||||
chase cam), `BT_DEV_GAUGES=1`. Copied verbatim from the shipped .bats;
|
||||
`bt_assert_player_env` warns on drift.
|
||||
- `bt_launch <log> <egg> <affinity> [args]` — one node, env-scoped, PID
|
||||
tracked in `/tmp/bt_bench_pids.$$`.
|
||||
- `bt_expert_egg` — shipped eggs are ALL `experience=expert`; a novice egg
|
||||
silences heat/crits/jams and is only legal for the gimp bench (which must
|
||||
say so out loud). See [[experience-levels]].
|
||||
- `bt_kill_ours` — teardown kills ONLY this run's PIDs (a blanket
|
||||
`taskkill /IM` once shot down two live sessions).
|
||||
|
||||
### Single-node bench skeleton
|
||||
```bash
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2 # stale-node clear
|
||||
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=$V/" MP.EGG > X.EGG
|
||||
export BT_<gates>=... # diagnostics + scalpels
|
||||
bt_launch x.log X.EGG 0x03
|
||||
sleep <duration>; taskkill //F //IM btl4.exe; sleep 2 # then grep x.log
|
||||
```
|
||||
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
|
||||
bt_expert_egg MP.EGG X.EGG
|
||||
BT_MP_LOG=1 <gates> bt_launch x_b.log X.EGG 0x0C -net 1601 # node B first
|
||||
sleep 2
|
||||
BT_MP_LOG=1 <gates> bt_launch x_a.log X.EGG 0x03 -net 1501 # node A
|
||||
sleep 5
|
||||
python ../tools/btconsole.py X.EGG 127.0.0.1:1501 127.0.0.1:1601 & # the relay STARTS the mission
|
||||
sleep <duration>; kill $relay; bt_kill_ours
|
||||
```
|
||||
- A `-net` node renders NOTHING until `btconsole.py` (the headless relay)
|
||||
starts the mission — a black window is pre-mission, not a hang.
|
||||
- Affinity: TWO logical processors per node, disjoint sets (`0x03`/`0x0C`);
|
||||
one LP starves the gauge executive ([[multiplayer]] peer-shakiness fix).
|
||||
- Give the SHOOTER dense fire (`BT_AF_PERIOD=3` + `BT_AF_MISSILE=1`) and the
|
||||
victim sparse (`=9`) when one side must lose a grind. Unthrottled autofire
|
||||
trips the FailureHeat all-weapons brick and combat dies.
|
||||
- Combat drive: `BT_GOTO=enemy BT_GOTO_STOP=<units>` — 100 ends in a visual
|
||||
ram scrum; 180 gives a standing exchange.
|
||||
- Read each claim on the node that can see it: damage on the VICTIM's log,
|
||||
replication on the OBSERVER's (`[zone-repl]`, `[mlrec]`, mirrored `DET`s).
|
||||
|
||||
### Process hygiene (every one of these cost a session)
|
||||
- **Stale-node taskkill FIRST.** A leftover node joins the next lobby as a
|
||||
third instance and poisons the run.
|
||||
- **Never double-background.** `run_in_background` + an inner `&` orphans the
|
||||
script mid-startup: nodes launch, the script's own cleanup never runs.
|
||||
- **Teardown kill order closes windows one at a time** — a "crashed" window at
|
||||
the end of a timed run is usually the bench's own taskkill.
|
||||
- **Stale exe:** `LNK1104` / a bench ignoring new diagnostics = the previous
|
||||
process still holds `btl4.exe`, or the build silently didn't run — check the
|
||||
`btl4.vcxproj ->` line printed, then rerun.
|
||||
- **Editing source through bash-heredoc python collapses `\` one level** even
|
||||
single-quoted -- a `"\n"` you meant as backslash-n arrives as a real newline,
|
||||
the "fix" silently no-ops (`good == bad`), and the C2001 hunt repeats. Build
|
||||
backslashes as `bytes([92])`/`chr(92)`, and verify a replace by LENGTH DELTA
|
||||
or a re-read, never by the script printing "fixed".
|
||||
- Bench artifacts (`*.EGG` copies, `*_NNN.png`, bench logs) must be deleted
|
||||
from `content/` before any dist cut; field logs are never committed.
|
||||
|
||||
### Reading logs without fooling yourself
|
||||
- **A capped/throttled diagnostic that is silent is NOT evidence of absence**
|
||||
— the #99 `[seqrun]` shared counter "proved" a sequence never ran; it was
|
||||
throttle starvation. Per-object throttles for per-object questions.
|
||||
- **Alarm-line counters are not trend lines** — `free=0` printed AT the
|
||||
failure is true by definition; the 30s census carries the trend (#32, three
|
||||
separate times).
|
||||
- **Log the actor's NAME at every refusal/decision** (`'PPC' fire REFUSED`);
|
||||
anonymous counters (`FIRED #20`) cannot support attribution claims.
|
||||
- Field logs have NO diagnostic gates set — a fix whose acceptance evidence
|
||||
sits behind an env var is unverifiable in the field. Spawn-time summaries
|
||||
(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), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
@@ -120,8 +120,15 @@ Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDe
|
||||
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
|
||||
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible
|
||||
(`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the
|
||||
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (mechdmg.cpp:1074, gated to
|
||||
`ReplicantInstance`; `simulationState` rides every update-record header so the observer tracks the
|
||||
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (`MechDeathHandler::Tick`,
|
||||
since the #94 fix triggered by the death-state EXIT edge of the replicated `MovementMode` —
|
||||
cause-agnostic, covers zone-less collision deaths; gated to
|
||||
`ReplicantInstance`; the effect machine is a SINGLE global slot, and since 2026-07-30 the local
|
||||
pilot's own POV lifecycle OWNS it — `BTStartWarpEffect` self-skips while `gWarpPhase!=0 && gWarpPOV`
|
||||
(`[tloc] peer warp SKIPPED`), because a peer's un-wreck landing mid-collapse/wait/expand used to
|
||||
overwrite the machine and kill the POV vortex ("my respawn had no blue whirlwind"). Invisible
|
||||
before the #81 respawn fix only because overlapping respawns barely existed; 2-node bench: 20/20
|
||||
POV collapse+expand pairs, 9 peer spheres played, 11 correctly skipped; `simulationState` rides every update-record header so the observer tracks the
|
||||
peer's state). The ONLY remaining nuance [T3, non-gating]: the observer's peer sphere is anchored to
|
||||
the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't
|
||||
replicated) — a fidelity refinement, not a missing effect.
|
||||
|
||||
@@ -16,6 +16,30 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`.
|
||||
## What WinTesla already did (do NOT rebuild)
|
||||
- **Renderer bypass: DONE** — `MUNGA_L4/L4D3D.cpp` + `DXUtils` replace libDPL / the IG board with
|
||||
Direct3D9. No `libdpl.lib` in the tree. (The early from-scratch D3D9 viewer was retired and removed.) [T1]
|
||||
- **Device-loss: the #35 "Owens crash" — ROOT-CAUSED + FIXED 2026-07-29** [T2, bench-reproduced].
|
||||
8 byte-identical field stacks (one player, Surface Pro 9 / **Iris Xe 128 MB** — the only GPU in
|
||||
the fleet that ever actually loses the device): `ParticleEngine::Destroy +0x11`, `access=0
|
||||
target=0x0`, from the per-frame render path. **Not a weapons bug** — the Owens two-trigger
|
||||
missile+laser combo is merely what provokes the GPU timeout on that iGPU. The broken protocol
|
||||
(both Present sites, inline copies): on `D3DERR_DEVICELOST` they called `Reset()` IMMEDIATELY —
|
||||
which **always fails while still lost** (`V()` only logged) — then `ParticleEngine::Initialize()`
|
||||
ran against the lost device, its creates failed and **NULLed the out-params** (verified: the bench
|
||||
repro faulted at `target=0x0`, not a dangling address), and the NEXT lost frame's blind
|
||||
`mVertBuffer->Release()` read a vtable at 0x0. FIX (`L4VIDEO.cpp` + `L4PARTICLES.cpp`): one shared
|
||||
`DPLRenderer::BTResetLostDevice()` — release POOL_DEFAULT resources every lost frame (idempotent,
|
||||
null-safe `Destroy`), **gate `Reset()` on `TestCooperativeLevel() != D3DERR_DEVICELOST`**, check
|
||||
the Reset HRESULT, and on success re-create via **`CreateDeviceObjects()` — NOT `Initialize()`**,
|
||||
whose `memset(mInstalledEffects)` silently killed every particle effect for the rest of the
|
||||
mission on each reset that DID succeed (the quieter sibling bug). Draw paths guard the NULL buffer
|
||||
(`ExecuteParticles` keeps draining particles while dormant). Bench: `BT_DEVICELOST_TEST` repro'd
|
||||
the field crash byte-for-byte pre-fix, and post-fix the same shape logs `SURVIVED` + 3 forced
|
||||
loss/reset cycles recover (`[render] device reset OK`).
|
||||
⚠ **Discovery from the verify: `VIDEO\particles.png` has NEVER existed** — absent from the tree,
|
||||
the RES, and all of git history — so `mParticleTexture` has been NULL on every machine since the
|
||||
engine was written and **all billboard particles draw as untextured quads** (`SetTexture(0,NULL)`
|
||||
= the shipped look). Deliberately NOT gated on in `RenderParticles` — that would disable all
|
||||
particles everywhere. Filed on the tracker; a real texture (or the original particle sheet) is a
|
||||
content task. Possibly related to the field "missile artifact looks wrong" observation.
|
||||
- **Audio SOURCE BUDGET — the dropout complaint was fixed 2026-07-23; the leftover is NOT the same
|
||||
bug** [T2, 2026-07-28]. **The reported bug is closed**: `a999e5c` (per-source delete instead of the
|
||||
spec-atomic bulk `alDeleteSources`) stopped the pool leaking, and there have been **no field
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# The GHOST MECH / zombie-wreck failure — field analysis 2026-07-29
|
||||
|
||||
## ✅ FIXED 2026-07-30 — two defects that were masking each other
|
||||
**1. The death latch never released on failure.** The binary's `FUN_004c012c` tail is
|
||||
`Post(...)` → `*(this+0x290) = 0` → `*(this+0x258) = 0`. We had the `Post` and the
|
||||
`suppressConsole` and were **missing the middle instruction**, so `deathPending` cleared only on
|
||||
SUCCESS paths. One failed respawn latched the pilot for the rest of the mission — every later death
|
||||
hit the dedup and was SWALLOWED, so the cycle could never restart. That is what made a transient
|
||||
hiccup a PERMANENT ghost, and it matches the field signature exactly (8 cycles, 6 stranded, none
|
||||
recovering). Fixed in `btplayer.cpp` (death-handler tail).
|
||||
**2. `VehicleDeadMessage` was dispatched TWICE per death.** `BTPostKillScore` (`btplayer.cpp:2263`)
|
||||
sent a second one "to credit a death" — but that message is the RESPAWN-CYCLE TRIGGER, not a
|
||||
scoreboard increment, and the tally is already credited by the handler's `++deathTally` (`:538`).
|
||||
Both fire inside the same death transition (`mech4.cpp:2006` and `:2110`), so they were always
|
||||
paired. Removed.
|
||||
**They hid each other:** the duplicate made the latch look necessary, and the latch made the
|
||||
duplicate invisible — every `SWALLOWED` warning in the field logs was simply the latch deduping our
|
||||
own duplicate (8 of 8 deaths, a 100 % base rate, which is why it correlated with nothing). Fixing
|
||||
either alone makes things visibly worse, which is why earlier passes at #57/#55 kept adding
|
||||
clear-sites instead of finding the root. The binary broke the tie.
|
||||
|
||||
**Verified (4-node stress, 2026-07-30):** four simultaneous networked nodes, all dying every ~35 s
|
||||
on staggered timers so respawn handshakes overlap (the field condition): **56 death cycles, every
|
||||
completed one `START`→`RESET`, 0 swallowed / 0 mismatch / 0 discarded / 0 ghost / 0 crash** across
|
||||
all four. Scoreboard: every node's `PLAYER_DEAD` matchlog rows show `deaths=N tally=N` in lockstep,
|
||||
one increment per death (13/13/15/15) — the DEATHS column the comms panel draws is exact; the
|
||||
kill-credit path evaluated all 56 deaths and correctly declined each self-kill (`NOCREDIT self=1`),
|
||||
KILLS untouched by the fix (separate `ScoreMessage` path). Bench: `scratchpad/night6/mp4_stress.sh`
|
||||
+ `content/MP4.EGG`.
|
||||
**Two observations from watching the 4-node run, both run down (2026-07-30):** (1) *"comms panel
|
||||
counted no deaths"* — the panel machinery is CORRECT (2-node rerun with a draw-value probe: both
|
||||
rows on both nodes drew 0→9/0→11 live, local + replicated); the 4-node zeros were the artificial
|
||||
load (4 core-pinned instances → PilotList Execute starved to <0.6 Hz, panel minutes-stale). Probe
|
||||
stays in (`[score] panel DRAW`, BT_SCORE_LOG). (2) *"a respawn had no blue vortex"* — REAL and now
|
||||
fixed: the warp effect is one global slot and a PEER's un-wreck sphere (`BTStartWarpEffect`) could
|
||||
stomp the local pilot's own POV vortex mid-lifecycle; fixed by POV-priority self-skip
|
||||
(btl4vid.cpp; `context/translocation-warp.md`). Pre-fix this never showed because overlapping
|
||||
respawns barely existed — the ghost fix CREATED the traffic that exposed it.
|
||||
**Verified:** solo **17 consecutive death/respawn cycles**, every one `START`→`RESET`, 0 swallowed /
|
||||
0 mismatch / 0 crash (pre-fix this strands permanently after cycle 1). Two-node MP over a real
|
||||
network path with cross-machine drop-zone replies: A 11 cycles, B 12, 0 swallowed / 0 mismatch /
|
||||
0 discarded / 0 crash. Bench: `scratchpad/night6/mp_ghost.sh`, `BT_SELF_DAMAGE_REPEAT=1`.
|
||||
|
||||
**Status of the analysis below:** the mechanism write-up that led to the fix. The instrumentation
|
||||
(`[dz]`, `[dzreq]`, `[ghost]`) stays in — it is what will prove the fix in the field, and the
|
||||
cross-machine dependency in §4 is still a real fragility even with the latch fixed. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the
|
||||
verbatim agent findings are in `scratchpad/night6/` (uncommitted — they contain machine names and
|
||||
Steam identities). Tracker: #81 (zombie wreck), #57 (deathPending latch), #45 (tally replication).
|
||||
|
||||
## The one-sentence version
|
||||
A death whose **drop-zone reply never arrives** leaves the respawn cycle stranded, so the mech is
|
||||
never reset and never repainted — and because the render-side wreck swap is **one-way**, the pilot
|
||||
keeps driving a burning hulk that sinks out of the world after ~18 s and then cannot be drawn at
|
||||
all. The "wreckage moving and shooting" report and the "invisible ghost" report are **the same
|
||||
failure at two different ages**.
|
||||
|
||||
## Established from the field logs [T1]
|
||||
- Exactly **8 death cycles** in the whole session. Every one has the same 3-line signature at
|
||||
consecutive lines: `death cycle START` → `*** DESTROYED ***` → `WARNING: death ... SWALLOWED`.
|
||||
- **Only 2 of 8 reached `RESET at drop zone`.** A stranded cycle predicts ghosting **perfectly**:
|
||||
8/8 cycles, 10/10 player-matches, in both directions.
|
||||
- Match 2: all four non-host players died, all four stranded, zero RESETs anywhere = "everybody
|
||||
ghosted". **The host did not ghost because he never died** — his log holds no death cycle at all.
|
||||
(There is nothing special about being host; an earlier reading of this was wrong.)
|
||||
- Match 1: three deaths, two stranded, one completed. The completion is the control case, and it is
|
||||
the "self-corrected for one player" the testers reported.
|
||||
- ⚠ **The SWALLOWED warning is benign.** It fires on 8 of 8 deaths, including both clean
|
||||
completions and a build-641 death, so its base rate is 100% and its correlation with ghosting is
|
||||
zero. It is not even a second death — it is the same death re-entering the notify from inside the
|
||||
cycle. Do not treat it as a signal (an earlier reading did).
|
||||
- ⚠ **Player IDs are per-match**: node = lobby token octet + 1, host always `.1`. `player 3:1` is a
|
||||
different human in every match. Any cross-machine identification must decode this first.
|
||||
- ⚠ An earlier "three spurious RESETs on the host" finding was an **aggregation artifact** — one
|
||||
log file spans ~18 sessions and each session legitimately logs one spawn RESET.
|
||||
|
||||
## The render mechanism [T1, code]
|
||||
- `btl4vid.cpp:1255-1300` — the wreck sink is a **pure timer**, nothing about movement: 0.25 s
|
||||
reveal, then `sink = -0.025·t²`, and at `-8.0` the hulk/debris/flames get `SetDrawObj(NULL)` plus
|
||||
`[BTrender] wreck buried (sink complete)`. That threshold is reached at **t ≈ 18 s**.
|
||||
- The wreck objects hang off the mech's own render tree as an offset, so **they travel with the
|
||||
pilot** — hence a moving, burning, shooting wreck for the first ~18 s.
|
||||
- `mech4.cpp:1827` — a healthy respawn calls `BTRebuildMechModel` on Reset precisely because "the
|
||||
wreck swap is one-way on the render side". No reply → no Reset → no rebuild → the hulk sinks and
|
||||
the player is invisible but still simulated and driveable.
|
||||
- Peers apply the wreck swap correctly and in a bit-identical order across machines, so the ghost is
|
||||
**not** an entity peers never learned about — it is an entity whose presentation is frozen dead
|
||||
and then hidden.
|
||||
|
||||
## The stall itself [T0 WinTesla — NOT proven authentic]
|
||||
`engine/MUNGA/DROPZONE.cpp`: a slot is granted only if `IsAvailable()` — busy for `DOWN_TIME = 5 s`
|
||||
after use, and any other player's vehicle within 2 m marks it busy. **When no slot is free the
|
||||
handler reposts the message to itself every 0.1 s at `MaxEventPriority` and never replies**, so the
|
||||
requester's `deathPending` latch is never cleared.
|
||||
⚠ **Provenance:** `engine/MUNGA` is **WinTesla**, the later Windows port of MUNGA taken from updated
|
||||
Red Planet (which converted the Division hardware to software). **The 1995 game never ran it.** So
|
||||
this behaviour is authoritative for what we compile, *not* for what the pods did. BT's own
|
||||
`munga/dropzone.cpp` is **absent from the decomp export** — the 1995 assignment policy is an
|
||||
unrecovered gap. Recovering it (byte-scan + windowed disasm, the technique that recovered
|
||||
`FUN_004a6344`) is the open task.
|
||||
|
||||
## 2026-07-30 dig: the respawn handshake, walked end to end
|
||||
Re-read the whole chain (death → +5 s re-post → engine hunt → DropZone grant → reply → Reset) and
|
||||
**measured** the parts that were assumptions. Results, in order of importance:
|
||||
|
||||
**1. The engine hunt RETRIES EVERY 2 SECONDS, forever, while the mech is dead** [T0]
|
||||
(`PLAYER.cpp:325-327` posts the message back to itself unconditionally; its only state-based exit is
|
||||
`GetSimulationState() == DropZoneAcquiredState`, which never happens — see 3). The DropZone also has
|
||||
a resend net: a repeat request from the same requester + same `deathCount` re-grants the **same**
|
||||
slot even while it is busy (`DROPZONE.cpp:182-194`). **So "a transient no-slot" and "one lost reply"
|
||||
are both ELIMINATED as causes** — the request keeps being re-sent and re-granted every 2 s. A
|
||||
permanent strand needs a permanent reason.
|
||||
|
||||
**2. THE SILENT DISCARD — the one remaining unlit path, now instrumented** [T1]
|
||||
`BTPlayer::DropZoneReplyMessageHandler` is `if (!playerVehicle) … else if (deathCount ==
|
||||
message->deathCount) … else { return; }`. That final branch was a **bare return with no logging**:
|
||||
the drop zone granted a spot and replied, the numbers disagreed, and the reply was dropped —
|
||||
`deathPending` stays latched, the mech is never `Reset`, and the pilot is a permanent ghost **with
|
||||
zero trace in the log**. That is exactly why 6 of 8 field cycles stranded leaving no evidence. Now
|
||||
always-on (`[ghost] DROP-ZONE REPLY DISCARDED …`) and it prints the **direction** of the mismatch,
|
||||
which decides the fix: `msgDeath < ours` = genuinely stale (dropping is correct); `msgDeath > ours` =
|
||||
**our counter is behind and we threw away a live respawn** — which points straight at #45 (the death
|
||||
tally does not replicate correctly). Deliberately NOT "recovered" yet: guessing the direction would
|
||||
be a stand-in, and the wrong guess respawns a mech that is still alive.
|
||||
|
||||
**3. ⚠ LANDMINE — do not convert the two `Set_Alarm_Level` raw writes to `SetSimulationState()`.**
|
||||
`Set_Alarm_Level` is an **empty stub** (`btstubs.cpp:87`), so the death path's
|
||||
`Set_Alarm_Level(this+0x2c, 1)` and the reply path's `(this+0x2c, 2)` are **no-ops** today. Their
|
||||
values decode perfectly against `Player`'s enum (`PLAYER.h:273-279`) as `DropZoneAcquiredState`(1)
|
||||
and `VehicleTranslocatedState`(2) — `DropZoneAcquiredState = Entity::StateCount`, and `Entity` adds
|
||||
no states, so it is **1**. That makes "obviously these should be `SetSimulationState` calls" a very
|
||||
attractive and **catastrophic** fix: the engine hunt is gated on `GetSimulationState() !=
|
||||
DropZoneAcquiredState`, so setting state 1 on death would stop `AssignDropZone` from ever being
|
||||
dispatched and make **every** pilot ghost permanently.
|
||||
Measured: our `BTPlayer::simulationState` is at **0x24**, not 0x2c, and the write leaves it at 0
|
||||
(`[ghost] death raw-write(+0x2c,1): simState 0 -> 0 (hunt gate still open)`). In the binary `+0x2c`
|
||||
is the **Simulation-base alarm** — the same field the mech side calls `graphicAlarm` (`@0x4ac126`:
|
||||
"owner alarm+0x2C -> level 9"). Our layout models that only as a `Mech` member, so a `BTPlayer` has
|
||||
nowhere to put it; the stub is harmless until we recover what reads a *Player's* alarm in the binary
|
||||
(likely a cockpit/HUD respawn indicator). [T1 offsets, T3 purpose]
|
||||
|
||||
**4. EVERY MP RESPAWN IS A CROSS-MACHINE ROUND TRIP — and that is the single point of failure**
|
||||
[T2, two-node bench `scratchpad/night6/mp_ghost.sh`]. Measured on a 2-node rig with the host:local
|
||||
labels: each player's request was answered by **the OTHER machine's** DropZone.
|
||||
```
|
||||
mp_a.log: [dzreq] player 2:1 asking for a drop zone ... <- A's own player asks
|
||||
mp_b.log: [dz] GRANTED slot 7 to 2:1 death#1 ... <- B answered it
|
||||
mp_b.log: [dzreq] player 3:1 asking for a drop zone ... <- B's own player asks
|
||||
mp_a.log: [dz] GRANTED slot 0 to 3:1 death#1 ... <- A answered it
|
||||
```
|
||||
`FindGroup("DropZones")` iterates **every** DropZone entity including replicants of remotely-mastered
|
||||
ones and picks the geometrically closest, so a respawn is: *my request → an arbitrary peer's
|
||||
DropZone → that peer's reply → back over the wire to me.* With five players that is five round trips
|
||||
through arbitrary peers, and **one degraded peer can ghost everybody else.**
|
||||
That dovetails exactly with the otherwise-unexplained field finding that **one machine stopped
|
||||
processing the effect/death-transition stream 57 % into match 1 and never recovered** (0 explosions /
|
||||
0 wreck swaps / 0 burials while the other four logged 4/4/4). A node in that state that owns the
|
||||
closest drop zone cannot answer anyone's respawn — which is what "everybody ghosted" looks like.
|
||||
It also explains why solo is 100 % reliable (the whole handshake is in-process) and why the
|
||||
2-node bench passes (both nodes healthy, replies land, `deathCount` agrees).
|
||||
|
||||
**Revised theory of the ghost (two layers):**
|
||||
- *Structural:* respawn depends on a cross-machine round trip through a peer that may be lagging,
|
||||
stalled, or holding a stale view of my player. Not drop-zone starvation (`slots=8`) and not a
|
||||
single lost reply (the hunt retries every 2 s forever).
|
||||
- *Mechanism when the reply does arrive but is stale/mismatched:* the silent discard in 2 — the
|
||||
reply's `deathCount` disagrees and it is dropped without a trace, which couples this to #45.
|
||||
|
||||
**Two candidate fixes, both needing the next field logs to choose between:**
|
||||
(a) **Prefer a locally-mastered DropZone** when one exists, keeping the handshake in-process and
|
||||
removing the remote dependency entirely — cheap and robust, but it changes which physical pad you
|
||||
land on, so check it against the maps first. (b) **Make the reply path tolerant**: accept a granted
|
||||
reply whose `deathCount` is *ahead* of ours (our counter is behind) instead of discarding it, and
|
||||
never leave `deathPending` latched on a discard. ⚠ Neither should be guessed at blind — the
|
||||
`[ghost] DROP-ZONE REPLY DISCARDED` line's mismatch direction decides it in one line of log.
|
||||
|
||||
## HOW THE BINARY HANDLES IT — `deathPending` is OUR INVENTION [T1, decisive]
|
||||
Read BT's own respawn code (it *is* in the export, even though `munga/dropzone.cpp` is not) and
|
||||
checked it against the raw binary.
|
||||
|
||||
**The hunt is faithful.** BT's `Player::VehicleDeadMessageHandler` = `FUN_0042db80`
|
||||
(`part_003.c:12029`) gates on `message->deathCount == player->deathCount` **and**
|
||||
`player+0x40 != 1` — i.e. `simulationState != DropZoneAcquiredState`, exactly WinTesla's two gates —
|
||||
then runs the same closest-DropZone search skipping `"win*"` zones, dispatches `AssignDropZone`, and
|
||||
**re-posts the message to itself** on a timer. So the retry loop and the cross-machine hunt are
|
||||
authentic 1995 behaviour, not a WinTesla artifact.
|
||||
|
||||
**But the latch is not.** BT's `BTPlayer::VehicleDeadMessageHandler` = `FUN_004c012c`
|
||||
(`part_013.c:10504`) ends with:
|
||||
```
|
||||
++deathCount; message->deathCount = deathCount; --lives;
|
||||
updateModel |= 1; alarm(+0x2c, 1); save dropZoneLocation;
|
||||
Post(HighPriority, self, message, Now()+delay);
|
||||
*(param_1 + 0x290) = 0; // <-- the field our reconstruction calls deathPending
|
||||
```
|
||||
**The binary CLEARS that field at the end of every death.** Verified against `BTL4OPT.EXE` itself:
|
||||
`+0x290` is written in exactly **three** places in the whole executable
|
||||
(`file_off 0x0b75fb`, `0x0bffe3`, `0x0c0a05`), and **all three store a zeroed register** —
|
||||
`xor ecx,ecx` / `xor eax,eax` / `xor edx,edx` immediately before. There is **no write of 1, or of
|
||||
any non-zero value, to `+0x290` anywhere in the binary.** Two of the three are constructor/reset
|
||||
sweeps; the middle one is the death handler above (it sits right after the `call` to `Post` and
|
||||
`add esp,0x14`, matching the decompiled tail exactly).
|
||||
|
||||
**So in 1995 there is no death-pending gate at all.** Our `btplayer.cpp:505` does
|
||||
`deathPending = 1`, and six separate sites then have to clear it (`:382 :469 :1431 :1442 :1461
|
||||
:1532`) — each one a patch for a different stranding scenario. Gitea **#57 and #55 are artifacts of
|
||||
that invention**, not of the original design.
|
||||
|
||||
**And that is what makes a ghost PERMANENT.** In BT a failed respawn is harmless: the 2-second
|
||||
re-post keeps hunting, and the next death starts a clean cycle. In ours the first failure latches
|
||||
the pilot — every later death hits the dedup and is SWALLOWED ("deaths so far 1" forever), so the
|
||||
pilot can never recover for the rest of the mission. That is precisely the field signature: 8 death
|
||||
cycles, 6 stranded, none of them ever recovering.
|
||||
|
||||
**Proposed fix (faithful, and it removes the whole bug class):** match the binary — clear the field
|
||||
at the end of the death handler instead of latching it, and drop the dedup gate that depends on it.
|
||||
⚠ Six sites currently rely on that latch's existence, and the SWALLOWED dedup is load-bearing for
|
||||
the "one death, one cycle" behaviour, so this wants a deliberate two-node bench rather than a
|
||||
late-night edit. Caveat on the identification: `+0x290 ↔ deathPending` rests on our own
|
||||
reconstruction's mapping (our compiled `BTPlayer` is only `0x28c` bytes, so the offsets cannot
|
||||
match directly) — but the *behavioural* evidence (BT's death handler clears it; nothing in the
|
||||
binary ever sets it) is read straight from the executable.
|
||||
|
||||
## What the instrumentation already killed
|
||||
`slots=8` (measured, `[dz] POOL`). An 8-slot pool **cannot** be cooldown-starved by 5 players (at
|
||||
most 5 slots on cooldown at once), so the shared `dropzone=one` in `tools/eggmodel.py:42` is **not**
|
||||
the bottleneck. Suspicion moves to the handshake: a lost reply, or a `deathCount` mismatch — the
|
||||
engine gates the hunt on `message->deathCount == deathCount` and the DropZone's resend safety net is
|
||||
keyed on `lastDeathCount == message->deathCount`, so one mismatch breaks both silently (cf. #45).
|
||||
|
||||
## Instrumentation shipped (`ba6756c`) — always-on, no env var needed
|
||||
| line | meaning |
|
||||
|---|---|
|
||||
| `[dz] POOL name= slots= downTime= proximityBlock=` | once per mission: the pool size |
|
||||
| `[dz] GRANTED slot N to entity E death#D waited=Xs` | one per respawn; `waited` is the headline; tags `(GHOST RECOVERED)` past 15 s |
|
||||
| `[dz] STALL` / `[dz] GHOST LIKELY` | escalating, rate-limited per waiter; names **why** each slot is busy (age + last user) — this discriminates cooldown saturation from proximity blocking |
|
||||
| `[dzreq] player P asking ... try=N msgDeath= ourDeathCount=` | requester half; flags `*** MISMATCH ***` |
|
||||
`BT_DROPZONE_LOG=1` adds a verbose per-request slot dump for bench work.
|
||||
|
||||
## Next actions, in order
|
||||
1. **Recover BT's 1995 drop-zone policy** from `content/BTL4OPT.EXE` (decomp gap). The key question:
|
||||
when no slot was available, did BT also repost forever and never reply? If yes, the stall is
|
||||
authentic and our divergence is elsewhere; if no, implement what BT did.
|
||||
2. **Read the next field logs' `[dz]`/`[dzreq]` lines** — they answer cooldown-vs-proximity and
|
||||
lost-reply-vs-deathCount-mismatch directly.
|
||||
3. Ask the testers one question that discriminates a second defect: **did a flaming mover stay
|
||||
visible for more than ~20 s?** If yes, the sink tick is not running for that entity (updater
|
||||
early-return, or a render-tree rebuild resetting `wreckAge`) — a second bug on top of this one.
|
||||
4. Separately filed/needed: one machine **stopped processing the effect/death-transition stream**
|
||||
57 % into a match and never recovered (0 explosions / 0 wreck swaps / 0 burials while the other
|
||||
four logged 4/4/4) — that machine was playing a stale world, and it is why that player reported
|
||||
the whole "desync cluster".
|
||||
|
||||
## The desync cluster is four separate things, not one bug [T2]
|
||||
- **Throttle reads zero** — local, and present in the *good* sessions too. ⚠ Unresolved nuance: the
|
||||
internal `thr` field is 0 in every field `[drive]` sample while mechs moved, but reads 0.8 in solo
|
||||
benches; whether that is the same thing the player saw on his gauge is **not** established.
|
||||
- **No weapon audio** — local audio-source starvation caused by the **map's ambient emitters**
|
||||
(`arena2_morning` ≈ 10.6 effects/s × ~2.5 voices pins the 256-source pool); thousands of
|
||||
`ACQUIRE FAILED ... pool is exhausted`. Unrelated to crits or the network.
|
||||
- **Weapons fire without damage** — replication staleness. The shooter resolves damage and awards
|
||||
its own score locally, so a shooter always *sees* hits even when the victim applies nothing.
|
||||
- **Other mechs lagging/misplaced** — the same stale-world condition as item 4 above.
|
||||
|
||||
## Why tonight and not before
|
||||
Nothing in 642 broke multiplayer. Critical hits landing for real (first time) made deaths far more
|
||||
frequent, and every death is a chance for this stall — so a rare race became a common one. Fewer
|
||||
players ⇒ fewer deaths ⇒ clean matches, which is exactly what the testers observed.
|
||||
@@ -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.
|
||||
=============================================================================
|
||||
@@ -45,7 +45,7 @@ be "called on":
|
||||
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
|
||||
`gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait.
|
||||
|
||||
**Deferred polish (post-core):** airborne/fall gaits (AdvanceBody/LegAnimationAirborne), torso-twist-to-target
|
||||
**Deferred polish (post-core):** ~~airborne/fall gaits (AdvanceBody/LegAnimationAirborne)~~ — RESOLVED 2026-07-30: those were the GIMP (limp) drivers, now `AdvanceBody/LegAnimationGimp`, live for #78 (see context/locomotion.md §visible limp); torso-twist-to-target
|
||||
(Torso is FULLY reconstructed — TorsoSimulation twist/elevation @004b5cf0 + WriteJoints; needs wiring), gyro sway.
|
||||
|
||||
**First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -108,6 +108,8 @@ The whole roster sits on the BT base chain `engine Subsystem → MechSubsystem
|
||||
dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root
|
||||
joint). Remaining = a full ctor+integrator reconstruction from @004b3778 (bigger than the torso). Full detail: CLAUDE.md
|
||||
§10d "WAVE-5 GYROSCOPE". Also: the mech.cpp gyro↔torso cross-link write is broken (SubProxy::linkTarget→gyro+4) — commented out.
|
||||
- **WAVE 6 — ✅ COMPLETE (2026-07-31): the "mover cutover" was a phantom.** @004b9550/@004b95b8 are MechWeapon button-mapping functions (misattribution); the binary has NO dynamic myomer→speed feed (AvailableOutput's only callers: RegisterMaxOutput at assembly + the SeekVoltageGraph sampler; speedEffect@0x31C is a gauge attribute). Landed: real drive-heat operands (#85), RegisterMaxOutput → mech+0x7A0 gait cap (idempotent per-tick), real OwnerBaseSpeed (graph scale), the invented `speedDemand *= speedEffect` multiplier REMOVED, ConnectToMover/BTMyomersDriveOf deleted. Myomer damage does not slow a live mech; destruction kills (the only authored vital=1 subsystem). Full authority: context/subsystems.md WAVE 6.
|
||||
(Historical entry below kept for the record:)
|
||||
- **WAVE 6 — Mover-coupled: ◐ STRUCTURAL un-stub DONE + WIRED (gated `BT_MYOMERS` default ON; verified INERT, no locomotion/combat regression).** Myomers (0xBC6) constructs + ticks its real Performance via `CreateMyomersSubsystem`, but is INERT: `MyomersSimulation` early-returns on `OwnerAdvancedDamage()==False` (real gate = messmgr 0xBD3), and the mover feed (`MoverAttach`) + `SetOwnerMaxSpeed` are no-op stubs so the live JointedMover is never touched. De-shim dropped the `owner*`/`segmentFlags` shim fields (accessors return neutral defaults) to fit the exact-0x358 alloc (`sizeof` lock). ⚠ AUTHENTIC COUPLING DEFERRED: routing `SpeedEffect@0x31C` into the mover + real owner-motion accessors + the advanced-damage heat gate — this DRIVES THE LIVE MOVER, so reconcile with the gait cutover FIRST. BLH tick 26→27; `BT_MYOMERS=0` → Actuator stub. See CLAUDE.md §10d "WAVE 6".
|
||||
- **WAVE 7 — Projectile/missile weapons:** ProjectileWeapon (0xBCD), MissileLauncher (0xBD0). **PARTIALLY BLOCKED:** case 0xBCE calls `@4bdcb4`, which has no CLASSMAP entry — decompile it first and resolve the 0xBCD/0xBCE/0xBD0 label tangle (Risk 1) before swapping these.
|
||||
- **WAVE 8 — Message hub:** SubsystemMessageManager (0xBD3). Real, but **verify the cache-slot/tick-bypass interaction** (Risk 4) before enabling its tick.
|
||||
|
||||
+38
-1
@@ -193,6 +193,33 @@ Background_Loop:
|
||||
current_application.First();
|
||||
application = current_application.GetCurrent();
|
||||
|
||||
//
|
||||
// BT MINIMUM BACKGROUND FLOOR (#45 root cause, 2026-07-30). Authentically
|
||||
// the background tasks run ONLY in the frame's leftover time, with a floor
|
||||
// of a single pump per frame -- fine on a 1995 pod (one app, designed
|
||||
// slack), but on a busy MP mission the foreground eats the whole frame
|
||||
// budget, the floor becomes the norm, and the GAUGE renderer (1 of ~7
|
||||
// round-robin tasks, ONE gauge advanced per turn) starves: every cockpit
|
||||
// instrument -- the comms-panel K/D columns, weapon recharge tickers --
|
||||
// freezes at its last paint while the underlying values (and fps!) stay
|
||||
// perfectly healthy. Measured: 4-node bench at 70-90 fps gave the
|
||||
// PilotList ~2 Execute turns in SIX MINUTES. Guarantee a minimum number
|
||||
// of pumps per frame so a full instrument rotation completes in ~1s
|
||||
// regardless of slack; the added worst-case cost is bounded and small.
|
||||
// BT_BG_MIN overrides (0 = authentic slack-only behavior).
|
||||
//
|
||||
{
|
||||
static int s_bgMin = -1;
|
||||
if (s_bgMin < 0)
|
||||
{
|
||||
const char *e = getenv("BT_BG_MIN");
|
||||
s_bgMin = e ? atoi(e) : 32;
|
||||
if (s_bgMin < 0) s_bgMin = 0;
|
||||
if (s_bgMin > 256) s_bgMin = 256;
|
||||
}
|
||||
if (backgroundTasksRun < s_bgMin)
|
||||
goto Background_Loop;
|
||||
}
|
||||
if (t2 < end_of_frame)
|
||||
{
|
||||
|
||||
@@ -219,10 +246,20 @@ Background_Loop:
|
||||
if (Now() - s_lastP >= 1.0f)
|
||||
{
|
||||
s_lastP = Now();
|
||||
// gauge-executive rotation health (#45): turns = one-gauge
|
||||
// Updates and sweeps = full active-list passes SINCE THE LAST
|
||||
// PRINT (~1s); active = the instrument roster size. A frozen
|
||||
// panel shows here as sweeps=0.
|
||||
extern int gBTGaugeTurns, gBTGaugeSweeps, gBTGaugeActive;
|
||||
DEBUG_STREAM << "[perf] frame=" << (float)(Now() - beginFrameTimestamp)
|
||||
<< " fg=" << (float)(startBackground - beginFrameTimestamp)
|
||||
<< " bg=" << (float)(endBackground - startBackground)
|
||||
<< " bgTasks=" << backgroundTasksRun << "\n" << std::flush;
|
||||
<< " bgTasks=" << backgroundTasksRun
|
||||
<< " gaugeTurns=" << gBTGaugeTurns
|
||||
<< " sweeps=" << gBTGaugeSweeps
|
||||
<< " active=" << gBTGaugeActive << "\n" << std::flush;
|
||||
gBTGaugeTurns = 0;
|
||||
gBTGaugeSweeps = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ void
|
||||
return;
|
||||
}
|
||||
if (getenv("BT_AUDIO_SPATIAL") && message->controlID == StartAudioControlID) {
|
||||
static int s_st=0; if (s_st++<40)
|
||||
static int s_st=0; if (s_st++<5000)
|
||||
DEBUG_STREAM << "[spatial] START request src=" << (void*)audio_source
|
||||
<< " vol=" << audio_source_volume_scale << "\n" << std::flush; }
|
||||
|
||||
|
||||
+33
-1
@@ -243,6 +243,7 @@ void
|
||||
audioComponentSocket.Add(audio_component);
|
||||
|
||||
isLooped = is_looped;
|
||||
runProbeCount = 0; // #99 diag
|
||||
isRunning = False;
|
||||
startTime = AudioTime::Null;
|
||||
audioControlEventIterator = NULL;
|
||||
@@ -393,7 +394,12 @@ void
|
||||
AudioControlSequence::StartSequence()
|
||||
{
|
||||
if (getenv("BT_ATTRBIND_LOG")) { static int s_ss=0; if (s_ss++<40)
|
||||
DEBUG_STREAM << "[seqstart] seq=" << (void*)this << "\n" << std::flush; }
|
||||
{ AudioControlEventIterator probe(&audioControlEventSocket);
|
||||
DEBUG_STREAM << "[seqstart] seq=" << (void*)this
|
||||
<< " events=" << (int)probe.GetSize()
|
||||
<< " looped=" << (int)isLooped
|
||||
<< " tempo=" << (int)tempo
|
||||
<< " divPerBeat=" << (int)divisionsPerBeat << std::endl; } }
|
||||
|
||||
Check(this);
|
||||
|
||||
@@ -467,6 +473,17 @@ void
|
||||
//
|
||||
// If the sequence is not running, then return
|
||||
//
|
||||
// #99 probe: PER-SEQUENCE throttle. A single shared counter here hid the
|
||||
// alarm sequence entirely -- the busy sequences ate every slot.
|
||||
static const int s_seqDiag = (getenv("BT_ATTRBIND_LOG") != 0); // hot path: resolve once
|
||||
if (s_seqDiag && isLooped) { if (runProbeCount < 6)
|
||||
{ ++runProbeCount;
|
||||
DEBUG_STREAM << "[seqrun] seq=" << (void *)this
|
||||
<< " isRunning=" << (int)isRunning
|
||||
<< " iter=" << (void *)audioControlEventIterator
|
||||
<< " cur=" << (void *)(audioControlEventIterator ? audioControlEventIterator->GetCurrent() : 0)
|
||||
<< std::endl;
|
||||
} }
|
||||
if (!isRunning)
|
||||
return;
|
||||
|
||||
@@ -478,6 +495,16 @@ void
|
||||
|
||||
Check(audioControlEventIterator);
|
||||
control_event = audioControlEventIterator->GetCurrent();
|
||||
if (s_seqDiag) { static int s_nr=0;
|
||||
if (control_event != NULL && !IsEventReady(control_event) && (s_nr++ % 500)==0)
|
||||
{
|
||||
AudioTime off(startTime);
|
||||
off += CalculateEventSeconds(control_event);
|
||||
DEBUG_STREAM << "[seqwait] seq=" << (void *)this
|
||||
<< " eventSeconds=" << CalculateEventSeconds(control_event)
|
||||
<< " readyAt-now=" << (Scalar)(off - AudioTime::Now())
|
||||
<< std::endl;
|
||||
} }
|
||||
while (
|
||||
control_event != NULL &&
|
||||
IsEventReady(control_event)
|
||||
@@ -513,6 +540,11 @@ void
|
||||
// Send the controller to the connected audio component
|
||||
//
|
||||
Check(control_event);
|
||||
if (s_seqDiag) { static int s_se=0; if (s_se++<60)
|
||||
DEBUG_STREAM << "[seqsend] seq=" << (void *)this
|
||||
<< " -> comp=" << (void *)audioComponentSocket.GetCurrent()
|
||||
<< " ctl=" << (int)control_event->audioControlID
|
||||
<< "/" << control_event->audioControlValue << std::endl; }
|
||||
control_event->Send(audioComponentSocket.GetCurrent());
|
||||
|
||||
//
|
||||
|
||||
@@ -148,6 +148,8 @@ private:
|
||||
|
||||
Logical isLooped;
|
||||
Logical isRunning;
|
||||
int runProbeCount; // #99 diag: PER-SEQUENCE throttle for the [seqrun] probe
|
||||
// (a shared static counter hid the alarm sequence entirely)
|
||||
AudioTime startTime;
|
||||
AudioDivisionsPerBeat divisionsPerBeat;
|
||||
AudioTempo tempo;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include "munga.h"
|
||||
#pragma hdrstop
|
||||
|
||||
@@ -112,6 +113,10 @@ void
|
||||
{
|
||||
Check(this);
|
||||
Check(audioComponentSocket.GetCurrent());
|
||||
static const int s_idleDiag = (getenv("BT_ATTRBIND_LOG") != 0); // hot path: resolve once
|
||||
if (s_idleDiag) { static int s_iw=0; if ((s_iw++ % 400)==0)
|
||||
DEBUG_STREAM << "[idlewatch] #" << s_iw << " comp="
|
||||
<< (void *)audioComponentSocket.GetCurrent() << std::endl; }
|
||||
audioComponentSocket.GetCurrent()->ReceiveControl(
|
||||
IdleAudioControlID,
|
||||
0.0f
|
||||
@@ -974,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()
|
||||
@@ -1072,13 +1102,31 @@ 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());
|
||||
if (triggerState >= 5 && getenv("BT_AUDIO_SPATIAL")) { static int s_sf=0; if (s_sf++<60)
|
||||
DEBUG_STREAM << "[statefire] trigState=" << triggerState
|
||||
<< " old=" << old_state << " new=" << new_state
|
||||
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
|
||||
<< " compClass=" << (int)audioComponentSocket.GetCurrent()->GetClassID()
|
||||
<< " ctl=" << (int)controlID << "/" << controlValue
|
||||
<< "\n" << std::flush; }
|
||||
#if 1
|
||||
audioComponentSocket.GetCurrent()->ReceiveControl(
|
||||
controlID,
|
||||
|
||||
@@ -450,6 +450,11 @@ CulturalIcon::~CulturalIcon()
|
||||
break;
|
||||
}
|
||||
}
|
||||
// #93: an icon can be a projectile's picked target -- scrub the static
|
||||
// projectile pool so a round in flight never dispatches into a freed icon
|
||||
// (same teardown race as the mech case; see mech4.cpp BTProjectilesDropEntity).
|
||||
extern void BTProjectilesDropEntity(void *e);
|
||||
BTProjectilesDropEntity(this);
|
||||
}
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
|
||||
@@ -160,6 +160,18 @@ void
|
||||
Check_Pointer(update_record);
|
||||
Verify(damageZoneIndex == update_record->damageZoneIndex);
|
||||
|
||||
// #110 MP verification (BT): log a PEER-side zone state transition, so the
|
||||
// two-node bench can prove the destruction cascade replicates. Change-only.
|
||||
if (getenv("BT_MP_LOG")
|
||||
&& (damageZoneGraphicState.GetState() != update_record->damageZoneGraphicState
|
||||
|| (damageLevel < 1.0f && update_record->damageLevel >= 1.0f)))
|
||||
{
|
||||
DEBUG_STREAM << "[zone-repl] zone " << damageZoneIndex
|
||||
<< " '" << (const char *)damageZoneName << "'"
|
||||
<< " lvl " << damageLevel << "->" << update_record->damageLevel
|
||||
<< " gstate " << damageZoneGraphicState.GetState()
|
||||
<< "->" << update_record->damageZoneGraphicState << std::endl;
|
||||
}
|
||||
damageLevel = update_record->damageLevel;
|
||||
damageZoneState.SetState(update_record->damageZoneState);
|
||||
damageZoneGraphicState.SetState(update_record->damageZoneGraphicState);
|
||||
|
||||
@@ -137,6 +137,13 @@ DropZone::~DropZone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// #81: print an EntityID as host:local. A player's LOCAL id is 1 on every
|
||||
// machine, so printing only the local part made every one of five players read
|
||||
// "entity 1" -- useless for telling WHO stalled. GetHostID() is non-const.
|
||||
static inline int DZHost(const EntityID &id) { EntityID c(id); return (int)c.GetHostID(); }
|
||||
static inline int DZLocal(const EntityID &id) { return (int)id; }
|
||||
|
||||
//#############################################################################
|
||||
// Dropzone assignment
|
||||
//
|
||||
@@ -170,6 +177,69 @@ void
|
||||
Entity *entity = host->GetEntityPointer(message->requestingEntity);
|
||||
Check(entity);
|
||||
|
||||
// ---- BT_DROPZONE_LOG (2026-07-30) -------------------------------------
|
||||
// The GHOST MECH investigation: a respawn strands forever when no slot is
|
||||
// available, because the no-zone path below reposts this message to itself
|
||||
// every 0.1 s at MaxEventPriority and NEVER replies -- so the player's
|
||||
// deathPending latch is never cleared (Gitea #57/#81). This whole
|
||||
// subsystem was previously DARK: not one log line about drop zones in a
|
||||
// full night of field logs, which is why it hid. Log the pool state on
|
||||
// every request, and the outcome, so saturation is measurable.
|
||||
// Rate-limited: reposts are logged for the first few then once a second,
|
||||
// because a single stranded player generates 10 reposts/second forever.
|
||||
// The POOL CENSUS and the STALL WATCHDOG below are ALWAYS ON (no env gate):
|
||||
// a respawn stall is rare, catastrophic and field-only, exactly like the
|
||||
// Gitea #57/#59 guards. Volume is bounded -- one census line per mission,
|
||||
// one line per grant (i.e. per respawn), and stall lines are rate-limited
|
||||
// per waiting player. BT_DROPZONE_LOG adds the verbose per-request dump
|
||||
// for bench work.
|
||||
const Logical dzLog = (getenv("BT_DROPZONE_LOG") != 0);
|
||||
static Logical s_census = False;
|
||||
if (!s_census)
|
||||
{
|
||||
s_census = True;
|
||||
DEBUG_STREAM << "[dz] POOL name='" << GetDropZoneName()
|
||||
<< "' slots=" << dropZoneCount << " downTime=" << DOWN_TIME
|
||||
<< "s proximityBlock=2m\n" << std::flush;
|
||||
}
|
||||
//
|
||||
// Track how long THIS (requester, death) pair has been waiting. The engine
|
||||
// reposts the same message to itself every 0.1 s while no slot is free, so
|
||||
// the first sighting is the start of the stall.
|
||||
//
|
||||
enum { DZ_WAITERS = 8 };
|
||||
static EntityID s_waitWho[DZ_WAITERS];
|
||||
static int s_waitDeath[DZ_WAITERS];
|
||||
static Time s_waitSince[DZ_WAITERS];
|
||||
static Time s_waitLastLog[DZ_WAITERS];
|
||||
static int s_waitUsed = 0;
|
||||
int wslot = -1;
|
||||
for (int w = 0; w < s_waitUsed; ++w)
|
||||
if (s_waitWho[w] == message->requestingEntity
|
||||
&& s_waitDeath[w] == message->deathCount)
|
||||
{ wslot = w; break; }
|
||||
if (wslot < 0 && s_waitUsed < DZ_WAITERS)
|
||||
{
|
||||
wslot = s_waitUsed++;
|
||||
s_waitWho[wslot] = message->requestingEntity;
|
||||
s_waitDeath[wslot] = message->deathCount;
|
||||
s_waitSince[wslot] = Now();
|
||||
s_waitLastLog[wslot].ticks = 0;
|
||||
}
|
||||
const Scalar waited = (wslot >= 0) ? (Scalar)(Now() - s_waitSince[wslot]) : 0.0f;
|
||||
|
||||
if (dzLog)
|
||||
{
|
||||
DEBUG_STREAM << "[dz] REQUEST from "
|
||||
<< DZHost(message->requestingEntity) << ":"
|
||||
<< DZLocal(message->requestingEntity)
|
||||
<< " deathCount=" << message->deathCount
|
||||
<< " waited=" << waited << "s slots[";
|
||||
for (int d = 0; d < dropZoneCount; ++d)
|
||||
DEBUG_STREAM << (d ? " " : "") << d << (IsAvailable(d) ? ":free" : ":BUSY");
|
||||
DEBUG_STREAM << "]\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
//---------------------------------------------------------------------
|
||||
// If we have allocated a dropzone within the last ten seconds for this
|
||||
@@ -306,6 +376,44 @@ void
|
||||
//
|
||||
if (!drop_zone)
|
||||
{
|
||||
// NO SLOT: repost to ourselves in 0.1 s and reply to nobody. The
|
||||
// requester's respawn is stalled until this eventually succeeds -- if it
|
||||
// never does, that player is a permanent GHOST (dead, un-reset, still
|
||||
// simulated and driveable, wearing the wreck on every peer).
|
||||
// ALWAYS-ON STALL WATCHDOG. Escalating, rate-limited (first sighting,
|
||||
// then every 5 s per waiting player), and it names WHY each slot is
|
||||
// busy -- seconds since last use and who used it -- which is the
|
||||
// actionable half: cooldown saturation (pool too small for the death
|
||||
// rate) looks different from proximity blocking (mechs parked on the
|
||||
// pad). After 15 s a stalled respawn is a confirmed GHOST: the player
|
||||
// is dead, un-reset, still driveable, and a wreck on every peer.
|
||||
if (wslot >= 0
|
||||
&& (!s_waitLastLog[wslot].ticks || (Now() - s_waitLastLog[wslot]) > 5.0f))
|
||||
{
|
||||
s_waitLastLog[wslot] = Now();
|
||||
DEBUG_STREAM << (waited >= 15.0f ? "[dz] GHOST LIKELY -- " : "[dz] STALL -- ")
|
||||
<< DZHost(message->requestingEntity) << ":"
|
||||
<< DZLocal(message->requestingEntity)
|
||||
<< " death#" << message->deathCount
|
||||
<< " has waited " << waited << "s for a slot; all "
|
||||
<< dropZoneCount << " busy [";
|
||||
for (int d = 0; d < dropZoneCount; ++d)
|
||||
{
|
||||
Scalar age = lastUsageTime[d].ticks ? (Scalar)(Now() - lastUsageTime[d]) : -1.0f;
|
||||
DEBUG_STREAM << (d ? " " : "") << d << ":usedBy="
|
||||
<< DZHost(lastUsedBy[d]) << ":" << DZLocal(lastUsedBy[d])
|
||||
<< ",age=" << age << "s";
|
||||
}
|
||||
DEBUG_STREAM << "]\n" << std::flush;
|
||||
}
|
||||
if (dzLog)
|
||||
{
|
||||
static int s_rp = 0;
|
||||
if (++s_rp <= 5)
|
||||
DEBUG_STREAM << "[dz] repost #" << s_rp << " ("
|
||||
<< DZHost(message->requestingEntity) << ":"
|
||||
<< DZLocal(message->requestingEntity) << ")\n" << std::flush;
|
||||
}
|
||||
Time when=Now();
|
||||
when += 0.1f;
|
||||
application->Post(MaxEventPriority, this, message, when);
|
||||
@@ -318,6 +426,36 @@ void
|
||||
//--------------------------------------------------------------------
|
||||
//
|
||||
Found_One:
|
||||
// ALWAYS ON -- one line per respawn. `waited` is the headline number: 0 is
|
||||
// healthy, anything seconds-long means the pool is saturating, and a grant
|
||||
// after a long wait is a ghost that RECOVERED (which is what testers see as
|
||||
// "it fixed itself after a while").
|
||||
{
|
||||
static int s_grants = 0, s_stalledGrants = 0;
|
||||
int slot = (int)(drop_zone - dropZones);
|
||||
if (waited > 0.5f) ++s_stalledGrants;
|
||||
++s_grants;
|
||||
DEBUG_STREAM << "[dz] GRANTED slot " << slot << " to "
|
||||
<< DZHost(message->requestingEntity) << ":"
|
||||
<< DZLocal(message->requestingEntity)
|
||||
<< " death#" << message->deathCount
|
||||
<< " waited=" << waited << "s"
|
||||
<< (waited >= 15.0f ? " (GHOST RECOVERED)" : "")
|
||||
<< " [grants=" << s_grants << " stalled=" << s_stalledGrants << "]"
|
||||
<< "\n" << std::flush;
|
||||
// retire this waiter slot so the table does not fill up over a match
|
||||
if (wslot >= 0)
|
||||
{
|
||||
for (int w = wslot; w + 1 < s_waitUsed; ++w)
|
||||
{
|
||||
s_waitWho[w] = s_waitWho[w+1];
|
||||
s_waitDeath[w] = s_waitDeath[w+1];
|
||||
s_waitSince[w] = s_waitSince[w+1];
|
||||
s_waitLastLog[w] = s_waitLastLog[w+1];
|
||||
}
|
||||
--s_waitUsed;
|
||||
}
|
||||
}
|
||||
ReplyMessage
|
||||
reply(
|
||||
message->replyMessageID,
|
||||
|
||||
@@ -3819,7 +3819,28 @@ Logical
|
||||
|
||||
case background:
|
||||
{
|
||||
result = ProcessOneActiveGauge();
|
||||
// BT GAUGE BATCH (#45, with the APPMGR minimum-pump floor): one gauge
|
||||
// per turn is the authentic pod pacing, but a visit is just a
|
||||
// rate-mask check unless the gauge is due, so on modern hardware it
|
||||
// starves the rotation for no benefit -- with ~140 active instruments
|
||||
// and ~4 gauge turns/frame a full sweep took ~1s and a D-rate gauge
|
||||
// (the comms-panel PilotList) ran every ~4s. Advance a BATCH of
|
||||
// gauges per turn so a sweep completes ~every frame and panel rows
|
||||
// track the sim within a second. BT_GAUGE_BATCH=1 restores the
|
||||
// authentic single-step.
|
||||
static int s_batch = -1;
|
||||
if (s_batch < 0)
|
||||
{
|
||||
const char *e = getenv("BT_GAUGE_BATCH");
|
||||
s_batch = e ? atoi(e) : 32;
|
||||
if (s_batch < 1) s_batch = 1;
|
||||
if (s_batch > 1024) s_batch = 1024;
|
||||
}
|
||||
int n = s_batch;
|
||||
do
|
||||
{
|
||||
result = ProcessOneActiveGauge();
|
||||
} while (--n > 0 && result && taskMode == background);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3963,6 +3984,10 @@ void
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
// BT gauge-executive counters (#45) -- definitions; sampled + reset by the
|
||||
// BT_PERF probe in APPMGR.cpp.
|
||||
int gBTGaugeTurns = 0, gBTGaugeSweeps = 0, gBTGaugeActive = 0;
|
||||
|
||||
//
|
||||
//===========================================================================
|
||||
// ProcessOneActiveGauge
|
||||
@@ -3979,8 +4004,15 @@ Logical
|
||||
GaugeBase
|
||||
*base_pointer = activeIterator->ReadAndNext();
|
||||
|
||||
// BT gauge-executive counters (#45): read by the BT_PERF probe (APPMGR.cpp)
|
||||
// to expose the instrument-rotation rate -- turns = single-gauge Updates,
|
||||
// sweeps = full active-list passes (the rate mask advances once per sweep,
|
||||
// so a "D-rate" gauge executes once per FOUR sweeps).
|
||||
extern int gBTGaugeTurns, gBTGaugeSweeps, gBTGaugeActive;
|
||||
if (base_pointer == NULL)
|
||||
{
|
||||
++gBTGaugeSweeps;
|
||||
gBTGaugeActive = activeIterator->GetSize();
|
||||
//--------------------------------------------------
|
||||
// We're done!
|
||||
// Bump the bit mask and index for the next pass
|
||||
@@ -3997,6 +4029,7 @@ Logical
|
||||
}
|
||||
else
|
||||
{
|
||||
++gBTGaugeTurns;
|
||||
//--------------------------------------------------
|
||||
// Process one gauge
|
||||
//--------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
//#########################################################################
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
//############################ Renderer #################################
|
||||
//#############################################################################
|
||||
|
||||
const RendererRate DefaultRendererRate = 30.0f;
|
||||
// 2026-08-02 (#96 clock trace): the DOS binary's audio/effect frame clock runs
|
||||
// at the ENGINE FRAME RATE GLOBAL DAT_0052140c = 28.0f (0x401ace), not 30 --
|
||||
// AudioTime::Seconds_To_Frames in the image is `fmul [0x52140c]; fadd 0.5`
|
||||
// (e.g. @0x42c611), and every authored frame-count duration assumes 28.
|
||||
// 30 here ran ALL audio timing ~7% fast. [T1]
|
||||
const RendererRate DefaultRendererRate = 28.0f;
|
||||
const RendererComplexity MaxRendererComplexity = 3.0f;
|
||||
const RendererComplexity MinRendererComplexity = 0.0f;
|
||||
const RendererFrameBudget DefaultRendererFrameBudget = 0.14f; //0.12f; // 0.16f;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -126,6 +126,17 @@ protected:
|
||||
// Accessors
|
||||
//
|
||||
public:
|
||||
// #110 (BT reconstruction): read access to the two streamed tables the
|
||||
// binary's zone-destruction cascade walks (Mech__DamageZone::
|
||||
// RecurseSegmentTable @0049cad4 -- seg+0xD0 damage zones, seg+0xE8 child
|
||||
// indices). Read-only; population stays with JointedMover streaming.
|
||||
IntegerTable&
|
||||
GetDamageZoneTable()
|
||||
{Check(this); return damageZoneTable;}
|
||||
IntegerTable&
|
||||
GetChildIndexTable()
|
||||
{Check(this); return childIndexTable;}
|
||||
|
||||
void
|
||||
ModifySegment(Logical modified = True)
|
||||
{Check(this); segmentModified = modified;}
|
||||
|
||||
@@ -157,6 +157,17 @@ void
|
||||
{
|
||||
Check(this);
|
||||
Verify(new_state < stateCount);
|
||||
// #78 DIAG: trap ANY 3/4 -> <=1 transition on ANY indicator, with the
|
||||
// caller's return address (symbolize via tools/symcrash.py). Catches
|
||||
// writers that bypass Simulation::SetSimulationState.
|
||||
if (getenv("BT_AUDIO_SPATIAL")
|
||||
&& (currentState == 3 || currentState == 4) && new_state <= 1)
|
||||
{
|
||||
static int s_it=0; if (s_it++<40)
|
||||
DEBUG_STREAM << "[indstomp] " << currentState << "->" << new_state
|
||||
<< " ind=" << (void*)this
|
||||
<< " ra=" << _ReturnAddress() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
+19
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "state.h"
|
||||
#include "receiver.h"
|
||||
#include <intrin.h> // _ReturnAddress (the #78 simstomp diag)
|
||||
#include "time.h"
|
||||
#include "resource.h"
|
||||
|
||||
@@ -221,7 +222,24 @@ public:
|
||||
{Check(this); return simulationState.GetOldState();}
|
||||
void
|
||||
SetSimulationState(unsigned new_state)
|
||||
{Check(this); simulationState.SetState(new_state);}
|
||||
{Check(this);
|
||||
// #78 DIAG: trap the gimp-cell downgrade (3/4 -> <=1) with the
|
||||
// caller's return address (symbolize via tools/symcrash.py).
|
||||
// #78/#82 DIAG: who resets the GIMP cell? Scoped to the one mech
|
||||
// mechdmg just gimped (g_btGimpWatchMech) -- subsystems are
|
||||
// Simulations too and their 3->0 churn drowned the earlier trap.
|
||||
// ra is printed MODULE-RELATIVE (btl4+0xNNNN) for symcrash.py.
|
||||
{
|
||||
extern void *g_btGimpWatchMech;
|
||||
extern void BTGimpStompTrap(void *sim, unsigned cur,
|
||||
unsigned nw, void *ra);
|
||||
unsigned _cur = simulationState.GetState();
|
||||
if ((void*)this == g_btGimpWatchMech
|
||||
&& (_cur == 3 || _cur == 4) && new_state != _cur)
|
||||
BTGimpStompTrap((void*)this, _cur, new_state,
|
||||
_ReturnAddress());
|
||||
}
|
||||
simulationState.SetState(new_state);}
|
||||
|
||||
StateIndicator
|
||||
simulationState;
|
||||
|
||||
@@ -47,6 +47,13 @@ public:
|
||||
Check(&gaugeWatcherSocket);
|
||||
gaugeWatcherSocket.Add(watcher);
|
||||
}
|
||||
int DebugAudioWatcherCount() // DEBUG (#78 audio flake): registered audio watchers
|
||||
{
|
||||
int n = 0;
|
||||
SChainIteratorOf<Component*> it(audioWatcherSocket);
|
||||
while (it.ReadAndNext() != NULL) ++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
private:
|
||||
SChainOf<Component*> audioWatcherSocket;
|
||||
|
||||
@@ -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
|
||||
|
||||
+337
-42
@@ -395,12 +395,27 @@ void
|
||||
// DPLIndependantEffect voices, and while the pool is pinned every new
|
||||
// acquire fails outright and that sound is silently dropped.
|
||||
//
|
||||
// ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win, and
|
||||
// the reported dropout was already fixed on 2026-07-23 by a999e5c (the
|
||||
// atomic-delete leak) -- no field complaints since. What remains in the
|
||||
// night-5 logs is transient exhaustion at the peak of a firefight, where
|
||||
// a dropped voice competes with ~256 already sounding, so it is very
|
||||
// likely sub-perceptual. Against that: the 256 ceiling also acts as a
|
||||
// ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win.
|
||||
//
|
||||
// ⚠⚠ UPDATE 2026-08-01 (#32): the "no field complaints since" claim below
|
||||
// did NOT hold. Every 4.11.674 player log is saturated -- 3031/4657/5245/
|
||||
// 6275/6571 failures across five machines, first failure ~10% into a match
|
||||
// and still failing at 97%. The root cause was NOT the source budget at
|
||||
// all: RequestAudioChannels was alGenSources'ing per sound event and
|
||||
// ReleaseSourceSet alDeleteSources'ing on release, so combat CHURNED
|
||||
// through the ceiling. Sources are now POOLED and recycled (see
|
||||
// BTAudioPoolAcquire below), which fixes it WITHOUT touching this budget.
|
||||
// Measured on the same bench: frame time went 8.67ms -> 7.79ms over ~10k
|
||||
// frames, i.e. removing the churn is a net CPU WIN, so the governor
|
||||
// argument below does not apply to pooling. The reasoning about raising
|
||||
// the CAP (more voices mixing = more CPU) still stands on its own, which
|
||||
// is why BT_AUDIO_SOURCES remains opt-in and unset by default.
|
||||
//
|
||||
// (Historical:) the 2026-07-23 fix a999e5c addressed the atomic-delete
|
||||
// leak. What remained in the night-5 logs is transient exhaustion at the
|
||||
// peak of a firefight, where a dropped voice competes with ~256 already
|
||||
// sounding, so it is very likely sub-perceptual. Against that: the 256
|
||||
// ceiling also acts as a
|
||||
// GOVERNOR. The steal loop only steals when the incoming source outranks
|
||||
// a running one, so a higher cap means many more voices mixing at once --
|
||||
// with EFX reverb + the lowpass chains live, that is real CPU, spent
|
||||
@@ -593,13 +608,19 @@ void
|
||||
{
|
||||
Check(this);
|
||||
Check(audio_source);
|
||||
|
||||
|
||||
if (getenv("BT_AUDIO_SPATIAL")) { static int s_sr=0; if (s_sr++<5000)
|
||||
DEBUG_STREAM << "[startreq] src=" << (void*)audio_source
|
||||
<< " state=" << (int)audio_source->GetAudioSourceState()
|
||||
<< " class=" << (int)audio_source->GetClassID()
|
||||
<< "\n" << std::flush; }
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
// Verify that the source is stopped
|
||||
//--------------------------------------------------------------------------
|
||||
//
|
||||
Verify(audio_source->GetAudioSourceState() == StoppedAudioSourceState);
|
||||
Verify(audio_source->GetAudioSourceState() == StoppedAudioSourceState);
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -619,6 +640,9 @@ void
|
||||
);
|
||||
if (!resources_available)
|
||||
{
|
||||
if (getenv("BT_AUDIO_SPATIAL")) { static int s_rf=0; if (s_rf++<40)
|
||||
DEBUG_STREAM << "[startreq] RESOURCE FAIL src=" << (void*)audio_source
|
||||
<< "\n" << std::flush; }
|
||||
// Audio-dropout fix: a failed acquisition can leave a PARTIAL set
|
||||
// (alGenSources succeeded for some slots before the pool ran dry).
|
||||
// A dropped transient never plays and nothing else ever released it,
|
||||
@@ -1241,6 +1265,13 @@ Logical
|
||||
source_result = audio_source->GetAudioChannelSet();
|
||||
Check(source_result);
|
||||
|
||||
// #32 census: identify the requester while RequestAudioChannels runs, so a
|
||||
// failure can be attributed to a component CLASS without threading an
|
||||
// argument through the virtual (audio is main-thread only).
|
||||
extern int gBTAudioReqClass, gBTAudioReqVoices;
|
||||
gBTAudioReqClass = (int)audio_source->GetClassID();
|
||||
gBTAudioReqVoices = audio_source->GetAudioVoiceCount();
|
||||
|
||||
resources_available =
|
||||
RequestAudioChannels(
|
||||
audio_source->GetAudioVoiceCount(),
|
||||
@@ -1328,6 +1359,11 @@ Logical
|
||||
// Else suspend the source
|
||||
//--------------------------------------------------------------------
|
||||
//
|
||||
{ // #32 census: a steal ends a RUNNING sound early -- count it
|
||||
// ungated so field logs show how hard the mixer is fighting.
|
||||
extern long gBTAudioSteals;
|
||||
++gBTAudioSteals;
|
||||
}
|
||||
if (
|
||||
running_audio_source->GetAudioRenderType() ==
|
||||
TransientAudioRenderType
|
||||
@@ -1358,10 +1394,32 @@ Logical
|
||||
iterator.Last();
|
||||
running_audio_source = iterator.GetCurrent();
|
||||
}
|
||||
|
||||
// #32 census: the steal loop is done and the request is STILL
|
||||
// unsatisfied -- this sound is genuinely dropped. (The raw
|
||||
// "ACQUIRE FAILED" print fires once per attempt INSIDE the steal loop,
|
||||
// so its count wildly overstates real drops; this one does not.)
|
||||
if (!resources_available)
|
||||
{
|
||||
extern long gBTAudioTrueDrops;
|
||||
extern void BTAudioDropTally(int class_ID, int voices);
|
||||
++gBTAudioTrueDrops;
|
||||
BTAudioDropTally((int)audio_source->GetClassID(),
|
||||
audio_source->GetAudioVoiceCount());
|
||||
}
|
||||
}
|
||||
return resources_available;
|
||||
}
|
||||
|
||||
//
|
||||
// OpenAL source pool (gitea #32) -- defined below, used here.
|
||||
//
|
||||
extern Logical BTAudioPoolAcquire(ALuint *out);
|
||||
extern void BTAudioPoolRelease(ALuint src);
|
||||
extern int BTAudioPoolSize();
|
||||
extern int BTAudioPoolFree();
|
||||
extern long BTAudioPoolReuses();
|
||||
|
||||
//
|
||||
//#############################################################################
|
||||
// RequestAudioChannels
|
||||
@@ -1392,9 +1450,17 @@ Logical
|
||||
if (now_ms - s_censusAt > 30000)
|
||||
{
|
||||
s_censusAt = now_ms;
|
||||
extern long gBTAudioSteals, gBTAudioTrueDrops;
|
||||
DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive
|
||||
<< " pooled=" << BTAudioPoolSize()
|
||||
<< " free=" << BTAudioPoolFree()
|
||||
<< " reuses=" << BTAudioPoolReuses()
|
||||
<< " acquireFails=" << gBTAudioAcquireFails
|
||||
<< " steals=" << gBTAudioSteals
|
||||
<< " drops=" << gBTAudioTrueDrops
|
||||
<< std::endl << std::flush;
|
||||
extern void BTAudioDropCensus();
|
||||
BTAudioDropCensus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1405,35 +1471,55 @@ Logical
|
||||
if (requested > AUDIO_SOURCESET_CAPACITY)
|
||||
requested = AUDIO_SOURCESET_CAPACITY;
|
||||
|
||||
bool failed = true;
|
||||
|
||||
alGetError();
|
||||
|
||||
//
|
||||
// SOURCE POOLING (gitea #32). This used to alGenSources() here and
|
||||
// alDeleteSources() in ReleaseSourceSet -- i.e. CREATE and DESTROY OpenAL
|
||||
// sources per sound event. Every 4.11.674 player log shows the result:
|
||||
// thousands of acquisition failures each (3031-6571 across five machines),
|
||||
// beginning ~10% into a match and never recovering, because a combat burst
|
||||
// churns straight through OpenAL's hard per-context source limit (256).
|
||||
// The 30s census reading `live=6` while the failure line read `live=256`
|
||||
// was the tell: the same counter, sampled between bursts -- churn, not a
|
||||
// steady leak.
|
||||
//
|
||||
// Sources are now generated ONCE and recycled through a free list, so
|
||||
// steady-state demand costs no allocation at all and the driver cap is
|
||||
// never approached.
|
||||
//
|
||||
for (int i = 0; i < requested; i++)
|
||||
{
|
||||
if (!alIsSource(source_request->sources[i]))
|
||||
if (alIsSource(source_request->sources[i]))
|
||||
continue; // slot already holds one
|
||||
ALuint src = 0;
|
||||
if (!BTAudioPoolAcquire(&src))
|
||||
{
|
||||
alGenSources(1, source_request->sources + i);
|
||||
if (alIsSource(source_request->sources[i]))
|
||||
++gBTAudioSourcesLive;
|
||||
++gBTAudioAcquireFails;
|
||||
// Rate-limited: the night-9 field logs carried 6.8k-19k of these per
|
||||
// player, and the count is NOISE -- the steal loop retries after every
|
||||
// failed attempt, so lines pile up per EVENT, and most events still
|
||||
// end in a successful steal. One line per 30s band keeps the signal
|
||||
// (the census carries the real counters: steals + true drops).
|
||||
extern int gBTAudioReqClass, gBTAudioReqVoices;
|
||||
static unsigned long s_failNoteAt = 0;
|
||||
unsigned long fail_now = GetTickCount();
|
||||
if (fail_now - s_failNoteAt > 30000)
|
||||
{
|
||||
s_failNoteAt = fail_now;
|
||||
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
|
||||
<< " reqClass=" << gBTAudioReqClass
|
||||
<< " reqVoices=" << gBTAudioReqVoices
|
||||
<< " live=" << gBTAudioSourcesLive
|
||||
<< " pooled=" << BTAudioPoolSize()
|
||||
<< " free=" << BTAudioPoolFree()
|
||||
<< " fails=" << gBTAudioAcquireFails
|
||||
<< ") -- saturated this instant; the steal loop decides what plays"
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
return False;
|
||||
}
|
||||
}
|
||||
|
||||
ALenum error = alGetError();
|
||||
if (error == AL_NO_ERROR)
|
||||
{
|
||||
failed = false;
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
++gBTAudioAcquireFails;
|
||||
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
|
||||
<< " live=" << gBTAudioSourcesLive
|
||||
<< " fails=" << gBTAudioAcquireFails
|
||||
<< ") -- the pool is exhausted; expect dropouts"
|
||||
<< std::endl << std::flush;
|
||||
return False;
|
||||
source_request->sources[i] = src;
|
||||
}
|
||||
|
||||
return True;
|
||||
@@ -1515,6 +1601,220 @@ Logical
|
||||
long gBTAudioSourcesLive = 0;
|
||||
long gBTAudioAcquireFails = 0;
|
||||
|
||||
//
|
||||
// #32 census v2. The night-9 field logs proved the FIRST generation of these
|
||||
// counters mislead under pressure: "ACQUIRE FAILED ... free=0" reads as
|
||||
// permanent retention, but free is 0 at the instant of any failed acquire BY
|
||||
// DEFINITION -- the 30s census showed free back at ~230 between bursts, i.e.
|
||||
// the pool cycles and the mixer is simply saturated DURING combat. (Same
|
||||
// counter-sampling trap as the original live=256-vs-live=6, second offence.)
|
||||
// These count what actually matters:
|
||||
// gBTAudioSteals -- a running sound was ended early to service a new one
|
||||
// gBTAudioTrueDrops -- the steal loop ran dry and the sound NEVER PLAYED
|
||||
// plus a per-component-class tally of the dropped, so the next field log names
|
||||
// the class that saturates the mixer instead of leaving it to inference.
|
||||
//
|
||||
int gBTAudioReqClass = -1;
|
||||
int gBTAudioReqVoices = 0;
|
||||
long gBTAudioSteals = 0;
|
||||
long gBTAudioTrueDrops = 0;
|
||||
|
||||
struct BTAudioDropBin { int classID; int voices; long count; };
|
||||
static BTAudioDropBin gBTAudioDropBins[12];
|
||||
static int gBTAudioDropBinCount = 0;
|
||||
|
||||
void BTAudioDropTally(int class_ID, int voices)
|
||||
{
|
||||
for (int i = 0; i < gBTAudioDropBinCount; ++i)
|
||||
{
|
||||
if (gBTAudioDropBins[i].classID == class_ID
|
||||
&& gBTAudioDropBins[i].voices == voices)
|
||||
{
|
||||
++gBTAudioDropBins[i].count;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (gBTAudioDropBinCount < 12)
|
||||
{
|
||||
gBTAudioDropBins[gBTAudioDropBinCount].classID = class_ID;
|
||||
gBTAudioDropBins[gBTAudioDropBinCount].voices = voices;
|
||||
gBTAudioDropBins[gBTAudioDropBinCount].count = 1;
|
||||
++gBTAudioDropBinCount;
|
||||
}
|
||||
}
|
||||
|
||||
// One line under the 30s census, only when something was dropped since boot:
|
||||
// which component classes lost sounds, and how many.
|
||||
void BTAudioDropCensus()
|
||||
{
|
||||
if (gBTAudioDropBinCount == 0)
|
||||
return;
|
||||
DEBUG_STREAM << "[audio] dropped by class:";
|
||||
for (int i = 0; i < gBTAudioDropBinCount; ++i)
|
||||
DEBUG_STREAM << " {class " << gBTAudioDropBins[i].classID
|
||||
<< " x" << gBTAudioDropBins[i].voices
|
||||
<< "v: " << gBTAudioDropBins[i].count << "}";
|
||||
DEBUG_STREAM << std::endl << std::flush;
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// OpenAL SOURCE POOL (gitea #32)
|
||||
//#############################################################################
|
||||
//
|
||||
// The renderer used to create an AL source per sound event and destroy it on
|
||||
// release. OpenAL sources are a scarce driver resource -- OpenAL Soft caps a
|
||||
// context at 256 -- and creating them is not cheap, so combat bursts ran the
|
||||
// context dry. Field evidence (4.11.674, five machines): 3031-6571 acquisition
|
||||
// failures per player, first failure ~10% into a match, still failing at 97%.
|
||||
//
|
||||
// Sources are now generated once, on demand, up to kAudioPoolCap and then
|
||||
// RECYCLED: release scrubs the source and returns it to the free list instead
|
||||
// of deleting it. Steady-state play performs no AL allocation at all.
|
||||
//
|
||||
// THE SCRUB IS LOAD-BEARING. A recycled source carries whatever the previous
|
||||
// owner set on it, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327).
|
||||
// Hand a looping source to a one-shot and it plays forever -- exactly the
|
||||
// "sound stuck looping" family (#51, #5). Every reusable property is reset
|
||||
// here, at the single point where sources change owner.
|
||||
//
|
||||
// Not locked: the audio renderer runs on the main thread (only the network RX
|
||||
// socket has its own). If that ever changes, this needs a mutex.
|
||||
//
|
||||
// The pool's ceiling. DEFAULT: just under OpenAL Soft's 256-source context
|
||||
// default. BT_AUDIO_SOURCES=<n> raises the AL context budget at Initialize
|
||||
// (above); the pool cap now FOLLOWS it -- before this, the env raised the
|
||||
// context and the pool still stopped at 240, so the experiment was impossible
|
||||
// to run in the field. kAudioPoolMax bounds the static free-list array.
|
||||
static const int kAudioPoolMax = 1024;
|
||||
static int kAudioPoolCap = 240;
|
||||
static int BTAudioPoolCapResolve()
|
||||
{
|
||||
static int s_done = 0;
|
||||
if (!s_done)
|
||||
{
|
||||
s_done = 1;
|
||||
const char *sv = getenv("BT_AUDIO_SOURCES");
|
||||
if (sv != 0)
|
||||
{
|
||||
int n = atoi(sv);
|
||||
if (n >= 64 && n <= 4096)
|
||||
{
|
||||
// stay under the context grant with a small reserve
|
||||
kAudioPoolCap = (n - 16 < kAudioPoolMax) ? (n - 16) : kAudioPoolMax;
|
||||
DEBUG_STREAM << "[audio] source pool cap follows BT_AUDIO_SOURCES: "
|
||||
<< kAudioPoolCap << std::endl << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
return kAudioPoolCap;
|
||||
}
|
||||
//
|
||||
// PEAK DEMAND is set by how many audio COMPONENTS are alive, not by how many
|
||||
// sounds are audible: each component reserves a SourceSet of up to
|
||||
// AUDIO_SOURCESET_CAPACITY (25) voices and holds them until it is released.
|
||||
// Measured high-water: 138 solo vs one enemy, 149 across two nodes. That grows
|
||||
// with player count, so the cap is deliberately near the driver ceiling and the
|
||||
// pool reports its high-water mark once, to size this from FIELD logs rather
|
||||
// than from a guess. Growth also stops on its own if a driver offers fewer
|
||||
// sources than the cap -- alGenSources failing simply ends growth and the pool
|
||||
// recycles what it has.
|
||||
|
||||
static ALuint gAudioPoolFree[kAudioPoolMax];
|
||||
static int gAudioPoolFreeCount = 0; // entries in gAudioPoolFree
|
||||
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
|
||||
static long gAudioPoolReuses = 0; // diagnostics
|
||||
|
||||
int BTAudioPoolSize() { return gAudioPoolTotal; }
|
||||
int BTAudioPoolFree() { return gAudioPoolFreeCount; }
|
||||
long BTAudioPoolReuses() { return gAudioPoolReuses; }
|
||||
|
||||
//
|
||||
// Reset a source to a known-neutral state so nothing carries across owners.
|
||||
//
|
||||
static void
|
||||
BTAudioScrubSource(ALuint src)
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
alGetSourcei(src, AL_SOURCE_STATE, &state);
|
||||
if (state == AL_PLAYING || state == AL_PAUSED)
|
||||
alSourceStop(src);
|
||||
alSourcei(src, AL_BUFFER, 0); // detach (no queued buffers here)
|
||||
alSourcei(src, AL_LOOPING, AL_FALSE); // <-- the stuck-loop guard
|
||||
alSourcef(src, AL_GAIN, 1.0f);
|
||||
alSourcef(src, AL_PITCH, 1.0f);
|
||||
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
|
||||
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
|
||||
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
|
||||
alSourcef(src, AL_MIN_GAIN, 0.0f);
|
||||
alSourcef(src, AL_MAX_GAIN, 1.0f);
|
||||
alGetError(); // swallow any property complaint
|
||||
}
|
||||
|
||||
//
|
||||
// Hand out a source: recycle first, generate only when the pool has never been
|
||||
// that large. False = genuinely out (peak concurrent demand exceeded the cap).
|
||||
//
|
||||
Logical
|
||||
BTAudioPoolAcquire(ALuint *out)
|
||||
{
|
||||
Check_Pointer(out);
|
||||
while (gAudioPoolFreeCount > 0)
|
||||
{
|
||||
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
|
||||
if (alIsSource(src)) // paranoia: a context reset invalidates names
|
||||
{
|
||||
++gAudioPoolReuses;
|
||||
*out = src;
|
||||
return True;
|
||||
}
|
||||
--gAudioPoolTotal; // stale name: forget it
|
||||
}
|
||||
|
||||
if (gAudioPoolTotal >= BTAudioPoolCapResolve())
|
||||
return False;
|
||||
|
||||
ALuint src = 0;
|
||||
alGetError();
|
||||
alGenSources(1, &src);
|
||||
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
|
||||
return False; // driver said no before our cap
|
||||
|
||||
++gAudioPoolTotal;
|
||||
++gBTAudioSourcesLive;
|
||||
{
|
||||
// One line per new high-water band, so a field log shows how close a
|
||||
// real match gets to the ceiling without spamming.
|
||||
static int s_notified = 0;
|
||||
if (gAudioPoolTotal >= s_notified + 25)
|
||||
{
|
||||
s_notified = gAudioPoolTotal;
|
||||
DEBUG_STREAM << "[audio] source pool high-water: " << gAudioPoolTotal
|
||||
<< " of " << kAudioPoolCap << std::endl << std::flush;
|
||||
}
|
||||
}
|
||||
*out = src;
|
||||
return True;
|
||||
}
|
||||
|
||||
//
|
||||
// Take a source back. Scrubbed and parked, NOT deleted.
|
||||
//
|
||||
void
|
||||
BTAudioPoolRelease(ALuint src)
|
||||
{
|
||||
if (!alIsSource(src))
|
||||
return;
|
||||
BTAudioScrubSource(src);
|
||||
if (gAudioPoolFreeCount < kAudioPoolMax)
|
||||
{
|
||||
gAudioPoolFree[gAudioPoolFreeCount++] = src;
|
||||
return;
|
||||
}
|
||||
alDeleteSources(1, &src); // cannot happen (max == array size)
|
||||
--gAudioPoolTotal;
|
||||
--gBTAudioSourcesLive;
|
||||
}
|
||||
|
||||
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||
{
|
||||
// Audio-dropout fix: the old bulk alDeleteSources(count, sources) is
|
||||
@@ -1524,19 +1824,14 @@ void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||
// partial release leaked its real sources and the pool never recovered
|
||||
// ("audio cutting in and out toward the end of the match"). Delete each
|
||||
// valid source individually and park the slot at 0 (never a valid name).
|
||||
extern long gBTAudioSourcesLive;
|
||||
// SOURCE POOLING (#32): hand each source back to the free list instead of
|
||||
// destroying it. BTAudioPoolRelease stops it, detaches its buffer and
|
||||
// clears AL_LOOPING before parking it, so the next owner starts clean.
|
||||
for (int i = 0; i < sourceSet.count; i++)
|
||||
{
|
||||
if (alIsSource(sourceSet.sources[i]))
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
|
||||
if (state == AL_PLAYING)
|
||||
alSourceStop(sourceSet.sources[i]);
|
||||
alDeleteSources(1, sourceSet.sources + i);
|
||||
--gBTAudioSourcesLive;
|
||||
}
|
||||
sourceSet.sources[i] = 0;
|
||||
if (sourceSet.sources[i] != 0)
|
||||
BTAudioPoolRelease(sourceSet.sources[i]);
|
||||
sourceSet.sources[i] = 0; // 0 is never a valid AL name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -393,6 +393,17 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
|
||||
object->mDrawOps[i].lodNear = data.batches[i].lodNear;
|
||||
object->mDrawOps[i].lodFar = data.batches[i].lodFar;
|
||||
object->mDrawOps[i].lodDepthBias = data.batches[i].lodBias;
|
||||
// MECH ARMOUR DAMAGE (issue #87): carry the authored material name so the
|
||||
// mech's .DZM zone->material lists can drive this batch's darkening.
|
||||
{
|
||||
const std::string &mn = data.batches[i].matName;
|
||||
size_t n = mn.size();
|
||||
if (n >= sizeof(object->mDrawOps[i].dzMatName))
|
||||
n = sizeof(object->mDrawOps[i].dzMatName) - 1;
|
||||
memcpy(object->mDrawOps[i].dzMatName, mn.c_str(), n);
|
||||
object->mDrawOps[i].dzMatName[n] = '\0';
|
||||
object->mDrawOps[i].dzDamageLevel = 0.0f;
|
||||
}
|
||||
|
||||
const bool useRamp = (s_ramp && data.batches[i].hasRamp);
|
||||
uint32_t c = data.batches[i].color;
|
||||
@@ -432,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
|
||||
@@ -1198,6 +1214,33 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
|
||||
mDevice->SetMaterial(&drawOp->material);
|
||||
|
||||
// MECH ARMOUR DAMAGE (issue #87): the 1995 renderer kept a per-material
|
||||
// watcher that lerped the material's colour terms from the authored values
|
||||
// toward 0.1x as the owning damage zone's damageLevel ran 0->1
|
||||
// (FUN_004573e4 precomputed pristine*0.1; FUN_00457784 re-pushed
|
||||
// lerp(pristine, damaged, level) on every change, constant @0x4579a4 = 1.0).
|
||||
//
|
||||
// We cannot express that by scaling this op's D3DMATERIAL9: D3D9 defaults
|
||||
// DIFFUSEMATERIALSOURCE to D3DMCS_COLOR1, and every BGF vertex carries a
|
||||
// baked colour, so the material's Diffuse is never consulted for this
|
||||
// geometry -- scaling it renders identically (measured: 0 changed pixels).
|
||||
// Instead modulate the FINAL colour by k on texture stage 1, which darkens
|
||||
// the result whatever the diffuse source was, and equally covers the
|
||||
// ramp-baked-texture path (the mech's own case) and pure-emissive batches.
|
||||
const bool damageTint = (drawOp->dzDamageLevel > 0.0f);
|
||||
if (damageTint)
|
||||
{
|
||||
float d = drawOp->dzDamageLevel;
|
||||
if (d > 1.0f) d = 1.0f;
|
||||
const float k = 1.0f - (1.0f - kDamagedMaterialScale) * d; // lerp(1, 0.1, d)
|
||||
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR, D3DCOLOR_COLORVALUE(k, k, k, 1.0f));
|
||||
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE);
|
||||
mDevice->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_CURRENT);
|
||||
mDevice->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_TFACTOR);
|
||||
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
|
||||
mDevice->SetTextureStageState(1, D3DTSS_ALPHAARG1, D3DTA_CURRENT);
|
||||
}
|
||||
|
||||
#ifndef RP3_EMULATE
|
||||
SetTextureScrolling(&(drawOp->texture), targetRenderFrame);
|
||||
SetTexture(drawOp->texture.texture);
|
||||
@@ -1241,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);
|
||||
@@ -1254,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
|
||||
@@ -1337,6 +1404,13 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2);
|
||||
}
|
||||
if (damageTint)
|
||||
{
|
||||
// stage 1 is otherwise unused in this draw path -- put it back to
|
||||
// DISABLE so the next (undamaged) op is not tinted too.
|
||||
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
|
||||
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
|
||||
}
|
||||
if (drawOp->lodDepthBias != 0.0f)
|
||||
mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB);
|
||||
}
|
||||
|
||||
+1215
-27
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "l4particles.h"
|
||||
#include "../munga/time.h"
|
||||
#include "../munga/style.h" // DEBUG_STREAM (#35 diagnostics)
|
||||
#include <iostream>
|
||||
|
||||
LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL;
|
||||
PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS];
|
||||
@@ -251,26 +253,90 @@ void ParticleEmitter::Execute()
|
||||
|
||||
void ParticleEngine::Destroy()
|
||||
{
|
||||
mVertBuffer->Release();
|
||||
mParticleTexture->Release();
|
||||
// #35 (the field "Owens crash"). This runs on the DEVICELOST path, and a
|
||||
// device can stay lost for several frames -- so this must be idempotent
|
||||
// and null-safe. It used to Release() blind: the first lost frame
|
||||
// released the buffer, the failed re-create on the still-lost device left
|
||||
// both statics NULL, and the SECOND lost frame's Release() read the
|
||||
// vtable at 0x0 (8/8 field stacks byte-identical at Destroy +0x11;
|
||||
// reproduced on the bench with BT_DEVICELOST_TEST=<n>,crashrepro).
|
||||
if (mVertBuffer != NULL)
|
||||
{
|
||||
mVertBuffer->Release();
|
||||
mVertBuffer = NULL;
|
||||
}
|
||||
if (mParticleTexture != NULL)
|
||||
{
|
||||
mParticleTexture->Release();
|
||||
mParticleTexture = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device)
|
||||
{
|
||||
mDevice = device;
|
||||
// Full STARTUP init only. The device-reset path must use
|
||||
// CreateDeviceObjects() below -- coming through here would memset the
|
||||
// installed-effects table and kill every particle effect for the rest of
|
||||
// the mission (#35's quieter sibling).
|
||||
memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
|
||||
|
||||
ParticleEngine::mMaxParticleCount = atoi(getenv("MAXPARTICLES"));
|
||||
|
||||
// create the vertex buffer that will store the four vertices we need for the billboards
|
||||
mDevice->CreateVertexBuffer(ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX),
|
||||
const char *max_env = getenv("MAXPARTICLES");
|
||||
ParticleEngine::mMaxParticleCount = (max_env != NULL) ? atoi(max_env) : 0;
|
||||
if (ParticleEngine::mMaxParticleCount <= 0)
|
||||
ParticleEngine::mMaxParticleCount = 8192; // btl4main defaults the env; belt + braces
|
||||
|
||||
CreateDeviceObjects(device);
|
||||
}
|
||||
|
||||
void ParticleEngine::CreateDeviceObjects(LPDIRECT3DDEVICE9 device)
|
||||
{
|
||||
// (Re)create the D3D resources. #35: CHECK the HRESULTs -- on a lost
|
||||
// device (or an out-of-video-memory iGPU, the field machine is a 128 MB
|
||||
// Iris Xe) these FAIL and null their out params; the old code drove
|
||||
// straight on, and the NULLs then killed the next Destroy(). On failure
|
||||
// we log and leave the engine dormant -- Execute/Render guard on the
|
||||
// NULLs, particles drain but don't draw -- and the next successful reset
|
||||
// brings it back.
|
||||
mDevice = device;
|
||||
|
||||
mVertBuffer = NULL;
|
||||
mParticleTexture = NULL;
|
||||
|
||||
// create the vertex buffer that will store the six vertices we need for the billboards
|
||||
HRESULT hr = mDevice->CreateVertexBuffer(
|
||||
ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX),
|
||||
D3DUSAGE_DYNAMIC,
|
||||
L4BASICVERTEX_FVF,
|
||||
D3DPOOL_DEFAULT,
|
||||
&mVertBuffer,
|
||||
NULL);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
mVertBuffer = NULL; // belt + braces vs runtime behavior
|
||||
DEBUG_STREAM << "[particles] CreateVertexBuffer FAILED hr=0x"
|
||||
<< std::hex << (unsigned long)hr << std::dec
|
||||
<< " (max=" << ParticleEngine::mMaxParticleCount
|
||||
<< ") -- particles dormant until the next successful reset"
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture);
|
||||
// NB: VIDEO\particles.png SHIPS as of 2026-07-29 (recovered by cyd,
|
||||
// issue #79 -- 128x128 RGBA). Before that it had never shipped (absent
|
||||
// from the tree, the RES, and all of git history), so this load failed
|
||||
// on every machine since the engine was written and particles drew
|
||||
// untextured -- which is therefore what the 1995 pods displayed. The failure is expected + logged once; rendering
|
||||
// proceeds without the texture (SetTexture(0, NULL) = the shipped look).
|
||||
hr = D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture);
|
||||
if (FAILED(hr))
|
||||
mParticleTexture = NULL;
|
||||
|
||||
if (mVertBuffer != NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[particles] device objects created (max="
|
||||
<< ParticleEngine::mMaxParticleCount
|
||||
<< ", texture=" << (mParticleTexture != NULL ? "loaded" : "MISSING (untextured quads -- the shipped look)")
|
||||
<< ")" << std::endl << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
void ParticleEngine::InstallEffect(int effectNumber, PARTICLE_EFFECT effect)
|
||||
@@ -453,6 +519,12 @@ void ParticleEngine::ExecuteParticles(const D3DXMATRIX *view_matrix, Scalar time
|
||||
}
|
||||
}
|
||||
|
||||
// #35: with the device objects torn down (device lost mid-reset) the
|
||||
// update/expire loop above must still run -- particles keep draining --
|
||||
// but there is no buffer to build into.
|
||||
if (mVertBuffer == NULL)
|
||||
return;
|
||||
|
||||
// now we're going to sort the particle list based on distance to the camera
|
||||
mergesort(&mParticlesHead, &mParticlesTail, view_matrix);
|
||||
|
||||
@@ -484,7 +556,14 @@ void ParticleEngine::RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeS
|
||||
// variables get all screwed up when we don't run for a while
|
||||
ExecuteParticles(view_matrix, timeSlice);
|
||||
|
||||
if (!mDevice || mTotalParticleCount <= 0)
|
||||
// (#35: the buffer is legitimately NULL while the device is lost. The
|
||||
// TEXTURE is deliberately NOT part of this gate: VIDEO\particles.png was
|
||||
// recovered only in 2026 (issue #79) -- before that mParticleTexture was
|
||||
// NULL on every machine since the engine was written, and
|
||||
// SetTexture(0, NULL) drawing untextured quads WAS the
|
||||
// shipped look. Gating on it would silently disable all billboard
|
||||
// particles everywhere.)
|
||||
if (!mDevice || mVertBuffer == NULL || mTotalParticleCount <= 0)
|
||||
return;
|
||||
|
||||
// setup stages for particle's texture
|
||||
|
||||
@@ -136,6 +136,11 @@ class ParticleEngine
|
||||
public:
|
||||
static void Destroy();
|
||||
static void Initialize(LPDIRECT3DDEVICE9 device);
|
||||
// #35: the device-RESET path re-creates ONLY the D3D objects. Initialize()
|
||||
// is full startup init -- it also wipes the installed-effects table, which
|
||||
// a mid-mission reset must never do (it silently killed every particle
|
||||
// effect for the rest of the mission).
|
||||
static void CreateDeviceObjects(LPDIRECT3DDEVICE9 device);
|
||||
static void InstallEffect(int effectNumber, PARTICLE_EFFECT effect);
|
||||
static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,18 @@ static LRESULT CALLBACK
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case WM_EXITSIZEMOVE:
|
||||
// Finished dragging (framed mode): persist the new position so it sticks
|
||||
// -- same contract as the per-display panels. No-op unless save mode.
|
||||
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); }
|
||||
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);
|
||||
@@ -44,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;
|
||||
}
|
||||
@@ -52,6 +78,7 @@ PlasmaWindow::~PlasmaWindow()
|
||||
{
|
||||
if (window != NULL)
|
||||
{
|
||||
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); } // backstop before the HWND goes
|
||||
DestroyWindow((HWND)window);
|
||||
window = NULL;
|
||||
}
|
||||
@@ -75,20 +102,43 @@ void
|
||||
window_class.lpszClassName = L"BTPlasmaWnd";
|
||||
RegisterClassW(&window_class);
|
||||
|
||||
RECT frame = { 0, 0, plasmaWidth * scale, plasmaHeight * scale };
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;
|
||||
AdjustWindowRect(&frame, style, FALSE);
|
||||
// glass_layout.cfg integration (2026-08-04): the plasma window rides the same
|
||||
// file as the per-display panels, so it can be dragged-and-remembered and set
|
||||
// frameless with ",noframe" -- BT_GLASS_LAYOUT selects load/save. Read its
|
||||
// saved rect + flag BEFORE sizing (WS_POPUP has a different frame extent than
|
||||
// the framed tool window).
|
||||
extern int BTGlassLayout_QueryWindow(const char *title, RECT *rect, int *noframe);
|
||||
extern void BTGlassLayout_RegisterExtern(HWND hwnd, const char *title);
|
||||
RECT saved;
|
||||
int noframe = 0;
|
||||
int have_saved = BTGlassLayout_QueryWindow("BattleTech - Plasma", &saved, &noframe);
|
||||
|
||||
//
|
||||
// Bottom-right, TOPMOST -- under the (topmost, right-parked) button
|
||||
// panel, clear of the game window (which would otherwise bury it).
|
||||
//
|
||||
DWORD style = noframe
|
||||
? (DWORD)WS_POPUP // bare + pinned, same as a ",noframe" glass panel
|
||||
: (DWORD)(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX);
|
||||
|
||||
RECT frame = { 0, 0, plasmaWidth * scale, plasmaHeight * scale };
|
||||
AdjustWindowRect(&frame, style, FALSE);
|
||||
int frame_width = frame.right - frame.left;
|
||||
int frame_height = frame.bottom - frame.top;
|
||||
int plasma_x = GetSystemMetrics(SM_CXSCREEN) - frame_width - 12;
|
||||
int plasma_y = GetSystemMetrics(SM_CYSCREEN) - frame_height - 60;
|
||||
if (plasma_x < 0) plasma_x = 0;
|
||||
if (plasma_y < 0) plasma_y = 0;
|
||||
|
||||
int plasma_x, plasma_y;
|
||||
if (have_saved)
|
||||
{
|
||||
plasma_x = saved.left; // position restored; size stays native
|
||||
plasma_y = saved.top;
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// Default: bottom-right, TOPMOST -- under the (topmost, right-parked)
|
||||
// button panel, clear of the game window (which would otherwise bury it).
|
||||
//
|
||||
plasma_x = GetSystemMetrics(SM_CXSCREEN) - frame_width - 12;
|
||||
plasma_y = GetSystemMetrics(SM_CYSCREEN) - frame_height - 60;
|
||||
if (plasma_x < 0) plasma_x = 0;
|
||||
if (plasma_y < 0) plasma_y = 0;
|
||||
}
|
||||
|
||||
window = CreateWindowExW(
|
||||
WS_EX_TOPMOST,
|
||||
@@ -102,6 +152,7 @@ void
|
||||
DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
BTGlassLayout_RegisterExtern((HWND)window, "BattleTech - Plasma");
|
||||
ShowWindow((HWND)window, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -258,7 +258,10 @@ void
|
||||
int row = i / columns;
|
||||
int x = originX + column * (cw + gap);
|
||||
int y = originY + row * (ch + gap);
|
||||
Push(out, baseAddress + i, x, y, cw, ch, 2,
|
||||
// Flight blocks are blue (colorClass 2), EXCEPT the Panic/Eject at 0x3D:
|
||||
// red (colorClass 0) so the eject stands out on the blue panel.
|
||||
int cc = (baseAddress + i == 0x3D) ? 0 : 2;
|
||||
Push(out, baseAddress + i, x, y, cw, ch, cc,
|
||||
(inert != 0) ? inert[i] : 0,
|
||||
(labels != 0) ? labels[i] : 0);
|
||||
GrowBounds(out, x, y, cw, ch);
|
||||
|
||||
@@ -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).
|
||||
|
||||
+103
-1
@@ -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++)
|
||||
{
|
||||
@@ -1284,6 +1343,47 @@ void BTCockpitMouseUp(void)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// (glass lamp responsiveness, 2026-08) Sweep the cockpit lamp STATE every frame.
|
||||
//
|
||||
// The lamp sweep -- LampManager::Update -> AssertNewLampValue -> RIO/PadRIO::SetLamp,
|
||||
// which is what lights the cockpit buttons -- authentically rides the gauge
|
||||
// renderer's FOREGROUND turn (GaugeRenderer::ExecuteForeground, gaugrend.cpp), and
|
||||
// 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 (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
|
||||
// "lighting slow or nonexistent" playtest reports (all on glass desktops).
|
||||
//
|
||||
// The lamp sweep is CHEAP -- deduped state pushes over ~72 lamps, no raster -- so on
|
||||
// the dev/glass composite path we run it EVERY frame here, decoupled from the gauge
|
||||
// cycle, and the buttons track the sim regardless of background-sweep slack. The pod
|
||||
// NEVER enters BTDrawGaugeInset (it drives real gauge hardware), so its authentic
|
||||
// bandwidth-paced serial lamp cadence is untouched. BT_GLASS_LAMP_SWEEP=0 restores the
|
||||
// authentic once-per-cycle behaviour.
|
||||
//
|
||||
static void
|
||||
BTGlassSweepLamps()
|
||||
{
|
||||
static int s_on = -1;
|
||||
if (s_on < 0)
|
||||
{
|
||||
const char *e = getenv("BT_GLASS_LAMP_SWEEP");
|
||||
s_on = (e != NULL && e[0] == '0') ? 0 : 1;
|
||||
}
|
||||
if (!s_on) return;
|
||||
|
||||
GaugeRenderer *gr = BTResolveGaugeRenderer();
|
||||
if (gr == NULL || application == NULL) return;
|
||||
LampManager *lm = gr->GetLampManager();
|
||||
ModeManager *mm = application->GetModeManager();
|
||||
if (lm != NULL && mm != NULL)
|
||||
lm->Update(mm->GetModeMask());
|
||||
}
|
||||
|
||||
//
|
||||
// Free entry point the MAIN renderer calls (L4VIDEO DPLRenderer::ExecuteImplementation,
|
||||
// just before EndScene). No-op unless BT_DEV_GAUGES is set. Docks the 6-surface panel
|
||||
@@ -1292,7 +1392,9 @@ void BTCockpitMouseUp(void)
|
||||
//
|
||||
void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device)
|
||||
{
|
||||
if (!DevGaugeComposite() || !DevGaugeDocked() || device == NULL) return;
|
||||
if (!DevGaugeComposite()) return;
|
||||
BTGlassSweepLamps(); // keep the cockpit lamps tracking the sim EVERY frame (see above)
|
||||
if (!DevGaugeDocked() || device == NULL) return;
|
||||
if (GlassPanelsOwnSurfaces()) return; // the per-display glass windows draw the surfaces
|
||||
if (gBTGaugeCockpit)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
//########################################################################
|
||||
|
||||
+303
-38
@@ -745,6 +745,7 @@ struct BTPfxEmitter
|
||||
struct BTPfxParticle
|
||||
{
|
||||
D3DXVECTOR3 pos, vel, accel;
|
||||
int defIndex; // census attribution (#114 / wreck-smoke tail)
|
||||
float age, life;
|
||||
float rad, exp, dexp;
|
||||
float colorWarp, alphaWarp; // def-level warps, carried per particle
|
||||
@@ -875,6 +876,39 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
|
||||
gBTPfxEmitters.push_back(e);
|
||||
}
|
||||
|
||||
// #114/wreck-smoke census (BT_PFX_LOG): every emitter start + a 2s population
|
||||
// line, so "what is still smoking 40s after the kill" is a log fact.
|
||||
static void BTPfxCensus()
|
||||
{
|
||||
if (!getenv("BT_PFX_LOG")) return;
|
||||
static unsigned long s_at = 0;
|
||||
unsigned long now = GetTickCount();
|
||||
if (now - s_at < 2000) return;
|
||||
s_at = now;
|
||||
int perDef[BT_PFX_SLOTS]; memset(perDef, 0, sizeof(perDef));
|
||||
int emitters = 0;
|
||||
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
|
||||
if (gBTPfxEmitters[i].active)
|
||||
{
|
||||
++emitters;
|
||||
int di = (int)(gBTPfxEmitters[i].def - gBTPfxDefs);
|
||||
if (di >= 0 && di < BT_PFX_SLOTS) ++perDef[di];
|
||||
}
|
||||
int partDef[BT_PFX_SLOTS]; memset(partDef, 0, sizeof(partDef));
|
||||
for (size_t i = 0; i < gBTPfxParticles.size(); ++i)
|
||||
{
|
||||
int di = gBTPfxParticles[i].defIndex;
|
||||
if (di >= 0 && di < BT_PFX_SLOTS) ++partDef[di];
|
||||
}
|
||||
DEBUG_STREAM << "[pfx] t=" << (now / 1000) << "s census: emitters=" << emitters
|
||||
<< " particles=" << (int)gBTPfxParticles.size();
|
||||
for (int d = 0; d < BT_PFX_SLOTS; ++d)
|
||||
if (perDef[d] > 0) DEBUG_STREAM << " {E" << d << ":" << perDef[d] << "}";
|
||||
for (int d = 0; d < BT_PFX_SLOTS; ++d)
|
||||
if (partDef[d] > 0) DEBUG_STREAM << " {p" << d << ":" << partDef[d] << "}";
|
||||
DEBUG_STREAM << std::endl;
|
||||
}
|
||||
|
||||
void BTStartPfx(int effect_number, float x, float y, float z)
|
||||
{
|
||||
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
|
||||
@@ -964,6 +998,7 @@ static void BTPfxSpawn(const BTPfxEmitter &e)
|
||||
// laser bursts during missile volleys)
|
||||
return;
|
||||
BTPfxParticle p;
|
||||
p.defIndex = (int)(e.def - gBTPfxDefs); // census attribution
|
||||
// Sample in the effect's LOCAL frame, then orient into the world through
|
||||
// the emitter's basis (the victim's localToWorld rows). The position
|
||||
// jitter pv is an isotropic scatter about the base offset -> symmetric
|
||||
@@ -1181,6 +1216,7 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
|
||||
}
|
||||
}
|
||||
}
|
||||
BTPfxCensus();
|
||||
for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei)
|
||||
{
|
||||
BTPfxEmitter &e = gBTPfxEmitters[ei];
|
||||
@@ -4325,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,
|
||||
@@ -4345,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,
|
||||
@@ -8197,6 +8241,75 @@ static void BTVerifyCockpitCanvasAfterReset(LPDIRECT3DDEVICE9 device)
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Execute Method, performs the rendering of one frame
|
||||
//
|
||||
//
|
||||
//#############################################################################
|
||||
// BTResetLostDevice -- the one true D3D9 device-loss recovery (#35).
|
||||
//
|
||||
// Both Present sites (the scene frame and the wait-screen idle frame) used to
|
||||
// carry an inline copy of this, and both copies were wrong the same way:
|
||||
// on DEVICELOST they called Reset() IMMEDIATELY. Reset() on a still-lost
|
||||
// device always fails; V() only logged it; ParticleEngine::Initialize then ran
|
||||
// against the lost device, its creates failed and NULLed the statics, and the
|
||||
// NEXT lost frame's ParticleEngine::Destroy() read a vtable at 0x0 -- the
|
||||
// field crash (8/8 byte-identical stacks at Destroy +0x11 on the one machine
|
||||
// whose GPU ever actually loses the device, a 128 MB Iris Xe).
|
||||
//
|
||||
// Correct protocol: release the POOL_DEFAULT resources every lost frame
|
||||
// (Destroy is idempotent now), but Reset ONLY once TestCooperativeLevel stops
|
||||
// answering D3DERR_DEVICELOST; until then skip and retry next frame. On a
|
||||
// successful Reset re-create ONLY the device objects -- NOT Initialize(),
|
||||
// which memsets the installed-effects table and killed every particle effect
|
||||
// for the rest of the mission on every reset that did succeed.
|
||||
//#############################################################################
|
||||
//
|
||||
void DPLRenderer::BTResetLostDevice()
|
||||
{
|
||||
if (mWaitOverlaySurface != NULL)
|
||||
{
|
||||
mWaitOverlaySurface->Release(); // pre-Reset, like every non-MANAGED resource
|
||||
mWaitOverlaySurface = NULL;
|
||||
}
|
||||
ParticleEngine::Destroy(); // idempotent + null-safe (#35)
|
||||
|
||||
HRESULT coop = mDevice->TestCooperativeLevel();
|
||||
if (coop == D3DERR_DEVICELOST)
|
||||
{
|
||||
static unsigned long s_lostLogAt = 0;
|
||||
unsigned long now_ms = GetTickCount();
|
||||
if (now_ms - s_lostLogAt > 2000)
|
||||
{
|
||||
s_lostLogAt = now_ms;
|
||||
DEBUG_STREAM << "[render] device LOST -- waiting for the driver "
|
||||
"before Reset" << std::endl << std::flush;
|
||||
}
|
||||
return; // cannot reset yet; retry next frame
|
||||
}
|
||||
|
||||
int bbCount = mPresentParams.BackBufferCount;
|
||||
int bbWidth = mPresentParams.BackBufferWidth;
|
||||
int bbHeight = mPresentParams.BackBufferHeight;
|
||||
|
||||
HRESULT reset_hr = mDevice->Reset(&mPresentParams);
|
||||
|
||||
mPresentParams.BackBufferCount = bbCount; // Reset writes actuals back;
|
||||
mPresentParams.BackBufferWidth = bbWidth; // keep the app's own intent
|
||||
mPresentParams.BackBufferHeight = bbHeight;
|
||||
|
||||
if (FAILED(reset_hr))
|
||||
{
|
||||
DEBUG_STREAM << "[render] device Reset FAILED hr=0x"
|
||||
<< std::hex << (unsigned long)reset_hr << std::dec
|
||||
<< " -- will retry next frame" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
ParticleEngine::CreateDeviceObjects(mDevice); // NOT Initialize (effects table!)
|
||||
this->SetCoreRenderStates();
|
||||
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
|
||||
DEBUG_STREAM << "[render] device reset OK (lost -> restored)"
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator)
|
||||
{
|
||||
Component *component;
|
||||
@@ -8328,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);
|
||||
@@ -8847,6 +9061,17 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
extern void BTGaugeWindowRenderAndPresent(LPDIRECT3DDEVICE9 device);
|
||||
BTGaugeWindowRenderAndPresent(mDevice);
|
||||
|
||||
#ifdef BT_GLASS
|
||||
// Drive the per-display glass panels' repaint from the (always-running) main
|
||||
// loop so their lamp flash keeps animating when they're in the background --
|
||||
// Windows throttles an unfocused window's own WM_TIMER ("indicators flash
|
||||
// slowly unless you focus that window"). No-op unless the panels are up.
|
||||
{
|
||||
extern void BTGlassPanels_Tick();
|
||||
BTGlassPanels_Tick();
|
||||
}
|
||||
#endif
|
||||
|
||||
// DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU.
|
||||
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
|
||||
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
|
||||
@@ -8910,30 +9135,88 @@ 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;
|
||||
}
|
||||
}
|
||||
// #35 BENCH HOOK (BT_DEVICELOST_TEST=<frame>[,crashrepro], off by default).
|
||||
// A real device loss needs a GPU that actually hangs (Conn Man's Iris Xe);
|
||||
// no bench machine here can produce one on demand. Two modes:
|
||||
// <frame> force the DEVICELOST branch below at that render
|
||||
// frame and twice more at +600/+1200 -- drives the
|
||||
// REAL recovery code, not a copy of it.
|
||||
// <frame>,crashrepro run the exact field sequence at that frame:
|
||||
// teardown, a re-Initialize forced to fail
|
||||
// (MAXPARTICLES=0 -> CreateVertexBuffer(0) fails ->
|
||||
// NULL out-params), then a second teardown -- the
|
||||
// two-lost-frames-in-a-row shape from the field logs.
|
||||
{
|
||||
static long s_dltFrame = -2; // -2 unparsed, -1 off
|
||||
static long s_dltTick = 0;
|
||||
static int s_dltRepro = 0;
|
||||
if (s_dltFrame == -2)
|
||||
{
|
||||
const char *e = getenv("BT_DEVICELOST_TEST");
|
||||
s_dltFrame = -1;
|
||||
if (e != NULL && *e != '\0')
|
||||
{
|
||||
s_dltFrame = strtol(e, NULL, 10);
|
||||
if (s_dltFrame <= 0) s_dltFrame = -1;
|
||||
if (strstr(e, "crashrepro") != NULL) s_dltRepro = 1;
|
||||
}
|
||||
}
|
||||
if (s_dltFrame > 0)
|
||||
{
|
||||
++s_dltTick;
|
||||
if (s_dltRepro)
|
||||
{
|
||||
if (s_dltTick == s_dltFrame)
|
||||
{
|
||||
// The field null-teardown shape: Destroy releases + nulls,
|
||||
// the second Destroy runs against the NULL statics -- the
|
||||
// exact state the field crash died in. (The PRE-fix repro
|
||||
// of 2026-07-29 used Destroy / MAXPARTICLES=0-forced-
|
||||
// failed-Initialize / Destroy, and faulted at Destroy
|
||||
// +0x11 with target=0x0, matching all 8 field stacks; the
|
||||
// hardened Initialize defends against that injection now,
|
||||
// so the double-Destroy is the surviving regression form.)
|
||||
DEBUG_STREAM << "[dltest] crashrepro: Destroy / Destroy on NULL statics"
|
||||
<< std::endl << std::flush;
|
||||
ParticleEngine::Destroy();
|
||||
ParticleEngine::Destroy(); // field crash site
|
||||
DEBUG_STREAM << "[dltest] crashrepro SURVIVED; restoring device objects"
|
||||
<< std::endl << std::flush;
|
||||
ParticleEngine::CreateDeviceObjects(mDevice);
|
||||
}
|
||||
}
|
||||
else if (s_dltTick >= s_dltFrame
|
||||
&& ((s_dltTick - s_dltFrame) % 600) == 0
|
||||
&& (s_dltTick - s_dltFrame) <= 1200)
|
||||
{
|
||||
DEBUG_STREAM << "[dltest] forcing DEVICELOST branch (cycle "
|
||||
<< ((s_dltTick - s_dltFrame) / 600 + 1) << "/3)"
|
||||
<< std::endl << std::flush;
|
||||
hr = D3DERR_DEVICELOST;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hr == D3DERR_DEVICELOST)
|
||||
{
|
||||
if (mWaitOverlaySurface != NULL)
|
||||
{
|
||||
mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: pre-Reset
|
||||
mWaitOverlaySurface = NULL;
|
||||
}
|
||||
int bbCount = mPresentParams.BackBufferCount;
|
||||
int bbWidth = mPresentParams.BackBufferWidth;
|
||||
int bbHeight = mPresentParams.BackBufferHeight;
|
||||
|
||||
ParticleEngine::Destroy();
|
||||
V(mDevice->Reset(&mPresentParams));
|
||||
ParticleEngine::Initialize(mDevice);
|
||||
this->SetCoreRenderStates();
|
||||
|
||||
mPresentParams.BackBufferCount = bbCount;
|
||||
mPresentParams.BackBufferWidth = bbWidth;
|
||||
mPresentParams.BackBufferHeight = bbHeight;
|
||||
|
||||
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
|
||||
BTResetLostDevice(); // #35: the guarded recovery
|
||||
}
|
||||
|
||||
ticks = HiResNowTicks();
|
||||
@@ -9199,27 +9482,9 @@ void DPLRenderer::ExecuteIdle()
|
||||
BTWaitScreenPaint(wait_line1, wait_line2);
|
||||
}
|
||||
if (present_hr == D3DERR_DEVICELOST)
|
||||
{
|
||||
if (mWaitOverlaySurface != NULL)
|
||||
{
|
||||
mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: must go pre-Reset
|
||||
mWaitOverlaySurface = NULL;
|
||||
BTResetLostDevice(); // #35: the guarded recovery
|
||||
}
|
||||
int bbCount = mPresentParams.BackBufferCount;
|
||||
int bbWidth = mPresentParams.BackBufferWidth;
|
||||
int bbHeight = mPresentParams.BackBufferHeight;
|
||||
|
||||
ParticleEngine::Destroy();
|
||||
V(mDevice->Reset(&mPresentParams));
|
||||
ParticleEngine::Initialize(mDevice);
|
||||
this->SetCoreRenderStates();
|
||||
|
||||
mPresentParams.BackBufferCount = bbCount;
|
||||
mPresentParams.BackBufferWidth = bbWidth;
|
||||
mPresentParams.BackBufferHeight = bbHeight;
|
||||
|
||||
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
|
||||
@@ -306,6 +306,12 @@ public:
|
||||
//
|
||||
dpl_ZONE* MakeNewZone();
|
||||
|
||||
// #35: the one true device-loss recovery -- both Present sites route here.
|
||||
// Correct D3D9 protocol: release POOL_DEFAULT resources, then Reset ONLY
|
||||
// once TestCooperativeLevel says the device is ready; a Reset while still
|
||||
// lost always fails, and the old inline copies drove on past that failure.
|
||||
void BTResetLostDevice();
|
||||
|
||||
void MarkDCSHiearchy(dpl_DCS *root_DCS, Entity *entity);
|
||||
void FlushBitSliceTexture(unsigned int *local_storage);
|
||||
void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage);
|
||||
|
||||
@@ -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).
|
||||
@@ -619,6 +633,9 @@ struct Builder {
|
||||
uint32_t currentColor = 0xFFB0B0B8u;
|
||||
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
|
||||
std::string currentTex;
|
||||
// authored (pre-substitution) "library:material_mtl" of the material in force --
|
||||
// the .DZM armour-damage lists key on this (issue #87)
|
||||
std::string currentMatName;
|
||||
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
|
||||
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
|
||||
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
|
||||
@@ -637,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;
|
||||
@@ -775,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));
|
||||
}
|
||||
@@ -872,6 +903,7 @@ struct Builder {
|
||||
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
|
||||
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
|
||||
batch.texPath = currentTex;
|
||||
batch.matName = currentMatName; // .DZM armour-damage key (issue #87)
|
||||
batch.texChannel = currentTexChannel;
|
||||
batch.texScroll = currentTexScroll;
|
||||
batch.texScrollU = currentTexScrollU;
|
||||
@@ -909,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
|
||||
@@ -935,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) {
|
||||
@@ -1185,6 +1240,9 @@ struct Builder {
|
||||
size_t maxn = ch.len - 1, n = 0;
|
||||
while (n < maxn && s[n]) ++n;
|
||||
std::string full(s, n);
|
||||
// remember the AUTHORED name (before the paint callback rewrites
|
||||
// it) -- the .DZM zone material lists are authored against it
|
||||
currentMatName = full;
|
||||
// baked ground-shadow material (basev:shadow_mtl etc.)
|
||||
currentShadowMat = false;
|
||||
for (size_t si = 0; si + 6 <= full.size(); ++si)
|
||||
@@ -1209,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
|
||||
@@ -1379,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
|
||||
@@ -106,6 +112,17 @@ struct BgfDrawBatch {
|
||||
// routed through the mech-shadow pipeline (translucent dark, depth-biased,
|
||||
// no z-write) instead.
|
||||
bool shadowMat = false;
|
||||
// AUTHORED MATERIAL NAME ("library:material_mtl", e.g. "avaskin:avat2_dz_ltorso_mtl"),
|
||||
// recorded PRE-substitution -- the per-pilot paint callback rewrites names, but the
|
||||
// .DZM damage-zone material lists are authored against these base names.
|
||||
//
|
||||
// This is the key the mech ARMOUR-DARKENING system matches on (issue #87): the 1995
|
||||
// BTL4VideoRenderer built one material-damage watcher per (zone, material) pair
|
||||
// (FUN_004573e4) that lerped the material's colour terms from pristine toward 0.1x
|
||||
// as that zone's damageLevel ran 0->1. Our draw path bakes colour into a
|
||||
// D3DMATERIAL9 at load, so the equivalent modulation is applied per draw op --
|
||||
// see L4D3D d3d_OBJECT::SetDamageScaleForMaterials.
|
||||
std::string matName;
|
||||
};
|
||||
|
||||
struct BgfData {
|
||||
|
||||
@@ -71,6 +71,12 @@ struct L4RAMP
|
||||
float r1, g1, b1;
|
||||
};
|
||||
|
||||
// MECH ARMOUR DAMAGE (issue #87): the factor the 1995 material-damage watcher
|
||||
// (FUN_004573e4) precomputed as the FULLY-DAMAGED colour -- pristine x 0.1, the
|
||||
// literal 0x3dcccccd passed at every construction site. A zone at damageLevel 1
|
||||
// renders its panels at 10% of the authored brightness.
|
||||
const float kDamagedMaterialScale = 0.1f;
|
||||
|
||||
struct L4DRAWOP
|
||||
{
|
||||
D3DMATERIAL9 material;
|
||||
@@ -103,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).
|
||||
@@ -112,6 +123,18 @@ struct L4DRAWOP
|
||||
// coplanar duplicated surfaces resolve to the detail layer instead of
|
||||
// venetian-blind z-fighting (the board's submission-order rule, in D3D terms).
|
||||
float lodDepthBias;
|
||||
// MECH ARMOUR DAMAGE (issue #87). The 1995 BTL4VideoRenderer bound one
|
||||
// material-damage watcher per (damage zone, material) pair -- FUN_004573e4 --
|
||||
// which lerped that material's colour terms from their authored values toward
|
||||
// 0.1x as the zone's damageLevel ran 0->1 (FUN_00457784 re-pushed them whenever
|
||||
// the level changed). The zone->material lists come from the per-mech .DZM
|
||||
// files, already parsed into DamageZone::materialTable (MUNGA DAMAGE.cpp).
|
||||
//
|
||||
// Our draw path bakes colour into this D3DMATERIAL9 at load, so the equivalent
|
||||
// modulation is applied at SetMaterial time from the level cached here.
|
||||
// Both fields are memset-0 safe: empty name = not zone-mapped, level 0 = pristine.
|
||||
char dzMatName[64]; // authored "library:material_mtl" for this batch
|
||||
float dzDamageLevel; // owning zone's damageLevel [0..1] (0 = undamaged)
|
||||
};
|
||||
|
||||
class d3d_OBJECT
|
||||
|
||||
@@ -31,6 +31,22 @@ 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
|
||||
// background (Windows throttles a background window's own WM_TIMER; this drives a
|
||||
// synchronous repaint at the flash cadence instead). No-op unless the windows
|
||||
// are up. See the note at the definition (L4GLASSWIN.cpp).
|
||||
//
|
||||
void
|
||||
BTGlassPanels_Tick();
|
||||
|
||||
//
|
||||
// True when BT_GLASS_PANELS mode is selected (default ON under `-platform
|
||||
// glass`, OFF otherwise). Read by the render loop / L4VB16 dev-composite to
|
||||
|
||||
+99
-4
@@ -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];
|
||||
@@ -1512,9 +1595,14 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
// (end_of_frame = now + 1/frameRate) degenerated and the game ran at a
|
||||
// timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware.
|
||||
// Default 60; env TARGETFPS still overrides (clamped to sanity).
|
||||
int target_fps = 30; // the pod's authentic rate; frame work (~20-40ms in
|
||||
// this build) misses 60Hz beats -> jitter, but holds
|
||||
// 30 rock-steady. TARGETFPS env still overrides.
|
||||
int target_fps = 30; // NOTE: the pod's byte-proven NOMINAL rate is 28
|
||||
// (DAT_0052140c = 28.0f @0x401ace; 18.2065 BIOS
|
||||
// fallback @0x401ada -- decomp-reference.md). We
|
||||
// keep 30 for display smoothness; anything that
|
||||
// must match the pod's per-tick math normalizes
|
||||
// itself (e.g. the myomer kinetic term). Frame
|
||||
// work (~20-40ms) misses 60Hz beats -> jitter,
|
||||
// holds 30 steady. TARGETFPS env overrides.
|
||||
{
|
||||
const char *tf = getenv("TARGETFPS");
|
||||
if (tf != 0)
|
||||
@@ -1539,6 +1627,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
|
||||
app_manager->RunMissions();
|
||||
std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush;
|
||||
// #93: the projectile pool is static -- kill any rounds still in flight so
|
||||
// nothing dangles across the mission boundary (the per-entity dtor scrub
|
||||
// covers the in-mission teardown race; this covers everything else).
|
||||
{
|
||||
extern void BTProjectilesClearAll(void);
|
||||
BTProjectilesClearAll();
|
||||
}
|
||||
|
||||
// MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's
|
||||
// match forensic log back to the operator's relay (saved under the
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
|
||||
+40
-5
@@ -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" },
|
||||
@@ -742,8 +742,18 @@ static void
|
||||
{
|
||||
// JOIN trim: the mech list + the JOIN button; callsign is the edit
|
||||
// control (repositioned in BTFrontEnd_Run). Everything else is the
|
||||
// operator's call.
|
||||
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y);
|
||||
// operator's call -- EXCEPT experience, which is PER-PLAYER by the
|
||||
// original design (the sysop set each user's tier; mixed-experience
|
||||
// matches were legal), and each node's master reads its OWN egg's
|
||||
// value, so mixing already works end to end.
|
||||
//
|
||||
// #116: this group was missing here, so joiners silently launched with
|
||||
// the hidden menu default (veteran) no matter what they believed they
|
||||
// selected -- "standard mode still having heat and leaks only occurred
|
||||
// in steam": hosts got their pick, all three affected players were
|
||||
// JOINERS, and the one clean player (Sauron) was the one HOSTING.
|
||||
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y);
|
||||
y = MenuTopY; AddGroup(GroupExperience, MenuCol3X, &y);
|
||||
AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52);
|
||||
return;
|
||||
}
|
||||
@@ -1173,6 +1183,8 @@ int
|
||||
strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key);
|
||||
strcpy(self.color, kColors[menu.selection[GroupColor]].key);
|
||||
strcpy(self.experience, kExperience[menu.selection[GroupExperience]].key);
|
||||
printf("[fe] pilot experience=%s (%s)\n", self.experience,
|
||||
FeJoinOnly() ? "join" : "host/solo");
|
||||
strcpy(self.badge, kBadges[menu.selection[GroupBadge]].key);
|
||||
strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key);
|
||||
strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key);
|
||||
@@ -1187,7 +1199,8 @@ int
|
||||
if (menu.steamAction == 1)
|
||||
{
|
||||
BTLobbyRoster roster;
|
||||
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color,
|
||||
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)
|
||||
@@ -1195,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)
|
||||
@@ -1208,6 +1230,18 @@ int
|
||||
strncpy(pilot.color,
|
||||
member.color[0] ? member.color : kColors[0].key,
|
||||
sizeof(pilot.color) - 1);
|
||||
// #116: the member's OWN experience choice rides the lobby now;
|
||||
// absent (older build in the lobby) leaves the field empty and the
|
||||
// egg writer's "veteran" fallback applies -- the old behavior,
|
||||
// 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)
|
||||
@@ -1226,7 +1260,8 @@ int
|
||||
}
|
||||
if (menu.steamAction == 2)
|
||||
{
|
||||
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color,
|
||||
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)
|
||||
{
|
||||
|
||||
+192
-11
@@ -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).
|
||||
@@ -83,7 +128,8 @@ static int
|
||||
static CSteamID currentLobby;
|
||||
|
||||
static void
|
||||
PublishSelf(const char *pilot_name, const char *vehicle, const char *color)
|
||||
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience, const char *badge, const char *patch)
|
||||
{
|
||||
//
|
||||
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||
@@ -93,6 +139,16 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
||||
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
|
||||
@@ -118,6 +174,24 @@ static int
|
||||
strncpy(out->color,
|
||||
matchmaking->GetLobbyMemberData(lobby, member, "cl"),
|
||||
sizeof(out->color) - 1);
|
||||
{
|
||||
// #116: experience rides the lobby too -- absent (an older build in
|
||||
// the lobby) leaves it empty and the egg writer's "veteran" fallback
|
||||
// applies, same as before this field existed.
|
||||
const char *xp = matchmaking->GetLobbyMemberData(lobby, member, "xp");
|
||||
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)
|
||||
{
|
||||
@@ -133,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)
|
||||
@@ -176,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
|
||||
{
|
||||
@@ -281,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)
|
||||
{
|
||||
@@ -312,6 +404,7 @@ static int
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
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)
|
||||
@@ -335,7 +428,13 @@ int
|
||||
}
|
||||
currentLobby = created.m_ulSteamIDLobby;
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
||||
PublishSelf(pilot_name, vehicle, color);
|
||||
// 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)
|
||||
@@ -360,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;
|
||||
@@ -403,18 +521,26 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
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));
|
||||
@@ -422,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;
|
||||
}
|
||||
|
||||
@@ -438,7 +593,25 @@ int
|
||||
return -1;
|
||||
}
|
||||
currentLobby = lobby;
|
||||
PublishSelf(pilot_name, vehicle, color);
|
||||
// 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);
|
||||
@@ -475,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);
|
||||
|
||||
@@ -29,6 +29,14 @@ struct BTLobbyMember
|
||||
int gamePort; // token game port (1502)
|
||||
char vehicle[16];
|
||||
char color[16];
|
||||
char experience[16]; // #116: per-player by design (sysop-set
|
||||
// in the pod; mixed matches legal) --
|
||||
// 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;
|
||||
};
|
||||
@@ -46,6 +54,7 @@ struct BTLobbyRoster
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
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);
|
||||
@@ -57,5 +66,6 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
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);
|
||||
|
||||
@@ -14,7 +14,16 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP)
|
||||
│ CollisionCriticalHitWeight, VideoObjectName, VitalSubsystem) — NO thermal. Couples subsystem→DamageZone@this+0xE0.
|
||||
│ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8,
|
||||
│ printSimulationState@0x104, criticalReference@0x108, collisionCriticalHitWeight@0x10C, vitalSubsystemIndex@0x110(-1).
|
||||
│ methods: GetStatusFlags@4ac144 (structureLevel tier), HandleMessage@4ac0bc, ResetToInitialState@4ac1d4, ClearStatus@4ac22c,
|
||||
│ methods: GetStatusFlags@4ac144 (structureLevel tier), TakeDamage@4ac0bc (vtable +0x24; #80 CORRECTION -- was mislabeled
|
||||
│ "HandleMessage": the body consumes a Damage&, hits the private zone, and on level>=1.0 raises Destroyed + the
|
||||
│ vital-subsystem kill. HandleMessage's real address is unknown), ResetToInitialState@4ac1d4, ClearStatus@4ac22c,
|
||||
│ #80 GAP RECOVERIES (raw disasm; none exported): Mech::TakeDamageMessageHandler@4a0230 (via the Mech MESSAGE TABLE
|
||||
│ @50bdf8: rows {id,name,handler} -- 0x12 TakeDamage/4a0230, 0x14 PlayerLink/49f624, 0x15 RealMaxSpeed/49f604,
|
||||
│ 0x16 BalanceCoolant/49f728, 0x17 SetBurningState/49f674, 0x18 ClearBurningState/49f700, 0x19 EjectPilot/49f854,
|
||||
│ 0x1a DuckRequest/49fa00), crit-chance@4a0164 (p = clamp(0.7*lvl^2+0.01, 0..1), gate player+0x25c simLive),
|
||||
│ collision-divert@49ffcc (damageType==0, unreconstructed), Mech vtable +0x18/@4a122c +0x1c/@4a0c2c (switch fns,
|
||||
│ unidentified). Subsystem CSS keys @50e09d..50e15d: WeaponDamagePoints(req), CriticalHitScoreBonus(req),
|
||||
│ Collision/Ballistic/Explosive/Laser/EnergyDamagePoints -> res+0x44/+0xE0/+0x30[5].
|
||||
│ PrintState@4ac8c0 (DefaultState/Destroyed/Exploding), IsDamaged@4ac9c8 (mech bus down), ApplyDamageAndMeasure@4ac07c,
|
||||
│ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74).
|
||||
│ TechStatusType states @50df17: Destroyed/Damaged/CoolantLeaking/Overheating/AmmoBurning/Jammed/BadPower (count=7).
|
||||
@@ -389,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
|
||||
|
||||
@@ -631,3 +631,14 @@ Subsystem *CreateAmmoBinSubsystem(Mech *owner, int id, void *seg)
|
||||
return (Subsystem *) new (Memory::Allocate(0x22c))
|
||||
AmmoBin(owner, id, (AmmoBin::SubsystemResource *)seg);
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
// #84 bridge -- the launcher resolves its round's MODEL RESOURCE ID through
|
||||
// its connected bin (the binary Missile ctor receives the same id via the
|
||||
// launch descriptor). Complete-AmmoBin TU accessor.
|
||||
//###########################################################################
|
||||
int BTAmmoBinModelFile(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->AmmoModelFileID() : -1;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,12 @@
|
||||
// member. The 0x54 ammoAlarm (was an 8-byte HeatAlarm) lands ammoModelFile
|
||||
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
|
||||
int ammoModelFile; // @0x1E8 (word 0x7A) round model index
|
||||
public:
|
||||
// #84: the round's model RESOURCE ID (the type-15 record carrying the
|
||||
// MissileThruster tail the binary Missile ctor copies) -- read by the
|
||||
// launcher's fire path via the BTAmmoBinModelFile bridge.
|
||||
int AmmoModelFileID() const { return ammoModelFile; }
|
||||
protected:
|
||||
int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index
|
||||
|
||||
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -163,8 +163,20 @@ static const int kBTQuadModeMask[12] = { 0x1,0x1,0x1,0x1, 0x20,0x20,0x20,0x20, 0
|
||||
static const int kBTEngModeMask[12] = { 0x2,0x4,0x8,0x10, 0x40,0x80,0x100,0x200, 0x800,0x1000,0x2000,0x4000 };
|
||||
static const int kBTQuadLamp[12] = { 0xF,0xD,0xB,0x9, 0x27,0x25,0x23,0x21, 0x7,0x5,0x3,0x1 };
|
||||
static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0x7,0x7,0x7,0x7 };
|
||||
static const int kBTCondenserLamp[6] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A }; // [condenserNumber]
|
||||
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
|
||||
// @0051d058 per-condenser lamps, BYTE-VERIFIED from the image (int32 each):
|
||||
// 07 2F 2E 2D 2B 2A 29 ... indexed 1-BASED by condenserNumber (+0x1D4),
|
||||
// so slot 0 is unused and condensers 1..6 -> 2F 2E 2D 2B 2A 29.
|
||||
// Deliberately NOT the coolingLoop set of FUN_004cc148 (which has 0x2C and
|
||||
// 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 };
|
||||
// #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.
|
||||
@@ -270,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;
|
||||
@@ -287,8 +307,33 @@ void
|
||||
{
|
||||
if (the_condition == 2) // CoolantLeaking
|
||||
{
|
||||
//
|
||||
// #98 -- "I'm getting leaks but no indicators" / "the display
|
||||
// buttons aren't ALWAYS flashing on leaking components".
|
||||
//
|
||||
// FUN_004cc264 is literally:
|
||||
// return *(int *)(&DAT_0051d058 + sub[0x1d4] * 4);
|
||||
// -- indexed by condenserNumber with NO bounds check. The table
|
||||
// bytes at 0051d058 are, verified from the image:
|
||||
// 07 2F 2E 2D 2B 2A 29 ... (int32 each)
|
||||
// and condenserNumber is 1-BASED (live: 'Condenser6' reports 6), so
|
||||
// slot 0 (0x7) is unused and condensers 1..6 map to
|
||||
// 0x2F 0x2E 0x2D 0x2B 0x2A 0x29.
|
||||
// NOTE these are NOT the coolingLoop1..6 lamps of FUN_004cc148
|
||||
// (0x2F 0x2E 0x2D 0x2C 0x2B 0x2A) -- the condenser set skips 0x2C
|
||||
// and ends on 0x29. They overlap but are different lamps; do not
|
||||
// "correct" one into the other (I did, and it mis-mapped 4, 5 and 6).
|
||||
//
|
||||
// THE PORT BUG was only the bounds check: `n >= 0 && n < 6` is a
|
||||
// 0-based bound on a 1-based index, so condenser 6 was rejected and
|
||||
// annunciated NOTHING, while the binary resolves it to 0x29. Six
|
||||
// condensers, one permanently silent -- which is what players read as
|
||||
// "sometimes". The guard now admits 1..6 (kept, unlike the binary's
|
||||
// unchecked read, so a stray authored number cannot walk off the
|
||||
// table into the adjacent per-placement one).
|
||||
//
|
||||
int n = BTCondenserNumber(the_subsystem);
|
||||
if (n >= 0 && n < 6)
|
||||
if (n >= 1 && n <= 6)
|
||||
lamp_id = kBTCondenserLamp[n];
|
||||
}
|
||||
}
|
||||
@@ -296,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)
|
||||
@@ -331,6 +381,21 @@ void
|
||||
|
||||
if (lamp_id < 0)
|
||||
{
|
||||
// DIAGNOSTIC (#98): this is the SILENT path -- the item matched the
|
||||
// condition but no lamp could be resolved for this subsystem, so nothing
|
||||
// flashes and nothing was logged. Players see "leaking but no
|
||||
// indicator". Name the subsystem and why it fell through, so the
|
||||
// mapping gap is visible in a field log instead of being invisible.
|
||||
if (BTLampLog())
|
||||
DEBUG_STREAM << "[galarm] condition " << (int)the_condition
|
||||
<< " code 0x" << std::hex << lamp_code << std::dec
|
||||
<< " sub '" << (the_subsystem && the_subsystem->GetName()
|
||||
? the_subsystem->GetName() : "?")
|
||||
<< "' -> NO LAMP RESOLVED (condenser=" << BTSubsystemIsCondenser(the_subsystem)
|
||||
<< " condenserNumber=" << (BTSubsystemIsCondenser(the_subsystem)
|
||||
? BTCondenserNumber(the_subsystem) : -1)
|
||||
<< " generator=" << BTSubsystemIsGenerator(the_subsystem)
|
||||
<< ")" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -980,6 +980,7 @@ GeneratorCluster::GeneratorCluster(
|
||||
Scalar *currentTemp = (Scalar *)AttributePointerOf(subsystem_in, "CurrentTemperature");
|
||||
Scalar *degradeTemp = (Scalar *)AttributePointerOf(subsystem_in, "DegradationTemperature");
|
||||
Scalar *failTemp = (Scalar *)AttributePointerOf(subsystem_in, "FailureTemperature");
|
||||
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
|
||||
Scalar *coolantLeak = (Scalar *)AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
||||
Scalar *outputVolt = (Scalar *)AttributePointerOf(subsystem_in, "OutputVoltage");
|
||||
|
||||
@@ -1006,7 +1007,7 @@ GeneratorCluster::GeneratorCluster(
|
||||
// child 3: coolant-leak inverse-wipe (CoolantMassLeakRate); third=*(subsys+0x150), frames=3
|
||||
leakGauge = new BitMapInverseWipe(rate3, mode_mask, renderer_in, graphics_port_number,
|
||||
x, y + 0x27, leak_image, extra_color, leak_color_b,
|
||||
*(int *)((char *)subsystem_in + 0x150), // third
|
||||
BTHeatSinkLeakFullScale(subsystem_in), // third: full-scale divisor (#97 bridge)
|
||||
3, // frames
|
||||
coolantLeak, "LeakGauge");
|
||||
|
||||
@@ -1460,6 +1461,7 @@ SubsystemCluster::SubsystemCluster(
|
||||
void *linkedHeat = ResolveLink(heatSink); // FUN_00417ab4
|
||||
void *linkTemp = AttributePointerOf(linkedHeat, "CurrentTemperature");
|
||||
void *linkDegrade = AttributePointerOf(linkedHeat, "DegradationTemperature");
|
||||
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
|
||||
void *coolantLeak = AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
||||
|
||||
coolingLoopB = new AnimatedSubsystemLamp(ChildRate(), eng_mode, renderer_in, // @004c70a4
|
||||
@@ -1513,7 +1515,7 @@ SubsystemCluster::SubsystemCluster(
|
||||
|
||||
leakGauge = new BitMapInverseWipe(ChildRate(), eng_mode, renderer_in, // @004c5b7c
|
||||
engPort, 0x255, 0xe0, "eleak.pcc", 0, 0xff,
|
||||
*(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/,
|
||||
BTHeatSinkLeakFullScale(subsystem_in) /*third*/, 3 /*frames*/,
|
||||
(Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150)
|
||||
|
||||
failedState = False; // @0xC4 this[0x31] (1 = destroyed)
|
||||
@@ -2018,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()
|
||||
|
||||
@@ -547,8 +547,27 @@ void
|
||||
// briefly redirected to Player::deathCount@0x200, but that is the respawn-handshake
|
||||
// identity (seeded -2), which is why it needed a display clamp; +0x280 was never
|
||||
// dead, only unwritten. Both counters now replicate owner->replicant.
|
||||
e.nameDisplay->Draw(&localView, (Scalar)BTPilotKills(pilot)); // KILLS (killCount)
|
||||
e.mechDisplay->Draw(&localView, (Scalar)BTPilotDeaths(pilot)); // DEATHS (deathTally @0x280)
|
||||
int drawnKills = BTPilotKills(pilot);
|
||||
int drawnDeaths = BTPilotDeaths(pilot);
|
||||
e.nameDisplay->Draw(&localView, (Scalar)drawnKills); // KILLS (killCount)
|
||||
e.mechDisplay->Draw(&localView, (Scalar)drawnDeaths); // DEATHS (deathTally @0x280)
|
||||
// DIAG (BT_SCORE_LOG): the exact value handed to the panel numerics, edge-
|
||||
// logged per slot. This is the arbiter between "the tally moved but the
|
||||
// panel drew stale zeros" (this line shows 0) and "the panel drew it but
|
||||
// the on-screen surface didn't" (this line shows N).
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
{
|
||||
static int s_lastK[8] = { -1,-1,-1,-1,-1,-1,-1,-1 };
|
||||
static int s_lastD[8] = { -1,-1,-1,-1,-1,-1,-1,-1 };
|
||||
if (s_lastK[currentSlot] != drawnKills || s_lastD[currentSlot] != drawnDeaths)
|
||||
{
|
||||
s_lastK[currentSlot] = drawnKills;
|
||||
s_lastD[currentSlot] = drawnDeaths;
|
||||
DEBUG_STREAM << "[score] panel DRAW slot " << currentSlot
|
||||
<< " pilot " << pilot << " kills=" << drawnKills
|
||||
<< " deaths=" << drawnDeaths << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
++currentSlot;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2054,7 +2071,7 @@ Logical
|
||||
BitMapInverseWipe::BitMapInverseWipe(
|
||||
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
||||
int graphics_port_number, int x, int y, const char *image,
|
||||
int color_a, int color_b, int third, int frames,
|
||||
int color_a, int color_b, Scalar third, int frames,
|
||||
Scalar *value_pointer, const char *identification_string
|
||||
):
|
||||
GraphicGauge(rate, mode_mask, renderer_in, 0, graphics_port_number,
|
||||
@@ -2107,7 +2124,21 @@ void BitMapInverseWipe::BecameActive() // @004c5cf4
|
||||
//
|
||||
void BitMapInverseWipe::Execute()
|
||||
{
|
||||
int level = (int)(value + (value < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
|
||||
// #97 -- the level is NORMALISED, which this reconstruction had lost.
|
||||
// Ghidra renders the round as a bare `FUN_004dcd94()` because it drops the
|
||||
// x87 expression feeding __ftol (KB gotcha #19). The real prologue is:
|
||||
// fild [fullWidth] ; ST0 = (float)fullWidth (frames*2)
|
||||
// fmul [this+0xb4] ; ST0 *= value (leak rate)
|
||||
// fdiv [this+0xb0] ; ST0 /= third (full scale)
|
||||
// call __ftol ; level = round(ST0)
|
||||
// Without it the gauge rounded the RAW leak rate, which never exceeds ~1.0,
|
||||
// so `level` could only ever be 0 or (via the floor below) 1 -- one triangle,
|
||||
// always, no matter how bad the leak. Players: "you can get up to three
|
||||
// triangles"; "only seeing one level (lowest) right now".
|
||||
Scalar scaled = (third > 0.0f)
|
||||
? ((Scalar)fullWidth * value / third)
|
||||
: value;
|
||||
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
|
||||
if (level < 0) level = 0;
|
||||
if (level > fullWidth) level = fullWidth;
|
||||
if (value > 0.0025f && level < 1) level = 1; // _DAT_0050e3d8
|
||||
@@ -2138,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
|
||||
|
||||
@@ -522,7 +522,7 @@
|
||||
BitMapInverseWipe( // @004c5b7c
|
||||
GaugeRate, ModeMask, L4GaugeRenderer *, int,
|
||||
int x, int y, const char *image,
|
||||
int color_a, int color_b, int third, int frames,
|
||||
int color_a, int color_b, Scalar third, int frames,
|
||||
Scalar *value_pointer, const char *);
|
||||
~BitMapInverseWipe(); // @004c5c80
|
||||
Logical TestInstance() const;
|
||||
@@ -537,10 +537,43 @@
|
||||
int frameHeight; // @0xA4 this[0x29]
|
||||
int frames; // @0xA0 this[0x28]
|
||||
int previousLevel; // @0xAC this[0x2B]
|
||||
int third; // @0xB0 this[0x2C]
|
||||
Scalar third; // @0xB0 this[0x2C] full-scale divisor (fdiv, #97)
|
||||
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).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user