Author SHA1 Message Date
CydandClaude Opus 4.8 b59e2ac040 sec-surface phantoms: #48 wrap lit idx-254 art in live armor colors
Playtester report: (1) the CONTROL MODE stack showed filled boxes around the
inactive MID/ADV entries, (2) a phantom block between the HEADING dial and the
ARMOR rosette.  Root-caused [T1]:

  The #48 translation-table cycle-fill (6bb03ae, 2026-07-25) mapped
  out-of-range art indices in-plane as (index mod 2^bits) -- so art index 254
  landed on plane slot 62 = the LIVE colorMapperMultiArmor right-armor damage
  slot.  And idx-254 art EXISTS: every SMODE.PCC frame fills the INACTIVE
  mode-box interiors with 254, and BTSEC1.PCX carries a stray 52x13 idx-254
  bar at port (199-250,101-113) between the heading dial and the rosette (a
  scratch duplicate of the rosette quadrant bars).  Both lit up in the
  current right-armor color (adpal ramp green/orange/red) on EVERY render
  path.  On the shipped machine those regions rendered BLACK (the garbage
  entry's low plane bits were 0), which is why the 2026-07-19 smode audit --
  run before the cycle-fill landed -- verified CORRECT.

FIX (BuildSecondaryTranslation): map [2^bits..255] to translationTable[0]
  (plane BACKGROUND) -- the authentic on-screen result, same no-leak
  guarantee.  Verified on both desktop paths: MID/ADV back to authored
  borders+text (idx 5/9), no interior fills; the phantom bar gone; the BAS
  badge and the live armor rosette (in-range slots 60-63) untouched.

KB: gauges-hud #48 REFINED addendum; GAUGE_COMPOSITE audit row 33 corrected
  (binding is ControlsMapper/ControlMode, not DisplayMode) + re-verification
  note.

(The companion glass-token palette-generation fix lives on glass-panel-perf --
it depends on the dirty-skip code there.  The two branches touch disjoint
hunks and merge in either order.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 10:51:44 -05:00
Joe DiPrima 773595b347 integrate Cyd's glass-panel-perf (adopted over the local dirty-gate draft)
# Conflicts:
#	engine/MUNGA_L4/L4GLASSWIN.cpp
2026-08-09 23:27:16 -05:00
CydandClaude Opus 4.8 3aeb2dbbe8 glass panels: add the PlaneChecksum header decl (completes c9e25e5)
The dirty-skip commit staged the header via a lowercase path (l4vb16.h) while git
tracks it as L4VB16.h on this case-insensitive FS, so the PlaneChecksum declaration
was left out -- c9e25e5 as committed would not build from clean (L4VB16.cpp defines
SVGA16::PlaneChecksum, L4GLASSWIN.cpp calls it, but the header never declared it).
This adds the one-line decl.  HEAD now builds standalone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 23:03:41 -05:00
CydandClaude Opus 4.8 c9e25e59c5 glass panels: dirty-skip -- repaint only the windows whose gauges changed
Follow-on to the HALFTONE->COLORONCOLOR fix.  BTGlassPanels_Tick repainted all 7
glass windows every ~16 Hz pump unconditionally.  Now each window carries a change
token and the pump re-blits ONLY the windows whose token differs:

  token = FNV-1a checksum of the shared gauge pixelBuffer masked to the bits this
          window can show (SVGA16::PlaneChecksum over primary + Eng-twin + RGB-group
          ports)  combined with  each button's RENDERED lamp brightness + held/latch.

Folding the flash BRIGHTNESS (not the raw lamp state) into the token means a flashing
lamp repaints exactly when it toggles; a static panel or an idle cockpit skips its
expand + StretchDIBits entirely.  The masked checksum reads the RENDERED RESULT of the
gauges' values, so it catches everything -- discrete value gauges, the continuous
radar sweep, any imagery -- with no gauge->port->window plumbing and no risk of a
frozen display (full pass, no stride; collision-free in practice).

Measured (dev box, solo mission, BT_GLASS_DIRTY): ~31 pumps/2s -> 4-15 window-repaints
vs 217-224 always-on (~15-40x fewer); avg frame work 3.3 -> 0.93ms.  BT_GLASS_DIRTY=1
logs the repaint tally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 23:01:20 -05:00
CydandClaude Opus 4.8 f3d27f51c2 glass panels: drop HALFTONE stretch (the per-display-mode perf sink)
Playtester (Dave, SCREECH-PC, 4K) reported ~20 fps in the exploded per-display
glass panels vs ~130 fps in the cockpit surround -- same scene, same machine
(solo_20260809.log; maxDraw 57-92ms in panels vs 6-31ms surround).

Root cause: BlitSurface used SetStretchBltMode(HALFTONE) -- GDI's slowest,
per-output-pixel resample filter -- and BTGlassPanels_Tick repaints all 7 glass
windows SYNCHRONOUSLY on the main render thread every ~16 Hz.  7x HALFTONE
StretchDIBits of a 640x480 surface per pump stalls the frame.

Default to COLORONCOLOR (nearest); BT_GLASS_SMOOTH=1 restores HALFTONE.  The
MFDs are low-res pixel content, so nearest reads crisp -- arguably closer to the
pod CRT than the blur.  A/B on the dev box (fast, understates Dave's gain):
avg work 3.3->1.4ms, maxDraw stall 12-27->7ms, ~2.2x more frames per window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 22:40:25 -05:00
Joe DiPrimaandClaude Opus 5 2ed51cb1f3 #149: [glassperf] VERIFIED in the 2-node render harness (correcting 9e78d1c's premature claim)
9e78d1c said 'smoke-tested: lines live' -- that run never entered the render
loop and printed nothing.  This run did: 54 [glassperf] lines, e.g.

  ticks=16 tickMs=256.9 | Heat MFD 24.8 | Engineering 20.6 | Comm 18.7 |
  LeftW 20.6 | RightW 22.8 | Secondary/Radar 67.9 | ...

First finding, already: the synchronous panel sweep costs ~257ms/second even
on the DEV box (25% tax, hidden in headroom), and Secondary/Radar -- the
ROTATED portrait blit -- is the most expensive window.  Oracle's machine
paying 4-8x that through his driver is now a quantified hypothesis, not a
guess.  Optimization targets regardless of his verdict: dirty-gated repaints,
non-HALFTONE unscaled blits, cheaper rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 19:05:20 -05:00
Joe DiPrimaandClaude Opus 5 9e78d1cc23 #149: [glassperf] -- per-window glass paint timing, one line per second, default on
Oracle confirmed the separate-window glass mode WAS fast and tanked ~2 builds
back, so the regression is real and expresses only on his machine.  This names
the cost from inside his own session log:

  [glassperf] ticks=N tickMs=T | <window> n=paints ms=cost | ...

tickMs is the synchronous UpdateWindow sweep measured INSIDE the render frame
(L4VIDEO calls the tick), so on a machine where GDI serialises against D3D
present it IS the per-frame tax, and the per-window split names the panel.
Each WM_PAINT is QPC-timed at the dispatch chokepoint (the HALFTONE
StretchDIBits whose cost is driver-dependent).  Default ON like [segperf];
BT_PERF_LOG=0 opts out.  Smoke-tested: lines live in a 60s panel run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 18:51:51 -05:00
Joe DiPrimaandClaude Opus 5 2b6b0276fd #152: the real hole -- glass/RIO rigs had NO torso-centre control; wire pod button 0x42
The reported "torso no longer recentres on its own" decomposed into two parts:
the auto-recentre in Mid/Adv was never authentic (it was the stuck-centerCommand
bug acting as a phantom feature since ~674 -- analysis on the ticket, awaiting
Oracle's 1995-memory verdict), but underneath it sat a REAL defect: with the
phantom gone, a glass/RIO player had no way to recentre the torso at all.

WHY.  The only centerCommand writer lived inside the desktop key-bridge block,
which is OFF whenever a RIO/PadRIO is present -- glass and the pod both.  The
pod's dedicated CENTER button (0x42, "the shipped .RES name", UP arrow via
bindings.txt) reached nothing: benched two scripted 0x42 holds on the RIO path,
ctrCmd=0 throughout, twist parked forever.  (Keyboard X/NumPad5 recentred only
as a side effect of ALL-STOP -- you could not recentre without stopping.)

FIX, two pieces, both existing patterns:
  * L4PADRIO::EmitButton -- the documented single chokepoint every button
    source funnels through -- publishes the 0x42 HOLD state
    (gBTTorsoCenterHeld), exactly the 0x3F ReverseThrust precedent.
  * mechmppr gains ONE unified centerCommand writer, deliberately OUTSIDE the
    key-bridge gate (the same placement lesson as the mode-cycle hook):
    hold = torsoCenter(@0x154 databound) OR gBTTorsoCenterHeld(0x42) OR the
    one-frame X pulse; asserts while held, clears on release.  Single writer
    == the sources can never stomp each other's clear.

BENCHED (scratchpad/night14/center42.sh, RIO path, bridge off):
  before: ctrCmd=0 in all samples across two 0x42 holds; twist parked at 1.478
  after:  hold -> ctrCmd=1 recen=1, twist slews 2.22->0.44 at the authored
          0.87 rad/s; twist input DURING the hold cancels sim-side (authentic);
          release clears; no button -> aim holds (authentic Std/Vet).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 17:26:27 -05:00
Joe DiPrimaandClaude Opus 5 aa250c4e22 #149: [segperf] telemetry DEFAULT ON for every player (BT_PERF_LOG=0 opts out)
Operator call: an opt-in flag on the one machine we most need data from is
the silent-diagnostic mistake this project's doctrine exists to prevent.
Every session log now carries [segperf] beside every [rstat] window, so the
next playtest gives the 817->857 stall hunt cross-machine baselines for free
-- Oracle's stalling rig and Sauron's clean one, same night, same build.
Cost: ~2 QPC reads per segment query, microseconds per second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 17:09:15 -05:00
Joe DiPrimaandClaude Opus 5 a77c5a1407 #149: measure the filed suspect -- the #141 segment sweep is EXONERATED; ship [segperf] telemetry
A/B on identical 2-node beam-heavy runs (scratchpad/night14/segperf.sh),
BT_BEAM_SEGFRESH=1 (swept behaviour) vs =0 (pre-sweep compose at the beam
site, the only per-frame swept call):

                    fresh(swept)   legacy(pre-sweep)
  rstat blocks>50ms       0              0
  maxDraw worst         730ms          646ms      (mission-load spike, BOTH)
  segperf dirty-passes   38/s            4/s      <- the sweep DOES multiply
  segperf accessor ms   0.97/s          0.57/s    <- ...by 0.4 ms/s.  Noise.

So the invalidation-storm hypothesis I filed on #149 is wrong by three orders
of magnitude, and Oracle's sustained 50-104ms stall window does NOT reproduce
on this rig at all.  Refusing to guess a third time: the build now carries the
telemetry to answer it on the machine that actually regresses --

  [segperf] calls= dirty= ms=   printed beside every [rstat] window under
  BT_PERF_LOG (JMOVER counters; two integer increments when unset), and
  BT_BEAM_SEGFRESH=0 remains as a one-env A/B for the beam site.

Default stays FRESH (the swept accessor): its measured cost is trivial and it
is the correctness-cautious side while the peer-beam-staleness question is
unmeasured.

Next for #149: Oracle runs one session with BT_PERF_LOG=1.  If [segperf] ms is
large inside his stalled windows, segment work is implicated on HIS
configuration and BT_BEAM_SEGFRESH=0 gives the immediate A/B; if it is small
(as here), the stall is elsewhere in the 817->857 delta and we hunt with his
numbers instead of my theories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:46:10 -05:00
Joe DiPrimaandClaude Opus 5 84f8b1c415 night14: #137 fix write-up + close
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:20:19 -05:00
Joe DiPrimaandClaude Opus 5 0d6ed40db9 #137 FIXED: restore the binary Reset's +0x58c re-seed -- the respawn freeze was a
teleport-poisoned finite difference, not heat, not the reset, not velocity

The one-line fix is the binary's own SECOND Reset instruction, dropped in
transcription:  FUN_00408440(mech+0x58c, param_2)  ==
    accelPrevPos = origin.linearPosition;
+0x58c is the previous-position memory of the AccelerationLastFrame ring feed
(+0x81c/0x824/0x828/0x82c).  The port reconstructed the ring faithfully (ctor
part_012.c:9836, derivative :15169) but Reset never re-seeded its cache, so the
first post-respawn sample computed |newPos - prevPos|/dt = TELEPORT DISTANCE/dt
(~1e5) into the velocity ring; the ring-mean derivative spiked
AccelerationLastFrame (pure forward, with an opposite-sign echo ~15 frames later
as the sample rotated out of the 15-ring); and the myomer integrator @004b8d18
turned it into a one-tick pendingHeat deposit of ~3e9:
    termAccel = (1-accEff) * |v| * |a| * m * dt
      = 0.2 * 40 * 1.04e5 * 75000 * 0.044  =  2.75e9
snapping the freshly-reset myomers from T=77 to T~9000 against failT=2000 ->
speedEffect 0 -> speedDemand *= 0 -> "respawned unable to move until it cools".

WHY ~8% IN THE FIELD: the deposit needs |v| in the same 1-2 frames, so only
pilots whose throttle was still forward at the respawn -- physical lever /
HOTAS, exactly who reported it -- had gait-republished speed in the spike
frames.  Idle-throttle respawns deposit ~nothing.  (And frozen-subset-of-
died-hot: died hot == was running hard == lever still forward.)

MEASURED, same abusive bench (0.95 throttle + continuous autofire):
    before: deposits up to 3.35e9, every one 3-4 lines after a Mech::Reset;
            4-6 of 7 respawns frozen; post-reset myomers T 7700-12100
    after:  deposits >1e7: ZERO across 7 respawns; frozen: ZERO;
            post-reset myomers T 77-178 (vs degradeT=1000)

THEORIES KILLED ON THE WAY, each by operand data, in order:
  * stale pendingHeat carryover (bounded to 1 frame, +1.2K -- gates agent)
  * slow accumulation during the death window (consumers tick the wreck)
  * conduction from a hot neighbour (roster-wide snapshot: ALL partners 77;
    flow trap: zero e6 flows into the myomers, ever)
  * drag/impulse writers (both trapped: never fired)
  * my own earlier "velocity-driven, players accelerate to top speed" close of
    the ticket -- arithmetically impossible (input ceiling ~6.5e5/frame ~=
    60 deg/s; the observed snap was 1,100-21,000 deg/s) and corrected in
    context/subsystems.md, which carried the wrong paragraph.
  * the localAcceleration zero-fill (+0x1dc) restored along the way is KEPT --
    the binary does it -- but it was NOT the cause; the snapshot is rebuilt
    from the position difference one frame later.

Probes kept (all BT_HEAT_LOG-gated): roster-wide [myofreeze] at-death/at-reset/
post-reset (T + heatEnergy + pendingHeat per subsystem), the [heatflow]
conduction trap with full operands, the [myodep] deposit trap with mech
identity + acceleration components.  Engine MOVER.cpp traps reverted -- they
proved their negative (drag/impulse innocent) and do not belong in engine
source.

Gotcha #30 records the class: when the binary's Reset writes a cell you don't
recognize, that write IS the spec -- transcribe the whole zero/seed list; any
prev-value cell backing a finite difference must be re-seeded at every
teleport; and derived state (T) sampled at the reset proves nothing about the
backing producers.

The operator called the shape of this two days ago: "maybe the math gets screwy
in respawning while some systems are ticking while values are being reset."
That is precisely what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:19:36 -05:00
Joe DiPrimaandClaude Opus 5 df1b4651b3 #137 CALIBRATION SETTLED: the myomer heat math is faithful -- constants read byte-exact
Read the integrator's constants from .rdata rather than inferring them.  Row
` 4b8ee0  5dc30000 0000003f 00000000 0000803f` gives:

    _DAT_004b8ee4 = 0.5f   the kinetic half  (work = mass * |v|^2 * 0.5)
    _DAT_004b8ee8 = 0.0f   the Abs() idiom zero
    _DAT_004b8eec = 1.0f   the (1 - efficiency) complements + gear clamp floor

All three are exactly what the port computes, and the logged terms reproduce
from the authored tuning to the digit:

    work        = 75000 * 54^2 * 0.5                       = 109.3e6
    complement  = 1 - VelocityEfficiency(0.995)            = 0.005
    termKinetic = 109.3e6 * 0.005 * (dt*28 = 0.728)        = 398e3  (logged 399003)

THE ONE DEVIATION IS DELIBERATE AND IS THE FAITHFUL CHOICE.  The binary applies
NO time_slice to the kinetic term (`fVar5 * fVar1`) while climb and accel both
carry param_2 -- a per-frame energy add at the pod's FIXED ~28 Hz.  The port's
`work * (time_slice * 28)` is identical at 28 Hz (dt*28 = 1.0) and holds the same
heat-per-SECOND at any frame rate.  Transcribing it literally would add the full
term once per frame, so at Oracle's measured 170 fps it would inject ~6x the heat
the pod ever did.  Preserving behaviour beats preserving the artifact of a fixed
timestep.  BT_MYO_HZ still brackets the reference rate.

SO #137 IS NOT A CALIBRATION DEFECT.  Heat is QUADRATIC in speed, so v~50 on open
ground after a respawn is ~9x the input of v~10-18 in a fight -- which is why the
overshoot correlates with respawns without being caused by them.  It is also
self-limiting: effectiveness reaches 0, the mech stops, speed falls, it cools.
That is the authentic governor.  Whether the cliff is too punishing for players
is a DESIGN call for the operator, not a fidelity bug.

Recorded in context/subsystems.md so the next reader does not re-litigate the
dt-normalisation as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:22:15 -05:00
Joe DiPrimaandClaude Opus 5 6a122f6cdf #137: the post-respawn heat spike is VELOCITY-driven, not reset corruption
Operator hypothesis tested: does something tick mid-reset and compute across
half-reset values (the teleport discontinuity) and inject a heat slug?

NOT SUPPORTED, measured.  [myoheat] terms straddling a respawn:

    BEFORE (-334)  v=10.7  kinetic= 21941
    BEFORE (-116)  v=18.8  kinetic= 59560
    AFTER  (  +17) v= 6.1  kinetic=  3517   <- SMALL at the discontinuity
    AFTER  ( +129) v=46.7  kinetic=297349
    AFTER  ( +337) v=54.1  kinetic=399003

The sample immediately after the reset is small.  Nothing computes across the
teleport; there is no injected slug.

WHAT IT ACTUALLY IS.  work = mass * v^2 * 0.5, so heat is quadratic in speed.
After a respawn the mech reaches v~50 against v~10-18 while fighting before it
died -- roughly 9x the heat input -- because it comes back on open ground with
nothing to fight and accelerates to top speed.  That is why the earlier
correlation looked like causation: temperature is highest just after a reset and
decays with distance from it, but the driver is SPEED, not the reset.

Reconciles both benches: 0.95-throttle runs overshoot, 0.50-throttle runs never
approach failT, and the operator's point that ordinary gameplay feels fine holds
-- at combat speeds the model behaves.  It only runs away at sustained top speed.

Term arithmetic checks out against the authored tuning:
    work = 75000 * 54^2 * 0.5              = 109.3e6
    workComplement = 1 - VelocityEfficiency(0.995) = 0.005
    termKinetic = 109.3e6 * 0.005 * (dt*28 = 0.728) = 398e3   (logged 399003)
so the port is computing what it intends to.  The open question is whether the
INTENT is right -- i.e. whether (1 - 0.995) against the FULL kinetic energy is
the authentic scaling, or whether the binary's dt-less per-frame add at the
pod's fixed ~28 Hz means something different from our rate-normalised form at
100-170 fps.  That is now a calibration question with a specific target, not a
hunt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:18:59 -05:00
Joe DiPrimaandClaude Opus 5 abea159b23 night14: #137 mechanism write-up to the tracker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:04:57 -05:00
Joe DiPrimaandClaude Opus 5 8c612e4e18 #137 SOLVED (mechanism): not a respawn bug -- the myomer heat RATE under load is
Controlled A/B on the same bench, only the load differs:

  0.95 throttle + continuous missile autofire : 4 respawns, 17 frozen samples,
                                                T climbs 77 -> ~11,600
  0.50 throttle, no weapons                   : 2 respawns, ZERO post-reset
                                                freezes; T stays ~77

So the respawn is exonerated on measurement, not on argument:
  * the reset works -- T == startingTemperature at every reset, both runs;
  * the stale Myomers::speedEffect (@0x31C, which no RTIS writes) is real but
    self-heals on the next tick (T=77.13 -> speedEffect=1);
  * with ordinary load the myomers never approach failT=2000 after a respawn.

WHAT PLAYERS ARE ACTUALLY SEEING.  Overheat while running hard -> myomers pass
failT=2000 -> derating curve @004b8ac0 returns 0.0 -> chain MAX 0 ->
speedDemand *= 0 -> bogged down -> die (often BECAUSE bogged down).  Respawn
correctly resets to 77.  Resume high throttle + firing and it climbs back over
the cliff within seconds, which reads as "respawned with the heat bar maxed".
That is why it looks like a reset bug and why it is intermittent (~8% of
respawns in the field): it tracks how hard you were driving, not the respawn.

THE REMAINING DEFECT is the CLIMB RATE, not the reset.  Blowing ~6x past a
cliff the design treats as coolant-managed (degradeT=1000 governor onset,
failT=2000) is not a lever a player can work with.  Suspect under review: the
kinetic term.  The binary (@004b8d18) applies NO time_slice to it --
`fVar5 * fVar1` -- while the climb and accel terms both carry param_2, i.e. it
is a per-frame energy add at the pod's fixed ~28 Hz.  Our port rate-normalises
it (`work * (time_slice * 28)`), which agrees per-second at any frame rate, so
that is NOT yet a proven discrepancy -- it needs a term-by-term dump against
the authored tuning (VelocityEfficiency 0.995, AccelerationEfficiency 0.8,
thermalMass 2.5e5, myomers linked Condenser5 = Oracle's "loop 5").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:04:02 -05:00
Joe DiPrimaandClaude Opus 5 5556329807 #137: REPRODUCE the myomer freeze -- reset is innocent, the myomers RUN AWAY after it
First reproduction of #137 in a bench.  The reset path is exonerated and the
real defect is located, though not yet explained.

DECOMP RE-READ (what the reset actually does):
  Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
    (slot 10 = ResetToInitialState), then @0049f788.
  Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
    HeatSink::RTIS @004ad760, whose first act is
        param_1[0x45] = param_1[0x4f]     // currentTemperature = startingTemperature
    (byte 0x114 = 0x13C).  Our port matches AND additionally resets heatEnergy,
    which it must, since HeatSinkSimulation derives
        currentTemperature = heatEnergy / thermalMass.
  The freeze itself is the derating curve @004b8ac0:
        temp >= degradation(@0x118) -> falls off
        temp >= FAILURE(@0x11C)     -> 0.0  -> chain MAX 0 -> speedDemand *= 0.
  Nothing in the reset chain touches Myomers::speedEffect (@0x31C).

MEASURED (scratchpad/night14/myofreeze.sh -- hot mech, repeated self-kill):

    at-reset   Myomers T=77      deg=1000 fail=2000  speedEffect=0   <- stale
    post-reset Myomers T=77.14                        speedEffect=1   <- 1 frame
    post-reset Myomers T=9297.8  fail=2000            speedEffect=0
    post-reset Myomers T=11620.4 fail=2000            speedEffect=0

So, in order:
  * THE RESET WORKS.  T is exactly startingTemperature at the reset.  My
    original bench was right about that much; it just stopped looking there.
  * THE STALE speedEffect IS REAL BUT HARMLESS -- it survives the reset (no
    RTIS writes it) yet self-heals on the very next tick.  Not the bug.
  * THE MYOMERS THEN RUN AWAY: 77 -> ~11,600 against a FAILURE point of 2,000,
    nearly 6x over, in seconds.  That is not heat earned by running; that is a
    runaway, and it is what pins speedDemand at 0 until it cools -- exactly
    Oracle's "maxed heat bar ... unable to move until it cools off".
  * Rate: 7 of 105 census samples over the failure temp (~6.7%), against the
    field's 5-of-61 respawns (~8%).  Same order, so the bench is reproducing
    the field condition and not a bench artifact.
  * The myomers link to Condenser5 (mass=250000 k=190000) -- literally Oracle's
    "loop 5 and generator D heating up as all the excess heat goes into the loop".

NOT YET ESTABLISHED, and the reason this is a checkpoint and not a fix: whether
the runaway is CAUSED by the respawn or is a heat-model calibration problem that
merely CORRELATES with it (you die when you overheat, so respawns cluster around
hot periods).  The bench drives at 95% throttle with continuous missile autofire,
which is abusive, and the [heat-t] census has no pre-first-reset samples to
compare against.  Next step is to instrument the heat INPUT and diff a
respawn-adjacent window against a steady-running window.

Probe added: [myofreeze] prints T / degradation / FAILURE / speedEffect per
myomers plus the chain MAX the mover multiplies by, AT the reset and for ~4 s
after (armed by Mech::Reset, sampled where the multiplier is formed).  The
post-reset window is what nothing was watching -- sampling only at the reset is
what made this look innocent and got the ticket wrongly closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 11:46:23 -05:00
Joe DiPrimaandClaude Opus 5 92783b9935 night14: tracker housekeeping for the 4.11.857 playtest
Closed 5 confirmed-fixed (#52 #108 #140 #141 #142), commented 3 (#135 #146
#147), REOPENED #137, filed #149-#158.

#137 reopened because I closed it wrongly.  Field scan of all four logs: 5 of
61 respawns froze (throttle up, speedDemand pinned at 0) across THREE machines
-- ~8%, which is why a bench that reset cleanly every time never saw it.  The
myomers come up Overheating in the same breath as Mech::Reset, before any
running could earn the heat.  Oracle's 'unable to move' was the detail that
disproved my 'it earns the heat by respawning under throttle' explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 11:20:21 -05:00
Joe DiPrimaandClaude Opus 5 0961a0b3f3 Revert the key G action ModeCycle default -- F7 already does it, on both keymaps
c1a87b6 added G on the premise that a keyboard-only player could not reach the
control mode.  That premise was WRONG, caught by the operator: I checked only
for `action ModeCycle` verbs and missed the CONSOLE BUTTON route.

    content/bindings.txt:147   key F7 button 0x18
    content/CONTROLS.MAP:136   key F7 button 0x18

0x18 IS CycleControlMode (context/pod-hardware.md:69), and it is wired:
MESSAGE_ENTRY(MechControlsMapper, CycleControlMode) ->
CycleControlModeMessageHandler -> CycleControlModeNow -- the same body the
gamepad Start and the pod console button drive.  So F7 has always cycled the
mode from the keyboard, on BOTH keymaps.  Every console button really is mapped.

Worse than redundant: CONTROLS.MAP:172 already binds `key G action Flush`, so
shipping G=ModeCycle in the glass default board would have made G mean different
things on the pod and on glass -- two populations with two sets of muscle memory,
which is precisely what the bindings-board migration exists to prevent.

Reverted from the shipped default template and from the local bindings.txt.
The torso-twist verification stands: the operator cycled modes and confirmed the
fix live; only the key used to do it was needlessly new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 17:48:57 -05:00
Joe DiPrimaandClaude Opus 5 06d3b93507 pod: fix the podkit INFINITE LOOP, version-control podkit.ps1, and write the remote runbook
Deploying 4.11.854 to the cart hit a wall that was entirely self-inflicted and
entirely undocumented.  Both are fixed here.

THE BUG.  podkit.ps1 (which pushes the frozen rig config into a fresh install)
had, at line 30:

    while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }

Once $keep trims to a SINGLE blank line, $keep.Count-2 is -1, and PowerShell's
$keep[0..-1] returns TWO elements (index 0 and index -1) instead of shrinking --
so the array GROWS and the loop never ends, RSS climbing past 60 MB.

It fires whenever everything outside environ.ini's marker block is blank, i.e.
any environ.ini that was ALREADY kitted -- which is exactly what you get when you
carry config forward from the previous install, the normal upgrade path.  So this
would have bitten every future deploy, not just this one.

Signature: a BLANK cmd console on the cab, BT411Run stuck "Running", no btl4.exe,
no podrun.log.  And it CASCADES: each hung run holds environ.ini so every later
attempt blocks behind it -- while over SSH your client times out and the REMOTE
powershell keeps running, so "it returned instantly and did nothing" actually
means "it is still hung".  Six stuck processes had piled up before I spotted it.

Fixed to `-gt 1`, patched on the pod (podkit.ps1.bak is the original), and the
script is now IN THE REPO (tools/podkit.ps1) with the trap explained inline --
previously it existed only on the cab, so a restore would silently bring the bug
back.  Pod and repo copies are byte-identical.

THE RUNBOOK (context/pod-hardware.md).  Written because I improvised instead of
reading the one paragraph that already existed, on a live stream.  Now covers:
  * ssh bt411-pod -- and WHY it looked like auth was never set up: the key has
    existed since 2026-08-06, but ssh will not OFFER it without a ~/.ssh/config
    entry, so you get "Permission denied (publickey,password,...)".  Also that
    the Tailscale NODE KeyExpiry is not an SSH credential and Tailscale SSH is
    not enabled on the pod.
  * bare taskkill/setx return "The system cannot find the path specified" over
    this SSH+cmd session -- call System32 tools by ABSOLUTE path.
  * the deploy sequence, identical to a tester's: mkdist -> scp -> Expand-Archive
    -> podkit.  Local config is never clobbered because mkdist packs git-TRACKED
    content only and bindings/environ/glass_layout are gitignored.
  * PODTEST.EGG is not in the repo -- only podkit carries it; without it the
    launcher runs and nothing appears, with no error.
  * schtasks /run /tn BT411Run for GUI work (session 1); /end first, because a
    task already Running refuses /run with 2147946720.
  * the measured panel identities (below).

PANEL IDENTITY, measured over SSH (WMI is session-independent, so no GUI needed):
the cab's two RAR0005 panels SHARE one EDID code and have blank serials --
exactly the collision flagged as unproven in ec080cd -- so monitor:id:RAR0005 is
ambiguous and they must use the per-connector form (monitor:id:UID224795 /
monitor:id:UID200195).  The Dell 1908FP's code is unique.  The cab's shipped
glass_layout.cfg still uses the fragile monitor:DISPLAY4 device-name form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 17:31:40 -05:00
Joe DiPrimaandClaude Opus 5 c1a87b602c bindings: ship key G action ModeCycle in the built-in default board
ModeCycle was bound ONLY to the gamepad Start (CONTROLS.MAP), and bindings.txt
carried no action bindings at all -- so a KEYBOARD-ONLY player could not reach
the control mode.  That is the control the torso-twist fix has to be tested
through, and it is a shipped gameplay feature (Basic steers with the stick and
auto-centres the torso; Standard/Veteran twist the torso and steer with the
pedals), so it should not be gamepad-only.

G is free.  M is taken -- it is keypad button 0x02.

DELIBERATELY the default TEMPLATE only, not a bindings-board bump.  content/
bindings.txt is a per-machine runtime file (gitignored) and an existing one is
never overwritten, so:
  * a FRESH extract writes the new board and gets G -- which is the zip flow;
  * an EXISTING tester keeps their file, and their customizations, untouched.
Shipping it to existing files means bumping "# bindings-board" and extending
kHistoricalDefaultRows so board-2 rows are recognised as "the old board" rather
than player customizations.  That is the designed path, but it rewrites every
tester's bindings file, and doing that in the same build as a large unverified
fix pile is how you lose a playtest evening.  Left for a build where it is the
headline change.  Testers on an existing install can add the one line by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 16:00:05 -05:00
Joe DiPrimaandClaude Opus 5 990ad52563 podprobe: emit boot-STABLE panel identities + ready-to-paste monitor🆔 lines
Adds a section resolving each screen's panel identity via the SAME Win32 API the
game uses (EnumDisplayDevices on the display's MONITOR child) rather than the
WmiMonitorID query above -- if probe and engine read different sources the
printed fragments could fail to match what the engine tests.  Verified on the
dev box: probe and engine emit byte-identical identities.

Answers the open [T3] multi-panel question WITHOUT deploying a build (pure OS
data).  When one EDID code appears on SEVERAL panels (identical MFD models) it
detects the collision and emits the per-connector UID form instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 15:58:12 -05:00
Joe DiPrimaandClaude Opus 5 ec080cd61d pod displays: bind glass panels to EDID identity, not to Windows' shifting display numbers
Nick, after re-cabling + rebooting the pod: "the order changed ... sometimes
they change when one gets turned off and back on, at least how windows SEEs
them, even if the visual desktop tool looks the same."

Both existing binding forms are boot-fragile: `monitor:2` is an ENUMERATION
INDEX and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.  Neither
survives a panel power-cycle.  (His gos-displays.txt shows the same trap next
door in GameOS: -tmon takes DIRECTDRAW device indices -- not Windows monitor
numbers -- and a NULL-device merge shifts every index down by one on top.)

FIX: bind to the panel's own hardware identity.  EnumDisplayDevices on a
display's MONITOR child returns a DeviceID embedding the EDID manufacturer +
product code and the connector instance; neither moves across a reboot.

  DISCOVER  BT_GLASS_IDS=1 logs every attached panel's stable-id AND a
            ready-to-paste `cfg form = monitor🆔<fragment>`.  It prints the
            VOLATILE identifiers alongside on purpose: run it either side of a
            power-cycle and index/device move while stable-id does not.
  BIND      Heat MFD=monitor:id:AUO10ED,bare

NOTHING CHANGES BY DEFAULT -- no env and no `id:` prefix means identical
behaviour; `monitor:<name|index>` and raw x,y keep working, so playtester glass
builds are untouched.

An `id:` that matches nothing WARNS and falls back to computed placement.
Silence would put a picture on the wrong glass and look exactly like the bug
this form exists to prevent.

VERIFIED on a 1-monitor dev box (the pod is offline), end to end:
  * discovery printed
      stable-id = \?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-...}
      cfg form  = monitor:id:AUO10ED
  * `Heat MFD=monitor:id:AUO10ED,bare` resolved and CENTRED correctly
      [glasswin] 'Heat MFD' bound to monitor 0,0 1920x1080 -> window at 640,300
  * a bogus id warned instead of misplacing.
The EDID-code extractor is deliberately STRUCTURAL (3 letters + 4 hex digits,
tokenising on \ # ?) rather than positional: the first cut walked separators by
position and returned EMPTY for the `\?\DISPLAY#...` interface-name form, which
is exactly the form this machine produces.  Which form you get depends on
whether EDD_GET_DEVICE_INTERFACE_NAME succeeds, so both must parse.

STILL UNPROVEN [T3] -- the pod is offline: multi-panel disambiguation when
several MFDs share one model (EDID codes collide).  The documented answer is a
longer fragment from stable-id, whose UID/instance tail differs per connector,
but that needs the cab to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 14:20:38 -05:00
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 43777569f9 night13: close #148 as not-a-bug on the tracker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:11:47 -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 bb6605d53b night13: #148 tracker correction script
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:57:00 -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 eebe7e61a4 gotcha #28: hand-composing an engine-derived transform reads a stale cache (replicant-only)
The #141 bug class, written up so it is not re-introduced.  GetSegmentToEntity
recomputes ONLY when segmentModified is set; JointedMover::GetSegmentToWorld is
what sets it -- and the binary's own GetMuzzlePoint @004b9948 goes through it
(FUN_00424da8), so every muzzle query in the 1995 image performs the
joints->segments refresh.  Four port sites hand-composed instead, one of them
commented "the faithful FUN_004b9948".

Records the four rules the investigation actually cost:
 (a) never hand-compose; call GetSegmentToWorld
 (b) never force the dirty flag to fix a stale read -- that stand-in scored
     IDENTICALLY to the faithful fix while patching only one consumer
 (c) "peer POV only" geometry bugs = suspect a cache the local render pass
     refreshes for free, before suspecting replication (it was provably fine)
 (d) a partial-looking score: check PREFIX vs interleaved before calling it
     partial -- these were a clean prefix ending when the peer first had a
     twist to carry, so the fix was complete and "64% fixed" was wrong
 (e) the probe trap: one shared static sampled every Nth call hides one of two
     alternating instances entirely

#141 closed with the full write-up; #148 filed for the torso replication
cadence (13 records in a 5-minute run) which is a separate, real problem and
likely bears on #37 and #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:29:47 -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 98082e64a0 KB sweep: retire the three claims that WERE the scoring bugs
Night 13 turned up three confident KB/source notes that each closed off a
working path, and each one was the defect:

  1. context/combat-damage.md -- "@0x4c0200 is in NO table entry: dead code",
     used to justify retiring per-hit inflicted credit in build 787.  It is
     reached through BTPlayer's Dispatch override (vtable @00513300 slot 3).
     (corrected in 2772175)
  2. context/decomp-reference.md -- "shipped content authors NO role keys, so
     the cost is 0 in the field".  Wrong on both halves: the fields come from
     the role MODEL's GameModel record, not notation keys, and dfltrole
     authors killBonus=500 / deathPenalty=500 / dmgInf=1 -- the manual's chart
     verbatim.  The cost read 0 because the role was never bound.
  3. docs/RECONCILE.md -- "role registry has no WinTesla analog -> stubbed;
     base-set scenarioRole stands".  The base ctor sets it NULL, so nothing
     stood, and Mission::GetScenarioRole IS the analog.

Common shape worth remembering: all three asserted an absence (dead code, no
authored data, no analog) and none was re-tested against the binary before
being built on.  An absence claim in this KB should carry the check that
established it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:22:47 -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 2fcce53bb2 scoring benches: self-inflicted + deaths counter verified (night 13)
scoreself2.sh -- one rig covering three unverified items, using a SELF-DESTRUCT
to reach the same paths an eject does without the panic button that defeated
five earlier rigs.

VERIFIED:
  chart '-1 each self-inflicted point'  type=0 award=-40.00 x11, total -440
  self-kill negation (#134)             type=2 award=-539.00, kills NOT incremented
  deaths counter                        PLAYER_DEAD deaths=1 tally=1

OPEN, found by arithmetic: the -500 death cost fires on a COMBAT death (prior
run: victim total exactly -500.00) but NOT on a self-kill -- A's total is
exactly -440 + -539 = -979, with no -500 in it, despite advDamage=1 and the
role bound.  The cost is dispatched by a DIRECT Player::ScoreMessageHandler()
base call, bypassing the BT handler, so it never reaches the matchlog and only
the totals expose it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:29:34 -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 43d30ca7e4 #140 receipts: name the corruption case in a FIELD log
Two ungated one-liners, because no bench here reached the failing path and
the next playtest is a better instrument than more automation:

  [glasswin] destroy entry #N windows=M   -- N=2,M=0 is the double-destroy
  [glasswin] saved ... (live=L remembered=R) -- live=0 IS the corruption case
                                                (pre-cache that wrote a file
                                                holding only the plasma line)

Also lands the benches that did NOT reproduce it, with their failure modes
recorded in the headers so the next attempt does not repeat them:
layoutsave.sh (round trip -- passes on the fixed build), layoutteardown.sh
(graceful WM_CLOSE; still never reaches the dtor chain), layoutround.sh (MP
round boundary; the relay never started the mission inside the window).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 01:23:33 -05:00
Joe DiPrimaandClaude Opus 5 c04bec0a52 #140 glass_layout.cfg lost every MFD line on a desktop teardown
Regression from d213c98 (the pod PadRIO/panel coupling fix).

BTGlassPanels_Destroy calls SaveLayout FIRST, unconditionally, before it
looks at whether any windows are left.  d213c98 added a SECOND caller
(~LBE4ControlsManager) alongside the existing one in ~PadRIO, so on the
desktop path both run: ~LBE4ControlsManager does `delete rioPointer`, which
fires ~PadRIO -> destroy #1 saves the live windows and zeroes gWinCount ->
destroy #2 then rewrites the whole file from an empty list.

Why only the MFDs vanished, which is the detail that identifies it: external
windows (plasma) already cached a last-known rect (gExtern[].haveLast) and
were written from the cache; the per-display glass windows had no cache and
were simply skipped once their HWND was gone.  Hence the reported signature,
"all the MFDs and secondary lines missing, but plasma was still there".

The pod was never affected -- no PadRIO there, so only one destroy, and it
runs BT_GLASS_LAYOUT=load off a frozen master regardless.

Two fixes, because the guard alone would leave the trap armed for the next
teardown-ordering change:
  1. glass windows get the same remembered-geometry cache the extern windows
     have, kept ACROSS teardown.  The file is now monotonic -- a save can
     update a line or add one, never drop one.
  2. the teardown save is guarded on there being windows to report.

Also corrects the comment at the L4CTRL call site, which claimed the second
call was "a no-op on the PadRIO path".  That claim is what made it look safe.

Verified (scratchpad/night13/layoutsave.sh -- the tester's round trip, not a
single launch, since the report was "saved fine, reset on relaunch"):
  run 1  one "saved 8 window position(s)" line (was two, the second wiping)
         cfg holds all 7 glass windows + plasma
  run 2  "restored 7 window position(s)", cfg byte-identical after the trip

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 01:03:32 -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
84 changed files with 6155 additions and 784 deletions
+70 -17
View File
@@ -606,10 +606,21 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry:
dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the
strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides
**`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base
dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in
its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes
`CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`.
`ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees
type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART
(`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each
self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored;
benched 83 inflicted rows, awards 0.9825.00, zero type-0 Verify rejections. [T1]
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
@@ -617,9 +628,61 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that
followed from the dead-code misreading corrected in report B above, and retiring the credit IS the
scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`)
is the cross-check for every row and should be consulted before touching this path again:
| Points | Event |
|---|---|
| +1000 | Starting the game |
| +1 | Each damage point scored on opponent's armor |
| +10..+30 | Destroying an opponent's internal system |
| +500 | Destroying an opponent's 'Mech |
| 1 | Each self-inflicted point of armor damage |
| 10..30 | Knocking out one of your own internal systems |
| 500 | Destroying your own 'Mech by an ammo explosion |
| 1000 | Destroying your own 'Mech by ejecting |
**THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The
binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL`
and 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 a delta: every node's contribution is flushed
stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly
where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it.
**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge),
`Player::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 credit is banked on the wrong machine,
where the master's next update record overwrites it (benched: totals climb to ~35, snap back every
few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0
interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension
fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base
`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already
credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the
victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report
does — do not ship the basis and recompute where it cannot be seen.
**The damage-bias term is VESTIGIAL — do not "finish" it** [T1, audit 2026-08-08]. The kill/inflicted
formula's `(victimAvgZoneDamage@0x354 × damageBias + 1.0)` factor is **always 1.0** in the shipped
binary. `mech+0x354` has exactly one writer — `Mech::Reset` (@0049fb74, part_012.c:14340), which
computes `mean(zone+0x158)` across every damage zone *after* the zone heal has zeroed those cells —
and exactly one reader, `CalcInflictedScore` (@004c052c). Nothing recomputes it during play, so it
holds ~0 for the mech's whole life. `0x358`/`0x35c` are the same computation over subsystem zones
and have **no reader at all**. The port's `MECH_DAMAGE_BIAS(m) → 0.0f` therefore reproduces the
binary exactly; wiring it to live damage would look like completing a stub and would silently
inflate every award (the chart-verified +1/point and +500/kill both assume 1.0).
**Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]:
(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at
game start** has no known implementation; (c) **1000 eject / 500 ammo** would live in
`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads
(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors
nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by
hard-coding chart numbers; find where the binary sources them.
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
@@ -637,16 +700,6 @@ Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 awa
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch
[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test
@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable
slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal
Total by 9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view
is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing
else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the
burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync
DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Implemented 2026-08-06** (branch `ppc-sync-distortion`).
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
(half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by
+36 -1
View File
@@ -430,6 +430,31 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
recomputes output via `(1 damage) × rated` and zeroes it. stateAlarm 4's
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
destruction→state-4 bridge was removed as unfounded.
- **THE CONDITION FLAGS ARE ROUTINE, NOT ALARMS — field-verified 2026-08-08 [T2]:** a
`[techstat] <sub> condition <n> SET` is an *operating* flag ("this component is above
nominal / browned out RIGHT NOW"), not a fault report. Census over a full Steam match
(`steam_20260806_c_michael_XIAOLONG.log`): cond 3 **Overheating** fires 33× on LLaser_2,
31× LLaser_1, 26× SRM4, 18× PPC_2 — every weapon trips it on each volley and clears it on
cooldown; cond 6 **BadPower** fires 8× on Myomers, 3× each on SLaser_1/PPC_1/MLaser_1 as
the bus browns out under simultaneous draw. **Every subsystem's SET/CLEARED counts are
balanced** (GeneratorD 5/4, GeneratorA 8/7, Myomers 5/4, Condenser5 1/1 — the odd extra SET
is only the log ending mid-heat). Nothing latches. **Never read a post-respawn `condition 3
SET` as a reset failure** — that inference cost a full two-sided reset audit (#137 below).
Diagnose a heat complaint from the SET/CLEARED *balance* and the throttle state, not from
the presence of a SET.
- **#137 "respawn came back with MYOMERS heat MAXED" = NOT A BUG, closed 2026-08-08 [T2]:**
`Mech::Reset` is clean on both sides — bench: every roster subsystem incl. all six
Condensers at `T=77 start=77`; field log: every live condition CLEARs 1216 lines after each
reset. The heat is real and immediate because **the mech respawns still under power**: at the
reset the log reads `thr=1`, `cycleSpeed=14.6`, gait state 12 (running), and the myomers →
Condenser5 → GeneratorD trip Overheating within ~12 s. `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, which is correct for a pod whose throttle is a PHYSICAL lever still
under the pilot's hand. **Desktop caveat (real, and separate):** the glass bridge emulates
that lever with a file-static ramp accumulator `sLever` (mech4.cpp:3250), zeroed ONLY by the
X all-stop and a direction-crossing snap — so a pad/keyboard pilot, who is physically holding
nothing, likewise respawns at speed with the lever state invisible. Port-layer question, not
a heat bug.
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
@@ -439,7 +464,17 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
pod's death cost may never have displayed; our single-cell port shows it. Shipped
content authors NO role keys, so the cost is 0 in the field. Other role+0x1c reader:
content authors NO role keys, so the cost is 0 in the field. **CORRECTED 2026-08-07 —
that sentence was wrong on both halves.** The role's scoring fields are not authored via
notation keys at all: the ctor `@00429a9c` loads them from the role MODEL's GameModel
record (type 0xf, 7 dwords — rec[0]=killBonus, rec[1]=specialCaseDeathPenalty,
rec[2]=dmgRcvd, rec[3]=dmgInflctd, rec[4]=bias, rec[5]=friendlyFire,
rec[6]=returnFromDeath); the notation keys are optional OVERRIDES. Shipped content
authors `Role::Default` (model `dfltrole`) with **killBonus=500, deathPenalty=500,
dmgInf=1, dmgRcv=0, bias=1, ff=1, return=1000** — the original manual's scoring chart
verbatim (`reference/manual/scoring_chart.webp`). The cost read 0 only because
`BTPlayer::scenarioRole` was never BOUND (the registry lookup sat commented out); wired
2026-08-07 and the cost is verified APPLYING at 500. Other role+0x1c reader:
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
+16 -84
View File
@@ -13,7 +13,6 @@ open_questions:
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
- "PPC `scrambleVideo` IMPLEMENTED 2026-08-06 (branch ppc-sync-distortion): a PPC hit scrambles every secondary display for 0.8 s (modern per-scanline shear stand-in for the CRTC Horizontal-Total detune). Trigger in Mech::TakeDamageMessageHandler (damageType==4), visual in SVGA16::FunkyVideo/ScrambleRowShift (DrawDevSurface + ExpandPlaneToBGRA), non-stacking latch in L4GaugeRenderer::SpecialEffect. Screenshot-verified; live PPC-fire confirmation + by-eye shear tuning (BT_SCRAMBLE_SHEAR/ROLL) left to playtesters. Spec: phases/phase-14-ppc-sync-distortion.md"
---
# Cockpit Gauges / MFD HUD
@@ -756,89 +755,6 @@ pooling fix) — an alarm that cannot acquire a source is silent.
⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0
pooled=0`), so the control chain is verified but final playback is not.
## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified]
**✅ IMPLEMENTED 2026-08-06** (branch `ppc-sync-distortion`; screenshot-verified —
every secondary MFD + the radar shear together, the out-the-window view stays
clean). Trigger + visual + non-stacking latch — see
`phases/phase-14-ppc-sync-distortion.md` for the port details and the
`BT_SCRAMBLE_*` tuning envs. Reported by playtesters as "being hit by a PPC makes
it look like all of the secondary CRTs were being degaussed" — main (VPX) view
unaffected, PPC strikes only. Both observations are exactly what the binary does.
The disasm chain below is the ground truth the port was built from.
**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES`
subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records,
every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30),
Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive.
The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`,
md5 `a97075bcb5634d13263e9ad5a2b96fd0`):
1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits
between the collision divert and the burst loop, so it runs **once per
damage message**, not per burst:
```
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
004a03f6 cmp ecx,4 ; EnergyDamageType
004a03f9 jne 0x4a0423 ; everything else -> burst loop
004a03fb mov eax,[0x4efc94] ; the global `application`
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
004a0405 je 0x4a0423 ; null-guarded
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
004a040a fld xword [0x4a0c08] ; long double 0.2
004a0410 fmulp st(1) ; => 0.8
004a041d call dword [edx+0x4c] ; vtable slot 19, args (0.8f, 0)
```
⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`.
2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`**
(an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the
capability is shared-engine; BT is what wires it to Energy damage).
Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]`
(null-guarded — same member RP fetches for its `FlashPalette`), then
`this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks.
3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag.
4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O:
```
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
if (arg==0) { out(0x3D5, saved); modified=0; } ; restore
else if (!modified) { modified=1; saved=in(0x3D5);
out(0x3D5, saved-9); } ; <<< shorten the scanline
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
```
Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch
makes it **idempotent** — overlapping PPC hits do NOT stack, and a second
hit does not re-save an already-detuned value.
5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0;
SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total.
**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is
the character-clock count per scanline — it *is* the horizontal scan frequency.
9 drives every attached monitor's horizontal oscillator off frequency: the
image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All
six secondary displays are derived by the VDB from that one VGA's timing, so
they glitch **together**; the main view comes off the Division VPX card on an
independent timing chain and is untouched. No relay, no VDB register, no
palette work — the VDB just propagates a deliberately corrupted sync. (The
`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are
driven by nothing in the surviving tree.)
⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler**
(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and
its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call
sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it.
**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 =
SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor —
hardware-assisted alarm blinking by masking off the top two pixel bits. Same
pods, so it is an easy source of cross-game misattribution.
Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual
dispatch); the PPC site was found via the three `application+0x4c` uses
(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS
exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two
(set @`0x47002b`, restore @`0x470076`).
## Key Relationships
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
@@ -867,6 +783,22 @@ pattern across entries [2^bits..255] (high-index art degrades to its
index-mod-2^bits colour IN-PLANE, can never leak). The 1995 binary ships the
SAME 64-entry fill and relied on 6-bit art discipline -- garbage is not a
preservable behaviour, so the cycle-fill is a guarded PORT deviation.
**REFINED 2026-08-10 [T1] -- the cycle-fill itself made phantoms.** The
in-plane wrap mapped art index 254 onto plane slot 62 = the LIVE
`colorMapperMultiArmor` right-armor damage slot, and idx-254 art EXISTS:
every SMODE.PCC frame fills the INACTIVE control-mode box interiors with 254
(active border/text idx 9 yellow, inactive borders idx 5 orange-red), and
BTSEC1.PCX carries a stray 52x13 idx-254 bar at port (199-250, 101-113) --
between the heading dial and the armor rosette, geometrically a scratch
duplicate of the rosette quadrant bars. Both lit up in the current
right-armor colour (adpal ramp green/orange/red) on EVERY render path --
playtester-reported as "red boxes around MID/ADV" + "a block between Armor
and Heading". On the shipped machine those regions rendered BLACK (the
garbage entry's low plane bits were 0 -> in-plane index 0), so the fill now
maps [2^bits..255] to `translationTable[0]` (plane BACKGROUND) -- authentic
on-screen result, same no-leak guarantee. NB the 2026-07-19 audit verified
smode BEFORE the 07-25 cycle-fill landed, which is why the row said CORRECT.
Post-fix: **0 leaks over 60s** on the same probe; sim3 3-pod regression clean.
**The tripwires are DEFAULT-ON in every build** (BT_PLANE_AUDIT=0 opts out): the
plane-leak trap, plus an out-of-bounds draw-start trap in `buildDestPointer`
+37
View File
@@ -273,6 +273,43 @@ number the peer Standing case walks on). Verified: arena circle replicant 218×s
replicant, every `MechControlsMapper` demand cell except those explicitly re-derived
(`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.**
**The #52 SEQUEL: the peer body-channel STANDING-LOCK (root-caused + fixed 2026-08-07) [T2].**
Fixing (2) above closed an ACCIDENTAL escape hatch and the skate came back in a new shape. The
port's body case 4 is an INSERTION (the task-#64 lockstep twin) sitting between case 0 and the
advance group — but in the binary `case 4` is a **member of that advance group**
(`FUN_004a5678` @004a5678: `case 2,3,`**`4`**`,5,8,…`, no turn block, no speed exit) [T1], so
case 0's fallthrough is supposed to land on `Advance()`. The insertion intercepted it. On a
REPLICANT that is fatal and not a race: case 0 arms walk iff `standSpeed < bodyTargetSpeed`, and
the inserted block's exit tests `standSpeed < bspd` where `bspd` **IS** `bodyTargetSpeed` on a
replicant — the *same expression*. Arm and reset therefore fire on the same frame, every frame,
and a peer parked at Standing with a live replicated demand can never start cycling (reverse
likewise: both sides test `< ZeroSpeed`). Before e91d447 the replicant branch read the dead mapper
cell (0 forever) so the exit never fired and the fallthrough worked by accident. **Fix:** case 0
`goto advance_body_normally` — the leg twin's own idiom (`goto advance_normally`, mech2.cpp) —
restoring the binary's structure without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1`
reverts. Measured: legacy 336 consecutive locked seconds with `bspd=39.2324 bts=39.2324` identical
on every line; fixed 0 locks across every pass; the MASTER's body-Standing samples also fell 52→21
(it was locking too, invisibly — mj=0 writes no joints, and its two tests read *different* cells so
it only stalls in the window where they disagree). Turn-in-place re-verified under the fix (pivoter
reached body state 4 ×9 / leg state 4 ×8 — armed in lockstep).
**Why a peer must be able to self-arm walking at all** (the load-bearing bit behind
`mech4.cpp` "stand; case 0 walk-begins next tick"): the peer's body state is set directly from
`record->legState` only on **type-3 edges** (`ReadUpdateRecord`), and entering Standing emits one
while *leaving* it does not. So between gait-change records a replicant is REQUIRED to derive
walking itself from the replicated `bodyTargetSpeed`. That is why the lock needs a mech holding a
*steady* demand — a mech whose gait keeps changing keeps getting rescued by records, which is why
free-walking and wall-jammed benches each reproduce only half the symptom. [T2]
**The field symptom link is [T3], not T2.** The Standing-lock is proven and proven removed; that
it accounts for the night-13 episodes is inference (a locked peer has `bodyCycleSpeed==0` and never
advances its clip, so locked + translating *is* the `[skate]` signature by construction) — but no
bench caught the two together. The `[skate]` line now carries `bstate=`, so the next playtest
settles it: episodes gone → confirmed; any survivor names its own state. **NB the night-12
`skatebench` "reproductions" were a DETECTOR ARTIFACT** — the first detector build tested only
`legCycleSpeed==0`, which is normal on a peer (the body channel poses it), so it fired on every
healthy movement phase. Old-format lines (`legCycleSpeed=`, no `bodyCyc=`) are not evidence.
## Controls (`BT_REAL_CONTROLS`, default-on)
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
/ `turnDemand`. ⚠ **WndProc NEVER receives WM_KEYUP** (the engine's per-frame reader `GetMessage`s
+12
View File
@@ -954,6 +954,18 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
deviates safely (award=0). Curiosity, not a blocker.
## HUD range-bias @0x22C — read but NOT reconstructed (2026-08-08) [T1 read, unimplemented]
`HudSimulation` subtracts `_DAT_004b7ecc` = **100.0f** from `RangeToTarget@0x1EC` on **every frame
that the flag @0x22C is non-zero**, while accumulating a timer @0x21C by `time_slice` until it
reaches the limit @0x1D8, at which point BOTH the timer and the flag are cleared. So the authentic
HUD has a timed **100 m range offset** state that our targeting step (mech4.cpp — which does
reconstruct the 500 m/s slide correctly) never applies.
**Open:** what SETS @0x22C. Candidates not yet checked — a targeting-computer damage/jam effect, an
ECM/spoof, or a weapon-lock transient. Found while correcting hud.cpp's stand-in constant block
(the values are now byte-grounded: ec4 0.75 / ec8 1.0 / ecc 100.0 / ed0 0.0 / f90 0.0). Details:
[[gauges-hud]] §Right ladder. Worth resolving before trusting any field report about the range
readout being wrong — it is a real behavioural gap, not a cosmetic one.
## Rendering follow-ups (non-blocking)
- ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat +
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
+93
View File
@@ -254,6 +254,40 @@ desktops. Two pieces were added for the cab:
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
landed on the right glass.
### Boot-STABLE panel binding — `monitor:id:` (2026-08-08, staged, pod-untested)
**Windows renumbers displays.** Nick, after re-cabling + a reboot: *"the order changed … sometimes
they change when one gets turned off and back on, at least how windows SEEs them, even if the
visual desktop tool looks the same."* So the two original binding forms are both boot-fragile —
`monitor:2` is an enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
(Same trap next door in GameOS: its `-tmon` takes **DirectDraw device indices**, which are neither
Windows monitor numbers nor stable, and a NULL-device merge shifts every index down by one on top
— see Nick's `gos-displays.txt`.)
**The fix: bind to the panel's own hardware identity.** `EnumDisplayDevices` on a display's MONITOR
child returns a DeviceID embedding the **EDID manufacturer + product code** and the connector
instance — neither moves across a reboot or a power-cycle.
- **Discover:** run once on the pod with `BT_GLASS_IDS=1`. Every attached panel logs its
`stable-id` **and a ready-to-paste `cfg form`**:
```
[glassid] index=0 device=\\.\DISPLAY1 PRIMARY rect=0,0 1920x1080
stable-id = \\?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-…}
cfg form = monitor:id:AUO10ED
```
The volatile identifiers print beside the stable one deliberately: run it twice across a
power-cycle and `index`/`device` move while `stable-id` does not — that IS the proof.
- **Bind:** `Heat MFD=monitor:id:AUO10ED,bare` in `glass_layout.cfg`.
- **Identical panels** (the pod's mono MFDs are likely one model, so EDID codes collide): use a
longer fragment from `stable-id` — the `UID…`/instance tail differs per connector, so
`monitor:id:UID265988` picks exactly one.
- **An `id:` that matches nothing WARNS and falls back** to computed placement:
`[glasswin] monitor id 'X' matched NO attached panel`. Silence there would put a picture on the
wrong glass and look exactly like the bug this form exists to prevent.
- **Nothing changes by default.** No env, no `id:` → identical behaviour; `monitor:<name|index>`
and raw `x,y` keep working. Playtester glass builds are untouched. [T2 verified on a 1-monitor
dev box — discovery, binding, centring and the mismatch warning; **T3 on the pod**, which was
offline: multi-panel disambiguation is unproven.]
**Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC):
1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original
panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that
@@ -390,6 +424,65 @@ returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or
locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an
otherwise-logging run is real evidence that code path did not execute.
### The remote runbook — READ THIS BEFORE IMPROVISING (2026-08-08)
Every line below cost real time to rediscover on a live stream. Follow it in order.
**1. Connect.** `ssh bt411-pod` (alias in `~/.ssh/config`) → `bt411-pod.tail840fa4.ts.net`, user
`user`, key `~/.ssh/bt411_pod`.
**The key has existed since 2026-08-06 but ssh will NOT offer it without the config entry** — it
only tries default names (`id_rsa`/`id_ed25519`). Without it you get
`Permission denied (publickey,password,keyboard-interactive)`, which reads exactly like "auth was
never set up". It was. Also: the Tailscale **node** KeyExpiry (2027-02-02) is what keeps the cab on
the tailnet — it is **not** an SSH credential, and Tailscale SSH is **not** enabled on the pod (the
peer advertises no `sshHostKeys`), so port 22 is the pod's own Windows OpenSSH.
**2. Absolute paths for System32 tools.** Over this SSH+cmd session, bare `taskkill` / `setx`
return `The system cannot find the path specified.` and silently do nothing. Use
`C:\Windows\System32\taskkill.exe`. (`tasklist`, `schtasks`, `dir`, `copy` happen to resolve.)
**3. Deploy — the SAME procedure a tester uses.** No pod-special exe drops.
```
python tools/mkdist.py # -> dist/BT411_4.11.NNN.zip
scp dist/BT411_4.11.NNN.zip bt411-pod:C:/bt411/
ssh bt411-pod "powershell -NoProfile -Command \"Expand-Archive C:\bt411\BT411_4.11.NNN.zip -DestinationPath C:\bt411 -Force\""
ssh bt411-pod "powershell -NoProfile -ExecutionPolicy Bypass -File C:\bt411\podkit.ps1 -Content C:\bt411\BT411_4.11.NNN\content"
```
`runpod.bat` auto-resolves the **newest** `BT411_*` by date, so nothing else needs pointing.
Local config is **never clobbered**: `mkdist.py` packs git-TRACKED content only, and
`bindings.txt`/`environ.ini`/`glass_layout.cfg` are all gitignored. `podkit.ps1` pushes the frozen
rig masters (`podprofile.ini`, `glass_layout.cfg`, `PODTEST.EGG`) from `C:\bt411\` into the install.
**`PODTEST.EGG` is not in the repo** — only podkit carries it. If podkit fails, the launcher runs
and *nothing appears*, with no error.
**4. Launch.** `schtasks /run /tn BT411Run` → runs `C:\bt411\runpod.bat` as `user`,
LogonType=Interactive, so it lands in **session 1** and is visible on the panels. Log:
`<install>\content\podrun.log`.
**5. ⚠ THE PODKIT INFINITE LOOP — the trap that ate an evening.** `podkit.ps1` line 30 was
```powershell
while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
```
When `$keep` trims to ONE blank line, `$keep.Count-2` is `-1` and PowerShell's `$keep[0..-1]`
returns **two** elements instead of shrinking — infinite loop, RSS climbing past 60 MB. It fires
whenever everything outside `environ.ini`'s marker block is blank, i.e. **any `environ.ini` that was
already kitted** — exactly what you get carrying it forward from the previous install. Patched to
`-gt 1` on the pod 2026-08-08 (`podkit.ps1.bak` is the original); **this fix is NOT in any repo**,
so a restored/replaced podkit brings the bug back.
*Signature:* a **blank cmd console** on the pod, task stuck `Status: Running`, **no `btl4.exe`, no
`podrun.log`**. *The cascade:* each hung run holds `environ.ini`, so every later attempt blocks too —
and over SSH your client times out while the REMOTE powershell keeps running, so "it returned
instantly and did nothing" actually means "it is still hung". Recover with
`taskkill /F /PID <pid>` (absolute path) on the session-1 `cmd`+`powershell` pair, then
`schtasks /end /tn BT411Run` before re-running — a task already Running refuses `/run` with
`2147946720` (`0x800710E0`, "operator refused the request").
**6. Panel identity (measured 2026-08-08, over SSH — WMI is session-independent so this works
without a GUI):** `DISPLAY\RAR0005\…UID224795`, `DISPLAY\DEL4025\…UID249395` (DELL 1908FP),
`DISPLAY\RAR0005\…UID200195`. **The two RAR panels share one EDID code and have blank serials**, so
`monitor:id:RAR0005` is ambiguous — they must be bound by the per-connector form
(`monitor:id:UID224795` / `monitor:id:UID200195`). The Dell's code is unique. The cab's shipped
`glass_layout.cfg` still uses the fragile `monitor:DISPLAY4` device-name form.
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
every checked control behavior:
+134
View File
@@ -863,3 +863,137 @@ warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
state-change repaints -- before suspecting the event's logic; (d) the
operator's screenshot is worth ten theories: the red-faces capture identified
in one frame what three log-side hypotheses missed.
## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08)
`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when
`segmentModified` is already set** — otherwise it hands back the cached matrix,
and if the segment has no parent it can never recompute at all. The thing that
sets that flag after a joint moves is `JointedMover::GetSegmentToWorld`
(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the
whole segment table marking every entry dirty, then clears the joint flag.
The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in
`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld`
instruction-for-instruction (`GetJointSubsystem``if (AreJointsModified())`
mark all → `ModifyJoints(False)``× localToWorld`). **So in the 1995 image
every muzzle query performs the joints→segments refresh.** [T1]
Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(),
mech->localToWorld)` — including one commented "the faithful FUN_004b9948".
They skip the refresh and read whatever cache is present.
**Why it hid for a year:** the local mech is refreshed every frame anyway — the
renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso
pushes the joint. So master-side output is correct and solo testing is clean.
A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on
a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw`
EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso
was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from
correctly replicated records. Twist arrived, joint moved, segment never
re-derived.
Rules:
(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call
`GetSegmentToWorld` — it is the binary's own path and it does the refresh.
(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting
`ModifyJoints(True)` at the read site made the symptom go away and scored
identically to the faithful fix — it was a stand-in that patched ONE
consumer and left every other peer segment reader stale. The binary only
ever *tests* that flag.
(c) A cached-transform bug is **master/replicant asymmetric by construction**.
If a geometry symptom is reported "peer POV only", suspect a cache that the
local render pass refreshes for free — before suspecting replication. Here
the replication was provably fine.
(d) When a fix lands at a partial percentage, **check whether the failures are
interleaved or a PREFIX** before calling it partial. These were a clean
prefix that ended the moment the peer first had a non-zero twist to carry —
i.e. the fix was complete and the remainder was correct behaviour. Reporting
it as "64% fixed" was wrong.
(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th
call. With a master torso and a copy torso ticking 1:1, every 30th call is
always the SAME instance — the probe showed only the local untwisted torso
and hid the copy's writes entirely. Sample **per instance-kind** whenever
master and replicant objects share a diagnostic. (See also §gotcha on
process-wide statics serving the player's data as the replicant's.)
## 29. A peer mech does NOT tick before RunningMission — bench-only, and it fakes a replication bug (#148, 2026-08-08)
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls
`PerformAndWatch` **only** when
```cpp
application->GetApplicationState() == Application::RunningMission
|| application->GetApplicationState() == Application::EndingMission
|| IsPreRunnable()
```
otherwise it just does `WriteSimulationUpdate`. `Entity::DefaultFlags` is
`DynamicFlag|MasterInstance`**no `PreRunFlag`**; only `Player` and `Director`
add it in their DefaultFlags, and `Mech::Reset` sets it for a reset MASTER
("a reset master must tick"). A **replicant mech never gets it.**
So during `LoadingMission` / `WaitingForLaunch` / `LaunchingMission` a peer mech
performs **zero** subsystem ticks, no matter how much correctly-replicated data
is arriving for it. Measured on the observer node:
```
235 [perf-first] mech 3:161 master <- own mech, immediately
402 [torso-rec-rx] <- peer's torso records start 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
```
This is correct engine behaviour, **but it silently corrupts any bench that acts
before the round starts.** `BT_AUTOFIRE`/`BT_GOTO` begin immediately, so early
salvos measure a peer whose torso, gait and subsystems have never run — and the
result reads exactly like a replication failure. It cost a full investigation
(filed as #148) before the app-state trace showed the peer was simply not
executing yet.
Rules:
(a) **Judge a 2-node bench by PREFIX vs INTERLEAVED, never by raw percentage.**
A clean leading run of failures that stops for good is almost always the
pre-`RunningMission` window; interleaved failures are the real thing.
(b) When a peer looks inert, check `[ent-exec] state=` before suspecting
replication. States: `2` LoadingMission, `3` WaitingForLaunch,
`4` LaunchingMission, `5` RunningMission.
(c) Prefer benches that wait for `RunningMission` before acting — or slice the
log at the transition — otherwise every peer-side metric carries this bias.
(d) The receipts that make this legible: `BT_NET_TRACE` gives `[upd-repl]`
(offered to the performer), `[ent-exec]` (state / preRun / instance) and
`[perf-first]` (one-shot per mech: entity ID + instance at its FIRST
performance). Anonymous per-frame receipts are useless in a 2-node log —
**name the mech.**
## 30. A finite-difference cache the binary re-seeds at Reset — transcribe the WHOLE seed list (#137, 2026-08-09)
The binary `Mech::Reset @0049fb74` opens with more than the obvious origin writes: its SECOND
instruction is `FUN_00408440(mech+0x58c, param_2)` — re-seeding the **previous-position memory**
of the AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c) to the new origin. The port
reconstructed the ring itself faithfully (ctor `part_012.c:9836`, derivative `:15169`) but its
Reset never got that one line. Result: the first post-respawn sample computed
`|newPos prevPos| / dt` = **teleport distance / dt ≈ 1e5** into the velocity ring; the
ring-mean derivative turned it into an acceleration spike (with an opposite-sign ECHO ~15 frames
later as the sample rotated out of the mean); the myomer heat integrator's
`termAccel = (1accEff)·|v|·|a|·m·dt` turned THAT into a ~3e9 one-tick heat deposit; and the
freshly-reset myomers snapped from 77 to ~9000 against failT=2000 — the #137 respawn freeze.
Rules:
(a) **When the binary's Reset writes a cell you don't recognize, that write IS the spec.** The
+0x58c re-seed looked like bookkeeping and was silently dropped; it was the only thing
standing between a teleport and a position-derivative spike. Transcribe the whole zero/seed
list, then map each cell — never the recognizable subset.
(b) **Any prev-value cell backing a finite difference must be re-seeded at every discontinuity**
(teleport, warp, respawn). If you add such a cache port-side, grep the binary's reset for its
analog before assuming none exists.
(c) **Derived state hides stale backing state.** `currentTemperature` sampled AT the reset read
77 (clean) because RTIS wrote it — while the freeze arrived one frame later through
`heatEnergy += pendingHeat` from a live producer. Probing the derived cell at the reset
instant proves nothing about the producers; trace the WINDOW after, per producer.
(d) The diagnosis chain that worked, for reuse: roster-wide state snapshot (at-death / at-reset /
post-reset) → eliminate conduction by trapping flows with full operands → trap the remaining
producer's deposits with operands → cross-reference the operand SHAPE (pure local z,
magnitude = distance/dt, echo at ring-length) against the writers. Each trap eliminated a
theory the previous data had made plausible; three plausible theories died on operands.
+36
View File
@@ -299,3 +299,39 @@ the `BTGetSubsystemAuxScreen` bridge. See `docs/VEHICLE_SUBSYSTEMS.md` + [[gauge
- Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]].
- Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state).
- Plan: `docs/SUBSYS_PLAN.md`.
## Myomer drive-heat calibration — VERIFIED FAITHFUL (2026-08-09, #137) [T1]
The integrator `@004b8d18` accumulates into `pendingHeat@0x1C8`:
`gear² × (1 + X) × [ (1velEff)·|vy|·m·g·dt + (1velEff)·work + (1accEff)·|v|·|a|·m·dt ]`
with `work = mass · |v|² · 0.5`. Its constants, read byte-exact from `.rdata`
(`section_dump.txt` row ` 4b8ee0 5dc30000 0000003f 00000000 0000803f`):
**`_DAT_004b8ee4` = 0.5f** (the kinetic ½), **`_DAT_004b8ee8` = 0.0f** (the `Abs()` idiom),
**`_DAT_004b8eec` = 1.0f** (the `1 efficiency` complements and the gear-ratio clamp floor).
All three match what the port computes — the formula and its authored inputs
(VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5, myomers linked
Condenser5) are reconstructed correctly.
**The one deliberate deviation, and why it is the faithful choice.** The binary applies **no
`time_slice`** to the kinetic term (`fVar5 * fVar1`) while the climb and accel terms both carry
`param_2` — it is a per-frame energy add at the pod's **fixed ~28 Hz**. The port uses
`work × (time_slice × 28)`, which is *identical* at 28 Hz (`dt·28 = 1.0`) but holds the same
heat-per-SECOND at any frame rate. A literal transcription would add the full term once per
frame, so at 170 fps it would inject ~6× the heat the pod ever did. `BT_MYO_HZ` overrides the
reference rate for bracketing.
**Consequence for #137 — CORRECTED 2026-08-09, the paragraph that stood here was wrong.** The
"players read 'respawned with heat maxed' as acceleration to top speed" claim did not survive
the data: the deposits were e9-scale within 30 frames of the reset, physically impossible from
motion input (~6.5e5/frame ceiling). The actual cause was a **dropped binary re-seed**: the
binary Reset's second instruction (`FUN_00408440(mech+0x58c, origin)`) re-seeds the
previous-position memory of the AccelerationLastFrame ring feed (+0x81c..+0x82c); the port
reconstructed the ring but not the re-seed, so the first post-respawn sample computed
TELEPORT-DISTANCE/dt (~1e5) into the velocity ring, the ring-mean derivative spiked
`AccelerationLastFrame`, and `termAccel = (1-accEff)·|v|·|a|·m·dt` deposited ~3e9 into
`pendingHeat` in one tick → myomers snapped from 77 to ~9000 (failT 2000) → speedEffect 0 →
frozen until cooled. Fixed by restoring the re-seed (`accelPrevPos = origin.linearPosition` in
Mech::Reset). The ~8% field rate was the |v| factor: only pilots whose throttle was still
forward at the respawn (physical lever / HOTAS — exactly who reported it) had gait-republished
speed in the spike frames. The calibration facts above (constants byte-exact, dt-normalised
kinetic term) all STAND; the in-life governor (running hot at sustained top speed derates the
myomers) is authentic and remains.
+1 -1
View File
@@ -696,7 +696,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b
| 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT |
| 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT |
| 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT |
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/DisplayMode oneOfSeveralPixInt | attr wave incr.5; M-cycle verified #6 | T2 | CORRECT |
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/**ControlMode** oneOfSeveralPixInt (row previously mislabeled DisplayMode — that is the sibling sdspmod) | attr wave incr.5; M-cycle verified #6. **2026-08-10:** this row's verification PREDATED the 07-25 #48 cycle-fill, which then lit the inactive box interiors (art idx 254 → live armor slot 62) — playtester-reported, re-fixed by mapping out-of-range indices to plane background (see context/gauges-hud.md §#48 REFINED); both render paths re-verified against the reference | T2 | CORRECT |
| 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) |
| 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) |
| 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT |
+9 -2
View File
@@ -14,8 +14,15 @@ Resolve AFTER all 7 families report (Stage 1 reconciliation), then again at link
- **Player__VehicleDeadMessage**: BT build carried `killerName` (+0x1c) the engine base lacks; read via offset.
### Stubbed (no WinTesla analog) — revisit at integration
- BTPlayer ctor role/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
`GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
- ~~BTPlayer ctor role-registry resolution~~**RESOLVED 2026-08-07.** The claim "no WinTesla
analog → stubbed; base-set `scenarioRole` stands" was wrong twice over: the base ctor sets
`scenarioRole` to **NULL** (PLAYER.cpp:680), so nothing "stood"; and the analog exists —
`Mission::GetScenarioRole(name)` (MISSION.h:162) walks the same `scenarioRoleChain` that
`BTL4Mission` fills via `AddScenarioRole()` when it parses the role pages. A NULL role zeroed
EVERY scoring value (a kill scored 4.88 instead of 505.88, the eject charge read 0, the death
cost was skipped). Now bound, with a `Role::Default` fallback and a BOUND/NULL receipt.
`GetMission()->GetGameModel()` (the freeForAll compare) stays genuinely stubbed — the shipped
code discarded its result anyway.
### Link-time externs to be PROVIDED by owning modules (Stage 3)
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
+24
View File
@@ -117,6 +117,16 @@ EntitySegment*
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// #149 perf telemetry (BT_PERF_LOG; counters cost two increments when unset).
// gBTSegWDirty counts the EXPENSIVE arm -- the mark-every-segment pass that
// invalidates the whole table -- separately from plain calls, because the #141
// sweep put this accessor on per-frame weapon/beam paths and the open question
// is whether that multiplied the dirty-pass rate (each pass forces the next
// query of EVERY segment to re-derive its parent chain).
int gBTSegWCalls = 0;
int gBTSegWDirty = 0;
double gBTSegWMs = 0.0;
//
void
JointedMover::GetSegmentToWorld(
@@ -125,6 +135,13 @@ void
)
{
Check(this);
// DEFAULT ON (operator, 2026-08-09): every player's session log should
// carry this -- cross-machine comparison is the point. BT_PERF_LOG=0
// opts out. Cost: ~2 QPC reads per call, microseconds per second.
static const int sPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
LARGE_INTEGER t0, t1, fq;
++gBTSegWCalls;
if (sPerf) QueryPerformanceCounter(&t0);
JointSubsystem *joints = GetJointSubsystem();
Check(joints);
@@ -135,6 +152,7 @@ void
//
if (joints->AreJointsModified())
{
++gBTSegWDirty;
EntitySegment::SegmentTableIterator iterator(segmentTable);
EntitySegment *current_segment;
while( (current_segment = iterator.ReadAndNext() ) != NULL)
@@ -153,6 +171,12 @@ void
my_segment.GetSegmentToEntity(),
localToWorld
);
if (sPerf)
{
QueryPerformanceCounter(&t1);
QueryPerformanceFrequency(&fq);
gBTSegWMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
}
Check_Fpu();
}
+23
View File
@@ -55,9 +55,32 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
damageBias = player_data->damageBias;
friendlyFirePenalty = player_data->friendlyFirePenalty;
player_res_des->Unlock();
// SCORE PROVENANCE (ungated, 2026-08-07). Every unmatched row of the
// original manual's scoring chart -- +500 a kill, -1000 an eject, -500
// an ammo death -- is sourced HERE, from the role's GameModel record
// (type 0xf, 7 dwords; the binary's ctor @00429a9c copies rec[0..6]).
// A field log showed `charge=0 (role killBonus)`, and the two ways that
// happens -- record authored zero, or resource lookup missed -- are
// indistinguishable in release because the miss path's Warn/Tell compile
// out (DEBUGOFF.h). One line per role settles it.
DEBUG_STREAM << "[role] '" << (const char *)role_name
<< "' model='" << (const char *)model_file
<< "' killBonus=" << (float)killBonus
<< " deathPenalty=" << (float)specialCaseDeathPenalty
<< " dmgInf=" << (float)damageInflictedModifier
<< " dmgRcv=" << (float)damageReceivedModifier
<< " bias=" << (float)damageBias
<< " ff=" << (float)friendlyFirePenalty
<< " return=" << (int)returnFromDeath
<< "\n" << std::flush;
}
else
{
DEBUG_STREAM << "[role] '" << (const char *)role_name
<< "' model='" << (const char *)model_file
<< "' -- RESOURCE NOT FOUND, all score values default to 0"
<< "\n" << std::flush;
Tell(role_name);
Warn(" does not exists in resource! ");
damageReceivedModifier = 0.0f;
+16 -5
View File
@@ -1118,11 +1118,22 @@ LBE4ControlsManager::~LBE4ControlsManager()
#ifdef BT_GLASS
//
// Symmetric with the Create above (2026-08-06). ~PadRIO tears the panels
// down itself, so this is a no-op there -- but the HARDWARE RIO's dtor
// knows nothing about them, and windows that outlive the surfaces they
// blit are a crash waiting for the next mission cycle. Destroy is safe
// if they were never created.
// Symmetric with the Create above (2026-08-06). The HARDWARE RIO's dtor
// knows nothing about the panels, and windows that outlive the surfaces
// they blit are a crash waiting for the next mission cycle.
//
// CORRECTION (2026-08-07, #140): the original comment here claimed this was
// "a no-op" on the PadRIO path because ~PadRIO tears the panels down itself.
// It was not. ~PadRIO runs first (delete rioPointer, above), zeroing the
// window list -- and BTGlassPanels_Destroy unconditionally ran SaveLayout
// BEFORE looking at whether anything was left, so this second call rewrote
// glass_layout.cfg with every MFD and radar line missing. Only the plasma
// window survived, because external windows cached a last-known rect and the
// per-display windows did not. That is the regression testers hit on the
// desktop the same day the pod panels were wired up; the pod itself was
// unaffected (no PadRIO, so only ONE destroy, and it runs BT_GLASS_LAYOUT=
// load anyway). Fixed on both sides in L4GLASSWIN: the geometry is now
// remembered across teardown, and the teardown save is guarded.
//
BTGlassPanels_Destroy();
#endif
+425 -18
View File
@@ -96,6 +96,8 @@ struct GButton
struct GWin
{
double perfMs; // [glassperf] paint ms this window, this second
int perfN; // [glassperf] paints this second
const char *title;
HWND hwnd;
@@ -127,6 +129,12 @@ struct GWin
char monitorName[40]; // the PHYSICAL monitor this window landed on
// (\.\DISPLAYn), stamped at creation; shown
// by BT_POD_IDENT so the cab can be mapped.
// Dirty-skip (2026-08-09): the last change token the pump blitted for this
// window (plane checksum + lamp render). Zero-init (static storage); the pump
// re-blits only when the token differs, so unchanged panels cost nothing.
unsigned long lastToken;
int haveToken;
};
static GWin gWins[8];
@@ -744,7 +752,70 @@ void
// Heat MFD=monitor:2,bare (index into the enumeration order)
// The surface is centred on that monitor; an exact-size panel fills it.
//---------------------------------------------------------------------------
struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
struct MonScan { int index; const char *want; const char *wantId; int wantIndex; RECT rect; int found; };
// ---------------------------------------------------------------------------
// STABLE PANEL IDENTITY (2026-08-08) -- the fix for "the monitors came back in a
// different order after I moved cables and rebooted".
//
// Windows renumbers \\.\DISPLAYn, and reorders the enumeration, when a panel is
// power-cycled or re-cabled -- even when the visible desktop arrangement is
// unchanged. So BOTH existing binding forms are boot-fragile: `monitor:2` is an
// enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
// (The same trap bites GameOS next door: its -tmon takes DirectDraw device
// indices, which are neither Windows monitor numbers nor stable -- and its
// NULL-device merge shifts every index down by one on top of that.)
//
// What IS stable is the panel's own hardware identity. EnumDisplayDevices on a
// display's MONITOR child returns a DeviceID like
// MONITOR\DEL4231\{4d36e96e-e325-11ce-bfc1-08002be10318}\0002
// or, with EDD_GET_DEVICE_INTERFACE_NAME,
// \\?\DISPLAY#DEL4231#5&1a2b3c&0&UID4353#{e6f07b5f-...}
// The `DEL4231` field is the EDID manufacturer + product code, and the UID/
// instance identifies the physical connector. Neither moves on a reboot.
//
// Fill `out` with that string for a \\.\DISPLAYn adapter name. Empty on failure
// (no monitor child / remote session) -- callers must treat "" as "no identity"
// and fall back, never as a match.
static void
MonitorStableId(const char *displayName, char *out, size_t outLen)
{
if (outLen == 0) return;
out[0] = '\0';
if (displayName == NULL || displayName[0] == '\0') return;
DISPLAY_DEVICEA mon;
memset(&mon, 0, sizeof(mon));
mon.cb = sizeof(mon);
// index 0 = the attached monitor child. The interface-name flag gives the
// richer path (includes the connector UID); it is Vista+, and if the call
// fails we retry without it for the plain MONITOR\... form.
if (!EnumDisplayDevicesA(displayName, 0, &mon, EDD_GET_DEVICE_INTERFACE_NAME))
{
memset(&mon, 0, sizeof(mon));
mon.cb = sizeof(mon);
if (!EnumDisplayDevicesA(displayName, 0, &mon, 0))
return;
}
strncpy(out, mon.DeviceID, outLen - 1);
out[outLen - 1] = '\0';
}
// Case-insensitive substring test -- the cfg quotes a FRAGMENT of the identity
// (usually just the EDID code, e.g. `id:DEL4231`) rather than the whole path,
// because the full string is long and contains characters a config file and a
// shell each mangle differently.
static int
IdContains(const char *haystack, const char *needle)
{
if (haystack == NULL || needle == NULL || *needle == '\0') return 0;
size_t hl = strlen(haystack), nl = strlen(needle);
if (nl > hl) return 0;
for (size_t i = 0; i + nl <= hl; ++i)
if (_strnicmp(haystack + i, needle, nl) == 0)
return 1;
return 0;
}
static BOOL CALLBACK
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
@@ -756,7 +827,17 @@ static BOOL CALLBACK
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
int hit = 0;
if (sc->want != NULL)
// `id:<fragment>` -- match the panel's HARDWARE identity, not its
// current \\.\DISPLAYn or enumeration slot. Boot-stable; this is the
// form the pod should use.
if (sc->wantId != NULL)
{
char sid[256];
MonitorStableId(mi.szDevice, sid, sizeof(sid));
if (sid[0] != '\0' && IdContains(sid, sc->wantId))
hit = 1;
}
if (!hit && sc->want != NULL)
{
// Match the full device name OR just its tail, so a hand-written
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
@@ -781,6 +862,96 @@ static BOOL CALLBACK
return TRUE;
}
// BT_GLASS_IDS=1 -- dump every attached panel with its BOOT-STABLE identity, and
// a ready-to-paste `monitor:id:` fragment for glass_layout.cfg. This is the
// discovery half of the fix: run it once on the pod, copy the fragments into the
// cfg, and the assignment survives reboots and re-cabling.
//
// Prints the volatile identifiers too, deliberately side by side -- seeing
// \\.\DISPLAYn and the enumeration index MOVE between two runs while the id
// stays put is the proof that the id form is the right one.
struct MonDump { int index; };
static BOOL CALLBACK
MonDumpProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
{
MonDump *d = (MonDump *)param;
MONITORINFOEXA mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
char sid[256];
MonitorStableId(mi.szDevice, sid, sizeof(sid));
// The EDID make+product sits between the first two separators of the
// DeviceID (MONITOR\DEL4231\... or \\?\DISPLAY#DEL4231#...). Offer it
// as the suggested fragment -- short, and unique when the panels are
// different models. Identical models need more (see the note below).
// Find it STRUCTURALLY rather than by position: an EDID PnP code is
// exactly 3 letters + 4 hex digits (AUO10ED, DEL4231). Walking the
// separators positionally broke on the interface-name form, whose
// `\\?\DISPLAY#...` prefix has a different number of leading segments
// than the plain `MONITOR\...` form -- and which form you get depends on
// whether EDD_GET_DEVICE_INTERFACE_NAME succeeded. Tokenising on all of
// \ # ? handles every variant the same way.
char frag[64];
frag[0] = '\0';
for (const char *t = sid; *t != '\0'; )
{
while (*t == '\\' || *t == '#' || *t == '?') ++t;
const char *e = t;
while (*e != '\0' && *e != '\\' && *e != '#' && *e != '?') ++e;
size_t n = (size_t)(e - t);
if (n == 7)
{
int ok = 1;
for (int i = 0; i < 3 && ok; ++i)
if (!isalpha((unsigned char)t[i])) ok = 0;
for (int i = 3; i < 7 && ok; ++i)
if (!isxdigit((unsigned char)t[i])) ok = 0;
if (ok)
{
memcpy(frag, t, 7);
frag[7] = '\0';
break;
}
}
t = e;
}
DEBUG_STREAM << "[glassid] index=" << d->index
<< " device=" << mi.szDevice
<< (((mi.dwFlags & MONITORINFOF_PRIMARY) != 0) ? " PRIMARY" : "")
<< " rect=" << (int)mi.rcMonitor.left << "," << (int)mi.rcMonitor.top
<< " " << (int)(mi.rcMonitor.right - mi.rcMonitor.left)
<< "x" << (int)(mi.rcMonitor.bottom - mi.rcMonitor.top)
<< "\n stable-id = " << (sid[0] ? sid : "(unavailable)")
<< "\n cfg form = monitor:id:" << (frag[0] ? frag : "<see stable-id>")
<< "\n" << std::flush;
}
++d->index;
return TRUE;
}
void
BTGlassDumpMonitorIds()
{
if (getenv("BT_GLASS_IDS") == NULL)
return;
DEBUG_STREAM << "[glassid] ---- attached panels, boot-STABLE identities ----\n"
<< "[glassid] index and device= move across reboots / power-cycles;\n"
<< "[glassid] stable-id does not. Bind the pod with monitor:id:<fragment>.\n"
<< "[glassid] If two panels are the SAME MODEL their EDID codes match --\n"
<< "[glassid] use a longer fragment from stable-id (the UID/instance tail\n"
<< "[glassid] differs per connector) so each line matches exactly one.\n"
<< std::flush;
MonDump d;
d.index = 0;
EnumDisplayMonitors(NULL, NULL, MonDumpProc, (LPARAM)&d);
DEBUG_STREAM << "[glassid] ---- " << d.index << " panel(s) ----\n" << std::flush;
}
// Resolve "monitor:<spec>" to a rect. Returns 1 on success.
static int
ResolveMonitorSpec(const char *spec, RECT *out)
@@ -789,13 +960,31 @@ static int
MonScan sc;
memset(&sc, 0, sizeof(sc));
sc.wantIndex = -1;
if (spec[0] >= '0' && spec[0] <= '9')
// `monitor:id:<fragment>` -- BOOT-STABLE hardware identity (preferred on the
// pod). Anything else keeps its historic meaning exactly: a leading digit is
// the enumeration index, otherwise a \\.\DISPLAYn device name (or its tail).
if (_strnicmp(spec, "id:", 3) == 0)
{
sc.wantId = spec + 3;
while (*sc.wantId == ' ' || *sc.wantId == '\t') ++sc.wantId;
}
else if (spec[0] >= '0' && spec[0] <= '9')
sc.wantIndex = atoi(spec);
else
sc.want = spec;
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
if (sc.found)
*out = sc.rect;
else if (sc.wantId != NULL)
{
// An id: binding that matched nothing is worth shouting about: the panel
// is unplugged, asleep, or the cfg fragment is wrong. Silently falling
// back to computed placement would put a picture on the wrong glass and
// look like the very bug this form exists to prevent.
DEBUG_STREAM << "[glasswin] monitor id '" << sc.wantId
<< "' matched NO attached panel -- check BT_GLASS_IDS=1 output"
<< "\n" << std::flush;
}
return sc.found;
}
@@ -916,9 +1105,50 @@ static void
<< layoutFileName << "\n" << std::flush;
}
// Write every window's current on-screen frame rect. Whole-file rewrite (it is
// tiny), so partial/hard kills never leave a half-written file for long. Called
// on finished-drag and on teardown in save mode.
// LAST-KNOWN GEOMETRY, kept ACROSS teardown (2026-08-07, #140).
//
// SaveLayout rewrites the whole file, so it can only be as complete as what it
// can see -- and it could only see LIVE windows. Destroy() nulls every hwnd and
// zeroes gWinCount, so any save that ran after a teardown wrote a file with
// every MFD and radar line MISSING. The plasma window survived that because
// external windows already cached a last-known rect (gExtern[].haveLast); the
// per-display windows had no such cache, which is exactly the reported
// signature: "all the MFDs and secondary lines missing, but plasma was still
// there". Give the glass windows the same guarantee, so the file is monotonic
// -- a save can update a line or add one, never drop one.
struct SavedGeom
{
char title[64];
RECT r;
int noFrame;
};
static SavedGeom gLastGeom[16];
static int gLastGeomCount = 0;
static void
RememberGeom(const char *title, const RECT &r, int noFrame)
{
if (title == NULL || title[0] == '\0')
return;
for (int i = 0; i < gLastGeomCount; ++i)
if (strcmp(gLastGeom[i].title, title) == 0)
{
gLastGeom[i].r = r; gLastGeom[i].noFrame = noFrame;
return;
}
if (gLastGeomCount >= (int)(sizeof(gLastGeom) / sizeof(gLastGeom[0])))
return;
SavedGeom &g = gLastGeom[gLastGeomCount++];
strncpy(g.title, title, sizeof(g.title) - 1);
g.title[sizeof(g.title) - 1] = '\0';
g.r = r; g.noFrame = noFrame;
}
// Write every window's frame rect. Whole-file rewrite (it is tiny), so
// partial/hard kills never leave a half-written file for long. Called on
// finished-drag and on teardown in save mode. Live windows refresh the
// remembered geometry first; the FILE is then written from the remembered set,
// so a window that has already been torn down keeps its line.
static void
SaveLayout()
{
@@ -945,6 +1175,7 @@ static void
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
f);
int wrote = 0;
// 1. refresh the remembered geometry from whatever is currently alive
for (int i = 0; i < gWinCount; ++i)
{
GWin &gw = gWins[i];
@@ -953,10 +1184,16 @@ static void
RECT r;
if (!GetWindowRect(gw.hwnd, &r))
continue;
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", gw.title,
(long)r.left, (long)r.top,
(long)(r.right - r.left), (long)(r.bottom - r.top),
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
RememberGeom(gw.title, r, gw.noFrame); // keep the hand-added option
}
// 2. write the remembered set -- including windows already torn down
for (int i = 0; i < gLastGeomCount; ++i)
{
const SavedGeom &g = gLastGeom[i];
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", g.title,
(long)g.r.left, (long)g.r.top,
(long)(g.r.right - g.r.left), (long)(g.r.bottom - g.r.top),
g.noFrame ? ",noframe" : "");
++wrote;
}
// External windows (the plasma window) ride the same file. Cache the last
@@ -982,8 +1219,15 @@ static void
++wrote;
}
fclose(f);
// #140 receipt (ungated): `live=` is the diagnostic that matters. A save
// that runs with live=0 is the corruption case -- before the remembered-
// geometry cache it wrote a file containing ONLY the plasma line, which is
// what testers reported. It stays in the log so the FIELD can tell us which
// caller does that (teardown ordering, a drag after a round boundary, ...),
// which no bench here managed to reach.
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
<< layoutFileName << "\n" << std::flush;
<< layoutFileName << " (live=" << gWinCount
<< " remembered=" << gLastGeomCount << ")\n" << std::flush;
}
// Public save trigger for registered external windows -- their WndProc calls
@@ -1180,7 +1424,16 @@ static void
info.bmiHeader.biCompression = BI_RGB;
const RECT &r = w->surfaceRect;
SetStretchBltMode(dc, HALFTONE);
// STRETCH MODE (perf, 2026-08-09): default COLORONCOLOR. HALFTONE runs a
// per-output-pixel resample filter; done synchronously for all 7 glass windows
// every ~16 Hz repaint (BTGlassPanels_Tick), it was the per-display-mode perf
// sink -- playtesters saw ~20 fps in the exploded panels vs ~130 fps in the
// cockpit surround (same scene, same machine). The MFDs are low-res pixel
// content, so nearest-neighbour reads crisp (and closer to the pod CRT).
// BT_GLASS_SMOOTH=1 restores HALFTONE for anyone who prefers smoothing to speed.
static int sSmooth = -1;
if (sSmooth < 0) sSmooth = getenv("BT_GLASS_SMOOTH") ? 1 : 0;
SetStretchBltMode(dc, sSmooth ? HALFTONE : COLORONCOLOR);
SetBrushOrgEx(dc, 0, 0, NULL);
StretchDIBits(dc,
r.left, r.top, r.right - r.left, r.bottom - r.top,
@@ -1459,7 +1712,20 @@ static LRESULT CALLBACK
switch (message)
{
case WM_PAINT:
if (w != NULL) { PaintGlass(window, w); return 0; }
if (w != NULL)
{
// [glassperf] (#149): time EVERY panel paint. The blit is a
// HALFTONE StretchDIBits whose cost is strongly driver-dependent
// -- the whole point is to measure it on the machine that pays it.
LARGE_INTEGER t0, t1, fq;
QueryPerformanceCounter(&t0);
PaintGlass(window, w);
QueryPerformanceCounter(&t1);
QueryPerformanceFrequency(&fq);
w->perfMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
++w->perfN;
return 0;
}
break;
case WM_ERASEBKGND:
@@ -1561,6 +1827,11 @@ void
if (gWinCount != 0)
return; // already up
// BT_GLASS_IDS=1: dump every panel's boot-stable identity before any window
// is placed, so the log shows what the cfg COULD bind to right next to what
// it actually did. No-op without the env -- playtesters see nothing new.
BTGlassDumpMonitorIds();
// BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface.
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
// portrait CRT, user-verified upright).
@@ -1741,8 +2012,24 @@ void
void
BTGlassPanels_Destroy()
{
SaveLayout(); // backstop for a clean teardown (WM_EXITSIZEMOVE already
// caught every finished drag); no-op unless mode==save
// #140 receipt (ungated, one line per teardown): this function has two
// callers on the desktop path and the ORDER is what broke the layout file.
// A run that shows `entry #2 windows=0` is the double-destroy, on the
// record, without needing to catch the cfg mid-corruption.
{
static int s_destroyN = 0;
DEBUG_STREAM << "[glasswin] destroy entry #" << ++s_destroyN
<< " windows=" << gWinCount << "\n" << std::flush;
}
// Backstop for a clean teardown (WM_EXITSIZEMOVE already caught every
// finished drag); no-op unless mode==save. GUARDED on there being windows:
// this function has TWO callers (~PadRIO and ~LBE4ControlsManager), so on
// the desktop path it runs twice, and the second pass has nothing live to
// report. The remembered-geometry cache above already makes that harmless,
// but there is no reason to rewrite the file to say the same thing.
if (gWinCount > 0)
SaveLayout();
for (int i = 0; i < gWinCount; ++i)
{
@@ -1778,6 +2065,62 @@ void
// one-shot re-snap); a focused window just repaints from whichever fires first.
//###########################################################################
//
// GLASS DIRTY-SKIP (2026-08-09): re-blit only the windows whose content changed.
// Each window is a plane of the ONE shared gauge pixelBuffer, so its change token is
// the masked plane checksum (SVGA16::PlaneChecksum over every port that can feed the
// window) combined with each button's RENDERED lamp brightness + held/latched state.
// Folding the flash BRIGHTNESS in (not the raw lamp state) means a flashing lamp
// repaints exactly when it toggles, and a static panel -- or a fully idle cockpit --
// skips its expand + StretchDIBits entirely. The plane checksum is the only added
// cost (once per window per ~16 Hz pump) and is far cheaper than the paint it saves.
//
static unsigned long
GlassWindowToken(GaugeRenderer *gr, GWin *w, unsigned long tick)
{
unsigned long token = 2166136261UL;
if (w->portPrimary != NULL && gr != NULL)
{
// Every plane that could feed the window (primary + Eng twin + RGB group +
// their twins) -- OR their masks so a change to ANY is caught; checksum once.
const char *ports[8];
int np = 0;
ports[np++] = w->portPrimary;
if (w->portAlt != NULL) ports[np++] = w->portAlt;
for (int gi = 0; gi < w->groupCount && np < 7; ++gi)
{
ports[np++] = w->groupPort[gi];
if (w->groupAlt[gi] != NULL && np < 8) ports[np++] = w->groupAlt[gi];
}
int combined = 0;
SVGA16 *svga = NULL;
for (int i = 0; i < np; ++i)
{
L4GraphicsPort *p =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(ports[i]));
if (p == NULL) continue;
combined |= p->GetBitMask();
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
}
if (svga != NULL && combined != 0)
token ^= svga->PlaneChecksum(combined);
}
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
// press repaints exactly the window it lives on.
for (int j = 0; j < w->buttonCount; ++j)
{
int addr = w->buttons[j].address;
unsigned long shade =
(unsigned long)LampBrightnessOf(PadRIO::GetLampState(addr), tick);
int held = (addr == pressedAddress) || latched[addr & 0x7F];
token = (token ^ ((unsigned long)(addr & 0xFF) << 4)
^ (shade << 1) ^ (unsigned long)held) * 16777619UL;
}
return token;
}
void
BTGlassPanels_Tick()
{
@@ -1790,12 +2133,76 @@ void
return;
sLastPaint = now;
// MERGED: Cyd's dirty-skip (glass-panel-perf) + the #149 [glassperf]
// telemetry. BT_GLASS_SWEEP=1 bypasses the skip entirely -- the legacy
// always-repaint escape hatch, kept so the field can A/B in one env var.
static const int sGlassPerf =
!(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
static const int sLegacySweep =
(getenv("BT_GLASS_SWEEP") && *getenv("BT_GLASS_SWEEP") == '1');
LARGE_INTEGER gt0, gt1, gfq;
QueryPerformanceCounter(&gt0);
GaugeRenderer *gr = BTResolveGaugeRenderer();
int repainted = 0;
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].hwnd != NULL)
GWin &w = gWins[i];
if (w.hwnd == NULL)
continue;
unsigned long token = GlassWindowToken(gr, &w, now);
if (!sLegacySweep && w.haveToken && token == w.lastToken)
continue; // gauges + lamps unchanged -> skip this window
w.lastToken = token;
w.haveToken = 1;
++repainted;
InvalidateRect(w.hwnd, NULL, FALSE);
UpdateWindow(w.hwnd); // synchronous paint, not the throttled queue
}
// BT_GLASS_DIRTY: report how many window-repaints the dirty-skip let through vs
// the old fixed gWinCount-per-pump, so the saving is visible.
static int sDirtyLog = -1;
if (sDirtyLog < 0) sDirtyLog = getenv("BT_GLASS_DIRTY") ? 1 : 0;
if (sDirtyLog)
{
static unsigned long sWin = 0;
static int sPumps = 0, sPaints = 0;
++sPumps; sPaints += repainted;
if (now - sWin >= 2000)
{
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
DEBUG_STREAM << "[glass-dirty] " << sPumps << " pumps -> " << sPaints
<< " window-repaints (was " << (sPumps * gWinCount) << " always-on)\n"
<< std::flush;
sWin = now; sPumps = 0; sPaints = 0;
}
}
QueryPerformanceCounter(&gt1);
QueryPerformanceFrequency(&gfq);
// [glassperf] (#149): one line per second -- the tick's total synchronous
// cost plus each window's own paint time. This runs INSIDE the render
// frame (L4VIDEO calls the tick), so on a machine where GDI serialises
// against D3D present, tickMs IS the per-frame tax and the per-window
// split names the guilty panel. Default ON like [segperf]; BT_PERF_LOG=0
// opts out.
static double sTickMs = 0.0; static int sTicks = 0; static int sPaintAcc = 0;
sPaintAcc += repainted;
static unsigned long sLastReport = 0;
sTickMs += 1000.0 * (double)(gt1.QuadPart - gt0.QuadPart) / (double)gfq.QuadPart;
++sTicks;
if (sGlassPerf && now - sLastReport >= 1000)
{
sLastReport = now;
DEBUG_STREAM << "[glassperf] ticks=" << sTicks << " tickMs=" << sTickMs
<< " paints=" << sPaintAcc;
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].perfN > 0)
DEBUG_STREAM << " | " << gWins[i].title
<< " n=" << gWins[i].perfN << " ms=" << gWins[i].perfMs;
gWins[i].perfMs = 0.0; gWins[i].perfN = 0;
}
DEBUG_STREAM << "\n" << std::flush;
sTickMs = 0.0; sTicks = 0; sPaintAcc = 0;
}
}
+2 -7
View File
@@ -829,18 +829,13 @@ void
switch (type)
{
case scrambleVideo:
// NON-STACKING LATCH (phase-14 fidelity): the binary latches on `modified`
// @0x4fe0fe -- a second PPC hit while the scramble is ALREADY live does NOT
// re-save the (already detuned) value and does NOT extend the window. This
// original unconditionally overwrote scrambleVideoTimeout, so rapid PPC fire
// STACKED the effect -- a divergence from the binary. Ignore re-arm while live.
if (graphicsDisplay != NULL && !scrambleVideoFlag)
if (graphicsDisplay != NULL)
{
scrambleVideoFlag = True;
scrambleVideoTimeout = ((Scalar)Now()) + duration;
Check(graphicsDisplay);
((SVGA16*) graphicsDisplay)->FunkyVideo(True, duration); // duration -> the recovery envelope
((SVGA16*) graphicsDisplay)->FunkyVideo(True);
}
break;
+12
View File
@@ -36,6 +36,7 @@ int gBTPadViewToggleEdges = 0;
// desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame.
//
int gBTReverseHeld = 0;
int gBTTorsoCenterHeld = 0; // button 0x42 hold (#152; same seam as 0x3F)
//
// The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed
@@ -458,6 +459,17 @@ void
// via SetScreenButton), so the desktop bridge can honour the button exactly
// like the pod's RIO board did.
//
// TORSO CENTER (pod button 0x42, 'the shipped .RES name' -- UP arrow via
// bindings.txt). Same chokepoint pattern as 0x3F below: publish the HOLD
// state so the mapper's unified recenter writer (#152) can honour it on
// every rig. Before this, no RIO/glass path reached centerCommand at all
// -- bench: two scripted 0x42 holds, ctrCmd=0 throughout.
if (address == 0x42)
{
extern int gBTTorsoCenterHeld;
gBTTorsoCenterHeld = pressed ? 1 : 0;
}
if (address == 0x3F)
{
gBTReverseHeld = pressed ? 1 : 0;
+51 -240
View File
@@ -615,41 +615,17 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word *source = pixelBuffer.Data.MapPointer;
Word *dest = (Word*)rect.pBits;
int postRowIncrement = (rect.Pitch / 2) - w;
// PPC sync-scramble (phase-14): map the SOURCE read through the recovery
// envelope (collapse toward a line -> broaden -> lock). Identity when not
// armed (ScrambleParams returns False). Output columns outside the collapsed
// band read index 0 (black), so the picture squeezes to a line and grows back.
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (monoTint < 0)
{
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
SVGA16Palette *pal = &palette[paletteID];
for (int y = 0; y < h; y++)
{
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y; // scroll + diagonal, per row
for (int x = 0; x < w; x++)
{
Word px;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
px = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
px = srcRow[sx];
}
}
else px = srcRow[x];
PaletteTriplet *pe = &(pal->paletteData.Color[px & mask]);
PaletteTriplet *pe = &(pal->paletteData.Color[*source & mask]);
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
dest++;
dest++; source++;
}
dest += postRowIncrement;
}
@@ -661,27 +637,10 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word tint = (Word) monoTint;
for (int y = 0; y < h; y++)
{
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++)
{
Word px;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx;
if (srcBase < 0.0 || srcBase >= w)
px = 0;
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w;
px = srcRow[sx];
}
}
else px = srcRow[x];
*dest = (px & mask) ? tint : 0;
dest++;
*dest = (*source & mask) ? tint : 0;
dest++; source++;
}
dest += postRowIncrement;
}
@@ -733,6 +692,28 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
device->DrawPrimitiveUP(D3DPT_TRIANGLEFAN, 2, quad, sizeof(InsetVert));
}
//===========================================================================//
// GLASS dirty-skip: FNV-1a over the shared pixelBuffer masked to `mask` -- the bits
// one glass window can show. The glass repaint pump compares this per window and
// re-blits only the ones whose plane changed (L4GLASSWIN BTGlassPanels_Tick). Full
// pass (no stride) so a single-word gauge change is never missed; ~640*480 cheap
// integer ops, run at most once per window per ~16 Hz pump.
//===========================================================================//
unsigned long SVGA16::PlaneChecksum(int mask) const
{
int w = pixelBuffer.Data.Size.x;
int h = pixelBuffer.Data.Size.y;
const Word *p = pixelBuffer.Data.MapPointer;
if (p == NULL || w <= 0 || h <= 0)
return 0;
unsigned long sum = 2166136261UL; // FNV-1a offset basis
Word m = (Word)mask;
int n = w * h;
for (int i = 0; i < n; ++i)
sum = (sum ^ (unsigned long)(p[i] & m)) * 16777619UL;
return sum;
}
//===========================================================================//
// GLASS per-display windows -- the CPU (no-D3D) analog of DrawDevSurface: expand
// one bit-plane of the shared gauge pixelBuffer into a 32-bit BGRA image that the
@@ -753,38 +734,16 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
Word *base = pixelBuffer.Data.MapPointer;
SVGA16Palette *pal = &palette[paletteID];
// PPC sync-scramble (phase-14) -- the recovery envelope (collapse -> broaden ->
// lock), mapped in SOURCE space so it survives the rotation. Identity when not
// armed (ScrambleParams False). Reads outside the collapsed band -> index 0 (black).
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (rotateQuadrant == 0)
{
// Native orientation, top-down straight copy.
for (int y = 0; y < h; y++)
{
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *src = base + sry * w; // vertical shake bounces the row
Word *src = base + y * w;
unsigned long *d = dst + y * w;
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++)
{
Word s;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
s = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
s = src[sx];
}
}
else s = src[x];
Word s = src[x];
if (monoTint < 0)
{
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -832,8 +791,6 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
// The scramble maps the SOURCE column (sx keyed on source row sy) through the
// same envelope, so the radar recovers the same way the landscape MFDs do.
int ow = h, oh = w;
for (int oy = 0; oy < oh; oy++)
{
@@ -851,21 +808,7 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
sx = w - 1 - oy;
sy = ox;
}
int syS = sy + shake; if (syS < 0) syS = 0; else if (syS >= h) syS = h - 1; // shake
Word s;
if (scr)
{
double srcBase = ((double)sx - cx) * invS + cx; // collapse band (source col)
if (srcBase < 0.0 || srcBase >= w)
s = 0;
else
{
long msx = (long)(srcBase + roff + shr * (double)sy + 0.5);
msx %= w; if (msx < 0) msx += w; // roll SCROLLS within source
s = base[syS * w + msx];
}
}
else s = base[syS * w + sx];
Word s = base[sy * w + sx];
if (monoTint < 0)
{
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -5502,9 +5445,6 @@ SVGA16::SVGA16(
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
mDevSurfaceTex[_i] = NULL;
scrambleActive = False; // PPC sync-scramble (phase-14) -- armed by FunkyVideo
scrambleStartMs = 0;
scrambleDurationS = 0.0;
//STUBBED: VIDEO RB 1/15/07
# if defined(DEBUG)
Tell("SVGA16::SVGA16()\n");
@@ -6464,153 +6404,13 @@ void
Check_Fpu();
}
//
// PPC sync-scramble (phase-14), animated as a RECOVERY. The original
// (`//STUBBED: VIDEO RB 1/15/07`) detuned the VGA CRTC Horizontal Total so every
// secondary monitor lost horizontal lock for ~0.8 s. There is no CRTC here, so
// FunkyVideo records the start + duration and the per-surface expansions
// (DrawDevSurface / ExpandPlaneToBGRA) map the pixelBuffer read through the
// ScrambleParams envelope: at the hit the image COLLAPSES horizontally toward a
// line, then BROADENS back out as the tear + roll DECAY, and LOCKS to the full
// clean display at the end. The 0.8 s window + non-stacking latch stay in
// L4GaugeRenderer; the shape lives here.
//
void
SVGA16::FunkyVideo(Logical on_off, Scalar duration)
SVGA16::FunkyVideo(Logical on_off)
{
if (on_off)
{
scrambleActive = True;
scrambleStartMs = GetTickCount();
scrambleDurationS = (duration > 0.0f) ? (double)duration : 0.8; // the HOLD
}
// FunkyVideo(False) is a NO-OP: the card restoring the sync (at HOLD end) is
// where the RECOVERY begins, so ScrambleParams keeps running past the flag,
// self-terminating at hold+recovery. scrambleActive stays armed; the clock gates.
}
//
// Two-phase envelope (per the "hold the bad sync, THEN recover" direction):
// HOLD [0, hold] -- the card holds the detuned CRTC. Deep collapse (a line),
// and a WILDLY fast horizontal roll that decelerates over
// the hold (content scrolls, wrapping, so it can't be read).
// RECOVERY [hold, +] -- the sync is restored and the monitor re-locks: the line
// BROADENS back to full while the residual scroll + tear
// settle to zero, then LOCKS (returns False = clean).
// Fills the read-loop parameters: scale (collapse band width fraction), rollOff
// (SCROLL offset in px, wrapped within the source), shear (px/row diagonal),
// centerX. Tunables (read once): BT_SCRAMBLE_COLLAPSE (min band, deeper=smaller),
// BT_SCRAMBLE_ROLL (initial scroll px/sec -- "wildly high"), BT_SCRAMBLE_SHEAR
// (px/row), BT_SCRAMBLE_RECOVER (recovery seconds), BT_SCRAMBLE_DUR (hold seconds
// override). BT_SCRAMBLE_TEST loops it; BT_SCRAMBLE_CYCLE loops stepping the roll.
//
Logical
SVGA16::ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const
{
*shakeRow = 0;
if (width <= 0)
return False;
static int sInit = 0, sTest = 0, sCycle = 0;
static double shearMax = 3.0, rollMax = 9000.0, collapse = 0.03,
recover = 0.5, holdEnv = 0.0, shakeAmp = 10.0;
if (!sInit)
{
sInit = 1;
sTest = (getenv("BT_SCRAMBLE_TEST") != NULL) ? 1 : 0;
sCycle = (getenv("BT_SCRAMBLE_CYCLE") != NULL) ? 1 : 0;
const char *e;
if ((e = getenv("BT_SCRAMBLE_SHEAR")) != NULL) shearMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_ROLL")) != NULL) rollMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_COLLAPSE")) != NULL) collapse = atof(e);
if ((e = getenv("BT_SCRAMBLE_RECOVER")) != NULL) recover = atof(e);
if ((e = getenv("BT_SCRAMBLE_DUR")) != NULL) holdEnv = atof(e);
if ((e = getenv("BT_SCRAMBLE_SHAKE")) != NULL) shakeAmp = atof(e);
if (collapse < 0.004) collapse = 0.004;
if (collapse > 1.0) collapse = 1.0;
if (recover < 0.05) recover = 0.05;
}
double hold = (holdEnv > 0.0) ? holdEnv
: (scrambleDurationS > 0.0 ? scrambleDurationS : 0.8);
double total = hold + recover;
double rlMax = rollMax;
double elapsed;
if (sTest || sCycle)
{
// Tuning: loop the whole effect (hold+recovery) + a short locked pause.
// CYCLE steps the initial roll speed each loop.
double loop = total + 0.6;
double t = (double)GetTickCount() * 0.001;
long n = (long)(t / loop);
double ph = t - (double)n * loop;
if (ph >= total)
return False; // locked pause between loops
elapsed = ph;
if (sCycle)
{
static const double kR[] = { 3000.0, 6000.0, 9000.0, 14000.0, 20000.0 };
int nS = (int)(sizeof(kR) / sizeof(kR[0]));
rlMax = kR[(int)(n % nS)];
static long lastN = -1;
if (n != lastN) { lastN = n;
DEBUG_STREAM << "[scramble-cycle] loop " << n << " rollMax=" << rlMax
<< "px/s (hold " << hold << "s + recover " << recover << "s)\n"
<< std::flush; }
}
}
else
{
if (!scrambleActive)
return False;
elapsed = ((double)(GetTickCount() - scrambleStartMs)) * 0.001;
if (elapsed >= total)
return False; // LOCKED -- clean display
}
if (elapsed < 0.0) elapsed = 0.0;
double sScale, sScroll, sShear;
if (elapsed < hold)
{
// HOLD: bad sync held. Deep collapse; roll starts wild and decelerates to a
// stop by the hold end. scroll = integral of v(t)=rlMax*(1-t/hold): the
// content scrolls fast then coasts to rest (rlMax*hold/2 accumulated).
sScale = collapse;
sScroll = rlMax * elapsed * (1.0 - elapsed / (2.0 * hold));
sShear = shearMax;
}
else
{
// RECOVERY: sync restored, monitor re-locks. Broaden line->full and settle
// the residual scroll + tear to zero (smoothstep), then it hits total = lock.
double tr = (elapsed - hold) / recover;
double b = tr * tr * (3.0 - 2.0 * tr);
sScale = collapse + (1.0 - collapse) * b;
sScroll = (rlMax * hold * 0.5) * (1.0 - b);
sShear = shearMax * (1.0 - b);
}
// SHAKE: a per-FRAME jitter (LCG, so all surfaces shake together within a frame
// but re-roll each frame), violent at the hit and fading through the recovery.
// Horizontal folds into the scroll; vertical bounces the source row (shakeRow).
double shakeEnv = (elapsed < hold)
? (1.0 - 0.5 * (elapsed / hold)) // 1.0 at impact -> 0.5 at hold end
: (0.5 * (1.0 - (elapsed - hold) / recover)); // 0.5 -> 0 through recovery
if (shakeEnv < 0.0) shakeEnv = 0.0;
unsigned int fr = (unsigned int)(GetTickCount() / 16); // ~per-frame index
unsigned int r = fr * 1103515245u + 12345u;
double jX = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0; // [-1,1)
r = r * 1103515245u + 12345u;
double jY = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0;
*scale = sScale;
*shear = sShear;
*rollOff = sScroll + shakeAmp * shakeEnv * jX; // horizontal shake -> scroll
*centerX = width / 2;
*shakeRow = (int)(shakeAmp * shakeEnv * jY + (jY >= 0.0 ? 0.5 : -0.5)); // vertical bounce
return True;
//STUBBED: VIDEO RB 1/15/07
//Check(this);
//SVGAFunkyVideo(on_off);
//Check_Fpu();
}
//########################################################################
@@ -7757,17 +7557,28 @@ void
// LEFT AS HEAP GARBAGE and any pixmap pixel >= that count wrote the
// garbage's high bits into other displays' planes (the #48 stray
// blocks; convicted live by BT_PLANE_AUDIT -- the 480x640 radar
// background carries index 217). Cycle the in-plane pattern across
// the remainder: high-index art degrades to its (index mod 2^bits)
// colour IN-PLANE, and can never leak. (The 1995 binary shipped the
// same 64-entry fill and relied on art discipline; garbage is not a
// preservable behaviour, so this is a guarded PORT deviation.)
// background carries index 217). Map the remainder to the plane
// BACKGROUND (translationTable[0]): high-index art renders as the
// port's background colour in-plane, and can never leak.
//
// REFINED 2026-08-10 (the sec-surface phantoms): the first #48 fill
// cycled the remainder IN-PLANE (index mod 2^bits), which mapped art
// index 254 onto plane slot 62 -- a LIVE colorMapperMultiArmor damage
// slot. SMODE.PCC's inactive control-mode box interiors and a stray
// 52x13 idx-254 bar baked into BTSEC1.PCX (between the heading dial
// and the armor rosette) lit up in the current right-armor damage
// colour on every render path. On the shipped machine those regions
// rendered BLACK (the heap garbage's low plane bits were 0 ->
// in-plane index 0), so background IS the authentic on-screen result.
// (The 1995 binary shipped the same 64-entry fill and relied on art
// discipline; garbage is not a preservable behaviour, so this stays a
// guarded PORT deviation.)
{
int filled = 1;
for (int b = bitMask & 0xFF; b != 0; b &= (b - 1))
filled <<= 1;
for (int i = filled; i < 256; ++i)
translationTable[i] = translationTable[i & (filled - 1)];
translationTable[i] = translationTable[0];
}
Check_Fpu();
}
+7 -21
View File
@@ -332,27 +332,7 @@ public:
UnflashPalette(int palette_number);
void
FunkyVideo(Logical on_off, Scalar duration = 0.0f);
// PPC sync-scramble (phase-14): the modern stand-in for the VGA CRTC
// Horizontal-Total detune, animated as a RECOVERY over the effect window -- at
// the hit the image collapses horizontally toward a line, then broadens back
// out as the tear + roll decay, and LOCKS to the full clean display at the end.
// FunkyVideo(on, dur) records the start + duration; ScrambleParams derives the
// per-frame envelope (returns True while active + fills scale/shear/rollOff/
// centerX); DrawDevSurface (surround) + ExpandPlaneToBGRA (glass) map the
// pixelBuffer read through it, so all secondary surfaces recover together and
// the main 3D view is untouched. Tunable by eye: BT_SCRAMBLE_SHEAR (max
// px/line), BT_SCRAMBLE_ROLL (max px/sec), BT_SCRAMBLE_COLLAPSE (min width
// fraction 0..1), BT_SCRAMBLE_DUR (anim seconds override). BT_SCRAMBLE_TEST
// loops the transition for tuning; BT_SCRAMBLE_CYCLE loops it stepping the
// max shear. (Exact look depended on each pod monitor's PLL -- not recoverable.)
Logical
ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const;
Logical scrambleActive;
unsigned long scrambleStartMs;
double scrambleDurationS;
FunkyVideo(Logical on_off);
protected:
@@ -439,6 +419,12 @@ public:
// dwords; the image is written TOP-DOWN. *outW/*outH receive the produced size.
void ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotateQuadrant,
unsigned long *dst, int *outW, int *outH);
// GLASS dirty-skip (L4GLASSWIN, 2026-08-09): FNV-1a checksum of the shared
// gauge pixelBuffer, masked to the bits a given window can show. Lets the
// glass repaint pump re-blit ONLY the windows whose plane actually changed
// (idle MFDs / static panels skip; the sweeping radar keeps updating).
unsigned long PlaneChecksum(int mask) const;
};
//########################################################################
+14
View File
@@ -9034,6 +9034,20 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames)
<< "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP
<< " batches=" << gNumBatches << " culled=" << gBTNumCulled << "\n" << std::flush;
// #149: segment-refresh telemetry on the same cadence (BT_PERF_LOG).
// calls = GetSegmentToWorld entries; dirty = the mark-every-segment
// invalidation passes (the expensive arm the #141 sweep may have
// multiplied); ms = time inside the accessor for the whole window.
{
static const int sSegPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
extern int gBTSegWCalls, gBTSegWDirty;
extern double gBTSegWMs;
if (sSegPerf)
DEBUG_STREAM << "[segperf] calls=" << gBTSegWCalls
<< " dirty=" << gBTSegWDirty
<< " ms=" << gBTSegWMs << "\n" << std::flush;
gBTSegWCalls = 0; gBTSegWDirty = 0; gBTSegWMs = 0.0;
}
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
}
}
+6
View File
@@ -31,6 +31,12 @@ void
void
BTGlassPanels_Destroy();
// BT_GLASS_IDS=1 -- log every attached panel's BOOT-STABLE hardware identity
// plus a ready-to-paste `monitor:id:<fragment>` line for glass_layout.cfg.
// Called from BTGlassPanels_Create; no-op unless the env is set.
void
BTGlassDumpMonitorIds();
//
// Per-frame repaint pump. Call once per frame from the main render loop so the
// per-display windows' lamp flash keeps animating even when they are in the
+18 -1
View File
@@ -1744,10 +1744,27 @@ Logical
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
{
DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
// WAS DebugStream -- the no-op ReconStream (gotcha: use DEBUG_STREAM).
// A missing strip therefore failed COMPLETELY SILENTLY, which is
// exactly the state #142 was stuck in: the crouch symbol never drew and
// nothing anywhere said why.
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt: MISSING IMAGE '"
<< p[2].data.string << "' -- element not created\n" << std::flush;
return False;
}
warehouse->pixelMap8Bin.Release(p[2].data.string); // FUN_00442e51
// #142 receipt (ungated, one line per element): does this strip exist, and
// did its integer attribute actually RESOLVE? A NULL attributePointer
// leaves the connection reading nothing, so the strip pins to frame 0 and
// looks like "no animation at all" -- indistinguishable, from outside, from
// a missing image or an unbuilt page.
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt '" << p[2].data.string
<< "' frames=" << p[3].data.integer << "x" << p[4].data.integer
<< " port=" << display_port_index
<< " at(" << position.x << "," << position.y << ")"
<< " attr=" << (p[5].data.attributePointer != 0 ? "BOUND" : "NULL !!")
<< "\n" << std::flush;
return True;
}
+8
View File
@@ -2622,6 +2622,14 @@ void
{
// the range caret translate, from the live target range
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
// #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH
// false for NaN, so the old pair let a poisoned value straight through into
// AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its
// bar silently STOP DRAWING while the static ticks remain. Test for NaN
// first (x == x is false only for NaN) and fall back to the binary's
// no-target default rather than rendering nothing.
if (!(range == range))
range = maxRange; // 1200: the authentic no-target peg
if (range < minRange) range = minRange;
if (range > maxRange) range = maxRange;
Scalar frac = (range - minRange) / (maxRange - minRange);
+328 -28
View File
@@ -305,13 +305,38 @@ static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
// tonnage ratio stubbed 1.0 + damage bias stubbed 0.0 for bring-up (SCORE == raw
// damage, un-tonnage-scaled; the real per-mech tonnage/bias accessors are a follow-up).
#define MECH_TONNAGE(m) (1.0f) // bring-up: ratio == 1
#define MECH_DAMAGE_BIAS(m) (0.0f) // bring-up: factor = 0*bias+1 = 1
// mech+0x354 -- the score formula's per-target damage-bias factor.
//
// ⚠ DO NOT "FINISH" THIS. 0.0f is not a stand-in; it is the value the shipped
// binary always has, established by audit 2026-08-08:
//
// * mech+0x354 has exactly ONE writer in the whole image -- Mech::Reset
// (@0049fb74, part_012.c:14340). Nothing touches it during play.
// * Reset computes it as mean(zone + 0x158) across every damage zone, and it
// does that AFTER the zone heal has already zeroed those cells. So it is
// ~0 the instant it is written, stays ~0 for the mech's entire life, and is
// recomputed as ~0 on the next respawn.
// * It has exactly ONE reader -- CalcInflictedScore (@004c052c,
// part_013.c:19055) -- where it appears as `avg * role.damageBias + 1.0`.
//
// So the factor is 1.0 for the whole game, and returning 0.0f here reproduces
// the binary EXACTLY. It is a vestigial aggregate (0x358 and 0x35c are the
// same computation and have NO reader at all).
//
// Wiring this to live accumulated damage would look like completing an unfinished
// port and would silently inflate every inflicted and kill award -- the numbers
// verified against the original manual's scoring chart (+1 a damage point, +500
// a kill) all assume this term is 1.0.
#define MECH_DAMAGE_BIAS(m) (0.0f) // = the binary's value; see above
#define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy)
//#############################################################################
//############################### BTPlayer ##############################
//#############################################################################
Scalar BTScoreWatermarkOf(int owner);
void BTScoreWatermarkSet(int owner, Scalar sent);
//#############################################################################
// Message Support
//
@@ -684,6 +709,20 @@ void
// scoreboard (they read +0x278), so the pod's penalty may never have been
// visible; our port has ONE currentScore, so it shows.
//
// #45 receipt (ungated): this cost is dispatched by a DIRECT base-handler
// call, so it never reaches the BT matchlog and only the running total
// exposes it -- which is how a combat death costing -500 while a SELF-KILL
// cost nothing went unnoticed. Say out loud whether it fires and why not.
DEBUG_STREAM << "[deathcost] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID()
<< " advDmg=" << (int)advancedDamageOn
<< " role=" << (scenarioRole != 0 ? "bound" : "NULL")
<< " penalty=" << (scenarioRole != 0
? (float)scenarioRole->GetSpecialCaseDeathPenalty() : 0.0f)
<< " scoreBefore=" << (float)currentScore
<< ((advancedDamageOn && scenarioRole != 0) ? " -> APPLYING" : " -> SKIPPED")
<< "\n" << std::flush;
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
{
BTPlayer::ScoreMessage death_cost(
@@ -731,6 +770,187 @@ void
suppressConsole = 0; // this+0x258
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// MissionStartingMessageHandler (@004bfbe8)
//
// THE "+1000 STARTING THE GAME" ROW of the original manual's scoring chart,
// decoded 2026-08-07. BT overrides the engine's MissionStarting purely to
// seed the score, and the override was never reconstructed -- the
// MESSAGE_ENTRY resolved to the inherited Player:: handler, so the grant
// simply never happened. The binary:
//
// FUN_004bfbe8(player):
// base_MissionStarting(player);
// if (app->state == 4 && (player[0x29] & 0x40) == 0)
// player[0x1c8] = 0x447a0000; // = 1000.0f
//
// Both operands decode exactly: application state 4 is LaunchingMission
// (APP.h -- the same enum whose 6 is EndingMission, already used by the
// console flush), and simulationFlags bit 14 is NonScoringPlayerBit
// (PLAYER.h: `NonScoringPlayerBit = Entity::NextBit`), so the byte test
// `(+0x29 & 0x40) == 0` IS `IsScoringPlayer()`. Camera-ship and spectator
// players are non-scoring and correctly get nothing.
//
// CELL NOTE: the binary seeds the ENGINE score cell (+0x1c8), not BT's own
// (+0x278) -- the 1995 build carried two accumulators, which is why the KB
// suspected the pod's death cost "may never have displayed". Our port has a
// single currentScore, so the grant, the awards and the death cost all land
// together, and the chart reads coherently for a player: start at 1000, +1 a
// damage point, +500 a kill, -500 a special-case death.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTPlayer::MissionStartingMessageHandler(Entity::Message *message)
{
Player::MissionStartingMessageHandler(message); // FUN_0042d9c0
if (application->GetApplicationState() == Application::LaunchingMission // app+0x88 == 4
&& IsScoringPlayer()) // !(simulationFlags & NonScoringPlayerFlag)
{
currentScore = 1000.0f; // this+0x1c8 = 0x447a0000
// The console watermark is what we have already reported; a fresh
// mission must report the grant, not the difference from the last
// round's tally.
BTScoreWatermarkSet(ownerID, 0.0f);
DEBUG_STREAM << "[score] mission start: player "
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
<< " seeded to " << (float)currentScore
<< " (chart: +1000 starting the game)\n" << std::flush;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Console score watermark (port-side, 2026-08-07)
//
// How much of a player's running score has already been reported to the
// operator console. Keyed by ownerID and kept OUTSIDE BTPlayer: sizeof
// (BTPlayer) is static_assert-locked at 652 against the binary, so a new data
// member is not available (the console timer above is a file static for the
// same reason). A pod round is a handful of players; linear scan is free.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
namespace {
struct ScoreWatermark { int owner; Scalar sent; };
ScoreWatermark gScoreWatermarks[16];
int gScoreWatermarkCount = 0;
}
Scalar
BTScoreWatermarkOf(int owner)
{
for (int i = 0; i < gScoreWatermarkCount; ++i)
if (gScoreWatermarks[i].owner == owner)
return gScoreWatermarks[i].sent;
return 0.0f;
}
void
BTScoreWatermarkSet(int owner, Scalar sent)
{
for (int i = 0; i < gScoreWatermarkCount; ++i)
if (gScoreWatermarks[i].owner == owner)
{
gScoreWatermarks[i].sent = sent;
return;
}
if (gScoreWatermarkCount
< (int)(sizeof(gScoreWatermarks) / sizeof(gScoreWatermarks[0])))
{
gScoreWatermarks[gScoreWatermarkCount].owner = owner;
gScoreWatermarks[gScoreWatermarkCount].sent = sent;
++gScoreWatermarkCount;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Dispatch (@004bffa0, vtable @00513300 slot 3)
//
// THE TYPE-0 INTERCEPTOR. Restored 2026-08-07 -- it was missing, and its
// absence silently deleted per-hit inflicted scoring:
//
// * the binary carries all three score reports under ONE id (0x16) and
// splits type 0 off here, BEFORE base dispatch, straight into
// ScoreInflictedMessageHandler (@004c0200 -- which names itself in its own
// Verify string, "BTPlayer::ScoreInflictedMessageHandler");
// * ScoreMessageHandler's type-0 arm Verify-rejects ON PURPOSE, because this
// interceptor guarantees type 0 never gets that far;
// * the port had the handler, faithfully reconstructed, and NO interceptor.
// Block B sends its inflicted report under Player::ScoreMessageID, so every
// one of them landed in the rejecting arm and banked 0.
//
// The KB previously recorded @004c0200 as "in NO table entry: dead code" and
// concluded 1995 folded an uninitialised stack float into the shooter's score
// on every non-lethal hit -- and the port's per-hit crediting was retired as an
// "invention" on that basis (#45/#134, build 787, the build players report
// scoring regressed in). That reading was wrong: the handler is live through
// THIS vtable slot, and the original manual's SCORING CHART independently
// corroborates what it computes -- "+1 each damage point scored on opponent's
// armor" and "-1 each self-inflicted point of armor damage", which is exactly
// this handler's negate-if-target-is-self arm. combat-damage.md is corrected.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTPlayer::Dispatch(Receiver::Message *what)
{
if (what != 0
&& what->messageID == Player::ScoreMessageID
&& ((BTPlayer::ScoreMessage *)what)->scoreType
== BTPlayer::ScoreMessage::DamageInflictedScore
//
// MASTER ONLY. The binary intercepts unconditionally because its
// +0x278 is a console DELTA -- every node's contribution is flushed
// under the scoring player's ownerID and the CONSOLE totals it, so the
// computing node is irrelevant. Our port has no console tally
// (btconsole.py/btoperator.py handle no score at all) and reads +0x278
// on the OWNING node for the SCORE gauge, CalcRanking and the replicated
// Player__UpdateRecord. Banking on the victim's replicant copy
// therefore loses the credit to the master's next update record.
// Falling through lets Entity::Dispatch reroute to the owner, where the
// wire delivery lands in ScoreMessageHandler's type-0 arm (which now
// delegates back to the inflicted handler).
//
&& GetInstance() == Entity::MasterInstance)
{
ScoreInflictedMessageHandler((BTPlayer::ScoreMessage *)what);
return;
}
Player::Dispatch(what);
}
//
// ⚠ OPEN, cross-node credit routing (2026-08-07). The interception above is
// binary-faithful and restores per-hit inflicted credit -- benched, awards
// track damage. What it does NOT yet solve is WHICH MACHINE banks it.
//
// Damage is applied on the VICTIM's node, so block B dispatches the inflicted
// report to the SHOOTER's player object THERE, which on that node is a
// REPLICANT. In 1995 that was fine: +0x278 is only ever a console DELTA, and
// it is flushed to the operator console stamped with the shooter's ownerID --
// the CONSOLE holds the authoritative total, so it does not matter which node
// computed a delta. (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() (the SCORE gauge),
// CalcRanking() and the replicated Player__UpdateRecord all read +0x278 on the
// OWNING node. So a credit banked on the victim's replicant copy is
// overwritten by the master's next update record -- benched as totals climbing
// to ~35 and snapping back every few seconds.
//
// TRIED AND REJECTED: gating the interception to MasterInstance so a replicant
// falls through and reroutes to the master. The message arrives, but the BT
// extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the
// wire -- only the base Player::ScoreMessage `scoreAward` does -- so every
// award computed to 0.00. That is also WHY the kill path (type 2) already
// credits cross-node correctly: its value rides `scoreAward`.
//
// THE FIX SHAPE, therefore: compute the award on the victim's node (where the
// damage data lives, exactly as now) and ship the RESULT to the owner in
// `scoreAward`, the way the kill report already does -- rather than shipping
// the basis and recomputing on a machine that cannot see it.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ScoreInflictedMessageHandler
//
@@ -865,16 +1085,34 @@ void
{
case BTPlayer::ScoreMessage::DamageInflictedScore: // 0
//
// Inflicted-damage messages belong to ScoreInflictedMessageHandler.
// Inflicted-damage messages belong to ScoreInflictedMessageHandler, and
// the binary's Dispatch override (@004bffa0) guarantees they never reach
// here -- which is why the original arm is a bare Verify.
//
Verify(
False,
"BTPlayer::ScoreMessageHandler should not be "
"given DamageInflictedScoreMessages!", // @0051324a
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051329a
0x296
);
break;
// PORT DIVERGENCE (2026-08-07), and it is a DELIVERY-PATH difference,
// not a scoring one. 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 Entity::Dispatch reroutes it to the owning host so
// the credit lands on the shooter's OWN machine (the same reroute that
// carries kill credit, ENTITY.cpp:244-251). But a message arriving over
// the WIRE is delivered through Receive(), straight to this handler
// table: the virtual Dispatch override is never called on the receiving
// side. So the rerouted report lands HERE, and Verify-rejecting it
// threw away every cross-node inflicted credit (benched: award=0.00).
//
// Delegate instead. Local deliveries are still intercepted by Dispatch
// exactly as the binary does; wire deliveries land here and get the same
// handler. One accumulator, on the machine that owns the score.
//
// RETURN, not break. The post-switch tail folds the local `award` into
// message->scoreAward and hands it to the base handler -- and for this
// arm `award` is still 0, so falling through clobbered scoreAward to
// zero, added nothing, and emitted a second SCORE row reading
// "type=0 award=0.00" (the 80 real + 80 zero rows in the bench).
// ScoreInflictedMessageHandler is self-contained: it accumulates,
// ForceUpdate()s and logs its own receipt.
ScoreInflictedMessageHandler(message);
return;
case BTPlayer::ScoreMessage::DamageReceivedScore: // 1
{
@@ -1143,20 +1381,38 @@ void
//
// Only bother if our score actually changed since last time.
//
if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f)
// DELTA vs RUNNING TOTAL (2026-08-07). The binary sends currentScore
// and then ZEROES it, ungated (@FUN_..., `param_1[0x9e] = 0` right after
// the send) -- so in 1995 +0x278 was a CONSOLE DELTA and the running
// total lived on the operator console, which is also where the manual's
// "+1000 starting the game" would have been seeded.
//
// Our port cannot copy that literally: THREE port-side consumers read
// +0x278 as a running total -- GetScore() (the SCORE gauge),
// Player::CalcRanking(), and Player__UpdateRecord (the only score field
// we replicate to peers). Zeroing it made all three reset every
// CONSOLE_UPDATE_INTERVAL, which is what players saw as "scoring went
// screwy" (bench: totals climbed to ~35 and dropped back). It was
// mostly invisible until the type-0 interceptor was restored, because
// before that currentScore barely moved.
//
// So: keep the WIRE authentic (the console still receives a DELTA) and
// keep the OBJECT sane (currentScore stays a true running total). The
// last-sent watermark is a file static keyed by player -- a new data
// member would change sizeof(BTPlayer) and break the offset locks (same
// reason the console timer above is a static).
//
const Scalar sent_already = BTScoreWatermarkOf(ownerID);
const Scalar delta = (Scalar)currentScore - sent_already;
if (delta != 0.0f)
{
int score = (int)currentScore;
int score = (int)delta;
ConsolePlayerVTVScoreUpdateMessage score_message(
ownerID,
score
); // FUN_00420ea4(0x20, 0x1a, 1, ...)
// gauge scoring wave: the binary's currentScore is a console DELTA that is
// flushed to the operator console then zeroed. Our SCORE/RANK gauges read
// currentScore as the RUNNING total, so only flush+zero when a console host
// is actually present (MP / pod); in solo there is no console -> keep the
// running score so the SCORE gauge + CalcRanking don't reset every 10s.
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host)
@@ -1175,7 +1431,10 @@ void
NetworkClient::ConsoleClientID, // 5
&score_message
);
currentScore = 0; // this[0x9e] = 0
// The binary does `currentScore = 0` here. We advance the
// watermark by the amount actually SENT instead, so the console
// sees the same deltas while the object keeps the total.
BTScoreWatermarkSet(ownerID, sent_already + (Scalar)score);
}
}
}
@@ -1778,15 +2037,51 @@ BTPlayer::BTPlayer(
}
//
// Look the scoring role up in the role registry (keyed by the role name
// in the creation message, +0x90) and stash it as our scenarioRole. The
// BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
// analog, so the scenarioRole set by the base Player ctor stands.
// CROSS-FAMILY: needs BTMission role-registry access. BEST-EFFORT.
// Look the scoring role up in the role registry (keyed by the role name in
// the creation message, +0x90) and stash it as our scenarioRole.
//
// WIRED 2026-08-07. This was commented out with "the BT role registry
// (BTMission::GetRoleRegistry()->Lookup) has no WinTesla analog, so the
// scenarioRole set by the base Player ctor stands". The base ctor sets it
// to NULL (PLAYER.cpp:680), so it stood NULL forever -- and EVERY scoring
// value the game has lives on that pointer:
//
// killBonus -> the kill award basis (authored 500)
// specialCaseDeathPenalty-> the death cost (authored 500)
// damageInflictedModifier-> the per-hit multiplier (authored 1)
// returnFromDeath -> the entry credit (authored 1000)
//
// With it NULL: kill_bonus reads 0 (a kill scored the damage tally alone --
// benched 4.88 instead of ~500), the eject charge is 0 (field log:
// "PUNCH-OUT: charge=0 (role killBonus)" = #134's missing penalty), and the
// death cost block is skipped entirely. That is four rows of the original
// manual's scoring chart, all from one commented-out line.
//
// The analog DOES exist: Mission::GetScenarioRole(name) (MISSION.h:162)
// walks scenarioRoleChain -- the very dictionary BTL4Mission fills via
// AddScenarioRole() when it parses the role pages (btl4mssn.cpp), whose own
// comment already says the WinTesla base exposes it. Same lookup, same
// key, no cross-family gap.
//
CString role_key(creation_message->roleName); // make+0x90
(void)role_key;
// scenarioRole = playerMission->GetRoleRegistry()->Lookup(&role_key); // this[0x7e]+0x50, this[0x82]
if (playerMission != 0)
{
ScenarioRole *found = playerMission->GetScenarioRole(role_key); // this[0x7e]+0x50
if (found == 0)
{
// The shipped content authors ONE role page, "Role::Default"
// (model dfltrole). A creation message naming anything else -- or
// naming nothing -- must still score, so fall back to it rather
// than leave the pointer NULL and silently zero every award.
found = playerMission->GetScenarioRole(CString("Role::Default"));
}
scenarioRole = found; // this[0x82]
DEBUG_STREAM << "[role] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " key='" << (const char *)role_key
<< "' -> " << (scenarioRole != 0 ? "BOUND" : "NULL (scores will be 0)")
<< (scenarioRole != 0 ? "" : " !!")
<< "\n" << std::flush;
}
if ((simulationFlags & 0xc) == 4)
{
@@ -2351,9 +2646,14 @@ void BTMechPostCombatReports(
else if (damage_tally != 0.0f && shooter_player != 0)
{
//
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
// uninitialized stack float; see the handler's type-0 arm note).
// Block B: the inflicted report -- "+1 each damage point scored on
// opponent's armor" (original manual scoring chart). Goes out under
// Player::ScoreMessageID with type 0, exactly as the binary does; the
// Dispatch override (@004bffa0) intercepts it into
// ScoreInflictedMessageHandler. It is NOT wire-fidelity-only -- the
// old note here claimed the handler banks 0 because @004c0200 was
// "dead code", which was a misreading of the vtable; corrected
// 2026-08-07 and the interceptor restored.
//
BTPlayer::ScoreMessage inflicted(
Player::ScoreMessageID,
+41 -8
View File
@@ -84,16 +84,24 @@ class DropZone__ReplyMessage;
public:
//
// Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
// and @004c0200, the only function that accepts it, appears in no
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
// scoring = kills + received-damage penalties; per-hit inflicted
// credit never existed.
// @004c02e4 (this->scoreType, message+0x20).
//
// CORRECTED 2026-08-07. This note used to read "type 0 has NO scoring
// arm in the binary ... @004c0200 appears in no handler-table entry ...
// per-hit inflicted credit never existed", and build 787 deleted the
// credit on that basis. The byte-scan was right that no TABLE entry
// binds @004c0200 and wrong to conclude it is unreachable: BTPlayer
// overrides Dispatch (vtable @00513300 slot 3 = FUN_004bffa0), which
// splits type 0 off BEFORE base dispatch and calls it directly. That
// is also WHY @004c02e4 Verify-rejects type 0 -- the interceptor
// guarantees it never arrives there. @004c0200 names itself
// "BTPlayer::ScoreInflictedMessageHandler" in its own Verify string,
// and the original manual's scoring chart independently confirms what
// it computes: "+1 each damage point scored on opponent's armor",
// "-1 each self-inflicted point".
//
enum ScoreType {
DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above)
DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed
};
@@ -288,6 +296,23 @@ class DropZone__ReplyMessage;
private:
static const HandlerEntry MessageHandlerEntries[];
public:
//
// @004bffa0 -- the DISPATCH OVERRIDE (vtable @00513300 slot 3). The
// binary uses ONE message id (0x16) for all three score reports and
// splits type 0 off HERE, before base dispatch:
//
// if (msg->id == 0x16 && msg->type == 0) ScoreInflicted(msg);
// else base dispatch;
//
// which is why ScoreMessageHandler's own type-0 arm can Verify-reject:
// the interceptor guarantees type 0 never reaches it. Without this
// override every inflicted report lands in the rejecting arm and scores
// nothing -- see the note in btplayer.cpp.
//
virtual void
Dispatch(Receiver::Message *what); // @004bffa0
protected:
static MessageHandlerSet& GetMessageHandlers();
@@ -302,6 +327,14 @@ class DropZone__ReplyMessage;
void
ScoreMessageHandler(ScoreMessage *message); // @004c02e4
//
// @004bfbe8 -- BT's MissionStarting override. Seeds the starting score
// ("+1000 Starting the game", original manual scoring chart). The base
// Player handler does the fade-in; BT adds the grant.
//
void
MissionStartingMessageHandler(Entity::Message *message); // @004bfbe8
//
// @004bffd0 -- the spawn / respawn handshake. When the drop zone
// replies with our spawn location we create (or reset) the player's
+134
View File
@@ -947,6 +947,26 @@ void
if (other != 0 && coolantAvailable != 0)
{
Scalar flow = ComputeHeatFlow(other, time_slice); // FUN_004ad9ec
// #137 FLOW TRAP (BT_HEAT_LOG): the myomers gains ~2e9 of energy within
// 30 frames of a respawn while every partner reads T=77 -- and the only
// writers into its pendingHeat are its (dead-at-spawn) integrator and
// THIS line. Any e6-scale single flow is the bug caught in the act;
// print the complete operand set so the arithmetic can be re-run by
// hand instead of guessed at.
if (getenv("BT_HEAT_LOG") != 0 && (flow > 1.0e6f || flow < -1.0e6f))
{
DEBUG_STREAM << "[heatflow] " << (GetName() ? GetName() : "?")
<< " -> " << (other->GetName() ? other->GetName() : "?")
<< " flow=" << flow << " dt=" << time_slice
<< " | this: T=" << currentTemperature << " E=" << heatEnergy
<< " pend=" << pendingHeat << " m=" << thermalMass
<< " mScale=" << massScale << " k=" << thermalConductance
<< " lvl=" << coolantLevel << " cap=" << thermalCapacity
<< " fScale=" << coolantFlowScale
<< " | other: T=" << other->currentTemperature
<< " E=" << other->heatEnergy << " pend=" << other->pendingHeat
<< " m=" << other->thermalMass << "\n" << std::flush;
}
other->pendingHeat += flow;
pendingHeat -= flow;
BalanceCoolant(time_slice); // FUN_004ada94
@@ -1446,3 +1466,117 @@ int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
*out = bank->CoolantFractionOf();
return 1;
}
//===========================================================================//
// BTReportHeatAtReset -- #137 forensic (ungated when BT_HEAT_LOG is set).
//
// The [heat-t] census runs on a 5-second per-instance timer, which is far too
// coarse to answer the question #137 actually poses: "respawn came back with
// MYOMERS heat MAXED". Is the temperature high BECAUSE the reset did not
// clear it, or because it climbs again within the first second? Those need a
// sample taken AT the reset, which is what this is. Called from Mech::Reset
// immediately after the subsystem sweep, so every heat-bearing subsystem
// reports the temperature the reset actually left it at.
//===========================================================================//
void BTReportHeatAtReset(void *mech_v)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
const int count = mech->GetSubsystemCount();
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// UNFILTERED first: the earlier pass filtered on IsDerivedFrom(HeatSink)
// and reported no Condensers. That test rides a hand-built Derivation
// chain, so a false negative there is indistinguishable from "not in the
// roster" -- name every roster entry and say whether the test passed.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
{
DEBUG_STREAM << "[heat-reset] roster[" << i << "] "
<< (s->GetName() ? s->GetName() : "?")
<< " (not HeatSink-derived)" << "\n" << std::flush;
continue;
}
HeatableSubsystem *sink = (HeatableSubsystem *)s;
DEBUG_STREAM << "[heat-reset] " << (s->GetName() ? s->GetName() : "?")
<< " T=" << (float)sink->currentTemperature
<< "\n" << std::flush;
}
}
//===========================================================================//
// BTReportMyomerFreeze -- #137 forensic (BT_HEAT_LOG), the POST-reset trace.
//
// BTReportHeatAtReset samples AT the reset and showed every subsystem at
// T == startingTemperature, which is what made the reset path look innocent and
// got #137 wrongly closed. The field then proved the freeze is real (5 of 61
// respawns: throttle up, speedDemand pinned at 0) -- so the interesting window
// is the frames immediately AFTER the reset, which nothing was sampling.
//
// Three explanations survive the decomp read and only data separates them:
// (a) RESET DIDN'T TAKE -> temp is high right after the reset
// (b) STALE CACHE -> temp is at start but speedEffect is still 0
// (HeatSink::RTIS @004ad760 writes only bytes
// 0x114/0x12C/0x130/0x134/0x138/0x158/0x15C --
// it does NOT touch Myomers::speedEffect @0x31C,
// and Myomers::RTIS @004b8aa4 only chains to the
// PoweredSubsystem one, so the pre-death value
// survives until the myomers next ticks)
// (c) INSTANT RE-HEAT -> temp starts at start and climbs back at once
//
// Prints per myomers: temperature, its own speedEffect, and the mech-level MAX
// the mover actually multiplies by. Freeze == that MAX at 0.
//===========================================================================//
void BTReportMyomerFreeze(void *mech_v, const char *when)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
extern Scalar BTMyomersSpeedEffectOf(void *subsystem);
const int count = mech->GetSubsystemCount();
Scalar best = -1.0f;
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// ROSTER-WIDE now (2026-08-09): the myomers-only version proved the
// myomers is being COOKED FROM OUTSIDE -- +2.3e9 of energy arrives in
// <=30 frames while its own integrator reads near-zero v (our Reset
// zeroes localVelocity, matching the binary's +0x1c4 zero-fill, so
// termKinetic/termAccel are dead at spawn). ConductHeat flow is
// bounded by deltaT, so an e9 slug through it demands a NEIGHBOUR at
// extreme temperature. Print EVERY heat-bearing subsystem's T plus
// the two cells T is actually derived from (heatEnergy @0x158,
// pendingHeat @0x1C8) to NAME that neighbour.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
continue;
HeatableSubsystem *h = (HeatableSubsystem *)s;
// Every Heatable-positive roster member in this game IS a HeatSink
// (the Watcher branch -- Torso/HUD/Gyro -- rides HeatWatcher, which is
// not HeatableSubsystem-derived), so the downcast for E/pend is safe.
// Deliberately NOT IsDerivedFrom(HeatSink): that hand-built chain
// returns false for Condenser (the night-13 trap).
HeatSink *hs = (HeatSink *)s;
Scalar f = BTMyomersSpeedEffectOf(s); // >= 0 only for a Myomers
if (f > best) best = f;
DEBUG_STREAM << "[myofreeze] " << when << " " << (s->GetName() ? s->GetName() : "?")
<< " T=" << h->currentTemperature
<< " E=" << hs->heatEnergy
<< " pend=" << hs->pendingHeat
<< " fail=" << h->failureTemperature;
if (f >= 0.0f)
{
DEBUG_STREAM << " speedEffect=" << f
<< ((f <= 1.0e-4f && h->currentTemperature < h->failureTemperature)
? " <<<< STALE (cold but zero)" : "");
}
DEBUG_STREAM << "\n" << std::flush;
}
if (best >= 0.0f)
DEBUG_STREAM << "[myofreeze] " << when << " CHAIN MAX=" << best
<< (best <= 1.0e-4f ? " <<<< FROZEN" : "") << "\n" << std::flush;
}
+57 -10
View File
@@ -52,15 +52,41 @@ struct HUDLayoutCheck
};
//
// Tuning constants observed as read-only float globals adjacent to the
// HudSimulation body (.rdata, recovered from section_dump.txt).
// Tuning constants read as read-only float globals adjacent to the
// HudSimulation body. The VALUES BELOW ARE THE BINARY'S, read straight off
// the .rdata rows in reference/decomp/section_dump.txt [T1]:
// 4b7ec0 8be55dc3 0000403f 0000803f 0000c842
// 4b7ed0 00000000
// -> ec4 = 0.75f ec8 = 1.0f ecc = 100.0f ed0 = 0.0f
//
// CORRECTED 2026-08-08. Every entry here used to be a 0.0f/500.0f STAND-IN
// under a guessed name, and the names described the wrong mechanism: there is
// no "heat threshold for HUD page visibility" at 0x4b7ec4 -- ec4/ec8 are the
// fire-control LOCK damage limits, and "MaxTorsoSlew = 500.0f" at ed0 read the
// range-slide Abs() idiom backwards (ed0 is the ZERO; 500.0 is an immediate).
// The LIVE implementation of the lock rule and the range slide is mech4.cpp's
// targeting step, which had both thresholds right all along. These stay so
// the addresses resolve to the truth for the next reader.
//
static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c
static const Scalar SegmentTempLimit = 0.0f; // _DAT_004b7ec4 (heat threshold for HUD page visibility)
static const Scalar TargetTempLimit = 0.0f; // _DAT_004b7ec8
static const Scalar RangeBias = 0.0f; // _DAT_004b7ecc
static const Scalar MaxTorsoSlew = 500.0f; // _DAT_004b7ed0
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
// LOCK gate (a): your OWN HUD's host zone must be BELOW 75% damage, else
// fire-control lock is lost -- the `_DAT_004b7ec4 <= ownZone->damageLevel`
// arm of HudSimulation. A shot-up cockpit drops to "target held, no lock".
static const Scalar LockOwnZoneDamageLimit = 0.75f; // _DAT_004b7ec4
// LOCK gate (b): the TARGETED zone must be below 1.0 damage (a whole-mech
// target checks zone 0), so a dead zone cannot be re-locked.
static const Scalar LockTargetZoneDamageLimit = 1.0f; // _DAT_004b7ec8
// Subtracted from RangeToTarget (@0x1EC) every frame while the timed flag
// @0x22C is set (timer @0x21C accumulates to @0x1D8, then both clear).
// NOT reconstructed in the port's targeting step -- tracked, not implemented.
static const Scalar RangeBias = 100.0f; // _DAT_004b7ecc
// The shared ZERO: the right-hand side of the Abs() idiom on the range slide
// (`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 literal (0x43fa0000), never this.
static const Scalar HudZero = 0.0f; // _DAT_004b7ed0
// The decay FLOOR for horizontalTorsoOffset (@0x294) in FUN_004b7ed4. The
// decay RATE is the object's own @0x298, not a constant. (Value verified.)
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
//
// Cross-family helper (definition lives in the mech game layer; declared here
@@ -322,9 +348,13 @@ Logical
// (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no
// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676).
// 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the
// commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to
// +/- horizontalLimit (@0x29C), then written to the graphic at
// mech +0x36C. The flicker helper (@004b7ed4) damps the settle.
// commanded torso heading, clamped to +/- horizontalLimit (@0x29C), then
// written to the graphic at mech +0x36C. The flicker helper (@004b7ed4)
// damps the settle: it decays @0x294 toward ZERO at the object's own
// @0x298 (horizontalMovementPerSecond) x time_slice. (CORRECTED
// 2026-08-08: this used to read "at up to MaxTorsoSlew (500/sec)" -- the
// rate is that per-object field, and there is no 500 constant here. The
// 500 m/s belongs to the RANGE slide in step 5, as an immediate.)
//
void
HUD::HudSimulation(Scalar time_slice)
@@ -475,3 +505,20 @@ void
{
ResetToInitialState(reset_command != 0); // @004b77bc
}
//
// BTSetHudFlickerActive -- complete-type bridge for the CONTROL-MODE switch
// (mechmppr.cpp treats the mech's subsystems as opaque pointers, so it cannot
// touch HUD members directly; same pattern as torso.cpp's BTGetTorsoTwistAddr).
//
// @004afbe0's BASIC arm ends in `*(mech+0x5b4 + 0x2a0) = 1` -- mech+0x5b4 is the
// HUD subsystem cache and +0x2A0 is flickerActive. Basic mode re-centres the
// torso, so the HUD horizon is kicked into its settle animation to follow it
// (UpdateFlicker @004b7ed4 decays horizontalTorsoOffset and reports whether it
// is still moving). The port never made this call.
//
void BTSetHudFlickerActive(Subsystem *hud)
{
if (hud != 0)
((HUD *)hud)->SetFlickerActive(1);
}
+6
View File
@@ -196,6 +196,12 @@
// Simulation Support
//
public:
// @0x2A0 -- raised by the CONTROL-MODE switch's BASIC arm
// (`*(mech+0x5b4 + 0x2a0) = 1`, @004afbe0) so the HUD horizon re-settles
// with the torso that Basic just re-centred. Reached from mechmppr via
// hud.cpp's BTSetHudFlickerActive bridge (that TU sees Subsystem*, not HUD).
void SetFlickerActive(int on) { Check(this); flickerActive = on; }
typedef void
(HUD::*Performance)(Scalar time_slice);
+45 -29
View File
@@ -82,7 +82,6 @@
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
// for the same reason.
#include "dmgtable.hpp"
#include <GAUGREND.hpp> // GaugeRenderer::SpecialEffect (virtual) -- PPC scramble trigger
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
@@ -511,8 +510,25 @@ void
{
return;
}
duckState = 1;
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
// #142: duckState is the POSTURE the cockpit's crouch-symbol animation
// reads -- L4GAUGE.CFG:5001 binds attribute 0x37 to a THREE-frame
// bduck.pcc strip, confirmed on screen as a duck animation:
// 0 = standing 1 = moving between 2 = crouched
//
// A bare 1 here is therefore exactly right, and is what the binary writes:
// it means "in transition", which is both the request AND the middle frame.
// The consumer (mech4.cpp) reads the parked leg alarm to decide DIRECTION
// -- parked means the pending move is a rise, not parked means a squat --
// and settles duckState to 0 or 2 when the clip finishes. No separate
// request cell, no toggle, no divergence from @0049fa00.
//
// (An earlier revision toggled 0<->1 here. That produced a two-pose snap,
// which is what the cockpit reported as "it lights up and sticks, no
// animation": frame 2 was never reachable.)
duckState = 1; // show the MIDDLE frame at once (the binary's write)
duckRequest = 1; // and remember that a move is pending (#142)
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1 (in transition)"
<< std::endl << std::flush;
}
//
@@ -1071,7 +1087,31 @@ void
// Capturing it after the divert (where #89 first placed it) meant a
// COLLISION death could never arm the tail.
//
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the
// graphic alarm. FIXED 2026-08-07 -- this substitution was the eject-ghost.
//
// The binary tests movementMode 9|10 here; the port used IsMechDestroyed()
// (graphicAlarm >= 9) and justified it with "the death transition sets mode
// 9 synchronously with the structural flag on every path through here, so
// the edges coincide". That is true of every DAMAGE path and 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 whole death tail was
// skipped -- no VehicleDead, which IS the respawn trigger.
//
// Consequences, all three reported from the field on the same night:
// * the ejecting player never respawns ("panic button, didn't respawn");
// * the peer wrecks the mech and never un-wrecks it, because the un-wreck
// rides the master's respawn -> the permanent EJECT GHOST (#108);
// * the eject scores only its -500 self-damage: no negated kill award and
// no -500 death cost, because both live in the tail that never ran --
// which is why the manual chart's "-1000 ejecting" never materialised.
//
// MovementMode 9|10 is untouched by the eject's alarm write, so the latch
// now arms on the eject exactly as it does on a combat death.
const int deathBlastArmed =
!(MovementMode() == 9 || MovementMode() == 10); // [ebp-0x10], inverted (@0x4a0303)
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
@@ -1123,31 +1163,6 @@ void
}
}
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
//
// @0x4a03f3 [T1] -- PPC/ERPPC SECONDARY-DISPLAY SCRAMBLE. EnergyDamageType
// (==4) is authored on exactly the 14 PPC/ERPPC subsystem records and nothing
// else, so this branch is structurally PPC-exclusive -- NO explicit weapon
// class check (the data authorship IS the gate). It sits OUTSIDE the burst
// loop below, so it fires ONCE per damage message, not per burst. The
// duration is DERIVED from the type ordinal, not a literal: (Scalar)4 * 0.2 ==
// 0.8s (binary loads long double 0.2 @0x4a0c08, fmulp). SpecialEffect ->
// L4GaugeRenderer scrambles every SECONDARY cockpit display (SVGA16 sync
// detune, FunkyVideo) for that window; the main 3D view is on an independent
// timing chain and is left clean. Spec: phases/phase-14-ppc-sync-distortion.md.
//
if (message->damageData.damageType == Damage::EnergyDamageType)
{
GaugeRenderer *gauges =
(application != 0) ? application->GetGaugeRenderer() : 0; // APP.h:355 -- named, not application+0x4c
if (gauges != 0) // the binary null-guards the renderer too (@0x4a0405)
gauges->SpecialEffect(GaugeRenderer::scrambleVideo,
(Scalar)message->damageData.damageType * 0.2f);
if (BTEnvOn("BT_DMG_LOG", 0))
DEBUG_STREAM << "[ppc-scramble] EnergyDamageType hit -> scrambleVideo for "
<< ((Scalar)message->damageData.damageType * 0.2f) << "s\n" << std::flush;
}
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
@@ -1570,6 +1585,7 @@ Mech::Mech(
radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position...
radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin)
duckState = 0; // not crouching
duckRequest = 0; // no pending duck request (#142)
// (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX
// (part_012.c:9446-9447; FLT_MAX = "no missile" far default)
incomingLock = 0;
+9 -1
View File
@@ -964,7 +964,15 @@ protected:
Scalar radarRange; // 0x2f RadarRange (scale/max)
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
int duckState; // 0x37 DuckState (crouch posture)
int duckState; // 0x37 DuckState (crouch POSTURE the cockpit
// strip draws: 0 stand, 1 moving, 2 crouched)
// PORT-ONLY (#142): the pending duck REQUEST, kept separate from the
// posture above. duckState cannot carry both -- settling it to the
// real posture destroys the request, and the consumer then re-issues
// the opposite direction on the very frame the clip parks (benched:
// 69 squat/rise transitions from 2 presses). Not a binary field; it
// is appended, never read by offset.
int duckRequest;
// (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary
// Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match
// ==1 Start / ==0 Stop of the looped beeper), DistanceToMissile id 56
+51 -1
View File
@@ -1246,6 +1246,10 @@ Scalar
Mech::AdvanceBodyAnimation(Scalar time_slice, int loop)
{
Scalar distance = 0.0f;
// #52 probe (BT_BODY_SM_LOG): case 0 and the inserted turn block below run
// in the SAME invocation, so a plain local proves the arm->reset pair --
// no cross-frame state, no per-mech bookkeeping.
int armedFromStanding = 0;
// In the binary `bodyAnimationState`@0x728 IS `bodyStateAlarm`'s level (one field);
// the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)`
@@ -1297,7 +1301,35 @@ Scalar
}
SetBodyAnimation(0x10);
}
// FALLTHROUGH
// FALLTHROUGH -- into the ADVANCE GROUP, which is where the binary
// sends it. #52 SKATE ROOT CAUSE (2026-08-07): in FUN_004a5678 case 4
// is a MEMBER of the advance list (case 2,3,*4*,5,8,...), so a state
// just armed away from Standing lands on Advance(). The port's turn
// block below is an INSERTION (task #64 lockstep twin) and, sitting
// between case 0 and the advance group, it intercepted that fallthrough.
// On a REPLICANT that is fatal and not a race: case 0 arms walk iff
// `standSpeed < bodyTargetSpeed`, and the inserted block's exit tests
// `standSpeed < bspd` where bspd IS bodyTargetSpeed for a replicant --
// the SAME expression. Arm and reset therefore fire on the same frame,
// every frame, and a peer parked at Standing with a live replicated
// demand can never start cycling (reverse likewise: both sides test
// `< ZeroSpeed`). It cycles again only when a record sets the state
// directly (ReadUpdateRecord, mech.cpp) -- the observed self-recovery.
// Masters escape because their two tests read DIFFERENT cells
// (bodyTargetSpeed = last-sent vs the live mapper speedDemand) and
// because the body channel is mj=0 there, so its stall is invisible.
// Introduced by e91d447 (#82): before it the replicant branch read the
// dead mapper cell (0 forever), so the exit never fired and this
// fallthrough worked BY ACCIDENT. Fixing the dead cell closed the
// accidental escape hatch and the trn-lock skate came back as a
// Standing-lock skate. BT_NO_BODY_FALLTHRU=1 restores the old path.
armedFromStanding = (int)bodyStateAlarm.GetLevel();
{
static const int s_bodyFallthru = getenv("BT_NO_BODY_FALLTHRU") ? 0 : 1;
if (s_bodyFallthru)
goto advance_body_normally;
}
// FALLTHROUGH (legacy path only)
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
// The body channel runs trn in LOCKSTEP with the leg: armed together at
@@ -1324,6 +1356,23 @@ Scalar
: (bm != 0) ? bm->speedDemand : 0.0f;
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
{
// #52 probe: when this fires on a state case 0 JUST armed, the
// mech is being pushed straight back to Standing on the same
// frame it tried to leave it -- the Standing-lock. On a
// replicant `bspd` IS the same cell case 0 tested, so the pair
// is unconditional, not a race.
if (armedFromStanding != 0 && getenv("BT_BODY_SM_LOG"))
{
static float s_bsm = 0.0f; s_bsm += time_slice;
if (s_bsm >= 1.0f) { s_bsm = 0.0f;
DEBUG_STREAM << "[bodySM] " << (GetInstance() == ReplicantInstance
? "REPLICANT " : "master ")
<< GetEntityID() << " case0 armed " << armedFromStanding
<< " -> turn-block RESET to Standing bspd=" << (float)bspd
<< " bts=" << (float)bodyTargetSpeed
<< " standSpeed=" << (float)standSpeed
<< " (STANDING-LOCK)\n" << std::flush; }
}
bodyStateAlarm.SetLevel(0);
ForceUpdate(8);
distance = 0.0f;
@@ -1347,6 +1396,7 @@ Scalar
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
advance_body_normally: // case 0's fallthrough target (leg twin: advance_normally)
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
time_slice * globalTimeScale * idleStrideScale, loop);
bodyCycleSpeed = distance / time_slice; // this+0x6b8
+505 -24
View File
@@ -666,6 +666,7 @@ static int gBTPPCKey = 0;
static int gBTMissileKey = 0;
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45)
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
int gBTMyoTrace = 0; // #137: frames of post-reset myomer tracing left (armed by Mech::Reset)
int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes)
int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes)
//
@@ -790,8 +791,26 @@ void
EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index)
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
// #141 -- THE BINARY GOES THROUGH FUN_00424da8, AND SO MUST WE.
// @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()
// ...walk owner+0x300 setting 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. This port hand-composed GetSegmentToEntity() x localToWorld
// and skipped it -- and GetSegmentToEntity only recomputes when
// segmentModified is already set (SEGMENT.cpp:262), so it returned a
// stale cache. On the MASTER that was invisible (the render pass
// refreshes the local mech every frame); on a REPLICANT nothing did, so
// peer muzzles sat at the BIND POSE -- the missile launched along the
// leg facing (#141). Use the engine accessor; do NOT force the dirty
// flag, the binary does not.
LinearMatrix mw;
m->GetSegmentToWorld(*seg, &mw);
out = mw; // Point3D = matrix W_Axis translation
}
else
@@ -1044,8 +1063,13 @@ int
{
if (seg->GetIndex() == seg_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
// #141 sweep: go through the engine accessor, which is the
// binary's FUN_00424da8 (the joints->segments refresh). A hand
// composed GetSegmentToEntity() x localToWorld reads a STALE
// cache on any mech whose segments were not refreshed this frame
// -- i.e. every REPLICANT. See BTResolveWeaponMuzzle.
LinearMatrix mw;
m->GetSegmentToWorld(*seg, &mw);
p = mw; // Point3D = matrix translation
break;
}
@@ -1493,8 +1517,57 @@ void
EntitySegment *seg = sm->GetSegment(muzzle_seg);
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld);
// #141 -- USE THE ENGINE ACCESSOR, not a hand-rolled
// GetSegmentToEntity() x localToWorld.
//
// EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262)
// recomputes ONLY when `segmentModified` is set; otherwise it
// returns the CACHED matrix. The thing that sets that flag
// after a joint moves is JointedMover::GetSegmentToWorld
// (JMOVER.cpp:136-146): it tests AreJointsModified() and, when
// set, marks EVERY segment dirty and clears the flag. Compose
// the matrix by hand and you skip that entirely -- you read
// whatever cache happens to be sitting there.
//
// On the MASTER that was invisible: the renderer / cockpit
// camera call GetSegmentToWorld for the LOCAL mech every frame,
// so the cache was already fresh when we fired. A REPLICANT
// gets no such refresh, so its cache stayed at the BIND POSE
// and the torso twist never reached the launch frame.
//
// Measured (scratchpad/night13/missileframe.sh, 165 salvos
// mirrored 1:1): master |twistDelta| max 2.2962 / mean 1.2283,
// 100% > 0.1 rad -- REPLICANT max 0.0000, mean 0.0000, 0%,
// with segResolved=1 and segYaw == bodyYaw EXACTLY, while that
// same peer's copy torso was demonstrably writing its joint
// (`PushTwist COPY ... twist=-1.49601`) off correctly
// replicated records (`cur=-2.13987 target=-2.13987 copy=1`).
// Twist arrived, joint moved, segment cache never refreshed.
// That is #141: "missiles launch along the LEG/FOOT facing,
// then curve to the target -- peer POV only".
// FORCE the recompute. GetSegmentToWorld only refreshes when
// AreJointsModified() is set, and by fire time the frame's
// renderer/camera pass has already consumed and cleared that
// flag on BOTH nodes (measured: jointsDirty=0 master AND peer).
// On the master the cache it left behind is correct, because
// that pass ran AFTER the local torso pushed its joint. On a
// replicant the cache is stale, so seg 18 returned its
// bind-pose matrix (segYaw == bodyYaw EXACTLY) even though the
// hierarchy is identical -- same parentIdx 4, same non-null
// parent + joint subsystem. Setting the flag makes
// GetSegmentToWorld mark every segment dirty so the whole
// chain re-derives from the CURRENT joint angles. Costs one
// segment-table walk per salvo.
// NO forced dirty flag here. An earlier pass set
// ModifyJoints(True) before this read; it bought 64% of
// salvos but it is NOT what the binary does -- @00424da8
// tests AreJointsModified() and never sets it. The authentic
// refresh happens in the MUZZLE query (GetMuzzlePoint ->
// @00424da8), which the launcher calls just above this, so by
// the time we compose the launch frame the segment cache is
// already current. See BTResolveWeaponMuzzle.
LinearMatrix mw;
sm->GetSegmentToWorld(*seg, &mw);
mw.GetFromAxis(X_Axis, &ax);
mw.GetFromAxis(Y_Axis, &ay);
mw.GetFromAxis(Z_Axis, &az);
@@ -1507,6 +1580,55 @@ void
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
sm->localToWorld.GetFromAxis(Z_Axis, &az);
}
// #141 DIAGNOSTIC (BT_PROJ_LOG). The peer-POV report is that the
// round leaves along the LEG facing, ignoring torso twist, while the
// shooter's own view is correct. Both nodes pass GetSegmentIndex()
// as the mount frame, so if this is real the difference is whether
// the SEGMENT actually carries the twist on a replicant. Print the
// frame we launched through on BOTH sides: twistDelta is the yaw of
// the launch forward vs the BODY forward, so it should equal the
// torso twist on the master and MUST match on the replicant. A
// replicant reading ~0 while the master reads non-zero IS the bug.
if (getenv("BT_PROJ_LOG"))
{
UnitVector bz;
sm->localToWorld.GetFromAxis(Z_Axis, &bz);
const float kPi = 3.14159265f;
float segYaw = atan2f(-(float)az.x, -(float)az.z);
float bodyYaw = atan2f(-(float)bz.x, -(float)bz.z);
float dYaw = segYaw - bodyYaw;
while (dYaw > kPi) dYaw -= 2.0f * kPi;
while (dYaw < -kPi) dYaw += 2.0f * kPi;
// #141 probe 2: GetSegmentToEntity only RECOMPUTES when
// (segmentModified && parentSegment). A null parent means it can
// never recompute -- it returns the bind-pose baseOffset forever,
// which would read as segYaw == bodyYaw exactly. Print the
// hierarchy + joint-dirty state so master and peer can be diffed.
EntitySegment *pseg = (muzzle_seg >= 0) ? sm->GetSegment(muzzle_seg) : 0;
const void *parent = (pseg != 0) ? (const void *)pseg->GetParent() : 0;
int parentIdx = (pseg != 0) ? pseg->GetParentIndex() : -99;
JointSubsystem *js = sm->GetJointSubsystem();
// #148: the SHOOTER's live torso twist AT THIS INSTANT. The
// [torso-copy] probe samples every 120th call, so its "first
// non-zero" tells you when it first SAMPLED, not when the twist
// started -- that is exactly the artifact that made the earlier
// "the peer had no twist to carry" reading look right. Read the
// cell directly instead, so twistDelta and the twist that should
// be driving it are on the SAME line.
extern Scalar *BTGetTorsoTwistAddr(Subsystem *torso);
Scalar *twp = BTGetTorsoTwistAddr(sm->GetTorsoSubsystem());
DEBUG_STREAM << "[launchframe] "
<< (sm->GetInstance() == Entity::ReplicantInstance
? "REPLICANT" : "master ")
<< " liveTwist=" << (twp != 0 ? (float)*twp : -99.0f)
<< " seg=" << muzzle_seg << " segResolved=" << haveFrame
<< " segYaw=" << segYaw << " bodyYaw=" << bodyYaw
<< " twistDelta=" << dYaw
<< " parent=" << parent << " parentIdx=" << parentIdx
<< " joints=" << (void *)js
<< " jointsDirty=" << (js != 0 ? (int)js->AreJointsModified() : -1)
<< "\n" << std::flush;
}
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z;
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z;
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z;
@@ -2038,8 +2160,11 @@ void
{
if (seg->GetIndex() == segment_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
// #141 sweep: engine accessor (== the binary's FUN_00424da8),
// not a hand-composed product -- otherwise a peer's damage
// effect anchors to the BIND-POSE segment.
LinearMatrix mw;
mech->GetSegmentToWorld(*seg, &mw);
fxPos = mw; // Point3D = matrix translation
break;
}
@@ -2146,6 +2271,39 @@ void
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
// Binary zero-fills restored (Reset @0049fb74 zero-fills FOUR Motion cells:
// +0x1c4/+0x1dc/+0x298/+0x2c8; +0x1dc = localAcceleration by the engine
// Mover layout). NOTE: zeroing these did NOT fix #137 on its own -- the
// acceleration snapshot is REBUILT from a position finite-difference one
// frame later (see accelPrevPos below), so the stale-carryover story first
// written here was wrong. The zero-fills stay because the binary does them.
localAcceleration = Motion::Identity; // binary +0x1dc zero-fill
worldLinearAcceleration = Vector3D(0.0f, 0.0f, 0.0f); // world-space mirror
// #137 ROOT CAUSE -- the DROPPED RE-SEED. The binary Reset's SECOND
// instruction is FUN_00408440(mech+0x58c, param_2): re-seed the Point3D at
// +0x58c to the NEW ORIGIN. +0x58c is the previous-position memory of the
// AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c -- the F19
// block below, port member accelPrevPos). This port reconstructed the
// ring (ctor part_012.c:9836-9840, derivative :15169-15195) but its Reset
// never got the +0x58c line -- so the first post-respawn sample computed
// |newPos - accelPrevPos| / dt = TELEPORT DISTANCE / dt ~ 1e5
// into the velocity ring, and the ring-mean derivative turned that into an
// AccelerationLastFrame spike of 3e4..2.4e5 (pure forward/-z; with a +z
// ECHO ~15 frames later as the garbage sample rotates out of the mean).
// The myomer drive-heat integrator (@004b8d18) then computed
// termAccel = (1-accEff) * |v| * |a| * mass * dt
// with |a|~1e5 while the gait re-published |v|~40 under a still-held
// throttle: ONE tick deposited ~2.7e9 into pendingHeat -> heatEnergy,
// snapping the freshly-reset myomers from T=77 to T~9000 (failT=2000) ->
// speedEffect 0 -> speedDemand *= 0 -> "respawned unable to move until it
// cools" (#137). Measured: top deposits 3.35e9/3.29e9/3.05e9, every one
// 3-4 log lines after a Mech::Reset, aXYZ pure z, master mech.
// Why ~8% in the field: the deposit needs |v| in the SAME 1-2 frames, so
// only pilots whose throttle is still forward at the respawn (physical
// lever / HOTAS -- exactly who reported it) get the freeze; idle-throttle
// respawns read v~0 and deposit nothing.
accelPrevPos = origin.linearPosition; // binary +0x58c re-seed
ramLastVictim = 0;
ramContactLinger = 0.0f;
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
@@ -2172,6 +2330,26 @@ void
poseSyncLatch = 0; // @0x77c
graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger)
// --- STAND THE MECH UP. Restored 2026-08-07 (#142, Oracle: "crouch wasn't
// resetting on respawn"). These are in the binary's own Reset sweep and the
// port had dropped all of them, so a pilot who died CROUCHED respawned
// crouched -- leg parked in 'sqd', the cockpit strip still showing the
// up-arrow "press to rise" frame. Verbatim from FUN_0049fb74:
// *(this+0x398) = 0 duckState -- not crouching
// FUN_0041bbd8(this+0x39c, 0) legStateAlarm -> 0 (standing)
// FUN_0041bbd8(this+0x714, 0) bodyStateAlarm -> 0 (standing)
// *(this+0x650/0x654/0x658) = 0 death + leg/body reset latches
// *(this+0x5ac) = 1.0f idleStrideScale
duckState = 0; // @0x398
duckRequest = 0; // port-only pending flag (#142)
legStateAlarm.SetLevel(0); // @0x39c -- stand
bodyStateAlarm.SetLevel(0); // @0x714 -- stand
stabilityAlarm.SetLevel(1); // risen (the rise path's value)
deathAnimationLatched = 0; // @0x650
legResetLatch = 0; // @0x654
bodyResetLatch = 0; // @0x658
idleStrideScale = 1.0f; // @0x5ac = 0x3f800000
// --- HEAL every damage zone: full structure, intact skin, no burning ---
for (int z = 0; z < damageZoneCount; ++z)
{
@@ -2229,6 +2407,62 @@ void
BTRecomputeCondenserValves((Entity *)this);
}
// #137 forensic: sample every heat-bearing subsystem's temperature AT the
// reset, so "respawn came back with heat MAXED" can be split into "the
// reset did not clear it" vs "it climbs again immediately".
{
extern void BTReportHeatAtReset(void *mech_v);
BTReportHeatAtReset((void *)this);
// #137: sample the myomers AT the reset, and arm the POST-reset trace.
// Sampling only here is what got this bug wrongly closed -- every
// subsystem reads T == start at this instant, which looks innocent.
// The freeze shows up in the frames AFTER.
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
extern int gBTMyoTrace;
BTReportMyomerFreeze((void *)this, "at-reset");
gBTMyoTrace = 240; // ~4 s of post-reset frames
}
// --- DESKTOP THROTTLE RELEASE (#146) -- PORT LAYER, desktop-only ---------
// The pod's throttle is a PHYSICAL lever and the binary deliberately
// leaves it alone across a respawn: Reset's subsystem loop starts at
// index 2 and the ControlsMapper is index 0, so a pod pilot comes back
// under whatever power their hand is still holding. Authentic; it stays.
//
// The desktop bridge only EMULATES that lever, with the persistent ramp
// accumulator sLever below -- and a pad/keyboard pilot is physically
// holding nothing, with the lever position invisible to them. So they
// respawned at speed for no reason they could see, and the mech earned a
// real heat load straight out of the drop zone (myomers -> Condenser5 ->
// GeneratorD all tripping Overheating inside 1-2s). That is the true
// cause of the "#137 respawn came back with heat MAXED" reports -- the
// reset itself was always clean. Field-diagnosed from Sauron's
// 2026-08-06 log, which reads thr=1 / cycleSpeed=14.6 AT the reset.
//
// Reuse the existing all-stop path instead of touching sLever directly:
// it already zeroes the lever AND clears the zero-crossing detent, and it
// lives inside the virtual-controls block that owns that state.
//
// LOCAL VIEWPOINT MECH ONLY -- gBTDrive is the local bridge's state and
// Reset also runs for replicants, so an ungated write here would all-stop
// the player every time a REMOTE mech respawned. (Same guard idiom as
// the isPlayerMech test in PerformAndWatch.) Pod-safe besides: with a RIO
// present mechmppr's key bridge is off (BTRIODevicePresent, mechmppr.cpp
// :672) and gBTDrive.throttle is never read at all.
// BT_NO_RESPAWN_THROTTLE_RELEASE=1 reverts.
if (application != 0 && (Entity *)this == application->GetViewpointEntity())
{
static const int s_releaseThrottle =
getenv("BT_NO_RESPAWN_THROTTLE_RELEASE") ? 0 : 1;
if (s_releaseThrottle)
{
gBTDrive.allStop = 1;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] desktop throttle released (all-stop queued)\n"
<< std::flush;
}
}
// --- locomotion pre-run + interest gates (a reset master must tick) ---
SetPreRunFlag();
if (interestCount == 0) interestCount = 1;
@@ -2397,6 +2631,14 @@ void
// (wreck shape: alarms/state settle, ammo
// bins do NOT refill the corpse)
}
// #137 forensic: roster heat state right AFTER the death shutdown sweep --
// whatever is still hot here is what the wreck period starts from, and a
// member that stays hot through BOTH sweeps is the conduction source that
// cooks the fresh myomers after the respawn.
{
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
BTReportMyomerFreeze((void *)this, "at-death");
}
// Request the DEATH record BEFORE entering the disabled state (the
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
// death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The
@@ -2512,6 +2754,27 @@ volatile float gBTReplRenderYaw = -999.0f;
void
Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream)
{
// #148 probe: one-shot per mech, the FIRST time this mech's per-frame
// performance runs. Every other receipt in this file is anonymous, so
// master and replicant lines are indistinguishable in a 2-node log -- which
// is exactly what made the "when does the peer torso start ticking?" search
// go in circles. Name the mech.
if (getenv("BT_NET_TRACE"))
{
static const Mech *s_seen[16]; static int s_seenN = 0;
int known = 0;
for (int si = 0; si < s_seenN; ++si) if (s_seen[si] == this) { known = 1; break; }
if (!known && s_seenN < 16)
{
s_seen[s_seenN++] = this;
DEBUG_STREAM << "[perf-first] mech " << GetEntityID()
<< " instance=" << (GetInstance() == Entity::ReplicantInstance
? "REPLICANT" : "master")
<< " this=" << (const void *)this
<< " subsysCount=" << subsystemCount << "\n" << std::flush;
}
}
// Frame time slice from the simulation clock (same idiom as Mover::Perform).
Scalar dt = till - lastPerformance;
lastPerformance = till;
@@ -3311,9 +3574,23 @@ void
sEjAt = (e && *e) ? atoi(e) : -1;
}
++sEjFrame;
// Receipt: five separate rigs failed to fire a punch-out
// and it was never established whether this hook is even
// REACHED. Announce once a second while armed.
if (sEjAt > 0)
{
static int sEjLog = 0;
if ((++sEjLog % 60) == 0)
DEBUG_STREAM << "[ejecttest] armed at " << sEjAt
<< ", frame " << sEjFrame << "\n" << std::flush;
}
if (sEjAt > 0 && sEjFrame >= sEjAt
&& ((sEjFrame - sEjAt) % 300) == 0)
{
ejectPress = 1;
DEBUG_STREAM << "[ejecttest] FIRING punch-out at frame "
<< sEjFrame << "\n" << std::flush;
}
}
if (ejectPress)
{
@@ -4406,9 +4683,60 @@ void
// stability, so peers pose the squat for free) and flip the
// stability alarm (ducked = 0, risen = 1). The request is
// consumed whenever both gates passed, hit or miss.
if (duckState != 0 && squatCapable != 0)
// #142 RESTRUCTURE 2026-08-07 -- duckState is the POSTURE, not a
// one-shot request, because the COCKPIT ANIMATION reads it.
//
// L4GAUGE.CFG:5001 authors
// oneOfSeveralPixInt(E,ModeAlwaysActive,bduck.pcc,3,1,DuckState)
// -- a 3-frame mech-symbol strip beside the CROUCH button, indexed by
// attribute 0x37 (= duckState). Asset (content/GAUGE/BDUCK.PCC),
// element (OneOfSeveralPixInt @004c5204) and factory registration
// (btl4grnd.cpp) are all present. The animation never played because
// the old 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".
//
// The binary does NOT zero it per frame. Every writer of +0x398 in
// the export is: the DuckRequest handler (=1) and Mech::Reset (=0).
// The master perf FUN_004a9b5c -- which contains the address the old
// comment cited as "the DuckRequest consumer (@0x4aa011)" -- does not
// reference 0x398 anywhere, and 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. The per-frame zeroing was ours.
//
// So: drive on DESIRED vs ACTUAL instead of on a latch. duckState is
// the desired posture (the handler now toggles it); the parked leg
// alarm is the actual one. Act only on a mismatch -- no re-fire, and
// nothing clears the attribute behind the gauge's back. A frame
// where mapPosture is not ready RETRIES next frame instead of
// silently dropping the request, which also retires the old
// "request consumed, posture=N" miss.
// THE POSTURE MACHINE (#142). duckState is the cockpit strip's frame:
// 0 = standing 1 = moving between 2 = crouched
// The handler writes 1 (the binary's exact behaviour) meaning "a move is
// pending", which doubles as the middle frame. Direction comes from the
// parked leg alarm, so no separate request cell is needed:
// parked -> the pending move is a RISE
// !parked -> the pending move is a SQUAT
// While the clip runs we hold 1; when it settles we write 0 or 2.
// Read the ALARM, not the cached legAnimationState member: the cache
// is only refreshed at the top of AdvanceLegAnimation, so in the
// frame right after SetLegAnimation it still reads the OLD state.
// With the cached read, "am I already moving?" answered no on the
// frame after issuing, the consumer re-issued, and the machine
// ping-ponged squat/rise -- 68 transitions from 2 presses, benched.
// SetLegAnimation writes the alarm synchronously, so the alarm is
// true the instant the clip is armed.
const int duckLegLvl = (int)legStateAlarm.GetLevel();
const int duckParked = (duckLegLvl == 1);
const int duckMoving = (duckLegLvl == 2 || duckLegLvl == 3); // 'sqd' / 'squ'
if (duckRequest != 0 && !duckMoving && squatCapable != 0)
{
if (mapPosture == 1)
duckRequest = 0; // one shot, whatever happens
if (!duckParked && mapPosture == 1)
{
SetLegAnimation(2); // 'sqd' -- squat down
ForceUpdate(8);
@@ -4417,7 +4745,7 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
DEBUG_STREAM << "[duck] SQUAT (posture 1 -> leg clip 2)\n" << std::flush;
}
else if (mapPosture == 2)
else if (duckParked && mapPosture == 2)
{
SetLegAnimation(3); // 'squ' -- rise
ForceUpdate(8);
@@ -4426,14 +4754,76 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
DEBUG_STREAM << "[duck] RISE (posture 2 -> leg clip 3)\n" << std::flush;
}
else if (getenv("BT_DUCK_LOG"))
DEBUG_STREAM << "[duck] request consumed, posture=" << mapPosture
<< " (mode=" << MovementMode()
<< " legLvl=" << (int)legStateAlarm.GetLevel()
<< " simLive=" << 1 // re-read below costs a bridge call; posture already folded it
<< " myo=" << myomerEffectiveness
<< " squat=" << squatCapable << ")\n" << std::flush;
duckState = 0; // consumed (@0x4aa0a9)
else
{
// Gate refuses -- posture reads 0 for a MOVING mech, which is the
// authentic rule (Lynx: "when a mech STOPS, crouch button lowers
// its stance"; benched: crouch at a walk gives posture=0). Settle
// the strip back to the truth instead of holding the mid frame or
// queueing the request for the next time the pilot stops.
static int s_duckRefuse = 0;
if ((s_duckRefuse++ % 30) == 0)
DEBUG_STREAM << "[duck] REFUSED (not stopped): posture="
<< mapPosture << " mode=" << MovementMode()
<< " myo=" << myomerEffectiveness << "\n" << std::flush;
}
}
// SETTLE, unconditionally, re-reading the alarm AFTER any issue
// above. This is the part that was missing: duckState stayed 1
// forever, so once a clip completed the block above kept firing and
// flipped direction every frame -- 68 transitions from 2 presses.
// The squat/rise clips finish fast enough that the alarm is already
// back to 0/1 by the next visit, so "am I still moving?" has to be
// asked fresh, and the strip settled whenever the answer is no.
{
// SETTLE to the binary's VALUE RANGE: duckState is 0 or 1, never
// 2. Corrected 2026-08-07 after checking FUN_004a9b5c -- the
// mech4 master perf is fully indexed and exported (5645 bytes,
// no dark region inside) and never references +0x398. So the
// binary has NO code reader of duckState at all: the gauge
// attribute is the only consumer, and the only writers are the
// handler (=1) and Mech::Reset (=0). Frame 2 of bduck.pcc was
// unreachable in the original too.
//
// The strip is therefore a TWO-STATE indicator -- standing and
// crouched -- and the "crouch animation" is the MECH's 'sqd'
// clip, not the symbol stepping. An earlier revision here made
// duckState a 3-state posture (0/1/2); that was an invention on
// top of a stand-in and it put the crouched pose on the wrong
// frame.
// #142: report EVERY change of the value the cockpit strip is fed,
// so 'which frames actually got shown, and for how long' stops
// being guesswork. Ungated: one line per posture change.
static int s_lastDuck = -1;
// THE STRIP IS A BUTTON-STATE INDICATOR, not a pose animation.
// Decoded 2026-08-07 by RENDERING THE ASSET itself
// (content/GAUGE/BDUCK.PCC -- PCX, 108x102, three 36x102 frames;
// see scratchpad/night13/bduck_frames.png):
//
// frame 0 GREY mech standing, GREY down-arrow -- UNAVAILABLE
// frame 1 ORANGE standing, YELLOW down -- ready to crouch
// frame 2 ORANGE CROUCHED, YELLOW up -- press to rise
//
// There is NO mid-transition pose. The arrow tells the pilot
// what the next press will do, and the grey frame says the
// button is inert -- which is the visual half of the
// must-be-stopped rule (Lynx: "when a mech STOPS").
//
// Two earlier readings were wrong: a 2-state flag (which
// ignores frame 2's up-arrow entirely) and a stand/moving/
// crouched pose animation (there is no mid pose). Both were
// inferred from code and logs; only the ART settled it, and it
// also explains the field report -- a stopped mech that COULD
// crouch was drawing the grey "unavailable" frame.
const int lvlNow = (int)legStateAlarm.GetLevel();
if (lvlNow != 2 && lvlNow != 3) // hold through the clip
{
if (lvlNow == 1) duckState = 2; // crouched
else if (squatCapable != 0 && mapPosture == 1)
duckState = 1; // ready
else duckState = 0; // inert
}
}
// (3b) AIRBORNE AUTO-RISE -- recovered 2026-08-06 by the #60
@@ -6091,7 +6481,21 @@ void
// clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as
// the boresight crosses near/far ground instead of teleporting.
// (Applies to the no-target 1200 default too.)
static float sShownRange = 1200.0f;
// #147: sShownRange is DISPLAY state, but it is a function-level
// static -- one cell for the whole process, shared by every mech and
// carried across drops. Re-seed it whenever the viewpoint mech
// CHANGES (a new drop hands us a new entity) so a fresh drop starts
// at the binary's 1200 default instead of inheriting the last
// mission's slid value. A respawn REUSES the entity (Mech::Reset
// heals in place), so this deliberately does not fire there -- the
// binary does not reset the readout on respawn either.
static float sShownRange = 1200.0f;
static const void *sShownOwner = 0;
if (sShownOwner != (const void *)this)
{
sShownOwner = (const void *)this;
sShownRange = 1200.0f;
}
float trueRange = 1200.0f; // no target: the binary default
Entity *des = MECH_TARGET_ENTITY(this);
if (des != 0 && des != hotTarget)
@@ -6148,6 +6552,26 @@ void
// the 500 m/s slide toward trueRange (see the banner above)
{
// #147 NaN TRAP. NaN is ABSORBING here and the clamps below
// cannot catch it: `step > maxStep` and `step < -maxStep` are
// BOTH false for NaN, so a single poisoned frame makes
// sShownRange NaN and it stays NaN for the life of the process
// (the static is never re-seeded except on a mech change).
// Downstream, BTReticleRenderable::Draw clamps the same way, so
// the NaN reaches AddPoint/ConcatMatrix and the caret + its bar
// render as degenerate geometry -- i.e. they VANISH while every
// static reticle element (the tick marks) still draws. That is
// exactly the reported "no range finder on this drop: ticks
// there, moving caret gone". Re-seed instead of propagating.
if (!(trueRange == trueRange) || !(sShownRange == sShownRange))
{
if (getenv("BT_RANGE_LOG"))
DEBUG_STREAM << "[range] NaN TRAPPED (true=" << trueRange
<< " shown=" << sShownRange << ") -- re-seeded to 1200\n"
<< std::flush;
trueRange = 1200.0f;
sShownRange = 1200.0f;
}
float maxStep = (float)dt * 500.0f;
if (maxStep < 0.0f) maxStep = -maxStep;
float step = trueRange - sShownRange;
@@ -6155,6 +6579,22 @@ void
if (step < -maxStep) step = -maxStep;
sShownRange += step;
BTSetHudTargetRange((Scalar)sShownRange);
// 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 -- so a field
// log could neither confirm nor refute a dead caret. Fixed.
if (getenv("BT_RANGE_LOG"))
{
static float sRlog = 0.0f;
sRlog += (float)dt;
if (sRlog >= 1.0f)
{
sRlog = 0.0f;
DEBUG_STREAM << "[range] caret input shown=" << sShownRange
<< " true=" << trueRange << " lock=" << gBTHudLockState
<< "\n" << std::flush;
}
}
}
// BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag):
@@ -7581,7 +8021,33 @@ void
continue;
if (i != 0) // slot 0 = the mapper (task #7)
++subsystemsPresent;
if (!subsystem->IsNonReplicantExecutable())
// #148 -- THE INSTANCE BRANCH. Entity::Perform (ENTITY.cpp:733-793,
// real engine source [T0]) picks the predicate by instance:
// if (GetInstance() != ReplicantInstance) IsNonReplicantExecutable()
// else IsReplicantExecutable()
// and they 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". This loop used the
// NonReplicant predicate for every mech, so on a REPLICANT any
// ExecuteOnUpdate subsystem never ran at all, no matter how many
// records arrived for it.
//
// Measured (scratchpad/night13/missileframe2.sh): the peer's copy
// TORSO received its first record at log line 205 but its Performance
// did not run until line 1014 -- ~800 lines of arriving twist data
// integrated by nobody, so the peer's torso sat at 0 and its missiles
// launched along the body facing (the tail of #141). The records
// themselves were fine: they are sent on RATE CHANGE (the sweep's
// direction flips -- atUpd +/-2.39 with rate flipping sign), and the
// peer dead-reckons `atUpd + rate * elapsed` between them.
const Logical execOK =
(GetInstance() != Entity::ReplicantInstance)
? subsystem->IsNonReplicantExecutable()
: subsystem->IsReplicantExecutable();
if (!execOK)
continue;
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
@@ -8561,9 +9027,24 @@ void
}
if (s_portCache[energyOrdinal] != 0)
{
AffineMatrix mw;
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld);
// #141 sweep: engine accessor (== the binary's FUN_00424da8).
// This is the BEAM muzzle -- the same stale-cache exposure the
// missile launch had, so a peer's beam would also originate
// from the bind-pose gun port instead of the twisted torso.
// #149 A/B (BT_BEAM_SEGFRESH=0): revert THIS site to the
// pre-sweep plain compose, to measure whether the per-beam
// per-frame dirty-pass is the 857 draw-stall regression.
// This site runs inside the DRAW path per emitter per frame;
// the other swept sites are per-salvo/per-hit and cannot be
// a per-frame cost. Default = fresh (the swept behaviour).
static const int sBeamFresh =
!(getenv("BT_BEAM_SEGFRESH") && *getenv("BT_BEAM_SEGFRESH") == '0');
LinearMatrix mw;
if (sBeamFresh)
GetSegmentToWorld(*s_portCache[energyOrdinal], &mw);
else
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld);
mz = mw; // Point3D = matrix translation
}
}
+50 -1
View File
@@ -1255,26 +1255,50 @@ void
+ fabsf((float)owner->bodyCycleSpeed);
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
&& cyc < 0.05f) ? 1 : 0;
// #52 A/B probe (BT_BODY_SM_LOG): the POSITIVE half of the Standing-lock
// evidence. The [skate] line only speaks once a lock has ALSO produced
// 90 sustained moving frames; this says every second what a moving peer's
// body channel is actually doing. Locked: bstate=0, bodyCyc=0. Healthy:
// bstate in the walk/run family with a live cycle.
if (step > 0.08f && step < 5.0f && getenv("BT_BODY_SM_LOG"))
{
static float s_pg = 0.0f; s_pg += 1.0f / 30.0f;
if (s_pg >= 1.0f) { s_pg = 0.0f;
DEBUG_STREAM << "[peergait] replicant " << owner->GetEntityID()
<< " step=" << step
<< " bstate=" << (int)owner->bodyAnimationState
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bts=" << (float)owner->bodyTargetSpeed
<< (movingNoLegs ? " <-- IDLE CHANNELS" : "")
<< "\n" << std::flush; }
}
if (movingNoLegs)
{
if (++skateFrames > 90 && !skateLogged)
{
skateLogged = 1;
// bstate = the peer's BODY animation state (@0x728), the channel
// that poses a replicant (s_peerLegCh=0). Added 2026-08-07 after
// night 13: the field lines proved "moving with both channels
// idle" but not WHICH state it was idling in, and the answer
// (0 = Standing, pinned) is the whole diagnosis -- see #52.
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
<< " SKATING: " << skateFrames << " frames moving ("
<< step << " u/frame) with legCyc="
<< (float)owner->legCycleSpeed
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
<< " bstate=" << (int)owner->bodyAnimationState
<< " destroyed=" << (int)owner->IsMechDestroyed()
<< " mode=" << (int)owner->MovementMode()
<< " at (" << px << "," << pz << ")\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f "
"cmdSpd=%.2f destroyed=%d mode=%d x=%.1f z=%.1f",
"cmdSpd=%.2f bstate=%d destroyed=%d mode=%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
skateFrames, step, cyc,
(float)owner->bodyTargetSpeed,
(int)owner->bodyAnimationState,
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
px, pz);
}
@@ -1334,6 +1358,31 @@ void
const bool nowDead = (mode == 2 || mode == 9);
const bool prevDead = (oldMode == 2 || oldMode == 9);
prevMode = mode;
// #108 GHOST CENSUS, the ENTER edge (ungated, 2026-08-07). The
// un-wreck receipt below has 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 therefore
// found ONE ghost while testers reported many, and there was no way to
// tell a real count from a chassis accident. This line is emitted for
// EVERY replicant that enters the wreck state, so a log's ghost count
// is exactly (wreck-enters minus un-wrecks) per entity.
if (!prevDead && nowDead
&& owner->GetInstance() == Entity::ReplicantInstance)
{
DEBUG_STREAM << "[wreck] replicant " << owner->GetEntityID()
<< " entered wreck state (mode "
<< oldMode << "->" << mode << ") at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("WRECK", "mech=%d:%d mode=%d->%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
oldMode, mode,
(float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.z);
}
if (prevDead && !nowDead
&& owner->GetInstance() == Entity::ReplicantInstance)
{
+128 -27
View File
@@ -508,25 +508,69 @@ void
}
NotifyOfControlModeChange(controlMode); // vtable+0x48
// TYPED torso reconfiguration (2026-07-13): the raw block this
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
// straight onto OUR compiled Torso -- the databinding trap: garbage
// writes into whatever members live there in this build. The
// observable semantics via named members: Basic clears the analog
// axes and recenters (the sim's centerCommand -> Recenter); the
// assisted modes just free the torso (the sim clamps to the authored
// limits on its own).
// TYPED torso reconfiguration. The raw block this replaces wrote the
// BINARY's offsets straight onto OUR compiled Torso (the databinding
// trap); the typed rewrite that followed then got the SEMANTICS wrong in
// three ways. Corrected 2026-08-08 against @004afbe0, which is a
// complete spec:
//
// 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) THE BUG Sauron hit. Basic set `centerCommand` (@0x208) via
// CommandRecenter(). That is the HELD-BUTTON cell: TorsoSimulation
// re-arms recenterActive from it EVERY frame it is non-zero, and only
// the input path clears it -- and a MODE SWITCH has no button release
// to follow. So one visit to Basic pinned it at 1 forever, the torso
// re-centred every frame, and the digital twist commands (processed
// BEFORE the centerCommand block) were overridden as fast as they were
// applied. Cycling Standard -> Veteran -> (wraps through BASIC) ->
// Standard is enough to trigger it, which is exactly the reported
// "toggled to advanced and back, lost torso control". 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 were
// ctor-written and never read by anything. So Basic never restricted
// downward travel and the assisted modes never restored it.
// (3) Basic also raises the HUD's flickerActive (@0x2A0) so the horizon
// re-settles with the torso. Not ported.
// Also: the binary zeroes ONLY analogTwistAxis (@0x1F0). The extra
// SetAnalogElevationAxis(0) was invented; removed.
Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0)
{
if (controlMode == BasicMode)
{
torso->SetAnalogTwistAxis(0.0f);
torso->SetAnalogElevationAxis(0.0f);
torso->CommandRecenter();
torso->SetAnalogTwistAxis(0.0f); // @0x1F0
// BT_LEGACY_MODE_RECENTER=1 restores the defective pre-2026-08-08
// behaviour (the sticky centerCommand) for A/B measurement.
static const int s_legacyRecenter =
getenv("BT_LEGACY_MODE_RECENTER") ? 1 : 0;
if (s_legacyRecenter)
torso->CommandRecenter(); // @0x208 STICKY -- the bug
else
torso->BeginRecenterOnce(); // @0x274 (NOT centerCommand)
torso->ApplyBasicElevationLimits(); // @0x220/@0x224 <- @0x228/@0x22C
extern void BTSetHudFlickerActive(Subsystem *hud);
BTSetHudFlickerActive(mech->GetHudSubsystem()); // HUD @0x2A0 = 1
}
else // StandardMode / VeteranMode -- `mode - 1U < 2` in the binary
{
torso->ApplyAssistedElevationLimits(); // @0x220/@0x224 <- @0x230/@0x234
}
// Standard/Veteran: nothing to force -- the sim's limits govern.
}
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
@@ -660,6 +704,58 @@ void
// after the push, immediately before interpretation -- making the keyboard
// authoritative on the dev box. Interpretation below stays 100% authentic.
//
// BENCH (BT_MODECYCLE_EVERY=<n>): cycle the control mode every n
// InterpretControls calls, driving the SAME body the 'M' key and the pod
// console button (key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
// it) drive. Dev-only; default off.
//
// ⚠ DELIBERATELY OUTSIDE the key-bridge block below. The ONLY caller of
// ClearRecenterCommand() lives inside that block, so forcing BT_KEY_BRIDGE=1
// to make this hook run would ALSO switch on the one thing that clears
// centerCommand -- masking the very bug under test. That is exactly how the
// first run of modecycle.sh came back clean. Keeping the hook out here lets
// the bench reproduce the RIO-present (glass/PadRIO) configuration, where the
// bridge is OFF and nothing clears the cell.
{
static const char *s_mcEvery = getenv("BT_MODECYCLE_EVERY");
if (s_mcEvery != 0)
{
static int s_mcN = 0;
int period = atoi(s_mcEvery);
if (period < 1) period = 300;
if (++s_mcN % period == 0)
CycleControlModeNow();
}
}
// (#152) TORSO-CENTER -- the ONE writer of the torso's centerCommand
// (@0x208, HELD-button semantics: writer asserts while held, clears on
// release; TorsoSimulation re-arms recenterActive from it each frame).
// Sources OR'd here, deliberately OUTSIDE the key-bridge gate:
// * torsoCenter (@0x154) -- this mapper's databound "TorsoCenter" cell,
// the streamed pod-button route (button 0x42);
// * gBTTorsoRecenter -- the desktop 'X' one-frame pulse (mech4 key poll).
// The old writer lived INSIDE the key-bridge block, so on any rig with a
// RIO/PadRIO present (glass + the pod -- the bridge is off there) NO path
// could reach centerCommand: with the stuck-cell phantom auto-recentre
// fixed, those players had no way to recentre the torso in Std/Vet at all
// (Oracle's #152 report). Single-writer here also means the two sources
// can never stomp each other's clear.
{
Mech *rcMech = GetMech();
Torso *rcTorso = (rcMech != 0) ? (Torso *)rcMech->GetTorsoSubsystem() : 0;
if (rcTorso != 0)
{
int hold = (torsoCenter != 0);
extern int gBTTorsoRecenter;
extern int gBTTorsoCenterHeld; // pod button 0x42 (L4PADRIO chokepoint)
if (gBTTorsoCenterHeld) hold = 1;
if (gBTTorsoRecenter) { gBTTorsoRecenter = 0; hold = 1; }
if (hold)
rcTorso->CommandRecenter();
else
rcTorso->ClearRecenterCommand();
}
}
{
// STAND-DOWN (glass-cockpit step 2c): BT_KEY_BRIDGE unset = AUTO --
// the bridge runs only when NO live cockpit device (serial RIO /
@@ -871,21 +967,12 @@ void
// centerCommand is a pod BUTTON state, so the writer clears
// it while unpressed (Basic's own path re-asserts every
// frame; this branch owns it in Standard/Veteran).
{
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
if (rcTorso != 0)
{
if (gBTTorsoRecenter)
{
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
// (#152) the recenter writer moved OUT of this key-bridge
// block to the unified consumer below -- inside here it was
// DEAD on every RIO/glass rig (bridge off), which left those
// players with no torso-centre control at all once the
// stuck-cell phantom auto-recentre was fixed. Bench: two
// scripted 0x42 holds on the RIO path, ctrCmd=0 throughout.
}
}
// (stickPosition.y no longer zeroed here -- the bridge above
@@ -1023,6 +1110,20 @@ void
// reads the same live factor (dead/overheated myomers cannot squat
// or rise; the posture selector tests |factor| <= 1e-4).
mech->myomerEffectiveness = drive;
// #137 POST-RESET TRACE. Mech::Reset arms gBTMyoTrace; sample here,
// where the mover's actual multiplier is formed, for a few seconds
// after a respawn. This is the window nothing was watching -- the
// at-reset sample always looks clean.
{
extern int gBTMyoTrace;
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
if (gBTMyoTrace > 0)
{
--gBTMyoTrace;
if ((gBTMyoTrace % 30) == 0)
BTReportMyomerFreeze((void *)mech, "post-reset");
}
}
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
}
+25
View File
@@ -809,6 +809,31 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
Scalar gain = ratio * ratio * damageGain;
// #137 DEPOSIT TRAP (BT_HEAT_LOG): conduction into the myomers is measured
// ZERO post-respawn (the [heatflow] trap), weapons deposit into themselves,
// so THIS add is the only writer left that can carry the observed one-shot
// slug (9e7..2.3e9, varying per respawn). The [myoheat] receipt is
// time-sampled and would miss a 1-2 frame spike; this prints EVERY add
// over 1e6 with the full operand set, unconditionally.
{
Scalar deposit = gain * (termClimb + termKinetic + termAccel);
if (getenv("BT_HEAT_LOG") != 0 && (deposit > 1.0e6f || deposit < -1.0e6f))
{
// WHOSE mech (master vs the peer's replicant shares this log!) and
// the acceleration COMPONENTS (pure-y = gravity accumulation on the
// wreck; planar = teleport/warp-derived).
Mech *om = (Mech *)owner;
const Vector3D &av = om->localAcceleration.linearMotion;
DEBUG_STREAM << "[myodep] mech=" << om->GetEntityID()
<< (om->GetInstance() == Entity::ReplicantInstance ? " REPL" : " mstr")
<< " deposit=" << deposit
<< " v=" << velMag << " a=" << accMag
<< " aXYZ=(" << av.x << "," << av.y << "," << av.z << ")"
<< " vy=" << vy << " dt=" << time_slice << " gain=" << gain
<< " climb=" << termClimb << " kinetic=" << termKinetic
<< " accel=" << termAccel << "\n" << std::flush;
}
}
pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel);
if (getenv("BT_MYO_LOG"))
+50 -5
View File
@@ -618,6 +618,30 @@ void
if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f;
if (s_lockSweep > 1.0f) s_lockSweep = 1.0f;
}
// BENCH (BT_TWIST_PULSE=<n>): deflect the analog twist axis for n ticks,
// then RELEASE it for n ticks, repeating. BT_LOCK_SWEEP never releases,
// so it cannot show the reported symptom: with a stuck centerCommand the
// torso holds while you are actively pushing (the analog arm clears
// recenterActive) and snaps back the moment you let go (centerCommand
// re-arms it) -- "the torso centering FOUGHT my control". Measure the
// RELEASE windows: currentTwist should HOLD, not decay toward 0.
{
static const char *s_tp = getenv("BT_TWIST_PULSE");
if (s_tp != 0)
{
static int s_tpN = 0;
int period = atoi(s_tp);
if (period < 1) period = 120;
const int phase = (s_tpN++ / period) % 2;
analogTwistAxis = phase ? 0.0f : 0.6f;
if ((s_tpN % 30) == 0)
DEBUG_STREAM << "[twistpulse] phase=" << (phase ? "RELEASE" : "deflect")
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive << "\n" << std::flush;
}
}
if (s_lockSweep > 0.0f)
{
effectiveTwistRate = baseTwistRate;
@@ -646,6 +670,13 @@ void
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
// control-mode recenter state. centerCommand (@0x208) is the
// HELD-button cell -- if it reads 1 with no button down, the
// torso re-arms recenterActive every frame and digital twist is
// dead (Sauron's "lost torso control" after cycling modes).
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive
<< " vLim=(" << verticalLimitBottom << ".." << verticalLimitTop << ")"
<< " wIdx=" << watchedSubsystem
<< " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
@@ -823,6 +854,13 @@ void
<< " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime
<< " now=" << GetCurrentTime()
// #148: ComputeTargetTwist ends in Min(limitLeft)/Max(limitRight).
// If the COPY's limits never loaded they are 0/0, which pins
// targetTwist to EXACTLY 0 no matter what the record carried --
// which is what a peer stuck at zero twist would look like.
<< " limL=" << horizontalLimitLeft
<< " limR=" << horizontalLimitRight
<< " enab=" << (int)horizontalEnabled
<< " copy=" << (int)isDamagedCopy << std::endl;
}
}
@@ -897,14 +935,21 @@ void
// bring-up verification (env BT_TORSO_LOG; default OFF): show the first few
// joint writes so the per-frame path can be confirmed in a headless run.
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
static int s_count = 0;
if (s_log && (s_count % 30) == 0 && s_count < 1800) // sample periodically to show the sweep
// ⚠ SAMPLING TRAP (fixed 2026-08-08, #141): this used to sample ONE shared
// static every 30th call. With a master torso and a replicant COPY torso
// both ticking, the calls alternate 1:1 -- so every 30th call is always the
// SAME instance, and the probe reported only the local (untwisted) torso
// while the copy's writes were invisible. Sample per instance-kind instead.
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
static int s_count[2] = { 0, 0 };
const int kind = isDamagedCopy ? 1 : 0;
if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800)
{
DEBUG_STREAM << "[torso] PushTwist node=" << (void*)node << " type=" << (int)jt
DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master")
<< " node=" << (void*)node << " type=" << (int)jt
<< " twist=" << (float)twist << "\n" << std::flush;
}
++s_count;
++s_count[kind];
switch (jt) // node+0x10
{
+23 -1
View File
@@ -235,8 +235,30 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
void CommandRecenter() { centerCommand = 1; } // @0x208 HELD button -- writer MUST clear it
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
// ⚠ centerCommand (@0x208) is a HELD-BUTTON cell: TorsoSimulation re-arms
// `recenterActive` from it EVERY frame it is non-zero, and only the input
// path clears it. Do NOT use CommandRecenter() for a one-shot recenter --
// nothing releases it and the torso re-centres forever, which reads to the
// pilot as "lost torso control" (Sauron, control-mode cycle).
//
// The one-shot the mode switch actually wants is recenterActive (@0x274)
// itself: TorsoSimulation runs `recenterActive = Recenter(dt)`, so it
// SELF-CLEARS on settle, and any twist input cancels it. This is exactly
// what the binary writes -- `*(torso + 0x274) = 1` @004afbe0.
void BeginRecenterOnce() { recenterActive = 1; } // @0x274 one-shot (@004afbe0)
// The TWO authored elevation-limit pairs the control mode swaps between
// (@004afbe0). BASIC gets @0x228/@0x22C (full top, HALF bottom -- reduced
// downward travel); STANDARD/VETERAN get @0x230/@0x234 (the full authored
// pair). Before 2026-08-08 all four were written by the ctor and never
// read by anything -- the port simply never implemented the swap.
void ApplyBasicElevationLimits()
{ verticalLimitTop = elevationCenter; verticalLimitBottom = elevationHalfBottom; }
void ApplyAssistedElevationLimits()
{ verticalLimitTop = twistCenterHigh; verticalLimitBottom = twistCenterLow; }
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
-252
View File
@@ -1,252 +0,0 @@
# Phase 14 — PPC hit = `scrambleVideo`, the cockpit-CRT sync detune
**Goal:** restore the PPC's authentic secondary effect — a PPC strike scrambles
**every secondary cockpit display for 0.8 s**, leaving the main view untouched.
**Status:** ✅ IMPLEMENTED 2026-08-06 (branch `ppc-sync-distortion`) [T2 —
visual screenshot-verified, trigger by construction]. Discovered 2026-08-06 by
disassembly of the shipped `BTL4OPT.EXE` (md5 `a97075bcb5634d13263e9ad5a2b96fd0`)
after playtesters reported *"being hit by a PPC makes it look like all of the
secondary CRTs were being degaussed."* Full findings: `context/gauges-hud.md`
§"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"; cross-ref in
`context/combat-damage.md`.
**What shipped (all three work items):**
- **A — trigger:** `game/reconstructed/mech.cpp`, `Mech::TakeDamageMessageHandler`
@0x4a03f3 position (after the cylinder resolve, before the burst loop) — fires
`GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f)` once per
EnergyDamageType message. `BT_DMG_LOG` prints `[ppc-scramble]`.
- **B — visual (animated recovery):** `SVGA16::FunkyVideo(on, dur)` (was the 2007
stub) records the start + hold; `SVGA16::ScrambleParams` (new) is a two-phase
envelope read by BOTH `DrawDevSurface` (surround/dock) and `ExpandPlaneToBGRA`
(glass windows, native + rotated radar), so all secondary surfaces recover
together and the main 3D view (separate timing chain) is untouched. The sequence
per hit: **collapse** to a thin centred line → **HOLD** for the ~0.8 s the card
held bad sync (content scrolls wildly fast + decelerating, plus a violent
**shake**) → **RECOVERY** (~0.5 s: the line broadens back to full while
scroll/tear/shake settle) → **LOCK** (clean). The read loops map the source
through the envelope: horizontal `scale` sets a black-bordered collapse band,
`rollOff` scrolls (wraps) within it, `shear` is a per-row diagonal, and a
per-frame LCG `shake` bounces the row + jitters the scroll. Tunables (by eye —
the pod-monitor PLL look is not recoverable): `BT_SCRAMBLE_COLLAPSE` (min band
fraction, 0.03), `BT_SCRAMBLE_ROLL` (initial scroll px/s, 9000),
`BT_SCRAMBLE_SHAKE` (px, 10), `BT_SCRAMBLE_SHEAR` (px/row, 3), `BT_SCRAMBLE_DUR`
(hold s), `BT_SCRAMBLE_RECOVER` (recovery s, 0.5). **`BT_SCRAMBLE_TEST=1` loops
the whole transition** for tuning without a hit; `BT_SCRAMBLE_CYCLE=1` loops it
stepping the roll speed.
- **C — non-stacking latch:** `L4GaugeRenderer::SpecialEffect` now ignores the
re-arm while `scrambleVideoFlag` is set (matches the binary's `modified` latch);
a second PPC during the window no longer extends it. NB the SVGA16 animation runs
the full hold+recovery on its own clock (`FunkyVideo(False)` is a no-op — the
card restoring sync is where the recovery *begins*), so the recovery isn't cut
off when the 0.8 s latch clears.
- Verified [T2]: Release links clean; surround boots + runs with the effect looped
(`BT_SCRAMBLE_TEST`) at both slowed and true full speed — every secondary MFD +
the radar collapse/roll/shake/recover together while the out-the-window view
stays clean, no crash (screenshots). **SHIPPING with the current defaults for
playtester feedback.** Open: live PPC-fire confirmation (one arm per hit,
non-stacking across two hits) + by-eye tuning of the envelope constants — none
recoverable from the binary, so the testers who filed the report are the ground
truth.
**Good news up front:** the engine half already exists in our tree under the
**original VWE names** (`SpecialEffect` / `scrambleVideo` / `FunkyVideo`). Only
two things are missing: the **trigger** (never ported) and the **visual**
(stubbed out in 2007). This is a small, well-bounded job.
---
## 1. What the original did [T1 — disasm-verified]
On the **victim's** machine, `Mech::TakeDamageMessageHandler` @`0x4a0230` tests
the damage type between the collision divert and the burst loop:
```
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
004a03f6 cmp ecx,4 ; EnergyDamageType
004a03f9 jne 0x4a0423 ; everything else -> burst loop
004a03fb mov eax,[0x4efc94] ; global `application`
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
004a0405 je 0x4a0423 ; null-guarded
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
004a040a fld xword [0x4a0c08] ; long double 0.2
004a0410 fmulp st(1) ; => 0.8
004a041d call dword [edx+0x4c] ; vtable slot 19 == SpecialEffect(0, 0.8f)
```
`L4GaugeRenderer::SpecialEffect(scrambleVideo, 0.8f)` @`0x46ffcc`
`SVGA16::FunkyVideo(True)` @`0x47d76d`, which reprograms the **VGA CRT
controller**:
```
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
saved = in(0x3D5); out(0x3D5, saved - 9) ; shorten every scanline
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
```
A per-frame timer @`0x47003c` writes `saved` back 0.8 s later.
**Why only the PPC:** a `BTL4.RES` census gives `damageType` 4 (`Energy`) =
**14 subsystem records, every one PPC or ERPPC**. Everything else is Ballistic
(16), Explosive (30), Laser (78). The branch is structurally PPC-exclusive —
no extra gating needed.
**Why only the secondaries:** all six secondary displays are derived by the VDB
from that one VGA's timing, so they break together. The main out-the-window
view comes off the Division VPX card on an independent timing chain and is
unaffected. Playtesters confirm both halves.
---
## 2. What our tree already has
| Piece | Where | State |
|---|---|---|
| `enum VideoEffectType { scrambleVideo }` (value **0**) | `engine/MUNGA/GAUGREND.h:482` | ✅ present |
| `virtual void GaugeRenderer::SpecialEffect(VideoEffectType, Scalar) {}` | `engine/MUNGA/GAUGREND.h:488` | ✅ base no-op |
| `L4GaugeRenderer::SpecialEffect` — sets `scrambleVideoFlag`, `scrambleVideoTimeout = Now()+duration`, calls `FunkyVideo(True)` | `engine/MUNGA_L4/L4GREND.cpp:806` | ✅ implemented |
| `L4GaugeRenderer::ProcessVideoEffects()` — on timeout, `FunkyVideo(False)` | `engine/MUNGA_L4/L4GREND.cpp:832` | ✅ implemented |
| …called every frame from `ExecuteForeground` | `engine/MUNGA_L4/L4GREND.cpp:370` | ✅ live |
| `SVGA16::FunkyVideo(Logical)` | `engine/MUNGA_L4/L4VB16.cpp:6358` | ❌ **STUBBED** (`//STUBBED: VIDEO RB 1/15/07`, body commented out) |
| Any caller of `SpecialEffect` | — | ❌ **NONE** |
The base-class declaration carries its original comment:
> `// Quick and dirty hack to allow calling L4GaugeRenderer::SpecialEffect`
> `// from non L4 level. GDU 2/28/96`
That hack exists **because the damage handler (non-L4 level) had to call it**
independent corroboration that the trigger belonged in `mech.cpp`, and the
reason you can call it through the base pointer without dragging L4 headers
into a game-layer TU.
---
## 3. Work item A — the trigger (`game/reconstructed/mech.cpp`)
In `Mech::TakeDamageMessageHandler`, **after** the `damageType==0` collision
divert and **before** the burst loop, add the type-4 branch.
```cpp
// @0x4a03f3 [T1] -- PPC/ERPPC only: EnergyDamageType is authored on exactly
// the 14 PPC/ERPPC subsystem records and nothing else. Duration is DERIVED
// from the type ordinal, not a constant: (float)damageType * 0.2 == 0.8f.
if (damage.damageType == Damage::EnergyDamageType) // == 4
{
GaugeRenderer *gauges = (application != 0)
? application->GetGaugeRenderer() : 0; // APP.h:355
if (gauges != 0) // binary null-guards too
{
gauges->SpecialEffect(
GaugeRenderer::scrambleVideo,
(Scalar)damage.damageType * 0.2f); // long double 0.2 @0x4a0c08
}
}
```
**Placement matters.** The binary's branch is *outside* the burst loop, so it
fires **once per damage message**, not once per burst. Putting it inside the
loop would re-arm it `burstCount` times.
Use the named accessor — do not raw-read `application+0x4c` (databinding rule,
`context/reconstruction-gotchas.md`).
---
## 4. Work item B — the visual (`SVGA16::FunkyVideo`)
There is no CRTC to detune, so reproduce the **look**, applied to the gauge
composite only.
What the original did physically: Horizontal Total sets character clocks per
scanline. Subtracting 9 shortens every line by roughly **9%**, far outside any
monitor's sync lock range, so the picture breaks into a rolling diagonal tear
until the value is restored. VWE's own name for it — `scrambleVideo` — is the
best description of the intended result.
Suggested model (per-scanline horizontal displacement of the gauge buffer):
```
shift(y, t) = ( y * k + roll(t) ) mod width
```
- `k` — per-line shear, the fraction of a line lost. ~9% of width is the
physically-derived starting point; **tune by eye** against the playtester
description rather than treating it as exact, because the on-screen result
depended on how each pod monitor's H-sync PLL misbehaved — that is not
recoverable from the binary.
- `roll(t)` — a time-varying offset so the tear drifts rather than sitting
static. The original rolled because the monitor never re-locked.
Constraints:
- **Gauge composite only.** Apply to the `SVGA16` `pixelBuffer`
(`engine/MUNGA_L4/l4vb16.h:243`) or at the point the strip/surfaces are
presented — *never* the main 3D view. The main view being clean is a
confirmed observation, not an assumption.
- **All secondary surfaces together.** They are bit-planes of one shared
buffer, so a single buffer-level effect is authentic by construction; do not
implement it per-MFD.
- **Keep the existing timing path.** `SpecialEffect` / `ProcessVideoEffects`
already own the flag and the 0.8 s timeout and are already called per frame.
`FunkyVideo` should only set/clear state — no timing logic of its own.
---
## 5. Fidelity constraints (do not "improve" these)
1. Duration is `damageType * 0.2f`, **not** a literal `0.8f`.
2. Fires on `EnergyDamageType` — never add an explicit PPC class check; the
data authorship *is* the gate.
3. **Idempotent, non-stacking.** The binary latches on `modified` @`0x4fe0fe`:
a second PPC hit while the effect is live does **not** re-save the (already
detuned) value and does **not** extend or double the effect. Our
`SpecialEffect` currently *does* overwrite `scrambleVideoTimeout`, which
extends the effect on a second hit — **that is a divergence.** Match the
binary: ignore re-arm while `scrambleVideoFlag` is set. (The original's
latch was in `FunkyVideo`; ours must go in `SpecialEffect` or `FunkyVideo`,
but it must exist.)
4. Victim-side only. The shooter sees nothing; this runs in the victim's
damage handler.
5. Null-guard the gauge renderer — the binary does, and headless/bench runs
have none.
---
## 6. Verification
- **Headless:** add a one-line log in the new branch; fire a PPC at a dummy
with `BT_DMG_LOG`. Expect exactly one arm per PPC message, zero for laser /
autocannon / missile / Gauss.
- **Non-stacking:** two PPC hits ~0.2 s apart must produce one 0.8 s effect
measured from the *first* hit, not 1.0 s or two effects.
- **Live:** confirm every secondary MFD scrambles together and the main view
stays clean. Have the playtesters who filed the report compare — they are
the only ground truth for `k` and `roll`.
- **Against the original (optional, decisive):** in the DOSBox-X fork, log
writes to CRTC index 0 via `0x3D4`/`0x3D5` during a real BT mission.
Prediction: exactly one write of `saved-9` per PPC strike, one restore 0.8 s
later, zero for every other weapon.
---
## 7. Gotchas
- Do **not** confuse this with `SVGA16::FlashPalette` (the pixel-mask cycler on
ports `0x302/0x30A/0x312`). That machinery is linked and its per-frame cycler
runs, but `FlashPalette` @`0x46d5f4` has **zero call sites and zero
address-of references** in `BTL4OPT.EXE` — BT never arms it. **RP does**
(`RPL4OPT.EXE` @`0x4addce`, palette 1, rate 2.0, masks `{FF,BF,7F,3F}`) for
alarm blinking. Wrong mechanism, wrong game.
- The `LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are **not**
involved and are driven by nothing in the surviving tree.
- Scope note: callers of gauge-renderer vtable slot 19 were not exhaustively
enumerated (virtual dispatch), so another arming site may exist. The
low-level path *is* exhaustive — @`0x47d76d` has one caller, @`0x46d840` two
(set @`0x47002b`, restore @`0x470076`).
## References
- `context/gauges-hud.md` §"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"
- `context/combat-damage.md` §`Mech::TakeDamageMessageHandler` (Energy branch)
- `HISTORY.md` (TeslaRel410) §"Anatomy of a surviving weapon — the PPC"
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+127
View File
@@ -0,0 +1,127 @@
"""Night 13: close #137 (not a bug) and file the desktop throttle-latch follow-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY_137 = """**NOT A BUG -- closing (2026-08-08).** The reset is correct on both sides. The
heat is real, and the mech earned it: it respawns still under power.
## What the field log actually shows
From Sauron's match log (`steam_20260806_c_michael_XIAOLONG.log`), the respawn at line 39450:
```
39450 [respawn] Mech::Reset 3:30 healed+moved to (...) alive=1 zones=21 subsys=33
[techstat] SLaser_1 / LLaser_1 / HUD / Gyroscope / Avionics / GeneratorA / HeatSink
... condition CLEARED <- every live condition clears
[techstat] Myomers condition 3 SET <- Overheating, immediately
[mppr] in thr=1 pre=1 -> ...
[gaitSM] cycleSpeed=14.6 legCycle=14.6 state=12 <- already RUNNING
[techstat] Condenser5 condition 3 SET <- "dumping into coolant loop 5"
39519 [techstat] GeneratorD condition 3 SET <- Sauron's generator D
```
`[rstat]` fires about once per second (98 frames @ 10.2ms), and the reset plus the myomers trip sit
inside one rstat block -- so myomers overheat in well under a second, generator D in one to two.
The reset itself is clean: **every** live condition CLEARs 12-16 lines after each `Mech::Reset`, at
every respawn in the log. The synthetic bench agrees -- every roster subsystem, including all six
Condensers, reads `T=77 start=77` at the reset.
## Why it looked like maxed heat
**`condition 3` is an operating flag, not an alarm.** Census over the whole match:
| subsystem | cond 3 SET | CLEARED |
|---|---|---|
| LLaser_2 | 33 | 33 |
| LLaser_1 | 31 | 31 |
| SRM4 | 26 | 26 |
| PPC_2 | 18 | 18 |
| GeneratorD | 5 | 4 |
| Myomers | 5 | 4 |
| Condenser5 | 1 | 1 |
Every weapon trips Overheating on each volley and clears it on cooldown. **Every subsystem is
balanced** -- the odd extra SET is only the log ending mid-heat. Nothing latches, nothing sticks.
`condition 6` (BadPower) behaves the same way (Myomers 8/8, PPC_1 3/3, MLaser_1 3/3) as the bus
browns out under simultaneous draw. GeneratorD's longest continuous overheat came out normally.
So the post-respawn SET is the mech *operating*, not a failed reset.
## Why the heat arrives instantly
The mech comes back **at whatever throttle the pilot left**: `thr=1`, `cycleSpeed=14.6`, gait state
12 at the instant of reset. `Mech::Reset`'s subsystem loop starts at **index 2**, and the
ControlsMapper is **index 0** -- so the throttle is never reset.
**The binary does exactly the same.** That is correct for a pod: the throttle is a physical lever
still under the pilot's hand. Respawning under power is authentic behaviour.
Oracle's read that the myomer heat rate "felt right" was correct, and matches the data.
## Ruled out along the way
`Mech::Reset` dispatch, the full subsystem RTIS chain (Generator `@004b215c` is an
instruction-for-instruction match; HeatSink `@004ad760` faithful; every RTIS class has a
DeathReset), stale coolant-loop links (`linkedSinks` is written only in the streaming ctor and
cannot drift), and the valve fractions (`BTRecomputeCondenserValves == @0049f788`).
## Follow-up
The one genuine defect found is a desktop-only input issue, filed separately -- see the throttle
latch ticket. It does not affect the pod.
Knowledge base updated so this is not re-chased: `context/decomp-reference.md` §TechStatus (the
routine/self-clearing semantics + this post-mortem) and `context/gauges-hud.md`."""
TITLE_NEW = "Desktop/Steam: virtual throttle lever survives death -- you respawn already running"
BODY_NEW = """Split out of #137, which was closed as not-a-bug. The heat model and the reset are
both correct; this is a port-layer input issue and it affects **desktop/Steam only, never the pod**.
## The defect
On the pod the throttle is a physical lever. Respawning under power is authentic -- the pilot's hand
is on it, they can see and feel where it is, and `Mech::Reset` deliberately does not touch it (the
subsystem loop starts at index 2; the ControlsMapper is index 0). The binary behaves the same way.
The desktop glass bridge emulates that lever with a **file-static ramp accumulator**:
* `sLever` -- `static float sLever = 0.0f;` (`game/reconstructed/mech4.cpp:3250`)
* published each frame as `gBTDrive.throttle = sLever` (mech4.cpp:3892/3897)
* consumed as `key_throttle` -> `throttlePosition` (mechmppr.cpp:677, :780)
The only things that zero `sLever` are the **X-button all-stop** (mech4.cpp:3754) and a
direction-crossing snap (:3842). **Nothing on death or respawn touches it.**
So a pad/keyboard pilot who dies at speed respawns at speed -- while physically holding nothing, and
with the lever position invisible to them. The pod's authenticity argument does not cover this case:
there is no lever to feel, and a gamepad stick self-centers, so the input affordance actively
contradicts the latched state.
Observed consequence in the field (Sauron's log, the #137 evidence): the mech leaves the drop zone
at `thr=1` / `cycleSpeed=14.6` the instant it spawns, and the myomers, Condenser5 and GeneratorD all
trip Overheating within one to two seconds. Reads to the player as "respawned with heat maxed".
Note this is **not** the same as the Thrustmaster/RIO path, which is already correct:
`MechThrustmasterMapper::InterpretControls` (`@004d2150`) recomputes `throttlePosition` from scratch
every frame off the live `throttleForward`/`throttleReverse` states, and `throttleForward` is
databound (written by the input layer each frame). Only the desktop `sLever` accumulator latches.
## Suggested fix
Zero `sLever` (and the detent) on respawn in the desktop bridge only -- the same treatment the
X all-stop already applies -- so a desktop pilot comes back stopped. Gate it so pod builds keep the
authentic physical-lever behaviour.
This is a gameplay-behaviour decision as much as a bug fix: it changes whether desktop players
respawn moving or stopped. Worth a call before it ships.
## Not yet done
Not fixed, not benched. Filed from a static read of the input path plus the field log."""
gitea.close(137, BODY_137)
num = gitea.create(TITLE_NEW, BODY_NEW)
print("closed 137; created:", num)
+147
View File
@@ -0,0 +1,147 @@
"""Night 13: report #141 (fixed) and file the torso-cadence follow-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY_141 = """**ROOT CAUSE FOUND AND FIXED** (`f01de8c` + sweep `e6c5ac9`), reproduced and measured on a
2-node bench (`scratchpad/night13/missileframe.sh`).
## Reproduced
Only A sweeps its torso and only A fires, so every REPLICANT line in B's log mirrors one A salvo.
New `[launchframe]` receipt (`BT_PROJ_LOG`) prints the yaw of the launch forward vs the BODY forward:
| | n | max abs(twistDelta) | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.2962 | 1.2283 | **100%** |
| REPLICANT | 165 | **0.0000** | 0.0000 | **0%** |
`segResolved=1` on both, and `segYaw == bodyYaw` EXACTLY on the peer -- the launch frame was the
bind pose.
## What it was NOT
Everything upstream was already correct, which is why it looked like netcode. Both sides pass the
mount segment (`GetSegmentIndex()`, task #67 -- master `mislanch.cpp:363`, replicant mirror `:478`).
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 identical on both nodes
-- same seg 18, same `parentIdx=4`, non-null parent and joint subsystem.
The twist reached the joint and died at the **segment cache**.
## Root cause
`MechWeapon::GetMuzzlePoint` `@004b9948` ends in `FUN_00424da8(owner, segment, out)`, which is
`JointedMover::GetSegmentToWorld` instruction-for-instruction:
```c
iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified() <- TESTED, never set
... walk owner+0x300, seg+0xc = 1 ... // ModifySegment() on every segment
*(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
}
FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
```
**In the 1995 image every muzzle query performs the joints->segments refresh.** Our
`BTResolveWeaponMuzzle` -- labelled "the faithful FUN_004b9948" -- hand-composed
`GetSegmentToEntity() x localToWorld` and skipped it. `GetSegmentToEntity` only recomputes when
`segmentModified` is already set (`SEGMENT.cpp:262`), so it returned a stale cache. On the MASTER
that was invisible (the render pass refreshes the local mech every frame, after its torso pushes
the joint); a REPLICANT gets no such refresh, so peer muzzles sat at the bind pose.
## Fix
Route the muzzle path through the engine accessor, where the binary puts it. **No forced dirty
flag** -- an earlier attempt set `ModifyJoints(True)` and scored the same, so it bought nothing and
was removed; the binary only ever tests that flag.
Swept the same unfaithful pattern at three more sites: the generic segment->world bridge, the
damage-effect anchor, and **the energy-beam gun port** -- a peer's BEAM had the identical exposure
and would also have originated from the untwisted gun port. Repo-wide there is now exactly one
`GetSegmentToEntity` call outside `SEGMENT.cpp`: inside `GetSegmentToWorld` itself, after the
refresh.
## Result
| | n | max | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.1719 | 1.2781 | 100% |
| REPLICANT | 165 | **2.0907** | 0.8201 | **64%** |
**The 64% is not a partial fix.** The failures are a contiguous PREFIX with zero interleaved cases:
```
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 sampling 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.
## Field-verify
Unreleased. Next playtest: have a peer watch a twisted mech fire missiles -- rounds should leave
along the torso, not the feet. Also worth checking beams for the same reason (same fix).
Follow-up filed separately: the peer's torso takes far too long to FIRST acquire the master's
twist."""
TITLE_NEW = "Peer torso twist takes far too long to first sync -- only 13 update records across a 5-minute run"
BODY_NEW = """Split out of #141, whose launch-frame defect is fixed. This is a separate, measured
problem in the torso REPLICATION CADENCE.
## Measurement
From the #141 bench (`scratchpad/night13/missileframe.sh` / `missileframe2.sh`, 2 nodes, node A
sweeping its torso continuously at 0.35 rad/s for the whole run):
```
first torso RECORD received on the peer : line 206
first copy currentTwist != 0 : line 1016
```
and across the entire ~5 minute run the peer received only **13** `[torso-rec-rx]` records, despite
the master's twist changing continuously the whole time.
So the master was twisted from very early on, while the peer's copy torso reported `currentTwist`
of exactly 0 for a long stretch afterwards. The extrapolator itself is fine once fed --
`ComputeTargetTwist` predicts `twistAtUpdate + twistRate * elapsed` and the copy tracks its target
exactly (`cur=-2.13987 target=-2.13987 copy=1`). The problem is how rarely it is fed, and how late
the first useful feed arrives.
## Why it matters
* It is the entire reason #141's fix reads 64% instead of 100% on the bench -- 60 salvos fired
before the peer had any twist to carry.
* A peer's torso will visibly LAG or sit straight while the mech is actually twisted. That is
plausibly relevant to **#37** (MadCat torso is BACKWARDS) and **#70** (torso twist stops working
after respawn) -- worth re-testing both against this once it is understood.
## Not yet investigated
Whether 13 records is the authentic cadence (the binary may deliberately send torso updates rarely
and lean on `twistRate` extrapolation to cover the gaps -- in which case the bug is that our
extrapolation is not running or not seeded until late), or whether our send-side gate is simply too
conservative. `Torso::WriteUpdateRecord` snapshots `twistAtUpdate = currentTwist` at send, so the
send trigger is the thing to read first.
Diagnostics already in place: `BT_TORSO_LOG` gives `[torso-rec-rx]` (receive), `[torso-copy]`
(the copy's cur/target/atUpd/rate), and `[torso] PushTwist master|COPY` (per instance-kind -- note
that probe previously sampled one shared static every 30th call, which with two torsos ticking 1:1
always reported the SAME instance and hid the copy entirely; fixed in `05d7b58`)."""
gitea.comment(141, BODY_141)
gitea.call("/issues/141", method="PATCH", payload={"state": "closed"})
print("commented + closed #141")
num = gitea.create(TITLE_NEW, BODY_NEW)
print("created #%d" % num["number"])
+81
View File
@@ -0,0 +1,81 @@
"""Night 13: close #148 as not-a-bug with the measured chain. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**NOT A BUG -- closing (2026-08-08).** There was nothing to fix here. The bench was lying,
and this ticket's own premise was wrong twice over.
## The answer
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls `PerformAndWatch` **only** when
```cpp
application->GetApplicationState() == Application::RunningMission
|| application->GetApplicationState() == Application::EndingMission
|| IsPreRunnable()
```
and otherwise merely `WriteSimulationUpdate()`s. `Entity::DefaultFlags` is
`DynamicFlag|MasterInstance` -- **no `PreRunFlag`**. Only `Player` and `Director` add it in their
DefaultFlags, 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, no matter how
much correctly-replicated data is arriving for it. Measured on the observer node:
```
235 [perf-first] mech 3:161 master <- own mech, immediately
402 [torso-rec-rx] <- peer's torso records start 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 it does.
## So the symptom was a BENCH ARTIFACT
`BT_AUTOFIRE` starts shooting immediately, during `WaitingForLaunch` -- something no player can do
in a real match. Those leading salvos measured a peer whose torso, gait and subsystems had never
run. Every `ZZZZ...XXXX` prefix in this investigation was that, and the first `X` lands within a
few lines of the state transition.
**#141 is unaffected and stays fixed** -- its segment-cache defect was real and mid-match.
## Ruled out along the way (all measured, all recorded so nobody repeats them)
* **The record cadence is authentic.** My "only 13 records in 5 minutes" premise was wrong. The
payloads are the sweep EXTREMES with `rate` flipping sign at each one -- 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 `ComputeTargetTwist` clamp.** The copy's limits load correctly (`limL=2.44346
limR=-2.44346 enab=1`), so `Min/Max` was not pinning `targetTwist` to zero.
* **The torso's own executable flag.** `Entity::Perform` picks its predicate by instance
(`IsNonReplicantExecutable` vs `IsReplicantExecutable`, differing on
`|| lastUpdate >= lastPerformance`), and Mech's tick loop had dropped that branch. Restoring it
(`f36f013`) is a genuine fidelity fix and is kept -- but it moved this bug by nothing.
* **The scheduler.** The replicant entity IS offered to the performer with `executable=1` from
line 171, ~2500 lines before its first `PerformAndWatch`. The gate was inside `Execute`, not in
who gets offered.
## What came out of it
* `[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 exactly why this took
so long to see in a 2-node log.
* `[torso-copy]` now prints `limL/limR/enab`; `[launchframe]` prints the shooter's live torso twist.
* **Gotcha #29** in `context/reconstruction-gotchas.md`: judge a 2-node bench by PREFIX vs
INTERLEAVED, never by raw percentage; check `[ent-exec] state=` before suspecting replication.
`missileframe.sh` carries the same warning inline.
## Still worth checking separately
`#37` (MadCat torso BACKWARDS) and `#70` (torso twist stops after respawn) were flagged here as
possibly sharing a cause. They do **not** share this one -- it is not a defect. They should be
re-tested against the #141 segment-cache fix instead, which is a real mid-match change."""
gitea.comment(148, BODY)
gitea.call("/issues/148", method="PATCH", payload={"state": "closed"})
print("closed #148")
+90
View File
@@ -0,0 +1,90 @@
"""Night 13: report the #52 root cause on the tracker. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**ROOT CAUSE FOUND AND FIXED (2026-08-07, commit `6a96fb6`)** -- but read the
"what is NOT proven" section before treating this as closed.
## The defect
A replicant cannot start walking between gait-change records.
The port's body `case 4` (the task-#64 lockstep twin, mech2.cpp) is an INSERTION sitting between
`case 0` and the advance group. In the binary it is a MEMBER of that group -- `FUN_004a5678`
@004a5678 reads `case 2,3,`**`4`**`,5,8,...` with no turn block and no speed exit [T1] -- so case
0's fallthrough is meant to land on `Advance()`. The insertion intercepted it.
On a replicant that is not a race, it is an identity:
* case 0 arms walk iff `standSpeed < bodyTargetSpeed`
* the inserted block resets iff `standSpeed < bspd`, and `bspd` **IS** `bodyTargetSpeed` on a replicant
Same expression. Arm and reset fire on the same frame, every frame. A peer parked at Standing with a
live replicated demand never cycles: `bodyCycleSpeed` stays 0 while position advances from dead
reckoning. That is the skate. (Reverse is dead the same way -- both sides test `< ZeroSpeed`.)
## Why it appeared when it did
This is the sequel to `e91d447` (#82). Before that commit the replicant branch read the LOCAL
mapper's `speedDemand` -- a dead cell on a peer, 0 forever -- so the exit never fired and the
fallthrough worked BY ACCIDENT. Fixing the dead cell (correctly) closed the accidental escape
hatch, and the trn-lock skate came back as a Standing-lock skate.
## Why masters were unaffected
Two reasons, either sufficient: their two tests read DIFFERENT cells (`bodyTargetSpeed` held at
last-sent by the gait mirror, vs the live mapper `speedDemand`), so they only stall in the window
where those disagree; and the master's body channel runs `mj=0` and writes no joints, so its stall
is invisible -- the leg channel, whose case 0 falls through correctly, drives pose and travel.
## The load-bearing detail
`mech4.cpp`'s "stand; case 0 walk-begins next tick" is not an aside. A peer's body state is set from
`record->legState` only on **type-3 edges**, and ENTERING Standing emits one while LEAVING it does
not. Between gait-change records a replicant is REQUIRED to derive walking itself from the
replicated demand. The insertion removed that ability.
This also explains the shape of the field data: the lock needs a mech holding a STEADY demand, so a
mech whose gait keeps changing is continually rescued by records. Night 13's four episodes all carry
`bodyTargetSpeed` 39-48 held across 100-400 frames, and each ended when that mech next changed gait.
## 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 | fixed |
|---|---|---|
| STANDING-LOCK seconds | **336 consecutive**, `bspd=39.2324 bts=39.2324` identical every line | **0**, every pass |
| master body-Standing samples | 52 | 21 |
| turn-in-place | -- | body state 4 x9 / leg state 4 x8, armed in lockstep |
## What is NOT proven [T3]
That this accounts for the night-13 episodes. The lock is proven and proven removed; the link to the
field symptom is INFERENCE -- a locked peer has `bodyCycleSpeed==0` and never advances its clip, so
locked + translating IS the `[skate]` signature by construction -- but no bench caught the two
together. Four rigs failed to reproduce the symptom end-to-end.
The `[skate]` line now carries `bstate=`, which is the diagnostic tonight's logs lacked. Next
playtest settles it: episodes gone -> confirmed; any survivor names its own state.
**Correction for the record:** the night-12 `skatebench` "reproductions" (6 episodes, `sk_run.out`)
were a DETECTOR ARTIFACT, not this bug. The first detector build tested only `legCycleSpeed==0`,
which is NORMAL on a peer -- the body channel poses it -- so it fired on every healthy movement
phase. It was corrected the same day to require both channels idle. Old-format lines
(`legCycleSpeed=`, no `bodyCyc=`) are not evidence of anything.
Related: #130 (Vulture skating) is very likely the same defect -- re-test it against this build
before spending separate effort."""
gitea.comment(52, BODY)
gitea.comment(130,
"Cross-ref: #52's root cause was found and fixed 2026-08-07 (`6a96fb6`) -- a replicant could not "
"start walking between gait-change records, so a peer parked at Standing with a live replicated "
"demand kept its position advancing with a dead animation channel. That is the same shape as the "
"skating reported here. **Re-test this against a build newer than `6a96fb6` before investigating "
"separately.** Full write-up in #52.")
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# =========================================================================
# #142 CROUCH does not toggle -- name the gate.
#
# FIELD (Oracle, night 13): "the crouch button did not toggle to display
# crouched ... no state change ... the light next to it always flashes when
# pressed, but state does not change. Remains in stand mode."
#
# That is the EJECT bug's signature: the press reaches the handler (lamp
# responds) and a GATE silently declines it. DuckRequestMessageHandler
# always succeeds -- it just sets duckState=1. The CONSUMER (mech4.cpp) is
# where it dies:
#
# if (duckState != 0 && squatCapable != 0) {
# if (mapPosture == 1) squat;
# else if (mapPosture == 2) rise;
# else if (BT_DUCK_LOG) log; <- only diagnostic, env-gated
# duckState = 0;
# }
#
# squatCapable == 0 skips the whole block: no log, and the latch is not even
# consumed. A new ungated [duck] REQUEST DROPPED receipt covers both misses.
#
# Runs the SAME chassis twice is pointless -- squatCapable is per-model
# ('squ'/'sqd' clips shipped), so sweep several. CROUCH is button 0x13
# (shipped bindings: left column 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/
# display). BT_BTNTEST2 exists precisely for "crouch then rise".
# =========================================================================
set -x
V="${1:-madcat}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f cr_${V}.log
bt_expert_egg MP.EGG CR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=${V}/" CR.EGG
( export BT_BTNTEST=0x13,900,960 # CROUCH press
export BT_BTNTEST2=0x13,1500,1560 # and again (toggle back / retry)
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_KEY_NOFOCUS=1
bt_launch cr_${V}.log CR.EGG 0x03 )
for i in $(seq 1 60); do grep -aq "btntest" cr_${V}.log 2>/dev/null && break; sleep 2; done
sleep 45
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2
echo "=================== #142 CROUCH vehicle=$V ==================="
echo "--- 1. did the press reach the handler? ---"
grep -a "btntest" cr_${V}.log | head -4
grep -a "DuckRequest" cr_${V}.log | head -3
echo
echo "--- 2. THE GATE: why was it dropped? ---"
grep -a "REQUEST DROPPED" cr_${V}.log | head -4
echo -n "dropped-receipt count: "; grep -ac "REQUEST DROPPED" cr_${V}.log
echo
echo "--- 3. did a posture change actually happen? ---"
grep -aE "\[duck\] (SQUAT|RISE)" cr_${V}.log | head -4
echo -n "SQUAT/RISE events: "; grep -acE "\[duck\] (SQUAT|RISE)" cr_${V}.log
echo
echo "--- 4. does this chassis even ship the squat clips? ---"
grep -aiE "squatCapable|squ.*clip|sqd" cr_${V}.log | head -4
+59
View File
@@ -0,0 +1,59 @@
"""#142 -- correct the report and record what benching established. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
gitea.comment(142, """**RE-SCOPED 2026-08-07 -- the mech crouches fine; this is a missing PANEL ANIMATION.**
The original report ("the crouch button did not toggle to display crouched ... the light next to
it always flashes when pressed, but state does not change") reads like a locomotion or lamp bug.
Benching says it is neither.
## What works (benched, `scratchpad/night13/crouch142.sh` + `crouchmp.sh`)
* SOLO, madcat: both presses reach `DuckRequestMessageHandler`, zero drops, `[duck] SQUAT (posture
1 -> leg clip 2)` then `squat clip parked`, then `[duck] RISE`. A full toggle cycle.
* MULTIPLAYER, same chassis: identical -- 2 requests, 0 drops, SQUAT + RISE. So the MP path is not
refusing it either.
A receipt was added at the consumer's silent miss (`[duck] REQUEST DROPPED`, ungated) covering both
gates -- `squatCapable == 0` (which skips the consumer entirely AND leaves `duckState` latched at 1,
with no log at all today) and `mapPosture` not 1/2 (previously logged only under `BT_DUCK_LOG`,
which no player sets). Neither fired on madcat. Chassis without `squ`/`sqd` clips are still
untested; the bench takes a vehicle argument for that sweep.
## What the pilot actually sees
The crouch button's LAMP is momentary press feedback, not state. Traced with `BT_LAMP_LOG`:
PRESS -> [lamp] 0x13 <- 0x3c (lit)
SQUAT -> mech crouches, clip parked
RELEASE -> [lamp] 0x13 <- 0x14 (unlit) <-- while still CROUCHED
PRESS2 -> [lamp] 0x13 <- 0x3c
RISE -> mech stands
RELEASE -> [lamp] 0x13 <- 0x14
Crouched and standing are visually identical, so the lamp can never carry posture.
## What it is SUPPOSED to be (era testimony, this day)
The operator, correcting the framing: the crouch button is supposed to **animate a MECH SYMBOL next
to the button**, standing <-> crouching. Not a two-state lamp -- a missing animation.
VGL 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: *"Checks out with my memories."*
## Two gaps, and the second is the reported one
1. **No immobilization while crouched.** Nothing in the port gates movement on `duckState` or the
parked leg alarm -- grep of every consumer of both across `game/reconstructed/` finds no
speed/throttle/demand gate. Per Lynx the mech should be immobile until the button is pushed
again. Worth checking what a crouched mech that IS driven does today, since the leg clip is
parked -- a moving mech with a parked leg channel is the [skate] signature (#52).
2. **No stance symbol.** No gauge element anywhere in `btl4gau*.cpp` draws a posture/stance icon,
and the decomp has no crouch/squat/stance/duck graphic STRING (only substring false positives:
"existance", "distance"). So it is likely an authored IMAGE element on the secondary/radar MFD
rather than something findable by name -- next step is the secondary MFD's element list in the
binary, not another string search.
Not fixed. Re-scoped, with the locomotion half cleared and the real target named.""")
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# =========================================================================
# #142 CROUCH in MULTIPLAYER -- solo works, so MP is the variable.
#
# crouch142.sh proved a madcat crouches and rises cleanly SOLO: both presses
# reached DuckRequest, zero REQUEST DROPPED, SQUAT then RISE fired. The
# reporters were in MP, and the wording matters:
# Oracle: "the crouch button did not toggle to DISPLAY crouched ... no state
# change ... the light next to it always flashes when pressed, but
# state does not change. Remains in stand mode."
#
# Three things that could produce that with the mech itself working:
# (a) MP-only refusal -- some gate differs on a networked master
# (b) replication -- master squats, PEER never poses it
# (c) indicator -- mech squats, but the button LAMP / state readout
# never latches, so the pilot sees "stand"
#
# Same chassis as the passing solo run, so MP is the only changed variable.
# A presses crouch twice; B observes. Read all three layers.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f cm_a.log cm_b.log cm_relay.log
bt_expert_egg MP.EGG CM.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" CM.EGG
( export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1
bt_launch cm_b.log CM.EGG 0x0C -net 1601 )
sleep 2
( export BT_BTNTEST=0x13,900,960 BT_BTNTEST2=0x13,1800,1860
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
bt_launch cm_a.log CM.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py CM.EGG 127.0.0.1:1501 127.0.0.1:1601 > cm_relay.log 2>&1 &
RELAY=$!
sleep 240
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" cm_a.log | head -1 | cut -d= -f2)
echo "=================== #142 CROUCH in MP (A=${AID:-?}) ==================="
echo "--- (a) did A's press reach the handler, and was it dropped? ---"
grep -a "btntest" cm_a.log | head -4
echo -n "DuckRequests : "; grep -ac "DuckRequest" cm_a.log
echo -n "REQUEST DROPPED: "; grep -ac "REQUEST DROPPED" cm_a.log
grep -a "REQUEST DROPPED" cm_a.log | head -3
echo
echo "--- (b) did A's own mech actually change posture? ---"
grep -aE "\[duck\] (SQUAT|RISE|squat clip)" cm_a.log | head -6
echo -n "SQUAT/RISE on A: "; grep -acE "\[duck\] (SQUAT|RISE)" cm_a.log
echo
echo "--- (c) did the PEER pose the squat? (legState ships in the type-3 record) ---"
echo -n "B duck lines for A: "; grep -ac "\[duck\]" cm_b.log
grep -a "\[duck\]" cm_b.log | head -4
echo -n "B legState-2/3 sightings: "; grep -aoE "legState=[23]" cm_b.log | wc -l
echo
echo "--- (d) the INDICATOR: does anything latch a crouched state? ---"
grep -aiE "lamp.*0x13|stability|duckState" cm_a.log | head -6
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# =========================================================================
# #142 -- does CROUCH reset on respawn? (Oracle: "crouch wasn't resetting
# on respawn ... i guess mechs always spawn standing")
#
# Mech::Reset (@0049fb74) stands the mech up in the binary:
# *(this+0x398) = 0 duckState
# legStateAlarm -> 0 (this+0x39c)
# bodyStateAlarm -> 0 (this+0x714)
# death + leg/body reset latches, idleStrideScale = 1.0
# The port had dropped every one of those, so a pilot who died CROUCHED came
# back crouched.
#
# THE TEST: A crouches, then is killed while crouched, then respawns.
# PASS: after "[respawn] Mech::Reset", the leg alarm is 0 (standing) and
# duckState is 0 -- and no squat clip is parked.
# FAIL: legLvl stays 1 (parked in 'sqd') across the respawn.
#
# B force-damages A so the death lands while A is parked in the squat.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f dr_a.log dr_b.log dr_relay.log
bt_expert_egg MP.EGG DR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" DR.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch dr_b.log DR.EGG 0x0C -net 1601 )
sleep 2
# A: crouch early and STAY down, so the kill lands on a crouched mech
( export BT_BTNTEST=0x13,900,960 BT_SELF_DAMAGE=6
export BT_DUCK_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
bt_launch dr_a.log DR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py DR.EGG 127.0.0.1:1501 127.0.0.1:1601 > dr_relay.log 2>&1 &
RELAY=$!
sleep 260
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #142 CROUCH ACROSS RESPAWN ==================="
echo "--- did A crouch, then die, then respawn? ---"
grep -aE "\[duck\] (SQUAT|RISE)|death cycle START|Mech::Reset" dr_a.log | head -8
echo
echo "--- leg alarm around the respawn (1 = parked in 'sqd', 0 = standing) ---"
grep -aE "probe legLvl|Mech::Reset" dr_a.log | tail -12
echo
echo "--- the strip's frame after respawn (must NOT be the up-arrow) ---"
grep -a "probe legLvl" dr_a.log | tail -3
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# =========================================================================
# PANIC EJECT -> does the respawn replicate? (#108, night 13)
#
# Fourth attempt at the trigger. The first three drove the panic button
# through BT_BTNTEST (EmitButton -> RIO queue). It never reached the
# mapper: no [eject], no PUNCH-OUT, not even a lamp change -- and the same
# seam failed to toggle the searchlight (0x14), so it was the SEAM, not the
# button address. BT_EJECT_AT is the purpose-built hook (mech4.cpp:3310):
# it synthesizes the identical press inside the input path -- "one message,
# one dispatch", the same Mech::EjectPilotMessageID the key sends -- and
# repeats every 300 frames, so one run yields many punch-outs.
#
# PASS : for every punch-out on A, B logs
# "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
# FAIL : punch-outs on A with no matching un-wreck <- the ghost
#
# Control already banked (ejectghost.sh): 9 force-kill deaths -> 8 peer
# un-wrecks, 0 ghost lines. NORMAL death replication is good on this build,
# so an eject-only shortfall here is the defect, not a broken rig.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f ea_a.log ea_b.log ea_relay.log
bt_expert_egg MP.EGG EA.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EA.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_FOG_LOG=1
bt_launch ea_b.log EA.EGG 0x0C -net 1601 )
sleep 2
( export BT_AUTODRIVE=0.6 BT_EJECT_AT=1800
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch ea_a.log EA.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EA.EGG 127.0.0.1:1501 127.0.0.1:1601 > ea_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" ea_a.log | head -1 | cut -d= -f2)
echo "=================== PANIC-EJECT REPLICATION ==================="
echo "A entity: ${AID:-UNKNOWN}"
echo
echo "--- 1. did punch-outs actually happen? (if 0 the run is VOID) ---"
echo -n "DeathWithoutHonor notices : "; grep -ac "DeathWithoutHonor" ea_a.log
echo -n "PUNCH-OUT lines : "; grep -ac "PUNCH-OUT" ea_a.log
grep -a "PUNCH-OUT" ea_a.log | head -6
echo
echo "--- 2. A's own death/respawn cycles ---"
echo -n "death cycle STARTs : "; grep -ac "death cycle START" ea_a.log
echo -n "Mech::Reset : "; grep -ac "Mech::Reset ${AID:-@@@}" ea_a.log
echo
echo "--- 3. B: the peer's un-wrecks for A (THE NUMBER THAT MATTERS) ---"
echo -n "un-wrecks seen : "; grep -a "respawn\] replicant" ea_b.log | grep -ac "${AID:-@@@}"
grep -a "respawn\] replicant" ea_b.log | grep -a "${AID:-@@@}" | head -8
echo
echo "--- 4. B: ghost detector + searchlight cones ---"
echo -n "ghost lines : "; grep -ac "\[ghost\]" ea_b.log
grep -a "\[ghost\]" ea_b.log | head -4
echo -n "cone SHOWN/HIDDEN : "; grep -ac "\[spot\] cone" ea_b.log
echo
echo "--- 5. VERDICT INPUTS ---"
echo "punch-outs=$(grep -ac 'PUNCH-OUT' ea_a.log) A-respawns=$(grep -ac 'death cycle START' ea_a.log) B-unwrecks=$(grep -a 'respawn\] replicant' ea_b.log | grep -ac "${AID:-@@@}")"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# =========================================================================
# EJECT-GHOST bench (#108 / night 13) -- does a PANIC EJECT replicate its
# respawn to peers?
#
# FIELD REPORT: many ghost mechs tonight, all from panic-button self
# destructs, none respawning properly -- and the operator has seen
# eject-respawn work every time before tonight, so this is a REGRESSION
# (night 12 = build 774, tonight = 817).
#
# FIELD EVIDENCE (staged logs): the ejecting mech's OWNER ran the whole
# death path and respawned and kept driving in the same round, while BOTH
# peers created his wreck and never processed the un-wreck. The same mech's
# NORMAL deaths replicated their respawns 4-6 times in that same session.
#
# THE TEST: A punches out via the real click seam (button 0x3D, the panic
# button -- the same addr the field log shows). B watches.
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
# FAIL : B logs the wreck and never the un-wreck <- the ghost
# A CONTROL kill comes first (B force-damages A), so the SAME run shows a
# normal death replicating correctly -- otherwise a silent B proves nothing.
#
# Press polls: >=900 per the harness contract (round-start jitter eats
# earlier presses). Panic twice, so one missed press does not read as a
# pass.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f eg_a.log eg_b.log eg_relay.log
bt_assert_player_env
bt_expert_egg MP.EGG EG.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EG.EGG
# ---- node B: OBSERVER + the control killer -------------------------------
( export BT_MP_FORCE_DMG=1
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch eg_b.log EG.EGG 0x0C -net 1601 )
sleep 2
# ---- node A: walks, gets killed (control), then PUNCHES OUT twice --------
( export BT_AUTODRIVE=0.6
export BT_BTNTEST=0x3d,900,960 # panic eject #1
export BT_BTNTEST2=0x3d,2400,2460 # panic eject #2
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch eg_a.log EG.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EG.EGG 127.0.0.1:1501 127.0.0.1:1601 > eg_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" eg_a.log | head -1 | cut -d= -f2)
echo "=================== EJECT-GHOST RESULT ==================="
echo "A's entity id: ${AID:-UNKNOWN}"
echo
echo "--- did the scripted panic actually press? ---"
grep -a "btntest" eg_a.log
echo
echo "--- A: punch-outs + its own death/respawn cycle ---"
grep -aE "\[eject\]|PUNCH-OUT|death cycle START|dz\] GRANTED|Mech::Reset" eg_a.log | head -20
echo
echo "--- B: the peer's view of A, in order (wreck / un-wreck) ---"
grep -aE "wreck:|respawn\] replicant" eg_b.log | grep -a "${AID:-@@@}" | head -20
echo
echo "--- B: totals for A ---"
echo -n "wrecks seen : "; grep -a "wreck:" eg_b.log | grep -ac "${AID:-@@@}"
echo -n "respawns seen : "; grep -a "respawn\] replicant" eg_b.log | grep -ac "${AID:-@@@}"
echo
echo "--- B: ghost detector ---"
grep -a "\[ghost\]" eg_b.log | head -5
echo -n "ghost lines: "; grep -ac "\[ghost\]" eg_b.log
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =========================================================================
# EJECT + SEARCHLIGHT (#108, night 13) -- the operator's observation was
# that the ghost mechs all had their SEARCHLIGHTS ON. A plain panic eject
# respawns fine (ejectghost.sh), so the searchlight is the variable.
#
# WHY IT IS PLAUSIBLE, mechanically: the searchlight is the only thing that
# attaches EXTRA GEOMETRY into a peer's mech render tree -- btl4vid.cpp
# builds a spot.bgf cone as a DPLStaticChildRenderable parented to the
# lamp's mount-segment renderable. The death path then does a wreck swap
# ('victim -> thrdbr.bgf + debris') and respawn does 'rebuilt intact model
# (N segs restored, hulk dropped)'. A child renderable held across those
# two rebuilds is a lifetime hazard, and it would only bite mechs whose
# lamp was ON -- which is exactly the reported population.
#
# SEQUENCE on A: searchlight ON (0x14) -> confirm it lit -> panic eject
# (0x3D) -> respawn. B watches for the un-wreck.
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp"
# FAIL : wreck with no un-wreck <- the ghost, and the searchlight is it
#
# NB 0x14 is the searchlight per the shipped bindings comment (left column
# 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/display). The hardware name
# table calls it "Secondary5" -- the FUNCTION comes from the streamed .CTL
# rows, so the script VERIFIES the lamp actually lit before trusting the
# result; a silent B with a lamp that never came on proves nothing.
# =========================================================================
#
# MODE=light -- searchlight ON, then punch out
# MODE=nolight -- punch out with the lamp OFF (the A/B control)
#
# NB no BT_MP_FORCE_DMG here. ejectghost.sh had it, and that is why its
# panic presses did nothing: the "control killer" had A dead or mid-respawn
# for most of the run, and a punch-out press on a dead mech is a no-op.
# That run still produced its control result -- 8 force-kill deaths, 8 peer
# un-wrecks, so NORMAL death replication is proven good on this build.
set -x
MODE="${1:-light}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f el_${MODE}_a.log el_${MODE}_b.log el_${MODE}_relay.log
bt_expert_egg MP.EGG EL.EGG
# NIGHT map: the searchlight is a night system and the fog swap only has
# meaning there; also the field population was night-map rounds.
sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=night/; s/^vehicle=.*/vehicle=madcat/" EL.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SPLASH_LOG=1
bt_launch el_${MODE}_b.log EL.EGG 0x0C -net 1601 )
sleep 2
( export BT_AUTODRIVE=0.6
if [ "$MODE" = "light" ]; then
export BT_BTNTEST=0x14,600,660 # SEARCHLIGHT on
export BT_BTNTEST2=0x3d,1500,1560 # then punch out
else
export BT_BTNTEST=0x3d,1500,1560 # punch out, lamp OFF
fi
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch el_${MODE}_a.log EL.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EL.EGG 127.0.0.1:1501 127.0.0.1:1601 > el_${MODE}_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" el_${MODE}_a.log | head -1 | cut -d= -f2)
echo "=================== EJECT+SEARCHLIGHT RESULT ==================="
echo "A's entity id: ${AID:-UNKNOWN}"
echo
echo "--- 1. did the scripted presses land? ---"
grep -a "btntest" el_${MODE}_a.log
echo
echo "--- 2. DID THE SEARCHLIGHT ACTUALLY COME ON? (if not, the run is void) ---"
grep -aiE "searchlight|\[spot\]|lightState" el_${MODE}_a.log | head -10
echo
echo "--- 3. A: punch-out + own respawn ---"
grep -aE "\[eject\]|PUNCH-OUT|dz\] GRANTED" el_${MODE}_a.log | head -10
echo
echo "--- 4. B: the peer's view of A (wreck / un-wreck, in order) ---"
grep -aE "wreck:|respawn\] replicant" el_${MODE}_b.log | grep -a "${AID:-@@@}" | head -20
echo -n "wrecks: "; grep -a "wreck:" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
echo -n "respawns: "; grep -a "respawn\] replicant" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
echo
echo "--- 5. B: searchlight cones + ghost detector ---"
grep -aE "\[spot\]" el_${MODE}_b.log | head -6
echo -n "cone skipped: "; grep -ac "cone skipped" el_${MODE}_b.log
echo -n "ghost lines : "; grep -ac "\[ghost\]" el_${MODE}_b.log
grep -a "\[ghost\]" el_${MODE}_b.log | head -3
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# =========================================================================
# A REAL PANIC EJECT -- at last. (#108 ghost + the chart's "-1000 ejecting")
#
# WHY SIX RIGS FAILED. It was never the trigger. BT_EJECT_AT reaches the
# dispatch every time (proved with an [ejecttest] receipt: "FIRING punch-out
# at frame 600/900/1200"), and the HANDLER refuses it:
#
# [eject] 1:139 REFUSED (mech not crippled enough)
#
# Mech::EjectPilotMessageHandler gates on EvaluateEjectPermission() (@0x414) --
# a healthy mech cannot punch out. Every bench so far ejected a pristine mech.
# In the field players eject BECAUSE they are wrecked, which is why it works
# for them and never for me. (The button seam was never broken either; that
# earlier conclusion was wrong too.)
#
# So: CRIPPLE FIRST, then eject. BT_SELF_DAMAGE grinds A down; BT_EJECT_AT
# retries every 300 frames, so the first retry after permission is granted
# takes it.
#
# WHAT THIS SETTLES:
# chart "-1000 ejecting" -- the eject total, currently arithmetic only
# #108 eject-ghost -- does the peer un-wreck after an EJECT death?
# (normal deaths replicate fine: 9 deaths -> 8
# un-wrecks, benched)
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f er_a.log er_b.log er_relay.log matchlog_*.txt
bt_expert_egg MP.EGG ER.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" ER.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch er_b.log ER.EGG 0x0C -net 1601 )
sleep 2
# A: grind itself down, then punch out once permission is granted
# THE GATE, decoded from Mech::EvaluateEjectPermission (@0049fa1c):
# permitted = liveWeapons < ejectMinWeapons || liveGenerators == 0
# || coolantFrac < 0.05 || (leg-gimped && !simLive)
# Armour damage satisfies NONE of them -- which is why grinding A down gave
# 53 attempts and 785 refusals. Killing the GENERATORS is the direct lever,
# and BT_KILL_SUBSYS's comma-list form was built for precisely this bench.
# It fires at frame 900, so arm the eject after that.
( export BT_KILL_SUBSYS=GeneratorA,GeneratorB,GeneratorC,GeneratorD BT_EJECT_AT=1200
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_DMG_LOG=1
bt_launch er_a.log ER.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py ER.EGG 127.0.0.1:1501 127.0.0.1:1601 > er_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" er_a.log | head -1 | cut -d= -f2)
echo "=================== REAL EJECT ==================="
echo "A entity: ${AID:-UNKNOWN}"
echo "--- 1. did a punch-out finally LAND? ---"
echo -n "FIRING attempts : "; grep -ac "FIRING punch-out" er_a.log
echo -n "REFUSED : "; grep -ac "REFUSED" er_a.log
echo -n "PUNCH-OUT : "; grep -ac "PUNCH-OUT" er_a.log
grep -aE "PUNCH-OUT|DeathWithoutHonor" er_a.log | head -3
echo
echo "--- 2. CHART '-1000 ejecting': the score trajectory around it ---"
grep -ah "player=2:1 type=" matchlog_*.txt | tail -6 | cut -c1-115
grep -a "\[deathcost\]" er_a.log | head -2 | cut -c1-140
echo
echo "--- 3. #108: does the peer UN-WRECK after an eject death? ---"
echo -n "B wreck-enters for A : "; grep -a "entered wreck state" er_b.log | grep -ac "${AID:-@@@}"
echo -n "B un-wrecks for A : "; grep -a "un-wrecked + warp" er_b.log | grep -ac "${AID:-@@@}"
grep -aE "entered wreck state|un-wrecked \+ warp" er_b.log | tail -6 | cut -c1-120
echo -n "B ghost lines : "; grep -ac "\[ghost\]" er_b.log
+22
View File
@@ -0,0 +1,22 @@
import sys, re
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
rows = gitea.all_issues("all")
rows = [r for r in rows if not r.get("pull_request")]
print("TOTAL ISSUES: %d" % len(rows))
terms = {
"crouch": r"crouch",
"missile-dir": r"missile.*(direction|facing|foot|feet|track)|emitter",
"night-vis": r"night|darkness|visibilit|fog|thermal|infrared|\bIR\b|predator",
"smoke-all": r"smoke",
"layout-save": r"layout|glass_layout|\bsave\b",
"eject-splash": r"eject|panic|suicide|splash",
"torso-yaw": r"torso.*(twist|yaw)|feet.*fac",
}
for k, pat in terms.items():
print("\n=== %s ===" % k)
rx = re.compile(pat, re.I)
for r in rows:
if rx.search(r["title"]):
print(" #%-4s %-7s %s" % (r["number"], r["state"], r["title"][:110]))
+91
View File
@@ -0,0 +1,91 @@
"""Night 13: post the ghost/eject findings to #108 and settle #144. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
gitea.comment(108, """Night 13 (2026-08-06, build **4.11.817**) -- eject-ghosts, and why the logs barely saw them.
## Field finding
Testers reported MANY ghost mechs, all from panic-button self-destructs, none respawning
properly -- and the operator has seen eject-respawn work every time before this night, so this is a
REGRESSION, not a gap (night 12 = build 774).
The logs show it happening, on three independent witnesses. Dave punch-ejected; on HIS machine the
whole death path ran (`DeathWithoutHonor`, `PUNCH-OUT`, death transition, wreck, explosion), he
respawned, took a drop-zone slot, and drove another 193 logged frames **in the same round**, which
then ended normally -- no crash, no disconnect. On BOTH peers his mech's history ends at the wreck
and never resumes; host 2 vanishes from santo's world for the remaining ~4300 lines of that round.
The discriminator is clean: the SAME mech's NORMAL deaths replicated their respawns 4-6 times in
that same session. Only the eject failed.
## Why the ghost detector said almost nothing
One `[ghost]` line all night, for an unrelated live mech. The gate is
&& !owner->WreckBuried() // "buried wrecks are expected-silent"
WreckBuried() { return collisionVolumeCount == 0; }
An eject-ghost IS a wreck that stopped receiving records, so the detector files it under
expected-silent. It can only ever catch a LIVE replicant going quiet -- structurally blind to this
failure.
## Why the census said "one" when testers saw many
Counting ghosts meant pairing the un-wreck receipt against
`[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback` -- a MISSING-ASSET warning, not a
death, which only prints for chassis whose wreck model is absent. Fixed in `4642129`: every
replicant entering the wreck state now emits an ungated
[wreck] replicant H:E entered wreck state (mode X->9) at (x,z)
symmetric with the existing un-wreck line, so the count is exactly (enters - un-wrecks) per entity.
Verified 2-node: 5 enters / 5 exits, exactly paired -- **while the old marker printed ZERO times in
the same run**. Five real deaths, invisible to what the census was reading. Treat the "one ghost on
night 13" number as a floor, not a count.
Census tooling: `scratchpad/night13/ghostcensus.py` (NB: filter the log owner's OWN mech -- an
owner's own wreck can never pair, since `un-wrecked` only logs for replicants; not doing so
manufactures false positives in every log).
## NOT reproduced by bench -- five rigs failed to trigger a punch-out at all
`BT_BTNTEST` never reached the mapper for the panic button (0x3D) or the searchlight (0x14): no
`[eject]`, no `PUNCH-OUT`, not even a lamp change -- so it is the SEAM, not the address.
`BT_EJECT_AT` (mech4.cpp:3310, the purpose-built hook) did not fire either. Benches are staged
(`ejectghost.sh`, `ejectlight.sh`, `ejectat.sh`) with their failure modes in the headers.
What the benches DID establish: **normal death replication is healthy on 817** -- 9 force-kill
deaths, 8 peer un-wrecks, 0 ghost lines. So an eject-only shortfall is the defect, not a broken rig.
## Open lead
The operator observed that the ghost mechs all had their SEARCHLIGHTS ON. Untested -- there is
currently no key or env that toggles the searchlight headlessly, so it needs a small bench hook.
Mechanically plausible: the searchlight is the only thing that attaches extra geometry into a peer's
mech render tree (btl4vid.cpp builds a `spot.bgf` cone as a `DPLStaticChildRenderable` parented to
the lamp's mount-segment renderable), and the death path does a wreck swap while respawn does
"rebuilt intact model (N segs restored, hulk dropped)" -- a child renderable held across those two
rebuilds is a lifetime hazard, and it would only bite mechs whose lamp was ON.
## Regression window
Commits 775-817. Two touch the death path: `91bd286` (787) rewrote the VehicleDead dispatch --
the message that drives the respawn cycle -- including the `killer == victim` eject case and the
filter that had blocked the panic-eject path; and `297127d` (784, death blast). Unverified.""")
gitea.close(144, """**NOT A BUG -- authentic. Closing.**
The eject sets `suppressConsole` (+0x258, `BTPlayerEjectBookkeeping`), and the #89 death-blast gate
reads it:
gates : owning player's advancedDamageOn (+0x264) AND
NOT suppressConsole (+0x258 -- eject sets it: punch-outs never blast)
[T1 @0x4a0aa8-0x4a0ad6]
So a mech that punches out does not splash its neighbourhood, by design, in the 1995 binary. SAURON's
observation ("splash worked on mechs that died close, but no splash on a panic / eject / suicide
death") is a correct reading of authentic behaviour.
Closing so nobody "fixes" it. Found while investigating the night-13 eject ghosts (#108).""")
+85
View File
@@ -0,0 +1,85 @@
"""Night 13: census every 'wreck with no following un-wreck' across all logs.
A peer's view of a mech death is a pair:
[BTrender] wreck: ... (entity=H:E) <- the wreck appears
[respawn] replicant H:E un-wrecked + warp <- it comes back
An UNPAIRED wreck -- one with no un-wreck before that log's session ends --
is the ghost signature. Report per SESSION so 'was it just one game?' is
answerable, and dump the lines immediately before the first one.
"""
import re, sys, os, io
LOGDIR = r"C:\git\bt411\scratchpad\night13"
SESS = re.compile(r"===== BT411 SESSION.*?local=(\S+ \S+)")
WRECK = re.compile(r"\[BTrender\] wreck:.*?entity=(\d+:\d+)")
UNWRECK = re.compile(r"\[respawn\] replicant (\d+:\d+) un-wrecked")
EJECT = re.compile(r"\[eject\]|PUNCH-OUT")
def scan(path):
sessions = [] # (startline, stamp)
events = [] # (line, kind, ent)
with io.open(path, "r", encoding="latin-1", errors="replace") as f:
for n, line in enumerate(f, 1):
m = SESS.search(line)
if m:
sessions.append((n, m.group(1)))
continue
m = WRECK.search(line)
if m:
events.append((n, "wreck", m.group(1))); continue
m = UNWRECK.search(line)
if m:
events.append((n, "unwreck", m.group(1))); continue
if EJECT.search(line):
events.append((n, "eject", "-"))
return sessions, events
def session_of(sessions, line):
idx, stamp = 0, "?"
for i, (sl, st) in enumerate(sessions):
if sl <= line:
idx, stamp = i + 1, st
else:
break
return idx, stamp
for fn in sorted(os.listdir(LOGDIR)):
if not fn.endswith(".log") or fn.startswith("FAILURE"):
continue
path = os.path.join(LOGDIR, fn)
sessions, events = scan(path)
# pair wrecks to the next un-wreck of the same entity IN THE SAME SESSION
pending = {} # ent -> (line, sessidx)
unpaired = []
for (n, kind, ent) in events:
si = session_of(sessions, n)[0]
if kind == "wreck":
if ent in pending and pending[ent][1] == si:
unpaired.append(pending[ent]) # wreck superseded by another wreck
pending[ent] = (n, si)
elif kind == "unwreck":
if ent in pending and pending[ent][1] == si:
del pending[ent]
for ent, (n, si) in pending.items():
unpaired.append((n, si, ent))
norm = []
for u in unpaired:
norm.append(u if len(u) == 3 else (u[0], u[1], "?"))
norm.sort()
print("=" * 72)
print("%s sessions=%d wrecks=%d unwrecks=%d ejects=%d"
% (fn, len(sessions),
sum(1 for e in events if e[1] == "wreck"),
sum(1 for e in events if e[1] == "unwreck"),
sum(1 for e in events if e[1] == "eject")))
if not norm:
print(" no unpaired wrecks")
continue
bysess = {}
for (n, si, ent) in norm:
bysess.setdefault(si, []).append((n, ent))
for si in sorted(bysess):
stamp = sessions[si - 1][1] if 0 < si <= len(sessions) else "?"
print(" SESSION %d (%s): %d unpaired -> %s"
% (si, stamp, len(bysess[si]),
", ".join("%s@%d" % (e, n) for n, e in bysess[si][:6])))
+143
View File
@@ -0,0 +1,143 @@
"""Night 13 (2026-08-06, build 4.11.817) tracker housekeeping. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BUG, INV, WIP, HUD, AUDIO, NET, WORLD, AWAIT = 1, 2, 3, 4, 5, 6, 7, 8
SRC = ("Source: playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**), "
"Discord #play-testing. Field logs staged in `scratchpad/night13/` "
"(4 players + the pod cart).\n\n")
new = {}
# ---------------------------------------------------------------- NEW ISSUES
i = gitea.create(
"REGRESSION (817): BT_GLASS_LAYOUT=save writes a layout file with the MFD and Secondary lines MISSING",
SRC +
"SAURON (Michael), repeatedly: \"the save command is no longer writing to the glass_layout.cfg "
"file since this latest build\" ... \"got my glass panels set, saved and borders off again now\" "
"-> \"and then relaunched and it reset again\" -> \"looks like it rewrote the glass_layout.cfg "
"again\".\n\n"
"**The diagnostic detail:** \"the glass_layout.cfg had all the MFDs and secondary lines missing, "
"but plasma was still there\". So `save` is not failing to write -- it writes a file in which the "
"MFD and Secondary/Radar entries are simply absent, and the Plasma entry survives. On the next "
"launch there is nothing to restore, so every panel resets and the border/bare state is lost too.\n\n"
"**Workaround (confirmed by SAURON):** set `BT_GLASS_LAYOUT=load` and restore a hand-kept backup "
"copy of `glass_layout.cfg` -- \"set to load and used backup copy of CFG file and working aok so far\".\n\n"
"Probably the same root cause: vwe_propwash the same night -- \"my screen order borked again so "
"I'll need the trick to re-align them\".\n\n"
"**Suspect [T4, unverified]:** today's pod-MFD work is the only thing that touched this area. "
"Candidates, in order: (a) `e179c70` / `67a4f09` -- `BT_POD_RGB` and the bare-panel mode changed "
"how panel surfaces are enumerated and added the new `monitor:<name|index>` and `,bare` line "
"forms, so the WRITER may no longer emit a line for a surface it cannot express (or the panel "
"list it walks is now populated differently); (b) `d213c98` -- panel create/destroy moved out of "
"the PadRIO ctor into `LBE4ControlsManager`, which changes panel LIFETIME: if the save runs after "
"`BTGlassPanels_Destroy()`, the windows are already gone and there is nothing to serialize, which "
"would explain exactly this signature (Plasma is not a glass panel and is destroyed elsewhere, so "
"it alone survives).\n\n"
"Related: #76 (main cockpit + Plasma positions not restored) -- that is the older, much smaller "
"version of this; fix this one first, #76 may be a subset. Desktop glass mode only; the pod cart "
"is unaffected because it runs `BT_GLASS_LAYOUT=load` off a frozen master.",
labels=[BUG, HUD])
new['layoutsave'] = i['number']
i = gitea.create(
"Missiles launch along the LEG/FOOT facing, then curve to the target -- peer POV only",
SRC +
"Oracle: \"the missile thing we observed where missiles are firing in the direction the mech feet "
"are facing\" ... \"and then coming around to track the target\" ... \"**the emitter is following "
"the foot facing**\".\n\n"
"Confirmed by Oracle as **peer POV only** (epilectrik: \"only from peer pov though right\" -> "
"\"yes correct\"): from your own cockpit the launch looks right; on a REPLICANT the launch vector "
"comes off the leg/hip yaw instead of the torso/turret yaw. The homing itself works -- the missile "
"curves onto the target after launch -- so this is the muzzle/emitter TRANSFORM on the replicated "
"mech, not the guidance.\n\n"
"Reads as the replicant's weapon hardpoint being attached to (or composed against) the wrong node "
"in the segment hierarchy -- the leg/root segment rather than the twisted torso. Compare against "
"the master-side emitter, which is correct.\n\n"
"Related: #37 (MadCat torso is BACKWARDS), #70 (torso twist stops working after respawn) -- all "
"three are torso-yaw composition on a replicated model, and may share a cause.",
labels=[BUG, NET])
new['missiledir'] = i['number']
i = gitea.create(
"CROUCH does not toggle: the lamp flashes on every press but the mech stays STANDING",
SRC +
"Reported by Oracle and previously by SAURON. Oracle: \"the crouch button did not toggle to "
"display crouched\" ... \"no state change\" ... \"button flickers sometimes on press, **the light "
"next to it always flashes when pressed**, but state does not change. Remains in stand mode\".\n\n"
"So the INPUT is arriving and the annunciator responds -- the press is seen all the way to the "
"lamp -- but the crouch state itself never changes. That narrows it to the state transition / "
"gait request rather than the button wiring.\n\n"
"**Not to be confused with (and NOT a bug):** throttle-up does not stand you back up. Settled this "
"night by primary source -- Lynx, quoting the original manual: \"Throttle up should not make you "
"stand. Verified in manual. You must push the button again to stand. I agree that throttle up "
"'should' do that, but it's not designed that way.\" Standing requires a second CROUCH press, "
"which makes this bug worse than it looks: with no working toggle there is no designed way out of "
"crouch. Locomotion/CROUCH background: `context/locomotion.md`.",
labels=[BUG])
new['crouch'] = i['number']
i = gitea.create(
"One panel hit makes smoke erupt from MULTIPLE/ALL locations at once (seen on the sensor special panel)",
SRC +
"Oracle: \"the smoke sometimes emitting from multiple locations all at once when a location like "
"the sensor special panel is hit during testing. I saw this several times. It's not just with the "
"special panel, but it might have something to do with reaching a certain damage threshold which "
"is fairly easy to do quickly with a special panel. So you hit the special with an alpha and "
"suddenly the entire target mech emits smoke from all panels.\"\n\n"
"**Open question from the reporter, and the key discriminator:** \"What I'm not sure about is if "
"it's emitting only from previously damaged panels.\" If it is only previously-damaged panels, "
"this is a threshold that re-triggers the effect on every already-damaged zone at once (a "
"retrigger/latch bug). If it is genuinely ALL panels, the emitter is being attached per-mech "
"instead of per-zone. Answer that first -- it picks the fix.\n\n"
"Suspected trigger is a whole-mech damage THRESHOLD being crossed rather than the specific zone, "
"which a special-panel alpha reaches quickly.\n\n"
"Related: #90 (flames/smoke render less often and shorter than the original), #114 (missile impact "
"smoke too thick), #129 (closed: respawned mech emitted the wreck plume) -- same damage-effect "
"emitter family.",
labels=[BUG, INV])
new['smokeall'] = i['number']
i = gitea.create(
"No SPLASH damage from a PANIC/EJECT/suicide death (splash from normal deaths now confirmed working)",
SRC +
"SAURON: \"Splash damage looked good to me, splash worked on mechs that died close, **but no "
"splash on a panic / eject / suicide death**.\"\n\n"
"Filed as the leftover sub-case of #89, which is otherwise verified working this night and closed.\n\n"
"**Authenticity is NOT established** -- do not 'fix' this before deciding what the original did. A "
"panic eject is plausibly a different death path (the pilot leaves; whether the chassis still "
"detonates with an explosion payload is an open era question), so a self-destruct that does no "
"splash could be correct. Decide from the binary's eject/death path first: does the eject route "
"raise the same explosion object as a combat death, and does that object carry the splash payload?\n\n"
"Related: #89, #106 (closed: splash burstCount dropped), #118/#109 (EJECT/PANIC wiring), #134 "
"(closed: panic eject score penalty).",
labels=[INV])
new['ejectsplash'] = i['number']
i = gitea.create(
"ThermalSight / IR ('predator vision'): the VISIBLE half is unimplemented -- and it was never a heat image",
SRC +
"epilectrik: \"IR we solved but I think haven't implemented yet\" -- decode exists, presentation "
"does not, and there was no tracker item for the visible half (#61 covers the dead ToggleLamp "
"handler; #123 is the same shape for the searchlight).\n\n"
"**Primary-source description of what it should look like** -- Oracle, who played the original "
"pods at Lazer Park, this night:\n"
"- \"the IR was not what you would expect ... it was called predator vision, but it just looked "
"more like a **random palette shift**\"\n"
"- \"it was **not a heat image**\"\n"
"- \"which is why we generally called it LSD vision in Lazer Park\"\n"
"- \"IMHO it did nothing to improve visibility. All I found was that you had to look for some "
"movement and go after that\"\n"
"- epilectrik's recollection: it was a **test mode in the TH division hardware** that they "
"switched on, so it was not a specifically designed effect. Oracle: \"hardware optimized\".\n\n"
"**Why this matters for the port [T3]:** do not build a thermal/heat-gradient shader. The target "
"is a palette/colour-table transform on the existing image -- most likely whatever the 1995 "
"hardware path did when its test mode was enabled. Scope it from the decomp (and, if it really is "
"a hardware mode, decide what the honest modern equivalent is) before writing anything.\n\n"
"Related: #61 (ThermalSight ToggleLamp), #123 (searchlight visible half -- the same "
"toggle-works/presentation-deferred split).",
labels=[INV, WORLD])
new['ir'] = i['number']
print()
print("NEW:", new)
+66
View File
@@ -0,0 +1,66 @@
"""Night 13 housekeeping, part 2: comments + state changes on existing issues."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
N13 = "Playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**). "
# ---- #89 SPLASH: verified working, close -----------------------------------
gitea.close(89,
N13 + "**VERIFIED WORKING -- closing.**\n\n"
"SAURON: \"Splash damage looked good to me, **splash worked on mechs that died close**\". "
"epilectrik: \"splash damage seems ok?\" -- no dissent from any tester.\n\n"
"Oracle (original-pod player) also gave the era calibration that explains why splash reads as "
"inconsistent rather than absent, and says our current behaviour matches it: \"sometimes you "
"really catch it and sometimes not. It jogged my memory and I vividly recall that **being at "
"different elevations on a hillside sometimes made a real hash of it** -- the effect could be much "
"stronger or weaker\" ... \"it was **not** the mechwarrior 3/4 splash model\". SAURON: \"that seems "
"correct to me\". So an unpredictable, elevation-sensitive splash is AUTHENTIC and should not be "
"'fixed' into a clean radius falloff.\n\n"
"One sub-case survives and is filed separately as **#144**: no splash on a panic/eject/suicide "
"death (authenticity not yet established).")
# ---- #108 GHOST MECH: recurred ---------------------------------------------
gitea.comment(108,
N13 + "**Still present, and it was the headline problem of the night.** epilectrik: \"**ghost mech "
"biggest surprise**\" when reviewing regressions.\n\n"
"No new detail beyond the existing repro in this issue -- recording the recurrence on 817 so the "
"issue is not read as stale. Field logs for all four players (3 desktop + the pod cart) are staged "
"in `scratchpad/night13/`, including the pod's matchlogs, which is the first time this issue has "
"log coverage from a hardware seat as well. Analysis: `docs/GHOST_MECH_ANALYSIS.md`.")
# ---- #76 layout restore: superseded by #140 --------------------------------
gitea.comment(76,
N13 + "**Overtaken by #140.** As of 817 the problem is no longer 'two windows fail to restore' -- "
"`BT_GLASS_LAYOUT=save` now writes a `glass_layout.cfg` with the MFD and Secondary lines missing "
"entirely (Plasma survives), so nothing restores at all. See #140 for the reports and the suspect "
"commits.\n\n"
"Fix #140 first; this issue may turn out to be a subset of it. Re-verify the two windows named "
"here only once saving writes a complete file again.")
# ---- #123 searchlight / night visibility: era testimony --------------------
gitea.comment(123,
N13 + "**Primary-source testimony on night visibility** from Oracle, who played the original pods "
"at Lazer Park. This is directly about the deferred half of this issue (the fog swap), and it "
"raises the priority of the fog over the beam:\n\n"
"- \"some maps were not very dark, some were very dark\"\n"
"- \"**the FOG was what really killed visibility**\"\n"
"- \"I do recall basically stumbling into each other point blank at times and **having to use "
"radar to navigate**\"\n"
"- \"it could be very difficult to make a shot in those conditions\"\n"
"- \"it was 90's tech there\"\n\n"
"Takeaway for the implementation: the authentic night experience is driven by FOG DENSITY, not by "
"ambient darkness, and it was severe enough that radar navigation was the norm. The "
"`searchlightfog=` / `nosearchlightfog=` environment swap described above is therefore the "
"high-value half of this work, not the projected beam.\n\n"
"Operator note the same night: general night-visibility tuning is **deferred** until the correct "
"behaviour is known -- epilectrik: \"the night time visibility I might defer for now since we "
"don't really know the correct behavior ... I'll try to retrieve it from the binary again but "
"might be a hardware side thing\". This comment is that missing calibration.")
# ---- #61 ThermalSight: point at the new visible-half issue -----------------
gitea.comment(61,
N13 + "Cross-ref: the **visible** half of ThermalSight now has its own issue, **#145**, with "
"primary-source description of what the IR mode actually looked like (a palette shift, explicitly "
"NOT a heat image -- possibly a hardware test mode rather than a designed effect). Worth reading "
"before anyone implements a presentation off the toggle this issue restored.")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# =========================================================================
# #140 -- reach the teardown the way the FIELD does: a MISSION that ENDS.
#
# Three earlier attempts never exercised BTGlassPanels_Destroy at all:
# bt_kill_ours uses `taskkill /F` (no dtors), and a graceful WM_CLOSE on a
# solo -egg run did not reach it either. The path that matters is the one
# testers hit constantly -- the controls manager is destroyed and rebuilt at
# every ROUND boundary, which is exactly what the relay drives in MP.
#
# Panels ON for both nodes; the relay starts a round and the round ends.
# The receipt to read is `[glasswin] destroy entry #N windows=M`:
# #1 windows=7 then #2 windows=0 -> the double-destroy, confirmed
# and then exactly ONE "saved N window position(s)" line, with the cfg intact.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg lr_a.log lr_b.log lr_relay.log
bt_expert_egg MP.EGG LR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" LR.EGG
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
bt_launch lr_b.log LR.EGG 0x0C -net 1601 )
sleep 2
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
bt_launch lr_a.log LR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py LR.EGG 127.0.0.1:1501 127.0.0.1:1601 > lr_relay.log 2>&1 &
RELAY=$!
sleep 180
kill $RELAY 2>/dev/null
sleep 5
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== #140 TEARDOWN RECEIPTS ==================="
for n in a b; do
echo "--- node $n: destroy entries ---"
grep -a "destroy entry" lr_${n}.log
echo "--- node $n: SaveLayout receipts ---"
grep -a "window position" lr_${n}.log
done
echo
echo "--- glass_layout.cfg entries ---"
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# =========================================================================
# #140 glass_layout.cfg save regression -- the FIELD composition.
#
# SAURON's report: "set my glass panels, saved and borders off ... relaunched
# and it reset again ... the glass_layout.cfg had all the MFDs and secondary
# lines missing, but plasma was still there".
#
# So the acceptance test is the full ROUND TRIP a tester does, not just a
# single launch:
# run 1 -- BT_GLASS_LAYOUT=save, panels come up, quit cleanly
# check -- the cfg must list every MFD + the radar, not just plasma
# run 2 -- relaunch; the panels must come back where they were
#
# The teardown is what mattered: BTGlassPanels_Destroy has TWO callers on the
# desktop path (~PadRIO, then ~LBE4ControlsManager), and it ran SaveLayout
# BEFORE checking whether any windows were left -- so the second pass rewrote
# the file from an empty list.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg ls_run1.log ls_run2.log
# ---- run 1: create the layout ------------------------------------------
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch ls_run1.log ARENA1.EGG 0x03 )
sleep 55
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "############ RUN 1 SAVE RESULT ############"
echo "--- every 'saved N window position(s)' line (the double-save shows here) ---"
grep -a "window position" ls_run1.log
echo
echo "--- glass_layout.cfg AFTER a clean quit ---"
if [ -f glass_layout.cfg ]; then cat glass_layout.cfg; else echo "!!! NO FILE WRITTEN"; fi
echo
echo "--- line census (comments excluded) ---"
echo -n "total entries : "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
echo -n "MFD lines : "; grep -acE "^(Heat|Comm|Mfd|Eng|Weap|Sec)" glass_layout.cfg 2>/dev/null
echo -n "plasma line : "; grep -ac "Plasma" glass_layout.cfg 2>/dev/null
cp glass_layout.cfg /tmp/ls_after_run1.cfg 2>/dev/null
# ---- run 2: does it RESTORE? -------------------------------------------
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch ls_run2.log ARENA1.EGG 0x03 )
sleep 55
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "############ RUN 2 (RELAUNCH) ############"
echo "--- restore receipts ---"
grep -aE "restored|glass_layout|window position" ls_run2.log | head -12
echo
echo "--- cfg after run 2 -- must still hold every line ---"
cat glass_layout.cfg 2>/dev/null
echo
echo "--- DIFF run1 -> run2 (empty = layout survived the round trip) ---"
diff /tmp/ls_after_run1.cfg glass_layout.cfg && echo "IDENTICAL"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# =========================================================================
# #140 -- exercise the TEARDOWN path, which is where the bug lives.
#
# WHY THIS EXISTS: bench_common's bt_kill_ours uses `taskkill /F`. That is a
# HARD kill -- no destructors, so ~LBE4ControlsManager / ~PadRIO never run and
# BTGlassPanels_Destroy (the function that saves, and the one that ran twice)
# is never reached. Both of the first attempts died before teardown and the
# result looked like a difference between builds when it was really a
# difference in how far each run got. A graceful `taskkill` (NO /F) posts
# WM_CLOSE and lets the dtor chain run.
#
# Pass a label; run it once on the pre-fix build and once on the fixed one.
# =========================================================================
LABEL="${1:-run}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg lt_${LABEL}.log
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch lt_${LABEL}.log ARENA1.EGG 0x03 )
# wait for the panels to actually exist before asking for a shutdown
for i in $(seq 1 60); do
grep -aq "per-display cockpit up" lt_${LABEL}.log 2>/dev/null && break
sleep 2
done
grep -aq "per-display cockpit up" lt_${LABEL}.log || { echo "PANELS NEVER CAME UP"; }
sleep 20 # let it settle into the mission
PID=$(cat "$BT_PIDFILE" 2>/dev/null | head -1)
echo "graceful close of pid $PID"
taskkill //PID "$PID" > /dev/null 2>&1 # NO /F -- WM_CLOSE, dtors run
for i in $(seq 1 30); do
tasklist //FI "PID eq $PID" 2>/dev/null | grep -q btl4 || break
sleep 1
done
taskkill //F //IM btl4.exe > /dev/null 2>&1 # backstop
rm -f "$BT_PIDFILE"
sleep 2
echo "=============== $LABEL ==============="
echo "--- every SaveLayout receipt (TWO lines = the double-save) ---"
grep -a "window position" lt_${LABEL}.log
echo
echo "--- glass_layout.cfg entries after teardown ---"
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
echo
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo 0
+13
View File
@@ -0,0 +1,13 @@
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
labels = gitea.call("/labels")
print("LABELS:", ", ".join("%s=%s" % (l["id"], l["name"]) for l in labels))
print()
rows = [r for r in gitea.all_issues("open") if "pull_request" not in r or not r.get("pull_request")]
rows.sort(key=lambda r: r["number"])
print("OPEN ISSUES: %d" % len(rows))
for r in rows:
lab = ",".join(l["name"] for l in r.get("labels", []))
print("#%-4s [%-22s] %s" % (r["number"], lab, r["title"][:120]))
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# ⚠ READ THE PREFIX, NOT THE PERCENTAGE (learned the hard way, 2026-08-08).
# BT_AUTOFIRE starts shooting IMMEDIATELY, while the app is still in
# WaitingForLaunch/LaunchingMission -- and Entity::Execute (ENTITY.cpp:556,
# real engine source) only calls PerformAndWatch when the state is
# RunningMission/EndingMission or the entity IsPreRunnable(). A REPLICANT mech
# is not pre-runnable (Entity::DefaultFlags has no PreRunFlag; only Player /
# Director add it, and Mech::Reset sets it for a reset MASTER), so a peer mech
# does not tick at all until the round actually starts. Every salvo fired
# before that reads twistDelta=0 legitimately -- the peer has no twist yet.
# That is a BENCH artifact (nobody can fire pre-round in a real match), not a
# defect: it showed up as a clean leading run of zeros, e.g.
# ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
# and the first X lands within a few lines of the RunningMission transition.
# So: judge this bench by whether the failures are a PREFIX (fine) or
# INTERLEAVED (real), never by the raw percentage.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 170
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1 BT_NET_TRACE=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 170
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# =========================================================================
# Sauron: "toggled through advanced controls from standard to advanced and
# back to standard -- lost torso control."
#
# THE MECHANISM (@004afbe0, the binary's CycleControlModeMessageHandler):
# the mode cycles 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC. So
# getting from Veteran/"advanced" back to Standard PASSES THROUGH BASIC, and
# the Basic arm re-centres the torso.
#
# The port set that re-centre with CommandRecenter() -> centerCommand (@0x208).
# That is the HELD-BUTTON cell: TorsoSimulation re-arms `recenterActive` from
# it EVERY frame it is non-zero and only the input path clears it -- and a mode
# switch has no button release to follow. One visit to Basic pinned it at 1
# forever. Digital twist commands are processed BEFORE the centerCommand
# block, so they were overridden as fast as they were applied = "lost torso
# control". The binary writes recenterActive (@0x274) instead: a ONE-SHOT that
# self-clears on settle and is cancelled by any twist input.
#
# THE MEASUREMENT. BT_MODECYCLE_EVERY=<n> cycles the control mode every n mapper ticks
# frames from that frame. BT_TORSO_LOG's gate probe now prints the two cells:
# [torso] ... ctrCmd=<centerCommand> recen=<recenterActive> vLim=(lo..hi)
#
# PASS: ctrCmd stays 0 across every cycle (the one-shot is used instead), and
# vLim SWAPS between the Basic pair and the assisted pair as the mode
# changes -- proving the elevation-limit swap (@0x228/@0x22C vs
# @0x230/@0x234) that the port previously never implemented.
# FAIL: ctrCmd latches to 1 after the first pass through Basic and never
# returns to 0 -> the torso re-centres forever.
#
# Single node: this is entirely local control state, no peer needed.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mc_a.log
bt_expert_egg MP.EGG MC.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MC.EGG
( export BT_MODECYCLE_EVERY=400
export BT_TORSO_LOG=1 BT_KEY_NOFOCUS=1 BT_KEY_BRIDGE=0 BT_TWIST_PULSE=150 ${LEGACY:+BT_LEGACY_MODE_RECENTER=1}
bt_launch mc_a.log MC.EGG 0x03 )
sleep 150
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== CONTROL-MODE TORSO STATE ==================="
echo "--- the mode cycles that happened ---"
grep -a "\[mode\] control mode" mc_a.log | head -10
echo
echo "--- centerCommand must NEVER latch (ctrCmd=1 with no button = the bug) ---"
echo -n " samples with ctrCmd=1 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=1"
echo -n " samples with ctrCmd=0 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=0"
echo
echo "--- the elevation-limit SWAP (should differ between Basic and assisted) ---"
grep -ao "vLim=([^)]*)" mc_a.log | sort | uniq -c | sort -rn | head -5
echo
echo "--- torso state around each mode change ---"
grep -aE "\[mode\] control mode|ctrCmd=" mc_a.log | grep -aA1 "\[mode\]" | head -12
+88
View File
@@ -0,0 +1,88 @@
"""Night 13: rewrite #147's body (the inline attempt was mangled by the shell). ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**Reported by Oracle (2026-08-06 session):** "no range finder on this drop" + a screenshot.
Symptom confirmed by eye: **the ladder tick marks were present, the moving caret was not.**
Intermittent -- one drop, not every drop. He was the only tester who hit it.
## What it is NOT
Ruled out from the four field logs:
* **Not a Steam-host bug.** Oracle *was* hosting (`[lobby] host: lobby up` / `GO with N member(s)`
appears in his log and no other), but the host path is uninvolved: the range **computed** fine on
his node (1806 nonzero `[target] range=` samples) and the reticle **built** fine on every drop
(`[hud] reticle built: 7 weapon pip(s) registered` -- 6 drops, 6 builds).
* **Not the Thor chassis.** He flew a Thor, which is why the report reads as chassis-specific -- but
a second tester flew a Thor the same night *without* hosting and reported nothing, and the range
ladder is drawn by the shared `HudSimulation` / `BTReticleRenderable`, not per-chassis cockpit
content. No Thor asset-missing warnings in any log (only the known wreck-model fallbacks).
* **Not his destroyed HUD.** His HUD subsystem *was* destroyed twice -- the only tester to reach
`condition 0` all night -- but only for ~11 s and ~26 s, repaired by respawn each time. And a
destroyed HUD costs you the fire-control LOCK (own host zone >= 0.75 damage, `_DAT_004b7ec4`),
not the caret.
## The defect
`sShownRange` -- the value the caret ultimately binds to -- is a **function-level static** in
mech4.cpp's targeting step. One cell for the whole process, shared by every mech, carried across
drops, never re-seeded. Its update:
```c
float step = trueRange - sShownRange;
if (step > maxStep) step = maxStep;
if (step < -maxStep) step = -maxStep;
sShownRange += step;
```
**NaN is absorbing here, and the clamps cannot catch it** -- `step > maxStep` and `step < -maxStep`
are BOTH false for NaN. So a single poisoned frame makes `sShownRange` NaN and it stays NaN *for
the life of the process*.
The consumer repeats the same mistake -- `BTReticleRenderable::Draw`:
```c
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
if (range < minRange) range = minRange; // false for NaN
if (range > maxRange) range = maxRange; // false for NaN
Scalar frac = (range - minRange) / (maxRange - minRange);
```
NaN flows into `AddPoint` / `ConcatMatrix`, so the caret and its bar become **degenerate geometry
and stop rendering** -- while every static reticle element, tick marks included, still draws.
That is exactly the reported symptom: ticks present, caret gone, sticky until relaunch.
## Why no log could confirm it
**The caret's actual input had no diagnostic anywhere.** `BT_RANGE_LOG` instruments the *pick*
(#4), and the `range=` field in `[target]` is a separate, locally recomputed
`Sqrt(ddx*ddx + ddy*ddy + ddz*ddz)` inside the weapon-range check -- it is neither `sShownRange`
nor `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.
## Fixed (unreleased)
1. **Re-seed on mech change** -- a new drop starts at the binary's 1200 default instead of
inheriting the previous mission's slid value. Deliberately does NOT fire on respawn: that
reuses the entity, and the binary does not reset the readout on respawn either.
2. **NaN trap at the producer** -- re-seed to 1200 rather than propagate.
3. **NaN-safe clamp at the consumer** -- test `x == x` first, and fall back to the authentic
no-target peg (1200) instead of rendering nothing.
4. **`BT_RANGE_LOG` now prints the caret's real input** -- `[range] caret input shown=... true=...
lock=...` at 1 Hz, plus a `[range] NaN TRAPPED` receipt.
## Status [T3 -- honest]
The defect and the symptom match exactly, and the fix is correct on its own merits: a
process-lifetime static feeding unguarded float geometry is a bug regardless of who reported what.
But **the causal link to Oracle's report is INFERENCE, not proof.** The NaN source is unidentified
and the failure has not been reproduced. What would settle it: fly with `BT_RANGE_LOG=1` -- if the
caret dies again, the log now names the frame it happened on.
"""
gitea.call("/issues/147", method="PATCH", payload={"body": BODY})
print("rewrote #147 body")
+49
View File
@@ -0,0 +1,49 @@
"""Audit Mech::Reset (@0049fb74) -- every field the binary clears vs ours.
Motivated by finding EIGHT dropped posture clears (duckState, both state alarms,
the death/reset latches, idleStrideScale) in the port's Reset, and by #137
"respawn came back with MYOMERS heat MAXED" + Sauron's overheating generator D:
if the reconstruction dropped the posture clears it may have dropped others.
"""
import io, re
DECOMP = r"C:\git\bt411\reference\decomp\all\part_012.c"
HPP = r"C:\git\bt411\game\reconstructed\mech.hpp"
CPP = r"C:\git\bt411\game\reconstructed\mech4.cpp"
# --- 1. every offset the BINARY's Reset touches ---------------------------
lines = io.open(DECOMP, encoding="latin-1", errors="replace").readlines()
body = "".join(lines[14191:14400]) # FUN_0049fb74
bin_off = sorted({int(m, 16) for m in re.findall(r"param_1 \+ (0x[0-9a-f]+)", body)})
# --- 2. offset -> our field name, from mech.hpp's offset comments ---------
name_of = {}
for ln in io.open(HPP, encoding="latin-1", errors="replace"):
m = re.search(r"^\s*(?:[A-Za-z_][\w:<>* ]*?)\s+(\w+)\s*(?:\[\d+\])?\s*;.*?(?:@|//\s*)0x([0-9a-fA-F]+)", ln)
if m:
name_of.setdefault(int(m.group(2), 16), m.group(1))
# --- 3. what OUR Reset assigns -------------------------------------------
cpp = io.open(CPP, encoding="latin-1", errors="replace").read()
i = cpp.find("Mech::Reset(const Origin &origin, int mode)")
ours_body = cpp[i:i + 9000] if i != -1 else ""
ours = set(re.findall(r"^\s*(\w+)\s*(?:\.\w+\([^)]*\)|=)", ours_body, re.M))
print("Mech::Reset @0049fb74 -- %d distinct fields touched by the binary\n" % len(bin_off))
missing, covered, unknown = [], [], []
for off in bin_off:
nm = name_of.get(off)
if nm is None:
unknown.append(off)
elif nm in ours:
covered.append((off, nm))
else:
missing.append((off, nm))
print("COVERED by our Reset (%d):" % len(covered))
print(" " + ", ".join("%s@0x%x" % (n, o) for o, n in covered) + "\n")
print("*** NOT CLEARED by our Reset (%d) -- named fields the binary resets ***" % len(missing))
for o, n in missing:
print(" 0x%-5x %s" % (o, n))
print("\nUNMAPPED offsets (%d) -- no named field at that offset in mech.hpp:" % len(unknown))
print(" " + " ".join("0x%x" % o for o in unknown))
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# =========================================================================
# SELF-INFLICTED scoring + DEATHS counter (night 13).
#
# Two chart rows and one field complaint are still unverified:
# "-1 each self-inflicted point of armor damage" -- code-verified only
# (ScoreInflictedMessageHandler's negate-if-target-is-self arm); every
# previous run reported negative(self)=0 because nobody self-damaged.
# "-1000 destroying your own 'Mech by ejecting" -- arithmetic only
# (-500 death cost + a self-kill negating its own ~500 award). A
# SELF-DESTRUCT reaches the same two paths without needing the panic
# button, which five rigs failed to trigger.
# "kills/deaths counts were screwy" -- kills=1 is verified, deathTally is not.
#
# A self-destructs (BT_SELF_DAMAGE, inflictor = SELF); B stands off.
# Composition from night12/scoreself.sh, which was built for #134.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f s2_a.log s2_b.log s2_relay.log matchlog_*.txt
bt_expert_egg MP.EGG S2.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" S2.EGG
( export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch s2_b.log S2.EGG 0x0C -net 1601 )
sleep 2
( export BT_SELF_DAMAGE=40
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch s2_a.log S2.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py S2.EGG 127.0.0.1:1501 127.0.0.1:1601 > s2_relay.log 2>&1 &
RELAY=$!
sleep 280
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== SELF-SCORE + DEATHS ==================="
echo "--- 0. role bound? (if NULL every number below is meaningless) ---"
grep -a "\[role\] player" s2_a.log | head -3
echo
echo "--- 1. CHART '-1 each self-inflicted point': type=0 with award < 0 ---"
grep -ah "type=0 award=-" matchlog_*.txt | head -6 | cut -c1-115
echo -n "negative type=0 rows: "; grep -ahc "type=0 award=-" matchlog_*.txt | paste -sd+ | bc 2>/dev/null || grep -ah "type=0 award=-" matchlog_*.txt | wc -l
echo
echo "--- 2. CHART '-1000 ejecting' components: self-kill negation + death cost ---"
echo "self-kill (type=2, award must be NEGATIVE, kills must NOT increment):"
grep -ah "SCORE.*type=2" matchlog_*.txt | head -4 | cut -c1-115
echo "death cost (-specialCaseDeathPenalty, expect -500):"
grep -ah "type=1 award=-5" matchlog_*.txt | head -3 | cut -c1-115
echo
echo "--- 3. DEATHS counter (the other half of the field report) ---"
grep -ah "PLAYER_DEAD" matchlog_*.txt | head -5 | cut -c1-115
echo -n "death cycles on A: "; grep -ac "death cycle START" s2_a.log
echo
echo "--- 4. net score trajectory for the self-destructor ---"
grep -ah "player=2:1 type=" matchlog_*.txt | tail -4 | cut -c1-115
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =========================================================================
# SCORING verify -- the type-0 interceptor (@004bffa0) restored.
#
# Composition lifted from night12/scorekill.sh, which is known to produce a
# clean cross-node kill: A (madcat, shooter) zone-walk-hammers B (loki,
# spinner) at 90u until B dies.
#
# THE A/B IS READABLE IN ONE RUN, because the old behaviour left a receipt:
# BEFORE every inflicted report hit ScoreMessageHandler's type-0 arm and
# tripped Verify "ScoreMessageHandler should not be given
# DamageInflictedScoreMessages!" -- night12's bench listed those
# Verify prints as an expected PASS signal.
# AFTER the interceptor routes type 0 to ScoreInflictedMessageHandler, so
# those Verify prints must be GONE and matchlog SCORE type=0 rows
# with non-zero awards must appear instead.
#
# Chart cross-check (original manual, via Lynx): "+1 each damage point scored
# on opponent's armor". The handler scales by tonnage ratio and the role's
# damageInflictedModifier, so award != damage exactly -- but it must TRACK
# damage, not sit at zero, and must be NEGATIVE for self-damage.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f sv_a.log sv_b.log sv_relay.log matchlog_*.txt
bt_expert_egg MP.EGG SV.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/" SV.EGG
python - << 'EOF'
lines = open('SV.EGG').read().splitlines(True)
n = 0
for i, l in enumerate(lines):
if l.startswith('vehicle='):
n += 1
lines[i] = 'vehicle=madcat\n' if n == 1 else 'vehicle=loki\n'
open('SV.EGG', 'w').writelines(lines)
print('vehicles set:', n)
EOF
( export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch sv_b.log SV.EGG 0x0C -net 1601 )
sleep 2
( export BT_ZONE_WALK=8 BT_WALK_ZONES=dz_ldleg
export BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch sv_a.log SV.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py SV.EGG 127.0.0.1:1501 127.0.0.1:1601 > sv_relay.log 2>&1 &
RELAY=$!
sleep 400
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== SCORING VERIFY ==================="
echo "--- 0. did combat happen at all? (if 0 hits the run is VOID) ---"
echo -n "damage rows on victim B : "; grep -ac "dmghit\|DMG" sv_b.log
echo
echo "--- 1. THE OLD SYMPTOM: type-0 rejections (must be ZERO now) ---"
echo -n "'should not be given DamageInflictedScore' Verify prints: "
cat sv_a.log sv_b.log | grep -ac "should not be given DamageInflictedScore"
echo
echo "--- 2. THE FIX: inflicted score rows (matchlog SCORE type=0) ---"
echo -n "type=0 rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=0"
cat matchlog_*.txt 2>/dev/null | grep -a "type=0" | head -8
echo
echo "--- 3. award vs damage: does the credit TRACK damage? ---"
python - << 'EOF'
import glob, re
aw = []
for fn in glob.glob('matchlog_*.txt'):
for line in open(fn, errors='replace'):
m = re.search(r'type=0 award=(-?[\d.]+) total=(-?[\d.]+)', line)
if m:
aw.append((float(m.group(1)), float(m.group(2))))
if not aw:
print(' NO type=0 rows -- interceptor did not fire')
else:
pos = [a for a, t in aw if a > 0]
neg = [a for a, t in aw if a < 0]
print(' rows=%d positive=%d negative(self)=%d' % (len(aw), len(pos), len(neg)))
print(' award range: %.2f .. %.2f running total ends at %.2f'
% (min(a for a, t in aw), max(a for a, t in aw), aw[-1][1]))
EOF
echo
echo "--- 4. kill path un-regressed (type=2) + respawn ---"
echo -n "type=2 kill rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=2"
cat matchlog_*.txt 2>/dev/null | grep -a "type=2" | head -3
echo -n "victim death cycles: "; grep -ac "death cycle START" sv_b.log
+11
View File
@@ -0,0 +1,11 @@
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
for n in sys.argv[1:]:
i = gitea.call("/issues/%s" % n)
print("=" * 78)
print("#%s [%s] %s" % (i["number"], i["state"], i["title"]))
print("labels:", ",".join(l["name"] for l in i.get("labels", [])))
print("-" * 78)
print((i.get("body") or "")[:1800])
print()
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# =========================================================================
# #52 STANDING-LOCK bench -- the night-13 field composition.
#
# FIELD COMPOSITION (night 13, 4.11.817): a healthy PEER that pivots at
# stand and then accelerates. The peer's turn-arm block parks its body
# channel at Standing on the walk-demand yield (mech4.cpp:2853, "case 0
# walk-begins next tick"); the claim under test is that case 0 CANNOT
# walk-begin on a replicant because the port's inserted turn block resets
# the state case 0 just armed, using the SAME expression.
#
# PROVOCATION: B chases A and holds at BT_GOTO_STOP. Every time A walks
# back out of that radius B re-acquires -- goto turns hard at 0.2 throttle
# (mech4.cpp:4108-4113) = TURNING AT SUB-WALK SPEED, which arms the peer's
# body state 4 -- then the heading aligns, throttle goes to 1.0, and the
# walk-demand yield fires. That is the trap, once per re-acquisition.
#
# READ IT ON A: B's replicant lives on A, so A's log carries [bodySM] and
# [skate] for B. Both nodes carry both gates anyway.
#
# MODE=legacy -> BT_NO_BODY_FALLTHRU=1 (pre-fix path; expect the lock)
# MODE=fixed -> default (expect no lock)
# =========================================================================
set -x
MODE="${1:-fixed}"
DUR="${2:-230}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f sl_${MODE}_a.log sl_${MODE}_b.log sl_${MODE}_relay.log
bt_assert_player_env
bt_expert_egg MP.EGG SL.EGG
# map=CAVERN on purpose (not the usual grass/day combat default): the lock needs
# a mech holding a STEADY walk demand without its gait changing, and the
# reliable way to get that is a mech pushing into geometry -- throttle up, leg
# SM parked, no gait edges, so no type-3 record ever refreshes the peer. On
# open grass whether the autodriver finds a wall is luck: the first run locked
# for 336 consecutive seconds, the second for 1. Cavern guarantees it, and its
# walls also let the jammed mech SLIDE, which is what turns a lock into a
# visible skate. time=day only so the windows are watchable.
sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" SL.EGG
LEGACY=""
[ "$MODE" = "legacy" ] && LEGACY=1
# ROLES (v2, after the pass-1 miss): the mech we need MOVING WHILE LOCKED is
# the one being observed. v1 made the observer autodrive and it walked into
# the arena wall -- 336 locked seconds but ~zero translation, so the lock
# reproduced and the SKATE (which needs 90 sustained MOVING frames) did not.
# v2 puts the chaser on A: BT_GOTO with a tight stop radius keeps A walking at
# a target that keeps moving, so A gets a stop/turn/walk cycle (the lock entry)
# AND continuous travel (the symptom). B observes; B's own wall-bumping is
# irrelevant because we read A's replicant on B's log.
# v3 = back to the v1 roles, which is the rig that ACTUALLY reproduces.
# v2 (chaser observed) gave zero locks, and that is itself the finding: a mech
# whose gait keeps changing keeps emitting type-3 records, and each one sets the
# peer's body state directly (ReadUpdateRecord), so the peer never has to
# self-arm and never meets case 0. The lock needs the opposite -- a peer PARKED
# at Standing while the master holds a STEADY demand, so no refreshing record
# ever comes. A wall-jammed autodriver is exactly that, which is why v1 locked
# for 336 consecutive seconds. Keep it.
# ---- node B (back window): the OBSERVER -- chases, so it stays engaged ------
(
export BT_GOTO=enemy BT_GOTO_STOP=150 BT_GOTO_LOG=1
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch sl_${MODE}_b.log SL.EGG 0x0C -net 1601
)
sleep 2
# ---- node A (front window): the OBSERVED mech -- steady demand, held up -----
(
export BT_AUTODRIVE=0.7
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch sl_${MODE}_a.log SL.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py SL.EGG 127.0.0.1:1501 127.0.0.1:1601 > sl_${MODE}_relay.log 2>&1 &
RELAY=$!
sleep "$DUR"
kill $RELAY 2>/dev/null
bt_kill_ours
sleep 2
# bt_launch's winpid poll can MISS (documented) -- pass 1 orphaned a node that
# then sat holding a -net port. Passes run strictly one at a time here, so a
# blanket sweep is safe and is the only thing that guarantees a clean slate.
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== MODE=$MODE ==================="
echo "--- THE LOCK: [bodySM] arm->reset pairs (1 Hz throttled) ---"
echo -n "on B (observing A, the chaser): "; grep -ac "STANDING-LOCK" sl_${MODE}_b.log
echo -n "on A (observing B): "; grep -ac "STANDING-LOCK" sl_${MODE}_a.log
grep -a "STANDING-LOCK" sl_${MODE}_b.log | sort -u | head -4
echo
echo "--- THE PEER GAIT: what a MOVING replicant's body channel is doing ---"
echo -n "samples on B: "; grep -ac "peergait" sl_${MODE}_b.log
echo "body states seen while moving (B's view of A):"
grep -a "\[peergait\]" sl_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -10
echo "idle-channel samples (the skate condition):"
grep -ac "IDLE CHANNELS" sl_${MODE}_b.log
echo
echo "--- THE SYMPTOM: [skate] episodes ---"
echo -n "on B: "; grep -ac "SKATING" sl_${MODE}_b.log
grep -a "\[skate\]" sl_${MODE}_b.log | head -6
echo -n "on A: "; grep -ac "SKATING" sl_${MODE}_a.log
echo
echo "--- A's drive cycle (arrive/re-acquire churn = lock entries) ---"
grep -a "\[goto\]" sl_${MODE}_a.log | grep -oaE "arr=[01]" | uniq -c | wc -l
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# =========================================================================
# #52 STANDING-LOCK -- A/B on the rig that is KNOWN to reproduce.
#
# Composition lifted verbatim from scratchpad/night12/skatebench.sh, which
# produced 6 skate episodes in one 260s run (sk_run.out) and 4 in another.
# A walks continuously (autodrive) and is killed every ~40s by B, so it
# respawns WHILE MOVING -- and that is the field correlation both nights:
# night 12's skating clustered in the deaths-heavy final drop, and all four
# of night 13's episodes were in the last, deaths-heavy session.
#
# My own rigs (skatelock.sh v1/v2/v3) each failed to reproduce the SYMPTOM:
# a wall-jammed mech locks but does not translate, and a freely-walking mech
# transitions gait constantly, so records keep rescuing its peer copy. The
# kill cycle gives both halves at once -- a steady post-respawn walk demand
# with no gait edges, on a mech that is actually moving.
#
# B is the OBSERVER: A's replicant lives on B, so B's log carries [skate],
# [bodySM] and [peergait] for A.
#
# MODE=legacy -> BT_NO_BODY_FALLTHRU=1 (pre-fix; expect episodes)
# MODE=fixed -> default (expect none)
# =========================================================================
set -x
MODE="${1:-fixed}"
DUR="${2:-260}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f s2_${MODE}_a.log s2_${MODE}_b.log s2_${MODE}_relay.log
bt_assert_player_env
bt_expert_egg MP.EGG S2.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" S2.EGG
LEGACY=""
[ "$MODE" = "legacy" ] && LEGACY=1
# ---- node B: OBSERVER + killer (back window) ------------------------------
(
export BT_MP_FORCE_DMG=1
export BT_BODY_SM_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch s2_${MODE}_b.log S2.EGG 0x0C -net 1601
)
sleep 2
# ---- node A: the walking victim; autodrive persists across respawns -------
(
export BT_AUTODRIVE=0.8
export BT_BODY_SM_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch s2_${MODE}_a.log S2.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py S2.EGG 127.0.0.1:1501 127.0.0.1:1601 > s2_${MODE}_relay.log 2>&1 &
RELAY=$!
sleep "$DUR"
kill $RELAY 2>/dev/null
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== MODE=$MODE ==================="
echo -n "death cycles on A (the provocation actually ran): "
grep -acE "death cycle START" s2_${MODE}_a.log
echo
echo "--- THE SYMPTOM: [skate] episodes on B (A's replicant) ---"
echo -n "SKATING lines: "; grep -ac "SKATING" s2_${MODE}_b.log
grep -a "\[skate\]" s2_${MODE}_b.log | head -14
echo
echo "--- THE MECHANISM: [bodySM] arm->reset pairs (1 Hz throttled) ---"
echo -n "STANDING-LOCK on B: "; grep -ac "STANDING-LOCK" s2_${MODE}_b.log
grep -a "STANDING-LOCK" s2_${MODE}_b.log | grep -a REPLICANT | sort -u | head -3
echo
echo "--- THE POSITIVE: moving replicant's body state, 1 Hz ---"
grep -a "\[peergait\]" s2_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -8
echo -n "samples flagged IDLE CHANNELS: "; grep -ac "IDLE CHANNELS" s2_${MODE}_b.log
echo
echo "--- control: skate on A (B stands still -- expect 0) ---"
grep -ac "SKATING" s2_${MODE}_a.log
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# =========================================================================
# #146 -- desktop virtual throttle must be RELEASED on respawn.
#
# The pod's throttle is a physical lever and Mech::Reset deliberately leaves
# it alone (subsystem loop starts at index 2; ControlsMapper is index 0) --
# authentic, kept. The desktop bridge only EMULATES that lever with the
# static ramp accumulator sLever, so a pad/keyboard pilot respawned at speed
# while holding nothing. Fix: queue the existing all-stop at Mech::Reset,
# LOCAL VIEWPOINT MECH ONLY.
#
# WHAT THIS BENCH ACTUALLY TESTS. It does NOT test "does zeroing sLever
# stop the mech" -- that path is the X all-stop button, proven in the field
# every day, and BT_AUTODRIVE cannot exercise it anyway (forced mode reads
# gBTDrive.forcedThrottle, never sLever -- mechmppr.cpp:677).
#
# It tests the one thing the fix could genuinely get WRONG: the viewpoint
# gate. Mech::Reset also runs for REPLICANTS, so an ungated write to the
# global gBTDrive would all-stop the local player every time a REMOTE mech
# respawned -- a far worse bug than the one being fixed.
#
# PASS: A (who dies) logs "[respawn] desktop throttle released" once per
# Mech::Reset, and B logs it ZERO times while A is respawning.
# FAIL: any occurrence in B's log -> the gate leaks and remote respawns
# stop the local pilot.
#
# A self-damages to death on a timer; B just flies and watches.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f tr_a.log tr_b.log tr_relay.log
bt_expert_egg MP.EGG TR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" TR.EGG
# B: the OBSERVER. Also driving, so a leaked all-stop would be doubly visible.
( export BT_AUTODRIVE=0.6
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_LOG=1
bt_launch tr_b.log TR.EGG 0x0C -net 1601 )
sleep 2
# A: drives, dies repeatedly, respawns.
( export BT_AUTODRIVE=0.8 BT_SELF_DAMAGE=8
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_LOG=1
bt_launch tr_a.log TR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py TR.EGG 127.0.0.1:1501 127.0.0.1:1601 > tr_relay.log 2>&1 &
RELAY=$!
sleep 280
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #146 THROTTLE RELEASE ON RESPAWN ==================="
echo "--- A: how many respawns, how many releases? (must be 1:1) ---"
echo -n " A Mech::Reset .......... "; grep -ac "Mech::Reset" tr_a.log
echo -n " A throttle released .... "; grep -ac "desktop throttle released" tr_a.log
echo
echo "--- B: the VIEWPOINT GATE. Must be 0 releases despite seeing A respawn. ---"
echo -n " B Mech::Reset (incl. replicant) .... "; grep -ac "Mech::Reset" tr_b.log
echo -n " B throttle released (MUST BE 0) .... "; grep -ac "desktop throttle released" tr_b.log
echo
echo "--- A: the release must sit INSIDE the reset block ---"
grep -aE "Mech::Reset|desktop throttle released" tr_a.log | head -8
echo
echo "--- B: same window, to show B saw the respawn and still did not release ---"
grep -aE "Mech::Reset|desktop throttle released" tr_b.log | head -8
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# =========================================================================
# TURN-IN-PLACE regression check for the #52 fallthrough fix.
#
# The fix only changes ONE path: case 0 no longer falls into the port's
# turn block. Entry INTO state 4 is unaffected -- masters arm it via the
# leg twin's lockstep (mech2.cpp:1014) and peers via the #82 block
# (mech4.cpp:2851), and both set the state so the NEXT frame dispatches
# straight to case 4. This bench exercises that claim instead of asserting
# it: force sustained PIVOTING and confirm state 4 is still entered, still
# advances, and still exits -- on both the master and the peer.
#
# PROVOCATION: BT_GOTO with a tiny throttle and a stop radius it can never
# reach -- the mech steers at the enemy forever while creeping below
# standSpeed, which is exactly the trn entry gate (turning + speed in
# [0, standSpeed] + turnCapable).
# =========================================================================
set -x
MODE="${1:-fixed}"
DUR="${2:-150}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f tr_${MODE}_a.log tr_${MODE}_b.log tr_${MODE}_relay.log
bt_expert_egg MP.EGG TR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" TR.EGG
LEGACY=""
[ "$MODE" = "legacy" ] && LEGACY=1
# v2: MUTUAL goto converged instantly -- both mechs faced each other, err~0,
# nothing ever turned (146 samples parked at Standing). A pivot needs a target
# that keeps MOVING, so B autodrives away and A creeps-and-steers after it:
# heading error stays live while A's speed stays under standSpeed, which is the
# trn entry gate.
( export BT_AUTODRIVE=0.6 # B: the moving target
export BT_BODY_SM_LOG=1 BT_MP_LOG=1
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch tr_${MODE}_b.log TR.EGG 0x0C -net 1601 )
sleep 2
( export BT_GOTO=enemy BT_GOTO_STOP=5 BT_GOTO_THR=0.02 BT_GOTO_LOG=1
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 # A: the pivoter
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
bt_launch tr_${MODE}_a.log TR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py TR.EGG 127.0.0.1:1501 127.0.0.1:1601 > tr_${MODE}_relay.log 2>&1 &
RELAY=$!
sleep "$DUR"
kill $RELAY 2>/dev/null
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== TURN REGRESSION MODE=$MODE ==================="
echo "--- MASTER: does the turn-in-place clip still get entered + advanced? ---"
for n in a b; do
echo "node $n body state=4 samples : $(grep -a '\[gaitSM\] adv=' tr_${MODE}_${n}.log | grep -caE ' state=4 ')"
echo "node $n leg state=4 samples : $(grep -a '\[gaitSM\] adv=' tr_${MODE}_${n}.log | grep -caE ' legState=4')"
echo "node $n full body distribution:"
grep -a "\[gaitSM\] adv=" tr_${MODE}_${n}.log | grep -oaE "state=[0-9]+" | sort | uniq -c | sort -rn | head -6
done
echo
echo "--- PEER: does a replicant still pivot (body state 4 while moving)? ---"
grep -a "\[peergait\]" tr_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -8
echo
echo "--- no new lock / no skate ---"
echo -n "STANDING-LOCK a/b: "; echo "$(grep -ac 'STANDING-LOCK' tr_${MODE}_a.log) / $(grep -ac 'STANDING-LOCK' tr_${MODE}_b.log)"
echo -n "SKATING a/b: "; echo "$(grep -ac 'SKATING' tr_${MODE}_a.log) / $(grep -ac 'SKATING' tr_${MODE}_b.log)"
+84
View File
@@ -0,0 +1,84 @@
"""Night 13: correct #148's framing with the measured findings. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**Investigated, narrowed a long way, NOT fixed. Correcting this ticket's own framing first.**
## The title/premise was WRONG -- the cadence is authentic
I filed this as "only 13 update records across a 5-minute run". That is not a defect. Reading the
payloads with line numbers:
```
205 RECORD atUpd=0.0436769 rate=0.305433
502 RECORD atUpd=2.3558 rate=-0.305433
1150 RECORD atUpd=-2.39277 rate=0.305433
1698 RECORD atUpd=2.38543 rate=-0.305431
2044 RECORD atUpd=-2.39124 rate=0.305427
...
```
Those `atUpd` values are the sweep's EXTREMES and `rate` flips sign at each one. **The master sends
a torso record on RATE CHANGE**, and the peer dead-reckons `atUpd + rate * elapsed` in between
(`ComputeTargetTwist`). 12 records for 12 direction reversals is exactly right -- this is
extrapolation-based replication working as designed, not a starved channel.
Also ruled out: the `ComputeTargetTwist` clamp. The copy's limits load correctly
(`limL=2.44346 limR=-2.44346 enab=1 copy=1`), so `Min/Max` is not pinning `targetTwist` to zero.
And the extrapolator itself is exact -- the copy reports `cur == target` on every sample.
## The real defect
**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 data integrated by nobody.
This is now directly readable because `[torso-copy]` logs on call #0 (`s_cl++ % 120`), so its first
line IS the first `TorsoCopySimulation` call. (That also means my earlier "first copy currentTwist
!= 0 at line 1016" was a SAMPLING artifact -- first *sample*, not first non-zero.)
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**, somewhere in replicant model/clip initialisation.
Not yet found.
## Tried and rejected
`Entity::Perform` (ENTITY.cpp:733-793, real engine source) picks the executable predicate by
instance -- `IsNonReplicantExecutable()` for masters, `IsReplicantExecutable()` for replicants --
and they differ exactly on `|| lastUpdate >= lastPerformance`, which is what makes an
`ExecuteOnUpdate` subsystem run when a record arrives. Mech's reconstructed tick loop used the
NonReplicant predicate for every mech, dropping the branch.
That is a genuine fidelity gap and it is now **fixed** (`f36f013`) -- but it does **not** move this
bug (first copy tick 1014 -> 1006, noise). The torso's own flag was not the gate.
## Consequence, and what it is NOT
This is the tail of #141: the peer's missiles launch along the body facing until its torso starts
ticking. #141's own fix is complete and verified separately -- every zero-`twistDelta` peer launch
now reads `liveTwist=0` on the same log line, and the first launch with `liveTwist=-1.84061` reads
`twistDelta=-1.83813`. The launch frame tracks the twist perfectly; there is simply no twist to
track until the copy torso wakes up.
Likely also relevant to **#37** (MadCat torso BACKWARDS) and **#70** (twist stops after respawn) --
a peer torso that does not tick would sit at its bind pose, and a respawn re-runs model bring-up.
Re-test both once this is found.
## Diagnostics now in place
`BT_TORSO_LOG`: `[torso-rec-rx]` (receive + raw payload), `[torso-copy]` (cur/target/atUpd/rate plus
`limL/limR/enab`), `[torso] PushTwist master|COPY` (per instance-kind).
`BT_PROJ_LOG`: `[launchframe]` now carries the shooter's live torso twist, so `twistDelta` and its
driver appear on the same line."""
gitea.call("/issues/148", method="PATCH", payload={
"title": "Peer copy TORSO does not tick until replicant model bring-up -- ~800 lines of replicated twist integrated by nobody"})
gitea.comment(148, BODY)
print("updated #148")
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# #152: is the AUTHENTIC torso-CENTER button (0x42) alive on the RIO/glass path
# (key bridge OFF)? Cycle to Standard, deflect/release the twist axis, and
# hold 0x42 during release windows. ctrCmd=1 while held + twist slewing to 0
# = route works (ticket becomes player education). ctrCmd stuck 0 = the
# streamed 0x42 route is dead on glass and needs the forward implemented.
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f c42_a.log
bt_expert_egg MP.EGG C42.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" C42.EGG
( export BT_MODECYCLE_EVERY=400 BT_TWIST_PULSE=150
export BT_BTNTEST=0x42,900,1150 BT_BTNTEST2=0x42,1500,1750
export BT_TORSO_LOG=1 BT_KEY_NOFOCUS=1 BT_KEY_BRIDGE=0
bt_launch c42_a.log C42.EGG 0x03 )
sleep 130
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "============ RESULT ============"
grep -a "\[mode\] control mode" c42_a.log | head -4
echo "--- ctrCmd seen (want some =1 during the 0x42 holds) ---"
grep -ao "ctrCmd=[01]" c42_a.log | sort | uniq -c
echo "--- twist trajectory around the first hold ---"
grep -aE "twistpulse|ctrCmd=1" c42_a.log | head -8
+69
View File
@@ -0,0 +1,69 @@
"""#137: post the fix write-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**FIXED** (`0d6ed40`) -- and my previous two analyses of this ticket were both wrong, so here is
the final, measured story.
## The fix is ONE LINE, and it is the binary's own
The binary Reset's SECOND instruction is `FUN_00408440(mech+0x58c, param_2)` -- re-seed the
Point3D at `+0x58c` to the new origin. `+0x58c` is the **previous-position memory** of the
AccelerationLastFrame ring feed (`+0x81c/0x824/0x828/0x82c`). The port reconstructed the ring
faithfully but its Reset never got that line. Restored as:
```cpp
accelPrevPos = origin.linearPosition; // binary +0x58c re-seed
```
## What actually happened at a respawn
1. The respawn TELEPORTS the mech; `accelPrevPos` still held the death position.
2. First post-respawn sample: `|newPos - prevPos| / dt` = **teleport distance / dt ~ 1e5** enters
the velocity ring; the ring-mean derivative spikes `AccelerationLastFrame` (pure forward, with
an opposite-sign echo ~15 frames later as the sample rotates out of the 15-ring).
3. The myomer drive-heat integrator `@004b8d18`:
`termAccel = (1-accEff) * |v| * |a| * m * dt = 0.2 * 40 * 1.04e5 * 75000 * 0.044 = 2.75e9`
-- one tick deposits ~3e9 into `pendingHeat -> heatEnergy`.
4. The freshly-reset myomers snaps 77 -> ~9000 against `failT=2000` -> `speedEffect 0` ->
`speedDemand *= 0` -> **"respawned with heat maxed, unable to move until it cools"**. The
excess then sheds into Condenser5 and GeneratorD -- Oracle's "loop 5 and generator D heating
up" clause, literally.
**Why ~8% and why it eluded everyone:** the deposit needs `|v|` in the same 1-2 frames, so only
pilots whose throttle was still forward at the respawn (physical lever / HOTAS -- exactly who
reported it) got the freeze. Idle-throttle respawns deposit nothing. That is also why frozen
respawns were a strict subset of died-hot ones: died hot = was running hard = lever still forward.
## Measured (same abusive bench: 0.95 throttle, continuous autofire)
| | before | after |
|---|---|---|
| deposits >1e7 near resets | up to **3.35e9**, every one 3-4 lines after a reset | **0** |
| frozen respawns | 4-6 of 7 | **0 of 7** |
| post-reset myomers T | 7,700-12,100 | 77-178 (degradeT is 1000) |
## For the record: what it was NOT (each killed by operand data)
Not the reset (T=77 at every reset, roster-wide). Not stale pendingHeat (1-frame bound, +1.2K).
Not death-window accumulation (consumers tick the wreck). Not conduction (roster snapshot: all
partners at 77; flow trap: zero e6 flows into the myomers). Not drag or impulses (both trapped:
never fired). And not my earlier "players accelerate to top speed" close -- arithmetically
impossible (input ceiling ~60 deg/s vs the observed 1,100-21,000 deg/s snap); the KB paragraph
carrying that claim is corrected.
The in-life governor is untouched and authentic: sustained top-speed running still derates the
myomers (constants byte-verified). What is gone is the respawn injecting a teleport-sized heat
slug.
Credit where due: the operator called the shape of this from the start -- *"maybe the math gets
screwy in respawning while some systems are ticking while values are being reset."*
**Unreleased; needs a build cut. Field-verify:** respawn while holding the throttle forward on a
HOTAS -- the mech should drive off cleanly with a cold heat bar every time. Gotcha #30 records the
class so the next dropped re-seed gets caught in review."""
gitea.comment(137, BODY)
gitea.call("/issues/137", method="PATCH", payload={"state": "closed"})
print("posted + closed #137")
+299
View File
@@ -0,0 +1,299 @@
"""Night 14 (build 4.11.857) housekeeping: close what the field confirmed, reopen
#137 with evidence, and file the new reports. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
FIELD = ("Field-verified on **4.11.857** (playtest 2026-08-08, 4 testers: Oracle/SCREECH, "
"Sauron/XIAOLONG, santo/MS-FIREFLY, RajelAran/GAMERSLAB).")
# ---------------------------------------------------------------- CLOSE ----
CLOSE = {
52: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"no skating observed\"*.\n\n"
"Cross-check: the `[skate]` detector fires zero times across all four logs.\n\n"
"**#130 (Vulture skating) should be re-tested against this build** -- it was always "
"suspected to be the same defect.",
108: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Lynx panic ejected and didn't ghost\"*.\n\n"
"The eject path now runs the full death tail, so the respawn trigger fires.",
142: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Fixed, button indicating state and mech "
"is standing after respawn\"* -- both halves: the indicator animates, and the "
"`Mech::Reset` posture clear brings you back standing.",
140: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"It is not overwriting the saved settings\"*.",
141: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Fixed\"*. VGL Lynx, independently and "
"before seeing the notes: *\"Missiles seem to be launching from weapon ports\"* -- which is "
"the symptom stated positively.\n\n"
"Worth noting the same stale-segment-cache defect was fixed on the **beam muzzle** path in "
"the same sweep; nobody had reported that one, and a peer's beams would have had the "
"identical wrong origin whenever the shooter was twisted.",
}
# --------------------------------------------------------------- COMMENT ----
COMMENT = {
147: "**Not reproduced on 4.11.857** -- Oracle: *\"I have been unable to recreate the range caret "
"issue today ... but only tried in Solo\"*, and later *\"Not observed\"*.\n\n"
"Cross-check: zero `NaN TRAPPED` receipts in any of the four field logs, so the trap never "
"had to fire either.\n\n"
"**Keeping this OPEN.** One clean night is not proof for a defect whose whole signature is "
"'rare, and sticky once it happens' -- and solo exercises far less of the targeting path "
"than a 4-way drop. If the caret dies again, `BT_RANGE_LOG=1` now names the frame.",
146: "**Working on 4.11.857, with a nuance worth recording.** Oracle: *\"Initial spawn/respawn "
"throttle is idle and then begins to respond to match my throttle position on my X56 "
"without moving it.\"* That is the fix behaving correctly on a PHYSICAL throttle: the "
"virtual lever is released at respawn, then re-syncs to where the hardware lever actually "
"sits.\n\n"
"**But see the new report about being unable to move after respawn until the throttle is "
"cycled.** A HOTAS user who respawns with the lever already forward now gets no motion "
"until the axis next changes -- that may be this fix's side effect rather than a separate "
"bug, and the two need separating before either is 'fixed'.",
135: "**Related field report on 4.11.857** (Oracle): *\"buttons for gens are not flashing when "
"they leak\"* -- observed in GLASS mode this time, where this ticket was originally about "
"the normal (non-glass) screen path. Same annunciator chain, so treat them together: the "
"coolant-leak condition (cond 2) is supposed to light the per-generator lamps.",
}
# ------------------------------------------------------------------ NEW ----
NEW = [
("REOPEN #137 -- placeholder", None), # handled separately below
]
def main():
for num, body in CLOSE.items():
gitea.close(num, body)
for num, body in COMMENT.items():
gitea.comment(num, body)
# ---- #137: reopen. I closed this wrongly; the field disproved my analysis.
gitea.comment(137,
"**REOPENING -- I closed this incorrectly, and the field data disproves my analysis.**\n\n"
"I closed it as 'not a bug: the mech respawns still under throttle and earns the heat "
"honestly'. Oracle pushed back: *\"not sure if the bug report was understood ... the mech "
"is initially unable to move until it cools off sufficiently to start moving. This is "
"abnormal.\"* He is right, and 'unable to move' is the detail that kills my explanation -- "
"a mech that cannot move cannot be earning heat by running.\n\n"
"## Measured on 4.11.857, all four field logs\n\n"
"Scanning every respawn for the freeze signature (throttle up, speedDemand pinned at 0):\n\n"
"| player | respawns | FROZE |\n|---|---|---|\n"
"| Oracle / SCREECH | 16 | **2** |\n| Sauron / XIAOLONG | 19 | **2** |\n"
"| RajelAran / GAMERSLAB | 16 | **1** |\n| santo / MS-FIREFLY | 10 | 0 |\n\n"
"**5 of 61 respawns (~8%)**, across three independent machines.\n\n"
"The sequence at one of them (Oracle, log line 48584) is unambiguous:\n\n"
"```\n"
"[mppr] thr=0.847 -> speedDemand=44.87 <- moving normally, pre-death\n"
"[respawn] Mech::Reset 2:21 healed+moved\n"
"[techstat] Myomers condition 1 CLEARED <- Damaged cleared OK\n"
"[techstat] Myomers condition 2 CLEARED <- Leaking cleared OK\n"
"[techstat] Myomers condition 3 SET <- OVERHEATING, AT the reset\n"
"[mppr] thr=0.762 -> speedDemand=0 <- FROZEN\n"
"```\n\n"
"The reset clears the damage and leak flags correctly and then the myomers come up "
"**already overheating**, in the same breath as the reset -- before any running could have "
"happened. The myomer heat-freeze (`speedEffect` -> `speedDemand *= it`) then pins speed "
"at zero, which is exactly the reported 'cannot move until it cools'.\n\n"
"## Why my bench missed it\n\n"
"It is **intermittent (~8%)**. My synthetic bench reset cleanly every single time and "
"reported `T=77 start=77` for every heat-bearing subsystem including all six Condensers -- "
"so I concluded the reset path was correct and closed the ticket. A bench that never "
"reproduces the failure is not evidence the failure does not exist; I treated it as such.\n\n"
"## Next\n\n"
"The temperature is NOT logged in the field (`BT_HEAT_LOG` is off), so the next step is a "
"bench that reproduces the freeze rather than the happy path -- repeated death/respawn "
"under load until a reset lands hot -- and then reads `[heat-reset]` on the failing one. "
"The ~8% rate suggests a race or an ordering dependency rather than a plain missing reset, "
"since the plain path demonstrably works.")
gitea.call("/issues/137", method="PATCH", payload={"state": "open"})
print("REOPENED #137")
made = []
def new(title, body, ):
i = gitea.create(title, body)
made.append(i["number"])
new("PERFORMANCE REGRESSION 817 -> 857: hard stalls at mission load (glass)",
"Oracle: *\"frame rate very poor with glass cockpit starting with this build. Only "
"observed by Oracle, Sauron using glass had no issue. Oracle had no performance issues in "
"past with glass.\"*\n\n"
"**Confirmed, and it is a real regression -- but it is STUTTER, not low framerate.**\n\n"
"## Same machine, previous build vs this one (Oracle / SCREECH)\n\n"
"| build | median frame | stalls >50ms | worst |\n|---|---|---|---|\n"
"| 4.11.817 (Aug 6) | 5.79 ms | **0** (0.00%) | 15.4 ms |\n"
"| 4.11.857 (Aug 8) | 5.87 ms | **97** (6.70%) | 103.7 ms |\n\n"
"His steady state is **identical** (5.79 -> 5.87 ms). 817 had literally zero stalls; 857 "
"has 97, and the worst frame went 15 ms -> 104 ms.\n\n"
"## Why only Oracle noticed\n\n"
"| player | median | stalls >50ms |\n|---|---|---|\n"
"| Oracle | 5.87 ms (170 fps) | 97 |\n| santo | 3.55 ms | 0 |\n"
"| Sauron | 12.53 ms (80 fps) | 0 |\n| RajelAran | 6.70 ms | 0 |\n\n"
"Oracle has the **best median of the group** and Sauron -- who reported no problem -- has "
"the worst. Smooth-but-slower reads as fine; fast-with-hitches reads as broken. The "
"distribution is bimodal: **all 97 of Oracle's slow blocks are >50 ms**, nothing in "
"between.\n\n"
"## Where\n\n"
"All 97 stalls are one contiguous window (log lines 30307-32979, 30-32% through the "
"session), and it begins exactly at **mission load**, as peer mechs stream in:\n\n"
"```\n"
"[clipfix] mech 15A1FF38 -> EXTERIOR (lean)\n"
"[loadclips] end: fScale=0.8 ...\n"
"[spike] dt=0.356 turn=0 thr=0\n"
"[rstat] frames=10 avg=103.713ms maxDraw=518.497 maxPresent=7.046 batches=547 culled=226\n"
"```\n\n"
"`maxDraw=518ms` against `maxPresent=7ms` -- it is **draw time**, not vsync/present.\n\n"
"## NOT the instrumentation\n\n"
"The standing theory was that accumulated logging is slowing things down. The data says "
"no: the **median frame time is unchanged between builds** (5.79 -> 5.87 ms) and log "
"volumes are comparable across players (6.1-6.9 MB). Logging would raise the floor "
"everywhere, not add isolated 100 ms draw spikes in one window.\n\n"
"## Prime suspect\n\n"
"The #141 stale-segment-cache fix in this same build. It routes muzzle/beam queries "
"through `JointedMover::GetSegmentToWorld`, which -- when joints are modified, i.e. every "
"animating frame -- walks the whole segment table and marks every segment dirty, forcing "
"the hierarchy to re-derive. That is now on per-frame paths (`BTResolveWeaponMuzzle`, and "
"the energy-beam gun-port loop which runs per weapon per mech per frame). With several "
"mechs newly loaded it multiplies out. Faithful to the binary, but the binary was not "
"calling it this often.\n\n"
"**Next:** time `GetSegmentToWorld` per frame and count calls per mech; if it is the "
"cause, cache the resolved frame per (mech, segment) for the duration of a frame rather "
"than re-deriving per weapon.")
new("Double KILL credit: one kill counted twice",
"Two independent reports on 4.11.857:\n\n"
"* RajelAran: *\"in match beginning 20:33, I got a double kill on Oracle. One kill, "
"counted twice\"*\n"
"* Oracle: *\"12:12AM ET - Oracle killed Conn Man and got 2 kills credited. Rajel also "
"the same when killing me.\"*\n\n"
"Log cross-check (Oracle/SCREECH): the scoreboard `kills=` reading jumps **2 -> 4**, "
"skipping 3.\n\n"
"## Suspect (identified 2026-08-07, never fixed)\n\n"
"In the type-2 (Kill) arm of the score handler:\n\n"
"```c\n"
"++killCount; // this player\n"
"if (sender_owner) ++sender_owner->killCount; // the mech named by senderMechID\n"
"```\n\n"
"For type 2, `senderMechID` is the **victim**. The second increment is justified in the "
"comment by the SOLO case, where the dummy target has no owning player -- but in MP the "
"victim *does* have an owner, so the credit lands twice. In a 1v1 exchange both "
"increments can even land on the same scoreboard row, which is what 'one kill counted "
"twice' looks like.\n\n"
"Needs a 2-node bench confirming the victim's `killCount` moves on a kill.")
new("PANIC eject scores -499 instead of 0 (double-charged)",
"Oracle, 4.11.857, **confirmed twice**: *\"Had 1000 points, leaked out all coolant, hit "
"'panic' and ended up with -499 points. Should it have been zero points because "
"1000 - 1000 = 0 for ejecting? Why the extra -499 points?\"*\n\n"
"The shipped `Role::Default` carries `deathPenalty=500`, and the death tail applies "
"`-specialCaseDeathPenalty`. 1000 - 500 = 500, so -499 is roughly **1000 - 500 - 999**, "
"i.e. the penalty looks like it is being applied more than once, or the eject path "
"charges both the normal death cost and a separate panic cost.\n\n"
"Note this is the tail of **#134** (panic eject carried NO score penalty) -- that fix "
"landed and now over-charges. The two should be read together.\n\n"
"Also from the same report, lower confidence / subjective: *\"Missiles seem to be scoring "
"a little low (twin LRM15s scored ~30-40 points)\"* -- worth a damage-vs-score comparison "
"rather than a code change on its own.")
new("REGRESSION: torso no longer auto-recentres in ANY control mode",
"Oracle, 4.11.857: *\"Torso is no longer recentering on its own in any mode "
"Bas/Mid/Adv.\"*\n\n"
"**This is a regression from the control-mode fix in this build.** That change replaced "
"the Basic-mode re-centre write from `centerCommand` (@0x208, the HELD-button cell that "
"nothing released -- the bug where the centering *fought* the pilot) with "
"`recenterActive` (@0x274), a ONE-SHOT that self-clears on settle, matching @004afbe0.\n\n"
"The one-shot is what the binary does on the mode CHANGE. What it does not do is provide "
"the CONTINUOUS auto-centring that Basic mode is supposed to have per the 1995 manual "
"(BAS = joystick turns the mech, **no torso twist**). So the fix removed a behaviour that "
"was previously coming -- incorrectly, but coming -- from the stuck cell.\n\n"
"Both halves need to be true at once: Basic must hold the torso centred, without the "
"held-cell latch that made it fight the stick in Standard/Veteran.\n\n"
"The original complaint (Sauron: cycling to advanced and back made the centering fight "
"the controls) is fixed and should stay fixed -- verify any change against both.")
new("Basic-mode elevation limit does not clamp the CURRENT view",
"Oracle, 4.11.857: *\"Basic mode does limit downward view to 'half', but only after "
"cycling off 'Basic' and then back to 'Basic'. If in Mid or Advanced and move view past "
"'half' down and then switch to Basic, nothing happens until I move the control stick in "
"the pitch axis and then the view immediately snaps up to 'half' limit.\"*\n\n"
"The elevation-limit swap (Basic gets @0x228/@0x22C = full top, HALF bottom; "
"Standard/Veteran get @0x230/@0x234) is new in this build and previously was never "
"implemented at all. It writes the LIMITS but does not re-clamp `currentElevation`, so an "
"already-out-of-range view stays until the next pitch input clamps it.\n\n"
"Small fix: clamp current elevation into the new pair at the moment of the swap. Worth "
"checking against the binary whether it clamps on switch or leaves it to the sim -- the "
"snap-on-next-input behaviour may be authentic.\n\n"
"Also: Oracle asked what 'settles the HUD horizon' meant in the release note -- that was "
"my wording for Basic raising the HUD's `flickerActive` (@0x2A0) so the horizon "
"re-settles with the re-centred torso. Needs a plainer description for testers.")
new("Overheated generator never came back online; no generator-out alarm on thermal trip",
"Oracle, 4.11.857: *\"Noticed a generator that overheated never came back online even "
"after cooling off completely. Also noticed the generator out message doesn't sound after "
"a generator fails due to overheat. The second and third time I overheated a generator "
"for the test it did come back online after cooling halfway, and I heard the generator "
"out warning upon the generator coming back online.\"*\n\n"
"Two defects, and the intermittency is the interesting part -- the same test gave a stuck "
"generator once and a recovering one twice.\n\n"
"1. **Stuck offline after thermal trip.** The thermal breaker in `GeneratorSimulation` "
"produces `stateAlarm 4` (GeneratorOut); recovery should re-arm once temperature falls. "
"Related known nuance (context/decomp-reference.md): the generator sim has NO Ready-case "
"recompute, so a generator can hold stale state until *some* transition recomputes it.\n"
"2. **Alarm timing.** The generator-out warning did not sound on the trip, but DID sound "
"later on the way back online -- so the annunciator appears tied to the wrong edge.\n\n"
"Cross-check available: `[techstat] GeneratorX condition 3` (Overheating) transitions are "
"in the field logs and are balanced overall, so this is about the stateAlarm/voltage path "
"rather than the heat flag.")
new("PANIC lamp lit: rapid flicker, and engineering + weapon MFD buttons stop responding",
"Oracle, 4.11.857: *\"When the panic button lights it flickers very rapidly and the "
"engineering and weapon MFDs buttons stop responding. Other MFDs continue to respond to "
"button presses (using mouse). I also have mapped controls on my HOTAS for some of those "
"MFD buttons and they also stop working when the panic button is lit. Radar, Scoring and "
"coolant MFDs keep working.\"*\n\n"
"Two symptoms, probably one cause. Arming eject raises the panic-armed mode "
"**0x200000**, which drives the physical PANIC lamp (`MakeLinkedLamp`), the eject-mode "
"gauge elements, **and keypad routing** -- so eng/weapon buttons going dead while radar/"
"scoring/coolant keep working is consistent with the eject mode capturing exactly those "
"banks. That part may well be AUTHENTIC (the pod wants your hand on the eject decision, "
"not the weapon page).\n\n"
"The **rapid flicker is not** authentic: per the decode, the panic lamp is SOLID on arm "
"(engine linked-lamp semantic); FLASH is the GaugeAlarm `SetAlertState` overlay, which is "
"not authored for panic. A flickering panic lamp suggests the arm state is oscillating -- "
"and if it is oscillating, the keypad routing is being torn down and rebuilt repeatedly, "
"which would also explain buttons that 'stop responding' rather than cleanly switching.\n\n"
"Check `EvaluateEjectPermission` for a condition that chatters at the threshold "
"(coolantFrac < 0.05 is a likely candidate while coolant is draining).")
new("Fade-to-black at mission end is not happening",
"Oracle, 4.11.857: *\"Fade to black effect when mission ends is not happening\"*.\n\n"
"No log signature to cross-check against -- filing on the report. Worth confirming "
"whether this ever worked in a recent build or has been absent for a while.")
new("Crash at drop end (~23:59 ET, MS-FIREFLY / santo)",
"Conn Man / santo, 4.11.857: *\"Did not exit cleanly\"*, and Oracle: *\"Conn Man crashed "
"when drop ended ~11:59PM ET\"*.\n\n"
"`lastrun_steam.txt` is staged with the field logs. The launch bracketing shows the "
"process reaching WinMain normally, so this is a crash at teardown rather than a blocked "
"launch.\n\n"
"Teardown crashes have bitten this project before (the P5 teardown path). Needs the log "
"tail read against the mission-end sequence; the field log for that machine is staged as "
"`scratchpad/night14/steam_20260808_b_santo_MSFIREFLY.log`.\n\n"
"Also reported for the same player: *\"Conn Man was lagging sometimes during drops and "
"that caused the effect of missiles or direct fire exploding/hitting empty space\"* -- "
"likely a separate networking symptom, noted here only so the two are not conflated.")
new("Cannot move after respawn until the throttle is cycled (Abandoned Arena)",
"Sauron, 4.11.857: *\"respawned several times in Abandoned Arena and couldn't move until "
"throttle down/up or reversing out. Was near map edge and structure.\"*\n\n"
"**Two candidate causes, and they must be separated before either is called fixed:**\n\n"
"1. **A side effect of the #146 throttle fix in this build.** The virtual lever is now "
"released at respawn; a pilot whose PHYSICAL throttle is already forward gets no motion "
"until the axis next changes -- i.e. exactly 'couldn't move until throttle down/up'. "
"Oracle described the same mechanic positively (*\"throttle is idle and then begins to "
"respond to match my throttle position\"*), so the behaviour is real; the question is "
"whether it should re-sync immediately instead of on next change.\n"
"2. **Geometry.** 'Near map edge and structure' suggests spawning into or against "
"collision, which would also read as being stuck.\n\n"
"Note this is NOT the same as #137: there the mech is frozen by myomer heat with the "
"throttle up, and cycling the throttle does not help. Here cycling the throttle DOES "
"free it. The two will look identical to a player, so keep the distinguishing test in "
"mind: does throttle-cycling free it (this) or not (#137)?")
print("created:", made)
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# =========================================================================
# #137 -- "respawns with the myomers overheated ... initially unable to move
# until it cools off" (Oracle, across several builds).
#
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
# found every heat-bearing subsystem at T == startingTemperature, so I closed
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
# bench that never reproduces the failure is not evidence the failure does not
# exist, which is the mistake that closed the ticket.
#
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
# The freeze itself is the derating curve @004b8ac0:
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
#
# So three explanations survive, and only data separates them:
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
# (b) STALE CACHE -> T is at start but speedEffect is still 0
# ("<<<< STALE (cold but zero)" in the receipt)
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
#
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
# the mover's multiplier is actually formed:
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
#
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf137_a.log mf137_b.log mf137_relay.log
bt_expert_egg MP.EGG MF137.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
sleep 2
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
( export BT_AUTODRIVE=0.95 BT_SELF_DAMAGE=7
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=4
export BT_HEAT_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
RELAY=$!
sleep 540
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #137 MYOMER FREEZE ==================="
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
echo
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
python - <<'PY'
import re, io
bad = 0
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
if not m:
continue
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
if se <= 1e-4:
bad += 1
if bad <= 12:
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
print(" zero-effect samples: %d" % bad)
PY
echo
echo "--- the first frozen episode in context ---"
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# =========================================================================
# #137 -- "respawns with the myomers overheated ... initially unable to move
# until it cools off" (Oracle, across several builds).
#
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
# found every heat-bearing subsystem at T == startingTemperature, so I closed
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
# bench that never reproduces the failure is not evidence the failure does not
# exist, which is the mistake that closed the ticket.
#
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
# The freeze itself is the derating curve @004b8ac0:
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
#
# So three explanations survive, and only data separates them:
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
# (b) STALE CACHE -> T is at start but speedEffect is still 0
# ("<<<< STALE (cold but zero)" in the receipt)
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
#
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
# the mover's multiplier is actually formed:
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
#
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf137_a.log mf137_b.log mf137_relay.log
bt_expert_egg MP.EGG MF137.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
sleep 2
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
( export BT_AUTODRIVE=0.5 BT_SELF_DAMAGE=7
export BT_HEAT_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
RELAY=$!
sleep 330
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #137 MYOMER FREEZE ==================="
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
echo
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
python - <<'PY'
import re, io
bad = 0
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
if not m:
continue
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
if se <= 1e-4:
bad += 1
if bad <= 12:
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
print(" zero-effect samples: %d" % bad)
PY
echo
echo "--- the first frozen episode in context ---"
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# =========================================================================
# #137 -- "respawns with the myomers overheated ... initially unable to move
# until it cools off" (Oracle, across several builds).
#
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
# found every heat-bearing subsystem at T == startingTemperature, so I closed
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
# bench that never reproduces the failure is not evidence the failure does not
# exist, which is the mistake that closed the ticket.
#
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
# The freeze itself is the derating curve @004b8ac0:
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
#
# So three explanations survive, and only data separates them:
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
# (b) STALE CACHE -> T is at start but speedEffect is still 0
# ("<<<< STALE (cold but zero)" in the receipt)
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
#
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
# the mover's multiplier is actually formed:
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
#
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf137_a.log mf137_b.log mf137_relay.log
bt_expert_egg MP.EGG MF137.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
sleep 2
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
( export BT_AUTODRIVE=0.95 BT_SELF_DAMAGE=7
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=4
export BT_HEAT_LOG=1 BT_MYO_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
RELAY=$!
sleep 540
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #137 MYOMER FREEZE ==================="
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
echo
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
python - <<'PY'
import re, io
bad = 0
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
if not m:
continue
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
if se <= 1e-4:
bad += 1
if bad <= 12:
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
print(" zero-effect samples: %d" % bad)
PY
echo
echo "--- the first frozen episode in context ---"
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# =========================================================================
# #149 -- 817 -> 857 stall regression (Oracle: 97 blocks >50ms, all in the
# mission-load window, maxDraw up to 518ms; zero such blocks on 817, same
# machine). My filed suspect was the #141 segment-cache sweep -- but that is
# a HYPOTHESIS, and #137 just taught us what plausible-but-unmeasured
# theories are worth. So: measure first.
#
# INSTRUMENTATION (this build):
# [segperf] calls=<GetSegmentToWorld entries> dirty=<mark-every-segment
# invalidation passes> ms=<time inside the accessor> -- per rstat window,
# BT_PERF_LOG-gated. The DIRTY count is the tell: if the beam path
# multiplies invalidations, dirty >> mechs-per-frame and ms tracks maxDraw.
#
# A/B: identical 2-node runs, beams firing continuously.
# run A: default (swept behaviour: beam muzzle via GetSegmentToWorld)
# run B: BT_BEAM_SEGFRESH=0 (pre-sweep plain compose at the BEAM site only)
# If A's stalls/dirty collapse in B, the beam-site sweep is the regression.
# If A == B and both are clean, the suspect is EXONERATED and the real 857
# load-stall cause is elsewhere (next: diff the load path).
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
run_one () { # $1 = tag, $2 = extra env value for BT_BEAM_SEGFRESH
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f sp_${1}_a.log sp_${1}_b.log sp_${1}_relay.log
bt_expert_egg MP.EGG SP.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" SP.EGG
( export BT_GOTO=enemy BT_GOTO_STOP=120 BT_AUTOFIRE=1 BT_AF_PERIOD=3
export BT_MP_LOG=1
bt_launch sp_${1}_b.log SP.EGG 0x0C -net 1601 )
sleep 2
( export BT_GOTO=enemy BT_GOTO_STOP=120 BT_AUTOFIRE=1 BT_AF_PERIOD=3
export BT_PERF_LOG=1 BT_MP_LOG=1
export BT_BEAM_SEGFRESH=${2}
bt_launch sp_${1}_a.log SP.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py SP.EGG 127.0.0.1:1501 127.0.0.1:1601 > sp_${1}_relay.log 2>&1 &
local RELAY=$!
sleep 260
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
}
run_one fresh 1
run_one legacy 0
echo "=================== #149 SEGPERF A/B ==================="
for t in fresh legacy; do
echo "--- $t ---"
python - <<PY
import re, io
v=[]; seg=[]
for ln in io.open(r"C:\git\bt411\content\sp_${t}_a.log", encoding="latin-1", errors="replace"):
m=re.search(r"\[rstat\] frames=\d+ avg=([\d.]+)ms maxDraw=([\d.]+)", ln)
if m: v.append((float(m.group(1)), float(m.group(2))))
m=re.search(r"\[segperf\] calls=(\d+) dirty=(\d+) ms=([\d.]+)", ln)
if m: seg.append((int(m.group(1)), int(m.group(2)), float(m.group(3))))
if v:
import statistics
avgs=[a for a,_ in v]; draws=[d for _,d in v]
stall=sum(1 for a,_ in v if a>50)
print(" rstat blocks=%d avg-med=%.2fms maxDraw-med=%.2f maxDraw-worst=%.1f blocks>50ms=%d"
% (len(v), statistics.median(avgs), statistics.median(draws), max(draws), stall))
if seg:
c=[x[0] for x in seg]; d=[x[1] for x in seg]; ms=[x[2] for x in seg]
print(" segperf windows=%d calls-med=%d dirty-med=%d ms-med=%.2f ms-worst=%.2f"
% (len(seg), sorted(c)[len(c)//2], sorted(d)[len(d)//2], sorted(ms)[len(ms)//2], max(ms)))
PY
done
+58
View File
@@ -0,0 +1,58 @@
"""#137: post the clean mechanism write-up (the inline attempt was shell-mangled)."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """*(Reposting -- the previous comment was mangled in transit. This is the readable version.)*
**MECHANISM SOLVED -- and it is not the respawn.** Controlled A/B on one bench, only the load differs:
| load | respawns | post-reset runaway |
|---|---|---|
| 0.95 throttle + continuous missile fire | 4 | **17 frozen samples, T climbs 77 -> ~11,600** |
| 0.50 throttle, no weapons | 2 | **0** |
Measured, not argued:
* **The reset works.** `T == startingTemperature` (77) at every single reset, in both runs.
`HeatSink::RTIS @004ad760` does `param_1[0x45] = param_1[0x4f]`, and our port matches --
additionally resetting `heatEnergy`, which it must, since the sim derives
`currentTemperature = heatEnergy / thermalMass`.
* **The stale cache is real but harmless.** Nothing in the reset chain writes
`Myomers::speedEffect` (@0x31C) -- `Myomers::RTIS @004b8aa4` only chains to the
PoweredSubsystem one -- so it survives the reset reading 0. It self-heals on the very next
tick (`T=77.13` -> `speedEffect=1`). Not the bug.
* **With ordinary load the myomers never approach `failT=2000` after a respawn.**
## What players are actually experiencing
Run hard -> myomers pass `failT=2000` -> the derating curve `@004b8ac0` returns 0.0 -> chain
MAX 0 -> `speedDemand *= 0` -> bogged down -> die, often *because* bogged down. The respawn
correctly resets to 77. Resume high throttle and firing and it climbs back over the cliff within
seconds -- which reads as "respawned with the heat bar maxed".
That also explains the intermittency (5 of 61 field respawns, ~8%): it tracks how hard you were
driving into and out of the respawn, not the respawn itself.
Oracle's "loop 5 and generator D heating up as all the excess heat goes into the loop" is
confirmed literally -- the myomers link to **Condenser5** (`mass=250000 k=190000`).
## The remaining defect: the climb RATE
Overshooting roughly 6x past a cliff the design treats as coolant-managed (`degradeT=1000`
governor onset, `failT=2000`) is not a lever a player can work with. That is the real ticket now.
Suspect under review, **not yet proven**: the kinetic term. The binary `@004b8d18` applies **no**
`time_slice` to it (`fVar5 * fVar1`), while the climb and accel terms both carry `param_2` -- i.e.
it is a per-frame energy add at the pod's fixed ~28 Hz. Our port rate-normalises it
(`work * (time_slice * 28)`), which agrees per-second at any frame rate, so this is not yet a
demonstrated discrepancy. Next step is a term-by-term dump (`BT_MYO_LOG`) against the authored
tuning: VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5.
**Retitling suggestion:** this should stop being "respawn comes back hot" and become
"myomer heat rate under sustained load overshoots the failure cliff"."""
gitea.comment(137, BODY)
gitea.call("/issues/137", method="PATCH", payload={
"title": "Myomer heat rate under sustained load overshoots the failure cliff (was: respawn came back with MYOMERS heat MAXED)"})
print("posted + retitled #137")
+58
View File
@@ -0,0 +1,58 @@
# Apply the ALPHA-MR pod kit to a BT411 install's content directory.
#
# WHY THIS EXISTS: the frozen rig config lives in content\environ.ini and
# glass_layout.cfg -- both INSIDE the versioned install folder, and neither is
# shipped in the zip (environ.ini is generated on first run). So extracting a
# new build gives you a cab that comes up wrong with no error anywhere. The
# MASTERS live beside this script at a stable path; this pushes them into
# whichever install you point it at. Idempotent -- safe to re-run.
param([Parameter(Mandatory=$true)][string]$Content)
$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
$prof = Join-Path $here 'podprofile.ini'
$lay = Join-Path $here 'glass_layout.cfg'
$ini = Join-Path $Content 'environ.ini'
if (-not (Test-Path $Content)) { Write-Output "NO SUCH CONTENT DIR: $Content"; exit 1 }
if (-not (Test-Path $prof)) { Write-Output "MISSING MASTER: $prof"; exit 1 }
# --- the env block: replace anything between the markers, keep the rest ------
$block = Get-Content $prof
$keep = @()
if (Test-Path $ini) {
$inBlock = $false
foreach ($line in (Get-Content $ini)) {
if ($line -match '^# ==== BT411 POD PROFILE') { $inBlock = $true; continue }
if ($line -match '^# ==== END BT411 POD PROFILE') { $inBlock = $false; continue }
if (-not $inBlock) { $keep += $line }
}
# ⚠ `-gt 1`, NOT `-gt 0` (fixed 2026-08-08). With `-gt 0`, once $keep trims
# down to a SINGLE blank line, $keep.Count-2 is -1 and PowerShell's
# $keep[0..-1] returns TWO elements (index 0 and index -1 = the last) instead
# of shrinking -- so the array grows and this loops FOREVER, RSS climbing past
# 60 MB. It fires whenever everything outside environ.ini's marker block is
# blank, i.e. any environ.ini that was ALREADY kitted -- exactly what you get
# carrying config forward from the previous install. Symptom on the cab: a
# blank cmd console, BT411Run stuck "Running", no btl4.exe and no podrun.log,
# and each hung run holds environ.ini so every later attempt blocks too.
while ($keep.Count -gt 1 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
}
($keep + @('') + $block) | Set-Content $ini -Encoding ascii
# --- the layout: the master always wins -------------------------------------
# Re-tuning the cab means editing the MASTER beside this script, not the copy
# in the install -- a BT_GLASS_LAYOUT=save drag inside an install is overwritten
# on the next apply, on purpose. One place to look when a panel moves.
if (Test-Path $lay) { Copy-Item $lay (Join-Path $Content 'glass_layout.cfg') -Force }
# --- the pod test mission ----------------------------------------------------
# PODTEST.EGG is NOT in the repo -- it was made on the cart, so a fresh extract
# has no mission for runpod.bat to launch (found the first time a new build was
# dropped: the launcher ran and nothing came up). Carry it in the kit.
$egg = Join-Path $here 'PODTEST.EGG'
if (Test-Path $egg) { Copy-Item $egg (Join-Path $Content 'PODTEST.EGG') -Force }
Write-Output "pod kit applied to $Content"
Get-Content $ini | Select-String '^(BT_|L4)' | ForEach-Object { " " + $_.Line }
if (Test-Path $lay) { Get-Content (Join-Path $Content 'glass_layout.cfg') | Select-String '^[A-Z]' | ForEach-Object { " " + $_.Line } }
+88
View File
@@ -82,6 +82,94 @@ try {
} catch { W " (EDID query failed -- normal over some remote sessions: $_)" }
W ""
# ---------------------------------------------------------------------------
# BOOT-STABLE panel identity. Windows renumbers \\.\DISPLAYn and reorders the
# enumeration when a panel is power-cycled or re-cabled, so binding the pod by
# index or device name is fragile (Nick, 2026-08-07: "the order changed ... even
# if the visual desktop tool looks the same"). Bind by the panel's own EDID
# identity instead: glass_layout.cfg accepts `monitor:id:<fragment>`.
#
# This deliberately calls the SAME Win32 API the game does (EnumDisplayDevices
# on the display's MONITOR child) rather than the WmiMonitorID above -- if the
# probe and the engine read different sources, the fragments printed here might
# not be what the engine actually matches against.
# ---------------------------------------------------------------------------
W "---- BOOT-STABLE panel identity (paste these into glass_layout.cfg) ----"
try {
if (-not ("BTDisp" -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class BTDisp {
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]
public struct DISPLAY_DEVICE {
public int cb;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string DeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceString;
public int StateFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;
}
[DllImport("user32.dll", CharSet=CharSet.Ansi)]
public static extern bool EnumDisplayDevicesA(string dev, uint num, ref DISPLAY_DEVICE dd, uint flags);
public static string MonitorId(string display) {
DISPLAY_DEVICE dd = new DISPLAY_DEVICE();
dd.cb = Marshal.SizeOf(typeof(DISPLAY_DEVICE));
// 0x1 = EDD_GET_DEVICE_INTERFACE_NAME (richer path, includes connector UID)
if (!EnumDisplayDevicesA(display, 0, ref dd, 0x1)) {
dd = new DISPLAY_DEVICE();
dd.cb = Marshal.SizeOf(typeof(DISPLAY_DEVICE));
if (!EnumDisplayDevicesA(display, 0, ref dd, 0)) return "";
}
return dd.DeviceID;
}
}
'@
}
$codes = @{}
$rows = @()
foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
$sid = [BTDisp]::MonitorId($s.DeviceName)
# EDID PnP code = 3 letters + 4 hex digits (AUO10ED, DEL4231)
$code = ""
if ($sid -match '[\\#\?]([A-Za-z]{3}[0-9A-Fa-f]{4})[\\#\?]') { $code = $Matches[1] }
elseif ($sid -match '([A-Za-z]{3}[0-9A-Fa-f]{4})') { $code = $Matches[1] }
$rows += [pscustomobject]@{ Dev=$s.DeviceName; Prim=$s.Primary; Sid=$sid; Code=$code
X=$s.Bounds.X; Y=$s.Bounds.Y; W=$s.Bounds.Width; H=$s.Bounds.Height }
if ($code -ne "") { $codes[$code] = 1 + $(if ($codes.ContainsKey($code)) { $codes[$code] } else { 0 }) }
}
foreach ($r in $rows) {
W (" {0}{1} {2},{3} {4}x{5}" -f $r.Dev, $(if ($r.Prim) { " *PRIMARY*" } else { "" }), $r.X, $r.Y, $r.W, $r.H)
W (" stable-id : {0}" -f $(if ($r.Sid) { $r.Sid } else { "(unavailable)" }))
if ($r.Code -ne "" -and $codes[$r.Code] -gt 1) {
# Same model on more than one output: the EDID code alone is ambiguous.
# The connector UID in the tail is what separates them.
$uid = ""
if ($r.Sid -match '(UID[0-9]+)') { $uid = $Matches[1] }
if ($uid -ne "") {
W (" cfg form : monitor:id:{0} <-- code '{1}' is on {2} panels, so use the UID" -f $uid, $r.Code, $codes[$r.Code])
} else {
W (" cfg form : (AMBIGUOUS -- '{0}' appears on {1} panels and no UID found;" -f $r.Code, $codes[$r.Code])
W " use a longer unique substring of stable-id above)"
}
} elseif ($r.Code -ne "") {
W (" cfg form : monitor:id:{0}" -f $r.Code)
} else {
W " cfg form : (no EDID code parsed -- use a substring of stable-id)"
}
}
$dupes = ($codes.GetEnumerator() | Where-Object { $_.Value -gt 1 } | Measure-Object).Count
if ($dupes -gt 0) {
W ""
W (" NOTE: {0} EDID code(s) appear on more than one panel (identical models)." -f $dupes)
W " Those lines use the connector UID instead, which is per-output."
}
W ""
W " Run this again AFTER a reboot or a panel power-cycle: the device names"
W " and ordering above may move, but stable-id / cfg form must NOT."
} catch { W " (stable-identity probe failed: $_)" }
W ""
W "---- SERIAL PORTS (the RIO board lives on one of these) ----"
try {
$sp = Get-CimInstance Win32_SerialPort