Compare commits
196
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,12 @@
|
||||
`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-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
|
||||
damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
|
||||
all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
|
||||
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
|
||||
archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
|
||||
`context/open-questions.md`, recent git log.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +51,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 +62,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 +70,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` |
|
||||
@@ -85,7 +91,7 @@ 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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +154,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
|
||||
|
||||
+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]
|
||||
|
||||
|
||||
@@ -79,7 +79,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 +92,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 +182,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
|
||||
|
||||
+472
-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]
|
||||
@@ -181,7 +278,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 +368,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 +404,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 +433,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 +521,140 @@ 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 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, the handler that accepts type 0, is
|
||||
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
|
||||
- **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]: 1995 pod scoring = **kill awards + received-damage penalties +
|
||||
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
|
||||
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
|
||||
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 +953,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 +1005,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 +1044,150 @@ 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 HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; 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].
|
||||
|
||||
## Key Relationships
|
||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
||||
|
||||
+224
-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.
|
||||
|
||||
---
|
||||
|
||||
@@ -246,9 +263,36 @@ 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]].
|
||||
- **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 +363,100 @@ 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.
|
||||
- **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. 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 +
|
||||
@@ -409,6 +547,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 +572,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 +619,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 +645,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 +685,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 +712,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
|
||||
|
||||
+74
-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
|
||||
@@ -682,6 +697,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
|
||||
|
||||
+206
-5
@@ -206,11 +206,72 @@ 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.**
|
||||
|
||||
## Controls (`BT_REAL_CONTROLS`, default-on)
|
||||
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
|
||||
@@ -250,6 +311,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).
|
||||
|
||||
+32
-4
@@ -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]]
|
||||
|
||||
+252
-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,28 @@ 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.
|
||||
|
||||
## 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 +977,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 +1088,77 @@ 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.
|
||||
|
||||
+241
-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,212 @@ 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.
|
||||
|
||||
**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 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 +415,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,198 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+102
-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,69 @@ 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; 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 +157,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 +210,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
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
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).
|
||||
|
||||
## Key Relationships
|
||||
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
|
||||
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
|
||||
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
@@ -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,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.
|
||||
@@ -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/` |
|
||||
|
||||
@@ -404,3 +404,21 @@ 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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -112,6 +112,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
|
||||
@@ -1079,6 +1083,13 @@ void
|
||||
{
|
||||
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
|
||||
//--------------------------------------------------
|
||||
|
||||
@@ -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,6 +48,7 @@ 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;
|
||||
@@ -64,6 +65,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 +153,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;
|
||||
|
||||
+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,17 @@ LBE4ControlsManager::~LBE4ControlsManager()
|
||||
rioPointer = NULL;
|
||||
}
|
||||
|
||||
#ifdef BT_GLASS
|
||||
//
|
||||
// Symmetric with the Create above (2026-08-06). ~PadRIO tears the panels
|
||||
// down itself, so this is a no-op there -- but the HARDWARE RIO's dtor
|
||||
// knows nothing about them, and windows that outlive the surfaces they
|
||||
// blit are a crash waiting for the next mission cycle. Destroy is safe
|
||||
// if they were never created.
|
||||
//
|
||||
BTGlassPanels_Destroy();
|
||||
#endif
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -2624,12 +2662,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 +2724,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);
|
||||
}
|
||||
|
||||
+766
-17
@@ -115,6 +115,18 @@ struct GWin
|
||||
int wantX, wantY;
|
||||
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
|
||||
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
|
||||
|
||||
// RGB CHANNEL GROUP (BT_POD_RGB): on splitter-wired pod glass this window
|
||||
// is one VGA PORT feeding up to three mono monitors through R/G/B. These
|
||||
// are the OTHER surfaces sharing the port; each is composited into its own
|
||||
// channel by BlitSurface. groupCount 0 = ordinary single-surface window.
|
||||
const char *groupPort[3];
|
||||
const char *groupAlt[3]; // the Eng<n> twin, or NULL
|
||||
int groupCount;
|
||||
|
||||
char monitorName[40]; // the PHYSICAL monitor this window landed on
|
||||
// (\.\DISPLAYn), stamped at creation; shown
|
||||
// by BT_POD_IDENT so the cab can be mapped.
|
||||
};
|
||||
|
||||
static GWin gWins[8];
|
||||
@@ -124,7 +136,8 @@ static int pressedAddress = -1;
|
||||
static unsigned char latched[128];
|
||||
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
|
||||
static bool sRepositionedToMain = false;
|
||||
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
||||
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
||||
static unsigned long *gStage2 = NULL; // RGB composite scratch (BT_POD_RGB)
|
||||
|
||||
static HFONT titleFont = NULL;
|
||||
static HFONT buttonFont = NULL;
|
||||
@@ -190,7 +203,9 @@ static void
|
||||
GButton &b = w.buttons[w.buttonCount++];
|
||||
b.address = src.address;
|
||||
b.color = (src.colorClass == 1) ? ClrYellow
|
||||
: (src.colorClass == 2) ? ClrBlue : color;
|
||||
: (src.colorClass == 2) ? ClrBlue
|
||||
: (src.colorClass == 0) ? ClrRed // 0 = red (MFD, and the red Panic/Eject 0x3D)
|
||||
: color;
|
||||
b.rect.left = src.x + dx;
|
||||
b.rect.top = src.y + dy;
|
||||
b.rect.right = src.x + dx + src.w;
|
||||
@@ -242,17 +257,26 @@ static void
|
||||
// (0x15 sits on the left column -- Cyd listed 0x10-0x14; move it if wanted.)
|
||||
static const int bottomAddrs[4] = { 0x16, 0x17, 0x1F, 0x1E };
|
||||
|
||||
// The SURFACE SIZE follows the rotation: a quarter turn (1 or 3) transposes
|
||||
// the 640x480 source to 480x640 portrait -- the pod's rotated CRT -- while
|
||||
// 0 (none) and 2 (180) keep it LANDSCAPE. Sizing this independently of the
|
||||
// rotation squashed the picture into the wrong aspect on a landscape
|
||||
// replacement panel (crash cart, 2026-08-06).
|
||||
const int quarterTurn = (gRadarRot == 1 || gRadarRot == 3);
|
||||
const int surfW = quarterTurn ? RadarSurfW : RadarSurfH; // 480 : 640
|
||||
const int surfH = quarterTurn ? RadarSurfH : RadarSurfW; // 640 : 480
|
||||
|
||||
BTRioBankMetrics metrics;
|
||||
BTRioBankMetricsFor(RadarSurfW, RadarSurfH, &metrics);
|
||||
BTRioBankMetricsFor(surfW, surfH, &metrics);
|
||||
|
||||
BTRioBank bank;
|
||||
BTRioBankLayout(BTRioBankRadar, 0, 0, RadarSurfW, RadarSurfH,
|
||||
BTRioBankLayout(BTRioBankRadar, 0, 0, surfW, surfH,
|
||||
0x10, 0x18, &metrics, bottomAddrs, &bank);
|
||||
|
||||
BTRioBankDump("Secondary / Radar", &bank);
|
||||
int dx = -bank.boundsX, dy = -bank.boundsY;
|
||||
AdoptBank(w, bank, ClrYellow, dx, dy);
|
||||
SetRect(&w.surfaceRect, dx, dy, dx + RadarSurfW, dy + RadarSurfH);
|
||||
SetRect(&w.surfaceRect, dx, dy, dx + surfW, dy + surfH);
|
||||
w.clientW = bank.boundsW;
|
||||
w.clientH = bank.boundsH;
|
||||
}
|
||||
@@ -405,6 +429,179 @@ enum { LayoutOff = 0, LayoutLoad = 1, LayoutSave = 2 };
|
||||
|
||||
static const char *layoutFileName = "glass_layout.cfg";
|
||||
|
||||
// POD SURFACE MODE (BT_POD_SURFACES=1, 2026-08-06) -- the REAL pod bring-up
|
||||
// shape. On the actual cab each MFD is its own 640x480 panel with PHYSICAL
|
||||
// buttons and lamps around it, so the desktop chrome the glass windows draw
|
||||
// (the RIO button bank + the frame) is exactly wrong there: it would paint
|
||||
// fake buttons onto a panel that has real ones behind glass. In pod mode a
|
||||
// window is cropped to its SURFACE, frameless, and the bank is dropped:
|
||||
// one clean picture per physical panel, positioned by glass_layout.cfg.
|
||||
// The Flight Controls window (no surface at all) is not created.
|
||||
// BT_POD_SURFACES=1 -> every display window goes bare
|
||||
// glass_layout.cfg "<title>=x,y,bare" -> just that one (mixed rigs)
|
||||
// BT_POD_CHANMAP -- per-cart channel override, e.g.
|
||||
// BT_POD_CHANMAP=Comm=blue,Heat=red
|
||||
// The 1995 config authors which colour line each surface rides (L4GAUGE.CFG),
|
||||
// but a rebuilt cab can have its RGB splitter wired to different panels than
|
||||
// the original -- on Nick's cart the coolant (Heat) and hot-box (Comm) screens
|
||||
// came out swapped, which is a WIRING difference, not a software bug. Rather
|
||||
// than edit authentic config, override the channel here, per rig.
|
||||
// Returns 0/1/2 (red/green/blue) for a named port, or -1 if unmapped.
|
||||
static int
|
||||
PodChannelOverride(const char *portName)
|
||||
{
|
||||
static int loaded = 0;
|
||||
static char names[8][20];
|
||||
static int chans[8];
|
||||
static int count = 0;
|
||||
if (!loaded)
|
||||
{
|
||||
loaded = 1;
|
||||
const char *e = getenv("BT_POD_CHANMAP");
|
||||
while (e != NULL && *e && count < 8)
|
||||
{
|
||||
while (*e == ' ' || *e == ',') ++e;
|
||||
const char *eq = strchr(e, '=');
|
||||
if (eq == NULL) break;
|
||||
int n = 0;
|
||||
for (const char *p = e; p < eq && n < 19; ++p) names[count][n++] = *p;
|
||||
names[count][n] = 0;
|
||||
const char *v = eq + 1;
|
||||
int ch = -1;
|
||||
if (_strnicmp(v, "red", 3) == 0) ch = L4GraphicsPort::RedChannel;
|
||||
else if (_strnicmp(v, "green", 5) == 0) ch = L4GraphicsPort::GreenChannel;
|
||||
else if (_strnicmp(v, "blue", 4) == 0) ch = L4GraphicsPort::BlueChannel;
|
||||
if (ch >= 0)
|
||||
{
|
||||
chans[count] = ch;
|
||||
DEBUG_STREAM << "[glasswin] channel override: " << names[count]
|
||||
<< " -> " << (ch == L4GraphicsPort::RedChannel ? "red"
|
||||
: ch == L4GraphicsPort::GreenChannel ? "green" : "blue")
|
||||
<< std::endl << std::flush;
|
||||
++count;
|
||||
}
|
||||
const char *nx = strchr(v, ',');
|
||||
if (nx == NULL) break;
|
||||
e = nx + 1;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (portName != NULL && _stricmp(names[i], portName) == 0)
|
||||
return chans[i];
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The channel a surface actually rides on THIS rig: the override if given,
|
||||
// else what the pod config authored.
|
||||
static int
|
||||
ResolvedChannelOf(const char *portName, L4GraphicsPort *port)
|
||||
{
|
||||
int ov = PodChannelOverride(portName);
|
||||
if (ov >= 0)
|
||||
return ov;
|
||||
return (port != NULL) ? (port->GetEnableID() & 0x7F) : -1;
|
||||
}
|
||||
|
||||
// BT_POD_IDENT=1 -- draw each surface's identity ON the glass (title, monitor,
|
||||
// and in RGB mode the channel->surface map). The only reliable way to learn
|
||||
// which physical cab panel a Windows display drives.
|
||||
static int
|
||||
PodIdentMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_IDENT");
|
||||
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// BT_POD_CHANTEST=<seconds> -- CHANNEL WALK, the instrument for mapping
|
||||
// splitter-wired glass. On a cab, three mono CRTs share one VGA port through
|
||||
// its R/G/B lines, so "which panel is which" cannot be read off Windows: the
|
||||
// desktop sees ONE display for three monitors. This drives ONE channel at a
|
||||
// time -- all-red, then all-green, then all-blue, N seconds each, with the
|
||||
// channel name drawn huge -- so the answer is simply: watch which panel lights
|
||||
// up, and when. Readable even over a rough livestream.
|
||||
// Returns the dwell in seconds (0 = off).
|
||||
static int
|
||||
PodChanTestSeconds()
|
||||
{
|
||||
static int s = -1;
|
||||
if (s < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_CHANTEST");
|
||||
s = (e != NULL && e[0] != 0) ? atoi(e) : 0;
|
||||
if (s < 0) s = 0;
|
||||
if (e != NULL && e[0] != 0 && s == 0) s = 3; // "1"/"on" -> 3s default
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Which channel is lit right now (0=red 1=green 2=blue), from the wall clock.
|
||||
static int
|
||||
PodChanTestPhase()
|
||||
{
|
||||
int dwell = PodChanTestSeconds();
|
||||
if (dwell <= 0)
|
||||
return -1;
|
||||
return (int)((GetTickCount() / (DWORD)(dwell * 1000)) % 3);
|
||||
}
|
||||
|
||||
// BT_POD_RGB=1 -- the pod's REAL video wiring (see pod-hardware.md §THE RGB
|
||||
// SPLIT): one VGA port per window, its R/G/B analog lines split to three
|
||||
// monochrome MFD monitors. Implies pod surface mode.
|
||||
static int
|
||||
PodRgbSplitMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_RGB");
|
||||
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
static int
|
||||
PodSurfaceMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_SURFACES");
|
||||
m = (e != NULL && *e != '\0' && *e != '0') ? 1 : 0;
|
||||
if (!m && PodRgbSplitMode())
|
||||
m = 1; // the RGB split is pod glass -- always bare
|
||||
if (m)
|
||||
DEBUG_STREAM << "[glasswin] POD SURFACE MODE: bare surfaces, no "
|
||||
"button banks (physical pod buttons assumed)\n" << std::flush;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Crop a built window to its surface: drop the bank, move the picture to the
|
||||
// client origin, go frameless. Called at the end of each Build* when pod mode
|
||||
// is on (and from LoadLayout for a per-window ",bare").
|
||||
static void
|
||||
MakeBareSurface(GWin &w)
|
||||
{
|
||||
if (w.portPrimary == NULL) // Flight Controls -- nothing to show
|
||||
{
|
||||
w.clientW = w.clientH = 0;
|
||||
w.buttonCount = 0;
|
||||
return;
|
||||
}
|
||||
const int sw = w.surfaceRect.right - w.surfaceRect.left;
|
||||
const int sh = w.surfaceRect.bottom - w.surfaceRect.top;
|
||||
w.buttonCount = 0; // the pod's buttons are real
|
||||
SetRect(&w.surfaceRect, 0, 0, sw, sh);
|
||||
w.clientW = sw;
|
||||
w.clientH = sh;
|
||||
w.noFrame = 1;
|
||||
}
|
||||
|
||||
// A framed window is a normal tool window; ",noframe" makes it a bare
|
||||
// WS_POPUP -- no caption, no border, nothing but the display and its buttons.
|
||||
static DWORD
|
||||
@@ -449,7 +646,159 @@ static GWin *
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
// External windows that ride glass_layout.cfg (2026-08-04). A window created
|
||||
// by ANOTHER TU -- the plasma window (L4PLASMAWIN) -- can register here so it
|
||||
// shares the SAME position + ",noframe" persistence as the per-display windows:
|
||||
// it queries its saved rect/flag on creation, and Save() writes its line too.
|
||||
// Tiny (HWND + title + last-known rect); no dynamic state.
|
||||
//###########################################################################
|
||||
|
||||
struct ExternWin
|
||||
{
|
||||
HWND hwnd;
|
||||
char title[64];
|
||||
int noFrame;
|
||||
RECT last;
|
||||
int haveLast;
|
||||
};
|
||||
static ExternWin gExtern[4];
|
||||
static int gExternCount = 0;
|
||||
|
||||
// Read the saved rect + ",noframe" flag for a title (glass window OR extern).
|
||||
// Returns 1 if the title has a line in the cfg. rect / noframe may be NULL.
|
||||
int
|
||||
BTGlassLayout_QueryWindow(const char *title, RECT *rect, int *noframe)
|
||||
{
|
||||
if (title == NULL || GlassLayoutMode() == LayoutOff)
|
||||
return 0;
|
||||
FILE *f = fopen(layoutFileName, "rt");
|
||||
if (f == NULL)
|
||||
return 0;
|
||||
|
||||
int found = 0;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f) != NULL)
|
||||
{
|
||||
char *s = line;
|
||||
while (*s == ' ' || *s == '\t') ++s;
|
||||
if (*s == '#' || *s == '\r' || *s == '\n' || *s == '\0')
|
||||
continue;
|
||||
char *eq = strchr(s, '=');
|
||||
if (eq == NULL)
|
||||
continue;
|
||||
*eq = '\0';
|
||||
char *end = eq;
|
||||
while (end > s && (end[-1] == ' ' || end[-1] == '\t')) --end;
|
||||
*end = '\0';
|
||||
if (strcmp(s, title) != 0)
|
||||
continue;
|
||||
|
||||
int x = 0, y = 0, w = 0, h = 0;
|
||||
if (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
|
||||
continue;
|
||||
int nf = 0;
|
||||
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
|
||||
{
|
||||
const char *t = opt + 1;
|
||||
while (*t == ' ' || *t == '\t') ++t;
|
||||
if (_strnicmp(t, "noframe", 7) == 0) { nf = 1; break; }
|
||||
}
|
||||
if (rect) { rect->left = x; rect->top = y; rect->right = x + w; rect->bottom = y + h; }
|
||||
if (noframe) *noframe = nf;
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Register an externally-created window so Save() writes its line. Loads its
|
||||
// current ",noframe" flag from the cfg so a save round-trips the flag.
|
||||
void
|
||||
BTGlassLayout_RegisterExtern(HWND hwnd, const char *title)
|
||||
{
|
||||
if (hwnd == NULL || title == NULL)
|
||||
return;
|
||||
if (gExternCount >= (int)(sizeof(gExtern) / sizeof(gExtern[0])))
|
||||
return;
|
||||
ExternWin &e = gExtern[gExternCount++];
|
||||
e.hwnd = hwnd;
|
||||
strncpy(e.title, title, sizeof(e.title) - 1);
|
||||
e.title[sizeof(e.title) - 1] = '\0';
|
||||
e.noFrame = 0;
|
||||
e.haveLast = 0;
|
||||
BTGlassLayout_QueryWindow(title, NULL, &e.noFrame);
|
||||
}
|
||||
|
||||
// Restore saved positions over the just-computed pod-faithful defaults. Called
|
||||
//---------------------------------------------------------------------------
|
||||
// MONITOR BINDING (2026-08-06) -- "monitor:<name-or-index>" instead of x,y.
|
||||
//
|
||||
// Absolute desktop coordinates do not survive a reboot on a pod: Windows
|
||||
// renumbers the virtual desktop whenever adapters enumerate differently (a USB
|
||||
// display adapter is especially prone), so a layout pinned to 800,122 can land
|
||||
// on the wrong glass -- or off-screen -- after a power cycle. Binding to the
|
||||
// MONITOR instead and resolving its rect at startup is stable across boots.
|
||||
// Heat MFD=monitor:\.\DISPLAY4,bare (device name -- what the receipts print)
|
||||
// Heat MFD=monitor:2,bare (index into the enumeration order)
|
||||
// The surface is centred on that monitor; an exact-size panel fills it.
|
||||
//---------------------------------------------------------------------------
|
||||
struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
|
||||
|
||||
static BOOL CALLBACK
|
||||
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
|
||||
{
|
||||
MonScan *sc = (MonScan *)param;
|
||||
MONITORINFOEXA mi;
|
||||
memset(&mi, 0, sizeof(mi));
|
||||
mi.cbSize = sizeof(mi);
|
||||
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
int hit = 0;
|
||||
if (sc->want != NULL)
|
||||
{
|
||||
// Match the full device name OR just its tail, so a hand-written
|
||||
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
|
||||
// (backslashes in a config file are a foot-gun -- every shell,
|
||||
// editor and copy-paste path mangles them differently).
|
||||
size_t dl = strlen(mi.szDevice), wl = strlen(sc->want);
|
||||
if (_stricmp(mi.szDevice, sc->want) == 0)
|
||||
hit = 1;
|
||||
else if (wl > 0 && wl <= dl
|
||||
&& _stricmp(mi.szDevice + (dl - wl), sc->want) == 0)
|
||||
hit = 1;
|
||||
}
|
||||
if (!hit && sc->wantIndex >= 0 && sc->index == sc->wantIndex)
|
||||
hit = 1;
|
||||
if (hit && !sc->found)
|
||||
{
|
||||
sc->rect = mi.rcMonitor;
|
||||
sc->found = 1;
|
||||
}
|
||||
}
|
||||
++sc->index;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Resolve "monitor:<spec>" to a rect. Returns 1 on success.
|
||||
static int
|
||||
ResolveMonitorSpec(const char *spec, RECT *out)
|
||||
{
|
||||
while (*spec == ' ' || *spec == ' ') ++spec;
|
||||
MonScan sc;
|
||||
memset(&sc, 0, sizeof(sc));
|
||||
sc.wantIndex = -1;
|
||||
if (spec[0] >= '0' && spec[0] <= '9')
|
||||
sc.wantIndex = atoi(spec);
|
||||
else
|
||||
sc.want = spec;
|
||||
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
|
||||
if (sc.found)
|
||||
*out = sc.rect;
|
||||
return sc.found;
|
||||
}
|
||||
|
||||
// from Create AFTER ComputeLayout, so any window not named in the file keeps its
|
||||
// computed spot. Restored windows are flagged so the WM_TIMER re-snap (which
|
||||
// re-runs ComputeLayout once the main window appears) leaves them alone.
|
||||
@@ -487,30 +836,77 @@ static void
|
||||
*end = '\0';
|
||||
|
||||
int x = 0, y = 0, w = 0, h = 0;
|
||||
if (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
|
||||
int haveXY = (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) >= 2);
|
||||
int viaMonitor = 0;
|
||||
if (!haveXY)
|
||||
{
|
||||
// "monitor:<name|index>" -- boot-stable binding (see the resolver).
|
||||
const char *v = eq + 1;
|
||||
while (*v == ' ' || *v == '\t') ++v;
|
||||
if (_strnicmp(v, "monitor:", 8) == 0 || _strnicmp(v, "mon:", 4) == 0)
|
||||
{
|
||||
char spec[64];
|
||||
const char *p2 = strchr(v, ':') + 1;
|
||||
int n2 = 0;
|
||||
while (*p2 && *p2 != ',' && n2 < (int)sizeof(spec) - 1) spec[n2++] = *p2++;
|
||||
spec[n2] = 0;
|
||||
RECT mr;
|
||||
if (ResolveMonitorSpec(spec, &mr))
|
||||
{
|
||||
x = mr.left; y = mr.top;
|
||||
w = mr.right - mr.left; h = mr.bottom - mr.top;
|
||||
haveXY = 1;
|
||||
viaMonitor = 1;
|
||||
}
|
||||
else
|
||||
DEBUG_STREAM << "[glasswin] monitor '" << spec
|
||||
<< "' not found -- leaving computed placement\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (!haveXY)
|
||||
continue;
|
||||
|
||||
// Trailing options after the numbers, comma-separated. Unknown ones
|
||||
// are ignored (so an older build reading a newer file just loses the
|
||||
// option, never the line) -- the bindings.txt grammar rule.
|
||||
int noframe = 0;
|
||||
int noframe = 0, bare = 0;
|
||||
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
|
||||
{
|
||||
const char *t = opt + 1;
|
||||
while (*t == ' ' || *t == '\t') ++t;
|
||||
if (_strnicmp(t, "noframe", 7) == 0)
|
||||
{
|
||||
noframe = 1;
|
||||
break;
|
||||
}
|
||||
else if (_strnicmp(t, "bare", 4) == 0)
|
||||
bare = 1; // pod panel: surface only (see PodSurfaceMode)
|
||||
}
|
||||
|
||||
GWin *gw = FindGWinByTitle(s);
|
||||
if (gw == NULL)
|
||||
continue;
|
||||
gw->wantX = x;
|
||||
gw->wantY = y;
|
||||
gw->noFrame = noframe;
|
||||
// "bare" is a pod panel: it always implies frameless. (Without this a
|
||||
// cfg line saying only ",bare" silently re-framed a window that pod
|
||||
// surface mode had already cropped -- caught on the bench 2026-08-06.)
|
||||
gw->noFrame = (noframe || bare) ? 1 : 0;
|
||||
if (bare && gw->buttonCount > 0)
|
||||
MakeBareSurface(*gw); // per-window pod panel (mixed rigs)
|
||||
if (viaMonitor)
|
||||
{
|
||||
// x,y,w,h is the MONITOR rect -- centre this surface on it (an
|
||||
// exact-size pod panel fills it edge to edge).
|
||||
int sw = gw->clientW > 0 ? gw->clientW : (gw->surfaceRect.right - gw->surfaceRect.left);
|
||||
int sh = gw->clientH > 0 ? gw->clientH : (gw->surfaceRect.bottom - gw->surfaceRect.top);
|
||||
gw->wantX = x + ((w - sw) / 2);
|
||||
gw->wantY = y + ((h - sh) / 2);
|
||||
DEBUG_STREAM << "[glasswin] '" << gw->title
|
||||
<< "' bound to monitor " << x << "," << y << " " << w << "x" << h
|
||||
<< " -> window at " << gw->wantX << "," << gw->wantY
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
gw->wantX = x;
|
||||
gw->wantY = y;
|
||||
}
|
||||
gw->restored = 1;
|
||||
++restored;
|
||||
}
|
||||
@@ -545,7 +941,8 @@ static void
|
||||
"# Heat MFD=1920,0,657,539,noframe\n"
|
||||
"# A frameless window is also PINNED (no caption = nothing to drag it\n"
|
||||
"# by), so do the arranging first. Delete the flag to get the frame\n"
|
||||
"# back. Saves preserve it.\n",
|
||||
"# back. Saves preserve it. The 'BattleTech - Plasma' window is in\n"
|
||||
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
|
||||
f);
|
||||
int wrote = 0;
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
@@ -562,11 +959,41 @@ static void
|
||||
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
|
||||
++wrote;
|
||||
}
|
||||
// External windows (the plasma window) ride the same file. Cache the last
|
||||
// good rect so a window torn down before this save still keeps its line.
|
||||
for (int i = 0; i < gExternCount; ++i)
|
||||
{
|
||||
ExternWin &e = gExtern[i];
|
||||
RECT r;
|
||||
if (e.hwnd != NULL && IsWindow(e.hwnd) && GetWindowRect(e.hwnd, &r))
|
||||
{
|
||||
e.last = r; e.haveLast = 1;
|
||||
}
|
||||
else if (e.haveLast)
|
||||
{
|
||||
r = e.last; // window gone -> preserve its last-known line
|
||||
}
|
||||
else
|
||||
continue;
|
||||
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", e.title,
|
||||
(long)r.left, (long)r.top,
|
||||
(long)(r.right - r.left), (long)(r.bottom - r.top),
|
||||
e.noFrame ? ",noframe" : "");
|
||||
++wrote;
|
||||
}
|
||||
fclose(f);
|
||||
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
|
||||
<< layoutFileName << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// Public save trigger for registered external windows -- their WndProc calls
|
||||
// this on WM_EXITSIZEMOVE / teardown. No-op unless BT_GLASS_LAYOUT=save.
|
||||
void
|
||||
BTGlassLayout_Save()
|
||||
{
|
||||
SaveLayout();
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
// Painting
|
||||
//###########################################################################
|
||||
@@ -650,8 +1077,86 @@ static void
|
||||
mask = 0xFF; // index the low (palette) byte
|
||||
palId = SVGA16::SecondaryPalette;
|
||||
}
|
||||
// RGB mode: the base surface rides a CHANNEL, so its tint is that pure
|
||||
// primary -- resolved live (override first), never the build-time guess.
|
||||
if (w->groupCount > 0 && tint >= 0)
|
||||
{
|
||||
switch (ResolvedChannelOf(w->portPrimary, port))
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: tint = (int)0x00FF0000; break;
|
||||
case L4GraphicsPort::GreenChannel: tint = (int)0x0000FF00; break;
|
||||
case L4GraphicsPort::BlueChannel: tint = (int)0x000000FF; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
int ow = 0, oh = 0;
|
||||
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// RGB CHANNEL COMPOSITE (BT_POD_RGB, 2026-08-06) -- the AUTHENTIC pod
|
||||
// output. The cab does not give each mono MFD its own video port: one
|
||||
// VGA port's R/G/B analog lines are SPLIT to three monochrome monitors,
|
||||
// and the game puts a different surface in each colour channel of one
|
||||
// palettized framebuffer (L4GAUGE.CFG: Comm=red, Mfd2=green, Heat=blue
|
||||
// on clut2; Mfd1=red, Mfd3=green on clut1 -- and
|
||||
// L4GraphicsPort::BuildSecondaryColor writes exactly ONE component per
|
||||
// port, which IS the split). So on splitter-wired glass this window is
|
||||
// not one MFD: it is one VGA PORT, carrying up to three. Composite the
|
||||
// group's planes into this frame, each in its own channel, and let the
|
||||
// hardware separate them again.
|
||||
//
|
||||
// Channel comes from the port itself (GetEnableID), so the authored
|
||||
// config -- including a page swap that hands the channel to an Eng
|
||||
// plane -- decides, never a hardcoded table here.
|
||||
// ---------------------------------------------------------------------
|
||||
if (w->groupCount > 0 && ow > 0 && oh > 0)
|
||||
{
|
||||
const int n = ow * oh;
|
||||
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||
{
|
||||
L4GraphicsPort *gp =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupPort[gi]));
|
||||
if (gp == NULL)
|
||||
continue;
|
||||
// honour the live Mfd<n>/Eng<n> page swap: take whichever twin
|
||||
// currently owns a channel
|
||||
if (gp->GetEnableID() == L4GraphicsPort::BlankColor
|
||||
&& w->groupAlt[gi] != NULL)
|
||||
{
|
||||
L4GraphicsPort *ga =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupAlt[gi]));
|
||||
if (ga != NULL && ga->GetEnableID() != L4GraphicsPort::BlankColor)
|
||||
gp = ga;
|
||||
}
|
||||
const int ch = ResolvedChannelOf(w->groupPort[gi], gp);
|
||||
unsigned long chMask;
|
||||
switch (ch)
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: chMask = 0x00FF0000ul; break;
|
||||
case L4GraphicsPort::GreenChannel: chMask = 0x0000FF00ul; break;
|
||||
case L4GraphicsPort::BlueChannel: chMask = 0x000000FFul; break;
|
||||
default: continue; // blank / direct-colour -> contributes nothing
|
||||
}
|
||||
int gw = 0, gh = 0;
|
||||
// -1 tint would palette-expand; pass white so the plane comes back
|
||||
// as full-intensity mono, then keep only this port's channel.
|
||||
svga->ExpandPlaneToBGRA(gp->GetBitMask(), gp->paletteID,
|
||||
(int)0x00FFFFFF, w->rotate, gStage2, &gw, &gh);
|
||||
if (gw != ow || gh != oh)
|
||||
continue;
|
||||
for (int i = 0; i < n; ++i)
|
||||
gStage[i] |= (gStage2[i] & chMask);
|
||||
}
|
||||
if (log)
|
||||
{
|
||||
DEBUG_STREAM << "[glass] '" << w->title << "' RGB COMPOSITE of "
|
||||
<< w->groupCount << " plane(s):";
|
||||
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||
DEBUG_STREAM << " " << w->groupPort[gi];
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (log)
|
||||
{
|
||||
int nz = 0, n = ow * oh;
|
||||
@@ -809,6 +1314,117 @@ static void
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, oldPen);
|
||||
DeleteObject(pen);
|
||||
|
||||
// (NO full-face flash overlay here either -- same wrongness as the
|
||||
// L4VB16 one, removed 2026-08-03: the buttons are big rects tucked
|
||||
// under the surface by design, and the protruding edge is the lamp.)
|
||||
|
||||
// ---- CHANNEL WALK (BT_POD_CHANTEST) ---------------------------------
|
||||
// Overwrite the whole surface with ONE channel at full intensity, so
|
||||
// exactly one monitor of a splitter-fed trio lights. Cycles R->G->B.
|
||||
{
|
||||
const int phase = PodChanTestPhase();
|
||||
if (phase >= 0)
|
||||
{
|
||||
static const COLORREF chColor[3] =
|
||||
{ RGB(255, 0, 0), RGB(0, 255, 0), RGB(0, 0, 255) };
|
||||
static const char *chName[3] = { "RED", "GREEN", "BLUE" };
|
||||
HBRUSH fill = CreateSolidBrush(chColor[phase]);
|
||||
FillRect(dc, &w->surfaceRect, fill);
|
||||
DeleteObject(fill);
|
||||
|
||||
// Name the channel AND what the pod authors onto it here, so a
|
||||
// lit panel identifies itself completely: "GREEN = Mfd2".
|
||||
const char *who = "-";
|
||||
GaugeRenderer *cgr = BTResolveGaugeRenderer();
|
||||
if (cgr != NULL)
|
||||
{
|
||||
const char *cand[4]; int nc = 0;
|
||||
cand[nc++] = w->portPrimary;
|
||||
for (int gi = 0; gi < w->groupCount && nc < 4; ++gi)
|
||||
cand[nc++] = w->groupPort[gi];
|
||||
for (int i = 0; i < nc; ++i)
|
||||
{
|
||||
L4GraphicsPort *pp =
|
||||
static_cast<L4GraphicsPort*>(cgr->GetGraphicsPort(cand[i]));
|
||||
if (pp != NULL && (pp->GetEnableID() & 0x7F) == phase)
|
||||
{ who = cand[i]; break; }
|
||||
}
|
||||
}
|
||||
char big[96];
|
||||
sprintf(big, "%s %s = %s", w->monitorName, chName[phase], who);
|
||||
RECT br = w->surfaceRect;
|
||||
br.top += (br.bottom - br.top) / 3;
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
DrawLabelText(dc, big, titleFont, &br, RGB(0, 0, 0),
|
||||
DT_CENTER | DT_SINGLELINE | DT_VCENTER);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PANEL IDENTIFY (BT_POD_IDENT=1) --------------------------------
|
||||
// Pod bring-up: nobody can tell which PHYSICAL panel a Windows display
|
||||
// is, and desktop coordinate order says nothing about where a panel
|
||||
// sits in the cab -- guessing that mapping is exactly how it came out
|
||||
// wrong. So label each surface ON ITS OWN GLASS with what it is, which
|
||||
// monitor it came from, and (in RGB mode) the channel->surface map.
|
||||
// Walk the pod, read the panels, write the layout from what you SAW.
|
||||
if (PodIdentMode())
|
||||
{
|
||||
char line1[96], line2[160];
|
||||
sprintf(line1, "%s", w->title);
|
||||
if (w->groupCount > 0)
|
||||
{
|
||||
// name the channels so a splitter-fed trio is self-describing
|
||||
char parts[128];
|
||||
parts[0] = 0;
|
||||
GaugeRenderer *igr = BTResolveGaugeRenderer();
|
||||
const char *names[4]; int chans[4]; int nn = 0;
|
||||
names[nn] = w->portPrimary; chans[nn] = -1; ++nn;
|
||||
for (int gi = 0; gi < w->groupCount && nn < 4; ++gi)
|
||||
{ names[nn] = w->groupPort[gi]; chans[nn] = -1; ++nn; }
|
||||
for (int i = 0; i < nn; ++i)
|
||||
{
|
||||
const char *chName = "?";
|
||||
if (igr != NULL)
|
||||
{
|
||||
L4GraphicsPort *pp =
|
||||
static_cast<L4GraphicsPort*>(igr->GetGraphicsPort(names[i]));
|
||||
if (pp != NULL)
|
||||
switch (pp->GetEnableID() & 0x7F)
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: chName = "RED"; break;
|
||||
case L4GraphicsPort::GreenChannel: chName = "GREEN"; break;
|
||||
case L4GraphicsPort::BlueChannel: chName = "BLUE"; break;
|
||||
case L4GraphicsPort::AllChannels: chName = "RGB"; break;
|
||||
default: chName = "blank"; break;
|
||||
}
|
||||
}
|
||||
char one[48];
|
||||
sprintf(one, "%s%s=%s", (i ? " " : ""), chName, names[i]);
|
||||
if (strlen(parts) + strlen(one) < sizeof(parts) - 1)
|
||||
strcat(parts, one);
|
||||
}
|
||||
sprintf(line2, "%s %s", w->monitorName, parts);
|
||||
}
|
||||
else
|
||||
{
|
||||
sprintf(line2, "%s surface=%s", w->monitorName,
|
||||
w->portPrimary ? w->portPrimary : "-");
|
||||
}
|
||||
|
||||
RECT lr = w->surfaceRect;
|
||||
lr.bottom = lr.top + 64;
|
||||
HBRUSH back = CreateSolidBrush(RGB(0, 0, 0));
|
||||
FillRect(dc, &lr, back);
|
||||
DeleteObject(back);
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
RECT t1 = lr; t1.top += 4; t1.bottom = t1.top + 30;
|
||||
DrawLabelText(dc, line1, titleFont, &t1, RGB(255, 255, 0),
|
||||
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
|
||||
RECT t2 = lr; t2.top += 32; t2.bottom = t2.top + 28;
|
||||
DrawLabelText(dc, line2, buttonFont, &t2, RGB(0, 255, 255),
|
||||
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
|
||||
}
|
||||
}
|
||||
|
||||
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
|
||||
@@ -945,11 +1561,23 @@ void
|
||||
if (gWinCount != 0)
|
||||
return; // already up
|
||||
|
||||
// BT_GAUGE_SEC_ROT: the portrait-CRT unrotation direction (shared with the
|
||||
// D3D dev path; default 3 = CW = user-verified upright).
|
||||
// BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface.
|
||||
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
|
||||
// portrait CRT, user-verified upright).
|
||||
// 0 and 2 keep the picture LANDSCAPE, which a modern replacement panel
|
||||
// needs. (Until 2026-08-06 this read only '1' and forced everything else
|
||||
// to 3, so BT_GAUGE_SEC_ROT=2 was silently ignored -- caught on the cart
|
||||
// when a 180 request kept coming back portrait.)
|
||||
{
|
||||
const char *rv = getenv("BT_GAUGE_SEC_ROT");
|
||||
gRadarRot = (rv != NULL && rv[0] == '1') ? 1 : 3;
|
||||
if (rv != NULL && rv[0] >= '0' && rv[0] <= '3')
|
||||
gRadarRot = rv[0] - '0';
|
||||
else
|
||||
gRadarRot = 3;
|
||||
DEBUG_STREAM << "[glasswin] radar rotation " << gRadarRot
|
||||
<< (gRadarRot == 3 ? " (90 CW)" : gRadarRot == 1 ? " (90 CCW)"
|
||||
: gRadarRot == 2 ? " (180)" : " (none)")
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
memset(latched, 0, sizeof(latched));
|
||||
@@ -957,6 +1585,8 @@ void
|
||||
sRepositionedToMain = false;
|
||||
if (gStage == NULL)
|
||||
gStage = new unsigned long[640 * 480];
|
||||
if (gStage2 == NULL)
|
||||
gStage2 = new unsigned long[640 * 480];
|
||||
|
||||
// Build the seven windows. Order fixes the ComputeLayout roles (0..6).
|
||||
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
|
||||
@@ -968,6 +1598,52 @@ void
|
||||
BuildFlight(gWins[6]); // 0x38-0x47
|
||||
gWinCount = 7;
|
||||
|
||||
// POD SURFACE MODE: crop every display to its picture and drop the Flight
|
||||
// Controls pad (it is pure desktop chrome -- the cab has a real stick).
|
||||
if (PodSurfaceMode())
|
||||
{
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
MakeBareSurface(gWins[i]);
|
||||
if (gWins[6].portPrimary == NULL)
|
||||
gWinCount = 6; // drop Flight Controls
|
||||
}
|
||||
|
||||
// RGB SPLIT MODE (BT_POD_RGB): the cab's real wiring -- ONE VGA port per
|
||||
// window, its R/G/B lines split to three mono monitors. Collapse the five
|
||||
// separate MFD windows into the TWO ports the pod actually drives
|
||||
// (L4GAUGE.CFG clut2 = Comm/Mfd2/Heat, clut1 = Mfd1/Mfd3), each window
|
||||
// compositing its group into the colour channels; the radar keeps its own
|
||||
// full-colour port. Channels are read live from each port, so a page swap
|
||||
// to an Eng plane follows automatically.
|
||||
if (PodRgbSplitMode())
|
||||
{
|
||||
GWin port2 = gWins[0]; // reuse the Heat window's shape
|
||||
port2.title = "VGA Port A"; // Comm=red, Mfd2=green, Heat=blue
|
||||
port2.portPrimary = "Heat"; // base plane (blue); group adds the rest
|
||||
port2.portAlt = NULL;
|
||||
port2.groupCount = 2;
|
||||
port2.groupPort[0] = "Comm"; port2.groupAlt[0] = NULL;
|
||||
port2.groupPort[1] = "Mfd2"; port2.groupAlt[1] = "Eng2";
|
||||
port2.monoTint = (int)0x000000FF; // Heat rides BLUE
|
||||
|
||||
GWin port1 = gWins[3]; // the Mfd1 window's shape
|
||||
port1.title = "VGA Port B"; // Mfd1=red, Mfd3=green (blue spare)
|
||||
port1.portPrimary = "Mfd1";
|
||||
port1.portAlt = "Eng1";
|
||||
port1.groupCount = 1;
|
||||
port1.groupPort[0] = "Mfd3"; port1.groupAlt[0] = "Eng3";
|
||||
port1.monoTint = (int)0x00FF0000; // Mfd1 rides RED
|
||||
|
||||
GWin radar = gWins[5]; // unchanged: its own colour port
|
||||
|
||||
gWins[0] = port2;
|
||||
gWins[1] = port1;
|
||||
gWins[2] = radar;
|
||||
gWinCount = 3;
|
||||
DEBUG_STREAM << "[glasswin] RGB SPLIT MODE: 2 VGA ports + radar "
|
||||
"(each port's R/G/B feeds three mono panels)\n" << std::flush;
|
||||
}
|
||||
|
||||
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
|
||||
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
|
||||
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
|
||||
@@ -1023,6 +1699,39 @@ void
|
||||
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
|
||||
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
|
||||
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
|
||||
|
||||
// PLACEMENT RECEIPT (pod bring-up, 2026-08-06): which PHYSICAL monitor
|
||||
// each display actually landed on. On a real cab -- or any remote
|
||||
// session -- nobody can see all seven surfaces at once, so the log is
|
||||
// the only way to confirm the map without walking around the pod.
|
||||
{
|
||||
RECT wr = { 0, 0, 0, 0 };
|
||||
GetWindowRect(w.hwnd, &wr);
|
||||
HMONITOR mon = MonitorFromWindow(w.hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
MONITORINFOEXA mi;
|
||||
memset(&mi, 0, sizeof(mi));
|
||||
mi.cbSize = sizeof(mi);
|
||||
const char *devName = "?";
|
||||
int mx = 0, my = 0, mw = 0, mh = 0, primary = 0;
|
||||
if (mon != NULL && GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
devName = mi.szDevice;
|
||||
strncpy(w.monitorName, mi.szDevice, sizeof(w.monitorName) - 1);
|
||||
w.monitorName[sizeof(w.monitorName) - 1] = 0;
|
||||
mx = mi.rcMonitor.left; my = mi.rcMonitor.top;
|
||||
mw = mi.rcMonitor.right - mi.rcMonitor.left;
|
||||
mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
|
||||
primary = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0;
|
||||
}
|
||||
DEBUG_STREAM << "[glasswin] '" << w.title << "' surface="
|
||||
<< (w.portPrimary ? w.portPrimary : "-")
|
||||
<< " at " << wr.left << "," << wr.top
|
||||
<< " " << (wr.right - wr.left) << "x" << (wr.bottom - wr.top)
|
||||
<< (w.noFrame ? " bare" : " framed")
|
||||
<< " -> monitor " << devName
|
||||
<< " (" << mx << "," << my << " " << mw << "x" << mh
|
||||
<< (primary ? ", PRIMARY)" : ")") << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
|
||||
@@ -1050,3 +1759,43 @@ void
|
||||
if (subFont) { DeleteObject(subFont); subFont = NULL; }
|
||||
if (gStage) { delete[] gStage; gStage = NULL; }
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
// Per-frame repaint pump (2026-08-04, "indicators flash slowly unless focused").
|
||||
//
|
||||
// These windows repaint off a 62 ms WM_TIMER. Windows COALESCES/THROTTLES timer
|
||||
// AND paint messages for a window that is in the BACKGROUND (not focused) -- so
|
||||
// when the game window (or another app) holds focus, the panel's WM_TIMER slows
|
||||
// to a crawl and the lamp FLASH (a repaint-driven animation: BTLampBrightnessOf
|
||||
// alternates the shade off GetTickCount every paint) drags. Giving the panel
|
||||
// focus un-throttles its timer, which is exactly what playtesters saw.
|
||||
//
|
||||
// Fix: drive the repaint from the MAIN render loop (BTGlassPanels_Tick, called
|
||||
// once per frame from L4VIDEO), gated to the same ~16 Hz flash cadence. The main
|
||||
// loop runs every frame while the game is up regardless of which window has focus,
|
||||
// and InvalidateRect + UpdateWindow forces a SYNCHRONOUS WM_PAINT -- bypassing the
|
||||
// throttled timer-message path entirely. The WM_TIMER stays (it still owns the
|
||||
// one-shot re-snap); a focused window just repaints from whichever fires first.
|
||||
//###########################################################################
|
||||
|
||||
void
|
||||
BTGlassPanels_Tick()
|
||||
{
|
||||
if (gWinCount == 0)
|
||||
return;
|
||||
|
||||
static unsigned long sLastPaint = 0;
|
||||
unsigned long now = GetTickCount();
|
||||
if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
|
||||
return;
|
||||
sLastPaint = now;
|
||||
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
if (gWins[i].hwnd != NULL)
|
||||
{
|
||||
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
|
||||
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -466,6 +466,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 +494,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 +542,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);
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ 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
|
||||
return 0;
|
||||
@@ -52,6 +58,7 @@ PlasmaWindow::~PlasmaWindow()
|
||||
{
|
||||
if (window != NULL)
|
||||
{
|
||||
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); } // backstop before the HWND goes
|
||||
DestroyWindow((HWND)window);
|
||||
window = NULL;
|
||||
}
|
||||
@@ -75,20 +82,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 +132,7 @@ void
|
||||
DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
BTGlassLayout_RegisterExtern((HWND)window, "BattleTech - Plasma");
|
||||
ShowWindow((HWND)window, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -739,6 +739,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 +1208,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 +1321,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 +1370,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)
|
||||
{
|
||||
|
||||
+188
-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;
|
||||
@@ -8847,6 +8960,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
|
||||
@@ -8913,27 +9037,71 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
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 +9367,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,16 @@ void
|
||||
void
|
||||
BTGlassPanels_Destroy();
|
||||
|
||||
//
|
||||
// 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
|
||||
|
||||
+57
-4
@@ -407,6 +407,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)
|
||||
@@ -848,6 +874,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 +1183,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;
|
||||
@@ -1512,9 +1553,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 +1585,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
|
||||
|
||||
+22
-4
@@ -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,7 @@ 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,
|
||||
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
||||
roster.memberCount == 0)
|
||||
@@ -1208,6 +1220,12 @@ 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);
|
||||
sprintf(pilot.address, "%s:%d",
|
||||
member.fakeAddress, member.gamePort);
|
||||
if (!member.isSelf)
|
||||
@@ -1226,7 +1244,7 @@ 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,
|
||||
spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
||||
{
|
||||
|
||||
+164
-8
@@ -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)
|
||||
{
|
||||
//
|
||||
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||
@@ -93,6 +139,12 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
// 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 +170,14 @@ 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);
|
||||
}
|
||||
out->isSelf = (member == SteamUser()->GetSteamID());
|
||||
if (out->name[0] == 0)
|
||||
{
|
||||
@@ -176,10 +236,17 @@ 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"",
|
||||
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -312,6 +379,7 @@ static int
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
@@ -335,7 +403,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);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
if (RunRoom(1) != 0)
|
||||
@@ -360,6 +434,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 +496,26 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
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 +523,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 +568,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);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
int result = RunRoom(0);
|
||||
@@ -475,7 +623,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,11 @@ 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
|
||||
int isSelf;
|
||||
unsigned long long steamID;
|
||||
};
|
||||
@@ -46,6 +51,7 @@ struct BTLobbyRoster
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
@@ -57,5 +63,6 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
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).
|
||||
|
||||
@@ -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,7 +163,13 @@ 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]
|
||||
// @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 };
|
||||
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
|
||||
|
||||
//
|
||||
@@ -270,6 +276,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 +301,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];
|
||||
}
|
||||
}
|
||||
@@ -331,6 +370,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;
|
||||
|
||||
@@ -2054,7 +2054,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 +2107,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 +2152,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).
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
// Function -> method map:
|
||||
// L4MechControlsMapper ----------------------------- vtable @0051e440
|
||||
// @004d17ac ctor @004d1814 dtor
|
||||
// @004d196c InterpretControls (target-range ramp + review-mode watch)
|
||||
// @004d196c InterpretControls (target-range ramp + panic-arm watch)
|
||||
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
|
||||
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
|
||||
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
|
||||
@@ -91,7 +91,7 @@
|
||||
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
|
||||
//
|
||||
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 missionReviewMode.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 ejectPermitted.
|
||||
//
|
||||
|
||||
#include <bt.hpp>
|
||||
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
//
|
||||
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
|
||||
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
|
||||
// review-mode watcher.
|
||||
// panic-arm watcher (ejectPermitted edge).
|
||||
//
|
||||
L4MechControlsMapper::L4MechControlsMapper(
|
||||
Mech *owner,
|
||||
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
)
|
||||
{
|
||||
(void)class_ID;
|
||||
previousMissionReviewMode = 0; // this[0x6a] @0x1a8
|
||||
previousEjectPermitted = 0; // this[0x6a] @0x1a8
|
||||
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
|
||||
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
|
||||
Check_Fpu();
|
||||
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
|
||||
// 1. snap the throttle to a full-throttle detent,
|
||||
// 2. (re)build the pilot roster,
|
||||
// 3. watch the Mech's mission-review flag and toggle the corresponding mode
|
||||
// bit (0x200000) on the application mode manager,
|
||||
// 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
|
||||
// refreshed each frame by the master performance, FUN_004a9b5c+0x10 ->
|
||||
// @0049fa1c) and toggle the PANIC-ARMED mode bit (0x200000) on the
|
||||
// application mode manager. That mode bit is what lights the pod's
|
||||
// physical PANIC button (MakeLinkedLamp, btl4mppr RIO ctor) and any
|
||||
// gauge elements carrying the mode in their ModeMask. [Corrected
|
||||
// 2026-08-03: this cell was mislabeled "mission-review mode" -- the
|
||||
// real review mode is the GLOBAL DAT_004fd550 (btl4pb), not mech+0x414.]
|
||||
// 4. slew the smoothed target-range exponent toward the panel demand and
|
||||
// push the resulting range (250 * 2^exponent) onto the Mech,
|
||||
// 5. delegate to MechControlsMapper::InterpretControls for the actual
|
||||
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
BuildPilotArray(); // FUN_004b0600
|
||||
|
||||
//
|
||||
// (3) Mission-review mode change watcher.
|
||||
// (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
|
||||
//
|
||||
Mech *mech = GetMech();
|
||||
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414
|
||||
if (review_mode != previousMissionReviewMode) // @0x1a8
|
||||
int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
|
||||
if (eject_armed != previousEjectPermitted) // @0x1a8
|
||||
{
|
||||
previousMissionReviewMode = review_mode;
|
||||
previousEjectPermitted = eject_armed;
|
||||
|
||||
BTL4ModeManager *mode_manager =
|
||||
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
|
||||
// ModeManager API: Add/RemoveModeMask save the old mask into
|
||||
// previousModeMask (the recovered "savedMode = currentMode" step).
|
||||
if (review_mode == 0)
|
||||
if (eject_armed == 0)
|
||||
{
|
||||
mode_manager->RemoveModeMask(0x200000);
|
||||
}
|
||||
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
{
|
||||
mode_manager->AddModeMask(0x200000);
|
||||
}
|
||||
if (getenv("BT_EJECT_LOG"))
|
||||
DEBUG_STREAM << "[eject] panic-arm mode "
|
||||
<< (eject_armed ? "ON" : "off") << " ("
|
||||
<< mech->GetEntityID() << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -74,8 +74,17 @@
|
||||
//
|
||||
public:
|
||||
enum {
|
||||
KeypressMessageID = MechControlsMapper::NextMessageID,
|
||||
StringMatchMessageID,
|
||||
// PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
|
||||
// straight into the group Adds; corrected 2026-08-03). The chain
|
||||
// arithmetic (MechControlsMapper::NextMessageID) lands on 0x17 --
|
||||
// TWO mapper ids between 0x16 ToggleVoiceAssist and 0x19 are
|
||||
// unreconstructed -- and the mis-numbered Keypress send was
|
||||
// SILENTLY swallowed by the Mech (no 0x17 handler), which is why
|
||||
// the armed pilot-keypad press never ejected. Mech::EjectPilot is
|
||||
// id 0x19 on the MECH's receiver: the panic-mode keypad binding
|
||||
// sends THIS id to the OWNER, and the collision is the design.
|
||||
KeypressMessageID = 0x19,
|
||||
StringMatchMessageID, // 0x1a (unregistered; stringManager route)
|
||||
NextMessageID
|
||||
};
|
||||
|
||||
@@ -203,7 +212,9 @@
|
||||
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
|
||||
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
|
||||
int
|
||||
previousMissionReviewMode; // @0x1a8 last-seen mech mission-review flag
|
||||
previousEjectPermitted; // @0x1a8 last-seen mech ejectPermitted (@0x414) --
|
||||
// the panic-arm edge detector (was mislabeled
|
||||
// "mission-review flag"; corrected 2026-08-03)
|
||||
|
||||
//
|
||||
// Per-platform input staging slots (filled by the derived ctor's
|
||||
@@ -305,8 +316,12 @@
|
||||
// was previously self-consistent at the wrong value (0x2a), so it
|
||||
// worked by accident -- now it matches the binary.
|
||||
// (Faithful-fidelity note: the binary RIO table also carries its OWN
|
||||
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared
|
||||
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.)
|
||||
// Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
|
||||
// the binary's 0x19 (the old chain arithmetic landed on 0x17, and the
|
||||
// panic-mode pilot-keypad send was silently swallowed by the Mech --
|
||||
// no 0x17 handler -- so the armed eject press never fired). Remaining
|
||||
// refinement: the ctor registers the shared L4 Keypress BODY
|
||||
// (@004d1bf0); the RIO-specific body is @004d2514.)
|
||||
//
|
||||
enum {
|
||||
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
|
||||
|
||||
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
|
||||
|
||||
//
|
||||
// Controls only matter in interactive ("scrub") review mode.
|
||||
// The binary reads the GLOBAL review-mode cell DAT_004fd550 here --
|
||||
// NOT mech+0x414 (that cell is ejectPermitted; the old
|
||||
// GetMissionReviewMode() call was a mislabel, corrected 2026-08-03).
|
||||
// The port never enters scrub mode, so the global stays 0.
|
||||
//
|
||||
if (GetMissionReviewMode() == 2) // DAT_004fd550
|
||||
extern int gMissionReviewMode; // DAT_004fd550 (btstubs)
|
||||
if (gMissionReviewMode == 2)
|
||||
{
|
||||
Check(controlsManager);
|
||||
controlsManager->Execute(); // vtbl+0x18
|
||||
|
||||
+1052
-4
File diff suppressed because it is too large
Load Diff
@@ -706,7 +706,39 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
d3d_OBJECT *wreckDebrisObj;
|
||||
d3d_OBJECT *wreckFlamesObj;
|
||||
std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable
|
||||
// #73 (aimed per-part pick): each segment's drawn geometry + its
|
||||
// damage zone. The 1995 pick was a dpl SCENE intersection against
|
||||
// the rendered meshes; this map is the port's equivalent target set
|
||||
// (per-segment bounding spheres on the live posed skeleton).
|
||||
struct SegPick { d3d_OBJECT *obj; int zone; };
|
||||
std::map<int, SegPick> segPick; // segment idx -> pickable
|
||||
// ARMOUR DARKENING (issue #87): the resolved (object, damage zone)
|
||||
// bindings for this mech -- one entry per draw object that carries at
|
||||
// least one material listed in a zone's .DZM material list. Built once
|
||||
// with the tree (BindArmourDamage), replayed each frame by
|
||||
// TickArmourDamage. The binary's equivalent is the per-material
|
||||
// watcher list it builds in MakeMechRenderables (FUN_004573e4).
|
||||
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
|
||||
std::vector<DmgBind> dmgBinds;
|
||||
std::map<int, int> segGState; // slot -> last applied graphic state
|
||||
// SEARCHLIGHT (2026-08-05, MakeMechRenderables case 0xbd8 @004cef28):
|
||||
// external view = a spot.bgf cone child on the mount joint, shown/
|
||||
// hidden from the subsystem's "LightOn" attribute (the @0045612c
|
||||
// watcher, transcribed into TickSearchlight); cockpit view = the fog
|
||||
// swap (@00456778/@00456814 -> SetFogStyle searchLightOn/Off).
|
||||
struct SearchLight
|
||||
{
|
||||
DPLStaticChildRenderable *cone; // external only; NULL on cockpit builds
|
||||
d3d_OBJECT *coneObj; // spot.bgf (held while hidden)
|
||||
int *lightOn; // the subsystem's LightOn attribute
|
||||
int shown; // cone visibility last applied
|
||||
int mountSeg; // resource segmentIndex (subsys +0x1DC)
|
||||
};
|
||||
SearchLight searchLight[2]; // the binary keeps 2 slots
|
||||
int searchLightCount;
|
||||
int searchIsCockpit; // built with view_type 1
|
||||
int searchFogCache[2]; // seeded INVERTED (@00456778
|
||||
// [9]/[10]) -> first tick syncs
|
||||
char paintSerno; // the %serno% this mech was BUILT with
|
||||
// (0 = none) -- ApplyViewSkeleton re-parses
|
||||
// segment BGFs, so the paint substitutions
|
||||
@@ -726,6 +758,27 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
int
|
||||
TickWreck(Entity *victim, float dt);
|
||||
|
||||
//
|
||||
// SEARCHLIGHT per-frame drive (2026-08-05): the transcription of the two
|
||||
// 1995 watcher renderables -- cone visibility from "LightOn" (@0045612c
|
||||
// Execute @004561d8) and the cockpit fog swap (@00456778 Execute
|
||||
// @00456814). Rides the same sim bridge as TickArmourDamage.
|
||||
//
|
||||
void
|
||||
TickSearchlight(Entity *mech);
|
||||
|
||||
//
|
||||
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
|
||||
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
|
||||
// by MUNGA's DamageZone stream ctor) against the draw ops of the segment
|
||||
// geometry just loaded, recording one binding per match. TickArmourDamage
|
||||
// copies each bound zone's live damageLevel onto those ops.
|
||||
//
|
||||
void
|
||||
BindArmourDamage(Entity *entity, MechRenderTree &tree);
|
||||
void
|
||||
TickArmourDamage(Entity *entity);
|
||||
|
||||
//
|
||||
// VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic
|
||||
// cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only
|
||||
@@ -756,6 +809,30 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
void
|
||||
SwapToWreck(Entity *victim);
|
||||
|
||||
// #73 -- the aimed PER-PART pick. The 1995 target pick was a dpl scene
|
||||
// intersection run by the division card against the drawn geometry
|
||||
// (dpl_isect_mode_obj per renderable; results at Instance/DCS/Geometry
|
||||
// granularity), so aimed fire struck a SEGMENT and credited that
|
||||
// segment's damage zone. This is the port's equivalent: ray-vs-sphere
|
||||
// over the mech's per-segment draw objects (mCullCenter/mCullRadius,
|
||||
// world via the draw-cached mLocalToWorld -- one frame stale, fine for
|
||||
// aiming), nearest struck segment wins, returning its hit point and
|
||||
// PrimaryDamageZone. Returns 0 when the tree is absent/wrecked (the
|
||||
// caller falls back to the whole-mech box + the unaimed lottery).
|
||||
int
|
||||
MechSegmentPick(Entity *mech, const float ray_start[3],
|
||||
const float ray_dir[3], float max_range,
|
||||
float hit_out[3], int *zone_out);
|
||||
|
||||
// #124 zone walker: a zone's VISUAL aim point -- its largest pick
|
||||
// object's cull-center in world (what a player aims at; the segment
|
||||
// ORIGIN sits at the joint and makes lower segments strike the part
|
||||
// above). 0 when the zone has no pick geometry.
|
||||
int
|
||||
ZoneAimPoint(Entity *mech, int zone, float out3[3]);
|
||||
int
|
||||
SegAimPoint(Entity *mech, int seg, float out3[3]);
|
||||
|
||||
protected:
|
||||
//
|
||||
// Renderer-manager overrides
|
||||
|
||||
+513
-109
@@ -203,6 +203,35 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
|
||||
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
|
||||
"BTPlayer::MakeMessage wire size must be 0xD0");
|
||||
|
||||
// Wire-format lock (2026-08-05): the combat score reports and the extended
|
||||
// VehicleDead cross nodes raw (replicant-player Dispatch reroute), and the
|
||||
// sender/receiver field map was transcribed from the binary's builds
|
||||
// (@0x4a04da/@0x4a05d9/@0x4a06c0 and @0x4a07d4) -- lock every offset.
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, scoreAward) == 0x1c,
|
||||
"ScoreMessage::scoreAward must be at wire offset 0x1c");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, scoreType) == 0x20,
|
||||
"ScoreMessage::scoreType must be at wire offset 0x20");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, damageAmount) == 0x24,
|
||||
"ScoreMessage::damageAmount must be at wire offset 0x24");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, vitalHit) == 0x28,
|
||||
"ScoreMessage::vitalHit must be at wire offset 0x28");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, zoneIndex) == 0x2c,
|
||||
"ScoreMessage::zoneIndex must be at wire offset 0x2c");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, subsysID) == 0x30,
|
||||
"ScoreMessage::subsysID must be at wire offset 0x30");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, senderMechID) == 0x34,
|
||||
"ScoreMessage::senderMechID must be at wire offset 0x34");
|
||||
static_assert(sizeof(BTPlayer::ScoreMessage) == 0x3c,
|
||||
"ScoreMessage wire size must be 0x3c (binary build @0x4a052b)");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, reserved28) == 0x28,
|
||||
"VehicleDeadMessage::reserved28 must be at wire offset 0x28");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killedByPlayerID) == 0x2c,
|
||||
"VehicleDeadMessage::killedByPlayerID must be at wire offset 0x2c");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killZone) == 0x34,
|
||||
"VehicleDeadMessage::killZone must be at wire offset 0x34");
|
||||
static_assert(sizeof(BTPlayer::VehicleDeadMessage) == 0x38,
|
||||
"VehicleDeadMessage wire size must be 0x38 (binary build @0x4a07f6)");
|
||||
|
||||
//
|
||||
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
|
||||
// Entity base differs -- so the 1995 offsets must never be used as raw byte
|
||||
@@ -413,6 +442,42 @@ void
|
||||
<< message->deathCount << " deathCount=" << deathCount
|
||||
<< ")\n" << std::flush;
|
||||
|
||||
// ALWAYS-ON GHOST TRACE (#81 field diagnostics). The requester half of
|
||||
// the drop-zone handshake, paired with the [dz] lines from the DropZone
|
||||
// entity. Two things are worth recording on every re-post:
|
||||
// * how many times we have re-posted for THIS death -- the respawn is
|
||||
// supposed to complete on the first or second try, so a climbing
|
||||
// count is a ghost forming;
|
||||
// * msgDeath vs our own deathCount -- the engine gates the hunt on
|
||||
// message->deathCount == deathCount, and the DropZone's re-send
|
||||
// safety net is keyed on lastDeathCount == message->deathCount, so a
|
||||
// MISMATCH silently breaks both (Gitea #45: the death tally is known
|
||||
// not to replicate correctly). This is the cheapest way to see it.
|
||||
{
|
||||
static int s_tries = 0;
|
||||
static int s_forDeath = -999;
|
||||
if (s_forDeath != message->deathCount) { s_forDeath = message->deathCount; s_tries = 0; }
|
||||
++s_tries;
|
||||
// #81: the OTHER gate. The engine hunt (PLAYER.cpp:229) requires
|
||||
// BOTH message->deathCount == deathCount AND simState !=
|
||||
// DropZoneAcquiredState. Report both, so a stranded respawn names
|
||||
// its own cause instead of being inferred.
|
||||
if (GetSimulationState() == (unsigned)DropZoneAcquiredState)
|
||||
DEBUG_STREAM << "[ghost] hunt GATED OFF: simState="
|
||||
<< (int)GetSimulationState() << " == DropZoneAcquiredState"
|
||||
<< " -- the engine will not dispatch AssignDropZone, so no"
|
||||
<< " reply, no RESET, permanent ghost\n" << std::flush;
|
||||
if (s_tries <= 3 || (s_tries % 5) == 0)
|
||||
DEBUG_STREAM << "[dzreq] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID() << " asking for a drop zone"
|
||||
<< " try=" << s_tries
|
||||
<< " msgDeath=" << message->deathCount
|
||||
<< " ourDeathCount=" << deathCount
|
||||
<< (message->deathCount != deathCount ? " *** MISMATCH -- hunt gate + resend net both break ***" : "")
|
||||
<< (s_tries >= 4 ? " (respawn not completing -- ghost forming)" : "")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
// Respawning needs a drop zone. A dev mission without a "DropZones"
|
||||
// group would hit the engine base's ACTIVE Check(dropzones) -> an
|
||||
@@ -551,6 +616,25 @@ void
|
||||
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
|
||||
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
|
||||
<< std::flush;
|
||||
// ⚠ LANDMINE, MEASURED 2026-07-30 (#81 dig). `Set_Alarm_Level` is an empty
|
||||
// STUB (btstubs.cpp:87), so this write and its partner in
|
||||
// DropZoneReplyMessageHandler (+0x2c, 2) are NO-OPS today.
|
||||
//
|
||||
// DO NOT "fix" them by calling SetSimulationState(). The values 1 and 2
|
||||
// decode against Player's enum as DropZoneAcquiredState and
|
||||
// VehicleTranslocatedState (PLAYER.h:273-279), which makes that conversion
|
||||
// look obviously right -- and it would BREAK RESPAWN ENTIRELY: the engine's
|
||||
// respawn hunt is gated on `GetSimulationState() != DropZoneAcquiredState`
|
||||
// (PLAYER.cpp:231), so setting state 1 on death means AssignDropZone is
|
||||
// never dispatched for that death and EVERY pilot ghosts permanently.
|
||||
//
|
||||
// +0x2c is not simulationState (ours is at 0x24, measured). In the binary
|
||||
// it is the Simulation-base ALARM indicator -- the same field the mech side
|
||||
// calls `graphicAlarm` (@0x4ac126 sets "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 therefore harmless until we find what reads a
|
||||
// PLAYER's alarm in the binary (unrecovered; likely a cockpit/HUD respawn
|
||||
// indicator). [T1 offsets, T3 purpose]
|
||||
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
|
||||
|
||||
//
|
||||
@@ -586,6 +670,64 @@ void
|
||||
}
|
||||
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
|
||||
|
||||
//
|
||||
// THE DEATH SCORE COST (@004c07cd-0x4c0828, raw disasm 2026-08-02 -- this
|
||||
// tail was missing from the #52 reconstruction). Gated on advancedDamageOn
|
||||
// (+0x264) alone in the binary: self-award -SpecialCaseDeathPenalty
|
||||
// (role+0x20, the 4.10-only role field restored to SCNROLE.h) as a type-1
|
||||
// score message handed DIRECTLY to the engine base handler (@0042da20 ==
|
||||
// PLAYER.cpp:138 `currentScore += scoreAward` + the vehicle
|
||||
// RespondToScoreMessage no-op hook) -- NOT dispatched, so it bypasses the
|
||||
// BT ScoreInflicted Verify. Shipped content authors the key nowhere, so
|
||||
// the cost is 0 in the field today; a mission that authors it gets it.
|
||||
// BINARY NUANCE [T4]: the 1995 engine cell (+0x1c8) is read by no BT-side
|
||||
// scoreboard (they read +0x278), so the pod's penalty may never have been
|
||||
// visible; our port has ONE currentScore, so it shows.
|
||||
//
|
||||
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
|
||||
{
|
||||
BTPlayer::ScoreMessage death_cost(
|
||||
BTPlayer::ScoreInflictedMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageReceivedScore, // type 1
|
||||
-scenarioRole->GetSpecialCaseDeathPenalty(), // scoreAward @+0x1c, NEGATED
|
||||
0.0f,
|
||||
EntityID::Null); // senderMechID (binary copies @0x522524)
|
||||
Player::ScoreMessageHandler(&death_cost); // the DIRECT base call
|
||||
}
|
||||
|
||||
//
|
||||
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
|
||||
//
|
||||
// The binary clears it right here, between the re-post and suppressConsole:
|
||||
// FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0
|
||||
// (part_013.c:10519-10523). We had the Post and the suppressConsole and
|
||||
// were missing the middle instruction, so `deathPending` -- which we DO set
|
||||
// (:505, and the dedup gate that reads it IS authentic; the binary's own
|
||||
// @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only
|
||||
// ever cleared on the SUCCESS paths. One failed respawn therefore latched
|
||||
// the pilot dead for the rest of the mission: every later death hit the
|
||||
// dedup and was SWALLOWED, so the cycle could never restart. That is what
|
||||
// turned a transient respawn hiccup into a PERMANENT ghost (dead,
|
||||
// un-Reset, still driveable, a burning wreck on every peer that sinks out
|
||||
// of the world after ~18 s and can never be drawn again).
|
||||
//
|
||||
// Field signature it explains exactly: 2026-07-29, 8 death cycles, 6
|
||||
// stranded, NONE of them ever recovering.
|
||||
//
|
||||
// Binary evidence that the latch must not persist: `+0x290` is written in
|
||||
// exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb,
|
||||
// 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the
|
||||
// preceding instruction); there is no write of 1 -- or of any non-zero
|
||||
// value, in any instruction form -- anywhere in the executable. So in 1995
|
||||
// the gate exists but can never block. [T1]
|
||||
//
|
||||
// Ordering matters and is preserved: the re-entrant death that arrives
|
||||
// while the death EFFECTS are being dispatched still lands while the latch
|
||||
// is up, so the "one death, one cycle" dedup is untouched.
|
||||
//
|
||||
deathPending = 0; // this+0x290 (binary: FUN_004c012c tail)
|
||||
|
||||
suppressConsole = 0; // this+0x258
|
||||
}
|
||||
|
||||
@@ -738,30 +880,45 @@ void
|
||||
{
|
||||
//
|
||||
// Points lost for damage we took (unless we hit ourselves).
|
||||
// sender null guard is PORT SAFETY: the binary derefs the registry
|
||||
// lookup unguarded (@0x4c0453) -- an inflictor that died between
|
||||
// shot and impact would have crashed the 1995 pod.
|
||||
//
|
||||
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
||||
if (sender_mech != 0 && our_mech != 0 // port guards (dead-window straggler)
|
||||
&& sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
||||
{
|
||||
Scalar received =
|
||||
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
|
||||
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
|
||||
}
|
||||
// BINARY NUANCE [T1 raw disasm @0x4c0433]: on SELF-inflicted damage
|
||||
// (sender == own vehicle) the 1995 arm skips the math and the common
|
||||
// tail folds its UNINITIALIZED award local into currentScore --
|
||||
// stack garbage, every fall/ram self-hit. The port keeps award=0.0,
|
||||
// a deliberate safe deviation.
|
||||
|
||||
//
|
||||
// Tell the console (if any, and not suppressed) that our VTV
|
||||
// was damaged.
|
||||
// was damaged. points_transfered CORRECTED 2026-08-05: the binary
|
||||
// rounds THE COMPUTED AWARD here (raw disasm @0x4c04a7: fld
|
||||
// [ebp-0xc]; call 0x4dcd94 -- an ST0-arg __ftol the decompiler
|
||||
// rendered argless, which an earlier pass misread as Now()).
|
||||
//
|
||||
Host *console_host =
|
||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||
if (console_host && suppressConsole == 0) // this+0x258
|
||||
BTPlayer *damager_player =
|
||||
(sender_mech != 0) ? MECH_OWNING_PLAYER(sender_mech) : 0; // port guard (dummy shooter)
|
||||
if (console_host && suppressConsole == 0 // this+0x258
|
||||
&& damager_player != 0)
|
||||
{
|
||||
ConsolePlayerVTVDamagedMessage damaged_message(
|
||||
ownerID, // this+0x18c
|
||||
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c
|
||||
damager_player->ownerID, // *(sender+0x190)+0x18c
|
||||
Round(message->damageAmount), // FUN_004078fc
|
||||
message->pointSenderLo, // msg+0x2c
|
||||
message->pointSenderHi, // msg+0x28
|
||||
(int)Now().ticks, // FUN_004dcd94
|
||||
message->auxID // msg+0x30
|
||||
message->zoneIndex, // msg+0x2c
|
||||
message->vitalHit, // msg+0x28
|
||||
Round(award), // FUN_004dcd94(ST0)
|
||||
message->subsysID // msg+0x30
|
||||
);
|
||||
|
||||
application->SendMessage( // app+0x20, slot+0x18
|
||||
@@ -775,6 +932,13 @@ void
|
||||
|
||||
case BTPlayer::ScoreMessage::KillScore: // 2
|
||||
{
|
||||
// PORT SAFETY: an unresolvable victim mech (removed wreck racing the
|
||||
// cross-node dispatch) or a severed own vehicle skips the arm; the
|
||||
// binary derefs both unguarded (@0x4c037e).
|
||||
if (sender_mech == 0 || our_mech == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless
|
||||
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
|
||||
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
|
||||
@@ -1411,6 +1575,36 @@ void
|
||||
}
|
||||
else // stale / old message
|
||||
{
|
||||
//
|
||||
// #81 GHOST: THIS IS THE LAST PLACE A RESPAWN CAN VANISH WITHOUT A
|
||||
// TRACE. The drop zone granted us a spot and dispatched the reply, but
|
||||
// its deathCount does not match ours -- so the reply is dropped, the
|
||||
// mech is never Reset, `deathPending` stays latched, and the pilot is a
|
||||
// permanent GHOST: dead, un-reset, still simulated and driveable, and a
|
||||
// burning wreck on every peer (which then sinks after ~18s and can
|
||||
// never be drawn again, btl4vid.cpp:1277). Until now this branch was a
|
||||
// bare `return` with no log at all, which is exactly why 6 of 8 field
|
||||
// death cycles stranded on 2026-07-29 leaving no evidence.
|
||||
//
|
||||
// ALWAYS ON, and it prints the DIRECTION of the mismatch, which decides
|
||||
// the fix: msgDeath < ours means the reply is genuinely stale (a
|
||||
// superseded death) and dropping it is correct; msgDeath > ours means
|
||||
// OUR counter is behind (a missed increment / a replicated write -- cf.
|
||||
// #45, the death tally does not replicate correctly) and the reply is
|
||||
// for a live death we should have honoured. Do not "recover" here
|
||||
// until the field logs say which way it goes -- guessing would be a
|
||||
// stand-in, and the wrong guess re-spawns a mech that is still alive.
|
||||
//
|
||||
DEBUG_STREAM << "[ghost] DROP-ZONE REPLY DISCARDED for player "
|
||||
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
|
||||
<< " -- msgDeath=" << message->deathCount
|
||||
<< " ourDeathCount=" << deathCount
|
||||
<< (message->deathCount < deathCount
|
||||
? " (reply is STALE -- superseded death)"
|
||||
: " *** OUR COUNTER IS BEHIND -- this was a live respawn and we just threw it away ***")
|
||||
<< " deathPending=" << deathPending
|
||||
<< (deathPending != 0 ? " <== LATCHED: this pilot is now a GHOST" : "")
|
||||
<< "\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1516,6 +1710,23 @@ BTPlayer::BTPlayer(
|
||||
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
|
||||
<< std::dec << " <-- the raw write targets byte 0x284\n"
|
||||
<< std::flush;
|
||||
// #81 GHOST DIG: the death path does Set_Alarm_Level(this+0x2c, 1)
|
||||
// and the drop-zone reply does (this+0x2c, 2). Decoded against
|
||||
// Player's enum those are DropZoneAcquiredState(1) and
|
||||
// VehicleTranslocatedState(2) -- i.e. the BINARY's +0x2c is the
|
||||
// Player's simulationState. The engine's respawn hunt is gated on
|
||||
// `GetSimulationState() != DropZoneAcquiredState`
|
||||
// (engine/MUNGA/PLAYER.cpp:229), so if that raw byte really is our
|
||||
// simulationState, EVERY death marks "drop zone already acquired"
|
||||
// and the hunt can never run -> permanent ghost. Print where our
|
||||
// compiled layout actually puts it.
|
||||
DEBUG_STREAM << "[layout] simulationState@0x" << std::hex
|
||||
<< (int)((char *)&simulationState - (char *)this)
|
||||
<< std::dec << " (raw death write targets 0x2c;"
|
||||
<< " DropZoneAcquiredState=" << (int)DropZoneAcquiredState
|
||||
<< " VehicleTranslocatedState=" << (int)VehicleTranslocatedState
|
||||
<< ") liveState=" << (int)GetSimulationState() << "\n"
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1695,6 +1906,22 @@ Logical
|
||||
// these read the COMPILED named members the scoring handlers write, replacing the
|
||||
// earlier raw-offset reads (pilot+0x27c / +0x200) that don't match our layout.
|
||||
//
|
||||
// #83 (collision rattle): the technician "splash/collision damage" setting --
|
||||
// the binary's collision distributor gates on the owning player's
|
||||
// advancedDamage copy 2 (@0x268, FUN_0049ffcc @0x49ffde). TU-safe bridge.
|
||||
int BTPlayerAdvancedDamageOn(void *player_v)
|
||||
{
|
||||
return (player_v != 0) ? ((BTPlayer *)player_v)->advancedDamageOn2 : 0;
|
||||
}
|
||||
|
||||
// #89 death blast, second gate: suppressConsole (+0x258) is SET by the eject
|
||||
// bookkeeping -- an EJECTED pilot's abandoned mech never blast-splashes
|
||||
// (raw disasm @0x4a0ac8-0x4a0ad6).
|
||||
int BTPlayerConsoleSuppressed(void *player_v)
|
||||
{
|
||||
return (player_v != 0) ? (int)((BTPlayer *)player_v)->suppressConsole : 0;
|
||||
}
|
||||
|
||||
int BTPilotKills(void *pilot)
|
||||
{
|
||||
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
|
||||
@@ -2015,124 +2242,247 @@ Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
|
||||
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
|
||||
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
|
||||
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
|
||||
// THE AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
|
||||
//
|
||||
void BTPostDamageScore(Entity *victim, Scalar damage) // Step 6: per-hit SCORE
|
||||
// Reconstructed from the raw disasm of the Mech::TakeDamageMessageHandler
|
||||
// report tail @0x4a02f4-0x4a0890 (dark-gap region, not in the export). This
|
||||
// RETIRES the bring-up producers BTPostDamageScore / BTPostKillScore (see the
|
||||
// tombstone below). The binary's flow, transcribed:
|
||||
//
|
||||
// resolve: shooterEntity = registry find(msg->inflictingEntity) @0x4a033c
|
||||
// shooterPlayer = shooterEntity+0x190 @0x4a034f
|
||||
// victimPlayer = mech+0x190 @0x4a0358
|
||||
// block A @0x4a04da (was ALIVE at entry, destroyed now):
|
||||
// ScoreMessage{id 0x16, type 2 KillScore,
|
||||
// scoreAward = the APPLIED damage tally,
|
||||
// damageAmount = victim role's killBonus (role+0x1c),
|
||||
// vitalHit, zone, subsysID,
|
||||
// senderMechID = the VICTIM} -> the SHOOTER's player
|
||||
// block B @0x4a05d9 (not newly killed, tally != 0):
|
||||
// same shape, type 0 -- which the only registered 0x16 receiver
|
||||
// (@0x4c02e4) VERIFY-rejects; 1995 then folded an UNINITIALIZED
|
||||
// award. Sent for wire fidelity; our handler banks 0.
|
||||
// block C @0x4a06c0 (tally > 0; runs after A too -- kills included):
|
||||
// ScoreMessage{type 1 DamageReceivedScore,
|
||||
// scoreAward = tally,
|
||||
// damageAmount = INTENDED (burstCount x amount),
|
||||
// senderMechID = the INFLICTOR} -> the victim's player
|
||||
//
|
||||
// Score-model consequence [T1]: shooters are credited ONLY for kills; victims
|
||||
// are PENALIZED for damage received (CalcDamageReceivedScore returns the
|
||||
// negative). Per-hit inflicted credit never existed in the 1995 pod.
|
||||
//
|
||||
// Port accommodations, all guarded: registry/player lookups null-checked (the
|
||||
// binary derefs them raw); killBonus basis 0 for the ownerless dummy victim;
|
||||
// a skipped kill credit keeps the #45 NOCREDIT matchlog forensics.
|
||||
//
|
||||
void BTMechPostCombatReports(
|
||||
void *mech_v, // the VICTIM mech (the handler's this)
|
||||
void *msg_v, // the TakeDamageMessage being handled
|
||||
float damage_tally, // loop local_24: applied damage + crit bonuses
|
||||
int vital_hit, // loop local_2c: a zone entered BurningState
|
||||
int report_zone, // msg->damageZone at loop ENTRY (the binary
|
||||
// reads msg+0x24, never rewritten mid-loop)
|
||||
int newly_destroyed) // alive at entry && IsMechDestroyed() now
|
||||
{
|
||||
if (application == 0 || victim == 0)
|
||||
Mech *victim = (Mech *)mech_v;
|
||||
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||
if (application == 0 || victim == 0 || msg == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
|
||||
if (local_player == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
|
||||
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
|
||||
BTPlayer::ScoreMessage message(
|
||||
BTPlayer::ScoreInflictedMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
||||
0.0f, // scoreAward (unused for inflicted)
|
||||
damage, // damageAmount
|
||||
victim->GetEntityID()); // senderMechID = victim
|
||||
local_player->Dispatch(&message);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
|
||||
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death)
|
||||
{
|
||||
if (application == 0 || victim == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a
|
||||
// populated `lastInflictingID` (damage is applied master-side only: 0 of 8800
|
||||
// corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads
|
||||
// `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a
|
||||
// REPLICANT, so `Entity::Dispatch` reroutes it to the owning host
|
||||
// (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine.
|
||||
//
|
||||
// CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node
|
||||
// maintains LOCAL score copies ... each node witnesses the death transition and
|
||||
// tallies its own copies self-consistently". That was FALSE and it is what hid
|
||||
// this bug: the reroute means exactly ONE node increments, no other node can
|
||||
// observe the killer at all, and nothing carried the value back out -- so every
|
||||
// remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant:
|
||||
// 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill;
|
||||
// every DEATH inst=R row reads killer=0:0). The counters are
|
||||
// now replicated owner->replicant instead; see Read/WriteUpdateRecord above.
|
||||
BTPlayer *killer_player = 0;
|
||||
BTPlayer *shooter_player = 0;
|
||||
Entity *shooter = 0;
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (application->GetHostManager() != 0)
|
||||
{
|
||||
Entity *killer = application->GetHostManager()->GetEntityPointer(
|
||||
((Mech *)victim)->lastInflictingID);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
|
||||
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
|
||||
shooter = application->GetHostManager()->GetEntityPointer(
|
||||
msg->inflictingEntity);
|
||||
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||
shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
|
||||
}
|
||||
if (killer_player != 0)
|
||||
BTPlayer *victim_player = (BTPlayer *)victim->GetPlayerLink();
|
||||
|
||||
if (newly_destroyed)
|
||||
{
|
||||
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
|
||||
// receiver's mech) so the handler credits `killCount++` -- a suicide
|
||||
// (killer == victim) never reaches here.
|
||||
BTPlayer::ScoreMessage kill(
|
||||
Player::ScoreMessageID, // 0x12
|
||||
//
|
||||
// Block A: the KILL report, to the SHOOTER's player. On the victim's
|
||||
// node that player is a replicant, so Entity::Dispatch reroutes to the
|
||||
// owning host (ENTITY.cpp:244) and the credit lands on the killer's own
|
||||
// machine -- the #45 flow, unchanged. killer == victim (the eject
|
||||
// charge) IS dispatched: the handler negates the award. That is the
|
||||
// #134 panic-penalty path the old BTPostKillScore filtered out.
|
||||
//
|
||||
Scalar kill_bonus = 0.0f; // basis: victim role+0x1c
|
||||
if (victim_player != 0 && victim_player->GetScenarioRole() != 0)
|
||||
kill_bonus = victim_player->GetScenarioRole()->GetKillBonus();
|
||||
if (shooter_player != 0)
|
||||
{
|
||||
BTPlayer::ScoreMessage kill(
|
||||
Player::ScoreMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage), // 0x3c
|
||||
BTPlayer::ScoreMessage::KillScore, // 2
|
||||
damage_tally, // +0x1c
|
||||
kill_bonus, // +0x24
|
||||
victim->GetEntityID(), // +0x34 the VICTIM
|
||||
vital_hit, // +0x28
|
||||
report_zone, // +0x2c
|
||||
msg->inflictingSubsystemID); // +0x30
|
||||
shooter_player->Dispatch(&kill);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] *** KILL report *** -> shooterPlayer="
|
||||
<< (void *)shooter_player << " tally=" << damage_tally
|
||||
<< " bonus=" << kill_bonus << "\n" << std::flush;
|
||||
}
|
||||
else if (BTMatchLogActive())
|
||||
{
|
||||
// #45 forensics, carried from BTPostKillScore: WHY the credit was
|
||||
// skipped -- missing killer entity, unregistered, or NULL link.
|
||||
BTMatchLog("NOCREDIT",
|
||||
"victim=%d:%d killerID=%d:%d found=%d registered=%d link=%d",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
BTMatchHostOf(msg->inflictingEntity), (int)msg->inflictingEntity,
|
||||
(int)(shooter != 0),
|
||||
(int)(shooter != 0 && BTIsRegisteredMech(shooter)),
|
||||
(int)(shooter != 0 && ((Mech *)shooter)->GetPlayerLink() != 0));
|
||||
}
|
||||
}
|
||||
else if (damage_tally != 0.0f && shooter_player != 0)
|
||||
{
|
||||
//
|
||||
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
|
||||
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
|
||||
// uninitialized stack float; see the handler's type-0 arm note).
|
||||
//
|
||||
BTPlayer::ScoreMessage inflicted(
|
||||
Player::ScoreMessageID,
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::KillScore, // 2
|
||||
0.0f, // scoreAward (killBonus; 0 for bring-up)
|
||||
damage, // damageAmount (killing-blow)
|
||||
victim->GetEntityID()); // senderMechID = victim
|
||||
killer_player->Dispatch(&kill);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
|
||||
<< " killCount=" << killer_player->GetKillCount()
|
||||
<< " deaths=" << killer_player->GetDeaths()
|
||||
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
|
||||
}
|
||||
else if (BTMatchLogActive())
|
||||
{
|
||||
//
|
||||
// #45 observability: the credit was SKIPPED. This used to be completely
|
||||
// silent, which is why the bug survived so long -- the counter simply never
|
||||
// moved and no log said why. Record which link in the chain broke so a
|
||||
// single matchlog convicts it: a missing killer entity, an unregistered
|
||||
// mech, a self-kill, or the NULL playerLink hazard.
|
||||
//
|
||||
Entity *k = (application->GetHostManager() != 0)
|
||||
? application->GetHostManager()->GetEntityPointer(
|
||||
((Mech *)victim)->lastInflictingID)
|
||||
: 0;
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
BTMatchLog("NOCREDIT",
|
||||
"victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
BTMatchHostOf(((Mech *)victim)->lastInflictingID),
|
||||
(int)((Mech *)victim)->lastInflictingID,
|
||||
(int)(k != 0), (int)(k == victim),
|
||||
(int)(k != 0 && BTIsRegisteredMech(k)),
|
||||
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
|
||||
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
||||
damage_tally, // +0x1c
|
||||
damage_tally, // +0x24 basis = tally
|
||||
victim->GetEntityID(),
|
||||
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||
shooter_player->Dispatch(&inflicted);
|
||||
}
|
||||
|
||||
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
|
||||
// BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in
|
||||
// solo (authentic -- DEATHS only lands on a real pilot in multiplayer).
|
||||
BTPlayer *victim_player = (BTPlayer *)((Mech *)victim)->GetPlayerLink();
|
||||
if (victim_player != 0)
|
||||
if (damage_tally > 0.0f && victim_player != 0)
|
||||
{
|
||||
Player::VehicleDeadMessage dead(
|
||||
Player::VehicleDeadMessageID, // 0x13
|
||||
sizeof(Player::VehicleDeadMessage));
|
||||
victim_player->Dispatch(&dead);
|
||||
//
|
||||
// Block C: the RECEIVED report, to the victim's own player -- kills
|
||||
// included (the binary runs this after block A as well). Carries the
|
||||
// INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
|
||||
// penalty basis and the INFLICTOR as senderMechID; feeds the score
|
||||
// penalty and the operator-console VTVDamaged line.
|
||||
//
|
||||
int bursts = msg->damageData.burstCount;
|
||||
if (bursts < 1)
|
||||
bursts = 1; // port guard (mirrors the loop)
|
||||
BTPlayer::ScoreMessage received(
|
||||
Player::ScoreMessageID,
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageReceivedScore, // 1
|
||||
damage_tally, // +0x1c
|
||||
(Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
|
||||
msg->inflictingEntity, // +0x34 the INFLICTOR
|
||||
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||
victim_player->Dispatch(&received);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// The authentic VehicleDead sender (@0x4a07d4-0x4a0890): the respawn-cycle
|
||||
// trigger, dispatched from the death tail of the SAME TakeDamage that killed
|
||||
// the mech, to the mech's own player -- now carrying the killed-by player and
|
||||
// the killing zone (the BT 0x38-byte message extension). Replaces the mech4
|
||||
// death-transition dispatch site, which carried a [T3 -- the binary's exact
|
||||
// sender is undecoded] flag; this IS that sender, decoded. The #55
|
||||
// NULL-playerLink fallback and the DEAD_NOTIFY forensics move here intact.
|
||||
//
|
||||
void BTMechPostVehicleDead(void *mech_v, void *msg_v, int report_zone)
|
||||
{
|
||||
Mech *victim = (Mech *)mech_v;
|
||||
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||
if (application == 0 || victim == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Player *owner = victim->GetPlayerLink();
|
||||
// #55 (the David fix), moved intact: resolve the SAME object by the
|
||||
// reverse link the binary's own respawn branch uses when the once-written
|
||||
// playerLink was lost.
|
||||
if (owner == 0)
|
||||
{
|
||||
Player *mission_player = (Player *)application->GetMissionPlayer();
|
||||
if (mission_player != 0
|
||||
&& mission_player->GetPlayerVehicle() == (Entity *)victim)
|
||||
{
|
||||
owner = mission_player;
|
||||
}
|
||||
}
|
||||
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
(void *)owner);
|
||||
if (owner == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// killed-by: the shooter PLAYER's own EntityID (@0x4a07d4 copies
|
||||
// shooterPlayer+0x184); Null when the inflictor or its player is gone.
|
||||
EntityID killed_by = EntityID::Null;
|
||||
if (msg != 0 && application->GetHostManager() != 0)
|
||||
{
|
||||
Entity *shooter = application->GetHostManager()->GetEntityPointer(
|
||||
msg->inflictingEntity);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||
{
|
||||
Player *shooter_player = ((Mech *)shooter)->GetPlayerLink();
|
||||
if (shooter_player != 0)
|
||||
killed_by = shooter_player->GetEntityID();
|
||||
}
|
||||
}
|
||||
|
||||
BTPlayer::VehicleDeadMessage vehicle_dead(
|
||||
Player::VehicleDeadMessageID, // 0x17
|
||||
sizeof(BTPlayer::VehicleDeadMessage), // 0x38
|
||||
killed_by,
|
||||
report_zone);
|
||||
owner->Dispatch(&vehicle_dead);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player"
|
||||
<< " (killedBy=" << BTMatchHostOf(killed_by) << ":" << (int)killed_by
|
||||
<< " zone=" << report_zone << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// TOMBSTONE (2026-08-05): BTPostDamageScore / BTPostKillScore -- the gauge
|
||||
// scoring wave's bring-up producers -- are RETIRED, replaced by the authentic
|
||||
// report tail above (BTMechPostCombatReports / BTMechPostVehicleDead, called
|
||||
// from Mech::TakeDamageMessageHandler at the binary's exact positions).
|
||||
// What changed for the scoreboard:
|
||||
// - per-hit INFLICTED credit is GONE: it was a port invention riding the
|
||||
// never-registered @0x4c0200 handler (the binary's BTPlayer table, byte-
|
||||
// scanned at file 0x112dxx, has 6 entries and none binds it). 1995
|
||||
// scoring = kill awards + received-damage penalties, nothing per-hit.
|
||||
// This also retires the #95 salvo-credit fix that rode it.
|
||||
// - suicides now DISPATCH the kill report and the handler NEGATES the
|
||||
// award -- the #134 panic-eject penalty path the old filter blocked.
|
||||
// - kill credit resolves the shooter from msg->inflictingEntity of the
|
||||
// killing TakeDamage itself; lastInflictingID (stamped from the same
|
||||
// field at handler entry) stays for the LOD router + effect orientation.
|
||||
// - the cross-node reroute story is unchanged: the kill report still
|
||||
// Dispatches to a replicant player and lands on the owner host
|
||||
// (ENTITY.cpp:244-251); KILLS/DEATHS still replicate owner->replicant
|
||||
// (Read/WriteUpdateRecord + the scoreboard heartbeat).
|
||||
// - the #81 single-VehicleDead rule is unchanged: exactly one dispatch per
|
||||
// death, now from the authentic tail instead of the death transition.
|
||||
// The ScoreInflictedMessageID channel and its handler remain registered but
|
||||
// idle (port infra; the binary's analog was dead code in the table).
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
|
||||
@@ -2221,3 +2571,57 @@ Entity *BTPlayerObjectiveMechOf(void *player)
|
||||
return 0;
|
||||
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// BTPlayerEjectBookkeeping -- complete-type bridge for Mech::EjectPilot
|
||||
// (@0049f854, the PANIC/EJECT punch-out). The handler's two player-side
|
||||
// writes, verbatim from the raw disasm:
|
||||
// * suppressConsole (+0x258) = 1 -- the eject already notified the console
|
||||
// (its own message, the tracked relay tail), so the DEATH that the
|
||||
// returned charge causes must not double-notify;
|
||||
// * the self-destruct amount = ScenarioRole::killBonus (role+0x1c -- the
|
||||
// record map is byte-verified: reader @00429bec dest offsets + the role
|
||||
// ctor's record copy [7]=rec[0]).
|
||||
// Returns the charge; 0 with no player/role (binary derefs unguarded).
|
||||
//#############################################################################
|
||||
Scalar BTPlayerEjectBookkeeping(void *player_v)
|
||||
{
|
||||
BTPlayer *player = (BTPlayer *)player_v;
|
||||
if (player == 0)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// The console EJECT NOTICE (@0049f88e-0x49f8cf): FUN_004c198c is the
|
||||
// ConsolePlayerMechDeathWithoutHonorMessage ctor -- "death without honor"
|
||||
// IS the punch-out record (4.10 wire id 0xF; our catalogue re-tags it 6,
|
||||
// not load-bearing) -- sent to the console host through the same
|
||||
// ConsoleClientID path as the VTVDamaged notify. The suppressConsole
|
||||
// latch below is its other half: the eject notice REPLACES the death
|
||||
// notify for this death.
|
||||
Host *console_host =
|
||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||
if (console_host != 0)
|
||||
{
|
||||
ConsolePlayerMechDeathWithoutHonorMessage eject_notice(
|
||||
player->ownerID); // this+0x18c
|
||||
application->SendMessage(
|
||||
console_host->GetHostID(), // console_host+0xc
|
||||
NetworkClient::ConsoleClientID, // 5
|
||||
&eject_notice);
|
||||
// Rare + forensic -- always log (the DumpAmmo precedent).
|
||||
DEBUG_STREAM << "[eject] DeathWithoutHonor notice -> console host "
|
||||
<< (int)console_host->GetHostID() << "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[eject] no console host -- notice skipped\n" << std::flush;
|
||||
}
|
||||
player->suppressConsole = 1; // +0x258
|
||||
const ScenarioRole *role = player->GetScenarioRole(); // +0x208
|
||||
if (role == 0)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
return role->GetKillBonus(); // role+0x1c
|
||||
}
|
||||
|
||||
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
|
||||
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
|
||||
// score types describe mech combat instead of score zones.
|
||||
//
|
||||
// Observed message layout (param_2 in the handlers):
|
||||
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
|
||||
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
|
||||
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
|
||||
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
|
||||
// were guesses from RP's smaller message, which stops at +0x28):
|
||||
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
|
||||
// damage tally here; the receiving handler
|
||||
// overwrites it with the computed award before
|
||||
// delegating to Player::ScoreMessageHandler
|
||||
// +0x20 scoreType (int)
|
||||
// +0x24 damageAmount (Scalar) raw damage / point quantity
|
||||
// +0x28 pointSenderHi (EntityID word)
|
||||
// +0x2c pointSenderLo (EntityID word)
|
||||
// +0x30 auxID (EntityID / host word)
|
||||
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
|
||||
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
|
||||
// type 1 = intended (burstCount x amount),
|
||||
// type 2 = the VICTIM role's killBonus (role+0x1c)
|
||||
// +0x28 vitalHit (int) a zone reached BurningState during
|
||||
// this message's applications (loop local_2c)
|
||||
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
|
||||
// the INITIAL resolve -- not the last burst's)
|
||||
// +0x30 subsysID (int) inflictingSubsystemID passed through
|
||||
// from the TakeDamageMessage (+0x5c)
|
||||
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
|
||||
// type 1: the INFLICTING mech
|
||||
//
|
||||
class BTPlayer__ScoreMessage:
|
||||
public Player::ScoreMessage
|
||||
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
|
||||
public:
|
||||
//
|
||||
// Kind of scoring event. Recovered from the branch selector at
|
||||
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
|
||||
// inflicted-damage handler at @004c0200.
|
||||
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
|
||||
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
|
||||
// and @004c0200, the only function that accepts it, appears in no
|
||||
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
|
||||
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
|
||||
// scoring = kills + received-damage penalties; per-hit inflicted
|
||||
// credit never existed.
|
||||
//
|
||||
enum ScoreType {
|
||||
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
|
||||
DamageInflictedScore = 0, // sent, but scores nothing (see above)
|
||||
DamageReceivedScore = 1, // I took damage
|
||||
KillScore = 2 // I destroyed / was destroyed
|
||||
};
|
||||
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
|
||||
scoreType; // +0x20
|
||||
|
||||
Scalar
|
||||
damageAmount; // +0x24
|
||||
damageAmount; // +0x24 scoring basis (see layout table)
|
||||
|
||||
//
|
||||
// Point-source / auxiliary handles carried alongside the damage record
|
||||
// (read by ScoreMessageHandler when building the console feed message).
|
||||
//
|
||||
int
|
||||
pointSenderHi; // +0x28
|
||||
vitalHit; // +0x28 zone entered BurningState this message
|
||||
|
||||
int
|
||||
pointSenderLo; // +0x2c
|
||||
zoneIndex; // +0x2c struck damage zone
|
||||
|
||||
int
|
||||
auxID; // +0x30
|
||||
subsysID; // +0x30 inflicting weapon subsystem
|
||||
|
||||
EntityID
|
||||
senderMechID; // +0x34 inflicting mech (point sender)
|
||||
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
|
||||
|
||||
BTPlayer__ScoreMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
|
||||
int score_type,
|
||||
Scalar score_award,
|
||||
Scalar damage_amount,
|
||||
const EntityID &sender_mech_ID
|
||||
const EntityID &sender_mech_ID,
|
||||
int vital_hit = 0,
|
||||
int zone_index = 0,
|
||||
int subsys_ID = 0
|
||||
):
|
||||
Player::ScoreMessage(message_ID, length, score_award),
|
||||
scoreType(score_type),
|
||||
damageAmount(damage_amount),
|
||||
vitalHit(vital_hit),
|
||||
zoneIndex(zone_index),
|
||||
subsysID(subsys_ID),
|
||||
senderMechID(sender_mech_ID)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//################# BTPlayer::VehicleDeadMessage ######################
|
||||
//###########################################################################
|
||||
//
|
||||
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
|
||||
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
|
||||
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
|
||||
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
|
||||
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
|
||||
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
|
||||
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
|
||||
// dispatches it to the dying mech's own player -- the respawn-cycle
|
||||
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
|
||||
// handler consumes the two extension fields is still undecoded (the
|
||||
// function is not in the export); the sender carries them faithfully.
|
||||
//
|
||||
class BTPlayer__VehicleDeadMessage:
|
||||
public Player::VehicleDeadMessage
|
||||
{
|
||||
public:
|
||||
int
|
||||
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
|
||||
|
||||
EntityID
|
||||
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
|
||||
|
||||
int
|
||||
killZone; // +0x34 zone the killing damage struck
|
||||
|
||||
BTPlayer__VehicleDeadMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
size_t length,
|
||||
const EntityID &killed_by_player_ID,
|
||||
int kill_zone
|
||||
):
|
||||
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
|
||||
reserved28(0),
|
||||
killedByPlayerID(killed_by_player_ID),
|
||||
killZone(kill_zone)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//##################### BTPlayer::MakeMessage #########################
|
||||
//###########################################################################
|
||||
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
|
||||
// Player::ScoreMessage.)
|
||||
//
|
||||
public:
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Message Support
|
||||
@@ -436,6 +500,9 @@ class DropZone__ReplyMessage;
|
||||
friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12): experienceLevel == 0
|
||||
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
|
||||
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
|
||||
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
|
||||
friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
|
||||
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
|
||||
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
|
||||
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
|
||||
// compiled object is `deathPending` (the respawn latch). These bridges
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
|
||||
// //
|
||||
// == RUNTIME BRING-UP WORKLIST (replace these) == //
|
||||
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / //
|
||||
// Mech::IsAirborne / SetTargetRange / //
|
||||
// SetMappingSubsystem / RaiseStatusAlarm //
|
||||
// Mech__DamageZone::LoadCriticalSubsystems //
|
||||
// MechSubsystem::TakeDamage / OnAlarmChanged //
|
||||
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
|
||||
// Mech method stubs.
|
||||
//===========================================================================//
|
||||
|
||||
// TODO(bring-up): reads mech+0x414 (mission-review playback flag).
|
||||
int Mech::GetMissionReviewMode()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
|
||||
// mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
|
||||
// mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
|
||||
// the port never enters scrub/review, so it stays 0.
|
||||
int gMissionReviewMode = 0;
|
||||
|
||||
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
|
||||
int Mech::IsAirborne()
|
||||
@@ -175,10 +175,9 @@ void Mech::RaiseStatusAlarm(int /*alarm_id*/)
|
||||
// MechSubsystem method stubs.
|
||||
//===========================================================================//
|
||||
|
||||
// TODO(bring-up): apply damage to a generic mech subsystem (virtual override).
|
||||
void MechSubsystem::TakeDamage(Damage & /*damage*/)
|
||||
{
|
||||
}
|
||||
// (MechSubsystem::TakeDamage was a no-op stub here; it is now the real
|
||||
// @0x4ac0bc body in mechsub.cpp -- zone damage + destroyed alarms + the
|
||||
// vital-subsystem kill. #80.)
|
||||
|
||||
// TODO(bring-up): react to a change in this subsystem's alarm level.
|
||||
void MechSubsystem::OnAlarmChanged()
|
||||
|
||||
@@ -175,9 +175,14 @@ DamageZonePercentTable *
|
||||
{
|
||||
if (sliceCount <= 0) return 0;
|
||||
|
||||
Scalar thetaIn = theta;
|
||||
if (rotateWithTorso) // binary this[3] != 0
|
||||
{
|
||||
theta += owner->TorsoHeading(); // torso+0x1d8
|
||||
// #124 frame adapter, angular half: theta arrives in the 1995 frame
|
||||
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
|
||||
// -- enters with the OPPOSITE sign (a reflection reverses angular
|
||||
// direction). Binary form: theta += torso+0x1d8.
|
||||
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
|
||||
if (theta >= TwoPi) theta -= TwoPi;
|
||||
if (theta < 0.0f) theta += TwoPi;
|
||||
}
|
||||
@@ -185,6 +190,19 @@ DamageZonePercentTable *
|
||||
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
|
||||
if (index < 0) index = 0;
|
||||
if (index > sliceCount - 1) index = sliceCount - 1;
|
||||
|
||||
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
|
||||
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
static int s_sl = 0;
|
||||
if (s_sl++ < 400)
|
||||
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
|
||||
<< " twist=" << (float)owner->TorsoHeading()
|
||||
<< " thetaIn=" << (float)thetaIn
|
||||
<< " thetaAdj=" << (float)theta
|
||||
<< " -> slice " << index << "/" << sliceCount << std::endl;
|
||||
}
|
||||
return slices[index];
|
||||
}
|
||||
|
||||
@@ -211,6 +229,44 @@ DamageLookupTable::DamageLookupTable(
|
||||
{
|
||||
layers.push_back(new PieSlice(owner, stream));
|
||||
}
|
||||
|
||||
// #92 -- "unable to damage the foot panels on Loki ... could on Thor".
|
||||
// Direct-fire damage does NOT use the mesh or the aim pick: the impact's
|
||||
// HEIGHT picks a layer, its ANGLE picks a slice, and a weighted random roll
|
||||
// picks the zone from that slice. So a zone that appears in NO slice of the
|
||||
// lowest layer is simply unreachable by direct fire. Dump the whole table.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
DumpTable();
|
||||
}
|
||||
|
||||
//
|
||||
// #92 diagnostic: print every layer / slice / weighted zone entry.
|
||||
//
|
||||
void DamageLookupTable::DumpTable() const
|
||||
{
|
||||
DEBUG_STREAM << "[dmgtable] layers=" << layerCount << std::endl;
|
||||
for (int L = 0; L < (int)layers.size(); ++L)
|
||||
{
|
||||
const PieSlice *layer = layers[L];
|
||||
DEBUG_STREAM << "[dmgtable] layer " << L
|
||||
<< " rotateWithTorso=" << layer->DiagRotateWithTorso()
|
||||
<< " slices=" << layer->DiagSliceCount() << std::endl;
|
||||
for (int S = 0; S < layer->DiagSliceCount(); ++S)
|
||||
{
|
||||
const DamageZonePercentTable *leaf = layer->DiagSlice(S);
|
||||
if (leaf == 0) continue;
|
||||
DEBUG_STREAM << "[dmgtable] slice " << S << ":";
|
||||
Scalar prev = 0.0f;
|
||||
for (int E = 0; E < leaf->DiagEntryCount(); ++E)
|
||||
{
|
||||
Scalar cum = leaf->DiagCumulative(E);
|
||||
DEBUG_STREAM << " z" << leaf->DiagZone(E)
|
||||
<< "=" << (int)((cum - prev) * 100.0f + 0.5f) << "%";
|
||||
prev = cum;
|
||||
}
|
||||
DEBUG_STREAM << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DamageLookupTable::~DamageLookupTable() // @0x49eadc
|
||||
@@ -239,6 +295,24 @@ int
|
||||
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
|
||||
if (heightRef <= 0.0f) return -1;
|
||||
|
||||
// PORT FRAME ADAPTER (#124, corrected 2026-08-03 by the MUZZLE-ANCHOR
|
||||
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
|
||||
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
|
||||
// slice-name data]. Our engine frame, measured with the mech's own
|
||||
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
|
||||
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
|
||||
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
|
||||
// (drive-code convention, "forward = -Z at heading 0"). The frames
|
||||
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
|
||||
// rotation first committed (that crossed the pods: left muzzle resolved
|
||||
// rtorso). y (the band axis) untouched.
|
||||
//
|
||||
// A reflection reverses the angular walk direction, so the OTHER angular
|
||||
// input -- the torso-twist term SelectSlice adds -- must flip sign with
|
||||
// it (applied there, same adapter). Twist was 0 in every probe; the
|
||||
// twisted-torso verify is the follow-up on #124.
|
||||
local.z = -local.z;
|
||||
|
||||
// --- height -> layer (penetration depth) ---
|
||||
Scalar depth = local.y; // binary local_2c
|
||||
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
|
||||
@@ -259,5 +333,30 @@ int
|
||||
}
|
||||
|
||||
DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678
|
||||
return leaf ? leaf->SelectZone() : -1; // FUN_0049de14
|
||||
int resolved = leaf ? leaf->SelectZone() : -1; // FUN_0049de14
|
||||
|
||||
// #92: which LAYER does a given impact height land in? A foot shot that
|
||||
// lands in a layer above the foot layer can never roll a foot zone.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
static int s_rh = 0;
|
||||
if (s_rh++ < 400)
|
||||
{
|
||||
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
|
||||
int *seg_out, const char **name_out);
|
||||
int seg = -1; const char *zname = 0;
|
||||
if (resolved >= 0)
|
||||
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
|
||||
DEBUG_STREAM << "[dmgresolve] impact=(" << impact.x << ","
|
||||
<< impact.y << "," << impact.z << ")"
|
||||
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
|
||||
<< " heightRef=" << heightRef
|
||||
<< " frac=" << (depth / heightRef)
|
||||
<< " layer=" << layerIndex << "/" << layerCount
|
||||
<< " theta=" << theta
|
||||
<< " -> zone " << resolved
|
||||
<< " '" << (zname ? zname : "?") << "'" << std::endl;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ class Point3D;
|
||||
// content-build authoring path -- is not part of the runtime port; the
|
||||
// shipped .RES is pre-built. Omitted here.)
|
||||
|
||||
// #92 diagnostics (read-only)
|
||||
int DiagEntryCount() const { return (int)entries.size(); }
|
||||
Scalar DiagCumulative(int i) const { return entries[i].cumulative; }
|
||||
int DiagZone(int i) const { return entries[i].zoneIndex; }
|
||||
|
||||
protected:
|
||||
Mech *owner; // @0x0c
|
||||
std::vector<Entry> entries; // @0x14 (was ReconTable) -- sorted ascending
|
||||
@@ -138,6 +143,11 @@ class Point3D;
|
||||
//
|
||||
DamageZonePercentTable *SelectSlice(Scalar theta) const;
|
||||
|
||||
// #92 diagnostics (read-only)
|
||||
int DiagSliceCount() const { return (int)slices.size(); }
|
||||
const DamageZonePercentTable *DiagSlice(int i) const { return slices[i]; }
|
||||
int DiagRotateWithTorso() const { return rotateWithTorso; }
|
||||
|
||||
protected:
|
||||
Mech *owner; // @0x0c
|
||||
std::vector<DamageZonePercentTable*>
|
||||
@@ -172,6 +182,7 @@ class Point3D;
|
||||
// the table is empty or the roll falls through (treated as a miss).
|
||||
//
|
||||
int ResolveHit(const Point3D &impact) const;
|
||||
void DumpTable() const; // #92 diagnostic
|
||||
|
||||
int LayerCount() const { return layerCount; } // bring-up verify
|
||||
|
||||
|
||||
@@ -428,8 +428,22 @@ void
|
||||
// simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat
|
||||
// (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead
|
||||
// mech's weapons drop everything.
|
||||
if (simulationState == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner))
|
||||
// #86 FIX (2026-07-31): the binary's +0x40 IS the statusAlarm level cell
|
||||
// (indicator @0x2C, level at +0x14 = +0x40 -- ONE cell); the port split
|
||||
// them and the destruction path writes only the alarm, so X'd-out energy
|
||||
// weapons on a blown-off arm kept firing. Read BOTH cells (see
|
||||
// projweap.cpp gate 1 for the full note).
|
||||
if (simulationState == 1 || statusAlarm.GetLevel() == 1
|
||||
|| GetFaultState() == 2 || BTMechDestroyed((Entity *)owner))
|
||||
{
|
||||
// #110 verification: name the refusal, so "the destroyed PPC stays
|
||||
// silent" is a log fact rather than an inference (the FIRED line does
|
||||
// not name its weapon). Once per weapon per destruction is enough --
|
||||
// keyed on the firing state actually being reset.
|
||||
if (getenv("BT_DMG_LOG") && GetWeaponState() != 0)
|
||||
DEBUG_STREAM << "[emitter] '" << (GetName() ? GetName() : "?")
|
||||
<< "' fire REFUSED (destroyed=" << (int)(simulationState == 1
|
||||
|| statusAlarm.GetLevel() == 1) << ")" << std::endl;
|
||||
ResetFiringState(); // @004ba9a8
|
||||
currentLevel = 0.0f; // 0x414
|
||||
ComputeOutputVoltage(); // (*vtable+0x44)()
|
||||
|
||||
@@ -1092,3 +1092,15 @@ void
|
||||
{
|
||||
ResetToInitialState(); // @004b2678 (takes no arg)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||
// roster members against this family's derivation chain (binary GUID 0x50fdc0).
|
||||
// Lives here because Gyroscope is a complete type only in this TU.
|
||||
//
|
||||
Derivation *
|
||||
BTGyroscopeFamily()
|
||||
{
|
||||
return &Gyroscope::ClassDerivations;
|
||||
}
|
||||
|
||||
+186
-22
@@ -359,22 +359,40 @@ Condenser::~Condenser()
|
||||
#endif
|
||||
|
||||
//###########################################################################
|
||||
// ResetToInitialState -- Condenser (HEAT.TCP)
|
||||
// ResetToInitialState -- Condenser
|
||||
//
|
||||
// RE-TRANSCRIBED FROM THE BINARY (respawn-reset audit 2026-08-04; the function
|
||||
// is MISSING from the Ghidra export -- disassembled raw, tools/disas2.py
|
||||
// 0x4ae534 0x60). The old body chained HeatableSubsystem (temp+heatLoad only,
|
||||
// per the stale HEAT.TCP shard) -- the binary chains HEATSINK (call 0x4ad760),
|
||||
// so the coolant refill DOES run for a condenser; then, RESPAWN-side only:
|
||||
//
|
||||
// test esi, esi ; je ... -> if (powered) {
|
||||
// [this+0x1d0] = 1 -> valveState = 1 (default detent --
|
||||
// the pod RESETS every coolant valve)
|
||||
// eax = [this+0x1d8]; [0x160] = eax -> massScale = refrigerationFactor
|
||||
// (restore the refrigeration output) }
|
||||
//
|
||||
// Mech::Reset's tail then re-runs BTRecomputeCondenserValves (@0049f788), so
|
||||
// the flow fractions rebuild from the reset detents (mech4.cpp). This is the
|
||||
// operator-reported "coolant valves survive a respawn" gap [was T3, now T1].
|
||||
//
|
||||
void
|
||||
Condenser::ResetToInitialState(Logical /*powered*/)
|
||||
Condenser::ResetToInitialState(Logical powered)
|
||||
{
|
||||
HeatableSubsystem::ResetToInitialState(True);
|
||||
HeatSink::ResetToInitialState(powered); // call 0x4ad760
|
||||
if (powered)
|
||||
{
|
||||
if (getenv("BT_DEATH_LOG") && valveState != 1)
|
||||
DEBUG_STREAM << "[respawn] " << GetName() << " valve detent "
|
||||
<< valveState << " -> 1 (authentic @004ae534 restore)\n" << std::flush;
|
||||
valveState = 1; // @0x1D0 = 1
|
||||
massScale = refrigerationFactor; // @0x160 = @0x1D8
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser -- a coolant
|
||||
// loop's valve. NOTE the body above chains HeatableSubsystem's (temperature +
|
||||
// heatLoad) and so does NOT run HeatSink's coolant refill, even though Condenser
|
||||
// derives from HeatSink. That mirrors the existing reconstruction; whether the
|
||||
// binary's Condenser reset also restores the VALVE SETTING is not established from
|
||||
// the decomp yet, so valve detents may still persist across a respawn.
|
||||
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
|
||||
//
|
||||
void
|
||||
Condenser::DeathReset(int reset_command)
|
||||
@@ -538,7 +556,10 @@ HeatSink::HeatSink(
|
||||
thermalConductance = subsystem_resource->thermalConductance; // +0xF0
|
||||
|
||||
heatFilter.Initialize(15, 0.0f); // FUN_0043ad4f(this+0x144, 0xF, 0)
|
||||
filterDecay = 0.4f;
|
||||
// @004b8fec stores 0.15f here (param_1[0x54] = 0x3e19999a). The value is
|
||||
// otherwise unread in the port, but it IS the leak gauge's full-scale
|
||||
// divisor: BitMapInverseWipe divides by subsystem+0x150 (#97).
|
||||
filterDecay = 0.15f; // was 0.4f -- did not match the image
|
||||
thermalMass = subsystem_resource->thermalMass; // +0xF4
|
||||
heatEnergy = thermalMass * startingTemperature;
|
||||
coolantFlowScale = 1.0f;
|
||||
@@ -546,6 +567,26 @@ HeatSink::HeatSink(
|
||||
|
||||
pendingHeat = 0.0f;
|
||||
|
||||
// #96 -- "each mech has a unique heating profile". The heat-family COUNT is
|
||||
// identical on every chassis (6 condensers, 1 bank, 1 reservoir), so if
|
||||
// per-mech cooling variation exists it must live in these authored values.
|
||||
// Dump them so that is a measured fact rather than an assumption.
|
||||
// ⚠ These are the RESOURCE values BEFORE any ctor scaling. The bank
|
||||
// (AggregateHeatSink) multiplies conductance by 0.1 x HeatSinkCount right
|
||||
// after this -- comparing chassis on THIS line alone mis-read the Owens and
|
||||
// Thor banks as identical (they are 115.5k vs 300.3k scaled; #96).
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
DEBUG_STREAM << "[hsparm] " << (GetName() ? GetName() : "?")
|
||||
<< " startT=" << startingTemperature
|
||||
<< " degradeT=" << degradationTemperature
|
||||
<< " failT=" << failureTemperature
|
||||
<< " conductance=" << thermalConductance
|
||||
<< " thermalMass=" << thermalMass
|
||||
// #99: correlate with [attrbind] -- is the audio watcher actually
|
||||
// bound to THIS object's leak flag?
|
||||
<< " &ReportLeak=" << (void *)&coolantActive
|
||||
<< "\n" << std::flush;
|
||||
|
||||
//
|
||||
// A "master" heat sink (segment flagged 0x100, not a sub-/damaged copy)
|
||||
// drives the per-frame thermal simulation.
|
||||
@@ -754,7 +795,7 @@ void
|
||||
{
|
||||
heatEnergy += pendingHeat;
|
||||
currentTemperature = heatEnergy / thermalMass;
|
||||
UpdateHeatLoad();
|
||||
UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
|
||||
|
||||
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
|
||||
// the old shared static timer aliased to whichever instance crossed the
|
||||
@@ -778,7 +819,25 @@ void
|
||||
}
|
||||
|
||||
pendingHeat = 0.0f;
|
||||
ConductHeat(time_slice); // FUN_004ad8ac
|
||||
// #96 dissipation audit: a 2s census of the chain -- prints from the SIM
|
||||
// tick (not the exchange) so the TERMINUS bank, which has no link and never
|
||||
// exchanges, is visible too. Gated: BT_HEAT_LOG.
|
||||
if (getenv("BT_HEAT_LOG"))
|
||||
{
|
||||
static unsigned long s_hcAt = 0;
|
||||
unsigned long now_ms = GetTickCount();
|
||||
if (now_ms - s_hcAt > 2000)
|
||||
s_hcAt = now_ms;
|
||||
if (now_ms - s_hcAt < 40 && GetName() != 0)
|
||||
DEBUG_STREAM << "[heatflow] " << GetName()
|
||||
<< " T=" << currentTemperature
|
||||
<< " E=" << heatEnergy
|
||||
<< " pend=" << pendingHeat
|
||||
<< " cool=" << coolantLevel << "/" << thermalCapacity
|
||||
<< " flowScale=" << coolantFlowScale
|
||||
<< " mScale=" << massScale << std::endl;
|
||||
}
|
||||
ConductHeat(time_slice); // FUN_004ad8ac
|
||||
}
|
||||
|
||||
if (HeatModelActive())
|
||||
@@ -809,8 +868,22 @@ void
|
||||
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
|
||||
// the running-average filter to produce the smoothed heatLoad reading.
|
||||
//
|
||||
// POD-CADENCE SAMPLING (#119, 2026-08-02). The binary adds ONE sample per
|
||||
// Perform -- a dt-less per-frame term (the myomer-kinetic class, FUN_0041c018
|
||||
// proves Performs ride the frame rate). 15 samples at the pod's 28 Hz = a
|
||||
// 0.536 s smoothing window; sampled at the port's ~59 fps the window halves
|
||||
// and heatLoad turns 2x as twitchy -- which doubles the cadence of the
|
||||
// ReportLeak relaxation oscillation at a drained tank (draw = damage x
|
||||
// heatLoad hunting across the authored 0.0025/0.003 hysteresis band), and
|
||||
// THAT is the rapidly-clipping "warning coo-- war--" leak voice Oracle
|
||||
// reproduced twice in 716. Sim calls pass their slice and samples accrue at
|
||||
// 28 Hz on any machine; ctor/reset prime calls (negative dt) sample
|
||||
// unconditionally. Per-instance clock lives in a static map (the census
|
||||
// precedent above) because the heat-family layouts are factory-size-locked
|
||||
// (sizeof(Myomers) == 0x358 exact) -- no new members.
|
||||
//
|
||||
void
|
||||
HeatSink::UpdateHeatLoad()
|
||||
HeatSink::UpdateHeatLoad(Scalar time_slice)
|
||||
{
|
||||
radiatedHeat = currentTemperature * coolantLevel;
|
||||
|
||||
@@ -824,7 +897,31 @@ void
|
||||
sample = HeatLoadMaximum;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
if (time_slice >= 0.0f)
|
||||
{
|
||||
static std::map<const void *, Scalar> s_filterClock;
|
||||
Scalar &clock = s_filterClock[this];
|
||||
clock += time_slice;
|
||||
const Scalar kPodTick = 1.0f / 28.0f; // [T1] pod Perform cadence
|
||||
if (clock < kPodTick)
|
||||
{
|
||||
return; // heatLoad holds the last average
|
||||
}
|
||||
int catchUp = 0;
|
||||
while (clock >= kPodTick && ++catchUp <= 15) // >15 samples saturate the window
|
||||
{
|
||||
clock -= kPodTick;
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
}
|
||||
if (catchUp > 15)
|
||||
{
|
||||
clock = 0.0f; // hitch: window already saturated
|
||||
}
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
return;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4 (prime call)
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
}
|
||||
|
||||
@@ -952,8 +1049,12 @@ void
|
||||
// to empty every frame -- the opposite of the authentic near-static behavior.
|
||||
// An undamaged subsystem has damageLevel 0 -> coolantDraw 0 -> NO leak (the coolant
|
||||
// bars stay full on a pristine mech); the draw rises only as the heat sink /
|
||||
// condenser itself takes battle damage. (Read the qualified engine zone -- the
|
||||
// nearer MechSubsystem::damageZone is a shim SHADOW; see mechweap.cpp:252.)
|
||||
// condenser itself takes battle damage. (#88 fix 2026-07-31: this qualified
|
||||
// engine-member read was NULL forever -- the MechSubsystem ctor only filled its
|
||||
// re-declared SHADOW, so `zoneDamage` pinned 0 and a leak was structurally
|
||||
// impossible no matter how much damage landed. The ctor now ALIASES the engine
|
||||
// base member to the same zone (mechsub.cpp, the #64 half-fix), so this read
|
||||
// sees the real crit/rattle damage.)
|
||||
::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38)
|
||||
Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158
|
||||
coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48]
|
||||
@@ -997,14 +1098,30 @@ void
|
||||
}
|
||||
|
||||
//
|
||||
// vtable slot 14 (@vtable+0x38). Base sinks supply nothing on their own;
|
||||
// a central-cooling-system subclass overrides this.
|
||||
// TODO: verify against the real slot-14 target (not captured in decomp).
|
||||
// vtable slot 14 (@vtable+0x38) -- the base @0x4add00, disassembled + decoded
|
||||
// 2026-07-31 (the old `return 0` stub was why a leaking subsystem could never
|
||||
// pull from the central tank -- the Reservoir bar never moved on damage):
|
||||
// linked = resolve(linkedSinks@0x164) ; FUN_00417ab4
|
||||
// return linked->DrawCoolant(requested * coolantFlowScale@0x15C)
|
||||
// i.e. the draw RECURSES UP the sink linkage, scaling per hop, and terminates
|
||||
// at the Reservoir's own override (@0x4af3b0: clamp to [0, coolantLevel],
|
||||
// drain the tank, return the granted amount). In the binary the terminal hop
|
||||
// is the bank's slot-14 override @0x4ae8b0 resolving its reservoir connection
|
||||
// @0x1D8; in the port the reservoir sits in the bank's linkedSinks (the
|
||||
// Reservoir-ctor Attach) and Reservoir::DrawCoolant IS the @0x4af3b0 body, so
|
||||
// the same chain terminates identically (the extra bank hop multiplies by its
|
||||
// coolantFlowScale, ctor-default 1.0 -- a no-op). PORT GUARD: the binary
|
||||
// calls through the resolved link UNGUARDED (authored topology always links);
|
||||
// an unlinked port sink supplies 0 instead of faulting.
|
||||
//
|
||||
Scalar
|
||||
HeatSink::DrawCoolant(Scalar /*requested*/)
|
||||
HeatSink::DrawCoolant(Scalar requested)
|
||||
{
|
||||
return 0.0f;
|
||||
HeatSink *linked = (HeatSink *)linkedSinks.Resolve(); // FUN_00417ab4(this+0x59)
|
||||
requested *= coolantFlowScale; // @0x15C
|
||||
if (linked == 0)
|
||||
return 0.0f;
|
||||
return linked->DrawCoolant(requested); // vtbl+0x38 (virtual)
|
||||
}
|
||||
|
||||
|
||||
@@ -1278,7 +1395,54 @@ int BTSubsystemIsCondenser(::Subsystem *sub)
|
||||
{
|
||||
return (sub != 0 && sub->IsDerivedFrom(*Condenser::GetClassDerivations())) ? 1 : 0;
|
||||
}
|
||||
//
|
||||
// #97 bridge: the leak gauge's FULL-SCALE divisor. BitMapInverseWipe computes
|
||||
// level = round(fullWidth * leakRate / thirdParam)
|
||||
// and the binary sources thirdParam from subsystem+0x150 (@004b8fec writes
|
||||
// 0.15f there). The gauge TU cannot see the HeatSink layout, and raw-reading
|
||||
// +0x150 from there would land on whatever OUR layout puts at that offset --
|
||||
// the databinding trap. Resolve it through the named member instead.
|
||||
//
|
||||
Scalar BTHeatSinkLeakFullScale(::Subsystem *sub)
|
||||
{
|
||||
if (sub == 0 || !sub->IsDerivedFrom(*HeatSink::GetClassDerivations()))
|
||||
return 0.15f; // the authored default
|
||||
Scalar fs = ((HeatSink *)sub)->LeakGaugeFullScale();
|
||||
return (fs > 0.0f) ? fs : 0.15f; // never divide by zero
|
||||
}
|
||||
|
||||
|
||||
int BTCondenserNumber(::Subsystem *sub)
|
||||
{
|
||||
return (sub != 0) ? ((Condenser *)sub)->condenserNumber : -1; // +0x1D4
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||
// roster members against this family's derivation chain (binary GUID 0x50e590).
|
||||
// Lives here because HeatSink is a complete type only in this TU.
|
||||
//
|
||||
Derivation *
|
||||
BTHeatSinkFamily()
|
||||
{
|
||||
return HeatSink::GetClassDerivations();
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
|
||||
// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
|
||||
// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
|
||||
// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
|
||||
// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
|
||||
// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
|
||||
// bank, else 1 with *out filled.
|
||||
//===========================================================================//
|
||||
int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
|
||||
{
|
||||
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
|
||||
return 0;
|
||||
HeatSink *bank = (HeatSink *)sub;
|
||||
*out = bank->CoolantFractionOf();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -512,8 +512,11 @@ inline int
|
||||
// Internal model helpers
|
||||
//
|
||||
public:
|
||||
// time_slice >= 0: sample the filter at the POD's 28 Hz cadence (#119
|
||||
// -- the per-Perform filter is frame-rate-dependent, the myomer-kinetic
|
||||
// class); negative (the ctor/reset prime calls) samples unconditionally.
|
||||
void
|
||||
UpdateHeatLoad(); // @004ad7f0
|
||||
UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
|
||||
void
|
||||
ClearHeatFilter(); // @004ad884
|
||||
void
|
||||
@@ -564,7 +567,14 @@ inline int
|
||||
Scalar startingTemperature; // @0x13C resource +0xE4 (saved initial temp)
|
||||
Scalar thermalConductance; // @0x140 resource +0xF0
|
||||
HeatFilter heatFilter; // @0x144 15-sample running average (12 bytes -> 0x150)
|
||||
Scalar filterDecay; // @0x150 init 0.4f
|
||||
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
|
||||
// leak gauge's full-scale divisor (#97)
|
||||
Scalar LeakGaugeFullScale() const { return filterDecay; }
|
||||
// Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
|
||||
// (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
|
||||
// the guard only shields a malformed stream from a NaN.
|
||||
Scalar CoolantFractionOf() const
|
||||
{ return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
|
||||
Scalar thermalMass; // @0x154 resource +0xF4
|
||||
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
|
||||
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
|
||||
|
||||
@@ -870,6 +870,14 @@ Scalar
|
||||
}
|
||||
}
|
||||
coolantLevel -= supplied;
|
||||
if (getenv("BT_COOL_LOG") && supplied > 0.0f)
|
||||
{
|
||||
static int s_n = 0;
|
||||
if ((s_n++ % 60) == 0)
|
||||
DEBUG_STREAM << "[resdraw] granted=" << supplied
|
||||
<< " tank=" << coolantLevel << "/" << thermalCapacity
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
return supplied;
|
||||
}
|
||||
|
||||
@@ -1203,9 +1211,29 @@ void
|
||||
{
|
||||
if (HeatModelActive()) // FUN_004ad7d4
|
||||
{
|
||||
// #96 dissipation audit: the bank runs THIS Performance, not the base
|
||||
// HeatSinkSimulation, so the [heatflow] census there never saw it.
|
||||
// Same census here, tagged RADIATOR -- if this line never prints, the
|
||||
// mech has NO ambient heat exit and everything cooks.
|
||||
if (getenv("BT_HEAT_LOG"))
|
||||
{
|
||||
static unsigned long s_rcAt = 0;
|
||||
unsigned long now_ms = GetTickCount();
|
||||
if (now_ms - s_rcAt > 2000)
|
||||
{
|
||||
s_rcAt = now_ms;
|
||||
DEBUG_STREAM << "[heatflow] RADIATOR '" << (GetName() ? GetName() : "?")
|
||||
<< "' T=" << currentTemperature
|
||||
<< " E=" << heatEnergy
|
||||
<< " pend=" << pendingHeat
|
||||
<< " k=" << thermalConductance
|
||||
<< " cool=" << coolantLevel << "/" << thermalCapacity
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
heatEnergy += pendingHeat; // [0x56] += [0x72]
|
||||
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
|
||||
UpdateHeatLoad(); // FUN_004ad7f0
|
||||
UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
|
||||
pendingHeat = 0.0f;
|
||||
|
||||
Scalar target = 300.0f
|
||||
|
||||
+443
-9
@@ -466,6 +466,7 @@ const Receiver::HandlerEntry
|
||||
MESSAGE_ENTRY(Mech, PlayerLink),
|
||||
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
|
||||
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
|
||||
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
|
||||
};
|
||||
|
||||
//
|
||||
@@ -513,6 +514,137 @@ void
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
|
||||
// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
|
||||
// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
|
||||
// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
|
||||
// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
|
||||
// additionally need weaponAlarm != 7 = NoAmmo)
|
||||
// then permitted = weapons < ejectMinWeapons || generators == 0
|
||||
// || coolant < 0.05 (@0049fb50)
|
||||
// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
|
||||
// The binary refreshes this per frame from an UNEXPORTED caller; the port
|
||||
// evaluates on demand (the handler + any future panic-lamp consumer).
|
||||
//
|
||||
int
|
||||
Mech::EvaluateEjectPermission()
|
||||
{
|
||||
extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
|
||||
extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
|
||||
extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
|
||||
|
||||
int liveWeapons = 0;
|
||||
int liveGenerators = 0;
|
||||
Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
|
||||
|
||||
for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
|
||||
{
|
||||
Subsystem *s = (Subsystem *)subsystemArray[i];
|
||||
if (s == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Scalar frac;
|
||||
if (BTHeatSinkBankCoolantFraction(s, &frac))
|
||||
{
|
||||
coolantFrac = frac;
|
||||
}
|
||||
else
|
||||
{
|
||||
int g = BTGeneratorCountsForEject(s);
|
||||
if (g >= 0)
|
||||
{
|
||||
liveGenerators += g;
|
||||
}
|
||||
else
|
||||
{
|
||||
int w = BTWeaponCountsForEject(s);
|
||||
if (w >= 0)
|
||||
{
|
||||
liveWeapons += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
|
||||
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
|
||||
int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
|
||||
|
||||
// (A short-lived coolant-clause HYSTERESIS was tried and REMOVED
|
||||
// 2026-08-03, same night, for strict fidelity: the binary's clause is
|
||||
// this plain `< 0.05` compare, evaluated per frame by the master
|
||||
// performance (FUN_004a9b5c+0x10) -- the ARCADE ran exactly this, so a
|
||||
// bank fraction hovering at the threshold flapped the panic-arm mode at
|
||||
// frame rate there too. The flap is cosmetic churn (mode mask + lamp;
|
||||
// hundreds of transitions ran crash-free on the bench) and was NOT the
|
||||
// operator's audio tick -- that was the autofire scalpel spamming the
|
||||
// per-pull jam click, no game bug. Keep the authentic compare.)
|
||||
ejectPermitted =
|
||||
(liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
|
||||
|| (liveGenerators == 0)
|
||||
|| (coolantFrac < 0.05f) // _DAT_0049fb50
|
||||
|| (gimped && noviceSim);
|
||||
return ejectPermitted;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
|
||||
// See the header comment (mech.hpp) for the byte-level story.
|
||||
//
|
||||
void
|
||||
Mech::EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message)
|
||||
{
|
||||
if (message->dataContents <= 0) // press only (msg+0xc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (mech not crippled enough)" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (disabled: movementMode "
|
||||
<< MovementMode() << ")" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
|
||||
// from ownerID @0x18c) and send it to the console host. CORE: forensic
|
||||
// log; the relay wire is the tracked tail. The suppressConsole latch set
|
||||
// below is the authentic other half (the following DEATH must not also
|
||||
// notify the console).
|
||||
extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
|
||||
void *player = GetPlayerLink(); // suppressConsole=1,
|
||||
Scalar charge = (player != 0) // returns role killBonus
|
||||
? BTPlayerEjectBookkeeping(player) : 0.0f;
|
||||
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " PUNCH-OUT: charge=" << charge
|
||||
<< " (role killBonus)" << std::endl << std::flush;
|
||||
|
||||
graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
|
||||
|
||||
Damage dmg; // FUN_0041db7c (Damage::Damage)
|
||||
dmg.damageType = (Enumeration)2; // Explosive
|
||||
dmg.damageAmount = charge; // role+0x1c (killBonus)
|
||||
dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
|
||||
dmg.burstCount = 1; // binary writes 0; our victim-side
|
||||
// guard clamps 0->1 -- same outcome
|
||||
Entity::TakeDamageMessage take_damage(
|
||||
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
|
||||
GetEntityID(), // inflicting = SELF (this+0x184)
|
||||
-1, // unaimed -> cylinder resolves
|
||||
dmg,
|
||||
-1); // inflictingSubsystemID = -1
|
||||
Dispatch(&take_damage); // vtbl+0xC self-dispatch
|
||||
}
|
||||
|
||||
Receiver::MessageHandlerSet
|
||||
Mech::MessageHandlers(
|
||||
ELEMENTS(Mech::MessageHandlerEntries),
|
||||
@@ -765,13 +897,119 @@ void
|
||||
}
|
||||
|
||||
//
|
||||
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two
|
||||
// call sites into the glue @0x49ed0c). An unaimed hit arrives with
|
||||
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
|
||||
// hit-location table (mech[0x111]) using the impact point, clear the flag, then
|
||||
// hand off to the base handler which routes damageZones[zone]->TakeDamage. Aimed
|
||||
// (reticle) hits carry a valid zone and pass straight through.
|
||||
// @0x4a0164 -- the CRIT CHANCE roll (#80; raw-disasm 2026-07-29 -- the single
|
||||
// binary caller of Mech__DamageZone::CriticalHit lives in the handler below,
|
||||
// and both sat in the un-exported decomp gap).
|
||||
//
|
||||
// zone = mech->damageZones[zone_index]
|
||||
// if (mech->player(+0x190)->simLive(+0x25c) == 0) return 0 ; novice: no crits
|
||||
// x = zone->damageLevel^2 * 0.7 + 0.01 ; dbl pool @0x4a0208/0x4a0210
|
||||
// clamp x to [0.0, 1.0] ; @0x4a0218..0x4a0228
|
||||
// return RandomUnit() <= x ; FUN_00408050(0x521f5c)
|
||||
//
|
||||
// Chance is ~1% on pristine armour, ~18.5% at half-stripped, ~58% at 90% --
|
||||
// crits become likely exactly as a zone's armour fails.
|
||||
//
|
||||
static int
|
||||
BTMechCriticalChance(Mech *mech, int zone_index)
|
||||
{
|
||||
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c)
|
||||
if (!BTPlayerExperienceSimLive(mech))
|
||||
return 0;
|
||||
|
||||
DamageZone *zone = (DamageZone *)mech->damageZones[zone_index];
|
||||
double x = (double)zone->damageLevel * (double)zone->damageLevel * 0.7 + 0.01;
|
||||
if (x < 0.0) x = 0.0;
|
||||
else if (x > 1.0) x = 1.0;
|
||||
|
||||
return (RandomUnit() <= (Scalar)x) ? 1 : 0;
|
||||
}
|
||||
|
||||
//
|
||||
// Mech override of Entity::TakeDamageMessageHandler -- the REAL binary body is
|
||||
// @0x4a0230 (#80: recovered from the un-exported gap via the Mech message
|
||||
// table @0x50bdf8, row {0x12, "TakeDamage", 0x4a0230}; the old "@0x4a037a"
|
||||
// note pointed into its middle). An unaimed hit arrives with
|
||||
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
|
||||
// hit-location table (mech[0x111]) using the impact point, clear the flag,
|
||||
// then apply the burst loop below (which supersedes the engine base's single
|
||||
// application). Aimed (reticle) hits carry a valid zone and pass straight to
|
||||
// the loop. Binary blocks not yet reconstructed here: the damageType==0
|
||||
// COLLISION divert (@0x4a0368 -> 0x49ffcc, its own distribution path) and the
|
||||
// id-0x16 damage/kill reports (see the tail comment) [T3/T4 -- decoded in
|
||||
// context/combat-damage.md].
|
||||
//
|
||||
//
|
||||
// @0049ffcc -- the COLLISION-DAMAGE DISTRIBUTOR (#83; raw disasm
|
||||
// scratchpad/night6/gap_49ffcc.txt -- an export-gap fn reached only from the
|
||||
// TakeDamage hub's damageType==0 divert @0x4a0368). Collision damage is the
|
||||
// manual's "splash/collision damage" TECHNICIAN SETTING: gated on the owning
|
||||
// player's advancedDamage copy (+0x268, @0x49ffde), priced against a
|
||||
// 100 km/h reference impact, and applied as 0.5-point rattle crits to random
|
||||
// INTERNAL subsystems -- armor is never touched.
|
||||
// scale = (2000 / moverMass) / (100 * 1/3.6)^2 / (1 - elasticity^2)
|
||||
// (tbyte @0x4a0148 = 0.2777778 = 1/3.6 km/h->u/s; 2000f @0x4a0154;
|
||||
// mass @+0x20c; elasticity @+0x244 -- Mover layout walk)
|
||||
// amount = raw * scale; amount < 0.5f (@0x4a015c) -> FREE (taps cost nothing)
|
||||
// n = Round(amount * 2) sub-hits (@0x4a0051) of amount/n each
|
||||
// each landing on ONE roster subsystem drawn by cumulative
|
||||
// collisionCriticalHitWeight (@0x10C) vs a [0,1) roll, restricted to the
|
||||
// HeatSink family / Gyroscope / Torso (derivation GUIDs 0x50e590 / 0x50fdc0 /
|
||||
// 0x510b08 -- all MechSubsystem-based, which is why +0x10C is valid on every
|
||||
// eligible entry). An un-won roll (total weight < roll) lands nowhere --
|
||||
// faithful: the binary does not normalize the weights.
|
||||
//
|
||||
void
|
||||
Mech::DistributeCollisionDamage(Damage *dmg)
|
||||
{
|
||||
extern int BTPlayerAdvancedDamageOn(void *player_v); // btplayer.cpp (+0x268)
|
||||
void *player = GetPlayerLink(); // mech+0x190 (ENTITY.h:430)
|
||||
if (player == 0 || !BTPlayerAdvancedDamageOn(player)) // @0x49ffde the technician gate
|
||||
return;
|
||||
|
||||
const Scalar c = 100.0f * 0.27777779f; // 100 km/h in u/s
|
||||
Scalar denom = 1.0f - elasticityCoefficient * elasticityCoefficient; // @0x244
|
||||
Scalar scale = ((2000.0f / moverMass) / (c * c)) / denom; // @0x49ffff-0x4a0027
|
||||
dmg->damageAmount *= scale; // @0x4a0030
|
||||
if (dmg->damageAmount < 0.5f) // @0x4a003c: a tap is FREE
|
||||
return;
|
||||
|
||||
int n = Round(dmg->damageAmount * 2.0f); // @0x4a0051 (FUN_004dcd94)
|
||||
if (n < 1)
|
||||
n = 1;
|
||||
dmg->damageAmount /= (Scalar)n; // @0x4a0065
|
||||
|
||||
if (getenv("BT_DMG_LOG"))
|
||||
DEBUG_STREAM << "[colldmg] rattle " << (dmg->damageAmount * n)
|
||||
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
|
||||
<< std::endl << std::flush;
|
||||
|
||||
extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
|
||||
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
|
||||
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
|
||||
for (int i = 0; i < n; ++i) // @0x4a006b
|
||||
{
|
||||
Scalar threshold = RandomUnit(); // @0x4a007d (FUN_00408050)
|
||||
Scalar acc = 0.0f;
|
||||
for (int b = 0; b < GetSubsystemCount(); ++b) // @0x4a0124 (count @+0x124)
|
||||
{
|
||||
Subsystem *s = GetSubsystem(b); // roster @+0x128
|
||||
if (s == 0)
|
||||
continue;
|
||||
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
|
||||
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
|
||||
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
|
||||
continue;
|
||||
acc += ((MechSubsystem *)s)->CollisionCritWeight(); // @0x4a00f2 (+0x10C)
|
||||
if (acc < threshold) // @0x4a010a
|
||||
continue;
|
||||
((MechSubsystem *)s)->ApplyDamageAndMeasure(*dmg); // @0x4a0117 (FUN_004ac07c)
|
||||
break; // @0x4a0121 -> next sub-hit
|
||||
}
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
void
|
||||
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
|
||||
{
|
||||
@@ -825,7 +1063,51 @@ void
|
||||
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
||||
}
|
||||
|
||||
//
|
||||
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
|
||||
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
|
||||
// arms the whole death tail -- kill report, VehicleDead, death blast.
|
||||
// Capturing it after the divert (where #89 first placed it) meant a
|
||||
// COLLISION death could never arm the tail.
|
||||
//
|
||||
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
|
||||
//
|
||||
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
|
||||
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
|
||||
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
|
||||
// leak into them.
|
||||
//
|
||||
int reportZone = message->damageZone;
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
|
||||
//
|
||||
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
|
||||
// type 0 == the mover's collision damage (what the crash response policy
|
||||
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
|
||||
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
|
||||
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
|
||||
// the rattle still posts VehicleDead (the respawn trigger) and still
|
||||
// blasts. The early `return` this block shipped with was a latent
|
||||
// "wall-death strands the pilot" hazard. No score reports on this path --
|
||||
// the jump bypasses them; a collision death credits no one. Authentic.
|
||||
//
|
||||
if (message->damageData.damageType == 0)
|
||||
{
|
||||
DistributeCollisionDamage(&message->damageData);
|
||||
goto death_tail;
|
||||
}
|
||||
// #92: when this gate fails the zone keeps whatever the message carried --
|
||||
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
static int s_g = 0;
|
||||
if (s_g++ < 40)
|
||||
DEBUG_STREAM << "[cylgate] invalidZone=" << (int)message->invalidDamageZone
|
||||
<< " table=" << (void *)table
|
||||
<< " incomingZone=" << message->damageZone
|
||||
<< " type=" << (int)message->damageData.damageType << std::endl;
|
||||
}
|
||||
if (message->invalidDamageZone && table != 0)
|
||||
{
|
||||
int zone = table->ResolveHit(message->damageData.impactPoint);
|
||||
@@ -839,7 +1121,93 @@ void
|
||||
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
|
||||
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
|
||||
//
|
||||
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
|
||||
// REPLACES the engine-base single application. Three things the base
|
||||
// never did:
|
||||
// 1. BURSTS: the base applied the Damage once and ignored burstCount
|
||||
// entirely (DamageZone::TakeDamage never reads it) -- multi-burst
|
||||
// damage under-applied by (burstCount-1)x.
|
||||
// 2. PER-BURST ZONE RE-ROLL: with bursts remaining, the binary re-runs
|
||||
// the cylinder lottery from the same impact point (@0x4a04b9), so a
|
||||
// burst SPRAYS across zones -- authentic scatter.
|
||||
// 3. THE CRIT ROLL (@0x4a0450): per burst, on the current zone -- gated
|
||||
// on the zone not already burning, on the player's simLive flag
|
||||
// (novice never crits), and on chance = clamp(0.7*lvl^2 + 0.01, 0..1)
|
||||
// rising quadratically as the zone's armour strips. A landed crit
|
||||
// REPLACES the zone application for that burst (CriticalHit @0049ccc4
|
||||
// already routes half the amount through the armour internally).
|
||||
//
|
||||
// The engine base's -1 guard is preserved: an unresolvable zone applies
|
||||
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
|
||||
// happen there because every mech ships a lookup table).
|
||||
//
|
||||
if (damageZones != 0 && message->damageZone >= 0
|
||||
&& message->damageZone < damageZoneCount)
|
||||
{
|
||||
int zoneIndex = message->damageZone; // local_20
|
||||
Scalar damageTally = 0.0f; // local_24 (the id-0x16 report tally)
|
||||
int zoneDestroyed = 0; // local_2c
|
||||
int burstsLeft = message->damageData.burstCount; // local_28
|
||||
if (burstsLeft < 1)
|
||||
burstsLeft = 1; // port guard (binary trusts >= 1)
|
||||
|
||||
for (;;)
|
||||
{
|
||||
Mech__DamageZone *zone =
|
||||
(Mech__DamageZone *)damageZones[zoneIndex]; // this[0x120][idx]
|
||||
Subsystem *critted = 0;
|
||||
|
||||
if (zone->GetDamageZoneState() != DamageZone::BurningState // zone state != 1
|
||||
&& BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below)
|
||||
{
|
||||
critted = zone->CriticalHit(message->damageData); // @0049ccc4
|
||||
if (critted != 0)
|
||||
{
|
||||
damageTally +=
|
||||
((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108
|
||||
if (BTEnvOn("BT_CRIT_LOG", 0))
|
||||
DEBUG_STREAM << "[critroll] zone=" << zoneIndex
|
||||
<< " -> " << (critted->GetName() ? critted->GetName() : "?")
|
||||
<< " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel()
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (critted == 0)
|
||||
zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488
|
||||
|
||||
damageTally += message->damageData.damageAmount; // +0x30
|
||||
if (zone->GetDamageZoneState() == DamageZone::BurningState)
|
||||
zoneDestroyed = 1;
|
||||
|
||||
if (--burstsLeft == 0)
|
||||
break;
|
||||
DamageLookupTable *tbl = (DamageLookupTable *)damageLookupTable;
|
||||
if (tbl != 0) // per-burst re-roll
|
||||
zoneIndex = tbl->ResolveHit(message->damageData.impactPoint);
|
||||
if (zoneIndex < 0 || zoneIndex >= damageZoneCount)
|
||||
break; // port guard
|
||||
if (IsDisabled()) // @0x4a04cc -- stop once dead
|
||||
break;
|
||||
}
|
||||
message->damageZone = zoneIndex; // matchlog sees the LAST zone
|
||||
|
||||
//
|
||||
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
|
||||
// the kill / inflicted / received score reports to the shooter's and
|
||||
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
|
||||
// TU, per the databinding rule); it receives the APPLIED tally, the
|
||||
// vital-wreck flag, and the loop-ENTRY zone. This retires
|
||||
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
|
||||
//
|
||||
{
|
||||
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
|
||||
BTMechPostCombatReports((void *)this, (void *)message,
|
||||
(float)damageTally, zoneDestroyed, reportZone,
|
||||
deathBlastArmed && IsMechDestroyed());
|
||||
}
|
||||
}
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
||||
// application -- one line per applied TakeDamage with the resolved zone
|
||||
@@ -867,6 +1235,45 @@ void
|
||||
(float)message->damageData.impactPoint.y,
|
||||
(float)message->damageData.impactPoint.z);
|
||||
}
|
||||
|
||||
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
|
||||
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
|
||||
// is dead(9)/eject(10) now. Sequence:
|
||||
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
|
||||
// carrying killed-by player + kill zone) -> the mech's OWN player:
|
||||
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
|
||||
// previously read this build as "SetBurningState (mech id 0x17)" --
|
||||
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
|
||||
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
|
||||
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
|
||||
// this is the decoded sender the mech4 transition site's
|
||||
// [T3 sender-undecoded] flag was waiting for.
|
||||
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
|
||||
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
|
||||
// death list from the death transition instead (#42 history): same
|
||||
// once-per-death edge, not double-spawned.
|
||||
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
|
||||
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
|
||||
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
|
||||
// impact=mech origin,
|
||||
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
|
||||
// radius=deathSplashRadius@0x524, excluded=the dying mech;
|
||||
// falloff bursts/dist^1.25 per victim inside the shared core.
|
||||
// 4. ForceUpdate(1).
|
||||
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
|
||||
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
|
||||
// the death transition sets mode 9 synchronously with the structural flag
|
||||
// on every path through here, so the edges coincide; this exact predicate
|
||||
// is the one the #89 blast benches verified both ways.
|
||||
death_tail:
|
||||
if (deathBlastArmed && IsMechDestroyed())
|
||||
{
|
||||
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||
|
||||
extern void BTApplyDeathSplash(void *mech_v);
|
||||
BTApplyDeathSplash((void *)this);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -1036,6 +1443,8 @@ Mech::Mech(
|
||||
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
|
||||
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
|
||||
rearFiring = 0; // (task #68) ORed from the weapons below
|
||||
ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
|
||||
ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
|
||||
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
|
||||
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
|
||||
// is retired; distanceToMissile (init below) owns the slot's meaning.
|
||||
@@ -1551,8 +1960,11 @@ Mech::Mech(
|
||||
|
||||
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
|
||||
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
|
||||
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
|
||||
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
|
||||
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
|
||||
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
|
||||
// members -- the Wword bank is
|
||||
// GLOBAL and clobbered per-mech
|
||||
// values chassis-to-chassis)
|
||||
Wword(0x195) = Wword(0x196) = 0;
|
||||
Wword(0xfd) = 0;
|
||||
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
|
||||
@@ -1706,6 +2118,9 @@ Mech::Mech(
|
||||
ramLastVictim = 0; // ram contact-edge state
|
||||
ramContactLinger = 0.0f;
|
||||
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
||||
mapPosture = 0; // binary @0x3f8 (the ctor zero-block @0x4b3d18 zeroes the band)
|
||||
myomerEffectiveness = 1.0f; // binary @0x79c: 0-init + per-frame MAX; the port holds the
|
||||
// healthy 1.0 until the Myomers Performance feeder lands [T3]
|
||||
if (GroundReal() && GetCollisionVolumeCount() > 0
|
||||
&& collisionTemplate != 0 && collisionVolume != 0)
|
||||
{
|
||||
@@ -1949,6 +2364,9 @@ Mech::~Mech()
|
||||
|
||||
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
|
||||
BTDeregisterMech((Entity *)this);
|
||||
extern void BTProjectilesDropEntity(void *e); // #93: scrub the STATIC projectile
|
||||
BTProjectilesDropEntity(this); // pool -- a round in flight must not
|
||||
// outlive its target/shooter pointer
|
||||
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
|
||||
BTUnstashClipState(this);
|
||||
|
||||
@@ -2062,9 +2480,25 @@ Logical
|
||||
// airborne 3,4) tracks the master automatically; the case-0 / tail edge
|
||||
// tests below then see the old->current transition.
|
||||
//
|
||||
// (#78 history: a "gimp-monotonic record guard" briefly lived here, patching
|
||||
// incoming records that carried a pre-gimp simulationState. It was treating a
|
||||
// symptom -- the real cause was the mech's own per-frame SetMovementMode(1) in
|
||||
// PerformAndWatch erasing the gimp level from that one cell every frame, found
|
||||
// with the [simstomp] trap and fixed at source (mech4.cpp). The guard is gone
|
||||
// because on a REPLICANT it would have been actively harmful: the master's
|
||||
// records are authoritative, so pinning 3/4 against them would have kept a
|
||||
// peer's mech limping forever through a respawn.)
|
||||
|
||||
void
|
||||
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
||||
{
|
||||
// #108 ghost forensics: stamp the staleness detector on every applied
|
||||
// record (replicants only -- the detector lives on the death handler).
|
||||
if (GetInstance() == ReplicantInstance)
|
||||
{
|
||||
extern void BTMechNoteUpdateApplied(void *mech_v);
|
||||
BTMechNoteUpdateApplied((void *)this);
|
||||
}
|
||||
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
||||
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
||||
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
||||
|
||||
+95
-11
@@ -357,7 +357,11 @@ struct ShotDescriptor
|
||||
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
|
||||
|
||||
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
|
||||
Logical GetMissionReviewMode(); // reads this+0x414 (btl4mppr.cpp:366)
|
||||
// ejectPermitted (@0x414) accessor -- the panic-arm watcher's read
|
||||
// (btl4mppr InterpretControls @004d196c). [The old GetMissionReviewMode
|
||||
// here was a mislabel of the same cell; the real review mode is the
|
||||
// GLOBAL DAT_004fd550 (btl4pb.cpp). Corrected 2026-08-03.]
|
||||
int GetEjectPermitted() const { return ejectPermitted; }
|
||||
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
|
||||
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
|
||||
|
||||
@@ -650,6 +654,12 @@ public:
|
||||
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
||||
// after the layout-locked fields so nothing shifts.
|
||||
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
|
||||
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
|
||||
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
|
||||
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
|
||||
// were silently clobbered to the last-loaded chassis).
|
||||
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
|
||||
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
|
||||
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
|
||||
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
|
||||
|
||||
@@ -739,6 +749,15 @@ protected:
|
||||
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
|
||||
// mech carries a rear arsenal". The look-back view arms those weapons.
|
||||
int rearFiring; // @0x410 this[0x104]
|
||||
int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
|
||||
// 1 = crippled enough that the PANIC/EJECT
|
||||
// punch-out is allowed (binary refreshes per
|
||||
// frame from an unexported caller; the port
|
||||
// evaluates on demand in the 0x19 handler)
|
||||
int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
|
||||
// clause. NO writer in the exported decomp --
|
||||
// zero-filled in the binary, so the clause is
|
||||
// inert there too [T3]; init 0 to match.
|
||||
// (task #68) the authored look-view angles (model record +0x50..0x5c,
|
||||
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
|
||||
// parked in the Wword scratch bank). Consumed by the look-state
|
||||
@@ -1013,14 +1032,31 @@ protected:
|
||||
Scalar gimpStrideLength; // @0x350
|
||||
AlarmIndicator legStateAlarm; // @0x39c
|
||||
int legAnimationState; // @0x3b0
|
||||
int mapPosture; // binary @0x3f8 -- the map-legend/duck posture arbiter
|
||||
// (0 none / 1 may-duck / 2 holding-squat), computed per
|
||||
// master frame (FUN_004a9b5c @0x4a9f61-0x4aa007; the
|
||||
// CROUCH reconstruction, 2026-08-05)
|
||||
Scalar myomerEffectiveness; // binary @0x79c -- the master-perf myomer chain MAX
|
||||
// (speedEffect@0x31C over the myomer subsystems),
|
||||
// published LIVE each mapper update by mechmppr.cpp's
|
||||
// drive-scale block (the 2026-07-31 seek-audit recon of
|
||||
// the same @0x4a9cf2-0x4a9da4 bytes). Consumed by the
|
||||
// crouch posture gate (mech4). Ctor 1.0 = pre-first-
|
||||
// update default only.
|
||||
Scalar gimpSpeedMax; // @0x52c
|
||||
Scalar standSpeed; // @0x530
|
||||
Scalar walkStrideLength; // @0x534
|
||||
Scalar reverseSpeedMax; // @0x538
|
||||
Scalar jumpRunSpeedMax; // @0x53c
|
||||
Scalar jumpWalkSpeedMax; // @0x540
|
||||
Scalar jumpRunStrideLength; // @0x544
|
||||
Scalar jumpWalkStrideLength; // @0x548
|
||||
// GIMP (limp-gait) measurements, filled by the conditional 'wgl' loader
|
||||
// block (FUN_004a80d4 raw :13705-13733). "Left/Right" = WHICH LEG IS
|
||||
// GIMPED (graphicAlarm level 3 = left, 4 = right). Speeds are the wg
|
||||
// entry clips' final keyframe strides; strides are MeasureClipStride
|
||||
// over the gg cycle clips. (These four + hasGimpClips@0x580 were
|
||||
// mislabeled jump* -- there is no jump-jet clip set.)
|
||||
Scalar gimpLeftSpeedMax; // @0x53c wgl final stride (mode-3 speed cap)
|
||||
Scalar gimpRightSpeedMax; // @0x540 wgr final stride (mode-4 speed cap)
|
||||
Scalar gimpLeftStrideLength; // @0x544 ggr cycle stride (mode 3 cycles state 0x18)
|
||||
Scalar gimpRightStrideLength; // @0x548 ggl cycle stride (mode 4 cycles state 0x19)
|
||||
Scalar walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master
|
||||
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
|
||||
Scalar groundCycleRate; // @0x5b8
|
||||
@@ -1035,9 +1071,9 @@ protected:
|
||||
Scalar globalTimeScale; // @0x5a8
|
||||
Scalar idleStrideScale; // @0x5ac
|
||||
Scalar gimpCycleRate; // @0x5b0
|
||||
int jumpCapable; // @0x580
|
||||
int hasReverseGimpSet; // @0x584
|
||||
int hasCrashSet; // @0x588
|
||||
int hasGimpClips; // @0x580 set iff the model ships the wg/gg/gs clip set
|
||||
int squatCapable; // @0x584 set iff squ/sqd ship
|
||||
int turnCapable; // @0x588 set iff trn ships
|
||||
int deathAnimationLatched; // @0x650
|
||||
int legResetLatch; // @0x654
|
||||
int bodyResetLatch; // @0x658
|
||||
@@ -1084,13 +1120,25 @@ protected:
|
||||
Scalar LegTransition(int next_state, Scalar adv_time, int move_joints);
|
||||
Scalar AdvanceLegAnimation(Scalar time_slice); // @004a5028
|
||||
Scalar AdvanceBodyAnimation(Scalar time_slice, int loop);
|
||||
Scalar AdvanceBodyAnimationAirborne(Scalar time_slice, int loop);
|
||||
Scalar AdvanceLegAnimationAirborne(Scalar time_slice);
|
||||
Scalar AdvanceBodyAnimationGimp(Scalar time_slice, int loop); // @004a5bf8 (was "Airborne" -- it's the LIMP driver)
|
||||
Scalar AdvanceLegAnimationGimp(Scalar time_slice); // @004a71f4
|
||||
// GIMP finished-callbacks: BodyClipFinished/LegClipFinished tail-branch
|
||||
// into these when (gimpLevel==3||4) && hasGimpClips. Same transition
|
||||
// machine as the normal cbs plus: a top clamp of the demand to the
|
||||
// gimp speed cap, phase-correct wg entries from the walk cases
|
||||
// (left-gimp enters 0x16/wgl from a RIGHT step, right-gimp 0x17/wgr
|
||||
// from a LEFT step -- forcing one extra normal step if mid-wrong-foot),
|
||||
// the gg cycles 0x18/0x19 at gimp cadence, and gs exits 0x1a/0x1b.
|
||||
Scalar GimpBodyClipFinished(Scalar carryover, int move_joints); // FUN_004a6344
|
||||
Scalar GimpLegClipFinished(Scalar carryover); // FUN_004a7970
|
||||
Logical IsDisabled(); // FUN_0049fb54
|
||||
// --- death sequence (the un-exported master-perf death branch, rebuilt
|
||||
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
|
||||
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
|
||||
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
|
||||
// --- #108 ghost-forensics accessors (PORT; the fields are protected) ---
|
||||
void *DeathHandlerPtr() { return (void *)deathHandler; }
|
||||
int WreckBuried() const { return collisionVolumeCount == 0; }
|
||||
|
||||
// --- clip loaders (mech3) -------------------------------------------
|
||||
ResourceDescription::ResourceID *
|
||||
@@ -1119,7 +1167,15 @@ protected:
|
||||
NotationFile *model_file, const char *model_name,
|
||||
NotationFile *model_notation,
|
||||
const ResourceDirectories *directories);
|
||||
static SharedData *
|
||||
// Returns the BASE shared-data type: the subsystem blocks are
|
||||
// Simulation__SharedData and only Mech's own is the derived
|
||||
// Entity__SharedData (ENTITY3.h:9 -- Entity__SharedData : public
|
||||
// Simulation::SharedData), so the common type of the mixed table is
|
||||
// the Simulation one. Declaring this as Mech::SharedData made every
|
||||
// `&<Subsystem>::DefaultData` stub in mech3.cpp mangle to a symbol
|
||||
// that does not exist -- ~20 unresolved externals /FORCE silently
|
||||
// resolved to garbage (reconstruction-gotchas #3).
|
||||
static Simulation::SharedData *
|
||||
SubsystemDefaultData(const char *type_name);
|
||||
|
||||
// --- simulation / damage (mech4) ------------------------------------
|
||||
@@ -1174,6 +1230,11 @@ protected:
|
||||
// Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed
|
||||
// (invalidDamageZone) hit's zone via the cylinder table, then base-route.
|
||||
void TakeDamageMessageHandler(TakeDamageMessage *message);
|
||||
// @0049ffcc (#83): the COLLISION-damage distributor the handler's
|
||||
// damageType==0 divert (@0x4a0368) routes to -- prices the mover's
|
||||
// kinetic figure vs a 100 km/h reference and lands 0.5-point rattle
|
||||
// crits on weighted random internal subsystems. Never touches armor.
|
||||
void DistributeCollisionDamage(Damage *dmg);
|
||||
// Mech override of Entity::PlayerLinkMessageHandler (@0049f624): the
|
||||
// base resolves mech->player; BT adds the REVERSE link
|
||||
// player->playerVehicle = this on EVERY node (replicants included --
|
||||
@@ -1209,6 +1270,29 @@ protected:
|
||||
enum { DuckRequestMessageID = 0x1a };
|
||||
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
|
||||
// @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
|
||||
// EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
|
||||
// 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
|
||||
// ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
|
||||
// console of the eject (FUN_004c198c ctor -> network sender; port core
|
||||
// LOGS it, the relay wire is the tail) and latch the player's
|
||||
// suppressConsole (+0x258) so the death that follows doesn't double-
|
||||
// notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
|
||||
// then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
|
||||
// Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
|
||||
// byte-verified via the role reader @00429bec dest offsets + the ctor
|
||||
// record copy), burst 0 (binary; our victim guard clamps to 1 --
|
||||
// identical outcome: the charge is lethal)}. The punch-out IS a
|
||||
// death: wreck, respawn cycle and replication all ride the normal
|
||||
// damage chain.
|
||||
enum { EjectPilotMessageID = 0x19 };
|
||||
void EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
// @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
|
||||
// roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
|
||||
// generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
|
||||
// (leg-gimped state 3/4 AND novice, player simLive == 0).
|
||||
int EvaluateEjectPermission();
|
||||
|
||||
// --- damage-routing support (mechdmg / mech4) -----------------------
|
||||
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
|
||||
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
|
||||
|
||||
+460
-54
@@ -19,8 +19,8 @@
|
||||
//
|
||||
// @004a5028 Mech::AdvanceLegAnimation (1543 bytes)
|
||||
// @004a5678 Mech::AdvanceBodyAnimation (1333 bytes)
|
||||
// @004a5bf8 Mech::AdvanceBodyAnimationAirborne(1792 bytes)
|
||||
// @004a71f4 Mech::AdvanceLegAnimationAirborne (1840 bytes)
|
||||
// @004a5bf8 Mech::AdvanceBodyAnimationGimp(1792 bytes)
|
||||
// @004a71f4 Mech::AdvanceLegAnimationGimp (1840 bytes)
|
||||
//
|
||||
// The embedded assert path on every one of them is
|
||||
// "d:\tesla\bt\bt\MECH2.CPP"
|
||||
@@ -90,6 +90,7 @@
|
||||
//
|
||||
|
||||
#include <bt.hpp>
|
||||
#include <intrin.h> // _ReturnAddress -- the #82 trn-armer trap
|
||||
#include <AUDCMP.hpp> // AudioComponent -- the foot-plant step-intensity broadcast
|
||||
#include <AUDSRC.hpp> // AudioSource -- footstep-source identification
|
||||
#include <AUDLVL.hpp> // AudioResource::GetAudioLevelOfDetail
|
||||
@@ -180,10 +181,10 @@ enum MechAnimationState
|
||||
// @0x530 standSpeed "at rest" / minimum move speed threshold
|
||||
// @0x534 walkStrideLength forward walk/run clip length (also speed cap)
|
||||
// @0x538 reverseSpeedMax speed cap while decelerating into Reverse (?)
|
||||
// @0x53c jumpRunSpeedMax airborne speed cap, movementMode==3
|
||||
// @0x540 jumpWalkSpeedMax airborne speed cap, otherwise
|
||||
// @0x544 jumpRunStrideLength airborne clip length, movementMode==3
|
||||
// @0x548 jumpWalkStrideLength airborne clip length, otherwise
|
||||
// @0x53c gimpLeftSpeedMax airborne speed cap, movementMode==3
|
||||
// @0x540 gimpRightSpeedMax airborne speed cap, otherwise
|
||||
// @0x544 gimpLeftStrideLength airborne clip length, movementMode==3
|
||||
// @0x548 gimpRightStrideLength airborne clip length, otherwise
|
||||
// @0x598 motionEventName (string) cleared to "" on fall/reset
|
||||
// @0x5a4 motionEventArmed (int) reset to 0 on fall/reset
|
||||
// @0x5a8 globalTimeScale multiplies every clip increment
|
||||
@@ -219,6 +220,16 @@ enum MechAnimationState
|
||||
void
|
||||
Mech::SetLegAnimation(int state)
|
||||
{
|
||||
// DIAG (BT_DUCK_LOG): every leg re-arm with the caller's return address --
|
||||
// the crouch-clobber hunt (who re-arms after the squat parks?).
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
{
|
||||
char dbuf[96];
|
||||
sprintf(dbuf, "[duck] SetLegAnimation(%d) ra=btl4+0x%lx", state,
|
||||
(unsigned long)_ReturnAddress()
|
||||
- (unsigned long)GetModuleHandleA(0));
|
||||
DEBUG_STREAM << dbuf << std::endl << std::flush;
|
||||
}
|
||||
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
|
||||
animationClips[state], // *(this+0x5cc + state*4)
|
||||
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
|
||||
@@ -250,6 +261,39 @@ void
|
||||
// StateIndicator::SetState's Verify(state<stateCount). [T2]
|
||||
if (state >= 0 && state < 0x21)
|
||||
{
|
||||
// DIAG (BT_TRNTRAP): who arms state 4 on a REPLICANT? Module-relative
|
||||
// ra (symbolize: tools/symcrash.py) -- the #82 trn-lock armer hunt.
|
||||
if (state == 4 && getenv("BT_TRNTRAP")
|
||||
&& GetInstance() == ReplicantInstance)
|
||||
{
|
||||
static int s_tt = 0;
|
||||
if (s_tt++ < 60)
|
||||
{
|
||||
char ttbuf[96];
|
||||
sprintf(ttbuf, "[trntrap] mech=%d ra=btl4+0x%lx",
|
||||
(int)GetEntityID(),
|
||||
(unsigned long)_ReturnAddress()
|
||||
- (unsigned long)GetModuleHandleA(0));
|
||||
DEBUG_STREAM << ttbuf << std::endl << std::flush;
|
||||
}
|
||||
}
|
||||
// #78 audio-flake diag: print the LIVE indicator address once per mech
|
||||
// so a session's [attrbind] ptr can be checked against it (stale-bind
|
||||
// hypothesis: watchers bound to a recreated mech's dead indicator).
|
||||
if (getenv("BT_AUDIO_SPATIAL")) { static int s_ai=0;
|
||||
// BT_ANIMIND_CAP: the 200-print budget hid every post-1-minute state
|
||||
// (two #82 investigations mis-read the silence as "never entered") --
|
||||
// raise it for state-timeline diagnosis.
|
||||
static int s_aiCap = -1;
|
||||
if (s_aiCap < 0) { const char *c = getenv("BT_ANIMIND_CAP"); s_aiCap = (c && *c) ? atoi(c) : 200; }
|
||||
if (s_ai++ < 6 || (state >= 22 && state <= 27 && s_ai < s_aiCap)
|
||||
|| (s_aiCap > 200 && s_ai < s_aiCap))
|
||||
DEBUG_STREAM << "[animind] mech=" << (int)GetEntityID()
|
||||
<< " &animationState=" << (void*)&animationState
|
||||
<< " inst=" << (int)(GetInstance() == ReplicantInstance)
|
||||
<< " state=" << state
|
||||
<< " audioWatchers=" << animationState.DebugAudioWatcherCount()
|
||||
<< "\n" << std::flush; }
|
||||
animationState.SetState(state);
|
||||
replicantAnimationState.SetState(state);
|
||||
}
|
||||
@@ -313,10 +357,16 @@ Scalar
|
||||
Scalar
|
||||
Mech::BodyClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
||||
{
|
||||
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a6344 (deferred; safe
|
||||
// no-op while grounded -- the test mech never jumps).
|
||||
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable)
|
||||
return 0.0f;
|
||||
// GIMP branch (FUN_004a6d8c top): a limping mech's body transitions run the
|
||||
// gimp machine instead. Mode = the graphicAlarm level (the binary's real
|
||||
// mech+0x40: 3=left-leg gimp, 4=right) read via the mechdmg bridge --
|
||||
// NEVER graphicAlarm directly here (AlarmIndicator ODR split, gotcha #23).
|
||||
{
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
|
||||
const int gl = BTMechGimpLevel(m);
|
||||
if ((gl == 3 || gl == 4) && m->hasGimpClips)
|
||||
return m->GimpBodyClipFinished(carryover, mj);
|
||||
}
|
||||
|
||||
const Scalar fcr = m->forwardCycleRate; // 0x344
|
||||
const Scalar gts = m->globalTimeScale; // 0x5a8
|
||||
@@ -454,9 +504,14 @@ Scalar
|
||||
Scalar
|
||||
Mech::LegClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
||||
{
|
||||
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a7970 (deferred).
|
||||
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable)
|
||||
return 0.0f;
|
||||
// GIMP branch (FUN_004a6928 top): route a limping mech's leg transitions
|
||||
// into the gimp machine (mode = graphicAlarm level via bridge, gotcha #23).
|
||||
{
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
|
||||
const int gl = BTMechGimpLevel(m);
|
||||
if ((gl == 3 || gl == 4) && m->hasGimpClips)
|
||||
return m->GimpLegClipFinished(carryover);
|
||||
}
|
||||
|
||||
// The binary reads edx = *(mech+0x128) then [edx]+0x128: subsystemArray[0]
|
||||
// (the roster's ControlsMapper slot 0) -> speedDemand; null (no mapper)
|
||||
@@ -475,6 +530,8 @@ Scalar
|
||||
return 0.0f;
|
||||
// slot10 state 2 (0x4a6b37): SetLevel(1).
|
||||
case 2:
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] squat clip parked (case 2 -> leg alarm 1)\n" << std::flush;
|
||||
m->legStateAlarm.SetLevel(1); return 0.0f;
|
||||
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
|
||||
case 3: case 4: case 8: case 9: case 20: case 21:
|
||||
@@ -546,6 +603,250 @@ Scalar
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// GimpBodyClipFinished / GimpLegClipFinished (#78 visible limp)
|
||||
//
|
||||
// @004a6344 (body) / @004a7970 (leg) -- the GIMP transition machines, entered
|
||||
// from the normal finished-callbacks when (gimpLevel 3|4) && hasGimpClips.
|
||||
// Same walk/run/reverse alternation as the normal cbs, PLUS:
|
||||
// - a top clamp of the demand to the gimped leg's speed cap (body: clamps
|
||||
// bodyTargetSpeed@0x6b4; leg: clamps the LIVE mapper speedDemand and
|
||||
// writes it back -- the binary's own "you can't outrun a shot leg");
|
||||
// - PHASE-CORRECT limp entry from the walk cases: left-gimp (mode 3) enters
|
||||
// 0x16/wgl only from a RIGHT step (5/6/0xe) -- the L case forces one more
|
||||
// normal R step first; right-gimp (mode 4) mirrors into 0x17/wgr from the
|
||||
// L case (7/0xf). The limp always starts on the correct foot.
|
||||
// - the gg cycles 0x16/0x18 (left, ggr clip, stride @0x544) and 0x17/0x19
|
||||
// (right, ggl, @0x548), continuing at gimp cadence or exiting through the
|
||||
// gs transitions 0x1a/0x1b when the demand dies;
|
||||
// - NO standing->reverse entry exists in the gimp machines (or drivers):
|
||||
// the binary itself refuses REVERSE while gimped.
|
||||
// Mode is the graphicAlarm level via the mechdmg bridge (gotcha #23).
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
Scalar
|
||||
Mech::GimpBodyClipFinished(Scalar carryover, int mj)
|
||||
{
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
|
||||
const int mode = BTMechGimpLevel(this); // binary: this+0x40
|
||||
const int state = bodyAnimationState; // 0x728
|
||||
const Scalar fcr = forwardCycleRate; // 0x344
|
||||
const Scalar gts = globalTimeScale; // 0x5a8
|
||||
|
||||
// Top clamp (0x4a6352): while in a moving cycle, cap the body target speed
|
||||
// to the gimped side's entry-clip speed, then floor at zero.
|
||||
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||
|| (unsigned)(state - 0x12) < 2)
|
||||
{
|
||||
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
|
||||
if (bodyTargetSpeed > cap) bodyTargetSpeed = cap;
|
||||
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
|
||||
}
|
||||
|
||||
const Scalar cyc = bodyCycleSpeed; // 0x6b8
|
||||
const Scalar tgt = bodyTargetSpeed; // 0x6b4 (post-clamp)
|
||||
const Scalar T = carryover * gts; // plain end-tail time
|
||||
int next;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
default: // 0/1 + unmapped: parked
|
||||
return 0.0f;
|
||||
case 2:
|
||||
bodyStateAlarm.SetLevel(1); return 0.0f;
|
||||
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
|
||||
case 0x1a: case 0x1b: case 0x20:
|
||||
bodyStateAlarm.SetLevel(0); return 0.0f;
|
||||
|
||||
// -- walk-R (0x4a6440): states 5,6,0xe -- the left-gimp entry point --
|
||||
case 5: case 6: case 0xe:
|
||||
{
|
||||
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||
if (!cont) { next = 9; break; } // walk->stand L
|
||||
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||
if (up) { next = 0xb; break; } // toward run (dead once clamped)
|
||||
int alt = 7; // normal alternation -> wwl
|
||||
if (mode == 4) alt = 7; // right-gimp: one more normal step
|
||||
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
|
||||
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj); // LAB_004a6505
|
||||
}
|
||||
// -- walk-L (0x4a63ff): states 7,0xf -- the right-gimp entry point --
|
||||
case 7: case 0xf:
|
||||
{
|
||||
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||
if (!cont) { next = 8; break; } // walk->stand R
|
||||
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||
if (up) { next = 0xa; break; }
|
||||
int alt = 6; // normal alternation -> wwr
|
||||
if (mode == 3) alt = 6; // left-gimp: force back to the R step
|
||||
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
|
||||
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj);
|
||||
}
|
||||
// -- run cycles (0x4a65f7 / 0x4a6663): 10/12 <-> 11/13 --
|
||||
case 0xa: case 0xc:
|
||||
{
|
||||
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||
if (cont) // LAB_004a6648 run-cadence tail
|
||||
return BodyTransition(0xd, carryover * cyc * gts / reverseStrideLength, mj);
|
||||
next = 0xf; break; // run->walk R
|
||||
}
|
||||
case 0xb: case 0xd:
|
||||
{
|
||||
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||
if (cont)
|
||||
return BodyTransition(0xc, carryover * cyc * gts / reverseStrideLength, mj);
|
||||
next = 0xe; break;
|
||||
}
|
||||
// -- back cycles (0x4a66d5 / 0x4a6754): 0x10/0x12 <-> 0x11/0x13 --
|
||||
case 0x10: case 0x12:
|
||||
{
|
||||
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||
if (up) { next = 0x15; break; } // back->stand L
|
||||
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||
if (t <= 0.0f) t = -t; // 0x350 stored negative
|
||||
return BodyTransition(0x13, t, mj);
|
||||
}
|
||||
case 0x11: case 0x13:
|
||||
{
|
||||
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||
if (up) { next = 0x14; break; }
|
||||
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||
if (t <= 0.0f) t = -t;
|
||||
return BodyTransition(0x12, t, mj);
|
||||
}
|
||||
// -- GIMP cycles (0x4a67cf / 0x4a6836): left 0x16/0x18, right 0x17/0x19 --
|
||||
case 0x16: case 0x18:
|
||||
{
|
||||
bool cont = (tgt >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
|
||||
if (cont) // keep limping (ggr cycle)
|
||||
return BodyTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, mj);
|
||||
next = 0x1a; break; // demand died -> gsl exit
|
||||
}
|
||||
case 0x17: case 0x19:
|
||||
{
|
||||
bool cont = (tgt >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
|
||||
if (cont)
|
||||
return BodyTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, mj);
|
||||
next = 0x1b; break; // -> gsr exit
|
||||
}
|
||||
}
|
||||
return BodyTransition(next, T, mj); // shared plain tail
|
||||
}
|
||||
|
||||
Scalar
|
||||
Mech::GimpLegClipFinished(Scalar carryover)
|
||||
{
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
|
||||
const int mode = BTMechGimpLevel(this); // binary: this+0x40
|
||||
const int state = legAnimationState; // 0x3b0
|
||||
MechControlsMapper *mppr = MappingMapper(); // **(this+0x128)
|
||||
|
||||
// Top clamp (0x4a7995): cap the LIVE COMMANDED speedDemand itself while in
|
||||
// a moving cycle, writing the clamp back -- this is the binary's authentic
|
||||
// gimp slowdown (the mapper re-derives demand each tick; this cb re-caps
|
||||
// it every clip end, and the gimp cycle cadence below enforces it anyway).
|
||||
if (((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||
|| (unsigned)(state - 0x12) < 2) && mppr != 0)
|
||||
{
|
||||
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
|
||||
if (mppr->speedDemand > cap) mppr->speedDemand = cap;
|
||||
if (mppr->speedDemand < ZeroSpeed) mppr->speedDemand = ZeroSpeed;
|
||||
}
|
||||
|
||||
const Scalar spd = (mppr != 0) ? mppr->speedDemand : 0.0f; // (binary derefs; port guards null)
|
||||
const Scalar fcr = forwardCycleRate; // 0x344
|
||||
const Scalar gts = globalTimeScale; // 0x5a8
|
||||
const Scalar cyc = legCycleSpeed; // 0x348
|
||||
const Scalar T = carryover * gts;
|
||||
int next;
|
||||
|
||||
switch (state) // all leg tails mj=1 (binary)
|
||||
{
|
||||
default:
|
||||
return 0.0f;
|
||||
case 2:
|
||||
legStateAlarm.SetLevel(1); return 0.0f;
|
||||
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
|
||||
case 0x1a: case 0x1b: case 0x20:
|
||||
legStateAlarm.SetLevel(0); return 0.0f;
|
||||
|
||||
// -- walk-R (0x4a7a52): states 5,6,0xe -- left-gimp entry --
|
||||
case 5: case 6: case 0xe:
|
||||
{
|
||||
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||
if (!cont) { next = 9; break; }
|
||||
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||
if (up) { next = 0xb; break; }
|
||||
int alt = 7;
|
||||
if (mode == 4) alt = 7; // right-gimp: one more normal step
|
||||
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
|
||||
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1); // LAB_004a7b38
|
||||
}
|
||||
// -- walk-L (0x4a7bb5): states 7,0xf -- right-gimp entry --
|
||||
case 7: case 0xf:
|
||||
{
|
||||
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||
if (!cont) { next = 8; break; }
|
||||
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||
if (up) { next = 0xa; break; }
|
||||
int alt = 6;
|
||||
if (mode == 3) alt = 6; // left-gimp: force back to the R step
|
||||
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
|
||||
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1);
|
||||
}
|
||||
// -- run cycles (0x4a7c33 / 0x4a7c93): --
|
||||
case 0xa: case 0xc:
|
||||
{
|
||||
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||
if (cont) // LAB_004a7c79
|
||||
return LegTransition(0xd, carryover * cyc * gts / reverseStrideLength, 1);
|
||||
next = 0xf; break;
|
||||
}
|
||||
case 0xb: case 0xd:
|
||||
{
|
||||
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||
if (cont)
|
||||
return LegTransition(0xc, carryover * cyc * gts / reverseStrideLength, 1);
|
||||
next = 0xe; break;
|
||||
}
|
||||
// -- back cycles: 0x10/0x12 <-> 0x11/0x13 --
|
||||
case 0x10: case 0x12:
|
||||
{
|
||||
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||
if (up) { next = 0x15; break; }
|
||||
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||
if (t <= 0.0f) t = -t;
|
||||
return LegTransition(0x13, t, 1);
|
||||
}
|
||||
case 0x11: case 0x13:
|
||||
{
|
||||
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||
if (up) { next = 0x14; break; }
|
||||
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||
if (t <= 0.0f) t = -t;
|
||||
return LegTransition(0x12, t, 1);
|
||||
}
|
||||
// -- GIMP cycles: left 0x16/0x18, right 0x17/0x19 --
|
||||
case 0x16: case 0x18:
|
||||
{
|
||||
bool cont = (spd >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
|
||||
if (cont)
|
||||
return LegTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, 1);
|
||||
next = 0x1a; break;
|
||||
}
|
||||
case 0x17: case 0x19:
|
||||
{
|
||||
bool cont = (spd >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
|
||||
if (cont)
|
||||
return LegTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, 1);
|
||||
next = 0x1b; break;
|
||||
}
|
||||
}
|
||||
return LegTransition(next, T, 1); // shared plain tail (mj=1)
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// AdvanceLegAnimation (channel A, ground)
|
||||
@@ -567,8 +868,35 @@ Scalar
|
||||
// alias (an AV); controlsMapper is the typed mirror of roster slot 0. A mech
|
||||
// with no mapper reads demand 0 (idles) -- matching a zeroed binary roster.
|
||||
MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7)
|
||||
// REPLICANT DEMAND FEED (#82 final root, 2026-07-30): a replicant's LOCAL
|
||||
// mapper cell is a dead 0 (input never drives it), so every demand
|
||||
// threshold in this machine failed on peers -- from STAND the walk-begin
|
||||
// could never fire and the trn state churned on the turn signal forever
|
||||
// ("lifting legs in an alternating turning fashion"). It never mattered
|
||||
// while a peer was mid-cycle (clip-end chains keep cycles going, which is
|
||||
// why straight-line/grass limpers replicated fine) -- it bit the moment a
|
||||
// KNOCKDOWN recovery dropped the replicant to STAND. The binary replicant
|
||||
// reads a LIVE demand here (its mapper cell replicates with the subsystem
|
||||
// records); the port's replicated equivalent is bodyTargetSpeed (@0x6b4 --
|
||||
// every update record's speedDemand writes it on RX). [gimpfeed]-probe
|
||||
// proof: AdvanceLegAnimationGimp never even runs on replicants, so THIS
|
||||
// driver is the peer's one and only leg machine.
|
||||
Scalar commandedSpeed =
|
||||
(mppr != 0) ? mppr->speedDemand : 0.0f;
|
||||
(GetInstance() == ReplicantInstance) ? bodyTargetSpeed
|
||||
: (mppr != 0) ? mppr->speedDemand : 0.0f;
|
||||
// DIAG (BT_GIMPFEED): 1 Hz -- the NORMAL driver is the only leg machine a
|
||||
// replicant runs; print every threshold operand it sees.
|
||||
if (getenv("BT_GIMPFEED") && GetInstance() == ReplicantInstance)
|
||||
{
|
||||
static Scalar s_gfAcc = 0.0f; s_gfAcc += time_slice;
|
||||
if (s_gfAcc >= 1.0f) { s_gfAcc = 0.0f;
|
||||
DEBUG_STREAM << "[gimpfeed] cmd=" << commandedSpeed
|
||||
<< " bts=" << bodyTargetSpeed
|
||||
<< " standSpeed=" << standSpeed
|
||||
<< " state=" << (int)legStateAlarm.GetLevel()
|
||||
<< " gl=" << ([](Mech *m){ extern int BTMechGimpLevel(void*); return BTMechGimpLevel(m); })(this)
|
||||
<< std::endl << std::flush; }
|
||||
}
|
||||
Scalar distance = 0.0f;
|
||||
|
||||
// binary: legAnimationState@0x3b0 IS legStateAlarm's level (one field; the
|
||||
@@ -664,7 +992,7 @@ Scalar
|
||||
// narrow near-zero entry gate (the pre-#64b accommodation). [T3]
|
||||
const int trnIsRepl = (GetInstance() == ReplicantInstance);
|
||||
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
|
||||
if (hasCrashSet != 0 && mppr != 0
|
||||
if (turnCapable != 0 && mppr != 0
|
||||
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
|
||||
&& (mppr->turnDemand > 0.05f
|
||||
|| mppr->turnDemand < -0.05f)
|
||||
@@ -985,7 +1313,15 @@ Scalar
|
||||
// re-enter walk on the same frame.
|
||||
{
|
||||
MechControlsMapper *bm = MappingMapper();
|
||||
const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f;
|
||||
// REPLICANT: the local mapper's speedDemand is a dead cell (nothing
|
||||
// writes it on a peer -- reads 0 forever, so a peer that entered trn
|
||||
// could NEVER take the speed exit = the #82 trn-lock). The peer's
|
||||
// commanded speed is the REPLICATED demand (bodyTargetSpeed@0x6b4,
|
||||
// stamped by every record RX) -- same source the peer Standing case
|
||||
// walks on, so entry and exit judge the same number.
|
||||
const Scalar bspd = (GetInstance() == ReplicantInstance)
|
||||
? bodyTargetSpeed
|
||||
: (bm != 0) ? bm->speedDemand : 0.0f;
|
||||
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
|
||||
{
|
||||
bodyStateAlarm.SetLevel(0);
|
||||
@@ -1112,38 +1448,47 @@ Scalar
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// AdvanceBodyAnimationAirborne (channel B, jump-capable)
|
||||
// AdvanceBodyAnimationGimp (channel B, limping)
|
||||
//
|
||||
// @004a5bf8 (MECH2.CPP:0x2E8)
|
||||
//
|
||||
// Airborne variant of AdvanceBodyAnimation selected by Mech::IntegrateMotion
|
||||
// when jump jets are active. Adds a pre-clamp of bodyTargetSpeed to the jump
|
||||
// speed limits and handles the FallForward / FallBackward jump cycles
|
||||
// (states 0x18/0x19); gimp-to-stand (0x16/0x17) and the lateral falls
|
||||
// (0x1a/0x1b) join the normal advance group here rather than resetting.
|
||||
// GIMP (limp-gait) variant of AdvanceBodyAnimation, selected when
|
||||
// (gimpLevel 3|4) && hasGimpClips ("Airborne"/jump-jet was a misread -- the
|
||||
// clip set is wg/gg/gs, #78). Adds a pre-clamp of bodyTargetSpeed to the
|
||||
// gimped side's speed cap and drives the gg limp cycles (0x18 left / 0x19
|
||||
// right) at gimp cadence; the wg entries (0x16/0x17) and gs exits (0x1a/0x1b)
|
||||
// join the plain advance group. Standing (case 0) only ever enters FORWARD
|
||||
// walk -- the binary refuses reverse while gimped.
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
Scalar
|
||||
Mech::AdvanceBodyAnimationAirborne(Scalar time_slice, int loop)
|
||||
Mech::AdvanceBodyAnimationGimp(Scalar time_slice, int loop)
|
||||
{
|
||||
Scalar distance = 0.0f;
|
||||
|
||||
// RE-SYNC alarm -> state member (the binary's one cell is split in the
|
||||
// recon, same as the ground drivers at :831/:1181). Without this the
|
||||
// member freezes at its pre-gimp value the moment this driver takes over
|
||||
// and the whole machine pins in one state (live-diagnosed 2026-07-30).
|
||||
bodyAnimationState = (int)bodyStateAlarm.GetLevel();
|
||||
|
||||
//
|
||||
// While in any forward/reverse/gimp *moving* cycle, clamp the target to
|
||||
// the jump speed limit for the current gait, then floor at zero.
|
||||
// the gimp speed limit for the current gait, then floor at zero.
|
||||
//
|
||||
{
|
||||
int state = bodyAnimationState; // this+0x728
|
||||
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||
|| (unsigned)(state - 0x12) < 2)
|
||||
{
|
||||
if (MovementMode() == 3) // run jump
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
|
||||
if (BTMechGimpLevel(this) == 3) // left leg gimped
|
||||
{
|
||||
if (bodyTargetSpeed > jumpRunSpeedMax) bodyTargetSpeed = jumpRunSpeedMax; // 0x53c
|
||||
if (bodyTargetSpeed > gimpLeftSpeedMax) bodyTargetSpeed = gimpLeftSpeedMax; // 0x53c
|
||||
}
|
||||
else // walk jump
|
||||
{
|
||||
if (bodyTargetSpeed > jumpWalkSpeedMax) bodyTargetSpeed = jumpWalkSpeedMax; // 0x540
|
||||
if (bodyTargetSpeed > gimpRightSpeedMax) bodyTargetSpeed = gimpRightSpeedMax; // 0x540
|
||||
}
|
||||
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
|
||||
}
|
||||
@@ -1259,7 +1604,8 @@ Scalar
|
||||
case 0x18: case 0x19: // FallForward / FallBackward (jump)
|
||||
{
|
||||
Scalar ratio;
|
||||
if (MovementMode() == 3) // run jump: caps 0x53c / 0x544
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
|
||||
if (BTMechGimpLevel(this) == 3) // left-gimp cycle: caps 0x53c / 0x544
|
||||
{
|
||||
if (bodyTargetSpeed <= bodyCycleSpeed)
|
||||
{
|
||||
@@ -1267,16 +1613,16 @@ Scalar
|
||||
{
|
||||
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
||||
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed < jumpRunSpeedMax) bodyCycleSpeed = jumpRunSpeedMax; // 0x53c
|
||||
if (bodyCycleSpeed < gimpLeftSpeedMax) bodyCycleSpeed = gimpLeftSpeedMax; // 0x53c
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyCycleSpeed += forwardCycleRate * time_slice;
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > jumpRunStrideLength) bodyCycleSpeed = jumpRunStrideLength; // 0x544
|
||||
if (bodyCycleSpeed > gimpLeftStrideLength) bodyCycleSpeed = gimpLeftStrideLength; // 0x544
|
||||
}
|
||||
ratio = bodyCycleSpeed / jumpRunStrideLength; // 0x544
|
||||
ratio = bodyCycleSpeed / gimpLeftStrideLength; // 0x544
|
||||
}
|
||||
else // walk jump: caps 0x540 / 0x548
|
||||
{
|
||||
@@ -1286,16 +1632,16 @@ Scalar
|
||||
{
|
||||
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
||||
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed < jumpWalkSpeedMax) bodyCycleSpeed = jumpWalkSpeedMax; // 0x540
|
||||
if (bodyCycleSpeed < gimpRightSpeedMax) bodyCycleSpeed = gimpRightSpeedMax; // 0x540
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyCycleSpeed += forwardCycleRate * time_slice;
|
||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||
if (bodyCycleSpeed > jumpWalkStrideLength) bodyCycleSpeed = jumpWalkStrideLength; // 0x548
|
||||
if (bodyCycleSpeed > gimpRightStrideLength) bodyCycleSpeed = gimpRightStrideLength; // 0x548
|
||||
}
|
||||
ratio = bodyCycleSpeed / jumpWalkStrideLength; // 0x548
|
||||
ratio = bodyCycleSpeed / gimpRightStrideLength; // 0x548
|
||||
}
|
||||
distance = bodyAnimation.Advance(
|
||||
time_slice * ratio * globalTimeScale, loop);
|
||||
@@ -1315,24 +1661,62 @@ Scalar
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// AdvanceLegAnimationAirborne (channel A, jump-capable)
|
||||
// AdvanceLegAnimationGimp (channel A, limping)
|
||||
//
|
||||
// @004a71f4 (MECH2.CPP:0x672)
|
||||
//
|
||||
// Airborne variant of AdvanceLegAnimation. Like the ground version it reads
|
||||
// the live commanded speed from the controls subsystem, but here it also
|
||||
// CLAMPS that source value to the jump speed limit (writing it back), and
|
||||
// handles the FallForward / FallBackward jump cycles (0x18/0x19). No death
|
||||
// latch / footstep block; gimp-to-stand and lateral falls join the normal
|
||||
// advance group.
|
||||
// GIMP variant of AdvanceLegAnimation (see the body variant's note). Like
|
||||
// the ground version it reads the live commanded speed from the controls
|
||||
// subsystem, but here it also CLAMPS that source value to the gimped side's
|
||||
// speed cap (writing it back -- the authentic "can't outrun a shot leg"),
|
||||
// and drives the gg limp cycles (0x18/0x19). No death latch / footstep
|
||||
// block; wg entries and gs exits join the normal advance group.
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
Scalar
|
||||
Mech::AdvanceLegAnimationAirborne(Scalar time_slice)
|
||||
Mech::AdvanceLegAnimationGimp(Scalar time_slice)
|
||||
{
|
||||
ReconMotionSource *motionSource = *(ReconMotionSource **)(this->controlSource); // **(this+0x128)
|
||||
// Binary: **(this+0x128) + 0x128 = the mapper's live speedDemand. The
|
||||
// port's controlSource@0x128 is NOT wired (null -> the first live gimp
|
||||
// engagement crashed here, 2026-07-30 bench) -- use the roster idiom the
|
||||
// ground driver + LegClipFinished use (MappingMapper), pointing the shim
|
||||
// straight at the speedDemand cell.
|
||||
MechControlsMapper *gimpMppr = MappingMapper();
|
||||
static Scalar s_nullDemand = 0.0f; // no mapper -> demand 0, writes inert
|
||||
// REPLICANT DEMAND FEED (#82 final root, 2026-07-30): a replicant's LOCAL
|
||||
// mapper demand is a dead 0 (no input ever drives it), so every speed
|
||||
// threshold in this machine read 0 on peers -- in particular the trn exit
|
||||
// (standSpeed < commandedSpeed) could NEVER fire, and a gimped replicant
|
||||
// that staggered through stand->trn was LOCKED in turn-in-place forever
|
||||
// (the observed "lifting legs in an alternating turning fashion" skate;
|
||||
// unblinded [animind] timeline: SIX transitions in 3 minutes, ending at
|
||||
// state 4). The binary's replicant reads a LIVE demand here because the
|
||||
// mapper's speedDemand cell replicates with the subsystem records; the
|
||||
// port's replicated equivalent of that same value is bodyTargetSpeed
|
||||
// (@0x6b4 -- every update record's speedDemand writes it on RX). Feed
|
||||
// the machine from it on replicants; the gimp caps that write back
|
||||
// through the source are transient there (the next record rewrites it),
|
||||
// and masters keep the authentic live mapper cell.
|
||||
ReconMotionSource *motionSource =
|
||||
(GetInstance() == ReplicantInstance)
|
||||
? (ReconMotionSource *)&bodyTargetSpeed
|
||||
: gimpMppr
|
||||
? (ReconMotionSource *)&gimpMppr->speedDemand
|
||||
: (ReconMotionSource *)&s_nullDemand;
|
||||
Scalar distance = 0.0f;
|
||||
int mode = MovementMode(); // this+0x40 = simulationState
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
|
||||
int mode = BTMechGimpLevel(this); // binary this+0x40 = gimp level
|
||||
// DIAG (BT_GIMPFEED): 1 Hz -- what demand does this machine actually see?
|
||||
if (getenv("BT_GIMPFEED") && GetInstance() == ReplicantInstance)
|
||||
{
|
||||
static Scalar s_gfAcc = 0.0f; s_gfAcc += time_slice;
|
||||
if (s_gfAcc >= 1.0f) { s_gfAcc = 0.0f;
|
||||
DEBUG_STREAM << "[gimpfeed] repl cmd=" << motionSource->commandedSpeed
|
||||
<< " bts=" << bodyTargetSpeed << " state=" << legAnimationState
|
||||
<< " standSpeed=" << standSpeed << " mode=" << mode << std::endl << std::flush; }
|
||||
}
|
||||
// RE-SYNC alarm -> state member (see AdvanceBodyAnimationGimp note).
|
||||
legAnimationState = (int)legStateAlarm.GetLevel();
|
||||
int state = legAnimationState; // this+0x3b0
|
||||
|
||||
//
|
||||
@@ -1344,13 +1728,13 @@ Scalar
|
||||
{
|
||||
if (mode == 3)
|
||||
{
|
||||
if (motionSource->commandedSpeed > jumpRunSpeedMax)
|
||||
motionSource->commandedSpeed = jumpRunSpeedMax; // 0x53c
|
||||
if (motionSource->commandedSpeed > gimpLeftSpeedMax)
|
||||
motionSource->commandedSpeed = gimpLeftSpeedMax; // 0x53c
|
||||
}
|
||||
else
|
||||
{
|
||||
if (motionSource->commandedSpeed > jumpWalkSpeedMax)
|
||||
motionSource->commandedSpeed = jumpWalkSpeedMax; // 0x540
|
||||
if (motionSource->commandedSpeed > gimpRightSpeedMax)
|
||||
motionSource->commandedSpeed = gimpRightSpeedMax; // 0x540
|
||||
}
|
||||
if (motionSource->commandedSpeed < ZeroSpeed)
|
||||
motionSource->commandedSpeed = ZeroSpeed;
|
||||
@@ -1375,6 +1759,28 @@ Scalar
|
||||
}
|
||||
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
|
||||
{
|
||||
// TURN-IN-PLACE while GIMPED (#78 field find, 2026-07-29: "rotating
|
||||
// statue"). The binary's trn dispatcher (FUN_004a9b5c, master perf)
|
||||
// runs OUTSIDE the driver selection, so it arms the turn step for
|
||||
// gimped mechs too -- 71f4's case 4 exists to advance it. The port
|
||||
// relocated that dispatcher into the NORMAL driver's Standing case,
|
||||
// which stops running the moment this driver takes over. Mirror it
|
||||
// (same gates + lockstep body arm; no reverse entry here -- the gimp
|
||||
// machines refuse reverse).
|
||||
const int trnIsRepl2 = (GetInstance() == ReplicantInstance);
|
||||
const Scalar trnEntryMax2 = trnIsRepl2 ? standSpeed * 0.25f : standSpeed;
|
||||
if (turnCapable != 0 && gimpMppr != 0
|
||||
&& motionSource->commandedSpeed >= ZeroSpeed
|
||||
&& motionSource->commandedSpeed <= trnEntryMax2
|
||||
&& (gimpMppr->turnDemand > 0.05f
|
||||
|| gimpMppr->turnDemand < -0.05f)
|
||||
&& (trnIsRepl2 || bodyAnimationState == StandingAnimation))
|
||||
{
|
||||
SetLegAnimation(4); // trn, channel A
|
||||
if (!trnIsRepl2)
|
||||
SetBodyAnimation(4); // lockstep, masters
|
||||
goto advance_normally;
|
||||
}
|
||||
distance = 0.0f;
|
||||
break;
|
||||
}
|
||||
@@ -1477,16 +1883,16 @@ Scalar
|
||||
{
|
||||
legCycleSpeed -= forwardCycleRate * time_slice;
|
||||
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||
if (legCycleSpeed < jumpRunSpeedMax) legCycleSpeed = jumpRunSpeedMax;
|
||||
if (legCycleSpeed < gimpLeftSpeedMax) legCycleSpeed = gimpLeftSpeedMax;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
legCycleSpeed += forwardCycleRate * time_slice;
|
||||
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||
if (legCycleSpeed > jumpRunStrideLength) legCycleSpeed = jumpRunStrideLength;
|
||||
if (legCycleSpeed > gimpLeftStrideLength) legCycleSpeed = gimpLeftStrideLength;
|
||||
}
|
||||
ratio = legCycleSpeed / jumpRunStrideLength; // 0x544
|
||||
ratio = legCycleSpeed / gimpLeftStrideLength; // 0x544
|
||||
}
|
||||
else // walk jump
|
||||
{
|
||||
@@ -1496,16 +1902,16 @@ Scalar
|
||||
{
|
||||
legCycleSpeed -= forwardCycleRate * time_slice;
|
||||
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||
if (legCycleSpeed < jumpWalkSpeedMax) legCycleSpeed = jumpWalkSpeedMax;
|
||||
if (legCycleSpeed < gimpRightSpeedMax) legCycleSpeed = gimpRightSpeedMax;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
legCycleSpeed += forwardCycleRate * time_slice;
|
||||
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||
if (legCycleSpeed > jumpWalkStrideLength) legCycleSpeed = jumpWalkStrideLength;
|
||||
if (legCycleSpeed > gimpRightStrideLength) legCycleSpeed = gimpRightStrideLength;
|
||||
}
|
||||
ratio = legCycleSpeed / jumpWalkStrideLength; // 0x548
|
||||
ratio = legCycleSpeed / gimpRightStrideLength; // 0x548
|
||||
}
|
||||
distance = legAnimation.Advance(time_slice * ratio * globalTimeScale, 1);
|
||||
}
|
||||
|
||||
@@ -93,19 +93,36 @@
|
||||
// Reconstruction stand-ins LOCAL to this translation unit.
|
||||
// The offline "Subsystems" authoring path dispatches each streamed component
|
||||
// to its subsystem class's static CreateStreamedSubsystem / DefaultData.
|
||||
// Those ~21 classes live in sibling modules not visible here (and PPC /
|
||||
// GaussRifle / SubsystemMessageManager have no reconstructed class at all),
|
||||
// so the uniform factory surface is declared here as minimal stubs. The
|
||||
// bodies/data are resolved at link against the real subsystem modules; these
|
||||
// declarations only let the dispatch compile. Kept LOCAL so they never
|
||||
// collide with the real sibling classes.
|
||||
// Those ~21 classes live in sibling modules not visible here, so the uniform
|
||||
// factory surface is declared here as minimal stubs. The bodies/data are
|
||||
// resolved at link against the real subsystem modules; these declarations
|
||||
// only let the dispatch compile. Kept LOCAL so they never collide with the
|
||||
// real sibling classes.
|
||||
// ⚠ The DefaultData member TYPE must be Simulation__SharedData -- the type
|
||||
// the real Subsystem-family definitions have (every sibling declares it via
|
||||
// the inherited SharedData typedef, dumpbin: ?DefaultData@X@@2VSimulation__
|
||||
// SharedData@@A). Declaring it as Mech::SharedData (== Entity__SharedData)
|
||||
// mangles to a DIFFERENT symbol, and /FORCE turned all ~20 of the resulting
|
||||
// unresolved externals into silent garbage pointers instead of link errors
|
||||
// (reconstruction-gotchas #3). Cold in-game (this is the tool path), but a
|
||||
// landmine for the authoring tools. FIXED for the DefaultData statics.
|
||||
// ⚠ STILL UNRESOLVED (known, cold): the CreateStreamedSubsystem stubs below
|
||||
// do NOT match the real modules' signatures -- the real ones take the
|
||||
// class's NESTED <X>::SubsystemResource * (arg 4) and a trailing
|
||||
// ResourceFile * (e.g. mechtech.cpp:407), where these stubs guessed
|
||||
// void* / int. All 20 factory references therefore stay unresolved under
|
||||
// /FORCE. Harmless while the offline authoring dispatch has no callers,
|
||||
// but before wiring a tool through CreateSubsystemStream these must become
|
||||
// per-module BRIDGE functions in complete-type TUs (the project's bridge
|
||||
// convention) -- the nested resource types make direct stubs impossible.
|
||||
// Tracked in context/open-questions.md.
|
||||
//===========================================================================//
|
||||
typedef Mech::SharedData SharedData; // namespace-scope alias (== Entity__SharedData)
|
||||
|
||||
// 7-arg (non-weapon) streamed-subsystem factory shape.
|
||||
#define STREAMED_SUBSYS(NAME) \
|
||||
struct NAME { \
|
||||
static SharedData DefaultData; \
|
||||
static Simulation__SharedData DefaultData; \
|
||||
static int CreateStreamedSubsystem(NotationFile *, const char *, \
|
||||
const char *, void *, NotationFile *, \
|
||||
const ResourceDirectories *, int); \
|
||||
@@ -113,7 +130,7 @@ typedef Mech::SharedData SharedData; // namespace-scope alias (== Entity__Shared
|
||||
// 8-arg (weapon) streamed-subsystem factory shape (leading pass counter).
|
||||
#define STREAMED_WEAPON(NAME) \
|
||||
struct NAME { \
|
||||
static SharedData DefaultData; \
|
||||
static Simulation__SharedData DefaultData; \
|
||||
static int CreateStreamedSubsystem(int, NotationFile *, \
|
||||
const char *, const char *, void *, NotationFile *, \
|
||||
const ResourceDirectories *, int); \
|
||||
@@ -141,13 +158,13 @@ STREAMED_WEAPON(AmmoBin);
|
||||
// MechTech is forward-declared in mech.hpp; complete it (non-weapon shape).
|
||||
struct MechTech
|
||||
{
|
||||
static SharedData DefaultData;
|
||||
static Simulation__SharedData DefaultData;
|
||||
static int CreateStreamedSubsystem(NotationFile *, const char *,
|
||||
const char *, void *, NotationFile *,
|
||||
const ResourceDirectories *, int);
|
||||
};
|
||||
// MechControlsMapper is forward-declared in mech.hpp; only its DefaultData is used.
|
||||
struct MechControlsMapper { static SharedData DefaultData; };
|
||||
struct MechControlsMapper { static Simulation__SharedData DefaultData; };
|
||||
// SubsystemMessageManager: leading pass, no trailing index, no DefaultData.
|
||||
struct SubsystemMessageManager
|
||||
{
|
||||
@@ -212,13 +229,13 @@ template<class...A> inline void *FUN_00406db4(A&&...) { return 0; } // ResourceF
|
||||
// @0x530 standSpeed = last-keyframe stride of the stand->walk clip
|
||||
// @0x534 walkStrideLength = (s_6+s_7)/(d_6+d_7) (forward walk/run)
|
||||
// @0x538 reverseSpeedMax = last-keyframe stride of the reverse-base clip
|
||||
// @0x53c jumpRunSpeedMax = last-keyframe stride of the run-jump clip
|
||||
// @0x540 jumpWalkSpeedMax = last-keyframe stride of the walk-jump clip
|
||||
// @0x544 jumpRunStrideLength = s/d of fall-forward jump clip (slot 0x18)
|
||||
// @0x548 jumpWalkStrideLength = s/d of fall-backward jump clip (slot 0x19)
|
||||
// @0x580 jumpCapable (int) set 1 iff the model defines the jump clip set
|
||||
// @0x584 hasReverseGimpSet (int)? set 1 iff the second optional clip set exists
|
||||
// @0x588 hasCrashSet (int)? set 1 iff the third optional clip set exists
|
||||
// @0x53c gimpLeftSpeedMax = last-keyframe stride of the run-jump clip
|
||||
// @0x540 gimpRightSpeedMax = last-keyframe stride of the walk-jump clip
|
||||
// @0x544 gimpLeftStrideLength = s/d of fall-forward jump clip (slot 0x18)
|
||||
// @0x548 gimpRightStrideLength = s/d of fall-backward jump clip (slot 0x19)
|
||||
// @0x580 hasGimpClips (int) set 1 iff the model defines the jump clip set
|
||||
// @0x584 squatCapable (int)? set 1 iff the second optional clip set exists
|
||||
// @0x588 turnCapable (int)? set 1 iff the third optional clip set exists
|
||||
// @0x5b8 groundCycleRate forward-cycle slew rate, on-ground (-> 0x344)
|
||||
// @0x5bc airborneCycleRate forward-cycle slew rate, airborne (-> 0x344)
|
||||
// @0x5c4 gyroRumbleTimer (float) reset to 0 at the top of a clip load
|
||||
@@ -319,7 +336,7 @@ void
|
||||
// stride @0x534, reverseSpeedMax @0x538, reverse stride @0x34c, gimpSpeedMax
|
||||
// @0x52c, gimp stride @0x350), then probe for the three OPTIONAL clip sets
|
||||
// (jump / reverse-gimp / crash) and, if their marker resource exists, resolve
|
||||
// those too and set the matching capability flag (jumpCapable @0x580 etc.).
|
||||
// those too and set the matching capability flag (hasGimpClips @0x580 etc.).
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
void
|
||||
@@ -406,10 +423,10 @@ void
|
||||
// ---- OPTIONAL clip set 1: JUMP JETS ----
|
||||
// Probe for the run-jump marker; if present, the model is jump-capable.
|
||||
//
|
||||
jumpCapable = 0; // this+0x580
|
||||
hasGimpClips = 0; // this+0x580
|
||||
if (ResolveAnimationClip(prefix, "wgl") != 0) // FUN_00406ff8 probe (real)
|
||||
{
|
||||
jumpCapable = 1;
|
||||
hasGimpClips = 1;
|
||||
|
||||
// SLOT MAP BINARY-VERIFIED (raw :13787-13812 + .data @0050d921-35):
|
||||
// wgl->0x624 [22], wgr->0x628 [23], ggr->0x62c [24], ggl->0x630 [25],
|
||||
@@ -417,19 +434,19 @@ void
|
||||
// shifted the rest one slot low.)
|
||||
animationClips[22] = *ResolveAnimationClip(prefix, "wgl"); // 0x624 state 0x16
|
||||
legAnimation.SelectSequence(animationClips[22], PTR_LAB_0050d738, DAT_0050d73c, DAT_0050d740);
|
||||
jumpRunSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
|
||||
gimpLeftSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
|
||||
|
||||
animationClips[23] = *ResolveAnimationClip(prefix, "wgr"); // 0x628 state 0x17
|
||||
legAnimation.SelectSequence(animationClips[23], PTR_LAB_0050d744, DAT_0050d748, DAT_0050d74c);
|
||||
jumpWalkSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
|
||||
gimpRightSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
|
||||
|
||||
animationClips[24] = *ResolveAnimationClip(prefix, "ggr"); // 0x62c state 0x18
|
||||
MeasureClipStride(0x18, &s0, &d0);
|
||||
jumpRunStrideLength = s0 / d0; // 0x544
|
||||
gimpLeftStrideLength = s0 / d0; // 0x544
|
||||
|
||||
animationClips[25] = *ResolveAnimationClip(prefix, "ggl"); // 0x630 state 0x19
|
||||
MeasureClipStride(0x19, &s0, &d0);
|
||||
jumpWalkStrideLength = s0 / d0; // 0x548
|
||||
gimpRightStrideLength = s0 / d0; // 0x548
|
||||
|
||||
animationClips[26] = *ResolveAnimationClip(prefix, "gsl"); // 0x634 state 0x1A
|
||||
animationClips[27] = *ResolveAnimationClip(prefix, "gsr"); // 0x638 state 0x1B
|
||||
@@ -443,10 +460,10 @@ void
|
||||
// suffixes/slots and stored into side members SetLeg/BodyAnimation never
|
||||
// consult -- states 2/3 read animationClips[] directly.)
|
||||
//
|
||||
hasReverseGimpSet = 0; // this+0x584 (squatCapable)
|
||||
squatCapable = 0; // this+0x584 (squatCapable)
|
||||
if (ResolveAnimationClip(prefix, "squ") != 0)
|
||||
{
|
||||
hasReverseGimpSet = 1;
|
||||
squatCapable = 1;
|
||||
animationClips[3] = *ResolveAnimationClip(prefix, "squ"); // 0x5d8
|
||||
animationClips[2] = *ResolveAnimationClip(prefix, "sqd"); // 0x5d4
|
||||
}
|
||||
@@ -457,14 +474,21 @@ void
|
||||
// flag @0x588 (turnCapable); loads trn -> 0x5dc == animationClips[4] --
|
||||
// the state-4 turn-in-place clip (SetLegAnimation(4) arms it).
|
||||
//
|
||||
hasCrashSet = 0; // this+0x588 (turnCapable)
|
||||
turnCapable = 0; // this+0x588 (turnCapable)
|
||||
if (ResolveAnimationClip(prefix, "trn") != 0)
|
||||
{
|
||||
hasCrashSet = 1;
|
||||
turnCapable = 1;
|
||||
animationClips[4] = *ResolveAnimationClip(prefix, "trn"); // 0x5dc
|
||||
}
|
||||
// (#82 dig 2026-07-30: this line used to print `gimpSpeedMax` @0x52c --
|
||||
// a field NOTHING in the binary reads (image-wide: no mech consumer) whose
|
||||
// negative back-cycle value sent the wall-grind investigation down a
|
||||
// "gimp cap is 5.8" red herring. Print the fields the gimp cycle ACTUALLY
|
||||
// caps on: gimpLeft/RightStrideLength = the gg clips' natural speeds,
|
||||
// the accel ceilings + ratio divisors @0x544/0x548.)
|
||||
DEBUG_STREAM << "[loadclips] end: fScale=" << forwardThrottleScale
|
||||
<< " gimpSpeedMax=" << gimpSpeedMax << " jumpCapable=" << jumpCapable << "\n" << std::flush;
|
||||
<< " ggCapL=" << gimpLeftStrideLength << " ggCapR=" << gimpRightStrideLength
|
||||
<< " hasGimpClips=" << hasGimpClips << "\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
@@ -549,25 +573,25 @@ void
|
||||
//
|
||||
// ---- OPTIONAL clip set 1: JUMP JETS (probe "wgli") ----
|
||||
//
|
||||
jumpCapable = 0; // this+0x580
|
||||
hasGimpClips = 0; // this+0x580
|
||||
if (ResolveAnimationClip(prefix, "wgli") != 0)
|
||||
{
|
||||
jumpCapable = 1;
|
||||
hasGimpClips = 1;
|
||||
animationClips[22] = *ResolveAnimationClip(prefix, "wgli"); // 0x624 state 0x16
|
||||
legAnimation.SelectSequence(animationClips[22], PTR_LAB_0050d738, DAT_0050d73c, DAT_0050d740);
|
||||
jumpRunSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
|
||||
gimpLeftSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
|
||||
|
||||
animationClips[23] = *ResolveAnimationClip(prefix, "wgri"); // 0x628 state 0x17
|
||||
legAnimation.SelectSequence(animationClips[23], PTR_LAB_0050d744, DAT_0050d748, DAT_0050d74c);
|
||||
jumpWalkSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
|
||||
gimpRightSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
|
||||
|
||||
animationClips[24] = *ResolveAnimationClip(prefix, "ggri"); // 0x62c state 0x18
|
||||
MeasureClipStride(0x18, &s0, &d0);
|
||||
jumpRunStrideLength = s0 / d0; // 0x544
|
||||
gimpLeftStrideLength = s0 / d0; // 0x544
|
||||
|
||||
animationClips[25] = *ResolveAnimationClip(prefix, "ggli"); // 0x630 state 0x19
|
||||
MeasureClipStride(0x19, &s0, &d0);
|
||||
jumpWalkStrideLength = s0 / d0; // 0x548
|
||||
gimpRightStrideLength = s0 / d0; // 0x548
|
||||
|
||||
animationClips[26] = *ResolveAnimationClip(prefix, "gsli"); // 0x634 state 0x1A
|
||||
animationClips[27] = *ResolveAnimationClip(prefix, "gsri"); // 0x638 state 0x1B
|
||||
@@ -576,10 +600,10 @@ void
|
||||
//
|
||||
// ---- OPTIONAL clip set 2: SQUAT (probe "squi") ----
|
||||
//
|
||||
hasReverseGimpSet = 0; // this+0x584 (squatCapable)
|
||||
squatCapable = 0; // this+0x584 (squatCapable)
|
||||
if (ResolveAnimationClip(prefix, "squi") != 0)
|
||||
{
|
||||
hasReverseGimpSet = 1;
|
||||
squatCapable = 1;
|
||||
animationClips[3] = *ResolveAnimationClip(prefix, "squi"); // 0x5d8
|
||||
animationClips[2] = *ResolveAnimationClip(prefix, "sqdi"); // 0x5d4
|
||||
}
|
||||
@@ -587,14 +611,14 @@ void
|
||||
//
|
||||
// ---- OPTIONAL clip set 3: TURN-IN-PLACE (probe "trni") ----
|
||||
//
|
||||
hasCrashSet = 0; // this+0x588 (turnCapable)
|
||||
turnCapable = 0; // this+0x588 (turnCapable)
|
||||
if (ResolveAnimationClip(prefix, "trni") != 0)
|
||||
{
|
||||
hasCrashSet = 1;
|
||||
turnCapable = 1;
|
||||
animationClips[4] = *ResolveAnimationClip(prefix, "trni"); // 0x5dc
|
||||
}
|
||||
DEBUG_STREAM << "[loadclips] INTERIOR ('i') set: fScale=" << forwardThrottleScale
|
||||
<< " walkStride=" << walkStrideLength << " jumpCapable=" << jumpCapable
|
||||
<< " walkStride=" << walkStrideLength << " hasGimpClips=" << hasGimpClips
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
@@ -905,7 +929,7 @@ fail:
|
||||
// CLASSMAP / the per-module reconstructions). Returns 0 for an unknown name.
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
/*static*/ SharedData *
|
||||
/*static*/ Simulation::SharedData *
|
||||
Mech::SubsystemDefaultData(const char *type_name)
|
||||
{
|
||||
if (Streq(type_name, "")) return &Mech::DefaultData; // 0050bde4 (DAT_0050dc1f == "")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user