Commit Graph
555 Commits
Author SHA1 Message Date
arcattackandClaude Opus 5 4736cba1ca relay: LAUNCH is never silently inert again -- fix the post-round wedge (operator report)
THE SYMPTOM: "after a game ends I push LAUNCH and nothing happens until I reset
the session entirely or reboot the console."  The operator also observed the
mirror image, which is the tell: with a STABLE group, relaunching worked fine
several times in a row.

ROOT CAUSE.  Two gates, both all-or-nothing, and a UI latch that agreed with
them:
  * btconsole.py: the manual LAUNCH branch is guarded on `launches_sent == 0`,
    but a finished mission leaves it at 2.  The only paths back to 0 were
    _check_launch_gate (needs EVERY seat of the last round to re-ACK) and
    _maybe_reset_round (needs EVERY pod gone).  A games night lives between
    those -- most pods rejoin, one player closes their window.  Worse, the
    "not all seats filled" diagnostic sits INSIDE the `launches_sent == 0`
    guard, so in exactly that state NOTHING was printed.
  * btoperator.py: the LAUNCH button is gated on `not monitor.launched`, and
    `launched` was cleared ONLY by the two relay lines those same gates emit
    ("WAITING FOR OPERATOR", "round RESET").  So the button was greyed out in
    precisely the wedged state -- the click was a no-op by construction.
That is why a stable group worked (everyone re-ACKs -> gate fires) and why only
a session restart recovered (fresh relay process = fresh state).

FIXES
  * An explicit operator LAUNCH is now sufficient authority to start a new
    round: _rearm_for_new_round() clears the finished round's state and restores
    the template roster/egg, KEEPING seats/beacons/connections, so whoever is
    here stays here.  Triggered on a press while a round is latched.
  * New `rearm`/`newround` operator command + a Re-arm button, so recovery never
    needs a session restart.  Proven live over the control port.
  * Every operator command is logged AT RECEIPT with the state that decides its
    fate, so "did it arrive or was it ignored?" is answerable from the log.
  * A stale End Mission can no longer kill the next mission: _tick_stop consumed
    a stop_requested set between rounds by latching it until launches_sent hit 2,
    then StopMissioning the new mission in the tick it launched -- also
    indistinguishable from "launch did nothing".  It is now consumed + announced
    while idle.  The UI's end_sent likewise reset per round, not per session
    (End Mission used to work exactly once a night).
  * The UI clears `launched` on "StopMission sent" and on the re-arm line, so the
    button returns when the round actually ends, and only greys once a command
    has really been written (a dead relay now says so instead of logging
    ">> sent" into the void -- _console_finished leaves console_proc non-None).

HARDENING (the "reboot the console" half)
  * SO_KEEPALIVE on every accepted socket + a last_seen deadline and a reaper:
    nothing detected a pod that vanished WITHOUT a FIN (sleeping laptop, dropped
    Wi-Fi, killed process, NAT timeout).  Such a conn kept acked=True forever,
    which permanently blocked the round reset and held a phantom seat.  The
    reaper stands down while a mission is running.
  * Every pod-facing send went blocking with NO timeout on the single-threaded
    selector loop, so one wedged peer could freeze the entire relay.  All sends
    now go through _send_all_guarded (10s timeout, drops the peer on failure).
  * _send_egg no longer calls getpeername() on a possibly-dead socket.

REGRESSION I INTRODUCED AND CAUGHT ON THE RIG: the first cut re-armed whenever
launches_sent >= 1, which also matched the NORMAL state between RunMission #1 and
#2 (launch_requested deliberately stays set across the pair).  That reset the
pair mid-flight and re-released eggs every tick -- a re-arm storm that kicked
every pod into an identity-resync loop.  The re-arm now additionally requires
`launch_at is None`, i.e. no launch sequence in flight.  Verified: 0 REJOIN lines
and exactly 1 RE-ARM line across a full 3-mission rig session.

VERIFIED
  * scratchpad/test_relay_rearm.py -- 6 groups, all passing: the exact wedge
    state recovers; a stale stop is consumed while idle; staging is NOT restarted
    under an impatient operator; mid-pair never re-arms; the happy path is
    untouched and RunMission still reaches the pods; the reaper drops a silent
    conn and keeps a live one, and stands down mid-mission.
  * Live 2-pod rig (scratchpad/rig_relay.ps1 + relay_ctl.py over the control
    port): two clean back-to-back missions, StopMission, round RESET, a live
    `rearm`, and LAUNCH-with-nobody-present now printing why instead of nothing.

NOT reproduced end-to-end: the field wedge needs 3+ pods with staggered rejoins
(this rig's pods re-exec together, so the all-gone reset always fires).  The
state itself is covered by the unit test.  Awaiting live confirmation on a real
games night.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:21:24 -05:00
arcattackandClaude Opus 5 48d47ef806 #45 review pass: correct three claims the fix's own comments got wrong
An adversarial review of the change returned NO BLOCKERS but caught three
statements that were wrong or unverifiable.  All three are corrections to my own
comments/docs, not behaviour changes -- the verified binary is unchanged.

1. The phantom-kill note had the ORDERING backwards.  It claimed the owner's
   record "overwrites it on the next record -- the phantom stops being visible".
   The engine's event priorities run the other way: an inbound update record is
   posted at UpdateEventPriority == MaxEventPriority and drained by
   ExecuteBackgroundTask, while the rerouted score message sits at
   EntityManagerEventPriority == DefaultEventPriority and is popped later -- so
   the correction usually lands FIRST and the phantom AFTER it.  The artifact is
   BOUNDED, not masked: on the killer's pod only, that victim's KILLS reads +1
   until the next record, i.e. <= one 2s heartbeat instead of "until the victim
   next scores or dies".  Swept in btplayer.cpp, KD_SCOREBOARD_PLAN.md and
   RECONCILE.md.

2. The corpus figures were not reproducible, and were mis-tiered.  I cited
   "125 of 125 SCORE type=2 over 255 node-logs" and "0 of 8800 DMG rows" -- true
   when measured, but the corpus is append-only and this fix's OWN verification
   runs grew it from ~255 to 425 files, so nobody can re-derive them.  Replaced
   everywhere with invariant RATIOS that hold at any corpus size, re-measured
   here:
     * 0 of 18818 DMG rows are inst=R (damage is applied master-side only)
     * 439 of 439 DEATH inst=R rows read killer=0:0 killdmg=0.000, against
       0 of 225 inst=M rows (sole lastInflictingID writer: mech.cpp:746)
   Also re-tagged T1 -> T2: log-corpus field evidence is T2 per the CLAUDE.md
   tier table.  Lesson recorded in the banner: cite ratios, not counts, for a
   corpus your own rigs append to.

3. The recordLength guard's justification was wrong (and the plan's risk 5 with
   it).  A short legacy record does NOT desync the stream -- both sides advance
   by the WRITER's stamped recordLength and no reader asserts a length.  The real
   hazard is a read PAST THE ALLOCATION: an inbound update message lives in a
   per-event heap block sized from messageLength, so on a TRAILING legacy record
   the two new fields would be read off the end of it.

Plus: the record struct's own field comment still said "deathCount -- the DEATHS
column", the exact wrong-field belief #45 removed, sitting on the wire struct.

Filed the review's other finding on #60: a SECOND decomp export gap, index
jumping FUN_0049fe80 (ends 0x49ffc8) -> FUN_004a1232, 4714 unexported bytes
containing Mech::TakeDamageMessageHandler @0x4a0230 -- the producer of every
score and death message in the game.  Verified independently against
functions_index.tsv before posting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:15:06 -05:00
arcattackandClaude Opus 5 a52207d779 #45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims
Follow-up to 4fa7eee, driven by an adversarial review pass and three rig cycles.
Each item below is a real defect that pass found, not polish.

