Commit Graph
326 Commits
Author SHA1 Message Date
Joe DiPrimaandClaude Fable 5 62fc8409b6 seek-4 verification harness (BT_FORCE_SEEK) + the full supercharge/overheat
arc measured live

BT_FORCE_SEEK=<idx> pins the seek gear in MyomersSimulation (env-gated
bench; the eng-page toggle stays the authentic path) -- the supercharge
loop was unreachable on a headless rig.

Measured (Black Hawk, WALLTEST, full autodrive at gear 4):
- cold: drive=1.42843 (exactly 0.9999/0.7), demand 87.85, actual run
  speed 53.7 u/s = 193 kph -- SUPERCHARGE (Oracle's pod figure: 182 for
  a 75-tonner; the 60t Black Hawk runs faster);
- heat at ratio^2 = 2.04: myomer T 531 -> 1765 (deep past the 1000
  degradation line);
- heatFactor governor: drive 1.43 -> 1.10 -> 0.62 (the supercharged mech
  slows BELOW normal while cooked), coolant pulls T back (1765 -> 740),
  drive recovers to ~0.77, oscillates -- the mech self-settles into a
  thermally-limited cruise: 'top speed for less than a minute' exactly;
- the hard freeze (drive -> 0: demand AND turn zeroed) is the same code
  path past failure temp, reachable when the coolant loop is compromised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 11:00:31 -05:00
Joe DiPrimaandClaude Fable 5 905a4a86c0 RETRACTION + FIX: the myomer seek->speed coupling IS in the 4.10 binary --
restored byte-grounded; the morning's removal was export-gap blindness

