Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning). Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:
1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
an ARMED bay fire never detonated.
2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
mech TakeDamage handler drops) even if 1+2 had fired.
THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression. The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks. New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.
THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp). The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere. Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).
THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.
VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
"ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
(authentic death list).
scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
(bin empty)", no detonation.
scratchpad/sim3.py : the HEAT route arms organically in combat (overheated
AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
BAYBOOM matchlog record added for MP field forensics.
FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias). My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death. ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure). Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).
#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it. The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't"). Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges. [T2 -- the data path
is rig-verified; the pixels await the next live session.]
#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session. The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.
Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).
KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN. 40 LNK2019 unchanged (the two pre-existing families).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d39227ef39
commit
5ae4410914
@@ -271,6 +271,53 @@ autocannon/Gauss scaled against armor via `damageScale[Explosive]` not `damageSc
|
||||
(missiles still resolve Explosive from their own type; autocannon/Gauss now Ballistic). The
|
||||
missile-SPLASH template stays Explosive. [T2 verify: an autocannon mech produces type=1 DMG.]
|
||||
|
||||
## AMMO BAY FIRE / COOK-OFF — FIXED (2026-07-25, issue #46) [T1 mechanism, T2 live]
|
||||
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran, one purged one
|
||||
left burning). Root cause = **three stacked kill-switches** on the same path in `ammobin.cpp`:
|
||||
`GameClock::Now(){return 0}` (→ `cookOffTime < 0<0` — an armed fire NEVER detonated),
|
||||
`InjectHeat(void*){}` (detonation applied nothing), and a never-stamped damage record (0 dmg of
|
||||
type 0 even if it had). The authentic mechanism, raw-disasm verified (`scratchpad/disammo.py`):
|
||||
- **Arming**: bin `heatAlarm` FAILURE(2) with rounds left, or message 1 (crit-induced,
|
||||
`HandleMessage @004bdb94`). `cookOffTime = Now().ticks + round(10.0 × ticksPerSecond)` — a
|
||||
**FIXED 10-second fuse**. (The old "RandomDelay" was a Ghidra artifact: `FUN_004dcd94` is
|
||||
`__ftol`; the caller's x87 expression `10.0 × DAT_0052140c + 0.5` was dropped from the export.
|
||||
`DAT_0052140c` = SystemClock ticksPerSecond. See [[reconstruction-gotchas]] §__ftol.)
|
||||
- **Purge cancels**: bin Empty (eject-hold bay dump) → `cookOffArmed=0`, no detonation.
|
||||
- **The bin's damage record** (`Damage cookOffDamage @0x1F0..0x21C`, was mislabeled "heat
|
||||
descriptor"/"heatPerRound"): stamped at construction by the linked **ProjectileWeapon ctor
|
||||
@004bc3fc** — `bin+0x1F0.. = weapon->damageData @0x3A8` (type/per-round amount/force/normal/
|
||||
impact/burst), via `owner->roster[0x128][resource+0x1C0]`, UNGUARDED. Sequencing is
|
||||
load-bearing: the stamp precedes MissileLauncher's chained ctor dividing its own damageData by
|
||||
missileCount — a missile bin holds the per-SALVO amount. (projweap.cpp's old comment called
|
||||
this "the bin's HUD display block", and it was never wired.)
|
||||
- **Detonation** (`CookOff @004bd300` → `@004ac274` = `MechSubsystem::DistributeCriticalHit`):
|
||||
local Damage = record, amount = ammoCount × per-round; statusAlarm pulse Exploding(2)→
|
||||
Destroyed(1) (slot-13 = the `printSimulationState` state PRINT @004ac8c0, NOT an "explosion
|
||||
notify"); own private zone `structureLevel = 1.0`; then **collect the mech DamageZones whose
|
||||
crit entries plug this subsystem** (the binary iterates the bin's Node links filtering
|
||||
classID 0x4E = `DamageZoneClassID`, VDATA.h idx 78), split the amount evenly, and send the
|
||||
OWNER one full `TakeDamageMessage` per zone — `inflictingEntity`=SELF, `damageZone`=index,
|
||||
`inflictingSubsystemID`=the bin (the message-manager's explosion-resource bundling key,
|
||||
ENTITY3.h's own NOTE) — printing `"ammo explosion damaging <zoneName>"` (@0050df61). Port:
|
||||
`Mech::AmmoExplosionFanOut` (mechdmg.cpp) behind `BTAmmoExplosionFanOut`; guarded deviation:
|
||||
zoneCount==0 warns instead of the binary's unguarded divide.
|
||||
- **Verified live** (`scratchpad/baytest.py` / `baypurge.py`, hook `BT_BAYTEST=<frame>`):
|
||||
arm → 10s → `20 rounds × 35 = 700 (type 2 Explosive)` → `ammo explosion damaging dz_ltorso`
|
||||
→ zone cascade → **mech DESTROYED** (authentic death list); purge arm → `EXTINGUISHED`, no
|
||||
detonation. `BAYBOOM` matchlog record added for MP forensics.
|
||||
⚠ Family gap logged in [[open-questions]]: `HandleMessage` is vtable slot 8/9 in the binary but
|
||||
the reconstruction declares it NON-virtual across 10 classes (no live base-typed callers yet).
|
||||
⚠ **Fix-of-the-fix (caught by the sim3 regression, same day):** `MechSubsystem`'s
|
||||
`ReconDamageZone` proxy puts `structureLevel` at **offset 0 — aliasing the real engine
|
||||
`DamageZone`'s VTABLE pointer** (the private zone IS `new DamageZone`, mechsub.cpp:154).
|
||||
Writing `damageZone->structureLevel = 1.0f` through the proxy overwrote the zone's vptr with
|
||||
`0x3F800000`; the respawn sweep's virtual `SetGraphicState` (slot 3 = vtable+0xC) then called
|
||||
through it → AV at `0x3f80000c` in `RespawnRepair` (`sim3` pod3, one frame after a bay-fire
|
||||
death). ALL EIGHT proxy-view sites in mechsub.cpp swept to the engine view
|
||||
(`((DamageZone*)damageZone)->damageLevel` @0x158) — including two silently-wrong LIVE readers:
|
||||
`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.
|
||||
|
||||
## Damage delivery + the real damage model
|
||||
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch`.
|
||||
**Base handler IGNORES zone==−1** (`Entity::TakeDamageMessageHandler`, ENTITY.cpp:878 — returns on
|
||||
|
||||
@@ -319,6 +319,43 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
|
||||
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
|
||||
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
|
||||
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
|
||||
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
|
||||
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
|
||||
round(10.0 × DAT_0052140c)` — **fixed 10 s** (`FUN_004dcd94` is `__ftol`, the caller's x87
|
||||
expression was dropped by the export — see [[reconstruction-gotchas]] §19; `DAT_0052140c` =
|
||||
SystemClock ticksPerSecond, set 28.0/18.206 at part_000.c:921/926; `FUN_00414b60` = `Now()`
|
||||
returning `&Time`, pause-aware); `@004bd300` CookOff = local Damage copy, amount = ammoCount ×
|
||||
`bin+0x1F4` (**float** — `fmul`, not fild), → `@004ac274`; `@0041db7c` = **`Damage::Damage()`**
|
||||
(== T0 DAMAGE.cpp); `@004ac274` = **`MechSubsystem::DistributeCriticalHit`** — statusAlarm
|
||||
pulse 2→1 (slot 13 `+0x34` = @004ac8c0 the `printSimulationState`@+0x104-gated state PRINT,
|
||||
`__DefaultState/__Destroyed/__Exploding` — NOT an explosion notify), own zone
|
||||
`+0xE0→+0x158 = 1.0`, then per-DamageZone `TakeDamageMessage` (id 0x12, 100 B) to the owner —
|
||||
the collected plugs filter `classID@+4 == 0x4E` = **`DamageZoneClassID`** (VDATA.h idx 78;
|
||||
idx 28 = AudioStateTrigger cross-checks the enum count), `damageZone` = zone`+0x13C`
|
||||
(damageZoneIndex), `inflictingEntity` = mech`+0x184` (SELF), `inflictingSubsystemID` =
|
||||
bin`+0xD8` (the explosion-bundling key); `@004bc3fc` ProjectileWeapon ctor **stamps
|
||||
`bin+0x1F0..0x21C ← weapon->damageData@0x3A8`** via `owner->roster[0x128][res+0x1C0]`,
|
||||
unguarded, BEFORE MissileLauncher's ctor divides by missileCount. Env: `BT_BAYTEST=<frame>`
|
||||
(arm via message 1), `[ammo] BAY FIRE / DETONATION / EXTINGUISHED` always-on forensics,
|
||||
`BAYBOOM` matchlog record.
|
||||
- **GaugeAlarm / lamp-flash machinery MAPPED 2026-07-25 (issue #47, the eng-button flash) [T1]:**
|
||||
the shipped `BTL4.RES` carries **one `GaugeAlarmStream` resource (type 31)** — the authored
|
||||
{condition, level, lampCode} annunciator data survives, baked. Chain: subsystem-alarm
|
||||
`SetLevel` → StateIndicator gauge-watcher socket → `Renderer::StartEntityAlarmMessage` (id 7;
|
||||
Stop = 8) → `GaugeAlarmManager::Activate @00448d00` / `Deactivate @00448e00` (T0
|
||||
GAUGALRM.cpp, manager at L4GaugeRenderer`+0x1c0cc`) → the **BTL4 override bodies @004cc148..
|
||||
@004cc2fc** (`@004cc2fc` ReadGaugeAlarmStreamItem: stream-read {level, lampCode}; lampCode
|
||||
`<0x80` fixed via @004cc148, `≥0x80` SUBSYSTEM-RELATIVE eng-button via @004cc1a0; Condenser
|
||||
(0x50e4fc) / power-family (0x50fb60) specials @004cc264/@004cc27c) → `LampManager::FindLamp
|
||||
@00444c80` (lamp map at renderer`+0x1c0c8`) → **`Lamp::SetAlertState @00444e64`** (the flash
|
||||
COUNTER at lamp+0x1C) → the L4 lamp flush `@00474e94` emits `0x37`/`0x13` = `flashFast` RIO
|
||||
states (== T0 L4LAMP.cpp:234-239) → `RIO::SetLamp` (binary `FUN_00476568`; cached wrapper
|
||||
`@00474d54`). T0-compiled already: LAMP.cpp / L4LAMP.cpp / GAUGALRM.cpp / RENDERER messages.
|
||||
**Missing in the port (3 pieces)**: the BTL4 override bodies (btl4galm.cpp is best-effort and
|
||||
its provenance note claiming "no override exists in the binary" is WRONG — @004cc148-2fc IS
|
||||
it), the gauge-watcher sender registration, and the lamp objects for the aux buttons. Plan in
|
||||
[[open-questions]].
|
||||
- **MechRIOMapper** @0x51DD30: RE-REGISTERS the base ids 3..0x13 (Aux1Quad..ZoomOut) with the
|
||||
RIO override handlers @004d22fc..@004d24f8, + {0x19, Keypress→@004d2514} (RIO's OWN keypress,
|
||||
unreconstructed — we use the shared L4 0x17 @004d1bf0) + {0x1a, Hotbox→@004d2574} @0x51DE98.
|
||||
@@ -368,6 +405,7 @@ default-ON (`'0'` disables).
|
||||
| `BT_GAUGE_ATTR_LOG` | `[attr] <binding> OK/NULL` per config binding |
|
||||
| `BT_GAUGE_SKIP_LOG` | `[gskip] <primitive>` per unregistered gauge widget the parse skips |
|
||||
| `BT_VALVE_LOG` | condenser valve flow distribution |
|
||||
| `BT_BAYTEST=<frame>` | #46 rig: send message 1 (crit-induced cook-off arm) to the first AmmoBin at the given sim frame — the 10 s fuse then runs live (`scratchpad/baytest.py` / `baypurge.py`) |
|
||||
| `BT_LOOP_AUDIT` | `[loop-audit]` per-sound `sample-flag × source-render-type -> AL_LOOPING` (Gitea #51; rig `scratchpad/loopaudit.py`) |
|
||||
| `BT_LOOP_LEGACY=1` | restore the OLD always-loop rule (`AL_LOOPING = sample != ForceStatic`) for a field A/B — see the loop-flag note in [[wintesla-port]] |
|
||||
| `BT_AUDIO_DUMP` | once-a-second `[playing]` dump of every playing AL source + gain/pitch/**loop** — catches a stuck looping source red-handed |
|
||||
|
||||
@@ -351,6 +351,18 @@ authentically ANIMATES 0→1 through each reload. (The census's four other writ
|
||||
@004b9c9c is the MechWeapon/projectile base body.) The launcher panel's other live indicators:
|
||||
AMMO DIGITS (AmmoBin::ammoCount via the complete-type bridges), jam/fire lamps, eject wipe.
|
||||
|
||||
## The ballistic panel's FIRE icon = the BAY-FIRE annunciator — FIXED (2026-07-25, #47) [T1 decomp / T2 live]
|
||||
`BallisticWeaponCluster::Execute @004c9a38` (raw decomp): `this[0x3b] = *(bin+0x18C)` —
|
||||
**`cookOffArmed`**, the field the `btefire.pcc` TwoState watches; while armed, `this[0x3e] =
|
||||
(Now().ticks − bin->cookOffTime@0x190) / ticksPerSecond` — the **cook-off countdown seconds**
|
||||
(negative → 0 at detonation) shown by the `NumericDisplayScalarTwoState` beside it. The old
|
||||
reconstruction misread `bin+0x18C` as "the reload state" and bridged the icon to
|
||||
`BTAmmoBinFeeding` — so it blinked with every feed cycle and never lit on a bay fire
|
||||
(RajelAran: "it doesn't"). Now driven by `BTAmmoBinCookOffArmed/CookOffTime` complete-type
|
||||
bridges (ammobin.cpp). The member names `firing/reloading/reloadSeconds` in btl4gau2 are
|
||||
historic; semantically bayFireArmed / latch / secondsToDetonation. The ENG-BUTTON FLASH half
|
||||
of #47 (the GaugeAlarm/lamp machinery) is mapped-but-unbuilt — see [[open-questions]].
|
||||
|
||||
## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21)
|
||||
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
|
||||
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
|
||||
|
||||
@@ -200,6 +200,27 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
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
|
||||
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
|
||||
powersub×3/projweap) as reconstructions of vtable-slot overrides, none `virtual` — a
|
||||
base-typed `sub->HandleMessage(n)` statically binds to the base and SKIPS the override
|
||||
(bit the `BT_BAYTEST` hook; fixed there with a typed AmmoBin call). No live base-typed
|
||||
caller exists in the port today, so nothing is currently broken — but any future
|
||||
reconstruction that dispatches subsystem messages through a base pointer (the binary's crit
|
||||
path may) must first make the family virtual in one sweep (signatures must agree; some
|
||||
declare `Logical`, some `void`+int variants — audit before flipping).
|
||||
- **#47 remainder: the ENG-button attention FLASH (jam / bay fire) — machinery fully mapped,
|
||||
3 pieces unbuilt (2026-07-25).** The authored data SHIPS: `BTL4.RES` carries one
|
||||
`GaugeAlarmStream` (type 31). The full chain + addresses: [[decomp-reference]] §GaugeAlarm.
|
||||
To make it live: (1) reconstruct the REAL `BTL4GaugeAlarmManager` override bodies from
|
||||
@004cc148..@004cc2fc (btl4galm.cpp is best-effort scaffolding and its provenance note —
|
||||
"no override body exists in the image" — is WRONG; that code region IS the override,
|
||||
including the ≥0x80 subsystem-relative eng-button lamp mapping); (2) register the
|
||||
gauge-side StateIndicator watcher that sends `StartEntityAlarm` (id 7) on subsystem-alarm
|
||||
transitions (the AUDWTHR parallel on the gaugeWatcherSocket); (3) build the LampManager
|
||||
lamps for the aux/eng bezel buttons (LAMP.cpp/L4LAMP.cpp are T0-compiled; the glass panels
|
||||
+ pod RIO both consume `RIO::SetLamp`, so the flash lands on-screen AND on the pod).
|
||||
Decode the type-31 stream format first — it names which conditions flash which lamps.
|
||||
- **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's
|
||||
post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/
|
||||
`damageableSubsystems`(0x50e4fc) through the local `SubProxy` stub whose `IsDerivedFrom`
|
||||
|
||||
@@ -581,3 +581,22 @@ global sequence is untouched).
|
||||
**Rule:** when reconstructing, ask of every load-time-scoped hook — *what happens if this
|
||||
resource loads twice?* If the answer differs from the first load, either re-install the scope
|
||||
at every load site or cache the loaded object. Both are legitimate; silently re-parsing is not.
|
||||
|
||||
## 19. Ghidra drops the x87 expression around `__ftol` (the "RandomDelay" that was a 10-second constant)
|
||||
|
||||
`FUN_004dcd94` decompiles as a bare `return (int)ROUND(in_ST0);` — it is **`__ftol`**, the
|
||||
compiler's float→int helper, called with the value already in ST0. When Ghidra carves it as a
|
||||
normal function, the CALLER's decompile can silently LOSE the whole floating-point expression
|
||||
and show a bald `iVar = FUN_004dcd94();` — the computation vanishes from the export.
|
||||
|
||||
Archetype (issue #46): the AmmoBin cook-off delay read as `Now() + FUN_004dcd94()` and was
|
||||
reconstructed as "a randomised delay" stubbed to 0. The raw bytes @004bd450 are
|
||||
`fld 10.0 / fmul [ticksPerSecond] / fadd 0.5 / call __ftol` — a **fixed 10.0-second fuse**.
|
||||
The stub meant an armed bay fire never detonated.
|
||||
|
||||
**Rule:** any decomp line of the shape `iVar = FUN_004dcd94()` (or another arg-less FUN whose
|
||||
body reads `in_ST0`) is a **carve artifact** — raw-disassemble the call site
|
||||
(`scratchpad/disammo.py` pattern) and recover the x87 sequence before reconstructing. Related
|
||||
export blind spots: mid-function iterator carves at odd addresses (FUN_004acfa9-style thunks),
|
||||
truncated vtables.tsv rows (dump exe bytes at vtable+slot*4), and whole-function gaps (#60 —
|
||||
raw-disasm recovered @004b838c and @004bb9b8).
|
||||
|
||||
+112
-28
@@ -24,8 +24,9 @@
|
||||
// excluded : the 0x41xxxx engine vtable slots and the HeatWatcher /
|
||||
// HeatableSubsystem base bodies (FUN_004aeb40 / FUN_004ac644 chain)
|
||||
//
|
||||
// Decoded constant:
|
||||
// Decoded constants (raw disasm, scratchpad/disammo.py -- Gitea #46):
|
||||
// _DAT_004bd4e8 = 0.0f (feed-tick guard: advance only when time_slice > 0)
|
||||
// _DAT_004bd4ec = 10.0f _DAT_004bd4f0 = 0.5f (the cook-off FUSE, below)
|
||||
//
|
||||
// Resource classID stamps (CreateStreamedSubsystem @004bd6f0):
|
||||
// "ProjectileClassID" -> 0x0BD1 "MissileClassID" -> 0x0BBA
|
||||
@@ -37,10 +38,27 @@
|
||||
// FUN_004aea84 HeatWatcher::ReadUpdate (slot 9 base)
|
||||
// FUN_004aeac4 HeatWatcher::WatchSimulation (drive watch alarm from temp)
|
||||
// FUN_004ac1d4 HeatableSubsystem::HandleMessage (slot 8 base)
|
||||
// FUN_004ac274 HeatableSubsystem::InjectHeat(descriptor)
|
||||
// FUN_004ac274 MechSubsystem::DistributeCriticalHit -- the ammo-explosion
|
||||
// per-DamageZone TakeDamage fan-out (mechsub.cpp). The old
|
||||
// "HeatableSubsystem::InjectHeat" label was WRONG (#46).
|
||||
// FUN_0041b9ec AlarmIndicator::Init(levels) FUN_0041baa4 AlarmIndicator::Destroy
|
||||
// FUN_0041bbd8 AlarmIndicator::SetLevel FUN_0041db7c init heat descriptor
|
||||
// FUN_00414b60 GameClock::Now() FUN_004dcd94 random delay
|
||||
// FUN_0041bbd8 AlarmIndicator::SetLevel
|
||||
// FUN_0041db7c Damage::Damage() -- the T0 DAMAGE.cpp ctor, byte-matched.
|
||||
// The old "init heat descriptor" label was WRONG (#46).
|
||||
// FUN_00414b60 Now() -- returns &Time (pause-aware tick counter); the
|
||||
// caller dereferences .ticks (same mapping as btl4pb.cpp).
|
||||
// FUN_004dcd94 __ftol (float->int). The old "random delay" label was a
|
||||
// GHIDRA ARTIFACT: the export dropped the caller's x87
|
||||
// expression and left a bare `iVar = FUN_004dcd94();`. The
|
||||
// raw bytes @004bd450 are:
|
||||
// fld dword [0x4bd4ec] ; 10.0f
|
||||
// fmul dword [0x52140c] ; x SystemClock ticksPerSecond
|
||||
// fadd dword [0x4bd4f0] ; + 0.5f (rounding)
|
||||
// call __ftol
|
||||
// i.e. the cook-off fuse is a FIXED 10.0 SECONDS, not random.
|
||||
// (DAT_0052140c is the ticks-per-second global -- set to 28.0
|
||||
// or 18.206 at startup, part_000.c:921/926, and used as the
|
||||
// ticks->seconds divisor everywhere else.) [T1]
|
||||
// FUN_0041a1a4 IsDerivedFrom FUN_004022d0 operator delete
|
||||
//
|
||||
|
||||
@@ -60,23 +78,36 @@
|
||||
static const Scalar AmmoFeedTickFloor = 0.0f; // _DAT_004bd4e8
|
||||
|
||||
//
|
||||
// Reconstruction support: small proxy types + unrecovered-helper stand-ins for
|
||||
// the cook-off heat descriptor, the streamed-resource parser and the game clock.
|
||||
// Reconstruction support: small proxy types for the streamed-resource parser.
|
||||
//
|
||||
struct HeatRecord { Scalar amount; };
|
||||
struct AmmoModelRecord{ int index; };
|
||||
static const Scalar _DAT_004bdb74 = -1.0f; // FeedRate "unset" sentinel
|
||||
|
||||
static void InitCookOffHeatSource(void *) {} // FUN_0041db7c
|
||||
static void InjectHeat(void *) {} // FUN_004ac274
|
||||
static void SetStatusLevel(int) {} // FUN_0041bbd8 (status alarm)
|
||||
static int ReadInt(NotationFile *f, const char *p, const char *k, int *v) { return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static int ReadScalar(NotationFile *f, const char *p, const char *k, Scalar *v) { return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static int ReadString(NotationFile *f, const char *p, const char *k, const char **v){ return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static void ReportMissing(const char *, const char *) {} // FUN_004dbb24
|
||||
static AmmoModelRecord *LookupModel(NotationFile *, const char *, int, int) { return 0; } // FUN_00406ff8
|
||||
namespace GameClock { static int Now() { return 0; } } // FUN_00414b60
|
||||
static int RandomDelay() { return 0; } // FUN_004dcd94
|
||||
|
||||
//
|
||||
// Gitea #46 -- THE COOK-OFF CLOCK. Three stand-ins used to sit here and each
|
||||
// was a kill-switch stacked on the same path:
|
||||
// * `GameClock::Now() { return 0; }` -> `cookOffTime < Now()` was `0 < 0`,
|
||||
// so an ARMED bay fire never detonated (the field report exactly:
|
||||
// "two ammo bay fires and no death");
|
||||
// * `RandomDelay() { return 0; }` -> a misdecode; see the header block --
|
||||
// the real fuse is a FIXED 10.0 seconds in clock ticks;
|
||||
// * `InjectHeat(void*) {}` -> even a firing cook-off applied
|
||||
// NOTHING; the real @004ac274 is the per-DamageZone TakeDamage fan-out.
|
||||
//
|
||||
static int BTGameClockNowTicks() // FUN_00414b60, dereferenced
|
||||
{
|
||||
return ((volatile Time &)Now()).ticks;
|
||||
}
|
||||
static int BTCookOffFuseTicks() // fld 10.0; fmul tps; fadd 0.5; __ftol
|
||||
{
|
||||
return (int)(10.0f * (float)SystemClock::GetTicksPerSecond() + 0.5f);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
@@ -144,7 +175,10 @@ AmmoBin::AmmoBin(
|
||||
// (WAVE 4) statusState=0 removed -- the base MechSubsystem ctor owns
|
||||
// simulationState@0x40; the reads below use the inherited member.
|
||||
ammoAlarm.Initialize(6); // @0x194 FUN_0041b9ec(this+0x65, 6)
|
||||
InitCookOffHeatSource(&cookOffHeatSource); // @0x1F0 FUN_0041db7c(this+0x7c)
|
||||
// @0x1F0: FUN_0041db7c(this+0x7c) is Damage::Damage() -- our cookOffDamage
|
||||
// member ctor already ran it (amount 0, normal (0,1,0), burst 1). The
|
||||
// REAL type/amount arrive later, stamped by the linked ProjectileWeapon's
|
||||
// ctor @004bc3fc (StampCookOffDamage <- weapon->damageData). [#46]
|
||||
reserved = 0; // @0x228
|
||||
|
||||
ammoCount = resource->ammoCount; // @0x180 resource +0xFC
|
||||
@@ -200,6 +234,23 @@ int BTAmmoBinFeeding(void *bin)
|
||||
return (bin != 0 && ((AmmoBin *)bin)->ammoAlarm.Level() == AmmoBin::Feeding) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Gitea #47: the eng-panel FIRE icon + cook-off countdown bridges. The
|
||||
// binary's BallisticWeaponCluster::Execute (@004c9a38) reads
|
||||
// this[0x3b] = *(bin + 0x18C) <- cookOffArmed (the btefire TwoState)
|
||||
// this[0x3e] = (*Now() - *(bin+0x190)) / ticksPerSecond
|
||||
// <- seconds relative to DETONATION
|
||||
// The old gauge reconstruction misread bin+0x18C as "the reload state" and
|
||||
// bridged it to BTAmmoBinFeeding -- so the FIRE icon blinked on every reload
|
||||
// feed and never lit on a bay fire (RajelAran: "it doesn't").
|
||||
int BTAmmoBinCookOffArmed(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->cookOffArmed : 0;
|
||||
}
|
||||
int BTAmmoBinCookOffTime(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->cookOffTime : 0;
|
||||
}
|
||||
|
||||
// The round's GameModel resource ID (ammoModelFile @0x1E8, word 0x7A). In the
|
||||
// arcade the MissileLauncher seeds each spawned Missile's model from AmmoBin+0x1e8
|
||||
// (part_013.c:8778) and the Missile ctor reads SplashRadius from that model's
|
||||
@@ -256,17 +307,31 @@ void AmmoBin::DumpAmmo()
|
||||
//#############################################################################
|
||||
// CookOff -- detonate the remaining rounds
|
||||
//
|
||||
// @004bd300
|
||||
// @004bd300 (raw disasm, scratchpad/disammo.py -- Gitea #46)
|
||||
//
|
||||
// Loads the prebuilt heat descriptor, sets its heat amount to
|
||||
// (ammoCount * heatPerRound), injects it into the mech via the HeatableSubsystem
|
||||
// base, then zeroes the bin and raises the Empty alarm.
|
||||
// Copies the weapon-stamped cook-off Damage record to a local, scales its
|
||||
// amount to (ammoCount x per-round damageAmount):
|
||||
// fild dword [ebx+0x180] ; ammoCount (int)
|
||||
// fmul dword [ebp-0x2c] ; x damageAmount (FLOAT -- fmul, not fild;
|
||||
// ; the old "(float)param_1[0x7d]" int
|
||||
// ; reading was a Ghidra rendering)
|
||||
// then hands it to @004ac274 = MechSubsystem::DistributeCriticalHit -- the
|
||||
// status-alarm pulse + own-zone gut + per-DamageZone TakeDamage fan-out
|
||||
// (mechsub.cpp) -- and finally zeroes the bin and raises the Empty alarm.
|
||||
//
|
||||
void AmmoBin::CookOff()
|
||||
{
|
||||
HeatRecord record = *(HeatRecord *)&cookOffHeatSource; // words 0x7C, 0x7E..0x87
|
||||
record.amount = (Scalar)ammoCount * heatPerRound; // this[0x60] * this[0x7D]
|
||||
InjectHeat(&record); // FUN_004ac274(this, &record)
|
||||
Damage record = cookOffDamage; // words 0x7C..0x87 -> locals
|
||||
record.damageAmount =
|
||||
(Scalar)ammoCount * cookOffDamage.damageAmount; // fild [0x180] * fmul [0x1F4]
|
||||
|
||||
// Rare + forensic -- always log (the DumpAmmo precedent).
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE DETONATION: "
|
||||
<< ammoCount << " rounds x " << cookOffDamage.damageAmount
|
||||
<< " = " << record.damageAmount
|
||||
<< " (type " << (int)record.damageType << ")\n" << std::flush;
|
||||
|
||||
DistributeCriticalHit(record); // FUN_004ac274(this, &record)
|
||||
ammoCount = 0; // this+0x180
|
||||
ammoAlarm.SetLevel(Empty); // 2
|
||||
}
|
||||
@@ -303,25 +368,38 @@ void AmmoBin::AmmoBinSimulation(Scalar time_slice)
|
||||
}
|
||||
}
|
||||
|
||||
// arm cook-off when overheated and not already empty
|
||||
// arm cook-off when overheated and not already empty: the 10-second fuse
|
||||
if (heatAlarm.Level() == 2 /* FAILURE, this+0x140 */ &&
|
||||
ammoAlarm.Level() != Empty && cookOffArmed == 0)
|
||||
{
|
||||
cookOffArmed = 1; // this+0x18C
|
||||
cookOffTime = GameClock::Now() + RandomDelay(); // this+0x190
|
||||
cookOffTime = BTGameClockNowTicks() + BTCookOffFuseTicks(); // this+0x190
|
||||
// Rare + forensic -- always log (mirrors the detonation line).
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE: cook-off armed, "
|
||||
<< ammoCount << " rounds, detonation in 10s (purge to cancel)\n" << std::flush;
|
||||
}
|
||||
|
||||
// fire / cancel cook-off
|
||||
if (cookOffArmed != 0)
|
||||
{
|
||||
if (ammoAlarm.Level() == Empty)
|
||||
{
|
||||
cookOffArmed = 0; // nothing left -- cancel
|
||||
else if (cookOffTime < GameClock::Now())
|
||||
DEBUG_STREAM << "[ammo] " << GetName()
|
||||
<< " bay fire EXTINGUISHED (bin empty)\n" << std::flush;
|
||||
}
|
||||
else if (cookOffTime < BTGameClockNowTicks())
|
||||
{
|
||||
cookOffArmed = 0;
|
||||
SetStatusLevel(1); // FUN_0041bbd8(this+0xb, 1) -- subsystem damaged
|
||||
// slot 13 (explosion notify): authoritative-only virtual call in the
|
||||
// shipped code; runs through the installed vtable at run time.
|
||||
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb=+0x2C, 1) -- Destroyed
|
||||
// @004bd4b1: `if (this[0x41]) (*vtbl+0x34)(this)` -- this+0x104 is
|
||||
// printSimulationState and slot 13 is the OnAlarmChanged state
|
||||
// PRINT ("<name> = __Destroyed/__Exploding", @004ac8c0). The old
|
||||
// "authoritative-only explosion notify" comment was wrong [#46];
|
||||
// the explosion VISUAL rides the TakeDamageMessage's
|
||||
// inflictingSubsystemID (the message-manager explosion bundling).
|
||||
if (printSimulationState)
|
||||
OnAlarmChanged();
|
||||
CookOff(); // @004bd300
|
||||
}
|
||||
}
|
||||
@@ -344,7 +422,9 @@ Logical AmmoBin::HandleMessage(int message)
|
||||
if (cookOffArmed == 0)
|
||||
{
|
||||
cookOffArmed = 1;
|
||||
cookOffTime = GameClock::Now() + RandomDelay(); // FUN_00414b60 + FUN_004dcd94
|
||||
cookOffTime = BTGameClockNowTicks() + BTCookOffFuseTicks(); // Now() + the 10s fuse
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE (message): cook-off armed, "
|
||||
<< ammoCount << " rounds, detonation in 10s\n" << std::flush;
|
||||
return True;
|
||||
}
|
||||
return False;
|
||||
@@ -510,7 +590,11 @@ struct AmmoBinLayoutCheck
|
||||
static_assert(offsetof(AmmoBin, ammoAlarm) == 0x194, "AmmoBin ammoAlarm @0x194 (0x54-byte WatcherGaugeAlarm)");
|
||||
static_assert(offsetof(AmmoBin, ammoModelFile) == 0x1E8, "AmmoBin ammoModelFile @0x1E8 (proves ammoAlarm==0x54 AND statusState deleted)");
|
||||
static_assert(offsetof(AmmoBin, explosionModelFile) == 0x1EC, "AmmoBin explosionModelFile @0x1EC");
|
||||
static_assert(offsetof(AmmoBin, heatPerRound) == 0x1F4, "AmmoBin heatPerRound @0x1F4");
|
||||
// #46: the cook-off Damage record occupies exactly words 0x7C..0x87.
|
||||
static_assert(offsetof(AmmoBin, cookOffDamage) == 0x1F0, "AmmoBin cookOffDamage @0x1F0 (words 0x7C..0x87)");
|
||||
static_assert(sizeof(Damage) == 0x30, "Damage must be 0x30 (12 words -- the binary descriptor span)");
|
||||
static_assert(offsetof(Damage, damageAmount) == 0x4, "Damage::damageAmount @+4 -- @004bd300 fmul reads bin+0x1F4");
|
||||
static_assert(offsetof(Damage, burstCount) == 0x2C, "Damage::burstCount @+0x2C -- @004bc3fc writes bin+0x21C");
|
||||
static_assert(offsetof(AmmoBin, initialAmmoCount) == 0x220, "AmmoBin initialAmmoCount @0x220");
|
||||
static_assert(offsetof(AmmoBin, feedRate) == 0x224, "AmmoBin feedRate @0x224");
|
||||
static_assert(offsetof(AmmoBin, reserved) == 0x228, "AmmoBin reserved @0x228 (+4 => sizeof 0x22C)");
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
friend int *BTAmmoBinCountPtr(void *bin); // panel ammo counter bridge (Streak fix)
|
||||
friend int BTAmmoBinFeeding(void *bin); // panel reload-state bridge
|
||||
friend int BTAmmoRoundModelResource(void *bin); // splash-radius resolve (round GameModel +0x50)
|
||||
friend int BTAmmoBinCookOffArmed(void *bin); // #47: the eng-panel FIRE icon (bin+0x18C)
|
||||
friend int BTAmmoBinCookOffTime(void *bin); // #47: the cook-off countdown numeric (bin+0x190)
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Shared Data Support
|
||||
//
|
||||
@@ -259,14 +261,41 @@
|
||||
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
|
||||
int ammoModelFile; // @0x1E8 (word 0x7A) round model index
|
||||
int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index
|
||||
// @0x1F0 (word 0x7C) cook-off heat-injection descriptor, inited by
|
||||
// FUN_0041db7c; CookOff() fills word 0x7D (@0x1F4) below with the total
|
||||
// heat (ammoCount * heatPerRound) before injecting via HeatableSubsystem.
|
||||
int cookOffHeatSource[1]; // @0x1F0 (word 0x7C)
|
||||
Scalar heatPerRound; // @0x1F4 (word 0x7D)
|
||||
int cookOffHeatRest[10]; // @0x1F8..0x21C (words 0x7E..0x87)
|
||||
|
||||
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
|
||||
// engine `Damage` (0x30 bytes: type@+0, amount@+4, force@+8, normal@+0x14,
|
||||
// impact@+0x20, burstCount@+0x2C). Gitea #46 CORRECTION: the old
|
||||
// reconstruction modeled this span as an opaque "heat-injection
|
||||
// descriptor" (`cookOffHeatSource/heatPerRound/cookOffHeatRest`) -- wrong
|
||||
// on three counts, all [T1]:
|
||||
// * FUN_0041db7c is `Damage::Damage()` (raw disasm == the T0
|
||||
// DAMAGE.cpp:34 ctor: amount 0, force identity, normal (0,1,0),
|
||||
// burst 1);
|
||||
// * "@0x1F4 = heatPerRound" is the record's `damageAmount` slot -- the
|
||||
// LINKED WEAPON's per-round damage, stamped by the ProjectileWeapon
|
||||
// ctor @004bc3fc (bin+0x1F0..0x21C <- weapon->damageData @0x3A8,
|
||||
// via owner->roster[0x128][resource+0x1C0]; the projweap.cpp comment
|
||||
// calling this "the bin's HUD display block" was wrong, and the
|
||||
// stamping had never been wired -- so the record stayed all-zero);
|
||||
// * CookOff() does not "inject heat": it builds a local copy with
|
||||
// amount = ammoCount x damageAmount (fild [0x180]; fmul [0x1F4])
|
||||
// and hands it to @004ac274 = MechSubsystem::DistributeCriticalHit,
|
||||
// the per-DamageZone TakeDamage fan-out (see mechsub.cpp).
|
||||
Damage cookOffDamage; // @0x1F0 (words 0x7C..0x87)
|
||||
|
||||
int initialAmmoCount; // @0x220 (word 0x88) ammoCount captured at ctor
|
||||
Scalar feedRate; // @0x224 (word 0x89) resource FeedRate
|
||||
int reserved; // @0x228 (word 0x8A) init 0 (unused)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Cross-family stamp (ProjectileWeapon ctor @004bc3fc -> this bin).
|
||||
//
|
||||
public:
|
||||
// The binary writes the roster slot UNGUARDED (no AmmoBin type test) --
|
||||
// every authored weapon's AmmoBin index names a real bin, so the port
|
||||
// keeps the same contract behind this typed accessor.
|
||||
void
|
||||
StampCookOffDamage(const Damage &weapon_damage)
|
||||
{ cookOffDamage = weapon_damage; }
|
||||
};
|
||||
#endif
|
||||
|
||||
@@ -19,6 +19,23 @@
|
||||
//
|
||||
// RECONSTRUCTED -- IMPORTANT PROVENANCE NOTE
|
||||
// ------------------------------------------
|
||||
// ⚠ 2026-07-25 (#47): THE CENTRAL CLAIM BELOW IS WRONG. The BTL4 override
|
||||
// bodies DO exist in the shipped image, at @004cc148..@004cc2fc -- immediately
|
||||
// BELOW the @004cc40c boundary this note drew:
|
||||
// @004cc2fc ReadGaugeAlarmStreamItem: stream-reads {trigger level, lamp
|
||||
// code}; when the subsystem's alarm level matches, maps the lamp
|
||||
// (<0x80 fixed via @004cc148; >=0x80 SUBSYSTEM-RELATIVE eng-page
|
||||
// button via @004cc1a0; Condenser @004cc264 / power-family
|
||||
// @004cc27c specials), FindLamp @00444c80 on the renderer's lamp
|
||||
// map (+0x1c0c8), Lamp::SetAlertState(@00444e64, the FLASH
|
||||
// counter), and records the lamp on the GaugeAlarm's lampList.
|
||||
// @004cc294 the Create-side sibling.
|
||||
// This is the #47 ENG-BUTTON ATTENTION FLASH (jam / bay fire); the authored
|
||||
// data survives as the type-31 GaugeAlarmStream in BTL4.RES (exactly 1). The
|
||||
// bodies below remain best-effort scaffolding UNTIL re-transcribed from those
|
||||
// addresses -- see context/open-questions.md for the 3-piece plan. The
|
||||
// original (wrong) note is preserved for the record:
|
||||
//
|
||||
// btl4galm.obj links after btl4grnd.obj and before btl4vid.obj (BTL4.MAK
|
||||
// BTL4_OBJS order). In the shipped optimised image (BTL4OPT.EXE) there is **no
|
||||
// distinct, separately-emitted BTL4GaugeAlarmManager override body**:
|
||||
|
||||
@@ -2039,26 +2039,41 @@ void BallisticWeaponCluster::BecameActive()
|
||||
}
|
||||
|
||||
//
|
||||
// @004c9a38 -- Execute: jammed = (weaponAlarm subsys+0x364 == 5 Jammed); read the
|
||||
// ammo-bin reload state (bin+0x18c) and, while reloading, compute the elapsed
|
||||
// reload seconds; chain WeaponCluster::Execute.
|
||||
// @004c9a38 -- Execute (Gitea #47 CORRECTION, raw decomp re-read):
|
||||
//
|
||||
// this[0x3a] = (subsys+0x364 == 5) jam icon (works)
|
||||
// this[0x3b] = *(bin + 0x18C) <- cookOffArmed!
|
||||
// if armed: this[0x3d] = *Now() - *(bin + 0x190) (400 == 0x190 = cookOffTime)
|
||||
// this[0x3e] = this[0x3d] / DAT_0052140c (ticksPerSecond)
|
||||
//
|
||||
// bin+0x18C is NOT "the reload state" -- it is the BAY-FIRE cook-off flag, and
|
||||
// the numeric this[0x3e] is the countdown seconds RELATIVE TO DETONATION
|
||||
// (negative while burning, counting up to 0). The old reconstruction bridged
|
||||
// it to BTAmmoBinFeeding, so the "fire" TwoState (btefire.pcc) blinked with
|
||||
// every feed cycle and never lit on an actual bay fire -- RajelAran's
|
||||
// "it doesn't". The `firing/reloading/reloadSeconds` member names below are
|
||||
// historic (kept to avoid churning the header); semantically they are
|
||||
// bayFireArmed / bayFireArmed-latch / secondsToDetonation.
|
||||
//
|
||||
void BallisticWeaponCluster::Execute()
|
||||
{
|
||||
jammed = (*(int *)((char *)subsystem + 0x364) == 5); // BEST-EFFORT raw (weaponAlarm==Jammed)
|
||||
void *ammoBin = ResolveLink((char *)subsystem + 0x43c); // FUN_00417ab4
|
||||
extern int BTAmmoBinFeeding(void *bin);
|
||||
int reloadState = BTAmmoBinFeeding(ammoBin); // was raw bin+0x18c (garbage on our layout)
|
||||
firing = reloadState;
|
||||
if (reloadState == 0)
|
||||
extern int BTAmmoBinCookOffArmed(void *bin); // bin+0x18C (complete-type bridge)
|
||||
extern int BTAmmoBinCookOffTime(void *bin); // bin+0x190
|
||||
int bayFire = BTAmmoBinCookOffArmed(ammoBin);
|
||||
firing = bayFire; // the btefire TwoState input
|
||||
if (bayFire == 0)
|
||||
{
|
||||
reloading = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
reloading = 1;
|
||||
// (elapsed reload seconds = (now - reloadStart) / frameClock; the frame
|
||||
// clock DAT_0052140c is a runtime value -- left at the base value here.)
|
||||
reloadStartTime = ((volatile Time &)Now()).ticks // this[0x3d] = now - cookOffTime
|
||||
- BTAmmoBinCookOffTime(ammoBin);
|
||||
reloadSeconds = (Scalar)reloadStartTime // this[0x3e] = ticks -> seconds
|
||||
/ (Scalar)SystemClock::GetTicksPerSecond(); // DAT_0052140c
|
||||
}
|
||||
WeaponCluster::Execute();
|
||||
}
|
||||
|
||||
@@ -1201,6 +1201,13 @@ protected:
|
||||
// First VITAL damage zone index (0 if none) -- for concentrated fire that kills.
|
||||
// Defined in mech4.cpp (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
|
||||
int FirstVitalZone() const;
|
||||
// @0x4ac274 mech side (Gitea #46): the ammo-explosion fan-out -- split the
|
||||
// total across the zones listing the subsystem as a critical subsystem and
|
||||
// send one full TakeDamageMessage per zone (inflictingEntity = SELF,
|
||||
// inflictingSubsystemID = the bin). Defined in mechdmg.cpp; reached from
|
||||
// MechSubsystem::DistributeCriticalHit via the BTAmmoExplosionFanOut bridge.
|
||||
void AmmoExplosionFanOut(::Subsystem *inflicting_subsystem,
|
||||
int inflicting_subsystem_id, Damage &total_damage);
|
||||
int flags; // entity flags word (this+0x28 region)
|
||||
Scalar lastInflictingDamage; // task #60: killing-blow magnitude (port-only; reuses
|
||||
// the retired phantom `stance` slot -- feeds the kill score)
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
#include <messmgr.hpp> // SubsystemMessageManager (task #7 consolidated delivery)
|
||||
#include <mechweap.hpp> // MechWeapon::GetExplosionResourceID (per-round detonation)
|
||||
#include <mislanch.hpp> // MissileLauncher::ClassDerivations (splash-radius gate)
|
||||
#include <ammobin.hpp> // AmmoBin::HandleMessage (BT_BAYTEST -- slot 8 is non-virtual in the recon, see the hook comment)
|
||||
#include <matchlog.hpp> // MP match forensics (DEATH/PROJ/SPLASH/RAM event lines)
|
||||
#include "btinput.hpp" // the CONTROLS.MAP + XInput binding engine
|
||||
#include <string.h> // [aud-tail] strchr -- BT_FIRE_PULSE "on,off" parse (Gitea #5, instrumentation only)
|
||||
@@ -5553,6 +5554,42 @@ void
|
||||
gBTFlushKey = (s_ftFrame >= t0 && s_ftFrame < t0 + 60) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Gitea #46 scripted verify (BT_BAYTEST=<frame>): at the given sim frame
|
||||
// send message 1 to the FIRST AmmoBin (0xBCB) on the roster -- the
|
||||
// crit-induced "begin cook-off countdown" channel (@004bdb94). The
|
||||
// authentic 10-second fuse then runs: expect the [ammo] BAY FIRE arm
|
||||
// line, ~10s of countdown, the DETONATION line, "ammo explosion
|
||||
// damaging <zone>", and zone damage on this mech. Purge (eject-hold)
|
||||
// before it fires and the cook-off cancels instead.
|
||||
if ((Entity *)this == application->GetViewpointEntity()
|
||||
&& getenv("BT_BAYTEST"))
|
||||
{
|
||||
static int s_btFrame = 0;
|
||||
++s_btFrame;
|
||||
int t0 = atoi(getenv("BT_BAYTEST"));
|
||||
if (t0 < 1) t0 = 600;
|
||||
if (s_btFrame == t0)
|
||||
{
|
||||
for (int s = 1; s < GetSubsystemCount(); ++s)
|
||||
{
|
||||
Subsystem *sub = GetSubsystem(s);
|
||||
if (sub != 0 && (int)sub->GetClassID() == 0xBCB) // AmmoBin
|
||||
{
|
||||
DEBUG_STREAM << "[baytest] message 1 -> "
|
||||
<< sub->GetName() << "\n" << std::flush;
|
||||
// NOTE: the binary dispatches message 1 through vtable
|
||||
// slot 8; the reconstruction family declares HandleMessage
|
||||
// NON-virtual (10 classes, no live callers), so a base cast
|
||||
// would statically bind to MechSubsystem's. Call the
|
||||
// AmmoBin override directly; the family-wide virtual-slot
|
||||
// gap is logged in context/open-questions.md.
|
||||
((AmmoBin *)sub)->HandleMessage(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #7: the COOLANT FLUSH button (the coolant MFD's top-right
|
||||
// button, manual p24 -- "punch (or hold down) the coolant button to
|
||||
// flush fresh coolant through your 'Mech's loops"). Press AND
|
||||
|
||||
@@ -1160,3 +1160,105 @@ void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// Mech::AmmoExplosionFanOut -- the mech side of @0x4ac274 (Gitea #46)
|
||||
//
|
||||
// The binary iterates the plugs connected to the EXPLODING SUBSYSTEM
|
||||
// (FUN_004acfa9 walks its Node link chain), collecting every object whose
|
||||
// classID@+4 == 0x4E = DamageZoneClassID -- i.e. the mech damage zones whose
|
||||
// critical-subsystem entries plug this subsystem -- then splits the total
|
||||
// damage evenly and sends the OWNER one full Entity::TakeDamageMessage
|
||||
// (id 0x12, 100 bytes) per zone:
|
||||
//
|
||||
// +0x1C inflictingEntity = owner+0x184 (the mech ITSELF)
|
||||
// +0x24 damageZone = zone+0x13C (damageZoneIndex)
|
||||
// +0x2C damageData = the split Damage record
|
||||
// +0x5C inflictingSubsystemID = subsystem+0xD8 (so the message manager
|
||||
// can bundle the bin's explosion resource for the view --
|
||||
// ENTITY3.h's own NOTE)
|
||||
//
|
||||
// printing "ammo explosion damaging <zoneName>" (@0050df61, name from
|
||||
// zone+0x15C = DamageZone::damageZoneName) per zone. [T1 raw decomp]
|
||||
//
|
||||
// The port walks its own zone table with the SAME membership test (the
|
||||
// criticalSubsystems[] plug back-reference IS the plug link the binary
|
||||
// iterated from the subsystem's side). ONE guarded deviation: the binary
|
||||
// divides by the collected count UNGUARDED (every authored bin lives in at
|
||||
// least one zone); the port warns-and-returns on zero so a malformed model
|
||||
// cannot div-by-zero.
|
||||
//
|
||||
void
|
||||
Mech::AmmoExplosionFanOut(
|
||||
::Subsystem *inflicting_subsystem,
|
||||
int inflicting_subsystem_id,
|
||||
Damage &total_damage
|
||||
)
|
||||
{
|
||||
enum { kMaxZones = 32 };
|
||||
int zones[kMaxZones];
|
||||
int zoneCount = 0;
|
||||
|
||||
for (int z = 0; z < damageZoneCount && zoneCount < kMaxZones; ++z)
|
||||
{
|
||||
Mech__DamageZone *dz = Zone(z);
|
||||
if (dz == 0)
|
||||
continue;
|
||||
for (int c = 0; c < dz->criticalSubsystemCount; ++c)
|
||||
{
|
||||
MechCriticalSubsystem *cs = dz->criticalSubsystems[c];
|
||||
if (cs != 0 && (::Subsystem *)cs->subsystemPlug.GetCurrent() == inflicting_subsystem)
|
||||
{
|
||||
zones[zoneCount++] = z;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (zoneCount < 1)
|
||||
{
|
||||
// Guarded deviation (see the header comment).
|
||||
DEBUG_STREAM << "[ammo] explosion fan-out: subsystem "
|
||||
<< inflicting_subsystem_id << " is in NO damage zone -- dropped\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
total_damage.damageAmount /= (Scalar)zoneCount; // even split (@004ac2xx)
|
||||
|
||||
for (int i = 0; i < zoneCount; ++i)
|
||||
{
|
||||
Mech__DamageZone *dz = Zone(zones[i]);
|
||||
|
||||
// The binary's exact print (@0050df61) -- warning channel.
|
||||
DEBUG_STREAM << "ammo explosion damaging "
|
||||
<< (const char *)dz->damageZoneName << "\n" << std::flush;
|
||||
|
||||
Entity::TakeDamageMessage td(
|
||||
Entity::TakeDamageMessageID, // 0x12
|
||||
sizeof(Entity::TakeDamageMessage),
|
||||
GetEntityID(), // inflictingEntity = SELF (owner+0x184)
|
||||
dz->damageZoneIndex, // zone+0x13C
|
||||
total_damage,
|
||||
inflicting_subsystem_id); // the bin (explosion bundling)
|
||||
Dispatch(&td); // (**owner_vtbl)(owner,&msg) -- the
|
||||
// proven master-side damage path
|
||||
}
|
||||
|
||||
// MP MATCH FORENSICS: the bay-fire detonation landed (field verify for #46).
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("BAYBOOM", "mech=%d:%d sub=%d zones=%d dmg=%.3f",
|
||||
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
|
||||
inflicting_subsystem_id, zoneCount, total_damage.damageAmount);
|
||||
}
|
||||
|
||||
//
|
||||
// The cross-family bridge (called from MechSubsystem::DistributeCriticalHit in
|
||||
// mechsub.cpp, where Mech is incomplete -- the databinding rule).
|
||||
//
|
||||
void BTAmmoExplosionFanOut(
|
||||
void *owner_mech, void *inflicting_subsystem,
|
||||
int inflicting_subsystem_id, Damage &total_damage)
|
||||
{
|
||||
((Mech *)owner_mech)->AmmoExplosionFanOut(
|
||||
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
|
||||
}
|
||||
|
||||
@@ -333,28 +333,11 @@ struct ReconDamageZone
|
||||
ReconAlarm alarm;
|
||||
};
|
||||
|
||||
//===========================================================================//
|
||||
// Subsystem proxy + critical-subsystem chain (DistributeCriticalHit support).
|
||||
//===========================================================================//
|
||||
struct ReconSub
|
||||
{
|
||||
template<class...A> Recon GetName(A&&...) { return Recon(); }
|
||||
template<class...A> void TakeDamage(A&&...) {}
|
||||
};
|
||||
|
||||
struct CriticalEntry
|
||||
{
|
||||
ReconSub *sub;
|
||||
ReconSub *Subsystem() { return sub; }
|
||||
};
|
||||
|
||||
struct CriticalChain
|
||||
{
|
||||
template<class A> CriticalChain(A) {}
|
||||
int CountOfType(int) { return 1; }
|
||||
CriticalEntry *First() { return 0; }
|
||||
CriticalEntry *Next() { return 0; }
|
||||
};
|
||||
// (Gitea #46: the ReconSub/CriticalEntry/CriticalChain stand-ins that lived
|
||||
// here are DELETED. They existed only for DistributeCriticalHit, whose real
|
||||
// @004ac274 body is the per-DamageZone TakeDamage fan-out -- see mechsub.cpp
|
||||
// + Mech::AmmoExplosionFanOut in mechdmg.cpp. The stubs iterated nothing and
|
||||
// returned a fabricated count.)
|
||||
|
||||
//===========================================================================//
|
||||
// Callable returned by the "base vtable slot 0x18" thunk: (*BaseSlot0x18())(...)
|
||||
|
||||
@@ -288,7 +288,7 @@ void MechSubsystem::SetSubsystemDamageLevel(Scalar level)
|
||||
LWord
|
||||
MechSubsystem::GetStatusFlags()
|
||||
{
|
||||
Scalar structure = damageZone->structureLevel; // *(this[0x38]+0x158)
|
||||
Scalar structure = ((DamageZone *)damageZone)->damageLevel; // *(this[0x38]+0x158) (engine view; the recon proxy's structureLevel@0 ALIASES THE VPTR)
|
||||
if (StatusThreshold <= structure) return 1; // _DAT_004ac18c
|
||||
if (StatusFloor < structure) return 2; // _DAT_004ac190
|
||||
return 0;
|
||||
@@ -305,14 +305,14 @@ Logical
|
||||
MechSubsystem::HandleMessage(int message)
|
||||
{
|
||||
(*BaseSlot0x18())(this, message); // *(this[0x38].vtable+0x18)
|
||||
if (StatusThreshold <= damageZone->structureLevel) // _DAT_004ac140
|
||||
if (StatusThreshold <= ((DamageZone *)damageZone)->damageLevel) // _DAT_004ac140 (engine view)
|
||||
{
|
||||
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1)
|
||||
if (printSimulationState != 0) // this[0x41]
|
||||
{
|
||||
OnAlarmChanged(); // (*this.vtable+0x34)(this)
|
||||
}
|
||||
damageZone->structureLevel = 1.0f; // dz+0x158 = 1.0
|
||||
((DamageZone *)damageZone)->damageLevel = 1.0f; // dz+0x158 = 1.0 (engine view)
|
||||
if (vitalSubsystem != 0) // this[0x39]
|
||||
{
|
||||
((Mech *)owner)->RaiseStatusAlarm(9); // FUN_0041bbd8(owner+0x2c, 9)
|
||||
@@ -333,7 +333,7 @@ void
|
||||
{
|
||||
if (damageZone != 0)
|
||||
{
|
||||
damageZone->structureLevel = 1.0f; // dz+0x158 = 1.0
|
||||
((DamageZone *)damageZone)->damageLevel = 1.0f; // dz+0x158 = 1.0 (engine view)
|
||||
}
|
||||
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1)
|
||||
if (printSimulationState != 0)
|
||||
@@ -354,7 +354,7 @@ void
|
||||
void
|
||||
MechSubsystem::ClearStatus()
|
||||
{
|
||||
damageZone->structureLevel = 0.0f; // dz+0x158 = 0
|
||||
((DamageZone *)damageZone)->damageLevel = 0.0f; // dz+0x158 = 0 (engine view)
|
||||
damageZone->alarm.SetLevel(0); // FUN_0041bbd8(dz+0x10, 0)
|
||||
statusAlarm.SetLevel(0); // FUN_0041bbd8(this+0xb, 0)
|
||||
if (printSimulationState != 0)
|
||||
@@ -409,34 +409,57 @@ Logical
|
||||
Scalar
|
||||
MechSubsystem::ApplyDamageAndMeasure(Damage &damage)
|
||||
{
|
||||
Scalar before = damageZone->structureLevel; // dz+0x158
|
||||
Scalar before = ((DamageZone *)damageZone)->damageLevel; // dz+0x158 (engine view)
|
||||
TakeDamage(damage); // (*this.vtable+0x24)(this, &damage)
|
||||
return damageZone->structureLevel - before;
|
||||
return ((DamageZone *)damageZone)->damageLevel - before;
|
||||
}
|
||||
|
||||
//
|
||||
// @0x4ac274 -- distribute a critical hit across the contained critical
|
||||
// subsystems. Drives the status alarm through level 2 then 1, pins structure
|
||||
// to 1.0, then iterates the critical-subsystem chain (ClassID 0x4E entries),
|
||||
// divides the incoming damage amount by the critical count, and re-applies the
|
||||
// scaled damage to each, logging "ammo explosion damaging " per hit.
|
||||
// @0x4ac274 -- the AMMO-EXPLOSION fan-out (Gitea #46, re-read from the raw
|
||||
// decomp). The old reconstruction here was wrong in kind: it "re-applied the
|
||||
// scaled damage to critical subsystems" through the CriticalChain STAND-IN in
|
||||
// mechrecon.hpp, whose CountOfType/First/Next were stubs -- it iterated
|
||||
// nothing, divided by a fabricated count, and had no caller anyway (AmmoBin's
|
||||
// CookOff went to a local no-op). The real body [T1, part_012.c @004ac274]:
|
||||
//
|
||||
// 1. statusAlarm pulse 2 (Exploding) -> 1 (Destroyed), each followed by the
|
||||
// slot-13 state PRINT when printSimulationState is set (this+0x104 gates
|
||||
// `(*vtbl+0x34)(this)` = @004ac8c0, the "<name> = __Exploding/__Destroyed"
|
||||
// debug print -- NOT an "explosion notify").
|
||||
// 2. *(this[0x38] + 0x158) = 1.0f -- the subsystem's OWN DamageZone structure
|
||||
// pinned to destroyed.
|
||||
// 3. Iterate the plugs connected to THIS SUBSYSTEM (FUN_004acfa9 walks the
|
||||
// Node link chain at this+8), collecting every object whose classID@+4 ==
|
||||
// 0x4E = **DamageZoneClassID** (VDATA.h enum -- index 78; cross-checked:
|
||||
// index 28 = AudioStateTrigger matches the audio-trace finding) -- i.e.
|
||||
// the MECH damage zones that list this subsystem as a critical subsystem.
|
||||
// 4. damage.damageAmount /= zoneCount (evenly split; the binary divides
|
||||
// UNGUARDED -- every authored bin is in at least one zone).
|
||||
// 5. Per zone: print "ammo explosion damaging <zoneName>" (@0050df61, name
|
||||
// from zone+0x15C = damageZoneName) and send the OWNER a full 100-byte
|
||||
// Entity::TakeDamageMessage (id 0x12) with
|
||||
// inflictingEntity = the owner mech itself (owner+0x184)
|
||||
// damageZone = the zone's index (zone+0x13C)
|
||||
// damageData = the split Damage record
|
||||
// inflictingSubsystemID = this subsystem's ID (this+0xD8)
|
||||
// -- the message-manager uses inflictingSubsystemID to bundle the
|
||||
// explosion resource for the view (ENTITY3.h's own comment).
|
||||
//
|
||||
// Steps 3-5 need the Mech/zone complete types, so they live in
|
||||
// Mech::AmmoExplosionFanOut (mechdmg.cpp) behind the BTAmmoExplosionFanOut
|
||||
// bridge (databinding rule).
|
||||
//
|
||||
void
|
||||
MechSubsystem::DistributeCriticalHit(Damage &damage)
|
||||
{
|
||||
statusAlarm.SetLevel(2); if (printSimulationState) OnAlarmChanged();
|
||||
statusAlarm.SetLevel(1); if (printSimulationState) OnAlarmChanged();
|
||||
damageZone->structureLevel = 1.0f;
|
||||
statusAlarm.SetLevel(2); if (printSimulationState) OnAlarmChanged(); // Exploding
|
||||
statusAlarm.SetLevel(1); if (printSimulationState) OnAlarmChanged(); // Destroyed
|
||||
SetSubsystemDamageLevel(1.0f); // own private zone gutted (dz+0x158, ENGINE view)
|
||||
|
||||
CriticalChain criticals(this); // FUN_004acfa9
|
||||
int count = criticals.CountOfType(0x4E); // entries whose +4 == 0x4E
|
||||
damage.damageAmount /= (Scalar)count; // per-critical share
|
||||
|
||||
for (CriticalEntry *c = criticals.First(); c != 0; c = criticals.Next())
|
||||
{
|
||||
DebugStream << "ammo explosion damaging " << c->Subsystem()->GetName(); // 0050df61
|
||||
c->Subsystem()->TakeDamage(damage); // (*owner.vtable+0xc)(...)
|
||||
}
|
||||
extern void BTAmmoExplosionFanOut( // mechdmg.cpp (Mech-complete TU)
|
||||
void *owner_mech, void *inflicting_subsystem,
|
||||
int inflicting_subsystem_id, Damage &total_damage);
|
||||
BTAmmoExplosionFanOut(owner, this, subsystemID, damage);
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -334,9 +334,31 @@ ProjectileWeapon::ProjectileWeapon(
|
||||
{
|
||||
Subsystem *roster_bin = OwnerSubsystem(owner, subsystem_resource->ammoBinIndex); // +0x128 table
|
||||
ammoBinLink.Connect(roster_bin); // (**bin.vtbl[1])(...)
|
||||
// CROSS-FAMILY (mech/ammobin): the shipped ctor also primed the bin's HUD
|
||||
// display block (iVar1+0x1f0/0x1f4/0x1f8/0x210) from this weapon; wired in
|
||||
// the AmmoBin family.
|
||||
|
||||
// @004bc3fc (Gitea #46): stamp the bin's COOK-OFF DAMAGE RECORD from this
|
||||
// weapon's own Damage -- bin+0x1F0..0x21C <- weapon->damageData @0x3A8:
|
||||
// *(bin+0x1f0) = this[0xea] damageType *(bin+0x1f4) = this[0xeb] damageAmount
|
||||
// *(bin+0x1f8..) = force *(bin+0x204..) = normal *(bin+0x210..) = impact
|
||||
// *(bin+0x21c) = this[0xf5] burstCount
|
||||
// The old comment here called this "the bin's HUD display block ... wired
|
||||
// in the AmmoBin family" -- BOTH halves were wrong: it is the Damage record
|
||||
// the bay-fire detonation multiplies by ammoCount (CookOff @004bd300), and
|
||||
// it had never been wired anywhere, so the record stayed all-zero and even
|
||||
// a detonating cook-off would have dealt 0 damage of type 0 (which the
|
||||
// mech TakeDamage handler drops). NOTE the sequencing is authentic and
|
||||
// load-bearing: this runs INSIDE the ProjectileWeapon ctor, BEFORE
|
||||
// MissileLauncher's chained ctor divides its own damageData by
|
||||
// missileCount -- so a missile bin holds the per-SALVO amount. The
|
||||
// binary writes the roster slot UNGUARDED (no AmmoBin type check).
|
||||
if (getenv("BT_AMMO_LOG"))
|
||||
DEBUG_STREAM << "[ammo] stamp " << GetName() << " -> roster["
|
||||
<< subsystem_resource->ammoBinIndex << "]="
|
||||
<< (roster_bin ? roster_bin->GetName() : "NULL")
|
||||
<< " classID=0x" << std::hex
|
||||
<< (roster_bin ? (int)roster_bin->GetClassID() : 0) << std::dec
|
||||
<< " dmg={t=" << (int)damageData.damageType
|
||||
<< " a=" << damageData.damageAmount << "}\n" << std::flush;
|
||||
((AmmoBin *)roster_bin)->StampCookOffDamage(damageData);
|
||||
}
|
||||
|
||||
AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Gitea #46 -- verify the ammo bay fire detonates and applies real damage.
|
||||
|
||||
Before this fix, three stacked defects made an armed bay fire permanently inert:
|
||||
the clock stub (`0 < 0` never fired), the InjectHeat no-op (nothing applied),
|
||||
and the never-stamped Damage record (0 damage of type 0 even if it had).
|
||||
|
||||
Rig: BT_BAYTEST=<frame> sends message 1 (the crit-induced "begin cook-off
|
||||
countdown", @004bdb94) to the first AmmoBin. The authentic fuse is a FIXED
|
||||
10.0 seconds (raw disasm @004bd450: 10.0 x ticksPerSecond + 0.5, __ftol --
|
||||
the old "RandomDelay" was a Ghidra artifact).
|
||||
|
||||
PASS =
|
||||
[ammo] ... BAY FIRE (message): cook-off armed, N rounds, detonation in 10s
|
||||
...~10 wall seconds later...
|
||||
[ammo] ... BAY FIRE DETONATION: N rounds x D = total (type T)
|
||||
ammo explosion damaging <zoneName> (the binary's exact @0050df61 print)
|
||||
...and the mech takes real zone damage (BT_MP_NET handler line / DAMAGE state).
|
||||
|
||||
Kills only the PID it spawns.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = r"C:\git\bt411"
|
||||
LOG = os.path.join(REPO, "scratchpad_baypurge.log")
|
||||
if os.path.exists(LOG):
|
||||
os.remove(LOG)
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"BT_START_INSIDE": "1",
|
||||
"BT_DEV_GAUGES": "1",
|
||||
"BT_BAYTEST": "400",
|
||||
"BT_EJECTTEST": "hold", # dump the bay ~13s in -- inside the 10s fuse window if arming is late,
|
||||
# after it if early; PASS here = EXTINGUISHED, no detonation # arm at sim frame 400
|
||||
"BT_AMMO_LOG": "1",
|
||||
"BT_MP_NET": "1", # [mp-hdlr] TakeDamageHandler lines (zone + amount)
|
||||
"BT_DEATH_LOG": "1", # crit cascade / zone destruction
|
||||
"BT_LOG": LOG,
|
||||
})
|
||||
proc = subprocess.Popen(
|
||||
[os.path.join(REPO, "build", "Release", "btl4.exe"), "-egg", "LAST.EGG"],
|
||||
cwd=os.path.join(REPO, "content"), env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print("pid", proc.pid)
|
||||
|
||||
try:
|
||||
deadline = time.time() + 240
|
||||
armed = False
|
||||
while time.time() < deadline:
|
||||
if os.path.exists(LOG):
|
||||
t = open(LOG, errors="replace").read()
|
||||
if "BAY FIRE (message)" in t:
|
||||
armed = True
|
||||
break
|
||||
time.sleep(2)
|
||||
if not armed:
|
||||
print("FAIL: never armed")
|
||||
# wait through the 10s fuse + margin for the fan-out and damage
|
||||
time.sleep(25)
|
||||
finally:
|
||||
proc.terminate()
|
||||
time.sleep(1)
|
||||
|
||||
t = open(LOG, errors="replace").read() if os.path.exists(LOG) else ""
|
||||
arm = re.findall(r"^\[ammo\].*BAY FIRE.*armed.*$", t, re.M)
|
||||
boom = re.findall(r"^\[ammo\].*DETONATION.*$", t, re.M)
|
||||
zones = re.findall(r"^ammo explosion damaging.*$", t, re.M)
|
||||
hdlr = re.findall(r"^\[mp-hdlr\] TakeDamageHandler.*$", t, re.M)
|
||||
dfx = re.findall(r"^\[deathfx\].*$", t, re.M)
|
||||
ext = re.findall(r"^\[ammo\].*EXTINGUISHED.*$", t, re.M)
|
||||
|
||||
print("\n=============== RESULT ===============")
|
||||
print("armed:", len(arm))
|
||||
for l in arm[:2]:
|
||||
print(" ", l[:120])
|
||||
print("\ndetonation:", len(boom))
|
||||
for l in boom[:2]:
|
||||
print(" ", l[:130])
|
||||
print("\n'ammo explosion damaging' zone prints:", len(zones))
|
||||
for l in zones[:6]:
|
||||
print(" ", l[:100])
|
||||
print("\nTakeDamage handler runs:", len(hdlr))
|
||||
for l in hdlr[:4]:
|
||||
print(" ", l[:150])
|
||||
print("\n[deathfx] zone/crit lines:", len(dfx))
|
||||
for l in dfx[:8]:
|
||||
print(" ", l[:120])
|
||||
if ext:
|
||||
print("\nextinguished (unexpected in this rig):", ext[:2])
|
||||
|
||||
ok = arm and ext and not boom
|
||||
print("\nVERDICT:", "PASS -- bay fire armed, PURGE extinguished it, no detonation"
|
||||
if ok else "FAIL -- see which stage is missing above")
|
||||
sys.exit(0 if ok else 2)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Gitea #46 -- verify the ammo bay fire detonates and applies real damage.
|
||||
|
||||
Before this fix, three stacked defects made an armed bay fire permanently inert:
|
||||
the clock stub (`0 < 0` never fired), the InjectHeat no-op (nothing applied),
|
||||
and the never-stamped Damage record (0 damage of type 0 even if it had).
|
||||
|
||||
Rig: BT_BAYTEST=<frame> sends message 1 (the crit-induced "begin cook-off
|
||||
countdown", @004bdb94) to the first AmmoBin. The authentic fuse is a FIXED
|
||||
10.0 seconds (raw disasm @004bd450: 10.0 x ticksPerSecond + 0.5, __ftol --
|
||||
the old "RandomDelay" was a Ghidra artifact).
|
||||
|
||||
PASS =
|
||||
[ammo] ... BAY FIRE (message): cook-off armed, N rounds, detonation in 10s
|
||||
...~10 wall seconds later...
|
||||
[ammo] ... BAY FIRE DETONATION: N rounds x D = total (type T)
|
||||
ammo explosion damaging <zoneName> (the binary's exact @0050df61 print)
|
||||
...and the mech takes real zone damage (BT_MP_NET handler line / DAMAGE state).
|
||||
|
||||
Kills only the PID it spawns.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = r"C:\git\bt411"
|
||||
LOG = os.path.join(REPO, "scratchpad_baytest.log")
|
||||
if os.path.exists(LOG):
|
||||
os.remove(LOG)
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"BT_START_INSIDE": "1",
|
||||
"BT_DEV_GAUGES": "1",
|
||||
"BT_BAYTEST": "400", # arm at sim frame 400
|
||||
"BT_AMMO_LOG": "1",
|
||||
"BT_MP_NET": "1", # [mp-hdlr] TakeDamageHandler lines (zone + amount)
|
||||
"BT_DEATH_LOG": "1", # crit cascade / zone destruction
|
||||
"BT_LOG": LOG,
|
||||
})
|
||||
proc = subprocess.Popen(
|
||||
[os.path.join(REPO, "build", "Release", "btl4.exe"), "-egg", "LAST.EGG"],
|
||||
cwd=os.path.join(REPO, "content"), env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print("pid", proc.pid)
|
||||
|
||||
try:
|
||||
deadline = time.time() + 240
|
||||
armed = False
|
||||
while time.time() < deadline:
|
||||
if os.path.exists(LOG):
|
||||
t = open(LOG, errors="replace").read()
|
||||
if "BAY FIRE (message)" in t:
|
||||
armed = True
|
||||
break
|
||||
time.sleep(2)
|
||||
if not armed:
|
||||
print("FAIL: never armed")
|
||||
# wait through the 10s fuse + margin for the fan-out and damage
|
||||
time.sleep(25)
|
||||
finally:
|
||||
proc.terminate()
|
||||
time.sleep(1)
|
||||
|
||||
t = open(LOG, errors="replace").read() if os.path.exists(LOG) else ""
|
||||
arm = re.findall(r"^\[ammo\].*BAY FIRE.*armed.*$", t, re.M)
|
||||
boom = re.findall(r"^\[ammo\].*DETONATION.*$", t, re.M)
|
||||
zones = re.findall(r"^ammo explosion damaging.*$", t, re.M)
|
||||
hdlr = re.findall(r"^\[mp-hdlr\] TakeDamageHandler.*$", t, re.M)
|
||||
dfx = re.findall(r"^\[deathfx\].*$", t, re.M)
|
||||
ext = re.findall(r"^\[ammo\].*EXTINGUISHED.*$", t, re.M)
|
||||
|
||||
print("\n=============== RESULT ===============")
|
||||
print("armed:", len(arm))
|
||||
for l in arm[:2]:
|
||||
print(" ", l[:120])
|
||||
print("\ndetonation:", len(boom))
|
||||
for l in boom[:2]:
|
||||
print(" ", l[:130])
|
||||
print("\n'ammo explosion damaging' zone prints:", len(zones))
|
||||
for l in zones[:6]:
|
||||
print(" ", l[:100])
|
||||
print("\nTakeDamage handler runs:", len(hdlr))
|
||||
for l in hdlr[:4]:
|
||||
print(" ", l[:150])
|
||||
print("\n[deathfx] zone/crit lines:", len(dfx))
|
||||
for l in dfx[:8]:
|
||||
print(" ", l[:120])
|
||||
if ext:
|
||||
print("\nextinguished (unexpected in this rig):", ext[:2])
|
||||
|
||||
ok = arm and boom and zones and hdlr
|
||||
print("\nVERDICT:", "PASS -- bay fire armed, detonated on the 10s fuse, and applied real zone damage"
|
||||
if ok else "FAIL -- see which stage is missing above")
|
||||
sys.exit(0 if ok else 2)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Raw-disassemble the AmmoBin cook-off cluster from BTL4OPT.EXE (#46).
|
||||
|
||||
Ghidra's export dropped the float math around the FUN_004dcd94 (__ftol) calls --
|
||||
the decomp shows `iVar2 = FUN_004dcd94();` with the random-delay expression
|
||||
missing entirely. Recover:
|
||||
@004bd394 AmmoBinSimulation (the arming expression: delay = f(?) )
|
||||
@004bd5c4 AmmoBin ctor (who writes the Damage descriptor @+0x1F0/+0x1F4)
|
||||
@004bdb94 HandleMessage(1) (same delay expression, crit-induced fire)
|
||||
"""
|
||||
import struct
|
||||
from capstone import *
|
||||
|
||||
PATH = r"C:\git\bt411\content\BTL4OPT.EXE"
|
||||
data = open(PATH, "rb").read()
|
||||
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
coff = e_lfanew + 4
|
||||
num_sec = struct.unpack_from("<H", data, coff + 2)[0]
|
||||
opt_size = struct.unpack_from("<H", data, coff + 16)[0]
|
||||
opt = coff + 20
|
||||
image_base = struct.unpack_from("<I", data, opt + 28)[0]
|
||||
sec_tbl = opt + opt_size
|
||||
secs = []
|
||||
for i in range(num_sec):
|
||||
off = sec_tbl + i * 40
|
||||
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
|
||||
secs.append((va, vsize, rawptr, rawsize))
|
||||
|
||||
|
||||
def va_to_off(va):
|
||||
rva = va - image_base
|
||||
for sva, vsize, rawptr, rawsize in secs:
|
||||
if sva <= rva < sva + max(vsize, rawsize):
|
||||
return rawptr + (rva - sva)
|
||||
return None
|
||||
|
||||
|
||||
def dump_dword(va):
|
||||
off = va_to_off(va)
|
||||
return struct.unpack_from("<I", data, off)[0] if off is not None else None
|
||||
|
||||
|
||||
def dump_float(va):
|
||||
off = va_to_off(va)
|
||||
return struct.unpack_from("<f", data, off)[0] if off is not None else None
|
||||
|
||||
|
||||
md = Cs(CS_ARCH_X86, CS_MODE_32)
|
||||
|
||||
for name, va, length in (
|
||||
("AmmoBinSimulation @004bd394", 0x004bd394, 0x160),
|
||||
("AmmoBin ctor @004bd5c4", 0x004bd5c4, 0xec),
|
||||
("HandleMessage @004bdb94", 0x004bdb94, 0x70),
|
||||
):
|
||||
off = va_to_off(va)
|
||||
print("=" * 78)
|
||||
print(name)
|
||||
print("=" * 78)
|
||||
for ins in md.disasm(data[off:off + length], va):
|
||||
print(" %08x %-24s %s %s" % (ins.address, ins.bytes.hex(), ins.mnemonic, ins.op_str))
|
||||
print()
|
||||
|
||||
# constants likely referenced by the delay math
|
||||
for cva in (0x004bd4e8, 0x004bd4ec, 0x004bd4f0, 0x004bdb74, 0x004bdb78, 0x004bdb7c,
|
||||
0x004e0f74, 0x004e0f78, 0x004e0f7c, 0x004e0f80, 0x004e0f84, 0x004e0f88):
|
||||
print("DATA %08x: dword=%08x float=%r" % (cva, dump_dword(cva) or 0, dump_float(cva)))
|
||||
@@ -63,6 +63,7 @@ for i in (1, 2, 3):
|
||||
"BT_RELAY": "127.0.0.1:%d" % RELAY_PORT,
|
||||
"BT_SELF": "10.99.0.%d:1502" % i,
|
||||
"BT_START_INSIDE": "1",
|
||||
"BT_AMMO_LOG": "1",
|
||||
"BT_GOTO": "enemy", # close on the nearest enemy
|
||||
"BT_AUTOFIRE": "1", # hold the trigger
|
||||
"BT_MATCHLOG": "1",
|
||||
|
||||
Reference in New Issue
Block a user