Commit Graph
458 Commits
Author SHA1 Message Date
Joe DiPrimaandClaude Opus 5 9657fbb11e control mode: REPRODUCE the "centering fought my control" fight, and prove the fix in Sauron's config
Follow-up to 4ccc2a7, which fixed the mechanism but could not reproduce the
field symptom.  His fuller wording -- the centering "FOUGHT" his control, not
"the torso died" -- is what cracked it.

WHY "FOUGHT" IS THE PRECISE SYMPTOM.  TorsoSimulation's frame order is
  1. digital twist commands   -> currentTwist += d;  recenterActive = 0
  2. centerCommand > 0        -> recenterActive = 1        (re-armed)
  3. analog twist axis != 0   -> currentTwist += d;  recenterActive = 0
  4. if (recenterActive)      -> Recenter(dt)              (drags toward 0)
Desktop/glass torso input is ANALOG (Q/E -> gBTTwistAxis -> stickPosition.x), so
with centerCommand stuck the torso HOLDS while you are actively pushing (step 3
clears the arm) and snaps back the instant you ease off (step 2's arm survives
into step 4).  You can only hold it off-centre by pushing continuously.  That is
"the centering fought my control", exactly.

WHY IT ONLY BITES THE GLASS/POD BUILD -- and why the first bench came back clean.
The ONLY caller of ClearRecenterCommand() sits INSIDE the desktop key-bridge
block, gated on `gBTDrive.forced || !BTRIODevicePresent()`.  With a RIO present
-- and on glass builds PadRIO IS the rioPointer -- the bridge is OFF and NOTHING
ever clears centerCommand, so one pass through Basic pins it at 1 for good.  A
plain desktop build clears it every frame and self-recovers.
The first modecycle.sh run needed BT_KEY_BRIDGE=1 to make the mode-cycle hook
run at all -- and that same flag switched on the only thing that clears the cell,
masking the bug under test.  The hook is now deliberately OUTSIDE that block so
the bench can run the RIO-present configuration.

MEASURED A/B, bridge OFF (Sauron's config), BT_TWIST_PULSE deflect/release:

                          LEGACY                      FIXED
  ctrCmd=1 samples        310  (latched for good)     0
  twist during RELEASE    decays 0.443->0,            HOLDS 2.44346
                          0.900->0.436  (recen=1)
  recen=1 samples         permanently armed           14 (one-shot per Basic
                                                      entry, then self-clears)

So the authentic one-shot re-centre still happens on entering Basic; it just
settles instead of fighting the pilot forever.

New bench hook BT_TWIST_PULSE=<n>: deflect the analog twist axis for n ticks
then RELEASE for n ticks, repeating.  BT_LOCK_SWEEP never releases, so it cannot
show this symptom at all -- the release window IS the measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 13:59:39 -05:00
Joe DiPrimaandClaude Opus 5 4ccc2a7eec control mode: Basic re-centre used the STICKY held-button cell -- and the elevation-limit swap was never ported
Sauron: "toggled through advanced controls from standard to advanced and back
to standard -- lost torso control."

The cycle is 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC, so getting
from "advanced" back to Standard PASSES THROUGH BASIC, whose arm re-centres the
torso.  @004afbe0 is a complete spec and the port got three things wrong:

    iVar1 = mech+0x438 (TORSO)        iVar2 = mech+0x5b4 (HUD)
    if (mode == 0) {                                 // BASIC
        *(iVar1 + 0x1f0) = 0;                        // analogTwistAxis
        *(iVar1 + 0x274) = 1;                        // recenterActive
        *(iVar1 + 0x220) = *(iVar1 + 0x228);         // vertLimitTop
        *(iVar1 + 0x224) = *(iVar1 + 0x22c);         // vertLimitBottom
        *(iVar2 + 0x2a0) = 1;                        // HUD flickerActive
    } else if (mode - 1U < 2) {                      // STANDARD/VETERAN
        *(iVar1 + 0x220) = *(iVar1 + 0x230);
        *(iVar1 + 0x224) = *(iVar1 + 0x234);
    }

1. WRONG CELL.  Basic called CommandRecenter() -> centerCommand (@0x208), the
   HELD-BUTTON cell: TorsoSimulation re-arms recenterActive from it EVERY frame
   it is non-zero, and only the input path clears it -- a mode switch has no
   button release to follow.  Digital twist commands are processed BEFORE the
   centerCommand block, so while it is set they are overridden as fast as they
   are applied: the torso stops responding.  The binary sets recenterActive
   (@0x274) directly -- a ONE-SHOT that self-clears on settle
   (`recenterActive = Recenter(dt)`) and is cancelled by any twist input.
2. THE ELEVATION-LIMIT SWAP WAS MISSING ENTIRELY.  Two authored pairs exist --
   BASIC @0x228/@0x22C (full top, HALF bottom) vs STANDARD/VETERAN @0x230/@0x234
   (the full pair) -- and all four members were ctor-written and read by NOTHING.
   Basic never restricted downward travel; the assisted modes never restored it.
3. Basic also raises the HUD's flickerActive (@0x2A0) so the horizon re-settles
   with the torso it just re-centred.  Not ported.  (New BTSetHudFlickerActive
   bridge in hud.cpp -- mechmppr sees Subsystem*, not HUD.)
Also removed an invented SetAnalogElevationAxis(0); the binary zeroes only 0x1F0.

MEASURED A/B (scratchpad/night13/modecycle.sh, LEGACY=1 for the old path;
BT_LEGACY_MODE_RECENTER=1 is the revert switch):

    ctrCmd=1 samples   legacy 26   fixed 0
    vLim pairs         fixed run shows BOTH -- (-0.698..0.349) = -40..20 deg
                       assisted, and (-0.349..0.349) = -20..20 deg Basic.
                       Before this commit only the ctor pair ever appeared.

WHAT IS *NOT* PROVEN.  I did not reproduce Sauron's PERMANENT loss.  In this
bench the legacy latch is periodic, not sticky:

    ..........LLLL......LLLL......LLLL......LLLL......LLLL......LL

because the desktop key bridge writes centerCommand every frame and zeroes it
when no button is held, so it self-recovers.  The torso IS locked while the cell
is set, which is the symptom -- but whether it stays locked depends on the input
path OWNING that cell.  On the glass/pad route (Sauron's) nothing may clear it,
which would make it permanent.  So: mechanism fixed and binary-grounded, exact
field persistence unverified.  Field-verify by cycling modes on a pad build.

Probe: the BT_TORSO_LOG gate line now carries ctrCmd / recen / vLim.
Bench hook: BT_MODECYCLE_EVERY=<n> cycles the mode from the mapper (the pod's
own route is console key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
it, and mech4's BT_MODECYCLE_TEST counter did not advance in a solo run).
NOTE the bench needs BT_KEY_BRIDGE=1: with a PadRIO present the key-bridge
block that consumes the cycle is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:36:36 -05:00
Joe DiPrimaandClaude Opus 5 cacca58836 #148 is NOT A BUG: a peer mech does not tick before RunningMission -- by engine design
Chased to the bottom instead of stopping.  The answer is that there was
nothing to fix, and my bench was lying to me.

Entity::Execute (ENTITY.cpp:556, real engine source [T0]) calls PerformAndWatch
ONLY when the app state is RunningMission/EndingMission or the entity
IsPreRunnable(); otherwise it merely WriteSimulationUpdate()s.
Entity::DefaultFlags is DynamicFlag|MasterInstance -- no PreRunFlag.  Only
Player and Director add it, and Mech::Reset sets it for a reset MASTER ("a
reset master must tick").  A REPLICANT mech never gets it.

So a peer mech performs ZERO subsystem ticks until the round actually starts,
however much correctly-replicated data is arriving.  Measured on the observer:

     235  [perf-first] mech 3:161 master      <- own mech, immediately
     402  [torso-rec-rx]                      <- peer torso records arriving
    2754  [perf-first] mech 2:55 REPLICANT    <- peer's FIRST performance
    2758  [torso] PushTwist COPY              <- its torso ticks 4 lines later
    2761  [ent-exec] state=5                  <- RunningMission

The peer starts performing exactly at the RunningMission transition.  That is
the engine doing what it says.

WHICH MEANS THE PREFIX WAS A BENCH ARTIFACT.  BT_AUTOFIRE starts shooting
immediately, during WaitingForLaunch -- something no player can do in a real
match -- so those 60 leading salvos measured a peer whose torso had never run.
Every "ZZZZ...XXXX" pattern in this investigation was that, and the first X
lands within a few lines of the state transition.  #141's fix is unaffected and
remains verified: the segment-cache defect was real and mid-match.

Chain of things ruled out on the way, all measured:
  * record CADENCE is authentic -- sends on RATE CHANGE (payloads are the sweep
    extremes, rate flips sign), peer dead-reckons between them.  12 records for
    12 reversals is correct, not starved.  My "only 13 records" premise was wrong.
  * ComputeTargetTwist clamp -- limits load fine on the copy (+/-2.44346).
  * the torso's own executable flag -- restoring the engine's instance branch
    (f36f013) is a genuine fidelity fix but moved this by nothing.
  * the replicant entity IS offered to the performer, executable=1, from line
    171 -- 2500 lines before its first PerformAndWatch.  The gate was inside
    Execute, not the scheduler.

Adds [perf-first]: a ONE-SHOT per-mech receipt naming entity ID + instance at a
mech's first performance.  Every other per-frame receipt in mech4 is anonymous,
which is precisely why this took so long in a 2-node log -- master and replicant
lines were indistinguishable.  Name the mech.

Gotcha #29 records the bench-design rule this cost: judge a 2-node bench by
PREFIX vs INTERLEAVED, never by raw percentage, and check [ent-exec] state=
before suspecting replication.  missileframe.sh carries the same warning.

#148 to be closed as not-a-bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:11:14 -05:00
Joe DiPrimaandClaude Opus 5 f36f0136c8 #148: restore the ENGINE's replicant instance-branch in the Mech subsystem tick
Correct on its own merits as a fidelity fix; it is NOT the cause of #148, and
I am not claiming it is.

Entity::Perform (ENTITY.cpp:733-793, real engine source [T0]) picks the
executable predicate BY INSTANCE:

    if (GetInstance() != ReplicantInstance)  IsNonReplicantExecutable()
    else                                     IsReplicantExecutable()

and the two differ exactly on the replicant case (SIMULATE.h:195-206):

    NonReplicant : (flags & DontExecuteFlag) == 0
    Replicant    : (flags & DontExecuteFlag) == 0 || lastUpdate >= lastPerformance

`ExecuteOnUpdate()` SETS DontExecuteFlag -- it means "do not tick me every
frame, tick me when an UPDATE ARRIVES".  Mech's reconstructed tick loop used
the NonReplicant predicate for EVERY mech, dropping the branch, so on a
replicant any ExecuteOnUpdate subsystem could never run however many records
arrived.  Restored.

Measured: it does NOT move #148 (first TorsoCopySimulation call 1014 -> 1006,
noise).  So the torso's own flag was not the gate.  Keeping it because the
engine source is unambiguous about what the loop is supposed to do.

WHAT #148 ACTUALLY IS, now much better characterised:

* The record CADENCE is authentic -- my original "only 13 records" framing was
  wrong.  Payloads are the sweep EXTREMES with `rate` flipping sign each time
  (atUpd 0.0437, 2.3558, -2.3928, 2.3854, ...): the master sends on RATE
  CHANGE and the peer dead-reckons `atUpd + rate * elapsed` between them.  12
  records for 12 direction reversals is correct, not starved.

* The real defect is that the peer's copy torso PERFORMANCE does not run at
  all until log line ~1006, while its first record arrived at line 205 -- ~800
  lines of correctly-replicated twist integrated by nobody.  The first tick
  coincides with the replicant's MODEL bring-up, not with record arrival:

      [loadclips] end: fScale=0.8 ... hasGimpClips=1
      [clipfix] mech 05769358 -> EXTERIOR (lean)
      [torso] PushTwist COPY node=057A3C68 type=1 twist=-1.52319

  so the gate is above the subsystem level, in replicant model/clip init.
  Not yet found; #148 stays OPEN.

Also: [torso-copy] logs on call #0 (s_cl++ % 120), so its first line IS the
first Performance call -- that is what makes the 205-vs-1006 gap readable, and
it is why the earlier "first copy currentTwist != 0 at 1016" reading was a
SAMPLING artifact, not a measurement of when the twist started.

Probe additions kept: [torso-copy] now prints limL/limR/enab (which ruled out
the ComputeTargetTwist clamp -- limits load correctly at +/-2.44346 on the
copy), and [launchframe] now prints the shooter's live torso twist so
twistDelta and its driver sit on the SAME line.  That pairing is what proved
#141 is fully fixed: every zero-twistDelta peer launch reads liveTwist=0, and
the first launch with liveTwist=-1.84061 reads twistDelta=-1.83813.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:56:24 -05:00
Joe DiPrimaandClaude Opus 5 e6c5ac951e #141 sweep: every hand-composed segment->world now goes through the engine accessor
Finishing the audit the #141 fix implied.  The unfaithful pattern

    mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);

appeared at FOUR sites, not one.  GetSegmentToEntity only recomputes when
segmentModified is already set (SEGMENT.cpp:262); the thing that sets it is the
binary's FUN_00424da8 == JointedMover::GetSegmentToWorld, which tests
AreJointsModified() and marks the whole segment table dirty.  Compose by hand
and you read whatever cache is there -- fresh on the local mech (the render pass
refreshes it every frame), BIND POSE on any replicant.

Swept (the muzzle path was fixed in f01de8c):
  * BTResolveWeaponMuzzle           -- weapon muzzle          (already done)
  * BTGetMechSegmentWorldPos @1066  -- generic segment->world bridge
  * damage-effect anchor    @2148   -- peer effects anchored to the bind pose
  * energy-beam gun port    @8916   -- SAME exposure as the missile launch:
                                       a peer's BEAM would originate from the
                                       untwisted gun port too

Repo-wide grep now shows exactly one GetSegmentToEntity call outside
SEGMENT.cpp -- JMOVER.cpp:153, which is inside GetSegmentToWorld itself, after
the refresh.  That is the correct one.

No regression (scratchpad/night13/missileframe.sh):
    master     n=165  max 2.1719  mean 1.2781  >0.1rad 100%
    REPLICANT  n=165  max 2.0907  mean 0.8201  >0.1rad  64%
and the peer failures remain a clean PREFIX with zero interleaved cases --
i.e. only the window before the peer has any replicated twist to carry, which
is correct behaviour, not a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:28:04 -05:00
Joe DiPrimaandClaude Opus 5 f01de8cbfa #141 follow-up: do it the BINARY's way -- the muzzle query IS the segment refresh
The previous commit's fix worked but was NOT faithful: it set
ModifyJoints(True) to force the engine's dirty flag before reading the
segment.  The binary never does that.  Called out by the user; corrected.

WHAT THE BINARY ACTUALLY DOES.  MechWeapon::GetMuzzlePoint @004b9948 ends in
`FUN_00424da8(owner, segment, out)`, which is JointedMover::GetSegmentToWorld
instruction-for-instruction:

    iVar1 = FUN_00417ab4(param_1 + 0x31c);      // GetJointSubsystem()
    if (*(int *)(iVar1 + 0xfc) != 0) {          // AreJointsModified()  <- TESTED
        ... walk owner+0x300, seg+0xc = 1 ...   // ModifySegment()
        *(int *)(iVar1 + 0xfc) = 0;             // ModifyJoints(False)
    }
    FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0);   // x localToWorld

So in the 1995 image EVERY muzzle query performs the joints->segments refresh,
and the flag is only ever TESTED, never set.

THE REAL DEFECT.  BTResolveWeaponMuzzle -- labelled "the faithful FUN_004b9948"
-- hand-composed `seg->GetSegmentToEntity() x localToWorld` and skipped
@00424da8 entirely.  GetSegmentToEntity only recomputes when segmentModified is
already set (SEGMENT.cpp:262), so it returned a stale cache.  On the MASTER the
render pass refreshes the local mech every frame and hid it; a REPLICANT got no
refresh, so peer muzzles sat at the BIND POSE and the missile left along the leg
facing.  Fixed at the muzzle path, where the binary puts it -- and the forced
flag in BTPushProjectile is REMOVED (the launcher calls GetMuzzlePoint just
above, so the cache is already current when the launch frame is composed).

MEASURED -- the faithful path scores exactly what the hack did, so the hack
bought nothing and is gone:

    master     n=165  max 2.1389  mean 1.2718  >0.1rad 100%
    REPLICANT  n=165  max 1.9426  mean 0.8051  >0.1rad  64%

AND THE 64% IS NOT A PARTIAL FIX -- I called that wrong last commit.  The
failures are a contiguous PREFIX, not interleaved:

    ZZZZ...(60)...ZZZZXXXX...(105)...XXXX

and they end exactly when the peer acquires a twist to carry:

    first torso RECORD received  : line 206
    first copy currentTwist != 0 : line 1016
    first CORRECT launch frame   : line 1054   (38 lines = probe granularity)

Those 60 salvos fired while the replicated twist was genuinely 0, so launching
along the body facing was CORRECT.  Once the peer has a twist, 100% of launches
carry it.  #141 is fixed.

SEPARATE ISSUE FOUND, not fixed here: the peer's copy torso takes far too long
to first reflect the master's twist -- the master was twisted from the start,
only 13 torso records arrived across the whole run, and the copy's twist stayed
0 until line 1016.  That is a torso REPLICATION CADENCE problem, and it would
also make peer torsos visibly lag -- likely relevant to #37 (MadCat torso
backwards) and #70 (twist stops after respawn).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 08:30:01 -05:00
Joe DiPrimaandClaude Opus 5 05d7b5890a #141 peer missiles: the launch frame read a STALE segment cache on replicants
Oracle: "missiles are firing in the direction the mech feet are facing ...
and then coming around to track the target", peer POV only -- the shooter's
own view is correct.