DEATHS now uses the binary's own column.  PilotList::Execute @0x4cabd0 draws
`fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS); +0x280 has exactly
two runtime writers image-wide plus the ctor zero.  Our port had declared it
`pad_0x280`, never written, and displayed the ENGINE's Player::deathCount
(+0x200) instead -- which is the respawn-handshake identity, seeded -2, and is
why it needed a clamp to pass as a count.  It is now BTPlayer::deathTally (our
offset 0x274, offsetof-locked), incremented beside ++deathCount, read by the
gauge, and replicated.  deathCount is left to the engine's handshake.  So this
half moves TOWARD the binary; it also closes the plan's Headline-1.
(`deathTally`, NOT `deaths`/`deathCount` -- those would shadow the base.)

The mirror was not self-healing: update records are UNRELIABLE by construction
(Entity::UpdateMessage clears ReliableFlag, ENTITY3.h:112; the relay's UDP path
also drops stale/reordered datagrams), and the pair was dirtied only on an
event.  One lost datagram left every peer stale until that pilot's next kill or
death -- forever for the last kill of a round.  Added a 2s heartbeat that
re-dirties the record, which also bounds how long the binary's phantom
partner-increment stays visible.  The timer is a function static deliberately: a
data member would change sizeof(BTPlayer) and break the offset locks.

Also fixed: the SBMIRROR row AND its change-detect both still read deathCount (a
constant -2 here), so the "log only the edge" guard could never be false and
every row printed deaths=-2.  That is what made the first rig runs look like
DEATHS was broken when a WRITE/READ trace proved the transport correct.  Row
count per node fell from ~20 to 3, one per real change.

Guarded the record against per-bit layouts: update_model is a BIT INDEX and
Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352) -- the DamageZone
bit emits a variable-length packed stream whose length it computes itself, so
appending two ints and re-stamping recordLength over that would corrupt it.

Deleted BTPlayerCountObservedDeath -- definition, call site, extern and friend
together, since /FORCE hides stragglers.  It never executed (its call site sat
inside the once-per-death transition, which a replicant never enters) and could
not have worked (0 of 8800 corpus DMG rows target a replicant, which is why every
DEATH inst=R row reads killer=0:0).  Under replication it would have been a
second writer of a replicated counter.

New forensics: NOCREDIT names the failing link when a kill credit is skipped (it
used to be completely silent -- the counter simply never moved), and PLAYER_LINK
records whether the one-shot link resolved.  Both retire the NULL-playerLink
theory: every rig shows `PLAYER_LINK inst=R resolved=1` and no NOCREDIT rows.
PLAYER_DEAD now logs both counters (deaths=handshake, tally=scoreboard).

KB sweep of the claims that hid this bug for so long:
  * context/gauges-hud.md's "RESOLVED -- deaths tally per node from locally
    observed events" was FALSE; corrected with the measured evidence.
  * docs/GAUGE_COMPOSITE.md + btl4gau3.cpp "the dead pad_0x280" -- never dead,
    merely unwritten.
  * btplayer.hpp attributed VehicleDeadMessageHandler to @004c012c; it is
    @004c05c4 (absent from the decomp export -- the #60 gap).
  * docs/RESPAWN_REARM_PLAN.md's "#45 SUBSUMED" -- the PLAYER_DEAD symptom was
    subsumed, the scoreboard defect was not.
  * The corpus figure in the record banner now states its method so it is
    reproducible.

Rig-verified (2-node loopback, several cycles): owner 3:1 kills 2/tally 1 read
`kills=2 deaths=1` on the peer; owner 2:1 kills 1/tally 2 read `kills=1 deaths=2`
on the peer.  Respawn unaffected, no crash, no GLITCH rows.  Still awaiting live
multi-pod verification by a human; all pods must run the same build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:09:00 -05:00
arcattackandClaude Opus 5 4fa7eee54f K/D scoreboard ROOT-CAUSED for real (#45): the kill credit was rerouted, not lost
Kills/deaths only ever appeared on ONE machine.  The cause is not a missing
tally -- it is Entity::Dispatch:

    if (GetInstance() == ReplicantInstance)              // ENTITY.cpp:244-251
        application->SendMessage(ownerID, EntityManagerClientID, message);

BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it.  The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only.  Every
other pod's copy therefore read 0 all mission.  playerLink was never NULL for
these kills -- the dispatch proves it resolved.

Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.

This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill.  So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.

  * BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
    Read/WriteUpdateRecord overrides.  No new data member, no new virtual ->
    sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
  * Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
    no dirty-bit edit was needed (plan risk 6 avoided).
  * recordLength guard in ReadUpdateRecord: a pod on a build without the
    extension degrades to "remote counters don't move" instead of reading a
    neighbouring record as a kill count.
  * Size locks added per plan risk 2 (no false offsetof assert).

Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3.  Exact convergence
where a remote column previously never left 0.  3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.

Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced.  It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2).  The phantom kill
stops being visible without silently deciding the fidelity question.

Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity.  Awaiting live multi-pod verification by a human.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:40:59 -05:00
arcattackandClaude Opus 5 da617e6f8f README + release notes: match reports UPLOAD THEMSELVES -- stop asking testers to send them
The operator caught this: I had carried the old "please send the operator your
matchlog" instruction into the release notes, and the shipped README has said
it for a while.  It is out of date.

Since 2026-07-22 the client auto-uploads its match forensic log to the relay
after every round: btl4main.cpp:914 calls BTRelayUploadMatchLog() once
RunMissions() returns, and the console receives it on route -9 and saves it as
matchlogs/<peer>_<name> (btconsole.py:1026-1042).  content/matchlogs/ is
already full of them.

So the instruction was making every tester hunt down and attach a file the
operator already had.  Corrected to state the real contract, including the
gaps the auto-upload genuinely cannot cover:

  * RELAY modes only -- join.bat and join_lan.bat both set BT_RELAY, so both
    upload.  play_solo.bat and play_steam.bat set none, so those still need a
    manual send.
  * The upload fires when a round ends CLEANLY (after RunMissions returns), so
    a mid-round CRASH loses it -- exactly the case we most want the file for.
  * join.log is NEVER uploaded -- no mechanism exists -- so that one is still
    a manual ask, and is now called out separately rather than buried.

Also added the same correction to dist/RELEASE_NOTES_4.11.550.md.

The zip cut before this (BT411_4.11.550.zip) carries the stale README and must
be re-cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:56:56 -05:00
arcattackandClaude Opus 5 4360908d38 README (the shipped tester doc): repair two corrupted paths + bring it current with today's wave
Asked to double-check the launchers and make sure the user-facing docs in the
zip are up to date.  players/README.txt is the ONLY user-facing doc mkdist
ships (content/VIDEO/RESULT2.TXT is a game asset), and it was last touched
2026-07-24 -- before all of today's work.

1. CORRUPTED PATHS (real defect, worst possible placement).  The file
   contained literal CONTROL BYTES where two paths should be:

       offset 440  0x08  ...personal files (content<BS>indings.txt with...
       offset 490  0x0b  ...joystick setup, content<VT>olume.cfg with...

   i.e. `content\bindings.txt` and `content\volume.cfg` had been written
   through a string that interpreted \b as backspace and \v as vertical tab.
   Testers read "contentindings.txt" / "contentolume.cfg" -- files that do not
   exist -- in the UPGRADE paragraph, the one place that tells them which
   personal files to preserve when extracting over an old install.  Repaired
   by byte, and verified: 0 control bytes remain in all four packaging
   variants.  (The other two occurrences, content\join.log and a later
   content\bindings.txt, were undamaged -- \j and the second \b happened to
   survive.)

2. CONTENT now current with today's 13-issue wave, tester-relevant parts only:
   * AMMO BAY FIRE is now LETHAL (#46) -- flagged prominently as a gameplay
     change, with the ~10 s fuse, what it costs, and the EJECT-hold escape.
     Previously these fires were harmless; a tester who ignores one now dies.
   * ENG-page buttons FLASH on a jam or bay fire (#47) -- the restored pod
     annunciator, so testers know to look for it.
   * KNOWN ISSUE + WORKAROUND for #62: the BUS MODE button's second press
     detaches a weapon from its generator and the auto re-attach is dead, so a
     weapon can go permanently dark.  Documented with the recovery (press a
     GENERATOR SELECT A/B/C/D button) to pre-empt a wave of "my weapon broke"
     reports.

Verified by simulating mkdist's own marker processing across all four
steam-on/off x expire-on/off variants: new content survives every path, no
leftover {VERSION}/{STEAM}/{EXPIRE} markers reach the tester, no control bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:03:37 -05:00
arcattackandClaude Opus 5 373889760c #66: joyconfig.bat no longer hangs the game -- clear BT_JOYCONFIG once the wizard is done
Found while sweeping the shipped launchers for the PATH-shadowing bug class.
Previously recorded ONLY as row 10 of docs/INPUT_PATH_AUDIT.md and never
filed, so it was invisible on the tracker.

joyconfig.bat sets BOTH `BT_JOYCONFIG=1` and `BT_FE_SOLO=1`, so after the
wizard writes bindings.txt the boot continues into the FRONT END, which hands
off by CreateProcessW()ing the next generation with the CURRENT environment.
That clear-list (btl4console.cpp) covers only BT_FE_EGG/PODS/SECS/LOOP --
BT_JOYCONFIG is never cleared -- so the handed-off generation re-entered
BTJoyConfigWizard() and blocked on _getch() BEHIND the 3D window: an
unrecoverable hang, hitting exactly the players most likely to run that bat
(flight stick / HOTAS / pedals).

Fix: SetEnvironmentVariableA("BT_JOYCONFIG", NULL) immediately after the
wizard returns.  Cleared in the WIN32 environment (what CreateProcess
inherits, matching the console's own pattern) rather than the CRT copy, so
exactly one generation ever runs it.  Chosen over extending the console's
clear-list because it also covers any future relaunch path.

NOT YET REBUILT: the exe was locked by a live play session when this landed.
Must be rebuilt + verified (run joyconfig.bat, finish the wizard, confirm no
second "=== BattleTech joystick setup ===" banner and that the game proceeds
into the menu) before the next zip is cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:49:49 -05:00
arcattackandClaude Opus 5 bf40c53e1e Launchers: make the handoff wait tool-independent -- my first version broke on any PATH carrying Git-Bash/MSYS
Regression in my own fix from an hour ago, reported immediately by the
operator on relaunch:

    find: '/I': No such file or directory
    find: 'btl4.exe': No such file or directory
    The game has exited. ...

The wait loop called bare `find`, which resolves to the MSYS/Git-Bash **Unix**
find on any box with git on PATH (confirmed here: `which find` ->
/usr/bin/find, `which timeout` -> /usr/bin/timeout).  So the loop errored out
and fell straight through -- restoring exactly the misleading "The game has
exited" message it was meant to remove.  `timeout` was the same exposure one
line later (GNU coreutils ships one, with different arguments), so the block
had TWO PATH-dependent failures.

Harmless to the game (the loop only reports; it never touched the session),
but it made the fix a no-op for anyone with git installed -- which is every
developer and a fair number of players.

FIX: no external tools at all.
  * detection is now a pure-cmd `for /f "tokens=1"` over tasklist output,
    comparing the first token to btl4.exe -- no find/findstr;
  * tasklist and timeout are both invoked by ABSOLUTE %SystemRoot%\System32
    path, so a shadowing PATH cannot reach them.

Failure modes stay safe: if tasklist is unavailable the variable never gets
set and the loop degrades to reporting immediately (the old behaviour, never a
hang), and a genuinely dead exe still falls straight through so the #41 launch
forensics are untouched.

VERIFIED both branches by running the shipped detection lines from THIS bash
shell -- i.e. the hostile shadowed PATH that broke v1, a harder case than a
playtester's clean environment:
    game running        -> RESULT: WOULD-WAIT [process detected]
    absent-name control -> RESULT: WOULD-EXIT [no process]
No find/timeout errors in either.

(players/ is the shipped source per tools/mkdist.py:78; the gitignored root
copies re-synced byte-identical.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:37:56 -05:00
arcattackandClaude Opus 5 145bf368c8 Launchers (players/, the shipped source): wait for the handed-off game process instead of announcing "The game has exited" mid-load
Operator report while trying the build: the window said the game had exited
"with an error", yet the game loaded and played fine.

There was no error.  The launchers were lying.  The front end does NOT stay
resident: btl4console.cpp CreateProcessW()es the mission generation, closes
both handles and ExitProcess(0)s itself.  So the bat's child exiting means
"the menu handed off", not "the game closed".  All four launchers then
immediately printed

    [.. ] btl4.exe exited with code 0
    The game has exited.  If it closed unexpectedly, send the operator ...

while the game was still loading in the handed-off process.  Verified live on
the operator's own session: first process pid 33352 = MENU profile, handed off
to pid 12564 = GLASS profile, which was the one actually running; exit code 0
throughout.  Harmless in itself, but it reads as a crash to a playtester and
would have generated false bug reports on a test night.

FIX: after the front-end child exits, poll until no btl4.exe remains, THEN
report.  Applied to play_solo / join / join_lan / play_steam.

The #41 launch forensics are preserved: an exe that dies before it can run
leaves no process, so the wait falls straight through and launch_report.txt
still records timing, exit code and whether the log was ever created.

Verified the wait condition both ways against the live session (read-only, no
second instance spawned): btl4.exe present -> WOULD-WAIT; absent -> WOULD-EXIT.

NOTE for future edits: players/ is the SOURCE OF TRUTH (mkdist packages from
there, tools/mkdist.py:78); the root-level copies are gitignored local
conveniences AND were LF-only, while players/ is correctly CRLF.  Since this
change adds a `goto` label -- the construct most likely to misbehave in an
LF-only bat -- the root copies have been re-synced byte-identical from
players/ so local runs match the shipped artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:31:34 -05:00
arcattack 4537cb42e5 KB: link the fourth deferred defect (#65 HandleMessage non-virtual) -- the sed anchor missed on the first pass 2026-07-25 18:15:38 -05:00
arcattack 23978bd27a KB: link the four deferred defects to their new Gitea reports (#62 AutoConnect / #63 PlayerStatus databinding / #64 damageZone shadow / #65 HandleMessage non-virtual)
Findings from the #46/#47/#48 work were living only in context/open-questions.md.
Each is a real defect with player-visible or latent-crash impact, so each now has
a tracker item with evidence + fix design; the KB entries point at them by number
so the two stay in sync.
2026-07-25 18:15:13 -05:00
arcattackandClaude Fable 5 f3179ef020 #47 follow-up: restore the DAMAGE TIER to the heat branch -- HeatableSubsystem::GetStatusFlags was a stub returning 0
Found while auditing today's vtable change before release.  The binary's
HeatSink::GetStatusFlags @004add30 OPENS with `call 0x4ac144` --
MechSubsystem::GetStatusFlags, the damage tier (damageLevel >= 1.0 -> bit 0
Destroyed; > 0 -> bit 1 Damaged).  There is no zero-returning
HeatableSubsystem override anywhere in the image.

Our port routed that call through `HeatableSubsystem::GetStatusFlags()
{ return 0; }` -- a "neutral default" that silently dropped bits 0 and 1 for
the ENTIRE heat branch (every weapon, sensor, emitter, PPC, generator,
powered subsystem).  Two live consequences:

  * Destroyed / Damaged could never reach MechTech's annunciator scan --
    gutting half the shipped mechalrm table (Destroyed -> gotoEngineering +
    engCooling + engBusMode) hours after #47 shipped the flash;
  * PoweredSubsystem's AutoConnect gate (`GetStatusFlags() == 0`,
    powersub.cpp:358) read a DAMAGED subsystem as pristine.

One-line fix: chain the real tier, reproducing @004add30 exactly.  The call
site's own comment already said `// FUN_004ac144` -- the implementation had
simply never matched it.