Player testimony (Oracle, pod veteran: seek-4 humanoid = 182 kph -- the
manual's printed Super Charged figure; overheat = 'freezing up') forced a
re-audit of the morning's 'no dynamic myomer->speed feed' verdict.  The
verdict was WRONG: a raw-image capstone sweep for FPU reads of [reg+0x31C]
found the consumer inside the UN-EXPORTED master-perf region -- the same
decomp gap that hid the #93 crash block.  Decoded @0x4a9cf2-0x4a9da4 [T1]:

  per frame: MAX speedEffect@0x31C over the myomer chain (+0x7AC)
             -> mech+0x79C
             mapper->speedDemand@0x128 *= it        (the SPEED coupling)
             if |drive| <= 1e-4 (@0x4ab16c):
                 mapper->turnDemand@0x12C = 0       (the FREEZE)

Everything the manual + the veterans describe emerges: gear 4 rides
speedEffect ~1.43 into the RegisterMaxOutput gait cap (base x 1.4284 --
the two mechanisms interlock: 61.5 x 1.43 = 87.9 vs cap 87.8 measured) =
SUPERCHARGE; myomer heatFactor degradation slows you; overheat freezes
speed AND turn; damage slows (speedEffect carries 1-damage -- #75's
original premise was RIGHT and its 'falsified' close was wrong).

Restored in mechmppr.cpp, byte-grounded and better than the pre-removal
draft: MAX over the chain (was first-found), NO 1.0 clamp (the old clamp
ate the overdrive), turn-freeze included.  BTMyomersSpeedEffectOf
(unclamped) replaces the deleted BTMyomersDriveOf.

Verified live: healthy drive=1.0 (no speed change), collision-rattled
myomers drive 0.999 -> 0.892 -> 0.742 with demand scaling in lockstep
(61.5 -> 54.9 -> 45.7).

KB corrected in place (subsystems WAVE 6, pod-hardware manual audit
retraction, open-questions resolved) + NEW GOTCHA #21: export-gap
blindness -- 'no callers in the decomp' is not 'no callers in the
binary'; sweep the raw image for the operand pattern before declaring a
data path dead, and when a primary source disagrees with your decomp
sweep, suspect your evidence first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:54:11 -05:00
Joe DiPrimaandClaude Fable 5 4704ab3d41 #84: missiles ACCELERATE like the binary -- the pool flew at constant
rack-eject speed; the authored MissileThruster was never applied

The binary Missile hosts a MissileThruster subsystem: authored
acceleration for BurnTime seconds after the MuzzleVelocity eject.
Authored values decoded from BTL4.RES (type-15 missile model records,
reached from the AmmoBin's ammoModelFile via the type-1 MODELLIST
indirection): SRM 2.5s @ 600 u/s^2, LRM 10s @ 300 (climb 50), Streak
3s @ 300 (turn 360deg/s), NARC 10s @ 300; splash 30 all (port constant
was already right); authored drag ~0.001 = negligible.

The port pool flew every round at CONSTANT |MuzzleVelocity| -- SRMs 100
u/s, LRMs 30 u/s (10x slower than authored) -- the entirety of the
night-7 "missiles are very slow" report, and the driver of the
"explosion before the missiles arrive" perception (the salvo-lead
detonates while the slow spread rounds straggle in).

Fix: BTMissileThrustOf (mech4.cpp) lazily parses + caches the RES
thruster table (ModelList ids aliased to their type-15 member's
burn/accel); both launch paths (master FireWeapon + the replicant salvo
mirror -- peers see the same speed) resolve through the launcher's bin
and pass burn/accel into the pool; the advance integrates speed +=
accel*dt while burn remains, heading preserved.  Ballistic rounds
(autocannon/gauss) pass 0/0 -- unchanged.  Impact log now prints
v=/burnLeft= evidence.

Verified live (solo, BT_SPAWN_ENEMY + BT_AF_MISSILE): thrust table 8
entries cached; Black Hawk Streak resolve burn=3 accel=300; impacts at
v=277 u/s (was a constant 100).  Fun authenticity note: the Black
Hawk's "SRM6" fires strk (STREAK) ammo per its authored bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:41:39 -05:00
Joe DiPrimaandClaude Fable 5 ac7c46b87a #93: fix the end-of-round PerformAndWatch crash -- the STATIC projectile
pool held dangling entity pointers across round teardown

Root cause (disasm-pinned on the shipped 4.11.659 exe): RajelAran's crash
(call to 0x65676769 = ASCII 'igge', EBP-walk return btl4+0x2b91a) is the
tgt->Dispatch vtable call in BTUpdateProjectiles' NON-MECH impact branch
(mech4.cpp:1598; the site matches the disasm literally -- the 0x12/0x64
TakeDamageMessage ctor, the EntityID::Null ternary, the getenv gate after).

The trap: the mech branch is guarded by BTIsRegisteredMech(tgt) -- but at
round teardown a destroyed mech is DEREGISTERED, so a round still in
flight (the pool is a static array that outlives the round; missiles are
slow, #84) holding it as p.target now FAILS the mech check and falls into
the !BTIsRegisteredMech branch, which treats the freed mech as a cultural
icon and dispatches into freed memory.  The liveness check itself routed
the dangling pointer into the unguarded branch.  Freed heap reused by a
string -> vtable slot +0x10 read 'igge' -> call 0x65676769.  (The EBP
walker explains the stack shape: the faulting call pushed its return
address on ESP, but the walk reads [EBP+4] = PerformAndWatch's frame.)

Fix -- scrub at the source of truth:
- BTProjectilesDropEntity(e): every pool entry drops a dying entity from
  p.target/p.shooter; called from ~Mech and ~CulturalIcon (the only free
  paths for targetable entities).  Flight + impact code is already
  null-guarded on both fields.
- BTProjectilesClearAll(): kills all rounds at RunMissions exit (cross-
  mission hygiene for the static pool).

Verified: 6 short-mission cycles (45s missions with a spawned dummy +
autofire, mission expiring mid-combat) -- 0 exceptions, 6/6 clean
'RunMissions returned'.  The original crash was a heap-reuse race with no
deterministic repro; the scrub eliminates the dangling-pointer class by
construction.  (Bench note: blind autofire never launches missiles -- the
launcher needs a target lock -- so the exact in-flight race was not
re-created; the non-regression + the pinned mechanism carry the verdict.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:19:24 -05:00
Joe DiPrimaandClaude Fable 5 cf8618bcb9 myomer system COMPLETE -- the 'WAVE 6 mover cutover' was a phantom built on
a misattribution; the authentic couplings are all live

THE CORRECTION [T1]: @004b9550/@004b95b8 ('ConnectToMover'/'Disconnect
FromMover') are MechWeapon::ConfigureMappables/ChooseButton -- their +0x31C
is the weapon TriggerState and **(mech+0x128) is the CONTROLS MAPPER's
button roster (the MECHWEAP.CPP assert string + the trigger-edge detector
@004b9608 sit adjacent in the image).  The binary has NO dynamic myomer->
speed feed: AvailableOutput has exactly TWO callers in the whole image --
RegisterMaxOutput (assembly) and the SeekVoltageGraph sampler -- and
speedEffect@0x31C is a published GAUGE attribute.

The authentic myomer system (now fully live):
1. drive heat (#85, this morning);
2. VITAL death -- myomers are the only authored vital=1 subsystem
   (BTL4.RES res+0x48 scan): destroying them kills the mech via the #80
   vital-crit path (this is also the ram-death mechanism -- myomers are a
   0.35-weight collision-rattle target);
3. the assembly-time gait cap: RegisterMaxOutput @004b8ef0 raises
   mech+0x7A0 to AvailableOutput(top gear) = base x ~1.4284 -- now called
   (idempotent max, per tick; the 0x358 layout lock leaves no latch room)
   with real OwnerBaseSpeed/OwnerMaxSpeed bridges; the old per-frame
   reverseSpeedMax2 heal that would have clobbered it is garbage-guarded;
4. the ENG-page power curve at correct absolute scale (real base speed);
5. electrical sourcing (low gears run on a browned-out generator).

REMOVED (inventions): the mechmppr `speedDemand *= speedEffect` demand
multiplier + BTMyomersDriveOf + the Myomers ConnectToMover/MoverAttach
family.  Damaged-but-alive myomers heat faster and drop the power curve
but do NOT slow the mech (gitea #75's premise is falsified by the binary;
the real damage slowdown is the LEG-damage gimp gait).  Seek gears change
heat/sourcing/graph -- never speed.

Verified live: a mech at myomer dmg=0.234 (speedEffect attr 0.766)
beelines at full commanded speed with dmgGain=1.234 heat; 0 faults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:17:31 -05:00
Joe DiPrimaandClaude Fable 5 31552c0444 #88 part 2: the central coolant pull-through -- HeatSink::DrawCoolant was a
return-0 stub; the Reservoir BAR now drops when a damaged mech leaks

The user watched the coolant panel's level bar during the leak bench and it
never moved -- correctly: a leaking subsystem drained only its own loop,
because the slot-14 top-up (DrawCoolant) was stubbed.  Disassembled the real
base @0x4add00: the draw RECURSES UP the sink linkage, scaling by
coolantFlowScale@0x15C per hop, terminating at the Reservoir's supply
(@0x4af3b0 -- clamp to [0, coolantLevel], drain the tank, return granted --
already faithfully ported as Reservoir::DrawCoolant).  The binary's terminal
hop is the bank's slot-14 override @0x4ae8b0 resolving its reservoir
connection @0x1D8 (the port's 'helper' member -- not vestigial after all);
the port's existing Reservoir-ctor Attach + Reservoir override terminate the
same chain equivalently (the extra bank hop scales by ctor-default 1.0).

Implemented the base recursion (null-guarded -- the binary calls through the
resolved link unguarded; authored topology always links) + a BT_COOL_LOG
[resdraw] probe at the supply.

Verified live: a destroyed myomer's damage leak pulled the central tank
6.0 -> 5.58 over ~30s ([resdraw] granted lines) -- Reservoir/CoolantMass,
the cockpit coolant bar, now visibly drops as a damaged mech bleeds
coolant, and DeathReset refills it on respawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:47:55 -05:00
Joe DiPrimaandClaude Fable 5 a8a0042f28 #88: coolant leaks LIVE -- the #64 damageZone shadow was the single break
in the leak chain

HeatSink::UpdateCoolant prices the damage-driven leak from the QUALIFIED
engine member (Subsystem::damageZone @0xE0): coolantDraw = zoneDamage *
heatLoad, with the coolantActive hysteresis (= the ReportLeak attribute
the 19 authored leak watchers ride).  But the MechSubsystem ctor only
ever filled its re-declared SHADOW member (gitea #64, gotcha #1), so the
engine member stayed NULL forever, zoneDamage pinned 0, and a coolant
leak was STRUCTURALLY IMPOSSIBLE no matter how much damage landed --
Oracle's night-7 report exactly.  The #80 crit fix and the #83 collision
rattle write real subsystem-zone damage, but into an object the leak
reader could never see.

Fix: alias the ENGINE base member to the same zone in both MechSubsystem
ctors.  Every shadow reader keeps working (same object); the engine base
Subsystem::TakeDamage latent null-AV (#64 consequence 2) is disarmed;
the full #64 de-shadow sweep remains the long-term cleanup.

Verified live (solo, BT_COOL_LOG/BT_CRIT_LOG): collision rattle drove a
damaged Myomers to [cool] draw = dmg*heatLoad with the level draining;
sustained weapon fire rolled real crits ([critroll]) and produced 588
leak-pricing lines.  Authored routing extracted from BTL4.RES: collision
rattle targets HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35
(Condenser+Reservoir authored 0 for collisions); weapon crits select via
the zone's crit-entry list.  Known-remaining fidelity item (documented):
HeatSink::DrawCoolant (slot 14, central top-up) is still a return-0 TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:26:40 -05:00
Joe DiPrimaandClaude Fable 5 1570983fde #85: myomer drive heat LIVE -- the five Mech motion operands were return-0
shims, so the reconstructed integrator @004b8d18 accumulated zero forever

The integrator, its gates and the heat plumbing were all reconstructed and
running -- but OwnerVelocityMag/OwnerDriveMag/OwnerVelocityY/OwnerMotionGain/
OwnerMass were cross-family compile shims returning 0.0f, so the myomers
priced ratio^2 * damageGain * 0 every tick: ice cold at any seek (Oracle's
night-7 panel-confirmed report).

Operands re-grounded against the raw disasm + decomp and mapped to NAMED
members via a complete-Mech-TU bridge (BTMechMyomerMotionSample, mech4.cpp),
per the databinding rule:
  +0x1C4 vec  -> localVelocity.linearMotion
  +0x82C vec  -> localAcceleration.linearMotion (the authentic 15-ring
                 smoothed AccelerationLastFrame the port already maintains)
  +0x20C      -> moverMass (the collision divert's cell; the old "motion
                 gain" label was a misread)
  *(+0x250)   -> environment gravity (FUN_00421e2c: vy -= **(+0x250)/tick),
                 via GetEnvironment()->gravityConstant
So the heat physics reads: 0.005 * 1/2 m v^2 (kinetic work, per-tick/no-dt
in the binary -- kept verbatim, frame-rate note documented) + 0.2 * m g
|vy| dt (climb power) + 0.2 * m |v||a| dt (acceleration power), all scaled
by ratio^2 and (1 + zone damage).  Restored the binary's fabs(v.y) (the
draft had signed vy -- descending would have COOLED).

Authored tuning extracted from BTL4.RES (18 mech records, one shared
tuning): VelocityEfficiency=0.995, AccelerationEfficiency=0.8, gears
0.3/0.5/0.7/0.9999 rec idx 2; myomer thermal profile 77/1000/2000,
conductance 1.9e5, thermalMass 2.5e5.

Live-verified (solo arena, BT_MYO_LOG + BT_HEAT_LOG): standstill generates
~0; hard circling drove Myomers T 77 -> 1225 (past the 1000 degradation
line) and the coolant loop conducted it back to ~520 equilibrium on
slowing; damageGain live at 1.048 from grind rattle.

Known-inert remainder (filed in open-questions): the climb term reads g=0
because Mover::localEnvironment is never populated in the port
(EnvironmentZone res type 23 unwired); the mover feed (speedEffect ->
locomotion) stays WAVE 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:14:05 -05:00
Joe DiPrimaandClaude Fable 5 37dd7f9663 collision-pricing audit: restore the binary's crash economy -- two fixes
A. KNOCKDOWN PACING: the 0.4s gBlockCooldown contact-hysteresis is gone.
It suppressed the crash knockdown for as long as contact was held, letting
the kinematic drive re-slam the obstacle at full commanded speed EVERY
FRAME -- continuous full-price crash self-damage, a healthy 65-tonner dead
in ~2-3s of grinding (the night-7 SAURON ram death, 110 priced frames at
|v|~34.7).  The binary needs no guard (crash threshold @0x4ab178 read from
the exe = 0.0, dispatch per blocked frame is authentic): the bmp clip
zeroes the drive, and a pressed mech re-triggers the knockdown at v>6.3
long BEFORE its grind can price anything -- the type-0 divert's 0.5-pt
free floor needs v>~18.5 at 65t.  Wall grinding is free taps + paced
staggers; only genuine fast arrivals pay.

B. RAW RAM DISPATCH: the *0.001 "ram economy normalization" (2026-07-12)
predated the #83 type-0 divert and became a double-normalization -- the
victim's divert scale (~4e-5) compounded with it, so NO physically
possible ram could clear the 0.5-pt free floor: rams were a no-op against
the victim while the rammer's own un-scaled crash path rattled for real.
The binary dispatches raw [T1 @part_012:15324]; the divert IS the
normalizer.  Also un-starves cultural-icon crunches (trucks now crush on
contact per their authored armor, not after ~19 bumps).

Bench (scratchpad/night7/mp_rampricing.sh):
- wall leg: 150s full-throttle wall push = 125 paced knockdown binds
  (~1.2s cadence, iv2 40-43), 4767 crash frames ALL under the free floor,
  0 rattle, 0 deaths (pre-fix: dead in seconds).
- ram leg: BT_GOTO=enemy + BT_GOTO_STOP=2 rams the peer; raw amount
  arrives byte-identical on the victim (66239.1 tx == rx), victim prices
  2.55 pts internal rattle (first ram damage to a victim in the port's
  history), pressed follow-ups free, 0 deaths both nodes, 108 knockdowns
  -> exactly 108 type-5 records on the observer (no skating regression).

KB swept: combat-damage.md (the 1.3e6 moverMass mis-attribution corrected
to the measured 60-90k tonnage scale), locomotion.md, rendering.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 02:48:35 -05:00
Joe DiPrimaandClaude Fable 5 054c3f2eee #94: replicant un-wreck now rides the DEATH-STATE EXIT edge -- the zone
falling-edge latch was blind to zone-less (collision) deaths

The peer wreck lifecycle listened to two different signals: wreck ON came
from the replicated death explosion (unconditional), wreck OFF from a
damage-zone falling edge (wasWrecked latch, armed only when a zone crossed
>= 1.0 on the observer).  The #83 collision damage prices as internal
rattle and never moves a zone, so a fresh mech killed by ramming could
NEVER un-wreck on peers -- the live respawned mech drove around wearing
the hulk (night-7 field report: SAURON's Loki, screenshotted by both
observers at the exact minute the log analysis places the ram death).

MechDeathHandler::Tick now tracks prevMode and fires the rebuild + warp
when the replicated MovementMode leaves the death modes {2,9} -- only
Mech::Reset ever does that, and the state rides every record header, so
the trigger is cause-agnostic.  The zone falling edge just refreshes the
cache.  Side effect fixed for free: the peer respawn warp vortex now
plays for zone-less deaths too (same gate).

Bench (scratchpad/night7/mp_zoneless.sh + the new
BT_SELF_DAMAGE_TYPE=collision harness option, which dispatches type-0
through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked
(mode 9->1) with zero zone rises on the observer; explosive regression
leg 12/12 with zone destruction active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 02:10:04 -05:00
Joe DiPrimaandClaude Fable 5 e91d447405 #82 FINAL LAYER: the peer body-channel trn-lock -- a circling limper skated because the peer's turn-arm had no walk-demand yield and the trn speed exit read a dead replicant mapper cell
The master limped clean (218x gimp-cycle records, zero trn) -- a _ReturnAddress
trap on SetAnimationState(4) proved ALL peer trn arming came from the port's own
mech4 turn-step block, not the type-3 reader.  Two dead ends, one root:
- mech4 peer arm: gated on !wantsWalk (standSpeed < bodyTargetSpeed), exits trn
  when walking demand appears -- the leg twin's authentic precedence
  (part_012.c:12013, the Standing speed test outranks the turn test).
- mech2 body case 4: bspd reads bodyTargetSpeed on ReplicantInstance; the local
  mapper's speedDemand is a dead cell on a peer (reads 0 forever -> no exit).
Verified 2-node timeline: arena circle replicant 218x state-24 / 0x state-4
(was 116x trn churn), grass circle 235-clean, knockdowns bounded, 0 deaths.
Diagnostics kept: BT_TRNTRAP ra-trap, BT_ANIMIND_CAP, [gimpfeed], [replgimp]
extension.  KB: locomotion.md trn-lock entry + the dead-mapper-cell lesson.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRjrTQpJd6u9XyfnUbTB3v
2026-07-30 18:26:15 -05:00
Joe DiPrimaandClaude Fable 5 14ff3519ac #83: the COLLISION DIVERT -- type-0 damage prices as internal rattle, never armor
The crash self-damage reconstruction (9c83a46) exposed its missing second
half: the binary's TakeDamage hub DIVERTS damageType 0 (@0x4a0368) into
FUN_0049ffcc -- an export-gap distributor recovered by raw disasm -- so
collision damage NEVER reaches the zone/armor loop.  Without the divert the
mover's raw kinetic-energy figure (60-ton mech: ~1000+ per wall tap, 90k+ on
a hard slam) fell into the WEAPON loop: 'tapped a wall and died instantly'.

The distributor, byte-anchored: gate on the owning player's advancedDamage
copy (+0x268 -- the manual's 'splash/collision damage' technician setting);
scale = (2000/moverMass) / (100 km/h in u/s)^2 / (1 - elasticity^2)
(tbyte 1/3.6 @0x4a0148; mass @+0x20c; elasticity @+0x244); scaled < 0.5 is
FREE; else Round(2x) sub-hits of 0.5 land on random INTERNAL subsystems
(HeatSink family / Gyroscope / Torso, drawn by collisionCriticalHitWeight
@0x10C) via ApplyDamageAndMeasure (@0x4ac07c).  Bridges: family derivations
(heat/gyro/torso.cpp), BTPlayerAdvancedDamageOn (btplayer.cpp).

Verified: bhk1 wall test -- 94k slam = 4.2 rattle pts in 8 sub-hits, taps
free (0.049 < 0.5), armor untouched.  A/B: arena wall-grind now 0 deaths,
27 rattle events, the limper STAYS GIMPED and peers' replicants re-enter the
gimp cycle between bounded staggers; grass circling unchanged (198 replicant
gimp entries).  [crashdmg]/[colldmg] probes document the pricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:04:24 -05:00
Joe DiPrimaandClaude Fable 5 9c83a468db #82 ROOT FIX: reconstruct the crash SELF-DAMAGE -- wall-grinding now hurts,
the knockdown storm self-limits, peers keep the limp

The 'peers see a limping mech skating' report decomposed into a wall-grind
KNOCKDOWN STORM: a gimped mech held against a blocking solid re-crashes each
time its gg cycle rebuilds speed (the gg ceiling is authentically the clip's
natural speed -- bhk1 ~14.9 u/s, ceiling+divisor @0x544/0x548, loader formula
byte-matched -- so iv2 ~222 >> the 40.0 threshold @0x4ab184), and every
type-5 KNOCKDOWN broadcast floors all peers' replicants into knockdown/trn
churn: gliding + leg-lifts = skating.  Turning and crushable cars were
A/B-exonerated (grass circling: 198 replicant gimp entries, 17 crunches,
zero broadcasts).

What the binary has that the port lacked -- recovered from the EXPORT GAP by
raw disasm (scratchpad/night6/gap_4a9770.txt, @4aa89f-4aaab4) -- is the crash
branch's tail: fallDirection = worldToLocal(damageForce) (FUN_0040879c, M^T v,
un-normalized), fallScalar = -(fallDirection . localVelocity.linearMotion),
and a self-dispatched TakeDamageMessage{0x64, zone=-1, the engine-computed
collision Damage verbatim}.  A wall crash COSTS ARMOR, so the grind degrades
the mech instead of looping forever.  That dispatch was the long-standing
'DEFERRED' note in the response policy; now reconstructed.

Falsified en route (comments + KB corrected, do not revive): the stagger's
FUN_004a4c54(1)/(0x20) 'action-request flags' feed NO drive suppressor --
image-wide sweep + full gap disasm show word[this+0x18] is read only by the
net record emitter; the bmp clip applies NO root motion (adv=0 measured); the
'gimp cap 5.8' figure was the BACK-cycle stride printed under a misleading
label (now ggCapL/R; @0x52c has no binary consumer).

Verified (mp_gimpAB rigs): arena1 wall-grind now 4 bounded strikes, limper
dies of its crashes and respawns cleanly twice (START->RESET, no strand);
grass circling unchanged-clean (198 replicant gimp states, 0 knocks).
Rig: pid capture race fixed (a missed winpid left a stale node holding the
-net port -> null runs), per-config sweep added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:18:28 -05:00
Joe DiPrimaandClaude Fable 5 4e89baeff3 bench parity: local nodes now launch exactly like a player's shortcut
Local benches had drifted from the shipped launchers and every divergence
cost a debugging session:

  * no BT_START_INSIDE -> benches opened in the EXTERNAL CHASE camera while
    every player starts in the COCKPIT.  The first first-person look at a
    bench window was misread as a broken HUD.
  * novice bench eggs  -> every SHIPPED egg is expert; novice gates off the
    entire heat model, crits and jams, so bench combat was not field combat.
    (Verified: the aligned node now boots experience=3 simLive=1
    heatModelOn=1, where it used to boot 0/0/0.)
  * 1-LP affinity pins -> starved the gauge executive and produced a false
    "the comms panel never counts deaths" reading.  Two LPs per node keeps
    the documented single-box jitter fix without the starvation.

scratchpad/night6/bench_common.sh now owns the contract (bt_player_env,
bt_launch, bt_expert_egg, bt_novice_egg) copied verbatim from play_solo.bat,
with bt_assert_player_env warning if the shipped bat ever drifts.  All three
4-node benches source it.  The limp bench keeps a novice egg -- expert crits
the aimed leg and the mech turns-but-never-moves -- and now says so loudly.

Also: BT_SHOT_EVERY=<n> headless backbuffer capture (btl4vid.cpp), the tool
this was diagnosed with.  It must sit above the warp phase-0 early-out since
that fn is the per-frame alpha-pass hook.  OS screen-capture is off-limits:
a foreground-lock failure photographed the user's browser instead of the game.

KB: build-and-run.md gains the bench-parity rule + the capture diag;
cockpit-view.md gains the measured aspect/FOV finding (the surround's 2.05:1
view collapses vertical FOV to 31.5 deg vs the pod's 46.8, so fixed canopy
geometry covers 68.6% of the lower half -- NOT a regression: the shipped 643
binary reports identical geometry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:30:30 -05:00
Joe DiPrimaandClaude Fable 5 82f1ea1183 bench: symbolic leg-zone targeting, gimp-follow/goto-release, legal egg colors
- BT_SELF_DAMAGE_ZONE=leg resolves the mech's actual leg zone via the
  streamed LegDamageZone flags (new Mech__DamageZone::IsLegZone) -- numeric
  zone ids are per-mech-type and a wrong one aims at a vital (killed the
  limp bench's mech instead of gimping it: 2 ticks, death cycle, respawn
  healed, harness spent -- 3 minutes of healthy circling).
- BT_GOTO=gimp: beeline mode that prefers the nearest LIMPING peer (gimp
  half-states 3/4 ride the one-cell in every update record); stands and
  waits while nobody limps.  BT_GOTO_RELEASE=1 hands the mech back to the
  keyboard once the beeline arrives.
- MP.EGG/MP4.EGG/MP4L.EGG: color=Red and color=Blue are PATCH values, not
  colors (RES vehicletable colors: Black/Brown/Crimson/Green/Grey/Tan/
  White) -- the two pilots carrying them rendered as untextured pastel
  mechs.  Boreas Red->Crimson, Caicias Blue->Grey.  MP4L.EGG = the 4-pilot
  bench egg on the open grass map.

4-node limp verification (user-eyeballed, all instances): the gimped mech
step-drags with the authentic limp on every observer -- no skating (#82
holds in the 4-node overlap case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:32:26 -05:00
Joe DiPrimaandClaude Fable 5 494f32efb4 #45 ROOT CAUSE + FIX: the gauge executive starves in MP -- panels froze at healthy fps
Instruments repaint via the background task pump, which authentically runs
ONLY in the frame's leftover time with a floor of ONE pump per frame; the
gauge renderer is 1 of ~7 round-robin tasks and each turn advances ONE gauge
of ~140 active, with the rate mask advancing once per full sweep.  On a busy
MP mission the foreground eats the whole frame budget, the floor becomes the
norm, and the whole instrument stack rotates once in MINUTES at perfect fps:
comms-panel K/D stuck at 0, recharge tickers frozen, while the tallies and
their replication underneath were exactly right.  Measured: 4-node bench at
70-90 fps gave the PilotList ~2 Execute turns in six minutes.

Fix, both env-tunable, authentic behavior restorable:
  BT_BG_MIN      (APPMGR.cpp, default 32, 0=authentic)  minimum background
                 pumps per frame regardless of slack;
  BT_GAUGE_BATCH (GAUGREND.cpp, default 32, 1=authentic) gauges advanced per
                 gauge-renderer turn (a visit is only a rate-mask check
                 unless the gauge is due).
Verified 4-node self-damage bench: sweeps 0.006/s -> 18-20/s, PilotList ~10
Exec/s, ALL FOUR panels tracked every death live (0->11) within ~1s, bg cost
2-4 ms/frame, respawn ledger clean (40 cycles, 0 swallow / 0 mismatch).

Also: BT_PERF now reports gaugeTurns/sweeps/active alongside bgTasks;
BT_AF_PERIOD now throttles the missile autofire group too (unthrottled spam
trips the documented FailureHeat all-weapons brick, which froze run 1 of the
cross-fire bench).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:22:47 -05:00
Joe DiPrimaandClaude Fable 5 bfa1b04990 #81 follow-ups: peer warp must not stomp the POV vortex; panel draw probe; mech3 ghost symbols
Two field observations from watching the 4-node stress, both run down:

1. "A respawn happened without the blue vortex" -- REAL.  The translocation
   effect is ONE global slot shared by the POV lifecycle and the world-anchored
   peer sphere (a port extension).  A peer's un-wreck arriving while the local
   pilot's own collapse/wait/expand was in flight overwrote gWarpPhase/gWarpPOV
   and killed the POV vortex.  Invisible before the respawn fix only because
   overlapping respawns barely existed; now they are routine.  Fix: the POV
   lifecycle owns the slot -- BTStartWarpEffect self-skips while it is active
   ([tloc] peer warp SKIPPED).  2-node bench: 20/20 POV collapse+expand pairs,
   9 peer spheres played, 11 correctly skipped; solo: 8/8 pairs unchanged.

2. "Comms panel counted no deaths" -- panel machinery CORRECT; the 4-node
   zeros were the bench's own CPU crush (4 core-pinned instances starved the
   PilotList to <0.6 Hz, so rows redrew minutes-stale).  Added the arbiter:
   [score] panel DRAW slot/pilot/kills/deaths edge log (BT_SCORE_LOG) -- the
   2-node rerun drew 0->9 / 0->11 live on both nodes, local AND replicated
   (SBMIRROR rows confirm owner->replicant tally flow on all 4 stress nodes).

Also: the mech3 offline-authoring stubs declared every <Subsystem>::DefaultData
as Entity__SharedData while the real statics are Simulation__SharedData (this
engine derives Entity FROM Simulation) -- ~20 ghost symbols /FORCE silently
resolved to garbage.  DefaultData half fixed (SubsystemDefaultData now returns
the true common base Simulation::SharedData); the CreateStreamedSubsystem stub
signatures remain wrong (nested SubsystemResource* + ResourceFile*), are
cold (no callers), and are tracked in open-questions + gotchas §6 stub-typedef
corollary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:10:29 -05:00
Joe DiPrimaandClaude Fable 5 a357dc4265 #81 GHOST MECH FIXED: release the death latch + stop the duplicate VehicleDead
Two defects that were masking each other, both now fixed and benched.

1) THE LATCH NEVER RELEASED ON FAILURE.  The binary's FUN_004c012c tail is
   Post(...) ; *(this+0x290)=0 ; *(this+0x258)=0   (part_013.c:10519-10523).
   We had the Post and the suppressConsole and were missing the middle
   instruction, so deathPending cleared only on SUCCESS paths.  One failed
   respawn latched the pilot for the whole mission: every later death hit the
   dedup and was SWALLOWED, so the cycle could never restart -- a transient
   hiccup became a PERMANENT ghost (dead, un-Reset, still driveable, a burning
   wreck on every peer that sinks after ~18s and can never be drawn again).
   Binary evidence: +0x290 is written in exactly THREE places in all of
   BTL4OPT.EXE (0x0b75fb, 0x0bffe3, 0x0c0a05) and all three store a ZEROED
   register; there is no write of 1 -- or any non-zero, in any instruction form
   -- anywhere.  The dedup gate itself IS authentic (@004c05c4 does
   mov edx,[ebx+0x290]; test edx,edx; jne ret), so it is kept.

2) VehicleDeadMessage WAS DISPATCHED TWICE PER DEATH.  BTPostKillScore
   (btplayer.cpp:2263) sent a second one "to credit a death", but that message
   is the RESPAWN-CYCLE TRIGGER, not a scoreboard increment, and the tally is
   already credited by the handler's ++deathTally (:538).  Both fire inside the
   same death transition (BTPostKillScore at mech4.cpp:2006, the hardened
   authentic notify at :2110), so they were always paired.  Removed; the kill
   credit above it is untouched (it correctly uses a ScoreMessage).

THEY HID EACH OTHER: the duplicate made the latch look necessary, and the latch
made the duplicate invisible.  Every "death ... SWALLOWED" warning in the field
logs was just the latch deduping our own duplicate -- 8 of 8 deaths, a 100% base
rate, which is exactly why it correlated with nothing when tested.  Fixing
either alone makes things visibly worse (the first bench of fix 1 alone produced
a DOUBLE cycle: deathCount double-incremented, cycle 1's re-post gone stale and
tripping the drop-zone MISMATCH).  That is why earlier passes at #57/#55 kept
adding clear-sites instead of finding the root; the binary broke the tie.

VERIFIED
  solo: 17 consecutive death/respawn cycles, every one START->RESET,
        0 swallowed / 0 mismatch / 0 crash.  Pre-fix this strands permanently
        after cycle 1.
  MP  : two nodes over a real network path with cross-machine drop-zone replies
        (each node's request is answered by the OTHER machine) -- A 11 cycles,
        B 12, 0 swallowed / 0 mismatch / 0 discarded / 0 crash.
  harness: BT_SELF_DAMAGE_REPEAT=1 re-arms the self-damage bench after respawn
        so multiple cycles can be driven (a one-death harness can never
        exercise this fix).  scratchpad/night6/mp_ghost.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:49:27 -05:00
Joe DiPrimaandClaude Fable 5 0dbdad17f3 #81 ghost: light the SILENT DISCARD -- the only path a respawn can vanish on
Walked the respawn handshake end to end and measured the assumptions.  Two
eliminations and one find:

ELIMINATED: the engine hunt re-posts to itself every 2s forever while the mech
is dead (PLAYER.cpp:325-327; its only state-based exit never fires, see below),
and the DropZone re-grants the SAME slot to a repeat request from the same
requester+deathCount even while busy (DROPZONE.cpp:182-194).  So neither a
transient "no slot" nor a single lost reply can strand a respawn -- the request
keeps being re-sent and re-granted.  A permanent strand needs a permanent cause.

THE FIND: BTPlayer::DropZoneReplyMessageHandler ends
  if (!playerVehicle) ... else if (deathCount == message->deathCount) ... else { return; }
and that last branch was a BARE RETURN WITH NO LOG.  The drop zone grants a
spot, replies, the numbers disagree, the reply is dropped, deathPending stays
latched, the mech is never Reset -> permanent ghost, zero evidence.  That is
why 6 of 8 field cycles stranded silently.  Now always-on, printing the
DIRECTION of the mismatch: msgDeath < ours = genuinely stale (dropping is
right); msgDeath > ours = our counter is behind and we just threw away a LIVE
respawn -> points straight at #45 (the death tally does not replicate).
Deliberately NOT auto-recovered: guessing would be a stand-in, and the wrong
guess resets a mech that is still alive.

LANDMINE DOCUMENTED IN CODE: Set_Alarm_Level is an empty stub (btstubs.cpp:87),
so the death path's Set_Alarm_Level(this+0x2c,1) and the reply's (+0x2c,2) are
no-ops.  Their values decode against Player's enum as DropZoneAcquiredState(1)
and VehicleTranslocatedState(2), which makes "these should obviously be
SetSimulationState() calls" both attractive and CATASTROPHIC: the engine hunt is
gated on GetSimulationState() != DropZoneAcquiredState, so setting 1 on death
would stop AssignDropZone ever being dispatched and ghost EVERY pilot.  Measured
our simulationState at 0x24 (not 0x2c) and the write leaves it 0; in the binary
+0x2c is the Simulation-base alarm (the field the mech side calls graphicAlarm,
@0x4ac126 "owner alarm+0x2C -> level 9"), which our layout models only on Mech.

Verified solo: a healthy death+respawn logs the grant and the RESET and emits
ZERO discard/gate-off lines (no false positives).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 01:08:01 -05:00
Joe DiPrimaandClaude Fable 5 ba6756c40a drop-zone / respawn instrumentation: make the GHOST MECH visible in field logs
The ghost (a player who dies, never respawns, stays dead-but-driveable and is a
wreck on every peer) traces to a respawn stalling on drop-zone acquisition:
DropZone::AssignDropZoneMessageHandler grants a slot only if IsAvailable(), and
when none is free it reposts to ITSELF every 0.1s at MaxEventPriority and never
replies -- so deathPending is never cleared.  This whole subsystem was DARK: not
one drop-zone line in a full night of field logs.

ALWAYS-ON (no env gate -- rare, catastrophic, field-only, same rationale as the
#57/#59 guards; volume bounded and rate-limited):
  [dz] POOL   -- slots + downTime + proximity radius, once per mission
  [dz] GRANTED-- one per respawn, with the WAIT time; tags (GHOST RECOVERED)
                 when a long-stalled request finally lands (what testers see as
                 "it fixed itself")
  [dz] STALL / GHOST LIKELY -- escalating, per-waiter rate-limited, and it names
                 WHY each slot is busy (age + last user), which discriminates
                 cooldown saturation from proximity blocking -- different fixes
  [dzreq]     -- the REQUESTER half: re-try count for this death, and msgDeath vs
                 our own deathCount.  The engine gates the hunt on those being
                 equal AND the DropZone resend net is keyed on the same value, so
                 a mismatch breaks both silently (cf #45, tally replication).
BT_DROPZONE_LOG=1 adds the verbose per-request slot dump for bench work.

First run already overturned a theory: slots=8, so an 8-slot pool cannot be
cooldown-starved by 5 players (max 5 on cooldown at once) -- the shared
dropzone=one in eggmodel.py is NOT the bottleneck.  Suspicion moves to the
handshake (lost reply / deathCount mismatch).

Verified with NO env vars set, exactly as a tester runs it: 4 probe lines for a
full death+respawn cycle, all in the normal day log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:13:14 -05:00
Joe DiPrimaandClaude Fable 5 e1c15eb806 #82 peers see a limping mech SKATING: the mech erased its own gimp cell every frame
The binary's one mech+0x40 IS Simulation::simulationState, which rides EVERY
update record header -- so in 1995 a peer's replicant learned the gimp level on
every packet and its gait limped with no gimp-specific replication anywhere.
The port's Mech::PerformAndWatch wrote SetMovementMode(1) every frame ("ground,
non-death, non-airborne"), which erased the level once it was mirrored in for
the warning voice (#78): the wire carried 1, bystanders walked while sliding at
limp speed, and the voice sequence restarted on every damage event (1->4 edge
per tick) instead of announcing once.

- mech4: that per-frame write now writes the AUTHORITATIVE level
  (gimped ? 3/4 : 1) via a new alarm-only bridge BTMechGimpAlarmLevel -- which
  deliberately never consults the cell it feeds, so a respawn-cleared alarm
  cannot re-latch stale gimp out of it.
- BTMechGimpLevel: falls back to the replicated cell, with ONE-CELL precedence
  (a fall/death/limbo state wins, so the normal drivers and their death latch
  run -- what the binary's single cell enforced structurally).
- Reverted the #78 record guard: on a replicant the master's records are
  authoritative, so pinning 3/4 against them would keep a peer limping through
  a respawn.  Fixed at the writer instead.
- [simstomp] trap now scoped to the watched mech with a module-relative return
  address (symcrash-able) -- that is what named the writer.
- Also learned + recorded: zone damage levels replicate only when the EXPLOSION
  TABLE's tier is crossed (peer measured at 0.428 vs master 0.857), so peer-side
  damage state must never be inferred from them.

Verified two-node (scratchpad/night6/mp_skate.sh): the observer's replicant gets
sim=4 and its gait runs 23 -> 25 (wgr entry -> ggl limp cycle).  Field clue that
cracked it: "after respawning a peer DID see the limp" (epilectrik/SAURON).
KB: locomotion.md + gotcha #25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:29:03 -05:00
Joe DiPrimaandClaude Fable 5 98907f45af hotfix: null-damageZone guard on the crit path (field crash, build 641, Conn Man's Owens)
First field session with real crits (#80) found a weapon subsystem with a
NULL damageZone: Mech__DamageZone::CriticalHit -> ApplyDamageAndMeasure ->
MechWeapon::TakeDamage +0xf (the engine base derefs the zone unguarded --
every 1995 subsystem shipped with one).  Stack symbolized from the field log;
the path was unreachable before tonight because ApplyDamageAndMeasure was a
stub until #80.

Guards at both choke points; a one-shot [crit] log NAMES the zoneless
subsystem when hit so the root cause (build that subsystem's zone) can be
fixed from the next field log.  Owens crit bench: no crash, guard inert on
zoned subsystems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:41:02 -05:00
Joe DiPrimaandClaude Fable 5 5b19cd6c3f demo/bench harness polish: BT_SELF_DAMAGE_DELAY + two harness bug fixes + the OBS limp demo bat
- BT_SELF_DAMAGE_DELAY=<s>: hold the harness off for n seconds (capture demos:
  healthy walking first).  Fixed its underflow bug (the countdown crossed below
  zero, which is also the "read the env" sentinel -- the delay re-armed forever).
- The one-death latch was fed by EVERY mech's update: a mission that generated
  any destroyed mech entity tripped it at frame one and the harness silently
  never fired (random per mission roll).  Now viewpoint-mech only.
- run\limpdemo.cmd: the OBS capture demo -- 30 s healthy auto-walk, then the
  leg crosses half on the final damage tick: klaxon-klaxon-"reverse disabled"
  + the limp, then unlimited limp footage (waypoint 8000,8000).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:47:20 -05:00
Joe DiPrimaandClaude Fable 5 1671b7d4b2 the REVERSE DISABLED voice (#78): SimulationState 3/4 IS the trigger -- mirror the gimp level into the engine cell
User + old-timers were right; the earlier "no voice exists" verdict was wrong.
The authored mech audio has state watchers on Entity.SimulationState==3/4
(the binary's one-cell mech+0x40) that start sequence notes 29,16,40 -- two
klaxon hits then Warnings01 zone 8 (key 40-41) = the "reverse disabled"
voice line the testers remember.  The port's cell split (graphicAlarm vs
engine simulationState, gotcha #23) meant the trigger value never arrived.

- mechdmg: mirror gimp 3/4 into SetSimulationState at the leg-half crossing
  (guarded: never stomps disabled/fall/dead states).  Voice verified playing
  end-to-end on the bench: SetupPatch bank2 patch113 note=40 ->
  Warnings01_z7.wav.
- mech.cpp: BT_GIMP_SAFE_BASE_READ on all seven Simulation::ReadUpdateRecord
  sites -- gimp is monotonic per life; a record captured pre-gimp must not
  stomp the cell (the loopback otherwise perpetuates the stale value and
  restarts the warning on every damage event).
- diagnostics (all env-gated): [statefire]/[startreq]/[animind]/[gimp-sim]/
  [simstomp]/[indstomp] + StateIndicator::DebugAudioWatcherCount + raised
  spatial-log caps.  These traced the whole chain and PROVED no SetState
  path stomps the cell.

OPEN (follow-up): a RAW writer (bypasses SetState entirely; invisible to the
indicator-level trap) resets the cell between damage events -- under the
bench's 1 Hz metronome harness it restarted the sequence before the 1.8 s
voice note; sporadic real-play damage is unaffected (one edge -> full
sequence).  Needs a cdb write-watchpoint session; candidates: a recon raw
+0x2c-equivalent write or a struct copy spanning it.

Also decoded en route: the AnimationState triggers on the limp states play
EngineShiftRev01 (the downshift foley) -- working, and NOT the voice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:22:01 -05:00
Joe DiPrimaandClaude Fable 5 95cf49d1b6 gimped turn-in-place (#78 field find): arm the trn step in the gimp leg driver
Field test: a gimped mech pivoted as a rotating statue.  The binary's
turn dispatcher (FUN_004a9b5c, master perf) runs OUTSIDE the driver
selection so it arms trn for gimped mechs too -- 71f4's case 4 exists to
advance it.  The port relocated that dispatcher into the NORMAL leg
driver's Standing case, which stops running when the gimp driver takes
over.  Mirror the arming (same gates + lockstep body arm) into
AdvanceLegAnimationGimp's Standing case.  No gimp-turn clip exists in the
RES -- a limping mech step-turns with the normal trn clip, authentic.

Field-verified: stepping pivots both directions while gimped, heading
sweeping, clean re-entry into the wgr/ggl limp on throttle-up.  [T2]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:44:13 -05:00
Joe DiPrimaandClaude Fable 5 36f68718c2 the visible limp (#78): there was never a jump-jet clip set -- the 'Airborne' drivers ARE the gimp gait machines
The port had FUN_004a5bf8/71f4 fully reconstructed as AdvanceBody/Leg-
AnimationAirborne, gated on (MovementMode()==3||4) && jumpCapable@0x580 and
believed dead ("the test mech never jumps").  The binary says otherwise:
mech+0x40 in that gate is the graphicAlarm LEVEL (3=left-leg gimp, 4=right)
and +0x580 is hasGimpClips, set by the conditional loader block that probes
'wgl' and fills clip slots 22-27 (wgl/wgr/ggr/ggl/gsl/gsr) plus the four
measurements at 0x53c-0x548 (wg entry strides = speed caps, gg cycle strides).
Renamed the five jump* members + both drivers accordingly.

New: GimpBodyClipFinished @004a6344 / GimpLegClipFinished @004a7970 -- the
gimp transition machines, branched from the normal finished-callbacks.  Phase-
correct limp entry (left-gimp enters 0x16/wgl only from a RIGHT step, right-
gimp 0x17/wgr from a LEFT step), gg cycles at gimp cadence, gs exits, and the
demand clamp to the gimped side's speed cap (leg cb writes it back into the
mapper -- the binary's authentic slowdown; the T3 x0.5 stand-in in mechmppr is
retired, BT_GIMP_SPEED now defaults 1.0).  The binary's gimp machines have no
reverse entry -- the "reverse disabled" behavior is now binary-proven.

Reviving the dead drivers replayed two port-glue bugs (gotcha #24): the raw
*(controlSource) mapper read (null -> crash at first engagement) and the
missing alarm->member state re-sync (machine pinned in one run state).  Both
fixed; bench harness gained BT_SELF_DAMAGE_TICKS=<n> to hold a zone past
LegHalfStructure without destroying it.

Bench-verified (madcat, novice, zone 16): crossing -> alarm 4 -> wgr entry
from a left step -> 8k+ frames stable in the ggl limp cycle at cadence 14.77
(vs 18.5 walk / 22+ run) with raw demand still 50.  [T2]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:49:39 -05:00
Joe DiPrimaandClaude Fable 5 520f6eecd3 myomer damage reaches the wheels, legs gimp at half structure, reverse refuses -- and an ODR trap unmasked (#75/#78)
The speed-demand site (MechControlsMapper::InterpretControls) now applies the
drive scale the mover's feed roster applied in 1995: speedDemand multiplies by
the myomers' live speedEffect (gear ratio, thermal curve, 1 - zone damage, via
the BTMyomersDriveOf bridge) and, while GIMPED, by 0.5 [T3: VGL Lynx's
"roughly 50%", BT_GIMP_SPEED overrides]. The gimp states were already being
raised -- mechdmg sets graphicAlarm 3 (left) / 4 (right) when a LegDamageZone-
flagged zone crosses half structure -- but nothing downstream ever saw them.
While gimped, reverse input is refused ("reverse disabled"), matching the
old-timers' account; the pod's audio cue rides the alarm's watchers.

Bench, one trajectory, arithmetically exact: a deterministic right-leg ramp
(the new BT_SELF_DAMAGE_ZONE harness) crosses 0.5 and the demand goes
44.837 -> 6.726 = 44.837 x 0.5 (gimp) x 0.3 (a crit-chewed myomers from the
same ramp -- the #80 crits composing with #75's scale, unprompted).

The reason "nothing downstream ever saw them" is the real find of the night,
now gotcha #23: AlarmIndicator is typedef'd to DIFFERENT TYPES per header
family -- mech.hpp says ReconAlarm (4 bytes), heat.hpp says GaugeAlarm (0x54)
-- so Mech::graphicAlarm and EVERY member after it sit at different offsets
depending on a TU's include order. mechdmg wrote level 4 and read it back;
mechmppr read 0 from the same object, same expression. No compiler error can
catch it: each TU only ever sees one definition. Until the split is audited,
cross-TU reads of the gimp level go through BTMechGimpLevel (compiled in
mechdmg's TU) -- and the same split-brain explains why the port carries the
binary's ONE movementMode cell as two live members (engine simulationState vs
graphicAlarm level) that never meet.

Also landed en route: the Myomers un-powered self-repair observed healing in
the field logs at exactly 0.011 x the authored Explosive scale per tick --
the 2026-07-29 reversal confirmed live; and Mech message 0x15 "RealMaxSpeed"
raw-decoded (@0x49f604: sets mech+0x7a0 from the message unless the +0x7a4
latch holds -- a console-tunable top speed).

Open on #78, documented in locomotion.md: the Gimp animation clips (authored
keys in the binary's model-record parser; mech2's state enum vs mech3's
reverse-fix disagree about slots 0x12-0x17 -- reconcile before wiring) and
the audio-cue binding.

Diags: BT_SELF_DAMAGE_ZONE, BT_DRIVE_LOG, BT_GIMP_SPEED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:29:36 -05:00
Joe DiPrimaandClaude Fable 5 8a99972f22 aimed fire strikes the part under the crosshair: the per-segment pick (#73)
The port's target pick was a whole-mech bounding-box slab test, and every hit
-- aimed or not -- dispatched zone -1 into the victim's cylinder lottery. The
recovered 1995 model (the division-card scene intersection) struck a SEGMENT
and credited that segment's own damage zone. This is the port's equivalent.

At render-tree build, each segment's draw object and its PrimaryDamageZone --
authored per segment in the skeleton stream, read by JMOVER.cpp:290 -- are
recorded in MechRenderTree::segPick. The pick (BTL4VideoRenderer::
MechSegmentPick) ray-tests the per-segment bounding spheres on the live posed
skeleton, using the draw-cached mLocalToWorld (at most one frame stale, fine
for aiming).

Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
smallest radius wins, normalized-distance tie-break. Both obvious rules were
measured failing the same way before this one: the torso mesh's sphere
(r~4.1 on the MadCat, vs shoulders at r~1.0) envelops nearly the whole mech,
so its front face is nearest for any aim AND any near-body ray normalizes to
~0 against it. Limb spheres nest inside the envelope; smallest-pierced picks
the most specific part on the aim line, and the torso wins only when no limb
is threaded -- the per-part semantic the pod's mesh test produced.

mech4.cpp tries the segment pick per candidate; the box PickRayHit survives
only as the fallback (no tree yet, wrecked, spectator), still carrying zone
-1 into the lottery, and a structure occlusion clears the zone. The winner's
zone rides MECH_TARGET_SUBIDX + targetReticle.targetDamageZone into
SendDamageMessage, so aimed hits now dispatch a real zone; the victim's
handler applies it directly (bursts 2+ still re-lottery, authentic per the
recovered @0x4a0230 loop).

Bench, the same L/C/R sweep that exposed the bug: aiming left now lands
36/41 hits on zone 2 = jointlshoulder -- the left arm -- with a 0.30 thread
score, where the same aim was a 6-way lottery spray before. The MadCat's
authored segment->zone map is rich (shoulders 2/9, guns 6/17, hip 1, six leg
zones, torso 0). Known approximations, flagged for field verification:
sphere bounds rather than triangles, and a torso-envelope graze credits the
torso where the pod's exact mesh test would have missed into air.

The field protocol is the one the testers already ran on night 6: stationary
mechs, short range, fire only at one arm -- the paper doll should now damage
THAT arm.

Diag: BT_PICK_LOG ([segpick] map at build, [pickwin] per pick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:04:11 -05:00
Joe DiPrimaandClaude Fable 5 a5fb96ae96 the crit system, complete: two gap functions recovered, the dead sink revived, and a wrong verdict reversed (#80)
The whole critical-hit pipeline was dark, three layers deep, and one of those
layers had fooled us into a false conclusion about the 1995 binary itself.

LAYER 1 -- the trigger, recovered from the un-exported gap. The Mech MESSAGE
TABLE at 0x50bdf8 ({id, name, handler} rows) names the real
Mech::TakeDamageMessageHandler at 0x4a0230 -- message 0x12 "TakeDamage" --
plus seven sibling handlers (PlayerLink, RealMaxSpeed, BalanceCoolant,
Set/ClearBurningState, EjectPilot, DuckRequest). Inside it, the crit chance
at 0x4a0164: p = clamp(0.7 * damageLevel^2 + 0.01, 0..1), gated on the
player's simLive flag (+0x25c -- novice never crits), rolled PER BURST on the
current zone, skipping a zone already burning. Chance is ~1% on fresh armour,
~18% at half-stripped, ~58% at 90% -- crits arrive exactly as armour fails.

The handler's application loop replaces the engine base's single call, which
ignored burstCount entirely (multi-burst damage under-applied (burst-1)x).
Faithful shape: per burst, crit-roll -> CriticalHit @0049ccc4 (which routes
half the amount through the armour internally and picks ONE critical
subsystem by criticalWeight) else zone->TakeDamage -- then RE-RUN the
cylinder lottery from the impact point for the next burst, stopping early
once the mech is disabled. Multi-burst damage sprays across zones by design.

LAYER 2 -- the sink. MechSubsystem::TakeDamage was an empty btstubs stand-in;
the real body is at 0x4ac0bc (CLASSMAP had that address mislabeled
"HandleMessage"): zone damage, then on level >= 1.0 the Destroyed alarm, the
PrintState gate, the 1.0 pin, and -- for a vital subsystem -- the owner
mech's graphicAlarm to level 9, the same fall/death level the leg path
raises. That is the #28 vital-subsystem kill machinery, now real.

LAYER 3 -- the one that rewrites yesterday. The subsystem ctor DID copy
armour points + per-type scales into the private zone -- through the
ReconDamageZone PROXY, whose fields sit at struct offsets +4/+8, not the
binary's +0x140/+0x144. The floats landed on the engine object's header and
the real damageScale[] stayed zero. The 2026-07-28 experiment that "proved"
subsystem zones cannot be damaged -- and that the Myomers un-powered
self-repair was dead code in the original -- was measuring exactly this port
bug. Both verdicts reversed: the binary ctor (0x4ac7bb) initializes the zone
from the resource keys WeaponDamagePoints (required) + CriticalHitScoreBonus
(required) + Collision/Ballistic/Explosive/Laser/EnergyDamagePoints, none of
which the CSS parsed. Now parsed (with the binary's own error strings), and
the ctor writes the engine's NAMED members -- layout-parity holds, so they
land on +0x140/+0x144 faithfully. The Myomers repair branch is LIVE, in 1995
and here. KB corrected and swept (combat-damage, subsystems WAVE 6,
myomers.cpp, CLASSMAP).

Live-verified twice: [subarmor] prints real parsed scales for every subsystem
at spawn (HeatSink pts=10 scale=0.1x5, Condensers pts=5 scale=0.2x5, ...);
[critroll] landed full-chain crits in both runs (zone -> weighted pick ->
subsystem's own zone driven to 1.0 -> Destroyed); mech death/respawn and the
ammo gates un-regressed; zero crashes/asserts. Honest gaps: burst>1 spraying
is transcribed but not yet exercised live (self-damage fires burst=1), the
damageType==0 COLLISION divert (@0x49ffcc) is documented-not-reconstructed,
and the id-0x16 damage/kill report messages to the players (the authentic
stats plumbing, decoded to field level in the KB) are deferred to the #45
work.

Diags: BT_CRIT_LOG ([subarmor] + [critroll]), the existing BT_DMG_LOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:08:33 -05:00
Joe DiPrimaandClaude Fable 5 819772f974 the rest of the Myomers wrapper: two live outputs recovered, and one branch that never fires
Follow-on to b70654d, which chained the base sim and stopped there. Decoding
the remaining 0x17c bytes of @004b8b9c turned up four more blocks the port had
dropped along with it. In binary order the registered Performance is:

  @004b8bab  chain PoweredSubsystemSimulation                  (fixed in b70654d)
  @004b8bb9  un-powered self-repair of this myomer's own zone   (dead -- see below)
  @004b8c1e  republish outputVoltage@0x344 from the source
  @004b8c5a  republish speedEffect@0x31C, the drive fed to the mover
  @004b8ceb  run the inner integrator, ONLY when outputVoltage > 0

speedEffect is the interesting one. AvailableOutput scales by the Mech base
speed and the wrapper divides by it again, so the two cancel and what lands in
+0x31C is a 0..1 FRACTION of full drive carrying the gear ratio, the thermal
degradation curve and the accumulated zone damage. Neither it nor outputVoltage
was ever written before: outputVoltage sat wherever the ctor left it and
speedEffect stayed pinned at its ctor 1.0f.

Un-stubbed DamageStructureLevel() while in here. It was `return 0.0f`, which
held AvailableOutput's (1 - damage) factor at 1, so a shot-up myomer drove
exactly as well as a fresh one. Routed to the base's GetSubsystemDamageLevel()
bridge, the same cell sensor.cpp:312 already reads. Measured dmg=0.6 -> speed=0.4.

@004b8bb9 does not work, and did not work in 1995 either. It builds a Damage
(Explosive, amount 0xbc343958 ~= -0.011f, impactPoint from owner+0x100, burst 1)
and calls the ZONE's TakeDamage -- vtable +0x18, not the subsystem's +0x24 -- so
the plain `damageLevel += amount * damageScale[type]`. Read on its own that is
"a myomer you power down slowly heals". But a subsystem's private zone is built
by the 2-arg `new DamageZone(this, 0)`, and DAMAGE.cpp:187-190 zeroes all five
damageScale entries; Reset never touches them and the only other writer is
Mech__DamageZone, the mech's streamed zones. The sum is always `+= amount * 0`.
Seeded a zone to 0.6, held it at NoVoltage for ~1500 ticks: damageLevel never
moved. Reconstructed and deliberately not "fixed" -- a working repair here would
be behavior we invented. The crit path reaches subsystem damage by writing
damageLevel directly, which is why subsystems still die.

That generalises, so it is written up in context/combat-damage.md on its own:
you cannot damage a subsystem's own zone through DamageZone::TakeDamage at all.
Anything reconstructed later that means to hurt a subsystem has to go the way
the crit path goes, or it will silently do nothing.

Verified, self-damage runs across two death/respawn cycles:
  torso    96/96 samples elec=4, zero dips
  myomers  96/97 elec=4 (the odd one is the first tick, before the machine runs)
  healthy  outV=10000 speed=1     un-powered  outV=0 speed=0

BT_MYOMERS_LOG probes the four outputs; BT_MYOMERS_REPAIR_TEST=<level> seeds the
zone and drops the subsystem into Manual so the repair branch can be watched
without the AutoConnect hunt restoring power a frame later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:06:56 -05:00
Joe DiPrimaandClaude Fable 5 b70654dd11 torso twist died after a respawn: Myomers ran the inner integrator, never the wrapper
Gitea #70. Torso twist intermittently stops working after a death/respawn and
stays stopped, with the generator at a full 10000V and the voltage link
resolving fine. A stale state, not a live one.

The Torso is a PowerWatcher that mirrors the electrical level of the subsystem
it watches -- for the MadCat that is roster slot 15, Myomers -- and
torso.cpp:570 holds effectiveTwistRate at 0 whenever that mirrored level is not
Ready. So the question was never about the torso. It was: why does Myomers sit
at NoVoltage forever?

Because it never ran the state machine that would walk it back. The Myomers ctor
@004b8fec stores [0x511620] into activePerformance, and that pointer resolves to
0x4b8b9c -- whose first instruction is `call 0x4b0bd0`, which is
PoweredSubsystem::PoweredSubsystemSimulation. @004b8d18, the function this port
had registered, is the INNER drive-heat integrator that wrapper goes on to run.
We registered the inside of the onion. PoweredSubsystemSimulation is the only
code that both enters NoVoltage (source == 0) and leaves it
(NoVoltage -> Starting -> Ready), so a Myomers that lost power during the
death/reset window had no way back out.

Order is load-bearing: the base call precedes the heat-model gate in the binary.
Gating first -- as this did -- also denied the electrical machine to every
non-expert pilot, since OwnerAdvancedDamage() reads the +0x260 heat-model flag
and that is off below veteran.

Measured with BT_SELF_DAMAGE + BT_TORSO_LOG, before and after:

  pristine, before  48/48 Ready    after  38/38 Ready
  post-respawn      95/97 Ready           89/89 Ready

Two dead windows per respawn, now none, and the clean case stays clean.

The wrapper has a tail this does not yet reconstruct: @004b8bb9-0x4b8c1e runs an
effect when electricalStateAlarm == 1 and the myomer's damage zone is below 1.0,
building a small vector (const 0xbc343958, about -0.011f) against the owner's
localOrigin@0x100. Almost certainly the un-powered movement penalty. Flagged in
the KB as T4 rather than guessed at.

Worth generalizing, and noted as such in context/subsystems.md: when a
subsystem's Performance address in a PTR_LAB_* slot does not match the function
we reconstructed, suspect a wrapper that chains the base sim. The whole family
does it -- Generator and PoweredSubsystem both lead with
HeatSink::HeatSinkSimulation, and the Torso's own perf @004b5cf0 leads with
UpdateWatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:30:48 -05:00
Joe DiPrimaandClaude Opus 5 c5537a1247 BT_SELF_DAMAGE: kill your own pilot in solo, so the RESPAWN family is bench-testable at last
Nothing could kill the LOCAL pilot on a bench.  BT_MP_FORCE_DMG only targets
REPLICANTS (mech4.cpp:4884 skips anything else), a solo dummy never shoots back,
and every other damage path needs a second live pod.  So the entire respawn
family -- #70 torso twist after respawn, #22 ammo/weapons not resetting, #55
coolant/heat/generator restore, #57 the respawn latch -- could only ever be
tested by asking a playtester to die.  That is why they are all still open or
awaiting-verification.

BT_SELF_DAMAGE=<amount/sec> dispatches an unaimed TakeDamage at our OWN mech
through the same virtual Entity::Dispatch a real beam hit uses, so the cylinder
hit-location lookup, the zone cascade, the vital-subsystem kill and the whole
authentic death -> 5s -> DropZone -> Mech::Reset cycle all run for real.  No
state is poked.  =60 kills a fresh MadCat in about a minute.

It LATCHES OFF at the first death.  That matters more than it sounds: live
damage knocks the power bus down, which is exactly the signal a respawn test
wants to read.  The first run of this harness produced a torso electrical state
flapping 4 <-> 1 and I nearly filed it as #70 -- it was the harness still
shooting me.  One death, then silence, then measure.

FIRST RESULT -- #70 does NOT reproduce in solo, but something near it does.
After the respawn the MadCat's torso recovers fully: rate back to 0.872665
(the authored 50 deg/s), elec=4 Ready.  Twist is not broken by a respawn.
However the post-respawn mission shows INTERMITTENT power dropouts that a
pristine mission never shows:

    pristine control (no damage, no death):   48/48 samples elec=4,  ZERO dips
    post-respawn (harness silent):            95/97 elec=4,  2 dips to elec=1

A dip zeroes effectiveTwistRate (torso.cpp:570), so a pilot mid-turn feels the
twist cut out -- plausibly what "torso twist stops working after a respawn"
actually is: not a permanent stop but a stutter.  Both dips are transient and
the tail of the run is steady, so it is not a stuck state.  Cause not yet
found; the watched subsystem's own electrical level (wElec) dips with it, while
the generator holds a full 10000V against a 5000V brownout threshold -- so it
is NOT the brownout path in PowerWatcher::UpdateWatch.  Filed here as the next
thread to pull, with the harness that makes it reproducible in one solo run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:02:41 -05:00
Joe DiPrimaandClaude Opus 5 034e55c7f5 prove generator FAILOVER, not just re-attach: BT_POWER_DETACH_TEST takes a subsystem name
The #62 verification so far only showed a subsystem detaching and re-attaching to
the SAME generator it started on.  That exercises the roster walk but never the
case players actually hit: your generator dies and Auto has to find a DIFFERENT
one.  (Raised by the operator, who also pointed out solo has no way to damage a
generator -- but it does not need one: a generator has its own on/off button.)

BT_POWER_DETACH_TEST now accepts a NAME (=PPC_1, =ERSLaser_1, ...) instead of
firing on whichever powered subsystem happens to tick first, so the scenario can
be aimed at a real weapon.  "1" keeps the old first-one behaviour.

Verified end-to-end, everything through real paths -- the detach via
DetachFromVoltageSource @004b0e30, the generator kill via its actual RIO button
0x1A through the click seam (EmitButton -> RIO queue -> manager drain ->
Generator::ToggleGeneratorOnOff @004b1ed0), no state pokes:

    BT_POWER_DETACH_TEST=PPC_1  BT_BTNTEST=0x1a,300,320  BT_POWER_LOG=1

    [power] TEST: detaching PPC_1 (forcing Auto)
    [power] AutoConnect RE-ATTACHED PPC_1 -> generator GeneratorA
    [btntest] PRESS 0x1a at poll 300            <- GeneratorA switched OFF
    [power] AutoConnect RE-ATTACHED PPC_1 -> generator GeneratorB

So the hunt skips the dead generator (HasVoltage requires GeneratorStateOf()==2
plus real measured voltage, powersub.cpp:860-876) and finds a live one.  Same
result first observed on Avionics; the named form proves it on a WEAPON, which
is the player-visible case.

Second proof in the same output: EXACTLY ONE re-attach line, then silence.  The
hunt re-runs every frame while `mode==Auto && !HasVoltage()`, so an
attached-but-dark weapon (the #21 symptom) would spam that line forever.  One
line then quiet means GeneratorB is really supplying it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:35:21 -05:00
Joe DiPrimaandClaude Opus 5 901cf1b459 four ENG-page lamps were invisible, not missing: the ctors dropped their colour parameters
Playtest report: "generators don't seem to have an Auto setting" / "no auto mode
is ever displayed", plus the operator's own memory that the BUS MODE button once
stepped through three states.  The state machine was NEVER wrong: @004b0abc has
exactly two branches (<2 -> Auto(2); ==2 -> detach + Off(0)), Manual(1) is set
only by the four SelectGenerator buttons, and the auto-hunt gates on ==2 -- all
byte-verified, and the #62 re-attach fires live.  Manual is a one-way door out
of BUS MODE by design: a single cycle button cannot know WHICH generator manual
should mean.  Verified on-screen by the operator this session: A-D returns to
manual, BUS MODE toggles auto/off thereafter.

What was actually broken: the connect-mode lamp -- and three siblings -- never
drew a pixel.  OneOfSeveralStates (@004c5470) and OneOfSeveralInt (@004c5148)
forward caller-supplied background/foreground colours in the binary; the recon
dropped both parameters and hardcoded 0,0 into the base.  A BitMap-strip lamp
draws SetColor(bg) + DrawBitMapOpaque(fg,...), so 0/0 painted colour-0 on
colour-0: invisible.  Affected: btemode (connect mode, 1x3 -- THE report),
btecmode (coolant on/off, 1x2), and both bteseek gear-step lamps (1x4).  The
cluster call sites pass 0xff/0, byte-verified (@004c866c disasm;
part_014.c:1479-1481, :2146-2147).  Same dropped-element family as issue #42's
MoveToAbsolute.

THE TRAP THAT WAS NOT LANDED, recorded so it stays unlanded: with the lamp
first made visible, the frames appeared inverted against the levels (art reads
AUTO/MANUAL/OFF top-down; levels run OFF/MANUAL/AUTO), and a
row=(rows-1)-selected "fix" was proposed.  An adversarial workflow proved it
wrong: the gauge blit addresses SOURCE rows BOTTOM-UP
(Video16BitBuffered::DrawBitMapOpaque, L4VB16.cpp:3846-3850 -- sTop =
map_max_y - sTop, rows walked upward), the 1995 blit @0046bdfc performs the
identical flip, and the vertical strips are AUTHORED bottom-up to match.
Identity level->row therefore draws the pod-correct display; the inversion
would have created the very bug it claimed to cure.  Two of five investigators
(and the first human pass) assumed top-down; the engine source overruled all
three.  Convention + warning now recorded in context/gauges-hud.md.
btecmode doubles as the standing tripwire: a second vertical strip that must
show ON when coolant is available -- if it ever reads inverted, the bottom-up
verdict is falsified.

Also corrected: @004c552c is OneOfSeveralStates' EXECUTE override (clamp >= 0,
chain the base draw) -- the port had the body on BecameActive under a wrong
label; vtable-diffed against OneOfSeveral (0x518b24 vs 0x518bf0).  BecameActive
is inherited.  Behaviorally inert today (the state source never goes negative),
byte-faithful now.

README: the "KNOWN ISSUE -- automatic re-attach is not working yet" text is
replaced with how power routing actually works (A-D = manual, BUS MODE = auto
then off, two presses off / one back to auto).  That text shipped in 600, which
already contained the #62 fix -- players were being TOLD auto was broken while
it worked, which is half of how a painted-out lamp became "no auto mode".

Diagnostic kept: BT_GENSEL_TEST=<id> now drives any of the five power-routing
message ids (4..8, default 7) and pulses four times, so the whole mode cycle is
observable headlessly under BT_FIRE_LOG ([gensel] lines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:22:21 -05:00
Joe DiPrimaandClaude Opus 5 c32d02b3cc the heat schematic was lit at every spawn: a dropped x87 tail, and the last #47 derivation
Playtest night 5: "heat damage @ launch is lit up like a Christmas tree",
confirmed by two players at ANY spawn -- so not the respawn-reset bug it was
first filed as.  Two stacked causes, both fixed here.

1. THE RATIO WAS NEVER RECONSTRUCTED.  HeatConnection::Transfer @004c3720 sets
   the colour index a ColorMapper pushes into the palette slot.  The port wrote

       *currentColorIndex = HeatRound(heat->currentTemperature);

   straight into an index whose range is 0..99.  A stone-cold mech sits at ~77
   and the generators idle near 260, so every one of the 24 cmHeat mappers in
   GAUGE/L4GAUGE.CFG (GeneratorA-D, Condenser1-6, HUD, Avionics, Gyroscope,
   Torso, GAUSS, the lasers, SRM6, Myomers) saturated at the hot end from the
   first frame and stayed there.

   The real computation was INVISIBLE in the decomp.  Ghidra renders the tail as
   `uVar1 = FUN_004dcd94();` -- an arg-less __ftol, exactly the carve artifact
   reconstruction-gotchas §19 documents: the x87 expression that left the value
   in ST0 is dropped from the export.  The previous author reconstructed the
   only thing visible and flagged the scaling as unreconciled in a comment.
   Raw disasm @0x4c379a-0x4c37c1 recovers it:

       fld [num] ; fdiv [den]        ; ratio
       fcomp 1.0f ; jbe -> ratio=1   ; clamp
       fmul 99.0f ; call __ftol      ; -> 0..99

   i.e. "how close to failure am I", not a raw temperature.  At spawn that is
   77/2000 -> 4, and the schematic reads cold.

   The two operands come from one of two branches, chosen by a class flag at
   +0x14 that the port did not model at all (ctor disasm @0x4c3682-0x4c36c2):
     HeatableSubsystem (0x50e3ec) -> own temp@0x114 / own failureTemperature@0x11C
     HeatWatcher       (0x50e604) -> WATCHED subsystem's temp@0x114
                                     / the WATCHER's failureTemperature@0x124
     neither                      -> source NULLed, Transfer writes 100
   Note the watcher asymmetry: temperature from the watched subsystem, reference
   from the watcher itself.  Bridged as BTHeatWatcherSample so the gauge TU need
   not include the heat family's headers.

2. THE WATCHER BRANCH COULD NEVER BE SELECTED.  With (1) fixed, HUD, Gyroscope
   and Torso still pinned at 99 reading temp=1.6369e-35 failAt=0 -- uninitialised
   bytes.  HeatWatcher's C++ base had been re-based to MechSubsystem, but its
   DERIVATION chain still said HeatableSubsystem, so every watcher answered "yes,
   I am heat-bearing", took branch one, and read its own watchedLink@0x114 as a
   temperature.  This is the last surviving instance of the #47 bug -- in the
   file that fix's own comment cites as already correct.  The two families are
   disjoint in the binary and the branch test depends on it.

VERIFIED LIVE (solo, F6 to the Heat schematic, BT_HEATGAUGE_LOG=1): mode mask
reached 0x510421 (ModeSecondaryHeat) and the gauge tracks per subsystem rather
than saturating -- GeneratorA 259.5->13, Condenser3 202.0->10, SRM6 182.8->9,
lasers 90-97->4-5, and AmmoBinSRM6_1 (a watcher) resolves its link and reads
182.7->9 through the branch that previously read 1.6369e-35.  Nothing pinned at
99.  Operator confirms the panel is blue, not red.

BT_HEATGAUGE_LOG kept as a permanent env-gated diagnostic: bind-time
classification plus per-sample temp/failAt/ratio/index.  A fresh spawn reading
99 is this regression returning.

Not covered: ColorMapperCritical (cmCrit / ModeSecondaryCritical) already
computes a proper damageLevel*100 ratio and was read, not measured -- if the
Critical page specifically still misbehaves that is a separate lead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:28:11 -05:00
arcattackandClaude Opus 5 e26f4e6285 Gitea #62 FIXED: AutoConnect can re-attach again -- slot 16 is HasVoltage(source), not GetStatusFlags()
A weapon detached by two eng-page BUS MODE presses stayed dark for the REST OF
THE MISSION: no voltage, blank recharge arc, dead ready dot, will not fire.
The arcade recovers by pressing bus-mode back to Auto and letting the per-frame
auto-hunt re-tap a generator.  In the port that hunt's body was UNREACHABLE.

THE DEFECT, visible in three lines of powersub.cpp:

    if (modeAlarm == AutoConnect && GetStatusFlags() == 0)   // outer: NOT damaged
        for (...)
            if (... && GetStatusFlags() != 0 && Attach(sub))  // inner: DAMAGED?!

Both call sites modelled the no-argument GetStatusFlags(), so the outer demanded
"no status flags" and the inner "some status flag", with nothing in between able
to change the value -- mutually exclusive, body never entered.  The binary's
@004b0bd0 calls vtable slot +0x40 (slot 16) with TWO SHAPES: slot16(this, 0) == 0
("am I unpowered?") and slot16(this, candidate) != 0 ("would THIS generator
supply me?").

WHAT WAS ACTUALLY WRONG was narrower than the issue assumed -- the BODY was
already a faithful transcription of @004b0b5c (complete-type accessors, no raw
offsets).  Only its NAME and its WIRING were wrong:
  * it was called `IsSourceShorted`, asserting the INVERSE of what it computes:
    state 2 is the generator's ON-LINE state (Generator::GeneratorReady, what
    @004b215c's stateAlarm->2 sets) and the fabsf test requires meaningfully
    non-zero output voltage.  True means "live and supplying".
  * it was declared NON-VIRTUAL, so it could not be the slot-16 body, and a weak
    stand-in (`electricalStateAlarm == Ready`, no source arg, no voltage test)
    occupied `HasVoltage()` instead.

FIX: rename to `virtual Logical HasVoltage(Subsystem *source = 0)`, delete the
stand-in, restore the two AutoConnect call shapes, and rename Myomers' slot-16
override (`HasAdequateVoltage` -> `HasVoltage`) so it actually overrides -- it
tightens the test to "at least the SELECTED SEEK voltage", which is why it
exists.

BLAST RADIUS (the issue's warning): GetStatusFlags ORs the BadPower bit 0x40 on
!HasVoltage(), and that bit feeds #47's annunciator -- so power, firing and
annunciation semantics moved together.  Checked: a healthy solo mission shows
zero spurious BadPower, subsystems simulate normally, no faults.

VERIFIED with a new diagnostic hook in the codebase's existing family
(BT_POWER_DETACH_TEST=1, off by default): it reproduces the player's exact state
-- drop the voltage link and force Auto, i.e. what the second BUS MODE press
does -- so the fix can be tested without the eng-page UI.  Live result:

    [power] TEST: detaching Avionics (forcing Auto) -- the auto-hunt must recover it
    [power] AutoConnect RE-ATTACHED Avionics -> generator GeneratorA

Before the fix that second line was impossible.  BT_POWER_LOG=1 prints every
re-attach; both hooks stay for regression use.

(The file-static one-shot is deliberate: PoweredSubsystem's layout is byte-locked
by PoweredSubsystemLayoutCheck, so a test flag as an instance member would break
sizeof and every offset assert downstream.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:34:00 -05:00
arcattackandClaude Opus 5 ef8e449a17 #55 steps 0/1/2/4: the 1995 DEATH pass restored, the arg gate made real, and 5 subsystems the respawn sweep never reached
THE HEADLINE: the port's death sweep was a total no-op, and fixing it required
fixing the arg gate in the same commit -- otherwise corpses refill their ammo.

STEP 2 -- the authentic dispatch shape:
  * engine/MUNGA/SUBSYSTM.h: Subsystem::DeathShutdown gains the binary's
    universal base body { DeathReset(c) } (@004ad10e).  It was empty, and NO
    port class overrode it, so everything the 1995 game does at death was
    skipped entirely.
  * mech4.cpp death sweep now passes 0, not 1 (the binary's @0049fe0c arg) --
    the wreck shape: alarms/state settle, nothing refills.
  * ARG PROPAGATION (mandatory once the sweep runs): AmmoBin::DeathReset is now
    arg-gated exactly as @004bd26c -- refill only when arg != 0, ammoAlarm
    unconditional.  Ignoring the arg was harmless only while the sweep was
    dead; with it restored, every corpse would have re-armed.  Also forwarded
    in PoweredSubsystem / HeatSink / Condenser / HeatableSubsystem / Generator
    (each binary body forwards it -- verified addresses in the plan).

STEP 4 -- coverage for 5 classes that had an authentic slot-10 body but no
DeathReset, so the respawn sweep fell through to the empty base:
  Torso (this is Gitea #70 -- "loosing torso twist function after a death"),
  Gyroscope, Myomers, HUD, Seeker.

STEPS 0/1 -- observability + the David chain:
  * Three unconditional matchlog rows: DEAD_NOTIFY (mech + resolved link),
    PLAYER_LINK (player <-> vehicle), RESPAWN (mode/alive/zones/subsys/pos).
    A respawn was previously INVISIBLE in the matchlog -- night 3's analysis
    had to infer them from ammo arithmetic.
  * Mech::PlayerLinkMessageHandler + the death dispatch site: when the engine's
    one-shot registry lookup misses (no null check, no retry -> the whole
    death/respawn cycle silently swallowed), recover the SAME object via the
    reverse link the binary's own respawn branch walks (player+0x1FC ==
    playerVehicle).  Complete-type TU, no raw offsets.
  * deathPending cleared in the first-spawn branch (a latch carried in would
    permanently kill every later respawn -- the #57 class).

VERIFIED on the 2-pod rig (madcat vs thor, forced kills, 5 death/respawn cycles
per pod): build clean, ZERO new /FORCE unresolved externs from the 5 new
overrides; refill lines appear ONLY immediately before a Mech::Reset and NEVER
between a death and the next respawn (the corpse-refill regression this commit
had to pre-empt); all three probe rows present in both pods' matchlogs; every
DEAD_NOTIFY carried a non-null link.

DELIBERATELY DEFERRED (documented, not forgotten): the mechsub.cpp rename pair
(ResetToInitialState -> GenerateFault, ClearStatus -> the root reset @004ac22c),
deleting HeatableSubsystem::ResetToInitialState, and deleting RespawnRepair.
Those need the HeatableSubsystem vtable-slot-10 pre-flight the plan calls for,
and we have no binary image here to dump the vtable from -- guessing at it
risks the vptr-alias trap.  Next session with the decomp shards open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:09:31 -05:00
arcattackandClaude Opus 5 26e678e570 #67 part 2, ROOT-CAUSED AND FIXED: 1995 latent uninitialized fields, exposed by the port's allocator
THE HUNT (the deterministic rig repro made it a two-hour arc):
  1. cdb write-watch armed from the Torso ctor (bp plants `ba w4 this+0x21c`
     per torso -- ASLR-proof).  The poison reproduced (atUpd=-1250 this run;
     -3750 and int-15-as-float before)... and the watch stayed SILENT.  Nobody
     writes the garbage.  The field is never INITIALIZED.
  2. Confirmed in our ctor reconstruction: it inits every neighbour but skips
     targetTwist @0x218 and twistAtUpdate @0x21C (the function that zeroes
     them is a death-reset handler, not the ctor).
  3. Confirmed in the BINARY: the real ctor @004b6b0c contains no store to
     either offset -- a genuine 1995 latent bug (uninitialized read on the
     copy path).
  4. Why the pod never showed it: MemoryBlock arenas carve fresh OS-zeroed
     pages, one pool per type, low churn -- first allocations read as zero.
     The engine's own DEBUG_NEW_ON NaN-fill proves the developers knew the
     hazard class.  Why WE show it: mechrecon.hpp's Memory::Allocate shim
     ("a plain heap allocation is behaviour-equivalent" -- false) recycles
     dirty heap.  Replicant torsos spawned with garbage twist targets; the
     limit clamp turned any garbage into FULL TWIST -- rendered "twisted full
     right while not using TT" to every peer (playtest night 4, 3 reporters).

THE FIX -- environmental, class-wide, byte-faithful to the binary's code:
Memory::Allocate / AllocateArray / Alloc now ZERO-FILL, reproducing the pod's
EFFECTIVE allocation semantics for every never-stored field in every
reconstructed factory at once (Torso, Reservoir, all of them).  No ctor gains
stores the binary lacks.

PROVEN on the rig: the replicant thor now spawns cur=0 target=0 atUpd=0
(was cur=-3.31613 = hard against the limit).  Teardown clean.

Ships with part 1 (the dead-reckoning clock fix) in the next zip.  Remaining
on #67 for the next games night: live confirmation that twist TRACKING looks
right in play, and whether the fire-from-centre facet (very plausibly the same
never-stored-field class, now zeroed) is cured with it.

Tools kept: scratchpad/torso_watch.cdb + rig_watch.ps1 (the ctor-armed
write-watch pattern -- reusable for any "who wrote this field" hunt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:42:37 -05:00
arcattackandClaude Opus 5 a3329a64da #67 torso replication, part 1: the dead-reckoning clock fix + the bug REPRODUCED on the rig
THE CLOCK FIX (landed, verified live).  The replicant torso's extrapolation
window was hardwired to ZERO: both shim accessors returned the same field
(torso.hpp, the "dormant in single-player" note).  The engine maintains both
clocks exactly as the binary expects -- WriteUpdateRecord stamps
lastUpdate=lastPerformance on the master, ReadUpdateRecord stamps
lastUpdate=Now() on the replicant (SIMULATE.cpp:276/296, the 1995 "HACK"
comment intact) -- so the fix is one mapping: GetCreationTime (misnamed; now
GetLastUpdateTime) reads lastUpdate.  ComputeTargetTwist's
`current - lastUpdate` window is real again.  Why legs always replicated
while torsos did not: Mover is ENGINE code reading these fields natively; the
Torso is our reconstruction with the collapsed shim.  Rig-verified: the
copy-side log now shows distinct lastUpd/now values.

THE FIELD BUG REPRODUCED (2-pod rig, thor vs thor -- thor because the first
attempt used FOGDAY's Black Hawk, whose torso is authentically FIXED like the
Owens: hEn=0, limits +/-0.01 deg):

    [torso-copy] cur=-3.31613 target=-3.31613 atUpd=-3750

The replicant renders at EXACTLY its twist limit (thor: +/-3.31613 rad)
because twistAtUpdate holds garbage (-3750) and the limit clamp slams it to
the stop -- "twisted full right but was not using TT", on demand.  An earlier
run poisoned it with 2.1e-44 (= int 15 as float, suspiciously the torso's
subsystem index).

NARROWED: the tx/rx probes added here (BT_TORSO_LOG prints every torso record
both directions, header + raw payload dwords) logged ZERO records in the
poisoned runs -- Torso::Read/WriteUpdateRecord never executed.  The garbage
therefore arrives OUTSIDE the torso record path: a raw state write during
mech spawn/consolidation spraying the torso's fields (stream-walker framing
or a consolidation blit).  That writer is part 2's hunt; the probes and the
deterministic repro make it a short one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:13:07 -05:00
arcattackandClaude Opus 5 184121c597 kill-storm repro rig for #35 (owens laser crash): parameterized damage hook + honest NEGATIVE
BT_MP_FORCE_DMG=<n> now sets the per-tick probe damage (plain =1 keeps the
original amount) -- kill-storm rigs need lethal ticks.  rig_killstorm.ps1:
offset-port relay + owens shooter under BT_AUTOFIRE vs a respawning victim.

Result: NEGATIVE, twice.  The respawn cycle (death anim + warp + handshake)
caps the harvest at 1-2 kill-teardown windows per 4-minute round, and none
crashed.  With July's 254-volley negative the conclusion firms up: the window
needs the reporter's slow-machine timing, not more attempts here.  The field
net (crash self-report + join.old.log rotation + the sweep guards) means the
next real occurrence names its own site.  Refined theory recorded in the
ledger: six-beam volley vs a target dying mid-volley, 515-class teardown race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:57:23 -05:00
arcattackandClaude Opus 5 e28dcbc161 the #35-class sweep + the games-night load crash: four lifecycle-window null-derefs guarded
THE CLASS (established by Gitea #35's 515 fix, confirmed growing tonight):
code the binary could run bare because a pod's mission world always existed,
crashing in the port's MP join/teardown/respawn windows where mission, player,
or a roster link is briefly NULL.  Swept every GetMissionPlayer()/
GetPlayerVehicle()/GetCurrentMission() chain in the tree (23+7 sites).

GUARDED (the 515 pattern -- authentic behavior whenever the object exists,
skip/degrade + loud log when mid-teardown):

1. DPLRenderer::SortAndReloadNameBitmaps -- runs in the round-STOP path (the
   end-of-round circle), raw mission+player+entity-manager derefs.
2. DPLRenderer::LoadOrdinalBitmaps -- called from (1); raw
   GetMissionPlayer()->GetInstance() evaluated on EVERY machine (the camera-
   director test derefs before it branches).
3. DPLRenderer::LoadNameBitmaps -- same window, raw GetPlayerCount() chain.
4. Reservoir::Reservoir master-gate block -- THE CAPTURED GAMES-NIGHT CRASH
   ([crash] btl4+0x4990d = this ctor inlined into CreateReservoirSubsystem,
   AV reading NULL+0x1d0):  linkedSinks.Resolve() derefed raw; +0x1d0 is
   exactly the masterScale FILD of the resolved master heat-sink bank
   (heatSinkCount @0x1D0 -- the offset the crash named).  Observed trigger: a
   DUPLICATE-PILOT egg (the seat-ghost bug put one identity in two seats)
   poisoning the ID registry, so Resolve() nulled during viewpoint-entity
   construction and at least two pods died loading the same egg.  Now: loud
   '[spawn] FATAL-AVOIDED' + degrade to the inactive-copy shape.  The
   duplicate-pilot ROOT CAUSE is the seat-ghost fix (tracked separately);
   this guard makes the failure survivable and self-identifying either way.

CLASSIFIED SAFE, not touched: btl4mppr/btplayer/mechmppr/btl4gau3 chains
(guarded or null-tolerant by design); APP.cpp's five Check(player) sites
(mission state machine -- player exists by construction, original engine
ordering); CAMMGR/CULTURAL GetCurrentMission chains (construction-time).

VERIFIED: build clean (0 errors, no new unresolved externs); solo smoke on the
guarded exe -- zero FATAL-AVOIDED lines (guards silent on the healthy path),
subsystems simulating, no faults.  STILL OWED: a full launch->stop rig cycle
(exercises the guarded round-stop path live) and the duplicate-pilot-egg repro
-- both blocked on port 1500 while the operator's console session is up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:03:31 -05:00
arcattackandClaude Opus 5 62e89018f5 Merge glass-cockpit-refit: cockpit scaling + one button geometry, the keyboard button board, RGB keylight, crouch, the cwd guard
Cyd's 9-commit branch, reviewed before merge (clean merge-tree, zero overlap
with the console/relay work that landed after his fork point; his L4VB16 refit
preserves the #48 plane-audit tripwires, and his lamp-decode fix corrects the
#47 flash rendering).  Four review findings are fixed in the follow-up commit:
the stale README controls table, the unguarded backtick view-toggle, the
environ.ini one-shot loophole, and volume-key documentation (the -/= -> PgUp/
PgDn move itself landed pre-merge so this branch's Comm-bank -/= bindings are
collision-free).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:13:08 -05:00
arcattackandClaude Opus 5 9384c38c08 volume keys: -/= -> PgUp/PgDn, ending the zoom double-fire (prep for the cockpit-refit merge)
The issue #26 volume poll watched VK_OEM_MINUS/VK_OEM_PLUS -- the same physical
keys the authentic 1995 typed-character target-zoom hotkeys ('+'/'-') live on,
so a single press zoomed AND stepped the volume (operator-reported annoyance).
The incoming glass-cockpit-refit branch also binds -/= as Comm-bank buttons,
which would have made it a TRIPLE dispatch on fresh installs.

PgUp/PgDn produce no WM_CHAR at all, so they cannot collide with any typed
hotkey in any install; they are unbound in every bindings layout including the
refit's 74-key board; and they exist on tenkeyless keyboards.  Numpad +/- were
considered and rejected: their typed characters feed the same zoom channel that
made -/= wrong.

README/CONTROLS documentation follows in the merge-fixes commit (the whole
controls table is being redone there for the new default board).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:12:52 -05:00
CydandClaude Opus 5 1debbeb240 Mech::DuckRequest -- the CROUCH button, and a dead-button census that lied
The 2026-07-20 audit listed 8 panel buttons dispatching a streamed message with
no reconstructed handler.  Only ONE was still missing: generator on/off,
ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant had all landed
between 07-20 and 07-25 while pod-hardware.md and open-questions.md still called
them dead.  docs/INPUT_PATH_AUDIT.md had already flagged the census as "stale in
both directions" and was right -- swept both files, and recorded the rule: check
the code, not the census.

DuckRequest @0049fa00 (id 0x1a, RIO 0x13 -- the manual gives crouch a whole
section).  The binary's entire body:

    if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; }

Press-only; sets duckState (mech+0x398, attribute 0x37).  A one-shot REQUEST
flag, not a posture toggle -- the handler never clears it and the only other
writer in the whole binary is the mech reset (part_012.c:9439, the same reset
that zeroes incomingLock, which is how that region was already mapped).

WHY THE FLAG HAS NO READER, AND WHY THAT IS CORRECT.  duckState has ZERO readers
anywhere in the decomp, because it is published as an ATTRIBUTE and consumed
through DATABINDING: content/GAUGE/L4GAUGE.CFG runs a 3-frame bduck.pcc
oneOfSeveralPixInt bound to DuckState -- "crouch mode: button 4" on the map's
legend column.  Verified live by capturing the Secondary/Radar window before and
after a 0x13 press: the crouch icon goes grey -> orange.  So the handler is
COMPLETE.  A crouch pose invented here would be a stand-in for data we have not
found (no SQUAT clip name survives in the decomp or in content/, only
DuckServo01.wav in AUDIO1.RES).

Bonus: those gauge widgets sit at offsets 537/430/322/215/108 -- a 107 pitch,
independently corroborating the map legend grid measured from pixels.

Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:23:58 -05:00
arcattackandClaude Opus 5 48d47ef806 #45 review pass: correct three claims the fix's own comments got wrong
An adversarial review of the change returned NO BLOCKERS but caught three
statements that were wrong or unverifiable.  All three are corrections to my own
comments/docs, not behaviour changes -- the verified binary is unchanged.

1. The phantom-kill note had the ORDERING backwards.  It claimed the owner's
   record "overwrites it on the next record -- the phantom stops being visible".
   The engine's event priorities run the other way: an inbound update record is
   posted at UpdateEventPriority == MaxEventPriority and drained by
   ExecuteBackgroundTask, while the rerouted score message sits at
   EntityManagerEventPriority == DefaultEventPriority and is popped later -- so
   the correction usually lands FIRST and the phantom AFTER it.  The artifact is
   BOUNDED, not masked: on the killer's pod only, that victim's KILLS reads +1
   until the next record, i.e. <= one 2s heartbeat instead of "until the victim
   next scores or dies".  Swept in btplayer.cpp, KD_SCOREBOARD_PLAN.md and
   RECONCILE.md.

2. The corpus figures were not reproducible, and were mis-tiered.  I cited
   "125 of 125 SCORE type=2 over 255 node-logs" and "0 of 8800 DMG rows" -- true
   when measured, but the corpus is append-only and this fix's OWN verification
   runs grew it from ~255 to 425 files, so nobody can re-derive them.  Replaced
   everywhere with invariant RATIOS that hold at any corpus size, re-measured
   here:
     * 0 of 18818 DMG rows are inst=R (damage is applied master-side only)
     * 439 of 439 DEATH inst=R rows read killer=0:0 killdmg=0.000, against
       0 of 225 inst=M rows (sole lastInflictingID writer: mech.cpp:746)
   Also re-tagged T1 -> T2: log-corpus field evidence is T2 per the CLAUDE.md
   tier table.  Lesson recorded in the banner: cite ratios, not counts, for a
   corpus your own rigs append to.

3. The recordLength guard's justification was wrong (and the plan's risk 5 with
   it).  A short legacy record does NOT desync the stream -- both sides advance
   by the WRITER's stamped recordLength and no reader asserts a length.  The real
   hazard is a read PAST THE ALLOCATION: an inbound update message lives in a
   per-event heap block sized from messageLength, so on a TRAILING legacy record
   the two new fields would be read off the end of it.

Plus: the record struct's own field comment still said "deathCount -- the DEATHS
column", the exact wrong-field belief #45 removed, sitting on the wire struct.

Filed the review's other finding on #60: a SECOND decomp export gap, index
jumping FUN_0049fe80 (ends 0x49ffc8) -> FUN_004a1232, 4714 unexported bytes
containing Mech::TakeDamageMessageHandler @0x4a0230 -- the producer of every
score and death message in the game.  Verified independently against
functions_index.tsv before posting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:15:06 -05:00
arcattackandClaude Opus 5 a52207d779 #45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims
Follow-up to 4fa7eee, driven by an adversarial review pass and three rig cycles.
Each item below is a real defect that pass found, not polish.

DEATHS now uses the binary's own column.  PilotList::Execute @0x4cabd0 draws
`fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS); +0x280 has exactly
two runtime writers image-wide plus the ctor zero.  Our port had declared it
`pad_0x280`, never written, and displayed the ENGINE's Player::deathCount
(+0x200) instead -- which is the respawn-handshake identity, seeded -2, and is
why it needed a clamp to pass as a count.  It is now BTPlayer::deathTally (our
offset 0x274, offsetof-locked), incremented beside ++deathCount, read by the
gauge, and replicated.  deathCount is left to the engine's handshake.  So this
half moves TOWARD the binary; it also closes the plan's Headline-1.
(`deathTally`, NOT `deaths`/`deathCount` -- those would shadow the base.)

The mirror was not self-healing: update records are UNRELIABLE by construction
(Entity::UpdateMessage clears ReliableFlag, ENTITY3.h:112; the relay's UDP path
also drops stale/reordered datagrams), and the pair was dirtied only on an
event.  One lost datagram left every peer stale until that pilot's next kill or
death -- forever for the last kill of a round.  Added a 2s heartbeat that
re-dirties the record, which also bounds how long the binary's phantom
partner-increment stays visible.  The timer is a function static deliberately: a
data member would change sizeof(BTPlayer) and break the offset locks.

Also fixed: the SBMIRROR row AND its change-detect both still read deathCount (a
constant -2 here), so the "log only the edge" guard could never be false and
every row printed deaths=-2.  That is what made the first rig runs look like
DEATHS was broken when a WRITE/READ trace proved the transport correct.  Row
count per node fell from ~20 to 3, one per real change.

Guarded the record against per-bit layouts: update_model is a BIT INDEX and
Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352) -- the DamageZone
bit emits a variable-length packed stream whose length it computes itself, so
appending two ints and re-stamping recordLength over that would corrupt it.

Deleted BTPlayerCountObservedDeath -- definition, call site, extern and friend
together, since /FORCE hides stragglers.  It never executed (its call site sat
inside the once-per-death transition, which a replicant never enters) and could
not have worked (0 of 8800 corpus DMG rows target a replicant, which is why every
DEATH inst=R row reads killer=0:0).  Under replication it would have been a
second writer of a replicated counter.

New forensics: NOCREDIT names the failing link when a kill credit is skipped (it
used to be completely silent -- the counter simply never moved), and PLAYER_LINK
records whether the one-shot link resolved.  Both retire the NULL-playerLink
theory: every rig shows `PLAYER_LINK inst=R resolved=1` and no NOCREDIT rows.
PLAYER_DEAD now logs both counters (deaths=handshake, tally=scoreboard).

KB sweep of the claims that hid this bug for so long:
  * context/gauges-hud.md's "RESOLVED -- deaths tally per node from locally
    observed events" was FALSE; corrected with the measured evidence.
  * docs/GAUGE_COMPOSITE.md + btl4gau3.cpp "the dead pad_0x280" -- never dead,
    merely unwritten.
  * btplayer.hpp attributed VehicleDeadMessageHandler to @004c012c; it is
    @004c05c4 (absent from the decomp export -- the #60 gap).
  * docs/RESPAWN_REARM_PLAN.md's "#45 SUBSUMED" -- the PLAYER_DEAD symptom was
    subsumed, the scoreboard defect was not.
  * The corpus figure in the record banner now states its method so it is
    reproducible.

Rig-verified (2-node loopback, several cycles): owner 3:1 kills 2/tally 1 read
`kills=2 deaths=1` on the peer; owner 2:1 kills 1/tally 2 read `kills=1 deaths=2`
on the peer.  Respawn unaffected, no crash, no GLITCH rows.  Still awaiting live
multi-pod verification by a human; all pods must run the same build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:09:00 -05:00
arcattackandClaude Opus 5 4fa7eee54f K/D scoreboard ROOT-CAUSED for real (#45): the kill credit was rerouted, not lost
Kills/deaths only ever appeared on ONE machine.  The cause is not a missing
tally -- it is Entity::Dispatch:

    if (GetInstance() == ReplicantInstance)              // ENTITY.cpp:244-251
        application->SendMessage(ownerID, EntityManagerClientID, message);

BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it.  The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only.  Every
other pod's copy therefore read 0 all mission.  playerLink was never NULL for
these kills -- the dispatch proves it resolved.

Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.

This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill.  So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.

  * BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
    Read/WriteUpdateRecord overrides.  No new data member, no new virtual ->
    sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
  * Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
    no dirty-bit edit was needed (plan risk 6 avoided).
  * recordLength guard in ReadUpdateRecord: a pod on a build without the
    extension degrades to "remote counters don't move" instead of reading a
    neighbouring record as a kill count.
  * Size locks added per plan risk 2 (no false offsetof assert).

Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3.  Exact convergence
where a remote column previously never left 0.  3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.

Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced.  It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2).  The phantom kill
stops being visible without silently deciding the fidelity question.

Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity.  Awaiting live multi-pod verification by a human.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:40:59 -05:00
arcattackandClaude Fable 5 f3179ef020 #47 follow-up: restore the DAMAGE TIER to the heat branch -- HeatableSubsystem::GetStatusFlags was a stub returning 0
Found while auditing today's vtable change before release.  The binary's
HeatSink::GetStatusFlags @004add30 OPENS with `call 0x4ac144` --
MechSubsystem::GetStatusFlags, the damage tier (damageLevel >= 1.0 -> bit 0
Destroyed; > 0 -> bit 1 Damaged).  There is no zero-returning
HeatableSubsystem override anywhere in the image.

Our port routed that call through `HeatableSubsystem::GetStatusFlags()
{ return 0; }` -- a "neutral default" that silently dropped bits 0 and 1 for
the ENTIRE heat branch (every weapon, sensor, emitter, PPC, generator,
powered subsystem).  Two live consequences:

  * Destroyed / Damaged could never reach MechTech's annunciator scan --
    gutting half the shipped mechalrm table (Destroyed -> gotoEngineering +
    engCooling + engBusMode) hours after #47 shipped the flash;
  * PoweredSubsystem's AutoConnect gate (`GetStatusFlags() == 0`,
    powersub.cpp:358) read a DAMAGED subsystem as pristine.

One-line fix: chain the real tier, reproducing @004add30 exactly.  The call
site's own comment already said `// FUN_004ac144` -- the implementation had
simply never matched it.

Also corrected a stale comment on MechSubsystem::GetStatusFlags that read the
field as a STRUCTURE level ("1.0 = intact, 0 = dead") -- backwards.  The
engine member is damageLevel and ACCUMULATES to 1.0 = destroyed (mechdmg's
crit cascade tests `level >= StructureMax` for exactly that), which is what
makes bits 0/1 line up with TechStatusType Destroyed/Damaged.  Behaviour was
always right; the comment was a leftover of the same structureLevel misreading
#46 retired.

VERIFIED (3-pod combat sim, BT_LAMP_LOG): the annunciator is genuinely live --
Jammed x7, AmmoBurning x5, BadPower x20, Overheating x207 (Overheating
correctly maps to NO lamp: the shipped table has no entry for it).  The lamp
resolution is per-subsystem as designed -- different weapons flash their own
panel buttons (lamps 0xf/0x5/0xb/0x3 under distinct mode masks), 32 flash
writes across a whole battle, no thrash.  Zero crashes, zero plane/OOB
tripwire hits across all three pods.

FILED, deliberately NOT fixed tonight (open-questions.md): vtable slot +0x40
is misattributed.  The binary calls it `(this, 0)` in AutoConnect's outer gate
and `(this, candidateGenerator)` in the roster walk -- a one-arg voltage query
(HasVoltage(source)), not GetStatusFlags().  Modelling both as the no-arg
GetStatusFlags makes the outer gate demand "no flags" and the inner "some
flag" with nothing between them, so AutoConnect's loop body can never execute.
Pre-existing before and after this change; it is a live power-bus behaviour
change and deserves its own playtest cycle, not a release-eve patch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:58:20 -05:00
arcattackandClaude Fable 5 6bb03aed0b Gitea #48 ROOT-CAUSED + FIXED: MFD "stray blocks / misplaced lamps" = uninitialized translation-table entries leaking pixels into other displays' bit-planes
The conviction was empirical, not theoretical: a new write-site trap
(BT_PLANE_AUDIT) in the seven L4VB16 drawing primitives logs any draw whose
color carries bits outside its port's plane mask -- the exact cross-display
corruption condition (Replace ORs an unmasked color; Or/Xor ignore the mask
entirely; And clears foreign planes).  A quiet solo session produced 15-30
leaks per minute:

    [plane] PORT 'sec' pixmap draw at(0,0) idx 217 entry=0xffffff00 mask=0x3f
    [plane] LEAK DrawPixelMap8[table] at(639,0) color=0xffffff00 mask=0x3f leak=0xff00

THE DEFECT: L4GraphicsPort::translationTable[256] is never initialized in the
ctor, and BuildSecondaryTranslation fills only the entries its BitWrangler
reaches -- 2^numberOfBits: 64 for the sec plane (mask 0x3F), 4 for the overlay
(0xC0).  Entries above that stay heap garbage.  Every draw resolves color
through this table, and the PIXMAP path indexes it with raw pixel values
0..255: the 480x640 radar background carries pixel index 217, whose garbage
entry's high bits (0xFF00 = ALL EIGHT MFD planes) were stamped into the shared
640x480 buffer -- invisible on the culprit page (the in-plane bits happened
dark) and visible as bright fragments at the same coordinates on every OTHER
display.  That is the operator's screenshot exactly: the same-position blips
on Mfd1+Mfd2 and the Heat-display block.

FIX (engine, both layers):
  * zero translationTable in the L4GraphicsPort ctor (plane-neutral default);
  * BuildSecondaryTranslation now cycles the in-plane pattern across entries
    [2^bits..255] -- high-index art degrades to its (index mod 2^bits) colour
    IN-PLANE and can never leak.  The 1995 binary ships the SAME 64-entry fill
    and survived on 6-bit art discipline; garbage is not a preservable
    behaviour, so the cycle-fill is a guarded PORT deviation (documented).

A/B PROOF: same probe, 60s -- 0 leaks after the fix (15-30/min before).
sim3 3-pod regression: zero crashes.

SECOND real defect found + fixed en route: sessions configure the CAMERA seat
first ("cameraInit" -- which includes the MISSION-REVIEW context:
configure(0, sec, 0, 0x00FF, native, rgb, mrpal.pcc), a DirectColor context
whose translation tables legitimately hold full RGB565 values spanning all 16
plane bits), and the viewpoint swap to the mech re-configured WITHOUT tearing
that tree down (btl4app's s_gaugeTreeBuilt latch treats the mech build as the
first).  The orphaned review gauges kept executing through stale DirectColor
ports.  BTL4GaugeRenderer::ConfigureForModel now overrides (ConfigureForModel
made virtual in L4GREND.h) and tears the prior entity-bound tree down before
every rebuild -- safe on first build, and the review screen rebuilds the same
way when the seat returns to the camera.

Also logged (open-questions): the review-screen PlayerStatus panels read the
compiled player at RAW BINARY OFFSETS (+0x1FC vehicle / +0x1C8 score /
+0x1C4 alive-dead box) -- the databinding trap, dormant until the review
screen runs; bridge before enabling the review.

Ruled out along the way (with evidence): the pilot-list label/erase geometry
(erase box 128x32 covers the 64x16 name rasters), the radar name labels
(view-level ClipImage clips them), PlayerStatus in-game execution ([ps] probe:
never runs in-game), and the PNAME bitmap dimensions (egg generator emits
64x16 exactly).

Why it "started with the comms feature" (#43): that wave registered the new
gauge classes with the config interpreter, letting more of the authored page
furniture parse and draw than before -- the leaking high-index pixmaps rode in
with it.  [T3 correlation detail; the leak + fix are T2 live-verified.]

Diagnostics kept (env-gated): BT_PLANE_AUDIT (the leak trap, now a permanent
regression tripwire), BT_PS_LOG, the port-level pixmap identity trap.

KB: gauges-hud.md (#48 section), decomp-reference.md (BT_PLANE_AUDIT),
open-questions.md (PlayerStatus databinding entry).  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:20:19 -05:00
arcattackandClaude Fable 5 6f5a264835 Gitea #47 COMPLETE: the ENG-button attention FLASH is live (jam / bay fire) -- MechTech status scan + BTL4GaugeAlarmManager + lamp chain, all from the binary
The pod behaviour Cyd described -- "on a jam or bay fire the display eng button
for the system flashes" -- is authored data + a five-stage chain, now running:

  MechTech::TechnicalAssistance (@004ad33c, per frame)
    edge-scans every monitored subsystem's GetStatusFlags() 7-bit condition
    mask (TechStatusType: Destroyed 0, Damaged 1, CoolantLeaking 2,
    Overheating 3, AmmoBurning 4, Jammed 5, BadPower 6)
  -> Start/StopEntityAlarmMessage (@00436688 id 7 size 0x20 / @004366b8 id 8
     size 0x1C; broadcast @004364e4; port shape: direct
     Start/StopEntityAlarmImplementation calls on the gauge renderer)
  -> GaugeAlarmManager::Activate(alarmModel) -- alarmModel = MechTech+0x100 =
     the 'mechalrm' ModelList (id 83) -> SearchList(83, type 31) -> the baked
     GaugeAlarmStream (id 331, 11 items {condition, lampCode}), decoded:
         Destroyed      -> gotoEngineering + engCooling + engBusMode
         CoolantLeaking -> gotoEngineering + engCooling
         AmmoBurning    -> gotoEngineering + engEject
         Jammed         -> gotoEngineering + engEject
         BadPower       -> gotoEngineering + engBusMode
  -> BTL4GaugeAlarmManager::ReadGaugeAlarmStreamItem -- THE REAL BODY, from
     @004cc2fc + helpers @004cc108/148/1a0/264/27c + tables @0051cf1c..0x51d084
     (gotoEngineering 0x80 = the subsystem's QUAD-SELECT bezel button via
     lamp[aux]/mode[aux] tables; 0x81+ descend the eng-page bank; Condenser /
     Generator specials on CoolantLeaking; <0x80 = the heat-bank fixed map).
     btl4galm.cpp's old bodies were admitted fabrications and its provenance
     note ("no override body exists in the image") was wrong -- corrected.
  -> LampManager::FindLamp (@00444c80) -> Lamp::SetAlertState (@00444e64, a
     COUNTER so stacked alarms hold the flash) -> L4Lamp::NotifyOfStateChange
     emits RIO flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239) -> the
     pod's physical lamps AND the glass panels (PadRIO IS rioPointer there).

FOUR load-bearing defects found and fixed en route -- each independently fatal
to the feature:

  1. HeatableSubsystem's Derivation chained Subsystem directly, SKIPPING
     MechSubsystem (written before the WAVE-1 re-basing) -- so EVERY subsystem
     on both family branches failed IsDerivedFrom(MechSubsystem), which is
     exactly MechTech's monitor filter: the status scan watched NOTHING.
  2. MechSubsystem::GetStatusFlags was non-virtual (binary: vtable slot 12) --
     the scan's MechSubsystem* call bound statically to the base tier and the
     weapon bits could never surface.
  3. ProjectileWeapon::GetStatusFlags sat in a Ghidra export gap -- raw-disasm
     @004bbf88: base | 0x20 (weaponAlarm==5 Jammed) | 0x10 (linked bin
     cookOffArmed@0x18C AmmoBurning).  The port had "defer to base".
  4. Mech::Reset's respawn sweep blanket-cast every roster entry to
     MechSubsystem and called RespawnRepair -- wrong for the Subsystem-level
     entries (MechTech 0xBDC, SubsystemMessageManager 0xBD3).  On MechTech the
     recon damageZone slot lands on its subsystemMonitors chain head: NULL
     while the scan was broken (fix #1's bug MASKED this one), but the moment
     the monitors populated, RespawnRepair virtual-called through a
     SubsystemMonitor as if it were a DamageZone -> load-time crash in
     StateIndicator::SetState (caught with cdb; call [edx+14h] on code bytes).
     The sweep now filters IsDerivedFrom(MechSubsystem) before the cast.

Also: GUID identities pinned -- 0x50f4bc = PoweredSubsystem::ClassDerivations
(via @004b1208 = its TestInstance; btl4gau2's two "Generator" comments were
wrong, swept), 0x50fb60 = Generator's.  A shadow-field instance documented for
the deferred de-shadow: MechSubsystem::damageZone re-declares the PUBLIC engine
Subsystem::damageZone (SUBSYSTM.h:159) -- mechtech's naive `sub->damageZone`
read the never-written engine member (0 monitors again); now reads the recon
member via GetDamageZoneProxy().  Logged in open-questions.

Wiring: BTL4GaugeRenderer now constructs + assigns the BTL4GaugeAlarmManager
(the base ctor NULLs it and Activate Check()s it); MechTech's Report*/
StatusMessageSink stubs are real; alarmModel confirmed baked (= 83) at runtime.

VERIFIED LIVE (scratchpad/lampflash.py, BT_LAMP_LOG chain trace):
    [techstat] MechTech id 32 monitors 29 subsystems, alarmModel 83
    [techstat] LRM15_1 condition 4 SET (alarmModel 83)      <- bay fire armed
    [galarm] condition 4 code 0x80 -> lamp 0xd mode 0x1 FLASH
    [lamp] 0xd <- 0x37  (FLASHING)                          <- the select button
    [galarm] condition 4 code 0x85 -> lamp 0xb mode 0x4 FLASH   <- engEject
    [techstat] LRM15_1 condition 4 CLEARED                  <- detonation clears
    [techstat] AmmoBinLRM15_1 condition 0 SET               <- bin Destroyed
Regressions: baytest PASS (bay fire kills), baypurge PASS (purge extinguishes),
sim3 3-pod brawl PASS with ZERO crashes (6 kills, organic heat-route bay fires,
respawns clean through the new sweep filter).

Env: BT_LAMP_LOG ([techstat]/[galarm]/[lamp]).  KB: gauges-hud (the flash
section), decomp-reference (the GaugeAlarm closure + tables + GUIDs),
open-questions (built-note + the de-shadow deferred item), btl4gau2 comment
sweep.  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:04:34 -05:00