REPRODUCED AND MEASURED (scratchpad/night13/missileframe.sh, 2-node: only A
sweeps its torso and only A fires, so every REPLICANT line in B's log is the
mirror of one A salvo).  New [launchframe] receipt prints the yaw of the
launch forward vs the BODY forward on both nodes:

    master     n=165  |twistDelta| max=2.2962  mean=1.2283  >0.1rad: 100%
    REPLICANT  n=165  |twistDelta| max=0.0000  mean=0.0000  >0.1rad:   0%

segResolved=1 on BOTH, and segYaw == bodyYaw EXACTLY on the peer.

WHAT IT IS NOT.  Both sides already pass the mount segment (mislanch.cpp:363
master, :478 replicant mirror, both `GetSegmentIndex()` from task #67), and
the peer's torso data is fine end to end: records arrive (atUpd=2.44/-2.39,
rate 0.305), the copy extrapolates correctly (cur=-2.13987 target=-2.13987
copy=1), and the copy torso demonstrably writes its joint (PushTwist COPY
twist=-1.49601).  Hierarchy is identical too: same seg 18, same parentIdx 4,
non-null parent + joint subsystem on both.

ROOT CAUSE.  BTPushProjectile composed the frame BY HAND --
`mw.Multiply(seg->GetSegmentToEntity(), localToWorld)`.  But
EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262) recomputes ONLY when
`segmentModified` is set, and the thing that sets it after a joint moves is
JointedMover::GetSegmentToWorld (JMOVER.cpp:136-146), which tests
AreJointsModified() and then marks every segment dirty.  Hand-composing skips
that, so you read whatever cache is sitting there.  On the MASTER that was
invisible -- the renderer/cockpit camera call GetSegmentToWorld for the local
mech every frame, AFTER the local torso pushes its joint, so the cache was
already correct.  A REPLICANT gets no such refresh: its cache stayed at the
BIND POSE, and the twist never reached the launch direction.

FIX.  Use the engine accessor, and set the joints-dirty flag first so it
actually refreshes (by fire time the frame's render pass has already consumed
and cleared it -- measured jointsDirty=0 on BOTH nodes).

RESULT (same bench):
    REPLICANT  max 0.0000 -> 2.1145   mean 0.0000 -> 0.8252   0% -> 64%