Also corrected a stale comment on MechSubsystem::GetStatusFlags that read the
field as a STRUCTURE level ("1.0 = intact, 0 = dead") -- backwards.  The
engine member is damageLevel and ACCUMULATES to 1.0 = destroyed (mechdmg's
crit cascade tests `level >= StructureMax` for exactly that), which is what
makes bits 0/1 line up with TechStatusType Destroyed/Damaged.  Behaviour was
always right; the comment was a leftover of the same structureLevel misreading
#46 retired.

VERIFIED (3-pod combat sim, BT_LAMP_LOG): the annunciator is genuinely live --
Jammed x7, AmmoBurning x5, BadPower x20, Overheating x207 (Overheating
correctly maps to NO lamp: the shipped table has no entry for it).  The lamp
resolution is per-subsystem as designed -- different weapons flash their own
panel buttons (lamps 0xf/0x5/0xb/0x3 under distinct mode masks), 32 flash
writes across a whole battle, no thrash.  Zero crashes, zero plane/OOB
tripwire hits across all three pods.

FILED, deliberately NOT fixed tonight (open-questions.md): vtable slot +0x40
is misattributed.  The binary calls it `(this, 0)` in AutoConnect's outer gate
and `(this, candidateGenerator)` in the roster walk -- a one-arg voltage query
(HasVoltage(source)), not GetStatusFlags().  Modelling both as the no-arg
GetStatusFlags makes the outer gate demand "no flags" and the inner "some
flag" with nothing between them, so AutoConnect's loop body can never execute.
Pre-existing before and after this change; it is a live power-bus behaviour
change and deserves its own playtest cycle, not a release-eve patch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:58:20 -05:00
arcattackandClaude Fable 5 1d31af61d7 Gitea #48: arm the artifact tripwires BY DEFAULT + surface detections through the matchlog
The user's ask: if the translation-table fix did not quite catch it, will the
next playtest LOG the artifact?  Before this commit, no -- BT_PLANE_AUDIT was
opt-in via env var, and playtest machines set none.

Changes (all engine L4VB16):
  * BT_PLANE_AUDIT is now DEFAULT-ON, =0 opts out.  Armed cost is a couple of
    integer compares per DRAW CALL (not per pixel); confirmed zero log noise
    on a clean default-env run post-fix.
  * A SECOND trap class: out-of-bounds draw STARTS, checked always-on in
    buildDestPointer (the funnel every primitive builds its pointer through).
    Release builds compile Verify() to nothing, so a wild start previously
    computed a silent rogue pointer into (or past) the shared buffer.  Now
    logged ([plane] OOB) and CLAMPED in-bounds.
  * First detection of each class writes a GLITCH matchlog record
    (kind=plane / kind=oob with position+color+mask), so the evidence arrives
    with the round logs the operator already collects -- unattended.
  * Both existing plane-leak trap levels (display primitives + the port-level
    pixmap table scan) flipped to the same default-on gate.

Honest coverage note (in gauges-hud.md): an in-buffer EXTENT overrun from a
valid start (a blit walking past the right/bottom edge into later rows of the
SAME plane) is not trapped -- per-pixel walk checks are too hot.  The plane
trap still catches any such overrun whose color leaks planes, which is the
class the night-3 artifacts belonged to.

Verified: full clean rebuild -- 40 pre-existing LNK2019 only (the
engine->game BTMatchLog linkage resolved, /FORCE-trap checked); default-env
probe run: 0 [plane] lines, game unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:28:13 -05:00
arcattackandClaude Fable 5 6bb03aed0b Gitea #48 ROOT-CAUSED + FIXED: MFD "stray blocks / misplaced lamps" = uninitialized translation-table entries leaking pixels into other displays' bit-planes
The conviction was empirical, not theoretical: a new write-site trap
(BT_PLANE_AUDIT) in the seven L4VB16 drawing primitives logs any draw whose
color carries bits outside its port's plane mask -- the exact cross-display
corruption condition (Replace ORs an unmasked color; Or/Xor ignore the mask
entirely; And clears foreign planes).  A quiet solo session produced 15-30
leaks per minute:

    [plane] PORT 'sec' pixmap draw at(0,0) idx 217 entry=0xffffff00 mask=0x3f
    [plane] LEAK DrawPixelMap8[table] at(639,0) color=0xffffff00 mask=0x3f leak=0xff00

THE DEFECT: L4GraphicsPort::translationTable[256] is never initialized in the
ctor, and BuildSecondaryTranslation fills only the entries its BitWrangler
reaches -- 2^numberOfBits: 64 for the sec plane (mask 0x3F), 4 for the overlay
(0xC0).  Entries above that stay heap garbage.  Every draw resolves color
through this table, and the PIXMAP path indexes it with raw pixel values
0..255: the 480x640 radar background carries pixel index 217, whose garbage
entry's high bits (0xFF00 = ALL EIGHT MFD planes) were stamped into the shared
640x480 buffer -- invisible on the culprit page (the in-plane bits happened
dark) and visible as bright fragments at the same coordinates on every OTHER
display.  That is the operator's screenshot exactly: the same-position blips
on Mfd1+Mfd2 and the Heat-display block.

FIX (engine, both layers):
  * zero translationTable in the L4GraphicsPort ctor (plane-neutral default);
  * BuildSecondaryTranslation now cycles the in-plane pattern across entries
    [2^bits..255] -- high-index art degrades to its (index mod 2^bits) colour
    IN-PLANE and can never leak.  The 1995 binary ships the SAME 64-entry fill
    and survived on 6-bit art discipline; garbage is not a preservable
    behaviour, so the cycle-fill is a guarded PORT deviation (documented).

A/B PROOF: same probe, 60s -- 0 leaks after the fix (15-30/min before).
sim3 3-pod regression: zero crashes.

SECOND real defect found + fixed en route: sessions configure the CAMERA seat
first ("cameraInit" -- which includes the MISSION-REVIEW context:
configure(0, sec, 0, 0x00FF, native, rgb, mrpal.pcc), a DirectColor context
whose translation tables legitimately hold full RGB565 values spanning all 16
plane bits), and the viewpoint swap to the mech re-configured WITHOUT tearing
that tree down (btl4app's s_gaugeTreeBuilt latch treats the mech build as the
first).  The orphaned review gauges kept executing through stale DirectColor
ports.  BTL4GaugeRenderer::ConfigureForModel now overrides (ConfigureForModel
made virtual in L4GREND.h) and tears the prior entity-bound tree down before
every rebuild -- safe on first build, and the review screen rebuilds the same
way when the seat returns to the camera.

Also logged (open-questions): the review-screen PlayerStatus panels read the
compiled player at RAW BINARY OFFSETS (+0x1FC vehicle / +0x1C8 score /
+0x1C4 alive-dead box) -- the databinding trap, dormant until the review
screen runs; bridge before enabling the review.

Ruled out along the way (with evidence): the pilot-list label/erase geometry
(erase box 128x32 covers the 64x16 name rasters), the radar name labels
(view-level ClipImage clips them), PlayerStatus in-game execution ([ps] probe:
never runs in-game), and the PNAME bitmap dimensions (egg generator emits
64x16 exactly).

