#150 -- one kill credited twice (Rajel on Oracle, Oracle on Conn Man).
CAUGHT: one death -> two type-2 arms 1ms apart (awards 501.96/505.88 -- the
tally GREW between them), victim inst=R. Mechanism: this port deliberately
applies damage on replicants for local visual response; the report tail ran
there too, and a replicant's movement mode cannot flip to wreck until the
victim's death record round-trips -- so each round of a killing VOLLEY inside
that window re-posted the kill. Why missiles, why intermittent.
The binary forbids the whole situation: its handler OPENS with the assertion
if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage",
MECH.CPP, 0x3da) [part_012.c:14613]
-- TakeDamage on a replicant was a BUG in 1995; the tail only ever ran on the
victim's master. Fix: (1) gate the REPORT TAIL master-only (local damage
application stays); (2) adopt the binary's once-latch -- the 0->1 edge of
IsMechDestroyed() across the handler call (local_14), replacing the
deathBlastArmed form for the report gate.
VERIFIED (scratchpad/night14/scorepair.sh): 3 victim deaths, 5 shooter
respawns -> exactly one type-2 per credited kill, kills 0->1->2, zero doubles
(was: kills 0->2 on one death).
#151 -- panic eject "-499 instead of 0" is AUTHENTIC 1995 SCORING, decoded
from EjectPilot @0049f854: the punch-out dispatches a SELF TakeDamage of
damageAmount = role->killBonus (+0x1c, 500 in dfltrole), type Explosive,
senderMechID = SELF, player->suppressConsole = 1, graphicAlarm -> 10
which flows through the report tail as a SUICIDE kill: award = -CalcKillScore
(your own death), plus the type-1 death cost. 1000 - ~999 - 500 = -499:
the binary predicts Oracle's exact number. Our port already used
role->killBonus for the blast (mech.cpp:652) -- faithful end to end; bench
shows each eject charged exactly once (-900/-1050 across three ejects).
Panic eject = scored as suicide. Whether that is fun is a design question;
it is not a porting defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
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>
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>
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>
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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