PARTIAL, and I am not claiming otherwise.  36% of peer salvos still read the
exact-zero stale signature while the master is 100%.  Forcing every per-joint
`jointModified` flag as well (GetSegmentToParent's own gate, SEGMENT.cpp:196)
was tried and moved the number by NOTHING -- 64% either way -- so the residual
is a different cause, most likely frame ORDER (the salvo mirror running before
the copy torso has posed that frame).  Cheap form kept.

Also fixes a SAMPLING TRAP in the torso probe: PushTwist sampled one shared
static every 30th call, and with a master torso and a copy torso ticking 1:1
every 30th call is always the SAME instance -- so the probe showed only the
local untwisted torso and hid the copy's writes entirely.  Now sampled per
instance-kind, which is what made the copy's correct joint writes visible and
moved the search downstream to the segment cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 08:14:25 -05:00
Joe DiPrimaandClaude Opus 5 1ae57398f1 #147 range caret: NaN poisons a process-lifetime static -- and the caret's input was never logged
Oracle: "no range finder on this drop" + a screenshot -- tick marks present,
moving caret absent, one drop, only tester affected.

Not the host, not the chassis, not his destroyed HUD.  From the four field
logs: he WAS hosting (`[lobby] host:` appears only in his log) but range
computed fine on his node (1806 nonzero samples) and the reticle built on all
6 drops; a second tester flew a Thor the same night without hosting and saw
nothing, and the ladder is shared HudSimulation/BTReticleRenderable, not
per-chassis content; his HUD was destroyed twice but for 11s and 26s only, and
a destroyed HUD costs the LOCK (_DAT_004b7ec4 = 0.75), not the caret.

THE DEFECT.  sShownRange -- what the caret binds to -- is a function-level
static in mech4's targeting step: one cell for the whole process, shared by
every mech, carried across drops, never re-seeded.  NaN is ABSORBING in

    step = trueRange - sShownRange;
    if (step >  maxStep) step =  maxStep;      // false for NaN
    if (step < -maxStep) step = -maxStep;      // false for NaN
    sShownRange += step;

so one poisoned frame makes it NaN for the life of the process.  The consumer
repeats the mistake -- BTReticleRenderable::Draw clamps with the same two
comparisons -- so NaN reaches AddPoint/ConcatMatrix and the caret + its bar
become degenerate geometry that STOPS RENDERING, while every static reticle
element including the tick marks still draws.  That is the reported symptom
exactly, and it is sticky until relaunch.

WHY NO LOG COULD SETTLE IT.  The caret's actual input had NO diagnostic
anywhere: BT_RANGE_LOG instruments the PICK (#4), and [target]'s `range=` is a
SEPARATE locally-recomputed Sqrt in the weapon-range check -- neither is
sShownRange or gBTHudRangeStorage.  Grepping the field logs for NaN returns
nothing because the poisoned variable was never printed.  Absence of the
signal was not evidence of absence.

FIX (4 parts):
  1. re-seed sShownRange when the viewpoint mech CHANGES, so a new drop starts
     at the binary's 1200 default.  Deliberately NOT on respawn -- that reuses
     the entity, and the binary does not reset the readout on respawn either.
  2. producer NaN trap -> re-seed to 1200 instead of propagating.
  3. NaN-safe consumer clamp (test x == x first) -> fall back to the authentic
     no-target peg rather than rendering nothing.
  4. BT_RANGE_LOG now prints the caret's real input at 1 Hz plus a
     "[range] NaN TRAPPED" receipt, so the next field log CAN settle it.

STATUS [T3 on the field link].  The defect and the symptom match exactly and
the fix stands on its own merits -- a process-lifetime static feeding unguarded
float geometry is a bug regardless.  But the causal link to Oracle's report is
INFERENCE: the NaN source is unidentified and this has not been reproduced.
Field-verify with BT_RANGE_LOG=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 07:16:32 -05:00
Joe DiPrimaandClaude Opus 5 832bec0966 hud.cpp: byte-ground the HudSimulation constants -- all five were stand-ins under guessed names
Found chasing the Thor "no range finder" report: _DAT_004b7ec4 was documented
as two incompatible things -- the 0.75 LOCK damage threshold (mech4.cpp:6325)
and a "heat threshold for HUD page visibility" valued 0.0f (hud.cpp:59).

The .rdata settles it (reference/decomp/section_dump.txt):

     4b7ec0  8be55dc3 0000403f 0000803f 0000c842
     4b7ed0  00000000

  _DAT_004b7ec4 = 0.75f    _DAT_004b7ec8 = 1.0f
  _DAT_004b7ecc = 100.0f   _DAT_004b7ed0 = 0.0f    _DAT_004b7f90 = 0.0f

mech4.cpp was right on both thresholds.  hud.cpp's whole tuning block was
wrong -- every entry a 0.0f/500.0f stand-in, and three of five names named the
wrong mechanism:

  * ec4/ec8 are the fire-control LOCK limits (own HUD host zone < 0.75 damage,
    targeted zone < 1.0), NOT heat/page-visibility.  A shot-up cockpit drops to
    "target held, no lock"; a dead zone cannot be re-locked.
  * ed0 is the shared ZERO -- the right-hand side of the range-slide Abs()
    idiom (`dt * 500.0 <= 0.0` picks the sign) and of an `== 0.0f` test at
    @0x28C.  The 500 m/s slide rate is an IMMEDIATE (0x43fa0000).  The old
    "MaxTorsoSlew = 500.0f" read that backwards.
  * f90 (FlickerFloor 0.0f) was the only correct entry.  Its decay RATE is the
    object's own @0x298, not a constant -- the step-6 banner said "up to
    MaxTorsoSlew (500/sec)" and is corrected too (hud.cpp:229 already had it
    right, so the file disagreed with itself).

All four wrong constants were DEAD (zero code uses; MaxTorsoSlew appeared only
in a comment), so this changes no behaviour -- it stops the next reader
trusting them.  Renamed to what they are: LockOwnZoneDamageLimit,
LockTargetZoneDamageLimit, RangeBias, HudZero.  Builds clean.

GAP FOUND, filed not fixed: HudSimulation subtracts _DAT_004b7ecc (100.0f)
from RangeToTarget@0x1EC every frame while the timed flag @0x22C is set
(timer @0x21C accumulates to @0x1D8, then both clear).  Our targeting step
does the 500 m/s slide but never this bias, so the authentic timed -100 m
range offset is missing.  What sets @0x22C is unidentified.  -> open-questions.

KB swept: no context/ or docs/ file repeated the wrong constants (gauges-hud's
0-1200 ladder / 500 m/s / pegs-at-1200 claims are all correct); the error was
confined to hud.cpp.  gauges-hud.md gains the byte-grounded table + the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 06:11:59 -05:00
Joe DiPrimaandClaude Opus 5 7b003243ae #146 respawn: release the DESKTOP throttle -- and close #137, which was never a bug
#137 ("respawn came back with MYOMERS heat MAXED", Oracle; "overheating
generator D", Sauron) sent us through a full two-sided audit of Mech::Reset
and the whole RTIS chain.  Both sides were correct.  The answer was in the
field log all along, one line after the reset:

    [respawn] Mech::Reset 3:30 healed+moved to (...) alive=1
    [techstat] ... every live condition CLEARED
    [techstat] Myomers condition 3 SET        <- Overheating, immediately
    [mppr] in thr=1 -> ...
    [gaitSM] cycleSpeed=14.6 state=12         <- already RUNNING
    [techstat] Condenser5 condition 3 SET     <- "dumping into coolant loop 5"
    [techstat] GeneratorD condition 3 SET     <- Sauron's generator D

The mech respawns STILL UNDER POWER and earns the heat honestly.  Two facts
close it:

1. condition 3 is an OPERATING flag, not an alarm.  Census over one match:
   LLaser_2 33 SET / 33 CLEARED, LLaser_1 31/31, SRM4 26/26, PPC_2 18/18 --
   every volley trips it and clears it.  EVERY subsystem is balanced
   (GeneratorD 5/4, Myomers 5/4, Condenser5 1/1; the extra SET is only the
   log ending mid-heat).  cond 6 BadPower behaves the same (Myomers 8/8).
   Nothing latches.  A post-respawn SET is not evidence of anything.

2. Mech::Reset's subsystem loop starts at index 2 and the ControlsMapper is
   index 0, so the throttle is never reset -- and the BINARY does the same.
   That is right for a pod: the throttle is a PHYSICAL lever still under the
   pilot's hand.  Respawning under power is authentic and stays.

Oracle's read that the myomer heat rate "felt right" was correct.

WHAT IS a real defect (#146), desktop only: the glass bridge merely EMULATES
that lever, with the static ramp accumulator sLever (mech4.cpp:3250) zeroed
ONLY by the X all-stop and a direction-crossing snap.  A pad/keyboard pilot
is physically holding nothing and cannot see the lever, so they respawned at
speed for no reason they could perceive -- and ate the heat load above.  The
Thrustmaster/RIO path was never affected: InterpretControls (@004d2150)
rebuilds throttlePosition every frame from the databound throttleForward.

Fix: queue the existing all-stop at Mech::Reset, reusing the proven path
(it already clears the zero-crossing detent too).  LOCAL VIEWPOINT MECH ONLY
-- gBTDrive is the local bridge's state and Reset also runs for replicants,
so an ungated write would all-stop the player whenever a REMOTE mech
respawned.  Pod-safe besides: with a RIO present the key bridge is off and
gBTDrive.throttle is never read.  BT_NO_RESPAWN_THROTTLE_RELEASE=1 reverts.

Benched 2-node (scratchpad/night13/throttlerespawn.sh): the release fires
1:1 with local respawns on both nodes independently (A 2/2, B 1/1) and never
spuriously.  HONEST LIMIT: the viewpoint gate was NOT stressed -- B ran
Mech::Reset 0 times for A's mech, so the remote-respawn path never fired.
The gate is correct by construction (the isPlayerMech idiom), not proven.
BT_AUTODRIVE cannot test the lever itself (forced mode reads forcedThrottle,
never sLever), and the zeroing path is the X button, proven in the field.

Also keeps BTReportHeatAtReset (heat.cpp, BT_HEAT_LOG): the [heat-t] census
runs on a 5s timer, far too coarse to sample AT the reset.  It is what
proved every roster subsystem including all six Condensers sits at T=77
start=77, and it corrected an earlier false negative from filtering on
IsDerivedFrom(HeatSink).

KB: context/decomp-reference.md gains the routine/self-clearing condition
semantics + this post-mortem, so it is not re-chased; cross-ref in
context/gauges-hud.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 01:23:35 -05:00
Joe DiPrimaandClaude Opus 5 5b7e481913 scoring: mech+0x354 is VESTIGIAL -- label it so nobody "finishes" it
MECH_DAMAGE_BIAS(m) returned 0.0f under a comment reading "bring-up: factor =
0*bias+1 = 1", which invites a future session to wire it up.  Auditing
Mech::Reset settled what it actually is, and 0.0f turns out to be EXACT:

  * mech+0x354 has exactly ONE writer in the image -- Mech::Reset (@0049fb74,
    part_012.c:14340).  Nothing touches it during play.
  * Reset computes mean(zone + 0x158) across every damage zone, AFTER the zone
    heal has already zeroed those cells.  So it is ~0 the moment it is written,
    stays ~0 for the mech's whole life, and is recomputed as ~0 next respawn.
  * It has exactly ONE reader -- CalcInflictedScore (@004c052c,
    part_013.c:19055) -- as `avg * role.damageBias + 1.0`.

So the factor is 1.0 for the entire game and the stand-in reproduces the
binary exactly.  0x358 and 0x35c are the same computation over subsystem zones
and have NO reader at all.

This also raises confidence in the night-13 scoring work: the 505.88 kill award
was not right DESPITE a missing term -- the term genuinely is 1.0.  Wiring
0x354 to live accumulated damage would silently inflate every inflicted and
kill award, and both chart-verified numbers (+1 a damage point, +500 a kill)
assume 1.0.

Comment rewritten at the macro; combat-damage.md carries the same finding [T1].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-08 00:19:20 -05:00
Joe DiPrimaandClaude Opus 5 1f0923747b Mech::Reset: restore the POSTURE clears the port had dropped (#142)
Oracle: "crouch wasn't resetting on respawn ... mechs always spawn standing".
Correct -- Mech::Reset (@0049fb74) stands the mech up and the port cleared
none of it:

    *(this+0x398) = 0             duckState
    Set_Alarm_Level(this+0x39c,0) legStateAlarm  -> standing
    Set_Alarm_Level(this+0x714,0) bodyStateAlarm -> standing
    *(this+0x650/0x654/0x658) = 0 death + leg/body reset latches
    *(this+0x5ac) = 1.0f          idleStrideScale

A pilot who died CROUCHED came back crouched -- leg parked in 'sqd' -- and now
that the cockpit strip works, showing the up-arrow "press to rise" frame on a
standing mech.

Benched (crouchrespawn.sh): A squats, dies while down, respawns -> legLvl 0
(standing) after Mech::Reset.  Weak but the failure mode (stuck legLvl=1) is
absent.  NB the first attempt was void: force-damage kept A dying before it
could crouch (legLvl 22/24 = death clips), so the run tested a STANDING death.
Switched to self-damage so the mech is stopped long enough to crouch.

Also carries the #142 gauge work: the crouch strip is a BUTTON-STATE indicator
(grey unavailable / orange down-arrow ready / orange up-arrow crouched),
decoded by rendering BDUCK.PCC rather than inferring it; and the gauge
factory's missing-image path no longer uses the no-op DebugStream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-08 00:11:00 -05:00
Joe DiPrimaandClaude Opus 5 03c4d55672 #142 crouch: duckState is a THREE-state posture -- the strip is an animation
Operator confirmed on screen: bduck.pcc is a real duck ANIMATION, and stepping
duckState 0->1->2 plays it.  So the attribute is not a flag:

    0 = standing        1 = moving between        2 = crouched

Everything else was already right -- asset, element (OneOfSeveralPixInt
@004c5204), factory registration, L4GAUGE.CFG:5001, and the attribute binding
(new [gauge] receipt confirms 'bduck.pcc' frames=3x1 attr=BOUND).  We were
writing a two-value flag into a three-frame strip, so frame 2 was unreachable
and the cockpit saw a snap: "it lights up and sticks, no animation".

The handler is back to the binary's exact write (duckState = 1, @0049fa00).
That value now MEANS the middle frame, so the press gives immediate visual
feedback and the earlier toggle divergence is retired.

Needed a separate duckRequest cell, which I tried twice to avoid:
  * duckState cannot be both the request and the display.  Settling it to the
    real posture destroys the request, so on the frame the squat clip parked
    the consumer read "crouched + pending" and issued the opposite direction --
    69 transitions from 2 presses, benched, twice.
  * reading the CACHED legAnimationState instead of the alarm made it worse:
    the cache refreshes only at the top of AdvanceLegAnimation, so right after
    SetLegAnimation it still reads the old state.  Read the alarm.
duckRequest is port-only, appended, never read by offset.

Also fixes a silent failure in the gauge factory: the missing-image path used
DebugStream -- the no-op ReconStream (project gotcha) -- so a strip that failed
to load reported NOTHING.  Now DEBUG_STREAM, plus an ungated one-line receipt
per element naming the image, frame grid, port and whether the attribute BOUND
or came back NULL.  That receipt is what proved the element was healthy and
sent me looking at the value instead of the plumbing.

Benched (crouch142.sh, madcat): 2 presses -> exactly 2 transitions,
SQUAT -> parked (settles to 2) then RISE (settles to 0).  Refusal while moving
still holds (posture=0, authentic per Lynx).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 23:35:37 -05:00
Joe DiPrimaandClaude Opus 5 fcd1a0ca8d #142 crouch: refuse-and-snap, not queue -- and honour the must-be-stopped rule
Fixes a regression I introduced in 59f53da.  That revision retried the request
when the posture gate was not ready, which is worse than the drop it replaced:
benched, a crouch tapped at a walk QUEUED for 41 seconds (41 [duck] WAITING
lines) and would fire the instant the pilot stopped -- while duckState stayed
1, so the cockpit symbol read "crouched" for the whole time a STANDING mech
walked around.  duckState is what the gauge strip draws; it has to tell the
truth.

Now: if the gate refuses, snap desired back to actual (duckState = duckActual)
and say so once, throttled.

This also confirms the authentic rule rather than assuming it.  Benched, a
crouch pressed while driving gives posture=0 and no squat -- exactly Lynx:
"When a mech STOPS, crouch button lowers its stance."  Immobilization while
crouched looks EMERGENT rather than gated: the leg channel parked in 'sqd'
produces no root motion to travel on, and the operator's read ("i think you
cant walk when you crouch") matches.  No [skate] in the driving case either.

Also guards re-issue: while 'sqd'/'squ' is playing (legAnimationState 2 or 3)
the transition owns the channel, so want != actual no longer re-fires
SetLegAnimation every frame.

Benched both cases:
  stopped  duckState -> 1 (crouch) -> SQUAT -> holds -> -> 0 (rise) -> RISE
  moving   REFUSED (not stopped): posture=0 ... duckState 1 -> 0
           0 squats, 0 queued waits

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:39:05 -05:00
Joe DiPrimaandClaude Opus 5 59f53da07b #142 crouch: duckState is the POSTURE the cockpit animation reads
The crouch symbol animation is fully present and we were starving it.

    content/GAUGE/BDUCK.PCC                         the 3-frame strip
    OneOfSeveralPixInt  @004c5204/@004c52d8         the element, reconstructed
    btl4grnd.cpp:144                                registered in the factory
    L4GAUGE.CFG:5001                                oneOfSeveralPixInt(
                                                      E,ModeAlwaysActive,
                                                      bduck.pcc,3,1,DuckState)
    ATTRIBUTE_ENTRY(Mech, DuckState, duckState)     attribute 0x37

A 3-frame mech symbol beside the CROUCH button, indexed by duckState -- the
standing<->crouching animation Lynx and Draco describe.  It never played
because the consumer zeroed duckState the frame after the press, in BOTH
directions, so the strip sat on frame 0 with a one-frame blip to frame 1.
That is the field report verbatim: "button flickers sometimes on press ...
state does not change.  Remains in stand mode."

THE ZEROING WAS OURS.  Every writer of +0x398 in the export is the
DuckRequest handler (=1) and Mech::Reset (=0).  FUN_004a9b5c -- the master
perf, which contains the address the old comment cited as "the DuckRequest
consumer (@0x4aa011)" -- does not reference 0x398 at all.  mech.hpp's own note
already said "duckState has NO code reader anywhere in the decomp ... whatever
consumes it consumes it through DATABINDING".  The databinding consumer is
this gauge strip, and we were clearing it behind the gauge's back.

Restructure: drive on DESIRED vs ACTUAL.  duckState is the desired posture;
the parked leg alarm is the actual.  Act only on a mismatch -- no re-fire, and
nothing clears the attribute.  A frame where mapPosture is not ready now
RETRIES (throttled [duck] WAITING) instead of silently dropping the request,
which retires the old "request consumed, posture=N" miss as well.

ONE DOCUMENTED DIVERGENCE: the handler now TOGGLES.  The binary writes a bare
1 and clears the cell only in Mech::Reset, with no per-frame reader, so a
second press could never rise -- and a pod pilot's second press must un-crouch
(Lynx: "Mech is immobilized until crouch is pushed again, and mech rises").
One cell, same meaning, noted at the site.

Benched (crouch142.sh, madcat):
  duckState -> 1 (crouch) -> SQUAT -> [holds 1 while crouched] ->
  duckState -> 0 (rise)   -> RISE
Value now persists across the crouched period instead of blipping, so frames
0/1 of the strip are reachable and stable.  Also removed the interim REQUEST
DROPPED receipt: after the restructure nothing is dropped, and a receipt that
says otherwise is a trap for the next session.

STILL OPEN on #142: no immobilization while crouched (Lynx) -- nothing gates
movement on duckState or the parked leg alarm.  A driven mech with a parked
leg channel is the [skate] signature (#52), so it may not be cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:31:20 -05:00
Joe DiPrimaandClaude Opus 5 0530366687 #142 crouch: the mech is fine -- it is a missing PANEL ANIMATION
Benched solo AND in MP on the same chassis: both presses reach
DuckRequestMessageHandler, zero drops, SQUAT -> squat clip parked -> RISE.
Locomotion is not the bug, and MP is not refusing it.

Added an ungated [duck] REQUEST DROPPED receipt at the consumer's silent miss.
The squatCapable==0 path skips the consumer entirely AND leaves duckState
latched at 1 with NO log today; the posture-gate miss logged only under
BT_DUCK_LOG, which no player sets.  Neither fired on madcat.

What the pilot sees, traced with BT_LAMP_LOG: the button lamp is momentary
press feedback, not state --

    PRESS   -> [lamp] 0x13 <- 0x3c
    SQUAT   -> mech crouches, clip parked
    RELEASE -> [lamp] 0x13 <- 0x14    <-- while still CROUCHED

so crouched and standing look identical.

Era testimony corrects the scope: the button should ANIMATE A MECH SYMBOL
beside it, standing <-> crouching (operator).  Lynx: 'When a mech stops,
crouch button lowers its stance and plays crouch animation.  Mech is
immobilized until crouch is pushed again, and mech rises.'  Draco concurs.

Two real gaps, neither fixed here:
  1. no immobilization while crouched -- nothing gates movement on duckState
     or the parked leg alarm.  NB a driven mech with a parked leg channel is
     the [skate] signature (#52), so this may not be cosmetic.
  2. no stance symbol -- no gauge element draws one, and the decomp carries no
     crouch/squat/stance/duck graphic string, so it is an authored IMAGE on the
     secondary MFD; find it in that gauge's element list, not by string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:16:12 -05:00
Joe DiPrimaandClaude Opus 5 61f21107b4 #108 THE EJECT GHOST: the death-edge latch tested the wrong field
One substitution, three field symptoms.  Mech::TakeDamageMessageHandler arms
the whole death tail -- kill report, VehicleDead, death blast -- from a
was-alive-at-entry latch:

    const int deathBlastArmed = !IsMechDestroyed();   // graphicAlarm >= 9

The binary tests movementMode 9|10 there (@0x4a0303).  The port swapped in the
graphic alarm and justified it: "the death transition sets mode 9 synchronously
with the structural flag on every path through here, so the edges coincide".
True of every DAMAGE path.  False of the one that matters:
Mech::EjectPilotMessageHandler raises graphicAlarm to 10 (the EJECT state)
BEFORE dispatching its self-damage, while movementMode is still 1.  So on an
eject the handler entered already reading "destroyed", the latch never armed,
and the death tail was skipped entirely -- including VehicleDead, which IS the
respawn trigger.

Everything the field reported on night 13 follows from that:
  * "they all self destructed with panic button and didn't respawn properly"
    -- no VehicleDead, so no drop-zone hunt, so no respawn;
  * the EJECT GHOST -- the peer wrecks the mech and never un-wrecks it, because
    the un-wreck rides the master's respawn.  Normal deaths replicated fine all
    along (9 deaths -> 8 un-wrecks, benched), which is why only ejects ghosted;
  * the manual chart's "-1000 ejecting" never materialised -- the negated kill
    award and the death cost both live in the tail that never ran.

Fix: use the binary's own predicate.  MovementMode is untouched by the eject's
alarm write, so the latch arms on an eject exactly as on a combat death.

WHY SEVEN RIGS MISSED IT: the punch-out was being REFUSED, not undelivered.
EvaluateEjectPermission (@0049fa1c) grants only on
  liveWeapons < ejectMinWeapons || liveGenerators == 0 || coolantFrac < 0.05
  || (leg-gimped && !simLive)
-- armour damage satisfies none of them, and every bench ejected a healthy
mech.  An [ejecttest] receipt (2 lines) proved the dispatch fired every time
and the handler declined; the "[eject] REFUSED (mech not crippled enough)" line
was sitting in the very first bench log, ungrepped.  BT_KILL_SUBSYS's
comma-list form ("GeneratorA,GeneratorB,...") was already built for this bench.

Verified 2-node (scratchpad/night13/ejectreal.sh), before -> after:
  PUNCH-OUT landed        0 (785 refusals)  ->  1, charge=500
  peer wreck-enters       1                 ->  1
  peer UN-WRECKS          0  (the ghost)    ->  1
  eject score (type=2)    absent            ->  award=-1000.00, score 1000 -> 0
  death cost              never ran         ->  APPLYING, penalty=500

That -1000 is the manual chart's eject row to the digit: killBonus 500 plus the
500 self-damage tally, negated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 15:38:50 -05:00
Joe DiPrimaandClaude Opus 5 29b4d68ba6 scoring: the +1000 START grant -- BT's MissionStarting override was never ported
Seventh chart row.  BT overrides MissionStarting purely to seed the score, and
the override was missing, so MESSAGE_ENTRY(BTPlayer, MissionStarting) resolved
to the inherited engine handler (which only does the fade-in) and the grant
never happened.

    FUN_004bfbe8(player):
        base_MissionStarting(player);
        if (app->state == 4 && (player[0x29] & 0x40) == 0)
            player[0x1c8] = 0x447a0000;          // = 1000.0f

Both operands decode exactly against engine headers: application state 4 is
LaunchingMission (APP.h -- same enum whose 6 is EndingMission, already used by
the console flush), and simulationFlags bit 14 is NonScoringPlayerBit
(PLAYER.h: NonScoringPlayerBit = Entity::NextBit), so `(+0x29 & 0x40) == 0` IS
IsScoringPlayer().  Camera-ship/spectator players are non-scoring and correctly
get nothing.

CELL NOTE: the binary seeds the ENGINE cell (+0x1c8), not BT's own (+0x278) --
1995 carried two accumulators, which is why the KB suspected the pod's death
cost "may never have displayed".  Our port has one currentScore, so grant,
awards and death cost land together and the chart reads coherently.

Also resets the console watermark so a fresh mission REPORTS the grant rather
than a difference from last round's tally.

Benched: both players "[score] mission start: player N:1 seeded to 1000",
scores run 1001.98 -> 1908.64 with kills=1 (1000 + ~400 damage + 505 kill).

Also corrects a FOURTH copy of the dead-code claim, in btplayer.hpp's ScoreType
enum ("type 0 has NO scoring arm ... per-hit inflicted credit never existed").
Its byte-scan was right that no TABLE entry binds @004c0200 and wrong to
conclude unreachable -- the vtable Dispatch override calls it directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:43:54 -05:00
Joe DiPrimaandClaude Opus 5 e0b91df3e1 scoring: CORRECTION -- the death cost was never missing; my arithmetic was
Retracts the "open item" claimed in 2fcce53.  An ungated [deathcost] receipt
at the block settles it:

  [deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=-779
              -> APPLYING

It fires once, on a self-kill, exactly as it does on a combat death.  There is
no combat-vs-self asymmetry.

WHY I GOT IT WRONG: the cost is dispatched by a DIRECT
Player::ScoreMessageHandler() base call, so it never reaches the BT matchlog.
I computed the total from the LAST matchlog row and found no -500 in it -- but
that row is emitted BEFORE the unlogged cost.  I had noted the bypass one
message earlier and still failed to apply it to my own sum.  The lesson is the
usual one: a value that cannot appear in the log you are reading is not
evidence of absence.

The receipt stays.  A debit that moves the player-visible score while being
structurally invisible to the forensic log is exactly the kind of thing that
should announce itself.

Chart status after this: -500 special-case death cost VERIFIED applying.  The
-1000 eject ROW remains unverified -- its components (self-damage credit,
negated self-kill award, death cost) are each verified, but no real punch-out
has ever fired in a bench, so the total is still arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:20:20 -05:00
Joe DiPrimaandClaude Opus 5 a4bfb64ace scoring: BIND the scenario role -- one commented-out line zeroed the whole chart
BTPlayer::scenarioRole was never assigned.  The lookup sat commented out with
"the BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
analog, so the scenarioRole set by the base Player ctor stands" -- and the base
ctor sets it to NULL (PLAYER.cpp:680).  So it stood NULL forever.

Every scoring value the game has hangs off that pointer, and the shipped
content authors them correctly.  New ungated receipt in the ScenarioRole ctor
prints what a real mission loads:

  [role] 'Role::Default' model='dfltrole' killBonus=500 deathPenalty=500
         dmgInf=1 dmgRcv=0 bias=1 ff=1 return=1000

That IS the original manual's scoring chart -- +500 a kill, -500 a special-case
death, +1 per damage point.  With the pointer NULL every award multiplied
against zero: kills scored the damage tally alone (4.88), the eject charge read
0 (the field log's "PUNCH-OUT: charge=0 (role killBonus)" = #134's missing
penalty), and the death-cost block was skipped.

The analog DOES exist: Mission::GetScenarioRole(name) (MISSION.h:162) walks
scenarioRoleChain -- the same dictionary BTL4Mission fills via AddScenarioRole()
when it parses the role pages, whose own comment says the WinTesla base exposes
it.  Same lookup, same key.  Falls back to Role::Default when a creation
message names an unknown role (shipped content authors exactly one page), and
logs BOUND/NULL so this cannot fail silently again.

Benched cross-node:
  role binding    player 2:1 BOUND, player 3:1 BOUND
  KILL AWARD      505.88  (was 4.88)   <- chart's +500, verified
  death cost      victim total -500.00 <- chart's -500
  inflicted       still tracking, killer total 1017.32 kills=1

The -500 on an ORDINARY combat death is AUTHENTIC, not a bug: the binary's gate
is advancedDamageOn alone (@004c05c4 tail: `if (player+0x264 != 0) { -role+0x20 }`),
verified in the decomp.  It only shows now because the role finally binds.  It
also reconciles the chart's two death rows: an EJECT costs -500 (death) plus its
self-kill negating its own ~500 award = -1000, and an ammo death costs -500.

CORRECTION to my own earlier note: returnFromDeath=1000 is NOT the chart's
"+1000 starting the game" -- role+0x28 is a lives/return gate (`if (< 1)` ->
mission review, else respawn).  The 1000 is coincidence.  That row is still
unlocated and is most likely console-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:21:15 -05:00
Joe DiPrimaandClaude Opus 5 b2498ca39a scoring: the type-0 arm must RETURN, not break -- it was clobbering scoreAward
Chasing the duplicate rows from 1324c81 (80 real awards + 80 reading
award=0.00).  Not a double delivery -- Entity::Dispatch sends exactly once on a
replicant.  It was the `break` I left in the delegating arm.

After delegating to ScoreInflictedMessageHandler, control fell into
ScoreMessageHandler's post-switch tail, where the LOCAL `award` is still 0:

    message->scoreAward = award;             // clobbered to 0
    BTMatchLog("SCORE", ... award=0.00 ...); // the phantom row
    Player::ScoreMessageHandler(message);    // base: currentScore += 0

Harmless to the total only because the value added happened to be zero -- but
it mutated a message on a shared path and ran a base handler for nothing.  A
later reader of scoreAward, or any side effect gained by that tail, would have
turned it into a real bug with no obvious cause.

ScoreInflictedMessageHandler is self-contained (accumulates, ForceUpdate()s,
logs its own receipt), so the arm returns.

Re-benched cross-node:
  rows on shooter's master  100, ZERO phantom rows (was 80 + 80)
  rows on victim's node     0
  running total             417.85, climbing continuously, no resets
  type-0 Verify rejects     0
  kill path                 intact (kills=1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:00:24 -05:00
Joe DiPrimaandClaude Opus 5 1324c81719 scoring: land inflicted credit on the OWNER's machine (Steam + console safe)
Completes 2772175/e82f54c.  The interceptor restored the credit; this puts it
on the right node, so a player's score accumulates again.

The operator corrected two of my claims, and both were load-bearing:

1. Scores DID accumulate before build 787.  Checked: build 774 already had
   `currentScore = 0` in the console flush, so the flush was never eating
   score.  My "score zeroed every interval" theory is dropped.  The watermark
   from e82f54c stays only because it is harmless and keeps a master's own
   total intact across a flush -- it was not fixing a field bug.

2. The reroute works, and 774's own comment says so: the killer's player is a
   REPLICANT, so Entity::Dispatch reroutes to the owning host
   (ENTITY.cpp:244-251) and the credit lands on the killer's OWN machine.
   That is how kill credit has always crossed nodes.

So the earlier master-only gate was the right idea and failed for a reason I
guessed wrong.  A rerouted message arrives over the WIRE through Receive(),
which goes straight to the handler table -- the virtual Dispatch override is
never called on the receiving side.  Type 0 therefore landed in
ScoreMessageHandler's arm, which Verify-rejected it: award 0.00.

Fix is both halves:
  * Dispatch intercepts on a MASTER only -- local delivery stays exactly as
    @004bffa0 does it;
  * ScoreMessageHandler's type-0 arm DELEGATES to ScoreInflictedMessageHandler
    instead of Verify-rejecting -- wire delivery gets the same handler.
One accumulator, on the machine that owns the score.

Works for Steam today (no console tally exists -- btconsole.py/btoperator.py
handle no score at all) AND for a real operator console later: the console
flush is untouched and still ships authentic deltas under the owner's ownerID.

Benched (cross-node zone-walk kill):
  credit node      shooter's master only (victim's node banks 0)
  running total    253.60 and CLIMBING, no resets
                   (was: peaks ~35, snapping back every few seconds)
  type-0 rejects   0
LOOSE END: each real award is followed by a duplicate row with award=0.00
(80 real + 80 zero).  Harmless -- the total is unaffected -- but it means the
report is delivered twice on the owner; not yet explained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 09:52:26 -05:00
Joe DiPrimaandClaude Opus 5 e82f54c957 scoring: the score AUTHORITY is the operator console -- and our port has none
Follow-up to 2772175 (type-0 interceptor restored).  Benching the restored
credit exposed the next layer, and two of my attempts at it were wrong; both
are recorded so they are not retried.

FINDING [T1]: the binary flushes ConsolePlayerVTVScoreUpdate(ownerID,
currentScore) then does `param_1[0x9e] = 0` -- UNGATED.  So +0x278 is a console
DELTA, never a running total, and it does not matter which NODE computed one:
every delta is stamped with the scoring player's ownerID and the CONSOLE
accumulates.  That is almost certainly where the manual chart's "+1000 starting
the game" was seeded, which is why no game-side code grants it.

Our port has no console as score authority.  GetScore() (SCORE gauge),
CalcRanking() and the replicated Player__UpdateRecord all read +0x278 on the
OWNING node.  Damage is applied on the VICTIM's node, so block B dispatches the
inflicted report to the SHOOTER's player object there -- a REPLICANT -- and the
master's next update record overwrites it.  Benched: totals climb to ~35 and
snap back every few seconds.  THAT is the "scoring went screwy" report.

WRONG TURN 1 (reverted in spirit, kept only where harmless): blamed the console
flush and added a last-sent watermark so the console still gets deltas while
+0x278 keeps a total.  The resets were 2s apart, not on the 10s console
interval -- the timing was already in the data.  The watermark stays because it
does stop the FLUSH from zeroing a master's own total, but it was not the bug.

WRONG TURN 2 (reverted): gated the interception to MasterInstance so a
replicant would reroute to the master.  The message arrives, but the BT
extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the
wire -- only the base scoreAward -- so every award computed 0.00.  That failure
is the clue to the answer: the kill report (type 2) credits cross-node
correctly precisely because its value rides scoreAward.

FIX SHAPE (not implemented -- landing it deliberately rather than guessing a
third time): compute the award on the victim's node, where the damage data
lives, and ship the RESULT in scoreAward the way the kill report already does,
instead of shipping the basis and recomputing on a machine that cannot see it.

State now = binary-faithful unconditional interception.  Re-benched: 79
inflicted rows, awards 0.98..25.00 all positive and tracking damage, 0 type-0
Verify rejections.  Cross-node banking still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 08:41:59 -05:00
Joe DiPrimaandClaude Opus 5 27721754da scoring: restore the type-0 INTERCEPTOR -- per-hit inflicted credit was live all along
Players reported scoring and K/D going screwy on 4.11.817.  Cause: build 787
(#45/#134) retired the port's per-hit inflicted crediting as an "invention",
on the strength of a KB claim that the type-0 score handler was dead code.
That claim was wrong.

BTPlayer overrides Dispatch -- vtable @00513300 slot 3 = FUN_004bffa0 -- and
splits type 0 off BEFORE base dispatch:

    if (msg->id == 0x16 && msg->type == 0)  FUN_004c0200(...);   // ScoreInflicted
    else                                    base dispatch;

@004c0200 names itself in its own Verify string
("BTPlayer::ScoreInflictedMessageHandler") and computes
CalcInflicted(basis) -> negate if target==self -> x (targetTonnage/ownTonnage)
-> accumulate into +0x278.  ScoreMessageHandler's type-0 arm Verify-rejects
precisely BECAUSE this interceptor guarantees type 0 never reaches it.

The port had the handler, faithfully reconstructed, and no interceptor -- so
Block B's inflicted reports all landed in the rejecting arm and banked 0.
Per-hit damage credit was silently deleted.

Independently corroborated by the ORIGINAL MANUAL'S SCORING CHART (filed as
reference/manual/scoring_chart.webp, from Lynx): "+1 each damage point scored
on opponent's armor" and "-1 each self-inflicted point of armor damage" -- the
negate-if-target-is-self arm exactly.  Without that chart the dead-code note
would probably have stood.

Verified (scratchpad/night13/scoreverify.sh, cross-node kill, 2 nodes):
  type-0 Verify rejections   0   (was firing on every non-lethal hit)
  inflicted score rows      83   awards 0.98..25.00, all positive, tracking damage
  kill path un-regressed    type=2 award=4.88 kills=1, victim respawn x1

KB: combat-damage.md report B and the score-model paragraph rewritten, with
the full chart and THREE unreconciled rows flagged [T4] -- flat +500 kill vs
the benched 4.88, +1000 at game start, and -1000 eject / -500 ammo (which
would live in ScenarioRole::specialCaseDeathPenalty @role+0x20, read by the
port but authored nowhere in shipped content).

KNOWN, NOT FIXED HERE: in MP the running total does not persist -- currentScore
is flushed to the operator console and ZEROED (btplayer.cpp ~1219) because the
binary treats it as a console DELTA.  Restoring the credit makes that very
visible (bench: totals climb to ~35 then reset).  Needs its own decision; the
chart's "+1000 starting the game" implies a persistent total lives somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 08:15:22 -05:00
Joe DiPrimaandClaude Opus 5 4642129e76 #108 peer-side WRECK receipt: make ghosts countable
The un-wreck receipt had no partner, so counting ghosts in a field log meant
pairing it against

    [BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback

which is a MISSING-ASSET WARNING, not a death -- it only prints for chassis
whose wreck model is absent.  Night 13's census found ONE ghost while testers
reported many, and there was no way to separate a real count from a chassis
accident.

Emit one ungated line for every REPLICANT entering the wreck state, symmetric
with the existing un-wreck line, so a log's ghost count is exactly
(wreck-enters minus un-wrecks) per entity:

    [wreck]   replicant H:E entered wreck state (mode X->9) at (x,z)
    [respawn] replicant H:E un-wrecked + warp   (mode 9->1) at (x,z)

Verified 2-node (200s, force-damage victim): 5 enters, 5 exits, exactly
paired -- while the old marker printed ZERO times in the same run.  That gap
is the point: five real deaths, invisible to what the census was reading.

Also lands the night-13 census tooling (ghostcensus.py) and the eject benches
that did NOT reproduce, with their failure modes in the headers so the next
attempt does not repeat them: five rigs failed to trigger a punch-out at all
(BT_BTNTEST never reached the mapper for 0x3D or 0x14; BT_EJECT_AT did not
fire either).  Panic-eject replication remains UNTESTED by bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 07:48:04 -05:00
Joe DiPrimaandClaude Opus 5 6a96fb6420 #52 the peer STANDING-LOCK: case 0's fallthrough was intercepted
A replicant could not start walking between gait-change records.  The port's
body case 4 (the task-#64 lockstep twin) is an INSERTION sitting between case 0
and the advance group; in the binary case 4 is a MEMBER of that group
(FUN_004a5678 @004a5678: case 2,3,4,5,8,... -- no turn block, no speed exit),
so case 0's fallthrough is meant to land on Advance().  The insertion caught it.

On a replicant that is not a race but an identity: case 0 arms walk iff
standSpeed < bodyTargetSpeed, and the inserted block resets iff standSpeed <
bspd -- where bspd IS bodyTargetSpeed for a replicant.  Same expression, so arm
and reset fire on the same frame, forever, and a peer parked at Standing with a
live replicated demand never cycles.  bodyCycleSpeed stays 0 while position
advances from dead reckoning: the skate.

This is the sequel to e91d447 (#82).  Before it the replicant branch read the
dead local mapper cell (0 forever), the exit never fired, and the fallthrough
worked BY ACCIDENT.  Fixing the dead cell closed the escape hatch.

Fix: case 0 -> goto advance_body_normally, the leg twin's own idiom, restoring
the binary's structure without touching the #64/#82 turn logic.
BT_NO_BODY_FALLTHRU=1 reverts.

Measured (2-node, scratchpad/night13/skatelock.sh):
  legacy  336 consecutive locked seconds, bspd=39.2324 bts=39.2324 every line
  fixed   0 locks, every pass
  master body-Standing samples 52 -> 21 (it locked too, invisibly at mj=0)
  turn-in-place intact: pivoter body state 4 x9 / leg state 4 x8, in lockstep

Diagnostics (both keepers): [skate] now carries bstate= -- the field lines
proved "both channels idle" but never named the state, which was the whole
answer; [bodySM]/[peergait] under BT_BODY_SM_LOG instrument the arm->reset pair
and a moving replicant's body channel.

NOT claimed: that this accounts for the night-13 field episodes.  That link is
inference -- locked + translating IS the skate signature by construction, but no
bench caught the two together.  bstate= settles it next playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 00:50:54 -05:00
Joe DiPrimaandClaude Opus 5 6fcff95010 pod: the HARDWARE RIO on COM1 (L4CONTROLS=RIO:COM1,KEYBOARD)
The real cockpit board now drives the cab instead of PadRIO, frozen in the
profile so all five tester launchers get it.  Nothing above the seam changed
-- the 109-mapping L4 control table installs exactly as on a desktop, and the
cab keeps the GLASS display stack.  BT_PLATFORM=pod is NOT the way to this;
that would drag in the 1995 gauge path.  RIO:COM1 -> \.\COM1 at 9600 8N1.

Evidence the link is real, not just "the port opened":
  RIO successfully initialized!
  FAILURE.LOG: 4 missing boards (Slot 3:0, 3:2, 4:0, 5:0), 16 dead lamps
  [ctrlmap] push stick x=0.0595238 y=0   <- physical stick outside deadband
A specific 4-of-many board inventory is the proof: a dead serial line reports
the WHOLE address space missing.  Reproduced after deleting FAILURE.LOG.  The
dead lamps are the boards this partial crash cart does not have.

Banner honesty: "GLASS (PadRIO ...)" was hardcoded, so a wired cab reported
PadRIO on the very line you read to check which device won.  It now names the
resolved one -- GLASS (hardware RIO; plasma off [L4PLASMA]).

Recorded in pod-hardware.md, including that RIO and PAD are mutually exclusive
(both assign rioPointer, last token wins) and that a CENTRED stick reads x=0,
which is indistinguishable from no data -- so the by-hand check of stick,
throttle, pedals, buttons and the Ranger calibration is still outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 18:11:28 -05:00
Joe DiPrimaandClaude Opus 5 654277bb7b pod: freeze the ALPHA-MR rig in environ.ini (BT_FIT, L4PLASMA=NONE)
The cart's display config was carried by a launcher .bat, so it only came up
right if the game was started one particular way.  Move it into the file the
engine already reads before anything touches the environment.

  content/environ.ini  <- scratchpad/pod/podprofile.ini, merged idempotently
                          between markers by scratchpad/pod/mergeprofile.ps1

Two gates were missing for that to be enough:

  BT_FIT=1      the env spelling of -fit, so the borderless main view does not
                depend on one launcher's command line (shortcut, scheduled
                task and autostart all have to produce the same rig)
  L4PLASMA=     NONE / OFF / 0 -> no marquee at all.  Leaving it unset does
                NOT work: the GLASS profile force-defaults it to SCREEN, which
                drops a desktop plasma window on the cab's glass.  The boot
                banner now reports the live state instead of always claiming
                "plasma window".

Also lands the bring-up engine work this depended on: monitor:<name|index>
layout binding (device-bound, not pixel-bound -- desktop rects move when a
display re-enumerates), ",bare" implying frameless, rotation-aware radar
surface sizing, BT_GAUGE_SEC_ROT accepting 0-3 (it silently forced 3 for
anything but 1), 180-degree ExpandPlaneToBGRA, and the BT_POD_CHANMAP /
BT_POD_IDENT / BT_POD_CHANTEST identification gates.

Verified on the cart, build 4.11.813, launcher carrying none of it:
  [boot] environ.ini: 9 setting(s) applied
  [boot] platform profile: GLASS (PadRIO; plasma off [L4PLASMA])
  [cockpit] -fit: borderless 800x600
  [glasswin] radar rotation 0 (none)
  ... all three surfaces on their intended \.\DISPLAYn
No [plasmawin] line in an otherwise-logging run = the ctor never ran.

KB: pod-hardware.md gains the ALPHA-MR section (mapping, the two wiring
deviations, the frozen profile, the session-0 remote-work traps) and its RGB
SPLIT "OPEN: which way is the cart wired" is now SETTLED -- it is splitter
wired, the composite is what lit it.  glass-cockpit.md documents monitor:
binding, BT_FIT and L4PLASMA=NONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 17:40:39 -05:00
Joe DiPrimaandClaude Fable 5 f44be87ab2 #60 PART 2: the RE-EXPORT -- dark code 90KB -> 41KB, coverage 87.3% -> 93.5%
Installed JDK 21 + Ghidra 12.1.2 (no admin, %LOCALAPPDATA%\bt411-tools beside
DXSDK/cmake; runner uses 8.3 SHORT paths because Ghidra's .bat expands
%JAVA_HOME% unquoted and the profile has a space).

New tooling: reference/ghidra_scripts/ExportGaps.java -- ExportAll's exact
output contract PLUS a gap-fill pass (force disassembly + createFunction at
E8 call targets outside functions, data->code pointers at a plausible
prologue, and the census's discovered starts; iterated to a fixpoint,
logged to gapfill_report.tsv).  tools/ghidra_reexport.sh (headless runner,
'reprocess' mode) and tools/gapdiff.py (score two censused exports);
gapcensus.py now censuses any export dir.

Results: 6267 -> 6472 functions (+205 created in 2 rounds: 195 census
starts, 6 call targets, 4 data pointers; 56.1KB newly covered), ZERO
decompile failures.  Dark real code 90.4 -> 40.8 KB (54.8% recovered);
game-side dark 53.1 -> 21.1 KB; regions 428 -> 321.  EVERY historically
dark function now has pseudocode -- including @0x4c05c4 VehicleDead, the
absence that opened this issue.

VALIDATION: the new pseudocode confirms this week's hand reconstruction of
the crouch field-for-field (mapPosture/duckState/squatCapable/myomerEff/
novice gate/SetLegAnimation/ForceUpdate/stability alarm) -- and exposed one
branch the raw pass missed: AIRBORNE AUTO-RISE (mode 3|4 && legState 1 ->
forced squ), now implemented in mech4.cpp and re-benched un-regressed.

PROMOTION: the re-export is canonical reference/decomp/; the previous export
is preserved at reference/decomp/archive_2025export/ so old
`part_0NN.c:LINE` citations still resolve (addresses are stable across both;
line/shard membership is NOT -- cite @ADDR).

New lead recorded: @0x4c0904 is the MASTER BTPlayer Performance (team
resolution, EndMission console post, score heartbeat) -- our @0x4c083c
PlayerSimulation attribution needs a re-check.  KB: source-completeness,
gotcha #20 (the rule is cheap now -- look it up), CLAUDE.md router/layout.
Log: phases/phase-04-gap-census.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:11:26 -05:00
Joe DiPrimaandClaude Fable 5 e1c3f2db6a Myomer factor: correct the false 'feeder unreconstructed' claim + wire the
crouch gate to the LIVE drive value

The myomer system was ALREADY COMPLETE (2026-07-31 seek audit): Performance
wrapper @004b8b9c, AvailableOutput @004b8ac0 (gear clamp x quadratic heat
degrade x (1 - zone damage)), and the master-perf chain walk + speedDemand
scale + turn freeze in mechmppr.cpp:990 -- the same @0x4a9cf2-0x4a9da4 bytes
the crouch dig re-decoded.  The 2026-08-05 banners calling the feeder dark
were an export-gap-blind grep (named members, not offsets).  Fixes:
mechmppr publishes the chain MAX into mech->myomerEffectiveness (the
binary's +0x79C home) so the crouch posture gate reads the live factor
(dead/overheated myomers now genuinely refuse squat/rise -- previously the
gate read a neutral 1.0 and never fired); the duplicate speedDemand multiply
in the posture block is removed (mechmppr's is the one application); banners
and locomotion.md corrected.  Squat re-benched green on the live wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:21:28 -05:00
Joe DiPrimaandClaude Fable 5 591d205b19 CROUCH complete: full cycle + MP replication verified
The WIP's 'pose does not hold' was a chain of bench-instrument errors, not a
code bug: every capture ran in COCKPIT view (the pilot cannot see their own
legs; the eye-height residual masked as reversion).  Joint probes prove the
park holds indefinitely (knee 1.138, root -2.219 steady); the 2-node bench
shows the observer's replicant fully crouched and held (duckmpA_031 -- the
type-3 state record carries it with zero new replication code); the second
scripted press (new BT_BTNTEST2 env) verifies RISE -> standing zeros.
MP button delivery confirmed mode-mask-clean (the one miss was round-start
jitter).  Diags added, all BT_DUCK_LOG-gated: SetLegAnimation re-arm tracer,
1 Hz joint probe, RIO press mode-mask, BT_TREE_LOG topology dump, and the
squat-park log.  RESIDUAL filed: pilot's own cockpit eye does not ride the
root drop (DPLEyeRenderable chain composition; cosmetic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:50:12 -05:00
Joe DiPrimaandClaude Fable 5 dd70061e0e CROUCH reconstruction (WIP): the master posture/duck machine, decoded + wired
Raw-disasm of the dark master-perf region (@0x4a9cf0-0x4aa0af): the myomer
effectiveness factor (+0x79c, MAX over heatables' +0x31c, scales speedDemand
-- feeder @004b8be3 unreconstructed, neutral 1.0 [T3]), the posture selector
(+0x3f8: mode/novice/leg-state/myomer gates -- novices cannot crouch), and
the DuckRequest consumer (standing -> SetLegAnimation(2) 'sqd', ducked ->
SetLegAnimation(3) 'squ', ForceUpdate 8+1 ships the type-3 state record,
stability alarm flips, request consumed).  +0x1DC = mountSegment... er, the
searchlight learned that one; here: mapPosture @0x3f8 + myomerEffectiveness
@0x79c members land; value-space note (port normal mode == 1, binary 0).

VERIFIED: request->consumer chain fires ([duck] SQUAT), the sqd clip plays
(22kf/7joint, ends root -2.22 crouched -- clip data parsed from BTL4.RES,
squ is its exact mirror; loader slot map re-verified byte-exact).  OPEN: the
parked crouch pose does not HOLD on screen (reverts ~1 frame after clip end
with NO SetLegAnimation re-arm logged) -- the hold's render path is the
remaining dig; [duck] re-arm tracer left in SetLegAnimation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:09:51 -05:00
Joe DiPrimaandClaude Fable 5 b75bb4a04c Searchlight beam: the btfx brighten material class + BT_SPOT_SELF rig
SPOT.BGF decoded: a 7-vert cone from the mount, ~50u forward and ~35deg DOWN
(a ground-pool lamp, not an air beam), verts tinted cyan-white, material
class 'brighten' smuggling its additive factor in DIFFUSE.r (0.25) with a
warm emissive on the night page.  The loader had never met the class -- it
drew as an opaque dark-red blob.  Now: brightenFactor parsed (name-gated to
brighten*), batch -> L4DRAWOP.brightenAlpha, drawn in the blend pass as an
additive veil (dest += vertexRGB x factor), unlit.  [T3] tint compose
(vertex cyan vs night emissive warm) noted in the draw branch -- field
eyeball accepted the current look.

BT_SPOT_SELF=1 (bench-only): builds the cone on the own-cockpit tree so the
BT_CAM=face view can inspect it solo.  KB: view toggle is BACKTICK ('V' is
the rear-view hold since #68 -- the toggle skips bound keys); stale V-toggle
claims swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:21:32 -05:00
Joe DiPrimaandClaude Fable 5 412053d5af Searchlight reconstruction -- the pod's night kit, both halves
The subsystem (sim/toggle/attribute/replication) was already complete; this
lands the missing VISUALS, decoded from MakeMechRenderables @004cef28 case
0xbd8 (raw pseudocode part_014):

- COCKPIT: the 1995 searchlight is a FOG SWAP -- the @00456778/@00456814
  watcher switches DPLRenderer::SetFogStyle between the authored fog= (lit)
  and nosearchlightfog= (dark) sets per map/time page in BTDPL.INI.  The
  engine kept the whole system under its real names; completed the stubbed
  plane application (currentFogNear/Far) and transcribed the watcher (with
  its inverted-cache seed) into TickSearchlight.  CONSEQUENCE: night now
  STARTS on the authentic dark set (near-plane 5u on arena pages) -- our
  builds had rendered the searchlight-ON fog permanently.
- EXTERNAL: spot.bgf beam cone hung on the searchlight SITE joint, shown/
  hidden from the replicated LightOn attribute (@0045612c watcher).  Site
  segments now build geometry-less DCS children (posed + parentable, as the
  1995 graph did) -- previously they were skipped entirely.
- searchlight.hpp: commandedOn @0x1DC identified as mountSegment (resource
  segmentIndex; the cone's mount joint).

Benches: searchfog.sh (solo cockpit: first-tick dark sync, F5/0x14 press ->
SetFogStyle(2), red-fog probe end-to-end), spotcone.sh (2-node: B's button ->
lightState replication -> A logs "[spot] cone SHOWN (seg 20)").  Cone look
(size/aim on the mount) pending an eyeball pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:44:23 -05:00
Joe DiPrimaandClaude Fable 5 91bd28669e #45/#134 authentic score/death report tail -- replaces the scoring stand-ins
Reconstructs the dark-gap tail of Mech::TakeDamageMessageHandler
(@0x4a02f4-0x4a0890, raw disasm): the three id-0x16 score reports (kill to
the shooter's player / type-0 wire-fidelity / received to the victim's
player) and the BT 0x38-byte VehicleDeadMessage extension {killed-by player,
kill zone} dispatched from the death tail.  Retires BTPostDamageScore /
BTPostKillScore and the per-hit inflicted credit (never existed in 1995:
@0x4c0200 is bound in no handler-table entry -- byte-scan receipt in
decomp-reference).  Suicides now dispatch and the handler negates the award
(the #134 panic penalty).  Collision divert falls through to the death tail
per @0x4a0375 (wall deaths respawn + blast; no score).  ScoreMessage fields
renamed to decoded truth (vitalHit/zoneIndex/subsysID) + wire asserts;
console VTVDamaged points_transfered corrected (Round(award), not Now()).

Benches: scorekill.sh cross-node kill (kills=1 award=4.88, killedBy=2:1
zone=3, single death cycle), scoreself.sh suicide (type=2 award=-39.00
kills=0), deathblast2.sh re-verified (72 bursts at ~9u).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:36:58 -05:00
Joe DiPrimaandClaude Fable 5 297127d0d7 #89 DEATH BLAST reconstructed: a dying mech splashes its neighborhood
The missing half of Advanced Damage, found by call-scanning
Explosion::SplashDamage @0042fad0: TWO callers, not one -- Missile::Perform
(the known #62 path) and 0x4a0bda, the UN-EXPORTED tail of
Mech::TakeDamageMessageHandler itself.  Raw disasm @0x4a07b8-0x4a0bda:
when the victim ENTERS dead(9)/eject(10) during the applications, the
binary sets the wreck burning (id 0x17, deferred -- handler not yet
reconstructed), spawns the death Explosion (model 0x31 -- our death-list
visuals stand in), and SPLASHES:
  gates : owning player's advancedDamageOn (+0x264) AND NOT
          suppressConsole (+0x258 -- eject sets it: PUNCH-OUTS NEVER
          BLAST, the authentic anti-suicide-bomb rule)
  damage: type 2 Explosive, amount = deathSplashDamage (mech+0x520),
          bursts = round(0.001 * moverMass * 15.0) -- scales with tonnage
  radius: deathSplashRadius (mech+0x524); per-victim falloff
          bursts/dist^1.25 in the shared core
Draco's collision-divert suspicion is settled: the blast is TYPE 2, the
divert never touched it -- the tail was simply never reconstructed.

Port: deathSplashDamage/Radius PROMOTED from the Wword scratch bank to
named Mech members (the bank is one GLOBAL array -- authored per-chassis
values were clobbered to the last-loaded mech); BTSplashCore split out of
the #62 weapon splash and shared; BTApplyDeathSplash + the death-edge arm
in the handler tail; BTPlayerConsoleSuppressed bridge (friend).

Bench (2-node, B parked 8.9u from a self-destructing A): blast fired with
authored madcat data (radius=50, amount=5, mass=75000 -> 1125 base
bursts), B took 73 bursts (falloff exact: 1125/8.91^1.25), cross-pod
delivery + cylinder spray verified on B's own log ([dmghit] type=2
burst=73 across zones).  ~365 damage at 9u -- Draco's 'double kills on
drops' economy restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 16:52:32 -05:00
Joe DiPrimaandClaude Fable 5 9604f4c492 #52 SKATE detector (ungated field forensic) -- negative-verified; local repro eludes
The night-12 field logs eliminated record starvation (zero [ghost] during
three observed skating windows), so the bug lives in gait APPLICATION on
peers.  This adds the [skate] detector to the death-handler tick: a
replicant moving >0.08 u/frame for 90+ frames with BOTH animation
channels idle (legCycleSpeed + bodyCycleSpeed ~ 0) logs one line per
episode + a SKATE matchlog record carrying the discriminating inputs
(legCyc/bodyCyc/cmdSpd/destroyed/mode).

Honest history: the first build keyed on legCycleSpeed alone and
false-fired on every healthy movement phase -- the current peer
architecture poses joints from the BODY channel (s_peerLegCh=0,
AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is NORMAL there.  Caught
same-session by the [gimpfeed] silence (AdvanceLegAnimation never runs
on peers); corrected to channel-agnostic before anything shipped.

Bench (skatebench2.sh, 2-node, autodrive walker + kill every ~40s):
7 death/respawn cycles, ZERO skate hits either side -- no false fires,
and light local conditions do NOT reproduce the field skating.  Next
provocations: leg-GIMPED walker (the #82 family transition) and 6-player
load; otherwise the detector rides the next cut and the field names the
failing case for us.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:41:55 -05:00
Joe DiPrimaandClaude Fable 5 546aabd5ba #131 false lock FIXED: miss-means-miss -- the pick answers only for drawn geometry
Night-12 field report (Ronin/Conn Man/Oracle, blackhawk-correlated): lock
ring lit with the reticle visibly off the mech + no-reg complaints.  Root
cause: TWO port stand-ins answered where the 1995 card (which cast against
the DRAWN geometry) would miss -- the pick's any-object sphere fallback and
the caller's whole-mech AABB fallback.  The regime that exposes them: a
LEVEL boresight over a SHORT mech -- the blackhawk's mesh tops out below
eye-ray height, so the ray clears every triangle but pierces the fat cull
spheres; the ring lights with the reticle above the mech's head (the
operator watched exactly this on the sweep bench).  Careful aimed-down fire
rides triangles, which is why Oracle's per-panel audit passed on the same
build.

Fix: MechSegmentPick returns 1=drawn-geometry hit / 0=TRUE MISS / -1=no
render tree; the sphere may answer ONLY for a mesh the reader cannot parse
(pm==0 -- currently none exist: counters objs/invFail/noTri all clean);
the AABB survives ONLY as the pre-tree replicant grace.  A readable mesh
the ray misses is a MISS -- no lock.

Verified (2-node vs bhk1 at 100u, all runs on force-relinked string-
verified exes after today's stale-link flake):
- LEVEL lock-sweep: 0 locks all run (pre-fix: lock band from 168 sphere
  answers; picksrc tri=0 sphereFB=168).
- DOWN-PITCHED sweep: locks return 100%% tri-sourced (tri=158 sphereFB=0),
  landing on real parts (rarm/ldleg/rdleg) with honest gaps.
- Full zone-walk matrix: tri=18874 sphereFB=0 box=0; victim took 156 hits
  across 16 zones incl. both side torsos -- combat un-regressed.

New instruments (all env-gated): BT_LOCK_SWEEP=<axis> torso pan (the
operator-visible lock-envelope bench), [locksweep] transition log,
BT_LOCK_ENVELOPE synthetic unit-sweep probe, [picksrc]/[pickbox] source
telemetry with objs/invFail/noTri localization counters.
Bench: scratchpad/night12/zonewalk_bhk.sh.

NOTE for the field: locking is now strictly TIGHTER (ring = reticle truly
on the machine).  If era testers feel the pods were more forgiving,
Draco's "slight lock linger" memory becomes a deliberate investigation
(sourced hysteresis), not an accidental sphere halo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:12:43 -05:00
Joe DiPrimaandClaude Fable 5 1d3616cceb #124 CORRECTION: aimed hull hits resolve the struck dz_* PANEL -- players were right
The 08-03 zone rule (struck SEGMENT's SKL dzone, ALWAYS -> every aimed
torso hit = center torso) was wrong, and era players' pushback caught it.
Byte-level proof: MAD_TOR.BGF zone-tags the hull PER PANEL (dz_utorso x36,
dz_ltorso/rtorso x18, dz_dtorso x16, all four rear panels, searchlight);
the dpl hit result kept GEOGROUP granularity (dplHitGeoGroup, T0); and the
binary's segment->zone map @49db20 has NO runtime caller (raw call-scan:
sole caller = CreateStreamedDamageZone, load time) -- no segment-level
collapse mechanism exists.  Oracle's night-10 'only LCT gets hits' audit
was the BUG's fingerprint, not the pod's design.

Fix: MechSegmentPick attributes the struck triangle to its draw op (index
range) and takes the op's .DZM-bound zone -- the #87 armour-darkening
bindings, the same authored patch->zone mapping that already paints the
panels -- with the segment dzone as the untagged fallback.  ZoneAimPoint
now aims hull zones at their patch CENTROIDS (all hull zones previously
shared the chest cull-center), which also upgrades the zone walker.

Bench (2-node zone-walk vs spinning madcat, zonewalk_madcat.sh): every
hull panel resolves individually -- utorso 11/12 in-zone, no L/R
mirroring, all four rear panels register; misses are the panel facing the
shooter mid-spin (correct geometry, not misattribution).  Victim applied
254 hits spread across every panel family.  Was 102/102 hull aims ->
dtorso before the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:57:14 -05:00
Joe DiPrimaandClaude Fable 5 81dda84e9b #108 forensics block (pre-717): ghost detector + IDs + field envs
The night-11 instrumentation that makes the next ghost/K-D report
diagnosable instead of anecdotal:
- UNGATED [ghost] stale-replicant detector: ReadUpdateRecord stamps every
  applied record; the death-handler tick logs ONE line per starvation
  episode (>600 frames, unburied) with entity id + mode + last position,
  plus a GHOST matchlog record.  Verified both ways: zero false positives
  on a healthy 2-node session; fires on both nodes at frame 601 after a
  mid-session relay kill.
- Entity IDs on the render forensics (MakeMechRenderables / RemakeEntity /
  wreck-swap fallbacks) and the replicant un-wreck line un-gated -- ghost
  triage no longer needs players to set envs.
- players/*.bat (steam + both joins): BT_MATCHLOG/BT_SCORE_LOG/BT_DEATH_LOG
  on for every field session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:34:06 -05:00
Joe DiPrimaandClaude Fable 5 26ab2fee3a Respawn-reset audit: valve restore + peer smoke cleanup (both binary-grounded)
Operator reports audited vs the binary (full matrix in RESPAWN_REARM_PLAN
addendum):
- VALVES (real gap): Condenser reset @004ae534 was missing from the decomp
  export -- raw disasm shows it chains HEATSINK (coolant refill runs; the
  old body chained HeatableSubsystem per the stale TCP shard) then, respawn-
  side, resets valveState to detent 1 and restores massScale from
  refrigerationFactor.  Mech::Reset now also runs the binary's tail call
  (@0049f788 BTRecomputeCondenserValves) so flow fractions rebuild from the
  reset detents.  Bench: detent 5 -> death -> "[respawn] Condenser1 valve
  detent 5 -> 1".
- #129 SMOKE (real gap, peers-only): the replicant un-wreck edge rebuilt
  the model without the @004d0c14 per-entity effect cleanup, so the
  observer's last 10s wreck-plume window rode the teleport onto the fresh
  mech.  BTStopEntityPfx now runs on the edge; bench shows no plume line
  after any un-wreck until the next death.
- AUTHENTIC (no fix): weapon->generator taps persist (@004b0e6c only
  resolves the link) and MFD display/control modes persist (mapper vtables
  0050f45c/0051e440 slots 8-11 = plain root bodies, read from the exe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:14:10 -05:00
Joe DiPrimaandClaude Fable 5 1f995ee35a #124 twist-sign VERIFIED correct (live missile bench) + the rig to do it
The frame-adapter's inferred twist-sign flip (SelectSlice theta -= twist,
vs the binary's += pre-reflection) was the last unverified half of #124 --
every earlier probe ran at twist 0.  Bench: stationary madcat target with
the torso PINNED at 0 / +140 / -140 deg, LRM salvos from a fixed shooter
(missiles = the authentic cylinder path; the binary DROPPED zone -1 beam
damage).  Result: slice picks track the physically-facing flank in BOTH
directions (twist-left -> right-family zones for left-flank impacts,
twist-right -> left-family), deterministic, wrong-sign outcome (slice 7
vs observed slice 1) clearly excluded.  No game-code change needed.

Instrumentation added (all env-gated):
- torso.cpp BT_FORCE_TWIST=<-1..1>: HOLD the sim's analogTwistAxis (the
  input-level pin was dead -- live input rides the CONTROLS.MAP device
  push, and Basic mode auto-centers; sim-level is plumbing-independent).
- dmgtable.cpp [slice] line: rot flag, live twist, thetaIn/thetaAdj,
  chosen slice -- the weighted leaf roll made zone-only logs ambiguous.
- [dmgresolve] now names the zone (BTMechZoneSegAndName).
- mech4.cpp BT_FORCE_TWIST input pin + one-shot mode cycle (kept as doc
  of the dead path), mechmppr [mppr] mode probe.
- scratchpad/night11/twistsign.sh: the 3-config bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:49:14 -05:00
Joe DiPrimaandClaude Fable 5 6180a44c64 #91 thor black rectangle: it's the OWN missile pod -- blakskn now material-keyed
Three testers reported a black rectangle swaying with the footsteps in the
thor cockpit (Summoner = the thr1 label, same THX canopy -- no isolation).
Decode: the inside view is a PER-MECH AUTHORED type-A set, not the cop
alone (no fallback in EntitySegment::GetVideoObjectName -- authored data):
madcat/vulture/bhk1 = cop; sunder/loki/avatar = +tor; thor = +tor +MSL
(the shoulder pod, the reported rectangle); owens = +both legs +tshd.
The pod/leg pilot-facing surfaces use the SAME "<pfx>skin:blakskn_dz_*"
interior-structure material as the canopy frame, but the unlit frame
constant was keyed on the _cop FILENAME -- identical material rendered
(0.13,0.12,0.15) on the canopy and pure (0,0,0) on the pod/legs
([matlog]: owx_cop blakskn vcol=FF211F26 vs owx_lule vcol=FF000000).

Fix (bgfload.cpp): the frame-constant treatment keys on meshIsCop OR
material contains "skin:blakskn_dz_". mechfx:blakskn_mtl (tshd shadow
quads) deliberately excluded. Verified: zero pure-black px in the lower
view band across walk captures, the pod plate renders frame-toned and
blends with the bar at rest (the reported anomaly dissolves), owens legs
read as coherent structure, canopy/terrain un-regressed.

Diags added: BT_MAT_LOG=<stem> per-batch material routing dump (bgfload),
BT_HIDE_INSIDE_SEG=<substr> inside-mesh hide (btl4vid), [view] per-segment
inside-roster names. KB: cockpit-view.md exactly-one claim corrected +
the #91 section; bench scratchpad/night11/thorrect.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 12:43:36 -05:00
Joe DiPrimaandClaude Fable 5 648f6b1675 Steam gate: rejected players now get a MESSAGE BOX, not a silent quit
A failed join returns 1 to the FE, which QUITS the exe -- so every
rejection so far was a log line plus "the game just closed" (#68's exact
complaint). New LobbyNotice() = same text in the day log (flattened, still
greppable) + blocking MessageBox. Wired to every join-side bail:

- BUILD MISMATCH: when the version-filtered search is empty, probe once
  without the version filter; if a lobby IS up, the box names the host's
  build vs ours (lobby data rides the list result -- no join needed).
  Post-entry verify mismatch gets the same box.
- NO LOBBY FOUND: probe empty too -> plain no-lobby box naming our build.
- STEAM UNAVAILABLE: transport install failed.
- LEFT BEHIND: host launched without us (no token in btl4map).

Old exes still exit silently on rejection -- nothing shipped today can
add text to a binary players already have; the host's roster marker +
REJECT log line remain the operator's view of those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:42:01 -05:00
Joe DiPrimaandClaude Fable 5 d050db5cae Steam BUILD GATE, host half: reject OLD exes at GO (the real Conn Man case)
The joiner-side filter only runs on builds that HAVE it -- a stale-zip
player runs an old exe with no filter, finds the lobby, and joins anyway.
The host must do the rejecting, and the lever already ships in every old
build: a member omitted from btl4map hits its own "the host's map is
missing us" path and fails the join cleanly.

1. PublishSelf stamps per-member data bv=BT_VERSION_STRING; an old exe
   cannot fake a key it never sets.
2. Host GO mint: any non-self member with absent/mismatched bv gets NO
   token -- omitted from the map, loud REJECT LobbyLog with both builds.
3. Room screen: mismatched members show [WRONG BUILD -- WILL NOT LAUNCH]
   so the host sees who's stale BEFORE pressing GO, not after the match
   starts short-handed.

Verified in the deployed exe by string scan (btl4ver, REJECT-at-GO,
BUILD MISMATCH ascii + WRONG BUILD utf16 all present). Field behavior to
verify on the next Steam night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:33:53 -05:00
Joe DiPrimaandClaude Fable 5 22f732fcfb Steam BUILD GATE: same-zip lobbies only (#108 confound killer)
Mixed-build lobbies silently corrupt raw-struct replication (night-9: one
stale-zip player, one desynced stream, ghost mechs + K/D doubt). Three
additive edits in btl4lobby.cpp:

1. HOST stamps the lobby with its exact build (btl4ver = BT_VERSION_STRING).
2. JOINER's lobby search filters on build equality -- a stale-zip player
   simply finds no lobby, and the "no lobby found" log line NAMES the local
   build so the report is self-diagnosing (#68's silent-exit lesson).
3. Post-entry verify (covers invites/direct joins + unstamped older hosts):
   mismatch -> log both versions loudly, LeaveLobby, fail the join.

LAN/relay (join.bat) handshake remains a separate 717 item -- this covers
the Steam path the operator asked about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:24:05 -05:00
Joe DiPrimaandClaude Fable 5 42b0691592 #119 leak-voice stutter SETTLED: authentic -- the wooHoo latch is authored OFF
Deterministic leak bench (BT_KILL_SUBSYS partial form, "Condenser4=0.5":
zone level without the crit -- a clean leak source) + the aud-tail receipts
decode the voice stutter end to end: the warning is a phrase-sequenced
voice patch (Warnings01 zones as notes), and every techstat leak-bit edge
restarts/stops the sequence mid-phrase (authored zero-release = hard cut).
At a drained tank the draw HUNTS the authored 0.0025/0.003 band -> edge
streams -> progressive clipping as more systems hunt.

The anti-spam wooHoo latch would bound exactly this -- but no armer exists
anywhere in the flat export, and the authored tuning is minDur=0 range=0
chance=0 (dumped live): DORMANT BY AUTHORING. The clipping is the 1995
experience; the port's one real bug here was the 2x hunt cadence, already
fixed by the 28 Hz filter (#119, 1df2c57). No code change warranted.

Bench additions: the partial-damage killsub form + the authored-tuning
dump (BT_LAMP_LOG).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:44:42 -05:00
Joe DiPrimaandClaude Fable 5 a728fa6de9 #118 alarm dig complete: the TechStatus bit model -- MarkGeneratorOut bridge removed
The gauge-alarm "condition" is a STATUS-FLAG BIT INDEX edge-scanned by
MechTech (bit 0/1 structure, 2 leak, 3 heat, 4 AmmoBurning, 5 Jammed,
6 !HasVoltage). Conditions 4/5 -> the engEject flash = the AMMO purge/unjam
invite (flashing the very key whose streamed function is EjectAmmo) --
never pilot eject; cond 6 -> the bus-switch invite; the PANIC lamp is the
sole pilot-eject indicator.

The destruction->stateAlarm(4) bridge is REMOVED as unfounded: alarms never
read stateAlarm; state 4 is the THERMAL BREAKER state produced by
GeneratorSimulation itself (byte-matched vs FUN_004b1f7c). And the binary's
crit distributor (@0049c9a8, read raw) touches nothing electrical -- a
generator destroyed in place keeps stale Ready voltage until any transition
recomputes output via (1 - damage) x rated. The port now matches that
subtlety exactly (verified: single-gen force-kill -> no bus invite, no arm,
silent unarmed keypad -- all authentic).

KB: the full bit table + invite semantics + electrical subtlety recorded in
decomp-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 03:18:50 -05:00
Joe DiPrimaandClaude Fable 5 609dde8051 #118 strict: REMOVE the coolant-clause hysteresis -- the flap is authentic
The operator's causality challenge held up: the hysteresis did NOT fix the
audio tick (the tick persisted past it and was the autofire scalpel's
20/sec jam clicks -- no game bug; control run without autofire is clean).
With the dead-code claim corrected, the boundary flap is AUTHENTIC: the
arcade evaluated the identical plain < 0.05 compare per frame, so a pod
hovering at the line flapped the same way -- cosmetic mode/lamp churn,
crash-free across hundreds of bench transitions. Deviation unjustified;
the binary's compare is restored.

Verified on the reverted build: armed PANIC press -> PUNCH-OUT (1); audio
profile clean (sparse explosions/warnings only).

Eject deviation ledger now: ONE item -- the Panic-button->pilot-keypad
desktop wire (hardware emulation, not behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:57:03 -05:00
Joe DiPrimaandClaude Fable 5 675fe68cb0 #118 correction sweep: the ARCADE's eject was LIVE -- 'dead code in 4.10' was wrong
The absolute-pointer scan missed the E8-relative call; the later byte-scan
found it (FUN_004a9b5c+0x10 -- the master performance evaluates eject
permission per frame in the shipped binary). Under deadline pressure the
disproven 'unfinished/dead code' claim leaked back into three comments and
the handoff; swept per the correction mandate.

Settled press model, now stated correctly everywhere: pilot eject = the
pilot KEYPAD bank while armed (+ the PANIC key reporting through that
matrix). The MFD soft keys NEVER pilot-eject in the binary -- every page
routes them to authored functions; the flashing engEject cell is the
INVITE LAMP. The port's page-gated eng-key eject is a MARKED CONVENIENCE
deviation (operator-requested), and the coolant hysteresis is a MARKED
smoothing deviation for a degenerate boundary oscillation -- not
completions of unfinished code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:40:39 -05:00