Why it "started with the comms feature" (#43): that wave registered the new
gauge classes with the config interpreter, letting more of the authored page
furniture parse and draw than before -- the leaking high-index pixmaps rode in
with it.  [T3 correlation detail; the leak + fix are T2 live-verified.]

Diagnostics kept (env-gated): BT_PLANE_AUDIT (the leak trap, now a permanent
regression tripwire), BT_PS_LOG, the port-level pixmap identity trap.

KB: gauges-hud.md (#48 section), decomp-reference.md (BT_PLANE_AUDIT),
open-questions.md (PlayerStatus databinding entry).  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:20:19 -05:00
arcattackandClaude Fable 5 6f5a264835 Gitea #47 COMPLETE: the ENG-button attention FLASH is live (jam / bay fire) -- MechTech status scan + BTL4GaugeAlarmManager + lamp chain, all from the binary
The pod behaviour Cyd described -- "on a jam or bay fire the display eng button
for the system flashes" -- is authored data + a five-stage chain, now running:

  MechTech::TechnicalAssistance (@004ad33c, per frame)
    edge-scans every monitored subsystem's GetStatusFlags() 7-bit condition
    mask (TechStatusType: Destroyed 0, Damaged 1, CoolantLeaking 2,
    Overheating 3, AmmoBurning 4, Jammed 5, BadPower 6)
  -> Start/StopEntityAlarmMessage (@00436688 id 7 size 0x20 / @004366b8 id 8
     size 0x1C; broadcast @004364e4; port shape: direct
     Start/StopEntityAlarmImplementation calls on the gauge renderer)
  -> GaugeAlarmManager::Activate(alarmModel) -- alarmModel = MechTech+0x100 =
     the 'mechalrm' ModelList (id 83) -> SearchList(83, type 31) -> the baked
     GaugeAlarmStream (id 331, 11 items {condition, lampCode}), decoded:
         Destroyed      -> gotoEngineering + engCooling + engBusMode
         CoolantLeaking -> gotoEngineering + engCooling
         AmmoBurning    -> gotoEngineering + engEject
         Jammed         -> gotoEngineering + engEject
         BadPower       -> gotoEngineering + engBusMode
  -> BTL4GaugeAlarmManager::ReadGaugeAlarmStreamItem -- THE REAL BODY, from
     @004cc2fc + helpers @004cc108/148/1a0/264/27c + tables @0051cf1c..0x51d084
     (gotoEngineering 0x80 = the subsystem's QUAD-SELECT bezel button via
     lamp[aux]/mode[aux] tables; 0x81+ descend the eng-page bank; Condenser /
     Generator specials on CoolantLeaking; <0x80 = the heat-bank fixed map).
     btl4galm.cpp's old bodies were admitted fabrications and its provenance
     note ("no override body exists in the image") was wrong -- corrected.
  -> LampManager::FindLamp (@00444c80) -> Lamp::SetAlertState (@00444e64, a
     COUNTER so stacked alarms hold the flash) -> L4Lamp::NotifyOfStateChange
     emits RIO flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239) -> the
     pod's physical lamps AND the glass panels (PadRIO IS rioPointer there).

FOUR load-bearing defects found and fixed en route -- each independently fatal
to the feature:

  1. HeatableSubsystem's Derivation chained Subsystem directly, SKIPPING
     MechSubsystem (written before the WAVE-1 re-basing) -- so EVERY subsystem
     on both family branches failed IsDerivedFrom(MechSubsystem), which is
     exactly MechTech's monitor filter: the status scan watched NOTHING.
  2. MechSubsystem::GetStatusFlags was non-virtual (binary: vtable slot 12) --
     the scan's MechSubsystem* call bound statically to the base tier and the
     weapon bits could never surface.
  3. ProjectileWeapon::GetStatusFlags sat in a Ghidra export gap -- raw-disasm
     @004bbf88: base | 0x20 (weaponAlarm==5 Jammed) | 0x10 (linked bin
     cookOffArmed@0x18C AmmoBurning).  The port had "defer to base".
  4. Mech::Reset's respawn sweep blanket-cast every roster entry to
     MechSubsystem and called RespawnRepair -- wrong for the Subsystem-level
     entries (MechTech 0xBDC, SubsystemMessageManager 0xBD3).  On MechTech the
     recon damageZone slot lands on its subsystemMonitors chain head: NULL
     while the scan was broken (fix #1's bug MASKED this one), but the moment
     the monitors populated, RespawnRepair virtual-called through a
     SubsystemMonitor as if it were a DamageZone -> load-time crash in
     StateIndicator::SetState (caught with cdb; call [edx+14h] on code bytes).
     The sweep now filters IsDerivedFrom(MechSubsystem) before the cast.

Also: GUID identities pinned -- 0x50f4bc = PoweredSubsystem::ClassDerivations
(via @004b1208 = its TestInstance; btl4gau2's two "Generator" comments were
wrong, swept), 0x50fb60 = Generator's.  A shadow-field instance documented for
the deferred de-shadow: MechSubsystem::damageZone re-declares the PUBLIC engine
Subsystem::damageZone (SUBSYSTM.h:159) -- mechtech's naive `sub->damageZone`
read the never-written engine member (0 monitors again); now reads the recon
member via GetDamageZoneProxy().  Logged in open-questions.

Wiring: BTL4GaugeRenderer now constructs + assigns the BTL4GaugeAlarmManager
(the base ctor NULLs it and Activate Check()s it); MechTech's Report*/
StatusMessageSink stubs are real; alarmModel confirmed baked (= 83) at runtime.

VERIFIED LIVE (scratchpad/lampflash.py, BT_LAMP_LOG chain trace):
    [techstat] MechTech id 32 monitors 29 subsystems, alarmModel 83
    [techstat] LRM15_1 condition 4 SET (alarmModel 83)      <- bay fire armed
    [galarm] condition 4 code 0x80 -> lamp 0xd mode 0x1 FLASH
    [lamp] 0xd <- 0x37  (FLASHING)                          <- the select button
    [galarm] condition 4 code 0x85 -> lamp 0xb mode 0x4 FLASH   <- engEject
    [techstat] LRM15_1 condition 4 CLEARED                  <- detonation clears
    [techstat] AmmoBinLRM15_1 condition 0 SET               <- bin Destroyed
Regressions: baytest PASS (bay fire kills), baypurge PASS (purge extinguishes),
sim3 3-pod brawl PASS with ZERO crashes (6 kills, organic heat-route bay fires,
respawns clean through the new sweep filter).

Env: BT_LAMP_LOG ([techstat]/[galarm]/[lamp]).  KB: gauges-hud (the flash
section), decomp-reference (the GaugeAlarm closure + tables + GUIDs),
open-questions (built-note + the de-shadow deferred item), btl4gau2 comment
sweep.  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:04:34 -05:00
arcattackandClaude Fable 5 5ae4410914 Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning).  Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:

  1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
     an ARMED bay fire never detonated.
  2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
  3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
     mech TakeDamage handler drops) even if 1+2 had fired.

THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression.  The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks.  New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.

THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp).  The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere.  Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).

THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.

VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
  scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
    "ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
    (authentic death list).
  scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
    (bin empty)", no detonation.
  scratchpad/sim3.py    : the HEAT route arms organically in combat (overheated
    AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
  BAYBOOM matchlog record added for MP field forensics.

FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias).  My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death.  ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure).  Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).

#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it.  The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't").  Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges.  [T2 -- the data path
is rig-verified; the pixels await the next live session.]

#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session.  The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.

Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).

KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN.  40 LNK2019 unchanged (the two pre-existing families).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:58:16 -05:00
arcattackandClaude Opus 5 d39227ef39 Gitea #51 ROOT CAUSE: LoopAtWill was mapped to "loop forever", arming 224 of 603 samples as endless OpenAL sources
SAURON's "coolant flush sound glitched and perma" is one instance of a systemic
defect.  SetupPatch (L4AUDLVL.cpp) decided looping from the SAMPLE flag alone:

    AL_LOOPING = (info.loop != ForceStatic)

That armed every non-ForceStatic sample -- 224 of the 603 authored zones,
including unmistakable one-shots -- as an OpenAL source that never ends on its
own.  Any such sound whose note-off never arrives plays forever.  Measured 1946
LoopAtWill x Transient arming events in one short session.

The sample flag expresses PERMISSION; the SOURCE decides.  Three facts [T1]:

  * the enum's own comments (L4AUDLVL.h:14-19) -- LoopAtWill = "will play once
    OR LOOP AS DESIRED", ForceStatic = "plays only once EVEN IF LOOPED" (a
    veto), LoopAlways = "ramp up and then down";
  * LoopAtWill is enum value 0, i.e. the DEFAULT an unauthored sample receives
    (WTPresets.cpp:37) -- it cannot mean "loop forever";
  * LoopAlways, the real always-loop, is used by ZERO shipped samples.

"As desired" is the source's authored AudioRenderType (Transient=one-shot vs
Sustained=held), streamed from AUDIO*.RES (AUDLVL.cpp:28) and already consulted
by the renderer (L4AUDRND.cpp:567, AUDREND.cpp:184).  SetupPatch now takes it,
threaded from all three L4AudioSource call sites (Direct/Dynamic3D/Static3D).
New rule: LoopAlways->1, ForceStatic->0, LoopAtWill->the source's render type.

MEASURED before changing behaviour (BT_LOOP_AUDIT=1, scratchpad/loopaudit.py):

    LoopAtWill  x Transient -> loop=0  (was 1)   1946 events
    LoopAtWill  x Sustained -> loop=1  unchanged    65 events
    ForceStatic x Transient -> loop=0  unchanged   292 events

The engine loops (EngineAccel07_z0..z2, EngineMotor01, EnginePower01) are all
LoopAtWill/Sustained and PRESERVED -- no regression there.  All 24 reclassified
samples are genuine one-shots: laser charge/fire/explosion/loaded, missile
loading, engine shift, coolant pressure inc/dec.

A/B PROOF (scratchpad/loopab.py, same session both arms, only BT_LOOP_LEGACY
differs), asking the engine's own BT_AUDIO_DUMP what is still playing with
loop=1 long after every release:

    [legacy] EnginePower01, CoolantPresInc03_z2, CoolantPresDcr03_z2
    [fixed]  EnginePower01

The two stuck coolant sources are the reported symptom.  They were eventually
reclaimed when a later trigger reused the source, which is why the bug was
intermittent -- the "perma" case is when nothing else retriggers it.  The fix
removes the mechanism.

BT_LOOP_LEGACY=1 restores the old rule for a field A/B without a rebuild.  The
layers to listen to are the beam sustains: LaserA/CSustain* are authored
LoopAtWill on a TRANSIENT source, so they now end with the sample instead of
looping until note-off.  The render type is T1 authored data; the interpretation
that LoopAtWill defers to it is a strong reading of the enum + defaults rather
than a disassembled statement, and is flagged as such in the KB.

Plausibly bears on #32 (audio cutting in/out late in a match): a stuck looping
source holds its pool slot for the rest of the round.

