Compare commits
193
Commits
+18
@@ -43,3 +43,21 @@ matchlog_*.txt
|
|||||||
|
|
||||||
# Operator control-channel secret -- NEVER commit (plaintext shared credential)
|
# Operator control-channel secret -- NEVER commit (plaintext shared credential)
|
||||||
operator_secret.txt
|
operator_secret.txt
|
||||||
|
|
||||||
|
# Player field logs: contain Steam IDs, machine names and usernames -- never commit.
|
||||||
|
# (bench .sh/.py under scratchpad/night*/ stay committable.)
|
||||||
|
scratchpad/night*/*.log
|
||||||
|
scratchpad/night*/*.rar
|
||||||
|
scratchpad/night*/*.zip
|
||||||
|
# tester-authored notes / lastrun dumps carry player handles + machine names
|
||||||
|
# (deliberate exceptions: git add -f)
|
||||||
|
scratchpad/night*/*notes*.txt
|
||||||
|
scratchpad/night*/lastrun*.txt
|
||||||
|
|
||||||
|
# Bench artifacts in content/ (screenshots, session logs, generated eggs, deployed exe)
|
||||||
|
# -- receipts stay on disk, out of `git status`. A new AUTHORED egg: git add -f.
|
||||||
|
content/*.png
|
||||||
|
content/*.log
|
||||||
|
content/matchlog_*.txt
|
||||||
|
content/btl4.exe
|
||||||
|
content/*.EGG
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
|
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
|
||||||
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
|
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
|
||||||
`engine/ game/ content/ docs/ reference/ tools/ context/`.
|
`engine/ game/ content/ docs/ reference/ tools/ context/`.
|
||||||
**Current front:** `btl4.exe` runs a full single-player loop; the gauge system is complete; the
|
**Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
|
||||||
active work is reconstructing each subsystem's authentic behavior from the binary. Details +
|
damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
|
||||||
what's-next: `context/project-overview.md`, `context/open-questions.md`, recent git log.
|
all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
|
||||||
|
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
|
||||||
|
archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
|
||||||
|
`context/open-questions.md`, recent git log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ precise than anything you can infer.
|
|||||||
3. **RULE: no stand-ins.** The full logic IS in the pseudocode; a "gap" is an unfilled stub, not a
|
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.
|
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.
|
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.
|
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/`
|
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,
|
ledger for detail), with an evidence tier. Keep the knowledge base current — this is a mandate,
|
||||||
@@ -58,6 +62,7 @@ precise than anything you can infer.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| What the project is, engine, platform, goal | `context/project-overview.md` |
|
| What the project is, engine, platform, goal | `context/project-overview.md` |
|
||||||
| Missing BT source, decompilation strategy | `context/source-completeness.md` |
|
| Missing BT source, decompilation strategy | `context/source-completeness.md` |
|
||||||
|
| **Export DARK REGIONS / "is X in the decomp?"** — the gap census | `reference/decomp/GAP_CENSUS.md` (tools: `tools/gapcensus.py`, `tools/ghidra_reexport.sh`, `tools/gapdiff.py`; log: `phases/phase-04-gap-census.md`) |
|
||||||
| The 4.10 literal-source manifest (per-TU function/size/address map) | `reference/BT410_SOURCE_MANIFEST.md` (tool: `tools/manifest410.py`; log: `phases/phase-03-bt410-source-manifest.md`) |
|
| The 4.10 literal-source manifest (per-TU function/size/address map) | `reference/BT410_SOURCE_MANIFEST.md` (tool: `tools/manifest410.py`; log: `phases/phase-03-bt410-source-manifest.md`) |
|
||||||
| The WinTesla Windows port (renderer/audio/HAL) | `context/wintesla-port.md` |
|
| The WinTesla Windows port (renderer/audio/HAL) | `context/wintesla-port.md` |
|
||||||
| Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
|
| Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
|
||||||
@@ -65,6 +70,7 @@ precise than anything you can infer.
|
|||||||
| BGF geometry, LODs, CONN/PCONN, ramps | `context/bgf-format.md` |
|
| BGF geometry, LODs, CONN/PCONN, ramps | `context/bgf-format.md` |
|
||||||
| A layout/linkage/databinding BUG | `context/reconstruction-gotchas.md` |
|
| A layout/linkage/databinding BUG | `context/reconstruction-gotchas.md` |
|
||||||
| The reconstruction method / workflow | `context/reconstruction-method.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` |
|
| Walking, gait, ground model, collision | `context/locomotion.md` |
|
||||||
| Subsystems, the factory, heat/weapons/power | `context/subsystems.md` |
|
| Subsystems, the factory, heat/weapons/power | `context/subsystems.md` |
|
||||||
| Damage zones, targeting, firing, death | `context/combat-damage.md` |
|
| Damage zones, targeting, firing, death | `context/combat-damage.md` |
|
||||||
@@ -85,7 +91,7 @@ precise than anything you can infer.
|
|||||||
| Term / acronym definitions | `reference/glossary.yaml` |
|
| 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 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) |
|
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
|
||||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md` |
|
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -148,7 +154,9 @@ bt411/
|
|||||||
│ └── <topic>.md # one concept each
|
│ └── <topic>.md # one concept each
|
||||||
├── reference/
|
├── reference/
|
||||||
│ ├── glossary.yaml # terms/acronyms
|
│ ├── glossary.yaml # terms/acronyms
|
||||||
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth)
|
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth; REBUILT 2026-08-06,
|
||||||
|
│ │ # 93.5% coverage. archive_2025export/ = the old export, kept
|
||||||
|
│ │ # ONLY so old `part_0NN.c:LINE` citations resolve -- cite @ADDR)
|
||||||
│ └── ghidra_scripts/ # the exporters
|
│ └── ghidra_scripts/ # the exporters
|
||||||
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
|
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
|
||||||
├── phases/ # restructuring / investigation logs
|
├── phases/ # restructuring / investigation logs
|
||||||
|
|||||||
@@ -164,3 +164,31 @@ pad X action LookBehind
|
|||||||
pad Start action ModeCycle
|
pad Start action ModeCycle
|
||||||
pad RightThumb action DisplayCycle
|
pad RightThumb action DisplayCycle
|
||||||
pad Back action Valve
|
pad Back action Valve
|
||||||
|
|
||||||
|
# COOLANT FLUSH -- HELD key dumps reservoir coolant into the loops
|
||||||
|
# (Reservoir InjectCoolant, msg 4). The tank drains while held; with the
|
||||||
|
# tank empty the loops burn coolant with no refill -- run hot long enough
|
||||||
|
# and the bank drops below 5%, which ARMS the eject (Panic lights).
|
||||||
|
key G action Flush
|
||||||
|
|
||||||
|
# PANIC / EJECT -- the punch-out (Mech msg 0x19). Only fires on a CRIPPLED
|
||||||
|
# mech (no generators, coolant leaked below 5%, or leg-gimped novice) -- a
|
||||||
|
# healthy mech refuses the button, so a stray press is a no-op.
|
||||||
|
key Back action Eject
|
||||||
|
pad LeftThumb action Eject
|
||||||
|
|
||||||
|
# PILOT KEYPAD (the pod's MFD program keypad, KeyboardPilot unit). While
|
||||||
|
# PANIC mode is armed, ANY pilot-keypad key fires the eject (the binary's
|
||||||
|
# keyboardGroup[KeyboardPilot].Add(0x200000, mech, 0x19) -- its ONLY armed
|
||||||
|
# eject binding); un-armed, the keys feed program entry. Desktop numpad =
|
||||||
|
# that keypad.
|
||||||
|
key NumPad0 keypad pilot 0
|
||||||
|
key NumPad1 keypad pilot 1
|
||||||
|
key NumPad2 keypad pilot 2
|
||||||
|
key NumPad3 keypad pilot 3
|
||||||
|
key NumPad4 keypad pilot 4
|
||||||
|
key NumPad5 keypad pilot 5
|
||||||
|
key NumPad6 keypad pilot 6
|
||||||
|
key NumPad7 keypad pilot 7
|
||||||
|
key NumPad8 keypad pilot 8
|
||||||
|
key NumPad9 keypad pilot 9
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ role=Role::Default
|
|||||||
dropzone=one
|
dropzone=one
|
||||||
vehicle=ava1
|
vehicle=ava1
|
||||||
vehicleValue=1000
|
vehicleValue=1000
|
||||||
color=Red
|
color=Crimson
|
||||||
[largebitmap]
|
[largebitmap]
|
||||||
bitmap=BitMap::Large::Aeolus
|
bitmap=BitMap::Large::Aeolus
|
||||||
[BitMap::Large::Aeolus]
|
[BitMap::Large::Aeolus]
|
||||||
|
|||||||
+64
-246
@@ -1,8 +1,8 @@
|
|||||||
[mission]
|
[mission]
|
||||||
adventure=BattleTech
|
adventure=BattleTech
|
||||||
map=arena1
|
map=cavern
|
||||||
scenario=freeforall
|
scenario=freeforall
|
||||||
time=day
|
time=night
|
||||||
weather=clear
|
weather=clear
|
||||||
temperature=27
|
temperature=27
|
||||||
length=600
|
length=600
|
||||||
@@ -156,206 +156,95 @@ x=128
|
|||||||
y=32
|
y=32
|
||||||
width=8
|
width=8
|
||||||
[pilots]
|
[pilots]
|
||||||
pilot=10.99.0.1:1502
|
pilot=127.0.0.1:1502
|
||||||
pilot=10.99.0.2:1502
|
pilot=127.0.0.1:1602
|
||||||
pilot=10.99.0.3:1502
|
pilot=127.0.0.1:1702
|
||||||
pilot=10.99.0.4:1502
|
pilot=127.0.0.1:1802
|
||||||
[10.99.0.1:1502]
|
[127.0.0.1:1502]
|
||||||
hostType=0
|
hostType=0
|
||||||
advancedDamage=1
|
advancedDamage=1
|
||||||
loadzones=1
|
loadzones=1
|
||||||
name=ALPHA
|
name=Aeolus
|
||||||
bitmapindex=1
|
bitmapindex=1
|
||||||
experience=expert
|
experience=novice
|
||||||
badge=VGL
|
badge=VGL
|
||||||
patch=Yellow
|
patch=Yellow
|
||||||
role=Role::Default
|
role=Role::Default
|
||||||
dropzone=one
|
dropzone=one
|
||||||
vehicle=madcat
|
vehicle=bhk1
|
||||||
vehicleValue=1000
|
vehicleValue=1000
|
||||||
color=White
|
color=White
|
||||||
[10.99.0.2:1502]
|
[127.0.0.1:1602]
|
||||||
hostType=0
|
hostType=0
|
||||||
advancedDamage=1
|
advancedDamage=1
|
||||||
loadzones=1
|
loadzones=1
|
||||||
name=BRAVO
|
name=Boreas
|
||||||
bitmapindex=2
|
bitmapindex=2
|
||||||
experience=expert
|
experience=novice
|
||||||
badge=VGL
|
badge=VGL
|
||||||
patch=Yellow
|
patch=Yellow
|
||||||
role=Role::Default
|
role=Role::Default
|
||||||
dropzone=one
|
dropzone=one
|
||||||
vehicle=thor
|
vehicle=ava1
|
||||||
vehicleValue=1000
|
vehicleValue=1000
|
||||||
color=Crimson
|
color=Crimson
|
||||||
[10.99.0.3:1502]
|
[127.0.0.1:1702]
|
||||||
hostType=0
|
hostType=0
|
||||||
advancedDamage=1
|
advancedDamage=1
|
||||||
loadzones=1
|
loadzones=1
|
||||||
name=CHARLIE
|
name=Caicias
|
||||||
bitmapindex=3
|
bitmapindex=3
|
||||||
experience=expert
|
experience=novice
|
||||||
badge=VGL
|
badge=VGL
|
||||||
patch=Yellow
|
patch=Yellow
|
||||||
role=Role::Default
|
role=Role::Default
|
||||||
dropzone=one
|
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
|
vehicleValue=1000
|
||||||
color=Green
|
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]
|
[largebitmap]
|
||||||
bitmap=BitMap::Large::ALPHA
|
bitmap=BitMap::Large::Aeolus
|
||||||
bitmap=BitMap::Large::BRAVO
|
[BitMap::Large::Aeolus]
|
||||||
bitmap=BitMap::Large::CHARLIE
|
|
||||||
bitmap=BitMap::Large::DELTA
|
|
||||||
[BitMap::Large::ALPHA]
|
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=000001F803C001FFE07807807E000000
|
bitmap=000003FE0000000001E0000000000000
|
||||||
bitmap=000001FC03C001FFF07807807F000000
|
bitmap=000007FF0000000001E0000000000000
|
||||||
bitmap=000001FC03C001FFF87807807F000000
|
bitmap=00000F8F8000000001E0000000000000
|
||||||
bitmap=000003FC03C001FFFC780780FF000000
|
bitmap=00000F078000000001E0000000000000
|
||||||
bitmap=000003DE03C001E07C780780F7800000
|
bitmap=00000F0781FF00FF81E0F0781FC00000
|
||||||
bitmap=000003DE03C001E03C780780F7800000
|
bitmap=00000F0783FF81FFC1E0F0783FE00000
|
||||||
bitmap=000007DE03C001E03C780781F7800000
|
bitmap=00000F0787C7C3E3E1E0F0783C700000
|
||||||
bitmap=0000078F03C001E07C780781E3C00000
|
bitmap=00000F078783C3C1E1E0F07878300000
|
||||||
bitmap=0000078F03C001FFFC7FFF81E3C00000
|
bitmap=00000F078783C3C1E1E0F07878000000
|
||||||
bitmap=00000F8F03C001FFF87FFF83E3C00000
|
bitmap=00000FFF8783C3C1E1E0F0787C000000
|
||||||
bitmap=00000F0783C001FFF07FFF83C1E00000
|
bitmap=00000FFF8783C3C1E1E0F0787F000000
|
||||||
bitmap=00000F0783C001FFC07FFF83C1E00000
|
bitmap=00000F0787FFC3C1E1E0F0783FC00000
|
||||||
bitmap=00001FFF83C001E000780787FFE00000
|
bitmap=00000F0787FFC3C1E1E0F0781FE00000
|
||||||
bitmap=00001FFFC3C001E000780787FFF00000
|
bitmap=00000F07878003C1E1E0F07807F00000
|
||||||
bitmap=00001FFFC3C001E000780787FFF00000
|
bitmap=00000F07878003C1E1E0F07801F00000
|
||||||
bitmap=00003E03C3C001E00078078F80F00000
|
bitmap=00000F07878003C1E1E0F07800F00000
|
||||||
bitmap=00003C01E3FFF1E00078078F00780000
|
bitmap=00000F078780C3C1E1E0F07860F00000
|
||||||
bitmap=00003C01E3FFF1E00078078F00780000
|
bitmap=00000F0787C1C3E3E1E078F871E00000
|
||||||
bitmap=00007C01E3FFF1E00078079F00780000
|
bitmap=00000F0783FF81FFC1E07FF83FE00000
|
||||||
bitmap=00007800F3FFF1E00078079E003C0000
|
bitmap=00000F0781FF00FF81E03F781FC00000
|
||||||
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=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=00000000000000000000000000000000
|
bitmap=00000000000000000000000000000000
|
||||||
@@ -366,87 +255,16 @@ x=128
|
|||||||
y=32
|
y=32
|
||||||
width=8
|
width=8
|
||||||
[smallbitmap]
|
[smallbitmap]
|
||||||
bitmap=BitMap::Small::ALPHA
|
bitmap=BitMap::Small::Aeolus
|
||||||
bitmap=BitMap::Small::BRAVO
|
[BitMap::Small::Aeolus]
|
||||||
bitmap=BitMap::Small::CHARLIE
|
bitmap=00000000000000000000000000000000
|
||||||
bitmap=BitMap::Small::DELTA
|
bitmap=0000000000000000003E00000C000000
|
||||||
[BitMap::Small::ALPHA]
|
bitmap=006300000C00000000631F0F8CC63C00
|
||||||
bitmap=0000000000000000
|
bitmap=00633198CCC66200007F3198CCC66000
|
||||||
bitmap=0000000000000000
|
bitmap=00633F98CCC6780000633018CCC61E00
|
||||||
bitmap=0000000000000000
|
bitmap=00633018CCC6060000633098CCC64600
|
||||||
bitmap=00038C1F98C70000
|
bitmap=00631F0F8C7E3C000000000000000000
|
||||||
bitmap=00038C18D8C70000
|
bitmap=00000000000000000000000000000000
|
||||||
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
|
|
||||||
x=64
|
x=64
|
||||||
y=16
|
y=16
|
||||||
width=4
|
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
|
## Collision / damage / subsystems
|
||||||
- **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes;
|
- **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes;
|
||||||
see [[locomotion]] ground model + [[combat-damage]]. [T1]
|
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),
|
zones/tables, **`.SUB`** subsystems (weapon params — DischargeTime/RechargeRate/WeaponRange),
|
||||||
**`.CTL`** control maps. [T1]
|
**`.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
|
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
|
authors.** The cwd guard stays either way (it protects the developer tree, where the split is
|
||||||
real and permanent).
|
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`
|
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
|
(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,
|
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
|
mad1/mad2, own1, snd1, thr1, vul1); each boots a solo mission, spawns, animates, no
|
||||||
crash. The code path is mech-agnostic. [T2]
|
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)
|
## Debug (cdb x86)
|
||||||
`"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"`. Pattern for a faulting stack (cwd
|
`"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
|
= content\): `-g -c ".lines;sxe av;g;kp 24;q"` with `BT_ASSERT_TO_DEBUGGER=1`. Debug CRT fills fresh
|
||||||
@@ -138,6 +182,18 @@ default (`-DBT_STEAM=ON` is the documented dev-checkout state). `BT_EXPIRE=ON` (
|
|||||||
14-day tester window; an expire-OFF zip is renamed `-noexpire` and warns. Verify by extracting the
|
14-day tester window; an expire-OFF zip is renamed `-noexpire` and warns. Verify by extracting the
|
||||||
zip somewhere clean and booting it with no repo present — that is what catches a missing runtime DLL.
|
zip somewhere clean and booting it with no repo present — that is what catches a missing runtime DLL.
|
||||||
|
|
||||||
|
## Build ritual — the stale-link flake (bit 4+ times; MANDATORY for bench work)
|
||||||
|
MSBuild often does NOT relink `btl4.exe` when only static-lib members changed — the bench then
|
||||||
|
runs a STALE exe and the session burns hours on phantom results. Ritual for every code-change build:
|
||||||
|
1. `rm -f build/Release/btl4.exe` FIRST (force the relink);
|
||||||
|
2. build; a REAL relink prints the 20 known-benign `CreateStreamedSubsystem` LNK2019s (`/FORCE`
|
||||||
|
baseline — see the top of this file); an up-to-date run prints none;
|
||||||
|
3. STRING-VERIFY before benching: `python -c "d=open(r'build/Release/btl4.exe','rb').read();
|
||||||
|
print(b'<A-NEW-LOG-MARKER>' in d)"` — pick a string your change added (NB: release strings
|
||||||
|
carry LOG TEXT, not function names — check the .map for symbols);
|
||||||
|
4. use ABSOLUTE paths in every bench command — `cd` leaks between compound commands (the cwd
|
||||||
|
trap) and has broken builds/appends/copies repeatedly.
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Base: [[wintesla-port]] (the engine build recipe).
|
- Base: [[wintesla-port]] (the engine build recipe).
|
||||||
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
|
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
|
||||||
|
|||||||
+72
-2
@@ -17,8 +17,21 @@ Verified in-game on all 8 mechs (Thor vs gameplay footage). [T2]
|
|||||||
|
|
||||||
- One per mech (12: AVX BLX FIX JAX LOX MAX OWX RAX SNX STX THX VUX; model→skeleton table in
|
- One per mech (12: AVX BLX FIX JAX LOX MAX OWX RAX SNX STX THX VUX; model→skeleton table in
|
||||||
BTL4.RES @~3232850: `madcat=mad`, `blkhawk=blh`, … — the INSIDE skeleton is the X-variant, so
|
BTL4.RES @~3232850: `madcat=mad`, `blkhawk=blh`, … — the INSIDE skeleton is the X-variant, so
|
||||||
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh; the inside view loads
|
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh. [T2]
|
||||||
EXACTLY ONE segment mesh (the `_cop`) — 25 others hidden. [T2]
|
- **The inside view is a PER-MECH AUTHORED SET, not the cop alone** (#91 decode, 2026-08-04;
|
||||||
|
the earlier "EXACTLY ONE segment mesh" claim was madcat-derived and over-generalized —
|
||||||
|
corrected [T1, decomp-grounded: the binary's renderable build `FUN_004cef28`
|
||||||
|
(part_014.c:5077) sets skeleton type 4 (=A) for the inside build and its per-segment loop
|
||||||
|
calls `FUN_00424084(segment, type)` (:5570) for EVERY segment — NULL name = no mesh, no
|
||||||
|
torso-only filter, no cop special-case (only `siteeyepoint` diverts to the eye ctor
|
||||||
|
:5560-64); engine side: no type fallback in `EntitySegment::GetVideoObjectName`,
|
||||||
|
SEGMENT.cpp:97 — so the binary drew every authored type-A mesh, own-body pieces included;
|
||||||
|
T2: live `[view]` roster logs]). Authored type-A rosters: **madcat / vulture / bhk1 = cop only; sunder /
|
||||||
|
loki / avatar = cop + `<pfx>_tor` torso piece; thor = cop + `thx_tor` + `thx_msl` (the
|
||||||
|
shoulder LRM pod!); owens = cop + BOTH complete legs (`owx_{l,r}{ule,dle,bto,fto}`) +
|
||||||
|
`own_tshd`** — the Owens pilot saw their own legs walking; the Thor pilot saw their
|
||||||
|
missile pod edge bob at the lower frame. `BT_HIDE_INSIDE_SEG=<substr>` hides a non-cop
|
||||||
|
type-A mesh by name (diag, btl4vid.cpp ApplyViewSkeleton).
|
||||||
- Material: `<pfx>skin:blakskn_dz_{u,r,l}torso_mtl` — DIFFUSE/AMBIENT (0,0,0) + RAMP_REF
|
- Material: `<pfx>skin:blakskn_dz_{u,r,l}torso_mtl` — DIFFUSE/AMBIENT (0,0,0) + RAMP_REF
|
||||||
`softer`, **NO texture** (BLXSKIN.BMF is 238 bytes total; byte-verified). The 56 nonzero-UV
|
`softer`, **NO texture** (BLXSKIN.BMF is 238 bytes total; byte-verified). The 56 nonzero-UV
|
||||||
verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
|
verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
|
||||||
@@ -88,6 +101,36 @@ inverse(eyeWorld)`. No LookAt anywhere.
|
|||||||
DPLEyeRenderable::Execute (order `Orient × R`, `D3DXMatrixInverse` on the live path — note the
|
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).
|
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)
|
## Status per mech + the gyro (task #56 — gyro now LIVE)
|
||||||
|
|
||||||
- **Madcat (MAX_COP): verified good on pure defaults** — dark domed frame (top arch +
|
- **Madcat (MAX_COP): verified good on pure defaults** — dark domed frame (top arch +
|
||||||
@@ -200,6 +243,33 @@ the shell green), `BT_COP_DUMP` (per-batch punch/bbox), `BT_EYE_FWD=<f>` (eye po
|
|||||||
- Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
|
- Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
|
||||||
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting)
|
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting)
|
||||||
|
|
||||||
|
## The #91 "black rectangle" (thor) — own-body inside meshes + the blakskn rule [T2]
|
||||||
|
|
||||||
|
Three testers (Oracle + RajelAran night 7; Ronin 2026-08-03: "moves diagonally 10→4 with the
|
||||||
|
footsteps, at rest sits ON the lower frame bar, right side, half-in") reported a black
|
||||||
|
rectangle in the Thor cockpit. Decoded 2026-08-04:
|
||||||
|
- The rectangle is the **thor's own `thx_msl` missile-pod mount plate** — authored, authentic
|
||||||
|
own-body geometry (see the roster above; "Summoner" = the `thr1` FE label, same THX canopy,
|
||||||
|
so the cross-reference isolated nothing). It rides the walk-animated shoulder while the eye
|
||||||
|
rides the gyro springs → the diagonal footstep-rhythm sway. Pixel-tracked across walk
|
||||||
|
captures: ~29px wide, x/y oscillating together along the 10↔4 axis, bottom center-right,
|
||||||
|
alternating with the gait [T2, scratchpad/night11/thorrect.sh].
|
||||||
|
- **What WAS wrong:** the pilot-facing surfaces of these own-body meshes are authored with the
|
||||||
|
same `<pfx>skin:blakskn_dz_*` interior-structure material as the canopy frame (texture-less,
|
||||||
|
black diffuse, `softer` ramp lo 0.25 → hi 0.99), but the unlit frame-constant treatment was
|
||||||
|
keyed on the `_cop` FILENAME — so the identical material rendered (0.13,0.12,0.15) on the
|
||||||
|
canopy and PURE (0,0,0) on the pod/legs (`[matlog]`-verified: owx_cop blakskn vcol=FF211F26
|
||||||
|
vs owx_lule blakskn vcol=FF000000). Fixed in bgfload.cpp: the constant now keys on
|
||||||
|
`meshIsCop || material contains "skin:blakskn_dz_"`. `mechfx:blakskn_mtl` (tshd shadow
|
||||||
|
quads) deliberately does NOT match. Verified: zero pure-black pixels in the lower view band
|
||||||
|
post-fix, tab renders in the frame tone and blends with the bar at rest; owens legs read as
|
||||||
|
coherent dark structure; canopy/terrain un-regressed. The textured pod/leg batches
|
||||||
|
(THX/GEN/OWEN.BSL pages — bright, mean texel 0.55-0.74) face outboard and were never the
|
||||||
|
visible surfaces.
|
||||||
|
- Diags from the dig: `BT_MAT_LOG=<mesh-stem substr>` (bgfload per-batch material routing:
|
||||||
|
resolved colour/texture/ramp/vcol), `BT_HIDE_INSIDE_SEG=<substr>`, `[view]` per-segment
|
||||||
|
inside-roster log. The `softer` ramp resolves lo=(0.25,0.25,0.25) hi=(0.99,0.99,0.99) [T1].
|
||||||
|
|
||||||
## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated]
|
## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated]
|
||||||
User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y)
|
User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y)
|
||||||
into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
|
into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
|
||||||
|
|||||||
+482
-31
@@ -69,6 +69,103 @@ player-only logic on `this == application->GetViewpointEntity()` (else a spawned
|
|||||||
player input). [T1]
|
player input). [T1]
|
||||||
|
|
||||||
## Targeting (mech offsets, [[decomp-reference]] §3)
|
## Targeting (mech offsets, [[decomp-reference]] §3)
|
||||||
|
**STRUCTURAL DISCOVERY (2026-07-29, the #73 pick dig): the target block is an embedded engine
|
||||||
|
`Reticle` at `mech+0x36c`** [T0 layout-verified]. `engine/MUNGA/RETICLE.h` lays out exactly:
|
||||||
|
`reticlePosition@0x36c` (Vector2D), `reticleState@0x374`, `pickPointingOn@0x378`,
|
||||||
|
**`rayIntersection@0x37c`**, **`targetEntity@0x388`**, **`targetDamageZone@0x38c`**,
|
||||||
|
`reticleElementMask@0x390`. This is why every "who writes 0x388" scan came back empty — writers
|
||||||
|
carry `&mech->reticle` (an `lea [mech+0x36c]`) and use small reticle-relative offsets.
|
||||||
|
`ENTITY3.h:131` states the model in T0 words: *"For BattleTech, damage zones are only valid via
|
||||||
|
reticle based weapons."* The engine `Reticle` is a **passive container** (ctor + resource parse
|
||||||
|
only — no pick math); `HudSimulation @0x4b7830` opens with `lea esi,[owner+0x36c]` and READS the
|
||||||
|
pick (range caret from `rayIntersection`, the designator transform) and slews `reticlePosition`
|
||||||
|
via `@0x4b7ed4`, but does not write the pick either. **The pick WRITER is BT code, still
|
||||||
|
unlocated** [T4 candidates]: the un-exported stretch `0x4a1674-0x4a2d48` (touches the reticle at
|
||||||
|
`0x4a16a5` — the Mech::Make-called init — and `0x4a1f93`, `0x4a294a/54/61` state+pickPointing+base
|
||||||
|
together near entry `0x4a2971`, which is called from `0x494483`), the two unidentified Mech vtable
|
||||||
|
overrides `+0x18=0x4a122c` / `+0x1c=0x4a0c2c` (both big switch functions), and lone sites
|
||||||
|
`0x4877a4` (byte read via the mission entity table `0x5015c8`), `0x4b0097`, `0x45fb14-24`.
|
||||||
|
**RESOLVED (same dig, deeper): there IS no software pick writer — the 1995 pick was a DPL SCENE
|
||||||
|
INTERSECTION** [T0 + T1 converging]. Evidence: (a) the WinTesla renderer still carries the result
|
||||||
|
members `dplHitInstance/dplHitDCS/dplHitGeoGroup/dplHitGeometry` + `vehicleReticle` (L4VIDEO.cpp
|
||||||
|
ctor, all NULL-init); (b) the stubbed 1995-era renderable constructors each took
|
||||||
|
**`dpl_isect_mode_obj` ("type of intersections to do on this object") + an intersection MASK** —
|
||||||
|
per-renderable intersection configuration against the scene; (c) Auric's account ("the pod's
|
||||||
|
division card cast from the view"); (d) VGL Lynx's LOD warning reads as firsthand knowledge — the
|
||||||
|
ray tested the DRAWN geometry, i.e. the active LOD's meshes; (e) exhaustive scans: nothing in the
|
||||||
|
binary or the pseudocode ever writes `rayIntersection/targetEntity/targetDamageZone` — game code
|
||||||
|
only reads, initializes (the Mech ctor @0x4a1674 constructs the embedded Reticle), gates
|
||||||
|
(`FUN_004afd10`, the look-state machine, toggles `pickPointingOn` per view with the π rear case),
|
||||||
|
and SUBMITS the reticle to the board for drawing (`FUN_00460a7c` packages +0x37c/+0x390 into dpl).
|
||||||
|
⇒ **Aimed fire in 1995 had PER-PART precision**: scene ray → struck triangle on the current LOD →
|
||||||
|
the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSegmentIndex@49db20`) =
|
||||||
|
`targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's
|
||||||
|
whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed
|
||||||
|
lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray.
|
||||||
|
**COMPLETED 2026-08-03 (#124, the zone-walk matrix): the pick is TRIANGLE-ACCURATE** [T2 matrix].
|
||||||
|
The sphere approximation measurably mis-picked (aim dead-on dtorso -> picked rgun/ruleg) —
|
||||||
|
`MechSegmentPick` runs a sphere PRE-FILTER then Moller-Trumbore nearest-hit over the threaded
|
||||||
|
segments' posed meshes (CPU triangle cache from each object's own BGF buffers, `BTGetPickMesh`).
|
||||||
|
**⚠ CORRECTED 2026-08-04 (field pushback from era players — they were RIGHT): the 08-03 zone rule
|
||||||
|
("the struck segment's SKL dzone, ALWAYS" -> every aimed torso hit = CENTER TORSO) was WRONG.**
|
||||||
|
The authentic rule: **the pick's zone is the struck PATCH's authored `dz_*` tag** — the art
|
||||||
|
zone-tags the hull PER PANEL (`MAD_TOR.BGF` carries `dz_utorso`×36 / `dz_ltorso`×18 /
|
||||||
|
`dz_rtorso`×18 / `dz_dtorso`×16 + all four rear panels + `dz_searchlight` across its patches
|
||||||
|
[T1 bytes]); the dpl hit result kept GEOGROUP granularity (`dplHitGeoGroup` in the renderer's
|
||||||
|
result block [T0]); and the binary's segment->zone map `@49db20` has NO runtime caller (sole
|
||||||
|
caller = `CreateStreamedDamageZone`, load-time — raw call-scan [T1]), so no segment-level
|
||||||
|
collapse mechanism even exists. Aimed fire in the pod stripped the exact hull panel under the
|
||||||
|
reticle. The segment-level model looked airtight because LIMBS agree at both granularities (one
|
||||||
|
zone per limb mesh) — the collapse only bit the hull, and Oracle's night-10 audit ("only LCT gets
|
||||||
|
hits") was the BUG's fingerprint, not the pod's design. Port: `MechSegmentPick` attributes the
|
||||||
|
struck triangle to its draw op (index-range) and takes the op's `.DZM`-bound zone (the #87
|
||||||
|
armour-darkening bindings — the same authored patch->zone mapping that already paints those
|
||||||
|
panels), segment dzone as the untagged-patch fallback; `ZoneAimPoint` aims hull zones at their
|
||||||
|
patch CENTROIDS so the zone walker can exercise per-panel. Bench: [[test-harness]] §zone-walk
|
||||||
|
(madcat variant, scratchpad/night11/zonewalk_madcat.sh).
|
||||||
|
**MISS-MEANS-MISS (#131, 2026-08-05) [T2 both-direction bench]:** the pick answers ONLY for
|
||||||
|
drawn geometry -- the any-object SPHERE fallback and the caller's whole-mech AABB fallback are
|
||||||
|
gone (sphere only for a mesh the reader cannot parse -- none exist, counters clean; AABB only as
|
||||||
|
the pre-tree replicant grace, `BTMechSegmentPick` returns 1/0/-1). They were port stand-ins the
|
||||||
|
1995 card never had, and their signature regime was a LEVEL boresight over a SHORT mech: the
|
||||||
|
blackhawk's mesh tops out below eye-ray height, so rays cleared every triangle while threading
|
||||||
|
the fat cull spheres -- lock ring lit with the reticle above its head (night-12 field report,
|
||||||
|
operator-witnessed on the sweep bench). Instruments: `BT_LOCK_SWEEP` (torso-pan lock envelope,
|
||||||
|
operator-watchable), `BT_LOCK_ENVELOPE` (synthetic unit sweep), `[picksrc]`/`[pickbox]` source
|
||||||
|
telemetry + objs/invFail/noTri localization. Locking is now strictly TIGHTER than 716-774; if
|
||||||
|
era testers report the pods were more forgiving, Draco's "slight lock linger" memory is the
|
||||||
|
sourced follow-up (tracked on #131).
|
||||||
|
**TWIST-SIGN VERIFIED (2026-08-04) [T2 live]:** the frame adapter's inferred angular flip
|
||||||
|
(`SelectSlice: theta -= TorsoHeading()`, vs the binary's `+=` before the z-reflection) is
|
||||||
|
CORRECT — bench: target torso pinned 0/+140°/−140° (`BT_FORCE_TWIST`, sim-level
|
||||||
|
`analogTwistAxis` hold in torso.cpp — the input-level pin is DEAD, live input rides the
|
||||||
|
CONTROLS.MAP device push and Basic mode auto-centers), LRM salvos (missiles = the authentic
|
||||||
|
cylinder consumers; the binary DROPPED zone-−1 beam damage). Slice picks track the
|
||||||
|
physically-facing flank in both twist directions (positive twist = `horizontalLimitLeft` =
|
||||||
|
CCW), deterministic; the wrong sign is cleanly excluded (would pick slice 7 where slice 1 is
|
||||||
|
observed). Diag: `[slice]` log (live twist, thetaIn/thetaAdj, wedge index) + zone NAMES on
|
||||||
|
`[dmgresolve]` (both under `BT_DMGTABLE_LOG`); bench `scratchpad/night11/twistsign.sh`.
|
||||||
|
|
||||||
|
**IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.**
|
||||||
|
`BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object +
|
||||||
|
`GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by
|
||||||
|
JMOVER.cpp:290) are recorded in `MechRenderTree::segPick`; the pick ray-tests the per-segment
|
||||||
|
bounding spheres (`mCullCenter/mCullRadius`, world via the draw-cached `mLocalToWorld` — ≤1 frame
|
||||||
|
stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST radius wins**
|
||||||
|
(normalized `d²/r²` tie-break). Both simpler rules were measured failing identically: the torso
|
||||||
|
mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always
|
||||||
|
faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs
|
||||||
|
it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line
|
||||||
|
— the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick
|
||||||
|
per candidate (box `PickRayHit` + zone −1 → the unaimed lottery survives only as fallback:
|
||||||
|
no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` +
|
||||||
|
`targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone.
|
||||||
|
Bench (L/C/R sweep, dummy at 8u): aim-left = **36/41 hits on zone 2 = `jointlshoulder`** with
|
||||||
|
thread score 0.30 (was a 6-way lottery spray); envelope grazes log score ≈0.99 and credit the
|
||||||
|
torso. The MadCat segment→zone map is authored and rich (shoulders 2/9, guns 6/17, hip 1, legs
|
||||||
|
3/5/8/10/16/19, torso 0). Known approximations for field verification: sphere bounds (not
|
||||||
|
per-triangle), the torso-envelope graze credits the torso where the pod's mesh test would miss
|
||||||
|
into air. Diag: `BT_PICK_LOG` (`[segpick]` the map at build, `[pickwin]` zone/score/t per pick).
|
||||||
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
|
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
|
||||||
`mech+0x38c` = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
|
`mech+0x38c` = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
|
||||||
refreshed each frame. [T1]
|
refreshed each frame. [T1]
|
||||||
@@ -181,7 +278,7 @@ no-arc). The old hardwired lock, the ±30°-default cone, and the projectile pat
|
|||||||
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
|
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
|
||||||
bug was fixed en route: every
|
bug was fixed en route: every
|
||||||
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
|
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
|
||||||
the chosen view — the aim camera feed and the V toggle both depend on it).
|
the chosen view — the aim camera feed and the view toggle (backtick since task #68 took 'V' for rear-view) both depend on it).
|
||||||
|
|
||||||
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
|
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
|
||||||
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
|
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
|
||||||
@@ -271,6 +368,34 @@ per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) a
|
|||||||
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
|
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
|
||||||
offsets). [T2]
|
offsets). [T2]
|
||||||
|
|
||||||
|
## "Mechs die with lightly damaged armor" — AUDITED, no port bug: it's AMMO COOK-OFF (2026-08-02) [T1 chain, T2 measured]
|
||||||
|
Field complaint (night 9): mechs die while their panels read nearly pristine, so "the scaling
|
||||||
|
must be off". A byte-level audit of the ENTIRE damage chain found **every link faithful**:
|
||||||
|
zone stream reads (arcade base ctor `@0041df5c` == engine DAMAGE.cpp, incl. the JointedMover
|
||||||
|
skip-5 branch); the BT ctor normalization loop (`@0049ce50` constants 1.0/1e-4/0.5 — and in
|
||||||
|
SHIPPED CONTENT the raw cells are EXACTLY `1/armorPts` uniform across all 5 types, so the
|
||||||
|
normalize is a no-op); `TakeDamage @0041e4e0` formula+clamps; the override `@0049c690`
|
||||||
|
(redirect gate, vital/leg thresholds 1.0/0.5/0.0 `@0049c99c..a4`); `CriticalHit @0049ccc4`
|
||||||
|
(fraction 0.5 `@0049ce48`, cap 1.0 `@0049ce4c`, the per-subsystem `applied<damagePercentage`
|
||||||
|
cap); the cook-off chain (`stamp @004bc3fc` per-SALVO before the missile divide, `CookOff
|
||||||
|
@004bd300` = ammoCount×amount, `DistributeCriticalHit @004ac274` = ÷(zones plugging the bin)
|
||||||
|
then one DESIGNATED-zone TakeDamageMessage each).
|
||||||
|
**The measured kill route** (death-kinetics bench, 7 deaths): 6 of 7 were **bay-fire
|
||||||
|
detonations** — e.g. `AmmoBinLRM15 10 rounds × 50 = 500 (burst 1)` into the bin's ONE authored
|
||||||
|
carrier zone (madcat bins are single-zone: LRM15_1=roster[26]→dz_ltorso, LRM15_2=[28]→dz_rtorso,
|
||||||
|
AFC100=[21]→{larm,lgun}), which is VITAL (madcat authors **15 of 22 zones vital** — all torsos,
|
||||||
|
legs, feet) → one application 0→1.0 → instant death, every other panel bright. The ÷1 is the
|
||||||
|
CONTENT's authoring, not a port shortcut — the arithmetic is the binary's own. The one mech that
|
||||||
|
died conventionally (300 applications) ended VISIBLY charred: 0.97/0.93/0.92/0.85/0.79 across 13
|
||||||
|
zones — mid-fight darkening DOES accumulate when mechs live long enough.
|
||||||
|
**Field logs confirm dominance**: Rajel's night-9 log alone has 12 bay-fire events (LRM10
|
||||||
|
455-490 pts); ConnMan 10; the "salvo barely dented my armor" reporter was dying to their OWN
|
||||||
|
cook-offs. **The real lever is HEAT** (bay fire arms at the bin heat-watcher's FAILURE level;
|
||||||
|
the [[subsystems]] heat/#96 too-hot economy arms it constantly) plus player training: EJECT
|
||||||
|
**hold** purges the burning bay (`DumpAmmo` → "bay fire EXTINGUISHED"); pod players were
|
||||||
|
manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
|
||||||
|
suppress the whole path ([[experience-levels]]).
|
||||||
|
|
||||||
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
||||||
Three-layer story, measured live + decomp-verified:
|
Three-layer story, measured live + decomp-verified:
|
||||||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
||||||
@@ -279,17 +404,26 @@ Three-layer story, measured live + decomp-verified:
|
|||||||
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
|
`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,
|
`@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
|
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`
|
NOT in this formula — `TakeDamage` itself IGNORES it** [T1]. ⚠ But one Damage message is **NOT**
|
||||||
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the
|
one application: `Mech::TakeDamageMessageHandler` calls `TakeDamage` **`burstCount` times**,
|
||||||
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the
|
re-rolling the struck zone per burst (@0x4a0423-0x4a04d8; `mech.cpp`, task #80). `burstCount` =
|
||||||
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓
|
number of applications, and it is load-bearing (missile cluster count, splash falloff, gyro
|
||||||
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000
|
bounce). A cluster missile lands its damageAmount once **per connecting missile** — the count is
|
||||||
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e).
|
rolled at impact, `Random(n) + n/4` clamped to `n`. See the corrected SALVO note below. ✓
|
||||||
The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local
|
2. **StaticBounce prices rams at ~600×v² raw** (measured: 59221@9.94, 66239@9.59, 398@0.81;
|
||||||
REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and proceeds) —
|
`[collide-tx]` logs mass/e — mech moverMass is the tonnage-scale 60000-90000, NOT the old
|
||||||
**ram damage was network-inert in the pod**. Our task-#47 replicant forwarding (required
|
"authored ≈1.3e6" mis-attribution; the ~24·v²·0.0005·mass factor is the head-on (vn·vp)
|
||||||
for MP weapon damage) surfaced it as a one-shot. Port: `BTDispatchCollisionDamage`
|
algebra). The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed
|
||||||
normalizes ×1e-3 to the armor-point economy (bump = points, charge = tens) [T3].
|
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
|
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
|
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
|
pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn
|
||||||
@@ -299,6 +433,34 @@ Band effects: `MechDeathHandler::Tick` fires the CURRENT band descriptor on any
|
|||||||
(the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/
|
(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).
|
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]
|
## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2]
|
||||||
Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType
|
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**,
|
histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**,
|
||||||
@@ -359,20 +521,150 @@ 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
|
`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.
|
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]
|
## ⚠ CORRECTED (2026-07-29, #80): subsystem private zones ARE damageable — the port was writing
|
||||||
Each `MechSubsystem` owns a private `DamageZone` at `+0xE0`, built by the **2-arg trivial ctor**
|
## their scales through the WRONG LAYOUT
|
||||||
`new DamageZone(this, 0)`. That ctor (`engine/MUNGA/DAMAGE.cpp:187-190`) zeroes **all five**
|
The 2026-07-28 verdict here ("a subsystem's private zone cannot be damaged via `TakeDamage` — in
|
||||||
`damageScale[]` entries, `Reset()` never touches them, and the only other writer in the tree is
|
the original too") was **wrong about the original**, and the experiment that "confirmed" it was
|
||||||
`Mech__DamageZone` — the mech's *streamed* zones (`mechdmg.cpp:246-253`), a different class. Since
|
measuring a PORT bug. The truth [T1, raw disasm @0x4ac7bb + live-verified]:
|
||||||
`DamageZone::TakeDamage` is `damageLevel += damageAmount * damageScale[damageType]`, **any**
|
- The binary's `MechSubsystem` resource ctor **initializes the private zone's armour**:
|
||||||
`TakeDamage` against a subsystem's own zone is arithmetically a no-op, whatever the amount or type.
|
`defaultArmorPoints@0x140` ← resource `WeaponDamagePoints` (+0x44, REQUIRED key) and
|
||||||
Verified by experiment: a zone seeded to 0.6 and fed −0.011f for ~1500 ticks never moved.
|
`damageScale[5]@0x144` ← the five per-damage-TYPE keys
|
||||||
The crit path works because it **bypasses this entirely** and writes `damageLevel` directly
|
`Collision/Ballistic/Explosive/Laser/EnergyDamagePoints` (+0x30), normalized
|
||||||
(`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`; `ForceCriticalFailure` sets the state).
|
`1/(scale·armorPoints)` exactly like the mech zones (without their extra ×0.5).
|
||||||
Practical consequence: if you are reconstructing something that "damages a subsystem", route it the
|
- The PORT had this copy — but wrote it through the **`ReconDamageZone` proxy**, whose
|
||||||
way the crit path does — a `TakeDamage` call there will silently do nothing. This is also why
|
`structureReference/armour[]` sit at struct offsets **+4/+8**, not the binary's +0x140/+0x144.
|
||||||
Myomers' Performance `@004b8bb9` (an un-powered self-repair) is dead code in the 1995 binary itself:
|
The floats landed on the engine object's header and the real scales stayed at the engine ctor's
|
||||||
see [[subsystems]] WAVE 6.
|
zeros — hence the frozen-damage experiment. Classic databinding trap, now on the engine side.
|
||||||
|
FIXED: the ctor writes the engine's NAMED members (layout-parity holds — `Mech__DamageZone`
|
||||||
|
locks its derived fields from 0x160 up).
|
||||||
|
- The port's CSS also never parsed the seven keys (now parsed; two are required with the binary's
|
||||||
|
own error strings).
|
||||||
|
Consequences: `MechSubsystem::TakeDamage @0x4ac0bc` (now real, was a stub) accumulates authored
|
||||||
|
per-type damage on subsystems; crits land and destroy subsystems; and **the Myomers un-powered
|
||||||
|
self-repair `@004b8bb9` was LIVE in 1995 and is live in the port now** — the earlier "dead code in
|
||||||
|
the original too" note in [[subsystems]] WAVE 6 is superseded. `DistributeCriticalHit`'s direct
|
||||||
|
`damageLevel = 1.0f` pin remains the ammo-explosion path, not the general mechanism.
|
||||||
|
|
||||||
|
## ⚠ ZONE SELECTION IS A WEIGHTED LOTTERY — pixel-precise limb damage does not exist (2026-07-29) [T1]
|
||||||
|
Issue #73 ("fired only at the left arm, damage credited elsewhere, no crits") is **largely the
|
||||||
|
authored 1995 model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream
|
||||||
|
format): impact point → `WorldToLocal` → **height layer** (`floor(layerCount·y/heightRef)`) →
|
||||||
|
**pie slice** (`atan2(local.z, local.x)`, optionally rotated by live torso twist —
|
||||||
|
`rotateWithTorso` per layer) → `DamageZonePercentTable::SelectZone()` = **`RandomUnit()` against
|
||||||
|
cumulative percent thresholds** (`@0x49de14`). Each slice sprays damage across an authored
|
||||||
|
*distribution* of zones — aiming at a limb at best biases WHICH SLICE you strike; the zone within
|
||||||
|
it is dice. Measured live (L/C/R aim sweep at 8u, `BT_DMG_LOG`): 24-46 hits per aim point spread
|
||||||
|
across 6+ zones each, with the distribution *shifting* by aim — the model working as designed.
|
||||||
|
Consequences for triage: reports of "damage landed somewhere I didn't aim" need this base rate
|
||||||
|
before being called bugs; crit expectations from aimed fire are likewise probabilistic.
|
||||||
|
**Residual genuine question [T3]:** the shooter's pick point comes from `Mech::PickRayHit` = a
|
||||||
|
**whole-mech AABB slab test** (a PORT stand-in — the binary's 0x37c/0x388/0x38c writer is in the
|
||||||
|
un-exported gap, never decompiled). Theta computed from a flat box FACE clusters toward the
|
||||||
|
facing slices, so flank slices may be under-reachable from frontal shots vs. the pod (which
|
||||||
|
plausibly intersected the mech's *cylinder* — the damage table is literally cylindrical).
|
||||||
|
Deciding whether that distortion is material needs a per-hit theta probe + a wheel dump (slices ×
|
||||||
|
percent tables, MadCat) — see #73 on the tracker.
|
||||||
|
|
||||||
|
## ✅ CRITS RECONSTRUCTED (2026-07-29, #80) — the section below records the GAP as found; all three
|
||||||
|
## layers are now fixed and live-verified
|
||||||
|
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the
|
||||||
|
Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink,
|
||||||
|
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState (⚠ label/body mismatch:
|
||||||
|
the 0x17 handler @0x49f674 randomizes position + graphicAlarm(2) + re-enables sim — not burning;
|
||||||
|
possibly a shifted name row, cf. the factory ClassID mislabels — open-questions), 0x19 EjectPilot @0x49f854,
|
||||||
|
0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**:
|
||||||
|
`p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice
|
||||||
|
never crits), rolled per BURST on the current zone (skip if the zone is already burning). The
|
||||||
|
handler's application loop (binary @0x4a0423-0x4a04d8, now in `mech.cpp`): per burst — crit roll →
|
||||||
|
`CriticalHit @0049ccc4` (replaces the zone application; routes half through armour internally,
|
||||||
|
tally += the subsystem's `CriticalHitScoreBonus`) else `zone->TakeDamage`; then **re-run the
|
||||||
|
cylinder lottery from the impact point for the next burst** (multi-burst damage sprays), stopping
|
||||||
|
early once the mech is disabled. The engine base's single-application (which ignored `burstCount`
|
||||||
|
entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsystem::TakeDamage
|
||||||
|
@0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
|
||||||
|
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel).
|
||||||
|
Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1`
|
||||||
|
— a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368 →
|
||||||
|
`0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
|
||||||
|
`BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
|
||||||
|
|
||||||
|
## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
|
||||||
|
The rest of `Mech::TakeDamageMessageHandler` (@0x4a02f4-0x4a0890, dark-gap raw disasm) is now
|
||||||
|
reconstructed: `BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), called from the
|
||||||
|
handler at the binary's exact positions (mech.cpp). Retires `BTPostDamageScore`/`BTPostKillScore`.
|
||||||
|
|
||||||
|
**Entry sequence** [T1]: gyro bounce → death-edge latch `[ebp-0x10]` (was-destroyed at ENTRY —
|
||||||
|
before the divert!) → `lastInflictingID` stamp (mech+0x43c ← msg inflictor) → shooter resolve
|
||||||
|
(registry find → `shooter+0x190` = shooter's PLAYER; victim's = `mech+0x190`) → **collision divert
|
||||||
|
JUMPS TO THE DEATH TAIL** (@0x4a0375 `jmp 0x4a07b5`, NOT a return — a wall/fall death still posts
|
||||||
|
VehicleDead + blasts, but skips the score blocks: collision deaths credit no one). Our old early
|
||||||
|
`return` here was a latent "wall-death strands the pilot" hazard, fixed with the move.
|
||||||
|
|
||||||
|
**The three id-0x16 reports** (all `Player::ScoreMessageID` 0x16, sizeof 0x3C — layout in
|
||||||
|
btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
|
||||||
|
- **A @0x4a04da, newly destroyed** → type 2 KillScore to the SHOOTER's player. Basis (+0x24) =
|
||||||
|
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
|
||||||
|
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
|
||||||
|
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
|
||||||
|
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
|
||||||
|
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
|
||||||
|
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
|
||||||
|
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
|
||||||
|
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
|
||||||
|
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
|
||||||
|
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
|
||||||
|
(gated `GetConsoleHost()` && !`suppressConsole` — which is why that field is named that).
|
||||||
|
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
|
||||||
|
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
|
||||||
|
|
||||||
|
**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
|
||||||
|
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
|
||||||
|
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
|
||||||
|
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
|
||||||
|
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
|
||||||
|
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
|
||||||
|
`freeForAll@0x250==0`); self-kill negates the whole award.
|
||||||
|
|
||||||
|
**The death tail** (@0x4a07b5, shared by the divert): gate = was-alive-at-entry && movementMode
|
||||||
|
9|10 → **`Player::VehicleDeadMessage` id 0x17 to the mech's OWN player** — the respawn trigger,
|
||||||
|
in the BT 0x38-byte extension `{deathCount -1, dropZone Null, +0x2c killed-by PLAYER EntityID,
|
||||||
|
+0x34 kill zone}` (bench: `killedBy=2:1 zone=3` cross-node) — then the death Explosion (id 3,
|
||||||
|
0x5C, model 0x31; port fires the authored death list from the transition instead, #42) and the
|
||||||
|
#89 SplashDamage (re-verified after the reorder: 72 bursts at ~9u). The old mech4
|
||||||
|
death-transition dispatch site (which was flagged [T3 sender-undecoded]) is retired — this IS
|
||||||
|
that sender. The #55 NULL-playerLink fallback + DEAD_NOTIFY forensics moved into the bridge.
|
||||||
|
Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 award=4.88`,
|
||||||
|
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
|
||||||
|
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
|
||||||
|
|
||||||
|
**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch
|
||||||
|
[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test
|
||||||
|
@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable
|
||||||
|
slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal
|
||||||
|
Total by −9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view
|
||||||
|
is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing
|
||||||
|
else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the
|
||||||
|
burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync
|
||||||
|
DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Implemented 2026-08-06** (branch `ppc-sync-distortion`).
|
||||||
|
|
||||||
|
## (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
|
## Damage delivery + the real damage model
|
||||||
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch`.
|
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch`.
|
||||||
@@ -671,7 +963,9 @@ stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the la
|
|||||||
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
|
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
|
||||||
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
|
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
|
||||||
|
|
||||||
## Kill-score damage (task #60)
|
## Kill-score damage (task #60) — ⚠ SUPERSEDED 2026-08-05 by §THE AUTHENTIC SCORE/DEATH REPORT
|
||||||
|
## TAIL above (`BTPostKillScore` is RETIRED; the kill basis is now the applied damage TALLY +
|
||||||
|
## the victim role's killBonus, posted by the victim's own TakeDamage handler). History below.
|
||||||
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
|
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
|
||||||
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
|
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
|
||||||
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
|
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
|
||||||
@@ -721,10 +1015,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.**
|
**⚠ 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
|
**KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is
|
||||||
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** So ONE arcade cluster
|
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.**
|
||||||
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit
|
⚠ **CORRECTION (2026-08-01, #95): do NOT generalise that into "burstCount is cosmetic for zone
|
||||||
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math
|
damage" — the earlier wording here said exactly that and it is WRONG.** `TakeDamage` ignores it;
|
||||||
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). The port
|
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
|
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
|
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
|
splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix
|
||||||
@@ -747,6 +1054,150 @@ its decomp writers (4668/10512) read as a per-frame state toggle, not a clean co
|
|||||||
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
|
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
|
||||||
enable. See [[open-questions]].
|
enable. See [[open-questions]].
|
||||||
|
|
||||||
|
## Collision damage — the type-0 DIVERT + rattle distributor (#83, 2026-07-30) [T1 disasm/T2]
|
||||||
|
`Mech::TakeDamageMessageHandler` diverts `damageType==0` (@0x4a0368) into
|
||||||
|
`DistributeCollisionDamage` (FUN_0049ffcc, export gap, raw disasm `scratchpad/night6/
|
||||||
|
gap_49ffcc.txt`): collision damage **never reaches the zone/armor loop**. Gate: the owning
|
||||||
|
player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the manual's
|
||||||
|
"splash/collision damage" technician setting. Pricing: `raw × (2000/moverMass)/(100·(1/3.6))²/
|
||||||
|
(1−elasticity²)` (constants @0x4a0148=1/3.6 tbyte, 2000f, 0.5f threshold; mass@+0x20c,
|
||||||
|
elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)`
|
||||||
|
sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative
|
||||||
|
`collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility =
|
||||||
|
IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
|
||||||
|
MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c —
|
||||||
|
the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere
|
||||||
|
(weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the
|
||||||
|
same way. Measured on bhk1 (mass 60000, e=0.2): scale 4.5e-5; a 94k spawn-slam = 4.2 rattle
|
||||||
|
points; taps free. `[colldmg]` (BT_DMG_LOG) + `[crashdmg]` (always-on, rare) are the probes.
|
||||||
|
|
||||||
|
## The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified]
|
||||||
|
|
||||||
|
`Mech__DamageZone::RecurseSegmentTable @0049cad4`: when a zone reaches damageLevel
|
||||||
|
1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT
|
||||||
|
TREE (mech+0x300, the engine `JointedMover::segmentTable` [T0]):
|
||||||
|
* `destroySiblingsOnDestruction` (streamed, Wword 0x68) -> every other zone on
|
||||||
|
the SAME segment (`EntitySegment::damageZoneTable`, seg+0xD0) recurses and is
|
||||||
|
set graphic state 2 (**Gone**);
|
||||||
|
* `descendOnDestruction` (Wword 0x67) -> every zone on every CHILD segment
|
||||||
|
(`childIndexTable` seg+0xE8 -> child's damageZoneTable) recurses -- **the arm
|
||||||
|
takes the gun pod with it**. Each recursed zone's `SendSubsystemDamage
|
||||||
|
@0049c9a8` pushes the unused crit allotment and `ForceCriticalFailure`s any
|
||||||
|
subsystem driven to 1.0 -- which the #86 fire gates then refuse.
|
||||||
|
Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything
|
||||||
|
else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin +
|
||||||
|
Condenser6 force-failed, `AFC100 -> NoAmmo (gate1): destroyed=1`; dz_larm -> PPC
|
||||||
|
force-failed, `[emitter] 'PPC' fire REFUSED (destroyed=1)` x4998. Torso weapons
|
||||||
|
unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade
|
||||||
|
(no guard in the binary's child loop; only the sibling loop checks graphic state
|
||||||
|
!= 1) -- authentic, idempotent in effect.
|
||||||
|
|
||||||
|
⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators
|
||||||
|
returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off
|
||||||
|
arms left firing gun pods across every chassis (Conn Man's three-chassis audit).
|
||||||
|
See reconstruction-gotchas #25.
|
||||||
|
|
||||||
|
## Zone-LEVEL replication -- the observer's picture (#87 root cause, 2026-08-03) [T1 decomp / T2 verified]
|
||||||
|
|
||||||
|
How an OBSERVER (replicant holder) learns a mech's zone damageLevels, end to end:
|
||||||
|
|
||||||
|
1. Damage messages do NOT echo locally: `Entity::Dispatch` on a REPLICANT
|
||||||
|
forwards to the master's node and returns (ENTITY.cpp:244) -- there is no
|
||||||
|
broadcast-bus application. The MASTER alone consumes `TakeDamage`.
|
||||||
|
2. The master ships levels via zone UPDATE RECORDS
|
||||||
|
(`DamageZone::Write/ReadUpdateRecord`, `DamageZoneUpdateModelBit`), gated by
|
||||||
|
`ForceUpdate(DamageZoneUpdateModelFlag)`.
|
||||||
|
3. **Who raises the flag (the binary's effect watcher `FUN_0042aa2c @0042aa2c`,
|
||||||
|
the BT analog of the engine's `EntityEffectWatcher` -- which itself never
|
||||||
|
runs for mechs: mech zones carry BAND DESCRIPTORS, the engine `ExplosionTable`
|
||||||
|
stays NULL and `EntityEffectWatcher` is constructed nowhere in the image):**
|
||||||
|
* level branch (zone changedFlags & 4): if the rise CROSSES a band-descriptor
|
||||||
|
threshold (`FUN_0042a5f4` = `Mech__DamageZone::DescriptorCrossed`) AND the
|
||||||
|
entity is a MASTER (`(entity+0x28 & 0xc) == 0`) -> `*(entity+0x18) |= 2`
|
||||||
|
(= ForceUpdate(DamageZoneUpdateModelFlag)).
|
||||||
|
* gstate branch (changedFlags & 8): ForceUpdate unconditionally (masters).
|
||||||
|
So observers track a fight at **band-crossing granularity** -- not per hit.
|
||||||
|
4. Consumption on the observer is passive: `ReadUpdateRecord` writes the level,
|
||||||
|
the armour watcher (`TickArmourDamage`) re-reads zones every frame and pushes
|
||||||
|
the draw-op tint; the doll gauges read `damageLevel*100` continuously.
|
||||||
|
|
||||||
|
**The port had dropped the level branch** -- mechdmg.cpp's band hub ForceUpdated
|
||||||
|
only on `graphicState != Exists`, so every observer's copy sat at 0.0 until a
|
||||||
|
zone DESTRUCTED: no enemy hull darkening, no observer-side doll movement, ever
|
||||||
|
(the "no armour discoloration" night-10 report). Restored 2026-08-03
|
||||||
|
(mechdmg.cpp band loop: `DescriptorCrossed(prev, level)` + master gate on both
|
||||||
|
branches). Verified 2-node: A-side watcher pushed 114 level changes; every
|
||||||
|
replicant peak matched the master's final level to 4 decimals (dtorso 0.9321
|
||||||
|
both sides).
|
||||||
|
|
||||||
|
Render-path note (gotcha #23 discharged): the tint itself was pixel-proven with
|
||||||
|
`BT_ARMOR_FORCE` A/B captures -- all-zones 1.0 renders the hull charcoal (0.1x
|
||||||
|
floor), forced 0.4 reads visibly grey at close range on a light skin, so "no
|
||||||
|
discoloration" was never the renderer. Solo/master-side perception is the
|
||||||
|
authentic ECONOMY: 2-25 pt laser events vs 68-185 pt zone pools. The eye
|
||||||
|
catches SNAPS, not creep: a single heavy hit (ERLG large laser / missile
|
||||||
|
class, amt=25) jumps a 77-pt arm 0 -> 0.3247 in one frame (measured live) =
|
||||||
|
the pod veterans' "a single missile leaves visible armor damage"; 2-pt ERS
|
||||||
|
fire creeps ~1.6%/hit and stays visually silent for a dozen hits (the
|
||||||
|
"legs never show" reports -- their levels peak ~0.08-0.13). The cockpit doll
|
||||||
|
maps all 28 zones (L4GAUGE.CFG:4788-4814 cmArmor lines -- feet + lower legs
|
||||||
|
included); nothing is unwired (orphan audit 2026-08-03: every drawn op with a
|
||||||
|
material name is claimed by a zone).
|
||||||
|
|
||||||
|
Fidelity boundary [T1]: the HOST binary's response is LINEAR (watcher
|
||||||
|
@004573e4/@00457784: 13 material floats lerped toward x0.1 by level, then
|
||||||
|
"flush_material" to the division card). The CARD-side material->ramp->texel
|
||||||
|
curve is i860 firmware absent from BTL4OPT.EXE -- see [[open-questions]]
|
||||||
|
"i860 DIVISION-CARD firmware". If pod darkening was perceptually steeper at
|
||||||
|
mid levels, that steepness lived there.
|
||||||
|
|
||||||
|
## The hit-location CYLINDER -- raw-bytes audit + chassis->table map (2026-08-02) [T1]
|
||||||
|
|
||||||
|
Full independent verification of the type-29 DamageLookupTable streams, parsed
|
||||||
|
STRAIGHT from BTL4.RES bytes with `scratchpad/night9/res29_scan.py` (no port
|
||||||
|
code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||||
|
|
||||||
|
* **18 streams, 8 distinct by content** (copies 3,3,2,2,2,2,2,2). All 7-layer.
|
||||||
|
* **Slice counts are authored, not fixed 8**: Black Hawk band 6 and the
|
||||||
|
Vulture/Battlemaster band 4 are **7-wedge** rings; everything else 8.
|
||||||
|
* rotateWithTorso: layers 3-6 on six tables; **all-zero on Black Hawk + Owens**.
|
||||||
|
* Chassis -> content hash (live-dump matched, dump-identical rounding):
|
||||||
|
ava1->1b9958b4, bhk1->5150f823, lok1->b0a0e692, **mad1->c02bfc7a**,
|
||||||
|
own1->f0f03c87, snd1->0e1a9ca0, thr1->0163740f, vul1->739b1a6b.
|
||||||
|
The two same-zone-set "Avatar/MadCat" tables ARE distinguishable by loader:
|
||||||
|
**the Mad Cat rides one (c02bfc7a), the Avatar the other (1b9958b4)**.
|
||||||
|
* **Wedge orientation, from the AUTHORED slice names** [T1 data]: W0/W7 =
|
||||||
|
"Right*", W1/W2 = "Front*" -- with theta = atan2(z,x) from +X toward +Z, the
|
||||||
|
mech-local FRONT is the **+Z arc (theta 45-135 deg)** and dead-ahead is the
|
||||||
|
W1|W2 SEAM. (Corrects the earlier #92 comment that put frontal hits in W6.)
|
||||||
|
* **⚠ PORT FRAME ADAPTER (#124, fixed 2026-08-03; corrected same day by the
|
||||||
|
MUZZLE-ANCHOR probe)**: that +Z-forward (and +X-right, from the W0/W7
|
||||||
|
"Right*" names) is the **1995 resolver frame**. OUR engine frame, measured
|
||||||
|
with the mech's own asymmetric geometry as the anchor (`BT_ASPECT_TEST`:
|
||||||
|
four world-cardinal self-impacts + one at each weapon's physical muzzle):
|
||||||
|
**RIGHT = +X, same as 1995; FORWARD = −Z, flipped** — the LEFT missile pod
|
||||||
|
sits at raw local x=−2.32, the RIGHT at +2.32, and `WorldToLocal` is a clean
|
||||||
|
R(yaw) with no hidden terms (verified at yaws −30°/150°/40°). The frames
|
||||||
|
differ by a **Z-NEGATION ONLY — a reflection** (the first commit's π
|
||||||
|
rotation CROSSED the pods: the left muzzle resolved rtorso). `ResolveHit`
|
||||||
|
negates local z; and because a reflection reverses angular direction, the
|
||||||
|
torso-twist term in `SelectSlice` enters with the OPPOSITE sign (twist was 0
|
||||||
|
in every probe — the twisted-torso verify is the tracked follow-up).
|
||||||
|
Anchor acceptance: left pod→ltorso, right pod→right wedges, left/right arm
|
||||||
|
muzzles→larm/rarm, nose→front cells, tail→rear cells, at a third yaw. The
|
||||||
|
separate "one band LOW" Y-signature is #16 (pick-ray parallax), not this.
|
||||||
|
Frontal unaimed foot-band hits therefore straddle the RightFoot/LeftFoot
|
||||||
|
cells by impact-x sign, and the authored cross-bleed (20% opposite foot,
|
||||||
|
10+10% opposite leg) is a data-true source of "hit the opposite leg" reports.
|
||||||
|
* Resolver mechanics re-confirmed from the decomp (@0049eb54/@0049e678/
|
||||||
|
@0049de14): WorldToLocal; local y CLAMPED to [0, heightRef] BEFORE scaling
|
||||||
|
(heightRef = live collision cylinder, owner+0x2ec->+0xc); layer floor+clamp;
|
||||||
|
atan2(z,x) +2pi wrap, on-axis -> random angle; rotateWithTorso adds the live
|
||||||
|
torso heading (torso+0x1d8) BEFORE slicing; uniform roll vs ascending
|
||||||
|
cumulatives, -1 fallthrough. **Collision damage (type 0) NEVER reaches the
|
||||||
|
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
|
||||||
|
call 0x49ffcc; jmp out) [T1 disasm].
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
||||||
|
|||||||
+224
-2
@@ -176,6 +176,22 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Master posture / crouch block (FUN_004a9b5c region, raw disasm 2026-08-05/06) [T1]
|
||||||
|
- mech+0x3f8 `mapPosture` (0 none / 1 may-duck / 2 holding-squat) — selector @0x4a9f61-0x4aa007:
|
||||||
|
gates = movementMode normal, simLive (player+0x25c, NOVICE lockout), leg alarm 0/4 → 1, leg
|
||||||
|
alarm 1 → 2, myomer factor |x|>1e-4 (const @0x4ab16c).
|
||||||
|
- mech+0x79c `myomerEffectiveness` — MAX over the myomer chain's speedEffect@0x31C, published by
|
||||||
|
the mapper drive-scale block (mechmppr.cpp:990 == @0x4a9cf2-0x4a9da4); scales speedDemand,
|
||||||
|
zeroes turnDemand at ~0 (the seek-4 freeze), and gates the crouch.
|
||||||
|
- DuckRequest consumer @0x4aa011-0x4aa0af: duckState && squatCapable → SetLegAnimation(2 'sqd')
|
||||||
|
/ (3 'squ'), ForceUpdate(8)+(1) (type-3 record ships legState → peers pose it), stability
|
||||||
|
alarm 0/1, request consumed. Clips: animationClips[2]=sqd(i), [3]=squ(i) (BTL4.RES, all 8
|
||||||
|
chassis; loader slots byte-verified vs @0x50d9c8 suffix pool).
|
||||||
|
- Myomers cluster: Performance wrapper @004b8b9c → drive-heat integrator @004b8be3;
|
||||||
|
AvailableOutput @004b8ac0 = gear clamp × QUADRATIC heat degrade (+0x114 vs +0x118/+0x11c band)
|
||||||
|
× (1 − legZoneDamage); speedEffect = output / ownerBaseSpeed(+0x34C) — unclamped (gear-4
|
||||||
|
supercharge ≈1.43).
|
||||||
|
|
||||||
## 4. Damage delivery
|
## 4. Damage delivery
|
||||||
|
|
||||||
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch(&msg)`.
|
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch(&msg)`.
|
||||||
@@ -183,6 +199,7 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
|||||||
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
|
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
|
||||||
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
|
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
|
||||||
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
|
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
|
||||||
|
- **BT effect watcher (zone replication + band effects)** [T1, 2026-08-03]: ctor `FUN_0042a984 @0042a984` (hooks entity+0xbc, allocs oldLevel[damageZoneCount@+0x11c]); Execute `FUN_0042aa2c @0042aa2c` — per zone: flag&4 → `FUN_0042a664` DescriptorForLevel + `FUN_0042a5f4` DescriptorCrossed(old,new) → **crossed && master (`(entity+0x28 & 0xc)==0`) → `*(ushort*)(entity+0x18) |= 2`** (= `ForceUpdate(DamageZoneUpdateModelFlag)`, the zone-record send); flag&8 → `FUN_0042a6c4` DescriptorForGraphicState + the same master-gated ForceUpdate; descriptor → effect via `FUN_0043663c/FUN_004364e4` (renderer mgr @`DAT_004efc94+0x38`); changedFlags reset when (master && !pending-update) or replicant. Zone band table @ zone+0xd4; level @+0x158; gstate @+0x78. Engine `EntityEffectWatcher`/`ExplosionTable` are DEAD for mechs (table NULL, watcher never constructed — band descriptors replace them). Port: mechdmg.cpp band hub. See [[combat-damage]] §Zone-LEVEL replication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -246,9 +263,36 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
|||||||
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
|
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
|
||||||
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
|
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
|
||||||
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
|
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
|
||||||
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674, 0x18
|
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674 (⚠ body mismatch — randomizes position, graphicAlarm 2, sim re-arm; see open-questions), 0x18
|
||||||
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
|
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
|
||||||
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
|
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
|
||||||
|
- **BTPlayer** (byte-scanned 2026-08-05, file 0x112dxx; 20-byte rows {id, namePtr, fn, 0, 0} — 6
|
||||||
|
entries EXACTLY, matching the T0 PLAYER.h enum): **0x15 DropZoneReply@0x4bffd0, 0x16
|
||||||
|
Score@0x4c02e4** (the type-1/2 switch — BT shadows the engine id with its override), **0x17
|
||||||
|
VehicleDead@0x4c05c4, 0x18 MissionStarting@0x4bfbe8, 0x19 MissionEnding@0x4bfc20, 0x1a
|
||||||
|
ScoreUpdate@0x4c02a8** (= Player::NextMessageID). **@0x4c0200 ("ScoreInflicted", type-0-only,
|
||||||
|
Verify line 0x18b) is in NO entry — dead code in 1995.** The 1995 type-0 path therefore lands in
|
||||||
|
@0x4c02e4's Verify arm and folds an UNINITIALIZED [ebp-0xc] into currentScore (real bug; port
|
||||||
|
banks 0). @0x4c02e4 internals [T1 raw]: `+0x40==4` MissionEnding gate; registry-find msg+0x34;
|
||||||
|
case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the
|
||||||
|
console VTVDamaged post (ConsoleHost && !suppressConsole@0x258; **arg 6 = Round(AWARD)** — an
|
||||||
|
ST0-arg __ftol @0x4dcd94 the decompiler rendered argless; the old "Now()" read was wrong); case
|
||||||
|
2 = kill: `CalcKillScore@0x4c052c` × (senderTonnage/ownTonnage), self-kill `fchs`, dual
|
||||||
|
`killCount@0x27c++` (shooter + victim's player — the phantom partner increment, masked by
|
||||||
|
replication), StatusMessage{type 0 Destroyed, victim player, 6.0s} from pool @0x512f6c with
|
||||||
|
vtable @0x513344, clear-target @0x4b04d8 via `playerVehicle+0x128` roster head when the dead
|
||||||
|
mech == `objectiveMech@0x284`. `CalcKillScore@0x4c052c` [T1]: same-team (strcmp
|
||||||
|
`teamName@0x20c`, gate `freeForAll@0x250==0`) → `-friendlyFirePenalty(role+0x14)`; else
|
||||||
|
`victimMech->avgZoneDamage@0x354 × damageBias(role+0x18) + 1.0` (const @0x4c05c0); result ×
|
||||||
|
`(basis + tally) × damageInflictedModifier(role+0xC)`. **ScenarioRole offsets** (Node base):
|
||||||
|
+0xC inflictedMod, +0x10 receivedMod, +0x14 ffPenalty, +0x18 damageBias, +0x1C killBonus,
|
||||||
|
+0x20 specialCaseDeathPenalty. **mech+0x354** = average zone damageLevel (computed with the
|
||||||
|
subsystem average @0x358 by the telemetry refresher, part_012.c:9515). **Wire layouts**
|
||||||
|
(static_assert-locked, btplayer.hpp): ScoreMessage 0x3C {+0x1c scoreAward=tally, +0x20 type,
|
||||||
|
+0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID(=TakeDamageMessage+0x5c,
|
||||||
|
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
|
||||||
|
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
|
||||||
|
— see [[combat-damage]].
|
||||||
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
|
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
|
||||||
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
|
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
|
||||||
priority" — that's the emergent effect, not the mechanism):
|
priority" — that's the emergent effect, not the mechanism):
|
||||||
@@ -319,6 +363,100 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
|||||||
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
|
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
|
||||||
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
|
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
|
||||||
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
|
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
|
||||||
|
- **EJECT/PANIC cluster DECODED + WIRED 2026-08-02 [T1 raw disasm]:** `@0049f854`
|
||||||
|
**Mech::EjectPilot** (table id 0x19; sat in the 0x49f854-0x49fa00 export gap) — gates
|
||||||
|
{msg+0xc > 0, ejectPermitted@0x414, !IsDisabled@0049fb54}, console eject notice
|
||||||
|
(`FUN_004c198c` ctor from ownerID@0x18c → network sender, code 5 — port tail),
|
||||||
|
player `suppressConsole`@0x258 = 1 (the following death must not double-notify),
|
||||||
|
`graphicAlarm → 10` (EJECT state; **kills via the ≥9 predicate** — IsMechDestroyed),
|
||||||
|
self TakeDamageMessage {inflicting=SELF, zone −1, Explosive, amount =
|
||||||
|
`ScenarioRole::killBonus` role+0x1c, burst 0}. Role layout byte-settled via the
|
||||||
|
reader `@00429bec` dest offsets + ctor record copy: +0xc inflicted, +0x10 received,
|
||||||
|
+0x14 ffPenalty, +0x18 damageBias, **+0x1c killBonus**, **+0x20
|
||||||
|
SpecialCaseDeathPenalty (4.10-only key)**, +0x28 returnFromDeath. `KillBonus`
|
||||||
|
authors NOWHERE in shipped content → charge 0, alarm does the killing — authentic.
|
||||||
|
`@0049fa1c` **EvaluateEjectPermission** (writes @0x414; unexported caller): roster
|
||||||
|
walk — 0xBBE bank coolant fraction (+0x12c/+0x128) < 0.05 (`@0049fb50`) | zero live
|
||||||
|
Generators (0xBC1, +0x40 ≠ 1 && stateAlarm@0x210 ≠ 4) | live MechWeapons
|
||||||
|
(0x511830; ProjectileWeapon family 0x5121a8 also needs weaponAlarm@0x364 ≠ 7 NoAmmo)
|
||||||
|
< mech+0x448 (**no exported writer — zero, clause inert** [T3]) | leg-gimped
|
||||||
|
(mode 3/4) novice (simLive 0). Port: mech.cpp handler+evaluator, bridges
|
||||||
|
BTWeaponCountsForEject / BTGeneratorCountsForEject / BTHeatSinkBankCoolantFraction /
|
||||||
|
BTPlayerEjectBookkeeping; input = binding-engine `Eject` action (Backspace /
|
||||||
|
pad LeftThumb; BENCH `BT_EJECT_AT=<frame>`, `BT_KILL_SUBSYS` now comma-list).
|
||||||
|
- **EJECT ARM CHAIN closed 2026-08-03 [T1 E8-scan / T2 lamp verified]:** the
|
||||||
|
evaluator's "unexported caller" FOUND — one relative CALL from
|
||||||
|
`0x004a9b6c` = **`FUN_004a9b5c` (the Mech MASTER PERFORMANCE) + 0x10: its
|
||||||
|
FIRST act each frame is EvaluateEjectPermission** (masters only; the fn sits
|
||||||
|
in the 004a977x-004ab188 export hole, located by E8 rel32 scan of the CODE
|
||||||
|
section). Downstream: `@004d196c` L4MechControlsMapper::InterpretControls
|
||||||
|
edge-detects @0x414 (cache @mapper+0x1a8) → Add/RemoveModeMask(**0x200000 =
|
||||||
|
the PANIC-ARMED mode** — the old "missionReviewMode" reading of mech+0x414
|
||||||
|
was a MISLABEL, swept 2026-08-03; the real review mode is the GLOBAL
|
||||||
|
`DAT_004fd550`, btl4pb) → `MakeLinkedLamp(ButtonPanic 0x3d, 0x200000)`
|
||||||
|
(`FUN_00476fc0`) lights the pod PANIC button; on glass the pad panel shades
|
||||||
|
the miniconsole Panic button from the same PadRIO lamp state
|
||||||
|
(L4PADPANEL:295, verified lit dark→bright on generator kill). NO gauge in
|
||||||
|
the shipped L4GAUGE.CFG rides mode 0x200000 — the eject indication is the
|
||||||
|
BUTTON LAMP, not an MFD element ("weapon-eng MFD eject lamp" reports = the
|
||||||
|
weapon page's UNJAM/EJECT ammo-jam indicator, a different feature). Lamp is
|
||||||
|
SOLID on arm (engine linked-lamp semantic); FLASH = the GaugeAlarm
|
||||||
|
`SetAlertState` overlay (RIO::flashFast), not authored for panic. Port fix:
|
||||||
|
per-frame `EvaluateEjectPermission()` at the top of Mech::PerformAndWatch
|
||||||
|
(master-gated) + `GetEjectPermitted()` accessor; `BT_EJECT_LOG=1` logs the
|
||||||
|
arm edges. Adjacent table fact: id 0x1a `DuckRequest@0049fa00` latches
|
||||||
|
mech+0x398 = 1 — that latch has NO reader anywhere in the image (vestigial).
|
||||||
|
- **THE TECHSTATUS / GAUGE-ALARM MODEL decoded 2026-08-04 [T1]:** the gauge-alarm
|
||||||
|
"condition" = a **STATUS-FLAG BIT INDEX** (0..6, TechStatusTypeCount). MechTech's
|
||||||
|
`TechnicalAssistance` edge-scans every monitored subsystem's `GetStatusFlags()`
|
||||||
|
(vtable +0x30) and on a bit flip calls `ReportStatusSet/Cleared(sink, owner, sub,
|
||||||
|
bitIndex, alarmModel)` → `GaugeAlarmManager::Activate(condition=bitIndex)` → the
|
||||||
|
SHARED 'mechalrm' stream's items matching that condition flash their lamps
|
||||||
|
(SetAlertState → RIO flashFast). The bit table, all byte-grounded overrides:
|
||||||
|
**bit 0** structure ≥ StatusThreshold (@004ac18c) | **bit 1** structure >
|
||||||
|
StatusFloor | **bit 2** coolantActive && HeatModelActive (CoolantLeaking) |
|
||||||
|
**bit 3** heatAlarm ≠ 0 | **bit 4** linked AmmoBin cookOffArmed (**AmmoBurning**)
|
||||||
|
| **bit 5** weaponAlarm == 5 (**Jammed**) | **bit 6** !HasVoltage (no usable
|
||||||
|
voltage; `HasVoltage` = source stateAlarm==2 Ready && |outputVoltage| > ε,
|
||||||
|
@004b0b5c). **Invite semantics:** conds 4/5 → gotoEngineering 0x80 + engEject
|
||||||
|
0x85 = the AMMO purge/unjam invite (flashes the very key whose streamed function
|
||||||
|
is EjectAmmo msg 0xB) — NEVER pilot eject; cond 6 → 0x80 + engBusMode 0x86 =
|
||||||
|
the bus-switch invite; cond 2 → the coolant loop/placement lamps (#98). The
|
||||||
|
PANIC LAMP (mode-linked, solid) is the SOLE pilot-eject indicator. **Electrical
|
||||||
|
subtlety [T1, @0049c9a8 + FUN_004b1f7c]:** the crit distributor touches nothing
|
||||||
|
electrical, and the generator sim has NO Ready-case recompute — a generator
|
||||||
|
destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any
|
||||||
|
transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)
|
||||||
|
recomputes output via `(1 − damage) × rated` and zeroes it. stateAlarm 4's
|
||||||
|
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
|
||||||
|
destruction→state-4 bridge was removed as unfounded.
|
||||||
|
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
|
||||||
|
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
|
||||||
|
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
|
||||||
|
`Player::ScoreMessageHandler` (`@0042da20` == PLAYER.cpp:138) a type-1 ScoreMessage
|
||||||
|
with `scoreAward = −ScenarioRole::specialCaseDeathPenalty` (role+0x20, the 4.10-only
|
||||||
|
key; restored to SCNROLE.h/.cpp — record slot [1] after KillBonus, fail-hard read).
|
||||||
|
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
|
||||||
|
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
|
||||||
|
pod's death cost may never have displayed; our single-cell port shows it. Shipped
|
||||||
|
content authors NO role keys, so the cost is 0 in the field. Other role+0x1c reader:
|
||||||
|
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
|
||||||
|
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
|
||||||
|
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
|
||||||
|
`slice = (till.ticks − this[4].ticks) / DAT_0052140c; this[4] = till;
|
||||||
|
(*this[7])(this, slice)` then watchers (`FUN_0041c08c`) + WriteSimulationUpdate
|
||||||
|
(`FUN_0041bd98`) — **VARIABLE-STEP, slice in real SECONDS**, line-for-line the
|
||||||
|
WinTesla SIMULATE.cpp body. Corollaries: every Performance's `time_slice` is
|
||||||
|
seconds in BOTH binary and port (no hidden tick/second unit gap); a dt-LESS
|
||||||
|
per-frame term (myomer kinetic @4b8d18's middle term; the Mover gravity
|
||||||
|
`-= **(mover+0x250)` @0x421e77) fires at the FRAME cadence = the tick rate,
|
||||||
|
28 Hz nominal, sagging under load. Mover velocity is u/s (the replicant
|
||||||
|
dead-reckoner @0x421f7c does `pos += vel × ticksΔ/DAT_0052140c`). The myomer
|
||||||
|
heat term's ONE caller is the **unexported MyomersSimulation body
|
||||||
|
@0x4b8b9a-0x4b8d0d** (E8-scan + raw disasm; the export gap hid it): calls
|
||||||
|
@4b8d18 once per Perform, raw slice, gated `measuredVoltage > 0` only — plus
|
||||||
|
a −0.011/Perform repair trickle at alarm 1 and `speedEffect(+0x31c) =
|
||||||
|
AvailableOutput(min(measuredV, seek gear, cap)) / owner->runSpeedBase(+0x34c)`.
|
||||||
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
|
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
|
||||||
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
|
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
|
||||||
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
|
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
|
||||||
@@ -409,6 +547,13 @@ default-ON (`'0'` disables).
|
|||||||
| Var | Effect |
|
| Var | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) |
|
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) |
|
||||||
|
| `BT_BTNTEST=addr,on,off` / `BT_BTNTEST2=...` | scripted screen-button press/release cycles at poll counts (the REAL RIO seam; 2nd cycle for e.g. squat→rise) |
|
||||||
|
| `BT_DUCK_LOG` | crouch diagnostics: RIO press mode-mask, SetLegAnimation re-arm tracer (+ra), 1 Hz joint probe, posture/squat events |
|
||||||
|
| `BT_TREE_LOG` | dump the built render-tree topology (segment/joint/parent, SITE rows) at mech build |
|
||||||
|
| `BT_SPOT_SELF` | bench-only: build the searchlight cone on the OWN-cockpit tree so `BT_CAM=face` can eyeball it solo |
|
||||||
|
| `BT_CAM=face` | chase camera in FRONT looking back (the old animation view); `BT_CAM_Y/Z` offsets |
|
||||||
|
| `BT_FOG_LOG` | log SetFogStyle transitions + fog page resolution (searchlight swap forensics) |
|
||||||
|
| `BT_GIMP_SPEED` | override the gimp-gait demand scale (1.0 = authentic) |
|
||||||
| `BT_SPAWN_ENEMY` | spawn a target mech 120u ahead along the spawn facing |
|
| `BT_SPAWN_ENEMY` | spawn a target mech 120u ahead along the spawn facing |
|
||||||
| `BT_AUTOFIRE=1` | hold the trigger (headless walk→fire→death harness; supersedes the dead `BT_FORCE_FIRE` — btl4main.cpp:310 set `fireForced` once at startup, but mech4.cpp:1567 unconditionally overwrites it every frame) |
|
| `BT_AUTOFIRE=1` | hold the trigger (headless walk→fire→death harness; supersedes the dead `BT_FORCE_FIRE` — btl4main.cpp:310 set `fireForced` once at startup, but mech4.cpp:1567 unconditionally overwrites it every frame) |
|
||||||
| `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
|
| `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
|
||||||
@@ -427,10 +572,11 @@ default-ON (`'0'` disables).
|
|||||||
| `BT_SECTOR_LOG` | SectorDisplay Make/Execute |
|
| `BT_SECTOR_LOG` | SectorDisplay Make/Execute |
|
||||||
| `BT_NET_TRACE` | `[net-tx]/[net-rx]/[net-upd]` MP tracing |
|
| `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_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="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness |
|
||||||
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
|
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
|
||||||
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
|
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
|
||||||
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
|
| `BT_START_INSIDE` | begin in the cockpit view (BACKTICK toggles; 'V' = the pod rear-view hold since #68 — the toggle skips bound keys, L4PADRIO.cpp:681) |
|
||||||
| `BT_FORCE_MODEL=<name>` | force the player mech (`madcat`/`owens`/…; btl4mssn.cpp — per-mech cockpit bring-up) |
|
| `BT_FORCE_MODEL=<name>` | force the player mech (`madcat`/`owens`/…; btl4mssn.cpp — per-mech cockpit bring-up) |
|
||||||
| `BT_HIDE_COCKPIT` | hide the `*_cop` canopy shell (SHOWS by default — see [[cockpit-view]]) |
|
| `BT_HIDE_COCKPIT` | hide the `*_cop` canopy shell (SHOWS by default — see [[cockpit-view]]) |
|
||||||
| `BT_COP_*` / `BT_EYE_FWD=<f>` | cockpit canopy + eye diagnostics (`FRAME` (unlit frame colour override, default 0.13,0.12,0.15) / `SINGLE` (single-sided diag; double-sided is default) / `PLATES=1` (disable the punch stencil-cut kit) / `FLIP` / `DEBUG` / `DUMP`; eye forward-push probe) — [[cockpit-view]] |
|
| `BT_COP_*` / `BT_EYE_FWD=<f>` | cockpit canopy + eye diagnostics (`FRAME` (unlit frame colour override, default 0.13,0.12,0.15) / `SINGLE` (single-sided diag; double-sided is default) / `PLATES=1` (disable the punch stencil-cut kit) / `FLIP` / `DEBUG` / `DUMP`; eye forward-push probe) — [[cockpit-view]] |
|
||||||
@@ -473,6 +619,14 @@ default-ON (`'0'` disables).
|
|||||||
| `BT_SELF_DAMAGE=<dps>` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you |
|
| `BT_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_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_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,
|
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]].
|
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
|
||||||
@@ -491,6 +645,22 @@ recoil @4bc136-4bc19c (damage>3 → (0,0.6,−1.5) × damage/16; `gyroRumbleTime
|
|||||||
ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over
|
ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over
|
||||||
`reference/decomp/section_dump.txt`.
|
`reference/decomp/section_dump.txt`.
|
||||||
|
|
||||||
|
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
|
||||||
|
address UNKNOWN — the old citation `FUN_0041e4e0` was WRONG (that function, part_002.c:7337,
|
||||||
|
is `DamageZone::TakeDamage`: `level += amount×scale[type]`, byte-matches DAMAGE.cpp:379 —
|
||||||
|
verified 2026-08-02, swept here + [[rendering]]); runtime
|
||||||
|
consumer inside `BTL4VideoRenderer::MakeMechRenderables` **`FUN_004cef28`** (part_014.c:5190-5272);
|
||||||
|
per-zone node ctor `FUN_00455c7c(...,0.04f)`; the per-material watcher
|
||||||
|
**`FUN_004573e4(material, &zone->damageLevel, 0.1f)`** (part_007.c:9133, vtable `PTR_LAB_004f1730`,
|
||||||
|
232 bytes) with Perform **`FUN_00457784`** (lerp constant `@0x4579a4` = 1.0; damaged factor
|
||||||
|
`0x3dcccccd` = 0.1). Material accessors: `dpl_GetMaterialAmbient` `FUN_0048baf4` /
|
||||||
|
`Set` `FUN_0048ba98`, `Emissive` `FUN_0048bbb0`/`FUN_0048bb54`, `Diffuse` `FUN_0048bc6c`/
|
||||||
|
`FUN_0048bc10`, `Specular`(4) `FUN_0048bd2c`/`FUN_0048bccc`, `Opacity` `FUN_0048bdf0` (READ ONLY --
|
||||||
|
lerped but never pushed), flush `FUN_0048d4d4`; `dpl_SetMaterialRamp` `@0x48bfb0` (writes
|
||||||
|
`material+0x1c`; ONE caller, load-time only). Material lookup by name `FUN_00497678`. The UNUSED
|
||||||
|
sibling path: `dpl_Damagize` `FUN_004902b0` (tokens `{0x92,0x05,0x04}`), `SV_SPECIAL DAMAGE`
|
||||||
|
dispatch @0x4599ae -- **zero shipped BGFs carry a DAMAGE token**.
|
||||||
|
|
||||||
**PUNCH firmware decode** [T1] (the definitive mechanism, 2026-07-11): cmd 0x20 =
|
**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)
|
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
|
handler 'damageize' @0xf040f6f8 writes the token triple onto the geogroup's first THREE
|
||||||
@@ -515,6 +685,22 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 7. Tools
|
## 7. Tools
|
||||||
|
- ⚠ **CITATION POLICY (since the 2026-08-06 re-export): cite `@ADDR`, never `part_0NN.c:LINE`.**
|
||||||
|
Addresses are stable across exports; shard membership and line numbers are NOT. Pre-08-06
|
||||||
|
part/line citations resolve only against `reference/decomp/archive_2025export/`.
|
||||||
|
- ⚠ **Ghidra renders member access as int-ARRAY indices** (`param_1[0xfe]` == byte offset 0x3f8,
|
||||||
|
`[0x1e7]` == 0x79c): grepping the export for an offset STRING misses every such site. Divide
|
||||||
|
by 4 (or grep both forms) — this is a live instance of gotcha §20.
|
||||||
|
- `tools/gapcensus.py [dir]` — the #60 dark-region census: index/export coverage, function-start
|
||||||
|
discovery, TU attribution, repo-citation flags → `<dir>/GAP_CENSUS.md` + `gap_census.tsv`.
|
||||||
|
Consult BEFORE any "absent from the export" claim (gotcha §20).
|
||||||
|
- `tools/ghidra_reexport.sh [reprocess]` — headless re-export: Ghidra 12.1.2 + JDK 21 (installed
|
||||||
|
in `%LOCALAPPDATA%\bt411-tools` beside DXSDK/cmake) run `reference/ghidra_scripts/ExportGaps.java`
|
||||||
|
(ExportAll's output contract + the dark-region gap fill). ⚠ the runner MUST use 8.3 SHORT paths
|
||||||
|
(`JOELAP~1`, `BT411-~1`): Ghidra's launcher .bat expands `%JAVA_HOME%` UNQUOTED and the user
|
||||||
|
profile has a space. `reprocess` re-runs the script on the saved project (skips re-analysis).
|
||||||
|
- `tools/gapdiff.py [old] [new]` — score two censused exports (coverage/dark deltas, which
|
||||||
|
previously-dark addresses the KB already cites, celebrity check).
|
||||||
|
|
||||||
- `tools/disas2.py <VA> [len]` — capstone disassembly of BTL4OPT.EXE at a VA (recovers x87 math
|
- `tools/disas2.py <VA> [len]` — capstone disassembly of BTL4OPT.EXE at a VA (recovers x87 math
|
||||||
Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
|
Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
|
||||||
@@ -526,6 +712,42 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## THE ENGINE FRAME-RATE GLOBAL — `DAT_0052140c` = 28.0f (2026-08-02) [T1]
|
||||||
|
|
||||||
|
The DOS binary's per-frame<->per-second conversion scalar, set ONCE at startup:
|
||||||
|
|
||||||
|
0x401ace: mov [0x52140c], 0x41E00000 ; 28.0f (nominal rate)
|
||||||
|
0x401ada: mov [0x52140c], 0x4191A6E0 ; 18.2065f (BIOS-tick fallback, PIT/65536)
|
||||||
|
|
||||||
|
and passed straight into the ApplicationManager ctor by the DOS main
|
||||||
|
(0x401189: `push [0x52140c]`). ~90 code sites `fmul`/`fdiv` this global —
|
||||||
|
whenever the decomp shows an unexplained multiply/divide by `_DAT_0052140c`,
|
||||||
|
it is per-second <-> per-frame conversion at the pod's 28 Hz.
|
||||||
|
|
||||||
|
**The AUDIO/EFFECT FRAME CLOCK is this same rate.** `AudioTime::
|
||||||
|
Seconds_To_Frames` in the image is `fmul [0x52140c]; fadd 0.5; round` (e.g.
|
||||||
|
@0x42c611, @0x42dd86 — the 0x42xxxx audio-layer cluster of the ~90 consumers),
|
||||||
|
so every authored audio duration/delay counts frames at 28. WinTesla's
|
||||||
|
`DefaultRendererRate` said **30** — all audio timing ran ~7% fast until
|
||||||
|
corrected (2026-08-02). Oracle's coupled report ("prolonged smoke and
|
||||||
|
explosion including sound") was the lead: effect visuals and audio share this
|
||||||
|
clock family.
|
||||||
|
|
||||||
|
**So the pod's nominal simulation cadence was 28 fps, not the i860 board's
|
||||||
|
30 Hz — and it was UNLOCKED.** Corroborated testimony (2026-08-02): Oracle —
|
||||||
|
the pod rate was "flipbook" territory, heavy fights "a slide show"; Lynx
|
||||||
|
(original era) — "BT never had a locked frame rate in 1st release testing",
|
||||||
|
RP targeted 30, Tesla 3.0 managed ~20 on a good day. The engine pacer design
|
||||||
|
agrees: fixed budget, background fill, NO overrun catch-up (APPMGR.cpp T0) —
|
||||||
|
target 28, allowed to slip. Consequence: any per-tick dt-LESS accumulator ran
|
||||||
|
SLOWER during heavy combat on real pods; per-tick behaviors must be calibrated
|
||||||
|
against the loaded effective rate (veteran bracketing), not the constant. Every "per tick with no dt" accumulation in the binary (the myomer
|
||||||
|
kinetic heat term; candidate: particle trail density) was calibrated against
|
||||||
|
28 nominal — and less under load (the 18.2 fallback existing at all says slow
|
||||||
|
paths were expected; the EFFECTIVE in-pod rate is an open question, see
|
||||||
|
[[open-questions]]). Our port's frame pacer note in btl4main.cpp claimed 30
|
||||||
|
as "the pod's authentic rate" — corrected to 28.
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Feeds: [[subsystems]] (factory + ClassIDs), [[combat-damage]] (damage delivery), [[gauges-hud]] (attribute binding).
|
- 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).
|
- Verified against: [[reconstruction-method]] (the decomp loop), [[reconstruction-gotchas]] (why raw offsets fail).
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both
|
|||||||
|
|
||||||
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
|
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
|
||||||
|
|
||||||
- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam`
|
- **+0x25c — "sim live", off only for novice.** Consumers: the CROUCH posture selector
|
||||||
|
(master-perf @0x4a9f70: novice → mapPosture 0 → squat/rise refused — 2026-08-06,
|
||||||
|
[[locomotion]] §CROUCH); ballistic jam roll `CheckForJam`
|
||||||
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
|
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
|
||||||
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
|
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
|
||||||
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
|
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
|
||||||
|
|||||||
+158
-1
@@ -11,8 +11,9 @@ open_questions:
|
|||||||
- "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)"
|
- "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)"
|
||||||
- "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat"
|
- "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat"
|
||||||
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
|
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
|
||||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90, unreconstructed); plus 0x13 = Mech DuckRequest (crouch), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles -- see pod-hardware.md + docs/GLASS_COCKPIT.md 2026-07-20"
|
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
|
||||||
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
|
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
|
||||||
|
- "PPC `scrambleVideo` IMPLEMENTED 2026-08-06 (branch ppc-sync-distortion): a PPC hit scrambles every secondary display for 0.8 s (modern per-scanline shear stand-in for the CRTC Horizontal-Total detune). Trigger in Mech::TakeDamageMessageHandler (damageType==4), visual in SVGA16::FunkyVideo/ScrambleRowShift (DrawDevSurface + ExpandPlaneToBGRA), non-stacking latch in L4GaugeRenderer::SpecialEffect. Screenshot-verified; live PPC-fire confirmation + by-eye shear tuning (BT_SCRAMBLE_SHEAR/ROLL) left to playtesters. Spec: phases/phase-14-ppc-sync-distortion.md"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Cockpit Gauges / MFD HUD
|
# Cockpit Gauges / MFD HUD
|
||||||
@@ -111,6 +112,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
|
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
|
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`.
|
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]
|
## 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
|
The live-MP "dev-gauges window froze entirely mid-session" incident (issue #12) is NOT a
|
||||||
@@ -682,6 +698,147 @@ BitMap-strip lamp drew `SetColor(0)` + `DrawBitMapOpaque(0,…)` = invisible. Hi
|
|||||||
Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base),
|
Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base),
|
||||||
not `BecameActive` — vtable-diffed.
|
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.
|
||||||
|
|
||||||
|
## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified]
|
||||||
|
|
||||||
|
**✅ IMPLEMENTED 2026-08-06** (branch `ppc-sync-distortion`; screenshot-verified —
|
||||||
|
every secondary MFD + the radar shear together, the out-the-window view stays
|
||||||
|
clean). Trigger + visual + non-stacking latch — see
|
||||||
|
`phases/phase-14-ppc-sync-distortion.md` for the port details and the
|
||||||
|
`BT_SCRAMBLE_*` tuning envs. Reported by playtesters as "being hit by a PPC makes
|
||||||
|
it look like all of the secondary CRTs were being degaussed" — main (VPX) view
|
||||||
|
unaffected, PPC strikes only. Both observations are exactly what the binary does.
|
||||||
|
The disasm chain below is the ground truth the port was built from.
|
||||||
|
|
||||||
|
**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES`
|
||||||
|
subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records,
|
||||||
|
every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30),
|
||||||
|
Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive.
|
||||||
|
|
||||||
|
The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`,
|
||||||
|
md5 `a97075bcb5634d13263e9ad5a2b96fd0`):
|
||||||
|
|
||||||
|
1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits
|
||||||
|
between the collision divert and the burst loop, so it runs **once per
|
||||||
|
damage message**, not per burst:
|
||||||
|
```
|
||||||
|
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
|
||||||
|
004a03f6 cmp ecx,4 ; EnergyDamageType
|
||||||
|
004a03f9 jne 0x4a0423 ; everything else -> burst loop
|
||||||
|
004a03fb mov eax,[0x4efc94] ; the global `application`
|
||||||
|
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
|
||||||
|
004a0405 je 0x4a0423 ; null-guarded
|
||||||
|
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
|
||||||
|
004a040a fld xword [0x4a0c08] ; long double 0.2
|
||||||
|
004a0410 fmulp st(1) ; => 0.8
|
||||||
|
004a041d call dword [edx+0x4c] ; vtable slot 19, args (0.8f, 0)
|
||||||
|
```
|
||||||
|
⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`.
|
||||||
|
2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`**
|
||||||
|
(an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the
|
||||||
|
capability is shared-engine; BT is what wires it to Energy damage).
|
||||||
|
Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]`
|
||||||
|
(null-guarded — same member RP fetches for its `FlashPalette`), then
|
||||||
|
`this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks.
|
||||||
|
3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag.
|
||||||
|
4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O:
|
||||||
|
```
|
||||||
|
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
|
||||||
|
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
|
||||||
|
if (arg==0) { out(0x3D5, saved); modified=0; } ; restore
|
||||||
|
else if (!modified) { modified=1; saved=in(0x3D5);
|
||||||
|
out(0x3D5, saved-9); } ; <<< shorten the scanline
|
||||||
|
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
|
||||||
|
```
|
||||||
|
Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch
|
||||||
|
makes it **idempotent** — overlapping PPC hits do NOT stack, and a second
|
||||||
|
hit does not re-save an already-detuned value.
|
||||||
|
5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0;
|
||||||
|
SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total.
|
||||||
|
|
||||||
|
**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is
|
||||||
|
the character-clock count per scanline — it *is* the horizontal scan frequency.
|
||||||
|
−9 drives every attached monitor's horizontal oscillator off frequency: the
|
||||||
|
image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All
|
||||||
|
six secondary displays are derived by the VDB from that one VGA's timing, so
|
||||||
|
they glitch **together**; the main view comes off the Division VPX card on an
|
||||||
|
independent timing chain and is untouched. No relay, no VDB register, no
|
||||||
|
palette work — the VDB just propagates a deliberately corrupted sync. (The
|
||||||
|
`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are
|
||||||
|
driven by nothing in the surviving tree.)
|
||||||
|
|
||||||
|
⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler**
|
||||||
|
(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and
|
||||||
|
its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call
|
||||||
|
sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it.
|
||||||
|
**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 =
|
||||||
|
SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor —
|
||||||
|
hardware-assisted alarm blinking by masking off the top two pixel bits. Same
|
||||||
|
pods, so it is an easy source of cross-game misattribution.
|
||||||
|
|
||||||
|
Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual
|
||||||
|
dispatch); the PPC site was found via the three `application+0x4c` uses
|
||||||
|
(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS
|
||||||
|
exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two
|
||||||
|
(set @`0x47002b`, restore @`0x470076`).
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
|
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
|
||||||
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
|
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ that one answer. Precedence, highest first:
|
|||||||
| Env | Layout | Buttons live in |
|
| Env | Layout | Buttons live in |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
|
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
|
||||||
|
| `BT_POD_SURFACES`=1 | **POD MODE** (2026-08-06): crop each panel window to its SURFACE (MFD 640×480, radar 480×640), drop the on-screen button banks + the Flight Controls pad, frameless. For REAL pod glass, where the buttons are physical — see [[pod-hardware]] §MFD PANELS. Per-window: `,bare` in `glass_layout.cfg`. | 6 bare surfaces |
|
||||||
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
|
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
|
||||||
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
|
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
|
||||||
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
|
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
|
||||||
@@ -169,6 +170,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
|
still dispatched on the frameless radar (`CLICK 'Secondary / Radar' addr=0x18`), and a
|
||||||
finished-drag save round-tripped both flags back into the file.
|
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;
|
**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
|
`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
|
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
|
||||||
|
|||||||
+206
-5
@@ -206,11 +206,72 @@ master mech needs a **CollisionAssistant** (GetCurrentCollisions iterates it unc
|
|||||||
ship ZERO tiles (h never negative). [T2]
|
ship ZERO tiles (h never negative). [T2]
|
||||||
|
|
||||||
## Knockdown / crash + the desync/stutter fixes
|
## 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
|
Wall impacts iv²>40 (~6.3 u/s) bind the `bmp` stagger clip. The knockdown must stagger BOTH
|
||||||
knockdown must stagger BOTH channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display
|
channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display + travel split permanently
|
||||||
+ travel split permanently (foot-slip); (b) a contact-hysteresis gate (0.4 s `gBlockCooldown`)
|
(foot-slip). `localVelocity` is zeroed while crashed (spec-faithful) so a knocked mech can't keep
|
||||||
stops the knockdown re-firing on sustained/glancing contact. `localVelocity` is zeroed while crashed
|
advancing. **The 0.4 s `gBlockCooldown` contact-hysteresis is GONE (pricing audit 2026-07-31)**: it
|
||||||
(spec-faithful) so a knocked mech can't keep advancing. [T2]
|
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)
|
## Controls (`BT_REAL_CONTROLS`, default-on)
|
||||||
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
|
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
|
||||||
@@ -250,6 +311,146 @@ resolved with gait v5, no shadow code changed — though (a) may have contribute
|
|||||||
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
|
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
|
||||||
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
|
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
|
||||||
|
|
||||||
|
## CROUCH -- COMPLETE 2026-08-06 [T1 decode / T2 full-cycle + MP benched]
|
||||||
|
The manual's CROUCH button (streamed 0x13; glass key F4), end to end: DuckRequest (mech id 0x1a
|
||||||
|
@0049fa00) latches duckState@0x398 -> the master posture block (mech4, from the dark master-perf
|
||||||
|
@0x4a9cf0-0x4aa0af): posture selector @0x3f8 (mode 9/10/2/3/4 block, NOVICE simLive lockout,
|
||||||
|
myomer-alive gate) -> standing: SetLegAnimation(2) ('sqd', squat down; LegClipFinished case-2
|
||||||
|
PARKS the pose + leg alarm 1) / ducked: SetLegAnimation(3) ('squ', rise; case-3 alarm 0);
|
||||||
|
stability alarm flips; ForceUpdate(8)+(1) ships the type-3 state record -- peers pose the squat
|
||||||
|
for FREE (screenshot receipt: the crouched crimson madcat from the observer's cockpit,
|
||||||
|
duckmpA_031). Clip data verified from BTL4.RES (root joint drops -2.22, knees +-1.14; squ/sqd
|
||||||
|
are exact mirrors; interior 'squi/sqdi' variants ship for all 8 chassis). Full solo cycle
|
||||||
|
benched (BT_BTNTEST + BT_BTNTEST2): squat -> hold (joints steady for minutes) -> rise ->
|
||||||
|
standing zeros. Myomer factor: LIVE, not deferred -- the 2026-08-05 "feeder unreconstructed" claim was an
|
||||||
|
export-gap-blind grep (the members are NAMED: speedEffect@0x31C, myomers.cpp; the chain walk +
|
||||||
|
speedDemand scale + turn freeze = mechmppr.cpp:990, the 2026-07-31 seek audit, same bytes).
|
||||||
|
mechmppr now publishes the chain MAX into mech->myomerEffectiveness (the binary's +0x79C home)
|
||||||
|
so the crouch gate reads the live value: dead/overheated myomers (the Oracle seek-4 freeze)
|
||||||
|
refuse the squat AND the rise.
|
||||||
|
The pilot's EYE drops too -- the task-#15 bob channel (gBTEyeBobY <- jointlocal.y each frame)
|
||||||
|
carries the parked -2.22 into DPLEyeRenderable (log receipt bob=-2.2187 through the hold; frame
|
||||||
|
receipt duckcp_030 = grass-level view). A false "eye does not drop" residual was briefly filed
|
||||||
|
2026-08-06 -- measurement error (canopy-dominant diff crops; the canopy drops WITH the eye, so
|
||||||
|
the interior looks unchanged and only the through-window ground shifts). Bench traps for reruns: MP button delivery can miss if pressed
|
||||||
|
during round-start jitter (press >= poll 900 after GO); diags all BT_DUCK_LOG-gated.
|
||||||
|
|
||||||
|
### CROUCH addendum -- the #60 re-export pass (2026-08-06) [T1]
|
||||||
|
The rebuilt export (93.5% coverage) produced pseudocode for the master-perf region this block
|
||||||
|
was hand-disassembled from, and it **confirms the transcription field for field**:
|
||||||
|
`param_1[0xfe]`=mapPosture(0x3f8), `[0xe6]`=duckState(0x398), `[0x161]`=squatCapable,
|
||||||
|
`[0x1e7]`=myomerEffectiveness(0x79c), `[0x10]`=movementMode, `player+0x25c` novice gate,
|
||||||
|
`FUN_004a7fc4(2|3)`=SetLegAnimation sqd/squ, `FUN_004a4c54(8)+(1)`=ForceUpdate, `+0x131`
|
||||||
|
stability alarm, duckState cleared after. (Ghidra renders these as int-ARRAY indices --
|
||||||
|
`[0xfe]` == byte 0x3f8 -- which is why an offset-string grep of the export misses them; see
|
||||||
|
[[reconstruction-gotchas]] §20.)
|
||||||
|
|
||||||
|
**It also caught a branch the raw pass missed** (the hand disassembly stopped at `0x4aa0af`):
|
||||||
|
an **AIRBORNE AUTO-RISE** immediately after the duck consumer --
|
||||||
|
`if ((movementMode == 3 || movementMode == 4) && legState == 1) -> SetLegAnimation(3) +
|
||||||
|
ForceUpdate(8)+(1) + stability 1`. A mech that leaves the ground (jump/knockback) while PARKED
|
||||||
|
in the squat is forced upright; unconditional on duckState/squatCapable, i.e. a safety
|
||||||
|
un-crouch rather than a request. Implemented in mech4.cpp the same day; crouch re-benched
|
||||||
|
un-regressed (squat still holds on flat ground, no spurious rises).
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
|
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
|
||||||
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
|
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
|
||||||
|
|
||||||
|
## Myomer drive + the GIMP (limp) chain — COMPLETE 2026-07-30 (#75/#78)
|
||||||
|
The speed-demand site (`mechmppr.cpp` InterpretControls) applies the **drive scale**:
|
||||||
|
`speedDemand *= myomers.speedEffect`. The myomers factor is the wrapper's live 0..1
|
||||||
|
output (gear/thermal/1−damage, via `BTMyomersDriveOf`); the authentic coupling attached
|
||||||
|
`&speedEffect` into the mover's feed roster, which the 2007 engine lacks — the multiplication at
|
||||||
|
the demand site is the port equivalent. The GIMP chain: `mechdmg` raises **graphicAlarm 3 (left)
|
||||||
|
/ 4 (right)** when a leg zone (the `LegDamageZone`-flagged zones; MadCat: 3/5/8 left, 10/16/19
|
||||||
|
right) crosses `LegHalfStructure` (0.5), and **reverse input is refused** ("reverse disabled",
|
||||||
|
`[gimp]` log — and the binary agrees: the gimp gait machines have NO standing→reverse entry).
|
||||||
|
⚠ Cross-TU reads of the gimp level MUST use `BTMechGimpLevel` (mechdmg.cpp) — see
|
||||||
|
[[reconstruction-gotchas]] §23 (the AlarmIndicator typedef split). Harness:
|
||||||
|
`BT_SELF_DAMAGE_ZONE=<n>` ramps one zone deterministically; `BT_DRIVE_LOG` prints
|
||||||
|
`[drive] n/drive/mm/dmd/mech`.
|
||||||
|
|
||||||
|
### The VISIBLE limp — the gimp gait machines (reconstructed + bench-verified 2026-07-30) [T2]
|
||||||
|
Bench (madcat, novice, `BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_ZONE=16 BT_SELF_DAMAGE_TICKS=2`,
|
||||||
|
`BT_GOTO` walk): right-leg crossing → alarm 4 → body enters 0x17/wgr FROM A LEFT STEP
|
||||||
|
(phase-correct) → both channels settle in 0x19/ggl and hold it 8k+ frames at the gimp cadence
|
||||||
|
cap (cycle 14.77 = the wgr entry stride, vs 18.5 walk / 22+ run) while raw demand stays 50 —
|
||||||
|
the slowdown IS the gait machine, no demand multiply. Two revive-bugs fixed en route: see
|
||||||
|
[[reconstruction-gotchas]] §24.
|
||||||
|
The binary's limp was hiding behind a **"jump-jet" misread**: the port had `FUN_004a5bf8` /
|
||||||
|
`FUN_004a71f4` reconstructed as `AdvanceBody/LegAnimationAirborne`, gated on
|
||||||
|
`(MovementMode()==3||4) && jumpCapable@0x580` and thought DEAD (mechs never jump). Truth
|
||||||
|
[T1, raw part_012.c]: **mech+0x40 there is the graphicAlarm level (3=left gimp, 4=right)**
|
||||||
|
and **+0x580 is `hasGimpClips`** — set by the conditional loader block that probes `wgl` and
|
||||||
|
loads clip slots 22-27 (`wgl wgr ggr ggl gsl gsr`, +0x624..0x638) plus four measurements:
|
||||||
|
`gimpLeft/RightSpeedMax`@0x53c/0x540 (the wg entry clips' final keyframe strides) and
|
||||||
|
`gimpLeft/RightStrideLength`@0x544/0x548 (MeasureClipStride over the gg cycles). The five
|
||||||
|
`jump*`-named members were renamed accordingly (mech.hpp; there is no jump clip set).
|
||||||
|
Machinery (all four reconstructed in mech2.cpp, gates in mech4.cpp):
|
||||||
|
- **Drivers** `AdvanceBodyAnimationGimp`@004a5bf8 / `AdvanceLegAnimationGimp`@004a71f4 —
|
||||||
|
selected per-frame when `(gimpLevel 3|4) && hasGimpClips`; clamp the demand (body:
|
||||||
|
`bodyTargetSpeed`; leg: the LIVE mapper `speedDemand`, written back) to the gimped side's
|
||||||
|
speed cap and advance the gg cycles at gimp cadence. **This clamp IS the authentic gimp
|
||||||
|
slowdown** — ground speed is clip travel, so the limp cadence bounds it; the earlier T3
|
||||||
|
`×0.5` stand-in in mechmppr is retired (`BT_GIMP_SPEED` remains as an override, default 1.0).
|
||||||
|
- **Finished-callbacks** `GimpBodyClipFinished`@004a6344 / `GimpLegClipFinished`@004a7970 —
|
||||||
|
branched to from the tops of the normal `Body/LegClipFinished`. **Phase-correct entry**:
|
||||||
|
left-gimp enters 0x16/wgl only from a RIGHT step (walk-R cases 5/6/0xe), right-gimp enters
|
||||||
|
0x17/wgr from a LEFT step (7/0xf) — the machine forces one extra normal step if mid-wrong-foot.
|
||||||
|
Cycles: left = 0x18 (ggr clip, stride @0x544), right = 0x19 (ggl, @0x548); exits through the gs
|
||||||
|
transitions 0x1a/0x1b when demand dies. No reverse entry exists while gimped.
|
||||||
|
**Turn-in-place while gimped** (field find, `95cf49d`): the binary's trn dispatcher
|
||||||
|
(`FUN_004a9b5c`, master perf) runs OUTSIDE the driver selection, so it arms the turn step for
|
||||||
|
gimped mechs too — the port relocated it into the NORMAL leg driver's Standing case, which
|
||||||
|
stops running when the gimp driver takes over ("rotating statue"). The arming is now mirrored
|
||||||
|
in `AdvanceLegAnimationGimp`. No gimp-turn clip exists — a limping mech step-turns with the
|
||||||
|
normal `trn` clip [T1: the RES limp set is only wg/gg/gs].
|
||||||
|
**Gimp audio — the "REVERSE DISABLED" voice IS REAL and now plays** (`1671b7d`; the earlier
|
||||||
|
"no voice exists" verdict here was WRONG — user/old-timer pushback found it). The authored mech
|
||||||
|
audio has **state watchers on `Entity.SimulationState == 3/4`** (the binary's one-cell mech+0x40
|
||||||
|
= the gimp level) that start a sequence playing notes 29, 16, **40** — two klaxon hits then
|
||||||
|
Warnings01 zone 8 (`Warnings01_z7.wav`, key 40-41) = the spoken "reverse disabled". It never
|
||||||
|
fired in the port because of the SAME cell split that hid the limp (graphicAlarm vs engine
|
||||||
|
simulationState, gotcha #23) — the audio watches the half the gimp system never wrote. Fixed:
|
||||||
|
mechdmg mirrors gimp 3/4 into `SetSimulationState` at the leg-half crossing (guarded vs
|
||||||
|
disabled/fall/dead), and `BT_GIMP_SAFE_BASE_READ` stops pre-gimp replication records from
|
||||||
|
stomping the cell (gimp is monotonic per life). Verified: `SetupPatch bank2 patch113 note=40`.
|
||||||
|
Layered under it: the `Entity.AnimationState` triggers on limp entries 22/23 + stops 26/27
|
||||||
|
(+ walk-stands 8/9) play **EngineShiftRev01** (downshift foley), velocity-gained — a separate
|
||||||
|
working layer (earlier mis-ID'd here as "the" limp audio). The earlier "watcher rebind"
|
||||||
|
hazard was a bench artifact (the harness gimped the mech before the audio object loaded —
|
||||||
|
impossible in play). ⚠ Open: an unidentified RAW writer (bypasses SetState; indicator-level
|
||||||
|
trap never fired while the value changed) resets the sim cell between damage events — under a
|
||||||
|
1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play
|
||||||
|
damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family
|
||||||
|
write or a struct copy spanning it).
|
||||||
|
### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29)
|
||||||
|
Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a
|
||||||
|
normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]:
|
||||||
|
- The binary's one `mech+0x40` **is** `Simulation::simulationState`, which
|
||||||
|
`Simulation::Write/ReadUpdateRecord` puts in **every update record header** — so a peer's
|
||||||
|
replicant learned the gimp level on every packet and its self-simulated gait limped with no
|
||||||
|
extra plumbing. There is no gimp-specific replication anywhere in the binary because none is
|
||||||
|
needed.
|
||||||
|
- A replicant NEVER re-derives the level: zone damage reaches it as a DamageZone update record
|
||||||
|
(`Entity::ReadDamageUpdateRecord` → `DamageZone::ReadUpdateRecord` writes `damageLevel` straight
|
||||||
|
from the wire), which never runs `Mech__DamageZone::TakeDamage` — where the leg-threshold
|
||||||
|
evaluation and the `graphicAlarm` 3/4 write live. ⚠ And zone levels replicate only when the
|
||||||
|
**explosion table's** damage tier is crossed (`EXPTBL.cpp:512-530`
|
||||||
|
`CrossedDamageLevelThreshold`) — coarse and lagging (measured: peer at 0.428 while the master
|
||||||
|
was at 0.857). **Never infer damage state on a peer from replicated zone levels.**
|
||||||
|
- The port's own `Mech::PerformAndWatch` wrote `SetMovementMode(1)` **every frame** ("ground,
|
||||||
|
non-death, non-airborne"), which erased the gimp level from that cell once it was mirrored in
|
||||||
|
for the voice — so the wire carried 1 and peers walked. Found with the scoped `[simstomp]` trap
|
||||||
|
(`g_btGimpWatchMech` + module-relative return address → `symcrash.py`).
|
||||||
|
Fix: that per-frame write now writes the authoritative level (`gimped ? 3/4 : 1`) via the
|
||||||
|
alarm-only bridge `BTMechGimpAlarmLevel` (never the cell it feeds, so a respawn-cleared alarm
|
||||||
|
can't re-latch a stale gimp). Verified two-node (`scratchpad/night6/mp_skate.sh`): the observer's
|
||||||
|
replicant receives `sim=4` and its gait runs `23 → 25` (wgr entry → ggl limp cycle). This also
|
||||||
|
made the voice fire exactly once per onset instead of restarting on every damage event, and
|
||||||
|
re-enabled the authentic leg-destroyed→fall gate (`mechdmg` MovementMode 3||4 [T1 task #60]),
|
||||||
|
which had been dead while the cell was pinned at 1.
|
||||||
|
Open: the load-time per-clip callbacks `PTR_LAB_0050d738/744` on the wg clips (uncarved LABs,
|
||||||
|
inert `Recon` placeholders in the port — likely footstep/cycle events, transitions work without
|
||||||
|
them).
|
||||||
|
|||||||
+32
-4
@@ -70,6 +70,13 @@ emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2]
|
|||||||
console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon,
|
console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon,
|
||||||
master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
|
master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
|
||||||
validity). [T2]
|
validity). [T2]
|
||||||
|
- **Zone-level replication (#87, 2026-08-03):** an observer's copy of a mech learns zone damageLevels
|
||||||
|
ONLY from zone update records, and the master sends those on BAND-THRESHOLD CROSSINGS (binary effect
|
||||||
|
watcher `FUN_0042aa2c`: `DescriptorCrossed` + master gate → `ForceUpdate(DamageZoneUpdateModelFlag)`)
|
||||||
|
plus every graphic-state change — never per hit. Damage messages do NOT echo locally (replicant
|
||||||
|
`Dispatch` forwards to the master and returns). The port had only the gstate branch (observers saw
|
||||||
|
0.0 until destruction); the level branch was restored + 2-node verified (replicant peaks == master
|
||||||
|
finals to 4 decimals). Full mechanism: [[combat-damage]] §Zone-LEVEL replication. [T1/T2]
|
||||||
- **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be
|
- **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be
|
||||||
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
|
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
|
||||||
EVERY MakeMessage for pointer payloads. [T2]
|
EVERY MakeMessage for pointer payloads. [T2]
|
||||||
@@ -282,6 +289,18 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
|
|||||||
NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn:
|
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
|
`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.
|
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
|
- 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.
|
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
|
- New by-name members: `poseSyncLatch@0x77c` (dead-reckon re-base latch, consumed by
|
||||||
@@ -676,7 +695,9 @@ Only the relay host needs port forwarding. Verified: scripted protocol suite in
|
|||||||
2-node mission launched + stopped purely over the socket.
|
2-node mission launched + stopped purely over the socket.
|
||||||
|
|
||||||
## The interest-teardown audio crash (2026-07-24) [T2 stack-captured + fixed]
|
## 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
|
hit a crash-filter build: `InterestManager::OrphanInterestOrigin → RemoveUninterestingEntity
|
||||||
→ DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV
|
→ DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV
|
||||||
reading NULL+0x38: the authentic burning-mech death-silence check dereferences
|
reading NULL+0x38: the authentic burning-mech death-silence check dereferences
|
||||||
@@ -800,7 +821,9 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
|||||||
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
|
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
|
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`
|
(`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
|
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.
|
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
|
Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged
|
||||||
@@ -858,8 +881,13 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
|||||||
REMAINING note is closed.
|
REMAINING note is closed.
|
||||||
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
|
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
|
||||||
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
|
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
|
||||||
harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is
|
harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
|
||||||
reconstructed (task #52 — see item 3b).
|
report tail in the victim's TakeDamage handler — BTMechPostCombatReports, replacing
|
||||||
|
BTPostKillScore; same cross-node reroute, see [[combat-damage]] §report tail). Respawn is
|
||||||
|
reconstructed (task #52 — see item 3b; the VehicleDead sender moved to the same tail).
|
||||||
|
CROUCH and the SEARCHLIGHT replicate with ZERO new wire code (2026-08-05/06): the squat rides
|
||||||
|
the existing type-3 state record (legState) and the lamp rides subsystem record 0x14
|
||||||
|
(lightState) — [[locomotion]] §CROUCH / [[rendering]] §SEARCHLIGHT.
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
|
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
|
||||||
|
|||||||
+252
-19
@@ -22,6 +22,20 @@ authentic path scoped.
|
|||||||
further assets exist; the runtime `BTL4.RES` with 8 maps + full anim set is the master we have).
|
further assets exist; the runtime `BTL4.RES` with 8 maps + full anim set is the master we have).
|
||||||
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
|
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
|
||||||
Win10+wrapper pod config. Status: OPEN.
|
Win10+wrapper pod config. Status: OPEN.
|
||||||
|
- **The i860 DIVISION-CARD firmware / DIV render docs (the material→pixel response curve).**
|
||||||
|
Established 2026-08-03 (#87 dig): the HOST binary lerps 13 material colour floats linearly
|
||||||
|
toward ×0.1 by zone damageLevel (watcher @004573e4/@00457784, 0.1 literal at the
|
||||||
|
MakeMechRenderables call site) and then just **marshals the material to the card**
|
||||||
|
(`FUN_0048d4d4` = "flush_material", dpl command opcode 100 — siblings flush_ramp/flush_texture).
|
||||||
|
The card-side shading (how material colour selected/scaled the 256-row material ramps → final
|
||||||
|
texels) is i860 firmware, NOT in BTL4OPT.EXE — so the PERCEPTUAL damage-darkening curve of the
|
||||||
|
1995 pods cannot be recovered from this binary. Our port applies the host's linear factor at
|
||||||
|
draw (TFACTOR modulate, L4D3D). Field testimony ("a single missile leaves visible armor
|
||||||
|
damage") is arithmetically consistent with the linear curve — one 25-pt hit snaps a 68-90-pt
|
||||||
|
zone by 0.28-0.37 (measured live: amt=25 → rarm 0→0.3247) — but if the card's ramp mapping was
|
||||||
|
steeper than linear, mid-level creep damage read stronger on the pods than in the port.
|
||||||
|
ASK: div-card firmware image, DIV/dpl render docs, or period screenshots showing a
|
||||||
|
known-damage-level mech. Status: OPEN.
|
||||||
- **Did the pod throttle quadrant have MECHANICAL detents** (how many / lever positions)? The
|
- **Did the pod throttle quadrant have MECHANICAL detents** (how many / lever positions)? The
|
||||||
software path is notch-free [T1, [[pod-hardware]]], but the gait SM's walk/run dead band
|
software path is notch-free [T1, [[pod-hardware]]], but the gait SM's walk/run dead band
|
||||||
([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
|
([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
|
||||||
@@ -161,6 +175,32 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
|
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
|
||||||
|
|
||||||
## Deferred subsystems / feeds (authentic path scoped, marked in code)
|
## 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**
|
- **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:
|
(`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
|
(a) can an `Entity::MakeMessage` be REGENERATED post-birth (serializing current state), or
|
||||||
@@ -184,16 +224,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) —
|
⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) —
|
||||||
most of the list had been reconstructed while the prose still called it dead. Check the code,
|
most of the list had been reconstructed while the prose still called it dead. Check the code,
|
||||||
not the census, before reconstructing anything here.
|
not the census, before reconstructing anything here.
|
||||||
- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets
|
- **❌ RETRACTED (2026-08-06): "`DuckState` has no CODE consumer, and that is authentic [T1]".**
|
||||||
`duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two
|
That verdict — and its corollary "no SQUAT clip survives; do not invent a crouch pose" — was
|
||||||
writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is
|
EXPORT-GAP BLINDNESS ([[reconstruction-gotchas]] §20, incident 2), and the [T1] tag was
|
||||||
a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState`
|
unearned: "ZERO readers" was true of the *export*, not the binary. The reader is the master-perf
|
||||||
on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So
|
posture/duck block in the un-exported region (@0x4aa011-0x4aa0af, raw disasm), and the squat
|
||||||
the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game
|
clips DID ship — `<pfx>squ/sqd` + interior `squi/sqdi` for all 8 chassis live in **BTL4.RES**
|
||||||
ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in
|
(the old claim searched decomp strings + loose `content/` files, never the RES TOC;
|
||||||
`content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it.
|
`DuckServo01.wav` was the tell). CROUCH is now fully reconstructed — request latch → posture
|
||||||
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is
|
arbiter (novice + myomer + mode gates) → squat/hold/rise, MP-replicated on the type-3 record
|
||||||
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and
|
([[locomotion]] §CROUCH). The `bduck.pcc` legend databinding observation stands and still works.
|
||||||
|
- **✅ Searchlight-driven fog swap — DONE 2026-08-05 (412053d/b75bb4a; [[rendering]] §SEARCHLIGHT).**
|
||||||
|
The port's `TickSearchlight` (btl4vid) is the `PullFogRenderable` equivalent: LightOn →
|
||||||
|
`SetFogStyle(searchLightOn/OffFogStyle)` with the binary's inverted-cache seed (authentic DARK
|
||||||
|
night start), plus the external spot.bgf beam cone (btfx `brighten` additive veil) on the
|
||||||
|
searchlight site joint, MP-replicated. Historical attribution notes kept below — the
|
||||||
|
"ORIGINAL 1995 LATENT BUG" premise was ❌ RETRACTED 2026-07-25 (#61).** The arcade swaps fog between `fog=` (lights on) and
|
||||||
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
|
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
|
||||||
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
|
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
|
||||||
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
|
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
|
||||||
@@ -203,15 +249,10 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
|
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
|
||||||
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
|
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
|
||||||
carries no novice gate. All T1.]
|
carries no novice gate. All T1.]
|
||||||
Searchlight's handler set is now WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
Searchlight's handler set is WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
||||||
`lightState 0→1`. So the sim needs **no repair** and the previous "DECISION: faithful to the buggy
|
`lightState 0→1`; the fog watcher + beam cone landed 2026-08-05 (see the header above), so no
|
||||||
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a
|
remaining work rides this entry. (NB the 1995 searchlight carries NO novice gate — that
|
||||||
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside
|
lockout is ThermalSight's; raw disasm @004b838c.)
|
||||||
pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new
|
|
||||||
`LightStatePtr()` accessor). `ControlsAllowLights()` is WIRED since issue #2 to the `player+0x25c`
|
|
||||||
not-novice experience flag via the BTPlayerExperienceSimLive bridge ([[experience-levels]]).
|
|
||||||
See [[rendering]] fog section. (The pre-#61 "verified inert live: BT_FOG_LOG zero `SetFogStyle(2/3)`"
|
|
||||||
observation still holds — reason (1), the un-constructed renderable, remains.)
|
|
||||||
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
|
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
|
||||||
FILED AS GITEA #65
|
FILED AS GITEA #65
|
||||||
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
|
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
|
||||||
@@ -668,6 +709,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
destroyed-subsystem dark-panel look.
|
destroyed-subsystem dark-panel look.
|
||||||
|
|
||||||
## Locomotion / combat polish (non-gating)
|
## Locomotion / combat polish (non-gating)
|
||||||
|
- **Myomer climb term is DEAD in the port: the gravity operand samples 0** (found in the #96
|
||||||
|
cruise benches, `[myoheat] g=0` every line — `BTMechMyomerMotionSample` reads
|
||||||
|
`GetEnvironment()->gravityConstant` and gets 0; the binary reads `**(mover+0x250)`, a POINTER
|
||||||
|
the Mover holds to the live gravity cell). Harmless on flat ground (|vy|≈0 anyway) but the
|
||||||
|
climb-work heat never accrues on slopes. Find where our Environment gravity actually lives
|
||||||
|
and re-point the bridge. [T2 measured]
|
||||||
|
- **Engine Mover gravity is dt-LESS per-frame in the binary** (`@0x421e77: vy -= **(+0x250)`,
|
||||||
|
raw, once per Perform — same class as the myomer kinetic term). If our linked WinTesla
|
||||||
|
MOVER.cpp kept that form, gravity acceleration in the port scales with OUR ~59Hz frame rate
|
||||||
|
(≈2.1× the pod's 28) — jump arcs, falls, missile droop all stiffer than the pod. CHECK the
|
||||||
|
WinTesla source's gravity line; if per-frame, it needs the same 28Hz reference-rate
|
||||||
|
normalization the myomer kinetic term got. [T1 binary side / T4 port impact until read]
|
||||||
|
- **Gait-noise phantom acceleration feeds the myomer accel term** (`[myoheat] a=4..33` at
|
||||||
|
steady cruise — our per-frame velocity re-derivation jitters; the binary read the same
|
||||||
|
`AccelerationLastFrame` cell off ITS OWN gait, noise level unknown). Bounded contributor
|
||||||
|
(~25-40% of cruise heat-in); revisit only if the 28Hz-faithful feel is contested. [T3]
|
||||||
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
|
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
|
||||||
mech was leaning −8° into every walk because the authentic ctor clip-set gate
|
mech was leaning −8° into every walk because the authentic ctor clip-set gate
|
||||||
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
||||||
@@ -761,6 +818,25 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
numbers no longer point at that code).
|
numbers no longer point at that code).
|
||||||
|
|
||||||
## Multiplayer (Phase 7 / P6)
|
## 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
|
- **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
|
(awaiting the human MP death-and-survive verification).** Findings [T1/T2 — see the #12
|
||||||
comments + `scratchpad/incident_2157/`]:
|
comments + `scratchpad/incident_2157/`]:
|
||||||
@@ -856,6 +932,28 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
|
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
|
||||||
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
|
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
|
||||||
|
|
||||||
|
## Score/death report tail residuals (2026-08-05, #45/#134 reconstruction — the tail itself is DONE + benched, [[combat-damage]])
|
||||||
|
- **Mech-table 0x17 "SetBurningState" label/body MISMATCH.** The recovered name row says
|
||||||
|
Set/ClearBurningState for 0x17/0x18, but @0x49f674's body randomizes the mech's position from
|
||||||
|
two scaled randoms, rebuilds localToWorld, sets graphicAlarm level 2, ForceUpdates and clears
|
||||||
|
the not-simulated flag — nothing burns. Possibly a shifted name row (cf. the factory ClassID
|
||||||
|
mislabels). Neither handler is reconstructed (nothing in the port sends mech-0x17/0x18);
|
||||||
|
identify the real bodies before wiring. The DEATH-tail id-0x17 message that was once labeled
|
||||||
|
"SetBurningState dispatch" is RESOLVED: it is Player::VehicleDeadMessage (player table, not mech).
|
||||||
|
- **Does @0x4c05c4 (VehicleDead handler) CONSUME the BT extension fields** (+0x2c killed-by
|
||||||
|
player, +0x34 kill zone)? The function is not in the export; our #52 reconstruction predates
|
||||||
|
the fields. If it reads them, the likely consumer is a killed-by console post
|
||||||
|
(ConsolePlayerMechDeathWithoutHonor / a killed-by line) + maybe an RP-style "DestroyedBy"
|
||||||
|
status banner on the victim. The port SENDS them faithfully; raw-disasm the handler to close.
|
||||||
|
- **Collision-death tail fallthrough is inspection-tier [T3]:** the divert now falls to the death
|
||||||
|
tail (VehicleDead + blast) per @0x4a0375, but no live wall-death bench has run; field
|
||||||
|
wall-deaths exercise it nightly (watch for DEAD_NOTIFY on collision deaths).
|
||||||
|
- **The 1995 type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
|
||||||
|
shipped binary folds an uninitialized stack float into the shooter's score on every non-lethal
|
||||||
|
hit's type-0 report. Era scoreboards looked sane — worth understanding WHY (dispatch path may
|
||||||
|
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
|
||||||
|
deviates safely (award=0). Curiosity, not a blocker.
|
||||||
|
|
||||||
## Rendering follow-ups (non-blocking)
|
## Rendering follow-ups (non-blocking)
|
||||||
- ~~Per-pilot mech PAINT (color/badge/patch)~~ — **DONE 2026-07-17, verified live** (crimson MadCat +
|
- ~~Per-pilot mech PAINT (color/badge/patch)~~ — **DONE 2026-07-17, verified live** (crimson MadCat +
|
||||||
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
|
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
|
||||||
@@ -879,6 +977,67 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
|||||||
## Content build sub-project (low priority)
|
## Content build sub-project (low priority)
|
||||||
- Lab/other-build maps (`des`/`burnt`/`frstrm`) are source-only (`.map` in CONTENT/BT/MAPS/); would
|
- 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.
|
need compiling into a RES via the DOS `btl4tool.exe`. The 8 RES maps cover testing.
|
||||||
|
- **Offline subsystem-authoring dispatch is un-linked (known, cold, 2026-07-30).** mech3.cpp's
|
||||||
|
`Mech::CreateSubsystemStream` dispatches to 20 per-class `CreateStreamedSubsystem` stubs whose
|
||||||
|
guessed signatures (`void*`/`int`) don't match the real modules (nested `<X>::SubsystemResource*`
|
||||||
|
+ trailing `ResourceFile*`, e.g. mechtech.cpp:407) — all 20 are /FORCE-silenced unresolved
|
||||||
|
externals. No callers exist, so it cannot crash today; before wiring any authoring tool through
|
||||||
|
it, convert each to a per-module bridge fn in a complete-type TU (gotchas §6 stub-typedef
|
||||||
|
corollary). The parallel `DefaultData` half of the table WAS the same trap and is fixed
|
||||||
|
(`Simulation__SharedData`).
|
||||||
|
|
||||||
|
## Night-8 playtest intelligence (2026-07-31, build 4.11.674) [T2 player testimony]
|
||||||
|
|
||||||
|
**SENSOR PANEL — the model, from a player who ran the cabinets** (VGL Lynx; the last major system
|
||||||
|
without one; Gitea #105). Sensor damage degrades, in descending confidence: (1) the **HUD flickers**
|
||||||
|
— corroborated by period gameplay footage, and the distinctive tell to match; (2) **radar/map**
|
||||||
|
degraded or lost; (3) the **searchlight** (cf. #61, where Searchlight/ThermalSight ToggleLamp were
|
||||||
|
dead handlers); (4) the **hot box / thermal** suspected but unconfirmable — that feature is not
|
||||||
|
functional in our build. Next step is to find the sensor subsystem's damage consumers in the binary
|
||||||
|
and check which display paths it actually gates, rather than implementing from the description.
|
||||||
|
|
||||||
|
**COOLANT LEAK severity is a THREE-level display** (Oracle + Draco, Gitea #97): one/two/three
|
||||||
|
triangles, top level draining the reservoir fast, with an audible alarm. We only ever produce the
|
||||||
|
lowest. Our leak rate is a continuous scalar (`ownZone->damageLevel x heatLoad`, floor 0.0025,
|
||||||
|
ON hysteresis 0.003) — so either the banding into three display levels is missing, or the magnitude
|
||||||
|
never leaves the bottom band. Check whether severity is separately authored before deriving it.
|
||||||
|
|
||||||
|
**MYOMER heat calibration is CHASSIS-RELATIVE, not absolute** (Oracle, Gitea #96): a **Thor should
|
||||||
|
sustain seek 4 more or less indefinitely** on its heatsink count, while a light chicken-walker
|
||||||
|
overheats in well under a minute. This is the acceptance test for the drive-heat coefficient — the
|
||||||
|
spread should fall out of (drive heat / authored sink capacity) on its own, so a uniform "too hot"
|
||||||
|
across ALL chassis indicts the coefficient rather than the sinks.
|
||||||
|
|
||||||
|
**RAM damage: the binary's internal-only divert MATCHES what players see, and may be correct**
|
||||||
|
(Gitea #103). Oracle reported ram causing gyro + myomer damage and a myomer coolant leak but **no
|
||||||
|
armor damage**, with grinding inert — exactly `Mech::DistributeCollisionDamage`'s type-0 divert
|
||||||
|
(internals weighted Myomers/Gyro 0.35, HeatSinkBank 0.30, Torso 0.25; 0.5-pt free-tap floor).
|
||||||
|
Awaiting a player ruling on whether a hard ram ever damaged ARMOR in the original before treating
|
||||||
|
the absence as a defect.
|
||||||
|
|
||||||
|
**MP STABILITY datapoint** (RajelAran): a **5-player match with no egregious issues** — *"no
|
||||||
|
stability problems or massive lag spikes"*. Relevant to the carried cross-fire-stall and
|
||||||
|
silent-node-crash opens: neither reproduced at 5 players this session. Not evidence of a fix
|
||||||
|
(neither was addressed), but it bounds how often they occur.
|
||||||
|
|
||||||
|
## Post-re-export leads (2026-08-06, #60 closed — [[source-completeness]], `phases/phase-04-gap-census.md`)
|
||||||
|
- **`@0x4c0904` is the MASTER BTPlayer Performance — our `@0x4c083c` attribution needs a
|
||||||
|
re-check.** Newly lit by the re-export: it resolves the player's Team by name out of the
|
||||||
|
`Teams` registry (error string "Team ... does not exist"), calls `@0x4c083c` and the engine
|
||||||
|
`Player::PlayerSimulation` (`@0x42e100`), then at mission-end (state 4, past a threshold,
|
||||||
|
once) posts "Sending EndMission" to the console host, and on a timer posts the periodic
|
||||||
|
console SCORE update (`Round(currentScore)` — corroborating our hand-built scoreboard
|
||||||
|
heartbeat). btplayer.cpp currently documents `@0x4c083c` as PlayerSimulation; read both and
|
||||||
|
fix the attribution + fold in the team resolution (we have no Team-by-name resolve today).
|
||||||
|
- **`0x480a2a-0x487ca0` (~9 KB, l4splr|btmssn boundary) stayed dark THROUGH the re-export** —
|
||||||
|
the gap fill reached it with neither a call nor a data reference, which is itself a finding:
|
||||||
|
dead code, data misparsed as code, or reachable only through a jump table. The earlier
|
||||||
|
spot-check showed a real `enter 0x3c` function driven by a global-index dispatch table
|
||||||
|
(`[0x4fef88]` → table `0x501588`), so "dead code" is unlikely. Byte-scan for the table's
|
||||||
|
writers to find the entry path.
|
||||||
|
- **~41 KB is still dark overall**, mostly munga/munga_l4 (we compile the real engine source, so
|
||||||
|
low value). Current map: `reference/decomp/GAP_CENSUS.md`; re-run the pipeline after any
|
||||||
|
future re-export.
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
||||||
@@ -929,3 +1088,77 @@ heat / FailureTemperature scaling is too aggressive, or the roach-motel (no reco
|
|||||||
falls) is the wrong reading. NEXT: audit the SRM6 heatPerShot vs FailureTemperature vs the mech's
|
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.
|
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).
|
Trace it live with `BT_AMMO_LOG=1` (+ `BT_PROJ_LOG=1` for per-tick heat/recoil).
|
||||||
|
|
||||||
|
## Night-9 playtest intelligence (build 4.11.693, 2026-08-01)
|
||||||
|
|
||||||
|
**A MEMBER OF THE ORIGINAL TEAM IS NOW TESTING.** "Ronin" (log user `torak`,
|
||||||
|
machine TOWER_OF_TERROR) worked on the 1995 game and has played the pods
|
||||||
|
recently, including in Japan. He is a **primary source** — treat his comparisons
|
||||||
|
against the pods as high-value evidence, above reconstruction inference. Two
|
||||||
|
contributions already:
|
||||||
|
* **Per-chassis cockpit BOUNCE was deliberate** [T1, testimony]: "all of the
|
||||||
|
chassis have different cockpit 'bounce' — this was made so to help give the
|
||||||
|
'mechs some individuality". If our port applies one shared bounce curve, a
|
||||||
|
deliberate identity cue is missing. (Gitea #117; `jointshakey` is the likely
|
||||||
|
carrier — its pick sphere radius already differs per chassis.)
|
||||||
|
* **NARC may never have been implemented** [T3, recollection]: the loadout data
|
||||||
|
shipped but he does not recall the weapon being built. Squares with what we
|
||||||
|
see — an authored record that resolves with no distinct behaviour to
|
||||||
|
reconstruct. Mike L. could confirm. (Gitea #104.)
|
||||||
|
|
||||||
|
**AUDIO #32, CORRECTED DIAGNOSIS (2026-08-02): saturation during combat, NOT
|
||||||
|
retention** [T2 — census-measured]. An earlier version of this entry claimed the
|
||||||
|
pool "fills and never returns a source"; that was read off the FAILURE line, where
|
||||||
|
`free=0` is true by definition (a third instance of the counter-sampling trap).
|
||||||
|
The 30-second census in the same logs disproves it: `free` returns to ~227-230
|
||||||
|
between bursts and `reuses` climbs ~20/s all session — the pool cycles fine, and
|
||||||
|
release-on-stop exists and works (the engine's steal loop:
|
||||||
|
AudioSourceStop/SuspendMaintenance -> ReleaseChannels -> ReleaseSourceSet).
|
||||||
|
* The real regime: during firefights CONCURRENT demand exceeds the 240 cap and
|
||||||
|
the priority steal loop services each new sound by killing an old one,
|
||||||
|
thousands of times per combat session. Idle standing demand is only ~13.
|
||||||
|
* The raw `ACQUIRE FAILED` line count (6.8k-19k/log) is NOISE — the steal loop
|
||||||
|
retries after every failed attempt, so lines pile up per event and most
|
||||||
|
events still play via a steal. It is now rate-limited (1/30s) and the census
|
||||||
|
carries the true metrics: `steals=` and `drops=` (drops = the steal loop ran
|
||||||
|
dry and the sound NEVER played), plus a per-class drop histogram.
|
||||||
|
* The saturating requester is class 1005 = **Static3DPatchSource, 4 voices** —
|
||||||
|
world-placed effect sounds, i.e. EXPLOSIONS/impacts. Demand tracks the
|
||||||
|
number of SHOOTERS (9-mech one-shooter bench peaks at 130 with 0 fails; a
|
||||||
|
6-shooter field lobby pins 240). **#84's double detonation doubles exactly
|
||||||
|
this class on observer nodes** — fix #84 first, then re-read the field
|
||||||
|
census before touching the audio budget.
|
||||||
|
* `BT_AUDIO_SOURCES=<n>` now raises the POOL cap too, not just the AL context
|
||||||
|
budget (it previously did only the latter, making the experiment
|
||||||
|
impossible). If post-#84 logs still saturate, that is the field experiment
|
||||||
|
— with frame time measured, since more concurrent voices = more mixing CPU.
|
||||||
|
|
||||||
|
**Reading the missile impact line.** `[projectile] IMPACT damage=X ... burst=N`
|
||||||
|
prints X = **per missile**; the delivered total is `X * N`. A tester reading X
|
||||||
|
alone will report LRMs as doing ~3.3 damage when the salvo is landing up to 65.
|
||||||
|
Field-verified across 382 impacts: LRM20 3.25 x 5..20, LRM15 3.33 x 3..15,
|
||||||
|
LRM10 3.50 x 2..10 — burst ranges matching the binary's `Random(n) + n/4` roll
|
||||||
|
exactly.
|
||||||
|
|
||||||
|
**Weapon damage values are published** in the original manuals (Oracle: "that is
|
||||||
|
published ... Manuals had that info and it was accurate") — but NOT in the 4.0
|
||||||
|
manual we have archived (confirmed by Lynx). A later manual, or the wayback
|
||||||
|
capture of the original site's final build notes, would give hard per-weapon
|
||||||
|
numbers to test the authored data against. Worth chasing: it converts weapon
|
||||||
|
tuning from taste into arithmetic.
|
||||||
|
|
||||||
|
**Standard vs Expert heat is unsettled** and it contaminates every heat report:
|
||||||
|
Conn Man saw heat build-up in Standard while Rajel expected none there. See
|
||||||
|
Gitea #116 and `experience-levels.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## Searchlight residuals (2026-08-05 — feature LIVE, [[rendering]] §SEARCHLIGHT)
|
||||||
|
- **Cone look pass pending**: spot.bgf hangs on the site joint with the site's rest transform;
|
||||||
|
the binary's per-case offset (local_2f0 at @004cef28 case 0xbd8) is believed to be the same
|
||||||
|
site offset [T3] — eyeball the size/aim on madcat/loki at night, then clear this.
|
||||||
|
- **BTL4VideoRenderer::LoadObject (@00498448) is a btstubs no-op** — the MechMarker beacon case
|
||||||
|
(MakeEntityRenderables 0xBBA) silently gets NULL and has never drawn. The searchlight went
|
||||||
|
around it (d3d_OBJECT::LoadObject direct). Reconstruct the wrapper or migrate the marker.
|
||||||
|
- **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night
|
||||||
|
pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing"
|
||||||
|
on some maps, it's page authoring, not the mechanism.
|
||||||
|
|||||||
+113
-6
@@ -54,9 +54,10 @@ button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x
|
|||||||
the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D
|
the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D
|
||||||
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
|
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
|
||||||
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
|
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
|
||||||
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **WIRED 2026-07-26**, the last handler in
|
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **COMPLETE 2026-08-06**: handler (07-26) +
|
||||||
this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see
|
the master-perf posture CONSUMER + squat/rise clips + MP replication ([[locomotion]] §CROUCH;
|
||||||
[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16
|
glass key F4); the map legend's `bduck.pcc` widget still lights. (The old "no code consumer"
|
||||||
|
verdict was export-gap blindness — [[reconstruction-gotchas]] §20) —, **0x28 → Mech `BalanceCoolant` (0x16
|
||||||
@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
|
@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
|
||||||
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
|
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
|
||||||
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
|
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
|
||||||
@@ -183,6 +184,84 @@ All cockpit surfaces are bit-plane MASKS over ONE shared `SVGA16` pixelBuffer: `
|
|||||||
byte; `Heat`=0x4000, `Mfd2`=0x0400, `Comm`=0x8000, `Mfd1`=0x0100, `Mfd3`=0x1000; `Eng1-3` =
|
byte; `Heat`=0x4000, `Mfd2`=0x0400, `Comm`=0x8000, `Mfd1`=0x0100, `Mfd3`=0x1000; `Eng1-3` =
|
||||||
engineering-mode alt planes; `overlay`=0x00C0 (shares the sec surface). See [[gauges-hud]]. [T2]
|
engineering-mode alt planes; `overlay`=0x00C0 (shares the sec surface). See [[gauges-hud]]. [T2]
|
||||||
|
|
||||||
|
## ⭐ THE RGB SPLIT — how ONE VGA port drives THREE mono MFDs (decoded 2026-08-06) [T0 engine source + T1 authentic pod config]
|
||||||
|
The five monochrome MFDs are NOT five video outputs. Each VGA port's **R, G and B analog lines
|
||||||
|
are split to three separate monochrome monitors**, and the software puts a different MFD in each
|
||||||
|
colour channel of one shared palettized framebuffer. Mechanism, end to end:
|
||||||
|
|
||||||
|
1. **Every surface is a bit-plane + a CHANNEL.** `content/GAUGE/L4GAUGE.CFG` (the authentic 1996
|
||||||
|
pod config) configures each port as `configure(idx, port, rotation, bitMask, clut, COLOUR, palette)`:
|
||||||
|
| port | panel | mask | clut | channel |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Comm` | upper right | 0x8000 | clut2 | **red** |
|
||||||
|
| `Mfd2` (Engineering) | upper centre | 0x0400 | clut2 | **green** |
|
||||||
|
| `Heat` | upper left | 0x4000 | clut2 | **blue** |
|
||||||
|
| `Mfd1` | lower left | 0x0100 | clut1 | **red** |
|
||||||
|
| `Mfd3` | lower right | 0x1000 | clut1 | **green** |
|
||||||
|
| `sec` (+`overlay` 0x00C0) | secondary/radar | 0x003F | clut0 | **rgb** (full colour, rotation 270 — the physically ROTATED portrait CRT) |
|
||||||
|
`Eng1/2/3` are the engineering-page twins of Mfd1/2/3: same monitor, second bit-plane, switched
|
||||||
|
by `reconfigure(...)` giving one plane the channel and the other `blank`.
|
||||||
|
2. **The channel assignment is literally a palette write.** `L4GraphicsPort::BuildSecondaryColor`
|
||||||
|
(L4VB16.cpp) walks the palette entries owned by the port's bit group (`BitWrangler(byteMask,8)`)
|
||||||
|
and writes ONE component: `RedChannel -> triplet->Red`, `GreenChannel -> ->Green`,
|
||||||
|
`BlueChannel -> ->Blue`, `AllChannels -> the whole triplet`. `BlankColor` blanks the group
|
||||||
|
(`BlankPalette()`), which is how a page swap silences the plane it replaces. The
|
||||||
|
`*TransparentZero` variants skip colour 0 so zero reads as transparent for that group.
|
||||||
|
3. **So the DAC output carries three independent pictures**, one per analog line, and the splitter
|
||||||
|
hands each line to its own mono monitor. Three MFDs per VGA port; the pod's two MFD ports are
|
||||||
|
the **1280x480 "horizontally spanned" surface** (2 x 640x480 halves) the Displays section
|
||||||
|
describes — clut2 = the upper row (Comm/Mfd2/Heat), clut1 = the lower row (Mfd1/Mfd3, blue
|
||||||
|
spare). The radar rides its own port in real colour.
|
||||||
|
|
||||||
|
**Why this matters for the port [T2]:** our modern path renders each surface as its own
|
||||||
|
mono-tinted window on its own Windows display (see §MFD PANELS ON REAL HARDWARE), which is right
|
||||||
|
when every panel has its own output. **On splitter-wired glass it is wrong** — three monitors
|
||||||
|
would share one Windows display and each would show only its channel's share of a single tinted
|
||||||
|
image. Driving original splitter hardware needs a CHANNEL-COMPOSITE mode: extract three planes
|
||||||
|
into ONE 640x480 RGB image with pure (255,0,0)/(0,255,0)/(0,0,255) tints, one window per VGA
|
||||||
|
output. `SVGA16::ExpandPlaneToBGRA` already does the per-plane extraction with a tint, so the
|
||||||
|
composite is a small addition. OPEN: which way Nick's crash cart is wired (Windows shows three
|
||||||
|
separate 640x480 displays there, which suggests per-panel outputs via the Trigger 6 USB adapter,
|
||||||
|
not a splitter) — settle it by eye before building.
|
||||||
|
|
||||||
|
## MFD PANELS ON REAL HARDWARE — the bring-up path (2026-08-06) [T2 local / T4 on-pod]
|
||||||
|
Nick's crash cart (pod hardware + Chrome Remote Desktop on a burner account) is the first chance
|
||||||
|
to drive the real panels. **The 1995 display path is NOT the way in.** That rig spanned the five
|
||||||
|
MFDs as ONE 1280×480 surface via **NVIDIA Horizontal Span, which every driver after XP dropped**;
|
||||||
|
`SVGA16::BuildWindows` also wants an exclusive-fullscreen D3D device per adapter, which is
|
||||||
|
fragile on modern drivers and over a remote session. Both are still in-tree and still the
|
||||||
|
authentic reference — they are just not the bring-up route.
|
||||||
|
|
||||||
|
**The route is the glass per-display windows** ([[glass-cockpit]]): one window per surface, which
|
||||||
|
maps 1:1 onto one physical panel per MFD, needs no special driver, and is already field-proven on
|
||||||
|
desktops. Two pieces were added for the cab:
|
||||||
|
- **`BT_POD_SURFACES=1` (pod surface mode, L4GLASSWIN)** — crops every window to its SURFACE
|
||||||
|
(MFDs exactly 640×480, radar 480×640 portrait), drops the on-screen RIO button banks (the cab's
|
||||||
|
buttons are PHYSICAL — drawing fake ones over a real panel is precisely wrong), goes frameless,
|
||||||
|
and does not create the Flight Controls pad at all (6 windows, not 7). Per-window equivalent:
|
||||||
|
append `,bare` to a line in `glass_layout.cfg` (mixed rigs).
|
||||||
|
- **Placement receipts** — every window logs `[glasswin] '<title>' surface=<port> at X,Y WxH
|
||||||
|
bare -> monitor \.\DISPLAYn (origin WxH, PRIMARY)`. On a cab nobody can see seven surfaces at
|
||||||
|
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
|
||||||
|
landed on the right glass.
|
||||||
|
|
||||||
|
**Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC):
|
||||||
|
1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original
|
||||||
|
panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that
|
||||||
|
assigns the six surfaces to the non-primary monitors top-to-bottom/left-to-right, centred.
|
||||||
|
2. Drop the cfg in the game's working directory; run with `BT_GLASS=1 BT_GLASS_PANELS=1
|
||||||
|
BT_POD_SURFACES=1 BT_GLASS_LAYOUT=load` (`=save` to persist drags instead).
|
||||||
|
3. Read the receipts; re-assign titles to monitors in the cfg until each picture is on its panel.
|
||||||
|
Surface→panel roles: Heat MFD = upper left (coolant), Engineering = upper centre, Comm MFD =
|
||||||
|
upper right (hot box), Left/Right Weapons = lower left/right, Secondary/Radar = the secondary
|
||||||
|
screen (portrait). Main 3D view stays the game's own window on the main-view monitor.
|
||||||
|
|
||||||
|
**Known constraints / open on-pod questions:** whether the panels are attached to this PC at all
|
||||||
|
(the probe answers it); whether Windows offers a 640×480 mode on them (if not, the surface renders
|
||||||
|
at native size CENTRED, not scaled — a scale-to-fit option is the obvious follow-up); whether
|
||||||
|
Chrome Remote Desktop holds the CONSOLE session (it should — an RDP session would get a virtual
|
||||||
|
display and light nothing); and the RIO serial input, which is a separate task from the displays.
|
||||||
|
|
||||||
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
|
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
|
||||||
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
|
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
|
||||||
every checked control behavior:
|
every checked control behavior:
|
||||||
@@ -208,9 +287,37 @@ every checked control behavior:
|
|||||||
side button columns + **EJECT button** beside the joystick + per-mech COOLANT LOOP tables
|
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
|
(weapons+generators per loop 1-6) and stat sheets (tonnage/armor/reservoir liters/heat
|
||||||
sinks/top speeds incl. "Super Charged").
|
sinks/top speeds incl. "Super Charged").
|
||||||
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen;
|
- **MYOMER SEEK / "SUPER CHARGE" — ⚠ the "4.0→4.10 drift" verdict below was WRONG and is
|
||||||
`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist:
|
RETRACTED (same day).** 4.10 HAS the seek→speed coupling — in the un-exported master-perf
|
||||||
EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech
|
gap (@0x4a9cf2: `speedDemand *= max myomer speedEffect`, turn-freeze at dead drive; see
|
||||||
|
[[subsystems]] WAVE 6 correction). The manual is ACCURATE for 4.10; Oracle's pod memory
|
||||||
|
(seek-4 humanoid = 182 kph = the printed figure) confirmed it and prompted the re-audit.
|
||||||
|
The paragraph below is retained as the record of the error (its BINARY facts — the two
|
||||||
|
exported AvailableOutput callers, the single attr ref — were correct but incomplete:
|
||||||
|
export-gap functions are invisible to decomp text sweeps).
|
||||||
|
~~ORIGINAL (WRONG) VERDICT:~~
|
||||||
|
Manual p20/p22: seek "translates to a lower or higher top speed", gear 4 is named
|
||||||
|
**Supercharge** (screenshot caption "Seek Level Is Set to Supercharge"), expert-only ("in
|
||||||
|
standard simulation mode, you are not able to supercharge"), and "overheated myomers can
|
||||||
|
reduce your 'Mech's speed to zero". Stat sheets print THREE top speeds (Loki/Thor: Normal
|
||||||
|
143 / **Super Charged 182** / **Gimped 40** kph). The 4.10 BINARY dropped the runtime
|
||||||
|
coupling entirely: the mapper demand @004afd10 has no myomer/seek term, `AvailableOutput`
|
||||||
|
has exactly two callers (assembly cap + graph), and the SpeedEffect attribute string has one
|
||||||
|
reference (its own table row). Even the printed ratio disagrees with 4.10's authored gears
|
||||||
|
(182/143 = 1.27 vs 0.9999/0.7 = 1.43) — the gear table and the assembly cap are vestiges.
|
||||||
|
In 4.10 the seek dial's live effects are heat (ratio² ≥ 1), the generator brown-out
|
||||||
|
threshold (whose real stake is TORSO TWIST — see [[subsystems]] WAVE 6), and the ENG graph.
|
||||||
|
"Top Speed Gimped 40" also confirms the limp-gait speed cap as an authored spec. The port
|
||||||
|
follows the BINARY (house rule); resurrecting manual-4.0 supercharge would be an opt-in
|
||||||
|
deviation for the operator to decide.
|
||||||
|
NEW LEADS (manual describes, port lacks input/UI): ~~CROUCH~~ ✅ **CROUCH COMPLETE 2026-08-06**
|
||||||
|
([[locomotion]] §CROUCH), ~~EJECT~~ **EJECT WIRED
|
||||||
|
2026-08-02** (core: `Mech::EjectPilotMessageHandler` id 0x19 @0049f854 + the crippled-mech
|
||||||
|
permission evaluator @0049fa1c; input = binding-engine "Eject" action, Backspace / pad
|
||||||
|
LeftThumb; the punch-out kills via graphicAlarm 10 ≥ 9 — KillBonus authors 0 in ALL shipped
|
||||||
|
content, so the zero charge is authentic; tails: console relay notice, RIO 0x38 panic control,
|
||||||
|
alarm-10 audio/canopy presentation, `SpecialCaseDeathPenalty` role+0x20 consumer), hot-box
|
||||||
|
viewscreen framing (the deferred PNAME marker chain). Per-mech
|
||||||
stat tables = a systematic cross-check source for our streamed subsystem resources.
|
stat tables = a systematic cross-check source for our streamed subsystem resources.
|
||||||
|
|
||||||
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
|
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
|
||||||
|
|||||||
+18
-13
@@ -39,19 +39,24 @@ sections in `docs/PROGRESS_LOG.md` cite old `C:/git/nick-games/...` paths — tr
|
|||||||
they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
|
they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
|
||||||
content → `content/`, raw decomp → `reference/decomp/`). [T2]
|
content → `content/`, raw decomp → `reference/decomp/`). [T2]
|
||||||
|
|
||||||
## Current state (2026-07)
|
## Current state (2026-08-06) — core gameplay reconstruction COMPLETE; polish phase
|
||||||
`btl4.exe` boots, renders the world + a skinned mech, and runs a full **drive → animate → target →
|
Field-tested nightly at 4-8 pods over Steam (builds 4.11.622→801). Authentic + benched: the full
|
||||||
fire → damage → destroy → respawn** single-player loop (task #52). **2-node MP is verified
|
SP+MP loop; per-panel mesh-true targeting/damage (+ crits, cylinder lottery, armour darkening);
|
||||||
end-to-end** — replication, cross-pod targeting/damage/kill, beam visuals, replicant gait (tasks
|
the 1995 SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
|
||||||
#46-#51, [[multiplayer]]). All 8 cockpit canopies are authentic + the horizontal-FOV fix (task #55,
|
death (blast/splash, wreck, burial) + respawn (full re-arm audit); locomotion (two-channel gait,
|
||||||
[[cockpit-view]]); the Gyroscope is live byte-exact with hit-bounce (task #56); the **gauge system
|
gimp limp, knockdowns, CROUCH F4); the night kit (fog-swap SEARCHLIGHT F5 + beam cone); heat/
|
||||||
is complete** ([[gauges-hud]]). The engine/renderer/HAL are done (WinTesla); AUDIO backend is now REAL
|
power/myomers (incl. the seek-4 freeze); replication (masters/replicants, kill/death columns,
|
||||||
(the repo's OpenAL32.dll + libsndfile-1.dll were both no-op STUBS — replaced with real OpenAL Soft +
|
ghost+skate field detectors); the Steam wire seam + build gate; the operator console/relay
|
||||||
an in-tree WAV loader, 2026-07-15) and the 241-sample soundbank is cracked from AUDIO1/2.RES + loading;
|
lifecycle; gauges/HUD; audio (real OpenAL backend + the AUDIO_FIDELITY trigger waves — footsteps/
|
||||||
the ONLY remaining audio gap is game-triggering (no AudioEntities fire PlayNote yet), so gameplay is
|
gait/alarms/impacts live in the field). [T2]
|
||||||
still silent apart from a proof-of-life hook (see [[wintesla-port]] Audio). The active work
|
**Infra ✅ 2026-08-06:** the #60 export gap census AND re-export shipped — coverage 87.3% →
|
||||||
is reconstructing each BT subsystem's authentic behavior from the binary. Remaining: pod-LAN
|
93.5%, dark code 90 → 41 KB, +205 functions, and the re-export independently confirmed the
|
||||||
config, Mech-level update records, per-subsystem waves. [T2]
|
week's raw-disasm reconstructions ([[source-completeness]], `phases/phase-04-gap-census.md`).
|
||||||
|
**Remaining (polish):** VehicleDead killed-by consumption (operator "X killed by Y" — the sender
|
||||||
|
already ships the fields); burning-wreck handler (mech-0x17 label mismatch); `@0x4c0904` master
|
||||||
|
BTPlayer Performance vs our `@0x4c083c` attribution; the id-0x16 type-0 curiosity; MechRIOMapper
|
||||||
|
Keypress @004d2514; marker beacon (LoadObject wrapper stub); DIV firmware intersection routine
|
||||||
|
(deep-cut); deferred ledger items in [[open-questions]]. [T2]
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Full detail: `docs/PROGRESS_LOG.md`.
|
- Full detail: `docs/PROGRESS_LOG.md`.
|
||||||
|
|||||||
@@ -121,6 +121,17 @@ mislanch.cpp's extern but not projweap.cpp's → first AUTOCANNON shot AV'd). **
|
|||||||
bridge-signature change, `grep -rn "extern .*<name>"` and update every declaration; then grep
|
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
|
the fresh link output for the symbol name** — the pre-existing LNK2019 wall camouflages new
|
||||||
entries if you only eyeball it.
|
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
|
**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`,
|
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
|
`ghWnd`, … exist in BOTH `munga_engine` and `bt410_l4`) links under `/FORCE:MULTIPLE` with
|
||||||
@@ -267,6 +278,12 @@ Suspect ANY reconstructed per-frame code with narrow equality/window tests or `x
|
|||||||
state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
|
state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
|
||||||
|
|
||||||
## 13. Verification gotchas (don't fool yourself)
|
## 13. Verification gotchas (don't fool yourself)
|
||||||
|
- **Capture the viewpoint that can SEE the change.** The crouch "pose does not hold" and the
|
||||||
|
false "eye does not drop" residual (2026-08-05/06) were BOTH capture errors: cockpit-view
|
||||||
|
screenshots cannot show your own legs, and canopy-dominant diff crops mask eye-relative motion
|
||||||
|
(the canopy drops WITH the eye — only the through-window ground shifts). Anchor pixel-diff
|
||||||
|
crops on a region the effect MUST change, pair them with a state probe (joint values), and
|
||||||
|
READ one frame with your own eyes before declaring a visual regression.
|
||||||
|
|
||||||
- **Lazy gauge build:** `GaugeRenderer::BuildConfigurationFile` runs LAZILY. A too-early process
|
- **Lazy gauge build:** `GaugeRenderer::BuildConfigurationFile` runs LAZILY. A too-early process
|
||||||
kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
|
kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
|
||||||
@@ -532,6 +549,34 @@ segment tables) — the same class of latent overflow.
|
|||||||
0xFEEEFEEE=freed).
|
0xFEEEFEEE=freed).
|
||||||
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
|
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
|
||||||
|
|
||||||
|
## 20. Export-gap blindness — absence in the EXPORT is not absence in the BINARY (4 incidents)
|
||||||
|
The Ghidra export (`reference/decomp/`) has coverage gaps (#60), and BTL4.RES content never
|
||||||
|
appears in it at all — so "no readers", "no caller", "no such clip/asset", and "unreconstructed"
|
||||||
|
claims made by grepping the export or the port ALONE are structurally unsound. This class has
|
||||||
|
produced four wrong conclusions, two of them [T1]-tagged at the time:
|
||||||
|
1. **2026-07-25 searchlight "1995 latent bug"** — compared Searchlight's Performance against
|
||||||
|
THERMALSIGHT's handler (wrong class) and invented a missing bridge; retracted (#61).
|
||||||
|
2. **2026-07-2x `DuckState` "has no CODE consumer, authentic [T1]" + "no SQUAT clip survives"** —
|
||||||
|
the consumer was the un-exported master-perf posture block (@0x4aa011), and the clips lived in
|
||||||
|
BTL4.RES (`squ/sqd/squi/sqdi` × 8 chassis); the search covered decomp strings + loose files,
|
||||||
|
never the RES TOC. Disproven by the CROUCH reconstruction (2026-08-06).
|
||||||
|
3. **2026-07-31 morning: the myomer drive-scale DELETION** ("the binary has NO dynamic
|
||||||
|
myomer→speed coupling") — the consumer was un-exported; restored same day by raw capstone.
|
||||||
|
4. **2026-08-05 "the myomer factor FEEDER (@004b8be3) is unreconstructed"** — it had been fully
|
||||||
|
reconstructed since 07-31 under NAMED members (`speedEffect`, `AvailableOutput`); the grep
|
||||||
|
searched raw offsets. Cost: a duplicate multiply + a dead crouch gate until 08-06.
|
||||||
|
**The rule — before claiming "X does not exist / is not reconstructed":**
|
||||||
|
- byte-scan `content/BTL4OPT.EXE` for the offset/immediate (disp32 patterns), never just the export;
|
||||||
|
- grep the port for NAMED members (check the .hpp for the offset's name) — offsets rot after promotion;
|
||||||
|
- for content claims, walk the **BTL4.RES TOC** (`tools/resscan.py` pattern), not the loose tree;
|
||||||
|
- check `reference/BT410_SOURCE_MANIFEST.md` + `game/reconstructed/CLASSMAP.md`;
|
||||||
|
- and tag the claim's tier by the WEAKEST source consulted — an export-only sweep caps at [T4].
|
||||||
|
The #60 gap census + RE-EXPORT is the systemic fix, and BOTH shipped 2026-08-06: the export
|
||||||
|
now covers 93.5% of .text (dark code 90 KB → 41 KB) and `reference/decomp/GAP_CENSUS.md` maps
|
||||||
|
what is still dark. **So the rule is cheap now: look the address up in GAP_CENSUS.md first.**
|
||||||
|
If it is NOT in a dark region, the export is authoritative and "absent" means absent. If it IS,
|
||||||
|
byte-scan before claiming anything. (Toolchain + method: `phases/phase-04-gap-census.md`.)
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
|
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
|
||||||
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
|
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
|
||||||
@@ -623,3 +668,198 @@ trusting what it is called — the reconstruction's names are reconstructions to
|
|||||||
|
|
||||||
**Sweep note:** the same misnomer sits in all three headers; renaming is safe (no binary meaning
|
**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.
|
attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately.
|
||||||
|
|
||||||
|
## 23. `AlarmIndicator` is a DIFFERENT TYPE per header family — Mech's layout diverges across TUs (2026-07-30)
|
||||||
|
|
||||||
|
Found chasing #78: `mechdmg.cpp` wrote `mech->graphicAlarm.SetLevel(4)` and read 4 back;
|
||||||
|
`mechmppr.cpp` read **0** from the *same object, same expression, same frame family*. Root cause:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
mech.hpp:67 typedef ReconAlarm AlarmIndicator; // 4 bytes {unsigned level}
|
||||||
|
heat.hpp:52 typedef GaugeAlarm AlarmIndicator; // 0x54 bytes (the real binary alarm)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Mech::graphicAlarm` is declared `AlarmIndicator` — so a TU's include ORDER decides which type
|
||||||
|
that member is, and **every Mech member after it shifts by 0x50 between the two families**. A
|
||||||
|
write through one family's layout is invisible to a read through the other. This is the shadow
|
||||||
|
trap operating at the TYPEDEF level, where no compiler error can catch it (each TU only ever sees
|
||||||
|
one definition).
|
||||||
|
|
||||||
|
**Rule:** any cross-TU read/write of a Mech member declared past `graphicAlarm` must go through a
|
||||||
|
bridge compiled in a KNOWN TU (`BTMechGimpLevel` in mechdmg.cpp is the pattern) until the typedef
|
||||||
|
split is audited and unified. The audit itself — which TUs resolve `AlarmIndicator` to which
|
||||||
|
type, and which member traffic crosses families — is an open work item; the same split-brain also
|
||||||
|
explains why `MovementMode()` (the engine `simulationState`) and the KB's "movementMode IS the
|
||||||
|
graphicAlarm level" (task #1) never actually met in the port: they are two different cells, both
|
||||||
|
alive, written by different subsystems.
|
||||||
|
|
||||||
|
## 24. Reviving DEAD reconstructed code replays every port-glue fix it predates (2026-07-30)
|
||||||
|
The gimp gait drivers (`AdvanceBody/LegAnimationGimp`, ex-"Airborne") sat reconstructed-but-dead
|
||||||
|
for weeks while their gate read the wrong cell (gotcha #23's split-brain). The moment the gate
|
||||||
|
was fixed and they ran for the first time, they reproduced — within seconds of engagement — TWO
|
||||||
|
bugs the LIVE drivers had each been cured of long before:
|
||||||
|
1. **the raw `*(this->controlSource)` mapper read** (`controlSource@0x128` is never wired in the
|
||||||
|
port; the live drivers were converted to `MappingMapper()` — the dead one crashed at
|
||||||
|
`AdvanceLegAnimationGimp+0x16`, null deref, first live engagement);
|
||||||
|
2. **the missing alarm→member state re-sync** (`bodyAnimationState = bodyStateAlarm.GetLevel()`;
|
||||||
|
the binary has ONE cell, the recon has two — the ground drivers re-sync at :831/:1181; the
|
||||||
|
dead driver didn't, so the state member froze at its pre-gimp value and the machine pinned in
|
||||||
|
one run state for 8000+ frames).
|
||||||
|
**Rule:** before wiring a long-dead reconstructed function into a live path, diff it against its
|
||||||
|
LIVE sibling for the port-glue idioms (mapper access via `MappingMapper()`, alarm→member
|
||||||
|
re-syncs, null guards, `BTEnvOn` gates). The binary-faithful parts transplant cleanly; it is the
|
||||||
|
RECON-side glue that will be missing, because every glue fix landed only where code was running.
|
||||||
|
|
||||||
|
## 25. A SPLIT cell breaks REPLICATION silently — and per-frame writers erase the half you added (2026-07-29)
|
||||||
|
The gimp-level saga's second act (#82). When the port carries one binary cell as TWO members, the
|
||||||
|
damage isn't only cross-TU reads (gotcha #23) — it is **which half rides the wire**:
|
||||||
|
- `Simulation::simulationState` is replicated in EVERY update record header
|
||||||
|
(`Simulation::Write/ReadUpdateRecord`). Any binary state living in `mech+0x40` therefore
|
||||||
|
replicated for free. The port's parallel member (`graphicAlarm`) replicates **nowhere**, so every
|
||||||
|
behavior a peer derives from it silently becomes master-only — visible as "peers see something
|
||||||
|
different" bugs (here: a limping mech that skated on every other pod).
|
||||||
|
- Worse, the engine cell usually already has a **per-frame writer** with a narrower idea of what it
|
||||||
|
means (`Mech::PerformAndWatch`: `SetMovementMode(1)` = "ground, non-death, non-airborne"). The
|
||||||
|
moment you mirror extra semantics into it, that writer erases them 60×/second — and the symptom
|
||||||
|
is not a stuck value but a 1→N→1 oscillation whose edges retrigger anything watching (the
|
||||||
|
warning-voice sequence restarted on every damage tick and never reached its spoken note).
|
||||||
|
**Rules:**
|
||||||
|
1. Before mirroring into an engine cell, `grep` for its per-frame writers and teach them the new
|
||||||
|
value (write `gimped ? 3/4 : 1`, not a blind `1`).
|
||||||
|
2. Read the authority, never the cell you feed — an alarm-only bridge (`BTMechGimpAlarmLevel`)
|
||||||
|
keeps a respawn-cleared alarm from re-latching stale state out of the cell.
|
||||||
|
3. Don't "fix" a stomp by patching incoming records: on a replicant the master's records ARE the
|
||||||
|
authority, and pinning a local value against them makes state stick forever (that draft would
|
||||||
|
have kept peers limping through a respawn). Find the writer instead.
|
||||||
|
**Tool:** scope the trap. `StateIndicator`/`Simulation` state traps drown in subsystem churn —
|
||||||
|
filter to one watched object (`g_btGimpWatchMech`) and print the caller module-relative
|
||||||
|
(`btl4+0x…`) so `tools/symcrash.py` names it. That turned a two-hour guess into one line.
|
||||||
|
|
||||||
|
|
||||||
|
## §21 — Export-gap blindness: "no callers in the decomp" is NOT "no callers in the binary"
|
||||||
|
**(2026-07-31, the myomer seek retraction — the costliest wrong verdict to date.)** The Ghidra
|
||||||
|
export has GAPS (gitea #60): whole functions absent from `reference/decomp/all/*.c` — the
|
||||||
|
master-perf region 0x4a9770-0x4ab188, the 0x4a03xx crit caller, @004b838c, and others. A text
|
||||||
|
sweep of the decomp for callers/readers of a symbol **cannot see a consumer that lives in a
|
||||||
|
gap**. The myomer `speedEffect@0x31C` demand feed was declared dead on exactly such a sweep
|
||||||
|
("two AvailableOutput callers, one attribute reference, no raw readers") and an already-correct
|
||||||
|
port mechanism was deleted on that basis — while the real consumer sat un-exported at
|
||||||
|
@0x4a9cf2 (`speedDemand *= max myomer speedEffect` + the dead-drive turn freeze). Field
|
||||||
|
testimony (Oracle: pod seek-4 = 182 kph, the manual's printed figure) forced the re-audit.
|
||||||
|
**RULE: before declaring a data path dead, sweep the RAW IMAGE for the operand pattern** —
|
||||||
|
capstone over the CODE section for the addressing form (e.g. FPU reads of `[reg+0x31C]`, SIB
|
||||||
|
`[reg+reg*4+0x330]`). Minutes of scanning; it found in one pass what three decomp sweeps
|
||||||
|
missed. Corollary: when the decomp and a PRIMARY SOURCE (the manual, a pod veteran) disagree,
|
||||||
|
treat the disagreement as a hole in YOUR evidence first, not in theirs.
|
||||||
|
|
||||||
|
|
||||||
|
## §22 — The SPLIT CELL: one binary offset, two port members (only one gets written)
|
||||||
|
**(2026-07-31, gitea #86 "destroyed weapons keep firing".)** The binary's weapon fire gates test
|
||||||
|
`subsystem+0x40`. In the 1995 layout that offset is *inside* the embedded status alarm
|
||||||
|
(`statusAlarm@0x2C` + the indicator's level at `+0x14` = `0x40`) — **one cell**, written by
|
||||||
|
`ForceCriticalFailure` when a zone's crit-cascade kills the subsystem. The port models the same
|
||||||
|
address as TWO independent members: `AlarmIndicator statusAlarm` **and** a plain
|
||||||
|
`int simulationState@0x40`. Every destruction path writes the ALARM (so the MFD correctly draws
|
||||||
|
its X, the paper doll correctly greys the mount) while the fire gates read the plain int — which
|
||||||
|
nothing ever writes. Result: a weapon on a blown-off arm shows destroyed on every panel and
|
||||||
|
keeps firing and scoring, on the shooter's screen and on peers' (all three night-7 testers).
|
||||||
|
**Detection smell:** a diagnostic that prints the gate's own inputs and shows a state flag
|
||||||
|
reading 0 while the UI bound to "the same" state shows destroyed. (`[ammo] NoAmmo (gate1):
|
||||||
|
destroyed=0` on a mech with an X'd-out launcher was the tell — it sat in the logs for weeks.)
|
||||||
|
**Rule:** when a binary offset falls inside an embedded object in OUR layout, do not mirror it as
|
||||||
|
a sibling scalar — read it through the object that owns it, or (if a duplicate member already
|
||||||
|
exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed
|
||||||
|
base field): same failure shape — two cells where the binary has one, and the readers pick the
|
||||||
|
dead one.
|
||||||
|
|
||||||
|
## §23 — Scaling `D3DMATERIAL9` does NOTHING for BGF geometry (the vertex-colour diffuse source)
|
||||||
|
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The 1995 armour damage darkens a
|
||||||
|
mech by scaling its MATERIALS' colour terms (`dpl_SetMaterialAmbient/Emissive/Diffuse/Specular`).
|
||||||
|
The obvious port translation — scale the draw op's `D3DMATERIAL9` before `SetMaterial` — renders
|
||||||
|
**byte-identically**: measured **0 changed pixels** at a full 0.1x scale.
|
||||||
|
**Why:** D3D9 defaults `D3DRS_DIFFUSEMATERIALSOURCE` to **`D3DMCS_COLOR1`** (and `D3DRS_COLORVERTEX`
|
||||||
|
to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted.
|
||||||
|
Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is
|
||||||
|
unlit and coloured by vertex/ramp), so for essentially all world geometry `SetMaterial` is inert
|
||||||
|
for colour. The material only matters for lit, normal-bearing meshes.
|
||||||
|
**Fix pattern:** modulate the FINAL fragment instead — `D3DRS_TEXTUREFACTOR` + a
|
||||||
|
`MODULATE(CURRENT, TFACTOR)` on texture **stage 1** (unused in `d3d_OBJECT::Draw`), restored to
|
||||||
|
`D3DTOP_DISABLE` after the op. That darkens the result whatever the diffuse source was, and
|
||||||
|
equally covers the ramp-baked-texture path and pure-emissive batches.
|
||||||
|
**Detection smell:** a colour change that logs perfectly at the source and produces zero visible
|
||||||
|
difference. Diff two runs pixel-wise against a control region before believing a colour path
|
||||||
|
works — "the log says 0.1x" is not evidence that anything reached the screen.
|
||||||
|
|
||||||
|
## §24 — A verified fact, OVER-GENERALISED, becomes a wrong design premise
|
||||||
|
**(2026-08-01, gitea #95 "missiles land 3 points instead of 50".)** The KB carried this, tagged
|
||||||
|
**[T1]**: *"`DamageZone::TakeDamage` is `damageLevel += amount*scale` and IGNORES `burstCount` — so
|
||||||
|
`burstCount` is cosmetic for zone damage."* The first clause is **true and byte-verified**
|
||||||
|
(`@0041e4e0`). The second is an **inference** that was written down beside it, inherited the [T1]
|
||||||
|
tag, and then justified a design decision (task #62's salvo-lead model) that silently divided every
|
||||||
|
missile salvo by its missile count for months.
|
||||||
|
**What was actually true:** `TakeDamage` ignores `burstCount`; the **CALLER**
|
||||||
|
(`Mech::TakeDamageMessageHandler` @0x4a0423-0x4a04d8) honours it by calling `TakeDamage` that many
|
||||||
|
times, re-rolling the zone per burst. The consumer was one stack frame up — the same blind spot
|
||||||
|
shape as §21 (export-gap blindness), but with the evidence *present* and simply not followed
|
||||||
|
outward.
|
||||||
|
**Detection smell:** a "cosmetic"/"unused"/"vestigial" claim about a field that other code still
|
||||||
|
computes carefully. Here the binary randomises `burstCount` at impact (`Random(n) + n/4`) and the
|
||||||
|
splash code derives it from a distance falloff — nobody spends instructions rolling a decorative
|
||||||
|
value. **If a field is called dead, ask who still writes it, and why.**
|
||||||
|
**Rule:** tag the VERIFIED clause, not the paragraph. An inference sitting next to a [T1] fact is
|
||||||
|
still [T4] — split them, or the next reader (including you) will build on the guess.
|
||||||
|
|
||||||
|
## 26. The SILENT-STUB walk: empty iterators make a faithful-looking mechanism a no-op (#110)
|
||||||
|
|
||||||
|
`RecurseSegmentTable` was "reconstructed" against local shim types whose
|
||||||
|
iterators unconditionally returned NULL (`SegmentIterator::Next() { return 0; }`,
|
||||||
|
`SegTableX::operator[] { return 0; }`). Every line of the walk mirrored the
|
||||||
|
binary -- and none of it did anything: the destruction cascade fired, logged,
|
||||||
|
"descended", and touched no other zone. The symptom (gun pods surviving their
|
||||||
|
blown-off arms) was field-reported for weeks while the code READ correct.
|
||||||
|
|
||||||
|
The trap: a stub that returns EMPTY (rather than asserting/crashing) converts a
|
||||||
|
reconstruction into silent fiction, and the no-stand-ins rule cannot catch it by
|
||||||
|
reading the call site -- the stand-in hides in the TYPES. The engine already had
|
||||||
|
the real structures (`EntitySegment::damageZoneTable` / `childIndexTable`, T0,
|
||||||
|
byte-matching the decomp's seg+0xD0/+0xE8) -- the stub was never necessary.
|
||||||
|
|
||||||
|
Rules: (a) when a reconstructed mechanism runs but nothing downstream changes,
|
||||||
|
grep its helper types for `return 0` bodies FIRST; (b) a bring-up shim must
|
||||||
|
either Fail() loudly or log its own emptiness, never silently iterate nothing;
|
||||||
|
(c) before shimming an engine-side structure, check whether the engine already
|
||||||
|
has it -- the offsets in the decomp comment ARE the lookup key.
|
||||||
|
|
||||||
|
## 27. Drawing where the design says DON'T: under-surface buttons, shared planes, and inherited D3D state (#118, 2026-08-03)
|
||||||
|
|
||||||
|
Three MFD-corruption incidents in one evening, all mine, all the same class:
|
||||||
|
adding a DRAW to a compositor whose layout invariants I had not read.
|
||||||
|
|
||||||
|
1. **The eject-wipe gauge** (BitMapInverseWipeScalar) drew into a weapon's ENG
|
||||||
|
graphics port -- but the Eng1-3 planes SHARE composite dest cells with the
|
||||||
|
weapon pages (`ckSlotOf` in L4VB16), so its mis-geometry scribbled striped
|
||||||
|
garbage OVER the weapon MFDs whenever an eject cycle redrew it (= at
|
||||||
|
overheat-jam onset, which the operator correctly correlated with "when the
|
||||||
|
coolant warning starts").
|
||||||
|
2. **The "flash overlay"** re-drew flashing buttons' full faces ON TOP of the
|
||||||
|
surfaces. But the side buttons are BIG rects deliberately tucked UNDER the
|
||||||
|
displays -- the protruding edge IS the lamp (the pod's backlit keys sat
|
||||||
|
beside the CRT; there is no in-display face). A full-face redraw is wrong
|
||||||
|
BY DESIGN: it covers gauge imagery with button faces.
|
||||||
|
3. **The same overlay, drawn after `DrawDevSurface`, inherited its bound
|
||||||
|
texture** -- an un-reset `CkFill` renders a TEXTURED quad sampling the
|
||||||
|
gauge atlas: green diagonal-striped rectangles. (With the state reset the
|
||||||
|
bug became honest: solid flashing faces covering the MFD -- which is how
|
||||||
|
the operator's "the panel buttons took over the MFD" report finally
|
||||||
|
identified the author.)
|
||||||
|
|
||||||
|
Rules: (a) before ADDING a draw to an existing render pass, read the pass's
|
||||||
|
layering comments -- "UNDER the surfaces", "masks the part inside", "shares
|
||||||
|
its sibling's dest cell" are INVARIANTS, not descriptions; (b) any D3D draw
|
||||||
|
inserted after another subsystem's draws must re-assert its own texture/color
|
||||||
|
state (CkColorState-equivalent) -- the previous draw's bindings are live;
|
||||||
|
(c) when a visual corruption tracks an EVENT ("starts with the coolant
|
||||||
|
warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
|
||||||
|
state-change repaints -- before suspecting the event's logic; (d) the
|
||||||
|
operator's screenshot is worth ten theories: the red-faces capture identified
|
||||||
|
in one frame what three log-side hypotheses missed.
|
||||||
|
|||||||
@@ -58,4 +58,5 @@ the ground-model decode (10 agents), the alarm-unification (8), the gauge-widget
|
|||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Bug classes: [[reconstruction-gotchas]] (check FIRST). Reference data: [[decomp-reference]].
|
- 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]].
|
- Why it's needed: [[source-completeness]].
|
||||||
|
|||||||
+108
-4
@@ -220,8 +220,13 @@ i860 firmware** `content/VREND.MNG` (same decode workflow as the task-#55 'damag
|
|||||||
- Remaining [T3]: sfx cone shape (v_bias as up-bias), size ×0.5 + 1.25m textured floor +
|
- 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
|
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
|
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
|
(all shipped files author 1); replicant visual salvo FIXED (2026-08-02, #84): the
|
||||||
(no re-lead without a target handle).
|
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)
|
## Weapon beams (the unported dpl_* layer)
|
||||||
The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`/`BTDrawBeams`
|
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
|
TakeDamageMessageHandler` → zone `damageLevel ≥ 1` → **Explosion entity** (Crunch variant for
|
||||||
collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes
|
collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes
|
||||||
DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch
|
DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch
|
||||||
(`mech4.cpp ProcessCollision` → `BTDispatchCollisionDamage`, ×0.001 ram economy) reaches icons
|
(`mech4.cpp ProcessCollision` → `BTDispatchCollisionDamage`, RAW since the 2026-07-31 pricing
|
||||||
(≈19 walking-speed bumps kill the spawn-yard truck). What was BROKEN (the "no state change"
|
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
|
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
|
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
|
controls the draw component via the **`SetDrawObj` in-place drawable swap** (the mech
|
||||||
@@ -319,6 +326,103 @@ Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object
|
|||||||
flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested
|
flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested
|
||||||
cross-pod).
|
cross-pod).
|
||||||
|
|
||||||
|
## Mech ARMOUR DARKENING (the .DZM material-damage system) — RECONSTRUCTED 2026-07-31 (Gitea #87) [T2]
|
||||||
|
A mech's armour panels **darken as their damage zone takes damage** — a per-zone, per-material
|
||||||
|
colour ramp-down, NOT a texture swap, a filter, or geometry. Players reported the port showing only
|
||||||
|
the limb swap ("the actual enemy mech in external view is not showing darkened armor panels").
|
||||||
|
|
||||||
|
**The 1995 chain** [T1]:
|
||||||
|
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
|
||||||
|
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
|
||||||
|
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
|
||||||
|
`destroyed`…`destroyedd` video variants); the res-compiler address is UNKNOWN (the old
|
||||||
|
`FUN_0041e4e0` citation was WRONG — that is `DamageZone::TakeDamage`; corrected 2026-08-02,
|
||||||
|
swept with [[decomp-reference]]).
|
||||||
|
- Compiled into BTL4.RES and parsed by **MUNGA's own `DamageZone` stream ctor** into
|
||||||
|
`materialTable`, keyed by `EntitySegment::SkeletonType` (`engine/MUNGA/DAMAGE.cpp:311-336`,
|
||||||
|
`GetMaterialList(skl_type)`) — real engine source [T0]. **This data was loaded and unused in the
|
||||||
|
port all along**; only the consumer was missing.
|
||||||
|
- **`BTL4VideoRenderer::MakeMechRenderables` (`FUN_004cef28`)** walks the zones and builds, per
|
||||||
|
(zone, material), a 232-byte watcher **`FUN_004573e4(material, &zone->damageLevel, 0.1f)`**
|
||||||
|
(a 60-byte per-zone parent first, factor 0.04). Error string: *"BTL4VideoRenderer: Material name
|
||||||
|
<X> could not be found"* @0x51d6f8.
|
||||||
|
- The watcher snapshots the material's **16 authored colour floats** — Ambient(3), Emissive(3),
|
||||||
|
Diffuse(3), Specular+shininess(4), Opacity(3) — precomputes a DAMAGED set = **pristine x 0.1**
|
||||||
|
(`0x3dcccccd`), and **`FUN_00457784`** re-pushes `lerp(pristine, damaged, damageLevel)` whenever
|
||||||
|
the level changes (constant `@0x4579a4` = **1.0**). So a zone at full damage renders at **10%**
|
||||||
|
of authored brightness, continuously interpolated.
|
||||||
|
- **Faithful quirk:** Opacity is read and lerped but **never written back** — only 13 of the 16
|
||||||
|
values are pushed (`dpl_GetMaterialOpacity` has no matching Set call in the watcher).
|
||||||
|
|
||||||
|
**NOT the mechanism** [T2]: the engine also has `SV_SPECIAL DAMAGE <material>` /
|
||||||
|
**`dpl_Damagize` `FUN_004902b0`** (punch's sibling — same VPX cmd 0x20, tokens
|
||||||
|
`f_t_dam(0x92)/sca(0x05)/undamage(0x04)`, dispatch @0x4599ae; error *"SPECIAL GEOGROUP DAMAGE
|
||||||
|
couldn't find material"*). A scan of **all 879 shipped BGFs finds ZERO `DAMAGE` tokens** — the path
|
||||||
|
exists in the engine but no shipped asset uses it. Don't chase it.
|
||||||
|
|
||||||
|
**The port** (`game/reconstructed/btl4vid.cpp` `BindArmourDamage`/`TickArmourDamage`,
|
||||||
|
`engine/MUNGA_L4/L4D3D.cpp`): batches now carry their AUTHORED (pre-substitution) material name
|
||||||
|
(`BgfDrawBatch::matName` -> `L4DRAWOP::dzMatName`); binding resolves the .DZM lists onto real draw
|
||||||
|
ops once per tree build, and the per-frame tick (driven from `Mech::PerformAndWatch`, EVERY mech)
|
||||||
|
copies `zone->damageLevel` onto them. Two hard-won details:
|
||||||
|
- **Key the material list on `viewSkeleton`, not the build `skeletonType`.** Each skeleton variant
|
||||||
|
is painted from its own skin library (Black Hawk N = `blhskin:`, X = `blxskin:`) and
|
||||||
|
`ApplyViewSkeleton` reloads every segment mesh; keying on the build skeleton matched a list
|
||||||
|
belonging to geometry that is not on screen -> **zero bindings, no darkening**.
|
||||||
|
- **Scaling the `D3DMATERIAL9` is inert** — see gotcha **§23** (`DIFFUSEMATERIALSOURCE` defaults to
|
||||||
|
vertex colour). The darkening is applied as a `TEXTUREFACTOR` modulate on texture stage 1.
|
||||||
|
Rebinding runs after every geometry swap (`RemakeEntityRenderables`, `ApplyViewSkeleton`), which
|
||||||
|
also keeps `segPick` (the #73 aimed pick) off discarded meshes.
|
||||||
|
Bench: `scratchpad/night7/armor_darken.sh` (self/enemy/ext legs) + `armor_ab.sh` (forced-level
|
||||||
|
pixel A/B). Gates `BT_ARMOR_LOG` / `BT_ARMOR_FORCE=<0..1>`. Verified: 57 bindings on the enemy
|
||||||
|
mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control 0.98x at level 1.
|
||||||
|
The `damcolor_`/`gen*gry_` arm+gun names go unmatched until the DESTROYED limb variant loads —
|
||||||
|
correct: those materials live on the `*D*.BGF` damaged meshes.
|
||||||
|
|
||||||
|
## SEARCHLIGHT -- the pod's night kit, RECONSTRUCTED 2026-08-05 [T1 decode, T2 both benches]
|
||||||
|
The subsystem (searchlight.cpp: sim @4b841c, ToggleLamp @4b838c toggles requestedOn@0x1E0 --
|
||||||
|
NO novice gate, that's ThermalSight's; lightState = requested && powered(4) && heat<2 && alive,
|
||||||
|
published as the "LightOn" attribute + replicated as subsystem record 0x14) was already complete;
|
||||||
|
the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8` [T1]:
|
||||||
|
- **Cockpit = a FOG SWAP, not a light.** The @00456778/@00456814 watcher polls up to two LightOn
|
||||||
|
attrs; either-on -> `SetFogStyle(searchLightOnFogStyle)` (the authored `fog=` set), off ->
|
||||||
|
`searchLightOffFogStyle` (the `nosearchlightfog=` set). Both sets are authored per
|
||||||
|
map/time/weather page in BTDPL.INI (arena_night 40/400 lit vs 5/400 dark; des_night 100/1100
|
||||||
|
vs 30/700; arnitclear 90/1100 vs 5/1000). The 2007 engine kept the whole system under its real
|
||||||
|
names (searchLightFog*/noSearchLightFog*, the FogStyle enum); the port completed the stubbed
|
||||||
|
plane application (currentFogNear/Far now swap with the style) and transcribed the watcher into
|
||||||
|
`TickSearchlight` -- including the ctor's INVERTED cache seed, whose first-tick "change"
|
||||||
|
normalizes a night start onto the DARK set. Fidelity consequence: every prior build rendered
|
||||||
|
the lit fog permanently (nothing called SetFogStyle(3)); authentic night starts darker.
|
||||||
|
Glass key: F5 -> streamed button 0x14.
|
||||||
|
- **PERCEIVED STRENGTH [T2 field eyeball 2026-08-05]: authentically SUBTLE.** With LINEAR fog the
|
||||||
|
near-plane move barely changes close-range haze (at 30u the arena pair differs ~5%); the swap
|
||||||
|
reads as a modest push-back of the night murk at mid/long range, NOT a flashlight. Geometry
|
||||||
|
brightness comes from the page's ambient= + ramps and never changes with the lamp -- a close
|
||||||
|
wall is identical lit/unlit. First eyeball matched exactly ("kinda sorta illuminates the
|
||||||
|
area... didn't illuminate the wall" -- that IS the 1995 behavior). No illuminating dynamic
|
||||||
|
light exists anywhere in BT's render path: ChildLightRenderable has ZERO instantiations
|
||||||
|
engine-wide, and the FUN_0048d060 cluster (@4568f8/@456a0c/@456b4c/@4586d0) is light-POINT
|
||||||
|
sprites (beacons/nav strobes, palette-tinted), not geometry lights. Set player expectations
|
||||||
|
in the release notes accordingly.
|
||||||
|
- **External = the spot.bgf beam-cone mesh** (VIDEO/GEO + per-time variants), hung as a child of
|
||||||
|
the searchlight SITE joint (mount = subsystem `mountSegment`@0x1DC == resource segmentIndex;
|
||||||
|
madcat 20, loki 21), shown/hidden per frame from the replicated LightOn (@0045612c watcher ->
|
||||||
|
the SetDrawObj swap). Site segments now build geometry-less DCS children (posed + parentable --
|
||||||
|
the 1995 graph gave every site a DCS node; our tree used to skip sites entirely; this also
|
||||||
|
enables future site-hung effects). Wrecks go dark through the authentic gate (host shutdown
|
||||||
|
forces lightState 0). Cross-node verified: B presses, A logs `[spot] cone SHOWN (seg 20)`.
|
||||||
|
- Benches: `scratchpad/night12/searchfog.sh` (solo cockpit chain + red-fog end-to-end probe),
|
||||||
|
`spotcone.sh` (2-node cone + replication). BEAM MATERIAL decoded 2026-08-06 (b75bb4a) [T1]:
|
||||||
|
SPOT.BGF is a 7-vert cone ~50u forward and ~35° DOWN (a ground-pool lamp, not an air beam),
|
||||||
|
verts tinted cyan-white; its material class `btfx:brighten.25` smuggles the ADDITIVE factor
|
||||||
|
in DIFFUSE.r (0.25) with a warm emissive on the night page — the loader draws brighten*
|
||||||
|
batches as an additive veil (dest += vertexRGB × factor, blend pass, unlit); tint compose
|
||||||
|
(vertex cyan vs night emissive warm) [T3], flagged in the L4D3D draw branch. Look pass
|
||||||
|
ACCEPTED by field eyeball 2026-08-06 (translucent beam reads correctly; solo rig
|
||||||
|
BT_SPOT_SELF=1 + BT_CAM=face). Still open ([[open-questions]]): the stubbed LoadObject
|
||||||
|
wrapper + dormant marker case, the subtle authored deltas.
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
|
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
|
||||||
- Renders on: [[pod-hardware]] (main 3D view).
|
- Renders on: [[pod-hardware]] (main 3D view).
|
||||||
|
|||||||
@@ -94,6 +94,32 @@ its recorded line 400 exactly. [T1/T3 per the sidecar]
|
|||||||
Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
|
Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
|
||||||
behavior-equivalent **reconstruction**, NOT original source. [T2]
|
behavior-equivalent **reconstruction**, NOT original source. [T2]
|
||||||
|
|
||||||
|
## THE GAP CENSUS + RE-EXPORT (2026-08-06, #60) — BOTH HALVES DONE [T1]
|
||||||
|
**The export was REBUILT 2026-08-06** (Ghidra 12.1.2 headless + `ExportGaps.java`'s gap-fill
|
||||||
|
pass): index coverage **87.3% → 93.5%**, dark real code **90.4 KB → 40.8 KB (54.8% recovered)**,
|
||||||
|
game-side dark 53.1 → 21.1 KB, functions 6,267 → **6,472** (+205, zero decompile failures).
|
||||||
|
Every historically-bitten dark function now has pseudocode — including `@0x4c05c4` VehicleDead,
|
||||||
|
the absence that opened #60. The re-export CONFIRMED the crouch reconstruction field-for-field
|
||||||
|
and revealed one branch the raw pass missed (airborne auto-rise, fixed same day).
|
||||||
|
⚠ **The old export is archived at `reference/decomp/archive_2025export/`** — addresses are
|
||||||
|
stable across both, but `part_0NN.c:LINE` citations only resolve against the archive. Cite
|
||||||
|
`@ADDR`, not part/line. Pipeline: `tools/ghidra_reexport.sh` → `tools/gapcensus.py` →
|
||||||
|
`tools/gapdiff.py`. Toolchain (JDK 21 + Ghidra 12.1.2) lives in `%LOCALAPPDATA%\bt411-tools`;
|
||||||
|
the runner MUST use 8.3 short paths (Ghidra's .bat expands %JAVA_HOME% unquoted and the user
|
||||||
|
profile has a space). Full log: `phases/phase-04-gap-census.md`.
|
||||||
|
|
||||||
|
### The original inventory (part 1, same day) [T1]
|
||||||
|
`tools/gapcensus.py` → `reference/decomp/GAP_CENSUS.md` + `gap_census.tsv` (regenerate after
|
||||||
|
any re-export; it censuses whatever dir you point it at). As found BEFORE the re-export: .text
|
||||||
|
892 KB, index 87.3%, **428 dark regions = 90 KB of real code** (indexed-but-unexported = 0 —
|
||||||
|
the gap class is purely "not indexed"), game-side dark ≈ 54 KB with 159 regions never touched.
|
||||||
|
Current numbers are in the header above. Validation: all six historically-bitten dark
|
||||||
|
addresses (VehicleDead, ToggleLamp, death tail, master-perf, myomer integrator, duck consumer)
|
||||||
|
land inside census regions. Top uncharted leads + method: `phases/phase-04-gap-census.md`.
|
||||||
|
**RULE (gotcha §20):** consult the census BEFORE any "absent from the export/binary" claim —
|
||||||
|
if the address falls in a dark region, the claim needs byte-scan evidence, not export silence.
|
||||||
|
(The second half — the re-export — landed the same day; see the header.)
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]].
|
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]].
|
||||||
- Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
|
- Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
|
||||||
|
|||||||
+102
-13
@@ -41,6 +41,9 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
|||||||
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
|
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
|
||||||
conducts to the central sink via the linked-sink roster).
|
conducts to the central sink via the linked-sink roster).
|
||||||
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
|
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
|
||||||
|
- ✅ **Searchlight VISUALS complete 2026-08-05** — cockpit fog swap + external spot.bgf beam
|
||||||
|
cone, both MP-replicated; mountSegment@0x1DC identified (= resource segmentIndex, the cone's
|
||||||
|
mount joint — was "commandedOn, role unidentified"). Full story: [[rendering]] §SEARCHLIGHT.
|
||||||
- ✅ **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
|
- ✅ **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
|
||||||
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
|
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
|
||||||
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
|
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
|
||||||
@@ -66,7 +69,69 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
|||||||
change needs its own verification pass).
|
change needs its own verification pass).
|
||||||
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
|
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
|
||||||
faithfully no visible twist).
|
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**
|
⚠ **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
|
[T1, fixed 2026-07-28]. The ctor @004b8fec stores `[0x511620]` into activePerformance, and that
|
||||||
pointer resolves to `0x4b8b9c`, whose first instruction is `call 0x4b0bd0` =
|
pointer resolves to `0x4b8b9c`, whose first instruction is `call 0x4b0bd0` =
|
||||||
@@ -92,18 +157,16 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
|||||||
thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**.
|
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
|
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.
|
`elec=4` across two death/respawn cycles.
|
||||||
⚠ **`@004b8bb9` is DEAD CODE — in the original too** [T0]. It builds a `Damage`
|
✅ **CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in 1995 and
|
||||||
(type=`Explosive`, amount=`0xbc343958`≈-0.011f, impactPoint=`owner+0x100`, burst=1) and calls the
|
now in the port.** The 2026-07-28 "dead code in the original too" verdict here was wrong about the
|
||||||
**zone's** `TakeDamage` (zone vtable `+0x18`, *not* the subsystem's `+0x24`). Reading it as
|
original: the frozen-at-0.6 experiment was measuring a PORT bug (the subsystem ctor wrote the
|
||||||
"an un-powered myomer heals" is the evident intent, but it can never fire: a subsystem's private zone
|
zone's armour/scales through the `ReconDamageZone` proxy at struct offsets +4/+8 instead of the
|
||||||
comes from the 2-arg `new DamageZone(this,0)`, and `engine/MUNGA/DAMAGE.cpp:187-190` zeroes **all five**
|
engine members at +0x140/+0x144, so the real `damageScale[]` stayed zero). The binary ctor
|
||||||
`damageScale[]` entries; `Reset()` never touches them and the only other writer is `Mech__DamageZone`
|
(`@0x4ac7bb`) initializes them from the resource keys `WeaponDamagePoints` + the five per-type
|
||||||
(the mech's *streamed* zones — `mechdmg.cpp:246-253`). Since `TakeDamage` is
|
`...DamagePoints`; the port now does the same through the engine's named members, and the CSS
|
||||||
`damageLevel += amount * damageScale[type]`, the sum is always `+= amount * 0`. Confirmed by
|
parses the keys. An un-powered, not-yet-destroyed myomer heals at `0.011 × damageScale[Explosive]`
|
||||||
experiment: seeded to 0.6 and held at NoVoltage ~1500 ticks, `damageLevel` never moved.
|
per tick. The retracted "cannot damage a subsystem's own zone" rule is superseded — see
|
||||||
Reconstructed and deliberately NOT "fixed" — inventing a working repair would be made-up behavior.
|
[[combat-damage]] §CORRECTED for the full mechanism (this also revived the crit sink, #80).
|
||||||
**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`).
|
|
||||||
Also un-stubbed here: `Myomers::DamageStructureLevel()` returned a hardcoded `0.0f`, which pinned
|
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
|
`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`.
|
one. Now routed to the base bridge `GetSubsystemDamageLevel()`; measured `dmg=0.6 → speed=0.4`.
|
||||||
@@ -147,6 +210,32 @@ its electrical state. The full chain, byte-verified [T1]:
|
|||||||
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
|
- **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]].
|
`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 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"**
|
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
|
on the Reservoir (handler table @**0x50e680**, one entry → @4aee70 — same per-receiver id space as the
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
id: test-harness
|
||||||
|
title: "The Test Harness — how benches run, and what counts as verified"
|
||||||
|
status: living
|
||||||
|
source_sections: "scratchpad/night6/bench_common.sh (the contract); build-and-run.md §bench-parity; nights 6-9 bench scripts; the #86/#110 verification failure"
|
||||||
|
related_topics: [build-and-run, reconstruction-method, multiplayer, reconstruction-gotchas, experience-levels]
|
||||||
|
key_terms: [bench, harness, field-composition, scalpel]
|
||||||
|
open_questions: []
|
||||||
|
---
|
||||||
|
# The Test Harness — how benches run, and what counts as verified
|
||||||
|
|
||||||
|
Two subjects, deliberately in one file: the MACHINERY (how to launch and read a
|
||||||
|
bench) and the DOCTRINE (what a bench must show before a fix may be called
|
||||||
|
fixed). The doctrine exists because the machinery was used wrong more than once.
|
||||||
|
|
||||||
|
## THE DOCTRINE: verify the FIELD COMPOSITION, not a constructed proxy
|
||||||
|
|
||||||
|
**The archetype failure (#86 → #110).** "Weapons on damaged arms keep firing"
|
||||||
|
was fixed and verified by destroying the WEAPON directly (`BT_KILL_SUBSYS`).
|
||||||
|
The field scenario was the ARM ZONE dying while the weapon stayed healthy — a
|
||||||
|
path the bench never took. The claim "fixed" stood for two playtests while the
|
||||||
|
players kept hitting the bug; the actual defect (a stubbed cascade walk,
|
||||||
|
gotcha #26) sat exactly in the gap between the proxy and the field. **A fix
|
||||||
|
verified only against a reproduction you constructed is not verified.**
|
||||||
|
|
||||||
|
Rules, in order of application:
|
||||||
|
|
||||||
|
1. **Reproduce the reported scenario first** — the player's composition, not a
|
||||||
|
convenient adjacent one. If the report is "peer's arm shot off in MP", the
|
||||||
|
proof is a two-node run where PEER FIRE kills the arm (`mp_armgrind.sh`),
|
||||||
|
not a solo self-damage run.
|
||||||
|
2. **Scalpels are for isolation, not for verdicts.** Deterministic hooks
|
||||||
|
(`BT_SELF_DAMAGE_ZONE`, `BT_KILL_SUBSYS`, `BT_FORCE_SEEK`, `BT_ARMOR_FORCE`)
|
||||||
|
are the right way to LOCATE a defect. Before a scalpel result supports a
|
||||||
|
"fixed" claim, prove PATH IDENTITY to the field composition (e.g. the
|
||||||
|
self-damage harness dispatches a real `Entity::TakeDamageMessage` — the
|
||||||
|
same message + handler a peer round takes — so the scalpel shares every
|
||||||
|
line from the handler down; verified 2026-08-02, and then the field
|
||||||
|
composition was STILL run).
|
||||||
|
3. **If the symptom is multiplayer, the proof is two nodes.** Master-side
|
||||||
|
correctness says nothing about what a peer sees; replication is its own
|
||||||
|
layer (records, edge-detects, instance gates). #94, #84 and #110 each had a
|
||||||
|
peer-side half invisible to any solo bench.
|
||||||
|
4. **If the symptom is visual, the proof is pixels** — `BT_SHOT_EVERY` frames
|
||||||
|
diffed against a control region, not a log line saying the value changed
|
||||||
|
(gotcha #23: a colour change that logs perfectly and renders nothing).
|
||||||
|
5. **Coverage claims need a sweep, not an instance.** "Works for every weapon
|
||||||
|
type / chassis" = enumerate the axis and measure each point (all 3 fire
|
||||||
|
gates through a zone death; all 8 chassis' authored cascade flags —
|
||||||
|
`zonesweep.sh`). Per-chassis behaviour usually lives in AUTHORED DATA, so
|
||||||
|
fixed code on one chassis proves nothing about the others.
|
||||||
|
6. **Chase every anomaly in a passing run to ground.** The Owens rack cascade
|
||||||
|
also killing the sensor suite looked like a walk bug; it was authored
|
||||||
|
skeleton data (the mast is a child of the rack). A pass with an
|
||||||
|
unexplained extra effect is not yet a pass.
|
||||||
|
|
||||||
|
## THE MACHINERY
|
||||||
|
|
||||||
|
### The contract: `scratchpad/night6/bench_common.sh`
|
||||||
|
The single source of truth for launching a node THE WAY A PLAYER'S SHORTCUT
|
||||||
|
DOES — read the file, its comments carry the incident history. Summary:
|
||||||
|
- `bt_player_env` — `BT_PLATFORM=glass`, `BT_START_INSIDE=1` (cockpit, not
|
||||||
|
chase cam), `BT_DEV_GAUGES=1`. Copied verbatim from the shipped .bats;
|
||||||
|
`bt_assert_player_env` warns on drift.
|
||||||
|
- `bt_launch <log> <egg> <affinity> [args]` — one node, env-scoped, PID
|
||||||
|
tracked in `/tmp/bt_bench_pids.$$`.
|
||||||
|
- `bt_expert_egg` — shipped eggs are ALL `experience=expert`; a novice egg
|
||||||
|
silences heat/crits/jams and is only legal for the gimp bench (which must
|
||||||
|
say so out loud). See [[experience-levels]].
|
||||||
|
- `bt_kill_ours` — teardown kills ONLY this run's PIDs (a blanket
|
||||||
|
`taskkill /IM` once shot down two live sessions).
|
||||||
|
|
||||||
|
### Single-node bench skeleton
|
||||||
|
```bash
|
||||||
|
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||||
|
cd /c/git/bt411/content || exit 1
|
||||||
|
taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2 # stale-node clear
|
||||||
|
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=$V/" MP.EGG > X.EGG
|
||||||
|
export BT_<gates>=... # diagnostics + scalpels
|
||||||
|
bt_launch x.log X.EGG 0x03
|
||||||
|
sleep <duration>; taskkill //F //IM btl4.exe; sleep 2 # then grep x.log
|
||||||
|
```
|
||||||
|
The vehicle sed must replace the WHOLE line (`s/^vehicle=.*/`), or you mint
|
||||||
|
`vehicle=thr1bhk1` and the spawn FATALs. Chassis codes: ava1 bhk1 lok1/2 mad1/2
|
||||||
|
own1 snd1 thr1 vul1 (own1 has NO arm zones — rack zones instead).
|
||||||
|
|
||||||
|
**Weapon/combat benches need `map=grass time=day`.** `MP.EGG` authors
|
||||||
|
`map=cavern time=night`; a bench that copies it without the map sed (e.g. via
|
||||||
|
`bt_expert_egg` alone — mp_double.sh has this gap) parks the GOTO-driven mechs
|
||||||
|
against cavern rock and they shoot terrain for the whole window. Always sed the
|
||||||
|
map/time on the COPY, single-node and two-node alike.
|
||||||
|
|
||||||
|
### THE ZONE-WALK MATRIX (`scratchpad/night10/zonewalk.sh`) — systematic
|
||||||
|
### per-panel hit-location testing, operator-watchable (built for #124)
|
||||||
|
The precision-targeting rig: **two nodes**. Node B (launched first, back
|
||||||
|
window) is the TARGET — `BT_SPIN_SELF=15` rotates its own viewpoint mech in
|
||||||
|
place (deg/s; the BT_SPAWN_AT write pattern, applied per frame) so every
|
||||||
|
aspect passes the shooter's boresight; its window shows its own paper doll
|
||||||
|
taking the hits (`BT_DMG_LOG=1` prints every `[dmghit]` with zone + level).
|
||||||
|
Node A (foreground — it owns the pick focus) runs `BT_ZONE_WALK=<secs/zone>`:
|
||||||
|
walks the target's damage zones in index order, resolves each zone's CARRIER
|
||||||
|
SEGMENT's live world position (`BTResolveSegmentWorld` — the same resolver the
|
||||||
|
attached smoke rides; zone→segment via the `BTMechZoneSegAndName` bridge),
|
||||||
|
SERVOS the torso twist + aim elevation until the centered reticle's pick ray
|
||||||
|
(`BTGetAimRay(0,0)`) points at the segment, then pulses the laser trigger 3×
|
||||||
|
and advances. `[walk] ZONE/FIRE/HOLD` lines on A pair with `[dmghit]` on B.
|
||||||
|
Launch: `bash scratchpad/night10/zonewalk.sh` (relay included; NO kill timer —
|
||||||
|
the session stays up for observation; teardown = taskkill btl4).
|
||||||
|
Node A also carries `BT_ARMOR_LOG=1` + `BT_SHOT_EVERY=300 BT_SHOT_PREFIX=zwA`
|
||||||
|
(added for #87): the armour watcher's `[armor] ... level P -> Q` lines on A are
|
||||||
|
the OBSERVER-side receipt that B's zone levels replicated (the walk is the
|
||||||
|
standing zone-replication bench — A-side peaks must match B's `[dmghit]`
|
||||||
|
finals), and the numbered captures are the pixel record of the enemy hull
|
||||||
|
darkening. Digest: `python scratchpad/night10/digest_walk.py` (per-zone hits,
|
||||||
|
amt/hit, level first→last on B; replicant peaks seen by A; zone-repl lines).
|
||||||
|
Companion scalpels: `BT_ASPECT_TEST=1` (the zero-premise frame probe — 4
|
||||||
|
world-cardinal self-impacts + one at each weapon's physical MUZZLE, the
|
||||||
|
left/right anchors that caught the #124 reflection-vs-rotation error);
|
||||||
|
`BT_AIM_SWEEP=<period>` (blind elevation sweep — superseded by the walker).
|
||||||
|
Hard-won constraints baked into the walker (do not relearn these):
|
||||||
|
- **Engage gate**: servo only when the aim ray is live AND range < 150 AND
|
||||||
|
|bearing| < 1.1 rad; otherwise RELAX the twist toward 0 and hold the zone
|
||||||
|
clock. Without it the servo winds the twist to its limit while the goto
|
||||||
|
marches (limit-clamp fight = the mech visibly SHAKES) — and the gate must
|
||||||
|
sit well above the goto's resting bearing error (~0.55 rad at STOP=90) or
|
||||||
|
it deadlocks in HOLD.
|
||||||
|
- **Yaw polarity**: the twist cell's angular sense is OPPOSITE atan2(x,−z)
|
||||||
|
world yaw (operator-observed live: `+=` turned the torso AWAY — the second
|
||||||
|
witness for the #124 SelectSlice twist-sign flip). The servo starts at −1
|
||||||
|
and carries a growth WATCHDOG that self-flips if the error diverges.
|
||||||
|
- **Damped servo**: gain 0.40, step cap 0.025 rad/frame, deadband — the aim
|
||||||
|
ray lags the twist write by a frame; full-error correction oscillates.
|
||||||
|
- **Approach port**: dropzones spawn cross-map; the walker one-shot teleports
|
||||||
|
A to 100 u off the target (BT_SPAWN_AT pattern incl. the +500 y ground-snap
|
||||||
|
lift) instead of minutes of marching.
|
||||||
|
|
||||||
|
### Two-node bench skeleton (the MP pattern)
|
||||||
|
```bash
|
||||||
|
bt_assert_player_env
|
||||||
|
bt_expert_egg MP.EGG X.EGG
|
||||||
|
BT_MP_LOG=1 <gates> bt_launch x_b.log X.EGG 0x0C -net 1601 # node B first
|
||||||
|
sleep 2
|
||||||
|
BT_MP_LOG=1 <gates> bt_launch x_a.log X.EGG 0x03 -net 1501 # node A
|
||||||
|
sleep 5
|
||||||
|
python ../tools/btconsole.py X.EGG 127.0.0.1:1501 127.0.0.1:1601 & # the relay STARTS the mission
|
||||||
|
sleep <duration>; kill $relay; bt_kill_ours
|
||||||
|
```
|
||||||
|
- A `-net` node renders NOTHING until `btconsole.py` (the headless relay)
|
||||||
|
starts the mission — a black window is pre-mission, not a hang.
|
||||||
|
- Affinity: TWO logical processors per node, disjoint sets (`0x03`/`0x0C`);
|
||||||
|
one LP starves the gauge executive ([[multiplayer]] peer-shakiness fix).
|
||||||
|
- Give the SHOOTER dense fire (`BT_AF_PERIOD=3` + `BT_AF_MISSILE=1`) and the
|
||||||
|
victim sparse (`=9`) when one side must lose a grind. Unthrottled autofire
|
||||||
|
trips the FailureHeat all-weapons brick and combat dies.
|
||||||
|
- Combat drive: `BT_GOTO=enemy BT_GOTO_STOP=<units>` — 100 ends in a visual
|
||||||
|
ram scrum; 180 gives a standing exchange.
|
||||||
|
- Read each claim on the node that can see it: damage on the VICTIM's log,
|
||||||
|
replication on the OBSERVER's (`[zone-repl]`, `[mlrec]`, mirrored `DET`s).
|
||||||
|
|
||||||
|
### Process hygiene (every one of these cost a session)
|
||||||
|
- **Stale-node taskkill FIRST.** A leftover node joins the next lobby as a
|
||||||
|
third instance and poisons the run.
|
||||||
|
- **Never double-background.** `run_in_background` + an inner `&` orphans the
|
||||||
|
script mid-startup: nodes launch, the script's own cleanup never runs.
|
||||||
|
- **Teardown kill order closes windows one at a time** — a "crashed" window at
|
||||||
|
the end of a timed run is usually the bench's own taskkill.
|
||||||
|
- **Stale exe:** `LNK1104` / a bench ignoring new diagnostics = the previous
|
||||||
|
process still holds `btl4.exe`, or the build silently didn't run — check the
|
||||||
|
`btl4.vcxproj ->` line printed, then rerun.
|
||||||
|
- **Editing source through bash-heredoc python collapses `\` one level** even
|
||||||
|
single-quoted -- a `"\n"` you meant as backslash-n arrives as a real newline,
|
||||||
|
the "fix" silently no-ops (`good == bad`), and the C2001 hunt repeats. Build
|
||||||
|
backslashes as `bytes([92])`/`chr(92)`, and verify a replace by LENGTH DELTA
|
||||||
|
or a re-read, never by the script printing "fixed".
|
||||||
|
- Bench artifacts (`*.EGG` copies, `*_NNN.png`, bench logs) must be deleted
|
||||||
|
from `content/` before any dist cut; field logs are never committed.
|
||||||
|
|
||||||
|
### Reading logs without fooling yourself
|
||||||
|
- **A capped/throttled diagnostic that is silent is NOT evidence of absence**
|
||||||
|
— the #99 `[seqrun]` shared counter "proved" a sequence never ran; it was
|
||||||
|
throttle starvation. Per-object throttles for per-object questions.
|
||||||
|
- **Alarm-line counters are not trend lines** — `free=0` printed AT the
|
||||||
|
failure is true by definition; the 30s census carries the trend (#32, three
|
||||||
|
separate times).
|
||||||
|
- **Log the actor's NAME at every refusal/decision** (`'PPC' fire REFUSED`);
|
||||||
|
anonymous counters (`FIRED #20`) cannot support attribution claims.
|
||||||
|
- Field logs have NO diagnostic gates set — a fix whose acceptance evidence
|
||||||
|
sits behind an env var is unverifiable in the field. Spawn-time summaries
|
||||||
|
(one line, ungated) answer questions retroactively; per-frame traces stay
|
||||||
|
gated.
|
||||||
|
|
||||||
|
## Bench-script gotchas (each has burned a session)
|
||||||
|
- **Write benches CLEAN, never sed-derive a chain** — sed-derived copies silently dropped envs
|
||||||
|
twice (the [replgimp] silence); export envs INSIDE the per-node subshell.
|
||||||
|
- **Absolute paths everywhere; `cd /c/git/bt411` first** — the cwd trap (heredoc python + `cd
|
||||||
|
scratchpad/nightN`) has broken later builds/appends with `fatal: pathspec`/`cannot stat`.
|
||||||
|
- **No apostrophes/backticks through bash heredocs** — write a `.py` file and run it (quoting has
|
||||||
|
mangled posted tracker comments and killed scripts mid-parse).
|
||||||
|
- **Drain stale watchdogs before relaunching** — a leftover waiter's `taskkill` has killed a live
|
||||||
|
bench mid-run.
|
||||||
|
- **Scripted input goes through the REAL seam**: `BT_BTNTEST`/`BT_BTNTEST2` (RIO queue → mode-mask
|
||||||
|
drain). In 2-node runs press at poll ≥900 — round-start jitter can eat earlier presses.
|
||||||
|
- **Capture the viewpoint that can see the change** (gotcha #13 bullet): cockpit view can't show
|
||||||
|
your own legs; pair screenshots with a state probe and READ a frame before concluding.
|
||||||
|
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
|
||||||
|
|
||||||
|
## Key Relationships
|
||||||
|
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
|
||||||
|
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
|
||||||
|
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||||
@@ -120,8 +120,15 @@ Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDe
|
|||||||
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
|
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
|
||||||
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible
|
(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
|
(`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
|
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (`MechDeathHandler::Tick`,
|
||||||
`ReplicantInstance`; `simulationState` rides every update-record header so the observer tracks the
|
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
|
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
|
the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't
|
||||||
replicated) — a fidelity refinement, not a missing effect.
|
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)
|
## What WinTesla already did (do NOT rebuild)
|
||||||
- **Renderer bypass: DONE** — `MUNGA_L4/L4D3D.cpp` + `DXUtils` replace libDPL / the IG board with
|
- **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]
|
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
|
- **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
|
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
|
spec-atomic bulk `alDeleteSources`) stopped the pool leaking, and there have been **no field
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# The GHOST MECH / zombie-wreck failure — field analysis 2026-07-29
|
||||||
|
|
||||||
|
## ✅ FIXED 2026-07-30 — two defects that were masking each other
|
||||||
|
**1. The death latch never released on failure.** The binary's `FUN_004c012c` tail is
|
||||||
|
`Post(...)` → `*(this+0x290) = 0` → `*(this+0x258) = 0`. We had the `Post` and the
|
||||||
|
`suppressConsole` and were **missing the middle instruction**, so `deathPending` cleared only on
|
||||||
|
SUCCESS paths. One failed respawn latched the pilot for the rest of the mission — every later death
|
||||||
|
hit the dedup and was SWALLOWED, so the cycle could never restart. That is what made a transient
|
||||||
|
hiccup a PERMANENT ghost, and it matches the field signature exactly (8 cycles, 6 stranded, none
|
||||||
|
recovering). Fixed in `btplayer.cpp` (death-handler tail).
|
||||||
|
**2. `VehicleDeadMessage` was dispatched TWICE per death.** `BTPostKillScore` (`btplayer.cpp:2263`)
|
||||||
|
sent a second one "to credit a death" — but that message is the RESPAWN-CYCLE TRIGGER, not a
|
||||||
|
scoreboard increment, and the tally is already credited by the handler's `++deathTally` (`:538`).
|
||||||
|
Both fire inside the same death transition (`mech4.cpp:2006` and `:2110`), so they were always
|
||||||
|
paired. Removed.
|
||||||
|
**They hid each other:** the duplicate made the latch look necessary, and the latch made the
|
||||||
|
duplicate invisible — every `SWALLOWED` warning in the field logs was simply the latch deduping our
|
||||||
|
own duplicate (8 of 8 deaths, a 100 % base rate, which is why it correlated with nothing). Fixing
|
||||||
|
either alone makes things visibly worse, which is why earlier passes at #57/#55 kept adding
|
||||||
|
clear-sites instead of finding the root. The binary broke the tie.
|
||||||
|
|
||||||
|
**Verified (4-node stress, 2026-07-30):** four simultaneous networked nodes, all dying every ~35 s
|
||||||
|
on staggered timers so respawn handshakes overlap (the field condition): **56 death cycles, every
|
||||||
|
completed one `START`→`RESET`, 0 swallowed / 0 mismatch / 0 discarded / 0 ghost / 0 crash** across
|
||||||
|
all four. Scoreboard: every node's `PLAYER_DEAD` matchlog rows show `deaths=N tally=N` in lockstep,
|
||||||
|
one increment per death (13/13/15/15) — the DEATHS column the comms panel draws is exact; the
|
||||||
|
kill-credit path evaluated all 56 deaths and correctly declined each self-kill (`NOCREDIT self=1`),
|
||||||
|
KILLS untouched by the fix (separate `ScoreMessage` path). Bench: `scratchpad/night6/mp4_stress.sh`
|
||||||
|
+ `content/MP4.EGG`.
|
||||||
|
**Two observations from watching the 4-node run, both run down (2026-07-30):** (1) *"comms panel
|
||||||
|
counted no deaths"* — the panel machinery is CORRECT (2-node rerun with a draw-value probe: both
|
||||||
|
rows on both nodes drew 0→9/0→11 live, local + replicated); the 4-node zeros were the artificial
|
||||||
|
load (4 core-pinned instances → PilotList Execute starved to <0.6 Hz, panel minutes-stale). Probe
|
||||||
|
stays in (`[score] panel DRAW`, BT_SCORE_LOG). (2) *"a respawn had no blue vortex"* — REAL and now
|
||||||
|
fixed: the warp effect is one global slot and a PEER's un-wreck sphere (`BTStartWarpEffect`) could
|
||||||
|
stomp the local pilot's own POV vortex mid-lifecycle; fixed by POV-priority self-skip
|
||||||
|
(btl4vid.cpp; `context/translocation-warp.md`). Pre-fix this never showed because overlapping
|
||||||
|
respawns barely existed — the ghost fix CREATED the traffic that exposed it.
|
||||||
|
**Verified:** solo **17 consecutive death/respawn cycles**, every one `START`→`RESET`, 0 swallowed /
|
||||||
|
0 mismatch / 0 crash (pre-fix this strands permanently after cycle 1). Two-node MP over a real
|
||||||
|
network path with cross-machine drop-zone replies: A 11 cycles, B 12, 0 swallowed / 0 mismatch /
|
||||||
|
0 discarded / 0 crash. Bench: `scratchpad/night6/mp_ghost.sh`, `BT_SELF_DAMAGE_REPEAT=1`.
|
||||||
|
|
||||||
|
**Status of the analysis below:** the mechanism write-up that led to the fix. The instrumentation
|
||||||
|
(`[dz]`, `[dzreq]`, `[ghost]`) stays in — it is what will prove the fix in the field, and the
|
||||||
|
cross-machine dependency in §4 is still a real fragility even with the latch fixed. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the
|
||||||
|
verbatim agent findings are in `scratchpad/night6/` (uncommitted — they contain machine names and
|
||||||
|
Steam identities). Tracker: #81 (zombie wreck), #57 (deathPending latch), #45 (tally replication).
|
||||||
|
|
||||||
|
## The one-sentence version
|
||||||
|
A death whose **drop-zone reply never arrives** leaves the respawn cycle stranded, so the mech is
|
||||||
|
never reset and never repainted — and because the render-side wreck swap is **one-way**, the pilot
|
||||||
|
keeps driving a burning hulk that sinks out of the world after ~18 s and then cannot be drawn at
|
||||||
|
all. The "wreckage moving and shooting" report and the "invisible ghost" report are **the same
|
||||||
|
failure at two different ages**.
|
||||||
|
|
||||||
|
## Established from the field logs [T1]
|
||||||
|
- Exactly **8 death cycles** in the whole session. Every one has the same 3-line signature at
|
||||||
|
consecutive lines: `death cycle START` → `*** DESTROYED ***` → `WARNING: death ... SWALLOWED`.
|
||||||
|
- **Only 2 of 8 reached `RESET at drop zone`.** A stranded cycle predicts ghosting **perfectly**:
|
||||||
|
8/8 cycles, 10/10 player-matches, in both directions.
|
||||||
|
- Match 2: all four non-host players died, all four stranded, zero RESETs anywhere = "everybody
|
||||||
|
ghosted". **The host did not ghost because he never died** — his log holds no death cycle at all.
|
||||||
|
(There is nothing special about being host; an earlier reading of this was wrong.)
|
||||||
|
- Match 1: three deaths, two stranded, one completed. The completion is the control case, and it is
|
||||||
|
the "self-corrected for one player" the testers reported.
|
||||||
|
- ⚠ **The SWALLOWED warning is benign.** It fires on 8 of 8 deaths, including both clean
|
||||||
|
completions and a build-641 death, so its base rate is 100% and its correlation with ghosting is
|
||||||
|
zero. It is not even a second death — it is the same death re-entering the notify from inside the
|
||||||
|
cycle. Do not treat it as a signal (an earlier reading did).
|
||||||
|
- ⚠ **Player IDs are per-match**: node = lobby token octet + 1, host always `.1`. `player 3:1` is a
|
||||||
|
different human in every match. Any cross-machine identification must decode this first.
|
||||||
|
- ⚠ An earlier "three spurious RESETs on the host" finding was an **aggregation artifact** — one
|
||||||
|
log file spans ~18 sessions and each session legitimately logs one spawn RESET.
|
||||||
|
|
||||||
|
## The render mechanism [T1, code]
|
||||||
|
- `btl4vid.cpp:1255-1300` — the wreck sink is a **pure timer**, nothing about movement: 0.25 s
|
||||||
|
reveal, then `sink = -0.025·t²`, and at `-8.0` the hulk/debris/flames get `SetDrawObj(NULL)` plus
|
||||||
|
`[BTrender] wreck buried (sink complete)`. That threshold is reached at **t ≈ 18 s**.
|
||||||
|
- The wreck objects hang off the mech's own render tree as an offset, so **they travel with the
|
||||||
|
pilot** — hence a moving, burning, shooting wreck for the first ~18 s.
|
||||||
|
- `mech4.cpp:1827` — a healthy respawn calls `BTRebuildMechModel` on Reset precisely because "the
|
||||||
|
wreck swap is one-way on the render side". No reply → no Reset → no rebuild → the hulk sinks and
|
||||||
|
the player is invisible but still simulated and driveable.
|
||||||
|
- Peers apply the wreck swap correctly and in a bit-identical order across machines, so the ghost is
|
||||||
|
**not** an entity peers never learned about — it is an entity whose presentation is frozen dead
|
||||||
|
and then hidden.
|
||||||
|
|
||||||
|
## The stall itself [T0 WinTesla — NOT proven authentic]
|
||||||
|
`engine/MUNGA/DROPZONE.cpp`: a slot is granted only if `IsAvailable()` — busy for `DOWN_TIME = 5 s`
|
||||||
|
after use, and any other player's vehicle within 2 m marks it busy. **When no slot is free the
|
||||||
|
handler reposts the message to itself every 0.1 s at `MaxEventPriority` and never replies**, so the
|
||||||
|
requester's `deathPending` latch is never cleared.
|
||||||
|
⚠ **Provenance:** `engine/MUNGA` is **WinTesla**, the later Windows port of MUNGA taken from updated
|
||||||
|
Red Planet (which converted the Division hardware to software). **The 1995 game never ran it.** So
|
||||||
|
this behaviour is authoritative for what we compile, *not* for what the pods did. BT's own
|
||||||
|
`munga/dropzone.cpp` is **absent from the decomp export** — the 1995 assignment policy is an
|
||||||
|
unrecovered gap. Recovering it (byte-scan + windowed disasm, the technique that recovered
|
||||||
|
`FUN_004a6344`) is the open task.
|
||||||
|
|
||||||
|
## 2026-07-30 dig: the respawn handshake, walked end to end
|
||||||
|
Re-read the whole chain (death → +5 s re-post → engine hunt → DropZone grant → reply → Reset) and
|
||||||
|
**measured** the parts that were assumptions. Results, in order of importance:
|
||||||
|
|
||||||
|
**1. The engine hunt RETRIES EVERY 2 SECONDS, forever, while the mech is dead** [T0]
|
||||||
|
(`PLAYER.cpp:325-327` posts the message back to itself unconditionally; its only state-based exit is
|
||||||
|
`GetSimulationState() == DropZoneAcquiredState`, which never happens — see 3). The DropZone also has
|
||||||
|
a resend net: a repeat request from the same requester + same `deathCount` re-grants the **same**
|
||||||
|
slot even while it is busy (`DROPZONE.cpp:182-194`). **So "a transient no-slot" and "one lost reply"
|
||||||
|
are both ELIMINATED as causes** — the request keeps being re-sent and re-granted every 2 s. A
|
||||||
|
permanent strand needs a permanent reason.
|
||||||
|
|
||||||
|
**2. THE SILENT DISCARD — the one remaining unlit path, now instrumented** [T1]
|
||||||
|
`BTPlayer::DropZoneReplyMessageHandler` is `if (!playerVehicle) … else if (deathCount ==
|
||||||
|
message->deathCount) … else { return; }`. That final branch was a **bare return with no logging**:
|
||||||
|
the drop zone granted a spot and replied, the numbers disagreed, and the reply was dropped —
|
||||||
|
`deathPending` stays latched, the mech is never `Reset`, and the pilot is a permanent ghost **with
|
||||||
|
zero trace in the log**. That is exactly why 6 of 8 field cycles stranded leaving no evidence. Now
|
||||||
|
always-on (`[ghost] DROP-ZONE REPLY DISCARDED …`) and it prints the **direction** of the mismatch,
|
||||||
|
which decides the fix: `msgDeath < ours` = genuinely stale (dropping is correct); `msgDeath > ours` =
|
||||||
|
**our counter is behind and we threw away a live respawn** — which points straight at #45 (the death
|
||||||
|
tally does not replicate correctly). Deliberately NOT "recovered" yet: guessing the direction would
|
||||||
|
be a stand-in, and the wrong guess respawns a mech that is still alive.
|
||||||
|
|
||||||
|
**3. ⚠ LANDMINE — do not convert the two `Set_Alarm_Level` raw writes to `SetSimulationState()`.**
|
||||||
|
`Set_Alarm_Level` is an **empty stub** (`btstubs.cpp:87`), so the death path's
|
||||||
|
`Set_Alarm_Level(this+0x2c, 1)` and the reply path's `(this+0x2c, 2)` are **no-ops** today. Their
|
||||||
|
values decode perfectly against `Player`'s enum (`PLAYER.h:273-279`) as `DropZoneAcquiredState`(1)
|
||||||
|
and `VehicleTranslocatedState`(2) — `DropZoneAcquiredState = Entity::StateCount`, and `Entity` adds
|
||||||
|
no states, so it is **1**. That makes "obviously these should be `SetSimulationState` calls" a very
|
||||||
|
attractive and **catastrophic** fix: the engine hunt is gated on `GetSimulationState() !=
|
||||||
|
DropZoneAcquiredState`, so setting state 1 on death would stop `AssignDropZone` from ever being
|
||||||
|
dispatched and make **every** pilot ghost permanently.
|
||||||
|
Measured: our `BTPlayer::simulationState` is at **0x24**, not 0x2c, and the write leaves it at 0
|
||||||
|
(`[ghost] death raw-write(+0x2c,1): simState 0 -> 0 (hunt gate still open)`). In the binary `+0x2c`
|
||||||
|
is the **Simulation-base alarm** — the same field the mech side calls `graphicAlarm` (`@0x4ac126`:
|
||||||
|
"owner alarm+0x2C -> level 9"). Our layout models that only as a `Mech` member, so a `BTPlayer` has
|
||||||
|
nowhere to put it; the stub is harmless until we recover what reads a *Player's* alarm in the binary
|
||||||
|
(likely a cockpit/HUD respawn indicator). [T1 offsets, T3 purpose]
|
||||||
|
|
||||||
|
**4. EVERY MP RESPAWN IS A CROSS-MACHINE ROUND TRIP — and that is the single point of failure**
|
||||||
|
[T2, two-node bench `scratchpad/night6/mp_ghost.sh`]. Measured on a 2-node rig with the host:local
|
||||||
|
labels: each player's request was answered by **the OTHER machine's** DropZone.
|
||||||
|
```
|
||||||
|
mp_a.log: [dzreq] player 2:1 asking for a drop zone ... <- A's own player asks
|
||||||
|
mp_b.log: [dz] GRANTED slot 7 to 2:1 death#1 ... <- B answered it
|
||||||
|
mp_b.log: [dzreq] player 3:1 asking for a drop zone ... <- B's own player asks
|
||||||
|
mp_a.log: [dz] GRANTED slot 0 to 3:1 death#1 ... <- A answered it
|
||||||
|
```
|
||||||
|
`FindGroup("DropZones")` iterates **every** DropZone entity including replicants of remotely-mastered
|
||||||
|
ones and picks the geometrically closest, so a respawn is: *my request → an arbitrary peer's
|
||||||
|
DropZone → that peer's reply → back over the wire to me.* With five players that is five round trips
|
||||||
|
through arbitrary peers, and **one degraded peer can ghost everybody else.**
|
||||||
|
That dovetails exactly with the otherwise-unexplained field finding that **one machine stopped
|
||||||
|
processing the effect/death-transition stream 57 % into match 1 and never recovered** (0 explosions /
|
||||||
|
0 wreck swaps / 0 burials while the other four logged 4/4/4). A node in that state that owns the
|
||||||
|
closest drop zone cannot answer anyone's respawn — which is what "everybody ghosted" looks like.
|
||||||
|
It also explains why solo is 100 % reliable (the whole handshake is in-process) and why the
|
||||||
|
2-node bench passes (both nodes healthy, replies land, `deathCount` agrees).
|
||||||
|
|
||||||
|
**Revised theory of the ghost (two layers):**
|
||||||
|
- *Structural:* respawn depends on a cross-machine round trip through a peer that may be lagging,
|
||||||
|
stalled, or holding a stale view of my player. Not drop-zone starvation (`slots=8`) and not a
|
||||||
|
single lost reply (the hunt retries every 2 s forever).
|
||||||
|
- *Mechanism when the reply does arrive but is stale/mismatched:* the silent discard in 2 — the
|
||||||
|
reply's `deathCount` disagrees and it is dropped without a trace, which couples this to #45.
|
||||||
|
|
||||||
|
**Two candidate fixes, both needing the next field logs to choose between:**
|
||||||
|
(a) **Prefer a locally-mastered DropZone** when one exists, keeping the handshake in-process and
|
||||||
|
removing the remote dependency entirely — cheap and robust, but it changes which physical pad you
|
||||||
|
land on, so check it against the maps first. (b) **Make the reply path tolerant**: accept a granted
|
||||||
|
reply whose `deathCount` is *ahead* of ours (our counter is behind) instead of discarding it, and
|
||||||
|
never leave `deathPending` latched on a discard. ⚠ Neither should be guessed at blind — the
|
||||||
|
`[ghost] DROP-ZONE REPLY DISCARDED` line's mismatch direction decides it in one line of log.
|
||||||
|
|
||||||
|
## HOW THE BINARY HANDLES IT — `deathPending` is OUR INVENTION [T1, decisive]
|
||||||
|
Read BT's own respawn code (it *is* in the export, even though `munga/dropzone.cpp` is not) and
|
||||||
|
checked it against the raw binary.
|
||||||
|
|
||||||
|
**The hunt is faithful.** BT's `Player::VehicleDeadMessageHandler` = `FUN_0042db80`
|
||||||
|
(`part_003.c:12029`) gates on `message->deathCount == player->deathCount` **and**
|
||||||
|
`player+0x40 != 1` — i.e. `simulationState != DropZoneAcquiredState`, exactly WinTesla's two gates —
|
||||||
|
then runs the same closest-DropZone search skipping `"win*"` zones, dispatches `AssignDropZone`, and
|
||||||
|
**re-posts the message to itself** on a timer. So the retry loop and the cross-machine hunt are
|
||||||
|
authentic 1995 behaviour, not a WinTesla artifact.
|
||||||
|
|
||||||
|
**But the latch is not.** BT's `BTPlayer::VehicleDeadMessageHandler` = `FUN_004c012c`
|
||||||
|
(`part_013.c:10504`) ends with:
|
||||||
|
```
|
||||||
|
++deathCount; message->deathCount = deathCount; --lives;
|
||||||
|
updateModel |= 1; alarm(+0x2c, 1); save dropZoneLocation;
|
||||||
|
Post(HighPriority, self, message, Now()+delay);
|
||||||
|
*(param_1 + 0x290) = 0; // <-- the field our reconstruction calls deathPending
|
||||||
|
```
|
||||||
|
**The binary CLEARS that field at the end of every death.** Verified against `BTL4OPT.EXE` itself:
|
||||||
|
`+0x290` is written in exactly **three** places in the whole executable
|
||||||
|
(`file_off 0x0b75fb`, `0x0bffe3`, `0x0c0a05`), and **all three store a zeroed register** —
|
||||||
|
`xor ecx,ecx` / `xor eax,eax` / `xor edx,edx` immediately before. There is **no write of 1, or of
|
||||||
|
any non-zero value, to `+0x290` anywhere in the binary.** Two of the three are constructor/reset
|
||||||
|
sweeps; the middle one is the death handler above (it sits right after the `call` to `Post` and
|
||||||
|
`add esp,0x14`, matching the decompiled tail exactly).
|
||||||
|
|
||||||
|
**So in 1995 there is no death-pending gate at all.** Our `btplayer.cpp:505` does
|
||||||
|
`deathPending = 1`, and six separate sites then have to clear it (`:382 :469 :1431 :1442 :1461
|
||||||
|
:1532`) — each one a patch for a different stranding scenario. Gitea **#57 and #55 are artifacts of
|
||||||
|
that invention**, not of the original design.
|
||||||
|
|
||||||
|
**And that is what makes a ghost PERMANENT.** In BT a failed respawn is harmless: the 2-second
|
||||||
|
re-post keeps hunting, and the next death starts a clean cycle. In ours the first failure latches
|
||||||
|
the pilot — every later death hits the dedup and is SWALLOWED ("deaths so far 1" forever), so the
|
||||||
|
pilot can never recover for the rest of the mission. That is precisely the field signature: 8 death
|
||||||
|
cycles, 6 stranded, none of them ever recovering.
|
||||||
|
|
||||||
|
**Proposed fix (faithful, and it removes the whole bug class):** match the binary — clear the field
|
||||||
|
at the end of the death handler instead of latching it, and drop the dedup gate that depends on it.
|
||||||
|
⚠ Six sites currently rely on that latch's existence, and the SWALLOWED dedup is load-bearing for
|
||||||
|
the "one death, one cycle" behaviour, so this wants a deliberate two-node bench rather than a
|
||||||
|
late-night edit. Caveat on the identification: `+0x290 ↔ deathPending` rests on our own
|
||||||
|
reconstruction's mapping (our compiled `BTPlayer` is only `0x28c` bytes, so the offsets cannot
|
||||||
|
match directly) — but the *behavioural* evidence (BT's death handler clears it; nothing in the
|
||||||
|
binary ever sets it) is read straight from the executable.
|
||||||
|
|
||||||
|
## What the instrumentation already killed
|
||||||
|
`slots=8` (measured, `[dz] POOL`). An 8-slot pool **cannot** be cooldown-starved by 5 players (at
|
||||||
|
most 5 slots on cooldown at once), so the shared `dropzone=one` in `tools/eggmodel.py:42` is **not**
|
||||||
|
the bottleneck. Suspicion moves to the handshake: a lost reply, or a `deathCount` mismatch — the
|
||||||
|
engine gates the hunt on `message->deathCount == deathCount` and the DropZone's resend safety net is
|
||||||
|
keyed on `lastDeathCount == message->deathCount`, so one mismatch breaks both silently (cf. #45).
|
||||||
|
|
||||||
|
## Instrumentation shipped (`ba6756c`) — always-on, no env var needed
|
||||||
|
| line | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `[dz] POOL name= slots= downTime= proximityBlock=` | once per mission: the pool size |
|
||||||
|
| `[dz] GRANTED slot N to entity E death#D waited=Xs` | one per respawn; `waited` is the headline; tags `(GHOST RECOVERED)` past 15 s |
|
||||||
|
| `[dz] STALL` / `[dz] GHOST LIKELY` | escalating, rate-limited per waiter; names **why** each slot is busy (age + last user) — this discriminates cooldown saturation from proximity blocking |
|
||||||
|
| `[dzreq] player P asking ... try=N msgDeath= ourDeathCount=` | requester half; flags `*** MISMATCH ***` |
|
||||||
|
`BT_DROPZONE_LOG=1` adds a verbose per-request slot dump for bench work.
|
||||||
|
|
||||||
|
## Next actions, in order
|
||||||
|
1. **Recover BT's 1995 drop-zone policy** from `content/BTL4OPT.EXE` (decomp gap). The key question:
|
||||||
|
when no slot was available, did BT also repost forever and never reply? If yes, the stall is
|
||||||
|
authentic and our divergence is elsewhere; if no, implement what BT did.
|
||||||
|
2. **Read the next field logs' `[dz]`/`[dzreq]` lines** — they answer cooldown-vs-proximity and
|
||||||
|
lost-reply-vs-deathCount-mismatch directly.
|
||||||
|
3. Ask the testers one question that discriminates a second defect: **did a flaming mover stay
|
||||||
|
visible for more than ~20 s?** If yes, the sink tick is not running for that entity (updater
|
||||||
|
early-return, or a render-tree rebuild resetting `wreckAge`) — a second bug on top of this one.
|
||||||
|
4. Separately filed/needed: one machine **stopped processing the effect/death-transition stream**
|
||||||
|
57 % into a match and never recovered (0 explosions / 0 wreck swaps / 0 burials while the other
|
||||||
|
four logged 4/4/4) — that machine was playing a stale world, and it is why that player reported
|
||||||
|
the whole "desync cluster".
|
||||||
|
|
||||||
|
## The desync cluster is four separate things, not one bug [T2]
|
||||||
|
- **Throttle reads zero** — local, and present in the *good* sessions too. ⚠ Unresolved nuance: the
|
||||||
|
internal `thr` field is 0 in every field `[drive]` sample while mechs moved, but reads 0.8 in solo
|
||||||
|
benches; whether that is the same thing the player saw on his gauge is **not** established.
|
||||||
|
- **No weapon audio** — local audio-source starvation caused by the **map's ambient emitters**
|
||||||
|
(`arena2_morning` ≈ 10.6 effects/s × ~2.5 voices pins the 256-source pool); thousands of
|
||||||
|
`ACQUIRE FAILED ... pool is exhausted`. Unrelated to crits or the network.
|
||||||
|
- **Weapons fire without damage** — replication staleness. The shooter resolves damage and awards
|
||||||
|
its own score locally, so a shooter always *sees* hits even when the victim applies nothing.
|
||||||
|
- **Other mechs lagging/misplaced** — the same stale-world condition as item 4 above.
|
||||||
|
|
||||||
|
## Why tonight and not before
|
||||||
|
Nothing in 642 broke multiplayer. Critical hits landing for real (first time) made deaths far more
|
||||||
|
frequent, and every death is a chance for this stall — so a rare race became a common one. Fewer
|
||||||
|
players ⇒ fewer deaths ⇒ clean matches, which is exactly what the testers observed.
|
||||||
@@ -74,7 +74,7 @@ Ranked by impact. **Reverse thrust `0x3F` (key LALT / pad B) is FIXED** and conf
|
|||||||
| 3 | **pad LT / RT (turn pedals)** — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (`L4PADRIO.cpp:710-715` → `L4CTRL.cpp:1438-1446` → `btl4mppr.cpp:1337-1340` → `:1399 pedalsPosition`) but only *Standard/Veteran* read them (`mechmppr.cpp:1034`, `:1061`); the mapper boots in **Basic** (`mechmppr.cpp:361`) — and **the pad has no binding for `0x18` CycleControlMode**. Verified: `content/bindings.txt:74-89` binds only `0x40 0x3F 0x47 0x42 0x44 0x43 0x41`; no `0x18`, no `0x15` | **S** | `BT_MPPR_TRACE=1`, squeeze LT on a pad-only launch: `pedals=` moves, `turn=0`. Add `pad BACK button 0x18`, press it → `[mode] control mode -> 1`, then LT yaws |
|
| 3 | **pad LT / RT (turn pedals)** — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (`L4PADRIO.cpp:710-715` → `L4CTRL.cpp:1438-1446` → `btl4mppr.cpp:1337-1340` → `:1399 pedalsPosition`) but only *Standard/Veteran* read them (`mechmppr.cpp:1034`, `:1061`); the mapper boots in **Basic** (`mechmppr.cpp:361`) — and **the pad has no binding for `0x18` CycleControlMode**. Verified: `content/bindings.txt:74-89` binds only `0x40 0x3F 0x47 0x42 0x44 0x43 0x41`; no `0x18`, no `0x15` | **S** | `BT_MPPR_TRACE=1`, squeeze LT on a pad-only launch: `pedals=` moves, `turn=0`. Add `pad BACK button 0x18`, press it → `[mode] control mode -> 1`, then LT yaws |
|
||||||
| 4 | **Fire dropout: releasing one alias of a held fire button** (`1`+`SPACE` on `0x40`; `3`+`LCTRL`+`RCTRL` on `0x47`; pad A/X on the same) | Two keys on one address = two wires to one button | Edge state is per **binding**, not per **address**: `previousKeyHeld[k]` is indexed by binding index and `EmitButton(addr, held)` fires on every per-binding edge (`engine/MUNGA_L4/L4PADRIO.cpp:580-595`; same shape in the pad loop `:665-685`). The release writes `-(addr+1)` (`L4CTRL.cpp:2635-2647`) and `ControlsUpdateManager::Update` then suppresses the redundant press, so fire stays **off** until the held key is released and re-pressed | **M** (shared plumbing — **defer**) | `BT_FIRE_LOG=1`: hold LCTRL (missiles firing), tap `3`, release `3` → fire stops with LCTRL still down |
|
| 4 | **Fire dropout: releasing one alias of a held fire button** (`1`+`SPACE` on `0x40`; `3`+`LCTRL`+`RCTRL` on `0x47`; pad A/X on the same) | Two keys on one address = two wires to one button | Edge state is per **binding**, not per **address**: `previousKeyHeld[k]` is indexed by binding index and `EmitButton(addr, held)` fires on every per-binding edge (`engine/MUNGA_L4/L4PADRIO.cpp:580-595`; same shape in the pad loop `:665-685`). The release writes `-(addr+1)` (`L4CTRL.cpp:2635-2647`) and `ControlsUpdateManager::Update` then suppresses the redundant press, so fire stays **off** until the held key is released and re-pressed | **M** (shared plumbing — **defer**) | `BT_FIRE_LOG=1`: hold LCTRL (missiles firing), tap `3`, release `3` → fire stops with LCTRL still down |
|
||||||
| 5 | **`0x1A`-`0x1D`** Generator A-D ON/OFF (4 radar-rail buttons) | Take a generator off line (heat/power management) | `Generator : HeatSink` (`powersub.hpp:320-322`) and **no `Generator::GetMessageHandlers()` exists** — `powersub.cpp:1014` resolves to HeatSink's set `{id 3 ToggleCooling}`, so streamed `msg 4` (`ctrlmap.log:44-47`) finds nothing. `ToggleGeneratorOnOff` @`004b1ed0` is unreconstructed (0 hits in `game/`) | **M** | Click `0x1A`: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
|
| 5 | **`0x1A`-`0x1D`** Generator A-D ON/OFF (4 radar-rail buttons) | Take a generator off line (heat/power management) | `Generator : HeatSink` (`powersub.hpp:320-322`) and **no `Generator::GetMessageHandlers()` exists** — `powersub.cpp:1014` resolves to HeatSink's set `{id 3 ToggleCooling}`, so streamed `msg 4` (`ctrlmap.log:44-47`) finds nothing. `ToggleGeneratorOnOff` @`004b1ed0` is unreconstructed (0 hits in `game/`) | **M** | Click `0x1A`: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
|
||||||
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | **M** (reconstruct @`0049fa00`) | Click `0x13` on a stationary mech: expect the squat clip + `duckState 0→1` |
|
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | ~~M~~ ✅ **COMPLETE** (handler 07-26; consumer + clips + MP 2026-08-06 — [[locomotion]] §CROUCH) | Verified: squat clip plays, holds, rises; peers replicate it |
|
||||||
| 7 | **Torso recenter from the keyboard** (`0x42` is pad/mouse only) | Center a twisted torso — the manual's anti-disorientation control (p9) | `0x42` itself is **LIVE** (streamed `subsys 17 attr 14 -> +0x208` = `Torso::centerCommand`, consumed `torso.cpp:636-640`) but `content/bindings.txt:83,86` bind it only on `pad Y`/`DPAD_UP`. The port's keyboard path (`gBTTorsoRecenter`, `mech4.cpp:2875`) is fed from the zeroed `gBTInput` and its consumer sits inside the stood-down bridge (`mechmppr.cpp:854-863`, gate `:655-660`) | **S** | Twist with `E`, press `X`: `currentTwist` does not return to 0. Add `key X button 0x42` (two rows on one VK both fire — `L4PADRIO.cpp:580-629` iterates all bindings) |
|
| 7 | **Torso recenter from the keyboard** (`0x42` is pad/mouse only) | Center a twisted torso — the manual's anti-disorientation control (p9) | `0x42` itself is **LIVE** (streamed `subsys 17 attr 14 -> +0x208` = `Torso::centerCommand`, consumed `torso.cpp:636-640`) but `content/bindings.txt:83,86` bind it only on `pad Y`/`DPAD_UP`. The port's keyboard path (`gBTTorsoRecenter`, `mech4.cpp:2875`) is fed from the zeroed `gBTInput` and its consumer sits inside the stood-down bridge (`mechmppr.cpp:854-863`, gate `:655-660`) | **S** | Twist with `E`, press `X`: `currentTwist` does not return to 0. Add `key X button 0x42` (two rows on one VK both fire — `L4PADRIO.cpp:580-629` iterates all bindings) |
|
||||||
| 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 |
|
| 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 |
|
||||||
| 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` |
|
| 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` |
|
||||||
|
|||||||
@@ -404,3 +404,21 @@ design change for its own pass. `killCount`/`deathTally` are still zeroed only i
|
|||||||
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
|
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
|
||||||
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
|
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
|
||||||
reproduced; it is merely no longer visible).
|
reproduced; it is merely no longer visible).
|
||||||
|
|
||||||
|
## Addendum 2026-08-05 — the AUTHENTIC report tail lands (#45/#134)
|
||||||
|
The kill/damage report sender is no longer the BTPostDamageScore/BTPostKillScore stand-in pair:
|
||||||
|
`Mech::TakeDamageMessageHandler`'s dark-gap tail (@0x4a02f4-0x4a0890) is reconstructed as
|
||||||
|
`BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), dispatching the binary's three
|
||||||
|
id-0x16 reports (kill to shooter / type-0 wire-fidelity / received to victim) and the BT-extended
|
||||||
|
0x38 VehicleDead {killed-by player, kill zone} from the victim's handler at the binary's exact
|
||||||
|
positions. Consequences for this plan:
|
||||||
|
- KILLS credit path unchanged in shape (victim node -> replicant-player Dispatch reroute ->
|
||||||
|
killer's master ++killCount) — re-benched cross-node (scorekill.sh: kills=1, award=4.88).
|
||||||
|
- Suicides now dispatch and the handler negates the award (#134 panic penalty live;
|
||||||
|
scoreself.sh: type=2 award=-39.00 kills=0).
|
||||||
|
- Per-hit INFLICTED credit is REMOVED — it never existed in 1995 (@0x4c0200 is bound in no
|
||||||
|
handler-table entry; scoring = kill awards + received penalties + death costs). SCORE column
|
||||||
|
behavior changes accordingly; KILLS/DEATHS columns unaffected.
|
||||||
|
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
|
||||||
|
replication-masked. The type-1 received report also fixes the console VTVDamaged
|
||||||
|
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ be "called on":
|
|||||||
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
|
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
|
||||||
`gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait.
|
`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.
|
(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,
|
**First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds,
|
||||||
|
|||||||
@@ -254,4 +254,27 @@ Keep all three unconditional (matchlog only arms on `-net`, `matchlog.cpp:66-76`
|
|||||||
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on death — the wreck stays (P5 teardown crash).
|
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on death — the wreck stays (P5 teardown crash).
|
||||||
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
|
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
|
||||||
9. **`AmmoBin +0x228`** is written to 0 by `@004bd26c` alongside `feedTimer`/`cookOffArmed` and is the object's last dword (`sizeof == 0x22C`); our `ammobin.hpp:270` calls it `reserved // unused`. It is a live per-feed latch — identify it from `AmmoBinSimulation @004bd394` / `FeedAmmo @004bd4f4` before shipping the refill move, or copy the write blind and flag it [T3].
|
9. **`AmmoBin +0x228`** is written to 0 by `@004bd26c` alongside `feedTimer`/`cookOffArmed` and is the object's last dword (`sizeof == 0x22C`); our `ammobin.hpp:270` calls it `reserved // unused`. It is a live per-feed latch — identify it from `AmmoBinSimulation @004bd394` / `FeedAmmo @004bd4f4` before shipping the refill move, or copy the write blind and flag it [T3].
|
||||||
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADDENDUM 2026-08-04 — the full-reset audit (operator request): valves, gensel, MFD modes, spawn smoke
|
||||||
|
|
||||||
|
Steps 0-5 above all landed via #55 and are byte-consistent (re-audited today).
|
||||||
|
Four operator/field reports were then grounded and dispositioned:
|
||||||
|
|
||||||
|
| report | binary ground truth | disposition |
|
||||||
|
|---|---|---|
|
||||||
|
| coolant valves survive respawn | `@004ae534` (MISSING from the Ghidra export -- raw disasm): chains **HeatSink** `0x4ad760` (coolant refill RUNS), then arg-gated `valveState=1` + `massScale=refrigerationFactor`; `Mech::Reset` tail (`call 0x49f788`) re-runs RecomputeCondenserValves | **PORT GAP -> FIXED** (heat.cpp authentic body + mech4.cpp Reset tail call). Bench: detent 5 -> death -> `[respawn] Condenser1 valve detent 5 -> 1` |
|
||||||
|
| generator switching survives | `@004b215c` sets `generatorOn=1` (landed, #55); `@004b0e6c` only RESOLVES the source link -- **the weapon->generator taps are never rewritten** | gens re-light = already correct; **taps persisting = AUTHENTIC** |
|
||||||
|
| MFD display mode / control mode survive | mapper vtables `0050f45c` + `0051e440` slots 8-11 read from the shipped exe = plain root bodies -- **displayMode/controlMode have NO reset anywhere** | **AUTHENTIC persist** -- do not "fix" |
|
||||||
|
| #129 fresh spawns smoke briefly | the pod's respawn ran the per-entity effect cleanup `@004d0c14` | **PORT GAP -> FIXED**: only the master's Mech::Reset ran the cleanup; the REPLICANT un-wreck edge (mechdmg.cpp MechDeathHandler::Tick) rebuilt without it, so the observer's last 10s plume window rode the teleport (peers-only symptom). BTStopEntityPfx now runs on the un-wreck edge |
|
||||||
|
|
||||||
|
Bench: `scratchpad/night11/respawnreset.sh` (BT_VALVE_TEST press -> BT_MP_FORCE_DMG
|
||||||
|
kill -> read `[valve]`/`[respawn]` on A, plume/un-wreck ordering on B).
|
||||||
|
|
||||||
|
## Addendum 2026-08-05 (#45 report tail): the VehicleDead SENDER moved
|
||||||
|
The death-transition dispatch site (mech4) is retired: the decoded sender is the TakeDamage
|
||||||
|
handler's death tail (@0x4a07d4-0x4a0890), now BTMechPostVehicleDead (btplayer.cpp) with the
|
||||||
|
BT 0x38-byte extension {killed-by player, kill zone}. The #55 NULL-playerLink fallback + the
|
||||||
|
DEAD_NOTIFY forensics moved into it intact; the #81 single-dispatch rule is unchanged. The
|
||||||
|
flow diagram above predates this -- read BTPostKillScore rows as historical (retired 08-05).
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ The whole roster sits on the BT base chain `engine Subsystem → MechSubsystem
|
|||||||
dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root
|
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
|
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.
|
§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 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 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.
|
- **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();
|
current_application.First();
|
||||||
application = current_application.GetCurrent();
|
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)
|
if (t2 < end_of_frame)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -219,10 +246,20 @@ Background_Loop:
|
|||||||
if (Now() - s_lastP >= 1.0f)
|
if (Now() - s_lastP >= 1.0f)
|
||||||
{
|
{
|
||||||
s_lastP = Now();
|
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)
|
DEBUG_STREAM << "[perf] frame=" << (float)(Now() - beginFrameTimestamp)
|
||||||
<< " fg=" << (float)(startBackground - beginFrameTimestamp)
|
<< " fg=" << (float)(startBackground - beginFrameTimestamp)
|
||||||
<< " bg=" << (float)(endBackground - startBackground)
|
<< " 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;
|
return;
|
||||||
}
|
}
|
||||||
if (getenv("BT_AUDIO_SPATIAL") && message->controlID == StartAudioControlID) {
|
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
|
DEBUG_STREAM << "[spatial] START request src=" << (void*)audio_source
|
||||||
<< " vol=" << audio_source_volume_scale << "\n" << std::flush; }
|
<< " vol=" << audio_source_volume_scale << "\n" << std::flush; }
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -243,6 +243,7 @@ void
|
|||||||
audioComponentSocket.Add(audio_component);
|
audioComponentSocket.Add(audio_component);
|
||||||
|
|
||||||
isLooped = is_looped;
|
isLooped = is_looped;
|
||||||
|
runProbeCount = 0; // #99 diag
|
||||||
isRunning = False;
|
isRunning = False;
|
||||||
startTime = AudioTime::Null;
|
startTime = AudioTime::Null;
|
||||||
audioControlEventIterator = NULL;
|
audioControlEventIterator = NULL;
|
||||||
@@ -393,7 +394,12 @@ void
|
|||||||
AudioControlSequence::StartSequence()
|
AudioControlSequence::StartSequence()
|
||||||
{
|
{
|
||||||
if (getenv("BT_ATTRBIND_LOG")) { static int s_ss=0; if (s_ss++<40)
|
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);
|
Check(this);
|
||||||
|
|
||||||
@@ -467,6 +473,17 @@ void
|
|||||||
//
|
//
|
||||||
// If the sequence is not running, then return
|
// 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)
|
if (!isRunning)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -478,6 +495,16 @@ void
|
|||||||
|
|
||||||
Check(audioControlEventIterator);
|
Check(audioControlEventIterator);
|
||||||
control_event = audioControlEventIterator->GetCurrent();
|
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 (
|
while (
|
||||||
control_event != NULL &&
|
control_event != NULL &&
|
||||||
IsEventReady(control_event)
|
IsEventReady(control_event)
|
||||||
@@ -513,6 +540,11 @@ void
|
|||||||
// Send the controller to the connected audio component
|
// Send the controller to the connected audio component
|
||||||
//
|
//
|
||||||
Check(control_event);
|
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());
|
control_event->Send(audioComponentSocket.GetCurrent());
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ private:
|
|||||||
|
|
||||||
Logical isLooped;
|
Logical isLooped;
|
||||||
Logical isRunning;
|
Logical isRunning;
|
||||||
|
int runProbeCount; // #99 diag: PER-SEQUENCE throttle for the [seqrun] probe
|
||||||
|
// (a shared static counter hid the alarm sequence entirely)
|
||||||
AudioTime startTime;
|
AudioTime startTime;
|
||||||
AudioDivisionsPerBeat divisionsPerBeat;
|
AudioDivisionsPerBeat divisionsPerBeat;
|
||||||
AudioTempo tempo;
|
AudioTempo tempo;
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ void
|
|||||||
{
|
{
|
||||||
Check(this);
|
Check(this);
|
||||||
Check(audioComponentSocket.GetCurrent());
|
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(
|
audioComponentSocket.GetCurrent()->ReceiveControl(
|
||||||
IdleAudioControlID,
|
IdleAudioControlID,
|
||||||
0.0f
|
0.0f
|
||||||
@@ -1079,6 +1083,13 @@ void
|
|||||||
{
|
{
|
||||||
Check(&audioComponentSocket);
|
Check(&audioComponentSocket);
|
||||||
Check(audioComponentSocket.GetCurrent());
|
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
|
#if 1
|
||||||
audioComponentSocket.GetCurrent()->ReceiveControl(
|
audioComponentSocket.GetCurrent()->ReceiveControl(
|
||||||
controlID,
|
controlID,
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ CulturalIcon::~CulturalIcon()
|
|||||||
break;
|
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);
|
Check_Pointer(update_record);
|
||||||
Verify(damageZoneIndex == update_record->damageZoneIndex);
|
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;
|
damageLevel = update_record->damageLevel;
|
||||||
damageZoneState.SetState(update_record->damageZoneState);
|
damageZoneState.SetState(update_record->damageZoneState);
|
||||||
damageZoneGraphicState.SetState(update_record->damageZoneGraphicState);
|
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
|
// Dropzone assignment
|
||||||
//
|
//
|
||||||
@@ -170,6 +177,69 @@ void
|
|||||||
Entity *entity = host->GetEntityPointer(message->requestingEntity);
|
Entity *entity = host->GetEntityPointer(message->requestingEntity);
|
||||||
Check(entity);
|
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
|
// If we have allocated a dropzone within the last ten seconds for this
|
||||||
@@ -306,6 +376,44 @@ void
|
|||||||
//
|
//
|
||||||
if (!drop_zone)
|
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();
|
Time when=Now();
|
||||||
when += 0.1f;
|
when += 0.1f;
|
||||||
application->Post(MaxEventPriority, this, message, when);
|
application->Post(MaxEventPriority, this, message, when);
|
||||||
@@ -318,6 +426,36 @@ void
|
|||||||
//--------------------------------------------------------------------
|
//--------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
Found_One:
|
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
|
ReplyMessage
|
||||||
reply(
|
reply(
|
||||||
message->replyMessageID,
|
message->replyMessageID,
|
||||||
|
|||||||
@@ -3819,7 +3819,28 @@ Logical
|
|||||||
|
|
||||||
case background:
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3963,6 +3984,10 @@ void
|
|||||||
Check_Fpu();
|
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
|
// ProcessOneActiveGauge
|
||||||
@@ -3979,8 +4004,15 @@ Logical
|
|||||||
GaugeBase
|
GaugeBase
|
||||||
*base_pointer = activeIterator->ReadAndNext();
|
*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)
|
if (base_pointer == NULL)
|
||||||
{
|
{
|
||||||
|
++gBTGaugeSweeps;
|
||||||
|
gBTGaugeActive = activeIterator->GetSize();
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// We're done!
|
// We're done!
|
||||||
// Bump the bit mask and index for the next pass
|
// Bump the bit mask and index for the next pass
|
||||||
@@ -3997,6 +4029,7 @@ Logical
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
++gBTGaugeTurns;
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Process one gauge
|
// Process one gauge
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
//############################ Renderer #################################
|
//############################ 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 MaxRendererComplexity = 3.0f;
|
||||||
const RendererComplexity MinRendererComplexity = 0.0f;
|
const RendererComplexity MinRendererComplexity = 0.0f;
|
||||||
const RendererFrameBudget DefaultRendererFrameBudget = 0.14f; //0.12f; // 0.16f;
|
const RendererFrameBudget DefaultRendererFrameBudget = 0.14f; //0.12f; // 0.16f;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
|||||||
resourceAddress;
|
resourceAddress;
|
||||||
|
|
||||||
killBonus = player_data->killBonus;
|
killBonus = player_data->killBonus;
|
||||||
|
specialCaseDeathPenalty = player_data->specialCaseDeathPenalty; // BT 4.10 (restored)
|
||||||
returnFromDeath = player_data->returnFromDeath;
|
returnFromDeath = player_data->returnFromDeath;
|
||||||
damageReceivedModifier = player_data->damageReceivedModifier;
|
damageReceivedModifier = player_data->damageReceivedModifier;
|
||||||
damageInflictedModifier = player_data->damageInflictedModifier;
|
damageInflictedModifier = player_data->damageInflictedModifier;
|
||||||
@@ -64,6 +65,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
|||||||
damageBias = 0.0f;
|
damageBias = 0.0f;
|
||||||
friendlyFirePenalty = 0.0f;
|
friendlyFirePenalty = 0.0f;
|
||||||
killBonus = 0.0f;
|
killBonus = 0.0f;
|
||||||
|
specialCaseDeathPenalty = 0.0f; // BT 4.10 (restored)
|
||||||
returnFromDeath = 0;
|
returnFromDeath = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +153,20 @@ Dump_And_Die:
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BT 4.10: read right after KillBonus, fail-hard like every other key
|
||||||
|
// (binary role reader @00429bec, dest = record slot [1]).
|
||||||
|
if(
|
||||||
|
!model_file->GetEntry(
|
||||||
|
"gamedata",
|
||||||
|
"SpecialCaseDeathPenalty",
|
||||||
|
&local_model->specialCaseDeathPenalty
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
std::cerr << model_name << "Missing SpecialCaseDeathPenalty" << std::endl;
|
||||||
|
goto Dump_And_Die;
|
||||||
|
}
|
||||||
|
|
||||||
if(
|
if(
|
||||||
!model_file->GetEntry(
|
!model_file->GetEntry(
|
||||||
"gamedata",
|
"gamedata",
|
||||||
|
|||||||
+13
-1
@@ -9,7 +9,12 @@ class NotationFile;
|
|||||||
|
|
||||||
struct ScenarioRole__ModelResource
|
struct ScenarioRole__ModelResource
|
||||||
{
|
{
|
||||||
Scalar killBonus, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
// BT 4.10 carries specialCaseDeathPenalty as record slot [1], right after
|
||||||
|
// killBonus (role reader @00429bec dest offsets; the 2007 WinTesla source
|
||||||
|
// dropped the BT-only field -- restored 2026-08-02, the DefaultRendererRate
|
||||||
|
// pattern). Consumed by the BT death handler tail (@004c07cd): every death
|
||||||
|
// with advancedDamageOn costs -specialCaseDeathPenalty score.
|
||||||
|
Scalar killBonus, specialCaseDeathPenalty, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
||||||
int returnFromDeath;
|
int returnFromDeath;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ public:
|
|||||||
|
|
||||||
protected:
|
protected:
|
||||||
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
|
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
|
||||||
|
Scalar specialCaseDeathPenalty; // BT 4.10 role+0x20 (restored 2026-08-02)
|
||||||
CString roleName;
|
CString roleName;
|
||||||
|
|
||||||
int returnFromDeath;
|
int returnFromDeath;
|
||||||
@@ -61,6 +67,12 @@ public:
|
|||||||
return killBonus;
|
return killBonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Scalar GetSpecialCaseDeathPenalty() const // BT 4.10 role+0x20
|
||||||
|
{
|
||||||
|
Check(this);
|
||||||
|
return specialCaseDeathPenalty;
|
||||||
|
}
|
||||||
|
|
||||||
CString GetRoleName() const
|
CString GetRoleName() const
|
||||||
{
|
{
|
||||||
Check(this);
|
Check(this);
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ protected:
|
|||||||
// Accessors
|
// Accessors
|
||||||
//
|
//
|
||||||
public:
|
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
|
void
|
||||||
ModifySegment(Logical modified = True)
|
ModifySegment(Logical modified = True)
|
||||||
{Check(this); segmentModified = modified;}
|
{Check(this); segmentModified = modified;}
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ void
|
|||||||
{
|
{
|
||||||
Check(this);
|
Check(this);
|
||||||
Verify(new_state < stateCount);
|
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 "state.h"
|
||||||
#include "receiver.h"
|
#include "receiver.h"
|
||||||
|
#include <intrin.h> // _ReturnAddress (the #78 simstomp diag)
|
||||||
#include "time.h"
|
#include "time.h"
|
||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
|
|
||||||
@@ -221,7 +222,24 @@ public:
|
|||||||
{Check(this); return simulationState.GetOldState();}
|
{Check(this); return simulationState.GetOldState();}
|
||||||
void
|
void
|
||||||
SetSimulationState(unsigned new_state)
|
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
|
StateIndicator
|
||||||
simulationState;
|
simulationState;
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ public:
|
|||||||
Check(&gaugeWatcherSocket);
|
Check(&gaugeWatcherSocket);
|
||||||
gaugeWatcherSocket.Add(watcher);
|
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:
|
private:
|
||||||
SChainOf<Component*> audioWatcherSocket;
|
SChainOf<Component*> audioWatcherSocket;
|
||||||
|
|||||||
+337
-42
@@ -395,12 +395,27 @@ void
|
|||||||
// DPLIndependantEffect voices, and while the pool is pinned every new
|
// DPLIndependantEffect voices, and while the pool is pinned every new
|
||||||
// acquire fails outright and that sound is silently dropped.
|
// acquire fails outright and that sound is silently dropped.
|
||||||
//
|
//
|
||||||
// ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win, and
|
// ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win.
|
||||||
// the reported dropout was already fixed on 2026-07-23 by a999e5c (the
|
//
|
||||||
// atomic-delete leak) -- no field complaints since. What remains in the
|
// ⚠⚠ UPDATE 2026-08-01 (#32): the "no field complaints since" claim below
|
||||||
// night-5 logs is transient exhaustion at the peak of a firefight, where
|
// did NOT hold. Every 4.11.674 player log is saturated -- 3031/4657/5245/
|
||||||
// a dropped voice competes with ~256 already sounding, so it is very
|
// 6275/6571 failures across five machines, first failure ~10% into a match
|
||||||
// likely sub-perceptual. Against that: the 256 ceiling also acts as a
|
// 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
|
// 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 --
|
// 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
|
// with EFX reverb + the lowpass chains live, that is real CPU, spent
|
||||||
@@ -593,13 +608,19 @@ void
|
|||||||
{
|
{
|
||||||
Check(this);
|
Check(this);
|
||||||
Check(audio_source);
|
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 that the source is stopped
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
Verify(audio_source->GetAudioSourceState() == StoppedAudioSourceState);
|
Verify(audio_source->GetAudioSourceState() == StoppedAudioSourceState);
|
||||||
|
|
||||||
//
|
//
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
@@ -619,6 +640,9 @@ void
|
|||||||
);
|
);
|
||||||
if (!resources_available)
|
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
|
// Audio-dropout fix: a failed acquisition can leave a PARTIAL set
|
||||||
// (alGenSources succeeded for some slots before the pool ran dry).
|
// (alGenSources succeeded for some slots before the pool ran dry).
|
||||||
// A dropped transient never plays and nothing else ever released it,
|
// A dropped transient never plays and nothing else ever released it,
|
||||||
@@ -1241,6 +1265,13 @@ Logical
|
|||||||
source_result = audio_source->GetAudioChannelSet();
|
source_result = audio_source->GetAudioChannelSet();
|
||||||
Check(source_result);
|
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 =
|
resources_available =
|
||||||
RequestAudioChannels(
|
RequestAudioChannels(
|
||||||
audio_source->GetAudioVoiceCount(),
|
audio_source->GetAudioVoiceCount(),
|
||||||
@@ -1328,6 +1359,11 @@ Logical
|
|||||||
// Else suspend the source
|
// 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 (
|
if (
|
||||||
running_audio_source->GetAudioRenderType() ==
|
running_audio_source->GetAudioRenderType() ==
|
||||||
TransientAudioRenderType
|
TransientAudioRenderType
|
||||||
@@ -1358,10 +1394,32 @@ Logical
|
|||||||
iterator.Last();
|
iterator.Last();
|
||||||
running_audio_source = iterator.GetCurrent();
|
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;
|
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
|
// RequestAudioChannels
|
||||||
@@ -1392,9 +1450,17 @@ Logical
|
|||||||
if (now_ms - s_censusAt > 30000)
|
if (now_ms - s_censusAt > 30000)
|
||||||
{
|
{
|
||||||
s_censusAt = now_ms;
|
s_censusAt = now_ms;
|
||||||
|
extern long gBTAudioSteals, gBTAudioTrueDrops;
|
||||||
DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive
|
DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive
|
||||||
|
<< " pooled=" << BTAudioPoolSize()
|
||||||
|
<< " free=" << BTAudioPoolFree()
|
||||||
|
<< " reuses=" << BTAudioPoolReuses()
|
||||||
<< " acquireFails=" << gBTAudioAcquireFails
|
<< " acquireFails=" << gBTAudioAcquireFails
|
||||||
|
<< " steals=" << gBTAudioSteals
|
||||||
|
<< " drops=" << gBTAudioTrueDrops
|
||||||
<< std::endl << std::flush;
|
<< std::endl << std::flush;
|
||||||
|
extern void BTAudioDropCensus();
|
||||||
|
BTAudioDropCensus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1405,35 +1471,55 @@ Logical
|
|||||||
if (requested > AUDIO_SOURCESET_CAPACITY)
|
if (requested > AUDIO_SOURCESET_CAPACITY)
|
||||||
requested = AUDIO_SOURCESET_CAPACITY;
|
requested = AUDIO_SOURCESET_CAPACITY;
|
||||||
|
|
||||||
bool failed = true;
|
|
||||||
|
|
||||||
alGetError();
|
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++)
|
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);
|
++gBTAudioAcquireFails;
|
||||||
if (alIsSource(source_request->sources[i]))
|
// Rate-limited: the night-9 field logs carried 6.8k-19k of these per
|
||||||
++gBTAudioSourcesLive;
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
source_request->sources[i] = src;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return True;
|
return True;
|
||||||
@@ -1515,6 +1601,220 @@ Logical
|
|||||||
long gBTAudioSourcesLive = 0;
|
long gBTAudioSourcesLive = 0;
|
||||||
long gBTAudioAcquireFails = 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)
|
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||||
{
|
{
|
||||||
// Audio-dropout fix: the old bulk alDeleteSources(count, sources) is
|
// 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
|
// partial release leaked its real sources and the pool never recovered
|
||||||
// ("audio cutting in and out toward the end of the match"). Delete each
|
// ("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).
|
// 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++)
|
for (int i = 0; i < sourceSet.count; i++)
|
||||||
{
|
{
|
||||||
if (alIsSource(sourceSet.sources[i]))
|
if (sourceSet.sources[i] != 0)
|
||||||
{
|
BTAudioPoolRelease(sourceSet.sources[i]);
|
||||||
ALint state = AL_STOPPED;
|
sourceSet.sources[i] = 0; // 0 is never a valid AL name
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2624,12 +2624,28 @@ void
|
|||||||
Verify(rio_event.Data.Unit < ButtonCount);
|
Verify(rio_event.Data.Unit < ButtonCount);
|
||||||
temp = rio_event.Data.Unit + 1;
|
temp = rio_event.Data.Unit + 1;
|
||||||
|
|
||||||
|
// DIAG (BT_DUCK_LOG): the mode mask a press dispatches under -- the
|
||||||
|
// crouch-button (0x13) MP-delivery hunt (mode-gated streamed rows).
|
||||||
|
if (getenv("BT_DUCK_LOG"))
|
||||||
|
DEBUG_STREAM << "[duck] RIO press unit=0x" << std::hex
|
||||||
|
<< (int)rio_event.Data.Unit << " modeMask=0x"
|
||||||
|
<< (unsigned)mode_mask << std::dec << "\n" << std::flush;
|
||||||
|
|
||||||
Check(&buttonGroup[rio_event.Data.Unit]);
|
Check(&buttonGroup[rio_event.Data.Unit]);
|
||||||
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
|
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
|
||||||
//----------------------------------------
|
//----------------------------------------
|
||||||
// Save 'pressed' mode mask for 'release'
|
// Save 'pressed' mode mask for 'release'
|
||||||
//----------------------------------------
|
//----------------------------------------
|
||||||
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask;
|
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask;
|
||||||
|
|
||||||
|
// (NO eng-page eject-key hook: REMOVED 2026-08-03 for strict binary
|
||||||
|
// fidelity. The soft keys 0x0B/0x23/0x03 never pilot-eject in the
|
||||||
|
// binary -- every page routes them to authored functions (weapon eng
|
||||||
|
// pages: EjectAmmo 0xB -- a hook here HIJACKED the unjam while armed,
|
||||||
|
// self-destructing a pilot trying to clear a jam). The flashing
|
||||||
|
// engEject lamp is the INVITE; the authored press is the pilot KEYPAD
|
||||||
|
// bank / PANIC key, both live on the desktop.)
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case RIO::ButtonReleasedEvent:
|
case RIO::ButtonReleasedEvent:
|
||||||
@@ -2670,6 +2686,16 @@ void
|
|||||||
);
|
);
|
||||||
|
|
||||||
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]);
|
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]);
|
||||||
|
{
|
||||||
|
static int s_klog = -1;
|
||||||
|
if (s_klog < 0) s_klog = (getenv("BT_PAD_LOG") != 0) ? 1 : 0;
|
||||||
|
if (s_klog)
|
||||||
|
DEBUG_STREAM << "[lbe4key] unit "
|
||||||
|
<< (int)rio_event.Data.Keyboard.Unit
|
||||||
|
<< " key " << (int)new_key
|
||||||
|
<< " mode 0x" << std::hex << (unsigned)mode_mask
|
||||||
|
<< std::dec << "\n" << std::flush;
|
||||||
|
}
|
||||||
keyboardGroup[rio_event.Data.Keyboard.Unit].
|
keyboardGroup[rio_event.Data.Keyboard.Unit].
|
||||||
ForceUpdate(&new_key, mode_mask);
|
ForceUpdate(&new_key, mode_mask);
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,17 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
|
|||||||
object->mDrawOps[i].lodNear = data.batches[i].lodNear;
|
object->mDrawOps[i].lodNear = data.batches[i].lodNear;
|
||||||
object->mDrawOps[i].lodFar = data.batches[i].lodFar;
|
object->mDrawOps[i].lodFar = data.batches[i].lodFar;
|
||||||
object->mDrawOps[i].lodDepthBias = data.batches[i].lodBias;
|
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);
|
const bool useRamp = (s_ramp && data.batches[i].hasRamp);
|
||||||
uint32_t c = data.batches[i].color;
|
uint32_t c = data.batches[i].color;
|
||||||
@@ -432,8 +443,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
|
|||||||
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
|
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
|
||||||
// stay in the sky pass.
|
// stay in the sky pass.
|
||||||
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj;
|
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj;
|
||||||
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp;
|
// BTFX brighten veil (spot cone): route to the blend pass like the
|
||||||
|
// vertex-alpha cards; the draw-state branch in DrawMesh adds
|
||||||
|
// dest += emissive x factor instead of SRCALPHA/INVSRCALPHA.
|
||||||
|
const bool brightenOp = data.batches[i].brightenFactor > 0.0f && !isSkyObj;
|
||||||
|
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp || brightenOp;
|
||||||
object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp;
|
object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp;
|
||||||
|
object->mDrawOps[i].brightenAlpha = brightenOp ? data.batches[i].brightenFactor : 0.0f;
|
||||||
object->mDrawOps[i].drawAsDecal = false;
|
object->mDrawOps[i].drawAsDecal = false;
|
||||||
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
|
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
|
||||||
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
|
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
|
||||||
@@ -1198,6 +1214,33 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
|||||||
|
|
||||||
mDevice->SetMaterial(&drawOp->material);
|
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
|
#ifndef RP3_EMULATE
|
||||||
SetTextureScrolling(&(drawOp->texture), targetRenderFrame);
|
SetTextureScrolling(&(drawOp->texture), targetRenderFrame);
|
||||||
SetTexture(drawOp->texture.texture);
|
SetTexture(drawOp->texture.texture);
|
||||||
@@ -1241,7 +1284,9 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
|||||||
// (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
|
// (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
|
||||||
// above routed it there; ALPHABLEND is on and z-write off pass-wide).
|
// above routed it there; ALPHABLEND is on and z-write off pass-wide).
|
||||||
DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0;
|
DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0;
|
||||||
const bool vtxAlphaCard = (drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND);
|
const bool brightenCard = (drawOp->brightenAlpha > 0.0f && pass == PASS_ALPHABLEND);
|
||||||
|
const bool vtxAlphaCard =
|
||||||
|
((drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND) || brightenCard);
|
||||||
if (vtxAlphaCard)
|
if (vtxAlphaCard)
|
||||||
{
|
{
|
||||||
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
|
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
|
||||||
@@ -1254,13 +1299,35 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
|||||||
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
|
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
|
||||||
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
|
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
|
||||||
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
|
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
|
||||||
mDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
|
mDevice->SetRenderState(D3DRS_DESTBLEND,
|
||||||
|
brightenCard ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA);
|
||||||
|
if (brightenCard)
|
||||||
|
{
|
||||||
|
// BTFX brighten veil: dest += vertexRGB x factor. SPOT.BGF's
|
||||||
|
// verts author the beam tint (cyan-white 0.12/0.98/1.0, alpha
|
||||||
|
// 1.0 -- no fade gradient); the factor rides TFACTOR alpha.
|
||||||
|
// [T3 tint compose]: the night material ALSO authors emissive
|
||||||
|
// (0.9,0.4,0.2 warm) -- whether the DIV board tinted the veil
|
||||||
|
// by vertex, emissive, or their product is unconfirmed; vertex
|
||||||
|
// (a white-blue searchlight) matches the authored geometry
|
||||||
|
// gradient model. Flip here if era-look evidence says warm.
|
||||||
|
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR,
|
||||||
|
D3DCOLOR_COLORVALUE(1.0f, 1.0f, 1.0f, drawOp->brightenAlpha));
|
||||||
|
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
|
||||||
|
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
|
||||||
|
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
|
||||||
|
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||||
|
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TFACTOR);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
|
mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
|
||||||
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
|
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
|
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
|
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool copCut = (drawOp->copRole == 2 && iOp > 0
|
const bool copCut = (drawOp->copRole == 2 && iOp > 0
|
||||||
@@ -1337,6 +1404,13 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
|||||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop);
|
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop);
|
||||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2);
|
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)
|
if (drawOp->lodDepthBias != 0.0f)
|
||||||
mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB);
|
mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ struct GWin
|
|||||||
int wantX, wantY;
|
int wantX, wantY;
|
||||||
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
|
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
|
||||||
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
|
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
|
||||||
|
|
||||||
|
// RGB CHANNEL GROUP (BT_POD_RGB): on splitter-wired pod glass this window
|
||||||
|
// is one VGA PORT feeding up to three mono monitors through R/G/B. These
|
||||||
|
// are the OTHER surfaces sharing the port; each is composited into its own
|
||||||
|
// channel by BlitSurface. groupCount 0 = ordinary single-surface window.
|
||||||
|
const char *groupPort[3];
|
||||||
|
const char *groupAlt[3]; // the Eng<n> twin, or NULL
|
||||||
|
int groupCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
static GWin gWins[8];
|
static GWin gWins[8];
|
||||||
@@ -124,7 +132,8 @@ static int pressedAddress = -1;
|
|||||||
static unsigned char latched[128];
|
static unsigned char latched[128];
|
||||||
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
|
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
|
||||||
static bool sRepositionedToMain = false;
|
static bool sRepositionedToMain = false;
|
||||||
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
||||||
|
static unsigned long *gStage2 = NULL; // RGB composite scratch (BT_POD_RGB)
|
||||||
|
|
||||||
static HFONT titleFont = NULL;
|
static HFONT titleFont = NULL;
|
||||||
static HFONT buttonFont = NULL;
|
static HFONT buttonFont = NULL;
|
||||||
@@ -190,7 +199,9 @@ static void
|
|||||||
GButton &b = w.buttons[w.buttonCount++];
|
GButton &b = w.buttons[w.buttonCount++];
|
||||||
b.address = src.address;
|
b.address = src.address;
|
||||||
b.color = (src.colorClass == 1) ? ClrYellow
|
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.left = src.x + dx;
|
||||||
b.rect.top = src.y + dy;
|
b.rect.top = src.y + dy;
|
||||||
b.rect.right = src.x + dx + src.w;
|
b.rect.right = src.x + dx + src.w;
|
||||||
@@ -405,6 +416,69 @@ enum { LayoutOff = 0, LayoutLoad = 1, LayoutSave = 2 };
|
|||||||
|
|
||||||
static const char *layoutFileName = "glass_layout.cfg";
|
static const char *layoutFileName = "glass_layout.cfg";
|
||||||
|
|
||||||
|
// POD SURFACE MODE (BT_POD_SURFACES=1, 2026-08-06) -- the REAL pod bring-up
|
||||||
|
// shape. On the actual cab each MFD is its own 640x480 panel with PHYSICAL
|
||||||
|
// buttons and lamps around it, so the desktop chrome the glass windows draw
|
||||||
|
// (the RIO button bank + the frame) is exactly wrong there: it would paint
|
||||||
|
// fake buttons onto a panel that has real ones behind glass. In pod mode a
|
||||||
|
// window is cropped to its SURFACE, frameless, and the bank is dropped:
|
||||||
|
// one clean picture per physical panel, positioned by glass_layout.cfg.
|
||||||
|
// The Flight Controls window (no surface at all) is not created.
|
||||||
|
// BT_POD_SURFACES=1 -> every display window goes bare
|
||||||
|
// glass_layout.cfg "<title>=x,y,bare" -> just that one (mixed rigs)
|
||||||
|
// BT_POD_RGB=1 -- the pod's REAL video wiring (see pod-hardware.md §THE RGB
|
||||||
|
// SPLIT): one VGA port per window, its R/G/B analog lines split to three
|
||||||
|
// monochrome MFD monitors. Implies pod surface mode.
|
||||||
|
static int
|
||||||
|
PodRgbSplitMode()
|
||||||
|
{
|
||||||
|
static int m = -1;
|
||||||
|
if (m < 0)
|
||||||
|
{
|
||||||
|
const char *e = getenv("BT_POD_RGB");
|
||||||
|
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
PodSurfaceMode()
|
||||||
|
{
|
||||||
|
static int m = -1;
|
||||||
|
if (m < 0)
|
||||||
|
{
|
||||||
|
const char *e = getenv("BT_POD_SURFACES");
|
||||||
|
m = (e != NULL && *e != '\0' && *e != '0') ? 1 : 0;
|
||||||
|
if (!m && PodRgbSplitMode())
|
||||||
|
m = 1; // the RGB split is pod glass -- always bare
|
||||||
|
if (m)
|
||||||
|
DEBUG_STREAM << "[glasswin] POD SURFACE MODE: bare surfaces, no "
|
||||||
|
"button banks (physical pod buttons assumed)\n" << std::flush;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop a built window to its surface: drop the bank, move the picture to the
|
||||||
|
// client origin, go frameless. Called at the end of each Build* when pod mode
|
||||||
|
// is on (and from LoadLayout for a per-window ",bare").
|
||||||
|
static void
|
||||||
|
MakeBareSurface(GWin &w)
|
||||||
|
{
|
||||||
|
if (w.portPrimary == NULL) // Flight Controls -- nothing to show
|
||||||
|
{
|
||||||
|
w.clientW = w.clientH = 0;
|
||||||
|
w.buttonCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int sw = w.surfaceRect.right - w.surfaceRect.left;
|
||||||
|
const int sh = w.surfaceRect.bottom - w.surfaceRect.top;
|
||||||
|
w.buttonCount = 0; // the pod's buttons are real
|
||||||
|
SetRect(&w.surfaceRect, 0, 0, sw, sh);
|
||||||
|
w.clientW = sw;
|
||||||
|
w.clientH = sh;
|
||||||
|
w.noFrame = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// A framed window is a normal tool window; ",noframe" makes it a bare
|
// A framed window is a normal tool window; ",noframe" makes it a bare
|
||||||
// WS_POPUP -- no caption, no border, nothing but the display and its buttons.
|
// WS_POPUP -- no caption, no border, nothing but the display and its buttons.
|
||||||
static DWORD
|
static DWORD
|
||||||
@@ -449,6 +523,91 @@ static GWin *
|
|||||||
return NULL;
|
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
|
// 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
|
// 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
|
// computed spot. Restored windows are flagged so the WM_TIMER re-snap (which
|
||||||
@@ -493,16 +652,15 @@ static void
|
|||||||
// Trailing options after the numbers, comma-separated. Unknown ones
|
// Trailing options after the numbers, comma-separated. Unknown ones
|
||||||
// are ignored (so an older build reading a newer file just loses the
|
// are ignored (so an older build reading a newer file just loses the
|
||||||
// option, never the line) -- the bindings.txt grammar rule.
|
// option, never the line) -- the bindings.txt grammar rule.
|
||||||
int noframe = 0;
|
int noframe = 0, bare = 0;
|
||||||
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
|
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
|
||||||
{
|
{
|
||||||
const char *t = opt + 1;
|
const char *t = opt + 1;
|
||||||
while (*t == ' ' || *t == '\t') ++t;
|
while (*t == ' ' || *t == '\t') ++t;
|
||||||
if (_strnicmp(t, "noframe", 7) == 0)
|
if (_strnicmp(t, "noframe", 7) == 0)
|
||||||
{
|
|
||||||
noframe = 1;
|
noframe = 1;
|
||||||
break;
|
else if (_strnicmp(t, "bare", 4) == 0)
|
||||||
}
|
bare = 1; // pod panel: surface only (see PodSurfaceMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
GWin *gw = FindGWinByTitle(s);
|
GWin *gw = FindGWinByTitle(s);
|
||||||
@@ -511,6 +669,8 @@ static void
|
|||||||
gw->wantX = x;
|
gw->wantX = x;
|
||||||
gw->wantY = y;
|
gw->wantY = y;
|
||||||
gw->noFrame = noframe;
|
gw->noFrame = noframe;
|
||||||
|
if (bare && gw->buttonCount > 0)
|
||||||
|
MakeBareSurface(*gw); // per-window pod panel (mixed rigs)
|
||||||
gw->restored = 1;
|
gw->restored = 1;
|
||||||
++restored;
|
++restored;
|
||||||
}
|
}
|
||||||
@@ -545,7 +705,8 @@ static void
|
|||||||
"# Heat MFD=1920,0,657,539,noframe\n"
|
"# Heat MFD=1920,0,657,539,noframe\n"
|
||||||
"# A frameless window is also PINNED (no caption = nothing to drag it\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"
|
"# 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);
|
f);
|
||||||
int wrote = 0;
|
int wrote = 0;
|
||||||
for (int i = 0; i < gWinCount; ++i)
|
for (int i = 0; i < gWinCount; ++i)
|
||||||
@@ -562,11 +723,41 @@ static void
|
|||||||
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
|
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
|
||||||
++wrote;
|
++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);
|
fclose(f);
|
||||||
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
|
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
|
||||||
<< layoutFileName << "\n" << std::flush;
|
<< 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
|
// Painting
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
@@ -652,6 +843,71 @@ static void
|
|||||||
}
|
}
|
||||||
int ow = 0, oh = 0;
|
int ow = 0, oh = 0;
|
||||||
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
|
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// RGB CHANNEL COMPOSITE (BT_POD_RGB, 2026-08-06) -- the AUTHENTIC pod
|
||||||
|
// output. The cab does not give each mono MFD its own video port: one
|
||||||
|
// VGA port's R/G/B analog lines are SPLIT to three monochrome monitors,
|
||||||
|
// and the game puts a different surface in each colour channel of one
|
||||||
|
// palettized framebuffer (L4GAUGE.CFG: Comm=red, Mfd2=green, Heat=blue
|
||||||
|
// on clut2; Mfd1=red, Mfd3=green on clut1 -- and
|
||||||
|
// L4GraphicsPort::BuildSecondaryColor writes exactly ONE component per
|
||||||
|
// port, which IS the split). So on splitter-wired glass this window is
|
||||||
|
// not one MFD: it is one VGA PORT, carrying up to three. Composite the
|
||||||
|
// group's planes into this frame, each in its own channel, and let the
|
||||||
|
// hardware separate them again.
|
||||||
|
//
|
||||||
|
// Channel comes from the port itself (GetEnableID), so the authored
|
||||||
|
// config -- including a page swap that hands the channel to an Eng
|
||||||
|
// plane -- decides, never a hardcoded table here.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
if (w->groupCount > 0 && ow > 0 && oh > 0)
|
||||||
|
{
|
||||||
|
const int n = ow * oh;
|
||||||
|
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||||
|
{
|
||||||
|
L4GraphicsPort *gp =
|
||||||
|
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupPort[gi]));
|
||||||
|
if (gp == NULL)
|
||||||
|
continue;
|
||||||
|
// honour the live Mfd<n>/Eng<n> page swap: take whichever twin
|
||||||
|
// currently owns a channel
|
||||||
|
if (gp->GetEnableID() == L4GraphicsPort::BlankColor
|
||||||
|
&& w->groupAlt[gi] != NULL)
|
||||||
|
{
|
||||||
|
L4GraphicsPort *ga =
|
||||||
|
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupAlt[gi]));
|
||||||
|
if (ga != NULL && ga->GetEnableID() != L4GraphicsPort::BlankColor)
|
||||||
|
gp = ga;
|
||||||
|
}
|
||||||
|
const int ch = gp->GetEnableID();
|
||||||
|
unsigned long chMask;
|
||||||
|
switch (ch & 0x7F)
|
||||||
|
{
|
||||||
|
case L4GraphicsPort::RedChannel: chMask = 0x00FF0000ul; break;
|
||||||
|
case L4GraphicsPort::GreenChannel: chMask = 0x0000FF00ul; break;
|
||||||
|
case L4GraphicsPort::BlueChannel: chMask = 0x000000FFul; break;
|
||||||
|
default: continue; // blank / direct-colour -> contributes nothing
|
||||||
|
}
|
||||||
|
int gw = 0, gh = 0;
|
||||||
|
// -1 tint would palette-expand; pass white so the plane comes back
|
||||||
|
// as full-intensity mono, then keep only this port's channel.
|
||||||
|
svga->ExpandPlaneToBGRA(gp->GetBitMask(), gp->paletteID,
|
||||||
|
(int)0x00FFFFFF, w->rotate, gStage2, &gw, &gh);
|
||||||
|
if (gw != ow || gh != oh)
|
||||||
|
continue;
|
||||||
|
for (int i = 0; i < n; ++i)
|
||||||
|
gStage[i] |= (gStage2[i] & chMask);
|
||||||
|
}
|
||||||
|
if (log)
|
||||||
|
{
|
||||||
|
DEBUG_STREAM << "[glass] '" << w->title << "' RGB COMPOSITE of "
|
||||||
|
<< w->groupCount << " plane(s):";
|
||||||
|
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||||
|
DEBUG_STREAM << " " << w->groupPort[gi];
|
||||||
|
DEBUG_STREAM << "\n" << std::flush;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (log)
|
if (log)
|
||||||
{
|
{
|
||||||
int nz = 0, n = ow * oh;
|
int nz = 0, n = ow * oh;
|
||||||
@@ -809,6 +1065,10 @@ static void
|
|||||||
SelectObject(dc, oldBrush);
|
SelectObject(dc, oldBrush);
|
||||||
SelectObject(dc, oldPen);
|
SelectObject(dc, oldPen);
|
||||||
DeleteObject(pen);
|
DeleteObject(pen);
|
||||||
|
|
||||||
|
// (NO full-face flash overlay here either -- same wrongness as the
|
||||||
|
// L4VB16 one, removed 2026-08-03: the buttons are big rects tucked
|
||||||
|
// under the surface by design, and the protruding edge is the lamp.)
|
||||||
}
|
}
|
||||||
|
|
||||||
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
|
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
|
||||||
@@ -957,6 +1217,8 @@ void
|
|||||||
sRepositionedToMain = false;
|
sRepositionedToMain = false;
|
||||||
if (gStage == NULL)
|
if (gStage == NULL)
|
||||||
gStage = new unsigned long[640 * 480];
|
gStage = new unsigned long[640 * 480];
|
||||||
|
if (gStage2 == NULL)
|
||||||
|
gStage2 = new unsigned long[640 * 480];
|
||||||
|
|
||||||
// Build the seven windows. Order fixes the ComputeLayout roles (0..6).
|
// Build the seven windows. Order fixes the ComputeLayout roles (0..6).
|
||||||
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
|
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
|
||||||
@@ -968,6 +1230,52 @@ void
|
|||||||
BuildFlight(gWins[6]); // 0x38-0x47
|
BuildFlight(gWins[6]); // 0x38-0x47
|
||||||
gWinCount = 7;
|
gWinCount = 7;
|
||||||
|
|
||||||
|
// POD SURFACE MODE: crop every display to its picture and drop the Flight
|
||||||
|
// Controls pad (it is pure desktop chrome -- the cab has a real stick).
|
||||||
|
if (PodSurfaceMode())
|
||||||
|
{
|
||||||
|
for (int i = 0; i < gWinCount; ++i)
|
||||||
|
MakeBareSurface(gWins[i]);
|
||||||
|
if (gWins[6].portPrimary == NULL)
|
||||||
|
gWinCount = 6; // drop Flight Controls
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB SPLIT MODE (BT_POD_RGB): the cab's real wiring -- ONE VGA port per
|
||||||
|
// window, its R/G/B lines split to three mono monitors. Collapse the five
|
||||||
|
// separate MFD windows into the TWO ports the pod actually drives
|
||||||
|
// (L4GAUGE.CFG clut2 = Comm/Mfd2/Heat, clut1 = Mfd1/Mfd3), each window
|
||||||
|
// compositing its group into the colour channels; the radar keeps its own
|
||||||
|
// full-colour port. Channels are read live from each port, so a page swap
|
||||||
|
// to an Eng plane follows automatically.
|
||||||
|
if (PodRgbSplitMode())
|
||||||
|
{
|
||||||
|
GWin port2 = gWins[0]; // reuse the Heat window's shape
|
||||||
|
port2.title = "VGA Port A"; // Comm=red, Mfd2=green, Heat=blue
|
||||||
|
port2.portPrimary = "Heat"; // base plane (blue); group adds the rest
|
||||||
|
port2.portAlt = NULL;
|
||||||
|
port2.groupCount = 2;
|
||||||
|
port2.groupPort[0] = "Comm"; port2.groupAlt[0] = NULL;
|
||||||
|
port2.groupPort[1] = "Mfd2"; port2.groupAlt[1] = "Eng2";
|
||||||
|
port2.monoTint = (int)0x000000FF; // Heat rides BLUE
|
||||||
|
|
||||||
|
GWin port1 = gWins[3]; // the Mfd1 window's shape
|
||||||
|
port1.title = "VGA Port B"; // Mfd1=red, Mfd3=green (blue spare)
|
||||||
|
port1.portPrimary = "Mfd1";
|
||||||
|
port1.portAlt = "Eng1";
|
||||||
|
port1.groupCount = 1;
|
||||||
|
port1.groupPort[0] = "Mfd3"; port1.groupAlt[0] = "Eng3";
|
||||||
|
port1.monoTint = (int)0x00FF0000; // Mfd1 rides RED
|
||||||
|
|
||||||
|
GWin radar = gWins[5]; // unchanged: its own colour port
|
||||||
|
|
||||||
|
gWins[0] = port2;
|
||||||
|
gWins[1] = port1;
|
||||||
|
gWins[2] = radar;
|
||||||
|
gWinCount = 3;
|
||||||
|
DEBUG_STREAM << "[glasswin] RGB SPLIT MODE: 2 VGA ports + radar "
|
||||||
|
"(each port's R/G/B feeds three mono panels)\n" << std::flush;
|
||||||
|
}
|
||||||
|
|
||||||
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
|
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
|
||||||
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
|
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
|
||||||
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
|
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
|
||||||
@@ -1023,6 +1331,37 @@ void
|
|||||||
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
|
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
|
||||||
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
|
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
|
||||||
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
|
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
|
||||||
|
|
||||||
|
// PLACEMENT RECEIPT (pod bring-up, 2026-08-06): which PHYSICAL monitor
|
||||||
|
// each display actually landed on. On a real cab -- or any remote
|
||||||
|
// session -- nobody can see all seven surfaces at once, so the log is
|
||||||
|
// the only way to confirm the map without walking around the pod.
|
||||||
|
{
|
||||||
|
RECT wr = { 0, 0, 0, 0 };
|
||||||
|
GetWindowRect(w.hwnd, &wr);
|
||||||
|
HMONITOR mon = MonitorFromWindow(w.hwnd, MONITOR_DEFAULTTONEAREST);
|
||||||
|
MONITORINFOEXA mi;
|
||||||
|
memset(&mi, 0, sizeof(mi));
|
||||||
|
mi.cbSize = sizeof(mi);
|
||||||
|
const char *devName = "?";
|
||||||
|
int mx = 0, my = 0, mw = 0, mh = 0, primary = 0;
|
||||||
|
if (mon != NULL && GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||||
|
{
|
||||||
|
devName = mi.szDevice;
|
||||||
|
mx = mi.rcMonitor.left; my = mi.rcMonitor.top;
|
||||||
|
mw = mi.rcMonitor.right - mi.rcMonitor.left;
|
||||||
|
mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
|
||||||
|
primary = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0;
|
||||||
|
}
|
||||||
|
DEBUG_STREAM << "[glasswin] '" << w.title << "' surface="
|
||||||
|
<< (w.portPrimary ? w.portPrimary : "-")
|
||||||
|
<< " at " << wr.left << "," << wr.top
|
||||||
|
<< " " << (wr.right - wr.left) << "x" << (wr.bottom - wr.top)
|
||||||
|
<< (w.noFrame ? " bare" : " framed")
|
||||||
|
<< " -> monitor " << devName
|
||||||
|
<< " (" << mx << "," << my << " " << mw << "x" << mh
|
||||||
|
<< (primary ? ", PRIMARY)" : ")") << "\n" << std::flush;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
|
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
|
||||||
@@ -1050,3 +1389,43 @@ void
|
|||||||
if (subFont) { DeleteObject(subFont); subFont = NULL; }
|
if (subFont) { DeleteObject(subFont); subFont = NULL; }
|
||||||
if (gStage) { delete[] gStage; gStage = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -812,13 +812,18 @@ void
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case scrambleVideo:
|
case scrambleVideo:
|
||||||
if (graphicsDisplay != NULL)
|
// NON-STACKING LATCH (phase-14 fidelity): the binary latches on `modified`
|
||||||
|
// @0x4fe0fe -- a second PPC hit while the scramble is ALREADY live does NOT
|
||||||
|
// re-save the (already detuned) value and does NOT extend the window. This
|
||||||
|
// original unconditionally overwrote scrambleVideoTimeout, so rapid PPC fire
|
||||||
|
// STACKED the effect -- a divergence from the binary. Ignore re-arm while live.
|
||||||
|
if (graphicsDisplay != NULL && !scrambleVideoFlag)
|
||||||
{
|
{
|
||||||
scrambleVideoFlag = True;
|
scrambleVideoFlag = True;
|
||||||
scrambleVideoTimeout = ((Scalar)Now()) + duration;
|
scrambleVideoTimeout = ((Scalar)Now()) + duration;
|
||||||
|
|
||||||
Check(graphicsDisplay);
|
Check(graphicsDisplay);
|
||||||
((SVGA16*) graphicsDisplay)->FunkyVideo(True);
|
((SVGA16*) graphicsDisplay)->FunkyVideo(True, duration); // duration -> the recovery envelope
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,21 @@ void
|
|||||||
<< (pressed ? "HELD" : "released") << "\n" << std::flush;
|
<< (pressed ? "HELD" : "released") << "\n" << std::flush;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PANIC (0x3D) -> pilot-keypad key. The binary's ONLY pilot-eject input
|
||||||
|
// is keyboardGroup[KeyboardPilot].Add(mode 0x200000, mech, msg 0x19)
|
||||||
|
// (FUN_004d266c; exhaustive scan: no other 0x200000 consumer). The
|
||||||
|
// guarded PANIC key has no button-space consumer anywhere in the image,
|
||||||
|
// so on the pod it reported through the pilot keypad matrix -- this is
|
||||||
|
// that wire.
|
||||||
|
if (address == 0x3D && pressed)
|
||||||
|
EmitKeypad(LBE4ControlsManager::KeyboardPilot, 0);
|
||||||
|
|
||||||
|
// (The ENG-PAGE EJECT KEY intent-completion lives in the game-thread RIO
|
||||||
|
// drain -- LBE4ControlsManager::ProcessRIOEvent, ButtonPressedEvent --
|
||||||
|
// where mode_mask is safely in hand. A first cut queried the mode
|
||||||
|
// manager HERE, on the window-click thread, and crashed on the unbound
|
||||||
|
// TU global (field-caught 2026-08-03).)
|
||||||
|
|
||||||
RIOEvent event;
|
RIOEvent event;
|
||||||
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
|
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
|
||||||
event.Data.Unit = address;
|
event.Data.Unit = address;
|
||||||
@@ -479,6 +494,9 @@ void
|
|||||||
event.Type = KeyEvent;
|
event.Type = KeyEvent;
|
||||||
event.Data.Keyboard.Unit = unit;
|
event.Data.Keyboard.Unit = unit;
|
||||||
event.Data.Keyboard.Key = key;
|
event.Data.Keyboard.Key = key;
|
||||||
|
if (getenv("BT_PAD_LOG"))
|
||||||
|
DEBUG_STREAM << "[padkey] unit " << unit << " key " << key
|
||||||
|
<< " queued\n" << std::flush;
|
||||||
PushEvent(event);
|
PushEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +542,33 @@ void
|
|||||||
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
|
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
|
||||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||||
}
|
}
|
||||||
|
// BT_BTNTEST2="addr,pressPoll,releasePoll": an optional SECOND
|
||||||
|
// scripted cycle (e.g. crouch then rise) on the same poll clock.
|
||||||
|
static int s_bt2Addr = -2, s_bt2On = 0, s_bt2Off = 0, s_bt2State = 0;
|
||||||
|
if (s_bt2Addr == -2)
|
||||||
|
{
|
||||||
|
s_bt2Addr = -1;
|
||||||
|
const char *e2 = getenv("BT_BTNTEST2");
|
||||||
|
if (e2 != NULL)
|
||||||
|
sscanf(e2, "%i,%i,%i", &s_bt2Addr, &s_bt2On, &s_bt2Off);
|
||||||
|
}
|
||||||
|
if (s_bt2Addr >= 0)
|
||||||
|
{
|
||||||
|
if (s_bt2State == 0 && s_poll >= s_bt2On)
|
||||||
|
{
|
||||||
|
s_bt2State = 1;
|
||||||
|
EmitButton(s_bt2Addr, 1);
|
||||||
|
DEBUG_STREAM << "[btntest] PRESS2 0x" << std::hex << s_bt2Addr
|
||||||
|
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||||
|
}
|
||||||
|
else if (s_bt2State == 1 && s_poll >= s_bt2Off)
|
||||||
|
{
|
||||||
|
s_bt2State = 2;
|
||||||
|
EmitButton(s_bt2Addr, 0);
|
||||||
|
DEBUG_STREAM << "[btntest] RELEASE2 0x" << std::hex << s_bt2Addr
|
||||||
|
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "l4particles.h"
|
#include "l4particles.h"
|
||||||
#include "../munga/time.h"
|
#include "../munga/time.h"
|
||||||
|
#include "../munga/style.h" // DEBUG_STREAM (#35 diagnostics)
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL;
|
LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL;
|
||||||
PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS];
|
PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS];
|
||||||
@@ -251,26 +253,90 @@ void ParticleEmitter::Execute()
|
|||||||
|
|
||||||
void ParticleEngine::Destroy()
|
void ParticleEngine::Destroy()
|
||||||
{
|
{
|
||||||
mVertBuffer->Release();
|
// #35 (the field "Owens crash"). This runs on the DEVICELOST path, and a
|
||||||
mParticleTexture->Release();
|
// 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)
|
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));
|
memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
|
||||||
|
|
||||||
ParticleEngine::mMaxParticleCount = atoi(getenv("MAXPARTICLES"));
|
const char *max_env = getenv("MAXPARTICLES");
|
||||||
|
ParticleEngine::mMaxParticleCount = (max_env != NULL) ? atoi(max_env) : 0;
|
||||||
// create the vertex buffer that will store the four vertices we need for the billboards
|
if (ParticleEngine::mMaxParticleCount <= 0)
|
||||||
mDevice->CreateVertexBuffer(ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX),
|
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,
|
D3DUSAGE_DYNAMIC,
|
||||||
L4BASICVERTEX_FVF,
|
L4BASICVERTEX_FVF,
|
||||||
D3DPOOL_DEFAULT,
|
D3DPOOL_DEFAULT,
|
||||||
&mVertBuffer,
|
&mVertBuffer,
|
||||||
NULL);
|
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)
|
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
|
// now we're going to sort the particle list based on distance to the camera
|
||||||
mergesort(&mParticlesHead, &mParticlesTail, view_matrix);
|
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
|
// variables get all screwed up when we don't run for a while
|
||||||
ExecuteParticles(view_matrix, timeSlice);
|
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;
|
return;
|
||||||
|
|
||||||
// setup stages for particle's texture
|
// setup stages for particle's texture
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ class ParticleEngine
|
|||||||
public:
|
public:
|
||||||
static void Destroy();
|
static void Destroy();
|
||||||
static void Initialize(LPDIRECT3DDEVICE9 device);
|
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 InstallEffect(int effectNumber, PARTICLE_EFFECT effect);
|
||||||
static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice);
|
static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ static LRESULT CALLBACK
|
|||||||
{
|
{
|
||||||
switch (message)
|
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:
|
case WM_CLOSE:
|
||||||
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
|
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
|
||||||
return 0;
|
return 0;
|
||||||
@@ -52,6 +58,7 @@ PlasmaWindow::~PlasmaWindow()
|
|||||||
{
|
{
|
||||||
if (window != NULL)
|
if (window != NULL)
|
||||||
{
|
{
|
||||||
|
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); } // backstop before the HWND goes
|
||||||
DestroyWindow((HWND)window);
|
DestroyWindow((HWND)window);
|
||||||
window = NULL;
|
window = NULL;
|
||||||
}
|
}
|
||||||
@@ -75,20 +82,43 @@ void
|
|||||||
window_class.lpszClassName = L"BTPlasmaWnd";
|
window_class.lpszClassName = L"BTPlasmaWnd";
|
||||||
RegisterClassW(&window_class);
|
RegisterClassW(&window_class);
|
||||||
|
|
||||||
RECT frame = { 0, 0, plasmaWidth * scale, plasmaHeight * scale };
|
// glass_layout.cfg integration (2026-08-04): the plasma window rides the same
|
||||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;
|
// file as the per-display panels, so it can be dragged-and-remembered and set
|
||||||
AdjustWindowRect(&frame, style, FALSE);
|
// 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);
|
||||||
|
|
||||||
//
|
DWORD style = noframe
|
||||||
// Bottom-right, TOPMOST -- under the (topmost, right-parked) button
|
? (DWORD)WS_POPUP // bare + pinned, same as a ",noframe" glass panel
|
||||||
// panel, clear of the game window (which would otherwise bury it).
|
: (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_width = frame.right - frame.left;
|
||||||
int frame_height = frame.bottom - frame.top;
|
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;
|
int plasma_x, plasma_y;
|
||||||
if (plasma_x < 0) plasma_x = 0;
|
if (have_saved)
|
||||||
if (plasma_y < 0) plasma_y = 0;
|
{
|
||||||
|
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(
|
window = CreateWindowExW(
|
||||||
WS_EX_TOPMOST,
|
WS_EX_TOPMOST,
|
||||||
@@ -102,6 +132,7 @@ void
|
|||||||
DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush;
|
DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
BTGlassLayout_RegisterExtern((HWND)window, "BattleTech - Plasma");
|
||||||
ShowWindow((HWND)window, SW_SHOWNOACTIVATE);
|
ShowWindow((HWND)window, SW_SHOWNOACTIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,10 @@ void
|
|||||||
int row = i / columns;
|
int row = i / columns;
|
||||||
int x = originX + column * (cw + gap);
|
int x = originX + column * (cw + gap);
|
||||||
int y = originY + row * (ch + 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,
|
(inert != 0) ? inert[i] : 0,
|
||||||
(labels != 0) ? labels[i] : 0);
|
(labels != 0) ? labels[i] : 0);
|
||||||
GrowBounds(out, x, y, cw, ch);
|
GrowBounds(out, x, y, cw, ch);
|
||||||
|
|||||||
+287
-13
@@ -615,17 +615,41 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
|||||||
Word *source = pixelBuffer.Data.MapPointer;
|
Word *source = pixelBuffer.Data.MapPointer;
|
||||||
Word *dest = (Word*)rect.pBits;
|
Word *dest = (Word*)rect.pBits;
|
||||||
int postRowIncrement = (rect.Pitch / 2) - w;
|
int postRowIncrement = (rect.Pitch / 2) - w;
|
||||||
|
// PPC sync-scramble (phase-14): map the SOURCE read through the recovery
|
||||||
|
// envelope (collapse toward a line -> broaden -> lock). Identity when not
|
||||||
|
// armed (ScrambleParams returns False). Output columns outside the collapsed
|
||||||
|
// band read index 0 (black), so the picture squeezes to a line and grows back.
|
||||||
|
double scl, shr, roff; int cx, shake;
|
||||||
|
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
|
||||||
|
double invS = scr ? (1.0 / scl) : 1.0;
|
||||||
if (monoTint < 0)
|
if (monoTint < 0)
|
||||||
{
|
{
|
||||||
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
|
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
|
||||||
SVGA16Palette *pal = &palette[paletteID];
|
SVGA16Palette *pal = &palette[paletteID];
|
||||||
for (int y = 0; y < h; y++)
|
for (int y = 0; y < h; y++)
|
||||||
{
|
{
|
||||||
|
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||||
|
Word *srcRow = source + sry * w; // vertical shake bounces the row
|
||||||
|
double rowScroll = roff + shr * (double)y; // scroll + diagonal, per row
|
||||||
for (int x = 0; x < w; x++)
|
for (int x = 0; x < w; x++)
|
||||||
{
|
{
|
||||||
PaletteTriplet *pe = &(pal->paletteData.Color[*source & mask]);
|
Word px;
|
||||||
|
if (scr)
|
||||||
|
{
|
||||||
|
double srcBase = ((double)x - cx) * invS + cx; // collapse band
|
||||||
|
if (srcBase < 0.0 || srcBase >= w)
|
||||||
|
px = 0; // black outside the line
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||||
|
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
|
||||||
|
px = srcRow[sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else px = srcRow[x];
|
||||||
|
PaletteTriplet *pe = &(pal->paletteData.Color[px & mask]);
|
||||||
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
|
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
|
||||||
dest++; source++;
|
dest++;
|
||||||
}
|
}
|
||||||
dest += postRowIncrement;
|
dest += postRowIncrement;
|
||||||
}
|
}
|
||||||
@@ -637,10 +661,27 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
|||||||
Word tint = (Word) monoTint;
|
Word tint = (Word) monoTint;
|
||||||
for (int y = 0; y < h; y++)
|
for (int y = 0; y < h; y++)
|
||||||
{
|
{
|
||||||
|
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||||
|
Word *srcRow = source + sry * w; // vertical shake bounces the row
|
||||||
|
double rowScroll = roff + shr * (double)y;
|
||||||
for (int x = 0; x < w; x++)
|
for (int x = 0; x < w; x++)
|
||||||
{
|
{
|
||||||
*dest = (*source & mask) ? tint : 0;
|
Word px;
|
||||||
dest++; source++;
|
if (scr)
|
||||||
|
{
|
||||||
|
double srcBase = ((double)x - cx) * invS + cx;
|
||||||
|
if (srcBase < 0.0 || srcBase >= w)
|
||||||
|
px = 0;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||||
|
sx %= w; if (sx < 0) sx += w;
|
||||||
|
px = srcRow[sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else px = srcRow[x];
|
||||||
|
*dest = (px & mask) ? tint : 0;
|
||||||
|
dest++;
|
||||||
}
|
}
|
||||||
dest += postRowIncrement;
|
dest += postRowIncrement;
|
||||||
}
|
}
|
||||||
@@ -712,16 +753,38 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
|||||||
Word *base = pixelBuffer.Data.MapPointer;
|
Word *base = pixelBuffer.Data.MapPointer;
|
||||||
SVGA16Palette *pal = &palette[paletteID];
|
SVGA16Palette *pal = &palette[paletteID];
|
||||||
|
|
||||||
|
// PPC sync-scramble (phase-14) -- the recovery envelope (collapse -> broaden ->
|
||||||
|
// lock), mapped in SOURCE space so it survives the rotation. Identity when not
|
||||||
|
// armed (ScrambleParams False). Reads outside the collapsed band -> index 0 (black).
|
||||||
|
double scl, shr, roff; int cx, shake;
|
||||||
|
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
|
||||||
|
double invS = scr ? (1.0 / scl) : 1.0;
|
||||||
|
|
||||||
if (rotateQuadrant == 0)
|
if (rotateQuadrant == 0)
|
||||||
{
|
{
|
||||||
// Native orientation, top-down straight copy.
|
// Native orientation, top-down straight copy.
|
||||||
for (int y = 0; y < h; y++)
|
for (int y = 0; y < h; y++)
|
||||||
{
|
{
|
||||||
Word *src = base + y * w;
|
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||||
|
Word *src = base + sry * w; // vertical shake bounces the row
|
||||||
unsigned long *d = dst + y * w;
|
unsigned long *d = dst + y * w;
|
||||||
|
double rowScroll = roff + shr * (double)y;
|
||||||
for (int x = 0; x < w; x++)
|
for (int x = 0; x < w; x++)
|
||||||
{
|
{
|
||||||
Word s = src[x];
|
Word s;
|
||||||
|
if (scr)
|
||||||
|
{
|
||||||
|
double srcBase = ((double)x - cx) * invS + cx; // collapse band
|
||||||
|
if (srcBase < 0.0 || srcBase >= w)
|
||||||
|
s = 0; // black outside the line
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||||
|
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
|
||||||
|
s = src[sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else s = src[x];
|
||||||
if (monoTint < 0)
|
if (monoTint < 0)
|
||||||
{
|
{
|
||||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||||
@@ -741,6 +804,8 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
|||||||
|
|
||||||
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
|
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
|
||||||
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
|
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
|
||||||
|
// The scramble maps the SOURCE column (sx keyed on source row sy) through the
|
||||||
|
// same envelope, so the radar recovers the same way the landscape MFDs do.
|
||||||
int ow = h, oh = w;
|
int ow = h, oh = w;
|
||||||
for (int oy = 0; oy < oh; oy++)
|
for (int oy = 0; oy < oh; oy++)
|
||||||
{
|
{
|
||||||
@@ -758,7 +823,21 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
|||||||
sx = w - 1 - oy;
|
sx = w - 1 - oy;
|
||||||
sy = ox;
|
sy = ox;
|
||||||
}
|
}
|
||||||
Word s = base[sy * w + sx];
|
int syS = sy + shake; if (syS < 0) syS = 0; else if (syS >= h) syS = h - 1; // shake
|
||||||
|
Word s;
|
||||||
|
if (scr)
|
||||||
|
{
|
||||||
|
double srcBase = ((double)sx - cx) * invS + cx; // collapse band (source col)
|
||||||
|
if (srcBase < 0.0 || srcBase >= w)
|
||||||
|
s = 0;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long msx = (long)(srcBase + roff + shr * (double)sy + 0.5);
|
||||||
|
msx %= w; if (msx < 0) msx += w; // roll SCROLLS within source
|
||||||
|
s = base[syS * w + msx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else s = base[syS * w + sx];
|
||||||
if (monoTint < 0)
|
if (monoTint < 0)
|
||||||
{
|
{
|
||||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||||
@@ -1180,6 +1259,15 @@ void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (NO full-face "flash overlay" here -- twice field-broken 2026-08-03.
|
||||||
|
// The side buttons are BIG rects deliberately tucked UNDER the surfaces;
|
||||||
|
// step 2 already animates their lamp shades, and the protruding edge IS
|
||||||
|
// the port's lamp light (the pod's backlit keys sat beside the CRT --
|
||||||
|
// there is no in-display face to light). An on-top redraw either covers
|
||||||
|
// the MFD imagery with flashing faces, or -- drawn without resetting the
|
||||||
|
// texture state DrawDevSurface leaves bound -- smears the gauge atlas as
|
||||||
|
// striped garbage (operator captures: green stripes, then red faces).)
|
||||||
|
|
||||||
// 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
|
// 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
|
||||||
for (int i = 0; i < L.buttonCount; i++)
|
for (int i = 0; i < L.buttonCount; i++)
|
||||||
{
|
{
|
||||||
@@ -1284,6 +1372,47 @@ void BTCockpitMouseUp(void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// (glass lamp responsiveness, 2026-08) Sweep the cockpit lamp STATE every frame.
|
||||||
|
//
|
||||||
|
// The lamp sweep -- LampManager::Update -> AssertNewLampValue -> RIO/PadRIO::SetLamp,
|
||||||
|
// which is what lights the cockpit buttons -- authentically rides the gauge
|
||||||
|
// renderer's FOREGROUND turn (GaugeRenderer::ExecuteForeground, gaugrend.cpp), and
|
||||||
|
// that turn fires only ONCE PER FULL GAUGE CYCLE (foreground -> background -> copy).
|
||||||
|
// The cycle only advances to the next foreground turn after the THROTTLED background
|
||||||
|
// gauge sweep drains the whole active-instrument list -- and that background task
|
||||||
|
// starves under load (field reports: "every cockpit instrument freezes at its last paint
|
||||||
|
// while the underlying values AND fps stay perfectly healthy"). So on a busy MP
|
||||||
|
// mission the lamp sweep ran ~1x/second: the lit buttons froze / flashes stalled while
|
||||||
|
// the 3D view (a separate per-frame foreground render) stayed smooth -- exactly the
|
||||||
|
// "lighting slow or nonexistent" playtest reports (all on glass desktops).
|
||||||
|
//
|
||||||
|
// The lamp sweep is CHEAP -- deduped state pushes over ~72 lamps, no raster -- so on
|
||||||
|
// the dev/glass composite path we run it EVERY frame here, decoupled from the gauge
|
||||||
|
// cycle, and the buttons track the sim regardless of background-sweep slack. The pod
|
||||||
|
// NEVER enters BTDrawGaugeInset (it drives real gauge hardware), so its authentic
|
||||||
|
// bandwidth-paced serial lamp cadence is untouched. BT_GLASS_LAMP_SWEEP=0 restores the
|
||||||
|
// authentic once-per-cycle behaviour.
|
||||||
|
//
|
||||||
|
static void
|
||||||
|
BTGlassSweepLamps()
|
||||||
|
{
|
||||||
|
static int s_on = -1;
|
||||||
|
if (s_on < 0)
|
||||||
|
{
|
||||||
|
const char *e = getenv("BT_GLASS_LAMP_SWEEP");
|
||||||
|
s_on = (e != NULL && e[0] == '0') ? 0 : 1;
|
||||||
|
}
|
||||||
|
if (!s_on) return;
|
||||||
|
|
||||||
|
GaugeRenderer *gr = BTResolveGaugeRenderer();
|
||||||
|
if (gr == NULL || application == NULL) return;
|
||||||
|
LampManager *lm = gr->GetLampManager();
|
||||||
|
ModeManager *mm = application->GetModeManager();
|
||||||
|
if (lm != NULL && mm != NULL)
|
||||||
|
lm->Update(mm->GetModeMask());
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Free entry point the MAIN renderer calls (L4VIDEO DPLRenderer::ExecuteImplementation,
|
// 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
|
// just before EndScene). No-op unless BT_DEV_GAUGES is set. Docks the 6-surface panel
|
||||||
@@ -1292,7 +1421,9 @@ void BTCockpitMouseUp(void)
|
|||||||
//
|
//
|
||||||
void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device)
|
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 (GlassPanelsOwnSurfaces()) return; // the per-display glass windows draw the surfaces
|
||||||
if (gBTGaugeCockpit)
|
if (gBTGaugeCockpit)
|
||||||
{
|
{
|
||||||
@@ -5343,6 +5474,9 @@ SVGA16::SVGA16(
|
|||||||
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
|
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
|
||||||
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
|
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
|
||||||
mDevSurfaceTex[_i] = NULL;
|
mDevSurfaceTex[_i] = NULL;
|
||||||
|
scrambleActive = False; // PPC sync-scramble (phase-14) -- armed by FunkyVideo
|
||||||
|
scrambleStartMs = 0;
|
||||||
|
scrambleDurationS = 0.0;
|
||||||
//STUBBED: VIDEO RB 1/15/07
|
//STUBBED: VIDEO RB 1/15/07
|
||||||
# if defined(DEBUG)
|
# if defined(DEBUG)
|
||||||
Tell("SVGA16::SVGA16()\n");
|
Tell("SVGA16::SVGA16()\n");
|
||||||
@@ -6302,13 +6436,153 @@ void
|
|||||||
Check_Fpu();
|
Check_Fpu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// PPC sync-scramble (phase-14), animated as a RECOVERY. The original
|
||||||
|
// (`//STUBBED: VIDEO RB 1/15/07`) detuned the VGA CRTC Horizontal Total so every
|
||||||
|
// secondary monitor lost horizontal lock for ~0.8 s. There is no CRTC here, so
|
||||||
|
// FunkyVideo records the start + duration and the per-surface expansions
|
||||||
|
// (DrawDevSurface / ExpandPlaneToBGRA) map the pixelBuffer read through the
|
||||||
|
// ScrambleParams envelope: at the hit the image COLLAPSES horizontally toward a
|
||||||
|
// line, then BROADENS back out as the tear + roll DECAY, and LOCKS to the full
|
||||||
|
// clean display at the end. The 0.8 s window + non-stacking latch stay in
|
||||||
|
// L4GaugeRenderer; the shape lives here.
|
||||||
|
//
|
||||||
void
|
void
|
||||||
SVGA16::FunkyVideo(Logical on_off)
|
SVGA16::FunkyVideo(Logical on_off, Scalar duration)
|
||||||
{
|
{
|
||||||
//STUBBED: VIDEO RB 1/15/07
|
if (on_off)
|
||||||
//Check(this);
|
{
|
||||||
//SVGAFunkyVideo(on_off);
|
scrambleActive = True;
|
||||||
//Check_Fpu();
|
scrambleStartMs = GetTickCount();
|
||||||
|
scrambleDurationS = (duration > 0.0f) ? (double)duration : 0.8; // the HOLD
|
||||||
|
}
|
||||||
|
// FunkyVideo(False) is a NO-OP: the card restoring the sync (at HOLD end) is
|
||||||
|
// where the RECOVERY begins, so ScrambleParams keeps running past the flag,
|
||||||
|
// self-terminating at hold+recovery. scrambleActive stays armed; the clock gates.
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Two-phase envelope (per the "hold the bad sync, THEN recover" direction):
|
||||||
|
// HOLD [0, hold] -- the card holds the detuned CRTC. Deep collapse (a line),
|
||||||
|
// and a WILDLY fast horizontal roll that decelerates over
|
||||||
|
// the hold (content scrolls, wrapping, so it can't be read).
|
||||||
|
// RECOVERY [hold, +] -- the sync is restored and the monitor re-locks: the line
|
||||||
|
// BROADENS back to full while the residual scroll + tear
|
||||||
|
// settle to zero, then LOCKS (returns False = clean).
|
||||||
|
// Fills the read-loop parameters: scale (collapse band width fraction), rollOff
|
||||||
|
// (SCROLL offset in px, wrapped within the source), shear (px/row diagonal),
|
||||||
|
// centerX. Tunables (read once): BT_SCRAMBLE_COLLAPSE (min band, deeper=smaller),
|
||||||
|
// BT_SCRAMBLE_ROLL (initial scroll px/sec -- "wildly high"), BT_SCRAMBLE_SHEAR
|
||||||
|
// (px/row), BT_SCRAMBLE_RECOVER (recovery seconds), BT_SCRAMBLE_DUR (hold seconds
|
||||||
|
// override). BT_SCRAMBLE_TEST loops it; BT_SCRAMBLE_CYCLE loops stepping the roll.
|
||||||
|
//
|
||||||
|
Logical
|
||||||
|
SVGA16::ScrambleParams(int width, double *scale, double *shear,
|
||||||
|
double *rollOff, int *centerX, int *shakeRow) const
|
||||||
|
{
|
||||||
|
*shakeRow = 0;
|
||||||
|
if (width <= 0)
|
||||||
|
return False;
|
||||||
|
|
||||||
|
static int sInit = 0, sTest = 0, sCycle = 0;
|
||||||
|
static double shearMax = 3.0, rollMax = 9000.0, collapse = 0.03,
|
||||||
|
recover = 0.5, holdEnv = 0.0, shakeAmp = 10.0;
|
||||||
|
if (!sInit)
|
||||||
|
{
|
||||||
|
sInit = 1;
|
||||||
|
sTest = (getenv("BT_SCRAMBLE_TEST") != NULL) ? 1 : 0;
|
||||||
|
sCycle = (getenv("BT_SCRAMBLE_CYCLE") != NULL) ? 1 : 0;
|
||||||
|
const char *e;
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_SHEAR")) != NULL) shearMax = atof(e);
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_ROLL")) != NULL) rollMax = atof(e);
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_COLLAPSE")) != NULL) collapse = atof(e);
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_RECOVER")) != NULL) recover = atof(e);
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_DUR")) != NULL) holdEnv = atof(e);
|
||||||
|
if ((e = getenv("BT_SCRAMBLE_SHAKE")) != NULL) shakeAmp = atof(e);
|
||||||
|
if (collapse < 0.004) collapse = 0.004;
|
||||||
|
if (collapse > 1.0) collapse = 1.0;
|
||||||
|
if (recover < 0.05) recover = 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
double hold = (holdEnv > 0.0) ? holdEnv
|
||||||
|
: (scrambleDurationS > 0.0 ? scrambleDurationS : 0.8);
|
||||||
|
double total = hold + recover;
|
||||||
|
double rlMax = rollMax;
|
||||||
|
double elapsed;
|
||||||
|
|
||||||
|
if (sTest || sCycle)
|
||||||
|
{
|
||||||
|
// Tuning: loop the whole effect (hold+recovery) + a short locked pause.
|
||||||
|
// CYCLE steps the initial roll speed each loop.
|
||||||
|
double loop = total + 0.6;
|
||||||
|
double t = (double)GetTickCount() * 0.001;
|
||||||
|
long n = (long)(t / loop);
|
||||||
|
double ph = t - (double)n * loop;
|
||||||
|
if (ph >= total)
|
||||||
|
return False; // locked pause between loops
|
||||||
|
elapsed = ph;
|
||||||
|
if (sCycle)
|
||||||
|
{
|
||||||
|
static const double kR[] = { 3000.0, 6000.0, 9000.0, 14000.0, 20000.0 };
|
||||||
|
int nS = (int)(sizeof(kR) / sizeof(kR[0]));
|
||||||
|
rlMax = kR[(int)(n % nS)];
|
||||||
|
static long lastN = -1;
|
||||||
|
if (n != lastN) { lastN = n;
|
||||||
|
DEBUG_STREAM << "[scramble-cycle] loop " << n << " rollMax=" << rlMax
|
||||||
|
<< "px/s (hold " << hold << "s + recover " << recover << "s)\n"
|
||||||
|
<< std::flush; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!scrambleActive)
|
||||||
|
return False;
|
||||||
|
elapsed = ((double)(GetTickCount() - scrambleStartMs)) * 0.001;
|
||||||
|
if (elapsed >= total)
|
||||||
|
return False; // LOCKED -- clean display
|
||||||
|
}
|
||||||
|
if (elapsed < 0.0) elapsed = 0.0;
|
||||||
|
|
||||||
|
double sScale, sScroll, sShear;
|
||||||
|
if (elapsed < hold)
|
||||||
|
{
|
||||||
|
// HOLD: bad sync held. Deep collapse; roll starts wild and decelerates to a
|
||||||
|
// stop by the hold end. scroll = integral of v(t)=rlMax*(1-t/hold): the
|
||||||
|
// content scrolls fast then coasts to rest (rlMax*hold/2 accumulated).
|
||||||
|
sScale = collapse;
|
||||||
|
sScroll = rlMax * elapsed * (1.0 - elapsed / (2.0 * hold));
|
||||||
|
sShear = shearMax;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// RECOVERY: sync restored, monitor re-locks. Broaden line->full and settle
|
||||||
|
// the residual scroll + tear to zero (smoothstep), then it hits total = lock.
|
||||||
|
double tr = (elapsed - hold) / recover;
|
||||||
|
double b = tr * tr * (3.0 - 2.0 * tr);
|
||||||
|
sScale = collapse + (1.0 - collapse) * b;
|
||||||
|
sScroll = (rlMax * hold * 0.5) * (1.0 - b);
|
||||||
|
sShear = shearMax * (1.0 - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHAKE: a per-FRAME jitter (LCG, so all surfaces shake together within a frame
|
||||||
|
// but re-roll each frame), violent at the hit and fading through the recovery.
|
||||||
|
// Horizontal folds into the scroll; vertical bounces the source row (shakeRow).
|
||||||
|
double shakeEnv = (elapsed < hold)
|
||||||
|
? (1.0 - 0.5 * (elapsed / hold)) // 1.0 at impact -> 0.5 at hold end
|
||||||
|
: (0.5 * (1.0 - (elapsed - hold) / recover)); // 0.5 -> 0 through recovery
|
||||||
|
if (shakeEnv < 0.0) shakeEnv = 0.0;
|
||||||
|
unsigned int fr = (unsigned int)(GetTickCount() / 16); // ~per-frame index
|
||||||
|
unsigned int r = fr * 1103515245u + 12345u;
|
||||||
|
double jX = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0; // [-1,1)
|
||||||
|
r = r * 1103515245u + 12345u;
|
||||||
|
double jY = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0;
|
||||||
|
|
||||||
|
*scale = sScale;
|
||||||
|
*shear = sShear;
|
||||||
|
*rollOff = sScroll + shakeAmp * shakeEnv * jX; // horizontal shake -> scroll
|
||||||
|
*centerX = width / 2;
|
||||||
|
*shakeRow = (int)(shakeAmp * shakeEnv * jY + (jY >= 0.0 ? 0.5 : -0.5)); // vertical bounce
|
||||||
|
return True;
|
||||||
}
|
}
|
||||||
|
|
||||||
//########################################################################
|
//########################################################################
|
||||||
|
|||||||
@@ -332,7 +332,27 @@ public:
|
|||||||
UnflashPalette(int palette_number);
|
UnflashPalette(int palette_number);
|
||||||
|
|
||||||
void
|
void
|
||||||
FunkyVideo(Logical on_off);
|
FunkyVideo(Logical on_off, Scalar duration = 0.0f);
|
||||||
|
|
||||||
|
// PPC sync-scramble (phase-14): the modern stand-in for the VGA CRTC
|
||||||
|
// Horizontal-Total detune, animated as a RECOVERY over the effect window -- at
|
||||||
|
// the hit the image collapses horizontally toward a line, then broadens back
|
||||||
|
// out as the tear + roll decay, and LOCKS to the full clean display at the end.
|
||||||
|
// FunkyVideo(on, dur) records the start + duration; ScrambleParams derives the
|
||||||
|
// per-frame envelope (returns True while active + fills scale/shear/rollOff/
|
||||||
|
// centerX); DrawDevSurface (surround) + ExpandPlaneToBGRA (glass) map the
|
||||||
|
// pixelBuffer read through it, so all secondary surfaces recover together and
|
||||||
|
// the main 3D view is untouched. Tunable by eye: BT_SCRAMBLE_SHEAR (max
|
||||||
|
// px/line), BT_SCRAMBLE_ROLL (max px/sec), BT_SCRAMBLE_COLLAPSE (min width
|
||||||
|
// fraction 0..1), BT_SCRAMBLE_DUR (anim seconds override). BT_SCRAMBLE_TEST
|
||||||
|
// loops the transition for tuning; BT_SCRAMBLE_CYCLE loops it stepping the
|
||||||
|
// max shear. (Exact look depended on each pod monitor's PLL -- not recoverable.)
|
||||||
|
Logical
|
||||||
|
ScrambleParams(int width, double *scale, double *shear,
|
||||||
|
double *rollOff, int *centerX, int *shakeRow) const;
|
||||||
|
Logical scrambleActive;
|
||||||
|
unsigned long scrambleStartMs;
|
||||||
|
double scrambleDurationS;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
|
||||||
|
|||||||
+188
-38
@@ -745,6 +745,7 @@ struct BTPfxEmitter
|
|||||||
struct BTPfxParticle
|
struct BTPfxParticle
|
||||||
{
|
{
|
||||||
D3DXVECTOR3 pos, vel, accel;
|
D3DXVECTOR3 pos, vel, accel;
|
||||||
|
int defIndex; // census attribution (#114 / wreck-smoke tail)
|
||||||
float age, life;
|
float age, life;
|
||||||
float rad, exp, dexp;
|
float rad, exp, dexp;
|
||||||
float colorWarp, alphaWarp; // def-level warps, carried per particle
|
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);
|
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)
|
void BTStartPfx(int effect_number, float x, float y, float z)
|
||||||
{
|
{
|
||||||
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
|
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
|
||||||
@@ -964,6 +998,7 @@ static void BTPfxSpawn(const BTPfxEmitter &e)
|
|||||||
// laser bursts during missile volleys)
|
// laser bursts during missile volleys)
|
||||||
return;
|
return;
|
||||||
BTPfxParticle p;
|
BTPfxParticle p;
|
||||||
|
p.defIndex = (int)(e.def - gBTPfxDefs); // census attribution
|
||||||
// Sample in the effect's LOCAL frame, then orient into the world through
|
// Sample in the effect's LOCAL frame, then orient into the world through
|
||||||
// the emitter's basis (the victim's localToWorld rows). The position
|
// the emitter's basis (the victim's localToWorld rows). The position
|
||||||
// jitter pv is an isotropic scatter about the base offset -> symmetric
|
// 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)
|
for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei)
|
||||||
{
|
{
|
||||||
BTPfxEmitter &e = gBTPfxEmitters[ei];
|
BTPfxEmitter &e = gBTPfxEmitters[ei];
|
||||||
@@ -4325,6 +4361,12 @@ void
|
|||||||
fogFar = searchLightFogFar;
|
fogFar = searchLightFogFar;
|
||||||
if(fogUpdating)
|
if(fogUpdating)
|
||||||
{
|
{
|
||||||
|
// BT (searchlight, 2026-08-05): the stubbed dpl_SetViewFog below also
|
||||||
|
// carried the PLANES; the per-frame FOGSTART/FOGEND application reads
|
||||||
|
// currentFogNear/Far, so the swap must land there too (the color line
|
||||||
|
// alone left the fog DISTANCES stuck at load values).
|
||||||
|
currentFogNear = fogNear;
|
||||||
|
currentFogFar = fogFar;
|
||||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||||
// dpl_SetViewFog(
|
// dpl_SetViewFog(
|
||||||
// dplMainView,
|
// dplMainView,
|
||||||
@@ -4345,6 +4387,8 @@ void
|
|||||||
fogFar = noSearchLightFogFar;
|
fogFar = noSearchLightFogFar;
|
||||||
if(fogUpdating)
|
if(fogUpdating)
|
||||||
{
|
{
|
||||||
|
currentFogNear = fogNear; // see the On case
|
||||||
|
currentFogFar = fogFar;
|
||||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||||
// dpl_SetViewFog(
|
// dpl_SetViewFog(
|
||||||
// dplMainView,
|
// dplMainView,
|
||||||
@@ -8197,6 +8241,75 @@ static void BTVerifyCockpitCanvasAfterReset(LPDIRECT3DDEVICE9 device)
|
|||||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
// Execute Method, performs the rendering of one frame
|
// 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)
|
void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator)
|
||||||
{
|
{
|
||||||
Component *component;
|
Component *component;
|
||||||
@@ -8847,6 +8960,17 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
|||||||
extern void BTGaugeWindowRenderAndPresent(LPDIRECT3DDEVICE9 device);
|
extern void BTGaugeWindowRenderAndPresent(LPDIRECT3DDEVICE9 device);
|
||||||
BTGaugeWindowRenderAndPresent(mDevice);
|
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.
|
// DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU.
|
||||||
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
|
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
|
||||||
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
|
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
|
||||||
@@ -8913,27 +9037,71 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
|||||||
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
|
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 (hr == D3DERR_DEVICELOST)
|
||||||
{
|
{
|
||||||
if (mWaitOverlaySurface != NULL)
|
BTResetLostDevice(); // #35: the guarded recovery
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ticks = HiResNowTicks();
|
ticks = HiResNowTicks();
|
||||||
@@ -9199,27 +9367,9 @@ void DPLRenderer::ExecuteIdle()
|
|||||||
BTWaitScreenPaint(wait_line1, wait_line2);
|
BTWaitScreenPaint(wait_line1, wait_line2);
|
||||||
}
|
}
|
||||||
if (present_hr == D3DERR_DEVICELOST)
|
if (present_hr == D3DERR_DEVICELOST)
|
||||||
{
|
|
||||||
if (mWaitOverlaySurface != NULL)
|
|
||||||
{
|
{
|
||||||
mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: must go pre-Reset
|
BTResetLostDevice(); // #35: the guarded recovery
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ public:
|
|||||||
//
|
//
|
||||||
dpl_ZONE* MakeNewZone();
|
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 MarkDCSHiearchy(dpl_DCS *root_DCS, Entity *entity);
|
||||||
void FlushBitSliceTexture(unsigned int *local_storage);
|
void FlushBitSliceTexture(unsigned int *local_storage);
|
||||||
void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage);
|
void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage);
|
||||||
|
|||||||
@@ -350,6 +350,12 @@ struct MatInfo {
|
|||||||
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
|
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
|
||||||
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
|
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
|
||||||
bool hasEmissive = false;
|
bool hasEmissive = false;
|
||||||
|
// BTFX "brighten" class (SPOT.BGF searchlight beam cone): the material
|
||||||
|
// smuggles its ADDITIVE factor in DIFFUSE.r (brighten.25 authors diffuse
|
||||||
|
// {0.25,0,0}) and its glow colour in EMISSIVE ({0.9,0.4,0.2} on the night
|
||||||
|
// page). 0 = not a brighten material; >0 = draw as an additive veil,
|
||||||
|
// dest += emissive x factor (the 1995 DIV brighten semantics).
|
||||||
|
float brightenFactor = 0.0f;
|
||||||
float emissive[3] = {0,0,0};
|
float emissive[3] = {0,0,0};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -495,12 +501,14 @@ struct MaterialResolver {
|
|||||||
std::string name, texName, rampName;
|
std::string name, texName, rampName;
|
||||||
MatInfo info;
|
MatInfo info;
|
||||||
bool haveColor = false;
|
bool haveColor = false;
|
||||||
|
float rawDiffuseR = -1.0f; // brighten-class factor carrier
|
||||||
for (const Chunk& ch : c.children) {
|
for (const Chunk& ch : c.children) {
|
||||||
if (ch.id == TAG_NAME) name = chunkStr(ch);
|
if (ch.id == TAG_NAME) name = chunkStr(ch);
|
||||||
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
|
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
|
||||||
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
|
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
|
||||||
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
|
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
|
||||||
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
|
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
|
||||||
|
rawDiffuseR = rdF32(ch.data);
|
||||||
haveColor = true;
|
haveColor = true;
|
||||||
info.hasDiffuse = true;
|
info.hasDiffuse = true;
|
||||||
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor
|
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor
|
||||||
@@ -520,6 +528,12 @@ struct MaterialResolver {
|
|||||||
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
|
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// BTFX brighten class: name-gated so ordinary materials keep
|
||||||
|
// their diffuse semantics untouched (the factor rides only in
|
||||||
|
// "brighten*" records -- brighten.25/.5/.75 in btfx.bmf).
|
||||||
|
if (name.compare(0, 8, "brighten") == 0 && rawDiffuseR > 0.0f)
|
||||||
|
info.brightenFactor = rawDiffuseR;
|
||||||
|
|
||||||
// Resolve the ramp reference to its low/high colours: this
|
// Resolve the ramp reference to its low/high colours: this
|
||||||
// file's own definitions first, then the cross-library
|
// file's own definitions first, then the cross-library
|
||||||
// registry (mech skins reference 'softer' defined elsewhere).
|
// registry (mech skins reference 'softer' defined elsewhere).
|
||||||
@@ -619,6 +633,9 @@ struct Builder {
|
|||||||
uint32_t currentColor = 0xFFB0B0B8u;
|
uint32_t currentColor = 0xFFB0B0B8u;
|
||||||
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
|
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
|
||||||
std::string currentTex;
|
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)
|
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
|
||||||
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
|
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
|
||||||
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
|
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
|
||||||
@@ -637,10 +654,12 @@ struct Builder {
|
|||||||
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
|
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
|
||||||
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
|
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
|
||||||
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
|
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
|
||||||
|
std::string meshStem; // lowercased load stem (BT_MAT_LOG diag)
|
||||||
std::map<const void*, int> copRoleMap; // punch-kit roles per PMESH chunk: 1=mask 2=hull 3=skip (see TAG_PATCH)
|
std::map<const void*, int> copRoleMap; // punch-kit roles per PMESH chunk: 1=mask 2=hull 3=skip (see TAG_PATCH)
|
||||||
int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
|
int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
|
||||||
bool currentHasEmissive = false;
|
bool currentHasEmissive = false;
|
||||||
float currentEmissive[3] = {0,0,0};
|
float currentEmissive[3] = {0,0,0};
|
||||||
|
float currentBrighten = 0.0f; // btfx brighten-class additive factor
|
||||||
|
|
||||||
// per-vertex scratch (normals accumulated from forward triangles only)
|
// per-vertex scratch (normals accumulated from forward triangles only)
|
||||||
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
|
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
|
||||||
@@ -775,7 +794,19 @@ struct Builder {
|
|||||||
// value is not recoverable from the code (texture-less ramp = no texel
|
// value is not recoverable from the code (texture-less ramp = no texel
|
||||||
// luminance to index); default (0.13,0.12,0.15) [T3] matches the dark
|
// luminance to index); default (0.13,0.12,0.15) [T3] matches the dark
|
||||||
// near-black frame in pod gameplay footage. BT_COP_FRAME="r g b" overrides.
|
// near-black frame in pod gameplay footage. BT_COP_FRAME="r g b" overrides.
|
||||||
if (useRamp && currentTex.empty() && meshIsCop) {
|
// The same interior-structure treatment applies to the blakskn SKIN
|
||||||
|
// family on ANY mesh, not just *_cop shells: the inside skeletons
|
||||||
|
// author pilot-facing surfaces of the OWN-BODY meshes (thor thx_msl
|
||||||
|
// missile pod, owens owx_* legs -- issue #91) with the identical
|
||||||
|
// "<pfx>skin:blakskn_dz_*" material the canopy frame uses, and the
|
||||||
|
// board shaded them all the same way. Keying on the filename alone
|
||||||
|
// left those surfaces vcol=pure-black -- the reported "black
|
||||||
|
// rectangle" was the thor pod's blakskn mount plate at RGB(0,0,0)
|
||||||
|
// instead of the frame constant. mechfx:blakskn_mtl (the tshd
|
||||||
|
// shadow quads) must NOT match -- key on the skin-lib "_dz_" form.
|
||||||
|
const bool blaksknSkin =
|
||||||
|
currentMatName.find("skin:blakskn_dz_") != std::string::npos;
|
||||||
|
if (useRamp && currentTex.empty() && (meshIsCop || blaksknSkin)) {
|
||||||
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
|
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
|
||||||
vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
|
vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
|
||||||
}
|
}
|
||||||
@@ -872,6 +903,7 @@ struct Builder {
|
|||||||
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
|
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
|
||||||
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
|
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
|
||||||
batch.texPath = currentTex;
|
batch.texPath = currentTex;
|
||||||
|
batch.matName = currentMatName; // .DZM armour-damage key (issue #87)
|
||||||
batch.texChannel = currentTexChannel;
|
batch.texChannel = currentTexChannel;
|
||||||
batch.texScroll = currentTexScroll;
|
batch.texScroll = currentTexScroll;
|
||||||
batch.texScrollU = currentTexScrollU;
|
batch.texScrollU = currentTexScrollU;
|
||||||
@@ -909,6 +941,7 @@ struct Builder {
|
|||||||
batch.shadowMat = currentShadowMat;
|
batch.shadowMat = currentShadowMat;
|
||||||
batch.hasEmissive = currentHasEmissive;
|
batch.hasEmissive = currentHasEmissive;
|
||||||
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
|
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
|
||||||
|
batch.brightenFactor = currentBrighten; // btfx additive veil (spot cone)
|
||||||
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
|
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
|
||||||
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
|
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
|
||||||
// PUNCH-cutout overlay + an inner shell, plane separations
|
// PUNCH-cutout overlay + an inner shell, plane separations
|
||||||
@@ -935,6 +968,28 @@ struct Builder {
|
|||||||
else
|
else
|
||||||
batch.lodBias = 0.0f;
|
batch.lodBias = 0.0f;
|
||||||
mesh->batches.push_back(batch);
|
mesh->batches.push_back(batch);
|
||||||
|
// #91 diag: BT_MAT_LOG=<substr> dumps each batch of a matching mesh
|
||||||
|
// stem -- the material routing decision (ramp vs lit vs emissive,
|
||||||
|
// resolved colour, texture) for the inside-view black-mesh hunt.
|
||||||
|
{
|
||||||
|
static const char* s_matLog = getenv("BT_MAT_LOG");
|
||||||
|
if (s_matLog && *s_matLog
|
||||||
|
&& meshStem.find(s_matLog) != std::string::npos)
|
||||||
|
fprintf(stderr, "[matlog] %s mat=%s color=%08X hasDiff=%d "
|
||||||
|
"tex='%s' ch=%d hasRamp=%d useRamp=%d norms=%d "
|
||||||
|
"emis=%d vcol=%08X tris=%u\n",
|
||||||
|
meshStem.c_str(), currentMatName.c_str(), currentColor,
|
||||||
|
(int)currentHasDiffuse, currentTex.c_str(),
|
||||||
|
currentTexChannel, (int)currentHasRamp, (int)useRamp,
|
||||||
|
(int)hasNormals(vtag), (int)pureEmissive, vcol,
|
||||||
|
idxCount / 3);
|
||||||
|
if (s_matLog && *s_matLog
|
||||||
|
&& meshStem.find(s_matLog) != std::string::npos)
|
||||||
|
fprintf(stderr, "[matlog] ramp lo=(%.3f,%.3f,%.3f) "
|
||||||
|
"hi=(%.3f,%.3f,%.3f)\n",
|
||||||
|
currentRampLo[0], currentRampLo[1], currentRampLo[2],
|
||||||
|
currentRampHi[0], currentRampHi[1], currentRampHi[2]);
|
||||||
|
}
|
||||||
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
|
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
|
||||||
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
|
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
|
||||||
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
|
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
|
||||||
@@ -1185,6 +1240,9 @@ struct Builder {
|
|||||||
size_t maxn = ch.len - 1, n = 0;
|
size_t maxn = ch.len - 1, n = 0;
|
||||||
while (n < maxn && s[n]) ++n;
|
while (n < maxn && s[n]) ++n;
|
||||||
std::string full(s, 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.)
|
// baked ground-shadow material (basev:shadow_mtl etc.)
|
||||||
currentShadowMat = false;
|
currentShadowMat = false;
|
||||||
for (size_t si = 0; si + 6 <= full.size(); ++si)
|
for (size_t si = 0; si + 6 <= full.size(); ++si)
|
||||||
@@ -1209,8 +1267,9 @@ struct Builder {
|
|||||||
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
|
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
|
||||||
currentHasEmissive = info.hasEmissive;
|
currentHasEmissive = info.hasEmissive;
|
||||||
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
|
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
|
||||||
|
currentBrighten = info.brightenFactor;
|
||||||
}
|
}
|
||||||
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; }
|
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; currentBrighten = 0.0f; }
|
||||||
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
|
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
|
||||||
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
|
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
|
||||||
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
|
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
|
||||||
@@ -1379,6 +1438,7 @@ bool LoadBgfFile(const std::string& name, BgfData& out) {
|
|||||||
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
|
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
|
||||||
// these meshes only.
|
// these meshes only.
|
||||||
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
|
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
|
||||||
|
b.meshStem = stemLower(name);
|
||||||
b.res = &res;
|
b.res = &res;
|
||||||
for (const Chunk& c : roots) b.collect(c);
|
for (const Chunk& c : roots) b.collect(c);
|
||||||
b.finish();
|
b.finish();
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ struct BgfDrawBatch {
|
|||||||
// alpha). The verts keep their authored RGBA gradient and the draw routes
|
// alpha). The verts keep their authored RGBA gradient and the draw routes
|
||||||
// to the alpha-blend pass, unlit, colour = texture x vertex gradient.
|
// to the alpha-blend pass, unlit, colour = texture x vertex gradient.
|
||||||
bool vertexAlpha = false;
|
bool vertexAlpha = false;
|
||||||
|
// BTFX "brighten" ADDITIVE VEIL (the searchlight SPOT.BGF beam cone): the
|
||||||
|
// material class authors its factor in DIFFUSE.r and its glow colour in
|
||||||
|
// EMISSIVE; the batch draws in the blend pass as dest += emissive x factor
|
||||||
|
// (1995 DIV brighten semantics; the record also authors fog IMMUNE +
|
||||||
|
// DRAWLAST, both satisfied by the blend pass). 0 = not a brighten batch.
|
||||||
|
float brightenFactor = 0.0f;
|
||||||
// TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
|
// TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
|
||||||
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
|
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
|
||||||
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
|
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
|
||||||
@@ -106,6 +112,17 @@ struct BgfDrawBatch {
|
|||||||
// routed through the mech-shadow pipeline (translucent dark, depth-biased,
|
// routed through the mech-shadow pipeline (translucent dark, depth-biased,
|
||||||
// no z-write) instead.
|
// no z-write) instead.
|
||||||
bool shadowMat = false;
|
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 {
|
struct BgfData {
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ struct L4RAMP
|
|||||||
float r1, g1, b1;
|
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
|
struct L4DRAWOP
|
||||||
{
|
{
|
||||||
D3DMATERIAL9 material;
|
D3DMATERIAL9 material;
|
||||||
@@ -103,6 +109,11 @@ struct L4DRAWOP
|
|||||||
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
|
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
|
||||||
// per-vertex fade (SRCALPHA/INVSRCALPHA).
|
// per-vertex fade (SRCALPHA/INVSRCALPHA).
|
||||||
bool vertexAlphaBlend;
|
bool vertexAlphaBlend;
|
||||||
|
// BTFX BRIGHTEN VEIL (the searchlight SPOT.BGF cone): additive brighten of
|
||||||
|
// the framebuffer, dest += material.Emissive x this factor (the class
|
||||||
|
// authors the factor in DIFFUSE.r, the glow colour in EMISSIVE). Drawn in
|
||||||
|
// the alpha-blend pass, unlit, fog-immune by authoring. 0 = off.
|
||||||
|
float brightenAlpha;
|
||||||
// COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
|
// COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
|
||||||
// 1=aperture MASK (drawn stencil-only, paired with the following hull op),
|
// 1=aperture MASK (drawn stencil-only, paired with the following hull op),
|
||||||
// 2=HULL (drawn stencil-rejected under the mask = window cutouts).
|
// 2=HULL (drawn stencil-rejected under the mask = window cutouts).
|
||||||
@@ -112,6 +123,18 @@ struct L4DRAWOP
|
|||||||
// coplanar duplicated surfaces resolve to the detail layer instead of
|
// coplanar duplicated surfaces resolve to the detail layer instead of
|
||||||
// venetian-blind z-fighting (the board's submission-order rule, in D3D terms).
|
// venetian-blind z-fighting (the board's submission-order rule, in D3D terms).
|
||||||
float lodDepthBias;
|
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
|
class d3d_OBJECT
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ void
|
|||||||
void
|
void
|
||||||
BTGlassPanels_Destroy();
|
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
|
// True when BT_GLASS_PANELS mode is selected (default ON under `-platform
|
||||||
// glass`, OFF otherwise). Read by the render loop / L4VB16 dev-composite to
|
// 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
|
// (end_of_frame = now + 1/frameRate) degenerated and the game ran at a
|
||||||
// timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware.
|
// timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware.
|
||||||
// Default 60; env TARGETFPS still overrides (clamped to sanity).
|
// Default 60; env TARGETFPS still overrides (clamped to sanity).
|
||||||
int target_fps = 30; // the pod's authentic rate; frame work (~20-40ms in
|
int target_fps = 30; // NOTE: the pod's byte-proven NOMINAL rate is 28
|
||||||
// this build) misses 60Hz beats -> jitter, but holds
|
// (DAT_0052140c = 28.0f @0x401ace; 18.2065 BIOS
|
||||||
// 30 rock-steady. TARGETFPS env still overrides.
|
// 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");
|
const char *tf = getenv("TARGETFPS");
|
||||||
if (tf != 0)
|
if (tf != 0)
|
||||||
@@ -1539,6 +1544,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
|||||||
|
|
||||||
app_manager->RunMissions();
|
app_manager->RunMissions();
|
||||||
std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush;
|
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
|
// 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
|
// 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
|
// JOIN trim: the mech list + the JOIN button; callsign is the edit
|
||||||
// control (repositioned in BTFrontEnd_Run). Everything else is the
|
// control (repositioned in BTFrontEnd_Run). Everything else is the
|
||||||
// operator's call.
|
// operator's call -- EXCEPT experience, which is PER-PLAYER by the
|
||||||
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y);
|
// 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);
|
AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1173,6 +1183,8 @@ int
|
|||||||
strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key);
|
strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key);
|
||||||
strcpy(self.color, kColors[menu.selection[GroupColor]].key);
|
strcpy(self.color, kColors[menu.selection[GroupColor]].key);
|
||||||
strcpy(self.experience, kExperience[menu.selection[GroupExperience]].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.badge, kBadges[menu.selection[GroupBadge]].key);
|
||||||
strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key);
|
strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key);
|
||||||
strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key);
|
strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key);
|
||||||
@@ -1187,7 +1199,7 @@ int
|
|||||||
if (menu.steamAction == 1)
|
if (menu.steamAction == 1)
|
||||||
{
|
{
|
||||||
BTLobbyRoster roster;
|
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),
|
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||||
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
||||||
roster.memberCount == 0)
|
roster.memberCount == 0)
|
||||||
@@ -1208,6 +1220,12 @@ int
|
|||||||
strncpy(pilot.color,
|
strncpy(pilot.color,
|
||||||
member.color[0] ? member.color : kColors[0].key,
|
member.color[0] ? member.color : kColors[0].key,
|
||||||
sizeof(pilot.color) - 1);
|
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",
|
sprintf(pilot.address, "%s:%d",
|
||||||
member.fakeAddress, member.gamePort);
|
member.fakeAddress, member.gamePort);
|
||||||
if (!member.isSelf)
|
if (!member.isSelf)
|
||||||
@@ -1226,7 +1244,7 @@ int
|
|||||||
}
|
}
|
||||||
if (menu.steamAction == 2)
|
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->steamMyToken, sizeof(spec->steamMyToken),
|
||||||
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
||||||
{
|
{
|
||||||
|
|||||||
+164
-8
@@ -13,6 +13,7 @@
|
|||||||
#pragma pack(push, 8)
|
#pragma pack(push, 8)
|
||||||
#include "steam/steam_api.h"
|
#include "steam/steam_api.h"
|
||||||
#include "steam/isteammatchmaking.h"
|
#include "steam/isteammatchmaking.h"
|
||||||
|
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
|
||||||
#pragma pack(pop)
|
#pragma pack(pop)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -48,6 +49,50 @@ static void
|
|||||||
}
|
}
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
|
|
||||||
|
//
|
||||||
|
// A rejection the PLAYER must see goes through LobbyNotice: the same text
|
||||||
|
// lands in the day log (newlines flattened so the line stays greppable)
|
||||||
|
// AND in a blocking message box. These paths are synchronous FE flows --
|
||||||
|
// a bare return here QUITS the exe, and #68 taught us a player bounced
|
||||||
|
// with only a log line reports "the game just closed".
|
||||||
|
//
|
||||||
|
static void
|
||||||
|
LobbyNotice(const char *format, ...)
|
||||||
|
{
|
||||||
|
char text[512];
|
||||||
|
va_list arguments;
|
||||||
|
va_start(arguments, format);
|
||||||
|
_vsnprintf(text, sizeof(text) - 1, format, arguments);
|
||||||
|
text[sizeof(text) - 1] = 0;
|
||||||
|
va_end(arguments);
|
||||||
|
{
|
||||||
|
char flat[512];
|
||||||
|
int n = 0;
|
||||||
|
for (const char *s = text; *s && n < 510; ++s)
|
||||||
|
{
|
||||||
|
if (*s == '\n')
|
||||||
|
{
|
||||||
|
if (n > 0 && flat[n - 1] == ' ')
|
||||||
|
continue;
|
||||||
|
flat[n++] = ' ';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
flat[n++] = *s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flat[n] = 0;
|
||||||
|
LobbyLog("NOTICE: %s", flat);
|
||||||
|
}
|
||||||
|
WCHAR wide[512];
|
||||||
|
int n = 0;
|
||||||
|
for (const char *s = text; *s && n < 511; ++s)
|
||||||
|
wide[n++] = (WCHAR)*s;
|
||||||
|
wide[n] = 0;
|
||||||
|
MessageBoxW(NULL, wide, L"BATTLETECH -- STEAM LOBBY",
|
||||||
|
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
|
||||||
|
}
|
||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// Synchronous Steam call-result helper (manual polling -- no callback
|
// Synchronous Steam call-result helper (manual polling -- no callback
|
||||||
// template machinery in this C-style TU).
|
// template machinery in this C-style TU).
|
||||||
@@ -83,7 +128,8 @@ static int
|
|||||||
static CSteamID currentLobby;
|
static CSteamID currentLobby;
|
||||||
|
|
||||||
static void
|
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
|
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||||
@@ -93,6 +139,12 @@ static void
|
|||||||
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
||||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||||
|
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||||
|
// BUILD GATE, member half: publish our exact build so the HOST can
|
||||||
|
// reject mismatches at GO time. This is what catches OLD exes -- they
|
||||||
|
// predate the joiner-side lobby filter, but they can't fake a "bv" key
|
||||||
|
// they never set.
|
||||||
|
matchmaking->SetLobbyMemberData(currentLobby, "bv", BT_VERSION_STRING);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int
|
static int
|
||||||
@@ -118,6 +170,14 @@ static int
|
|||||||
strncpy(out->color,
|
strncpy(out->color,
|
||||||
matchmaking->GetLobbyMemberData(lobby, member, "cl"),
|
matchmaking->GetLobbyMemberData(lobby, member, "cl"),
|
||||||
sizeof(out->color) - 1);
|
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());
|
out->isSelf = (member == SteamUser()->GetSteamID());
|
||||||
if (out->name[0] == 0)
|
if (out->name[0] == 0)
|
||||||
{
|
{
|
||||||
@@ -176,10 +236,17 @@ static void
|
|||||||
int n = 0;
|
int n = 0;
|
||||||
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
|
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
|
||||||
name[n] = 0;
|
name[n] = 0;
|
||||||
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
|
// BUILD GATE: flag mismatched members in the roster so the host
|
||||||
|
// sees WHO won't launch before pressing GO (the log alone left
|
||||||
|
// the operator guessing on field nights).
|
||||||
|
const char *bv = matchmaking->GetLobbyMemberData(currentLobby,
|
||||||
|
matchmaking->GetLobbyMemberByIndex(currentLobby, i), "bv");
|
||||||
|
int build_ok = (bv != NULL && strcmp(bv, BT_VERSION_STRING) == 0);
|
||||||
|
wsprintfW(line, L" %d. %-16s %hs, %hs%s%s", i + 1, name,
|
||||||
member.vehicle[0] ? member.vehicle : "mech",
|
member.vehicle[0] ? member.vehicle : "mech",
|
||||||
member.color[0] ? member.color : "-",
|
member.color[0] ? member.color : "-",
|
||||||
member.isSelf ? L" (you)" : L"");
|
member.isSelf ? L" (you)" : L"",
|
||||||
|
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -312,6 +379,7 @@ static int
|
|||||||
int
|
int
|
||||||
BTLobby_HostAndRoom(
|
BTLobby_HostAndRoom(
|
||||||
const char *pilot_name, const char *vehicle, const char *color,
|
const char *pilot_name, const char *vehicle, const char *color,
|
||||||
|
const char *experience,
|
||||||
BTLobbyRoster *roster_out,
|
BTLobbyRoster *roster_out,
|
||||||
char *my_token_out, int my_token_capacity,
|
char *my_token_out, int my_token_capacity,
|
||||||
char *steam_map_out, int steam_map_capacity)
|
char *steam_map_out, int steam_map_capacity)
|
||||||
@@ -335,7 +403,13 @@ int
|
|||||||
}
|
}
|
||||||
currentLobby = created.m_ulSteamIDLobby;
|
currentLobby = created.m_ulSteamIDLobby;
|
||||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
||||||
PublishSelf(pilot_name, vehicle, color);
|
// BUILD GATE: stamp the host's exact build on the lobby. Joiners filter
|
||||||
|
// on equality (below) and verify after entry -- a mixed-build lobby
|
||||||
|
// silently corrupts raw-struct replication (the night-9 ghost confound:
|
||||||
|
// one stale-zip player desyncs one connection's stream). Same build or
|
||||||
|
// no entry.
|
||||||
|
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
|
||||||
|
PublishSelf(pilot_name, vehicle, color, experience);
|
||||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||||
|
|
||||||
if (RunRoom(1) != 0)
|
if (RunRoom(1) != 0)
|
||||||
@@ -360,6 +434,25 @@ int
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!member.isSelf)
|
||||||
|
{
|
||||||
|
// BUILD GATE, host half: a member on a different zip (or an
|
||||||
|
// exe old enough to have no "bv" key at all) gets NO token.
|
||||||
|
// Omitted from btl4map, their client -- old builds included,
|
||||||
|
// the code predates the gate -- hits its own "the host's map
|
||||||
|
// is missing us" path and fails the join cleanly instead of
|
||||||
|
// desyncing the session (#108's stale-zip confound).
|
||||||
|
const char *bv = matchmaking->GetLobbyMemberData(
|
||||||
|
currentLobby, CSteamID(member.steamID), "bv");
|
||||||
|
if (bv == NULL || strcmp(bv, BT_VERSION_STRING) != 0)
|
||||||
|
{
|
||||||
|
LobbyLog("host: REJECT %s at GO -- their build [%s], "
|
||||||
|
"ours %s (no token minted; their join will fail)",
|
||||||
|
member.name, (bv && *bv) ? bv : "pre-gate/unknown",
|
||||||
|
BT_VERSION_STRING);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if ((pass == 0) == (member.isSelf != 0))
|
if ((pass == 0) == (member.isSelf != 0))
|
||||||
{
|
{
|
||||||
roster_out->members[roster_out->memberCount++] = member;
|
roster_out->members[roster_out->memberCount++] = member;
|
||||||
@@ -403,18 +496,26 @@ int
|
|||||||
int
|
int
|
||||||
BTLobby_JoinAndWait(
|
BTLobby_JoinAndWait(
|
||||||
const char *pilot_name, const char *vehicle, const char *color,
|
const char *pilot_name, const char *vehicle, const char *color,
|
||||||
|
const char *experience,
|
||||||
char *my_token_out, int my_token_capacity,
|
char *my_token_out, int my_token_capacity,
|
||||||
char *steam_map_out, int steam_map_capacity)
|
char *steam_map_out, int steam_map_capacity)
|
||||||
{
|
{
|
||||||
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
||||||
{
|
{
|
||||||
LobbyLog("join: Steam transport unavailable");
|
LobbyNotice("STEAM UNAVAILABLE\n\n"
|
||||||
|
"Could not reach Steam.\n"
|
||||||
|
"Is Steam running and logged in?");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
||||||
matchmaking->AddRequestLobbyListStringFilter(
|
matchmaking->AddRequestLobbyListStringFilter(
|
||||||
"btl4", "1", k_ELobbyComparisonEqual);
|
"btl4", "1", k_ELobbyComparisonEqual);
|
||||||
|
// BUILD GATE: only lobbies stamped with OUR exact build are visible. A
|
||||||
|
// stale-zip player finds nothing -- and the log names the build so the
|
||||||
|
// "no lobby found" report is self-diagnosing (#68's silent-exit lesson).
|
||||||
|
matchmaking->AddRequestLobbyListStringFilter(
|
||||||
|
"btl4ver", BT_VERSION_STRING, k_ELobbyComparisonEqual);
|
||||||
SteamAPICall_t call = matchmaking->RequestLobbyList();
|
SteamAPICall_t call = matchmaking->RequestLobbyList();
|
||||||
LobbyMatchList_t match_list;
|
LobbyMatchList_t match_list;
|
||||||
memset(&match_list, 0, sizeof(match_list));
|
memset(&match_list, 0, sizeof(match_list));
|
||||||
@@ -422,7 +523,36 @@ int
|
|||||||
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
|
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
|
||||||
match_list.m_nLobbiesMatching == 0)
|
match_list.m_nLobbiesMatching == 0)
|
||||||
{
|
{
|
||||||
LobbyLog("join: no btl4 lobby found");
|
//
|
||||||
|
// Nothing on OUR build. Probe once WITHOUT the version filter so
|
||||||
|
// the rejection is specific: "the host is on a different zip"
|
||||||
|
// beats "no lobby" when one is in fact up. Lobby data rides back
|
||||||
|
// with the list result, readable without joining. (Filters only
|
||||||
|
// apply to the next request, so the probe re-adds the game key.)
|
||||||
|
//
|
||||||
|
matchmaking->AddRequestLobbyListStringFilter(
|
||||||
|
"btl4", "1", k_ELobbyComparisonEqual);
|
||||||
|
call = matchmaking->RequestLobbyList();
|
||||||
|
memset(&match_list, 0, sizeof(match_list));
|
||||||
|
if (WaitApiCall(call, &match_list, sizeof(match_list),
|
||||||
|
LobbyMatchList_t::k_iCallback, 8000) == 0 &&
|
||||||
|
match_list.m_nLobbiesMatching > 0)
|
||||||
|
{
|
||||||
|
const char *host_ver = matchmaking->GetLobbyData(
|
||||||
|
matchmaking->GetLobbyByIndex(0), "btl4ver");
|
||||||
|
LobbyNotice("BUILD MISMATCH\n\n"
|
||||||
|
"A lobby is up, but the host runs build %s\n"
|
||||||
|
"and this machine runs build %s.\n\n"
|
||||||
|
"Everyone must run the same zip to play together.",
|
||||||
|
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||||
|
BT_VERSION_STRING);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LobbyNotice("NO LOBBY FOUND\n\n"
|
||||||
|
"No BattleTech lobby is up right now.\n"
|
||||||
|
"(This machine runs build %s.)", BT_VERSION_STRING);
|
||||||
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +568,25 @@ int
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
currentLobby = lobby;
|
currentLobby = lobby;
|
||||||
PublishSelf(pilot_name, vehicle, color);
|
// BUILD GATE belt-and-suspenders: the list filter covers discovery, but
|
||||||
|
// verify the entered lobby too (covers invite/direct joins and any
|
||||||
|
// filter drift). Mismatch = leave loudly, never play version-skewed.
|
||||||
|
{
|
||||||
|
const char *host_ver = matchmaking->GetLobbyData(currentLobby, "btl4ver");
|
||||||
|
if (host_ver == NULL || strcmp(host_ver, BT_VERSION_STRING) != 0)
|
||||||
|
{
|
||||||
|
matchmaking->LeaveLobby(currentLobby);
|
||||||
|
currentLobby = CSteamID();
|
||||||
|
LobbyNotice("BUILD MISMATCH\n\n"
|
||||||
|
"The host runs build %s\n"
|
||||||
|
"and this machine runs build %s.\n\n"
|
||||||
|
"Everyone must run the same zip to play together.",
|
||||||
|
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||||
|
BT_VERSION_STRING);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PublishSelf(pilot_name, vehicle, color, experience);
|
||||||
LobbyLog("join: in lobby, waiting for GO");
|
LobbyLog("join: in lobby, waiting for GO");
|
||||||
|
|
||||||
int result = RunRoom(0);
|
int result = RunRoom(0);
|
||||||
@@ -475,7 +623,15 @@ int
|
|||||||
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
|
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
|
||||||
if (my_token_out[0] == 0)
|
if (my_token_out[0] == 0)
|
||||||
{
|
{
|
||||||
result = -1; // the host's map is missing us
|
// The host launched without us -- no seat in the mission map.
|
||||||
|
// (Same-build clients can only hit this via a full lobby or a
|
||||||
|
// publish race now; version skew is gated before entry.)
|
||||||
|
LobbyNotice("LEFT BEHIND\n\n"
|
||||||
|
"The host launched the mission without this machine\n"
|
||||||
|
"(no seat in the mission map).\n\n"
|
||||||
|
"Rejoin on the next launch. If this repeats, compare\n"
|
||||||
|
"builds: this machine runs %s.", BT_VERSION_STRING);
|
||||||
|
result = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SteamMatchmaking()->LeaveLobby(currentLobby);
|
SteamMatchmaking()->LeaveLobby(currentLobby);
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ struct BTLobbyMember
|
|||||||
int gamePort; // token game port (1502)
|
int gamePort; // token game port (1502)
|
||||||
char vehicle[16];
|
char vehicle[16];
|
||||||
char color[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;
|
int isSelf;
|
||||||
unsigned long long steamID;
|
unsigned long long steamID;
|
||||||
};
|
};
|
||||||
@@ -46,6 +51,7 @@ struct BTLobbyRoster
|
|||||||
int
|
int
|
||||||
BTLobby_HostAndRoom(
|
BTLobby_HostAndRoom(
|
||||||
const char *pilot_name, const char *vehicle, const char *color,
|
const char *pilot_name, const char *vehicle, const char *color,
|
||||||
|
const char *experience,
|
||||||
BTLobbyRoster *roster_out,
|
BTLobbyRoster *roster_out,
|
||||||
char *my_token_out, int my_token_capacity,
|
char *my_token_out, int my_token_capacity,
|
||||||
char *steam_map_out, int steam_map_capacity);
|
char *steam_map_out, int steam_map_capacity);
|
||||||
@@ -57,5 +63,6 @@ int
|
|||||||
int
|
int
|
||||||
BTLobby_JoinAndWait(
|
BTLobby_JoinAndWait(
|
||||||
const char *pilot_name, const char *vehicle, const char *color,
|
const char *pilot_name, const char *vehicle, const char *color,
|
||||||
|
const char *experience,
|
||||||
char *my_token_out, int my_token_capacity,
|
char *my_token_out, int my_token_capacity,
|
||||||
char *steam_map_out, int steam_map_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.
|
│ CollisionCriticalHitWeight, VideoObjectName, VitalSubsystem) — NO thermal. Couples subsystem→DamageZone@this+0xE0.
|
||||||
│ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8,
|
│ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8,
|
||||||
│ printSimulationState@0x104, criticalReference@0x108, collisionCriticalHitWeight@0x10C, vitalSubsystemIndex@0x110(-1).
|
│ 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,
|
│ PrintState@4ac8c0 (DefaultState/Destroyed/Exploding), IsDamaged@4ac9c8 (mech bus down), ApplyDamageAndMeasure@4ac07c,
|
||||||
│ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74).
|
│ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74).
|
||||||
│ TechStatusType states @50df17: Destroyed/Damaged/CoolantLeaking/Overheating/AmmoBurning/Jammed/BadPower (count=7).
|
│ 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))
|
return (Subsystem *) new (Memory::Allocate(0x22c))
|
||||||
AmmoBin(owner, id, (AmmoBin::SubsystemResource *)seg);
|
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
|
// member. The 0x54 ammoAlarm (was an 8-byte HeatAlarm) lands ammoModelFile
|
||||||
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
|
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
|
||||||
int ammoModelFile; // @0x1E8 (word 0x7A) round model index
|
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
|
int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index
|
||||||
|
|
||||||
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
|
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ enum BTActionID
|
|||||||
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
|
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
|
||||||
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
|
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
|
||||||
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
|
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
|
||||||
|
BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
|
||||||
BTActCount
|
BTActCount
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
|
|||||||
{"Reconnect", BTActReconnect},
|
{"Reconnect", BTActReconnect},
|
||||||
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
|
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
|
||||||
{"Mfd3Cycle", BTActMfd3Cycle},
|
{"Mfd3Cycle", BTActMfd3Cycle},
|
||||||
|
{"Eject", BTActEject},
|
||||||
{0, 0}
|
{0, 0}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
|
|||||||
"key L action Mfd3Cycle\n"
|
"key L action Mfd3Cycle\n"
|
||||||
"key V action ViewToggle\n"
|
"key V action ViewToggle\n"
|
||||||
"key B action LookBehind\n"
|
"key B action LookBehind\n"
|
||||||
|
"key Back action Eject\n"
|
||||||
|
"pad LeftThumb action Eject\n"
|
||||||
"key F5 action Generator1\n"
|
"key F5 action Generator1\n"
|
||||||
"key F6 action Generator2\n"
|
"key F6 action Generator2\n"
|
||||||
"key F7 action Generator3\n"
|
"key F7 action Generator3\n"
|
||||||
@@ -991,6 +995,7 @@ void
|
|||||||
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
|
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
|
||||||
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
|
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
|
||||||
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
|
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
|
||||||
|
case BTActEject: next.eject = 1; break;
|
||||||
case BTActGenerator1: next.genSel = 4; break;
|
case BTActGenerator1: next.genSel = 4; break;
|
||||||
case BTActGenerator2: next.genSel = 5; break;
|
case BTActGenerator2: next.genSel = 5; break;
|
||||||
case BTActGenerator3: next.genSel = 6; break;
|
case BTActGenerator3: next.genSel = 6; break;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ struct BTInputState
|
|||||||
int valve;
|
int valve;
|
||||||
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
|
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
|
||||||
int configHold;
|
int configHold;
|
||||||
|
int eject; // PANIC/EJECT punch-out (level; mech4 edge-dispatches Mech 0x19)
|
||||||
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
|
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
|
||||||
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
|
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
|
||||||
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
|
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
|
||||||
|
|||||||
@@ -163,7 +163,13 @@ static const int kBTQuadModeMask[12] = { 0x1,0x1,0x1,0x1, 0x20,0x20,0x20,0x20, 0
|
|||||||
static const int kBTEngModeMask[12] = { 0x2,0x4,0x8,0x10, 0x40,0x80,0x100,0x200, 0x800,0x1000,0x2000,0x4000 };
|
static const int 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 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 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]
|
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -270,6 +276,14 @@ void
|
|||||||
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
|
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
|
||||||
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code));
|
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code));
|
||||||
|
|
||||||
|
if (BTLampLog())
|
||||||
|
DEBUG_STREAM << "[galm-item] sub='"
|
||||||
|
<< (the_subsystem ? (const char *)the_subsystem->GetName() : "?")
|
||||||
|
<< "' cond=" << (int)the_condition
|
||||||
|
<< " itemCond=" << item_condition
|
||||||
|
<< " lampCode=0x" << std::hex << lamp_code << std::dec
|
||||||
|
<< "\n" << std::flush;
|
||||||
|
|
||||||
if ((int)the_condition != item_condition)
|
if ((int)the_condition != item_condition)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -287,8 +301,33 @@ void
|
|||||||
{
|
{
|
||||||
if (the_condition == 2) // CoolantLeaking
|
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);
|
int n = BTCondenserNumber(the_subsystem);
|
||||||
if (n >= 0 && n < 6)
|
if (n >= 1 && n <= 6)
|
||||||
lamp_id = kBTCondenserLamp[n];
|
lamp_id = kBTCondenserLamp[n];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +370,21 @@ void
|
|||||||
|
|
||||||
if (lamp_id < 0)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -980,6 +980,7 @@ GeneratorCluster::GeneratorCluster(
|
|||||||
Scalar *currentTemp = (Scalar *)AttributePointerOf(subsystem_in, "CurrentTemperature");
|
Scalar *currentTemp = (Scalar *)AttributePointerOf(subsystem_in, "CurrentTemperature");
|
||||||
Scalar *degradeTemp = (Scalar *)AttributePointerOf(subsystem_in, "DegradationTemperature");
|
Scalar *degradeTemp = (Scalar *)AttributePointerOf(subsystem_in, "DegradationTemperature");
|
||||||
Scalar *failTemp = (Scalar *)AttributePointerOf(subsystem_in, "FailureTemperature");
|
Scalar *failTemp = (Scalar *)AttributePointerOf(subsystem_in, "FailureTemperature");
|
||||||
|
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
|
||||||
Scalar *coolantLeak = (Scalar *)AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
Scalar *coolantLeak = (Scalar *)AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
||||||
Scalar *outputVolt = (Scalar *)AttributePointerOf(subsystem_in, "OutputVoltage");
|
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
|
// child 3: coolant-leak inverse-wipe (CoolantMassLeakRate); third=*(subsys+0x150), frames=3
|
||||||
leakGauge = new BitMapInverseWipe(rate3, mode_mask, renderer_in, graphics_port_number,
|
leakGauge = new BitMapInverseWipe(rate3, mode_mask, renderer_in, graphics_port_number,
|
||||||
x, y + 0x27, leak_image, extra_color, leak_color_b,
|
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
|
3, // frames
|
||||||
coolantLeak, "LeakGauge");
|
coolantLeak, "LeakGauge");
|
||||||
|
|
||||||
@@ -1460,6 +1461,7 @@ SubsystemCluster::SubsystemCluster(
|
|||||||
void *linkedHeat = ResolveLink(heatSink); // FUN_00417ab4
|
void *linkedHeat = ResolveLink(heatSink); // FUN_00417ab4
|
||||||
void *linkTemp = AttributePointerOf(linkedHeat, "CurrentTemperature");
|
void *linkTemp = AttributePointerOf(linkedHeat, "CurrentTemperature");
|
||||||
void *linkDegrade = AttributePointerOf(linkedHeat, "DegradationTemperature");
|
void *linkDegrade = AttributePointerOf(linkedHeat, "DegradationTemperature");
|
||||||
|
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
|
||||||
void *coolantLeak = AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
void *coolantLeak = AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
|
||||||
|
|
||||||
coolingLoopB = new AnimatedSubsystemLamp(ChildRate(), eng_mode, renderer_in, // @004c70a4
|
coolingLoopB = new AnimatedSubsystemLamp(ChildRate(), eng_mode, renderer_in, // @004c70a4
|
||||||
@@ -1513,7 +1515,7 @@ SubsystemCluster::SubsystemCluster(
|
|||||||
|
|
||||||
leakGauge = new BitMapInverseWipe(ChildRate(), eng_mode, renderer_in, // @004c5b7c
|
leakGauge = new BitMapInverseWipe(ChildRate(), eng_mode, renderer_in, // @004c5b7c
|
||||||
engPort, 0x255, 0xe0, "eleak.pcc", 0, 0xff,
|
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)
|
(Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150)
|
||||||
|
|
||||||
failedState = False; // @0xC4 this[0x31] (1 = destroyed)
|
failedState = False; // @0xC4 this[0x31] (1 = destroyed)
|
||||||
@@ -2018,11 +2020,23 @@ BallisticWeaponCluster::BallisticWeaponCluster(
|
|||||||
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
|
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
|
||||||
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
|
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
|
||||||
|
|
||||||
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
|
// ejectWipe (BitMapInverseWipeScalar @004c61c8): the round-EJECT progress
|
||||||
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
|
// sweep -- bteejtm.pcc as the weapon's PercentOfEject (@0x3F8) runs 0..1.
|
||||||
// declared/reconstructed in btl4gaug (only the non-Scalar BitMapInverseWipe);
|
// Binary call (part_014.c:2312): FUN_004c61c8(., rate, eng_mode,
|
||||||
// tracked NULL until it lands.
|
// renderer, ownerID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||||
ejectWipe = NULL; // @0x44
|
// weapon+0x3f8, name). RE-WIRED 2026-08-03 after the draw op was
|
||||||
|
// properly settled: vtbl+0x58 = DrawBitMapOpaque, NOT DrawBitMap -- the
|
||||||
|
// mis-guess was the striped-MFD corruption (gotcha #27; the wipe's
|
||||||
|
// Execute in btl4gaug now carries the byte-exact two-span transcription).
|
||||||
|
{
|
||||||
|
extern Scalar *BTWeaponPercentOfEjectPtr(void *weapon);
|
||||||
|
Scalar *ejectPct = BTWeaponPercentOfEjectPtr(subsystem_in);
|
||||||
|
ejectWipe = (ejectPct != NULL)
|
||||||
|
? new BitMapInverseWipeScalar(ChildRate(), eng_mode, renderer_in,
|
||||||
|
owner_ID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||||
|
ejectPct, "BitMapInverseWipeScalar")
|
||||||
|
: NULL; // @0x44
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BallisticWeaponCluster::~BallisticWeaponCluster()
|
BallisticWeaponCluster::~BallisticWeaponCluster()
|
||||||
|
|||||||
@@ -547,8 +547,27 @@ void
|
|||||||
// briefly redirected to Player::deathCount@0x200, but that is the respawn-handshake
|
// 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
|
// identity (seeded -2), which is why it needed a display clamp; +0x280 was never
|
||||||
// dead, only unwritten. Both counters now replicate owner->replicant.
|
// dead, only unwritten. Both counters now replicate owner->replicant.
|
||||||
e.nameDisplay->Draw(&localView, (Scalar)BTPilotKills(pilot)); // KILLS (killCount)
|
int drawnKills = BTPilotKills(pilot);
|
||||||
e.mechDisplay->Draw(&localView, (Scalar)BTPilotDeaths(pilot)); // DEATHS (deathTally @0x280)
|
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;
|
++currentSlot;
|
||||||
|
|||||||
@@ -2054,7 +2054,7 @@ Logical
|
|||||||
BitMapInverseWipe::BitMapInverseWipe(
|
BitMapInverseWipe::BitMapInverseWipe(
|
||||||
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
||||||
int graphics_port_number, int x, int y, const char *image,
|
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
|
Scalar *value_pointer, const char *identification_string
|
||||||
):
|
):
|
||||||
GraphicGauge(rate, mode_mask, renderer_in, 0, graphics_port_number,
|
GraphicGauge(rate, mode_mask, renderer_in, 0, graphics_port_number,
|
||||||
@@ -2107,7 +2107,21 @@ void BitMapInverseWipe::BecameActive() // @004c5cf4
|
|||||||
//
|
//
|
||||||
void BitMapInverseWipe::Execute()
|
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 < 0) level = 0;
|
||||||
if (level > fullWidth) level = fullWidth;
|
if (level > fullWidth) level = fullWidth;
|
||||||
if (value > 0.0025f && level < 1) level = 1; // _DAT_0050e3d8
|
if (value > 0.0025f && level < 1) level = 1; // _DAT_0050e3d8
|
||||||
@@ -2138,6 +2152,114 @@ void BitMapInverseWipe::Execute()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//###########################################################################
|
||||||
|
// BitMapInverseWipeScalar @004c61c8 ctor / @004c5fb8 Execute
|
||||||
|
//###########################################################################
|
||||||
|
//
|
||||||
|
// The weapon-eng round-EJECT progress sweep (bteejtm.pcc <- PercentOfEject).
|
||||||
|
// Base ctor @004c5e84 interns the image and captures colours + the bitmap
|
||||||
|
// geometry; the @004c61c8 tail adds the Scalar value-watcher (FUN_00474855
|
||||||
|
// into this+0xA8) -- our GaugeConnectionDirectOf<Scalar> is that watcher.
|
||||||
|
//
|
||||||
|
BitMapInverseWipeScalar::BitMapInverseWipeScalar(
|
||||||
|
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
||||||
|
int owner_ID, int graphics_port_number, int x, int y, const char *image,
|
||||||
|
int color_a, int color_b, Scalar *value_pointer,
|
||||||
|
const char *identification_string
|
||||||
|
):
|
||||||
|
GraphicGauge(rate, mode_mask, renderer_in, owner_ID, graphics_port_number,
|
||||||
|
identification_string)
|
||||||
|
{
|
||||||
|
localView.SetOrigin(x, y);
|
||||||
|
imageName = new char[strlen(image) + 1];
|
||||||
|
strcpy(imageName, image);
|
||||||
|
colorA = color_a;
|
||||||
|
colorB = color_b;
|
||||||
|
value = 0.0f;
|
||||||
|
previousLevel = -1;
|
||||||
|
L4Warehouse *warehouse = (L4Warehouse *)renderer_in->warehousePointer;
|
||||||
|
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||||
|
if (bmp == NULL)
|
||||||
|
{
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
width = bmp->Data.Size.x;
|
||||||
|
height = bmp->Data.Size.y;
|
||||||
|
}
|
||||||
|
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &value, value_pointer));
|
||||||
|
}
|
||||||
|
|
||||||
|
BitMapInverseWipeScalar::~BitMapInverseWipeScalar()
|
||||||
|
{
|
||||||
|
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||||
|
warehouse->bitMapBin.Release(imageName);
|
||||||
|
delete[] imageName;
|
||||||
|
imageName = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logical BitMapInverseWipeScalar::TestInstance() const { return GraphicGauge::TestInstance(); }
|
||||||
|
|
||||||
|
void BitMapInverseWipeScalar::BecameActive() // @004c5fa4
|
||||||
|
{
|
||||||
|
previousLevel = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// @004c5fb8 -- Execute. level = round(value x width) clamped to [0, width]
|
||||||
|
// (the x87 form Ghidra drops -- fild width / fmul value, gotcha #19), and on
|
||||||
|
// a change draw the two source-column spans OPAQUE with INVERTED colour
|
||||||
|
// pairs -- the literal "inverse wipe".
|
||||||
|
//
|
||||||
|
// SETTLED 2026-08-03 (the MFD-corruption arc): the binary's draw call is
|
||||||
|
// vtbl+0x58 = GraphicsView::DrawBitMapOpaque(background, rotation, bitmap,
|
||||||
|
// sx1, sy1, sx2, sy2) [T0 GRAPH2D.h slot 22 -- the NEXT virtual after
|
||||||
|
// DrawBitMap at +0x54; verified by counting the declaration order:
|
||||||
|
// +0x18 SetColor, +0x24 MoveToAbsolute both land exactly]. The earlier
|
||||||
|
// reconstruction guessed DrawBitMap and fed it a colour as "rotation" --
|
||||||
|
// the striped-garbage smear the operator captured. Byte-exact spans:
|
||||||
|
// level != 0: MoveTo(0,0); SetColor(colorA);
|
||||||
|
// DrawBitMapOpaque(colorB, 0, bmp, 0, 0, level, height);
|
||||||
|
// ++level; // inclusive-bounds step
|
||||||
|
// level < w: MoveTo(level,0); SetColor(colorB);
|
||||||
|
// DrawBitMapOpaque(colorA, 0, bmp, level, 0, width, height);
|
||||||
|
//
|
||||||
|
void BitMapInverseWipeScalar::Execute()
|
||||||
|
{
|
||||||
|
Scalar scaled = value * (Scalar)width;
|
||||||
|
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
|
||||||
|
if (level < 0) level = 0;
|
||||||
|
if (level > width) level = width;
|
||||||
|
|
||||||
|
if (level != previousLevel)
|
||||||
|
{
|
||||||
|
previousLevel = level;
|
||||||
|
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||||
|
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||||
|
if (bmp != NULL)
|
||||||
|
{
|
||||||
|
int split = level;
|
||||||
|
if (split != 0)
|
||||||
|
{
|
||||||
|
localView.MoveToAbsolute(0, 0);
|
||||||
|
localView.SetColor(colorA);
|
||||||
|
localView.DrawBitMapOpaque(colorB, 0, bmp, 0, 0, split, height);
|
||||||
|
++split; // the binary's iVar2++
|
||||||
|
}
|
||||||
|
if (split < width)
|
||||||
|
{
|
||||||
|
localView.MoveToAbsolute(split, 0);
|
||||||
|
localView.SetColor(colorB);
|
||||||
|
localView.DrawBitMapOpaque(colorA, 0, bmp, split, 0, width, height);
|
||||||
|
}
|
||||||
|
warehouse->bitMapBin.Release(imageName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// HeadingPointer @004c554c Make / @004c562c ctor
|
// HeadingPointer @004c554c Make / @004c562c ctor
|
||||||
|
|||||||
@@ -522,7 +522,7 @@
|
|||||||
BitMapInverseWipe( // @004c5b7c
|
BitMapInverseWipe( // @004c5b7c
|
||||||
GaugeRate, ModeMask, L4GaugeRenderer *, int,
|
GaugeRate, ModeMask, L4GaugeRenderer *, int,
|
||||||
int x, int y, const char *image,
|
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 *);
|
Scalar *value_pointer, const char *);
|
||||||
~BitMapInverseWipe(); // @004c5c80
|
~BitMapInverseWipe(); // @004c5c80
|
||||||
Logical TestInstance() const;
|
Logical TestInstance() const;
|
||||||
@@ -537,10 +537,43 @@
|
|||||||
int frameHeight; // @0xA4 this[0x29]
|
int frameHeight; // @0xA4 this[0x29]
|
||||||
int frames; // @0xA0 this[0x28]
|
int frames; // @0xA0 this[0x28]
|
||||||
int previousLevel; // @0xAC this[0x2B]
|
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)
|
Scalar value; // @0xB4 this[0x2D] (connection)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//#######################################################################
|
||||||
|
// BitMapInverseWipeScalar @004c61c8 (vtable 0x518a14; base ctor @004c5e84,
|
||||||
|
// Execute @004c5fb8, BecameActive @004c5fa4). A bitmap COLUMN SWEEP whose
|
||||||
|
// level tracks a live Scalar 0..1 (the ctor's trailing Scalar* rides a
|
||||||
|
// value-watcher into this+0xA8): columns [0..level] draw fg colorA over
|
||||||
|
// bg colorB, the remainder draws the INVERSE -- the weapon-engineering
|
||||||
|
// panel's round-EJECT progress graphic (bteejtm.pcc, watching the
|
||||||
|
// weapon's PercentOfEject @0x3F8). Reconstructed 2026-08-03 (#118: the
|
||||||
|
// operator saw the eject slot flash with NO graphic -- this was the
|
||||||
|
// tracked-NULL bring-up stub in BallisticWeaponCluster).
|
||||||
|
//#######################################################################
|
||||||
|
class BitMapInverseWipeScalar :
|
||||||
|
public GraphicGauge
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
BitMapInverseWipeScalar( // @004c61c8
|
||||||
|
GaugeRate, ModeMask, L4GaugeRenderer *, int owner_ID,
|
||||||
|
int graphics_port_number, int x, int y, const char *image,
|
||||||
|
int color_a, int color_b, Scalar *value_pointer, const char *);
|
||||||
|
~BitMapInverseWipeScalar();
|
||||||
|
Logical TestInstance() const;
|
||||||
|
void BecameActive(); // @004c5fa4 (previousLevel = -1)
|
||||||
|
void Execute(); // @004c5fb8 (two-span inverse sweep)
|
||||||
|
protected:
|
||||||
|
char *imageName; // @0x90
|
||||||
|
int colorA; // @0x94
|
||||||
|
int colorB; // @0x98
|
||||||
|
int width; // @0x9C bitmap width (columns)
|
||||||
|
int height; // @0xA0 bitmap height
|
||||||
|
int previousLevel; // @0xA4
|
||||||
|
Scalar value; // @0xA8 (connection: the 0..1 progress)
|
||||||
|
};
|
||||||
|
|
||||||
//#######################################################################
|
//#######################################################################
|
||||||
// SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
|
// SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
|
||||||
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
|
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
// Function -> method map:
|
// Function -> method map:
|
||||||
// L4MechControlsMapper ----------------------------- vtable @0051e440
|
// L4MechControlsMapper ----------------------------- vtable @0051e440
|
||||||
// @004d17ac ctor @004d1814 dtor
|
// @004d17ac ctor @004d1814 dtor
|
||||||
// @004d196c InterpretControls (target-range ramp + review-mode watch)
|
// @004d196c InterpretControls (target-range ramp + panic-arm watch)
|
||||||
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
|
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
|
||||||
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
|
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
|
||||||
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
|
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
|
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
|
||||||
//
|
//
|
||||||
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
|
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
|
||||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 missionReviewMode.
|
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 ejectPermitted.
|
||||||
//
|
//
|
||||||
|
|
||||||
#include <bt.hpp>
|
#include <bt.hpp>
|
||||||
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
|||||||
//
|
//
|
||||||
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
|
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
|
||||||
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
|
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
|
||||||
// review-mode watcher.
|
// panic-arm watcher (ejectPermitted edge).
|
||||||
//
|
//
|
||||||
L4MechControlsMapper::L4MechControlsMapper(
|
L4MechControlsMapper::L4MechControlsMapper(
|
||||||
Mech *owner,
|
Mech *owner,
|
||||||
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
(void)class_ID;
|
(void)class_ID;
|
||||||
previousMissionReviewMode = 0; // this[0x6a] @0x1a8
|
previousEjectPermitted = 0; // this[0x6a] @0x1a8
|
||||||
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
|
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
|
||||||
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
|
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
|
||||||
Check_Fpu();
|
Check_Fpu();
|
||||||
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
|
|||||||
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
|
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
|
||||||
// 1. snap the throttle to a full-throttle detent,
|
// 1. snap the throttle to a full-throttle detent,
|
||||||
// 2. (re)build the pilot roster,
|
// 2. (re)build the pilot roster,
|
||||||
// 3. watch the Mech's mission-review flag and toggle the corresponding mode
|
// 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
|
||||||
// bit (0x200000) on the application mode manager,
|
// refreshed each frame by the master performance, FUN_004a9b5c+0x10 ->
|
||||||
|
// @0049fa1c) and toggle the PANIC-ARMED mode bit (0x200000) on the
|
||||||
|
// application mode manager. That mode bit is what lights the pod's
|
||||||
|
// physical PANIC button (MakeLinkedLamp, btl4mppr RIO ctor) and any
|
||||||
|
// gauge elements carrying the mode in their ModeMask. [Corrected
|
||||||
|
// 2026-08-03: this cell was mislabeled "mission-review mode" -- the
|
||||||
|
// real review mode is the GLOBAL DAT_004fd550 (btl4pb), not mech+0x414.]
|
||||||
// 4. slew the smoothed target-range exponent toward the panel demand and
|
// 4. slew the smoothed target-range exponent toward the panel demand and
|
||||||
// push the resulting range (250 * 2^exponent) onto the Mech,
|
// push the resulting range (250 * 2^exponent) onto the Mech,
|
||||||
// 5. delegate to MechControlsMapper::InterpretControls for the actual
|
// 5. delegate to MechControlsMapper::InterpretControls for the actual
|
||||||
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
|
|||||||
BuildPilotArray(); // FUN_004b0600
|
BuildPilotArray(); // FUN_004b0600
|
||||||
|
|
||||||
//
|
//
|
||||||
// (3) Mission-review mode change watcher.
|
// (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
|
||||||
//
|
//
|
||||||
Mech *mech = GetMech();
|
Mech *mech = GetMech();
|
||||||
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414
|
int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
|
||||||
if (review_mode != previousMissionReviewMode) // @0x1a8
|
if (eject_armed != previousEjectPermitted) // @0x1a8
|
||||||
{
|
{
|
||||||
previousMissionReviewMode = review_mode;
|
previousEjectPermitted = eject_armed;
|
||||||
|
|
||||||
BTL4ModeManager *mode_manager =
|
BTL4ModeManager *mode_manager =
|
||||||
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
|
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
|
||||||
// ModeManager API: Add/RemoveModeMask save the old mask into
|
// ModeManager API: Add/RemoveModeMask save the old mask into
|
||||||
// previousModeMask (the recovered "savedMode = currentMode" step).
|
// previousModeMask (the recovered "savedMode = currentMode" step).
|
||||||
if (review_mode == 0)
|
if (eject_armed == 0)
|
||||||
{
|
{
|
||||||
mode_manager->RemoveModeMask(0x200000);
|
mode_manager->RemoveModeMask(0x200000);
|
||||||
}
|
}
|
||||||
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
|
|||||||
{
|
{
|
||||||
mode_manager->AddModeMask(0x200000);
|
mode_manager->AddModeMask(0x200000);
|
||||||
}
|
}
|
||||||
|
if (getenv("BT_EJECT_LOG"))
|
||||||
|
DEBUG_STREAM << "[eject] panic-arm mode "
|
||||||
|
<< (eject_armed ? "ON" : "off") << " ("
|
||||||
|
<< mech->GetEntityID() << ")\n" << std::flush;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -74,8 +74,17 @@
|
|||||||
//
|
//
|
||||||
public:
|
public:
|
||||||
enum {
|
enum {
|
||||||
KeypressMessageID = MechControlsMapper::NextMessageID,
|
// PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
|
||||||
StringMatchMessageID,
|
// straight into the group Adds; corrected 2026-08-03). The chain
|
||||||
|
// arithmetic (MechControlsMapper::NextMessageID) lands on 0x17 --
|
||||||
|
// TWO mapper ids between 0x16 ToggleVoiceAssist and 0x19 are
|
||||||
|
// unreconstructed -- and the mis-numbered Keypress send was
|
||||||
|
// SILENTLY swallowed by the Mech (no 0x17 handler), which is why
|
||||||
|
// the armed pilot-keypad press never ejected. Mech::EjectPilot is
|
||||||
|
// id 0x19 on the MECH's receiver: the panic-mode keypad binding
|
||||||
|
// sends THIS id to the OWNER, and the collision is the design.
|
||||||
|
KeypressMessageID = 0x19,
|
||||||
|
StringMatchMessageID, // 0x1a (unregistered; stringManager route)
|
||||||
NextMessageID
|
NextMessageID
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,7 +212,9 @@
|
|||||||
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
|
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
|
||||||
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
|
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
|
||||||
int
|
int
|
||||||
previousMissionReviewMode; // @0x1a8 last-seen mech mission-review flag
|
previousEjectPermitted; // @0x1a8 last-seen mech ejectPermitted (@0x414) --
|
||||||
|
// the panic-arm edge detector (was mislabeled
|
||||||
|
// "mission-review flag"; corrected 2026-08-03)
|
||||||
|
|
||||||
//
|
//
|
||||||
// Per-platform input staging slots (filled by the derived ctor's
|
// Per-platform input staging slots (filled by the derived ctor's
|
||||||
@@ -305,8 +316,12 @@
|
|||||||
// was previously self-consistent at the wrong value (0x2a), so it
|
// was previously self-consistent at the wrong value (0x2a), so it
|
||||||
// worked by accident -- now it matches the binary.
|
// worked by accident -- now it matches the binary.
|
||||||
// (Faithful-fidelity note: the binary RIO table also carries its OWN
|
// (Faithful-fidelity note: the binary RIO table also carries its OWN
|
||||||
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared
|
// Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
|
||||||
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.)
|
// the binary's 0x19 (the old chain arithmetic landed on 0x17, and the
|
||||||
|
// panic-mode pilot-keypad send was silently swallowed by the Mech --
|
||||||
|
// no 0x17 handler -- so the armed eject press never fired). Remaining
|
||||||
|
// refinement: the ctor registers the shared L4 Keypress BODY
|
||||||
|
// (@004d1bf0); the RIO-specific body is @004d2514.)
|
||||||
//
|
//
|
||||||
enum {
|
enum {
|
||||||
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
|
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
|
||||||
|
|||||||
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
|
|||||||
|
|
||||||
//
|
//
|
||||||
// Controls only matter in interactive ("scrub") review mode.
|
// Controls only matter in interactive ("scrub") review mode.
|
||||||
|
// The binary reads the GLOBAL review-mode cell DAT_004fd550 here --
|
||||||
|
// NOT mech+0x414 (that cell is ejectPermitted; the old
|
||||||
|
// GetMissionReviewMode() call was a mislabel, corrected 2026-08-03).
|
||||||
|
// The port never enters scrub mode, so the global stays 0.
|
||||||
//
|
//
|
||||||
if (GetMissionReviewMode() == 2) // DAT_004fd550
|
extern int gMissionReviewMode; // DAT_004fd550 (btstubs)
|
||||||
|
if (gMissionReviewMode == 2)
|
||||||
{
|
{
|
||||||
Check(controlsManager);
|
Check(controlsManager);
|
||||||
controlsManager->Execute(); // vtbl+0x18
|
controlsManager->Execute(); // vtbl+0x18
|
||||||
|
|||||||
+1052
-4
File diff suppressed because it is too large
Load Diff
@@ -706,7 +706,39 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
|||||||
d3d_OBJECT *wreckDebrisObj;
|
d3d_OBJECT *wreckDebrisObj;
|
||||||
d3d_OBJECT *wreckFlamesObj;
|
d3d_OBJECT *wreckFlamesObj;
|
||||||
std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable
|
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
|
std::map<int, int> segGState; // slot -> last applied graphic state
|
||||||
|
// SEARCHLIGHT (2026-08-05, MakeMechRenderables case 0xbd8 @004cef28):
|
||||||
|
// external view = a spot.bgf cone child on the mount joint, shown/
|
||||||
|
// hidden from the subsystem's "LightOn" attribute (the @0045612c
|
||||||
|
// watcher, transcribed into TickSearchlight); cockpit view = the fog
|
||||||
|
// swap (@00456778/@00456814 -> SetFogStyle searchLightOn/Off).
|
||||||
|
struct SearchLight
|
||||||
|
{
|
||||||
|
DPLStaticChildRenderable *cone; // external only; NULL on cockpit builds
|
||||||
|
d3d_OBJECT *coneObj; // spot.bgf (held while hidden)
|
||||||
|
int *lightOn; // the subsystem's LightOn attribute
|
||||||
|
int shown; // cone visibility last applied
|
||||||
|
int mountSeg; // resource segmentIndex (subsys +0x1DC)
|
||||||
|
};
|
||||||
|
SearchLight searchLight[2]; // the binary keeps 2 slots
|
||||||
|
int searchLightCount;
|
||||||
|
int searchIsCockpit; // built with view_type 1
|
||||||
|
int searchFogCache[2]; // seeded INVERTED (@00456778
|
||||||
|
// [9]/[10]) -> first tick syncs
|
||||||
char paintSerno; // the %serno% this mech was BUILT with
|
char paintSerno; // the %serno% this mech was BUILT with
|
||||||
// (0 = none) -- ApplyViewSkeleton re-parses
|
// (0 = none) -- ApplyViewSkeleton re-parses
|
||||||
// segment BGFs, so the paint substitutions
|
// segment BGFs, so the paint substitutions
|
||||||
@@ -726,6 +758,27 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
|||||||
int
|
int
|
||||||
TickWreck(Entity *victim, float dt);
|
TickWreck(Entity *victim, float dt);
|
||||||
|
|
||||||
|
//
|
||||||
|
// SEARCHLIGHT per-frame drive (2026-08-05): the transcription of the two
|
||||||
|
// 1995 watcher renderables -- cone visibility from "LightOn" (@0045612c
|
||||||
|
// Execute @004561d8) and the cockpit fog swap (@00456778 Execute
|
||||||
|
// @00456814). Rides the same sim bridge as TickArmourDamage.
|
||||||
|
//
|
||||||
|
void
|
||||||
|
TickSearchlight(Entity *mech);
|
||||||
|
|
||||||
|
//
|
||||||
|
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
|
||||||
|
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
|
||||||
|
// by MUNGA's DamageZone stream ctor) against the draw ops of the segment
|
||||||
|
// geometry just loaded, recording one binding per match. TickArmourDamage
|
||||||
|
// copies each bound zone's live damageLevel onto those ops.
|
||||||
|
//
|
||||||
|
void
|
||||||
|
BindArmourDamage(Entity *entity, MechRenderTree &tree);
|
||||||
|
void
|
||||||
|
TickArmourDamage(Entity *entity);
|
||||||
|
|
||||||
//
|
//
|
||||||
// VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic
|
// VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic
|
||||||
// cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only
|
// cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only
|
||||||
@@ -756,6 +809,30 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
|||||||
void
|
void
|
||||||
SwapToWreck(Entity *victim);
|
SwapToWreck(Entity *victim);
|
||||||
|
|
||||||
|
// #73 -- the aimed PER-PART pick. The 1995 target pick was a dpl scene
|
||||||
|
// intersection run by the division card against the drawn geometry
|
||||||
|
// (dpl_isect_mode_obj per renderable; results at Instance/DCS/Geometry
|
||||||
|
// granularity), so aimed fire struck a SEGMENT and credited that
|
||||||
|
// segment's damage zone. This is the port's equivalent: ray-vs-sphere
|
||||||
|
// over the mech's per-segment draw objects (mCullCenter/mCullRadius,
|
||||||
|
// world via the draw-cached mLocalToWorld -- one frame stale, fine for
|
||||||
|
// aiming), nearest struck segment wins, returning its hit point and
|
||||||
|
// PrimaryDamageZone. Returns 0 when the tree is absent/wrecked (the
|
||||||
|
// caller falls back to the whole-mech box + the unaimed lottery).
|
||||||
|
int
|
||||||
|
MechSegmentPick(Entity *mech, const float ray_start[3],
|
||||||
|
const float ray_dir[3], float max_range,
|
||||||
|
float hit_out[3], int *zone_out);
|
||||||
|
|
||||||
|
// #124 zone walker: a zone's VISUAL aim point -- its largest pick
|
||||||
|
// object's cull-center in world (what a player aims at; the segment
|
||||||
|
// ORIGIN sits at the joint and makes lower segments strike the part
|
||||||
|
// above). 0 when the zone has no pick geometry.
|
||||||
|
int
|
||||||
|
ZoneAimPoint(Entity *mech, int zone, float out3[3]);
|
||||||
|
int
|
||||||
|
SegAimPoint(Entity *mech, int seg, float out3[3]);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
//
|
//
|
||||||
// Renderer-manager overrides
|
// Renderer-manager overrides
|
||||||
|
|||||||
+513
-109
@@ -203,6 +203,35 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
|
|||||||
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
|
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
|
||||||
"BTPlayer::MakeMessage wire size must be 0xD0");
|
"BTPlayer::MakeMessage wire size must be 0xD0");
|
||||||
|
|
||||||
|
// Wire-format lock (2026-08-05): the combat score reports and the extended
|
||||||
|
// VehicleDead cross nodes raw (replicant-player Dispatch reroute), and the
|
||||||
|
// sender/receiver field map was transcribed from the binary's builds
|
||||||
|
// (@0x4a04da/@0x4a05d9/@0x4a06c0 and @0x4a07d4) -- lock every offset.
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, scoreAward) == 0x1c,
|
||||||
|
"ScoreMessage::scoreAward must be at wire offset 0x1c");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, scoreType) == 0x20,
|
||||||
|
"ScoreMessage::scoreType must be at wire offset 0x20");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, damageAmount) == 0x24,
|
||||||
|
"ScoreMessage::damageAmount must be at wire offset 0x24");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, vitalHit) == 0x28,
|
||||||
|
"ScoreMessage::vitalHit must be at wire offset 0x28");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, zoneIndex) == 0x2c,
|
||||||
|
"ScoreMessage::zoneIndex must be at wire offset 0x2c");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, subsysID) == 0x30,
|
||||||
|
"ScoreMessage::subsysID must be at wire offset 0x30");
|
||||||
|
static_assert(offsetof(BTPlayer::ScoreMessage, senderMechID) == 0x34,
|
||||||
|
"ScoreMessage::senderMechID must be at wire offset 0x34");
|
||||||
|
static_assert(sizeof(BTPlayer::ScoreMessage) == 0x3c,
|
||||||
|
"ScoreMessage wire size must be 0x3c (binary build @0x4a052b)");
|
||||||
|
static_assert(offsetof(BTPlayer::VehicleDeadMessage, reserved28) == 0x28,
|
||||||
|
"VehicleDeadMessage::reserved28 must be at wire offset 0x28");
|
||||||
|
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killedByPlayerID) == 0x2c,
|
||||||
|
"VehicleDeadMessage::killedByPlayerID must be at wire offset 0x2c");
|
||||||
|
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killZone) == 0x34,
|
||||||
|
"VehicleDeadMessage::killZone must be at wire offset 0x34");
|
||||||
|
static_assert(sizeof(BTPlayer::VehicleDeadMessage) == 0x38,
|
||||||
|
"VehicleDeadMessage wire size must be 0x38 (binary build @0x4a07f6)");
|
||||||
|
|
||||||
//
|
//
|
||||||
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
|
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
|
||||||
// Entity base differs -- so the 1995 offsets must never be used as raw byte
|
// Entity base differs -- so the 1995 offsets must never be used as raw byte
|
||||||
@@ -413,6 +442,42 @@ void
|
|||||||
<< message->deathCount << " deathCount=" << deathCount
|
<< message->deathCount << " deathCount=" << deathCount
|
||||||
<< ")\n" << std::flush;
|
<< ")\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"
|
// Respawning needs a drop zone. A dev mission without a "DropZones"
|
||||||
// group would hit the engine base's ACTIVE Check(dropzones) -> an
|
// group would hit the engine base's ACTIVE Check(dropzones) -> an
|
||||||
@@ -551,6 +616,25 @@ void
|
|||||||
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
|
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
|
||||||
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
|
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
|
||||||
<< std::flush;
|
<< 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)
|
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -586,6 +670,64 @@ void
|
|||||||
}
|
}
|
||||||
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
|
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
|
||||||
|
|
||||||
|
//
|
||||||
|
// THE DEATH SCORE COST (@004c07cd-0x4c0828, raw disasm 2026-08-02 -- this
|
||||||
|
// tail was missing from the #52 reconstruction). Gated on advancedDamageOn
|
||||||
|
// (+0x264) alone in the binary: self-award -SpecialCaseDeathPenalty
|
||||||
|
// (role+0x20, the 4.10-only role field restored to SCNROLE.h) as a type-1
|
||||||
|
// score message handed DIRECTLY to the engine base handler (@0042da20 ==
|
||||||
|
// PLAYER.cpp:138 `currentScore += scoreAward` + the vehicle
|
||||||
|
// RespondToScoreMessage no-op hook) -- NOT dispatched, so it bypasses the
|
||||||
|
// BT ScoreInflicted Verify. Shipped content authors the key nowhere, so
|
||||||
|
// the cost is 0 in the field today; a mission that authors it gets it.
|
||||||
|
// BINARY NUANCE [T4]: the 1995 engine cell (+0x1c8) is read by no BT-side
|
||||||
|
// scoreboard (they read +0x278), so the pod's penalty may never have been
|
||||||
|
// visible; our port has ONE currentScore, so it shows.
|
||||||
|
//
|
||||||
|
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
|
||||||
|
{
|
||||||
|
BTPlayer::ScoreMessage death_cost(
|
||||||
|
BTPlayer::ScoreInflictedMessageID, // 0x16
|
||||||
|
sizeof(BTPlayer::ScoreMessage),
|
||||||
|
BTPlayer::ScoreMessage::DamageReceivedScore, // type 1
|
||||||
|
-scenarioRole->GetSpecialCaseDeathPenalty(), // scoreAward @+0x1c, NEGATED
|
||||||
|
0.0f,
|
||||||
|
EntityID::Null); // senderMechID (binary copies @0x522524)
|
||||||
|
Player::ScoreMessageHandler(&death_cost); // the DIRECT base call
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
|
||||||
|
//
|
||||||
|
// The binary clears it right here, between the re-post and suppressConsole:
|
||||||
|
// FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0
|
||||||
|
// (part_013.c:10519-10523). We had the Post and the suppressConsole and
|
||||||
|
// were missing the middle instruction, so `deathPending` -- which we DO set
|
||||||
|
// (:505, and the dedup gate that reads it IS authentic; the binary's own
|
||||||
|
// @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only
|
||||||
|
// ever cleared on the SUCCESS paths. One failed respawn therefore latched
|
||||||
|
// the pilot dead for the rest of the mission: every later death hit the
|
||||||
|
// dedup and was SWALLOWED, so the cycle could never restart. That is what
|
||||||
|
// turned a transient respawn hiccup into a PERMANENT ghost (dead,
|
||||||
|
// un-Reset, still driveable, a burning wreck on every peer that sinks out
|
||||||
|
// of the world after ~18 s and can never be drawn again).
|
||||||
|
//
|
||||||
|
// Field signature it explains exactly: 2026-07-29, 8 death cycles, 6
|
||||||
|
// stranded, NONE of them ever recovering.
|
||||||
|
//
|
||||||
|
// Binary evidence that the latch must not persist: `+0x290` is written in
|
||||||
|
// exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb,
|
||||||
|
// 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the
|
||||||
|
// preceding instruction); there is no write of 1 -- or of any non-zero
|
||||||
|
// value, in any instruction form -- anywhere in the executable. So in 1995
|
||||||
|
// the gate exists but can never block. [T1]
|
||||||
|
//
|
||||||
|
// Ordering matters and is preserved: the re-entrant death that arrives
|
||||||
|
// while the death EFFECTS are being dispatched still lands while the latch
|
||||||
|
// is up, so the "one death, one cycle" dedup is untouched.
|
||||||
|
//
|
||||||
|
deathPending = 0; // this+0x290 (binary: FUN_004c012c tail)
|
||||||
|
|
||||||
suppressConsole = 0; // this+0x258
|
suppressConsole = 0; // this+0x258
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,30 +880,45 @@ void
|
|||||||
{
|
{
|
||||||
//
|
//
|
||||||
// Points lost for damage we took (unless we hit ourselves).
|
// Points lost for damage we took (unless we hit ourselves).
|
||||||
|
// sender null guard is PORT SAFETY: the binary derefs the registry
|
||||||
|
// lookup unguarded (@0x4c0453) -- an inflictor that died between
|
||||||
|
// shot and impact would have crashed the 1995 pod.
|
||||||
//
|
//
|
||||||
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
if (sender_mech != 0 && our_mech != 0 // port guards (dead-window straggler)
|
||||||
|
&& sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
||||||
{
|
{
|
||||||
Scalar received =
|
Scalar received =
|
||||||
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
|
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
|
||||||
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
|
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
|
||||||
}
|
}
|
||||||
|
// BINARY NUANCE [T1 raw disasm @0x4c0433]: on SELF-inflicted damage
|
||||||
|
// (sender == own vehicle) the 1995 arm skips the math and the common
|
||||||
|
// tail folds its UNINITIALIZED award local into currentScore --
|
||||||
|
// stack garbage, every fall/ram self-hit. The port keeps award=0.0,
|
||||||
|
// a deliberate safe deviation.
|
||||||
|
|
||||||
//
|
//
|
||||||
// Tell the console (if any, and not suppressed) that our VTV
|
// Tell the console (if any, and not suppressed) that our VTV
|
||||||
// was damaged.
|
// was damaged. points_transfered CORRECTED 2026-08-05: the binary
|
||||||
|
// rounds THE COMPUTED AWARD here (raw disasm @0x4c04a7: fld
|
||||||
|
// [ebp-0xc]; call 0x4dcd94 -- an ST0-arg __ftol the decompiler
|
||||||
|
// rendered argless, which an earlier pass misread as Now()).
|
||||||
//
|
//
|
||||||
Host *console_host =
|
Host *console_host =
|
||||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||||
if (console_host && suppressConsole == 0) // this+0x258
|
BTPlayer *damager_player =
|
||||||
|
(sender_mech != 0) ? MECH_OWNING_PLAYER(sender_mech) : 0; // port guard (dummy shooter)
|
||||||
|
if (console_host && suppressConsole == 0 // this+0x258
|
||||||
|
&& damager_player != 0)
|
||||||
{
|
{
|
||||||
ConsolePlayerVTVDamagedMessage damaged_message(
|
ConsolePlayerVTVDamagedMessage damaged_message(
|
||||||
ownerID, // this+0x18c
|
ownerID, // this+0x18c
|
||||||
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c
|
damager_player->ownerID, // *(sender+0x190)+0x18c
|
||||||
Round(message->damageAmount), // FUN_004078fc
|
Round(message->damageAmount), // FUN_004078fc
|
||||||
message->pointSenderLo, // msg+0x2c
|
message->zoneIndex, // msg+0x2c
|
||||||
message->pointSenderHi, // msg+0x28
|
message->vitalHit, // msg+0x28
|
||||||
(int)Now().ticks, // FUN_004dcd94
|
Round(award), // FUN_004dcd94(ST0)
|
||||||
message->auxID // msg+0x30
|
message->subsysID // msg+0x30
|
||||||
);
|
);
|
||||||
|
|
||||||
application->SendMessage( // app+0x20, slot+0x18
|
application->SendMessage( // app+0x20, slot+0x18
|
||||||
@@ -775,6 +932,13 @@ void
|
|||||||
|
|
||||||
case BTPlayer::ScoreMessage::KillScore: // 2
|
case BTPlayer::ScoreMessage::KillScore: // 2
|
||||||
{
|
{
|
||||||
|
// PORT SAFETY: an unresolvable victim mech (removed wreck racing the
|
||||||
|
// cross-node dispatch) or a severed own vehicle skips the arm; the
|
||||||
|
// binary derefs both unguarded (@0x4c037e).
|
||||||
|
if (sender_mech == 0 || our_mech == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless
|
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless
|
||||||
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
|
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
|
||||||
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
|
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
|
||||||
@@ -1411,6 +1575,36 @@ void
|
|||||||
}
|
}
|
||||||
else // stale / old message
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,6 +1710,23 @@ BTPlayer::BTPlayer(
|
|||||||
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
|
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
|
||||||
<< std::dec << " <-- the raw write targets byte 0x284\n"
|
<< std::dec << " <-- the raw write targets byte 0x284\n"
|
||||||
<< std::flush;
|
<< std::flush;
|
||||||
|
// #81 GHOST DIG: the death path does Set_Alarm_Level(this+0x2c, 1)
|
||||||
|
// and the drop-zone reply does (this+0x2c, 2). Decoded against
|
||||||
|
// Player's enum those are DropZoneAcquiredState(1) and
|
||||||
|
// VehicleTranslocatedState(2) -- i.e. the BINARY's +0x2c is the
|
||||||
|
// Player's simulationState. The engine's respawn hunt is gated on
|
||||||
|
// `GetSimulationState() != DropZoneAcquiredState`
|
||||||
|
// (engine/MUNGA/PLAYER.cpp:229), so if that raw byte really is our
|
||||||
|
// simulationState, EVERY death marks "drop zone already acquired"
|
||||||
|
// and the hunt can never run -> permanent ghost. Print where our
|
||||||
|
// compiled layout actually puts it.
|
||||||
|
DEBUG_STREAM << "[layout] simulationState@0x" << std::hex
|
||||||
|
<< (int)((char *)&simulationState - (char *)this)
|
||||||
|
<< std::dec << " (raw death write targets 0x2c;"
|
||||||
|
<< " DropZoneAcquiredState=" << (int)DropZoneAcquiredState
|
||||||
|
<< " VehicleTranslocatedState=" << (int)VehicleTranslocatedState
|
||||||
|
<< ") liveState=" << (int)GetSimulationState() << "\n"
|
||||||
|
<< std::flush;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1695,6 +1906,22 @@ Logical
|
|||||||
// these read the COMPILED named members the scoring handlers write, replacing the
|
// 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.
|
// earlier raw-offset reads (pilot+0x27c / +0x200) that don't match our layout.
|
||||||
//
|
//
|
||||||
|
// #83 (collision rattle): the technician "splash/collision damage" setting --
|
||||||
|
// the binary's collision distributor gates on the owning player's
|
||||||
|
// advancedDamage copy 2 (@0x268, FUN_0049ffcc @0x49ffde). TU-safe bridge.
|
||||||
|
int BTPlayerAdvancedDamageOn(void *player_v)
|
||||||
|
{
|
||||||
|
return (player_v != 0) ? ((BTPlayer *)player_v)->advancedDamageOn2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #89 death blast, second gate: suppressConsole (+0x258) is SET by the eject
|
||||||
|
// bookkeeping -- an EJECTED pilot's abandoned mech never blast-splashes
|
||||||
|
// (raw disasm @0x4a0ac8-0x4a0ad6).
|
||||||
|
int BTPlayerConsoleSuppressed(void *player_v)
|
||||||
|
{
|
||||||
|
return (player_v != 0) ? (int)((BTPlayer *)player_v)->suppressConsole : 0;
|
||||||
|
}
|
||||||
|
|
||||||
int BTPilotKills(void *pilot)
|
int BTPilotKills(void *pilot)
|
||||||
{
|
{
|
||||||
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
|
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
|
||||||
@@ -2015,124 +2242,247 @@ Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap
|
|||||||
}
|
}
|
||||||
|
|
||||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
|
// THE AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
|
||||||
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
|
|
||||||
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
|
|
||||||
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
|
|
||||||
//
|
//
|
||||||
void BTPostDamageScore(Entity *victim, Scalar damage) // Step 6: per-hit SCORE
|
// Reconstructed from the raw disasm of the Mech::TakeDamageMessageHandler
|
||||||
|
// report tail @0x4a02f4-0x4a0890 (dark-gap region, not in the export). This
|
||||||
|
// RETIRES the bring-up producers BTPostDamageScore / BTPostKillScore (see the
|
||||||
|
// tombstone below). The binary's flow, transcribed:
|
||||||
|
//
|
||||||
|
// resolve: shooterEntity = registry find(msg->inflictingEntity) @0x4a033c
|
||||||
|
// shooterPlayer = shooterEntity+0x190 @0x4a034f
|
||||||
|
// victimPlayer = mech+0x190 @0x4a0358
|
||||||
|
// block A @0x4a04da (was ALIVE at entry, destroyed now):
|
||||||
|
// ScoreMessage{id 0x16, type 2 KillScore,
|
||||||
|
// scoreAward = the APPLIED damage tally,
|
||||||
|
// damageAmount = victim role's killBonus (role+0x1c),
|
||||||
|
// vitalHit, zone, subsysID,
|
||||||
|
// senderMechID = the VICTIM} -> the SHOOTER's player
|
||||||
|
// block B @0x4a05d9 (not newly killed, tally != 0):
|
||||||
|
// same shape, type 0 -- which the only registered 0x16 receiver
|
||||||
|
// (@0x4c02e4) VERIFY-rejects; 1995 then folded an UNINITIALIZED
|
||||||
|
// award. Sent for wire fidelity; our handler banks 0.
|
||||||
|
// block C @0x4a06c0 (tally > 0; runs after A too -- kills included):
|
||||||
|
// ScoreMessage{type 1 DamageReceivedScore,
|
||||||
|
// scoreAward = tally,
|
||||||
|
// damageAmount = INTENDED (burstCount x amount),
|
||||||
|
// senderMechID = the INFLICTOR} -> the victim's player
|
||||||
|
//
|
||||||
|
// Score-model consequence [T1]: shooters are credited ONLY for kills; victims
|
||||||
|
// are PENALIZED for damage received (CalcDamageReceivedScore returns the
|
||||||
|
// negative). Per-hit inflicted credit never existed in the 1995 pod.
|
||||||
|
//
|
||||||
|
// Port accommodations, all guarded: registry/player lookups null-checked (the
|
||||||
|
// binary derefs them raw); killBonus basis 0 for the ownerless dummy victim;
|
||||||
|
// a skipped kill credit keeps the #45 NOCREDIT matchlog forensics.
|
||||||
|
//
|
||||||
|
void BTMechPostCombatReports(
|
||||||
|
void *mech_v, // the VICTIM mech (the handler's this)
|
||||||
|
void *msg_v, // the TakeDamageMessage being handled
|
||||||
|
float damage_tally, // loop local_24: applied damage + crit bonuses
|
||||||
|
int vital_hit, // loop local_2c: a zone entered BurningState
|
||||||
|
int report_zone, // msg->damageZone at loop ENTRY (the binary
|
||||||
|
// reads msg+0x24, never rewritten mid-loop)
|
||||||
|
int newly_destroyed) // alive at entry && IsMechDestroyed() now
|
||||||
{
|
{
|
||||||
if (application == 0 || victim == 0)
|
Mech *victim = (Mech *)mech_v;
|
||||||
|
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||||
|
if (application == 0 || victim == 0 || msg == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
|
|
||||||
if (local_player == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
|
|
||||||
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
|
|
||||||
BTPlayer::ScoreMessage message(
|
|
||||||
BTPlayer::ScoreInflictedMessageID, // 0x16
|
|
||||||
sizeof(BTPlayer::ScoreMessage),
|
|
||||||
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
|
||||||
0.0f, // scoreAward (unused for inflicted)
|
|
||||||
damage, // damageAmount
|
|
||||||
victim->GetEntityID()); // senderMechID = victim
|
|
||||||
local_player->Dispatch(&message);
|
|
||||||
if (getenv("BT_SCORE_LOG"))
|
|
||||||
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
|
|
||||||
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
|
|
||||||
}
|
|
||||||
|
|
||||||
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death)
|
BTPlayer *shooter_player = 0;
|
||||||
{
|
Entity *shooter = 0;
|
||||||
if (application == 0 || victim == 0)
|
extern int BTIsRegisteredMech(Entity *e);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a
|
|
||||||
// populated `lastInflictingID` (damage is applied master-side only: 0 of 8800
|
|
||||||
// corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads
|
|
||||||
// `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a
|
|
||||||
// REPLICANT, so `Entity::Dispatch` reroutes it to the owning host
|
|
||||||
// (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine.
|
|
||||||
//
|
|
||||||
// CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node
|
|
||||||
// maintains LOCAL score copies ... each node witnesses the death transition and
|
|
||||||
// tallies its own copies self-consistently". That was FALSE and it is what hid
|
|
||||||
// this bug: the reroute means exactly ONE node increments, no other node can
|
|
||||||
// observe the killer at all, and nothing carried the value back out -- so every
|
|
||||||
// remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant:
|
|
||||||
// 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill;
|
|
||||||
// every DEATH inst=R row reads killer=0:0). The counters are
|
|
||||||
// now replicated owner->replicant instead; see Read/WriteUpdateRecord above.
|
|
||||||
BTPlayer *killer_player = 0;
|
|
||||||
if (application->GetHostManager() != 0)
|
if (application->GetHostManager() != 0)
|
||||||
{
|
{
|
||||||
Entity *killer = application->GetHostManager()->GetEntityPointer(
|
shooter = application->GetHostManager()->GetEntityPointer(
|
||||||
((Mech *)victim)->lastInflictingID);
|
msg->inflictingEntity);
|
||||||
extern int BTIsRegisteredMech(Entity *e);
|
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||||
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
|
shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
|
||||||
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
|
|
||||||
}
|
}
|
||||||
if (killer_player != 0)
|
BTPlayer *victim_player = (BTPlayer *)victim->GetPlayerLink();
|
||||||
|
|
||||||
|
if (newly_destroyed)
|
||||||
{
|
{
|
||||||
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
|
//
|
||||||
// receiver's mech) so the handler credits `killCount++` -- a suicide
|
// Block A: the KILL report, to the SHOOTER's player. On the victim's
|
||||||
// (killer == victim) never reaches here.
|
// node that player is a replicant, so Entity::Dispatch reroutes to the
|
||||||
BTPlayer::ScoreMessage kill(
|
// owning host (ENTITY.cpp:244) and the credit lands on the killer's own
|
||||||
Player::ScoreMessageID, // 0x12
|
// machine -- the #45 flow, unchanged. killer == victim (the eject
|
||||||
|
// charge) IS dispatched: the handler negates the award. That is the
|
||||||
|
// #134 panic-penalty path the old BTPostKillScore filtered out.
|
||||||
|
//
|
||||||
|
Scalar kill_bonus = 0.0f; // basis: victim role+0x1c
|
||||||
|
if (victim_player != 0 && victim_player->GetScenarioRole() != 0)
|
||||||
|
kill_bonus = victim_player->GetScenarioRole()->GetKillBonus();
|
||||||
|
if (shooter_player != 0)
|
||||||
|
{
|
||||||
|
BTPlayer::ScoreMessage kill(
|
||||||
|
Player::ScoreMessageID, // 0x16
|
||||||
|
sizeof(BTPlayer::ScoreMessage), // 0x3c
|
||||||
|
BTPlayer::ScoreMessage::KillScore, // 2
|
||||||
|
damage_tally, // +0x1c
|
||||||
|
kill_bonus, // +0x24
|
||||||
|
victim->GetEntityID(), // +0x34 the VICTIM
|
||||||
|
vital_hit, // +0x28
|
||||||
|
report_zone, // +0x2c
|
||||||
|
msg->inflictingSubsystemID); // +0x30
|
||||||
|
shooter_player->Dispatch(&kill);
|
||||||
|
if (getenv("BT_SCORE_LOG"))
|
||||||
|
DEBUG_STREAM << "[score] *** KILL report *** -> shooterPlayer="
|
||||||
|
<< (void *)shooter_player << " tally=" << damage_tally
|
||||||
|
<< " bonus=" << kill_bonus << "\n" << std::flush;
|
||||||
|
}
|
||||||
|
else if (BTMatchLogActive())
|
||||||
|
{
|
||||||
|
// #45 forensics, carried from BTPostKillScore: WHY the credit was
|
||||||
|
// skipped -- missing killer entity, unregistered, or NULL link.
|
||||||
|
BTMatchLog("NOCREDIT",
|
||||||
|
"victim=%d:%d killerID=%d:%d found=%d registered=%d link=%d",
|
||||||
|
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||||
|
BTMatchHostOf(msg->inflictingEntity), (int)msg->inflictingEntity,
|
||||||
|
(int)(shooter != 0),
|
||||||
|
(int)(shooter != 0 && BTIsRegisteredMech(shooter)),
|
||||||
|
(int)(shooter != 0 && ((Mech *)shooter)->GetPlayerLink() != 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (damage_tally != 0.0f && shooter_player != 0)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
|
||||||
|
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
|
||||||
|
// uninitialized stack float; see the handler's type-0 arm note).
|
||||||
|
//
|
||||||
|
BTPlayer::ScoreMessage inflicted(
|
||||||
|
Player::ScoreMessageID,
|
||||||
sizeof(BTPlayer::ScoreMessage),
|
sizeof(BTPlayer::ScoreMessage),
|
||||||
BTPlayer::ScoreMessage::KillScore, // 2
|
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
||||||
0.0f, // scoreAward (killBonus; 0 for bring-up)
|
damage_tally, // +0x1c
|
||||||
damage, // damageAmount (killing-blow)
|
damage_tally, // +0x24 basis = tally
|
||||||
victim->GetEntityID()); // senderMechID = victim
|
victim->GetEntityID(),
|
||||||
killer_player->Dispatch(&kill);
|
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||||
if (getenv("BT_SCORE_LOG"))
|
shooter_player->Dispatch(&inflicted);
|
||||||
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
|
|
||||||
<< " killCount=" << killer_player->GetKillCount()
|
|
||||||
<< " deaths=" << killer_player->GetDeaths()
|
|
||||||
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
|
|
||||||
}
|
|
||||||
else if (BTMatchLogActive())
|
|
||||||
{
|
|
||||||
//
|
|
||||||
// #45 observability: the credit was SKIPPED. This used to be completely
|
|
||||||
// silent, which is why the bug survived so long -- the counter simply never
|
|
||||||
// moved and no log said why. Record which link in the chain broke so a
|
|
||||||
// single matchlog convicts it: a missing killer entity, an unregistered
|
|
||||||
// mech, a self-kill, or the NULL playerLink hazard.
|
|
||||||
//
|
|
||||||
Entity *k = (application->GetHostManager() != 0)
|
|
||||||
? application->GetHostManager()->GetEntityPointer(
|
|
||||||
((Mech *)victim)->lastInflictingID)
|
|
||||||
: 0;
|
|
||||||
extern int BTIsRegisteredMech(Entity *e);
|
|
||||||
BTMatchLog("NOCREDIT",
|
|
||||||
"victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d",
|
|
||||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
|
||||||
BTMatchHostOf(((Mech *)victim)->lastInflictingID),
|
|
||||||
(int)((Mech *)victim)->lastInflictingID,
|
|
||||||
(int)(k != 0), (int)(k == victim),
|
|
||||||
(int)(k != 0 && BTIsRegisteredMech(k)),
|
|
||||||
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
|
if (damage_tally > 0.0f && victim_player != 0)
|
||||||
// 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
|
// Block C: the RECEIVED report, to the victim's own player -- kills
|
||||||
sizeof(Player::VehicleDeadMessage));
|
// included (the binary runs this after block A as well). Carries the
|
||||||
victim_player->Dispatch(&dead);
|
// INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
|
||||||
|
// penalty basis and the INFLICTOR as senderMechID; feeds the score
|
||||||
|
// penalty and the operator-console VTVDamaged line.
|
||||||
|
//
|
||||||
|
int bursts = msg->damageData.burstCount;
|
||||||
|
if (bursts < 1)
|
||||||
|
bursts = 1; // port guard (mirrors the loop)
|
||||||
|
BTPlayer::ScoreMessage received(
|
||||||
|
Player::ScoreMessageID,
|
||||||
|
sizeof(BTPlayer::ScoreMessage),
|
||||||
|
BTPlayer::ScoreMessage::DamageReceivedScore, // 1
|
||||||
|
damage_tally, // +0x1c
|
||||||
|
(Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
|
||||||
|
msg->inflictingEntity, // +0x34 the INFLICTOR
|
||||||
|
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||||
|
victim_player->Dispatch(&received);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// The authentic VehicleDead sender (@0x4a07d4-0x4a0890): the respawn-cycle
|
||||||
|
// trigger, dispatched from the death tail of the SAME TakeDamage that killed
|
||||||
|
// the mech, to the mech's own player -- now carrying the killed-by player and
|
||||||
|
// the killing zone (the BT 0x38-byte message extension). Replaces the mech4
|
||||||
|
// death-transition dispatch site, which carried a [T3 -- the binary's exact
|
||||||
|
// sender is undecoded] flag; this IS that sender, decoded. The #55
|
||||||
|
// NULL-playerLink fallback and the DEAD_NOTIFY forensics move here intact.
|
||||||
|
//
|
||||||
|
void BTMechPostVehicleDead(void *mech_v, void *msg_v, int report_zone)
|
||||||
|
{
|
||||||
|
Mech *victim = (Mech *)mech_v;
|
||||||
|
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||||
|
if (application == 0 || victim == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player *owner = victim->GetPlayerLink();
|
||||||
|
// #55 (the David fix), moved intact: resolve the SAME object by the
|
||||||
|
// reverse link the binary's own respawn branch uses when the once-written
|
||||||
|
// playerLink was lost.
|
||||||
|
if (owner == 0)
|
||||||
|
{
|
||||||
|
Player *mission_player = (Player *)application->GetMissionPlayer();
|
||||||
|
if (mission_player != 0
|
||||||
|
&& mission_player->GetPlayerVehicle() == (Entity *)victim)
|
||||||
|
{
|
||||||
|
owner = mission_player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
|
||||||
|
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||||
|
(void *)owner);
|
||||||
|
if (owner == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// killed-by: the shooter PLAYER's own EntityID (@0x4a07d4 copies
|
||||||
|
// shooterPlayer+0x184); Null when the inflictor or its player is gone.
|
||||||
|
EntityID killed_by = EntityID::Null;
|
||||||
|
if (msg != 0 && application->GetHostManager() != 0)
|
||||||
|
{
|
||||||
|
Entity *shooter = application->GetHostManager()->GetEntityPointer(
|
||||||
|
msg->inflictingEntity);
|
||||||
|
extern int BTIsRegisteredMech(Entity *e);
|
||||||
|
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||||
|
{
|
||||||
|
Player *shooter_player = ((Mech *)shooter)->GetPlayerLink();
|
||||||
|
if (shooter_player != 0)
|
||||||
|
killed_by = shooter_player->GetEntityID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BTPlayer::VehicleDeadMessage vehicle_dead(
|
||||||
|
Player::VehicleDeadMessageID, // 0x17
|
||||||
|
sizeof(BTPlayer::VehicleDeadMessage), // 0x38
|
||||||
|
killed_by,
|
||||||
|
report_zone);
|
||||||
|
owner->Dispatch(&vehicle_dead);
|
||||||
|
if (getenv("BT_DEATH_LOG"))
|
||||||
|
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player"
|
||||||
|
<< " (killedBy=" << BTMatchHostOf(killed_by) << ":" << (int)killed_by
|
||||||
|
<< " zone=" << report_zone << ")\n" << std::flush;
|
||||||
|
}
|
||||||
|
|
||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
// TOMBSTONE (2026-08-05): BTPostDamageScore / BTPostKillScore -- the gauge
|
||||||
|
// scoring wave's bring-up producers -- are RETIRED, replaced by the authentic
|
||||||
|
// report tail above (BTMechPostCombatReports / BTMechPostVehicleDead, called
|
||||||
|
// from Mech::TakeDamageMessageHandler at the binary's exact positions).
|
||||||
|
// What changed for the scoreboard:
|
||||||
|
// - per-hit INFLICTED credit is GONE: it was a port invention riding the
|
||||||
|
// never-registered @0x4c0200 handler (the binary's BTPlayer table, byte-
|
||||||
|
// scanned at file 0x112dxx, has 6 entries and none binds it). 1995
|
||||||
|
// scoring = kill awards + received-damage penalties, nothing per-hit.
|
||||||
|
// This also retires the #95 salvo-credit fix that rode it.
|
||||||
|
// - suicides now DISPATCH the kill report and the handler NEGATES the
|
||||||
|
// award -- the #134 panic-eject penalty path the old filter blocked.
|
||||||
|
// - kill credit resolves the shooter from msg->inflictingEntity of the
|
||||||
|
// killing TakeDamage itself; lastInflictingID (stamped from the same
|
||||||
|
// field at handler entry) stays for the LOD router + effect orientation.
|
||||||
|
// - the cross-node reroute story is unchanged: the kill report still
|
||||||
|
// Dispatches to a replicant player and lands on the owner host
|
||||||
|
// (ENTITY.cpp:244-251); KILLS/DEATHS still replicate owner->replicant
|
||||||
|
// (Read/WriteUpdateRecord + the scoreboard heartbeat).
|
||||||
|
// - the #81 single-VehicleDead rule is unchanged: exactly one dispatch per
|
||||||
|
// death, now from the authentic tail instead of the death transition.
|
||||||
|
// The ScoreInflictedMessageID channel and its handler remain registered but
|
||||||
|
// idle (port infra; the binary's analog was dead code in the table).
|
||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
||||||
//#############################################################################
|
//#############################################################################
|
||||||
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
|
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
|
||||||
@@ -2221,3 +2571,57 @@ Entity *BTPlayerObjectiveMechOf(void *player)
|
|||||||
return 0;
|
return 0;
|
||||||
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
|
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#############################################################################
|
||||||
|
// BTPlayerEjectBookkeeping -- complete-type bridge for Mech::EjectPilot
|
||||||
|
// (@0049f854, the PANIC/EJECT punch-out). The handler's two player-side
|
||||||
|
// writes, verbatim from the raw disasm:
|
||||||
|
// * suppressConsole (+0x258) = 1 -- the eject already notified the console
|
||||||
|
// (its own message, the tracked relay tail), so the DEATH that the
|
||||||
|
// returned charge causes must not double-notify;
|
||||||
|
// * the self-destruct amount = ScenarioRole::killBonus (role+0x1c -- the
|
||||||
|
// record map is byte-verified: reader @00429bec dest offsets + the role
|
||||||
|
// ctor's record copy [7]=rec[0]).
|
||||||
|
// Returns the charge; 0 with no player/role (binary derefs unguarded).
|
||||||
|
//#############################################################################
|
||||||
|
Scalar BTPlayerEjectBookkeeping(void *player_v)
|
||||||
|
{
|
||||||
|
BTPlayer *player = (BTPlayer *)player_v;
|
||||||
|
if (player == 0)
|
||||||
|
{
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console EJECT NOTICE (@0049f88e-0x49f8cf): FUN_004c198c is the
|
||||||
|
// ConsolePlayerMechDeathWithoutHonorMessage ctor -- "death without honor"
|
||||||
|
// IS the punch-out record (4.10 wire id 0xF; our catalogue re-tags it 6,
|
||||||
|
// not load-bearing) -- sent to the console host through the same
|
||||||
|
// ConsoleClientID path as the VTVDamaged notify. The suppressConsole
|
||||||
|
// latch below is its other half: the eject notice REPLACES the death
|
||||||
|
// notify for this death.
|
||||||
|
Host *console_host =
|
||||||
|
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||||
|
if (console_host != 0)
|
||||||
|
{
|
||||||
|
ConsolePlayerMechDeathWithoutHonorMessage eject_notice(
|
||||||
|
player->ownerID); // this+0x18c
|
||||||
|
application->SendMessage(
|
||||||
|
console_host->GetHostID(), // console_host+0xc
|
||||||
|
NetworkClient::ConsoleClientID, // 5
|
||||||
|
&eject_notice);
|
||||||
|
// Rare + forensic -- always log (the DumpAmmo precedent).
|
||||||
|
DEBUG_STREAM << "[eject] DeathWithoutHonor notice -> console host "
|
||||||
|
<< (int)console_host->GetHostID() << "\n" << std::flush;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DEBUG_STREAM << "[eject] no console host -- notice skipped\n" << std::flush;
|
||||||
|
}
|
||||||
|
player->suppressConsole = 1; // +0x258
|
||||||
|
const ScenarioRole *role = player->GetScenarioRole(); // +0x208
|
||||||
|
if (role == 0)
|
||||||
|
{
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
return role->GetKillBonus(); // role+0x1c
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
|
|||||||
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
|
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
|
||||||
// score types describe mech combat instead of score zones.
|
// score types describe mech combat instead of score zones.
|
||||||
//
|
//
|
||||||
// Observed message layout (param_2 in the handlers):
|
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
|
||||||
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
|
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
|
||||||
|
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
|
||||||
|
// were guesses from RP's smaller message, which stops at +0x28):
|
||||||
|
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
|
||||||
|
// damage tally here; the receiving handler
|
||||||
|
// overwrites it with the computed award before
|
||||||
|
// delegating to Player::ScoreMessageHandler
|
||||||
// +0x20 scoreType (int)
|
// +0x20 scoreType (int)
|
||||||
// +0x24 damageAmount (Scalar) raw damage / point quantity
|
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
|
||||||
// +0x28 pointSenderHi (EntityID word)
|
// type 1 = intended (burstCount x amount),
|
||||||
// +0x2c pointSenderLo (EntityID word)
|
// type 2 = the VICTIM role's killBonus (role+0x1c)
|
||||||
// +0x30 auxID (EntityID / host word)
|
// +0x28 vitalHit (int) a zone reached BurningState during
|
||||||
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
|
// this message's applications (loop local_2c)
|
||||||
|
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
|
||||||
|
// the INITIAL resolve -- not the last burst's)
|
||||||
|
// +0x30 subsysID (int) inflictingSubsystemID passed through
|
||||||
|
// from the TakeDamageMessage (+0x5c)
|
||||||
|
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
|
||||||
|
// type 1: the INFLICTING mech
|
||||||
//
|
//
|
||||||
class BTPlayer__ScoreMessage:
|
class BTPlayer__ScoreMessage:
|
||||||
public Player::ScoreMessage
|
public Player::ScoreMessage
|
||||||
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
|
|||||||
public:
|
public:
|
||||||
//
|
//
|
||||||
// Kind of scoring event. Recovered from the branch selector at
|
// Kind of scoring event. Recovered from the branch selector at
|
||||||
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
|
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
|
||||||
// inflicted-damage handler at @004c0200.
|
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
|
||||||
|
// and @004c0200, the only function that accepts it, appears in no
|
||||||
|
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
|
||||||
|
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
|
||||||
|
// scoring = kills + received-damage penalties; per-hit inflicted
|
||||||
|
// credit never existed.
|
||||||
//
|
//
|
||||||
enum ScoreType {
|
enum ScoreType {
|
||||||
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
|
DamageInflictedScore = 0, // sent, but scores nothing (see above)
|
||||||
DamageReceivedScore = 1, // I took damage
|
DamageReceivedScore = 1, // I took damage
|
||||||
KillScore = 2 // I destroyed / was destroyed
|
KillScore = 2 // I destroyed / was destroyed
|
||||||
};
|
};
|
||||||
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
|
|||||||
scoreType; // +0x20
|
scoreType; // +0x20
|
||||||
|
|
||||||
Scalar
|
Scalar
|
||||||
damageAmount; // +0x24
|
damageAmount; // +0x24 scoring basis (see layout table)
|
||||||
|
|
||||||
//
|
|
||||||
// Point-source / auxiliary handles carried alongside the damage record
|
|
||||||
// (read by ScoreMessageHandler when building the console feed message).
|
|
||||||
//
|
|
||||||
int
|
int
|
||||||
pointSenderHi; // +0x28
|
vitalHit; // +0x28 zone entered BurningState this message
|
||||||
|
|
||||||
int
|
int
|
||||||
pointSenderLo; // +0x2c
|
zoneIndex; // +0x2c struck damage zone
|
||||||
|
|
||||||
int
|
int
|
||||||
auxID; // +0x30
|
subsysID; // +0x30 inflicting weapon subsystem
|
||||||
|
|
||||||
EntityID
|
EntityID
|
||||||
senderMechID; // +0x34 inflicting mech (point sender)
|
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
|
||||||
|
|
||||||
BTPlayer__ScoreMessage(
|
BTPlayer__ScoreMessage(
|
||||||
Receiver::MessageID message_ID,
|
Receiver::MessageID message_ID,
|
||||||
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
|
|||||||
int score_type,
|
int score_type,
|
||||||
Scalar score_award,
|
Scalar score_award,
|
||||||
Scalar damage_amount,
|
Scalar damage_amount,
|
||||||
const EntityID &sender_mech_ID
|
const EntityID &sender_mech_ID,
|
||||||
|
int vital_hit = 0,
|
||||||
|
int zone_index = 0,
|
||||||
|
int subsys_ID = 0
|
||||||
):
|
):
|
||||||
Player::ScoreMessage(message_ID, length, score_award),
|
Player::ScoreMessage(message_ID, length, score_award),
|
||||||
scoreType(score_type),
|
scoreType(score_type),
|
||||||
damageAmount(damage_amount),
|
damageAmount(damage_amount),
|
||||||
|
vitalHit(vital_hit),
|
||||||
|
zoneIndex(zone_index),
|
||||||
|
subsysID(subsys_ID),
|
||||||
senderMechID(sender_mech_ID)
|
senderMechID(sender_mech_ID)
|
||||||
{}
|
{}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//###########################################################################
|
||||||
|
//################# BTPlayer::VehicleDeadMessage ######################
|
||||||
|
//###########################################################################
|
||||||
|
//
|
||||||
|
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
|
||||||
|
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
|
||||||
|
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
|
||||||
|
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
|
||||||
|
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
|
||||||
|
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
|
||||||
|
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
|
||||||
|
// dispatches it to the dying mech's own player -- the respawn-cycle
|
||||||
|
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
|
||||||
|
// handler consumes the two extension fields is still undecoded (the
|
||||||
|
// function is not in the export); the sender carries them faithfully.
|
||||||
|
//
|
||||||
|
class BTPlayer__VehicleDeadMessage:
|
||||||
|
public Player::VehicleDeadMessage
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
int
|
||||||
|
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
|
||||||
|
|
||||||
|
EntityID
|
||||||
|
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
|
||||||
|
|
||||||
|
int
|
||||||
|
killZone; // +0x34 zone the killing damage struck
|
||||||
|
|
||||||
|
BTPlayer__VehicleDeadMessage(
|
||||||
|
Receiver::MessageID message_ID,
|
||||||
|
size_t length,
|
||||||
|
const EntityID &killed_by_player_ID,
|
||||||
|
int kill_zone
|
||||||
|
):
|
||||||
|
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
|
||||||
|
reserved28(0),
|
||||||
|
killedByPlayerID(killed_by_player_ID),
|
||||||
|
killZone(kill_zone)
|
||||||
|
{}
|
||||||
|
};
|
||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//##################### BTPlayer::MakeMessage #########################
|
//##################### BTPlayer::MakeMessage #########################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
|
|||||||
// Player::ScoreMessage.)
|
// Player::ScoreMessage.)
|
||||||
//
|
//
|
||||||
public:
|
public:
|
||||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||||
typedef BTPlayer__MakeMessage MakeMessage;
|
typedef BTPlayer__MakeMessage MakeMessage;
|
||||||
|
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
|
||||||
|
|
||||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
// Message Support
|
// Message Support
|
||||||
@@ -436,6 +500,9 @@ class DropZone__ReplyMessage;
|
|||||||
friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12): experienceLevel == 0
|
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 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 BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
|
||||||
|
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
|
||||||
|
friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
|
||||||
|
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
|
||||||
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
|
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
|
||||||
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
|
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
|
||||||
// compiled object is `deathPending` (the respawn latch). These bridges
|
// compiled object is `deathPending` (the respawn latch). These bridges
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
|
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
|
||||||
// //
|
// //
|
||||||
// == RUNTIME BRING-UP WORKLIST (replace these) == //
|
// == RUNTIME BRING-UP WORKLIST (replace these) == //
|
||||||
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / //
|
// Mech::IsAirborne / SetTargetRange / //
|
||||||
// SetMappingSubsystem / RaiseStatusAlarm //
|
// SetMappingSubsystem / RaiseStatusAlarm //
|
||||||
// Mech__DamageZone::LoadCriticalSubsystems //
|
// Mech__DamageZone::LoadCriticalSubsystems //
|
||||||
// MechSubsystem::TakeDamage / OnAlarmChanged //
|
// MechSubsystem::TakeDamage / OnAlarmChanged //
|
||||||
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
|
|||||||
// Mech method stubs.
|
// Mech method stubs.
|
||||||
//===========================================================================//
|
//===========================================================================//
|
||||||
|
|
||||||
// TODO(bring-up): reads mech+0x414 (mission-review playback flag).
|
// (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
|
||||||
int Mech::GetMissionReviewMode()
|
// mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
|
||||||
{
|
// mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
|
||||||
return 0;
|
// the port never enters scrub/review, so it stays 0.
|
||||||
}
|
int gMissionReviewMode = 0;
|
||||||
|
|
||||||
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
|
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
|
||||||
int Mech::IsAirborne()
|
int Mech::IsAirborne()
|
||||||
@@ -175,10 +175,9 @@ void Mech::RaiseStatusAlarm(int /*alarm_id*/)
|
|||||||
// MechSubsystem method stubs.
|
// MechSubsystem method stubs.
|
||||||
//===========================================================================//
|
//===========================================================================//
|
||||||
|
|
||||||
// TODO(bring-up): apply damage to a generic mech subsystem (virtual override).
|
// (MechSubsystem::TakeDamage was a no-op stub here; it is now the real
|
||||||
void MechSubsystem::TakeDamage(Damage & /*damage*/)
|
// @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.
|
// TODO(bring-up): react to a change in this subsystem's alarm level.
|
||||||
void MechSubsystem::OnAlarmChanged()
|
void MechSubsystem::OnAlarmChanged()
|
||||||
|
|||||||
@@ -175,9 +175,14 @@ DamageZonePercentTable *
|
|||||||
{
|
{
|
||||||
if (sliceCount <= 0) return 0;
|
if (sliceCount <= 0) return 0;
|
||||||
|
|
||||||
|
Scalar thetaIn = theta;
|
||||||
if (rotateWithTorso) // binary this[3] != 0
|
if (rotateWithTorso) // binary this[3] != 0
|
||||||
{
|
{
|
||||||
theta += owner->TorsoHeading(); // torso+0x1d8
|
// #124 frame adapter, angular half: theta arrives in the 1995 frame
|
||||||
|
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
|
||||||
|
// -- enters with the OPPOSITE sign (a reflection reverses angular
|
||||||
|
// direction). Binary form: theta += torso+0x1d8.
|
||||||
|
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
|
||||||
if (theta >= TwoPi) theta -= TwoPi;
|
if (theta >= TwoPi) theta -= TwoPi;
|
||||||
if (theta < 0.0f) theta += TwoPi;
|
if (theta < 0.0f) theta += TwoPi;
|
||||||
}
|
}
|
||||||
@@ -185,6 +190,19 @@ DamageZonePercentTable *
|
|||||||
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
|
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
|
||||||
if (index < 0) index = 0;
|
if (index < 0) index = 0;
|
||||||
if (index > sliceCount - 1) index = sliceCount - 1;
|
if (index > sliceCount - 1) index = sliceCount - 1;
|
||||||
|
|
||||||
|
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
|
||||||
|
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
|
||||||
|
if (getenv("BT_DMGTABLE_LOG"))
|
||||||
|
{
|
||||||
|
static int s_sl = 0;
|
||||||
|
if (s_sl++ < 400)
|
||||||
|
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
|
||||||
|
<< " twist=" << (float)owner->TorsoHeading()
|
||||||
|
<< " thetaIn=" << (float)thetaIn
|
||||||
|
<< " thetaAdj=" << (float)theta
|
||||||
|
<< " -> slice " << index << "/" << sliceCount << std::endl;
|
||||||
|
}
|
||||||
return slices[index];
|
return slices[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +229,44 @@ DamageLookupTable::DamageLookupTable(
|
|||||||
{
|
{
|
||||||
layers.push_back(new PieSlice(owner, stream));
|
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
|
DamageLookupTable::~DamageLookupTable() // @0x49eadc
|
||||||
@@ -239,6 +295,24 @@ int
|
|||||||
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
|
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
|
||||||
if (heightRef <= 0.0f) return -1;
|
if (heightRef <= 0.0f) return -1;
|
||||||
|
|
||||||
|
// PORT FRAME ADAPTER (#124, corrected 2026-08-03 by the MUZZLE-ANCHOR
|
||||||
|
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
|
||||||
|
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
|
||||||
|
// slice-name data]. Our engine frame, measured with the mech's own
|
||||||
|
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
|
||||||
|
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
|
||||||
|
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
|
||||||
|
// (drive-code convention, "forward = -Z at heading 0"). The frames
|
||||||
|
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
|
||||||
|
// rotation first committed (that crossed the pods: left muzzle resolved
|
||||||
|
// rtorso). y (the band axis) untouched.
|
||||||
|
//
|
||||||
|
// A reflection reverses the angular walk direction, so the OTHER angular
|
||||||
|
// input -- the torso-twist term SelectSlice adds -- must flip sign with
|
||||||
|
// it (applied there, same adapter). Twist was 0 in every probe; the
|
||||||
|
// twisted-torso verify is the follow-up on #124.
|
||||||
|
local.z = -local.z;
|
||||||
|
|
||||||
// --- height -> layer (penetration depth) ---
|
// --- height -> layer (penetration depth) ---
|
||||||
Scalar depth = local.y; // binary local_2c
|
Scalar depth = local.y; // binary local_2c
|
||||||
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
|
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
|
||||||
@@ -259,5 +333,30 @@ int
|
|||||||
}
|
}
|
||||||
|
|
||||||
DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678
|
DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678
|
||||||
return leaf ? leaf->SelectZone() : -1; // FUN_0049de14
|
int resolved = leaf ? leaf->SelectZone() : -1; // FUN_0049de14
|
||||||
|
|
||||||
|
// #92: which LAYER does a given impact height land in? A foot shot that
|
||||||
|
// lands in a layer above the foot layer can never roll a foot zone.
|
||||||
|
if (getenv("BT_DMGTABLE_LOG"))
|
||||||
|
{
|
||||||
|
static int s_rh = 0;
|
||||||
|
if (s_rh++ < 400)
|
||||||
|
{
|
||||||
|
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
|
||||||
|
int *seg_out, const char **name_out);
|
||||||
|
int seg = -1; const char *zname = 0;
|
||||||
|
if (resolved >= 0)
|
||||||
|
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
|
||||||
|
DEBUG_STREAM << "[dmgresolve] impact=(" << impact.x << ","
|
||||||
|
<< impact.y << "," << impact.z << ")"
|
||||||
|
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
|
||||||
|
<< " heightRef=" << heightRef
|
||||||
|
<< " frac=" << (depth / heightRef)
|
||||||
|
<< " layer=" << layerIndex << "/" << layerCount
|
||||||
|
<< " theta=" << theta
|
||||||
|
<< " -> zone " << resolved
|
||||||
|
<< " '" << (zname ? zname : "?") << "'" << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class Point3D;
|
|||||||
// content-build authoring path -- is not part of the runtime port; the
|
// content-build authoring path -- is not part of the runtime port; the
|
||||||
// shipped .RES is pre-built. Omitted here.)
|
// 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:
|
protected:
|
||||||
Mech *owner; // @0x0c
|
Mech *owner; // @0x0c
|
||||||
std::vector<Entry> entries; // @0x14 (was ReconTable) -- sorted ascending
|
std::vector<Entry> entries; // @0x14 (was ReconTable) -- sorted ascending
|
||||||
@@ -138,6 +143,11 @@ class Point3D;
|
|||||||
//
|
//
|
||||||
DamageZonePercentTable *SelectSlice(Scalar theta) const;
|
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:
|
protected:
|
||||||
Mech *owner; // @0x0c
|
Mech *owner; // @0x0c
|
||||||
std::vector<DamageZonePercentTable*>
|
std::vector<DamageZonePercentTable*>
|
||||||
@@ -172,6 +182,7 @@ class Point3D;
|
|||||||
// the table is empty or the roll falls through (treated as a miss).
|
// the table is empty or the roll falls through (treated as a miss).
|
||||||
//
|
//
|
||||||
int ResolveHit(const Point3D &impact) const;
|
int ResolveHit(const Point3D &impact) const;
|
||||||
|
void DumpTable() const; // #92 diagnostic
|
||||||
|
|
||||||
int LayerCount() const { return layerCount; } // bring-up verify
|
int LayerCount() const { return layerCount; } // bring-up verify
|
||||||
|
|
||||||
|
|||||||
@@ -428,8 +428,22 @@ void
|
|||||||
// simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat
|
// simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat
|
||||||
// (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead
|
// (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead
|
||||||
// mech's weapons drop everything.
|
// 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
|
ResetFiringState(); // @004ba9a8
|
||||||
currentLevel = 0.0f; // 0x414
|
currentLevel = 0.0f; // 0x414
|
||||||
ComputeOutputVoltage(); // (*vtable+0x44)()
|
ComputeOutputVoltage(); // (*vtable+0x44)()
|
||||||
|
|||||||
@@ -1092,3 +1092,15 @@ void
|
|||||||
{
|
{
|
||||||
ResetToInitialState(); // @004b2678 (takes no arg)
|
ResetToInitialState(); // @004b2678 (takes no arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||||
|
// roster members against this family's derivation chain (binary GUID 0x50fdc0).
|
||||||
|
// Lives here because Gyroscope is a complete type only in this TU.
|
||||||
|
//
|
||||||
|
Derivation *
|
||||||
|
BTGyroscopeFamily()
|
||||||
|
{
|
||||||
|
return &Gyroscope::ClassDerivations;
|
||||||
|
}
|
||||||
|
|||||||
+186
-22
@@ -359,22 +359,40 @@ Condenser::~Condenser()
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// ResetToInitialState -- Condenser (HEAT.TCP)
|
// ResetToInitialState -- Condenser
|
||||||
|
//
|
||||||
|
// RE-TRANSCRIBED FROM THE BINARY (respawn-reset audit 2026-08-04; the function
|
||||||
|
// is MISSING from the Ghidra export -- disassembled raw, tools/disas2.py
|
||||||
|
// 0x4ae534 0x60). The old body chained HeatableSubsystem (temp+heatLoad only,
|
||||||
|
// per the stale HEAT.TCP shard) -- the binary chains HEATSINK (call 0x4ad760),
|
||||||
|
// so the coolant refill DOES run for a condenser; then, RESPAWN-side only:
|
||||||
|
//
|
||||||
|
// test esi, esi ; je ... -> if (powered) {
|
||||||
|
// [this+0x1d0] = 1 -> valveState = 1 (default detent --
|
||||||
|
// the pod RESETS every coolant valve)
|
||||||
|
// eax = [this+0x1d8]; [0x160] = eax -> massScale = refrigerationFactor
|
||||||
|
// (restore the refrigeration output) }
|
||||||
|
//
|
||||||
|
// Mech::Reset's tail then re-runs BTRecomputeCondenserValves (@0049f788), so
|
||||||
|
// the flow fractions rebuild from the reset detents (mech4.cpp). This is the
|
||||||
|
// operator-reported "coolant valves survive a respawn" gap [was T3, now T1].
|
||||||
//
|
//
|
||||||
void
|
void
|
||||||
Condenser::ResetToInitialState(Logical /*powered*/)
|
Condenser::ResetToInitialState(Logical powered)
|
||||||
{
|
{
|
||||||
HeatableSubsystem::ResetToInitialState(True);
|
HeatSink::ResetToInitialState(powered); // call 0x4ad760
|
||||||
|
if (powered)
|
||||||
|
{
|
||||||
|
if (getenv("BT_DEATH_LOG") && valveState != 1)
|
||||||
|
DEBUG_STREAM << "[respawn] " << GetName() << " valve detent "
|
||||||
|
<< valveState << " -> 1 (authentic @004ae534 restore)\n" << std::flush;
|
||||||
|
valveState = 1; // @0x1D0 = 1
|
||||||
|
massScale = refrigerationFactor; // @0x160 = @0x1D8
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser -- a coolant
|
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
|
||||||
// loop's valve. NOTE the body above chains HeatableSubsystem's (temperature +
|
|
||||||
// heatLoad) and so does NOT run HeatSink's coolant refill, even though Condenser
|
|
||||||
// derives from HeatSink. That mirrors the existing reconstruction; whether the
|
|
||||||
// binary's Condenser reset also restores the VALVE SETTING is not established from
|
|
||||||
// the decomp yet, so valve detents may still persist across a respawn.
|
|
||||||
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
|
|
||||||
//
|
//
|
||||||
void
|
void
|
||||||
Condenser::DeathReset(int reset_command)
|
Condenser::DeathReset(int reset_command)
|
||||||
@@ -538,7 +556,10 @@ HeatSink::HeatSink(
|
|||||||
thermalConductance = subsystem_resource->thermalConductance; // +0xF0
|
thermalConductance = subsystem_resource->thermalConductance; // +0xF0
|
||||||
|
|
||||||
heatFilter.Initialize(15, 0.0f); // FUN_0043ad4f(this+0x144, 0xF, 0)
|
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
|
thermalMass = subsystem_resource->thermalMass; // +0xF4
|
||||||
heatEnergy = thermalMass * startingTemperature;
|
heatEnergy = thermalMass * startingTemperature;
|
||||||
coolantFlowScale = 1.0f;
|
coolantFlowScale = 1.0f;
|
||||||
@@ -546,6 +567,26 @@ HeatSink::HeatSink(
|
|||||||
|
|
||||||
pendingHeat = 0.0f;
|
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)
|
// A "master" heat sink (segment flagged 0x100, not a sub-/damaged copy)
|
||||||
// drives the per-frame thermal simulation.
|
// drives the per-frame thermal simulation.
|
||||||
@@ -754,7 +795,7 @@ void
|
|||||||
{
|
{
|
||||||
heatEnergy += pendingHeat;
|
heatEnergy += pendingHeat;
|
||||||
currentTemperature = heatEnergy / thermalMass;
|
currentTemperature = heatEnergy / thermalMass;
|
||||||
UpdateHeatLoad();
|
UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
|
||||||
|
|
||||||
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
|
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
|
||||||
// the old shared static timer aliased to whichever instance crossed the
|
// the old shared static timer aliased to whichever instance crossed the
|
||||||
@@ -778,7 +819,25 @@ void
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingHeat = 0.0f;
|
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())
|
if (HeatModelActive())
|
||||||
@@ -809,8 +868,22 @@ void
|
|||||||
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
|
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
|
||||||
// the running-average filter to produce the smoothed heatLoad reading.
|
// the running-average filter to produce the smoothed heatLoad reading.
|
||||||
//
|
//
|
||||||
|
// POD-CADENCE SAMPLING (#119, 2026-08-02). The binary adds ONE sample per
|
||||||
|
// Perform -- a dt-less per-frame term (the myomer-kinetic class, FUN_0041c018
|
||||||
|
// proves Performs ride the frame rate). 15 samples at the pod's 28 Hz = a
|
||||||
|
// 0.536 s smoothing window; sampled at the port's ~59 fps the window halves
|
||||||
|
// and heatLoad turns 2x as twitchy -- which doubles the cadence of the
|
||||||
|
// ReportLeak relaxation oscillation at a drained tank (draw = damage x
|
||||||
|
// heatLoad hunting across the authored 0.0025/0.003 hysteresis band), and
|
||||||
|
// THAT is the rapidly-clipping "warning coo-- war--" leak voice Oracle
|
||||||
|
// reproduced twice in 716. Sim calls pass their slice and samples accrue at
|
||||||
|
// 28 Hz on any machine; ctor/reset prime calls (negative dt) sample
|
||||||
|
// unconditionally. Per-instance clock lives in a static map (the census
|
||||||
|
// precedent above) because the heat-family layouts are factory-size-locked
|
||||||
|
// (sizeof(Myomers) == 0x358 exact) -- no new members.
|
||||||
|
//
|
||||||
void
|
void
|
||||||
HeatSink::UpdateHeatLoad()
|
HeatSink::UpdateHeatLoad(Scalar time_slice)
|
||||||
{
|
{
|
||||||
radiatedHeat = currentTemperature * coolantLevel;
|
radiatedHeat = currentTemperature * coolantLevel;
|
||||||
|
|
||||||
@@ -824,7 +897,31 @@ void
|
|||||||
sample = HeatLoadMaximum;
|
sample = HeatLoadMaximum;
|
||||||
}
|
}
|
||||||
|
|
||||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
if (time_slice >= 0.0f)
|
||||||
|
{
|
||||||
|
static std::map<const void *, Scalar> s_filterClock;
|
||||||
|
Scalar &clock = s_filterClock[this];
|
||||||
|
clock += time_slice;
|
||||||
|
const Scalar kPodTick = 1.0f / 28.0f; // [T1] pod Perform cadence
|
||||||
|
if (clock < kPodTick)
|
||||||
|
{
|
||||||
|
return; // heatLoad holds the last average
|
||||||
|
}
|
||||||
|
int catchUp = 0;
|
||||||
|
while (clock >= kPodTick && ++catchUp <= 15) // >15 samples saturate the window
|
||||||
|
{
|
||||||
|
clock -= kPodTick;
|
||||||
|
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||||
|
}
|
||||||
|
if (catchUp > 15)
|
||||||
|
{
|
||||||
|
clock = 0.0f; // hitch: window already saturated
|
||||||
|
}
|
||||||
|
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
heatFilter.AddSample(sample); // FUN_0043ade4 (prime call)
|
||||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,8 +1049,12 @@ void
|
|||||||
// to empty every frame -- the opposite of the authentic near-static behavior.
|
// to empty every frame -- the opposite of the authentic near-static behavior.
|
||||||
// An undamaged subsystem has damageLevel 0 -> coolantDraw 0 -> NO leak (the coolant
|
// 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 /
|
// 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
|
// condenser itself takes battle damage. (#88 fix 2026-07-31: this qualified
|
||||||
// nearer MechSubsystem::damageZone is a shim SHADOW; see mechweap.cpp:252.)
|
// 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)
|
::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38)
|
||||||
Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158
|
Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158
|
||||||
coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48]
|
coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48]
|
||||||
@@ -997,14 +1098,30 @@ void
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// vtable slot 14 (@vtable+0x38). Base sinks supply nothing on their own;
|
// vtable slot 14 (@vtable+0x38) -- the base @0x4add00, disassembled + decoded
|
||||||
// a central-cooling-system subclass overrides this.
|
// 2026-07-31 (the old `return 0` stub was why a leaking subsystem could never
|
||||||
// TODO: verify against the real slot-14 target (not captured in decomp).
|
// 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
|
Scalar
|
||||||
HeatSink::DrawCoolant(Scalar /*requested*/)
|
HeatSink::DrawCoolant(Scalar requested)
|
||||||
{
|
{
|
||||||
return 0.0f;
|
HeatSink *linked = (HeatSink *)linkedSinks.Resolve(); // FUN_00417ab4(this+0x59)
|
||||||
|
requested *= coolantFlowScale; // @0x15C
|
||||||
|
if (linked == 0)
|
||||||
|
return 0.0f;
|
||||||
|
return linked->DrawCoolant(requested); // vtbl+0x38 (virtual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1278,7 +1395,54 @@ int BTSubsystemIsCondenser(::Subsystem *sub)
|
|||||||
{
|
{
|
||||||
return (sub != 0 && sub->IsDerivedFrom(*Condenser::GetClassDerivations())) ? 1 : 0;
|
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)
|
int BTCondenserNumber(::Subsystem *sub)
|
||||||
{
|
{
|
||||||
return (sub != 0) ? ((Condenser *)sub)->condenserNumber : -1; // +0x1D4
|
return (sub != 0) ? ((Condenser *)sub)->condenserNumber : -1; // +0x1D4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||||
|
// roster members against this family's derivation chain (binary GUID 0x50e590).
|
||||||
|
// Lives here because HeatSink is a complete type only in this TU.
|
||||||
|
//
|
||||||
|
Derivation *
|
||||||
|
BTHeatSinkFamily()
|
||||||
|
{
|
||||||
|
return HeatSink::GetClassDerivations();
|
||||||
|
}
|
||||||
|
|
||||||
|
//===========================================================================//
|
||||||
|
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
|
||||||
|
// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
|
||||||
|
// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
|
||||||
|
// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
|
||||||
|
// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
|
||||||
|
// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
|
||||||
|
// bank, else 1 with *out filled.
|
||||||
|
//===========================================================================//
|
||||||
|
int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
|
||||||
|
{
|
||||||
|
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
|
||||||
|
return 0;
|
||||||
|
HeatSink *bank = (HeatSink *)sub;
|
||||||
|
*out = bank->CoolantFractionOf();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -512,8 +512,11 @@ inline int
|
|||||||
// Internal model helpers
|
// Internal model helpers
|
||||||
//
|
//
|
||||||
public:
|
public:
|
||||||
|
// time_slice >= 0: sample the filter at the POD's 28 Hz cadence (#119
|
||||||
|
// -- the per-Perform filter is frame-rate-dependent, the myomer-kinetic
|
||||||
|
// class); negative (the ctor/reset prime calls) samples unconditionally.
|
||||||
void
|
void
|
||||||
UpdateHeatLoad(); // @004ad7f0
|
UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
|
||||||
void
|
void
|
||||||
ClearHeatFilter(); // @004ad884
|
ClearHeatFilter(); // @004ad884
|
||||||
void
|
void
|
||||||
@@ -564,7 +567,14 @@ inline int
|
|||||||
Scalar startingTemperature; // @0x13C resource +0xE4 (saved initial temp)
|
Scalar startingTemperature; // @0x13C resource +0xE4 (saved initial temp)
|
||||||
Scalar thermalConductance; // @0x140 resource +0xF0
|
Scalar thermalConductance; // @0x140 resource +0xF0
|
||||||
HeatFilter heatFilter; // @0x144 15-sample running average (12 bytes -> 0x150)
|
HeatFilter heatFilter; // @0x144 15-sample running average (12 bytes -> 0x150)
|
||||||
Scalar filterDecay; // @0x150 init 0.4f
|
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
|
||||||
|
// leak gauge's full-scale divisor (#97)
|
||||||
|
Scalar LeakGaugeFullScale() const { return filterDecay; }
|
||||||
|
// Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
|
||||||
|
// (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
|
||||||
|
// the guard only shields a malformed stream from a NaN.
|
||||||
|
Scalar CoolantFractionOf() const
|
||||||
|
{ return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
|
||||||
Scalar thermalMass; // @0x154 resource +0xF4
|
Scalar thermalMass; // @0x154 resource +0xF4
|
||||||
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
|
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
|
||||||
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
|
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
|
||||||
|
|||||||
@@ -870,6 +870,14 @@ Scalar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
coolantLevel -= supplied;
|
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;
|
return supplied;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,9 +1211,29 @@ void
|
|||||||
{
|
{
|
||||||
if (HeatModelActive()) // FUN_004ad7d4
|
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]
|
heatEnergy += pendingHeat; // [0x56] += [0x72]
|
||||||
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
|
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
|
||||||
UpdateHeatLoad(); // FUN_004ad7f0
|
UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
|
||||||
pendingHeat = 0.0f;
|
pendingHeat = 0.0f;
|
||||||
|
|
||||||
Scalar target = 300.0f
|
Scalar target = 300.0f
|
||||||
|
|||||||
+469
-9
@@ -82,6 +82,7 @@
|
|||||||
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
|
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
|
||||||
// for the same reason.
|
// for the same reason.
|
||||||
#include "dmgtable.hpp"
|
#include "dmgtable.hpp"
|
||||||
|
#include <GAUGREND.hpp> // GaugeRenderer::SpecialEffect (virtual) -- PPC scramble trigger
|
||||||
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
|
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
|
||||||
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
|
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
|
||||||
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
|
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
|
||||||
@@ -466,6 +467,7 @@ const Receiver::HandlerEntry
|
|||||||
MESSAGE_ENTRY(Mech, PlayerLink),
|
MESSAGE_ENTRY(Mech, PlayerLink),
|
||||||
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
|
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
|
||||||
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
|
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
|
||||||
|
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
|
||||||
};
|
};
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -513,6 +515,137 @@ void
|
|||||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
|
||||||
|
// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
|
||||||
|
// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
|
||||||
|
// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
|
||||||
|
// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
|
||||||
|
// additionally need weaponAlarm != 7 = NoAmmo)
|
||||||
|
// then permitted = weapons < ejectMinWeapons || generators == 0
|
||||||
|
// || coolant < 0.05 (@0049fb50)
|
||||||
|
// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
|
||||||
|
// The binary refreshes this per frame from an UNEXPORTED caller; the port
|
||||||
|
// evaluates on demand (the handler + any future panic-lamp consumer).
|
||||||
|
//
|
||||||
|
int
|
||||||
|
Mech::EvaluateEjectPermission()
|
||||||
|
{
|
||||||
|
extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
|
||||||
|
extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
|
||||||
|
extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
|
||||||
|
|
||||||
|
int liveWeapons = 0;
|
||||||
|
int liveGenerators = 0;
|
||||||
|
Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
|
||||||
|
|
||||||
|
for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
|
||||||
|
{
|
||||||
|
Subsystem *s = (Subsystem *)subsystemArray[i];
|
||||||
|
if (s == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Scalar frac;
|
||||||
|
if (BTHeatSinkBankCoolantFraction(s, &frac))
|
||||||
|
{
|
||||||
|
coolantFrac = frac;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int g = BTGeneratorCountsForEject(s);
|
||||||
|
if (g >= 0)
|
||||||
|
{
|
||||||
|
liveGenerators += g;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int w = BTWeaponCountsForEject(s);
|
||||||
|
if (w >= 0)
|
||||||
|
{
|
||||||
|
liveWeapons += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
|
||||||
|
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
|
||||||
|
int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
|
||||||
|
|
||||||
|
// (A short-lived coolant-clause HYSTERESIS was tried and REMOVED
|
||||||
|
// 2026-08-03, same night, for strict fidelity: the binary's clause is
|
||||||
|
// this plain `< 0.05` compare, evaluated per frame by the master
|
||||||
|
// performance (FUN_004a9b5c+0x10) -- the ARCADE ran exactly this, so a
|
||||||
|
// bank fraction hovering at the threshold flapped the panic-arm mode at
|
||||||
|
// frame rate there too. The flap is cosmetic churn (mode mask + lamp;
|
||||||
|
// hundreds of transitions ran crash-free on the bench) and was NOT the
|
||||||
|
// operator's audio tick -- that was the autofire scalpel spamming the
|
||||||
|
// per-pull jam click, no game bug. Keep the authentic compare.)
|
||||||
|
ejectPermitted =
|
||||||
|
(liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
|
||||||
|
|| (liveGenerators == 0)
|
||||||
|
|| (coolantFrac < 0.05f) // _DAT_0049fb50
|
||||||
|
|| (gimped && noviceSim);
|
||||||
|
return ejectPermitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
|
||||||
|
// See the header comment (mech.hpp) for the byte-level story.
|
||||||
|
//
|
||||||
|
void
|
||||||
|
Mech::EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message)
|
||||||
|
{
|
||||||
|
if (message->dataContents <= 0) // press only (msg+0xc)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
|
||||||
|
{
|
||||||
|
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||||
|
<< " REFUSED (mech not crippled enough)" << std::endl << std::flush;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
|
||||||
|
{
|
||||||
|
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||||
|
<< " REFUSED (disabled: movementMode "
|
||||||
|
<< MovementMode() << ")" << std::endl << std::flush;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
|
||||||
|
// from ownerID @0x18c) and send it to the console host. CORE: forensic
|
||||||
|
// log; the relay wire is the tracked tail. The suppressConsole latch set
|
||||||
|
// below is the authentic other half (the following DEATH must not also
|
||||||
|
// notify the console).
|
||||||
|
extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
|
||||||
|
void *player = GetPlayerLink(); // suppressConsole=1,
|
||||||
|
Scalar charge = (player != 0) // returns role killBonus
|
||||||
|
? BTPlayerEjectBookkeeping(player) : 0.0f;
|
||||||
|
|
||||||
|
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||||
|
<< " PUNCH-OUT: charge=" << charge
|
||||||
|
<< " (role killBonus)" << std::endl << std::flush;
|
||||||
|
|
||||||
|
graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
|
||||||
|
|
||||||
|
Damage dmg; // FUN_0041db7c (Damage::Damage)
|
||||||
|
dmg.damageType = (Enumeration)2; // Explosive
|
||||||
|
dmg.damageAmount = charge; // role+0x1c (killBonus)
|
||||||
|
dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
|
||||||
|
dmg.burstCount = 1; // binary writes 0; our victim-side
|
||||||
|
// guard clamps 0->1 -- same outcome
|
||||||
|
Entity::TakeDamageMessage take_damage(
|
||||||
|
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
|
||||||
|
GetEntityID(), // inflicting = SELF (this+0x184)
|
||||||
|
-1, // unaimed -> cylinder resolves
|
||||||
|
dmg,
|
||||||
|
-1); // inflictingSubsystemID = -1
|
||||||
|
Dispatch(&take_damage); // vtbl+0xC self-dispatch
|
||||||
|
}
|
||||||
|
|
||||||
Receiver::MessageHandlerSet
|
Receiver::MessageHandlerSet
|
||||||
Mech::MessageHandlers(
|
Mech::MessageHandlers(
|
||||||
ELEMENTS(Mech::MessageHandlerEntries),
|
ELEMENTS(Mech::MessageHandlerEntries),
|
||||||
@@ -765,13 +898,119 @@ void
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two
|
// @0x4a0164 -- the CRIT CHANCE roll (#80; raw-disasm 2026-07-29 -- the single
|
||||||
// call sites into the glue @0x49ed0c). An unaimed hit arrives with
|
// binary caller of Mech__DamageZone::CriticalHit lives in the handler below,
|
||||||
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
|
// and both sat in the un-exported decomp gap).
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
|
// 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
|
void
|
||||||
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
|
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
|
||||||
{
|
{
|
||||||
@@ -825,7 +1064,51 @@ void
|
|||||||
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
|
||||||
|
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
|
||||||
|
// arms the whole death tail -- kill report, VehicleDead, death blast.
|
||||||
|
// Capturing it after the divert (where #89 first placed it) meant a
|
||||||
|
// COLLISION death could never arm the tail.
|
||||||
|
//
|
||||||
|
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
|
||||||
|
//
|
||||||
|
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
|
||||||
|
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
|
||||||
|
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
|
||||||
|
// leak into them.
|
||||||
|
//
|
||||||
|
int reportZone = message->damageZone;
|
||||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||||
|
|
||||||
|
//
|
||||||
|
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
|
||||||
|
// type 0 == the mover's collision damage (what the crash response policy
|
||||||
|
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
|
||||||
|
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
|
||||||
|
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
|
||||||
|
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
|
||||||
|
// the rattle still posts VehicleDead (the respawn trigger) and still
|
||||||
|
// blasts. The early `return` this block shipped with was a latent
|
||||||
|
// "wall-death strands the pilot" hazard. No score reports on this path --
|
||||||
|
// the jump bypasses them; a collision death credits no one. Authentic.
|
||||||
|
//
|
||||||
|
if (message->damageData.damageType == 0)
|
||||||
|
{
|
||||||
|
DistributeCollisionDamage(&message->damageData);
|
||||||
|
goto death_tail;
|
||||||
|
}
|
||||||
|
// #92: when this gate fails the zone keeps whatever the message carried --
|
||||||
|
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
|
||||||
|
if (getenv("BT_DMGTABLE_LOG"))
|
||||||
|
{
|
||||||
|
static int s_g = 0;
|
||||||
|
if (s_g++ < 40)
|
||||||
|
DEBUG_STREAM << "[cylgate] invalidZone=" << (int)message->invalidDamageZone
|
||||||
|
<< " table=" << (void *)table
|
||||||
|
<< " incomingZone=" << message->damageZone
|
||||||
|
<< " type=" << (int)message->damageData.damageType << std::endl;
|
||||||
|
}
|
||||||
if (message->invalidDamageZone && table != 0)
|
if (message->invalidDamageZone && table != 0)
|
||||||
{
|
{
|
||||||
int zone = table->ResolveHit(message->damageData.impactPoint);
|
int zone = table->ResolveHit(message->damageData.impactPoint);
|
||||||
@@ -839,7 +1122,118 @@ void
|
|||||||
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
|
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
|
||||||
|
|
||||||
|
//
|
||||||
|
// @0x4a03f3 [T1] -- PPC/ERPPC SECONDARY-DISPLAY SCRAMBLE. EnergyDamageType
|
||||||
|
// (==4) is authored on exactly the 14 PPC/ERPPC subsystem records and nothing
|
||||||
|
// else, so this branch is structurally PPC-exclusive -- NO explicit weapon
|
||||||
|
// class check (the data authorship IS the gate). It sits OUTSIDE the burst
|
||||||
|
// loop below, so it fires ONCE per damage message, not per burst. The
|
||||||
|
// duration is DERIVED from the type ordinal, not a literal: (Scalar)4 * 0.2 ==
|
||||||
|
// 0.8s (binary loads long double 0.2 @0x4a0c08, fmulp). SpecialEffect ->
|
||||||
|
// L4GaugeRenderer scrambles every SECONDARY cockpit display (SVGA16 sync
|
||||||
|
// detune, FunkyVideo) for that window; the main 3D view is on an independent
|
||||||
|
// timing chain and is left clean. Spec: phases/phase-14-ppc-sync-distortion.md.
|
||||||
|
//
|
||||||
|
if (message->damageData.damageType == Damage::EnergyDamageType)
|
||||||
|
{
|
||||||
|
GaugeRenderer *gauges =
|
||||||
|
(application != 0) ? application->GetGaugeRenderer() : 0; // APP.h:355 -- named, not application+0x4c
|
||||||
|
if (gauges != 0) // the binary null-guards the renderer too (@0x4a0405)
|
||||||
|
gauges->SpecialEffect(GaugeRenderer::scrambleVideo,
|
||||||
|
(Scalar)message->damageData.damageType * 0.2f);
|
||||||
|
if (BTEnvOn("BT_DMG_LOG", 0))
|
||||||
|
DEBUG_STREAM << "[ppc-scramble] EnergyDamageType hit -> scrambleVideo for "
|
||||||
|
<< ((Scalar)message->damageData.damageType * 0.2f) << "s\n" << std::flush;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
|
||||||
|
// REPLACES the engine-base single application. Three things the base
|
||||||
|
// never did:
|
||||||
|
// 1. BURSTS: the base applied the Damage once and ignored burstCount
|
||||||
|
// entirely (DamageZone::TakeDamage never reads it) -- multi-burst
|
||||||
|
// damage under-applied by (burstCount-1)x.
|
||||||
|
// 2. PER-BURST ZONE RE-ROLL: with bursts remaining, the binary re-runs
|
||||||
|
// the cylinder lottery from the same impact point (@0x4a04b9), so a
|
||||||
|
// burst SPRAYS across zones -- authentic scatter.
|
||||||
|
// 3. THE CRIT ROLL (@0x4a0450): per burst, on the current zone -- gated
|
||||||
|
// on the zone not already burning, on the player's simLive flag
|
||||||
|
// (novice never crits), and on chance = clamp(0.7*lvl^2 + 0.01, 0..1)
|
||||||
|
// rising quadratically as the zone's armour strips. A landed crit
|
||||||
|
// REPLACES the zone application for that burst (CriticalHit @0049ccc4
|
||||||
|
// already routes half the amount through the armour internally).
|
||||||
|
//
|
||||||
|
// The engine base's -1 guard is preserved: an unresolvable zone applies
|
||||||
|
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
|
||||||
|
// happen there because every mech ships a lookup table).
|
||||||
|
//
|
||||||
|
if (damageZones != 0 && message->damageZone >= 0
|
||||||
|
&& message->damageZone < damageZoneCount)
|
||||||
|
{
|
||||||
|
int zoneIndex = message->damageZone; // local_20
|
||||||
|
Scalar damageTally = 0.0f; // local_24 (the id-0x16 report tally)
|
||||||
|
int zoneDestroyed = 0; // local_2c
|
||||||
|
int burstsLeft = message->damageData.burstCount; // local_28
|
||||||
|
if (burstsLeft < 1)
|
||||||
|
burstsLeft = 1; // port guard (binary trusts >= 1)
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
Mech__DamageZone *zone =
|
||||||
|
(Mech__DamageZone *)damageZones[zoneIndex]; // this[0x120][idx]
|
||||||
|
Subsystem *critted = 0;
|
||||||
|
|
||||||
|
if (zone->GetDamageZoneState() != DamageZone::BurningState // zone state != 1
|
||||||
|
&& BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below)
|
||||||
|
{
|
||||||
|
critted = zone->CriticalHit(message->damageData); // @0049ccc4
|
||||||
|
if (critted != 0)
|
||||||
|
{
|
||||||
|
damageTally +=
|
||||||
|
((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108
|
||||||
|
if (BTEnvOn("BT_CRIT_LOG", 0))
|
||||||
|
DEBUG_STREAM << "[critroll] zone=" << zoneIndex
|
||||||
|
<< " -> " << (critted->GetName() ? critted->GetName() : "?")
|
||||||
|
<< " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel()
|
||||||
|
<< "\n" << std::flush;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (critted == 0)
|
||||||
|
zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488
|
||||||
|
|
||||||
|
damageTally += message->damageData.damageAmount; // +0x30
|
||||||
|
if (zone->GetDamageZoneState() == DamageZone::BurningState)
|
||||||
|
zoneDestroyed = 1;
|
||||||
|
|
||||||
|
if (--burstsLeft == 0)
|
||||||
|
break;
|
||||||
|
DamageLookupTable *tbl = (DamageLookupTable *)damageLookupTable;
|
||||||
|
if (tbl != 0) // per-burst re-roll
|
||||||
|
zoneIndex = tbl->ResolveHit(message->damageData.impactPoint);
|
||||||
|
if (zoneIndex < 0 || zoneIndex >= damageZoneCount)
|
||||||
|
break; // port guard
|
||||||
|
if (IsDisabled()) // @0x4a04cc -- stop once dead
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
message->damageZone = zoneIndex; // matchlog sees the LAST zone
|
||||||
|
|
||||||
|
//
|
||||||
|
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
|
||||||
|
// the kill / inflicted / received score reports to the shooter's and
|
||||||
|
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
|
||||||
|
// TU, per the databinding rule); it receives the APPLIED tally, the
|
||||||
|
// vital-wreck flag, and the loop-ENTRY zone. This retires
|
||||||
|
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
|
||||||
|
//
|
||||||
|
{
|
||||||
|
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
|
||||||
|
BTMechPostCombatReports((void *)this, (void *)message,
|
||||||
|
(float)damageTally, zoneDestroyed, reportZone,
|
||||||
|
deathBlastArmed && IsMechDestroyed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
||||||
// application -- one line per applied TakeDamage with the resolved zone
|
// application -- one line per applied TakeDamage with the resolved zone
|
||||||
@@ -867,6 +1261,45 @@ void
|
|||||||
(float)message->damageData.impactPoint.y,
|
(float)message->damageData.impactPoint.y,
|
||||||
(float)message->damageData.impactPoint.z);
|
(float)message->damageData.impactPoint.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
|
||||||
|
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
|
||||||
|
// is dead(9)/eject(10) now. Sequence:
|
||||||
|
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
|
||||||
|
// carrying killed-by player + kill zone) -> the mech's OWN player:
|
||||||
|
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
|
||||||
|
// previously read this build as "SetBurningState (mech id 0x17)" --
|
||||||
|
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
|
||||||
|
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
|
||||||
|
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
|
||||||
|
// this is the decoded sender the mech4 transition site's
|
||||||
|
// [T3 sender-undecoded] flag was waiting for.
|
||||||
|
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
|
||||||
|
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
|
||||||
|
// death list from the death transition instead (#42 history): same
|
||||||
|
// once-per-death edge, not double-spawned.
|
||||||
|
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
|
||||||
|
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
|
||||||
|
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
|
||||||
|
// impact=mech origin,
|
||||||
|
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
|
||||||
|
// radius=deathSplashRadius@0x524, excluded=the dying mech;
|
||||||
|
// falloff bursts/dist^1.25 per victim inside the shared core.
|
||||||
|
// 4. ForceUpdate(1).
|
||||||
|
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
|
||||||
|
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
|
||||||
|
// the death transition sets mode 9 synchronously with the structural flag
|
||||||
|
// on every path through here, so the edges coincide; this exact predicate
|
||||||
|
// is the one the #89 blast benches verified both ways.
|
||||||
|
death_tail:
|
||||||
|
if (deathBlastArmed && IsMechDestroyed())
|
||||||
|
{
|
||||||
|
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||||
|
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||||
|
|
||||||
|
extern void BTApplyDeathSplash(void *mech_v);
|
||||||
|
BTApplyDeathSplash((void *)this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -1036,6 +1469,8 @@ Mech::Mech(
|
|||||||
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
|
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
|
||||||
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
|
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
|
||||||
rearFiring = 0; // (task #68) ORed from the weapons below
|
rearFiring = 0; // (task #68) ORed from the weapons below
|
||||||
|
ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
|
||||||
|
ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
|
||||||
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
|
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
|
||||||
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
|
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
|
||||||
// is retired; distanceToMissile (init below) owns the slot's meaning.
|
// is retired; distanceToMissile (init below) owns the slot's meaning.
|
||||||
@@ -1551,8 +1986,11 @@ Mech::Mech(
|
|||||||
|
|
||||||
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
|
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
|
||||||
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
|
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
|
||||||
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
|
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
|
||||||
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
|
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
|
||||||
|
// members -- the Wword bank is
|
||||||
|
// GLOBAL and clobbered per-mech
|
||||||
|
// values chassis-to-chassis)
|
||||||
Wword(0x195) = Wword(0x196) = 0;
|
Wword(0x195) = Wword(0x196) = 0;
|
||||||
Wword(0xfd) = 0;
|
Wword(0xfd) = 0;
|
||||||
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
|
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
|
||||||
@@ -1706,6 +2144,9 @@ Mech::Mech(
|
|||||||
ramLastVictim = 0; // ram contact-edge state
|
ramLastVictim = 0; // ram contact-edge state
|
||||||
ramContactLinger = 0.0f;
|
ramContactLinger = 0.0f;
|
||||||
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
||||||
|
mapPosture = 0; // binary @0x3f8 (the ctor zero-block @0x4b3d18 zeroes the band)
|
||||||
|
myomerEffectiveness = 1.0f; // binary @0x79c: 0-init + per-frame MAX; the port holds the
|
||||||
|
// healthy 1.0 until the Myomers Performance feeder lands [T3]
|
||||||
if (GroundReal() && GetCollisionVolumeCount() > 0
|
if (GroundReal() && GetCollisionVolumeCount() > 0
|
||||||
&& collisionTemplate != 0 && collisionVolume != 0)
|
&& collisionTemplate != 0 && collisionVolume != 0)
|
||||||
{
|
{
|
||||||
@@ -1949,6 +2390,9 @@ Mech::~Mech()
|
|||||||
|
|
||||||
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
|
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
|
||||||
BTDeregisterMech((Entity *)this);
|
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
|
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
|
||||||
BTUnstashClipState(this);
|
BTUnstashClipState(this);
|
||||||
|
|
||||||
@@ -2062,9 +2506,25 @@ Logical
|
|||||||
// airborne 3,4) tracks the master automatically; the case-0 / tail edge
|
// airborne 3,4) tracks the master automatically; the case-0 / tail edge
|
||||||
// tests below then see the old->current transition.
|
// 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
|
void
|
||||||
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
||||||
{
|
{
|
||||||
|
// #108 ghost forensics: stamp the staleness detector on every applied
|
||||||
|
// record (replicants only -- the detector lives on the death handler).
|
||||||
|
if (GetInstance() == ReplicantInstance)
|
||||||
|
{
|
||||||
|
extern void BTMechNoteUpdateApplied(void *mech_v);
|
||||||
|
BTMechNoteUpdateApplied((void *)this);
|
||||||
|
}
|
||||||
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
||||||
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
||||||
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
||||||
|
|||||||
+95
-11
@@ -357,7 +357,11 @@ struct ShotDescriptor
|
|||||||
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
|
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
|
||||||
|
|
||||||
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
|
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
|
||||||
Logical GetMissionReviewMode(); // reads this+0x414 (btl4mppr.cpp:366)
|
// ejectPermitted (@0x414) accessor -- the panic-arm watcher's read
|
||||||
|
// (btl4mppr InterpretControls @004d196c). [The old GetMissionReviewMode
|
||||||
|
// here was a mislabel of the same cell; the real review mode is the
|
||||||
|
// GLOBAL DAT_004fd550 (btl4pb.cpp). Corrected 2026-08-03.]
|
||||||
|
int GetEjectPermitted() const { return ejectPermitted; }
|
||||||
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
|
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
|
||||||
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
|
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
|
||||||
|
|
||||||
@@ -650,6 +654,12 @@ public:
|
|||||||
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
||||||
// after the layout-locked fields so nothing shifts.
|
// after the layout-locked fields so nothing shifts.
|
||||||
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
|
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
|
||||||
|
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
|
||||||
|
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
|
||||||
|
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
|
||||||
|
// were silently clobbered to the last-loaded chassis).
|
||||||
|
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
|
||||||
|
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
|
||||||
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
|
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
|
||||||
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
|
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
|
||||||
|
|
||||||
@@ -739,6 +749,15 @@ protected:
|
|||||||
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
|
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
|
||||||
// mech carries a rear arsenal". The look-back view arms those weapons.
|
// mech carries a rear arsenal". The look-back view arms those weapons.
|
||||||
int rearFiring; // @0x410 this[0x104]
|
int rearFiring; // @0x410 this[0x104]
|
||||||
|
int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
|
||||||
|
// 1 = crippled enough that the PANIC/EJECT
|
||||||
|
// punch-out is allowed (binary refreshes per
|
||||||
|
// frame from an unexported caller; the port
|
||||||
|
// evaluates on demand in the 0x19 handler)
|
||||||
|
int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
|
||||||
|
// clause. NO writer in the exported decomp --
|
||||||
|
// zero-filled in the binary, so the clause is
|
||||||
|
// inert there too [T3]; init 0 to match.
|
||||||
// (task #68) the authored look-view angles (model record +0x50..0x5c,
|
// (task #68) the authored look-view angles (model record +0x50..0x5c,
|
||||||
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
|
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
|
||||||
// parked in the Wword scratch bank). Consumed by the look-state
|
// parked in the Wword scratch bank). Consumed by the look-state
|
||||||
@@ -1013,14 +1032,31 @@ protected:
|
|||||||
Scalar gimpStrideLength; // @0x350
|
Scalar gimpStrideLength; // @0x350
|
||||||
AlarmIndicator legStateAlarm; // @0x39c
|
AlarmIndicator legStateAlarm; // @0x39c
|
||||||
int legAnimationState; // @0x3b0
|
int legAnimationState; // @0x3b0
|
||||||
|
int mapPosture; // binary @0x3f8 -- the map-legend/duck posture arbiter
|
||||||
|
// (0 none / 1 may-duck / 2 holding-squat), computed per
|
||||||
|
// master frame (FUN_004a9b5c @0x4a9f61-0x4aa007; the
|
||||||
|
// CROUCH reconstruction, 2026-08-05)
|
||||||
|
Scalar myomerEffectiveness; // binary @0x79c -- the master-perf myomer chain MAX
|
||||||
|
// (speedEffect@0x31C over the myomer subsystems),
|
||||||
|
// published LIVE each mapper update by mechmppr.cpp's
|
||||||
|
// drive-scale block (the 2026-07-31 seek-audit recon of
|
||||||
|
// the same @0x4a9cf2-0x4a9da4 bytes). Consumed by the
|
||||||
|
// crouch posture gate (mech4). Ctor 1.0 = pre-first-
|
||||||
|
// update default only.
|
||||||
Scalar gimpSpeedMax; // @0x52c
|
Scalar gimpSpeedMax; // @0x52c
|
||||||
Scalar standSpeed; // @0x530
|
Scalar standSpeed; // @0x530
|
||||||
Scalar walkStrideLength; // @0x534
|
Scalar walkStrideLength; // @0x534
|
||||||
Scalar reverseSpeedMax; // @0x538
|
Scalar reverseSpeedMax; // @0x538
|
||||||
Scalar jumpRunSpeedMax; // @0x53c
|
// GIMP (limp-gait) measurements, filled by the conditional 'wgl' loader
|
||||||
Scalar jumpWalkSpeedMax; // @0x540
|
// block (FUN_004a80d4 raw :13705-13733). "Left/Right" = WHICH LEG IS
|
||||||
Scalar jumpRunStrideLength; // @0x544
|
// GIMPED (graphicAlarm level 3 = left, 4 = right). Speeds are the wg
|
||||||
Scalar jumpWalkStrideLength; // @0x548
|
// 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 walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master
|
||||||
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
|
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
|
||||||
Scalar groundCycleRate; // @0x5b8
|
Scalar groundCycleRate; // @0x5b8
|
||||||
@@ -1035,9 +1071,9 @@ protected:
|
|||||||
Scalar globalTimeScale; // @0x5a8
|
Scalar globalTimeScale; // @0x5a8
|
||||||
Scalar idleStrideScale; // @0x5ac
|
Scalar idleStrideScale; // @0x5ac
|
||||||
Scalar gimpCycleRate; // @0x5b0
|
Scalar gimpCycleRate; // @0x5b0
|
||||||
int jumpCapable; // @0x580
|
int hasGimpClips; // @0x580 set iff the model ships the wg/gg/gs clip set
|
||||||
int hasReverseGimpSet; // @0x584
|
int squatCapable; // @0x584 set iff squ/sqd ship
|
||||||
int hasCrashSet; // @0x588
|
int turnCapable; // @0x588 set iff trn ships
|
||||||
int deathAnimationLatched; // @0x650
|
int deathAnimationLatched; // @0x650
|
||||||
int legResetLatch; // @0x654
|
int legResetLatch; // @0x654
|
||||||
int bodyResetLatch; // @0x658
|
int bodyResetLatch; // @0x658
|
||||||
@@ -1084,13 +1120,25 @@ protected:
|
|||||||
Scalar LegTransition(int next_state, Scalar adv_time, int move_joints);
|
Scalar LegTransition(int next_state, Scalar adv_time, int move_joints);
|
||||||
Scalar AdvanceLegAnimation(Scalar time_slice); // @004a5028
|
Scalar AdvanceLegAnimation(Scalar time_slice); // @004a5028
|
||||||
Scalar AdvanceBodyAnimation(Scalar time_slice, int loop);
|
Scalar AdvanceBodyAnimation(Scalar time_slice, int loop);
|
||||||
Scalar AdvanceBodyAnimationAirborne(Scalar time_slice, int loop);
|
Scalar AdvanceBodyAnimationGimp(Scalar time_slice, int loop); // @004a5bf8 (was "Airborne" -- it's the LIMP driver)
|
||||||
Scalar AdvanceLegAnimationAirborne(Scalar time_slice);
|
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
|
Logical IsDisabled(); // FUN_0049fb54
|
||||||
// --- death sequence (the un-exported master-perf death branch, rebuilt
|
// --- death sequence (the un-exported master-perf death branch, rebuilt
|
||||||
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
|
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
|
||||||
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
|
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
|
||||||
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
|
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
|
||||||
|
// --- #108 ghost-forensics accessors (PORT; the fields are protected) ---
|
||||||
|
void *DeathHandlerPtr() { return (void *)deathHandler; }
|
||||||
|
int WreckBuried() const { return collisionVolumeCount == 0; }
|
||||||
|
|
||||||
// --- clip loaders (mech3) -------------------------------------------
|
// --- clip loaders (mech3) -------------------------------------------
|
||||||
ResourceDescription::ResourceID *
|
ResourceDescription::ResourceID *
|
||||||
@@ -1119,7 +1167,15 @@ protected:
|
|||||||
NotationFile *model_file, const char *model_name,
|
NotationFile *model_file, const char *model_name,
|
||||||
NotationFile *model_notation,
|
NotationFile *model_notation,
|
||||||
const ResourceDirectories *directories);
|
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);
|
SubsystemDefaultData(const char *type_name);
|
||||||
|
|
||||||
// --- simulation / damage (mech4) ------------------------------------
|
// --- simulation / damage (mech4) ------------------------------------
|
||||||
@@ -1174,6 +1230,11 @@ protected:
|
|||||||
// Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed
|
// Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed
|
||||||
// (invalidDamageZone) hit's zone via the cylinder table, then base-route.
|
// (invalidDamageZone) hit's zone via the cylinder table, then base-route.
|
||||||
void TakeDamageMessageHandler(TakeDamageMessage *message);
|
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
|
// Mech override of Entity::PlayerLinkMessageHandler (@0049f624): the
|
||||||
// base resolves mech->player; BT adds the REVERSE link
|
// base resolves mech->player; BT adds the REVERSE link
|
||||||
// player->playerVehicle = this on EVERY node (replicants included --
|
// player->playerVehicle = this on EVERY node (replicants included --
|
||||||
@@ -1209,6 +1270,29 @@ protected:
|
|||||||
enum { DuckRequestMessageID = 0x1a };
|
enum { DuckRequestMessageID = 0x1a };
|
||||||
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
|
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||||
|
|
||||||
|
// @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
|
||||||
|
// EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
|
||||||
|
// 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
|
||||||
|
// ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
|
||||||
|
// console of the eject (FUN_004c198c ctor -> network sender; port core
|
||||||
|
// LOGS it, the relay wire is the tail) and latch the player's
|
||||||
|
// suppressConsole (+0x258) so the death that follows doesn't double-
|
||||||
|
// notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
|
||||||
|
// then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
|
||||||
|
// Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
|
||||||
|
// byte-verified via the role reader @00429bec dest offsets + the ctor
|
||||||
|
// record copy), burst 0 (binary; our victim guard clamps to 1 --
|
||||||
|
// identical outcome: the charge is lethal)}. The punch-out IS a
|
||||||
|
// death: wreck, respawn cycle and replication all ride the normal
|
||||||
|
// damage chain.
|
||||||
|
enum { EjectPilotMessageID = 0x19 };
|
||||||
|
void EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||||
|
// @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
|
||||||
|
// roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
|
||||||
|
// generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
|
||||||
|
// (leg-gimped state 3/4 AND novice, player simLive == 0).
|
||||||
|
int EvaluateEjectPermission();
|
||||||
|
|
||||||
// --- damage-routing support (mechdmg / mech4) -----------------------
|
// --- damage-routing support (mechdmg / mech4) -----------------------
|
||||||
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
|
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
|
||||||
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
|
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
|
||||||
|
|||||||
+460
-54
@@ -19,8 +19,8 @@
|
|||||||
//
|
//
|
||||||
// @004a5028 Mech::AdvanceLegAnimation (1543 bytes)
|
// @004a5028 Mech::AdvanceLegAnimation (1543 bytes)
|
||||||
// @004a5678 Mech::AdvanceBodyAnimation (1333 bytes)
|
// @004a5678 Mech::AdvanceBodyAnimation (1333 bytes)
|
||||||
// @004a5bf8 Mech::AdvanceBodyAnimationAirborne(1792 bytes)
|
// @004a5bf8 Mech::AdvanceBodyAnimationGimp(1792 bytes)
|
||||||
// @004a71f4 Mech::AdvanceLegAnimationAirborne (1840 bytes)
|
// @004a71f4 Mech::AdvanceLegAnimationGimp (1840 bytes)
|
||||||
//
|
//
|
||||||
// The embedded assert path on every one of them is
|
// The embedded assert path on every one of them is
|
||||||
// "d:\tesla\bt\bt\MECH2.CPP"
|
// "d:\tesla\bt\bt\MECH2.CPP"
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#include <bt.hpp>
|
#include <bt.hpp>
|
||||||
|
#include <intrin.h> // _ReturnAddress -- the #82 trn-armer trap
|
||||||
#include <AUDCMP.hpp> // AudioComponent -- the foot-plant step-intensity broadcast
|
#include <AUDCMP.hpp> // AudioComponent -- the foot-plant step-intensity broadcast
|
||||||
#include <AUDSRC.hpp> // AudioSource -- footstep-source identification
|
#include <AUDSRC.hpp> // AudioSource -- footstep-source identification
|
||||||
#include <AUDLVL.hpp> // AudioResource::GetAudioLevelOfDetail
|
#include <AUDLVL.hpp> // AudioResource::GetAudioLevelOfDetail
|
||||||
@@ -180,10 +181,10 @@ enum MechAnimationState
|
|||||||
// @0x530 standSpeed "at rest" / minimum move speed threshold
|
// @0x530 standSpeed "at rest" / minimum move speed threshold
|
||||||
// @0x534 walkStrideLength forward walk/run clip length (also speed cap)
|
// @0x534 walkStrideLength forward walk/run clip length (also speed cap)
|
||||||
// @0x538 reverseSpeedMax speed cap while decelerating into Reverse (?)
|
// @0x538 reverseSpeedMax speed cap while decelerating into Reverse (?)
|
||||||
// @0x53c jumpRunSpeedMax airborne speed cap, movementMode==3
|
// @0x53c gimpLeftSpeedMax airborne speed cap, movementMode==3
|
||||||
// @0x540 jumpWalkSpeedMax airborne speed cap, otherwise
|
// @0x540 gimpRightSpeedMax airborne speed cap, otherwise
|
||||||
// @0x544 jumpRunStrideLength airborne clip length, movementMode==3
|
// @0x544 gimpLeftStrideLength airborne clip length, movementMode==3
|
||||||
// @0x548 jumpWalkStrideLength airborne clip length, otherwise
|
// @0x548 gimpRightStrideLength airborne clip length, otherwise
|
||||||
// @0x598 motionEventName (string) cleared to "" on fall/reset
|
// @0x598 motionEventName (string) cleared to "" on fall/reset
|
||||||
// @0x5a4 motionEventArmed (int) reset to 0 on fall/reset
|
// @0x5a4 motionEventArmed (int) reset to 0 on fall/reset
|
||||||
// @0x5a8 globalTimeScale multiplies every clip increment
|
// @0x5a8 globalTimeScale multiplies every clip increment
|
||||||
@@ -219,6 +220,16 @@ enum MechAnimationState
|
|||||||
void
|
void
|
||||||
Mech::SetLegAnimation(int state)
|
Mech::SetLegAnimation(int state)
|
||||||
{
|
{
|
||||||
|
// DIAG (BT_DUCK_LOG): every leg re-arm with the caller's return address --
|
||||||
|
// the crouch-clobber hunt (who re-arms after the squat parks?).
|
||||||
|
if (getenv("BT_DUCK_LOG"))
|
||||||
|
{
|
||||||
|
char dbuf[96];
|
||||||
|
sprintf(dbuf, "[duck] SetLegAnimation(%d) ra=btl4+0x%lx", state,
|
||||||
|
(unsigned long)_ReturnAddress()
|
||||||
|
- (unsigned long)GetModuleHandleA(0));
|
||||||
|
DEBUG_STREAM << dbuf << std::endl << std::flush;
|
||||||
|
}
|
||||||
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
|
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
|
||||||
animationClips[state], // *(this+0x5cc + state*4)
|
animationClips[state], // *(this+0x5cc + state*4)
|
||||||
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
|
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
|
||||||
@@ -250,6 +261,39 @@ void
|
|||||||
// StateIndicator::SetState's Verify(state<stateCount). [T2]
|
// StateIndicator::SetState's Verify(state<stateCount). [T2]
|
||||||
if (state >= 0 && state < 0x21)
|
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);
|
animationState.SetState(state);
|
||||||
replicantAnimationState.SetState(state);
|
replicantAnimationState.SetState(state);
|
||||||
}
|
}
|
||||||
@@ -313,10 +357,16 @@ Scalar
|
|||||||
Scalar
|
Scalar
|
||||||
Mech::BodyClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
Mech::BodyClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
||||||
{
|
{
|
||||||
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a6344 (deferred; safe
|
// GIMP branch (FUN_004a6d8c top): a limping mech's body transitions run the
|
||||||
// no-op while grounded -- the test mech never jumps).
|
// gimp machine instead. Mode = the graphicAlarm level (the binary's real
|
||||||
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable)
|
// mech+0x40: 3=left-leg gimp, 4=right) read via the mechdmg bridge --
|
||||||
return 0.0f;
|
// 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 fcr = m->forwardCycleRate; // 0x344
|
||||||
const Scalar gts = m->globalTimeScale; // 0x5a8
|
const Scalar gts = m->globalTimeScale; // 0x5a8
|
||||||
@@ -454,9 +504,14 @@ Scalar
|
|||||||
Scalar
|
Scalar
|
||||||
Mech::LegClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
Mech::LegClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
|
||||||
{
|
{
|
||||||
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a7970 (deferred).
|
// GIMP branch (FUN_004a6928 top): route a limping mech's leg transitions
|
||||||
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable)
|
// into the gimp machine (mode = graphicAlarm level via bridge, gotcha #23).
|
||||||
return 0.0f;
|
{
|
||||||
|
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 binary reads edx = *(mech+0x128) then [edx]+0x128: subsystemArray[0]
|
||||||
// (the roster's ControlsMapper slot 0) -> speedDemand; null (no mapper)
|
// (the roster's ControlsMapper slot 0) -> speedDemand; null (no mapper)
|
||||||
@@ -475,6 +530,8 @@ Scalar
|
|||||||
return 0.0f;
|
return 0.0f;
|
||||||
// slot10 state 2 (0x4a6b37): SetLevel(1).
|
// slot10 state 2 (0x4a6b37): SetLevel(1).
|
||||||
case 2:
|
case 2:
|
||||||
|
if (getenv("BT_DUCK_LOG"))
|
||||||
|
DEBUG_STREAM << "[duck] squat clip parked (case 2 -> leg alarm 1)\n" << std::flush;
|
||||||
m->legStateAlarm.SetLevel(1); return 0.0f;
|
m->legStateAlarm.SetLevel(1); return 0.0f;
|
||||||
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
|
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
|
||||||
case 3: case 4: case 8: case 9: case 20: case 21:
|
case 3: case 4: case 8: case 9: case 20: case 21:
|
||||||
@@ -546,6 +603,250 @@ Scalar
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//###########################################################################
|
||||||
|
//###########################################################################
|
||||||
|
// GimpBodyClipFinished / GimpLegClipFinished (#78 visible limp)
|
||||||
|
//
|
||||||
|
// @004a6344 (body) / @004a7970 (leg) -- the GIMP transition machines, entered
|
||||||
|
// from the normal finished-callbacks when (gimpLevel 3|4) && hasGimpClips.
|
||||||
|
// Same walk/run/reverse alternation as the normal cbs, PLUS:
|
||||||
|
// - a top clamp of the demand to the gimped leg's speed cap (body: clamps
|
||||||
|
// bodyTargetSpeed@0x6b4; leg: clamps the LIVE mapper speedDemand and
|
||||||
|
// writes it back -- the binary's own "you can't outrun a shot leg");
|
||||||
|
// - PHASE-CORRECT limp entry from the walk cases: left-gimp (mode 3) enters
|
||||||
|
// 0x16/wgl only from a RIGHT step (5/6/0xe) -- the L case forces one more
|
||||||
|
// normal R step first; right-gimp (mode 4) mirrors into 0x17/wgr from the
|
||||||
|
// L case (7/0xf). The limp always starts on the correct foot.
|
||||||
|
// - the gg cycles 0x16/0x18 (left, ggr clip, stride @0x544) and 0x17/0x19
|
||||||
|
// (right, ggl, @0x548), continuing at gimp cadence or exiting through the
|
||||||
|
// gs transitions 0x1a/0x1b when the demand dies;
|
||||||
|
// - NO standing->reverse entry exists in the gimp machines (or drivers):
|
||||||
|
// the binary itself refuses REVERSE while gimped.
|
||||||
|
// Mode is the graphicAlarm level via the mechdmg bridge (gotcha #23).
|
||||||
|
//###########################################################################
|
||||||
|
//###########################################################################
|
||||||
|
Scalar
|
||||||
|
Mech::GimpBodyClipFinished(Scalar carryover, int mj)
|
||||||
|
{
|
||||||
|
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
|
||||||
|
const int mode = BTMechGimpLevel(this); // binary: this+0x40
|
||||||
|
const int state = bodyAnimationState; // 0x728
|
||||||
|
const Scalar fcr = forwardCycleRate; // 0x344
|
||||||
|
const Scalar gts = globalTimeScale; // 0x5a8
|
||||||
|
|
||||||
|
// Top clamp (0x4a6352): while in a moving cycle, cap the body target speed
|
||||||
|
// to the gimped side's entry-clip speed, then floor at zero.
|
||||||
|
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||||
|
|| (unsigned)(state - 0x12) < 2)
|
||||||
|
{
|
||||||
|
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
|
||||||
|
if (bodyTargetSpeed > cap) bodyTargetSpeed = cap;
|
||||||
|
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Scalar cyc = bodyCycleSpeed; // 0x6b8
|
||||||
|
const Scalar tgt = bodyTargetSpeed; // 0x6b4 (post-clamp)
|
||||||
|
const Scalar T = carryover * gts; // plain end-tail time
|
||||||
|
int next;
|
||||||
|
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
default: // 0/1 + unmapped: parked
|
||||||
|
return 0.0f;
|
||||||
|
case 2:
|
||||||
|
bodyStateAlarm.SetLevel(1); return 0.0f;
|
||||||
|
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
|
||||||
|
case 0x1a: case 0x1b: case 0x20:
|
||||||
|
bodyStateAlarm.SetLevel(0); return 0.0f;
|
||||||
|
|
||||||
|
// -- walk-R (0x4a6440): states 5,6,0xe -- the left-gimp entry point --
|
||||||
|
case 5: case 6: case 0xe:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||||
|
if (!cont) { next = 9; break; } // walk->stand L
|
||||||
|
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||||
|
if (up) { next = 0xb; break; } // toward run (dead once clamped)
|
||||||
|
int alt = 7; // normal alternation -> wwl
|
||||||
|
if (mode == 4) alt = 7; // right-gimp: one more normal step
|
||||||
|
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
|
||||||
|
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj); // LAB_004a6505
|
||||||
|
}
|
||||||
|
// -- walk-L (0x4a63ff): states 7,0xf -- the right-gimp entry point --
|
||||||
|
case 7: case 0xf:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||||
|
if (!cont) { next = 8; break; } // walk->stand R
|
||||||
|
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||||
|
if (up) { next = 0xa; break; }
|
||||||
|
int alt = 6; // normal alternation -> wwr
|
||||||
|
if (mode == 3) alt = 6; // left-gimp: force back to the R step
|
||||||
|
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
|
||||||
|
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj);
|
||||||
|
}
|
||||||
|
// -- run cycles (0x4a65f7 / 0x4a6663): 10/12 <-> 11/13 --
|
||||||
|
case 0xa: case 0xc:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||||
|
if (cont) // LAB_004a6648 run-cadence tail
|
||||||
|
return BodyTransition(0xd, carryover * cyc * gts / reverseStrideLength, mj);
|
||||||
|
next = 0xf; break; // run->walk R
|
||||||
|
}
|
||||||
|
case 0xb: case 0xd:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||||
|
if (cont)
|
||||||
|
return BodyTransition(0xc, carryover * cyc * gts / reverseStrideLength, mj);
|
||||||
|
next = 0xe; break;
|
||||||
|
}
|
||||||
|
// -- back cycles (0x4a66d5 / 0x4a6754): 0x10/0x12 <-> 0x11/0x13 --
|
||||||
|
case 0x10: case 0x12:
|
||||||
|
{
|
||||||
|
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||||
|
if (up) { next = 0x15; break; } // back->stand L
|
||||||
|
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||||
|
if (t <= 0.0f) t = -t; // 0x350 stored negative
|
||||||
|
return BodyTransition(0x13, t, mj);
|
||||||
|
}
|
||||||
|
case 0x11: case 0x13:
|
||||||
|
{
|
||||||
|
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||||
|
if (up) { next = 0x14; break; }
|
||||||
|
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||||
|
if (t <= 0.0f) t = -t;
|
||||||
|
return BodyTransition(0x12, t, mj);
|
||||||
|
}
|
||||||
|
// -- GIMP cycles (0x4a67cf / 0x4a6836): left 0x16/0x18, right 0x17/0x19 --
|
||||||
|
case 0x16: case 0x18:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
|
||||||
|
if (cont) // keep limping (ggr cycle)
|
||||||
|
return BodyTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, mj);
|
||||||
|
next = 0x1a; break; // demand died -> gsl exit
|
||||||
|
}
|
||||||
|
case 0x17: case 0x19:
|
||||||
|
{
|
||||||
|
bool cont = (tgt >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
|
||||||
|
if (cont)
|
||||||
|
return BodyTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, mj);
|
||||||
|
next = 0x1b; break; // -> gsr exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BodyTransition(next, T, mj); // shared plain tail
|
||||||
|
}
|
||||||
|
|
||||||
|
Scalar
|
||||||
|
Mech::GimpLegClipFinished(Scalar carryover)
|
||||||
|
{
|
||||||
|
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
|
||||||
|
const int mode = BTMechGimpLevel(this); // binary: this+0x40
|
||||||
|
const int state = legAnimationState; // 0x3b0
|
||||||
|
MechControlsMapper *mppr = MappingMapper(); // **(this+0x128)
|
||||||
|
|
||||||
|
// Top clamp (0x4a7995): cap the LIVE COMMANDED speedDemand itself while in
|
||||||
|
// a moving cycle, writing the clamp back -- this is the binary's authentic
|
||||||
|
// gimp slowdown (the mapper re-derives demand each tick; this cb re-caps
|
||||||
|
// it every clip end, and the gimp cycle cadence below enforces it anyway).
|
||||||
|
if (((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||||
|
|| (unsigned)(state - 0x12) < 2) && mppr != 0)
|
||||||
|
{
|
||||||
|
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
|
||||||
|
if (mppr->speedDemand > cap) mppr->speedDemand = cap;
|
||||||
|
if (mppr->speedDemand < ZeroSpeed) mppr->speedDemand = ZeroSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Scalar spd = (mppr != 0) ? mppr->speedDemand : 0.0f; // (binary derefs; port guards null)
|
||||||
|
const Scalar fcr = forwardCycleRate; // 0x344
|
||||||
|
const Scalar gts = globalTimeScale; // 0x5a8
|
||||||
|
const Scalar cyc = legCycleSpeed; // 0x348
|
||||||
|
const Scalar T = carryover * gts;
|
||||||
|
int next;
|
||||||
|
|
||||||
|
switch (state) // all leg tails mj=1 (binary)
|
||||||
|
{
|
||||||
|
default:
|
||||||
|
return 0.0f;
|
||||||
|
case 2:
|
||||||
|
legStateAlarm.SetLevel(1); return 0.0f;
|
||||||
|
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
|
||||||
|
case 0x1a: case 0x1b: case 0x20:
|
||||||
|
legStateAlarm.SetLevel(0); return 0.0f;
|
||||||
|
|
||||||
|
// -- walk-R (0x4a7a52): states 5,6,0xe -- left-gimp entry --
|
||||||
|
case 5: case 6: case 0xe:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||||
|
if (!cont) { next = 9; break; }
|
||||||
|
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||||
|
if (up) { next = 0xb; break; }
|
||||||
|
int alt = 7;
|
||||||
|
if (mode == 4) alt = 7; // right-gimp: one more normal step
|
||||||
|
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
|
||||||
|
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1); // LAB_004a7b38
|
||||||
|
}
|
||||||
|
// -- walk-L (0x4a7bb5): states 7,0xf -- right-gimp entry --
|
||||||
|
case 7: case 0xf:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
|
||||||
|
if (!cont) { next = 8; break; }
|
||||||
|
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
|
||||||
|
if (up) { next = 0xa; break; }
|
||||||
|
int alt = 6;
|
||||||
|
if (mode == 3) alt = 6; // left-gimp: force back to the R step
|
||||||
|
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
|
||||||
|
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1);
|
||||||
|
}
|
||||||
|
// -- run cycles (0x4a7c33 / 0x4a7c93): --
|
||||||
|
case 0xa: case 0xc:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||||
|
if (cont) // LAB_004a7c79
|
||||||
|
return LegTransition(0xd, carryover * cyc * gts / reverseStrideLength, 1);
|
||||||
|
next = 0xf; break;
|
||||||
|
}
|
||||||
|
case 0xb: case 0xd:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
|
||||||
|
if (cont)
|
||||||
|
return LegTransition(0xc, carryover * cyc * gts / reverseStrideLength, 1);
|
||||||
|
next = 0xe; break;
|
||||||
|
}
|
||||||
|
// -- back cycles: 0x10/0x12 <-> 0x11/0x13 --
|
||||||
|
case 0x10: case 0x12:
|
||||||
|
{
|
||||||
|
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||||
|
if (up) { next = 0x15; break; }
|
||||||
|
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||||
|
if (t <= 0.0f) t = -t;
|
||||||
|
return LegTransition(0x13, t, 1);
|
||||||
|
}
|
||||||
|
case 0x11: case 0x13:
|
||||||
|
{
|
||||||
|
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
|
||||||
|
if (up) { next = 0x14; break; }
|
||||||
|
Scalar t = carryover * cyc * gts / gimpStrideLength;
|
||||||
|
if (t <= 0.0f) t = -t;
|
||||||
|
return LegTransition(0x12, t, 1);
|
||||||
|
}
|
||||||
|
// -- GIMP cycles: left 0x16/0x18, right 0x17/0x19 --
|
||||||
|
case 0x16: case 0x18:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
|
||||||
|
if (cont)
|
||||||
|
return LegTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, 1);
|
||||||
|
next = 0x1a; break;
|
||||||
|
}
|
||||||
|
case 0x17: case 0x19:
|
||||||
|
{
|
||||||
|
bool cont = (spd >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
|
||||||
|
if (cont)
|
||||||
|
return LegTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, 1);
|
||||||
|
next = 0x1b; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LegTransition(next, T, 1); // shared plain tail (mj=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// AdvanceLegAnimation (channel A, ground)
|
// AdvanceLegAnimation (channel A, ground)
|
||||||
@@ -567,8 +868,35 @@ Scalar
|
|||||||
// alias (an AV); controlsMapper is the typed mirror of roster slot 0. A mech
|
// 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.
|
// with no mapper reads demand 0 (idles) -- matching a zeroed binary roster.
|
||||||
MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7)
|
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 =
|
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;
|
Scalar distance = 0.0f;
|
||||||
|
|
||||||
// binary: legAnimationState@0x3b0 IS legStateAlarm's level (one field; the
|
// binary: legAnimationState@0x3b0 IS legStateAlarm's level (one field; the
|
||||||
@@ -664,7 +992,7 @@ Scalar
|
|||||||
// narrow near-zero entry gate (the pre-#64b accommodation). [T3]
|
// narrow near-zero entry gate (the pre-#64b accommodation). [T3]
|
||||||
const int trnIsRepl = (GetInstance() == ReplicantInstance);
|
const int trnIsRepl = (GetInstance() == ReplicantInstance);
|
||||||
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
|
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
|
||||||
if (hasCrashSet != 0 && mppr != 0
|
if (turnCapable != 0 && mppr != 0
|
||||||
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
|
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
|
||||||
&& (mppr->turnDemand > 0.05f
|
&& (mppr->turnDemand > 0.05f
|
||||||
|| mppr->turnDemand < -0.05f)
|
|| mppr->turnDemand < -0.05f)
|
||||||
@@ -985,7 +1313,15 @@ Scalar
|
|||||||
// re-enter walk on the same frame.
|
// re-enter walk on the same frame.
|
||||||
{
|
{
|
||||||
MechControlsMapper *bm = MappingMapper();
|
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)
|
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
|
||||||
{
|
{
|
||||||
bodyStateAlarm.SetLevel(0);
|
bodyStateAlarm.SetLevel(0);
|
||||||
@@ -1112,38 +1448,47 @@ Scalar
|
|||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// AdvanceBodyAnimationAirborne (channel B, jump-capable)
|
// AdvanceBodyAnimationGimp (channel B, limping)
|
||||||
//
|
//
|
||||||
// @004a5bf8 (MECH2.CPP:0x2E8)
|
// @004a5bf8 (MECH2.CPP:0x2E8)
|
||||||
//
|
//
|
||||||
// Airborne variant of AdvanceBodyAnimation selected by Mech::IntegrateMotion
|
// GIMP (limp-gait) variant of AdvanceBodyAnimation, selected when
|
||||||
// when jump jets are active. Adds a pre-clamp of bodyTargetSpeed to the jump
|
// (gimpLevel 3|4) && hasGimpClips ("Airborne"/jump-jet was a misread -- the
|
||||||
// speed limits and handles the FallForward / FallBackward jump cycles
|
// clip set is wg/gg/gs, #78). Adds a pre-clamp of bodyTargetSpeed to the
|
||||||
// (states 0x18/0x19); gimp-to-stand (0x16/0x17) and the lateral falls
|
// gimped side's speed cap and drives the gg limp cycles (0x18 left / 0x19
|
||||||
// (0x1a/0x1b) join the normal advance group here rather than resetting.
|
// 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
|
Scalar
|
||||||
Mech::AdvanceBodyAnimationAirborne(Scalar time_slice, int loop)
|
Mech::AdvanceBodyAnimationGimp(Scalar time_slice, int loop)
|
||||||
{
|
{
|
||||||
Scalar distance = 0.0f;
|
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
|
// 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
|
int state = bodyAnimationState; // this+0x728
|
||||||
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|
||||||
|| (unsigned)(state - 0x12) < 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
|
else // walk jump
|
||||||
{
|
{
|
||||||
if (bodyTargetSpeed > jumpWalkSpeedMax) bodyTargetSpeed = jumpWalkSpeedMax; // 0x540
|
if (bodyTargetSpeed > gimpRightSpeedMax) bodyTargetSpeed = gimpRightSpeedMax; // 0x540
|
||||||
}
|
}
|
||||||
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
|
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
|
||||||
}
|
}
|
||||||
@@ -1259,7 +1604,8 @@ Scalar
|
|||||||
case 0x18: case 0x19: // FallForward / FallBackward (jump)
|
case 0x18: case 0x19: // FallForward / FallBackward (jump)
|
||||||
{
|
{
|
||||||
Scalar ratio;
|
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)
|
if (bodyTargetSpeed <= bodyCycleSpeed)
|
||||||
{
|
{
|
||||||
@@ -1267,16 +1613,16 @@ Scalar
|
|||||||
{
|
{
|
||||||
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
||||||
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||||
if (bodyCycleSpeed < jumpRunSpeedMax) bodyCycleSpeed = jumpRunSpeedMax; // 0x53c
|
if (bodyCycleSpeed < gimpLeftSpeedMax) bodyCycleSpeed = gimpLeftSpeedMax; // 0x53c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bodyCycleSpeed += forwardCycleRate * time_slice;
|
bodyCycleSpeed += forwardCycleRate * time_slice;
|
||||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
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
|
else // walk jump: caps 0x540 / 0x548
|
||||||
{
|
{
|
||||||
@@ -1286,16 +1632,16 @@ Scalar
|
|||||||
{
|
{
|
||||||
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
bodyCycleSpeed -= forwardCycleRate * time_slice;
|
||||||
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
||||||
if (bodyCycleSpeed < jumpWalkSpeedMax) bodyCycleSpeed = jumpWalkSpeedMax; // 0x540
|
if (bodyCycleSpeed < gimpRightSpeedMax) bodyCycleSpeed = gimpRightSpeedMax; // 0x540
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bodyCycleSpeed += forwardCycleRate * time_slice;
|
bodyCycleSpeed += forwardCycleRate * time_slice;
|
||||||
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
|
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(
|
distance = bodyAnimation.Advance(
|
||||||
time_slice * ratio * globalTimeScale, loop);
|
time_slice * ratio * globalTimeScale, loop);
|
||||||
@@ -1315,24 +1661,62 @@ Scalar
|
|||||||
|
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
// AdvanceLegAnimationAirborne (channel A, jump-capable)
|
// AdvanceLegAnimationGimp (channel A, limping)
|
||||||
//
|
//
|
||||||
// @004a71f4 (MECH2.CPP:0x672)
|
// @004a71f4 (MECH2.CPP:0x672)
|
||||||
//
|
//
|
||||||
// Airborne variant of AdvanceLegAnimation. Like the ground version it reads
|
// GIMP variant of AdvanceLegAnimation (see the body variant's note). Like
|
||||||
// the live commanded speed from the controls subsystem, but here it also
|
// the ground version it reads the live commanded speed from the controls
|
||||||
// CLAMPS that source value to the jump speed limit (writing it back), and
|
// subsystem, but here it also CLAMPS that source value to the gimped side's
|
||||||
// handles the FallForward / FallBackward jump cycles (0x18/0x19). No death
|
// speed cap (writing it back -- the authentic "can't outrun a shot leg"),
|
||||||
// latch / footstep block; gimp-to-stand and lateral falls join the normal
|
// and drives the gg limp cycles (0x18/0x19). No death latch / footstep
|
||||||
// advance group.
|
// block; wg entries and gs exits join the normal advance group.
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
//###########################################################################
|
//###########################################################################
|
||||||
Scalar
|
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;
|
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
|
int state = legAnimationState; // this+0x3b0
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -1344,13 +1728,13 @@ Scalar
|
|||||||
{
|
{
|
||||||
if (mode == 3)
|
if (mode == 3)
|
||||||
{
|
{
|
||||||
if (motionSource->commandedSpeed > jumpRunSpeedMax)
|
if (motionSource->commandedSpeed > gimpLeftSpeedMax)
|
||||||
motionSource->commandedSpeed = jumpRunSpeedMax; // 0x53c
|
motionSource->commandedSpeed = gimpLeftSpeedMax; // 0x53c
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (motionSource->commandedSpeed > jumpWalkSpeedMax)
|
if (motionSource->commandedSpeed > gimpRightSpeedMax)
|
||||||
motionSource->commandedSpeed = jumpWalkSpeedMax; // 0x540
|
motionSource->commandedSpeed = gimpRightSpeedMax; // 0x540
|
||||||
}
|
}
|
||||||
if (motionSource->commandedSpeed < ZeroSpeed)
|
if (motionSource->commandedSpeed < ZeroSpeed)
|
||||||
motionSource->commandedSpeed = ZeroSpeed;
|
motionSource->commandedSpeed = ZeroSpeed;
|
||||||
@@ -1375,6 +1759,28 @@ Scalar
|
|||||||
}
|
}
|
||||||
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
|
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;
|
distance = 0.0f;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1477,16 +1883,16 @@ Scalar
|
|||||||
{
|
{
|
||||||
legCycleSpeed -= forwardCycleRate * time_slice;
|
legCycleSpeed -= forwardCycleRate * time_slice;
|
||||||
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||||
if (legCycleSpeed < jumpRunSpeedMax) legCycleSpeed = jumpRunSpeedMax;
|
if (legCycleSpeed < gimpLeftSpeedMax) legCycleSpeed = gimpLeftSpeedMax;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
legCycleSpeed += forwardCycleRate * time_slice;
|
legCycleSpeed += forwardCycleRate * time_slice;
|
||||||
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
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
|
else // walk jump
|
||||||
{
|
{
|
||||||
@@ -1496,16 +1902,16 @@ Scalar
|
|||||||
{
|
{
|
||||||
legCycleSpeed -= forwardCycleRate * time_slice;
|
legCycleSpeed -= forwardCycleRate * time_slice;
|
||||||
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
||||||
if (legCycleSpeed < jumpWalkSpeedMax) legCycleSpeed = jumpWalkSpeedMax;
|
if (legCycleSpeed < gimpRightSpeedMax) legCycleSpeed = gimpRightSpeedMax;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
legCycleSpeed += forwardCycleRate * time_slice;
|
legCycleSpeed += forwardCycleRate * time_slice;
|
||||||
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
|
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);
|
distance = legAnimation.Advance(time_slice * ratio * globalTimeScale, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user