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
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
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
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
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
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
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
#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
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
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
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
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
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
Nick ran the cab with the real board and the mission came up on the main view
with every MFD dark -- and not one [glasswin] line in the log.
BTGlassPanels_Create() was called only from the END OF THE PadRIO CONSTRUCTOR.
The panels began as the frames around the on-screen RIO button banks, so "the
buttons ride the device" was reasonable then; it is backwards on a real cab.
L4CONTROLS=RIO:COM1 means PadRIO is never constructed, so the MFD windows were
never created -- silently, since nothing is wrong from the display layer's
point of view.
The panels are a DISPLAY concern. Creation moves to LBE4ControlsManager,
after the L4CONTROLS parse where every device branch converges, and a
symmetric BTGlassPanels_Destroy() goes in its destructor: ~PadRIO tore them
down, ~RIO knows nothing about them, and windows outliving the surfaces they
blit would crash on the next mission cycle. Both calls are idempotent
(Create returns on gWinCount != 0), so the proven PadRIO path is unchanged
and simply arrives first. Both guarded by BT_GLASS -- L4GLASSWIN is only in
the build when the gate is on.
Verified on the cart: hardware RIO up AND all three surfaces on their intended
displays in the same run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
The real cockpit board now drives the cab instead of PadRIO, frozen in the
profile so all five tester launchers get it. Nothing above the seam changed
-- the 109-mapping L4 control table installs exactly as on a desktop, and the
cab keeps the GLASS display stack. BT_PLATFORM=pod is NOT the way to this;
that would drag in the 1995 gauge path. RIO:COM1 -> \.\COM1 at 9600 8N1.
Evidence the link is real, not just "the port opened":
RIO successfully initialized!
FAILURE.LOG: 4 missing boards (Slot 3:0, 3:2, 4:0, 5:0), 16 dead lamps
[ctrlmap] push stick x=0.0595238 y=0 <- physical stick outside deadband
A specific 4-of-many board inventory is the proof: a dead serial line reports
the WHOLE address space missing. Reproduced after deleting FAILURE.LOG. The
dead lamps are the boards this partial crash cart does not have.
Banner honesty: "GLASS (PadRIO ...)" was hardcoded, so a wired cab reported
PadRIO on the very line you read to check which device won. It now names the
resolved one -- GLASS (hardware RIO; plasma off [L4PLASMA]).
Recorded in pod-hardware.md, including that RIO and PAD are mutually exclusive
(both assign rioPointer, last token wins) and that a CENTRED stick reads x=0,
which is indistinguishable from no data -- so the by-hand check of stick,
throttle, pedals, buttons and the Ranger calibration is still outstanding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
Both frozen files live inside the versioned install and neither ships in the
zip -- environ.ini is generated on first run, glass_layout.cfg is ours. So
Nick extracting the next build would get a cab that comes up wrong with no
error anywhere, which is exactly the failure the freeze was supposed to end.
Masters now live at the stable C:\bt411\ (podprofile.ini, glass_layout.cfg,
podkit.ps1). setup_pod.bat pushes them into the newest BT411_* folder -- run
once per extract. runpod.bat resolves the newest install and applies the kit
itself, so the remote launch path needs no per-build edit. Re-tuning means
editing the MASTER: the apply overwrites the install's copy on purpose, so a
moved panel has one place to look.
Also records, for playtesting on the cab: all five tester launchers set no pod
key at all, so each inherits the rig from environ.ini without knowing the pod
exists -- verified by running play_solo.bat on the cart (7 settings applied,
not 9, because the bat sets BT_PLATFORM and BT_START_INSIDE itself and the
real environment wins). That is the case against a separate pod-only ini: a
second file would need every launcher to opt in.
Verified: kit re-applies idempotently, and the rewritten runpod.bat brings the
cab up correct -- 9 settings, GLASS, -fit borderless, all three surfaces on
their intended displays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
BT_GLASS is a compile-time #ifdef; getenv("BT_GLASS") appears nowhere in the
tree. The line rode along from the bring-up launcher into the frozen profile
and into the pod-hardware runbook, reading like the switch that turns the
glass path on. It never did anything -- the rig worked because glass is the
DEFAULT profile when nothing is set.
Replaced with BT_PLATFORM=glass (the real spelling, so the cart does not lean
on that default) and noted why NOT BT_PLATFORM=pod: the pod profile selects
the 1995 multi-surface gauge path, which needs the NVIDIA horizontal span no
modern driver has. Behaviourally identical -- both land gBTPlatformGlass=1.
Re-verified on the cart: 9 settings applied, GLASS profile, -fit borderless,
all three surfaces on their intended displays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
The cart's display config was carried by a launcher .bat, so it only came up
right if the game was started one particular way. Move it into the file the
engine already reads before anything touches the environment.
content/environ.ini <- scratchpad/pod/podprofile.ini, merged idempotently
between markers by scratchpad/pod/mergeprofile.ps1
Two gates were missing for that to be enough:
BT_FIT=1 the env spelling of -fit, so the borderless main view does not
depend on one launcher's command line (shortcut, scheduled
task and autostart all have to produce the same rig)
L4PLASMA= NONE / OFF / 0 -> no marquee at all. Leaving it unset does
NOT work: the GLASS profile force-defaults it to SCREEN, which
drops a desktop plasma window on the cab's glass. The boot
banner now reports the live state instead of always claiming
"plasma window".
Also lands the bring-up engine work this depended on: monitor:<name|index>
layout binding (device-bound, not pixel-bound -- desktop rects move when a
display re-enumerates), ",bare" implying frameless, rotation-aware radar
surface sizing, BT_GAUGE_SEC_ROT accepting 0-3 (it silently forced 3 for
anything but 1), 180-degree ExpandPlaneToBGRA, and the BT_POD_CHANMAP /
BT_POD_IDENT / BT_POD_CHANTEST identification gates.
Verified on the cart, build 4.11.813, launcher carrying none of it:
[boot] environ.ini: 9 setting(s) applied
[boot] platform profile: GLASS (PadRIO; plasma off [L4PLASMA])
[cockpit] -fit: borderless 800x600
[glasswin] radar rotation 0 (none)
... all three surfaces on their intended \.\DISPLAYn
No [plasmawin] line in an otherwise-logging run = the ctor never ran.
KB: pod-hardware.md gains the ALPHA-MR section (mapping, the two wiring
deviations, the frozen profile, the session-0 remote-work traps) and its RGB
SPLIT "OPEN: which way is the cart wired" is now SETTLED -- it is splitter
wired, the composite is what lit it. glass-cockpit.md documents monitor:
binding, BT_FIT and L4PLASMA=NONE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
Answering 'how do the panels split RGB into 3 monitors' from primary sources
rather than inference:
- content/GAUGE/L4GAUGE.CFG (the authentic 1996 pod config) configures each
gauge port with a bit-plane mask AND A COLOUR CHANNEL: Comm=red,
Mfd2=green, Heat=blue on clut2 (the upper row); Mfd1=red, Mfd3=green on
clut1 (the lower row, blue spare); sec/radar = full rgb, rotation 270 (the
portrait CRT). Eng1/2/3 are the engineering-page twins on the same
monitors, swapped in/out via reconfigure() with 'blank'.
- L4GraphicsPort::BuildSecondaryColor (L4VB16.cpp) proves the mechanism at
T0: it walks the palette entries owned by the port's bit group and writes
exactly ONE component (RedChannel->Red, GreenChannel->Green,
BlueChannel->Blue, AllChannels->whole triplet); BlankColor blanks the
group. So one palettized framebuffer emits three independent pictures on
the R/G/B analog lines, and the splitter feeds each line to its own mono
monitor -- which is also what the '1280x480 horizontally spanned' MFD
surface actually is: two VGA outputs x three channels.
Port consequence recorded: the per-panel window path (BT_POD_SURFACES) is
right for per-panel outputs but WRONG for splitter-wired glass, which needs a
channel-composite mode (three planes -> one RGB image, pure primary tints).
ExpandPlaneToBGRA already does the per-plane half. Open: how Nick's cart is
actually wired. Also lands the pod bring-up scratch (ssh helper, layout cfg,
launcher, firestorm repo browser).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nick's crash cart is the first shot at driving the real MFD panels, so the
pieces the cab needs that the desktop glass path lacked:
- BT_POD_SURFACES=1 (L4GLASSWIN): crop every display window to its SURFACE
(MFDs exactly 640x480, radar 480x640 portrait), drop the on-screen RIO
button banks -- the cab's buttons are PHYSICAL, so drawing fake ones onto a
real panel is exactly wrong -- go frameless, and skip the Flight Controls
pad entirely (6 windows, not 7). Per-window ",bare" in glass_layout.cfg
for mixed rigs (idempotent with the global mode).
- Placement receipts: each window logs which PHYSICAL monitor it landed on
("[glasswin] 'Heat MFD' surface=Heat at X,Y 640x480 bare -> monitor
\.\DISPLAYn (...)"). Nobody can see 7 surfaces at once on a cab, and over
Chrome Remote Desktop you cannot see the panels at all -- the log is the
only confirmation the map is right.
- tools/podprobe.ps1: run-on-the-pod topology probe (no install/admin) --
GPUs, every monitor's virtual-desktop rect, EDID make/model, serial ports
(the RIO board), session type, plus a PROPOSED glass_layout.cfg assigning
the six surfaces to the non-primary monitors. Self-tested here (correctly
reports a single-monitor laptop and declines to map).
Local verify: 5 MFDs at 640x480 bare + portrait radar + Flight Controls
dropped, receipts printed. KB: pod-hardware §MFD PANELS ON REAL HARDWARE
(incl. WHY the 1995 path is not the route -- NVIDIA Horizontal Span is gone
from post-XP drivers and per-adapter exclusive fullscreen is fragile over a
remote session) + glass-cockpit env table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CLAUDE.md front + project-overview: census/re-export DONE (93.5% coverage,
41KB dark), polish list refreshed; the citation-policy warning rides the
front matter so it is unmissable.
- locomotion §CROUCH addendum: the re-export CONFIRMS the hand transcription
field-for-field, documents that Ghidra renders members as int-ARRAY indices
([0xfe] == byte 0x3f8 -- why offset-string greps of the export miss them),
and records the AIRBORNE AUTO-RISE branch the raw pass missed (+ its fix).
- decomp-reference §7: citation policy (@ADDR, never part/LINE -- old
citations resolve only against archive_2025export/), the array-index
gotcha, and the full re-export toolchain (ghidra_reexport.sh incl. the 8.3
short-path requirement, gapcensus, gapdiff).
- open-questions: the leads the re-export produced -- @0x4c0904 is the MASTER
BTPlayer Performance (team-by-name resolve + EndMission post + score
heartbeat; our @0x4c083c attribution needs a re-check) and the ~9KB
l4splr|btmssn cluster that stayed dark THROUGH the fill (no call/data
reference reaches it -- jump-table entry suspected).
- glossary: dark-region.
Section ordering fixed (addenda above Key Relationships); checkctx CLEAN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Installed JDK 21 + Ghidra 12.1.2 (no admin, %LOCALAPPDATA%\bt411-tools beside
DXSDK/cmake; runner uses 8.3 SHORT paths because Ghidra's .bat expands
%JAVA_HOME% unquoted and the profile has a space).
New tooling: reference/ghidra_scripts/ExportGaps.java -- ExportAll's exact
output contract PLUS a gap-fill pass (force disassembly + createFunction at
E8 call targets outside functions, data->code pointers at a plausible
prologue, and the census's discovered starts; iterated to a fixpoint,
logged to gapfill_report.tsv). tools/ghidra_reexport.sh (headless runner,
'reprocess' mode) and tools/gapdiff.py (score two censused exports);
gapcensus.py now censuses any export dir.
Results: 6267 -> 6472 functions (+205 created in 2 rounds: 195 census
starts, 6 call targets, 4 data pointers; 56.1KB newly covered), ZERO
decompile failures. Dark real code 90.4 -> 40.8 KB (54.8% recovered);
game-side dark 53.1 -> 21.1 KB; regions 428 -> 321. EVERY historically
dark function now has pseudocode -- including @0x4c05c4 VehicleDead, the
absence that opened this issue.
VALIDATION: the new pseudocode confirms this week's hand reconstruction of
the crouch field-for-field (mapPosture/duckState/squatCapable/myomerEff/
novice gate/SetLegAnimation/ForceUpdate/stability alarm) -- and exposed one
branch the raw pass missed: AIRBORNE AUTO-RISE (mode 3|4 && legState 1 ->
forced squ), now implemented in mech4.cpp and re-benched un-regressed.
PROMOTION: the re-export is canonical reference/decomp/; the previous export
is preserved at reference/decomp/archive_2025export/ so old
`part_0NN.c:LINE` citations still resolve (addresses are stable across both;
line/shard membership is NOT -- cite @ADDR).
New lead recorded: @0x4c0904 is the MASTER BTPlayer Performance (team
resolution, EndMission console post, score heartbeat) -- our @0x4c083c
PlayerSimulation attribution needs a re-check. KB: source-completeness,
gotcha #20 (the rule is cheap now -- look it up), CLAUDE.md router/layout.
Log: phases/phase-04-gap-census.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tools/gapcensus.py (deterministic): index-vs-.text interval math, export
cross-check, function-start discovery inside dark regions (E8 call targets +
data-section code pointers), pad exclusion, TU attribution, repo-citation
flags. Output: reference/decomp/GAP_CENSUS.md + gap_census.tsv.
Headline: .text 892KB, index covers 87.3%; 428 dark regions = 90KB REAL code
(indexed-but-unexported = 0 -- the gap class is purely 'never indexed').
Game-side dark ~54KB: 66 regions visited by past digs, 159 NEVER TOUCHED.
Validation: all six historically-bitten dark addresses (VehicleDead,
ToggleLamp, death tail, master-perf, myomer integrator, duck consumer) land
inside census regions; the two most-cited regions are the two that produced
the most reconstructions.
Top uncharted leads (spot-checked real code): the ~9KB l4splr|btmssn cluster
(dispatch-table state machine -- likely BTMission's unexported heart); the
613B btplayer hole before the ctor (mission-review id-0x18 sender suspect);
btl4app tails; heat|mechmppr + mechweap|btplayer boundaries. Full log:
phases/phase-04-gap-census.md. Re-export half deferred (no local Ghidra;
scripts ready). KB: source-completeness census section + CLAUDE.md lookup
row + decomp-reference tools entry; consult the census BEFORE any 'absent
from the export' claim (gotcha #20).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crouch gate to the LIVE drive value
The myomer system was ALREADY COMPLETE (2026-07-31 seek audit): Performance
wrapper @004b8b9c, AvailableOutput @004b8ac0 (gear clamp x quadratic heat
degrade x (1 - zone damage)), and the master-perf chain walk + speedDemand
scale + turn freeze in mechmppr.cpp:990 -- the same @0x4a9cf2-0x4a9da4 bytes
the crouch dig re-decoded. The 2026-08-05 banners calling the feeder dark
were an export-gap-blind grep (named members, not offsets). Fixes:
mechmppr publishes the chain MAX into mech->myomerEffectiveness (the
binary's +0x79C home) so the crouch posture gate reads the live factor
(dead/overheated myomers now genuinely refuse squat/rise -- previously the
gate read a neutral 1.0 and never fired); the duplicate speedDemand multiply
in the posture block is removed (mechmppr's is the one application); banners
and locomotion.md corrected. Squat re-benched green on the live wiring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The WIP's 'pose does not hold' was a chain of bench-instrument errors, not a
code bug: every capture ran in COCKPIT view (the pilot cannot see their own
legs; the eye-height residual masked as reversion). Joint probes prove the
park holds indefinitely (knee 1.138, root -2.219 steady); the 2-node bench
shows the observer's replicant fully crouched and held (duckmpA_031 -- the
type-3 state record carries it with zero new replication code); the second
scripted press (new BT_BTNTEST2 env) verifies RISE -> standing zeros.
MP button delivery confirmed mode-mask-clean (the one miss was round-start
jitter). Diags added, all BT_DUCK_LOG-gated: SetLegAnimation re-arm tracer,
1 Hz joint probe, RIO press mode-mask, BT_TREE_LOG topology dump, and the
squat-park log. RESIDUAL filed: pilot's own cockpit eye does not ride the
root drop (DPLEyeRenderable chain composition; cosmetic).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SPOT.BGF decoded: a 7-vert cone from the mount, ~50u forward and ~35deg DOWN
(a ground-pool lamp, not an air beam), verts tinted cyan-white, material
class 'brighten' smuggling its additive factor in DIFFUSE.r (0.25) with a
warm emissive on the night page. The loader had never met the class -- it
drew as an opaque dark-red blob. Now: brightenFactor parsed (name-gated to
brighten*), batch -> L4DRAWOP.brightenAlpha, drawn in the blend pass as an
additive veil (dest += vertexRGB x factor), unlit. [T3] tint compose
(vertex cyan vs night emissive warm) noted in the draw branch -- field
eyeball accepted the current look.
BT_SPOT_SELF=1 (bench-only): builds the cone on the own-cockpit tree so the
BT_CAM=face view can inspect it solo. KB: view toggle is BACKTICK ('V' is
the rear-view hold since #68 -- the toggle skips bound keys); stale V-toggle
claims swept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconstructs the dark-gap tail of Mech::TakeDamageMessageHandler
(@0x4a02f4-0x4a0890, raw disasm): the three id-0x16 score reports (kill to
the shooter's player / type-0 wire-fidelity / received to the victim's
player) and the BT 0x38-byte VehicleDeadMessage extension {killed-by player,
kill zone} dispatched from the death tail. Retires BTPostDamageScore /
BTPostKillScore and the per-hit inflicted credit (never existed in 1995:
@0x4c0200 is bound in no handler-table entry -- byte-scan receipt in
decomp-reference). Suicides now dispatch and the handler negates the award
(the #134 panic penalty). Collision divert falls through to the death tail
per @0x4a0375 (wall deaths respawn + blast; no score). ScoreMessage fields
renamed to decoded truth (vitalHit/zoneIndex/subsysID) + wire asserts;
console VTVDamaged points_transfered corrected (Round(award), not Now()).
Benches: scorekill.sh cross-node kill (kills=1 award=4.88, killedBy=2:1
zone=3, single death cycle), scoreself.sh suicide (type=2 award=-39.00
kills=0), deathblast2.sh re-verified (72 bursts at ~9u).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop plasma window (L4PLASMAWIN, "BattleTech - Plasma") is created by a
different TU than the per-display panels, so it couldn't be a gWins[] entry and
sat outside the sticky-layout / ,noframe system. Add a tiny extern-window
registry to L4GLASSWIN so it rides the same glass_layout.cfg:
- BTGlassLayout_QueryWindow(title, rect, noframe) -- read a title's saved rect
and ,noframe flag (used by the plasma on creation, BEFORE sizing, since
WS_POPUP's frame extent differs from the framed tool window).
- BTGlassLayout_RegisterExtern(hwnd, title) -- register an externally-created
window so SaveLayout writes its line (with a last-known-rect cache so a
teardown before the save still preserves the line).
- BTGlassLayout_Save() -- public save trigger the plasma WndProc calls on
WM_EXITSIZEMOVE / teardown.
L4PLASMAWIN now reads its saved rect+flag on create (WS_POPUP when ,noframe,
position restored), registers itself, and saves on finished-drag/teardown. All
BT_GLASS-gated; the pod is untouched. The plasma blits directly every frame, so
it has no WM_TIMER focus-throttle.
Verified: Release links clean; drag wrote "BattleTech - Plasma=321,222,528,167",
reload with ,noframe brought it up WS_POPUP (WS_CAPTION absent) at 321,222.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 08-03 zone rule (struck SEGMENT's SKL dzone, ALWAYS -> every aimed
torso hit = center torso) was wrong, and era players' pushback caught it.
Byte-level proof: MAD_TOR.BGF zone-tags the hull PER PANEL (dz_utorso x36,
dz_ltorso/rtorso x18, dz_dtorso x16, all four rear panels, searchlight);
the dpl hit result kept GEOGROUP granularity (dplHitGeoGroup, T0); and the
binary's segment->zone map @49db20 has NO runtime caller (raw call-scan:
sole caller = CreateStreamedDamageZone, load time) -- no segment-level
collapse mechanism exists. Oracle's night-10 'only LCT gets hits' audit
was the BUG's fingerprint, not the pod's design.
Fix: MechSegmentPick attributes the struck triangle to its draw op (index
range) and takes the op's .DZM-bound zone -- the #87 armour-darkening
bindings, the same authored patch->zone mapping that already paints the
panels -- with the segment dzone as the untagged fallback. ZoneAimPoint
now aims hull zones at their patch CENTROIDS (all hull zones previously
shared the chest cull-center), which also upgrades the zone walker.
Bench (2-node zone-walk vs spinning madcat, zonewalk_madcat.sh): every
hull panel resolves individually -- utorso 11/12 in-zone, no L/R
mirroring, all four rear panels register; misses are the panel facing the
shooter mid-spin (correct geometry, not misattribution). Victim applied
254 hits spread across every panel family. Was 102/102 hull aims ->
dtorso before the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The binary's inside renderable build sets type A (=4, part_014.c:5077) and
its per-segment loop looks up EVERY segment's type-A mesh (FUN_00424084,
:5570) with no torso/cop filter -- the arcade drew all authored own-body
meshes (thor pod, owens legs). No opt-out exists in the binary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three testers reported a black rectangle swaying with the footsteps in the
thor cockpit (Summoner = the thr1 label, same THX canopy -- no isolation).
Decode: the inside view is a PER-MECH AUTHORED type-A set, not the cop
alone (no fallback in EntitySegment::GetVideoObjectName -- authored data):
madcat/vulture/bhk1 = cop; sunder/loki/avatar = +tor; thor = +tor +MSL
(the shoulder pod, the reported rectangle); owens = +both legs +tshd.
The pod/leg pilot-facing surfaces use the SAME "<pfx>skin:blakskn_dz_*"
interior-structure material as the canopy frame, but the unlit frame
constant was keyed on the _cop FILENAME -- identical material rendered
(0.13,0.12,0.15) on the canopy and pure (0,0,0) on the pod/legs
([matlog]: owx_cop blakskn vcol=FF211F26 vs owx_lule vcol=FF000000).
Fix (bgfload.cpp): the frame-constant treatment keys on meshIsCop OR
material contains "skin:blakskn_dz_". mechfx:blakskn_mtl (tshd shadow
quads) deliberately excluded. Verified: zero pure-black px in the lower
view band across walk captures, the pod plate renders frame-toned and
blends with the bar at rest (the reported anomaly dissolves), owens legs
read as coherent structure, canopy/terrain un-regressed.
Diags added: BT_MAT_LOG=<stem> per-batch material routing dump (bgfload),
BT_HIDE_INSIDE_SEG=<substr> inside-mesh hide (btl4vid), [view] per-segment
inside-roster names. KB: cockpit-view.md exactly-one claim corrected +
the #91 section; bench scratchpad/night11/thorrect.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Playtesters (all on the glass surround) reported the cockpit lighting
sometimes going slow or nonexistent while the 3D view stayed perfectly
smooth. The RIO serial/lamp stack is byte-identical to pod-proven Red
Planet (diffed L4LAMP/L4RIO/L4SERIAL), so the wiring was fine -- the defect
is the glass UPDATE CADENCE:
The lit buttons draw every frame (BTDrawCockpitPanels, reading
PadRIO::GetLampState), but the lamp STATE sweep that fills that store
(LampManager::Update -> AssertNewLampValue -> SetLamp) rides the gauge
renderer's FOREGROUND turn, which fires only once per full gauge cycle
(foreground->background->copy). The cycle can't reach the next foreground
turn until the throttled background gauge sweep drains the ~140-instrument
active list -- and that background task starves under MP load (issue #45,
"instruments freeze while fps stays healthy"). So the lamp sweep ran
~1x/second: buttons froze / flashes stalled while the view (a separate
per-frame foreground render) stayed smooth.
Fix: BTGlassSweepLamps() runs the lamp sweep EVERY frame on the dev/glass
composite path (L4VB16.cpp, in BTDrawGaugeInset), decoupled from the gauge
cycle. It is cheap -- deduped state pushes over ~72 lamps, no raster. The
pod never enters BTDrawGaugeInset (real gauge hardware), so its authentic
bandwidth-paced serial lamp cadence is untouched. BT_GLASS_LAMP_SWEEP=0
restores the authentic once-per-cycle behaviour.
Verified: Release links clean (only the 40 tolerated /FORCE externals);
glass surround boots and runs, lamp sweep pushes state (43 [lamp] pushes,
buttons lit), ~60 fps, no crash. The under-load win is structural (the
sweep is now an unconditional per-frame call); true MP-starvation repro
needs multiple nodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gauge-alarm "condition" is a STATUS-FLAG BIT INDEX edge-scanned by
MechTech (bit 0/1 structure, 2 leak, 3 heat, 4 AmmoBurning, 5 Jammed,
6 !HasVoltage). Conditions 4/5 -> the engEject flash = the AMMO purge/unjam
invite (flashing the very key whose streamed function is EjectAmmo) --
never pilot eject; cond 6 -> the bus-switch invite; the PANIC lamp is the
sole pilot-eject indicator.
The destruction->stateAlarm(4) bridge is REMOVED as unfounded: alarms never
read stateAlarm; state 4 is the THERMAL BREAKER state produced by
GeneratorSimulation itself (byte-matched vs FUN_004b1f7c). And the binary's
crit distributor (@0049c9a8, read raw) touches nothing electrical -- a
generator destroyed in place keeps stale Ready voltage until any transition
recomputes output via (1 - damage) x rated. The port now matches that
subtlety exactly (verified: single-gen force-kill -> no bus invite, no arm,
silent unarmed keypad -- all authentic).
KB: the full bit table + invite semantics + electrical subtlety recorded in
decomp-reference.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The evaluator's "unexported caller" found by E8-scan: the Mech MASTER
PERFORMANCE (FUN_004a9b5c, in the 004a977x..004ab188 export hole) calls
EvaluateEjectPermission as its FIRST act every frame. Without that call the
port's ejectPermitted stayed at ctor-0 forever -- the panic-armed mode never
raised, so the PANIC lamp never lit and testers "never saw the option for
eject come alive". Restored at the top of Mech::PerformAndWatch (masters
only, like the binary).
Mislabel swept: mech+0x414 is ejectPermitted, NOT "missionReviewMode" -- the
mapper's @004d196c edge-watch arms PANIC mode 0x200000 from it (the real
review mode is the GLOBAL DAT_004fd550; btl4pb now reads that global, the
GetMissionReviewMode stub is retired, mapper member renamed
previousEjectPermitted).
Verified live: kill 4 generators -> "[eject] panic-arm mode ON" -> the
miniconsole Panic button lights dark->bright (pixel captures; the pad panel
shades it from the same PadRIO lamp state the pod's physical button uses) ->
BT_EJECT_AT press -> PUNCH-OUT -> respawn heals -> mode disarms -> healthy
presses REFUSED. BT_EJECT_LOG=1 logs the arm edges.
Note: no shipped gauge rides mode 0x200000 -- the 1995 eject indication is
the button lamp; the weapon-eng MFD "UNJAM/EJECT" is the ammo-jam indicator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-read the material damage watcher RAW (@004573e4 ctor / @00457784
change-push): 16 floats snapshotted, damaged = pristine x 0.1 (literal at
the MakeMechRenderables call site, watching zone+0x158), linear lerp, 13
floats written back, then FUN_0048d4d4 -- which is "flush_material": a dpl
command-stream marshal (opcode 100) to the i860 division card. The
material->ramp->texel response curve was CARD firmware and is not in
BTL4OPT.EXE; logged as an open question (ask: firmware/DIV docs/period
screenshots).
Field reconciliation, measured live: one 25-pt hit (ERLG class) snaps a
77-pt arm 0 -> 0.3247 in a frame -- "a single missile leaves visible
armor damage" holds under the linear curve for heavy hits; 2-pt ERS creep
(the leg-audit weapon) is the invisible case.
Bench additions: BT_WALK_ZONES=<name-list> walker filter (concentrated
per-region audits, scratchpad/night10/legwalk.sh) + BindArmourDamage
ORPHAN inventory under BT_ARMOR_LOG (proved every drawn op is
zone-claimed on the full tree).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The binary's effect watcher (FUN_0042aa2c) raises
ForceUpdate(DamageZoneUpdateModelFlag) when a zone's damageLevel CROSSES a
band-descriptor threshold (FUN_0042a5f4, master-gated on entity+0x28 & 0xc)
-- that send is how every other pod's replicant learns zone levels
mid-fight. The port's band hub kept only the graphic-state branch, so an
observer's copy sat at 0.0 until destruction: no enemy hull darkening, no
doll movement, ever (the night-10 "no armour discoloration" report).
Restored the level branch with the already-reconstructed DescriptorCrossed
(@0042a5f4) + the master gate on both branches (mesh swap still runs on
every instance). 2-node verified: A-side armour watcher pushed 114 level
changes; every replicant peak matched the master's finals to 4 decimals
(dtorso 0.9321 == 0.9321).
Render path cleared separately (gotcha #23 discharged): BT_ARMOR_FORCE 0/1
A/B captures prove the tint renders (hull -> charcoal at 1.0). Solo
perception is the authentic economy: 2-25 pt lasers vs 68-185 pt pools.
Bench: zonewalk.sh node A now carries BT_ARMOR_LOG + BT_SHOT_EVERY (the
standing zone-replication receipt) + digest_walk.py; armorpx.sh is the
force-pair pixel rig.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The precision rig the operator specified: two nodes, target visible,
every zone aimed at deliberately through the REAL reticle/pick path.
* BT_SPIN_SELF=<deg/s> (target node): rotates the node's own viewpoint
mech in place (the BT_SPAWN_AT write pattern per frame) so every
aspect passes the shooter's boresight; the operator watches this
node's paper doll take the hits.
* BT_ZONE_WALK=<secs/zone> (shooter node): walks the target's damage
zones in order, resolves each zone's carrier segment's live world
position (BTResolveSegmentWorld + the new BTMechZoneSegAndName
bridge), SERVOS the torso twist + aim elevation until the centered
reticle ray (BTGetAimRay) points at the segment, fires 3 laser
pulses, advances. [walk] ZONE/FIRE/HOLD on A pairs with [dmghit]
zone/level lines on B.
* scratchpad/night10/zonewalk.sh: launch both + relay, map sed'd to
grass/day, NO kill timer -- the session stays up for observation.
Hard-won servo constraints (documented in test-harness.md so they are
never relearned): the ENGAGE GATE (ray live + range<150 + bearing<1.1;
outside it RELAX the twist -- servoing at the twist limit while the
goto marches is a limit-clamp fight that visibly shakes the mech, and
a 0.55 gate deadlocks against the goto's ~0.55 resting bearing); YAW
POLARITY -1 (the twist cell's angular sense is opposite atan2(x,-z)
world yaw -- operator-observed live, the SECOND witness for the #124
SelectSlice twist-sign flip) with a divergence watchdog that
self-flips; damped correction (gain .40, cap .025/frame -- the aim ray
lags the twist write a frame); and a one-shot approach PORT to 100u
off the target instead of a cross-map march.
First live run: walker cycles zones, settles, fires; the target's
luleg climbed 0.458->0.523 under its own aimed pulses -- aimed shot,
correct panel, damage consumed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
proven by the mech's own pods
The first fix (c5dfb00) negated x AND z. The operator challenged it:
'they couldn't hit rear panels AT ALL -- a 180 rotation couldn't explain
that' -- and a rotation would also have relocated rear damage, not
erased it, while the bench premise (the dummy faces its attacker) was
assumed, never measured.
The zero-premise probe (BT_ASPECT_TEST): four self-impacts at known
WORLD cardinals + one at each weapon's PHYSICAL MUZZLE position -- the
mech's own asymmetric geometry as the left/right anchor. Measured:
* WorldToLocal is a clean R(yaw), no hidden terms (yaws -30/150/40 deg
all consistent);
* our frame: RIGHT = +X -- SAME as the 1995 ring (W0/W7 = Right*) --
and FORWARD = -Z, flipped vs the ring's Front*-in-+Z-arc;
* under the pi rotation the pods CROSSED (left muzzle -> rtorso);
under z-negation every anchor lands its own side.
So: negate local z only, and -- since a reflection reverses angular
direction -- the SelectSlice torso-twist term flips sign with it
(twist was 0 in all probes; twisted-torso verify tracked on #124).
Anchor acceptance (fresh exe, third yaw): LRM15_1 left pod -> ltorso,
LRM15_2 -> right wedges, left/right arm muzzles -> larm/rarm, nose ->
front cells, tail -> rear cells, flank cardinals -> rgun / left wedges.
The BT_ASPECT_TEST probe + BTWeaponMuzzleWorld bridge stay as bench
scalpels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Night-10's systematic targeting audit (rear panels unhittable from
direct fire, Owens left torso hitting the RIGHT weapon pod and vice
versa, missiles favoring the far side) measured down to one geometric
fact: the authored wedge ring puts the Front* cells in the +Z arc
(slice-name audit, bit-verified tables), i.e. the 1995 resolver frame
has mech-forward = +Z -- while OUR engine's entity frame carries
forward along -Z. Bench proof: a victim facing its attacker took
frontal beams at local z = -4..-10, theta ~270 deg, resolving the
REAR-named cells (zones 12/14/15) every time.
The two frames differ by a pi ROTATION about Y -- rotation, not
reflection, because the field flank observations CROSS (left->right)
rather than persist. ResolveHit now rotates the local impact into the
cylinder's frame (negate x and z; y untouched; handedness preserved so
the SelectSlice torso-twist add keeps its sign).
A/B (172 resolves, live missiles+beams on a facing victim): before
theta~4.7 -> rear zones only; after theta~1.42-1.59 -> utorso/dtorso/
ltorso/rtorso/arm -- the front-authored cells, with the per-burst
re-roll scatter intact.
NOT this bug: the "one band LOW" Y-axis half of the night-10 table --
that is #16 (pick-ray boresight parallax, code-confirmed, still open).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* SpecialCaseDeathPenalty restored to the engine ScenarioRole (SCNROLE.h/
.cpp) -- the 4.10-only role field the 2007 WinTesla source dropped
(record slot [1] after KillBonus, fail-hard read; role+0x20; the
DefaultRendererRate pattern). Its consumer decoded from the @004c05c4
export gap (@004c07cd, missing from the #52 reconstruction): every
death with advancedDamageOn hands the ENGINE base ScoreMessageHandler
(@0042da20 == PLAYER.cpp:138, a DIRECT call bypassing the BT type
Verifies) a type-1 award of -penalty. Wired verbatim. Shipped content
authors the key nowhere -> cost 0 in the field. NUANCE [T4]: the 1995
engine adds at Player+0x1c8, which no BT scoreboard reads (+0x278) --
the pod's cost may never have displayed; our single-cell port shows it.
* The eject console notice: FUN_004c198c IS the
ConsolePlayerMechDeathWithoutHonorMessage ctor (T0 BTCNSL.CPP survives
and is compiled) -- "death without honor" is the punch-out record.
BTPlayerEjectBookkeeping now sends it to the console host through the
same ConsoleClientID path as the VTVDamaged notify, then latches
suppressConsole: the eject notice REPLACES the death notify. Verified
two-node: "notice -> console host 1" fires on the ejecting node; the
relay-side delivery of client-5 traffic is a pre-existing question
shared with the never-observed VTVDamaged sibling (operator-console
workstream).
* Eject alarm-10 audio: resolved NEGATIVE -- the five Eject*.wav ship in
AUDIO/ but BTL4.RES references them nowhere (orphaned like the SQUAT
clips); wiring them would be a stand-in. Operator-optional deviation.
* RIO 0x38 panic control: deferred with the plumbing documented (the
glass Flight-Controls window already renders the 0x38 bank; consumer =
read buttonGroup[ButtonPanic] alongside the binding-engine edge) --
pod-hardware-only, untestable on this bench.
Two-node verify (fresh-exe rerun after a stale-exe trap): REFUSED
healthy, PUNCH-OUT after generator kill, death + wreck swap + smoke +
un-wreck warp all replicated on the observer, respawned mech refuses
again, console notice fired.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>