Rigs: scratchpad/flushsnd.py (flush start/stop pairing), flushsnd2.py (stuck-
source hunt), loopaudit.py (the classification matrix), loopab.py (the A/B).
KB: context/wintesla-port.md audio section, context/decomp-reference.md env
table (BT_LOOP_AUDIT / BT_LOOP_LEGACY / BT_AUDIO_DUMP / BT_AUD_TAIL),
docs/AUDIO_FIDELITY.md F38.  checkctx.py CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:20:19 -05:00
arcattackandClaude Opus 5 f3bdb3b85a Searchlight + ThermalSight ToggleLamp WIRED (#61) -- and a swapped table attribution ROOT-CAUSED, retracting a false "1995 latent bug"
Both classes' Receiver::MessageHandlerSet were default-constructed blackholes
(input-path audit systemic cause #1): entryCount 0, no parent chain, Find()
returns NullHandler for every id, Receive drops silently.  The new unhandled-
message trace printed it on the first press:

    [btntest] PRESS 0x14 at poll 400
    [msg] UNHANDLED: Searchlight has no handler for message id 3

Each class now has a GetMessageHandlers() function-local static chained to
PowerWatcher's (which name-resolves through HeatWatcher/MechSubsystem to
Receiver's empty ROOT set, so id 3 does NOT collide with HeatSink's
ToggleCooling), plus a correctly-typed forwarder -- Receiver::Handler is
void(const Message*) while the decomp shape is Logical(Message&), so a cast
would be UB that merely happens to work on x86 __thiscall.  DefaultData
re-pointed off the dead empty sets.  MessageArg now reads the NAMED
ReceiverDataMessageOf<int>::dataContents with a static_assert locking it to the
binary's message+0xC (was a raw offset read -- databinding rule).

ROOT CAUSE of a long-standing misattribution: @004b860c is **ThermalSight's**
ToggleLamp (table @0x51120C), NOT Searchlight's.  The two TUs emit parallel
shared-data blocks with identical stride (msg entry, +0x5C attrs, +0x84
Performance triple); what pins each entry to its TU is that "ToggleLamp" is NOT
pooled across them -- two copies exist, each emitted immediately before its own
class-name string ("Searchlight"@0x51144B, "ThermalSight"@0x511475).
[T1: reference/decomp/section_dump.txt:69661-69711]

Searchlight's own handler is @004b838c, which sits in a Ghidra EXPORT GAP (#60).
RECOVERED by raw disassembly of content/BTL4OPT.EXE (scratchpad/dis838c.py, the
EjectAmmo technique) -- and it corrected two things I had inferred wrong:
  * NO ControlsAllowLights/+0x25C novice gate.  Searchlight tests the press
    alone; that lock is ThermalSight-only.  A NOVICE pilot CAN work the lamp.
  * `or word ptr [this+0x18],1` sits AT the jle target -> the graphics-dirty bit
    (updateModel == ForceUpdate(), per #59) is raised UNCONDITIONALLY.

Consequence: the "ORIGINAL 1995 LATENT BUG -- the searchlight can never light"
claim is RETRACTED.  It compared Searchlight's Performance (@004b841c, reads
requestedOn@0x1E0) against ThermalSight's toggle (0x1DC) -- two different
classes -- and so invented a missing 0x1DC->0x1E0 bridge.  Every sibling toggles
the field its own Performance reads.  The searchlight DID light in the arcade,
the searchlight->fog swap was NOT inert there, and building PullFogRenderable is
FAITHFUL rather than a designer-intent deviation (the 2026-07-13 "left as-is"
decision is void; the sim needs no repair).

Verified live, real click seam (BT_BTNTEST):
  0x14 -> [light] requested ON -> reported lightState 0 -> 1     (lamp lights)
  0x12 -> [light] thermal sight requested ON -> thermalActive 0 -> 1
UNHANDLED lines for both classes gone; 40 LNK2019 unchanged (the pre-existing
CreateStreamedSubsystem + Entity__SharedData::DefaultData families only).

Swept the misattribution out of searchlight.cpp/.hpp, thermalsight.cpp/.hpp,
hud.cpp:282 (it had claimed @00511180/@004b838c as HUD's), btplayer.cpp's +0x25C
consumer list, context/{decomp-reference,subsystems,rendering,open-questions,
pod-hardware,experience-levels}.md, docs/{GLASS_COCKPIT,INPUT_PATH_AUDIT}.md.
checkctx.py CLEAN.

Still open (documented, not fixed): ThermalSight has no visible IR effect
(ToggleGlobalThermalVision is a marked no-op, pvision unported, IsLocallyViewed
returns False); ThermalSight publishes "LightState"->0x1D8 where the binary has
"LightOn"->0x1D8 and "LightState"->0x1E0; Searchlight's commandedOn@0x1DC has no
identified role and measured 21 live, hinting our SubsystemResource +0x28 differs
from the binary's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:46:59 -05:00
arcattackandClaude Opus 5 62513b2f8a Gitea #53: generators can be switched off -- ToggleGeneratorOnOff reconstructed from @004b1ed0
TWO missing pieces, both now in place.

1. Generator had NO handler set of its own, so every message aimed at it hit a
   default-constructed blackhole.  Proved empirically with the new
   unhandled-message trace by pressing button 0x1A:
     [msg] UNHANDLED: Generator has no handler for message id 4 -- silently dropped
   Added Generator::GetMessageHandlers() chained to **HeatSink**, deliberately NOT
   PoweredSubsystem: id 4 there is SelectGeneratorA (the weapon-side generator
   SELECTION that already works), so chaining to it would have made these four
   buttons invoke the WRONG function instead of doing nothing.  HeatSink owns id 3
   (ToggleCooling), leaving id 4 free.

2. ToggleGeneratorOnOff had no body at all (0 references in game/).  Transcribed
   instruction-for-instruction from @004b1ed0:
     - call @004ac9c8 -> BTPlayerRoleLocksAdvanced: a NOVICE pilot cannot use it
     - [msg+0xc] <= 0 -> press only, ignore the release
     - ON  : startTimer = 0; if heatAlarm(@0x184) < 1 also stateAlarm -> 0
             (Starting, so it spins up -- a heat-faulted generator keeps its fault
             level); generatorOn = 1, coolantAvailable = 1, coolantFlowScale = 1.0
     - OFF : stateAlarm -> 1 (GeneratorIdle); generatorOn = 0,
             coolantAvailable = 0, coolantFlowScale = 0 (its coolant share is
             released back to the loop)
   So taking a generator off line idles it, drops its coolant draw and stops it
   feeding its taps -- the authentic heat-management trade from the manual.

VERIFIED LIVE (BT_BTNTEST=0x1A,400,900 through the real click seam):
  [btntest] PRESS 0x1a at poll 400
  [gensel] GeneratorA generator OFF LINE (stateAlarm 1, coolantFlowScale 0)
and the UNHANDLED warning for Generator is gone.

Answers Cyd's report from two playtests ('still cannot turn of gennies').  Note
this is the ON/OFF control -- generator SELECTION (which generator a subsystem
draws from) is a different message and already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 10:15:25 -05:00
arcattackandClaude Opus 5 33ca99eb69 Input-audit cause #1: revive the Avionics power bank + add the unhandled-message trace
THE CLASS OF BUG.  A default-constructed Receiver::MessageHandlerSet is a total
blackhole: entryCount 0 and NO parent chain, so Find() returns NullHandler for
every id and Receiver::Receive dropped the message with no log, no counter,
nothing.  docs/INPUT_PATH_AUDIT.md ranked this systemic cause #1; running its own
lint finds SEVEN such sets (Sensor, Searchlight, ThermalSight, Gyroscope,
MechTech, SubsystemMessageManager, Torso).  It is also why generators cannot be
switched off (#53) -- same shape.

FIX 1 -- Sensor / Avionics (the big one).  The streamed mappings aim 108 records
across the 18 mechs at it: the MFD2 ENG1 power bank, 6 clickable buttons plus keys
F5-F9 (route Avionics power to generators A-D, gen mode, coolant cut).  All
silently swallowed.  Sensor needed NO handlers of its own -- ids 4-8 already live
on PoweredSubsystem, which chains HeatSink for id 3, covering the whole 3-8 range
those buttons send.  It only ever needed to CHAIN, which is exactly why the
byte-identical bank on ENG2 works (Myomers chains, myomers.cpp:88-100; Sensor did
not).  Added Sensor::GetMessageHandlers() with the same function-local-static
idiom and pointed DefaultData at it instead of the empty set.

VERIFIED LIVE (scratchpad/avionics.py -- pages MFD2 and presses the bank):
  [gensel] Avionics -> GeneratorA (tapped)   ... B, C, D
  [gensel] Avionics mode -> 2
i.e. Avionics now behaves exactly like Myomers.  24 handler hits, 0 unhandled.

FIX 2 -- the standing guard.  Receiver::Receive now logs once per (class,
message id) pair when no handler is found: '[msg] UNHANDLED: <class> has no
handler for message id N -- silently dropped (is its MessageHandlerSet chained?)'.
Once-per-pair on purpose, since some ids broadcast every frame.  This one line
would have printed Avionics, Searchlight, ThermalSight, crouch and all four
generator buttons on the first boot instead of costing us months of player
reports.  Uses SharedData::derivedClasses -> Derivation::className.

DEFERRED, with reasons (not guessed at): Searchlight's ToggleLamp body exists but
is unreachable -- it needs a correctly-typed forwarder (Receiver::Handler is
void(const Message*); ToggleLamp is Logical(Message&), so a cast would be UB that
happens to work on x86) AND an id check, because its id 3 collides with HeatSink's
ToggleCooling and I have not traced whether PowerWatcher's chain reaches HeatSink.
#53 needs more than chaining: ToggleGeneratorOnOff @004b1ed0 has no body at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 10:05:57 -05:00
arcattackandClaude Opus 5 5bbe070e9d Gitea #56 (+#50): pin the windowed backbuffer so a device reset cannot silently re-size it
ROOT CAUSE.  mPresentParams.BackBufferWidth/Height were assigned ONLY in the
fullscreen branch (L4VIDEO.cpp:3430-3434); windowed they stayed 0, which tells
D3D9 to derive the backbuffer from the device window's client area at
CreateDevice.  Fine at startup -- but the device-lost recovery path saves and
restores mPresentParams around Reset(), so it faithfully preserved those ZEROS
and Reset re-derived the backbuffer from whatever the client area was at THAT
moment, while gBTCockpitCanvasW/H still described the startup canvas.

The cockpit layout is computed in backbuffer space and clicks are mapped
client -> canvas (L4VB16.cpp BTCockpitMouseDown), so after that divergence panels
are drawn for one geometry and clicks are mapped in another: buttons drift off
their artwork, and at a large enough mismatch nothing is clickable at all -- which
is #50 (SAURON: 'none of my MFDs clickable' while other players' worked).

It needs BOTH a resize/maximise AND a device loss (alt-tab, Steam overlay,
monitor sleep, UAC, RDP, driver hiccup), in that order.  That is why it hits some
players and never the operator, who does not resize the cockpit window -- and it
matches the report exactly: the buttons 'become' misaligned rather than starting
that way.

FIX
 * windowed: record the client rect into BackBufferWidth/Height at CreateDevice.
   That is exactly what D3D would have derived, so startup behaviour is
   unchanged, but the save/restore now preserves a REAL size and Reset can never
   pick a different one.  Deliberately NOT screenWidth/screenHeight -- those are
   the app's -res values (default 800x600) while the cockpit window is sized
   independently, so pinning to them would shrink the backbuffer.
 * BTVerifyCockpitCanvasAfterReset(): called at both device-lost recovery sites;
   compares the real backbuffer against the canvas globals, and if they ever
   disagree it says so and re-syncs instead of drifting silently.

VERIFIED: solo cockpit run unchanged -- '[cockpit] view 900x440 canvas 1452x999',
BT_SHOT still 1452x999, Heat panel renders correctly, no mismatch warning.

NOTE: reverted an earlier scripted edit of this file that had corrupted an
unrelated line -- L4VIDEO.cpp is CRLF and a Python join('\n') introduced a bare
LF inside a string literal.  Use the edit tool on this file, not line surgery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:52:28 -05:00
arcattackandClaude Opus 5 08977ff128 Gitea #55: respawn now restores COOLANT / heat / generator state (+ a Generator reset re-transcribed from the binary)
THE GAP: Mech::Reset sweeps every subsystem with the virtual DeathReset
(mech4.cpp:1789), but only 7 weapon/ammo classes overrode it -- the entire
heat/coolant/power family fell through to the empty Subsystem::DeathReset base
(SUBSYSTM.h:161), so the respawn reset was a SILENT NO-OP for them.  All the
ResetToInitialState bodies already existed; nothing ever called them.  Hence the
field report: 'respawned with drained coolant'.

Added DeathReset forwarders (ResetToInitialState is not virtual, so each class
with its own body needs one): HeatableSubsystem, HeatSink, Condenser,
PoweredSubsystem, Generator.  HeatSink's also serves Reservoir -- the coolant
tank -- which has no body of its own; that is the one that refills coolant.

TWO MIS-TRANSCRIPTIONS FIXED, both verified against the binary with capstone:

1. HeatSink::ResetToInitialState chained HeatableSubsystem::ResetToInitialState
   with the comment 'FUN_004ac22c'.  But @004ac22c is Subsystem's terminus --
   disassembled: it reads the damage zone at this+0xe0, clears zone+0x158, and
   Set_Alarm_Levels zone+0x10 and this+0x2c to 0.  And the binary's HeatSink
   @004ad760 calls only @004ad884 (ClearHeatFilter), @004ad7f0 (UpdateHeatLoad)
   and @004ac22c -- never HeatableSubsystem.  Worse, HeatableSubsystem's body sets
   currentTemperature = 300.0f, CLOBBERING the startingTemperature that HeatSink
   assigned four lines earlier.  Dropped the call; the terminus work is already
   done for every subsystem by MechSubsystem::RespawnRepair (mech4.cpp:1790).

2. Generator::ResetToInitialState chained HeatableSubsystem and set
   outputVoltage = 0 -- which matches GNRATOR.TCP, but BT's BINARY DIVERGES from
   the TCP source, and the binary is what shipped.  Disassembled @004b215c
   instruction by instruction: it chains HeatSink (so the coolant refill DOES run
   for a generator), then generatorOn=1, startTimer=0, stateAlarm 0 then 2,
   **outputVoltage = ratedVoltage**, coolantAvailable=1, coolantFlowScale=1.0,
   startTimer=startTime.  Every offset matches our declared members exactly.
   The old version brought generators back from a respawn at ZERO output voltage,
   so energy weapons could never charge -- no recharge ring, no ready dot, nothing
   damaged.  That is a strong candidate for #54 (David's three dead lasers).

LIVE VERIFICATION (sim3.py, 3 mechs + force damage): 4 death/respawn cycles, and
every heat sink logged coolant == capacity on every respawn, with temp restored to
startingTemperature (77) rather than the clobbered 300.  Left as a silent
regression guard that only speaks if a respawn leaves coolant short.

KNOWN REMAINING [T3, noted in code]: Condenser's body chains HeatableSubsystem
rather than HeatSink, so a coolant loop's VALVE DETENT may still persist across a
respawn -- the binary's Condenser reset is not yet decompiled.  Do not claim
valves are fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:19:10 -05:00
arcattackandClaude Opus 5 2518e43719 Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()
Verified the claim in the binary myself with capstone before changing behaviour:
  0x4c0155:  or word ptr [ebx + 0x18], 1
+0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr'
settles it, since simulationFlags is an LWord at +0x28 and would assemble as
'or dword ptr'.  So the authentic op is updateModel |= DefaultUpdateModelFlag,
which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147).

The old transcription wrote simulationFlags |= 0x1 instead.  Bit 0 there is
DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips
ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only
ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a
Player never takes.  So every pilot's first death permanently disabled watcher
execution on their simulation, and the replication mark the binary intended was
never set at all.  context/reconstruction-gotchas.md had flagged this exact line
as the un-audited sibling of the #12 dirty-bit class, asking for the disasm
first; that is now on the record.

LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s):
4 consecutive death/respawn cycles on pod3, every one completing --
  death cycle START (death #N) -> RESET at drop zone [COMPLETE]
with 'player watchersDelayed=0' after every death, and no crash.  Also
re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED
lines are the authentic dedup of a second notification, each followed by a
completed RESET).

Leaves a silent regression guard: the death path now logs only if it ever finds
the watcher-delay flag set again.

Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation
proof: 18 designations, deathPending clean on all of them).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:04:07 -05:00
arcattackandClaude Opus 5 2dd4ee3910 ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)
MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic):
  sizeof=652 (0x28c)  killCount@0x270  pad_0x280@0x274
  objectiveMech@0x278  deathPending@0x284

