Commit Graph
314 Commits
Author SHA1 Message Date
Joe DiPrimaandClaude Fable 5 5410371b0c #86: destroyed weapons can no longer fire -- the fire gates read a
never-written cell (the split-cell gotcha)

Both weapon fire gates (ProjectileWeapon gate 1 @4bbd36, Emitter
hard-failure @4baab9) test the binary's subsystem+0x40 for Destroyed(1).
In the 1995 layout that offset sits INSIDE the embedded status alarm
(statusAlarm@0x2C + level@+0x14 = 0x40) -- ONE cell, written by
ForceCriticalFailure when a zone's crit cascade kills the subsystem.  The
port models the same address as TWO members: the AlarmIndicator AND a
plain int simulationState@0x40.  Every destruction path writes the ALARM
(so the MFD draws its X correctly) while the gates read the plain int,
which nothing ever writes -- so a weapon on a blown-off arm showed
destroyed on every panel and kept firing and scoring, locally and on
peers (night-7: all three testers, screenshots of a missile leaving a
destroyed pod).

The tell had been sitting in our own logs for weeks:
  [ammo] SRM6_1 -> NoAmmo (gate1): destroyed=0 ...
on a mech whose launcher was X'd out.

Fix: both gates now read statusAlarm.GetLevel()==1 as well as the int.
Also added: the crit-cascade log names the destroyed subsystem, a
BT_SELF_DAMAGE_ZONE=dz_* named-zone bench mode, and BT_KILL_SUBSYS=<name>
(force ForceCriticalFailure on one named subsystem -- the exact call the
zone cascade makes, so a bench can ask 'the panel says dead, does it
still shoot?' without hunting for the zone that carries a given weapon).

Verified A/B in ONE run: before the kill both SRM6 launchers fired 2
salvos each; after, the destroyed launcher fired ZERO (gate log
destroyed=1) while its twin kept firing normally.

KB: new gotcha #22 (the SPLIT CELL -- one binary offset, two port
members, only one written; sibling of #1) + combat-damage entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 12:11:09 -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 ba4af90d6b phase-13 log: night-7 session research state (collision economy, myomer system complete, coolant leaks, the seek 4.0-vs-4.10 drift) + open-questions entry for the seek player version-probe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:55:42 -05:00
Joe DiPrimaandClaude Fable 5 c18929b23a KB: manual cross-check -- myomer seek 'Super Charge' top speed is 4.0-manual behavior DROPPED by the 4.10 binary (stat sheets: Normal 143 / Super Charged 182 / Gimped 40 kph; printed ratio 1.27 vs 4.10's vestigial gear ratio 1.43)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:34:42 -05:00
Joe DiPrimaandClaude Fable 5 58f49ed942 KB: the seek dial's real stake is TORSO TWIST (PowerWatcher zeroes twist while myomers not Ready); generator has no load model -- gears 1-3 identical on a healthy mech
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:25:54 -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 19fe1e5fa2 KB: the wall-grind knockdown storm IS the surviving #82 skating -- A/B-isolated
Four-config matrix (mp_gimpAB.sh, [animind] replicant-state verdict):
grass straight/circle = clean replicant limp (198 gimp entries; 17 car
crunches during it -- crushable path broadcasts nothing, cars exonerated);
arena1 straight = one wall strike, replicant recovers into the limp;
arena1 circle = wall GRIND -- the bmp stagger clip's ~6.5 u/s root motion
re-strikes the wall (6.5^2 > iv2 threshold 40), each rebind re-broadcasts
the type-5 knockdown (26x in the user's run), and every peer's replicant
lives in knockdown/recovery churn: gliding + trn leg-lifts = "skating".
Turning while gimped is innocent; single strikes are benign.

Authentic fix filed in open-questions: reconstruct the consumer of the
stagger's action-request flags (FUN_004a4c54 bits 1/0x20 -- the drive
suppressor while the bmp clip plays); the rebind guard at mech4.cpp:6861
is already marked bring-up-pending exactly that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:41:56 -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 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 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 DiPrima cfa52a28d8 Merge remote-tracking branch 'origin/glass-cockpit-refit' 2026-07-29 19:56:31 -05:00
Joe DiPrimaandClaude Fable 5 18766a167b locomotion.md: correct the gimp-audio verdict -- the reverse-disabled voice is real and wired
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:23:22 -05:00
Joe DiPrimaandClaude Fable 5 7838df2924 locomotion.md: the gimp audio verdict (servo loop, no voice line) + rebind watch item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:41:33 -05:00
Joe DiPrimaandClaude Fable 5 0fd33531a4 locomotion.md: record the gimped turn-step relocation find
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:45:51 -05:00
Joe DiPrimaandClaude Fable 5 3db1062eb6 limp demo bat + the glass controls doc fix (SHIFT is throttle, not W)
run\limp.cmd: double-click #78 demo -- novice copy of DEV.EGG (expert crits
can kill the myomers and fake a no-drive bug), 2x60 into a bhk1 leg zone via
BT_SELF_DAMAGE_TICKS, BT_KEY_NOFOCUS for the glass plasma-window focus trap,
and the REAL controls in the header.  build-and-run.md: the "WASD drive" line
predated the glass merge -- the default profile drives with the 1995 throttle
lever (SHIFT/CTRL slew + stick, ALT reverse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:34:43 -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
CydandClaude Opus 5 aa788bc56d noframe: drop the drag-by-surface -- it is a finishing step, not a mode
Author's correction to the shape of yesterday's option.  The intended workflow
is: place the displays where you want them WITH their frames, quit, then edit
glass_layout.cfg to turn the frame off.  So a ,noframe window should be pinned,
not draggable -- removed the WM_NCHITTEST -> HTCAPTION handler that made the
surface a drag handle.

The arrangement being finished is the point: with no caption there is nothing
to drag it by, which is exactly what you want on a wall of monitors.  Delete
the flag to get the frame (and the dragging) back.

Unchanged and still needed: SaveLayout writes the flag back, because any later
drag of a FRAMED window rewrites the whole file and would otherwise strip it.

Re-verified live:
 - Heat MFD comes up with WS_CAPTION clear and answers WM_NCHITTEST on its
   surface with HTCLIENT (pinned) -- no window reports HTCAPTION any more.
 - Comm MFD (unflagged) keeps its caption.
 - A framed window's WM_EXITSIZEMOVE rewrote the file and BOTH ,noframe flags
   survived, with the new header explaining the arrange-first workflow.
 - surround / exploded / pod / dev boot and simulate clean.

Docs updated to describe the two-stage workflow rather than surface dragging:
the file's own header, environ.ini's BT_GLASS_LAYOUT block, context/
glass-cockpit.md and the ledger (which records the removed behaviour so the
next reader does not re-add it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:26:11 -05:00
Joe DiPrimaandClaude Fable 5 e110b10ac8 the #73 pick dig, part 2: there was never a software pick -- the division card cast the ray
The hunt for the un-decompiled pick writer ends with the reason no scan could
find it: it does not exist. In 1995 the pick was a DPL SCENE INTERSECTION run
by the video board. The evidence converges from five directions:

  - The WinTesla renderer still carries the result slots: dplHitInstance /
    dplHitDCS / dplHitGeoGroup / dplHitGeometry + vehicleReticle, NULL-inited
    in the ctor and never fed by the port.
  - The stubbed 1995-era renderable constructors each took dpl_isect_mode_obj
    ("type of intersections to do on this object") plus an intersection MASK
    -- every scene object was configured for ray queries.
  - Auric, quoted in the KB long before this dig: "the pod's division card
    cast from the view."
  - VGL Lynx's night-6 LOD warning ("the hit test may run against a different
    LOD") reads as firsthand knowledge: the ray tested the DRAWN geometry.
  - Exhaustive byte- and pseudocode-level scans: nothing in the binary writes
    rayIntersection/targetEntity/targetDamageZone. Game code only reads them,
    constructs them (the Mech ctor @0x4a1674 -- identified this dig, along
    with vtable +0x18/+0x1c = Mech::Read/WriteUpdateRecord, nine replication
    groups, reticle not among them), gates them (FUN_004afd10, the look-state
    machine: per-view crosshair positions, the pi rear case, the per-weapon
    rear-fire mask walk), and ships them to the board for drawing
    (FUN_00460a7c packages rayIntersection + elementMask into dpl).

What this means for #73: aimed fire in 1995 had PER-PART precision -- scene
ray, struck triangle on the active LOD, the DCS is the segment, the segment's
dzone is the credited zone. The cylinder lottery was only ever the UNAIMED
path. The port's whole-mech box pick funnels aimed fire through the unaimed
lottery, which is exactly what three testers documented on night 6: aim at
the arm, get the spray.

Fix design recorded in the KB: a per-SEGMENT ray test on the shooter side
(inverse(segmentWorld) * ray vs each segment BGF's local extent box, nearest
wins, its dzone dispatched as the aimed zone), falling back to box+lottery
when no segment resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:19:52 -05:00
Joe DiPrimaandClaude Fable 5 bcbc7cff12 the #73 pick dig, part 1: mech+0x36c is an embedded engine Reticle -- the writer is still at large
Chasing the un-decompiled targeting pick (the residual of #73). The hunt for
"who writes mech+0x37c/0x388/0x38c" kept coming back empty for a structural
reason now understood: those three fields are the tail of an embedded ENGINE
Reticle struct at mech+0x36c (RETICLE.h lays out position/state/pickPointingOn/
rayIntersection/targetEntity/targetDamageZone/elementMask, landing exactly on
0x37c/0x388/0x38c). Writers carry &mech->reticle and use small reticle-relative
offsets, invisible to any mech-relative displacement scan.

T0 corroboration, ENTITY3.h:131: "For BattleTech, damage zones are only valid
via reticle based weapons."

Ruled out as the writer: the engine Reticle itself (passive container -- ctor
and resource parse only); HudSimulation @0x4b7830 (holds &owner->reticle in esi
for its whole body but only READS the pick -- range caret, designator
transform -- and slews reticlePosition via the @0x4b7ed4 ease); the 0x482xxx
sites (mission-table target bookkeeping, address-of computations); the gyro
coefficient reads at 0x4b2c24/5c/6e (that class's own +0x370..0x38c table).

Still dark, with the candidates mapped: the un-exported 0x4a1674-0x4a2d48
stretch (reticle-touching at 0x4a16a5 in the Make-time init, 0x4a1f93, and a
state+pickPointing+base trio at 0x4a294a-61 near entry 0x4a2971), the two
unidentified Mech vtable overrides +0x18/@0x4a122c and +0x1c/@0x4a0c2c (both
big switch functions), and a few lone sites. One of these computes the
authentic pick; recovering it answers whether the pod tested the aim ray
against the mech's cylinder (as the damage table's geometry suggests) or a box
like the port's stand-in.

Recorded in combat-damage.md \xc2\xa7Targeting so the next session starts from the map
instead of the empty scans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:09:01 -05:00
CydandClaude Opus 5 f5f4c6c198 glass_layout.cfg: ",noframe" per-line option strips a window's title bar
Append it to a window's line and that window becomes a bare WS_POPUP -- no
caption, no border, just the display and its buttons -- for a multi-monitor
wall where the chrome is only noise:

    Heat MFD=1920,0,657,539,noframe

Options are comma-separated after the four numbers and unknown ones are
ignored, so an older build reading a newer file loses the option but never the
line (the bindings.txt grammar rule, applied here).

TWO THINGS THAT WOULD HAVE MADE IT A TRAP, both handled:

 - A frameless window has NO TITLE BAR TO DRAG, which is the entire point of
   the sticky layout.  So a noframe window is dragged BY ITS SURFACE:
   WM_NCHITTEST returns HTCAPTION anywhere that is not a button, HTCLIENT over
   one.  Buttons stay clickable, and because Windows drives the move the drag
   still ends in WM_EXITSIZEMOVE -- so it still saves.
 - SaveLayout rewrites the WHOLE file, so it writes the flag back.  Without
   that, the first finished-drag after adding the option would have silently
   stripped it.

Ordering: the flag is read BEFORE frame sizing (a quiet LoadLayout pre-pass,
then the normal pass after ComputeLayout), because AdjustWindowRect -- and
therefore the ring placement -- depends on whether a window carries chrome.
The pre-pass is quiet so the restore still logs exactly once.

Verified live: two flagged windows came up with WS_CAPTION clear while the
other five kept it (read back with GetWindowLong(GWL_STYLE)); a click still
dispatched on the frameless radar (CLICK 'Secondary / Radar' addr=0x18); and a
WM_EXITSIZEMOVE save round-tripped both flags back into the rewritten file.
surround / exploded / layout=off / pod / dev all boot and simulate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:57:00 -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 f7cf9850b1 crit panel: the roll has no caller and the sink is a stub -- filed as #80
Night-6 observation (Conn Man, with screenshot): armor panel showing damage,
Critical damage display showing no crits. Investigated; it is a real port gap
in three layers, stacked on one authentic fact.

The authentic fact: the paper doll shows per-ZONE armor tint and the Critical
view is a per-SUBSYSTEM list. Different data by design -- the panel staying
dark while armor accumulates is correct right up until a subsystem takes
critical damage.

The gap: subsystem critical damage essentially cannot happen.

  1. Mech__DamageZone::CriticalHit @0049ccc4 -- the authored roll, half the
     hit to armour, half to one critical subsystem by criticalWeight -- has
     ZERO callers in the port. A raw byte-scan of the binary finds exactly
     one call site, @0x4a0461, and it sits in the un-exported decomp gap
     (nothing covers 0x4a03xx-0x4a05xx, the same dark region as the
     targeting-pick writer). The trigger conditions are unknown.
  2. MechSubsystem::TakeDamage is an empty bring-up stub (btstubs.cpp:179),
     so even a wired caller would measure a delta of zero through
     ApplyDamageAndMeasure.
  3. The only live crit sources are zone destruction (SendSubsystemDamage)
     and ammo cook-off (DistributeCriticalHit), which pin subsystem damage
     directly -- so the panel can light after a zone is destroyed outright,
     never from accumulating fire.

#28 (the vital-subsystem-crit death path) very likely shares this root and is
cross-linked. Recovery plan on #80: raw-disasm the 0x4a04xx container for the
trigger, dump MechSubsystem vtable 0050e210 slot +0x24 for the real TakeDamage
body, wire both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:06:28 -05:00
Joe DiPrimaandClaude Fable 5 a5dd0eabc5 issue #73 investigated: zone selection is a weighted lottery -- per-limb aiming never existed
Night-6 report: three players, controlled conditions, "fired only at the left
arm, damage credited all over the paper doll, no crits". Chased it to the
bottom and the bottom is the authored 1995 model, working as designed.

The zone a hit credits is chosen by dice, in binary-faithful code reading
binary-shipped tables (dmgtable.cpp, stream format byte-verified): impact
point -> height layer (floor(layerCount*y/heightRef)) -> pie slice
(atan2(z,x) around the vertical axis, optionally rotating with live torso
twist) -> DamageZonePercentTable::SelectZone() = RandomUnit() rolled against
cumulative percent thresholds (@0x49de14). Every slice carries an authored
DISTRIBUTION of zones. Aiming at a limb at best biases which slice you
strike; the zone inside it is a weighted roll. Pixel-precise limb damage
does not exist in Tesla 4.10.

Measured live to be sure the code read was real: solo dummy, walked to 8u,
aim pinned left/center/right across the silhouette, trigger held. 24-46
hits per aim point, each spread across 6+ zones (the percent tables), with
the distribution shifting by aim point (the slice selection responding).
Both halves of the model visibly working.

What remains genuinely open, and is now the whole of #73: the shooter's pick
point comes from Mech::PickRayHit, a whole-mech AABB slab test -- a port
stand-in, since the binary's 0x37c/0x388/0x38c writer sits in an un-exported
gap nobody has decompiled. Theta computed from a flat box FACE clusters
toward the slices facing the shooter, so flank slices (presumably arm-heavy)
may be under-reachable from frontal shots compared with the pod, which
plausibly intersected the mech's cylinder -- the damage table is literally
cylindrical. Deciding that needs a per-hit theta probe and a one-shot wheel
dump; if confirmed, the fix is a ray-vs-cylinder pick replacing the box slab.
Filed on #73 with the plan.

KB: combat-damage.md gets the lottery model as a load-bearing triage fact --
"damage landed somewhere I didn't aim" now has a documented base rate, and
crit expectations from aimed fire are probabilistic by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:42:23 -05:00
Joe DiPrimaandClaude Fable 5 dca2586aa8 the Owens crash: a device reset that never waited for the device (#35)
Eight byte-identical field stacks from night 6, all one player, all in an
Owens: ParticleEngine::Destroy +0x11, access=0 target=0x0, from the plain
per-frame render path. Nothing in the stack touches weapons or the Owens.
Conn Man's Surface Pro 9 (Iris Xe, 128 MB shared) is simply the only GPU in
the fleet that ever actually LOSES the D3D9 device -- his two-trigger
missile+laser bursts are what provoke the timeout, not what crashes.

What crashed is our device-loss handling, which was wrong three ways at once,
in two inline copies (the scene Present and the wait-screen Present):

  1. On D3DERR_DEVICELOST it called Reset() IMMEDIATELY. Reset on a
     still-lost device ALWAYS fails, and V() only logs. There was no
     TestCooperativeLevel gate at all.
  2. It then ran ParticleEngine::Initialize against the lost device. The
     creates fail there and NULL their out-params -- proven, not assumed:
     the bench repro faults at target=0x0, not at a dangling address.
  3. The next lost frame called ParticleEngine::Destroy again, which
     Release()d those NULLs blind. Read of vtable at 0x0. Dead.

So: lost frame 1 tears down and leaves NULLs, lost frame 2 crashes. Two
frames, every time, deterministic -- which is exactly why all 8 field stacks
are byte-identical.

Reproduced before fixing. BT_DEVICELOST_TEST=<frame>,crashrepro runs the
field sequence on the bench; on the unfixed build it died at Destroy +0x11,
access=0 target=0x0, and symbolized to the same four frames as the field
logs. Same shape, same offsets-modulo-hook. That run also proved the
out-param-nulling assumption the whole diagnosis rested on.

The fix -- one shared DPLRenderer::BTResetLostDevice() replacing both inline
copies:

  - Destroy() is idempotent and null-safe, and nulls after release.
  - Reset() is gated on TestCooperativeLevel() != D3DERR_DEVICELOST; while
    the driver still says lost, skip the frame and retry.
  - The Reset HRESULT is checked; on failure, log and retry next frame
    instead of driving on.
  - On success, re-create via the new CreateDeviceObjects(), NOT
    Initialize(): Initialize memsets the installed-effects table, so every
    reset that DID succeed silently killed all particle effects for the rest
    of the mission. The quieter sibling bug, fixed by the same split.
  - Initialize checks its HRESULTs and defends MAXPARTICLES<=0; the draw
    paths guard the NULL buffer, and ExecuteParticles keeps draining
    particles while the engine is dormant so they cannot pile up.

Verified: the crashrepro shape now logs SURVIVED and play continues; three
forced full loss/reset cycles each log "[render] device reset OK"; a plain
run is assert-free.

Found while verifying, worth its own line: VIDEO\particles.png has NEVER
existed -- not in the tree, not in BTL4.RES, not anywhere in git history.
The texture load has failed on every machine since the engine was written,
and every billboard particle ever rendered was untextured quads via
SetTexture(0, NULL). RenderParticles deliberately does NOT gate on the
texture -- that would disable all particles everywhere; untextured IS the
shipped look. Filed separately; a real particle sheet is a content task.

The field verification that counts is Conn Man flying his exact crash
loadout on this build: instead of a dead process he should see at worst a
brief hitch and "[render] device reset OK" in his log. #35 stays open until
that happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:21:41 -05:00
CydandClaude Opus 4.8 29c502df83 Glass windows remember where you drag them (BT_GLASS_LAYOUT)
The BT_GLASS_PANELS windows self-place in a pod-faithful ring and re-snap
once the main window is up.  They carry WS_CAPTION so a dev can drag them,
but the drag never survived the menu->mission->menu relaunch loop.

BT_GLASS_LAYOUT (L4GLASSWIN.cpp) adds opt-in persistence to a cwd-relative
glass_layout.cfg beside bindings.txt (gitignored):
  off/0/unset  computed ring only, no file I/O (default, pod-faithful)
  load         restore saved positions on startup (per-window fallback
               to computed); never writes
  save/adjust  restore first, then rewrite on each finished drag
               (WM_EXITSIZEMOVE) and on teardown -- the round trip

One "<title>=x,y,w,h" line per window; position restored, size ignored
(frame size is deterministic from content, so an old w,h can't distort a
later geometry change).  A restored window is flagged so ComputeLayout's
post-main-window re-snap leaves hand-placed windows alone.  Native
analogue of TeslaRel410 pod-launch's per-rig --bridge-pos/--layout args.

Verified [T2 runtime round-trip]: load restored 7 from a seeded cfg;
save wrote all 7 with a window moved to 777,333 (GetWindowRect read the
live window); relaunch in load put that window physically at 777,333
after the re-snap fired.  Release links clean (only the 40 tolerated
/FORCE externals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:27:26 -05:00
Joe DiPrimaandClaude Fable 5 bc27313f20 sweep the knowledge base up to this session: log day, crash symbolization, a misnamed accessor
Audit of what actually landed in context/ versus what only ever got said in
chat. Five gaps and one stale claim repeated in four files.

New env gates were the biggest hole -- BT_TORSO_LOG, BT_MYOMERS_LOG,
BT_MYOMERS_REPAIR_TEST, BT_SELF_DAMAGE and BT_POWER_DETACH_TEST were all in the
code and none of them in decomp-reference §6, which is supposed to be the hub.
Added with the reasoning that makes them usable: why the myomers probe samples
every frame while unpowered (the NoVoltage window is about a second and a 1 Hz
probe steps straight over it), why the repair test also forces Manual (or
AutoConnect restores power a frame later), why SELF_DAMAGE latches off at the
first death, and why DETACH_TEST taking a NAME is what proves failover rather
than a same-generator re-attach.

The 6am log day was in the code and the player bats but not the KB. Now in
build-and-run with the stem table, the unconditional append, the 8 MB part
roll-over and the BT_LOG-truncates trap, plus the operator-facing note in
operator-console and OPERATOR_GUIDE that you ask for the NEWEST log, never
"today's".

Recorded the crash-symbolization gap, which is the one with teeth. BTCrashFilter
writes a stack into the day log and its own comment claims "we hold the PDB". We
do not: no Release PDB is produced at all, and /O2 implies /Oy so the EBP walk is
unreliable anyway. When the Owens crash finally lands we get an address we cannot
resolve. Written down with the fix (/Oy-, /Zi + /DEBUG, archive the PDB per
build) rather than left as something I mentioned once.

New gotcha 22: HeatModelOff() reads simulationState == 1, i.e. "am I destroyed",
and has nothing to do with the heat model. Behaviour at the call sites is right,
the name is not, and while chasing #70 it reads as "novice pilots cannot twist"
and sends you hunting an experience-level bug that does not exist. Same misnomer
in torso/gyro/sensor headers.

Also documented the dist flavor trap, having nearly shipped it myself: mkdist
reads build/CMakeCache.txt, so a tree configured BT_STEAM=OFF silently produces
a -nosteam zip with no play_steam.bat and no steam_api.dll. OFF is the CMake
default; ON is the documented dev state.

Swept the stale `btl4.log` filename out of reconstruction-gotchas,
reconstruction-method, build-and-run and CLAUDE.md itself -- the log has been
<stem>_YYYYMMDD.log since 1777d5a and every "read btl4.log" instruction was
pointing at a file that is no longer written.

checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:23:29 -05:00
Joe DiPrimaandClaude Fable 5 ca6718a876 turrets: correct the record -- the props are scenery, but PGN is a real turret that was cut
Amends what 0534e93 asserted. That commit said "the level turrets are scenery"
and called TurretClassID a settled red herring. The first half is right and the
project owner has confirmed it; the second half I stated far more confidently
than the evidence supports, and I missed the thing that explains why the old
timers keep saying turrets should be active.

They are half right, which is exactly why it reads as a mixed bag.

What shipped really is scenery. TT1/TT2/TWR/APC sit in BTL4.RES as
model + damaged-model + name + default with a .sld collision solid -- no
skeleton, no joints, no gun port. The destructible world classes in CULTURAL.h
have no weapon, fire or target members, and the binary has zero occurrences of
turret/sentry/emplac/brain/patrol/aggro/hostile/npc.

But PGN is a genuine, fully-articulated gun emplacement, and I walked straight
past it the first time because I filtered it out as a mech prefix:

  [ROOT]         pgn_base.bgf   dzone=dz_base        static base
  [jointturret]  hingey         pgn_tur.bgf          yaw   = traverse
  [jointgun]     hingex         pgn_gun.bgf          pitch = elevation
  [sitegunport]  tranz=-9.8505                       the muzzle

Traverse, elevation, a firing site, a damage zone, PGND* destroyed geometry, and
no legs or arms or torso -- not a mech. It exists only as loose source assets;
BTL4.RES contains zero pgn bytes in any case. So turrets were designed and
modelled and then cut before the content build. Finishing them would be
completing a cut feature rather than inventing one, but no turret code survives
to reconstruct -- it would still need an entity class, targeting, and
replication, since BT is networked PvP.

Its [LAB_ONLY] "not approved for release" header means nothing, by the way: 63
of the 64 .SKL files carry it, MadCat included. Checked before reading anything
into it.

Also flagging a ClassID conflict rather than papering over it. Counting the enum
in VDATA.h gives ThermalSight 0xBD9 and Turret 0xBDE, but CLASSMAP assigns 0xBDE
to ThermalSight from the ctor at @4b8718 -- and that ctor's vtable and
performance pointer do match thermalsight.cpp. The enum count agrees with
CLASSMAP on HUD and Searchlight and disagrees on MechTech, so the port's VDATA.h
ordering has drifted from the shipped binary somewhere in that range. CLASSMAP is
binary-derived and wins for runtime behaviour; the real numeric value of
TurretClassID is unknown. Marked UNRESOLVED so nobody cites either number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:52:26 -05:00
Joe DiPrimaandClaude Fable 5 0534e93eec the level turrets are scenery: TurretClassID is a red herring, 0xBDE is ThermalSight
Playtest question: "many levels have turrets/cannons that don't fire, aren't
those supposed to shoot at players?" Chased it properly because the enum really
does look like a smoking gun.

engine/MUNGA/VDATA.h:209 declares TurretClassID as the LAST entry of the BT
block -- right after MechTechClassID, immediately before the ND section -- which
is exactly where a real BattleTech class would live. And the mech factory has a
live `case 0xbde:` for it (part_012.c:10186). Both of those point the wrong way.

That enum slot computes to 3038 = 0xBDE, and 0xBDE in the shipped runtime is
ThermalSight: ctor @4b8718, already reconstructed and done in thermalsight.cpp.
Same enum-vs-runtime label drift CLASSMAP already records for HUD and MechTech
at 0xBD6/0xBDC, which is why the rule is to resolve the ctor address and never
trust the factory case label. The case is also in the mech SUBSYSTEM factory
(roster param_1[0x4a]), not an entity factory, so it could not spawn a world
object even if the label were right.

Everything else agrees. There is no `class Turret` anywhere in the engine or the
port. BTL4OPT.EXE contains zero occurrences of turret, sentry, emplac, brain,
patrol, aggro, hostile or npc (the apparent "ai"/"bot" hits are substrings of
failureheat and verticallimitbottom). jointturret, which looked promising, is a
skeleton joint on OWN/PGN/STI with own_tur.bgf -- a mech turret-torso, not a
world gun. And the destructible world classes in CULTURAL.h -- Landmark,
CulturalIcon, UnscalableTerrain -- have no weapon, fire or target members at
all; they take damage and break, and that is the whole of it.

So the turret and cannon models in the maps (TT1/TT2/TWR/TK1/APC, each with a D
damaged variant) are scenery. They did not fire in 1995. Making them fire would
be inventing a feature rather than reconstructing one, and it would need an
entity class, a targeting model and a threat model that the binary has no trace
of -- which is consistent with the standing T1 finding that BT shipped no AI at
all and is PvP-only by design.

Written up in combat-damage.md next to the No-AI section, since the enum is
convincing enough that someone will find it again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:38:22 -05:00
Joe DiPrimaandClaude Fable 5 1af2dfc769 back the audio cap off to opt-in: 7-23 already fixed the complaint, and the cap is a governor
Correcting my own framing in 7e57816. The dropout players actually reported was
fixed on 2026-07-23 by a999e5c, and nobody has complained since. I read the
leftover ACQUIRE FAILED lines in the night-5 logs as the same live bug and
raised the ceiling by default. They are not the same bug.

What is still in the logs is transient exhaustion, not a leak: the census sits
at 45-60 live, spikes during a firefight, and drains straight back (226 -> 42),
which is the priority steal loop working. A voice dropped in that window is
competing with ~256 already sounding, so it is very likely sub-perceptual --
which is consistent with the thing nobody is reporting.

And raising the cap is not free. The 256 ceiling doubles as a governor: the
steal loop only steals when the incoming source outranks a running one, so a
higher cap means many more voices mixing simultaneously, and with EFX reverb and
the lowpass chains live that is real CPU -- spent precisely during heavy combat,
when the frame budget is already tightest. I verified the ceiling moves. I never
measured frame time under a real firefight, and a solo bench cannot produce one.
Shipping that to testers tonight would be gambling their framerate against a
complaint nobody is making.

So the knob stays and the default does not move. BT_AUDIO_SOURCES=<n> raises it
with no rebuild if dropouts are ever reported again -- measure frame time while
you do. Unset reports `granted mono=255 stereo=1`, exactly what testers have
been running.

Keeping the diagnostic read-back either way, since it earned its keep: asking
for 64 grants 240, because OpenAL Soft has a floor of its own, which is also why
the NULL default lands on 256. A request is not a promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:30:36 -05:00
Joe DiPrimaandClaude Fable 5 7e57816d39 audio cut out in firefights because nobody ever asked OpenAL for more than 256 voices
Field report from 2026-07-23 was "audio cutting in and out toward the end of the
match". The night-5 logs say it precisely: 2017 ACQUIRE FAILED lines in a single
match, and every last one of them at live=256.

It is not a leak, which is where I started and where the code comments still
pointed. The source census sits at a healthy 45-60 live, spikes during
firefights, and drains right back down afterwards -- 226 back to 42 in one
window -- so the engine's priority steal loop is doing exactly what it should:
AudioSourceStop/SuspendMaintenance -> ReleaseChannels -> ReleaseSourceSet ->
alDeleteSources, with the live counter following it down. The 2026-07-23 fix
(a999e5c, deleting sources one at a time instead of the spec-atomic bulk call)
was real and is what makes that drain work. It just was not the ceiling.

The ceiling was ours. MakeAudioRenderer called alcCreateContext(device, NULL) --
no attribute list at all -- so OpenAL Soft applied its default budget of 256
mono sources. That number has nothing to do with the 1995 audio hardware; it is
just what you get for not asking. Meanwhile a busy match logs about 7200
explosions, each spawning three DPLIndependantEffect voices, so the pool pins at
the cap and every acquire during that window fails outright and drops its sound.
The peak we actually observed was 226 against a 256 cap, i.e. the bursts were
already scraping the ceiling.

So ask. ALC_MONO_SOURCES at 1024 by default, BT_AUDIO_SOURCES to override for
A/B testing, and read back what the driver GRANTED rather than assuming the
request was honoured. That last part earns its keep: asking for 64 grants 240,
because OpenAL Soft has a floor of its own -- which is also the reason the NULL
default landed on 256 in the first place.

  requested 1024 -> granted 1024
  requested 2048 -> granted 2048
  requested   64 -> granted  240   (driver floor)

Costs mixing headroom, not hardware voices -- OpenAL Soft mixes in software and
only touches voices that are actually sounding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:24:50 -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 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 1777d5a62e one log per day, and every log says who it sent it -- rotation was eating the crash stacks
Conn Man crashed twice in an Owens on 2026-07-27, kept playing, and by the time
we asked for his logs they no longer existed.  Not a mystery: every launcher did

    if exist content\X.old.log del content\X.old.log
    if exist content\X.log     ren content\X.log X.old.log

which keeps TWO generations.  He relaunched more than twice, so his own bat
deleted both crash sessions.  The player who crashes is exactly the player who
relaunches immediately, so the retention window was aimed at the wrong case.

THE LOG.  BT_LOG set still means "use this path verbatim" -- that contract is
load-bearing (mp_a.log/mp_b.log for 2-node runs from one cwd, btoperator's
operator_N.log, every scratchpad/mp_*.sh that greps the file it named) and is
untouched.  Only when BT_LOG is UNSET does the exe now name the file itself:
content\<stem>_YYYYMMDD.log, appended, one per day, no rotation window to fall
out of.  The stem (solo/join/steam/joyconfig) comes from the gates the bat
already sets, so solo still cannot overwrite the MULTIPLAYER log.

Self-named files append UNCONDITIONALLY.  The default open mode is ios::out --
TRUNCATE -- and the operator GUI's exported bats never set BT_LOG_APPEND, so
they were truncating already; leaving append implicit would have let the second
launch of the day erase the morning.

IDENTITY, because these arrive from many players at once and Discord renames
half of them to message.txt (most of the night-5 evidence had to be
re-identified by hand):

    ===== BT411 SESSION  build=4.11.603 (d64d75f+)  machine=...  user=...
          callsign=Conn Man  mode=solo  pid=13192  local=...  log=... =====

Also the session separator inside a per-day file, and it puts build+hash ABOVE
any [crash] block so btl4+0xNNNN offsets stay matchable to a PDB across builds.

SIZE.  Never delete, but stay sendable: past 8 MB the next launch rolls to
<stem>_YYYYMMDD.1.log.  Checked only at open, so a live session is never split.

CONSOLIDATION.  launch_report.txt is gone, folded into lastrun_<stem>.txt (one
launch record: the exe appends its block at first breath, the bat appends the
exit line).  Per-stem because one shared lastrun.txt let a second launcher's
`del` destroy the first launch's block.  Its ABSENCE after a run is now the
#41 "never reached WinMain" probe -- the old "if not exist X.log" test cannot
work against a per-day file, which survives earlier launches, and would have
reported every healthy run as blocked by antivirus.  marshal.log folded into
the day log too; it keeps its own handle and gained a CRITICAL_SECTION because
it runs on a worker thread while the engine log stream is single-threaded.
play_steam.bat gained a launch bracket it never had -- which is why a Steam
player killed before WinMain previously left no evidence at all.

_putenv_s, not SetEnvironmentVariableA, to pin the resolved name: MSVC's CRT
keeps its own environment copy, so getenv() in-process does NOT see a
SetEnvironmentVariableA write (measured).  btl4console/btl4lobby resolve the
day log via getenv, so with the Win32-only call they silently fell back to
marshal.log and the fold never happened.

logfile.open() is now checked -- a failed open used to rebind cout to a dead
buffer, silently dropping the header, [boot] and any crash stack while
lastrun still claimed log=<name>.

Verified: append across sessions with distinct pids, crash block landing under
its own header, BT_LOG verbatim unmangled, an inherited BT_LOG cleared by the
bats, roll-over at 8 MB, and both forensic verdicts (exe ran / exe blocked).
Console auto-retrieval is unaffected -- it uploads the matchlog, a different
file, and a crashed round never reaches the upload call anyway.

Known gaps, deliberately not in this commit: the setlocal hoist for gate
leakage when two bats share one console (dev-only), and btoperator's exported
bats still lack the lastrun bracket -- do not press Export on a shipped zip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:33:07 -05:00
arcattackandClaude Opus 5 7afbda900e the terminal that never closes: every launcher waited for ANY btl4.exe on the machine
Operator report: "X-closing the game leaves the terminal open and leaves
orphaned processes."  Investigated on the rig against 4.11.600.

The terminal half is real and is THIS: :btwait polled
`tasklist /FI "IMAGENAME eq btl4.exe"`, which is machine-wide, so a bat that
launched nothing at all keeps spinning while an unrelated instance lives --
proved with btwait_probe.ps1.  A second client, the operator's own pod, or an
orphan from a crash therefore hangs every join window, which reads as "the game
never exited" and invites people to start killing processes.  All four
launchers carried the identical block.

Fix: snapshot the btl4 PIDs alive BEFORE the launch; wait only on PIDs absent
from that snapshot.  The `if /I "%%P"=="btl4.exe"` guard is deliberately kept --
tokens=2 alone parses tasklist's "INFO: No tasks are running" line as a PID and
spins forever with nothing running, which would be worse than the bug.

Verified with the text lifted verbatim from the shipped play_solo.bat: nothing
running -> signs off (the regression guard); someone else's instance -> signs
off; our own generation -> keeps waiting; decoy gone -> signs off.  The patched
bat still launches (pid + launch_report.txt).  NOT verified: the full handoff
E2E, because the bat blocks on the FE menu waiting for a human.

The orphan half did NOT reproduce on 600: closing the MAIN window exits cleanly
in ~1s during solo model-load, in the relay join wait, and after a real console
launch, with the relay logging the seat freed.  The orphans the playtesters saw
match 584 and earlier, where every close relaunched.  Full write-up, including
the aux windows that hide instead of closing and the WM_QUIT that BTLoadPump
swallows, in phases/phase-12-orphan-processes.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:06:39 -05:00
arcattackandClaude Opus 5 9bb95d8580 steam-networking KB: the first real multi-player field verification (night 4)
Three back-to-back Steam lobbies, 3+ players, host-and-join, no operator
console: launch worked, DAMAGE APPLIED (previously suspected dead over Steam),
2 kills, clean round end, next mission launched.  Records what worked, the one
genuinely Steam-side defect found (#68 silent exit on a failed join), and the
triage lesson -- a bug found in a Steam lobby is only a Steam bug if it lives
in the lobby/token/transport layer; the three replication symptoms that night
produced (#67/#70/#55) are above the seam and would reproduce on the relay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:55:14 -05:00
arcattackandClaude Opus 5 05fdd319d6 parked relay: a supervisor that keeps it alive + a REMOTE restart the operator can actually reach
Completes the travelling-operator deployment.  The relay stays parked on the
home machine (so no player ever edits join.bat) and the console dials in from
anywhere -- but until now two things needed hands on that machine: bringing the
relay back when it died, and restarting it (the only way to clear a wedge or
pick up a roster resize).  Both are covered now.

tools/btrelay_park.py -- the supervisor:
  * runs the relay with cwd=content\ , which is what pins WHICH
    operator_secret.txt is live (the trap documented last commit);
  * relaunches on ANY exit, with 2/5/15/30/60s backoff when a relay dies inside
    20s, so a permanent fault (port taken, missing egg) cannot become a spin;
  * rotates content\parked_relay.log to .1 first, so the dead generation's
    evidence survives the relaunch that replaces it;
  * Ctrl-C stops it for good.  NOT a Windows service on purpose: session 0
    would hide the window an operator wants to tail.
tools/park_relay.cmd -- double-click to park; a shortcut to it in shell:startup
  gives start-after-reboot with no admin rights.

`restart` on the control port (btconsole.py): sets restart_requested, the run
loop returns, relay_main exits RESTART_EXIT_CODE=42 and the supervisor relaunches
-- re-reading the egg.  REFUSED while a mission is running (launches_sent >= 2
and not stop_sent): bouncing then would drop every pod out of a live round.
Local stdin gets the same command for parity.

btoperator.py: Restart Session in REMOTE mode now sends `restart` and reconnects
6s later instead of just tearing down our own link -- the old behaviour looked
like "Restart does nothing" against a parked relay.  (QTimer had to be imported;
it was missing, which py_compile does not catch -- it would have been a runtime
NameError on the first click.)

VERIFIED by scratchpad/test_parked_supervisor.py, 12/12, and the full
test_remote_console_e2e.py re-run green afterwards (15/15):
  * kill the relay -> a NEW pid is serving again, and the old log is kept as .1;
  * remote `restart` -> ACKed, new pid, egg re-read, serving again;
  * the supervisor distinguishes the two -- "operator requested a restart" vs
    the crash/backoff path -- proven from its own log, not assumed;
  * the mid-mission guard is present and wired to launches_sent/stop_sent.

Two harness defects fixed while proving it, both mine: a check written with
`or True` that could never fail (replaced with two real assertions against the
supervisor's log), and a recv that treated the relay's correct
close-after-restart as a ConnectionResetError failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:48:36 -05:00
arcattackandClaude Opus 5 9df7dff787 KB: the parked-relay/remote-console mode is COMPLETE -- record it so it stops being re-derived
Operator caught me re-investigating a feature we already shipped today.  Root
cause was a self-contradicting knowledge base, not just my compaction: the
§Modes entry still said "Remote relay -- KNOWN BROKEN: the LAUNCH button can
never enable in this mode", while §Mode-specific traps 60 lines later documented
the same thing as FIXED.  Reading the summary line first is exactly what a
future session does.  Swept: that was the only stale copy.

Now recorded once, authoritatively (context/operator-console.md §Parked relay +
remote console, and a sysop recipe in docs/OPERATOR_GUIDE.md):

  * THE PRODUCTION TOPOLOGY the operator actually wants: the relay parked
    permanently on the home machine so no player ever edits join.bat, with the
    console dialling in from anywhere (cell internet is fine -- outbound only).
  * The exact standalone park command, and why the control port is what makes a
    GUI-less relay usable at all (backgrounded, it has no stdin).
  * The COMPLETE remote command set as a table, read out of _ctl_command /
    _ctl_get_mission / _ctl_set_mission: launch, stop, rearm|newround, get,
    set (the six CONTROL_SET_KEYS, written to the relay's OWN egg, effective
    next round), ping, plus the log stream and the roster re-issued at AUTH.
    Written down so the next session reads instead of re-deriving.
  * The hard limits: remote mode ATTACHES -- it cannot start or restart the
    relay, cannot resize the roster, cannot edit callsign/mech/colour.
  * Multiple operators ARE accepted concurrently (ctl_conns is a list, each
    AUTHing independently), so hand-off is seamless -- but nothing arbitrates
    two people pressing LAUNCH.

Two live hazards found while verifying, both new:

  1. THE SECRET'S CWD TRAP.  control_secret() opens a bare relative
     "operator_secret.txt" and SILENTLY GENERATES A NEW ONE if absent, so the
     live secret depends on the relay's working directory.  This repo really has
     TWO secret files with DIFFERENT values (repo root and content\) -- a remote
     operator holding the wrong one fails AUTH as a CONTROL_AUTH_TIMEOUT drop,
     with no "wrong password" anywhere.  Documented in both files.
  2. operator_secret.txt was untracked but NOT ignored -- one `git add -A` from
     publishing a live credential.  Now in .gitignore.

Also corrected a nearby overreach: "the console must STAY CONNECTED" is about
the console channel to the PODS; an operator GUI dropping off a parked relay's
control port is harmless and the pods never notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:15:54 -05:00