The mech-vs-mech gather (Mover::GetCurrentCollisions) tests
mover->collisionVolume DIRECTLY -- it never consults collisionVolumeCount --
so the previous inert fix left the stale box blocking at the wreck spot.
The count-zero now serves only to stop MoveCollisionVolume from re-placing
the box, and the box itself is parked 100km underground where it intersects
nothing. Verified: the autodrive harness, previously pinned at ~7m from the
enemy, drives straight through the spot after burial (341,155 -> 992,-971).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "white lines toward the feet" report: the LAUNCHES are authentic (the
mech's real MissileLauncher/ProjectileWeapon roster subsystems firing on
their own recharge cadence -- that's the every-Nth-shot rhythm), but the
visual was the bring-up 3-segment beam tracer, and the aim point was the
victim's ORIGIN (ground level between the feet).
- Trail: per-frame puffs of the AUTHENTIC dsrm effect (psfx 0, "the lrm
smoke trail") along the flight path, frame-oriented so the smoke streams
behind the round (its .PFX velocities are +Z = backward); the round itself
is a short hot streak. New BTPfxTrailPuff spawns slot particles directly
at a moving point (no emitter instance).
- Aim: the resolved target point lifts to torso height (the beams already
converged there) -- impacts now cyl-resolve to body zones instead of
diving at the feet/legs.
Follow-up noted: the round MESHES (BULLET/LRMS.BGF ship) for a true
model-per-projectile look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers "it's still there but invisible": not normal -- the real game removes
the dead entity (death row). Full entity teardown needs the mech render tree
unhooked from the renderer first (the remaining P5 follow-through), so until
then the burial transition makes the wreck behaviorally gone:
- collisionVolumeCount = 0 (the collision gather skips volume-less movers;
MoveCollisionVolume early-outs) -> no phantom blocking
- the player's target lock drops -> beams stop converging on / hitting the
empty spot, no phantom impact smoke
Verified: zero [damage] hits after burial; INERT fires one-shot at
"wreck buried".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers "does the wreck fade away?": YES, by sinking. FUN_00456410 (the 1996
sink renderable) computes offsetY = rate * t^2; the hulk's authored rate is
-0.025 -> the ~7-unit hulk is fully underground ~17s after the kill. The
script also pairs the standing hulk with the LDBR strewn-debris field (12x13u
flat scatter), parented together and sinking together.
Also verified from the mesh data: BLHDBR (1537 verts -- more than the intact
torso) IS the authored Blackhawk wreck: the classic standing-leg-in-rubble
sculpt. The "just a leg standing there" report is the authentic art.
(THRDBR -- the mesh the 1996 script hardcoded -- parses to ZERO vertices;
more evidence the hardcode was an unfinished dev shortcut.)
Implementation: SwapToWreck adds the ldbr piece; TickWreck applies the
quadratic sink per frame (driven from the dead mech's UpdateDeathState),
hides both pieces at burial and reports it so the wreck-smoke re-arm stops
with the wreck. DPLStaticChildRenderable::SetOffsetTranslation added
(Execute re-reads OrientationMatrix per frame -- same in-place idiom as
SetDrawObj).
Lifecycle verified live: kill -> 'blhdbr.bgf' + ldbr debris -> smoke re-arm
@10s -> wreck buried @~17s -> smoke stops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user was right: a dead mech turns into a pile of wreckage. The authentic
1995 chain, recovered end-to-end:
kill -> the victim's per-mech DEATH ModelList ('blhdead'/'lokdead'/'owndead'/
'thrdead', .RES ids 22-25) -> its authored effects: 104 (the WRECK script) +
1007 (dnboom big explosion) + 1001 (ddthsmk rubble smoke plume) + a damage-
smoke burst (3/4/5/15).
Effect 104 = ExplosionScripts case 4 (part_008.c:2663, LIVE in the 1996 binary;
the "disabled" warning is case 6): loads the destroyed hulk + flamesml/flamebig
flame meshes with sweep flicker. Every mech ships its hulk (BLHDBR/MADDBR/
LOKDBR/... + GENDBR generic); the 1996 script hardcoded thrdbr.bgf (dev
shortcut) -- we use the victim's own.
Reconstructed as BTL4VideoRenderer::SwapToWreck: hide every segment mesh, hang
"<prefix>dbr.bgf" on the tree root (pending-swap if death precedes tree build);
routed from the engine's ExplosionClassID dispatch (effect 104 ->
BTSwapMechToWreck(explosion->GetEntityHit())). The kill now fires the
authentic 'blhdead' resource (manual 7+1 pfx calls removed -- the list carries
1007/1001 itself). Verified live:
[death] firing authentic death list 'blhdead' id=22
** effect_number = 104
[BTrender] wreck swap: victim -> 'blhdbr.bgf'
Follow-ups noted: mesh flames + hulk settle (cosmetic), DeathSplash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-burst emitter batching was wrong: in EVERY shipped .PFX,
maxIssue/rate == release_period (DAFC 25/100~=0.2, DNBOOM 35/150~=0.2, DDAM2
35/16~=2, DDTHSMK 30/3=10) -- release_period is the EMISSION WINDOW and rate
is particles/second emitted continuously across it. The death smoke plume is
authored as a 3/sec trickle for 10 seconds (+6s particle life), not one
same-frame puff. Emitters now integrate rate*dt until maximum_issue.
Wreck smoke (port addition, [T3]): a destroyed mech re-arms the death/rubble
smoke plume (psfx 1, DDTHSMK) every 10s -- its own authored window -- so the
dead hulk visibly keeps smoking instead of standing there looking alive (the
freeze-death is invisible on an already-stationary target). New member
Mech::wreckSmokeTimer; UpdateDeathState takes dt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task #32 (death collapse animation) resolved by decomp evidence -- BT 4.11 has
NO collapse animation; the movementMode 5-8 fall latch (clips 0x1c-0x1f) is an
engine-lineage vestige:
(1) the clip-table loader FUN_004a80d4 fills slots 0x00-0x1b + 0x20 (bmp
knockdown) and returns -- slots 0x1c-0x1f are never written;
(2) mech+0x63c..0x648 appear in NO exported function;
(3) no fall clip exists in the shipped 27-clip set;
(4) firing the latch would bind resource id 0 -- a StaticAudioStream -- as
keyframes.
Authentic death modes are the FREEZE modes (IsDestroyed == mode 2||9), so
UpdateDeathState now settles straight to 9 (was a [T3] mode-5 guess that would
trip the garbage latch in the binary). The death READ = freeze + dnboom +
ddthsmk smoke plume + destroyed skins + shutdown; the wreck stands.
Also: damage-band effects orient toward the ATTACKER (impact frame) via
lastInflictingID -- which was declared but never written (recon gap); now
maintained by Mech::TakeDamageMessageHandler, unblocking the DamageZone LOD
same-attacker redirect too.
KB corrected + proofs recorded (combat-damage "Death SEQUENCE", open-questions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 'can't kill the enemy / no visible damage' cluster, root-caused and fixed
faithfully:
- STEP-6 unaimed path was INERT: the cylinder table was 'cached' at Wword(0x111)
-- the recon ABSORBER bank (stores nothing, reads 0) -- so every unaimed hit
silently no-op'd. Promoted to the named member Mech::damageLookupTable
(binary this[0x111], was mislabeled ammoExpended). New gotcha class recorded
(reconstruction-gotchas §2) + sweep; 2 dead multiplayer branches logged.
- Fire path migrated off the stale vital-zone aim onto the completed STEP-6
unaimed dispatch (zone=-1 + beam entry point -> cylinder resolves the
exterior zone). No more invisible 1-shot kills; death via the authentic
cascade (~14 center-mass hits). Wreck stays TARGETED on kill (beams stop on
it); scoring latches off.
- SendSubsystemDamage AV fixed: unbound critical-subsystem plug guard (43
unbound plugs/mech logged as an open question -- the binding itself is a gap).
- RemakeEntity (render damage swap): the 1996 render state machine's missing
Remake state, reconstructed as an in-place SetDrawObj mesh swap keyed by each
segment's damage-zone graphic state (tree dtor doesn't cascade -> never
rebuild). Destroyed arms/guns visibly wreck (the only variants the RES
registers).
- BT .PFX particle layer (L4VIDEO.cpp): the 1995 explosion/damage effect layer,
unported since 2007 (DPLIndependantEffect/ReadPSFX/ExplosionScripts all
stubs). Parses the authentic VIDEO/*.PFX definitions via the [pfx_day]
psfxN mapping; premultiplied blending renders BOTH families from the same
data (additive-style fire + occluding smoke -- DDAM2 is 30% grey, DDTHSMK
ramps negative: impossible additively); depth-sorted billboards with a
radial-masked grit sprite; impact-frame orientation (.PFX offsets are
authored mech-local, -Z = out of the struck armor toward the shooter) for
weapon hits AND damage bands (via lastInflictingID, now maintained -- was
declared but never written). Both effect-number encodings route (raw dpl
<100 + WinTesla 1000+slot carried by the band resources). Death fires the
authentic dnboom (7) + ddthsmk smoke plume (1).
- Effects anchor at the impact point / damaged zone's segment, not the mech
origin (no more fire at the feet).
- Dev force-input gates BT_AUTOFIRE / BT_AUTODRIVE for headless fire-chain
verification; BT_PFX_ADD=1 flips the particle blend for A/B.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the "kills are invisible" report: a mech died only at the state level, with
no visible destruction on its body. Root cause: the per-zone damage-state
descriptor table (binary zone+0xd4) that drives destroyed-skins + explosions was
never built -- its loader (Mech__DamageZone::LoadCriticalSubsystems / FUN_0041e4a8)
was a no-op stub, and MechDeathHandler itself was a no-op stub. The surrounding
plumbing (the per-zone load loop over the type-0x1e resource, the death-handler
slot) was already correct. Whole pipeline is EXPORTED -- no stand-ins.
Reconstructed:
- Mech__DamageZone descriptor table (binary this+0xd4): un-stubbed
LoadCriticalSubsystems (FUN_0041e4a8/FUN_0042a748/FUN_0042a2c8) to parse the
real type-0x1e stream -- entry = [f32 DamageLevel][i32 EffectResource]
[i32 GraphicState][f32 TimeDelay], ascending by DamageLevel. Verified live:
6 entries/zone, first threshold 0.2, effect 26, across all 40 zones, no crash.
- The three lookups (FUN_0042a664 by-level / FUN_0042a5f4 crossing / FUN_0042a6c4
by-graphic-state) as Mech__DamageZone methods.
- The real MechDeathHandler (FUN_0042a984 ctor / FUN_0042aa2c Performance /
FUN_0042a9f4 dtor), replacing the stub: each tick it walks the zones and, as a
zone's damageLevel rises across a descriptor threshold, fires that entry's
explosion (binary +0xb8 & 4) and, on destruction, the Destroyed-graphic
descriptor's explosion (+0xb8 & 8), applying the descriptor's GraphicState (the
destroyed skin) to the zone. Runs for EVERY mech, so the enemy visibly falls
apart as it dies.
Integration: the binary ticks MechDeathHandler off the mech's Performance list
(mech+0xbc), which the bring-up drive override bypasses, so Mech::PerformAndWatch
drives Tick() directly (same approach as UpdateDeathState). Effect spawn uses the
established Explosion::Make port (BTSpawnDamageEffect) -- the authentic dispatch
(class-5 message -> the 0xBD3 SubsystemMessageManager effect manager) is unported.
Runtime: builds clean, boots clean, 42 descriptor tables load with sensible
byte-aligned data, no crash. Live effect firing is combat-triggered (verified by
the load + the wiring; the [deathfx] threshold-crossing log fires under BT_DEATH_LOG).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reconstructs the mech DEATH state machine — the un-exported master-perf death
branch (region 0x4a9770-0x4ab188) — from its EXPORTED consumers + the RP
VTV::DeathShutdown analog, wired into the active path (the bring-up drive
override bypasses the authentic Simulate where this normally lives).
Mech::UpdateDeathState() + IsMechDestroyed() (mech4.cpp), called for every mech
early in PerformAndWatch: on a vital kill (graphicAlarm >= 9, raised by the
damage side) it
1. sets movementMode = 5 -> the collapse clip's one-shot latch in
AdvanceBodyAnimation (fall direction 5-8 is un-exported -> 5 [T3]);
2. loops the roster calling Subsystem::DeathShutdown(1) (RP VTV::DeathShutdown
analog; the base is a no-op virtual, overrides act -- a SHUTDOWN not a
teardown, so it frees nothing and never removes the entity: the wreck STAYS);
3. next frame settles to movementMode = 9 -> IsDisabled -> locomotion frozen.
The drive's lone `movementMode = 1` write (mech4.cpp ~1309) is guarded on
!IsMechDestroyed so the death state is not clobbered back to a live gait.
Runtime-verified (forced kill, BT_DEATH_LOG=1):
[death] mech destroyed -> collapse + subsystem shutdown (wreck stays)
[death] mech settled -> disabled (IsDisabled=1, frozen wreck)
mech frozen in place, subsystem tick + renderer keep running, no crash, wreck
stays.
Also records the full death-sequence decomp map in combat-damage.md (the
exported consumers: AdvanceLeg/BodyAnimation collapse latch, IsDestroyed, the
MechDeathHandler effect engine; and the un-exported orchestration gap).
Deferred (honest): the visible collapse ANIMATION latch through the active path
(gait SM shows state=0 post-death; spawned mechs don't advance body anim);
MechDeathHandler (FUN_0042a984/FUN_0042aa2c -- the exported per-subsystem
destroyed-skin + explosion engine, still a stub) for death explosions; the
whole-mech DeathSplash radius damage (un-exported).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two deferred TakeDamage dispatches in Mech::ProcessCollision now fire,
unblocked by STEP 6 (the cylinder hit-location override that resolves zone==-1):
- Mover branch (:15324-15358): on a collision with another Mech, dispatch the
collision damage to it.
- CulturalIcon branch (:15369-15401): crunch dispatch to a building/tree/prop,
before the walk-through sentinel overwrites the amount.
Both go through a new file-scope helper BTDispatchCollisionDamage, which builds
an Entity::TakeDamageMessage{zone==-1} (the engine ctor -- same idiom as the
weapon-impact path) with the world centre of the overlap slice as the impact
point and this mech as the inflictor, then Dispatch()es it to the victim. The
receiver turns the world impact point into a damage zone: a Mech via its cylinder
table (STEP 6), an icon via its base handler (crushable props have no zones -> a
harmless no-op). Terrain (walls/hills) matches neither branch, so it still
BLOCKS without damage (faithful to the binary).
Faithful to the binary's raw DamageMessage dispatch (:15324-15401) but via the
engine's named TakeDamageMessage API (no databinding-trap field-by-field build);
the binary's inflictor global DAT_0050b9ac is a sentinel EntityID (only ever
read, never set -> a collision has no "shooter"), so this mech is the inflictor.
Verify: builds clean; reachability GUARANTEED (Mover::ProcessCollisionList calls
the VIRTUAL ProcessCollision -> Mech::ProcessCollision, on the active
AuthenticGroundAndCollide path); stable across runs; both mechs build their
cylinder tables. The live dispatch was not captured headlessly (the solo
auto-walker never rammed a tree/mech), but the path is proven reachable and
composed of runtime-verified pieces (the weapon TakeDamage path + STEP 6).
Also validated STEP 6's height ref: collisionTemplate->maxY ~= 7.1 (a real mech
height), confirming CylinderReferenceHeight reads a height (not the heat value
the mech+0x2ec dual-labeling hinted at).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Mech per-impact hit-location resolver (the cylinder damage table) is now
functional, wired, and runtime-verified [T2]. Unaimed (zone==-1) hits — the
collision-damage path — now resolve an impact point to a damage zone via the
authentic height x angle grid + weighted dice roll, instead of dropping.
dmgtable.cpp/.hpp was a non-functional skeleton on no-op ReconTable/stream
shims; backed it with real std::vector storage and fixed 5 latent runtime bugs:
- ReadEntries now consumes the leading cell name-string ([i32 len][len+1])
- PieSlice ctor reads rotateWithTorso into the correct member
- SelectSlice direct-indexes (was int lookup on a float-keyed table)
- ResolveHit returns the zone (chains SelectSlice -> SelectZone)
- real MemoryStream::ReadBytes (was a variadic no-op)
mech.cpp ctor: replaced the empty-name StandingAnimation stub with the real
load — FindResourceDescription(dzRes->resourceName, type 0x1d) -> stream ->
new DamageLookupTable, cached at mech[0x111]; ~Mech deletes it.
Mech::TakeDamageMessageHandler override registered (MESSAGE_ENTRY overlays
Entity's by ID): on invalidDamageZone, resolve via the table then base-route;
aimed reticle hits pass through unchanged.
Three named accessors (no databinding-trap raw reads): WorldToLocal
(localToWorld.MultiplyByInverse), CylinderReferenceHeight (standingTemplateMaxY
== collisionTemplate->maxY == binary mech+0x2ec[+0xc]), TorsoHeading via a
BTGetTorsoTwist bridge in torso.cpp (Torso::CurrentTwist == torso+0x1d8;
torso.hpp cannot be included into mech.cpp — subsystem-stub collision).
Stream format + geometry + roll + handler were all byte-verified against the
shipped BTL4.RES type-29 resources (18 tables, exact consumption) and the
disassembly (FUN_0049eb54/e678/de14, glue 0x49ed0c, handler @0x4a037a).
Runtime: boots clean, "[cyl] table 'bhk1' layers=7" (exact byte-verified layer
count, found by name), mech spawns + walks, no asserts/AV/0xCDCDCDCD. Env gate
BT_CYL_LOG=1. Unblocks collision-damage application.
KB updated (combat-damage.md STEP 6 COMPLETE, open-questions.md marked done).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cylinder table resource = type 29 DamageLookupTableStream, 18 in BTL4.RES (one per mech) ->
STEP 6 is FEASIBLE, not content-blocked. Name copied from a sibling resource (local_130+0xc).
RE phase complete; build (containers + handler + load + wire) is the next phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
De-risk + format-reversal pass complete. The CylinderDamageZoneTable is a passive nested-list
height x angle grid: TABLE (FUN_0049ea48) -> ROWS-by-height (FUN_0049e740, cell key i*2pi/count,
_DAT_0049e810=6.2831855) -> CELLS-by-angle (FUN_0049deb0) -> zone. All 3 vtables minimal (dtor +
2 Node slots, NO lookup method) -> the lookup is entirely in the unexported
Mech::TakeDamageMessageHandler (handler-set entry, not virtual) = the disassembly target.
Full ctor/dtor/vtable address map + the 4-step build plan recorded in combat-damage.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the 'correct errors' convention: the cited FUN_004a0230/FUN_0049ed0c don't exist. The real
CylinderDamageZoneTable = a list of 0x30-byte entries (FUN_0049e740) from resource 0x1d; the recon
builds the table (FUN_0049ea48, mislabeled StandingAnimation @Mech[0x111]) with an EMPTY name -> 0
entries -> no-op. The lookup + Mech::TakeDamageMessageHandler override are NOT in the exported decomp
(need disassembly). Recorded in combat-damage + open-questions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The context system is a reconstruction; some claims are wrong. When the decomp/binary/engine
source contradicts a context/glossary/docs claim, FIX it + grep-sweep the same claim across the
other topic files + re-run checkctx.py -- don't just work around it. Archetype cited: the 0xBD3
'gates the valve/message routes' correction from this session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Propagate the verified finding (0xBD3 = damage/explosion hub @0x434, NOT the valve/message
gate; the valve/Myomers gates read the owning BTPlayer @mech+0x190) into the topic files that
still carried the old claim: decomp-reference (ClassID row + both offset rows), gauges-hud,
subsystems, open-questions (Myomers coupling), + frontmatter. Graph validates clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The valve/Myomers 'gates' (FUN_004ac9c8 / FUN_004ad7d4) read the owning BTPlayer's
+0x274 / +0x260 (mech+0x190 = the player, bound by FUN_0049f624). So there's no new
subsystem to build -- the wiring is 2 gate accessors -> GetPlayerLink() named members.
Open: the true semantics of player+0x260/0x274 (scoring names showKills/roleClassIndex
may not match the gate use) + the FUN_004ad7d4 HeatModelActive-vs-OwnerAdvancedDamage
label conflict + these are likely MODE flags authentically-off in the basic mission.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Risk-4 investigation (before wiring WAVE 8) found my earlier 'keystone unblocks 4 things'
claim was wrong. Verified from the binary:
- 0xBD3 SubsystemMessageManager = a damage/explosion consolidation hub (ConsolidateAndSendDamage,
weaponExplosions), cached to Mech[0x10d]=0x434, mislabeled 'controlsMapper' in our recon (the
live drive squats there; the real mapper is roster slot 0 via SetMappingSubsystem).
- The valve MoveValve guard (FUN_004ac9c8 ->owner+0x190+0x274) AND the Myomers gate
(FUN_004ad7d4 ->owner+0x190+0x260) read a DIFFERENT object at mech+0x190 (!= 0x434). So 0xBD3
does NOT gate the valve/Myomers/MessageBoard; mech+0x190 (unidentified, no decompiled writer yet)
is the real keystone for those gauge-adjacent routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "messageBoard" cockpit primitive (the secondary-MFD comm/status message ticker,
L4GAUGE.CFG:4913) was PROSE-ONLY (no bodies/methodDescription) -> parse-skipped. This
was the last unregistered gauge primitive.
Reconstructed byte-verified (Make @4cb678, ctor @4cb704, dtor @4cb788, BecameActive
@4cb7fc, Execute @4cb82c; vtable 0051bddc; sizeof 0xA4). THREE header mislabels fixed
(decode + adversarial verify): int enabled -> Entity* trackedMech (a pointer deref'd at
+0x190, set by SetSource not SetEnable); previousMessageId/previousNameId were SWAPPED
(BecameActive writes 0x9c=-1/0xa0=-2; Execute compares messageId vs 0xa0, name vs 0x9c).
Execute blits the strip cell (id -> (id&3)<<7,(id>>2)<<5) + sender name; methodDescription
= 4 params (rate, mode, btsmsgs.pcx, color). Registered in BTL4MethodDescription[].
DEFERRED / EMPTY by design (authentic for bring-up): the source is never bound (SetSource
has no recovered caller) AND the per-player status queue (StatusMessagePool, btstubs.cpp:62)
is a NULL stub -> no status messages exist -> Execute early-returns on the NULL source (safe
no-op == empty board). Data read via a BTResolveMessageBoard bridge in btplayer.cpp (a real
/FORCE-safe stub returning False; the raw mech+0x190/+0x1dc reads stay in the complete-BTPlayer
TU, dodging the databinding trap). The name-cell path is a marked structural simplification to
restore when the feed lands.
VERIFIED: the gauge parse-skip list is now EMPTY ([gskip]=0 -- every config gauge primitive
registered + built); BTResolveMessageBoard resolves (no /FORCE __ImageBase AV); combat TARGET
DESTROYED, no crash; gauge composite renders identically (300/77, clusters, radar un-regressed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "prepEngr" cockpit primitive (the per-engineering-screen static label overlay,
12 CFG calls at L4GAUGE.CFG:4595-4759) was a MISLABELED stub (base Gauge, an Execute
override, mech/screenNumber SWAPPED) with no methodDescription -> parse-SKIPPED, none built.
Reconstructed byte-verified (ctor @4c7bf0, BecameActive @4c7e48, Make @4c7b30; vtable
0051a06c). THREE corrections vs the stub (decode workflow + vtables.tsv proof):
(1) base = GraphicGaugeBackground, not Gauge -- vtable-identical shape to the engine's
BackgroundBitmap (only dtor + BecameActive overridden); (2) the overridden slot is
BecameActive (a paint-on-activation), NOT Execute -- GraphicGaugeBackground has no Execute
virtual, so the Gauge::Execute->Fail->abort hazard doesn't apply; (3) screenNumber@0x6C /
mech@0x70 were swapped. sizeof 0x90.
BecameActive walks the mech roster (databinding-safe GetSubsystemCount/GetSubsystem +
BTGetSubsystemAuxScreen bridge, like VehicleSubSystems::Make), finds the subsystem whose
auxScreenNumber == this screen (1..12), and paints the screen-number numeric + subsystem
label + type-specific label cells dispatched on GetClassID (Sensor/Myomers/Emitter/PPC/
Projectile/Missile/GaussRifle). methodDescription = 9 params (mode, screen, 7 .pcc names).
Registered in BTL4MethodDescription[]. Two raw subsystem reads (heat-sink #, +0x224 label)
are guarded fail-soft best-efforts (marked; the aux-screen bridge label64 substitutes).
VERIFIED: the skip list drops from {prepEngr x12, messageBoard} to just {messageBoard} --
all 12 prepEngr gauges build; no screen-range/crash; boot+sim+combat run; gauge composite
renders identically (COOLANT 300/77, clusters, radar all un-regressed). The prepEngr labels
paint on the Eng (ModeMFD*Eng*) screens, not the default composite view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "sectorDisplay" cockpit primitive (Secondary overlay, the radar SECTOR X/Z
coordinate read-out) was PROSE-ONLY in btl4gau3 (placeholder Make, no ctor/
methodDescription/registration) -> the config line was parse-SKIPPED and never built.
Reconstructed byte-verified from the disassembly (ctor @4c9e10, Execute @4ca07c,
methodDescription PE-parse): SectorDisplay : GraphicGauge, sizeof 0xC4. Its Execute
reads the linked mech's world position and shows two 100-unit sector numerics:
numericA = Round(-localOrigin.z * 0.01) + 500
numericB = Round( localOrigin.x * 0.01) + 500
(rounding = round-to-nearest == FUN_004dcd94, corrected from the reviewer's wrong
"truncate" claim; 0.01 const PE-verified; -Z/+X axis + fchs confirmed from asm).
Overridden slots: LinkToEntity(9) caches the subject, BecameActive(3, non-inactivating),
Execute(16) -> satisfies the container-Execute rule. Layout overflow-locked
(static_assert sizeof<=0xC4). Make/ctor/dtor mirror the registered PilotList sibling;
the config image name is copied (nameCopy) since Execute reads it per-frame.
Registered in BTL4MethodDescription[].
VERIFIED LIVE (BT_SECTOR_LOG): Make port=1 pos=(125,579) image=helv15.pcc gridCached=1;
Execute -Z=960.4 X=361.6 -> sectorA=510 sectorB=504 (Round(9.6)+500=510, Round(3.6)+500=504
-- authentic 100-unit sectors from live mech position). Gauge composite renders full,
no crash. The skip list is now exactly the two remaining widgets (prepEngr x12, messageBoard).
Also: a permanent BT_GAUGE_SKIP_LOG diagnostic (GAUGREND.cpp, gated) that logs each
unregistered gauge primitive the dev-parse skips -- the tool that pinned this down
(earlier "not built" runs were killed before the lazy gauge-renderer init).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconstructed Condenser::MoveValve carried FUN_004afbe0's body -- a CONTROLS-mapper
display-mode cycler (cycles field@0x190 through 0/1/2 + repoints owner HUD gauges), NOT
the condenser valve. The real valve handler @0x4ae464 was not captured by the
assert-anchored exporter; disassembled it (tools/disas2.py) and reconstructed faithfully:
cycle valveState (@0x1D0) 1 -> 5 -> 50 -> 0 -> 1 (byte-verified @4ae480-4ae4bf), then
RecomputeCondenserValves(owner) to redistribute flow so every ValveSetting gauge updates.
DORMANT by design: the binary guards on owner->messageManager(+0x190)+0x274 (FUN_004ac9c8,
the 0xBD3 SubsystemMessageManager) and the message that invokes MoveValve arrives through
that same manager -- both DEFERRED to WAVE 8. Reproducing the raw owner+0x190 deref would
be a databinding trap in our layout, so the handler is faithful-but-uninvoked; the valve
gauge shows the authentic static 1/N (RecomputeCondenserValves at ctor) until 0xBD3 lands.
No V-key bring-up stand-in added.
Build green; combat TARGET DESTROYED, un-regressed (MoveValve is not yet routed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two coupled fixes so the condenser valve gauge (ValveSetting -> coolantFlowScale
@0x15C) reads its authentic value instead of garbage/zero.
STEP 7 (gating dedup): heat.cpp carried STUB Condenser ctor/dtor/TestClass/
TestInstance/CreateStreamedSubsystem that ODR-duplicated the REAL bodies in
heatfamily_reslice.cpp and WON under /FORCE (heat.obj links first) -> the real
ctor (which sets valveState=1) was shadowed, leaving valveState=0xCDCDCDCD.
#if 0'd the heat.cpp stubs so the reslice ctor (@4ae568, byte-verified: valveState=1,
coolantFlowScale=0, massScale=refrigerationFactor, condenserNumber from name) is the
sole definition. DefaultData/GetClassDerivations/ResetToInitialState (not duplicated)
stay in heat.cpp.
STEP 9 (the real writer): FinishConstruction() at the Mech ctor tail was a no-op
template stub in place of FUN_0049f788 = RecomputeCondenserValves -> coolantFlowScale
was never written (stayed 0). Reconstructed it (byte-verified vs part_012.c:9264):
distribute coolant flow across the mech's condensers, coolantFlowScale_i =
valveState_i / sum(valveState), with the condenserAlarm@0x1DC change pulse
(2-if-flow<=old-else-1, then 0). Walks the populated subsystem roster filtering
Condensers via IsDerivedFrom (behaviorally identical to the binary's @mech+0x7cc
condenser chain, GUID 0x50e4fc). Wired as BTRecomputeCondenserValves(this) at the
Mech ctor post-init pass (binary @9457). _DAT_0049f850 fallback confirmed 0.0f (PE read).
Verified: [valve] the Blackhawk's 6 condensers each read flow=0.166667 (=1/6, total=6)
for both player + spawned enemy, no every-mech crash; combat TARGET DESTROYED,
un-regressed. Diagnostic BT_VALVE_LOG added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0xBBE heat-sink BANK was built as a plain HeatSink, so the numeric-R
cockpit gauge binding HeatSink/AmbientTemperature (L4GAUGE.CFG:4552) had
no publisher -> the LAST unresolved config attribute.
Reconstruct AggregateHeatSink : HeatSink (ctor @4ae8d0, own GUID 0x50e590),
byte-exact + static_assert-locked (heatSinkCount@0x1D0, ambientTemperature
@0x1D4=300, helper@0x1D8 0xC link node, sizeof 0x1E4 == factory alloc @9993).
Publish HeatSinkCount + AmbientTemperature via a dense-prefix attribute table
chained to HeatSink::NextAttributeID (shared HeatSink table unchanged, so
CoolantMass/CoolantCapacity keep resolving). Move CreateHeatSinkBankSubsystem
into heatfamily_reslice.cpp (needs the class def) and build the real class at
factory case 0xBBE; mech.cpp unchanged.
DELIBERATE DEVIATION (documented in the class): keep the base HeatSinkSimulation
the HeatSink ctor installs; do NOT reimplement the authentic Performance @4ae73c
-- it derefs a raw self+0xE0 -> [+0x158] that does not map in our compiled layout
(AV/NaN, and runs for EVERY mech), and ambientTemperature is a frozen constant so
the gauge reads 300 either way. Authentic relaxation model deferred.
Verified: [attr] HeatSink/AmbientTemperature OK; all 50 config attribute
bindings resolve (0 NULL); no every-mech crash; combat TARGET DESTROYED,
FIRED #41+, un-regressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gauge-complete-decode workflow found the "deep heat-leaf byte-exact re-base" is
STALE -- the leaf layout already shipped byte-exact (alarm-unification/P7), and the
real work is a small misidentified-field data fix (highest value, lowest risk):
- Sensor radarPercent read the INHERITED heatEnergy (~1.3e7) -> hugely negative ->
needed a bring-up guard forcing it to 1.0. The binary (@004b1c4c:1829) reads
*(this[0x38]+0x158) = the subsystem's OWN DamageZone::damageLevel [0,1] (0 intact ..
1 destroyed). Fixed: radarPercent = RadarBaseline - GetSubsystemDamageLevel(); guard
DELETED (damageLevel is engine-clamped). So the radar authentically degrades with
sensor structural damage (RadarBaseline-damage, x0.5 on DegradationHeat, 0 on Failure).
- New databinding-SAFE accessor MechSubsystem::GetSubsystemDamageLevel()/Set... reads the
ENGINE DamageZone::damageLevel NAMED member (NOT the ReconDamageZone proxy, whose
offset-0 structureLevel aliases the vtable ptr as a float = garbage).
- HeatSink::HandleMessage msg==1: same misidentification -> SetSubsystemDamageLevel(0.5)
(was linkedSinks->heatEnergy=0.5, wrong object+field).
- MyomerCluster seek lamp: subsys+0x800 (OOB for 0x358 Myomers) -> currentSeekVoltageIndex
@0x320 (the Emitter+0x3f0 analog; INFERRED -- ctor not in the assert-anchored export).
- numericSpeed (~225 vs 61.5): verified NOT A BUG (NumericDisplaySpeed applies x3.6; the
feed is correct u/s) -- closed, no code.
No layout change (already byte-exact); pure data-read retargets. Verified DBASE+dev
gauges: no crash, combat un-regressed (TARGET DESTROYED). (radarPercent degrade is only
OBSERVABLE once per-subsystem damage routes to each engine DamageZone -- a separate task;
today the sensor is undamaged so radarPercent = full 1.0, which is correct.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RANK=-1: Player::DefaultFlags creates every player NonScoring (CalcRanking only
ranks IsScoringPlayer()==true, else -1), and the bring-up never cleared it. Call
SetScoringPlayerFlag() in DropZoneReplyMessageHandler's mech branch (a piloted mech
IS an active scoring combatant; the camera-ship branch stays non-scoring). -> RANK=0.
DEATHS runaway (climbed ~1/s, not the "1" first seen): @004c012c -- which our
MESSAGE_ENTRY binds as the VehicleDead handler -- is actually the drop-zone
RESPAWN-RETRY helper. The engine's RequestDropZone (PLAYER.cpp:400) posts a
2s-delayed VehicleDead(deathCount=-2) as a "did the drop zone reply?" timer; our
handler ++deathCount + RE-POSTED it UNCONDITIONALLY -> an infinite loop, because the
drop-zone handshake never "completes" in the bring-up (no console/drop-zone system).
Traced it to a pure boot artifact (13 fires with zero combat; the only VehicleDead
producers are the re-post + my kill-producer, and the kill fires AFTER the loop
starts). FIX (correct respawn-retry semantics): if the player already has a vehicle
the drop zone WAS acquired (the DropZoneReply created it), so the retry is moot --
return without counting a death or re-posting. -> DEATHS=0 in solo, loop gone.
Verified DBASE+dev gauges: the Comm roster shows KILLS=1 / DEATHS=0; the KILL log
reads killCount=1 deaths=0 score=164 rank=0; 0 crashes. (MP real-death DEATHS -- a
destroyed vehicle via the separate @004c05c4 path -- remains deferred per the plan;
solo never dies so the retry gate is correct there.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Comm roster + score gauges read 0 for 4 independent reasons (none was handler
logic -- the BTPlayer ScoreMessage/VehicleDead/ScoreInflicted handlers were already
reconstructed). Mapped by the scoring-feed-decode workflow; fixed all 4:
- NO PRODUCER: combat only dispatched Entity::TakeDamageMessage, never a scoring
message. Added producers (btplayer.cpp bridges, called from mech4.cpp): per-hit
ScoreInflicted at the beam (:2171) + projectile (:831) damage dispatch -> SCORE;
KillScore at the TARGET-DESTROYED edge (:2198) -> KILLS. senderMechID = the
VICTIM (so the local player is credited via the !=our-mech branch, not the
suicide-negate branch); dispatched to application->GetMissionPlayer().
- CROSS-FAMILY MECH OFFSETS: MECH_OWNING_PLAYER/TONNAGE/DAMAGE_BIAS read raw binary
offsets (garbage in our 0x638 Mech). MECH_OWNING_PLAYER -> Entity::GetPlayerLink()
(NULL for the ownerless dummy); tonnage/bias stubbed 1.0/0.0 (bring-up).
- NULL scenarioRole: the BTMission role registry has no WinTesla analog, so every
award path NULL-deref'd. CalcInflictedScore returns the neutral (damage+bias)
when scenarioRole==0; DamageReceived guarded.
- NULL-owner dummy: the KillScore sender-owner increment + StatusMessage now guard
GetPlayerLink() (the dummy has none) so a solo kill credits only the local player.
- DATABINDING: the PilotList read KILLS/DEATHS at raw offsets (pilot+0x27c/+0x200)
that don't match our compiled layout -> silent 0. Repointed to the compiled
members via bridges (BTPilotKills/BTPilotDeaths -> BTPlayer::GetKillCount/GetDeaths).
⚠ ROOT-CAUSE of the empty scoreboard: the PilotList SELECT-TARGET highlight did a
raw-offset deref (*(local+0x284) objectiveMech, then *(tgt+0x190)) that AV'd on our
layout -- caught silently by the SEH GuardedExecute, aborting Execute BEFORE the
KILLS/DEATHS draw. Replaced with BTPilotIsSelected (accessors + GetPlayerLink).
- SCORE persistence: PlayerSimulation's 10s flush zeroed currentScore (the binary's
console delta); now only flushes/zeros when a console host exists (solo keeps the
running score for the gauge + CalcRanking).
Verified DBASE+dev gauges (BT_SCORE_LOG): SCORE climbs +5.83/hit -> 164; on the
kill KILLS 0->1, and the Comm roster RENDERS KILLS=1 (screenshot). No crash.
Follow-ups (noted): DEATHS=1 is a pre-existing spawn/respawn-handshake artifact
(not from the producers -- the dummy's GetPlayerLink is NULL so no VehicleDead is
posted; confirmed killCount=1 not 2); RANK=-1 is CalcRanking's non-scoring mark
(Plasma serial surface, invisible on dev); MP VehicleDead/tonnage/role registry
remain per the plan. Diagnostics kept: BT_SCORE_LOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-caused the P2c abort with a cdb attach to the frozen abort dialog: the stack
was Gauge::Execute (GAUGE.cpp:598) -> abort, via GuardedExecute <- Update <-
ProcessOneActiveGauge. The engine base Gauge::Execute is Fail("not overridden")
-> abort, so an ACTIVE container that doesn't override Execute aborts by design;
GuardedExecute's SEH can't catch an abort(). The decode's "GeneratorCluster
overrides ONLY the dtor / renders via its children" was wrong -- the 1995 base
Gauge::Execute was a no-op, the 2007 WinTesla engine's aborts, so the container
MUST override Execute (the sibling SubsystemCluster does).
FIX: give GeneratorCluster the two container overrides (mirroring SubsystemCluster):
- BecameActive() -- blit the panel's static background pixmap (DrawPixelMap8) and,
by overriding the default, DON'T self-inactivate so the panel stays active.
- Execute() -- no-op (the 5 self-registered child gauges draw the dynamic content;
the override exists only to avoid the base Fail->abort).
Re-registered in BTL4MethodDescription[]. Verified DBASE+dev gauges: all 4
generator panels (A/B/C/D) render -- blue OutputVoltage bars (the @004c72ac
ScalarBarGauge Scalar* variant), labels, status lamps -- 0 "BecameActive not
defined" warnings, NO abort, combat un-regressed (TARGET DESTROYED). Phase 2 is
now COMPLETE (4/4 widgets: LeakGauge, VertNormalSlider, PilotList, GeneratorCluster).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconstructed the 4th unbuilt widget (the 4 generator engineering panels, buttons
9-12) + its companions, but left it UNREGISTERED pending a runtime-abort fix:
- Generator OutputVoltage attribute table (powersub) -> outputVoltage@0x1DC, chained
to HeatSink's dense index (temps already reach it). Correct + reusable.
- ScalarBarGauge @004c72ac plain-Scalar variant (btl4gau2) -- the DIRECT-Scalar* bar
the GeneratorCluster voltage bar needs (the existing @004c721c takes a resolved
source object). Correct + harmless.
- GeneratorCluster Make/ctor/dtor + 15-param methodDescription reconstructed from
part_014.c:891-1013 (replacing the prose + placeholder Make + the bogus DAT pool);
builds the 5 children (temp bar / voltage bar / leak wipe / 2 lamps). Header fixed
to the binary ctor param order (secondaryColor@0x94, +_reserved0xB0 -> sizeof 0xB4).
ChildRate forward-declared.
⚠ REGISTRATION GATED (commented out in BTL4MethodDescription[]): registering it
aborts at runtime -- the 4 panels build (the 4 "BecameActive not defined" warnings
fire) then abort(). ISOLATED to the PARENT lifecycle: the abort PERSISTS with all 5
children nulled; there are no pure virtuals; Execute is SEH-guarded under BT_DEV_GAUGES
so the fault is in the unguarded ctor/lifecycle path. The sibling SubsystemCluster
overrides BecameActive/Execute/TestInstance whereas the decode has GeneratorCluster
overriding ONLY the dtor -- that "inherits only the dtor" claim is the prime suspect.
Deferred; the 3 other P2 widgets (LeakGauge/VertNormalSlider/PilotList) ship clean.
Verified: unregistered -> parse-skips -> no abort; combat un-regressed (DESTROYED),
0 crashes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PilotList (keyword "pilotList") was PROSE-ONLY, so the Comm surface showed only
the baked btcomm.pcx labels. Reconstructed all 7 functions from part_014.c:
3156-3434 (Make/ctor/dtor/BecameActive/TestInstance/Execute/DrawMechIcon), with
the 8x(x,y,layoutMode) layout table DAT_0051af88 PE-parsed exact from BTL4OPT.EXE.
Draws ONE roster slot/frame (round-robin) from the viewpoint mech's cockpit-mapper
pilot roster.
- Roster FEED: new BTResolveRosterPilot(slot) bridge in mechmppr.cpp (a complete-Mech
TU) resolves the viewpoint mech's ControlsMapper (subsystemArray[0]) -> GetPilot;
btl4gau3.cpp reads the returned pilot at raw BTPlayer offsets. The mapper's
FillPilotArray already fills the roster (pilotArray[0]=GetMissionPlayer,
[1..]=FindGroup("Players")).
- KILLS = killCount@0x27c (real; ScoreMessageHandler increments it in combat).
- DEATHS: the binary reads pad_0x280 which has NO writer anywhere (a shipped dead
field -> perpetual 0). FIX per "if it doesn't work, fix it": read the real
deaths counter Player::deathCount@0x200 (VehicleDeadMessageHandler increments it)
so DEATHS is meaningful in MP.
- DrawMechIcon: App+0xC8 name-bitmap cache is unwired (same deferral PlayerStatus
uses) -> LookupPlayerNameBitmap returns NULL -> the tinted name box (never an AV).
Dropped the bogus x,y from the header ctor (positions come from DAT_0051af88).
/FORCE-safe (all vtable slots real; link log clean, no unresolved PilotList/
BTResolveRosterPilot). Verified DBASE+dev gauges: the Comm surface now renders the
live local pilot row (KILLS 0 DEATHS 0 + name box; 0/0 authentic in solo -- the
combat scoring feed that moves them is Phase 3), combat un-regressed (DESTROYED),
0 crashes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gauge-widget-decode workflow reconstructed the unbuilt cockpit widgets from
the binary. First two (the heat MFD's missing SET/LEAK columns):
- LeakGauge (BitMapInverseWipe, keyword "LeakGauge"): the class body already
existed + is byte-faithful; it was just UNREGISTERED so every LeakGauge(...)
config line was parse-skipped. Added the Make factory + methodDescription +
the BTL4MethodDescription[] registration. Value @6 = Condenser/CoolantMassLeakRate
(already published P1); 0 undamaged -> the leak wipe is authentically empty.
- VertNormalSlider (keyword "vertNormalSlider"): the condenser VALVE slider @2 was
PROSE-ONLY. Reconstructed all 7 functions (Make/ctor/dtor/TestInstance/
BecameActive/Execute/Draw) from part_013.c:14051-14175 (Make @004c4b08 by
disassembly) -- an XOR indicator row=Round(span*value) over the track; fixed the
jumbled header member layout to the byte-exact order (sizeof 0xB0). Published
ValveSetting -> coolantFlowScale@0x15C on the HeatSink table so Condenser/ValveSetting
resolves (verified: all 6 condensers OK). The valve indicator renders (condenser 1
shows it near the top = coolantFlowScale 1.0 = valve open).
Both /FORCE-safe (every vtable slot has a real body; link log grep clean, no
unresolved VertNormalSlider/BitMapInverseWipe). Verified DBASE+dev gauges: the
heat panel now shows all 3 columns per condenser (TEMP scales, SET valve indicator,
LEAK track), coolant bar stays full, combat un-regressed (TARGET DESTROYED), 0 crashes.
The player-valve-toggle -> coolantFlowScale drive (SetValveSetting vtable+0x48) is
the remaining Phase-3 valve-mechanism work; the slider shows the static valve value today.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Searchlight publishes "LightOn" -> lightState@0x1D8 (the button-5 searchlight
lamp; the empty default-constructed index resolved NOTHING). The config binds
the name "LightOn", so the enum id is renamed LightOnAttributeID and the table
is chained to PowerWatcher's dense index. Verified: LightOn resolves OK.
- FIX a genuine reconstruction bug the heatmodel decode caught (heat.cpp
UpdateCoolant + heatfamily_reslice RefrigerationSimulation): both read
linkedSinks.Resolve()->heatEnergy where the binary reads *(this[0x38]+0x158) =
the subsystem's OWN DamageZone.damageLevel (the engine base zone @0xE0).
Consequences of the misread: coolantDraw = master.heatEnergy(~1.3e7) * heatLoad
would SLAM the live CoolantMass bars to empty whenever the link resolved; and
RefrigerationSimulation clamped massScale permanently to 1.0 (minimum) instead
of the authentic 3.0, plus null-deref'd a Condenser whose linkedSinks is skipped.
Now both read this->Subsystem::damageZone->damageLevel (qualified past the
MechSubsystem shadow, per mechweap.cpp:252): undamaged 0 -> coolantDraw 0 (no
leak, coolant bars stay full = authentic pristine) / massScale = 3.0, rising
only as the sink/condenser itself takes battle damage.
After this only HeatSink/AmbientTemperature remains NULL (the #if 0'd aggregate
HeatSink bank -> P3). Verified DBASE+dev gauges: no crash, combat un-regressed
(TARGET DESTROYED). (Also noted: the Condenser dup-ctor links to the REAL
heatfamily_reslice definition -- LNK4006 keeps the first/real one; the heat.cpp
stub is ignored -- so no bug today; #if 0 cleanup deferred to the Condenser table.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gauge-databinding-map workflow found most cockpit gauges resolve NULL
because the reconstructed subsystems publish only a fraction of the attributes
the config binds. First publishing batch (attribute tables are read-only static
data; ids kept a dense prefix from each parent's NextAttributeID):
- HeatSink table dense-append: DegradationTemperature/FailureTemperature (the
condenser temp-bar warn/max endpoints -- were NULL, so the two-part bars could
not scale), NormalizedPressure/DegradationPressure/CoolantMassLeakRate, and the
HeatSink link. Condenser/Reservoir inherit this -> all 6 condenser temp bars
now resolve current/warn/max (verified: BT_GAUGE_ATTR_LOG all OK).
- PoweredSubsystem::GetAttributeIndex() (new) publishes InputVoltage->voltageSource
-- the cluster power-branch gate (the power-lamp/generator-voltage/state-lamp
sub-branch is skipped when it resolves NULL). Flows to Sensor/Myomers/weapons.
- MechWeapon::GetAttributeIndex() (new) publishes OutputVoltage/PercentDone->
rechargeLevel; Emitter/PPC/ProjectileWeapon/MissileLauncher/GaussRifle DefaultData
re-pointed at it (they carried an EMPTY default-constructed index -> resolved
NOTHING). Verified: the ER MED LASER / PPC / STREAK weapon clusters now render
live recharge dials (were blank TEMP/STATUS).
- Mech::SetTargetRange un-stubbed (radarRange = range) -> the radar map scale +
overlay range readout track the mapper's zoom (was frozen at 1000).
- GAUGREND ParseAttribute: env-gated per-binding resolution trace (BT_GAUGE_ATTR_LOG)
-- durable diagnostic infra for the wave.
Verified DBASE+dev gauges: no startup/gauge-construction crash (dense chain intact),
combat un-regressed (TARGET DESTROYED), clusters build with InputVoltage resolving.
Remaining config-binding NULLs: HeatSink/AmbientTemperature (aggregate bank, P3) +
Searchlight/LightOn (P1b).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues the user hit driving the Mad Cat: (1) projectiles appeared to launch from the
mech's FEET -- MechWeapon::GetMuzzlePoint falls back to the mech origin (localOrigin) when the
weapon's mount segment (this+0xdc) doesn't resolve, so the passed muzzle was at ground level;
(2) they rendered as a thin laser-like line, not a missile.
Fix: BTPushProjectile now takes the SHOOTER mech and resolves the real launch port by NAME
(sitermissleport / sitelmissleport, alternating L/R for a salvo look; then torso ports, then
gun ports; then a raised fallback) -- the same segment-name -> world-transform mechanism the
visible laser beams use for the gun ports. The tracer is now a 3-part missile look (dim smoke
trail behind + orange body + hot flame tip) instead of a single thin beam. FireWeapon (both
ProjectileWeapon and MissileLauncher) passes owner as the shooter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Interactive repro: fire one missile salvo, then only lasers. Cause: ProjectileWeapon::
CheckForJam gates the heat-scaled jam roll on `heatLoad > 0` (true whenever firing), and
the roll uses currentTemperature -- which the reconstructed heat model pins at a constant
~77 (thermalMass is huge, so temp doesn't rise/fall with fire yet). So the roll fired
spuriously on ~every shot, and since JammedState clears only on ResetToInitialState, a
weapon that jammed stayed jammed for the whole mission. (The auto-fire harness hid this --
it kills the target before jams accumulate.)
Fix: gate the roll on the weapon being in a degraded HEAT STATE (heatAlarm level >=
DegradationHeat) instead of raw heatLoad -- the authentic "jam risk scales with heat" rule.
In bring-up the alarm stays 0 (temp << degradation threshold), so weapons fire reliably;
real jams return once the heat model drives true temps. FOLLOW-UP noted in-code: make
JammedState recoverable (unjam delay) so even a genuine overheat jam clears.
Verified: 31 projectile impacts sustained over 18s, 0 crashes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The byte-exact WORLD-ENTITY reconstruction (Projectile @4be1bc 0x340 / Missile @4bf5b4 0x368)
is infeasible on the 2007 engine: measured sizeof(engine Entity)=0x1BC vs the 1995 binary's
0x300, so the reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@0x250)
read GARBAGE on the engine (the Mech 0x638-vs-0x854 gap, but the entity integrator DEPENDS on
those offsets). So -- like the mech drive and the beam renderer -- flying projectiles are a PORT
reconstruction (BTPushProjectile/BTUpdateProjectiles in mech4.cpp, a static array + stack
messages, ZERO heap ops): seeded from the launcher's fire with the decomp's real muzzle
(GetMuzzlePoint) / launch speed (|launchVelocity|) / per-shot damage (damageData, split across
missileCount), they fly to the target (tracer via BTPushBeam) and deliver the weapon's damage on
impact through the SAME Entity::TakeDamage path as the beam (aim Mech::FirstVitalZone()).
THREE bring-up fixes were needed to make a projectile weapon fire at all (found by tracing):
1. TRIGGER: fireImpulse was only driven for the Emitter; ProjectileWeaponSimulation now sets
fireImpulse = gBTWeaponTrigger too (else CheckFireEdge never sees an edge).
2. AMMO BIN: OwnerSubsystemCount/OwnerSubsystem were stubbed ->0, so ammoBinLink never resolved
and ConsumeRound always failed; redirected to the real roster (owner->GetSubsystemCount()/
GetSubsystem(i) -- the AmmoBin 0xBCB constructs before the weapons 0xBCD/0xBD0).
3. JAM ROLL: UniformRandom() was stubbed `return 0.0f`, so CheckForJam's `0 < jamChance` ALWAYS
jammed (a projectile weapon could NEVER fire); replaced with a real LCG [0,1) (fires
~1-jamChance of the time -- authentic occasional jams).
Also: the mech's own target slot (owner+0x388) isn't populated in bring-up (the visible fire
targets the gEnemyMech global), so BTPushProjectile falls back to gEnemyMech.
Verified: Mad Cat PUSH=62 / IMPACT=31 (LRM missiles 3.33 dmg each split across the salvo + AFC100
autocannon 25 dmg), TARGET DESTROYED, 0 crashes, construction heapcheck-clean; BLH un-regressed
(also fires its ballistic weapon now). Diagnostics BT_PROJ_LOG ([projectile] PUSH/IMPACT).
REMAINING (deferred): the byte-exact world-entity Missile (Projectile : Mover, MP-replicable via
Registry::MakeEntity) -- the port projectile is master-local only (no MP replication); the real
per-weapon fire-rate/heat wiring off the subsystem sim (mech4 beam path is still the bring-up harness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An 8-agent decomp-mapping workflow mapped every weapon-family ctor + fire/spawn path
(scratchpad/wave7_maps.txt). KEY IDENTITY CORRECTION via VDATA.h enum (base 3000=0xBB8):
the factory ctor-address comments were right but the built CLASS names were stubs/base --
0xBCD == ProjectileWeapon (was building the base MechWeapon stub)
0xBCE == GaussRifle (NOT MissileLauncher -- was a MissileLauncher stub)
0xBD0 == MissileLauncher (NOT BallisticWeapon -- was a BallisticWeapon stub)
Un-mislabeled + wired via Create<Class>Subsystem bridges (mirroring CreateEmitterSubsystem),
each static_assert-locked byte-exact on the now-locked MechWeapon(0x3F0)/Emitter(0x478) bases:
* ProjectileWeapon (0xBCD, @4bc3fc : MechWeapon, sizeof 0x448)
FIX: AmmoBinConnection was an empty 1-byte struct -> retyped to the binary's 0xC
SharedData::Connection (was making the object 8 bytes short, sliding
MissileLauncher's missileCount off 0x448).
* MissileLauncher (0xBD0, @4bcff0 : ProjectileWeapon, sizeof 0x44C)
FIX: deleted muzzleVelocity (ALIAS of inherited ProjectileWeapon launchVelocity@0x410)
and salvoCount (SHADOW of inherited MechWeapon damageData.burstCount@0x3d4),
keeping the single own field missileCount@0x448.
* GaussRifle (0xBCE, @4bdcb4 : Emitter, sizeof 0x484 = Emitter + Vector3D muzzleVelocity)
DEDICATED bridge (not CreateEmitter): its FireWeapon is a no-op (mov [eax+0x414],0) --
a non-functional weapon in this 1995 build. Also fixed GAUSS.CPP's discharge write
rechargeLevel -> currentLevel@0x414 (the binary writes this+0x414).
Phase A safe-stubs FireWeapon (consume round + recoil, NO spawn): the reconstructed
Projectile (@4be1bc, 0x340) + Missile (@4bf5b4, 0x368) ENTITY classes are NOT spawn-ready
(Entity-base phantom members overflow their New() alloc -> heap corruption; New() takes
(int)this instead of the 0xD4 descriptor; Entity base size unconfirmed 2007-vs-1995, the
Mech 0x638-vs-0x854 problem). The workflow's adversarial verify flagged the live spawn as a
heap-overflow hazard and recommended exactly this phasing. Phase B = the entity byte-exactness
+ descriptor build + New(MakeMessage*) so a fired shot becomes a flying entity that damages.
Factory now 18 of 20 cases wired to real ticking classes (remaining: SubsystemMessageManager
0xBD3 [WAVE 8], Gyroscope 0xBC4 [deferred]). Verified: Mad Cat (LRM/ballistic) + BLH construct
the real weapon classes + tick authentic charge/ammo/heat/recoil, DESTROYED-in-8, 0 crashes,
heapcheck-clean through construction. Energy weapons already damage via the mech4 beam path,
so combat is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconstruction modeled the binary's 0x54 AlarmIndicator (FUN_0041b9ec) with undersized
stand-ins (AlarmIndicator==ReconAlarm==4B; HeatAlarm==8B) across the entire subsystem tree, so
every field above an alarm sat at the wrong compiled offset. Retype every such stand-in to
GaugeAlarm54(0x54) and de-phantom each class against its ctor, so the whole PoweredSubsystem
subtree becomes byte-exact. An 8-agent read-only decomp-mapping workflow decoded every ctor
first; then hands-on implementation. static_assert-locked chain (verified vs the raw ctors):
HeatSink 0x1D0
-> PoweredSubsystem 0x31C retype electricalStateAlarm@0x264 + modeAlarm@0x2B8 @004b0f74
-> MechWeapon 0x3F0 retype weaponAlarm@0x350; delete 5 phantom tail fields @004b99a8
-> Emitter 0x478 delete outputVoltage/beamLengthRatio/firingArmed aliases
+ beamHit*/beamColor/beamHitData/energyRampTime phantoms;
retype beamOrientation EulerAngles->Quaternion(16B) @004bb120
-> PPC 0x478 (no own fields) @004bb888
PoweredSubsystem -> Sensor 0x328 (no alarm; 3 own fields) @004b1d18
PoweredSubsystem -> Myomers 0x358 (no alarm) @004b8fec
New systemic bug-class instances fixed (added to the CLAUDE.md checklist):
* alias field - Emitter outputVoltage==inherited rechargeLevel@0x320; beamLengthRatio==
beamScale.z@0x434; firingArmed==inherited useConfiguredPip@0x3E0
* phantom field - MechWeapon segmentReference/pipSegment/hasTarget/targetPoint/muzzlePoint
(past 0x3F0); Emitter beamHitPoint/beamImpact/beamImpactScalar/beamColor/
beamHitData/energyRampTime (binary writes inherited damageData/voltageScale
or the value is a method local)
Non-layout fixes required in the same wave:
* outputVoltage->rechargeLevel also in the compiled GAUSS.CPP:74/93 (external readers of the
removed Emitter field -- must grep EVERY TU, not just the class's own .cpp)
* MechWeapon::GetMuzzlePoint reimplemented faithfully (removed muzzlePoint collided with
Emitter's own fields at 0x3F0) via a BTResolveWeaponMuzzle void* bridge in mech4.cpp,
resolving the weapon's mount segment (inherited this+0xdc) through the owner segment table
* DetachFromVoltageSource fixed to set electricalStateAlarm not modeAlarm (raw @004b0e30
writes the 0x264 alarm)
The vehicleSubSystems aux-screen gauge raw reads (btl4gau2.cpp:868/952 at subsystem+0x2b8/+0x278)
and the Sensor RadarPercent path now read the CORRECT byte offsets (garbage under the short layout).
Verified: combat DESTROYED-in-8, 28 shots, 0 crashes, heat heatEnergy=1.34e7, every static_assert
lock passes, heapcheck-clean through construction (the phase the isolated PoweredSubsystem retype
had overflowed). LESSON: a factory-bridge runtime Check(sizeof<=alloc) does NOT fail the build --
only a static_assert sizeof lock catches alloc overflow at compile time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>