Compare commits
91
Commits
@@ -43,3 +43,9 @@ 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
|
||||
|
||||
@@ -48,6 +48,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,
|
||||
@@ -65,6 +66,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 +87,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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -88,6 +88,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 +
|
||||
|
||||
+278
-29
@@ -69,6 +69,59 @@ 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.
|
||||
**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]
|
||||
@@ -279,17 +332,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 +361,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 +449,92 @@ 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, 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. Still deferred [T4→documented]: the `damageType==0`
|
||||
COLLISION divert (@0x4a0368 → `0x49ffcc`, its own distribution) and the **id-0x16 damage/kill
|
||||
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45:
|
||||
`{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly
|
||||
disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` +
|
||||
`[critroll]`), `BT_DMG_LOG`.
|
||||
|
||||
## (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`.
|
||||
@@ -721,10 +883,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 +922,80 @@ 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.
|
||||
|
||||
## 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.)
|
||||
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.
|
||||
|
||||
@@ -427,6 +427,7 @@ 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) |
|
||||
@@ -473,6 +474,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 +500,20 @@ 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
|
||||
`FUN_0041e4e0` (part_002.c:7341, reads the `material` key list per `[dz_*]` section); 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
|
||||
@@ -526,6 +549,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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -169,6 +169,19 @@ 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.
|
||||
|
||||
**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
|
||||
|
||||
+164
-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`
|
||||
@@ -253,3 +314,101 @@ Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/ti
|
||||
## 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).
|
||||
|
||||
+18
-2
@@ -282,6 +282,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 +688,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 +814,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
|
||||
|
||||
@@ -161,6 +161,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
|
||||
@@ -761,6 +787,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/`]:
|
||||
@@ -879,6 +924,48 @@ 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.
|
||||
|
||||
## Key Relationships
|
||||
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
||||
@@ -929,3 +1016,66 @@ 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`.
|
||||
|
||||
|
||||
@@ -208,6 +208,29 @@ 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").
|
||||
- **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** (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
|
||||
|
||||
@@ -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
|
||||
@@ -623,3 +634,164 @@ 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.
|
||||
|
||||
@@ -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]].
|
||||
|
||||
+62
-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,57 @@ 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 is `FUN_0041e4e0`.
|
||||
- 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.
|
||||
|
||||
## Key Relationships
|
||||
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
|
||||
- Renders on: [[pod-hardware]] (main 3D view).
|
||||
|
||||
+99
-13
@@ -66,7 +66,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 +154,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 +207,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,145 @@
|
||||
---
|
||||
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).
|
||||
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1198,6 +1209,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);
|
||||
@@ -1337,6 +1375,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);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,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;
|
||||
@@ -449,6 +451,91 @@ 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
|
||||
// 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
|
||||
@@ -545,7 +632,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 +650,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
|
||||
//###########################################################################
|
||||
@@ -1050,3 +1168,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1284,6 +1284,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 (issue #45: "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 +1333,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)
|
||||
{
|
||||
|
||||
+180
-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];
|
||||
@@ -8197,6 +8233,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 +8952,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 +9029,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 +9359,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);
|
||||
|
||||
@@ -619,6 +619,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;
|
||||
@@ -872,6 +875,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;
|
||||
@@ -1185,6 +1189,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)
|
||||
|
||||
@@ -106,6 +106,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;
|
||||
@@ -112,6 +118,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
|
||||
|
||||
+15
-3
@@ -1512,9 +1512,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 +1544,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)
|
||||
{
|
||||
|
||||
@@ -83,7 +83,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 +94,7 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
}
|
||||
|
||||
static int
|
||||
@@ -118,6 +120,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)
|
||||
{
|
||||
@@ -312,6 +322,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 +346,7 @@ int
|
||||
}
|
||||
currentLobby = created.m_ulSteamIDLobby;
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
||||
PublishSelf(pilot_name, vehicle, color);
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
if (RunRoom(1) != 0)
|
||||
@@ -403,6 +414,7 @@ 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)
|
||||
{
|
||||
@@ -438,7 +450,7 @@ int
|
||||
return -1;
|
||||
}
|
||||
currentLobby = lobby;
|
||||
PublishSelf(pilot_name, vehicle, color);
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
int result = RunRoom(0);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
//
|
||||
@@ -287,8 +293,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 +362,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +537,7 @@
|
||||
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)
|
||||
};
|
||||
|
||||
|
||||
@@ -537,6 +537,25 @@ HierarchicalDrawComponent*
|
||||
for (int op = 0; op < this_object->GetDrawOpCount(); ++op)
|
||||
this_object->GetDrawOp(op)->alphaTest = true;
|
||||
}
|
||||
|
||||
// #73: record the pickable segment geometry + its zone for the
|
||||
// aimed per-part pick (MechSegmentPick). The shadow proxy is not
|
||||
// a target; a rebuild resets the map with the tree.
|
||||
if (this_object != NULL && this_object->GetIsShadow() == 0)
|
||||
{
|
||||
MechRenderTree::SegPick sp;
|
||||
sp.obj = this_object;
|
||||
sp.zone = segment->GetPrimaryDamageZone(); // SEGMENT.h:107
|
||||
render_tree.segPick[segment->GetIndex()] = sp;
|
||||
if (getenv("BT_PICK_LOG"))
|
||||
DEBUG_STREAM << "[segpick] seg=" << segment->GetIndex()
|
||||
<< " '" << (const char *)segment->GetName()
|
||||
<< "' zone=" << sp.zone
|
||||
<< " r=" << this_object->mCullRadius
|
||||
<< " c=(" << this_object->mCullCenter.x << ","
|
||||
<< this_object->mCullCenter.y << ","
|
||||
<< this_object->mCullCenter.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -724,6 +743,11 @@ HierarchicalDrawComponent*
|
||||
<< (mEyeCockpit ? "" : " (COCKPIT EYE MISSING)") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// ARMOUR DARKENING (issue #87): the segment geometry is loaded and segPick is
|
||||
// populated, so the mech's .DZM zone->material lists can now be resolved onto
|
||||
// real draw ops. The binary does this in the same function.
|
||||
BindArmourDamage(entity, render_tree);
|
||||
|
||||
return this_root;
|
||||
}
|
||||
|
||||
@@ -833,9 +857,30 @@ void
|
||||
else if ((int)seg_gstate == DamageZone::GoneGraphicState)
|
||||
r->second->SetDrawObj(NULL);
|
||||
|
||||
// The swap replaced the DRAWN object, so the segment's recorded geometry
|
||||
// is now stale. Re-point it: the armour-damage bindings (issue #87) hold
|
||||
// draw-op addresses inside these objects and would otherwise keep tinting
|
||||
// the mesh that was just swapped out. (#73's aimed pick reads the same
|
||||
// map, so it stops ray-testing a discarded mesh too.)
|
||||
{
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp =
|
||||
render_tree.segPick.find(segment_slot);
|
||||
if (sp != render_tree.segPick.end())
|
||||
{
|
||||
if (new_object != NULL && new_object->GetIsShadow() == 0)
|
||||
sp->second.obj = new_object;
|
||||
else if ((int)seg_gstate == DamageZone::GoneGraphicState)
|
||||
render_tree.segPick.erase(sp); // blown off: no geometry to hit or tint
|
||||
}
|
||||
}
|
||||
|
||||
++swapped;
|
||||
}
|
||||
|
||||
// Rebind the .DZM armour materials onto whatever geometry is now drawn.
|
||||
if (swapped != 0)
|
||||
BindArmourDamage(entity, render_tree);
|
||||
|
||||
if (swapped != 0 || getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
|
||||
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
|
||||
@@ -1300,6 +1345,190 @@ int
|
||||
}
|
||||
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// ARMOUR DARKENING -- BindArmourDamage / TickArmourDamage (issue #87)
|
||||
//
|
||||
// The 1995 renderer darkened a mech's armour panels as they took damage, and it
|
||||
// did it through the MATERIALS, not through geometry. In MakeMechRenderables
|
||||
// (FUN_004cef28) it walked the mech's damage zones and, for every material that
|
||||
// zone paints, built a watcher (FUN_004573e4) holding {material, &zone->damageLevel,
|
||||
// 0.1}. The watcher snapshotted the material's sixteen authored colour floats
|
||||
// (ambient 3, emissive 3, diffuse 3, specular+shininess 4, opacity 3), precomputed
|
||||
// a DAMAGED set = pristine x 0.1, and on every change of the level (FUN_00457784)
|
||||
// re-pushed lerp(pristine, damaged, level) -- the constant at 0x4579a4 is 1.0.
|
||||
// (Faithful quirk: opacity is read and lerped but never written back -- only 13 of
|
||||
// the 16 values are pushed. We scale colour terms only, which matches.)
|
||||
//
|
||||
// The zone->material lists are the per-mech .DZM files (VIDEO\<mech>SKIN.DZM,
|
||||
// "[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl"), compiled into BTL4.RES and
|
||||
// already parsed by MUNGA's own DamageZone stream ctor into materialTable, keyed
|
||||
// by skeleton type (DAMAGE.cpp:311-336) -- the data has been loaded and unused all
|
||||
// along. What was missing was purely the consumer.
|
||||
//
|
||||
// Our draw path bakes material colour into each draw op's D3DMATERIAL9 at load, so
|
||||
// instead of holding material pointers we bind (draw object, op index) pairs here
|
||||
// once, and copy the live level onto them each frame; L4D3D applies the same lerp
|
||||
// at SetMaterial time. Binding by op index keeps the per-frame cost to a pointer
|
||||
// write -- no string compare in the frame loop.
|
||||
//
|
||||
void
|
||||
BTL4VideoRenderer::BindArmourDamage(Entity *entity, MechRenderTree &tree)
|
||||
{
|
||||
tree.dmgBinds.clear();
|
||||
if (entity == NULL)
|
||||
return;
|
||||
|
||||
const int log = (getenv("BT_ARMOR_LOG") != NULL);
|
||||
|
||||
for (int zone_index = 0; zone_index < entity->damageZoneCount; ++zone_index)
|
||||
{
|
||||
DamageZone *zone = entity->damageZones[zone_index];
|
||||
if (zone == NULL)
|
||||
continue;
|
||||
|
||||
// The .DZM list for the skeleton currently DISPLAYED -- not the one the
|
||||
// tree was built with. Each skeleton variant is painted from its own
|
||||
// skin library (the Black Hawk's N set is blhskin:, its X set blxskin:),
|
||||
// and ApplyViewSkeleton reloads every segment mesh when the view changes,
|
||||
// so keying on the build skeleton matches a material list belonging to
|
||||
// geometry that is not on screen -> zero bindings and no darkening.
|
||||
// A zone with no list for this skeleton simply never darkens, as on the pod.
|
||||
MaterialList *material_list =
|
||||
zone->GetMaterialList((Enumeration)tree.viewSkeleton);
|
||||
if (material_list == NULL)
|
||||
continue;
|
||||
|
||||
int zone_binds = 0;
|
||||
material_list->First();
|
||||
CString *material_name;
|
||||
while ((material_name = material_list->ReadAndNext()) != NULL)
|
||||
{
|
||||
const char *want = (const char *)*material_name;
|
||||
if (want == NULL || want[0] == '\0')
|
||||
continue;
|
||||
int name_hits = 0;
|
||||
|
||||
// Every non-shadow segment object of this mech is a candidate: one
|
||||
// zone's materials can span several segments (the torso zones paint
|
||||
// across the torso and door pieces), and one segment can carry
|
||||
// several zones' materials.
|
||||
for (std::map<int, MechRenderTree::SegPick>::iterator si = tree.segPick.begin();
|
||||
si != tree.segPick.end(); ++si)
|
||||
{
|
||||
d3d_OBJECT *obj = si->second.obj;
|
||||
if (obj == NULL)
|
||||
continue;
|
||||
for (int op = 0; op < obj->GetDrawOpCount(); ++op)
|
||||
{
|
||||
L4DRAWOP *draw_op = obj->GetDrawOp(op);
|
||||
if (draw_op->dzMatName[0] == '\0')
|
||||
continue;
|
||||
// Both sides are hand-authored; case does not agree on every mech.
|
||||
if (_stricmp(draw_op->dzMatName, want) != 0)
|
||||
continue;
|
||||
MechRenderTree::DmgBind bind;
|
||||
bind.obj = obj;
|
||||
bind.op = op;
|
||||
bind.zone = zone_index;
|
||||
tree.dmgBinds.push_back(bind);
|
||||
draw_op->dzDamageLevel = 0.0f;
|
||||
++zone_binds;
|
||||
++name_hits;
|
||||
}
|
||||
}
|
||||
// A .DZM name that matches NOTHING on the drawn geometry is the
|
||||
// failure mode worth naming: it means the material list and the mesh
|
||||
// disagree (wrong skeleton key, or a mech re-skinned since authoring),
|
||||
// and that zone silently stops darkening.
|
||||
if (log && name_hits == 0)
|
||||
DEBUG_STREAM << "[armor] unmatched '" << want << "' (zone "
|
||||
<< zone_index << ")\n" << std::flush;
|
||||
}
|
||||
if (log)
|
||||
DEBUG_STREAM << "[armor] zone " << zone_index << " '"
|
||||
<< (const char *)zone->damageZoneName << "' -> "
|
||||
<< zone_binds << " draw op(s)\n" << std::flush;
|
||||
}
|
||||
|
||||
if (log)
|
||||
DEBUG_STREAM << "[armor] entity " << (void *)entity << " skel="
|
||||
<< tree.skeletonType << ": " << tree.dmgBinds.size()
|
||||
<< " material binding(s)\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
BTL4VideoRenderer::TickArmourDamage(Entity *entity)
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator tree_it =
|
||||
mMechRenderTrees.find(entity);
|
||||
if (tree_it == mMechRenderTrees.end())
|
||||
return;
|
||||
MechRenderTree &tree = tree_it->second;
|
||||
if (tree.dmgBinds.empty())
|
||||
return;
|
||||
// A wrecked mech has been swapped to the <mech>dbr hulk -- the bound segment
|
||||
// objects are gone from the draw; leave them alone.
|
||||
if (tree.wrecked)
|
||||
return;
|
||||
|
||||
const int log = (getenv("BT_ARMOR_LOG") != NULL);
|
||||
|
||||
// BENCH (BT_ARMOR_FORCE=<0..1>): pin EVERY bound zone to a fixed level, so the
|
||||
// same scene can be rendered undamaged and fully-damaged and diffed pixel-wise
|
||||
// without smoke, motion or death confounding the comparison.
|
||||
static int s_forceInit = 0;
|
||||
static int s_forceOn = 0;
|
||||
static float s_forceLevel = 0.0f;
|
||||
if (!s_forceInit)
|
||||
{
|
||||
s_forceInit = 1;
|
||||
const char *fv = getenv("BT_ARMOR_FORCE");
|
||||
if (fv != NULL && *fv != '\0')
|
||||
{
|
||||
s_forceOn = 1;
|
||||
s_forceLevel = (float)atof(fv);
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < tree.dmgBinds.size(); ++i)
|
||||
{
|
||||
MechRenderTree::DmgBind &bind = tree.dmgBinds[i];
|
||||
if (bind.zone < 0 || bind.zone >= entity->damageZoneCount)
|
||||
continue;
|
||||
DamageZone *zone = entity->damageZones[bind.zone];
|
||||
if (zone == NULL || bind.obj == NULL)
|
||||
continue;
|
||||
L4DRAWOP *draw_op = bind.obj->GetDrawOp(bind.op);
|
||||
const float level = s_forceOn ? s_forceLevel : (float)zone->damageLevel;
|
||||
// Change-gated, like the binary's watcher Perform (FUN_00457784 compares the
|
||||
// cached level against the live one and only then re-pushes the colours).
|
||||
if (log && fabs(level - draw_op->dzDamageLevel) > 0.001f)
|
||||
DEBUG_STREAM << "[armor] '" << draw_op->dzMatName << "' zone "
|
||||
<< bind.zone << " level " << draw_op->dzDamageLevel
|
||||
<< " -> " << level << " (brightness "
|
||||
<< (1.0f - (1.0f - kDamagedMaterialScale) * (level > 1.0f ? 1.0f : level))
|
||||
<< "x)\n" << std::flush;
|
||||
draw_op->dzDamageLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Sim-side bridge (per-mech, every frame, from Mech::PerformAndWatch).
|
||||
//
|
||||
void BTArmourDamageTick(Entity *mech)
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return;
|
||||
renderer->TickArmourDamage(mech);
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Sim-side bridge (UpdateDeathState drives the sink each dead frame).
|
||||
//
|
||||
@@ -1315,6 +1544,170 @@ int BTWreckSinkTick(Entity *victim, float dt)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// #73 -- the aimed PER-PART pick (see the header note). Ray-vs-sphere over
|
||||
// the per-segment draw objects recorded at tree build; world centers come
|
||||
// through the draw-cached mLocalToWorld (updated every drawn frame -- the
|
||||
// target being aimed at is on screen, so at most one frame stale).
|
||||
//
|
||||
int
|
||||
BTL4VideoRenderer::MechSegmentPick(
|
||||
Entity *mech,
|
||||
const float ray_start[3],
|
||||
const float ray_dir[3],
|
||||
float max_range,
|
||||
float hit_out[3],
|
||||
int *zone_out)
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
|
||||
// Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
|
||||
// SMALLEST radius wins (normalized-distance tie-break). Neither nearest-
|
||||
// entry nor pure normalized distance works here, and both were measured
|
||||
// failing the same way: the torso mesh's sphere (r~4.1 on the MadCat)
|
||||
// envelops nearly the whole mech, so its front face is nearest for any aim
|
||||
// AND any near-body ray scores ~0 against it (d/r rewards giant spheres).
|
||||
// The limb spheres (shoulders r~1.0, guns r~2.2) nest INSIDE the torso
|
||||
// envelope; smallest-pierced picks the most specific part on the aim line,
|
||||
// and the torso wins only when no limb is threaded -- which is the per-part
|
||||
// semantic the 1995 mesh intersection produced.
|
||||
float bestR = 1e30f; // primary key: sphere radius (ascending)
|
||||
float bestScore = 1.0f; // tie-break: normalized perpendicular d2/r2
|
||||
float bestT = max_range;
|
||||
int bestZone = -1;
|
||||
int hitAny = 0;
|
||||
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
d3d_OBJECT *obj = sp->second.obj;
|
||||
if (obj == NULL || obj->mCullRadius <= 0.0f)
|
||||
continue;
|
||||
|
||||
D3DXMATRIX l2w = obj->GetLocalToWorld();
|
||||
D3DXVECTOR3 cw;
|
||||
D3DXVec3TransformCoord(&cw, &obj->mCullCenter, &l2w);
|
||||
|
||||
float ocx = cw.x - ray_start[0];
|
||||
float ocy = cw.y - ray_start[1];
|
||||
float ocz = cw.z - ray_start[2];
|
||||
float tca = ocx*ray_dir[0] + ocy*ray_dir[1] + ocz*ray_dir[2];
|
||||
float r = obj->mCullRadius;
|
||||
float oc2 = ocx*ocx + ocy*ocy + ocz*ocz;
|
||||
if (tca < 0.0f && oc2 > r*r)
|
||||
continue; // wholly behind the ray
|
||||
float d2 = oc2 - tca*tca;
|
||||
float r2 = r*r;
|
||||
if (d2 > r2)
|
||||
continue; // ray passes outside the sphere
|
||||
float thc = sqrtf(r2 - d2);
|
||||
float t = tca - thc;
|
||||
if (t < 0.0f)
|
||||
t = tca + thc; // ray starts inside: exit point
|
||||
if (t < 0.0f || t >= max_range)
|
||||
continue;
|
||||
|
||||
float score = d2 / r2; // 0 = dead-center thread
|
||||
if (r > bestR
|
||||
|| (r == bestR && score >= bestScore))
|
||||
continue;
|
||||
|
||||
bestR = r;
|
||||
bestScore = score;
|
||||
bestT = t;
|
||||
bestZone = sp->second.zone;
|
||||
hitAny = 1;
|
||||
}
|
||||
|
||||
// #92 probe: which spheres did the ray actually THREAD, and which won?
|
||||
// "smallest radius wins" means a big sphere can never beat a small one that
|
||||
// the ray also grazes -- so a foot can be unhittable behind its own knee.
|
||||
if (getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_pc = 0;
|
||||
if ((s_pc++ % 60) == 0)
|
||||
{
|
||||
// one-shot: EVERY sphere in world space, so the foot/knee geometry
|
||||
// can be worked out analytically rather than by aim-hunting
|
||||
static int s_dumped = 0;
|
||||
if (!s_dumped)
|
||||
{
|
||||
s_dumped = 1;
|
||||
DEBUG_STREAM << "[pickgeom] ray_start=(" << ray_start[0] << ","
|
||||
<< ray_start[1] << "," << ray_start[2] << ")" << std::endl;
|
||||
std::map<int, MechRenderTree::SegPick>::iterator gp;
|
||||
for (gp = it->second.segPick.begin(); gp != it->second.segPick.end(); ++gp)
|
||||
{
|
||||
d3d_OBJECT *o3 = gp->second.obj;
|
||||
if (o3 == NULL || o3->mCullRadius <= 0.0f) continue;
|
||||
D3DXMATRIX m3 = o3->GetLocalToWorld();
|
||||
D3DXVECTOR3 c3;
|
||||
D3DXVec3TransformCoord(&c3, &o3->mCullCenter, &m3);
|
||||
DEBUG_STREAM << "[pickgeom] zone=" << gp->second.zone
|
||||
<< " r=" << o3->mCullRadius
|
||||
<< " world=(" << c3.x << "," << c3.y << "," << c3.z << ")"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
DEBUG_STREAM << "[pickcand]";
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
d3d_OBJECT *o2 = sp->second.obj;
|
||||
if (o2 == NULL || o2->mCullRadius <= 0.0f) continue;
|
||||
D3DXMATRIX m2 = o2->GetLocalToWorld();
|
||||
D3DXVECTOR3 c2;
|
||||
D3DXVec3TransformCoord(&c2, &o2->mCullCenter, &m2);
|
||||
float ax = c2.x - ray_start[0], ay = c2.y - ray_start[1], az = c2.z - ray_start[2];
|
||||
float tc = ax*ray_dir[0] + ay*ray_dir[1] + az*ray_dir[2];
|
||||
float rr = o2->mCullRadius;
|
||||
float a2 = ax*ax + ay*ay + az*az;
|
||||
if (tc < 0.0f && a2 > rr*rr) continue;
|
||||
float dd = a2 - tc*tc;
|
||||
if (dd > rr*rr) continue; // not threaded
|
||||
DEBUG_STREAM << " {z" << sp->second.zone << " r=" << rr
|
||||
<< " d=" << sqrtf(dd > 0.0f ? dd : 0.0f) << "}";
|
||||
}
|
||||
DEBUG_STREAM << " -> WON z" << bestZone << " r=" << bestR << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitAny)
|
||||
return 0;
|
||||
|
||||
if (getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_pl = 0;
|
||||
if ((s_pl++ % 60) == 0)
|
||||
DEBUG_STREAM << "[pickwin] zone=" << bestZone
|
||||
<< " score=" << bestScore << " t=" << bestT << "\n" << std::flush;
|
||||
}
|
||||
|
||||
hit_out[0] = ray_start[0] + bestT * ray_dir[0];
|
||||
hit_out[1] = ray_start[1] + bestT * ray_dir[1];
|
||||
hit_out[2] = ray_start[2] + bestT * ray_dir[2];
|
||||
*zone_out = bestZone; // -1 = zone-less segment (lottery downstream)
|
||||
return 1;
|
||||
}
|
||||
|
||||
//
|
||||
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
||||
// the wreck swap below).
|
||||
//
|
||||
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
||||
float max_range, float hit_out[3], int *zone_out)
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return 0;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return 0;
|
||||
return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir,
|
||||
max_range, hit_out, zone_out);
|
||||
}
|
||||
|
||||
//
|
||||
// Engine-side bridge (the ExplosionClassID dispatch calls this on effect 104).
|
||||
//
|
||||
@@ -2542,8 +2935,21 @@ int
|
||||
}
|
||||
r->second->SetDrawObj(obj);
|
||||
render_tree.segGState[slot] = (int)gstate;
|
||||
// Keep the per-segment geometry record in step with what is actually drawn
|
||||
// (the armour-damage bindings and the #73 pick both key off it).
|
||||
if (obj != NULL && obj->GetIsShadow() == 0)
|
||||
{
|
||||
MechRenderTree::SegPick sp;
|
||||
sp.obj = obj;
|
||||
sp.zone = zone_index;
|
||||
render_tree.segPick[slot] = sp;
|
||||
}
|
||||
else
|
||||
render_tree.segPick.erase(slot);
|
||||
if (obj) ++shown; else ++hidden;
|
||||
}
|
||||
// This reloaded every segment mesh, so every armour-damage binding is stale.
|
||||
BindArmourDamage(viewpoint, render_tree);
|
||||
if (reinstall_paint)
|
||||
{
|
||||
TearDownMaterialSubstitutionList();
|
||||
@@ -2747,6 +3153,20 @@ void BTStartWarpExpandPOV()
|
||||
//
|
||||
void BTStartWarpEffect(float x, float y, float z)
|
||||
{
|
||||
// ONE warp slot (the authentic effect is POV-only; this world-anchored peer
|
||||
// sphere is the port extension). The local pilot's own death/respawn
|
||||
// lifecycle OWNS the slot: a peer's un-wreck landing mid-collapse/wait/expand
|
||||
// would overwrite gWarpPhase/gWarpPOV and kill the POV vortex ("my respawn
|
||||
// had no blue vortex"). Harmless before the #81 respawn fix only because
|
||||
// overlapping respawns barely existed; now they are routine. Skip the peer
|
||||
// sphere instead -- the observer still sees the un-wreck swap itself.
|
||||
if (gWarpPhase != 0 && gWarpPOV)
|
||||
{
|
||||
if (getenv("BT_TLOC_LOG"))
|
||||
DEBUG_STREAM << "[tloc] peer warp SKIPPED (own POV warp active, phase "
|
||||
<< gWarpPhase << ")\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
BTWarpApplyScaleEnv();
|
||||
gWarpPhase = 2; // ExpandReveal
|
||||
gWarpT = 0.0f;
|
||||
@@ -2776,6 +3196,35 @@ void
|
||||
BTDrawTranslocationSpheres(LPDIRECT3DDEVICE9 device, const D3DXMATRIX *view,
|
||||
float dt, Time frame_time)
|
||||
{
|
||||
// DIAG (off by default; MUST sit before the phase-0 early-out -- this
|
||||
// fn is the per-frame alpha-pass hook, L4VIDEO.cpp:8831): BT_SHOT_EVERY=<n> dumps the backbuffer every n
|
||||
// frames as <BT_SHOT_PREFIX|shot>_NNN.png -- the GENERAL headless-eyes
|
||||
// harness (view/HUD/layout debugging without foregrounding the window).
|
||||
{
|
||||
static int s_shotEvery = -2;
|
||||
if (s_shotEvery == -2)
|
||||
{
|
||||
const char *se = getenv("BT_SHOT_EVERY");
|
||||
s_shotEvery = (se && *se) ? atoi(se) : 0;
|
||||
if (s_shotEvery < 0) s_shotEvery = 0;
|
||||
}
|
||||
if (s_shotEvery > 0)
|
||||
{
|
||||
static int s_frameN = 0, s_dumpN = 0;
|
||||
if ((++s_frameN % s_shotEvery) == 0 && s_dumpN < 400)
|
||||
{
|
||||
const char *pfx = getenv("BT_SHOT_PREFIX");
|
||||
char fn[600];
|
||||
_snprintf(fn, sizeof fn, "%s_%03d.png", (pfx && *pfx) ? pfx : "shot", s_dumpN++);
|
||||
IDirect3DSurface9 *bb = 0;
|
||||
if (SUCCEEDED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb)
|
||||
{
|
||||
D3DXSaveSurfaceToFileA(fn, D3DXIFF_PNG, bb, 0, 0);
|
||||
bb->Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// DIAG (off by default): BT_WARP_SELFTEST=1 forces a steady POV warp
|
||||
// (held in WaitForReincarnate, world masked) so the swirl can be frame-captured and
|
||||
// compared against the original (capture.png) in a solo game -- no death/respawn
|
||||
|
||||
@@ -706,6 +706,20 @@ 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
|
||||
char paintSerno; // the %serno% this mech was BUILT with
|
||||
// (0 = none) -- ApplyViewSkeleton re-parses
|
||||
@@ -726,6 +740,18 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
int
|
||||
TickWreck(Entity *victim, float dt);
|
||||
|
||||
//
|
||||
// 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 +782,21 @@ 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);
|
||||
|
||||
protected:
|
||||
//
|
||||
// Renderer-manager overrides
|
||||
|
||||
+167
-11
@@ -413,6 +413,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 +587,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 +641,38 @@ void
|
||||
}
|
||||
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
|
||||
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1411,6 +1498,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 +1633,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 +1829,14 @@ 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;
|
||||
}
|
||||
|
||||
int BTPilotKills(void *pilot)
|
||||
{
|
||||
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
|
||||
@@ -2120,17 +2262,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
|
||||
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
Player::VehicleDeadMessage dead(
|
||||
Player::VehicleDeadMessageID, // 0x13
|
||||
sizeof(Player::VehicleDeadMessage));
|
||||
victim_player->Dispatch(&dead);
|
||||
}
|
||||
// MP DEATH: the victim's death tally.
|
||||
//
|
||||
// REMOVED 2026-07-30 (#81): this used to dispatch a second
|
||||
// `Player::VehicleDeadMessage` to the victim's own player "to credit a
|
||||
// death". That message is the RESPAWN-CYCLE TRIGGER, not a scoreboard
|
||||
// increment, and the victim already receives one from the mech's death
|
||||
// transition (mech4.cpp:2110) -- which is the hardened, authentic notify
|
||||
// (it carries the #55 NULL-playerLink fallback). Both fire inside the SAME
|
||||
// death transition (BTPostKillScore is called at mech4.cpp:2006, the notify
|
||||
// at :2110), so they were always paired: every death dispatched the message
|
||||
// TWICE. The tally itself is credited by the handler's `++deathTally`
|
||||
// (:538) on the first one, so this dispatch never added anything.
|
||||
//
|
||||
// It was invisible because the `deathPending` latch silently deduped it --
|
||||
// that is what EVERY "death ... SWALLOWED" warning in the field logs
|
||||
// actually was (8 of 8 deaths, a 100% base rate, which is exactly why it
|
||||
// correlated with nothing). Once the latch was released to match the
|
||||
// binary (see the death handler's tail), the duplicate stopped being
|
||||
// masked and started a SECOND death cycle: deathCount double-incremented,
|
||||
// the first cycle's re-post went stale and tripped the drop-zone
|
||||
// `*** MISMATCH ***`, and one respawn burned two cycles. Caught on the
|
||||
// first solo bench of the latch fix.
|
||||
//
|
||||
// The kill credit above is unaffected -- it dispatches a ScoreMessage, the
|
||||
// correct message for a scoreboard change.
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -436,6 +436,7 @@ 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
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -211,6 +211,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
|
||||
@@ -259,5 +297,20 @@ 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)
|
||||
DEBUG_STREAM << "[dmgresolve] localY=" << local.y
|
||||
<< " heightRef=" << heightRef
|
||||
<< " frac=" << (depth / heightRef)
|
||||
<< " layer=" << layerIndex << "/" << layerCount
|
||||
<< " theta=" << theta
|
||||
<< " -> zone " << resolved << 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;
|
||||
}
|
||||
|
||||
@@ -538,7 +538,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 +549,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.
|
||||
@@ -778,7 +801,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())
|
||||
@@ -952,8 +993,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 +1042,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 +1339,36 @@ 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();
|
||||
}
|
||||
|
||||
@@ -564,7 +564,9 @@ 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; }
|
||||
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,6 +1211,26 @@ 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
|
||||
|
||||
+232
-7
@@ -765,13 +765,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 +931,35 @@ void
|
||||
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
||||
}
|
||||
|
||||
//
|
||||
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
|
||||
// type 0 == the mover's collision damage (what the crash response policy
|
||||
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc)
|
||||
// and the handler is done. This was the missing half of the crash
|
||||
// self-damage reconstruction -- without the divert, the raw kinetic-energy
|
||||
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the
|
||||
// WEAPON loop and a single bump killed the mech (run-14 field report).
|
||||
//
|
||||
if (message->damageData.damageType == 0)
|
||||
{
|
||||
DistributeCollisionDamage(&message->damageData);
|
||||
Check_Fpu();
|
||||
return;
|
||||
}
|
||||
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
// #92: when this gate fails the zone keeps whatever the message carried --
|
||||
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
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 +973,86 @@ void
|
||||
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
|
||||
//
|
||||
// #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
|
||||
|
||||
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
|
||||
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
|
||||
// inflicting subsystem, victim name} -- to the shooter's player (with a
|
||||
// kill-flagged variant when this damage NEWLY disabled the mech) and to
|
||||
// the victim's player. That is the authentic stats plumbing (#45); the
|
||||
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
|
||||
(void)zoneDestroyed;
|
||||
(void)damageTally;
|
||||
}
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
||||
// application -- one line per applied TakeDamage with the resolved zone
|
||||
@@ -1949,6 +2162,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,6 +2278,15 @@ 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)
|
||||
{
|
||||
|
||||
+38
-10
@@ -1017,10 +1017,16 @@ protected:
|
||||
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 +1041,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,8 +1090,17 @@ 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) ---
|
||||
@@ -1119,7 +1134,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 +1197,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 --
|
||||
|
||||
+448
-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
|
||||
@@ -250,6 +251,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 +347,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 +494,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)
|
||||
@@ -546,6 +591,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 +856,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 +980,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 +1301,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 +1436,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 +1592,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 +1601,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 +1620,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 +1649,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 +1716,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 +1747,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 +1871,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 +1890,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 == "")
|
||||
|
||||
+772
-125
File diff suppressed because it is too large
Load Diff
+223
-47
@@ -71,6 +71,25 @@
|
||||
#endif
|
||||
|
||||
#include <stdio.h> // sscanf (offline streamer)
|
||||
|
||||
// #78/#82 diagnostic scope: the mech whose GIMP cell the [simstomp] trap in
|
||||
// SIMULATE.h should watch (set at each leg-threshold crossing). Definition
|
||||
// lives here so the engine header can extern it without a game dependency.
|
||||
void *g_btGimpWatchMech = 0;
|
||||
|
||||
// The trap body (SIMULATE.h calls it): report WHO reset the gimp cell, with a
|
||||
// module-relative return address for tools/symcrash.py.
|
||||
void BTGimpStompTrap(void *sim, unsigned cur, unsigned nw, void *ra)
|
||||
{
|
||||
static int s_ds = 0;
|
||||
if (s_ds++ >= 40)
|
||||
return;
|
||||
unsigned long base = (unsigned long)GetModuleHandleA(0);
|
||||
char buf[160];
|
||||
sprintf(buf, "[simstomp] %u->%u mech=%p ra=btl4+0x%lx",
|
||||
cur, nw, sim, (unsigned long)ra - base);
|
||||
DEBUG_STREAM << buf << std::endl << std::flush;
|
||||
}
|
||||
#if !defined(RANDOM_HPP)
|
||||
# include <random.hpp> // the engine's global RandomGenerator Random
|
||||
#endif
|
||||
@@ -85,10 +104,11 @@
|
||||
static struct ReconClock { Scalar CurrentTick() { return 0; } } TheTime; // FUN_00414b60
|
||||
static const Scalar TicksPerSecond = 1.0f; // DAT_0052140c
|
||||
|
||||
struct DZRef { int index; };
|
||||
struct SegmentRecord { int siblings; int children; }; // mech segment-tree node
|
||||
struct SegmentIterator { template<class A> SegmentIterator(const A &) {} DZRef *Next() { return 0; } };
|
||||
struct SegTableX { SegmentRecord *operator[](int) { return 0; } }; // cast of EntitySegment::SegmentTable
|
||||
// (#110: the DZRef/SegmentRecord/SegmentIterator/SegTableX stubs that lived here
|
||||
// are GONE. They silently no-op'ed the whole destruction cascade walk --
|
||||
// RecurseSegmentTable "descended" into nothing, so blowing an arm off never
|
||||
// touched the gun pod hanging from it. The walk now uses the REAL engine
|
||||
// segment tree; see RecurseSegmentTable below.)
|
||||
|
||||
// Subsystem facets the recovered code reaches that the engine Subsystem does
|
||||
// not expose under these names (cast of Subsystem*).
|
||||
@@ -258,13 +278,22 @@ Mech__DamageZone::Mech__DamageZone(
|
||||
// the authored ram economy.
|
||||
if (getenv("BT_DMG_LOG"))
|
||||
DEBUG_STREAM << "[zone-armor] zone=" << damageZoneIndex
|
||||
<< " '" << (const char *)damageZoneName << "'"
|
||||
<< " armorPts=" << defaultArmorPoints
|
||||
<< " raw={" << rawScale[0] << "," << rawScale[1] << "," << rawScale[2]
|
||||
<< "," << rawScale[3] << "," << rawScale[4] << "}"
|
||||
<< " scale={" << damageScale[0] << "," << damageScale[1] << ","
|
||||
<< damageScale[2] << "," << damageScale[3] << "," << damageScale[4] << "}"
|
||||
<< " leg=" << (int)(rightLeg || leftLeg)
|
||||
<< " vital=" << (int)vitalDamageZone << std::endl;
|
||||
<< " vital=" << (int)vitalDamageZone
|
||||
// #110: the destruction-cascade authoring -- whether this zone's
|
||||
// death descends the segment tree / takes its siblings, and how many
|
||||
// critical subsystems it can fail. The arm chain lives or dies by
|
||||
// these values.
|
||||
<< " descend=" << (int)descendOnDestruction
|
||||
<< " destroySibs=" << (int)destroySiblingsOnDestruction
|
||||
<< " segIdx=" << segmentIndex
|
||||
<< " crits=" << criticalSubsystemCount << std::endl;
|
||||
|
||||
//
|
||||
// Critical-subsystem table.
|
||||
@@ -469,6 +498,13 @@ void
|
||||
}
|
||||
else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled)
|
||||
{
|
||||
if (getenv("BT_DMG_LOG") && (leftLeg || rightLeg))
|
||||
DEBUG_STREAM << "[gimp-eval] zone=" << damageZoneIndex
|
||||
<< " L=" << (int)leftLeg << " R=" << (int)rightLeg
|
||||
<< " lvl=" << damageLevel
|
||||
<< " half=" << (int)(damageLevel >= LegHalfStructure)
|
||||
<< " alarmBefore=" << (int)mech->graphicAlarm.GetLevel()
|
||||
<< "\n" << std::flush;
|
||||
// A live (non-disabled) mech: a half-destroyed leg zone trips the
|
||||
// partial-failure graphic (right -> 4, left -> 3). The oracle
|
||||
// compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60:
|
||||
@@ -480,6 +516,29 @@ void
|
||||
mech->graphicAlarm.SetLevel(4);
|
||||
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
|
||||
mech->graphicAlarm.SetLevel(3);
|
||||
//
|
||||
// #78 the "REVERSE DISABLED" voice: the binary's mech+0x40 is ONE
|
||||
// cell -- the same 3/4 write was ALSO Entity.SimulationState, and
|
||||
// the authored audio has state watchers on SimulationState==3/4
|
||||
// that start the warning sequence (notes 29,16,40 -> two klaxon
|
||||
// hits + the Warnings01 zone-8 "reverse disabled" voice line).
|
||||
// The port split the cell (graphicAlarm vs engine simulationState,
|
||||
// gotcha #23), so the voice never triggered. Mirror the gimp
|
||||
// level into the engine indicator -- guarded so a fall/disabled/
|
||||
// dead state (2, 5-8, 9) is never stomped; SetState dedupes, so
|
||||
// the voice fires once per onset (again only on a side switch),
|
||||
// which is the binary's own edge semantics.
|
||||
//
|
||||
if (damageLevel >= LegHalfStructure && (rightLeg != 0 || leftLeg != 0))
|
||||
{
|
||||
g_btGimpWatchMech = (void *)mech; // scope the #78 simstomp trap
|
||||
unsigned ss = mech->GetSimulationState();
|
||||
if (getenv("BT_AUDIO_SPATIAL")) { static int s_gm=0; if (s_gm++<30)
|
||||
DEBUG_STREAM << "[gimp-sim] ss=" << ss << " -> "
|
||||
<< ((rightLeg != 0) ? 4 : 3) << "\n" << std::flush; }
|
||||
if (ss <= 1 || ss == 3 || ss == 4)
|
||||
mech->SetSimulationState((rightLeg != 0) ? 4 : 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -580,6 +639,77 @@ void
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// #80 bridge: MechSubsystem::TakeDamage's vital-subsystem kill (@0x4ac126-
|
||||
// 0x4ac137: owner alarm+0x2C -> level 9). MechSubsystem's TU holds only an
|
||||
// incomplete Mech; this TU already writes graphicAlarm level 9 for the
|
||||
// leg-destruction path, so the vital path reuses the same complete-type site.
|
||||
//
|
||||
//
|
||||
// #78 bridge: the gimp level, read in THIS TU. The mapper's direct
|
||||
// `mech->graphicAlarm.GetLevel()` read 0 while this TU's read of the same
|
||||
// object returned 4 -- AlarmIndicator is typedef'd to DIFFERENT TYPES per
|
||||
// header family (mech.hpp: ReconAlarm, 4 bytes; heat.hpp: GaugeAlarm, 0x54),
|
||||
// so Mech's layout past graphicAlarm diverges between TU families. Until
|
||||
// that typedef split is audited and unified, every cross-TU read of the
|
||||
// alarm goes through here.
|
||||
//
|
||||
// #82 REPLICANTS (field: "peers see a limping mech SKATING", 2026-07-29): the
|
||||
// binary's mech+0x40 is ONE cell -- and because that cell IS
|
||||
// Simulation::simulationState, it rides EVERY update record header
|
||||
// (Simulation::Write/ReadUpdateRecord), so a peer's replicant learned the gimp
|
||||
// level for free and its gait machine limped with no extra plumbing.
|
||||
//
|
||||
// The port split the cell (graphicAlarm = where mechdmg writes; engine
|
||||
// simulationState = what replicates). 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 -- so the threshold evaluation, and with it
|
||||
// graphicAlarm 3/4, only ever happens on the damage-resolving node. Its
|
||||
// self-simulated gait therefore stayed in the WALK states while the replicated
|
||||
// speed dragged it along at limp pace: the skate.
|
||||
//
|
||||
// So read the cell the way the binary had it: the master's authoritative
|
||||
// graphicAlarm, else the replicated engine cell (which the master now mirrors
|
||||
// for the warning voice, #78 -- the same value the binary put on the wire).
|
||||
// Mech::StateCount is 0x21 and the engine base only defines 0/1 there, so 3/4
|
||||
// are unambiguously the mech's gimp levels (the authored audio watchers on
|
||||
// SimulationState 3/4 are the binary's own confirmation).
|
||||
int BTMechGimpAlarmLevel(void *mech_v)
|
||||
{
|
||||
if (mech_v == 0)
|
||||
return 0;
|
||||
return (int)((Mech *)mech_v)->graphicAlarm.GetLevel();
|
||||
}
|
||||
|
||||
int BTMechGimpLevel(void *mech_v)
|
||||
{
|
||||
if (mech_v == 0)
|
||||
return 0;
|
||||
Mech *m = (Mech *)mech_v;
|
||||
const int alarm = (int)m->graphicAlarm.GetLevel();
|
||||
const int sim = (int)m->GetSimulationState();
|
||||
|
||||
// ONE-CELL PRECEDENCE: in the binary a fall / death / limbo state OVERWROTE
|
||||
// the gimp level in the same cell, so those gaits win and the NORMAL
|
||||
// drivers (which carry the death latch) must run. Report the alarm
|
||||
// unchanged there -- never gimp.
|
||||
if (sim == 2 || (sim >= 5 && sim <= 9))
|
||||
return alarm;
|
||||
if (alarm == 3 || alarm == 4)
|
||||
return alarm; // master authority
|
||||
if (sim == 3 || sim == 4)
|
||||
return sim; // the replicated cell
|
||||
return alarm; // 0/9/... unchanged
|
||||
}
|
||||
|
||||
void BTMechVitalSubsystemKill(void *owner_mech)
|
||||
{
|
||||
if (owner_mech != 0)
|
||||
((Mech *)owner_mech)->graphicAlarm.SetLevel(9); // owner+0x2C, level 9
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// CriticalHit -- @0049ccc4
|
||||
//
|
||||
@@ -680,12 +810,16 @@ void
|
||||
}
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[deathfx] crit-subsys " << i
|
||||
<< " '" << (s->GetName() ? s->GetName() : "?") << "'"
|
||||
<< " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush;
|
||||
}
|
||||
else if (getenv("BT_DEATH_LOG"))
|
||||
{
|
||||
DEBUG_STREAM << "[deathfx] crit-subsys " << i << " damaged -> "
|
||||
<< level << "\n" << std::flush;
|
||||
DEBUG_STREAM << "[deathfx] crit-subsys " << i
|
||||
<< " '" << (s->GetName() ? s->GetName() : "?") << "'"
|
||||
<< " damaged -> " << level
|
||||
<< " (pct=" << cs->damagePercentage
|
||||
<< " used=" << cs->damagePercentageUsed << ")" << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -701,35 +835,66 @@ void
|
||||
void
|
||||
Mech__DamageZone::RecurseSegmentTable(Mech *my_mech)
|
||||
{
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[cascade] zone " << damageZoneIndex
|
||||
<< " DESTROYED -> descend=" << (int)descendOnDestruction
|
||||
<< " destroySibs=" << (int)destroySiblingsOnDestruction
|
||||
<< " crits=" << criticalSubsystemCount << std::endl;
|
||||
SendSubsystemDamage(); // FUN_0049c9a8
|
||||
SetGraphicState(1); // slot 0xc
|
||||
|
||||
SegmentRecord *seg = ((SegTableX &)my_mech->segmentTable)[segmentIndex]; // mech+0x300, Wword(0x65)
|
||||
//
|
||||
// The segment-tree walk (@0049cad4, re-read 2026-08-02 for #110). The
|
||||
// binary iterates the mech's segment table (mech+0x300, vtbl+0x34 GetNth)
|
||||
// to this zone's segment, then walks two lists ON the EntitySegment:
|
||||
// seg+0xD0 the damage-zone indices ATTACHED to this segment
|
||||
// seg+0xE8 the CHILD segment indices
|
||||
// Those are the engine's own EntitySegment::damageZoneTable /
|
||||
// childIndexTable -- byte-identical offsets (TableOf is 0x18: 0xD0+0x18 =
|
||||
// 0xE8), populated at stream time by JMOVER.cpp:373 (AddDamageZone) [T0].
|
||||
// The previous "reconstruction" here walked stub iterators that always
|
||||
// returned NULL, so the cascade never left this zone: an arm died and the
|
||||
// gun pod hanging off it kept firing (#110, Lynx + Conn Man's three-chassis
|
||||
// audit). Element payload = PlugOf<int> (operator int).
|
||||
//
|
||||
EntitySegment::SegmentTableIterator segs(my_mech->segmentTable); // mech+0x300
|
||||
EntitySegment *seg = segs.GetNth(segmentIndex); // vtbl+0x34
|
||||
if (seg == 0)
|
||||
return; // (binary derefs blind)
|
||||
|
||||
if (destroySiblingsOnDestruction) // Wword(0x68)
|
||||
{
|
||||
SegmentIterator sibs(&seg->siblings); // seg+0xd0
|
||||
for (DZRef *r; (r = sibs.Next()) != 0; ) // slot 0x28
|
||||
// Other zones on the SAME segment.
|
||||
EntitySegment::IntegerTableIterator sibs(seg->GetDamageZoneTable()); // seg+0xD0
|
||||
for (EntitySegment::IntegerPlug *r; (r = sibs.ReadAndNext()) != 0; )
|
||||
{
|
||||
Mech__DamageZone *sib = my_mech->Zone(r->index); // inherited damageZones[..], typed
|
||||
if (sib != this && sib->GetGraphicState() != 1)
|
||||
Mech__DamageZone *sib = my_mech->Zone((int)*r);
|
||||
if (sib != 0 && sib != this && sib->GetGraphicState() != 1)
|
||||
{
|
||||
sib->RecurseSegmentTable(my_mech);
|
||||
sib->SetGraphicState(2); // slot 0xc
|
||||
sib->SetGraphicState(2); // 2 = Gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (descendOnDestruction) // Wword(0x67)
|
||||
{
|
||||
SegmentIterator kids(&seg->children); // seg+0xe8
|
||||
for (DZRef *r; (r = kids.Next()) != 0; )
|
||||
// Every zone on every CHILD segment, recursively -- the arm takes the
|
||||
// gun with it. The binary's child-zone loop has NO graphic-state
|
||||
// guard (only the sibling loop has one); reproduced as-is.
|
||||
EntitySegment::IntegerTableIterator kids(seg->GetChildIndexTable()); // seg+0xE8
|
||||
for (EntitySegment::IntegerPlug *r; (r = kids.ReadAndNext()) != 0; )
|
||||
{
|
||||
SegmentRecord *child = ((SegTableX &)my_mech->segmentTable)[r->index];
|
||||
SegmentIterator czones(&child->siblings); // child+0xd0
|
||||
for (DZRef *cr; (cr = czones.Next()) != 0; )
|
||||
EntitySegment::SegmentTableIterator segs2(my_mech->segmentTable);
|
||||
EntitySegment *child = segs2.GetNth((int)*r);
|
||||
if (child == 0)
|
||||
continue;
|
||||
EntitySegment::IntegerTableIterator czones(child->GetDamageZoneTable()); // child+0xD0
|
||||
for (EntitySegment::IntegerPlug *cr; (cr = czones.ReadAndNext()) != 0; )
|
||||
{
|
||||
Mech__DamageZone *cz = my_mech->Zone(cr->index);
|
||||
Mech__DamageZone *cz = my_mech->Zone((int)*cr);
|
||||
if (cz == 0)
|
||||
continue;
|
||||
cz->RecurseSegmentTable(my_mech);
|
||||
cz->SetGraphicState(2);
|
||||
}
|
||||
@@ -1029,7 +1194,7 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind
|
||||
extern void BTRemakeMechModel(Entity *entity);
|
||||
|
||||
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
|
||||
: owner(mech), wasWrecked(0)
|
||||
: owner(mech), prevMode(0)
|
||||
{
|
||||
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
|
||||
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
|
||||
@@ -1052,6 +1217,40 @@ void
|
||||
if (owner == 0)
|
||||
return;
|
||||
|
||||
// REPLICANT UN-WRECK (#94): fire on the DEATH-STATE EXIT edge of the
|
||||
// replicated SimulationState dial (MovementMode 9/2 are the death modes;
|
||||
// they never revert except through Mech::Reset, whose SetMovementMode(0)
|
||||
// + ForceUpdate(0x1f) reaches the peer in every record header). This is
|
||||
// the cause-agnostic respawn signal: the previous trigger inferred the
|
||||
// respawn from a damage-zone falling edge, which a COLLISION death never
|
||||
// produces (type-0 damage prices as internal rattle, never armor -- the
|
||||
// zones of a fresh mech killed by ramming sit at 0 for its whole life),
|
||||
// so the peer's wreck could never un-wreck: the live respawned mech drove
|
||||
// around wearing the hulk (night-7 field report, SAURON's Loki). Gated
|
||||
// to ReplicantInstance: the master un-wrecks locally in Mech::Reset.
|
||||
{
|
||||
const int mode = (int)owner->MovementMode();
|
||||
const int oldMode = prevMode;
|
||||
const bool nowDead = (mode == 2 || mode == 9);
|
||||
const bool prevDead = (oldMode == 2 || oldMode == 9);
|
||||
prevMode = mode;
|
||||
if (prevDead && !nowDead
|
||||
&& owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
extern void BTRebuildMechModel(Entity *entity);
|
||||
extern void BTStartWarpEffect(float x, float y, float z);
|
||||
BTRebuildMechModel((Entity *)owner);
|
||||
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.y,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
int n = owner->damageZoneCount;
|
||||
for (int i = 0; i < n && i < (int)lastLevel.size(); ++i)
|
||||
{
|
||||
@@ -1063,35 +1262,13 @@ void
|
||||
Scalar prev = lastLevel[i];
|
||||
|
||||
// FALLING edge = the master's Mech::Reset healed + re-broadcast this zone
|
||||
// (ForceUpdate(DamageZoneUpdateModelFlag)). The rising-damage swap below is
|
||||
// one-way; on a REPLICANT observer, reverse it here -- drop the destroyed
|
||||
// segment meshes (BTRebuildMechModel) and play the recovery warp -- so the
|
||||
// peer's wreck un-wrecks + warps back on the observer's screen. Gated to
|
||||
// ReplicantInstance: the master already un-wrecks + warps locally in
|
||||
// Mech::Reset (mech4.cpp), so it must NOT double-fire here. The authentic
|
||||
// engine drives this off the type-0 graphic-state transition in
|
||||
// Mech::ReadUpdateRecord (mech.cpp case 0 -> ResetPose); our port has no
|
||||
// ported RemakeEntity state, so we react to the replicated zone level.
|
||||
// (ForceUpdate(DamageZoneUpdateModelFlag)). Just refresh the cache: the
|
||||
// respawn un-wreck no longer rides this edge -- it fires from the
|
||||
// death-state exit watcher above, which also covers deaths that never
|
||||
// moved a zone (#94).
|
||||
if (level < prev)
|
||||
{
|
||||
lastLevel[i] = level;
|
||||
if (wasWrecked && prev >= 1.0f && level < 1.0f)
|
||||
{
|
||||
wasWrecked = 0; // one un-wreck per death->respawn
|
||||
if (owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
extern void BTRebuildMechModel(Entity *entity);
|
||||
extern void BTStartWarpEffect(float x, float y, float z);
|
||||
BTRebuildMechModel((Entity *)owner);
|
||||
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.y,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (level <= prev) // unchanged -- no new damage
|
||||
@@ -1111,7 +1288,6 @@ void
|
||||
const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level);
|
||||
if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor
|
||||
{
|
||||
wasWrecked = 1; // latch: a wreck the observer must later reverse
|
||||
const Mech__DamageZone::DamageDescriptor *dd =
|
||||
zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1
|
||||
if (dd != 0)
|
||||
|
||||
@@ -254,6 +254,13 @@
|
||||
MechCriticalSubsystem
|
||||
**criticalSubsystems; // @0x1b4 array[criticalSubsystemCount]
|
||||
|
||||
public:
|
||||
// Bench query (BT_SELF_DAMAGE_ZONE=leg): leg zones stream the
|
||||
// LegDamageZone flags, so the harness resolves "the leg" symbolically --
|
||||
// zone NUMBERING is per-mech-type (MadCat 16, bhk1 18, ...) and a magic
|
||||
// number aimed at the wrong type has killed the mech instead of gimping.
|
||||
int IsLegZone() const { return (leftLeg || rightLeg) ? 1 : 0; }
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Damage-state descriptor table (binary this+0xd4). Streamed from the
|
||||
// type-0x1e resource by LoadCriticalSubsystems (FUN_0041e4a8 -> FUN_0042a748);
|
||||
@@ -360,12 +367,17 @@ class MechDeathHandler
|
||||
private:
|
||||
Mech *owner; // @0x14
|
||||
std::vector<Scalar> lastLevel; // @0x10 per-zone last damageLevel cache
|
||||
// PORT (respawn replication): latched when any zone is destroyed, so a
|
||||
// REPLICANT observer can reverse the one-way wreck swap when the master's
|
||||
// Mech::Reset heals + re-broadcasts the zones (falling damage edge). Not a
|
||||
// binary field -- the authentic un-wreck rides the type-0 graphic-state hook
|
||||
// (Mech::ReadUpdateRecord case 0), which our port drives via the zone levels.
|
||||
Logical wasWrecked;
|
||||
// PORT (respawn replication): last-tick MovementMode, so a REPLICANT
|
||||
// observer can reverse the one-way wreck swap on the DEATH-STATE EXIT
|
||||
// edge (9/2 -> 0/1, only Mech::Reset ever leaves the death modes). The
|
||||
// state rides EVERY record header, so the edge fires for ANY death
|
||||
// cause. Replaces a zone-level falling-edge latch (wasWrecked), which
|
||||
// was blind to zone-less deaths: collision/internal-rattle kills
|
||||
// (gitea #94) never move a damage zone, so the latch never armed and
|
||||
// the peer's wreck could never un-wreck. Not a binary field -- the
|
||||
// authentic un-wreck rides the type-0 graphic-state hook
|
||||
// (Mech::ReadUpdateRecord case 0), which our port has not reconstructed.
|
||||
int prevMode;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -933,6 +933,32 @@ void
|
||||
}
|
||||
}
|
||||
|
||||
// #78: a GIMPED mech cannot back up -- "reverse disabled". mechdmg raises
|
||||
// graphicAlarm level 3 (left leg) / 4 (right leg) when a leg zone passes
|
||||
// half structure, and MovementMode() IS that alarm's level (task #1). The
|
||||
// pod's cue fired through the alarm's audio watchers on the level change;
|
||||
// here the reverse INPUT is refused while limping (VGL Lynx's account of
|
||||
// the 4.10 behavior, night 6).
|
||||
{
|
||||
// NB read the GRAPHIC ALARM, not MovementMode(): the port carries the
|
||||
// binary's one mech+0x40 cell as TWO members (engine simulationState
|
||||
// vs graphicAlarm level) and mechdmg raises the gimp states on the
|
||||
// ALARM. See the split-brain note in combat-damage.md.
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
|
||||
int mm = BTMechGimpLevel(mech);
|
||||
if ((mm == 3 || mm == 4) && reverseThrust >= 1)
|
||||
{
|
||||
reverseThrust = 0;
|
||||
static int s_revWarned = 0;
|
||||
if (!s_revWarned)
|
||||
{
|
||||
s_revWarned = 1;
|
||||
DEBUG_STREAM << "[gimp] REVERSE DISABLED (leg damage, mode "
|
||||
<< mm << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reverseThrust < 1)
|
||||
{
|
||||
speedDemand =
|
||||
@@ -942,7 +968,79 @@ void
|
||||
{
|
||||
speedDemand = -mech->reverseStrideLength * throttlePosition;
|
||||
}
|
||||
|
||||
// (History note: this block was briefly REMOVED on the morning of
|
||||
// 2026-07-31 on the claim "the binary has NO dynamic myomer->speed
|
||||
// coupling" -- an export-gap-blind conclusion, retracted the same day
|
||||
// when the pod veterans testified seek 4 = 182 kph and the raw-image
|
||||
// float-read sweep found the consumer inside the un-exported master-perf
|
||||
// region. The restored form below is now BYTE-GROUNDED, and better than
|
||||
// the pre-removal draft: MAX over the myomer chain (not first-found), NO
|
||||
// 1.0 clamp (overdrive is real), and the turn-freeze at dead drive.)
|
||||
{
|
||||
// THE MYOMER DRIVE SCALE -- RESTORED + BYTE-GROUNDED (2026-07-31, the
|
||||
// seek-audit correction). The morning's removal ("the binary has NO
|
||||
// dynamic myomer->speed coupling") was EXPORT-GAP BLINDNESS: the
|
||||
// master perf's un-exported region (@0x4a9cf2-0x4a9da4, raw capstone
|
||||
// -- the same gap that hid the #93 crash block) walks the mech's
|
||||
// MYOMER capability chain (+0x7AC) every frame, keeps the MAX
|
||||
// speedEffect@0x31C in mech+0x79C, and MULTIPLIES the mapper's
|
||||
// speedDemand@0x128 by it in place; when |max| <= 1e-4 (@0x4ab16c)
|
||||
// it ALSO zeroes the mapper's turnDemand@0x12C -- dead/overheated
|
||||
// myomers freeze BOTH speed and turn ("less than a minute at seek 4
|
||||
// before overheating and freezing up" -- Oracle, who put pod seek-4
|
||||
// humanoids at 182 kph: the manual's printed Super Charged figure).
|
||||
// speedEffect = (gear/recGear) x heatFactor x (1 - damage), NO 1.0
|
||||
// clamp: gear 4 rides ~1.43x demand into the RegisterMaxOutput gait
|
||||
// cap (base x 1.4284) = SUPERCHARGE. [T1 disasm]
|
||||
Scalar drive = 0.0f; // mech+0x79C accumulator
|
||||
int myomers = 0;
|
||||
extern Scalar BTMyomersSpeedEffectOf(void *subsystem); // myomers.cpp (-1 = not a Myomers)
|
||||
int n = mech->GetSubsystemCount(); // chain-walk emulation (the
|
||||
for (int i = 2; i < n; ++i) // +0x7AC fill is the SubProxy stub)
|
||||
{
|
||||
Scalar f = BTMyomersSpeedEffectOf(mech->GetSubsystem(i));
|
||||
if (f >= -0.5f) // a Myomers (value may be 0)
|
||||
{
|
||||
++myomers;
|
||||
if (f > drive) drive = f; // MAX over the chain (@0x4a9d20)
|
||||
}
|
||||
}
|
||||
if (myomers > 0)
|
||||
{
|
||||
speedDemand *= drive; // @0x4a9d63: in-place demand scale
|
||||
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
|
||||
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
|
||||
}
|
||||
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
|
||||
int mm = BTMechGimpLevel(mech); // the gimp cell (see above)
|
||||
if (mm == 3 || mm == 4)
|
||||
{
|
||||
static Scalar s_gimp = -1.0f;
|
||||
if (s_gimp < 0.0f)
|
||||
{
|
||||
const char *e = getenv("BT_GIMP_SPEED");
|
||||
s_gimp = (e != 0 && *e != '\0') ? (Scalar)atof(e) : 1.0f; // 1.0 = authentic (clamp lives in the gimp SM)
|
||||
if (s_gimp < 0.0f || s_gimp > 1.0f) s_gimp = 1.0f;
|
||||
}
|
||||
speedDemand *= s_gimp;
|
||||
}
|
||||
if (getenv("BT_DRIVE_LOG"))
|
||||
{
|
||||
static float s_dAcc = 0.0f; s_dAcc += time_slice;
|
||||
if (s_dAcc >= 1.0f)
|
||||
{
|
||||
s_dAcc = 0.0f;
|
||||
DEBUG_STREAM << "[drive] drive=" << drive << " nMyo=" << myomers
|
||||
<< " mm=" << mm << " dmd=" << speedDemand
|
||||
<< " mech=" << (int)mech->GetEntityID()
|
||||
<< " @" << (void *)mech << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
extern int BTMechGimpLevel(void *mech_v);
|
||||
#define BTMechGimpLevelTrace BTMechGimpLevel
|
||||
static int s_cTrace = -1;
|
||||
if (s_cTrace < 0) { const char *e = getenv("BT_MPPR_TRACE"); s_cTrace = (e && *e != '0') ? 1 : 0; }
|
||||
if (s_cTrace)
|
||||
@@ -952,6 +1050,7 @@ void
|
||||
DEBUG_STREAM << "[mppr-c] thr=" << throttlePosition << " rev=" << reverseThrust
|
||||
<< " topSpd=" << mech->reverseStrideLength
|
||||
<< " fScale=" << mech->forwardThrottleScale
|
||||
<< " mm=" << BTMechGimpLevelTrace(mech) // #78: 3/4 = gimp
|
||||
<< " -> dmd=" << speedDemand
|
||||
// glass-regression verification 2026-07-20: the same trace
|
||||
// carries the turn/aim scalars so one gated line proves the
|
||||
|
||||
+138
-14
@@ -152,6 +152,16 @@ MechSubsystem::MechSubsystem(
|
||||
printSimulationState = 0; // this[0x41]
|
||||
configureActivePress = -1; // this[0x44] configure-session idle
|
||||
damageZone = (ReconDamageZone *)new DamageZone(this, 0); // this[0x38] = FUN_0041de1c(new 0x160, this, 0)
|
||||
// #64/#88: ALIAS the ENGINE base member to the same zone. The binary has
|
||||
// ONE cell (subsystem word 0x38); the port's re-declared shadow left
|
||||
// Subsystem::damageZone null forever, so every engine-member reader --
|
||||
// HeatSink::UpdateCoolant's leak feed (`coolantDraw = zoneDamage *
|
||||
// heatLoad`), the engine base TakeDamage -- saw a pristine/null zone.
|
||||
// With the crit sink + collision rattle now writing real damage into the
|
||||
// (same) zone, the null base was the single break in the coolant-leak
|
||||
// chain. Aliasing (not moving) keeps every shadow reader intact; the
|
||||
// full #64 de-shadow sweep remains the long-term cleanup.
|
||||
Subsystem::damageZone = (::DamageZone *)damageZone;
|
||||
}
|
||||
|
||||
//
|
||||
@@ -190,23 +200,48 @@ MechSubsystem::MechSubsystem(
|
||||
collisionCriticalHitWeight = subsystem_resource->collisionCriticalHitWeight; // res+0xdc
|
||||
|
||||
damageZone = (ReconDamageZone *)new DamageZone(this, 0); // this[0x38]
|
||||
Subsystem::damageZone = (::DamageZone *)damageZone; // #64/#88 alias -- see the sibling ctor
|
||||
BindName((char *)damageZone + 0x15c, GetName()); // FUN_00402a98(., dz+0x15c, name)
|
||||
|
||||
// structure reference and per-facing armour into the DamageZone
|
||||
damageZone->structureReference = subsystem_resource->structureReference; // dz+0x140 = res+0x44
|
||||
for (int i = 0; i < 5; ++i)
|
||||
//
|
||||
// #80 ROOT CAUSE, half one: armour points + per-damage-TYPE scales into the
|
||||
// private zone (binary @0x4ac7bb-0x4ac853). The old code wrote these
|
||||
// through the ReconDamageZone PROXY -- whose structureReference/armour[]
|
||||
// sit at struct offsets +4/+8, NOT the binary's +0x140/+0x144 -- so the
|
||||
// floats landed on the ENGINE object's header (owningSimulation / the
|
||||
// StateIndicators) and the engine's REAL damageScale[] stayed at the
|
||||
// ctor's zeros. Every subsystem-zone TakeDamage was therefore
|
||||
// `damageLevel += amount * 0` -- the dead crit sink, and the reason the
|
||||
// 2026-07-28 "cannot damage a subsystem's own zone -- in the original too"
|
||||
// verdict was WRONG about the original. The engine base layout is
|
||||
// binary-parity (Mech__DamageZone locks its derived fields from 0x160 up),
|
||||
// so the engine's NAMED members land exactly on +0x140/+0x144.
|
||||
//
|
||||
{
|
||||
damageZone->armour[i] = subsystem_resource->armorByFacing[i]; // dz+0x144+i = res+0x30+i
|
||||
}
|
||||
// invert each facing into an absorption coefficient
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
Scalar a = damageZone->armour[i] - ArmourNumerator / damageZone->structureReference;
|
||||
if (ArmourEpsilon < fabsf(a)) // FUN_004dcd00
|
||||
DamageZone *dz = (DamageZone *)damageZone;
|
||||
dz->defaultArmorPoints = subsystem_resource->weaponDamagePoints; // dz+0x140 = res+0x44
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
damageZone->armour[i] =
|
||||
ArmourNumerator / (damageZone->armour[i] * damageZone->structureReference);
|
||||
dz->damageScale[i] = subsystem_resource->damageTypePoints[i]; // dz+0x144+i*4 = res+0x30+i*4
|
||||
}
|
||||
// normalize: points -> absorption coefficient, exactly the mech-zone
|
||||
// math (no *0.5 here -- that halving is the MECH zones' extra step)
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
Scalar a = dz->damageScale[i] - ArmourNumerator / dz->defaultArmorPoints;
|
||||
if (ArmourEpsilon < fabsf(a)) // FUN_004dcd00, eps @0x4ac864
|
||||
{
|
||||
dz->damageScale[i] =
|
||||
ArmourNumerator / (dz->damageScale[i] * dz->defaultArmorPoints);
|
||||
}
|
||||
}
|
||||
if (BTEnvOn("BT_CRIT_LOG", 0))
|
||||
DEBUG_STREAM << "[subarmor] " << (GetName() ? GetName() : "?")
|
||||
<< " pts=" << dz->defaultArmorPoints
|
||||
<< " scale={" << dz->damageScale[0] << "," << dz->damageScale[1]
|
||||
<< "," << dz->damageScale[2] << "," << dz->damageScale[3]
|
||||
<< "," << dz->damageScale[4] << "}"
|
||||
<< " critBonus=" << criticalReference << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +451,22 @@ Logical
|
||||
Scalar
|
||||
MechSubsystem::ApplyDamageAndMeasure(Damage &damage)
|
||||
{
|
||||
// FIELD CRASH GUARD (2026-07-29, build 641, Conn Man's Owens): a crit
|
||||
// landed on a weapon subsystem whose damageZone is NULL -- the engine
|
||||
// base derefs it unguarded (every 1995 subsystem shipped with a zone).
|
||||
// This path was unreachable until #80 made crits land for real. No
|
||||
// zone -> nothing to damage or measure; the crit fizzles here. The
|
||||
// one-shot log names the zoneless subsystem so its zone can be BUILT
|
||||
// (the root-cause follow-up).
|
||||
if (damageZone == 0)
|
||||
{
|
||||
static int s_nz = 0;
|
||||
if (s_nz++ < 8)
|
||||
DEBUG_STREAM << "[crit] NULL damageZone on subsystem '"
|
||||
<< GetName() << "' -- crit fizzled (see the Owens field crash)\n"
|
||||
<< std::flush;
|
||||
return 0.0f;
|
||||
}
|
||||
Scalar before = ((DamageZone *)damageZone)->damageLevel; // dz+0x158 (engine view)
|
||||
TakeDamage(damage); // (*this.vtable+0x24)(this, &damage)
|
||||
return ((DamageZone *)damageZone)->damageLevel - before;
|
||||
@@ -437,6 +488,47 @@ void MechSubsystem::ApplyZoneDamage(Damage &damage)
|
||||
((DamageZone *)damageZone)->TakeDamage(damage); // zone vtable +0x18
|
||||
}
|
||||
|
||||
//
|
||||
// @0x4ac0bc -- the REAL virtual TakeDamage (vtable slot +0x24), raw-disasm
|
||||
// 2026-07-29 (#80). CLASSMAP had this address mislabeled "HandleMessage"; the
|
||||
// body unambiguously consumes a Damage&. Was an empty btstubs stand-in, so
|
||||
// ApplyDamageAndMeasure always measured a delta of 0 and no crit ever landed.
|
||||
//
|
||||
// zone->TakeDamage(damage) ; zone vtbl+0x18
|
||||
// if (zone->damageLevel >= 1.0) ; vs [0x4ac140] = 1.0f
|
||||
// statusAlarm.SetLevel(1) ; Destroyed (0x41bbd8, this+0x2C)
|
||||
// if (printSimulationState) PrintState() ; vtbl+0x34, gate this+0x104
|
||||
// zone->damageLevel = 1.0f ; pin
|
||||
// if (vitalSubsystem) ; this+0xE4
|
||||
// owner(+0xD0) alarm+0x2C SetLevel(9) ; the VITAL-SUBSYSTEM KILL (#28)
|
||||
//
|
||||
void MechSubsystem::TakeDamage(Damage &damage)
|
||||
{
|
||||
DamageZone *dz = (DamageZone *)damageZone;
|
||||
if (dz == 0) // port guard (binary derefs)
|
||||
return;
|
||||
|
||||
dz->TakeDamage(damage); // zone vtable +0x18
|
||||
|
||||
if (dz->damageLevel >= 1.0f) // _DAT_004ac140
|
||||
{
|
||||
statusAlarm.SetLevel(1); // Destroyed
|
||||
if (printSimulationState) // +0x104
|
||||
{
|
||||
PrintState(); // vtable +0x34 @4ac8c0
|
||||
}
|
||||
dz->damageLevel = 1.0f;
|
||||
if (vitalSubsystem) // +0xE4
|
||||
{
|
||||
// the owner Mech's graphicAlarm (+0x2C) to level 9 -- the same
|
||||
// fall/death level the leg-destruction path raises. Mech is an
|
||||
// incomplete type here; the bridge lives in mechdmg.cpp.
|
||||
extern void BTMechVitalSubsystemKill(void *owner_mech);
|
||||
BTMechVitalSubsystemKill(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// @0x4ac274 -- the AMMO-EXPLOSION fan-out (Gitea #46, re-read from the raw
|
||||
// decomp). The old reconstruction here was wrong in kind: it "re-applied the
|
||||
@@ -536,8 +628,8 @@ int
|
||||
// first pass: prime to "unset"
|
||||
subsystem_resource->criticalReference = ResourceUnset; // res+0xe0 = -1.0f
|
||||
for (int i = 0; i < 5; ++i)
|
||||
subsystem_resource->armorByFacing[i] = ResourceUnset; // res+0x30.. = -1.0f
|
||||
subsystem_resource->structureReference = ResourceUnset; // res+0x44
|
||||
subsystem_resource->damageTypePoints[i] = ResourceUnset; // res+0x30.. = -1.0f
|
||||
subsystem_resource->weaponDamagePoints = ResourceUnset; // res+0x44
|
||||
subsystem_resource->vitalSubsystemIndex = -1; // res+0x48
|
||||
memset(subsystem_resource->videoObjectName, 0, 128); // res+0x4c
|
||||
strcpy(subsystem_resource->videoObjectName, "None"); // DAT_0050dfc0
|
||||
@@ -570,6 +662,38 @@ int
|
||||
Get_Segment_Index(model_file, model_name, directories, vitalName); // FUN_004274f8
|
||||
}
|
||||
|
||||
// #80 -- the armour/crit keys the parse never consumed (binary key strings
|
||||
// @0x50e09d..0x50e15d, in this order). Their absence is the second half of
|
||||
// why the subsystem's private zone could never take damage: the ctor's
|
||||
// scale block normalized ResourceUnset garbage (and wrote it through the
|
||||
// wrong layout besides). WeaponDamagePoints and CriticalHitScoreBonus are
|
||||
// REQUIRED by the binary (" Must have a ..." @0x50e0b0/@0x50e0e6); the five
|
||||
// per-damage-type keys are optional.
|
||||
if (!model_file->GetEntry(subsystem_name, "WeaponDamagePoints",
|
||||
&subsystem_resource->weaponDamagePoints) // 0x50e09d
|
||||
&& subsystem_resource->weaponDamagePoints == ResourceUnset)
|
||||
{
|
||||
DebugStream << subsystem_name << " Must have a WeaponDamagePoints"; // 0x50e0b0
|
||||
return False;
|
||||
}
|
||||
if (!model_file->GetEntry(subsystem_name, "CriticalHitScoreBonus",
|
||||
&subsystem_resource->criticalReference) // 0x50e0d0
|
||||
&& subsystem_resource->criticalReference == ResourceUnset)
|
||||
{
|
||||
DebugStream << subsystem_name << " Must have a CriticalHitScoreBonus"; // 0x50e0e6
|
||||
return False;
|
||||
}
|
||||
model_file->GetEntry(subsystem_name, "CollisionDamagePoints",
|
||||
&subsystem_resource->damageTypePoints[0]); // 0x50e109
|
||||
model_file->GetEntry(subsystem_name, "BallisticDamagePoints",
|
||||
&subsystem_resource->damageTypePoints[1]); // 0x50e11f
|
||||
model_file->GetEntry(subsystem_name, "ExplosiveDamagePoints",
|
||||
&subsystem_resource->damageTypePoints[2]); // 0x50e135
|
||||
model_file->GetEntry(subsystem_name, "LaserDamagePoints",
|
||||
&subsystem_resource->damageTypePoints[3]); // 0x50e14b
|
||||
model_file->GetEntry(subsystem_name, "EnergyDamagePoints",
|
||||
&subsystem_resource->damageTypePoints[4]); // 0x50e15d
|
||||
|
||||
Check_Fpu();
|
||||
return True;
|
||||
}
|
||||
|
||||
@@ -95,8 +95,15 @@ class Damage;
|
||||
struct MechSubsystem__SubsystemResource:
|
||||
public Subsystem::SubsystemResource
|
||||
{
|
||||
Scalar armorByFacing[5]; // +0x30 per-facing armour (default -1.0f)
|
||||
Scalar structureReference; // +0x44 health/structure divisor (default -1.0f)
|
||||
// task #80 RENAME (binary key strings @0x50e109..0x50e15d): these five are
|
||||
// per-DAMAGE-TYPE points in Damage enum order -- "CollisionDamagePoints" /
|
||||
// "Ballistic" / "Explosive" / "Laser" / "EnergyDamagePoints" -- NOT
|
||||
// per-facing armour (the old name was a guess).
|
||||
Scalar damageTypePoints[5]; // +0x30 per-damage-TYPE points (default -1.0f)
|
||||
// "WeaponDamagePoints" (@0x50e09d) -- REQUIRED (" Must have a
|
||||
// WeaponDamagePoints", @0x50e0b0) -- the subsystem's armour points,
|
||||
// copied to its private zone's defaultArmorPoints@0x140 by the ctor.
|
||||
Scalar weaponDamagePoints; // +0x44 "WeaponDamagePoints" (default -1.0f)
|
||||
int vitalSubsystemIndex; // +0x48 "VitalSubsystem" segment (default -1)
|
||||
char videoObjectName[128]; // +0x4C "VideoObjectName" (default "None")
|
||||
ResourceDescription::ResourceID
|
||||
@@ -111,7 +118,9 @@ class Damage;
|
||||
int _alarmModelReserved[2]; // +0xD0 remainder of the 12-byte 0xCC field
|
||||
int printSimulationState; // +0xD8 "PrintSimulationState"
|
||||
Scalar collisionCriticalHitWeight; // +0xDC "CollisionCriticalHitWeight"
|
||||
Scalar criticalReference; // +0xE0 (default -1.0f)
|
||||
Scalar criticalReference; // +0xE0 "CriticalHitScoreBonus" (@0x50e0d0,
|
||||
// REQUIRED) -- the damage-tally value a
|
||||
// crit on this subsystem is worth
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
@@ -245,9 +254,17 @@ class Damage;
|
||||
void
|
||||
PrintState(); // slot 13, @0x4ac8c0
|
||||
Logical
|
||||
HandleMessage(int message); // @0x4ac0bc
|
||||
HandleMessage(int message); // (address unknown -- the old @0x4ac0bc
|
||||
// claim was the CLASSMAP mislabel; that
|
||||
// address is TakeDamage, see below)
|
||||
// @0x4ac0bc -- vtable slot +0x24 [T1, raw disasm 2026-07-29]. Routes the
|
||||
// Damage into the subsystem's PRIVATE zone (zone vtbl+0x18), then on
|
||||
// damageLevel >= 1.0: statusAlarm=Destroyed, the PrintState gate, pin 1.0,
|
||||
// and -- if vitalSubsystem -- the owner mech's graphicAlarm to level 9 (the
|
||||
// vital-subsystem KILL, issue #28). Was an empty btstubs stand-in, which is
|
||||
// half of why crits never registered (#80). Defined in mechsub.cpp.
|
||||
virtual void
|
||||
TakeDamage(Damage &damage); // slot via this+0x24 (see DamageDelta)
|
||||
TakeDamage(Damage &damage); // @0x4ac0bc (vtable +0x24)
|
||||
Logical
|
||||
IsDamaged(); // @0x4ac9c8 -- NOVICE-experience lockout predicate (player+0x274 == 0), NOT a damage query (issue #2); see mechsub.cpp
|
||||
|
||||
@@ -269,6 +286,16 @@ class Damage;
|
||||
Scalar GetSubsystemDamageLevel() const;
|
||||
void SetSubsystemDamageLevel(Scalar level);
|
||||
|
||||
// #80: the crit-tally worth of this subsystem ("CriticalHitScoreBonus",
|
||||
// member @0x108) -- the TakeDamage handler adds it per landed crit
|
||||
// (binary @0x4a0473: tally += critted->+0x108).
|
||||
Scalar CriticalScoreBonus() const { return criticalReference; }
|
||||
|
||||
// #83 (collision rattle): the authored lottery weight the collision-
|
||||
// damage distributor (FUN_0049ffcc @0x4a00f2) accumulates per eligible
|
||||
// subsystem against a [0,1) roll.
|
||||
Scalar CollisionCritWeight() const { return collisionCriticalHitWeight; }
|
||||
|
||||
// task #2 (crit propagation): the zone-side crit paths
|
||||
// (Mech__DamageZone::CriticalHit @0049ccc4 / SendSubsystemDamage
|
||||
// @0049c9a8) reach these subsystem facets.
|
||||
|
||||
@@ -327,6 +327,20 @@ MechWeapon::MechWeapon(
|
||||
|
||||
rangeToTarget = 0.0f; // 0x324
|
||||
targetWithinRange = False; // 0x34C
|
||||
|
||||
// #95-follow-up WEAPON SPEC AUDIT (BT_SPEC_LOG): one line per weapon with
|
||||
// the AUTHORED numbers, so "missile damage is too low" can be argued
|
||||
// against the shipped data instead of memory. damageAmount here is the
|
||||
// authored TOTAL (the MissileLauncher ctor divides by missileCount after
|
||||
// this, mirroring @004bcff0).
|
||||
if (getenv("BT_SPEC_LOG"))
|
||||
DEBUG_STREAM << "[wspec] " << (GetName() ? GetName() : "?")
|
||||
<< " dmg=" << damageData.damageAmount
|
||||
<< " type=" << (int)damageData.damageType
|
||||
<< " recycle=" << rechargeRate
|
||||
<< " range=" << weaponRange
|
||||
<< " heat=" << heatCostToFire
|
||||
<< "\n" << std::flush;
|
||||
// segmentReference/pipSegment were phantom tail members (past the binary 0x3F0 end);
|
||||
// removed. The muzzle segment index is the inherited base slot this+0xdc (used by
|
||||
// GetMuzzlePoint); the pip mount is gated by useConfiguredPip in DrawWeaponPip.
|
||||
@@ -405,7 +419,11 @@ Logical
|
||||
void
|
||||
MechWeapon::TakeDamage(Damage &damage)
|
||||
{
|
||||
Subsystem::TakeDamage(damage); // engine base (real chain: @004b0efc)
|
||||
// FIELD CRASH GUARD (2026-07-29, build 641): the engine base derefs
|
||||
// damageZone unguarded; an Owens weapon reached here with zone == NULL
|
||||
// the first night crits could land (#80). See ApplyDamageAndMeasure.
|
||||
if (this->Subsystem::damageZone != 0)
|
||||
Subsystem::TakeDamage(damage); // engine base (real chain: @004b0efc)
|
||||
|
||||
// Port behavior (kept): a destroyed weapon clears its display alarm;
|
||||
// otherwise the alarm tracks the resolved damage-zone state.
|
||||
|
||||
+530
-441
@@ -1,442 +1,531 @@
|
||||
//============================================================================//
|
||||
// File: mislanch.cpp //
|
||||
// Project: BattleTech //
|
||||
// Contents: Weapon Which Launches a missile (MissileLauncher : ProjectileWeapon)//
|
||||
//----------------------------------------------------------------------------//
|
||||
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
|
||||
// PROPRIETARY AND CONFIDENTIAL //
|
||||
//============================================================================//
|
||||
//
|
||||
// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra
|
||||
// pseudo-C in part_013.c; member / method names follow the surviving
|
||||
// MISLANCH.HPP, the resource-parser tag strings, and the reconstructed
|
||||
// mechweap.hpp / heat.hpp siblings. Each non-trivial method cites the
|
||||
// originating @ADDR.
|
||||
//
|
||||
// Class map for the projectile-weapon branch (resolved from vtable + ctor
|
||||
// chaining + CreateStreamedSubsystem resource strings):
|
||||
// ProjectileWeapon ctor @004bc3fc dtor @004bc688 vtable @0051242c
|
||||
// CSS @004bc7cc (TracerInterval, AmmoBin,
|
||||
// MinTimeOfFlight, MinJamChance,
|
||||
// MinVoltagePercentToFire)
|
||||
// MissileLauncher ctor @004bcff0 dtor @004bd060 vtable @00512570
|
||||
// CSS @004bd08c (MissileCount, MuzzleVelocity)
|
||||
//
|
||||
// ProjectileWeapon (base) member offsets used below:
|
||||
// @0x3ac this[0xeb] damageAmount (divided by missileCount in ctor)
|
||||
// @0x3d4 this[0xf5] per-salvo count copy
|
||||
// @0x3f0 this[0xfc] MinTimeOfFlight timer base
|
||||
// @0x3fc this[0xff] MinJamChance
|
||||
// @0x404 this[0x101] MinVoltagePercentToFire
|
||||
// @0x40c this[0x103] MinTimeOfFlight
|
||||
// @0x410 this[0x104] (MissileLauncher) muzzleVelocity (Vector3D)
|
||||
// @0x418 this[0x106] jam-chance-derived scalar
|
||||
// @0x438 this[0x10e] TracerInterval
|
||||
// @0x43c this[0x10f] AmmoBin SharedData connection (FUN_004bcbb0)
|
||||
// @0x448 this[0x112] (MissileLauncher) missileCount
|
||||
//
|
||||
// Helper-function name mapping:
|
||||
// FUN_004bc3fc ProjectileWeapon base constructor
|
||||
// FUN_004bc7cc ProjectileWeapon::CreateStreamedSubsystem
|
||||
// FUN_004bcbb0 AmmoBin-connection ctor (SharedData, vtable @00512424)
|
||||
// FUN_004b964c MechWeapon::TakeDamage FUN_004b9690 WriteUpdateRecord
|
||||
// FUN_004bcabc ProjectileWeapon::HandleMessage (msg 2 -> jam alarm)
|
||||
// FUN_004bc6c8 ProjectileWeapon::PrintState (Jammed/NoAmmo/Trigger...)
|
||||
// FUN_004bcc60 spawn-a-Missile (FireWeapon path) FUN_004bf8bc Missile::New
|
||||
// FUN_00408440 Point3D/Vector3D copy FUN_00404088/004040d8 resource get str/int
|
||||
// FUN_00404088 ResourceFile::GetString FUN_00408944 parse Vector3D from text
|
||||
// FUN_004084fc Vector3D approx-equal FUN_004dbb24 error-string append
|
||||
//
|
||||
//============================================================================//
|
||||
// File: mislanch.cpp //
|
||||
// Project: BattleTech //
|
||||
// Contents: Weapon Which Launches a missile (MissileLauncher : ProjectileWeapon)//
|
||||
//----------------------------------------------------------------------------//
|
||||
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
|
||||
// PROPRIETARY AND CONFIDENTIAL //
|
||||
//============================================================================//
|
||||
//
|
||||
// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra
|
||||
// pseudo-C in part_013.c; member / method names follow the surviving
|
||||
// MISLANCH.HPP, the resource-parser tag strings, and the reconstructed
|
||||
// mechweap.hpp / heat.hpp siblings. Each non-trivial method cites the
|
||||
// originating @ADDR.
|
||||
//
|
||||
// Class map for the projectile-weapon branch (resolved from vtable + ctor
|
||||
// chaining + CreateStreamedSubsystem resource strings):
|
||||
// ProjectileWeapon ctor @004bc3fc dtor @004bc688 vtable @0051242c
|
||||
// CSS @004bc7cc (TracerInterval, AmmoBin,
|
||||
// MinTimeOfFlight, MinJamChance,
|
||||
// MinVoltagePercentToFire)
|
||||
// MissileLauncher ctor @004bcff0 dtor @004bd060 vtable @00512570
|
||||
// CSS @004bd08c (MissileCount, MuzzleVelocity)
|
||||
//
|
||||
// ProjectileWeapon (base) member offsets used below:
|
||||
// @0x3ac this[0xeb] damageAmount (divided by missileCount in ctor)
|
||||
// @0x3d4 this[0xf5] per-salvo count copy
|
||||
// @0x3f0 this[0xfc] MinTimeOfFlight timer base
|
||||
// @0x3fc this[0xff] MinJamChance
|
||||
// @0x404 this[0x101] MinVoltagePercentToFire
|
||||
// @0x40c this[0x103] MinTimeOfFlight
|
||||
// @0x410 this[0x104] (MissileLauncher) muzzleVelocity (Vector3D)
|
||||
// @0x418 this[0x106] jam-chance-derived scalar
|
||||
// @0x438 this[0x10e] TracerInterval
|
||||
// @0x43c this[0x10f] AmmoBin SharedData connection (FUN_004bcbb0)
|
||||
// @0x448 this[0x112] (MissileLauncher) missileCount
|
||||
//
|
||||
// Helper-function name mapping:
|
||||
// FUN_004bc3fc ProjectileWeapon base constructor
|
||||
// FUN_004bc7cc ProjectileWeapon::CreateStreamedSubsystem
|
||||
// FUN_004bcbb0 AmmoBin-connection ctor (SharedData, vtable @00512424)
|
||||
// FUN_004b964c MechWeapon::TakeDamage FUN_004b9690 WriteUpdateRecord
|
||||
// FUN_004bcabc ProjectileWeapon::HandleMessage (msg 2 -> jam alarm)
|
||||
// FUN_004bc6c8 ProjectileWeapon::PrintState (Jammed/NoAmmo/Trigger...)
|
||||
// FUN_004bcc60 spawn-a-Missile (FireWeapon path) FUN_004bf8bc Missile::New
|
||||
// FUN_00408440 Point3D/Vector3D copy FUN_00404088/004040d8 resource get str/int
|
||||
// FUN_00404088 ResourceFile::GetString FUN_00408944 parse Vector3D from text
|
||||
// FUN_004084fc Vector3D approx-equal FUN_004dbb24 error-string append
|
||||
//
|
||||
|
||||
#include <bt.hpp>
|
||||
#pragma hdrstop
|
||||
|
||||
#if !defined(MISLANCH_HPP)
|
||||
# include <mislanch.hpp>
|
||||
#endif
|
||||
#if !defined(MISSILE_HPP)
|
||||
# include <missile.hpp>
|
||||
#endif
|
||||
#if !defined(AMMOBIN_HPP)
|
||||
# include <ammobin.hpp>
|
||||
#endif
|
||||
#if !defined(TESTBT_HPP)
|
||||
# include <testbt.hpp>
|
||||
#endif
|
||||
#if !defined(APP_HPP)
|
||||
# include <app.hpp> // #84: application -> host manager
|
||||
#endif
|
||||
#include <hostmgr.hpp> // #84: HostManager::GetEntityPointer (salvo target resolve)
|
||||
#include <math.h>
|
||||
|
||||
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a
|
||||
// complete-Mech TU with the render + damage path). A fired missile becomes a tracked
|
||||
// projectile that flies to the target + delivers this weapon's damage on impact.
|
||||
// Missile-arc wave: launch_velocity = the authored MuzzleVelocity VECTOR in the
|
||||
// shooter's frame (up-tilted rack launch); damage <= 0 = a VISUAL-ONLY round.
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1, int splash_burst = 0, int muzzle_seg = -1,
|
||||
int damage_type = 2 /*ExplosiveDamageType default*/,
|
||||
Scalar thrust_accel = 0.0f, Scalar thrust_burn = 0.0f /*#84 thruster*/,
|
||||
int salvo_lead = 1 /*ONE Explosion per salvo*/);
|
||||
// #84: resolve this launcher's authored thruster (burn s, accel u/s^2) through
|
||||
// its connected bin's round-model resource. 0/0 when unresolvable (flies at
|
||||
// the old constant eject speed -- never worse than before).
|
||||
extern void *BTWeaponAmmoBin(void *weapon); // projweap.cpp
|
||||
extern int BTAmmoBinModelFile(void *bin); // ammobin.cpp
|
||||
extern int BTMissileThrustOf(int model_id, float *out_burn, float *out_accel); // mech4.cpp
|
||||
|
||||
//###########################################################################
|
||||
// Port-side salvo replication state (missile-visibility wave).
|
||||
//
|
||||
// The launcher OBJECT is byte-locked at 0x44C (factory alloc) -- salvo state
|
||||
// cannot live on it. Keyed by launcher identity: on a MASTER, `fired` counts
|
||||
// FireWeapon salvos (serialized in the extended update record); on a
|
||||
// REPLICANT, `seen` tracks the last counter applied (-1 = unsynced, so a
|
||||
// freshly-joined peer does not replay the master's historical count).
|
||||
//###########################################################################
|
||||
namespace {
|
||||
struct BTSalvoState
|
||||
{
|
||||
const void *owner;
|
||||
int fired; // master: salvos launched
|
||||
int seen; // replicant: last counter applied (-1 = unsynced)
|
||||
Point3D target; // master: the last salvo's aim point
|
||||
EntityID targetID; // master: the locked target (#84; Null = point fire)
|
||||
};
|
||||
BTSalvoState gSalvoTable[64];
|
||||
|
||||
BTSalvoState &BTSalvoOf(const void *launcher)
|
||||
{
|
||||
int freeSlot = -1;
|
||||
for (int i = 0; i < 64; ++i)
|
||||
{
|
||||
if (gSalvoTable[i].owner == launcher)
|
||||
return gSalvoTable[i];
|
||||
if (gSalvoTable[i].owner == 0 && freeSlot < 0)
|
||||
freeSlot = i;
|
||||
}
|
||||
BTSalvoState &s = gSalvoTable[(freeSlot >= 0) ? freeSlot : 0];
|
||||
s.owner = launcher;
|
||||
s.fired = 0;
|
||||
s.seen = -1;
|
||||
s.target = Point3D(0.0f, 0.0f, 0.0f);
|
||||
s.targetID = EntityID::Null;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// MissileLauncher
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
|
||||
//#############################################################################
|
||||
// Shared Data Support
|
||||
//
|
||||
Derivation
|
||||
MissileLauncher::ClassDerivations(
|
||||
&ProjectileWeapon::ClassDerivations,
|
||||
"MissileLauncher"
|
||||
);
|
||||
|
||||
// task #6: inherit ProjectileWeapon's handler set (see emitter.cpp note).
|
||||
Receiver::MessageHandlerSet&
|
||||
MissileLauncher::GetMessageHandlers()
|
||||
{
|
||||
static Receiver::MessageHandlerSet messageHandlers(
|
||||
ProjectileWeapon::GetMessageHandlers()); // copy-inherit
|
||||
return messageHandlers;
|
||||
}
|
||||
Simulation::AttributeIndexSet MissileLauncher::AttributeIndex;
|
||||
|
||||
MissileLauncher::SharedData
|
||||
MissileLauncher::DefaultData(
|
||||
&MissileLauncher::ClassDerivations,
|
||||
MissileLauncher::GetMessageHandlers(),
|
||||
MechWeapon::GetAttributeIndex(), // gauge wave: inherit temps/InputVoltage/OutputVoltage/PercentDone (was empty)
|
||||
MissileLauncher::StateCount
|
||||
);
|
||||
|
||||
//#############################################################################
|
||||
// Construction
|
||||
//
|
||||
// @004bcff0 MissileLauncher::MissileLauncher(...)
|
||||
//
|
||||
// Chains the ProjectileWeapon base ctor, stamps the MissileLauncher vtable,
|
||||
// then reads the two launcher resource fields and splits the damage budget
|
||||
// evenly across the salvo so each spawned Missile carries
|
||||
// damageAmount/missileCount.
|
||||
//
|
||||
MissileLauncher::MissileLauncher(
|
||||
Mech *owner,
|
||||
int subsystem_ID,
|
||||
SubsystemResource *subsystem_resource,
|
||||
SharedData &default_data)
|
||||
:
|
||||
// ProjectileWeapon base ctor (ammo bin, jam, tracer, targeting).
|
||||
ProjectileWeapon(owner, subsystem_ID, subsystem_resource, default_data) // @004bc3fc
|
||||
{
|
||||
// vtable installed by the compiler (PTR_FUN_00512570)
|
||||
|
||||
missileCount = subsystem_resource->missileCount; // @0x448 resource +0x1d0
|
||||
|
||||
#include <bt.hpp>
|
||||
#pragma hdrstop
|
||||
|
||||
#if !defined(MISLANCH_HPP)
|
||||
# include <mislanch.hpp>
|
||||
#endif
|
||||
#if !defined(MISSILE_HPP)
|
||||
# include <missile.hpp>
|
||||
#endif
|
||||
#if !defined(AMMOBIN_HPP)
|
||||
# include <ammobin.hpp>
|
||||
#endif
|
||||
#if !defined(TESTBT_HPP)
|
||||
# include <testbt.hpp>
|
||||
#endif
|
||||
#include <math.h>
|
||||
|
||||
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a
|
||||
// complete-Mech TU with the render + damage path). A fired missile becomes a tracked
|
||||
// projectile that flies to the target + delivers this weapon's damage on impact.
|
||||
// Missile-arc wave: launch_velocity = the authored MuzzleVelocity VECTOR in the
|
||||
// shooter's frame (up-tilted rack launch); damage <= 0 = a VISUAL-ONLY round.
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1, int splash_burst = 0, int muzzle_seg = -1,
|
||||
int damage_type = 2 /*ExplosiveDamageType default*/);
|
||||
|
||||
//###########################################################################
|
||||
// Port-side salvo replication state (missile-visibility wave).
|
||||
//
|
||||
// The launcher OBJECT is byte-locked at 0x44C (factory alloc) -- salvo state
|
||||
// cannot live on it. Keyed by launcher identity: on a MASTER, `fired` counts
|
||||
// FireWeapon salvos (serialized in the extended update record); on a
|
||||
// REPLICANT, `seen` tracks the last counter applied (-1 = unsynced, so a
|
||||
// freshly-joined peer does not replay the master's historical count).
|
||||
//###########################################################################
|
||||
namespace {
|
||||
struct BTSalvoState
|
||||
{
|
||||
const void *owner;
|
||||
int fired; // master: salvos launched
|
||||
int seen; // replicant: last counter applied (-1 = unsynced)
|
||||
Point3D target; // master: the last salvo's aim point
|
||||
};
|
||||
BTSalvoState gSalvoTable[64];
|
||||
|
||||
BTSalvoState &BTSalvoOf(const void *launcher)
|
||||
{
|
||||
int freeSlot = -1;
|
||||
for (int i = 0; i < 64; ++i)
|
||||
{
|
||||
if (gSalvoTable[i].owner == launcher)
|
||||
return gSalvoTable[i];
|
||||
if (gSalvoTable[i].owner == 0 && freeSlot < 0)
|
||||
freeSlot = i;
|
||||
}
|
||||
BTSalvoState &s = gSalvoTable[(freeSlot >= 0) ? freeSlot : 0];
|
||||
s.owner = launcher;
|
||||
s.fired = 0;
|
||||
s.seen = -1;
|
||||
s.target = Point3D(0.0f, 0.0f, 0.0f);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// MissileLauncher
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
|
||||
//#############################################################################
|
||||
// Shared Data Support
|
||||
//
|
||||
Derivation
|
||||
MissileLauncher::ClassDerivations(
|
||||
&ProjectileWeapon::ClassDerivations,
|
||||
"MissileLauncher"
|
||||
);
|
||||
|
||||
// task #6: inherit ProjectileWeapon's handler set (see emitter.cpp note).
|
||||
Receiver::MessageHandlerSet&
|
||||
MissileLauncher::GetMessageHandlers()
|
||||
{
|
||||
static Receiver::MessageHandlerSet messageHandlers(
|
||||
ProjectileWeapon::GetMessageHandlers()); // copy-inherit
|
||||
return messageHandlers;
|
||||
}
|
||||
Simulation::AttributeIndexSet MissileLauncher::AttributeIndex;
|
||||
|
||||
MissileLauncher::SharedData
|
||||
MissileLauncher::DefaultData(
|
||||
&MissileLauncher::ClassDerivations,
|
||||
MissileLauncher::GetMessageHandlers(),
|
||||
MechWeapon::GetAttributeIndex(), // gauge wave: inherit temps/InputVoltage/OutputVoltage/PercentDone (was empty)
|
||||
MissileLauncher::StateCount
|
||||
);
|
||||
|
||||
//#############################################################################
|
||||
// Construction
|
||||
//
|
||||
// @004bcff0 MissileLauncher::MissileLauncher(...)
|
||||
//
|
||||
// Chains the ProjectileWeapon base ctor, stamps the MissileLauncher vtable,
|
||||
// then reads the two launcher resource fields and splits the damage budget
|
||||
// evenly across the salvo so each spawned Missile carries
|
||||
// damageAmount/missileCount.
|
||||
//
|
||||
MissileLauncher::MissileLauncher(
|
||||
Mech *owner,
|
||||
int subsystem_ID,
|
||||
SubsystemResource *subsystem_resource,
|
||||
SharedData &default_data)
|
||||
:
|
||||
// ProjectileWeapon base ctor (ammo bin, jam, tracer, targeting).
|
||||
ProjectileWeapon(owner, subsystem_ID, subsystem_resource, default_data) // @004bc3fc
|
||||
{
|
||||
// vtable installed by the compiler (PTR_FUN_00512570)
|
||||
|
||||
missileCount = subsystem_resource->missileCount; // @0x448 resource +0x1d0
|
||||
launchVelocity = subsystem_resource->muzzleVelocity; // @0x410 (inherited ProjectileWeapon slot; was aliased "muzzleVelocity")
|
||||
|
||||
// per-salvo count copy + per-missile damage (write the inherited Damage fields)
|
||||
this->damageData.burstCount = missileCount; // @0x3d4 this[0xf5] (was shadow "salvoCount")
|
||||
this->damageData.damageAmount /= (Scalar)missileCount; // @0x3ac this[0xeb] /= missileCount
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// Destruction
|
||||
//
|
||||
// @004bd060 ~MissileLauncher() -- restores the launcher vtable, chains the
|
||||
// ProjectileWeapon destructor (which releases the AmmoBin connection @00512424).
|
||||
//
|
||||
MissileLauncher::~MissileLauncher()
|
||||
{
|
||||
// vtable reset + base ~ProjectileWeapon chaining handled by the compiler.
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// CreateStreamedSubsystem
|
||||
//
|
||||
// @004bd08c Reads MissileCount (+0x1d0) and MuzzleVelocity (+0x1d4) after
|
||||
// chaining ProjectileWeapon::CreateStreamedSubsystem (@004bc7cc). Stamps the
|
||||
// streamed-record class/size header bytes, then validates both fields,
|
||||
// emitting "missing MissileCount!" / "missing MuzzleVelocity!" on failure.
|
||||
//
|
||||
Logical MissileLauncher::CreateStreamedSubsystem(
|
||||
ResourceFile *resource_file,
|
||||
NotationFile *model_file,
|
||||
const char *model_name,
|
||||
const char *subsystem_name,
|
||||
SubsystemResource *subsystem_resource,
|
||||
NotationFile *subsystem_file,
|
||||
const ResourceDirectories *directories,
|
||||
int passes)
|
||||
{
|
||||
// chain the ProjectileWeapon record parser (ammo/jam/tracer fields)
|
||||
if (!ProjectileWeapon::CreateStreamedSubsystem(
|
||||
resource_file, model_file, model_name, subsystem_name,
|
||||
subsystem_resource, subsystem_file, directories, passes)) // @004bc7cc
|
||||
return False;
|
||||
|
||||
// stamp streamed-record header (classID / size bytes @+0x20/+0x24)
|
||||
// ... (record housekeeping, see @004bd08c) ...
|
||||
|
||||
if (passes == 1)
|
||||
subsystem_resource->missileCount = -1; // sentinel
|
||||
|
||||
// "MissileCount" (+0x1d0)
|
||||
if (!model_file->GetEntry(subsystem_name, "MissileCount",
|
||||
&subsystem_resource->missileCount)
|
||||
&& subsystem_resource->missileCount == -1)
|
||||
{
|
||||
DebugStream << subsystem_name << " missing MissileCount!" << endl; // @004bd08c
|
||||
return False;
|
||||
}
|
||||
|
||||
// "MuzzleVelocity" (+0x1d4, Vector3D parsed from text)
|
||||
const char *text = "Unspecified";
|
||||
Vector3D &mv = subsystem_resource->muzzleVelocity;
|
||||
if (!model_file->GetEntry(subsystem_name, "MuzzleVelocity", &text)
|
||||
&& (mv.x == 0.0f && mv.y == 0.0f && mv.z == 0.0f))
|
||||
{
|
||||
DebugStream << subsystem_name << " missing MuzzleVelocity!" << endl; // @004bd08c
|
||||
return False;
|
||||
}
|
||||
if (strcmp(text, "Unspecified") != 0)
|
||||
{
|
||||
// FUN_00408944 -- parse a "x y z" Vector3D from the resource text.
|
||||
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||
if (sscanf(text, "%f %f %f", &x, &y, &z) == 3)
|
||||
{
|
||||
mv.x = x; mv.y = y; mv.z = z;
|
||||
}
|
||||
}
|
||||
|
||||
return True;
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// FireWeapon (ProjectileWeapon spawn path, @004bcc60)
|
||||
//
|
||||
// Builds a streamed-entity spawn descriptor for a Missile (entity ClassID
|
||||
// 0xBD0), seeded from this weapon's muzzle transform, the explosion model
|
||||
// resource (this->explosionResourceID @0x3e4), the per-missile Damage block
|
||||
// (this->damageData @0x3a8) and the owning Mech's target. The descriptor is
|
||||
// handed to Missile::New (FUN_004bf8bc), which allocates the 0x368-byte
|
||||
// entity and runs the Missile ctor (@004bf5b4). Heat from firing is added
|
||||
// to the owner here (this->heatCostToFire -> owner heatLoad) when the weapon
|
||||
// is heat-bearing (FUN_004ad7d4).
|
||||
//
|
||||
// NOTE: this body lives in the ProjectileWeapon address range and is shared
|
||||
// by MissileLauncher; it is documented here because MISLANCH.HPP declares
|
||||
// FireWeapon() as the launcher's protected entry point.
|
||||
//
|
||||
void MissileLauncher::FireWeapon()
|
||||
{
|
||||
Check(this);
|
||||
|
||||
// Gitea #12: this body is heat + spawn ONLY, per the recovered @004bcc60
|
||||
// (part_013.c:8741+): NO viewFireEnable gate, NO ammo pull, NO recoil set.
|
||||
// All of that lives in the CALLER -- the recovered ProjectileWeaponSimulation
|
||||
// Loaded case (@4bbec2 view/target denial blip, @4bbee6 bin->FeedAmmo,
|
||||
// @4bbf51 recoil). The old early-returns here, under a caller that cycled
|
||||
// the alarm unconditionally, faked a full firing pip cycle on every denied
|
||||
// shot -- the "missiles cycle but never launch" incident mechanic.
|
||||
|
||||
// THE FIRING HEAT (task #9): heatCostToFire RAW into the launcher's own
|
||||
// pendingHeat (pre-scaled 1e7-unit resources: SRM6 = 5e7, LRM15 = 6.5e7)
|
||||
// -- the binary's add @part_013.c:8744-8747, gated on the heatable check.
|
||||
if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp -> player+0x260 experience gate; issue #2)
|
||||
{
|
||||
pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw
|
||||
}
|
||||
|
||||
// Resolve the muzzle / lead geometry (seeds each flying missile).
|
||||
Point3D muzzle;
|
||||
GetMuzzlePoint(muzzle); // @004b9948
|
||||
damageData.burstCount = missileCount; // @0x3d4 (was shadow "salvoCount")
|
||||
|
||||
// WAVE 7 Phase B: launch `missileCount` flying projectiles toward the owner's target.
|
||||
// (Port reconstruction -- the byte-exact world-entity Missile is blocked by the 2007
|
||||
// engine Entity base mismatch; see BTPushProjectile / mech4.cpp.) Speed = |launchVelocity|
|
||||
// (resource MuzzleVelocity); per-missile damage = damageData.damageAmount (already
|
||||
// divided by missileCount in the ctor).
|
||||
char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech)
|
||||
void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up)
|
||||
Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point
|
||||
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
|
||||
+ launchVelocity.y*launchVelocity.y
|
||||
+ launchVelocity.z*launchVelocity.z);
|
||||
// Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target
|
||||
// slot isn't populated (bring-up); missileCount missiles per salvo. The authored
|
||||
// MuzzleVelocity VECTOR rides along (missile-arc wave): the round leaves the rack
|
||||
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
|
||||
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
for (int i = 0; i < nmiss; ++i)
|
||||
// DAMAGE + SPLASH ONCE PER SALVO (task #62 fix): the arcade fires ONE
|
||||
// cluster Missile per trigger, and its zone damage is applied EXACTLY ONCE
|
||||
// -- DamageZone::TakeDamage (@0041e4e0) is `damageLevel += amount*scale`
|
||||
// and IGNORES burstCount. The port draws that cluster as N visual rounds;
|
||||
// damaging on EVERY round applied the hit N times = ~missileCount-x too
|
||||
// lethal (the "2-shot kill" regression). Only the LEAD round (i==0)
|
||||
// carries the missile's damageAmount + the cluster splash; the rest are
|
||||
// VISUAL (damage 0) -- the ripple of tracers, no extra damage.
|
||||
BTPushProjectile(muzzle, o, target, targetPos, speed,
|
||||
(i == 0) ? damageData.damageAmount : 0.0f,
|
||||
&launchVelocity, 1,
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/,
|
||||
(i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/,
|
||||
GetSegmentIndex() /*task #67: launch through the MOUNT frame (torso twist)*/,
|
||||
(int)damageData.damageType /*missile = Explosive (authentic)*/);
|
||||
|
||||
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
|
||||
// the aim point; the extended update record carries both to peer nodes.
|
||||
// The CALLER's Loaded case makes the two `updateModel |= 1` marks
|
||||
// (@4bbf05/@4bbf4c == ForceUpdate) that serialize the record.
|
||||
// Gitea #12 / gotcha #20: the old `simulationFlags |= 0x1` here set the
|
||||
// engine DelayWatchersFlag -- permanently muting this launcher's audio
|
||||
// watchers after the first salvo; removed.
|
||||
{
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
++s.fired;
|
||||
s.target = targetPos;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// WriteUpdateRecord / ReadUpdateRecord (PORT record extension -- missile-
|
||||
// visibility wave; see the mislanch.hpp banner). The master serializes the
|
||||
// salvo counter + aim point after the MechWeapon base fields; the replicant
|
||||
// edge-detects the counter and mirrors the salvo as VISUAL-ONLY rounds
|
||||
// (damage 0 -- the master's own rounds deliver the damage cross-pod).
|
||||
//
|
||||
void
|
||||
MissileLauncher::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
|
||||
{
|
||||
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields)
|
||||
|
||||
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
rec->recordLength = sizeof(MissileLauncher__UpdateRecord);
|
||||
rec->salvoCounter = s.fired;
|
||||
rec->salvoRounds = missileCount;
|
||||
rec->salvoTarget = s.target;
|
||||
}
|
||||
|
||||
void
|
||||
MissileLauncher::ReadUpdateRecord(Simulation__UpdateRecord *message)
|
||||
{
|
||||
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
|
||||
|
||||
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
if (s.seen < 0)
|
||||
{
|
||||
// first sync after spawn/join: adopt the master's count silently --
|
||||
// no replay of salvos fired before we could see them.
|
||||
s.seen = rec->salvoCounter;
|
||||
}
|
||||
else if (rec->salvoCounter != s.seen)
|
||||
{
|
||||
s.seen = rec->salvoCounter;
|
||||
|
||||
// Mirror ONE salvo (a missed record collapses to one -- catch-up
|
||||
// replays would draw stale phantom volleys). Rounds fly the same
|
||||
// authored launch vector + seeker-loft arc as the master's, from
|
||||
// this replicant's own resolved rack ports, damage 0.
|
||||
Point3D mz;
|
||||
GetMuzzlePoint(mz); // @004b9948
|
||||
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
|
||||
+ launchVelocity.y*launchVelocity.y
|
||||
+ launchVelocity.z*launchVelocity.z);
|
||||
int n = rec->salvoRounds;
|
||||
if (n < 1 || n > 40)
|
||||
n = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
for (int i = 0; i < n; ++i)
|
||||
BTPushProjectile(mz, owner, 0 /*no entity: aim point only*/,
|
||||
rec->salvoTarget, spd, 0.0f /*VISUAL*/, &launchVelocity, 1,
|
||||
subsystemID /*per-round detonation resolve on this node*/,
|
||||
0, GetSegmentIndex() /*task #67 mount frame*/);
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
DEBUG_STREAM << "[projectile] REPLICANT salvo x" << n
|
||||
<< " at(" << rec->salvoTarget.x << "," << rec->salvoTarget.y
|
||||
<< "," << rec->salvoTarget.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// TakeDamage / DeathReset
|
||||
//
|
||||
// TakeDamage inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c).
|
||||
// DeathReset chains the ProjectileWeapon ammo/feed reset (@004bbaf8).
|
||||
//
|
||||
void MissileLauncher::TakeDamage(Damage &damage)
|
||||
{
|
||||
MechWeapon::TakeDamage(damage); // @004b964c
|
||||
}
|
||||
|
||||
void MissileLauncher::DeathReset(Logical /*full_reset*/)
|
||||
{
|
||||
// issue #22: the respawn sweep's dispatch vehicle -- chains the authentic
|
||||
// slot-10 RTIS [T1 vtable +0x28]; ammo itself refills in AmmoBin's own
|
||||
// DeathReset (the bins are separate roster subsystems).
|
||||
ProjectileWeapon::ResetToInitialState(); // @004bbaf8
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// TestInstance -- inherits the ProjectileWeapon/MechWeapon base check.
|
||||
//
|
||||
Logical MissileLauncher::TestInstance() const
|
||||
{
|
||||
return ProjectileWeapon::TestInstance();
|
||||
}
|
||||
|
||||
|
||||
//===========================================================================//
|
||||
// WAVE 7 factory bridge -- MissileLauncher (factory case 0xBD0, "BallisticWeapon"
|
||||
// mislabel). VDATA.h enum: 0xBD0 == MissileLauncherClassID. The real class
|
||||
// (ctor @004bcff0) is MissileLauncher : ProjectileWeapon (sizeof 0x44C); the
|
||||
// factory built a BallisticWeapon RECON_SUBSYS stub. alloc 0x44C. Ticks its
|
||||
// real per-frame sim (charge/ammo/heat, damage split across missileCount);
|
||||
// FireWeapon consumes a round + recoils but DEFERS the missile spawn (Phase B).
|
||||
//===========================================================================//
|
||||
Subsystem *CreateMissileLauncherSubsystem(Mech *owner, int id, void *seg)
|
||||
{
|
||||
Check(sizeof(MissileLauncher) <= 0x44C);
|
||||
return (Subsystem *) new (Memory::Allocate(0x44C))
|
||||
MissileLauncher(owner, id, (MissileLauncher::SubsystemResource *)seg, MissileLauncher::DefaultData);
|
||||
}
|
||||
launchVelocity = subsystem_resource->muzzleVelocity; // @0x410 (inherited ProjectileWeapon slot; was aliased "muzzleVelocity")
|
||||
|
||||
// per-salvo count copy + per-missile damage (write the inherited Damage fields)
|
||||
this->damageData.burstCount = missileCount; // @0x3d4 this[0xf5] (was shadow "salvoCount")
|
||||
this->damageData.damageAmount /= (Scalar)missileCount;
|
||||
if (getenv("BT_SPEC_LOG"))
|
||||
DEBUG_STREAM << "[wspec-msl] " << (GetName() ? GetName() : "?")
|
||||
<< " count=" << missileCount
|
||||
<< " perMissile=" << damageData.damageAmount
|
||||
<< " burstModel=[n/4..n]" << "\n" << std::flush; // @0x3ac this[0xeb] /= missileCount
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// Destruction
|
||||
//
|
||||
// @004bd060 ~MissileLauncher() -- restores the launcher vtable, chains the
|
||||
// ProjectileWeapon destructor (which releases the AmmoBin connection @00512424).
|
||||
//
|
||||
MissileLauncher::~MissileLauncher()
|
||||
{
|
||||
// vtable reset + base ~ProjectileWeapon chaining handled by the compiler.
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// CreateStreamedSubsystem
|
||||
//
|
||||
// @004bd08c Reads MissileCount (+0x1d0) and MuzzleVelocity (+0x1d4) after
|
||||
// chaining ProjectileWeapon::CreateStreamedSubsystem (@004bc7cc). Stamps the
|
||||
// streamed-record class/size header bytes, then validates both fields,
|
||||
// emitting "missing MissileCount!" / "missing MuzzleVelocity!" on failure.
|
||||
//
|
||||
Logical MissileLauncher::CreateStreamedSubsystem(
|
||||
ResourceFile *resource_file,
|
||||
NotationFile *model_file,
|
||||
const char *model_name,
|
||||
const char *subsystem_name,
|
||||
SubsystemResource *subsystem_resource,
|
||||
NotationFile *subsystem_file,
|
||||
const ResourceDirectories *directories,
|
||||
int passes)
|
||||
{
|
||||
// chain the ProjectileWeapon record parser (ammo/jam/tracer fields)
|
||||
if (!ProjectileWeapon::CreateStreamedSubsystem(
|
||||
resource_file, model_file, model_name, subsystem_name,
|
||||
subsystem_resource, subsystem_file, directories, passes)) // @004bc7cc
|
||||
return False;
|
||||
|
||||
// stamp streamed-record header (classID / size bytes @+0x20/+0x24)
|
||||
// ... (record housekeeping, see @004bd08c) ...
|
||||
|
||||
if (passes == 1)
|
||||
subsystem_resource->missileCount = -1; // sentinel
|
||||
|
||||
// "MissileCount" (+0x1d0)
|
||||
if (!model_file->GetEntry(subsystem_name, "MissileCount",
|
||||
&subsystem_resource->missileCount)
|
||||
&& subsystem_resource->missileCount == -1)
|
||||
{
|
||||
DebugStream << subsystem_name << " missing MissileCount!" << endl; // @004bd08c
|
||||
return False;
|
||||
}
|
||||
|
||||
// "MuzzleVelocity" (+0x1d4, Vector3D parsed from text)
|
||||
const char *text = "Unspecified";
|
||||
Vector3D &mv = subsystem_resource->muzzleVelocity;
|
||||
if (!model_file->GetEntry(subsystem_name, "MuzzleVelocity", &text)
|
||||
&& (mv.x == 0.0f && mv.y == 0.0f && mv.z == 0.0f))
|
||||
{
|
||||
DebugStream << subsystem_name << " missing MuzzleVelocity!" << endl; // @004bd08c
|
||||
return False;
|
||||
}
|
||||
if (strcmp(text, "Unspecified") != 0)
|
||||
{
|
||||
// FUN_00408944 -- parse a "x y z" Vector3D from the resource text.
|
||||
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||
if (sscanf(text, "%f %f %f", &x, &y, &z) == 3)
|
||||
{
|
||||
mv.x = x; mv.y = y; mv.z = z;
|
||||
}
|
||||
}
|
||||
|
||||
return True;
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// FireWeapon (ProjectileWeapon spawn path, @004bcc60)
|
||||
//
|
||||
// Builds a streamed-entity spawn descriptor for a Missile (entity ClassID
|
||||
// 0xBD0), seeded from this weapon's muzzle transform, the explosion model
|
||||
// resource (this->explosionResourceID @0x3e4), the per-missile Damage block
|
||||
// (this->damageData @0x3a8) and the owning Mech's target. The descriptor is
|
||||
// handed to Missile::New (FUN_004bf8bc), which allocates the 0x368-byte
|
||||
// entity and runs the Missile ctor (@004bf5b4). Heat from firing is added
|
||||
// to the owner here (this->heatCostToFire -> owner heatLoad) when the weapon
|
||||
// is heat-bearing (FUN_004ad7d4).
|
||||
//
|
||||
// NOTE: this body lives in the ProjectileWeapon address range and is shared
|
||||
// by MissileLauncher; it is documented here because MISLANCH.HPP declares
|
||||
// FireWeapon() as the launcher's protected entry point.
|
||||
//
|
||||
void MissileLauncher::FireWeapon()
|
||||
{
|
||||
Check(this);
|
||||
|
||||
// Gitea #12: this body is heat + spawn ONLY, per the recovered @004bcc60
|
||||
// (part_013.c:8741+): NO viewFireEnable gate, NO ammo pull, NO recoil set.
|
||||
// All of that lives in the CALLER -- the recovered ProjectileWeaponSimulation
|
||||
// Loaded case (@4bbec2 view/target denial blip, @4bbee6 bin->FeedAmmo,
|
||||
// @4bbf51 recoil). The old early-returns here, under a caller that cycled
|
||||
// the alarm unconditionally, faked a full firing pip cycle on every denied
|
||||
// shot -- the "missiles cycle but never launch" incident mechanic.
|
||||
|
||||
// THE FIRING HEAT (task #9): heatCostToFire RAW into the launcher's own
|
||||
// pendingHeat (pre-scaled 1e7-unit resources: SRM6 = 5e7, LRM15 = 6.5e7)
|
||||
// -- the binary's add @part_013.c:8744-8747, gated on the heatable check.
|
||||
if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp -> player+0x260 experience gate; issue #2)
|
||||
{
|
||||
pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw
|
||||
}
|
||||
|
||||
// Resolve the muzzle / lead geometry (seeds each flying missile).
|
||||
Point3D muzzle;
|
||||
GetMuzzlePoint(muzzle); // @004b9948
|
||||
damageData.burstCount = missileCount; // @0x3d4 (was shadow "salvoCount")
|
||||
|
||||
// WAVE 7 Phase B: launch `missileCount` flying projectiles toward the owner's target.
|
||||
// (Port reconstruction -- the byte-exact world-entity Missile is blocked by the 2007
|
||||
// engine Entity base mismatch; see BTPushProjectile / mech4.cpp.) Speed = |launchVelocity|
|
||||
// (resource MuzzleVelocity); per-missile damage = damageData.damageAmount (already
|
||||
// divided by missileCount in the ctor).
|
||||
char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech)
|
||||
void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up)
|
||||
Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point
|
||||
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
|
||||
+ launchVelocity.y*launchVelocity.y
|
||||
+ launchVelocity.z*launchVelocity.z);
|
||||
// Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target
|
||||
// slot isn't populated (bring-up); missileCount missiles per salvo. The authored
|
||||
// MuzzleVelocity VECTOR rides along (missile-arc wave): the round leaves the rack
|
||||
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
|
||||
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
float tBurn = 0.0f, tAccel = 0.0f; // #84 authored thruster
|
||||
{
|
||||
void *bin = BTWeaponAmmoBin(this);
|
||||
int mid = (bin != 0) ? BTAmmoBinModelFile(bin) : -1;
|
||||
int hit = 0;
|
||||
if (mid >= 0)
|
||||
hit = BTMissileThrustOf(mid, &tBurn, &tAccel);
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
DEBUG_STREAM << "[projectile] thrust resolve: bin=" << bin
|
||||
<< " modelID=" << mid << " hit=" << hit
|
||||
<< " burn=" << tBurn << " accel=" << tAccel << "\n" << std::flush;
|
||||
}
|
||||
for (int i = 0; i < nmiss; ++i)
|
||||
// DAMAGE + SPLASH ONCE PER SALVO (task #62 fix): the arcade fires ONE
|
||||
// cluster Missile per trigger, and its zone damage is applied EXACTLY ONCE
|
||||
// -- DamageZone::TakeDamage (@0041e4e0) is `damageLevel += amount*scale`
|
||||
// and IGNORES burstCount. The port draws that cluster as N visual rounds;
|
||||
// damaging on EVERY round applied the hit N times = ~missileCount-x too
|
||||
// lethal (the "2-shot kill" regression). Only the LEAD round (i==0)
|
||||
// carries the missile's damageAmount + the cluster splash; the rest are
|
||||
// VISUAL (damage 0) -- the ripple of tracers, no extra damage.
|
||||
//
|
||||
// The lead round carries the PER-MISSILE amount, exactly as the binary's
|
||||
// ctor prepared it (damageAmount /= missileCount, burstCount = missileCount).
|
||||
// The salvo total is reconstituted at IMPACT, where the round hands the
|
||||
// handler a burstCount and the handler applies the hit that many times
|
||||
// (Mech::TakeDamageMessageHandler @0x4a0423-0x4a04d8, mech.cpp) -- see the
|
||||
// cluster-burst note at the impact site in mech4.cpp. Do NOT pre-multiply
|
||||
// here: that would deliver the salvo as one lump on one zone, losing both
|
||||
// the per-burst zone re-roll and the arcade's hit-count variance.
|
||||
BTPushProjectile(muzzle, o, target, targetPos, speed,
|
||||
(i == 0) ? damageData.damageAmount : 0.0f,
|
||||
&launchVelocity, 1,
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/,
|
||||
(i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/,
|
||||
GetSegmentIndex() /*task #67: launch through the MOUNT frame (torso twist)*/,
|
||||
(int)damageData.damageType /*missile = Explosive (authentic)*/,
|
||||
tAccel, tBurn /*#84 thruster*/,
|
||||
(i == 0) /*ONE Explosion per salvo: the lead only*/);
|
||||
|
||||
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
|
||||
// the aim point; the extended update record carries both to peer nodes.
|
||||
// The CALLER's Loaded case makes the two `updateModel |= 1` marks
|
||||
// (@4bbf05/@4bbf4c == ForceUpdate) that serialize the record.
|
||||
// Gitea #12 / gotcha #20: the old `simulationFlags |= 0x1` here set the
|
||||
// engine DelayWatchersFlag -- permanently muting this launcher's audio
|
||||
// watchers after the first salvo; removed.
|
||||
{
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
++s.fired;
|
||||
s.target = targetPos;
|
||||
s.targetID = (target != 0)
|
||||
? ((Entity *)target)->GetEntityID() : EntityID::Null;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// WriteUpdateRecord / ReadUpdateRecord (PORT record extension -- missile-
|
||||
// visibility wave; see the mislanch.hpp banner). The master serializes the
|
||||
// salvo counter + aim point after the MechWeapon base fields; the replicant
|
||||
// edge-detects the counter and mirrors the salvo as VISUAL-ONLY rounds
|
||||
// (damage 0 -- the master's own rounds deliver the damage cross-pod).
|
||||
//
|
||||
void
|
||||
MissileLauncher::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
|
||||
{
|
||||
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields)
|
||||
|
||||
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
rec->recordLength = sizeof(MissileLauncher__UpdateRecord);
|
||||
rec->salvoCounter = s.fired;
|
||||
rec->salvoRounds = missileCount;
|
||||
rec->salvoTarget = s.target;
|
||||
rec->salvoTargetID = s.targetID;
|
||||
}
|
||||
|
||||
void
|
||||
MissileLauncher::ReadUpdateRecord(Simulation__UpdateRecord *message)
|
||||
{
|
||||
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
|
||||
|
||||
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
{
|
||||
static int s_rr = 0;
|
||||
if (s_rr++ < 200)
|
||||
DEBUG_STREAM << "[mlrec] len=" << (int)rec->recordLength
|
||||
<< "/" << (int)sizeof(MissileLauncher__UpdateRecord)
|
||||
<< " recID=" << (int)rec->recordID
|
||||
<< " subsys=" << (int)rec->subsystemID
|
||||
<< " ctr=" << rec->salvoCounter
|
||||
<< " n=" << rec->salvoRounds
|
||||
<< " aim=(" << rec->salvoTarget.x << "," << rec->salvoTarget.y
|
||||
<< "," << rec->salvoTarget.z << ")" << std::endl;
|
||||
}
|
||||
BTSalvoState &s = BTSalvoOf(this);
|
||||
if (s.seen < 0)
|
||||
{
|
||||
// first sync after spawn/join: adopt the master's count silently --
|
||||
// no replay of salvos fired before we could see them.
|
||||
s.seen = rec->salvoCounter;
|
||||
}
|
||||
else if (rec->salvoCounter != s.seen)
|
||||
{
|
||||
s.seen = rec->salvoCounter;
|
||||
|
||||
// Mirror ONE salvo (a missed record collapses to one -- catch-up
|
||||
// replays would draw stale phantom volleys). Rounds fly the same
|
||||
// authored launch vector + seeker-loft arc as the master's, from
|
||||
// this replicant's own resolved rack ports, damage 0.
|
||||
Point3D mz;
|
||||
GetMuzzlePoint(mz); // @004b9948
|
||||
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
|
||||
+ launchVelocity.y*launchVelocity.y
|
||||
+ launchVelocity.z*launchVelocity.z);
|
||||
int n = rec->salvoRounds;
|
||||
if (n < 1 || n > 40)
|
||||
n = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
float tBurn = 0.0f, tAccel = 0.0f; // #84: mirror the master's
|
||||
{ // thruster so peer missiles
|
||||
void *bin = BTWeaponAmmoBin(this); // fly at the same speed
|
||||
int mid = (bin != 0) ? BTAmmoBinModelFile(bin) : -1;
|
||||
if (mid >= 0)
|
||||
BTMissileThrustOf(mid, &tBurn, &tAccel);
|
||||
}
|
||||
// #84 -- home the mirror at the LIVE target, as the binary's GHOST
|
||||
// missiles did. The record carries the master's locked-target entity
|
||||
// ID; resolve it against THIS node's registry (the victim's local
|
||||
// master or replicant -- both track the true position here). The
|
||||
// frozen salvoTarget remains the launch aim + the fallback for point
|
||||
// fire, unresolved IDs (target died / not yet replicated here), and
|
||||
// short records from older builds (length-gated: a stale-build record
|
||||
// has no ID field and its tail would be garbage).
|
||||
Entity *mirror_tgt = 0;
|
||||
if (message->recordLength >= (int)sizeof(MissileLauncher__UpdateRecord)
|
||||
&& !(rec->salvoTargetID == EntityID::Null)
|
||||
&& application != 0 && application->GetHostManager() != 0)
|
||||
{
|
||||
mirror_tgt = application->GetHostManager()->
|
||||
GetEntityPointer(rec->salvoTargetID);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (mirror_tgt != 0 && !BTIsRegisteredMech(mirror_tgt))
|
||||
mirror_tgt = 0; // resolved to a non-mech: refuse
|
||||
}
|
||||
for (int i = 0; i < n; ++i)
|
||||
BTPushProjectile(mz, owner, mirror_tgt /*#84 live target; 0 -> point*/,
|
||||
rec->salvoTarget, spd, 0.0f /*VISUAL*/, &launchVelocity, 1,
|
||||
subsystemID /*per-round detonation resolve on this node*/,
|
||||
0, GetSegmentIndex() /*task #67 mount frame*/,
|
||||
2 /*Explosive*/, tAccel, tBurn,
|
||||
(i == 0) /*mirror lead: observers see ONE explosion per salvo*/);
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
DEBUG_STREAM << "[projectile] REPLICANT salvo x" << n
|
||||
<< " at(" << rec->salvoTarget.x << "," << rec->salvoTarget.y
|
||||
<< "," << rec->salvoTarget.z << ")"
|
||||
<< " tgtID=" << rec->salvoTargetID
|
||||
<< " resolved=" << (void *)mirror_tgt << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// TakeDamage / DeathReset
|
||||
//
|
||||
// TakeDamage inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c).
|
||||
// DeathReset chains the ProjectileWeapon ammo/feed reset (@004bbaf8).
|
||||
//
|
||||
void MissileLauncher::TakeDamage(Damage &damage)
|
||||
{
|
||||
MechWeapon::TakeDamage(damage); // @004b964c
|
||||
}
|
||||
|
||||
void MissileLauncher::DeathReset(Logical /*full_reset*/)
|
||||
{
|
||||
// issue #22: the respawn sweep's dispatch vehicle -- chains the authentic
|
||||
// slot-10 RTIS [T1 vtable +0x28]; ammo itself refills in AmmoBin's own
|
||||
// DeathReset (the bins are separate roster subsystems).
|
||||
ProjectileWeapon::ResetToInitialState(); // @004bbaf8
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// TestInstance -- inherits the ProjectileWeapon/MechWeapon base check.
|
||||
//
|
||||
Logical MissileLauncher::TestInstance() const
|
||||
{
|
||||
return ProjectileWeapon::TestInstance();
|
||||
}
|
||||
|
||||
|
||||
//===========================================================================//
|
||||
// WAVE 7 factory bridge -- MissileLauncher (factory case 0xBD0, "BallisticWeapon"
|
||||
// mislabel). VDATA.h enum: 0xBD0 == MissileLauncherClassID. The real class
|
||||
// (ctor @004bcff0) is MissileLauncher : ProjectileWeapon (sizeof 0x44C); the
|
||||
// factory built a BallisticWeapon RECON_SUBSYS stub. alloc 0x44C. Ticks its
|
||||
// real per-frame sim (charge/ammo/heat, damage split across missileCount);
|
||||
// FireWeapon consumes a round + recoils but DEFERS the missile spawn (Phase B).
|
||||
//===========================================================================//
|
||||
Subsystem *CreateMissileLauncherSubsystem(Mech *owner, int id, void *seg)
|
||||
{
|
||||
Check(sizeof(MissileLauncher) <= 0x44C);
|
||||
return (Subsystem *) new (Memory::Allocate(0x44C))
|
||||
MissileLauncher(owner, id, (MissileLauncher::SubsystemResource *)seg, MissileLauncher::DefaultData);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,15 @@ class Missile;
|
||||
int salvoCounter; // fires since spawn (edge-detected by peers)
|
||||
int salvoRounds; // missiles in the salvo (authored missileCount)
|
||||
Point3D salvoTarget; // the aim point the salvo flew at
|
||||
// #84: the LOCKED TARGET's entity ID (EntityID::Null = point fire).
|
||||
// The binary replicated the whole Missile entity, and its peers ran
|
||||
// a GHOST performance updated from the wire (CLASSMAP: Projectile
|
||||
// PTR_LAB_005129e8 authoritative / 005129f4 ghost) -- so peers saw
|
||||
// the TRUE trajectory. The pool's mirror flew at the frozen
|
||||
// fire-time point instead, detonating "where the target WAS": the
|
||||
// reported double explosion. Carrying the target ID lets the
|
||||
// mirror home at the LIVE local replicant, like the ghost did.
|
||||
EntityID salvoTargetID;
|
||||
};
|
||||
typedef MissileLauncher__UpdateRecord UpdateRecord;
|
||||
|
||||
|
||||
+213
-81
@@ -229,6 +229,25 @@ Myomers::Myomers(
|
||||
recommendedSeekVoltageIndex = resource->seekVoltageRecommendedIndex; // @0x324 res+0x1AC
|
||||
currentSeekVoltageIndex = recommendedSeekVoltageIndex; // @0x320
|
||||
minSeekVoltageIndex = 0; // @0x328
|
||||
|
||||
// #96 -- "each mech has a unique heating profile". Dump what THIS mech's
|
||||
// myomer record actually streamed, so per-chassis variation is a measured
|
||||
// fact rather than an assumption. One line per myomer built.
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
{
|
||||
DEBUG_STREAM << "[myoparm] " << (GetName() ? GetName() : "?")
|
||||
<< " velEff=" << velocityEfficiency
|
||||
<< " accEff=" << accelerationEfficiency
|
||||
<< " gears=[";
|
||||
for (int g = 0; g <= maxSeekVoltageIndex && g < 5; ++g)
|
||||
DEBUG_STREAM << (g ? "," : "") << seekVoltage[g];
|
||||
DEBUG_STREAM << "] rec=" << recommendedSeekVoltageIndex
|
||||
<< " max=" << maxSeekVoltageIndex
|
||||
<< " degradeT=" << degradationTemperature
|
||||
<< " failT=" << failureTemperature
|
||||
<< " thermalMass=" << thermalMass
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +433,26 @@ Scalar Myomers::SeekVoltageResponse(Scalar input_voltage)
|
||||
// (see emitter.cpp BTSeekVoltageSample). Guarded: a non-Myomers subsystem
|
||||
// samples 0 (only emitter/myomer pages own a graph in the shipped config).
|
||||
//***************************************************************************
|
||||
//***************************************************************************
|
||||
// BTMyomersSpeedEffectOf -- the myomer drive fraction for the master perf's
|
||||
// demand scale (mechmppr.cpp). RESTORED 2026-07-31 (the seek audit): the
|
||||
// morning's deletion of the predecessor (BTMyomersDriveOf) rested on an
|
||||
// export-gap-blind sweep -- the REAL consumer lives in the un-exported
|
||||
// master-perf region @0x4a9cf2-0x4a9da4: MAX speedEffect over the myomer
|
||||
// chain -> mech+0x79C -> `mapper->speedDemand *= it` (+ turn freeze at
|
||||
// |drive| <= 1e-4). UNCLAMPED (the old bridge's f>1 -> 1 clamp ate the
|
||||
// gear-4 overdrive): speedEffect legitimately rides to ~1.43 at seek 4.
|
||||
// Returns -1 for a non-Myomers subsystem. [T1 disasm]
|
||||
//***************************************************************************
|
||||
Scalar BTMyomersSpeedEffectOf(void *subsystem)
|
||||
{
|
||||
Entity *entity = (Entity *)subsystem;
|
||||
if (entity == 0 || !entity->IsDerivedFrom(Myomers::ClassDerivations))
|
||||
return -1.0f;
|
||||
Scalar f = ((Myomers *)entity)->SpeedEffect();
|
||||
return (f > 0.0f) ? f : 0.0f;
|
||||
}
|
||||
|
||||
Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage)
|
||||
{
|
||||
Entity *entity = (Entity *)subsystem;
|
||||
@@ -482,6 +521,35 @@ static const Scalar MyomerRecoveryPerTick = -0.010999999940395355f; // 0xbc34395
|
||||
//
|
||||
void Myomers::MyomersSimulation(Scalar time_slice)
|
||||
{
|
||||
// 0. RegisterMaxOutput (@004b8ef0) -- the assembly-time top-speed cap.
|
||||
// The binary calls it from the mech-assembly loop over the myomer chain
|
||||
// (part_012.c:10322), after the model record is streamed:
|
||||
// mech[0x7A0] = max(mech[0x7A0], AvailableOutput(seekVoltage[maxIdx]))
|
||||
// = base x (topGear/recGear) = base x ~1.4284 on a healthy assembly --
|
||||
// the gait rise-clamp both channels bound against (part_012:12103/12334).
|
||||
// The port's capability-chain fill is the SubProxy stub (same situation as
|
||||
// the flush's chain walk), so each myomer registers ITSELF here. Called
|
||||
// per tick instead of once (the 0x358 layout lock leaves no room for a
|
||||
// latch member): RegisterMaxOutput is an idempotent max() -- best falls as
|
||||
// heat/damage degrade AvailableOutput, and max() never lowers the cap --
|
||||
// so the resulting state is identical to the binary's once-at-assembly.
|
||||
RegisterMaxOutput(); // @004b8ef0
|
||||
|
||||
// BENCH (BT_FORCE_SEEK=<idx 0..max>): pin the seek gear without the eng-page
|
||||
// button -- the seek-4 supercharge/overheat loop is otherwise unreachable
|
||||
// on a headless rig. Env-gated, off by default; the toggle handler stays
|
||||
// the authentic path.
|
||||
{
|
||||
static int s_forceSeek = -2;
|
||||
if (s_forceSeek == -2)
|
||||
{
|
||||
const char *e = getenv("BT_FORCE_SEEK");
|
||||
s_forceSeek = (e != 0 && *e != '\0') ? atoi(e) : -1;
|
||||
}
|
||||
if (s_forceSeek >= 0 && s_forceSeek <= maxSeekVoltageIndex)
|
||||
currentSeekVoltageIndex = s_forceSeek;
|
||||
}
|
||||
|
||||
// 1. @004b8bab -- the base electrical state machine. MUST come first, and
|
||||
// must precede any heat-model gate: OwnerAdvancedDamage() is off below
|
||||
// veteran, so gating ahead of this denied the machine to most pilots.
|
||||
@@ -496,23 +564,16 @@ void Myomers::MyomersSimulation(Scalar time_slice)
|
||||
// so the READING is "a myomer you power down slowly heals, and the `< 1.0`
|
||||
// test stops a destroyed zone being resurrected".
|
||||
//
|
||||
// ⚠ BUT IT IS INERT -- IN THE ORIGINAL TOO. [T0, measured 2026-07-28]
|
||||
// A subsystem's private zone is built by the 2-arg trivial ctor
|
||||
// `new DamageZone(this, 0)`, and DAMAGE.cpp:187-190 zeroes ALL FIVE
|
||||
// damageScale[] entries; DamageZone::Reset never touches them, and the only
|
||||
// other writer in the codebase is Mech__DamageZone (the MECH's streamed
|
||||
// zones, a different class -- mechdmg.cpp:246-253). So damageScale stays
|
||||
// {0,0,0,0,0} for the life of the zone and the sum above is always
|
||||
// `damageLevel += amount * 0.0f` == no change. Confirmed live: seeded to
|
||||
// 0.6, held at NoVoltage for ~1500 ticks, damageLevel never moved off 0.6
|
||||
// (BT_MYOMERS_REPAIR_TEST below). The crit path reaches subsystem damage a
|
||||
// different way -- it writes damageLevel DIRECTLY (mechsub.cpp
|
||||
// DistributeCriticalHit pins *(this[0x38]+0x158) = 1.0f) -- which is why
|
||||
// subsystems can still be destroyed even though this route cannot.
|
||||
//
|
||||
// Reconstructed anyway, and deliberately NOT "fixed": this is what the 1995
|
||||
// binary does, and inventing a working repair here would be a behavior we
|
||||
// made up. Kept so the dead branch is visible rather than silently missing.
|
||||
// ⚠ CORRECTION (2026-07-29, #80): the earlier "inert in the original too"
|
||||
// note here was WRONG about the original. The 2026-07-28 experiment that
|
||||
// froze at 0.6 was measuring a PORT bug: the subsystem ctor wrote the
|
||||
// zone's armour/scales through the ReconDamageZone PROXY (struct offsets
|
||||
// +4/+8) instead of the engine members at +0x140/+0x144, so the real
|
||||
// damageScale[] stayed zero. The binary's ctor (@0x4ac7bb) initializes
|
||||
// them from the resource ("WeaponDamagePoints" + the five per-type
|
||||
// "...DamagePoints" keys), and the port now does the same -- so this
|
||||
// branch is LIVE, in 1995 and here: an un-powered, not-yet-destroyed
|
||||
// myomer heals at 0.011 * damageScale[Explosive] per tick.
|
||||
// (impactPoint/burstCount are set for fidelity; the callee ignores both.)
|
||||
if (electricalStateAlarm.GetLevel() == PoweredSubsystem::NoVoltage // this+0x278 == 1
|
||||
&& DamageStructureLevel() < 1.0f) // zone+0x158 < _DAT_004b8d10
|
||||
@@ -643,30 +704,137 @@ void Myomers::MyomersSimulation(Scalar time_slice)
|
||||
//
|
||||
void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
{
|
||||
if (!OwnerAdvancedDamage()) // FUN_004ad7d4 gate
|
||||
return;
|
||||
if (!OwnerAdvancedDamage()) // FUN_004ad7d4 gate (binary
|
||||
return; // gates the ACCUMULATE; same outcome)
|
||||
|
||||
Scalar velMag = OwnerVelocityMag(); // |Mech velocity| (+0x1C4/0x1C8/0x1CC)
|
||||
Scalar accMag = OwnerDriveMag(); // |Mech drive vector| (+0x82C/0x830/0x834)
|
||||
Scalar vy = OwnerVelocityY(); // Mech +0x1C8
|
||||
Scalar k = OwnerMotionGain(); // Mech +0x20C
|
||||
Scalar m = OwnerMass(); // *Mech[0x250]
|
||||
// #85 fix (2026-07-31): the five Mech motion operands were `return 0.0f`
|
||||
// cross-family shims, so this integrator faithfully accumulated ratio^2 *
|
||||
// damageGain * ZERO every tick -- the myomers stayed ice cold at any seek
|
||||
// (Oracle's night-7 panel-confirmed report). Now sampled for real via the
|
||||
// complete-Mech-TU bridge (mech4.cpp), operands re-grounded against the
|
||||
// raw disasm of @004b8d18 + the authored resource (BTL4.RES 0x0BC6 records:
|
||||
// VelocityEfficiency=0.995, AccelerationEfficiency=0.8, gears
|
||||
// 0.3/0.5/0.7/0.9999 rec idx 2 -- one shared tuning across all 18 mechs).
|
||||
float velMag, accMag, vy, mass, gravity;
|
||||
{
|
||||
extern void BTMechMyomerMotionSample(void *mech,
|
||||
float *vel_mag, float *acc_mag, float *vel_y,
|
||||
float *mass, float *gravity);
|
||||
BTMechMyomerMotionSample(owner, &velMag, &accMag, &vy, &mass, &gravity);
|
||||
}
|
||||
|
||||
Scalar velComplement = 1.0f - accelerationEfficiency; // @0x354
|
||||
Scalar workComplement = 1.0f - velocityEfficiency; // @0x350
|
||||
Scalar velComplement = 1.0f - accelerationEfficiency; // @0x354 (= 0.2 authored)
|
||||
Scalar workComplement = 1.0f - velocityEfficiency; // @0x350 (= 0.005 authored)
|
||||
Scalar damageGain = 1.0f + DamageStructureLevel(); // this[0xE0]+0x158
|
||||
|
||||
Scalar ratio = seekVoltage[currentSeekVoltageIndex] // @0x330[@0x320]
|
||||
/ seekVoltage[recommendedSeekVoltageIndex];// @0x330[@0x324]
|
||||
if (ratio < 1.0f) ratio = 1.0f;
|
||||
|
||||
Scalar work = k * (velMag * velMag) * 0.5f; // dot(v,v)*0.5 == |v|^2*0.5 (_DAT_004b8ee4)
|
||||
// The binary fabs()es all three motion operands (the jbe/fchs pairs
|
||||
// @4b8dd5/@4b8df0/@4b8e16); |v| and |a| are sqrt-positive already, but
|
||||
// v.y is signed -- descending must heat like climbing, not cool.
|
||||
if (vy < 0.0f) vy = -vy;
|
||||
|
||||
pendingHeat /* @0x1C8 */ +=
|
||||
ratio * ratio * damageGain *
|
||||
( velComplement * vy * k * m * time_slice
|
||||
+ workComplement * work
|
||||
+ velComplement * velMag * accMag * k * time_slice );
|
||||
// Physics (operands nailed 2026-07-31): mass@0x20C, gravity=**(0x250) --
|
||||
// work = 1/2 m v^2 (kinetic energy; NO dt in the
|
||||
// binary -- the accumulate is per-TICK, an authentic 1995
|
||||
// fixed-frame assumption, so heat/s scales with frame rate;
|
||||
// kept verbatim, field-calibrate if the pod veterans object)
|
||||
// velTerm = m g |vy| dt (climb power)
|
||||
// accTerm = m |v| |a| dt (acceleration power)
|
||||
Scalar work = mass * (velMag * velMag) * 0.5f; // dot(v,v)*0.5 (_DAT_004b8ee4)
|
||||
|
||||
// The three terms (#96 "myomers run too hot"). Two are POWER (x dt); the
|
||||
// kinetic one is an ENERGY the binary adds once per TICK with NO dt
|
||||
// (FUN_004b8d18: param_2 multiplies the climb and accel terms only).
|
||||
//
|
||||
// FRAME-RATE NORMALISATION. A per-tick energy makes heat/SECOND scale with
|
||||
// the tick rate, which on a 1995 fixed-frame machine is a constant and on a
|
||||
// modern PC is not: two players on different hardware, or the same player in
|
||||
// a heavy scene, get different heat from identical throttle. Measured here
|
||||
// at ~59Hz the myomers cross their degradation threshold in ~27s of seek-4
|
||||
// cruising; at the pod's documented 30Hz board frame that is ~54s.
|
||||
//
|
||||
// So the term is rescaled to the reference frame rate: multiply by
|
||||
// dt * hz, making the heat identical on every machine. This is the ONE
|
||||
// deliberate divergence from a verbatim transcription of @004b8d18, and it
|
||||
// exists because the binary's per-tick value depends on the pod's frame
|
||||
// rate, which our port does not run at.
|
||||
//
|
||||
// THE CLOCK ORIGIN, traced 2026-08-02 (was the open question here):
|
||||
// the DOS binary keeps its engine frame rate in the global DAT_0052140c,
|
||||
// set once at startup --
|
||||
// 0x401ace: mov [0x52140c], 0x41E00000 = 28.0f (nominal)
|
||||
// 0x401ada: mov [0x52140c], 0x4191A6E0 = 18.2065f (BIOS-tick fallback)
|
||||
// -- and consumed by ~90 fmul/fdiv sites across the image as THE
|
||||
// per-frame<->per-second conversion. The DOS main passes it straight into
|
||||
// the ApplicationManager ctor (0x401189: push [0x52140c]). So the pod's
|
||||
// nominal Performance cadence was 28 Hz, byte-proven. [T1]
|
||||
//
|
||||
// CALIBRATION FACTOR 0.5 -- the pod's EFFECTIVE rate under load, now
|
||||
// corroborated from three directions (2026-08-02):
|
||||
// * Oracle (pod veteran), asked if the frame rate was rock solid:
|
||||
// "I think I could do as well with a flipbook"; heavy fights "would
|
||||
// turn to a slide show".
|
||||
// * Lynx (original-era): "RP had a target frame rate of 30FPS. It
|
||||
// wasn't hard coded or locked. BT never had a locked frame rate in
|
||||
// 1st release testing... coming from 3.0, where 20FPS was a miracle."
|
||||
// * The engine's own pacer DESIGN: end_of_frame = Now() + frameDuration
|
||||
// with background fill and NO overrun catch-up (APPMGR.cpp, T0) --
|
||||
// "target 28 but allowed to slip" is the construction, and the
|
||||
// 18.2065 BIOS fallback anticipated degraded timing.
|
||||
// Corollary: a choking 486 generated LESS heat/s exactly during the
|
||||
// biggest fights -- veteran memory of seek-4 endurance encodes the LOADED
|
||||
// rate, so bracketing by their consensus is the correct instrument.
|
||||
// Effective default 28 * 0.5 = 14 Hz [T2 multi-witness]; BT_MYO_HZ=<hz>
|
||||
// overrides absolutely for bracketing.
|
||||
static const Scalar kPodFrameHz = 28.0f; // byte-proven nominal [T1]
|
||||
static const Scalar kSeekHeatCalib = 0.5f; // Oracle bracket 2026-08-02 [T3]
|
||||
static Scalar s_hz = -1.0f;
|
||||
if (s_hz < 0.0f)
|
||||
{
|
||||
const char *hv = getenv("BT_MYO_HZ");
|
||||
s_hz = (hv && *hv) ? (Scalar)atof(hv) : (kPodFrameHz * kSeekHeatCalib);
|
||||
if (s_hz <= 0.0f) s_hz = kPodFrameHz * kSeekHeatCalib;
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
DEBUG_STREAM << "[myoheat] kinetic-term reference rate: " << s_hz
|
||||
<< " Hz (pod nominal 28 [T1] x calib " << kSeekHeatCalib
|
||||
<< " [T3]" << (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
|
||||
}
|
||||
|
||||
Scalar termClimb = velComplement * vy * mass * gravity * time_slice;
|
||||
Scalar termKinetic = workComplement * work * (time_slice * s_hz); // rate-normalised
|
||||
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
|
||||
Scalar gain = ratio * ratio * damageGain;
|
||||
|
||||
pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel);
|
||||
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
{
|
||||
static int s_n = 0;
|
||||
static Scalar s_elapsed = 0.0f;
|
||||
s_elapsed += time_slice; // wall clock since the sim started
|
||||
if ((s_n++ % 120) == 0)
|
||||
{
|
||||
Scalar sum = termClimb + termKinetic + termAccel;
|
||||
// T and the clock: how long does seek 4 actually take to cook the
|
||||
// myomers? (#96 -- the players' claim is "under a minute".)
|
||||
DEBUG_STREAM << "[myotemp] t=" << s_elapsed
|
||||
<< " T=" << currentTemperature
|
||||
<< " degradeT=" << degradationTemperature
|
||||
<< " failT=" << failureTemperature << "\n" << std::flush;
|
||||
DEBUG_STREAM << "[myoheat] v=" << velMag << " a=" << accMag
|
||||
<< " m=" << mass << " g=" << gravity
|
||||
<< " ratio=" << ratio << " dmgGain=" << damageGain
|
||||
<< " dt=" << time_slice
|
||||
<< " | climb=" << (gain * termClimb)
|
||||
<< " kinetic=" << (gain * termKinetic)
|
||||
<< " accel=" << (gain * termAccel)
|
||||
<< " kineticShare=" << (sum != 0.0f ? (termKinetic / sum) : 0.0f)
|
||||
<< " pending=" << pendingHeat << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -691,52 +859,14 @@ void Myomers::ResetToInitialState(Logical powered)
|
||||
|
||||
|
||||
//***************************************************************************
|
||||
// Myomers::ConnectToMover (mislabeled)
|
||||
//***************************************************************************
|
||||
//
|
||||
// ⚠ MISATTRIBUTION (task #5 correction): @004b9550/@004b95b8 are NOT Myomers
|
||||
// functions -- the weapon message-table @0x511860 proves they are
|
||||
// MechWeapon::ConfigureMappables (id 9) / ChooseButton (id 10), the weapon->
|
||||
// button grouping handlers (the +0x31C coincidence: Myomers speedEffect vs
|
||||
// MechWeapon TriggerState). The bodies below are retained as the port's
|
||||
// Myomers stand-ins but carry no binary address.
|
||||
//
|
||||
// Attaches the live SpeedEffect output (@0x31C) into the Mech's JointedMover
|
||||
// roster (the mover at **(Mech[0xD0]+0x128)). Driven by a connect message
|
||||
// whose payload index is at message+0xC:
|
||||
// index = abs(message->value) - 1;
|
||||
// if (message->value < 1) { mover->Detach(index); this[0x110] = -1; }
|
||||
// else { this[0x110] = 0; mover->Attach(index, &speedEffect, this, 10, 9, 0); }
|
||||
// (mover vtable +0x38 = Attach, +0x3C = Detach.)
|
||||
//
|
||||
void Myomers::ConnectToMover(SubsystemMessage &message)
|
||||
{
|
||||
int index = abs(message.value) - 1; // message+0xC
|
||||
if (message.value < 1) {
|
||||
MoverDetach(index); // owner mover vtable +0x3C
|
||||
/* base+0x110 = -1 (detached) -- inert, no base accessor yet */
|
||||
} else {
|
||||
/* base+0x110 = 0 (attached) -- inert, no base accessor yet */
|
||||
MoverAttach(index, &speedEffect); // owner mover vtable +0x38, feed @0x31C
|
||||
// (original args: index,&speedEffect,this,10,9,0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//***************************************************************************
|
||||
// Myomers::DisconnectFromMover (stand-in; no binary address)
|
||||
//***************************************************************************
|
||||
// Counterpart of ConnectToMover: detaches the SpeedEffect feed.
|
||||
// if (message->value > 0)
|
||||
// mover->Release(message->value, &speedEffect); // mover vtable +0x44
|
||||
void Myomers::DisconnectFromMover(SubsystemMessage &message)
|
||||
{
|
||||
if (message.value > 0) { // message+0xC
|
||||
MoverRelease(message.value, &speedEffect); // owner mover vtable +0x44, feed @0x31C
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// (ConnectToMover / DisconnectFromMover are DELETED -- myomer-system
|
||||
// completion, 2026-07-31. Their source functions @004b9550/@004b95b8 are
|
||||
// MechWeapon::ConfigureMappables / ChooseButton (the +0x31C there is the
|
||||
// weapon TriggerState and **(mech+0x128) is the CONTROLS MAPPER's button
|
||||
// roster -- proven by the MECHWEAP.CPP assert string and the trigger-edge
|
||||
// detector @004b9608 adjacent in the image). The binary has NO dynamic
|
||||
// myomer->mover feed; see the RegisterMaxOutput latch in MyomersSimulation
|
||||
// for the real (assembly-time cap) locomotion coupling.)
|
||||
//***************************************************************************
|
||||
// Myomers::ToggleSeekVoltageMessageHandler @004b8a48
|
||||
//***************************************************************************
|
||||
@@ -791,9 +921,11 @@ struct MyomersLayoutCheck
|
||||
// The real class at 0xBC6 (ctor @004b8fec) is Myomers (the mech's artificial-
|
||||
// muscle drive); the factory built an Actuator RECON_SUBSYS stub. alloc 0x358.
|
||||
// The subsystem constructs (resolves its Generator + builds the seek-voltage
|
||||
// gear table) and ticks its real Performance, but is currently INERT w.r.t.
|
||||
// locomotion/heat (no-op mover feed + the OwnerAdvancedDamage()==False sim gate)
|
||||
// -- so it cannot regress the gait/drive. Authentic coupling = a follow-up.
|
||||
// gear table) and ticks its real Performance. DRIVE HEAT IS LIVE (#85 fix
|
||||
// 2026-07-31: the motion operands are sampled from the real Mech). Still
|
||||
// inert: the LOCOMOTION coupling (no-op mover feed -- speedEffect is computed
|
||||
// but nothing consumes it; WAVE 6 cutover) and the climb heat term (the
|
||||
// environment gravity is unwired in the port, so g reads 0).
|
||||
//===========================================================================//
|
||||
Subsystem *CreateMyomersSubsystem(Mech *owner, int id, void *seg)
|
||||
{
|
||||
|
||||
@@ -274,16 +274,19 @@ class Mech;
|
||||
HasVoltage(Subsystem *source = 0); // slot 16, @004b8f3c (overrides PoweredSubsystem::HasVoltage; Gitea #62 rename)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Mover-connection message handlers (bound via the simulation message map;
|
||||
// route the live SpeedEffect @0x31C into / out of the Mech's JointedMover
|
||||
// roster at owner Mech[0x128]).
|
||||
// (The "mover-connection" handlers are GONE -- myomer-system completion,
|
||||
// 2026-07-31. @004b9550/@004b95b8 are MechWeapon::ConfigureMappables /
|
||||
// ChooseButton (the +0x31C there is TriggerState, and **(mech+0x128) is
|
||||
// the CONTROLS MAPPER's button roster, not a mover) -- confirmed by the
|
||||
// decomp neighborhood (the MECHWEAP.CPP assert string + the trigger-edge
|
||||
// detector @004b9608 immediately after). The binary has NO dynamic
|
||||
// myomer->speed feed: AvailableOutput has exactly two callers in the whole
|
||||
// image (RegisterMaxOutput at assembly + the SeekVoltageGraph sampler),
|
||||
// and speedEffect@0x31C is a published GAUGE attribute. The authentic
|
||||
// locomotion couplings are the assembly-time top-speed cap (Mech+0x7A0)
|
||||
// and the VITAL flag (myomers destroyed = mech dead -- the only vital
|
||||
// subsystem in the authored records).)
|
||||
//
|
||||
protected:
|
||||
void
|
||||
ConnectToMover(SubsystemMessage &message); // @004b9550 (attach SpeedEffect feed)
|
||||
void
|
||||
DisconnectFromMover(SubsystemMessage &message); // @004b95b8 (detach SpeedEffect feed)
|
||||
|
||||
public:
|
||||
// ToggleSeekVoltage message handler @004b8a48 (Myomers handler table
|
||||
// @0x51158C: {9, "ToggleSeekVoltage"}). Issue #19: was an EMPTY stub +
|
||||
@@ -328,14 +331,31 @@ class Mech;
|
||||
// mech actually slows when damaged/overheated and motion generates heat --
|
||||
// that path DOES drive the live mover, so reconcile it with the gait cutover
|
||||
// FIRST (both would feed the mover).
|
||||
Scalar OwnerBaseSpeed() const { return 1.0f; } // Mech +0x34C (neutral)
|
||||
Scalar OwnerMaxSpeed() const { return 0.0f; } // Mech +0x7A0
|
||||
void SetOwnerMaxSpeed(Scalar) { } // no-op (real: write Mech top speed)
|
||||
Scalar OwnerVelocityMag() const { return 0.0f; } // |Mech velocity|
|
||||
Scalar OwnerDriveMag() const { return 0.0f; } // |Mech drive/accel|
|
||||
Scalar OwnerVelocityY() const { return 0.0f; } // Mech velocity.y
|
||||
Scalar OwnerMotionGain() const { return 0.0f; } // Mech +0x20C
|
||||
Scalar OwnerMass() const { return 0.0f; } // *Mech +0x250
|
||||
// Myomer-system completion (2026-07-31): the owner speed cells, via
|
||||
// complete-Mech-TU bridges (mech4.cpp), per the databinding rule:
|
||||
// Mech+0x34C = the run-speed base (port member reverseStrideLength --
|
||||
// documented misnomer; the mapper's demand multiplier)
|
||||
// Mech+0x7A0 = the gait rise-CLAMP RegisterMaxOutput raises at
|
||||
// assembly (port member reverseSpeedMax2 -- misnomer)
|
||||
Scalar OwnerBaseSpeed() const
|
||||
{
|
||||
extern float BTMechRunSpeedBase(void *mech);
|
||||
Scalar v = (Scalar)BTMechRunSpeedBase(owner);
|
||||
return (v > 0.0f) ? v : 1.0f; // pre-stream guard (record not read yet)
|
||||
}
|
||||
Scalar OwnerMaxSpeed() const
|
||||
{
|
||||
extern float BTMechTopSpeedGet(void *mech);
|
||||
return (Scalar)BTMechTopSpeedGet(owner);
|
||||
}
|
||||
void SetOwnerMaxSpeed(Scalar v)
|
||||
{
|
||||
extern void BTMechTopSpeedSet(void *mech, float v);
|
||||
BTMechTopSpeedSet(owner, (float)v);
|
||||
}
|
||||
// (The five drive-heat motion shims are GONE -- #85 fix 2026-07-31:
|
||||
// MyomersDriveHeat samples the real Mech via the complete-TU bridge
|
||||
// BTMechMyomerMotionSample (mech4.cpp).)
|
||||
// FUN_004ad7d4 [T1] -- the SAME player+0x260 heat-model experience gate
|
||||
// as HeatSink::HeatModelActive() (the "advanced damage" name was a
|
||||
// misread; the old `return False` stub kept movement heat off for
|
||||
@@ -356,26 +376,23 @@ class Mech;
|
||||
// which sensor.cpp:312 already reads the same way.
|
||||
Scalar DamageStructureLevel() const{ return GetSubsystemDamageLevel(); } // DamageZone +0x158
|
||||
|
||||
void MoverAttach(int index, Scalar *feed) { (void)index; (void)feed; } // JointedMover vtable +0x38 (no-op)
|
||||
void MoverDetach(int index) { (void)index; } // JointedMover vtable +0x3C (no-op)
|
||||
void MoverRelease(int handle, Scalar *feed) { (void)handle; (void)feed; } // JointedMover vtable +0x44 (no-op)
|
||||
|
||||
// Live-master gate constants (the ctor reads owner->simulationFlags).
|
||||
enum {
|
||||
SegmentCopyMask = 0x0C, // (flags & 0xC): 0 == master, 4 == damaged copy
|
||||
MasterHeatSinkFlag = 0x0100 // live-master segment bit
|
||||
};
|
||||
// The mover attachment handle lives at BASE+0x110 (a MechSubsystem slot), NOT a
|
||||
// Myomers own field -- declaring it here appended a 4-byte tail that overflowed the
|
||||
// 0x358 alloc once the PoweredSubsystem base became byte-exact. The coupling is
|
||||
// inert (ConnectToMover -> no-op MoverAttach), so the handle is write-only today;
|
||||
// when the real mover feed is revived, write base+0x110 via a base accessor.
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Local data. Offsets are byte offsets into the shipped object.
|
||||
//
|
||||
protected:
|
||||
Scalar speedEffect; // @0x31C attr 0x12; ctor 1.0f -- live drive fed to the mover
|
||||
public:
|
||||
// speedEffect is a published GAUGE attribute (attr 0x12) -- the demand
|
||||
// coupling that briefly read it was an invention (removed 2026-07-31).
|
||||
Scalar SpeedEffect() const { return speedEffect; }
|
||||
protected:
|
||||
int currentSeekVoltageIndex; // @0x320 attr 0x13; ctor = recommended -- selected drive "gear"
|
||||
int recommendedSeekVoltageIndex;// @0x324 attr 0x14; ctor = resource +0x1AC
|
||||
int minSeekVoltageIndex; // @0x328 attr 0x15; ctor 0
|
||||
|
||||
@@ -98,7 +98,9 @@ extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1, int splash_burst = 0, int muzzle_seg = -1,
|
||||
int damage_type = 2 /*ExplosiveDamageType default*/); // AC: no cluster splash (default 0)
|
||||
int damage_type = 2 /*ExplosiveDamageType default*/,
|
||||
Scalar thrust_accel = 0.0f, Scalar thrust_burn = 0.0f,
|
||||
int salvo_lead = 1); // AC: unpowered; single round = its own lead
|
||||
|
||||
|
||||
//#############################################################################
|
||||
@@ -751,8 +753,16 @@ void
|
||||
// FailureTemperature for the ballistic family), or the owning mech disabled
|
||||
// (FUN_0049fb54) -> pin recoil at the full rechargeRate + latch alarm 7.
|
||||
// No return: the frame continues into the state machine.
|
||||
// #86 FIX (2026-07-31): in the BINARY, "simulationState@0x40" IS the
|
||||
// statusAlarm's current-level cell (the indicator lives at +0x2C and its
|
||||
// level sits at +0x14 inside it = +0x40) -- ONE cell. The port models
|
||||
// them as TWO members, and the destruction path (ForceCriticalFailure /
|
||||
// the crit sink) writes only the ALARM -- so this gate read a
|
||||
// never-written int and a weapon on a blown-off arm kept firing with its
|
||||
// MFD X'd out (all three night-7 testers). Read BOTH cells.
|
||||
{
|
||||
int gate1Destroyed = (simulationState == 1);
|
||||
int gate1Destroyed = (simulationState == 1
|
||||
|| statusAlarm.GetLevel() == 1);
|
||||
int gate1FailHeat = (heatAlarm.GetLevel() == HeatSink::FailureHeat);
|
||||
int gate1Disabled = (owner != 0 && owner->IsDerivedFrom(*Mech::GetClassDerivations())
|
||||
&& ((Mech *)owner)->IsDisabled());
|
||||
|
||||
@@ -1101,3 +1101,15 @@ void
|
||||
{
|
||||
ResetToInitialState(); // @004b5bf8 (takes no arg)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||
// roster members against this family's derivation chain (binary GUID 0x510b08).
|
||||
// Lives here because Torso is a complete type only in this TU.
|
||||
//
|
||||
Derivation *
|
||||
BTTorsoFamily()
|
||||
{
|
||||
return &Torso::ClassDerivations;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Phase 13 — Night-7 follow-ups: collision economy, myomer system, coolant leaks (2026-07-31)
|
||||
|
||||
One session, six commits (`054c3f2..c18929b`), driven by the night-7 field reports (build
|
||||
4.11.659, testers SAURON / RajelAran / Oracle — Oracle is new, with hands-on original-pod
|
||||
experience). Every fix is bench-verified; field verification is the next playtest.
|
||||
KB authority per topic: [[multiplayer]], [[subsystems]], [[locomotion]], [[combat-damage]],
|
||||
[[pod-hardware]] §Manual, [[translocation-warp]], [[open-questions]].
|
||||
|
||||
## 1. #94 — zone-less-death un-wreck (`054c3f2`) [T1 root cause / T2 verified]
|
||||
Peers saw SAURON's live mech as flaming wreckage after a ram. Root cause: the peer wreck
|
||||
lifecycle's two halves listened to different signals — wreck ON = the replicated death
|
||||
explosion (unconditional); wreck OFF = a damage-zone falling-edge latch that a COLLISION
|
||||
death never produces (type-0 prices as internal rattle, zones never move). Fix: the un-wreck
|
||||
(+ peer respawn warp) now rides the death-state EXIT edge of the replicated MovementMode
|
||||
({2,9} → alive; only `Mech::Reset` leaves the death modes; the state rides every record
|
||||
header). Bench: 14/14 zone-less deaths un-wrecked, 12/12 explosive regression.
|
||||
Gitea #94: awaiting-verification. (#81's earlier fix stands — different hole, same machinery.)
|
||||
|
||||
## 2. Collision-pricing audit → two economy fixes (`37dd7f9`) [T1 formula / T2 verified]
|
||||
The formula layers were verified authentic (crash gate @0x4ab178 == 0.0 read from the exe;
|
||||
StaticBounce kinetic-loss pricing [T0]; divert scale + 0.5-pt free floor). Two port defects:
|
||||
- **A. Grind pacing**: the 0.4 s `gBlockCooldown` hysteresis suppressed the knockdown during
|
||||
sustained contact → full-commanded-speed crash price EVERY frame → the 2-3 s grind death
|
||||
(SAURON's ram death, 110 priced frames at |v|≈34.7). Removed; the binary's pacing
|
||||
(knockdown zeroes the drive; free floor needs v>~18.5 while the knockdown fires at v>6.3)
|
||||
makes wall grinding free taps + ~1.2 s staggers. Bench: 150 s full-throttle wall push =
|
||||
125 paced binds, 4,767 crash frames all free, 0 deaths.
|
||||
- **B. Raw ram dispatch**: the ×0.001 "ram economy normalization" (2026-07-12) predated the
|
||||
#83 divert and compounded with its ~4e-5 scale — no physically possible ram could clear the
|
||||
free floor; rams were a no-op against the victim. Raw restored [T1 @part_012:15324].
|
||||
Bench: tx==rx byte-identical (66239.1), victim priced 2.55 pts rattle, 108 knockdowns ↔
|
||||
exactly 108 type-5 records on the observer (no skating regression).
|
||||
|
||||
## 3. #85 — myomer drive heat LIVE (`1570983`) [T1 operands / T2 verified]
|
||||
The integrator @004b8d18 was fully reconstructed but its five Mech motion operands were
|
||||
`return 0.0f` shims → `ratio² × damageGain × 0` forever (Oracle's "ice cold at seek 4").
|
||||
Operands re-grounded from raw disasm + decomp: `+0x1C4`=localVelocity, `+0x82C`=the 15-ring
|
||||
smoothed AccelerationLastFrame, `+0x20C`=moverMass, `*(+0x250)`=environment gravity pointer
|
||||
(FUN_00421e2c). Physics: `heat += ratio²(1+dmg)[0.005·½mv² + 0.2·mg|vy|dt + 0.2·m|v||a|dt]`.
|
||||
Authored tuning extracted from BTL4.RES (18 records, one tuning): eff 0.995/0.8, gears
|
||||
0.3/0.5/0.7/0.9999 rec idx 2; myomer thermal 77/1000/2000, thermalMass 2.5e5. Bench: hard
|
||||
circling drove T 77→1225 and the coolant loop pulled it back to ~520. Caveats documented:
|
||||
the kinetic term is per-tick (no dt — 1995 fixed-frame assumption, mild fps dependence);
|
||||
the climb term reads g=0 (environment gravity unwired — open-questions). Gitea #85:
|
||||
awaiting-verification.
|
||||
|
||||
## 4. #88 — coolant leaks, both halves (`a8a0042`, `31552c0`) [T1 root cause / T2 verified]
|
||||
- **Arming** (the #64 shadow): `UpdateCoolant` prices the leak from the qualified ENGINE
|
||||
member `Subsystem::damageZone` — which the MechSubsystem ctor never assigned (it filled
|
||||
only its re-declared shadow). zoneDamage read 0 forever → leaks structurally impossible.
|
||||
Fix: both ctors alias the engine member to the same zone (#64 half-fix; the de-shadow
|
||||
sweep stays open). Bench: rattle + weapon crits both drive `[cool]` leak pricing.
|
||||
- **Central pull-through**: `HeatSink::DrawCoolant` was a `return 0` stub (the operator
|
||||
noticed the coolant panel bar never moved). Real base @0x4add00 disassembled: the draw
|
||||
RECURSES up the sink linkage (× coolantFlowScale@0x15C per hop) and terminates at the
|
||||
Reservoir's supply (@0x4af3b0, already faithfully ported). The bank's `helper@0x1D8`
|
||||
member is its reservoir connection (not vestigial). Bench: a destroyed myomer's leak
|
||||
drained the central tank 6.0→5.58 over ~30 s — the cockpit coolant BAR now drops.
|
||||
Gitea #88: awaiting-verification; #64 half-closed.
|
||||
|
||||
## 5. Myomer system COMPLETE — the "WAVE 6 mover cutover" was a phantom (`cf8618b`) [T1]
|
||||
`@004b9550/@004b95b8` ("ConnectToMover") are MechWeapon::ConfigureMappables/ChooseButton —
|
||||
the `+0x31C` there is weapon TriggerState and `**(mech+0x128)` is the CONTROLS MAPPER's
|
||||
button roster. **The binary has NO dynamic myomer→speed feed.** Full data-flow closure
|
||||
(every check [T1]): the demand fn @004afd10 (all three control-mode branches + turn clamp)
|
||||
has no myomer term; all `mapper->speedDemand` writers = that fn + the gimp clamps;
|
||||
`AvailableOutput` has exactly 2 callers (RegisterMaxOutput @assembly + the graph sampler);
|
||||
the virtual slot-0x3C sweep finds only the graph + the mapper detach; the SpeedEffect
|
||||
attribute string has 1 reference (its own table row); `mech+0x7A0` writers = ctor-zero +
|
||||
RegisterMaxOutput. The authentic system, all live: heat; VITAL death (myomers are the only
|
||||
authored vital=1 subsystem — also the ram-death mechanism, rattle weight 0.35); the
|
||||
assembly cap (RegisterMaxOutput → mech+0x7A0 = base×~1.4284, now really registered);
|
||||
the ENG power curve at true scale; electrical sourcing. REMOVED as inventions: the
|
||||
`speedDemand *= speedEffect` demand multiplier + BTMyomersDriveOf + the Myomers mover
|
||||
family. #75 CLOSED (premise falsified: myomer damage does not slow a live mech; the
|
||||
damage slowdown is the leg-damage gimp gait).
|
||||
|
||||
## 6. The seek dial — decoded end-to-end, one question OPEN for the players
|
||||
What seek does in 4.10 [T1]: heat ratio² (floors at 1 — gears 1-3 heat identically);
|
||||
the generator brown-out threshold, whose real stake is TORSO TWIST (the Torso PowerWatcher
|
||||
zeroes twist while the myomers are not Ready; generator has NO load model — demand never
|
||||
pulls the bus down); the ENG graph. Gear 4 = double heat + hair-trigger dropout for a
|
||||
taller graph. **The 1995 manual (4.0) says otherwise**: seek "translates to a lower or
|
||||
higher top speed", gear 4 is named SUPERCHARGE (expert-only), stat sheets print three top
|
||||
speeds (Loki/Thor: Normal 143 / Super Charged 182 / Gimped 40 kph). The printed ratio
|
||||
(1.27) doesn't even match 4.10's vestigial gear table (1.43) → a documented 4.0→4.10 DRIFT
|
||||
(third alongside experience gates + coolant-loop redistribution). Plausible motive: MP
|
||||
fairness (expert-only speed advantage patched out of mixed-experience pods).
|
||||
**OPEN — the player version-probe**: the operator is asking SAURON/Oracle whether seek 4
|
||||
made their pod-era mechs faster. If the pods they played had supercharge, resurrecting it
|
||||
would be an opt-in deviation (all plumbing exists: gear ratio, the 0x7A0 cap, heat penalty,
|
||||
heatFactor overheat-to-zero — one authorized multiplication in the demand path).
|
||||
|
||||
## Carried opens (unchanged this session)
|
||||
Cross-fire combat stall; silent node crash (procdump armed); 17s-late kill credit; #93
|
||||
end-of-round PerformAndWatch use-after-free (one occurrence, symbolized); goto
|
||||
release-to-manual freeze; environment gravity unwired; the #64 full de-shadow sweep;
|
||||
mech3 CreateStreamedSubsystem stub bridges (/FORCE known-cold).
|
||||
|
||||
## Field re-test list for the next session (all labeled awaiting-verification)
|
||||
1. #94: ram-kill victims respawn visibly (no zombie wreck) on every screen.
|
||||
2. Collision economy: wall-humping harmless; rams stagger + rattle BOTH parties; no 2-s
|
||||
grind deaths.
|
||||
3. #85: myomers warm on the ENG panel while driving; ~2× at seek 4; loops respond.
|
||||
4. #88: subsystem damage arms the leak warning and the coolant BAR bleeds down.
|
||||
5. Seek: heat changes with gear, speed does NOT (correct per 4.10) — plus the version-probe
|
||||
question above.
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
rem #78 visible-limp demo: Black Hawk, right-leg ramp, then watch it hobble.
|
||||
rem Double-click me. Controls (bindings.txt, the 1995 throttle-lever scheme):
|
||||
rem SHIFT = throttle up (sticks where you leave it) CTRL = throttle down
|
||||
rem ALT = reverse thrust (refuses while limping) BACKTICK = view toggle
|
||||
rem X = all-stop
|
||||
rem
|
||||
rem Uses a NOVICE copy of DEV.EGG: on expert the harness bursts roll CRITS on
|
||||
rem the aimed leg -- one myomers crit and the mech turns-but-never-moves
|
||||
rem (drive=0), which ruins the demo. Novice gates crits; leg gimp still fires.
|
||||
setlocal
|
||||
set BT_SELF_DAMAGE=60
|
||||
set BT_SELF_DAMAGE_ZONE=18
|
||||
set BT_SELF_DAMAGE_TICKS=2
|
||||
rem GLASS boots with the plasma window holding focus -> keyboard dead until
|
||||
rem you click the 3D window. NOFOCUS accepts keys regardless.
|
||||
set BT_KEY_NOFOCUS=1
|
||||
cd /d "%~dp0..\content"
|
||||
powershell -NoProfile -Command "(Get-Content DEV.EGG) -replace '^experience=.*','experience=novice' | Set-Content LIMP.EGG"
|
||||
echo [limp] self-damage 60 x2 ticks into zone 18 (bhk1 right-leg family), novice sim
|
||||
"%~dp0..\build\Release\btl4.exe" -egg LIMP.EGG
|
||||
del LIMP.EGG 2>nul
|
||||
if errorlevel 1 pause
|
||||
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
rem #78 OBS capture demo: Black Hawk walks ~12 s, right leg crosses half on
|
||||
rem the FINAL damage tick (one clean trigger edge), then: klaxon, klaxon,
|
||||
rem "REVERSE DISABLED" + the limp. Auto-walks via GOTO -- the only input
|
||||
rem needed is V (or BACKTICK) once, for the 3rd-person chase view.
|
||||
setlocal
|
||||
set BT_FE_SOLO=1
|
||||
set BT_GOTO=8000 8000
|
||||
set BT_GOTO_THR=0.9
|
||||
set BT_SELF_DAMAGE=6
|
||||
set BT_SELF_DAMAGE_ZONE=18
|
||||
set BT_SELF_DAMAGE_TICKS=12
|
||||
set BT_SELF_DAMAGE_DELAY=30
|
||||
set BT_KEY_NOFOCUS=1
|
||||
cd /d "%~dp0..\content"
|
||||
powershell -NoProfile -Command "(Get-Content DEV.EGG) -replace '^experience=.*','experience=novice' | Set-Content LIMPDEMO.EGG"
|
||||
echo [limpdemo] auto-walk, 6x12 into zone 18 (crossing on the last tick), novice
|
||||
"%~dp0..\build\Release\btl4.exe" -egg LIMPDEMO.EGG
|
||||
del LIMPDEMO.EGG 2>nul
|
||||
if errorlevel 1 pause
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# ===========================================================================
|
||||
# BENCH COMMON -- launch a local test node EXACTLY as a field player's
|
||||
# shortcut does, so what we see locally is what they see.
|
||||
#
|
||||
# WHY THIS FILE EXISTS (2026-07-30): local benches had drifted from the
|
||||
# shipped launchers, and every divergence cost a debugging session:
|
||||
# * no BT_START_INSIDE -> benches opened in the EXTERNAL CHASE camera while
|
||||
# every player starts in the COCKPIT. Hours were
|
||||
# spent on a "broken HUD" that was simply the first
|
||||
# first-person look at a view the benches never showed.
|
||||
# * novice bench eggs -> every SHIPPED egg is `expert`; novice silences the
|
||||
# whole heat model, crits and jams, so bench combat
|
||||
# was not the combat players get.
|
||||
# * 1-logical-processor affinity pins -> starved the gauge executive and
|
||||
# produced a false "the comms panel never counts"
|
||||
# reading. Two LPs per node keeps MP jitter pinned
|
||||
# (the documented single-box fix) without starving.
|
||||
#
|
||||
# THE PLAYER ENV IS THE CONTRACT. It is copied verbatim from the shipped
|
||||
# play_solo.bat / join.bat (dist zip). bt_assert_player_env compares this
|
||||
# list against a shipped .bat when one is available, so drift is caught.
|
||||
# ===========================================================================
|
||||
|
||||
BT_ROOT=/c/git/bt411
|
||||
BT_EXE=$BT_ROOT/build/Release/btl4.exe
|
||||
|
||||
# PIDs THIS run launched. A blanket `taskkill /IM btl4.exe` at teardown kills
|
||||
# every game on the machine -- including a NEWER bench a previous wrapper's
|
||||
# sleep outlived. That silently shot down two live sessions, so each run now
|
||||
# tracks and kills only its own children.
|
||||
BT_PIDFILE=/tmp/bt_bench_pids.$$
|
||||
|
||||
# --- the exact env every shipped launcher sets -----------------------------
|
||||
bt_player_env () {
|
||||
export BT_PLATFORM=glass # unified build, desktop cockpit layer
|
||||
export BT_START_INSIDE=1 # COCKPIT eyepoint -- what a player sees
|
||||
export BT_DEV_GAUGES=1 # MFD/gauge surfaces + cockpit surround
|
||||
}
|
||||
|
||||
# --- warn if the shipped launcher has gained/lost a setting ----------------
|
||||
bt_assert_player_env () {
|
||||
local bat="$BT_ROOT/dist/BT411_4.11.643/play_solo.bat"
|
||||
[ -f "$bat" ] || return 0
|
||||
for v in BT_PLATFORM BT_START_INSIDE BT_DEV_GAUGES; do
|
||||
grep -qi "set $v=" "$bat" || echo "[bench] WARNING: shipped bat no longer sets $v"
|
||||
done
|
||||
}
|
||||
|
||||
# --- launch one node -------------------------------------------------------
|
||||
# bt_launch <logfile> <egg> <affinity> [extra args...] (env: extra BT_* vars)
|
||||
# Two logical processors per node: disjoint core sets keep single-box packet
|
||||
# delivery even (peer-shakiness fix) without starving the gauge executive.
|
||||
bt_launch () {
|
||||
local log="$1" egg="$2" aff="$3"; shift 3
|
||||
( bt_player_env
|
||||
export BT_LOG="$log" BT_AFFINITY="$aff"
|
||||
"$BT_EXE" -egg "$egg" "$@" &
|
||||
# the winpid pseudo-file races process start -- poll briefly; a missed pid
|
||||
# here left a stale node holding the -net port and null-ran the next config
|
||||
for _i in 1 2 3 4 5; do
|
||||
[ -f /proc/$!/winpid ] && { cat /proc/$!/winpid >> "$BT_PIDFILE"; break; }
|
||||
sleep 0.3
|
||||
done )
|
||||
}
|
||||
|
||||
# --- an EXPERT copy of an egg (what players actually fly) ------------------
|
||||
# Shipped eggs are all experience=expert. Bench eggs must match unless the
|
||||
# harness itself needs otherwise (see bt_novice_egg).
|
||||
bt_expert_egg () { # bt_expert_egg <src.egg> <dst.egg>
|
||||
sed 's/^experience=.*/experience=expert/' "$1" > "$2"
|
||||
}
|
||||
|
||||
# --- a NOVICE copy, for the harnesses that genuinely require it ------------
|
||||
# ONLY the aimed-leg gimp bench: on expert the self-damage bursts roll CRITS on
|
||||
# the aimed leg, and one myomers crit leaves the mech turning-but-not-moving --
|
||||
# which destroys the limp demo. Novice gates crits; the leg gimp still fires.
|
||||
# Any bench using this is NOT showing player-authentic damage behaviour.
|
||||
bt_novice_egg () { # bt_novice_egg <src.egg> <dst.egg>
|
||||
sed 's/^experience=.*/experience=novice/' "$1" > "$2"
|
||||
echo "[bench] NOTE: novice egg $2 -- crits/heat/jams are GATED OFF (harness requirement)"
|
||||
}
|
||||
|
||||
# --- kill ONLY the nodes this run started --------------------------------
|
||||
bt_kill_ours () {
|
||||
[ -f "$BT_PIDFILE" ] || return 0
|
||||
while read -r pid; do
|
||||
[ -n "$pid" ] && taskkill //F //PID "$pid" > /dev/null 2>&1
|
||||
done < "$BT_PIDFILE"
|
||||
rm -f "$BT_PIDFILE"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user