The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the
BINARY's objectiveMech.  On our object 0x284 is deathPending -- the death-cycle
latch.  So EVERY target designation (the Comm bank 0x30-0x37, or the typed
t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero
deathPending makes VehicleDeadMessageHandler take its dedup early-return, which
skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt.

=> designating a target silently disabled your respawn for the rest of the
   process.  That is #57's missing first cause: it explains David's very first
   death being swallowed with no prior death, and the 3-of-3 swallowed deaths in
   the final round.  It also explains the operator's persistent observation that
   the trouble began when the comms panel went live -- before the pilot list
   populated, there was nothing to designate.
   And the designation never worked either: the value never reached
   objectiveMech (input-audit finding #1).

FIX: all three sites now use named-member bridges in the complete-BTPlayer TU --
BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition
/ BTVehicleDestroyed.  Also retires the raw +0x1fc (playerVehicle) reads and the
raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check
through the real Mech predicate instead of the Is_Destroyed stub.

LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284,
killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so
a member shift fails the BUILD instead of silently clobbering the latch again.

Rigs: scratchpad/commstest.py (drives designation + captures panels),
valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 08:33:22 -05:00
arcattackandClaude Opus 5 cb50a1d491 Gitea #48: add BT_NO_PILOTLIST=1 -- make the operator's comms hypothesis decidable
The operator reports consistently, across two sessions, that the MFD artifacts
began the night the Comm pilot list was enabled (#43) and were never seen before.
Four static theories of mine died -- plane masks (all 7 primitives invert
correctly), L4GraphicLamp (never instantiated, dead code), the sec-port lamps
(wrong plane AND wrong colour, operator confirmed on sight from the decoded art),
vertBar/VertTwoPartBar (clamps every value, sets its cursor per draw).  And no
rig reproduces it: not 2 pilots, not 5 pilots on last night's exact roster, not
autofire + repeated valve moves.

So stop theorising and make the field observation decidable: BT_NO_PILOTLIST=1
makes PilotList::Execute draw nothing, everything else untouched.
  artifacts gone with it / present without => confirmed, and it is the workaround
  artifacts either way                     => not the pilot list

Diagnostic only, default OFF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 08:22:08 -05:00
arcattackandClaude Opus 5 81fbfc7eaa Add scratchpad/cursoraudit.py: static audit for the #42 stale-cursor draw family
Scans the gauge TUs for cursor-relative draws with no MoveToAbsolute in scope.
Found 6 of 29, three of them STATE-CHANGE-ONLY draws -- notably
L4GraphicLamp::NotifyOfStateChange (L4LAMP.cpp:376/380), which blits a lamp using
whatever cursor the view happens to hold.  That is the '#48 misplaced lamp'
class: lamp-shaped, on the panels that have lamps, only on damage/jam/heat state
changes, and persistent.  Explains why the 5-pilot rig capture was clean (no
combat = no lamp state change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 07:48:29 -05:00
arcattackandClaude Opus 5 5174c638ce K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)
FIXES (compile clean; exe link pending -- a game client still holds btl4.exe):

#57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and
only if a LATER re-post happened to find the mech alive -- so if the +5s re-post
landed before the drop-zone reply, or never arrived, the flag stayed set while
the pilot respawned and fought on, and their NEXT death hit the dedup at :391
and was swallowed whole (no warp, no tally, no hunt) for the rest of the
process.  Tonight 3 of 3 deaths in the final round were swallowed, each by a
player who had respawned successfully earlier in the same process.  Clear moved
to the actual completion point (after Mech::Reset at the drop zone) + the four
abandon paths that also stranded it.  Cycle logging promoted to always-on
(START / RESET / SWALLOWED warning) + a RESPAWN matchlog record.

#58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my
dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and
L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills
with a constant).  Which state was live depended on what the 3D pass drew last
-- hence 'depends on the level'.  Now sets MODULATE texture x diffuse for both
channels and restores.  Latent sibling noted (CameraHUDDrawQuad, same omission).

ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md.  Headlines:
 - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws
   +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and
   substituted the engine's Player::deathCount (+0x200, the respawn identity
   number seeded to -2) -> remote rows read a clamped fake 0 by construction;
 - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp
   export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence
   is what produced the 'pad' belief.  An absent function is not evidence of an
   absent behaviour;
 - neither counter rides an update record, so divergence never self-heals and
   bystanders show 0/0 all mission;  BTPlayerCountObservedDeath is unreachable
   dead code;
 - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and
   COLLISIONS count (5 of 47 deaths had a type=0 killing blow);
 - the phantom kill is authentic 1995 (the kill handler bumps the victim's
   killCount too);
 - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does
   ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the
   first death of any pilot permanently disables ExecuteWatchers on that
   player's simulation.  Filing separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 01:50:07 -05:00
arcattackandClaude Opus 5 326023812a Respawn re-arm ROOT-CAUSED: David's death transition never started (#54) -- plan doc only
9-agent read-only investigation, every binary claim re-verified with capstone.

VERDICT: respawn re-arm WORKS in 4.11.524 -- 21 own-deaths in tonight's
matchlogs each have a 1:1 PLAYER_DEAD, except David's (1 death, 0 PLAYER_DEAD).
So #22's fix is holding and is NOT implicated (my earlier claim retracted).

His cycle never STARTED: PLAYER_DEAD is written unconditionally at
btplayer.cpp:422 in the same straight-line block as ++deathCount, so its absence
proves VehicleDeadMessageHandler never entered the deathCount==-1 branch -> no
drop-zone hunt -> no DropZoneReply -> Mech::Reset never ran.  Corroborated: a
zone reads lvl=1.0000 11.18s AFTER death, which Reset would have healed.

With no Reset movementMode stays 9, and ONE predicate -- Mech::IsDisabled()
(mode 2||9) -- darkens all six panels via two authentic gates (emitter.cpp:431
currentLevel=0; projweap.cpp:707 recoil=rechargeRate + alarm 7).  Ring and
ready-disc are the SAME scalar, so six dark panels is one data fault.

SEPARATE second bug: his AFC 100 went silent 123s BEFORE death holding 13
rounds, and one LRM 66s before while its twin kept firing -- that is the #21
family, untouched by this.

Port divergences found on the way: mech4.cpp:1928 calls DeathShutdown(1) but the
binary sweeps with 0 and forwards to DeathReset -- and our DeathShutdown is an
empty base nobody overrides, so the whole death sweep is a no-op; only 7 of ~20
subsystem classes implement DeathReset; HeatSink::ResetToInitialState clobbers
the startingTemperature it just wrote and stops short of the authentic terminus
@004ac22c (which heals each subsystem's own crit zone + status alarm).

UNKNOWN: why the -1 branch never ran.  One survivor after static elimination
(GetPlayerLink()==0 on his own master mech); section 6 'Check 0' settles it with
no code change.  Do that first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 23:56:09 -05:00
arcattackandClaude Opus 5 b517c4cb30 Input-path audit: 206 paths traced, 22 dead -- plan doc only, no code changes
13-agent read-only audit (6 tracers -> adversarial verifiers -> triage), validated
against 2,294 streamed control-mapping records decoded from all 18 mechs in
BTL4.RES plus the live 121-record install dump.  Verdict: 22 dead dispatch
routes, 11 player-visible, 8 documentation defects, 2 entirely dormant input
layers (CONTROLS.MAP and the whole DirectInput grammar).

RETRACTS MY OWN DIAGNOSIS from the reverse-thrust fix earlier today: streamed
BUTTON mappings ARE consumed (L4CTRL.cpp:2254-2262 / :2327-2336, and
ctrlmap.log:32 shows reverse's record resolving to +0xec).  The AddOrErase Fail
bodies are the config-session base traps, overridden by MechRIOMapper.  So
'reconstruct AddOrErase' would clear 0 of 2,294 records.  The false claim is in
pod-hardware.md:121-135 AND in mechmppr.cpp:894-919 -- both owed a sweep, and
reverseThrust now has two writers (unknown #2 decides which to keep).  The
'how did it break' question is therefore reopened; leading candidate is the
unhandled WM_SYSKEYDOWN/SC_KEYMENU menu-modal loop on a held ALT.

Biggest new finding: a default-constructed Receiver::MessageHandlerSet is a
silent blackhole -- Sensor/Avionics alone kills 108 records (6 cockpit buttons +
F5-F9).  One accessor in sensor.cpp is the best findings-cleared/risk ratio.
Worse-than-dead: target designation writes raw +0x284 on a modern-layout object,
possibly out of bounds, reachable by pressing 't' -- flagged as a playtest
safety note.

Also lands scratchpad/keysweep.py: the empirical counterpart (presses every
bound key, records what actually moves).  Not yet run -- needs the machine idle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 20:48:31 -05:00
arcattackandClaude Opus 5 6e810d99cc docs: mark the 'only .x meshes load' paragraph SUPERSEDED (native BGF reader exists)
Found during the input-path audit: RECONCILE.md's 'DEEPEST RENDER FAULT =
ASSET PIPELINE GAP' paragraph is stale -- d3d_OBJECT::LoadObject falls through
to LoadObjectBGF (L4D3D.cpp:98/230, bgfload.cpp LoadBgfFile) and BGF geometry
loads natively today.  Docs only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 19:52:10 -05:00
arcattackandClaude Opus 5 23229e573e Reverse thrust was dead on the desktop (user report): wire the 0x3F throttle-handle button
LALT -> button 0x3F was bound in both binding layers and PadRIO pushed the RIO
ButtonPressed event correctly, but nothing ever set the mapper's ReverseThrust:

 - the authentic route is BTL4.RES 'L4' streamed record [2] (Button Throttle1
   0x3F -> attr 6 ReverseThrust@0x124), but our streamed BUTTON mappings are
   not consumed at all -- MechControlsMapper::AddOrErase is still the
   unreconstructed Fail stub, so the event never reaches the attribute;
 - the ONLY writer of reverseThrust was the keyboard bridge's
   'key_throttle < 0' test, which is bypassed whenever a RIO is operational
   (the pad RIO always is, so the bridge is OFF on the desktop) AND is
   unreachable regardless, because L4PADRIO clamps the Throttle channel to
   [0,1].  Net: Alt did nothing and the flag was re-zeroed every frame.

FIX: publish the 0x3F hold state from PadRIO::EmitButton -- the one chokepoint
every desktop source funnels through (keyboard, gamepad, DirectInput joystick,
glass-panel clicks via SetScreenButton) -- and apply it in InterpretControls
gated on BTPadRIOActive() so real pod hardware is untouched (there the streamed
mapping owns the flag).  Marked [T3]; delete once streamed button mappings land.
Reverse is a HOLD (manual: hold the red throttle button, release = forward).

Verified with scratchpad/revtest.py (keybd_event injection, BT_KEY_NOFOCUS):
forward = rev=0 dmd +61.501; LALT held = rev=1 dmd -61.501; release edge logged.

KB: pod-hardware.md now records the streamed-button-mapping gap + this seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 19:19:21 -05:00
arcattackandClaude Opus 5 6c3fca2f67 Gitea #38 ROOT CAUSE + FIX: mech paint lost on geometry re-load (view toggle / respawn)
Found from the user's live 2-node demo: a Crimson MadCat went GREY in its own
view after a V (inside/outside) toggle, with NO new [paint] or
MakeMechRenderables line in the log -- so no rebuild, just a re-parse.

ROOT CAUSE: the per-pilot colour/badge/patch is applied by rewriting MATERIAL
NAMES while a BGF parses, and only while the substitution callback is installed
(SetupMaterialSubstitutionList .. TearDown, bgfload.cpp:15-18).
BTL4VideoRenderer::ApplyViewSkeleton re-parses every shown segment BGF on each
view toggle AND on respawn (that is what its fresh graphic-state read is for),
but did so OUTSIDE that bracket -> raw %color% placeholders -> unpainted
materials -> grey.  Nothing caches the geometry (d3d_OBJECT caches only
textures), so every call re-parses.  This is #38's mechanism: 'colors not
preserved on respawn' was never a replication or teardown bug.

FIX: re-install the substitution list around the reload, reusing the serial the
mech was BUILT with -- SetupMaterialSubstitutionList advances the global %serno%
per call, so a naive re-bracket would stamp a different serial and still resolve
the wrong names.  MechRenderTree gains paintSerno (captured before Setup at
build); ApplyViewSkeleton installs/restores around the loop and logs it.

Rig-verified: the crimson MadCat stays crimson (yellow patch intact) across
repeated inside/outside toggles; each toggle now logs '[paint] color=red
serno=0' + '(paint serno 0)'.

ALSO -- corrections to yesterday's #44 name plate from the adversarial pass:
 - kPlateARGB 0xFF808080 -> 0x80FFFFFF.  BMAP.BMF tag 0x0027 is
   dpfB_MATERIAL_OPACITY_TAG [T0 libDPL/dsys/PFBIZTAG.H], NOT a diffuse colour:
   the materials carry NO colour tag at all, so the plate is the unmodulated
   callsign raster at ~50% opacity, not a grey label.  (Two independent
   workflows converged on this.)
 - the plate's K = 1/2.8 is NOT the hotbox's constant (2.8145) -- de-unified.
 - the Lock attribute is HUD attr id 10, an int/Logical*, not a Scalar*.
 - the PNAME pair split is the V half, not U (our per-player texture is one
   128x32 cell, so the port quad samples v 0..1).
 - retracted bgf-format's 'tag 0x0027 likely SPECULAR [T4]'.

KB: new reconstruction-gotchas section on the whole bug class (load-time-only
state must be re-installed on every RE-load, incl. the serno trap).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 19:06:04 -05:00
arcattackandClaude Opus 5 35c750dc7c Gitea #44: the reticle TARGET NAME PLATE (PNAME1-8) -- the target's callsign under the crosshair
Reconstructed the last deferred piece of the 1995 reticle, decoded from the
Execute disassembly @004cdcf0 [T1]:
 - selection: target_mech+0x190 (owning BTPlayer) -> player+0x1e0
   (playerBitmapIndex), 1-based into the mesh table at this+0x2e8
 - placement: re-placed every frame the aim moves -- scale 0.12 uniform,
   translate (K*retPos.x, K*retPos.y - 0.08, -1.0), K = 1/2.8 (the x87 long
   double @0x4cee64) -- so the label TRACKS THE AIM POINT, not screen centre
 - visibility: the LOCK attribute (this+0x184, cached +0x188) AND an owning
   player; reticle-off / simple-X also hide it
 - art: the plate quad UV-addresses a shared 128x64 bitslice texmap whose
   texels the pod OVERWROTE each mission with the egg's callsign rasters
   (original source commented out at L4VIDEO.cpp:5682-5710) -- the baked
   'PLAYER n' art in BMAP.BSL is only the shipped fallback.  Material colour
   is a neutral (0.5,0.5,0.5): grey-white, not phosphor green.

Port: same geometry in reticle units (0.224 below the aim point, 0.336x0.084)
drawn as the target's egg callsign texture through the reticle's own MapX/MapY
mapping, so it follows the world view in every layout and BT_SHOT captures it.
New: dpl2d_DrawTexturedRect, BTReticleTargetPlate, BTGetPlayerNameTexture.

Also: kRetCaret corrected 0.02f -> 0.025f (_DAT_004cd7f4 read from the binary;
the old value was a T3 guess, 20% small).

KB: gauges-hud + open-questions corrected (the chain was filed as deferred and
mis-attributed to 'the 3D marker'); TWO PNAME consumers now distinguished (this
plate, and CameraShipHUDRenderable's ranking window which shipped 2026-07-18);
new bgf-format section on the plate atlas.

Rig-verified: 2-node relay, BT_GOTO=enemy -> '[hud] name plate: bmp=2 tex=1'
and 'Boreas' rendered under the crosshair on the locked target.  Solo clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 18:36:02 -05:00
arcattackandClaude Opus 5 b7107b1020 Gitea #43: the Comm pilot list now shows EVERY pilot -- three stacked fixes
1. Roster count used the binary's raw entry+0x29&0x40 read on our compiled
   objects (databinding trap) -> garbage latched pilotCount=1.  Decoded: the
   flag is Player::NonScoringPlayerFlag (bit 14 = Entity::NextBit); both
   loops now use IsScoringPlayer().  Also closes the pilotIDs[1] overrun.
2. The build-once latch races async replicant arrival (rig-proven 1-then-2):
   rebuild on scoring-census change [T3 accommodation, demand-latch
   precedent]; also un-dangles departed peers.  Forensic: '[score] pilot
   roster built: N'.
3. Name icons were a NULL stub + zeros blank authentically -> rows invisible.
   Wired BTPilotNameBitmap (playerBitmapIndex -> Mission small callsign
   raster, the radar-label chain); replaces the raw pilot+0x1e0 key.

Rig-verified: Aeolus + Boreas rows with callsigns on both nodes incl. the
staggered-start race; solo unregressed (1 scoring pilot).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 17:51:52 -05:00
arcattackandClaude Opus 5 7494e2ec6c KB: pilotList MP claim corrected -- only the local row renders live (Gitea #43, rig-reproduced)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 17:35:20 -05:00
arcattackandClaude Opus 5 ef5a15ae2c KB: late-join make-sync gap + revolving-door plan digest (multiplayer, open-questions)
The 1995 engine tolerates late HOST attach but never finished late ENTITY
sync (NotifyOfReplacementEntityCreation = Fail stub, makes broadcast-once).
Routes to docs/REVOLVING_DOOR_PLAN.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 16:13:29 -05:00
arcattackandClaude Opus 5 66d1410118 Revolving-door mode: feasibility findings + two-phase implementation plan (docs only)
Phase 1 = console-only round carousel (relay door mode, no new zip);
Phase 2 = true drop-in (engine make re-send on late PEER_UP), gated on a
2-node spike.  No code modified -- plan for the next work session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 15:05:06 -05:00
arcattack 37a520d8a0 README: control mode vs experience level are different systems + NumLock note
The same careful tester hit the BASIC-mode trap twice because 'expert/
veteran modes' (menu experience) sound like control modes (M key).
Spelled out the distinction + the numpad NumLock dependency.
2026-07-24 12:47:15 -05:00
arcattack da34e2d8ab KB: remote control channel + the interest-teardown audio crash (multiplayer.md) 2026-07-24 12:42:43 -05:00
arcattackandClaude Opus 4.8 210bdb05ee Remote operator control channel + THE teardown crash root-caused and fixed
REMOTE OPERATORS (dev-channel ask): the relay grows a control TCP
listener (console port + 7) speaking a line protocol -- AUTH <secret>
(auto-generated content\operator_secret.txt), then launch / stop /
ping / get mission / set key=value;...  The relay's timestamped log
stream tees to the authenticated operator (bounded per-conn buffers --
a stalled operator can NEVER stall the relay; overflow = drop), with a
400-line history replay on connect so mid-session operators see
current state.  One operator at a time; a new AUTH displaces the old.
stdin commands unchanged (rigs/GUI local mode untouched).

The operator GUI gains a 'Relay host' field: blank = today's local
flow byte-for-byte (child relay dies with the window); a hostname =
drive that machine's relay remotely (launch/stop/apply-settings over
the wire, roster rebuilt from the teed log, local-instance joins point
at the remote relay).  Only the relay host needs port forwarding.

Scripted protocol test: auth reject/accept, takeover, get/set, ping,
history replay, and a FULL 2-node mission launched and stopped
entirely over the socket.

CRASH FIX (the layout-shifting heisenbug -- and almost certainly issue
#35): the new crash self-report finally captured its 24-frame stack:
InterestManager::OrphanInterestOrigin -> RemoveUninterestingEntity ->
DestroyEntityAudioObjects -> {Static,Dynamic}3DPatchSource::
IsAudioSourceClipped, AV reading NULL+0x38 -- the unguarded chain
GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() (the
authentic burning-mech death-silence check) hit a NULL vehicle during
MP teardown windows.  Both sites now null-guard; burning semantics
preserved whenever the vehicle exists.  Also live-confirms that
interest-based entity teardown RUNS in MP (the #38 paint mechanism).
Regression: full combat round + control-channel drive, zero crashes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:41:51 -05:00
arcattack b710a848c2 README: upgrade = extract-over-top (preserves bindings.txt + volume.cfg)
The advertised upgrade flow was delete-and-unzip, which wipes the two
per-player files (key/joystick bindings incl. joyconfig output, saved
volume) every version.  The zip carries neither file, so extract-over-
top preserves them by construction -- now the documented flow, with the
copy-out fallback for clean-delete preferrers.
2026-07-24 12:16:28 -05:00
arcattack a235e2464d KB convention: bindings.txt grammar is a compatibility surface (player customizations must survive upgrades) 2026-07-24 12:07:45 -05:00
arcattackandClaude Opus 4.8 ef9a648c89 README: BASIC control mode does not steer with pedals (Ferret's 510 report)
Field report (new tester, 510): keyboard A/D + arrows 'not working' --
can twist and drive but not turn.  Root-caused live via key injection:
fresh spawns start in BASIC control mode, where steering rides the
STICK (authentic rookie mode) and the pedal-bound keys are inert by
design; one M press (MID) makes A/D steer.  (A false regression-bisect
chase established the metric noise came from an attached pad's stick
drift nudging BASIC steering.)  README now warns about the default
mode; kept the env-gated [padwrite] diagnostic from the hunt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:57:44 -05:00
arcattackandClaude Opus 4.8 41e899a9d7 Issue #41: launch forensics in the player bats -- exit code + never-started detection
David's failures leave a 0-byte (or absent) join.log -- the exe can't
testify about a death before WinMain.  The bats now bracket it from
OUTSIDE: launch_report.txt records the launch timestamp, the exe's
EXIT CODE (fingerprints the failure class: 0xC0000135 DLL-not-found,
0xC0000005 AV, AV-kill signatures, our clean exits), whether join.log
was ever created (never-created = blocked before our first
instruction), and its size.  Verified with a synthetic -1073741819
exit.  The 'exited' message now asks players to send BOTH files.

With this + the 507 first-breath line + the crash self-report, every
failure mode now leaves evidence somewhere: bat report (pre-WinMain),
first-breath (WinMain reached), [crash] stack (anything after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:02:51 -05:00
arcattackandClaude Opus 4.8 b2b7a45b5c Issues #39 + #40: gate input until the scene presents; cap the wait-screen idle loop
#39 (field: 'I was able to input before the game screen actually came
up -- fired weapons, moving, could hear the audio'): the simulation
runs during load/wait and PadRIO fed it live input.  Poll now emits
only the analog heartbeat until gBTSceneHasPresented -- keyboard, pad
and joystick all stand down; edge state stays parked so keys held
across the boundary press cleanly at first frame.  BT_BTNTEST and the
panel-click seam stay live (event injection, same as before).

#40 (field: 'GPU usage during waiting screen is sometimes consuming up
to 45-50%'): ExecuteIdle Cleared+Presented uncapped for the whole join
wait.  Now capped at ~40 Hz with a 5 ms sleep on skipped passes (the
overlay's change gate is 12.5 Hz -- nothing visible changes faster).

Regression: 2-node relay round through the wait screen -> load ->
mission (135 in-mission ticks, autofire) -> clean.  Awaiting the
reporter's live confirm on both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:57:06 -05:00
arcattack 85ac54fcee KB gotcha: dropped mid-sequence call passes first-use tests (issue #42 lesson) 2026-07-24 09:25:49 -05:00
arcattackandClaude Opus 4.8 a35cef4c01 Issue #42: doubled MFD joystick -- restore the dropped MoveToAbsolute(0,0)
The binary's ConfigMapGauge dirty-redraw (@004c6f1c) is SetColor(0xFF)
-> MoveToAbsolute(0,0) -> DrawBitMapOpaque; the transcription dropped
the reposition.  The FIRST draw worked by accident (fresh view cursor
at the origin); every forced redraw -- the weapon panel's DISPLAY
round-trip (main -> ENG DATA -> main, BecameActive sets dirty) --
blitted btjoy.pcc at the state-lamp loop's leftover cursor (0xc, 0x55),
painting an offset ghost joystick over the original on every MFD.

Repro pinned live by the operator (initial draw correct; doubles on
the DISPLAY round-trip, every MFD).  LIVE-VERIFIED fixed by the
operator: round-trips redraw cleanly.  NOT a regression -- present
since the joystick went live (89b4f53; bisect-confirmed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:25:12 -05:00
arcattackandClaude Opus 4.8 f7daf03507 Crash self-report + round-completed latch + PDB archiving
CRASH SELF-REPORT (the #35/#41 heisenbug hunt): every unhandled
exception now logs its code, address, and an EBP-walked stack as
module-relative offsets (btl4+0xNNNN) into BT_LOG before dying --
any field crash on any machine hands us a resolvable stack.  The
walker is per-dereference guarded (a corrupt chain truncates).
BT_CRASHTEST=1 exercises the path end-to-end (verified: forced AV
logs code/addr/5-frame stack).  mkdist archives each build's PDB
next to its zip so offsets resolve per distributed version.

Context: a flaky release-only crash in the mech build path surfaced
during #38 rigs -- it moves with build layout, spares neither peer,
and masks under a debugger heap (13 clean cdb runs vs 2/3 crashes
without).  A real latent memory bug; the self-report will catch it
wherever it strikes next.

ROUND-COMPLETED LATCH (issue #38, awaiting verification): relay-mode
clients refuse RunMission after a round has ended -- the lobby-loop
relaunch owns the next round (in-process round 2 rebuilt every
player/mech entity on stale globals; matchlog caught player=3:1->3:36
in one generation).  Latch sets ONLY when the stop ends a RUNNING
mission (a stray pre-mission StopMission must not poison round 1 --
the first cut ate a legit launch, caught + fixed in rig).  Regression
verification pending (contaminated by the heisenbug above).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:51:50 -05:00
arcattackandClaude Opus 4.8 ac7f0cb13a Issue #38 (partial): egg camo-color validation + paint forensics
Respawn-paint investigation: 10 forced respawns on the i22 rig showed
paint STABLE across in-place respawns on both master and replicant --
the in-place reset does NOT repaint.  Two real findings landed:

1. 'Red' is NOT a camo color (the vehicle table authors Crimson=red for
   camo; Red exists only as a PATCH color) -- an egg with color=Red
   paints the mech silent gray from first spawn (the engine tolerates
   the table miss by design).  The relay now WARNS at egg load on any
   color= outside the camo set; our MPSHORT test egg had exactly this
   bug (fixed).
2. [paint] log now carries the entity id, so the one-time-observed
   nondeterministic video-model rebuilds (sernos 2-5 with swapped
   colors, run 1 only) will be attributable when they recur.

The reported color/style change on respawn remains unreproduced in the
deterministic rig -- needs the observer detail (whose mech, which view)
from the next field sighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 00:22:42 -05:00
arcattackandClaude Opus 4.8 3d8bfdeba6 Hardening: unknown vehicle= in the egg fails loud instead of segfaulting
Found during the #35 (Owens laser crash) hunt: FindResourceDescription
returns NULL for an unknown vehicle name and release builds (Check
compiled out) segfaulted on Lock() -- a typo'd or corrupted vehicle
tag in a served egg would crash EVERY pod at drop.  Now logs
'[spawn] FATAL: unknown vehicle' + Fail() with a clean exit.
Verified: vehicle=nosuch -> message + exit 127 (was a segfault).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 00:10:04 -05:00