Commit Graph
172 Commits
Author SHA1 Message Date
arcattackandClaude Opus 5 ef8e449a17 #55 steps 0/1/2/4: the 1995 DEATH pass restored, the arg gate made real, and 5 subsystems the respawn sweep never reached
THE HEADLINE: the port's death sweep was a total no-op, and fixing it required
fixing the arg gate in the same commit -- otherwise corpses refill their ammo.

STEP 2 -- the authentic dispatch shape:
  * engine/MUNGA/SUBSYSTM.h: Subsystem::DeathShutdown gains the binary's
    universal base body { DeathReset(c) } (@004ad10e).  It was empty, and NO
    port class overrode it, so everything the 1995 game does at death was
    skipped entirely.
  * mech4.cpp death sweep now passes 0, not 1 (the binary's @0049fe0c arg) --
    the wreck shape: alarms/state settle, nothing refills.
  * ARG PROPAGATION (mandatory once the sweep runs): AmmoBin::DeathReset is now
    arg-gated exactly as @004bd26c -- refill only when arg != 0, ammoAlarm
    unconditional.  Ignoring the arg was harmless only while the sweep was
    dead; with it restored, every corpse would have re-armed.  Also forwarded
    in PoweredSubsystem / HeatSink / Condenser / HeatableSubsystem / Generator
    (each binary body forwards it -- verified addresses in the plan).

STEP 4 -- coverage for 5 classes that had an authentic slot-10 body but no
DeathReset, so the respawn sweep fell through to the empty base:
  Torso (this is Gitea #70 -- "loosing torso twist function after a death"),
  Gyroscope, Myomers, HUD, Seeker.

STEPS 0/1 -- observability + the David chain:
  * Three unconditional matchlog rows: DEAD_NOTIFY (mech + resolved link),
    PLAYER_LINK (player <-> vehicle), RESPAWN (mode/alive/zones/subsys/pos).
    A respawn was previously INVISIBLE in the matchlog -- night 3's analysis
    had to infer them from ammo arithmetic.
  * Mech::PlayerLinkMessageHandler + the death dispatch site: when the engine's
    one-shot registry lookup misses (no null check, no retry -> the whole
    death/respawn cycle silently swallowed), recover the SAME object via the
    reverse link the binary's own respawn branch walks (player+0x1FC ==
    playerVehicle).  Complete-type TU, no raw offsets.
  * deathPending cleared in the first-spawn branch (a latch carried in would
    permanently kill every later respawn -- the #57 class).

VERIFIED on the 2-pod rig (madcat vs thor, forced kills, 5 death/respawn cycles
per pod): build clean, ZERO new /FORCE unresolved externs from the 5 new
overrides; refill lines appear ONLY immediately before a Mech::Reset and NEVER
between a death and the next respawn (the corpse-refill regression this commit
had to pre-empt); all three probe rows present in both pods' matchlogs; every
DEAD_NOTIFY carried a non-null link.

DELIBERATELY DEFERRED (documented, not forgotten): the mechsub.cpp rename pair
(ResetToInitialState -> GenerateFault, ClearStatus -> the root reset @004ac22c),
deleting HeatableSubsystem::ResetToInitialState, and deleting RespawnRepair.
Those need the HeatableSubsystem vtable-slot-10 pre-flight the plan calls for,
and we have no binary image here to dump the vtable from -- guessing at it
risks the vptr-alias trap.  Next session with the decomp shards open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:09:31 -05:00
arcattackandClaude Opus 5 e28dcbc161 the #35-class sweep + the games-night load crash: four lifecycle-window null-derefs guarded
THE CLASS (established by Gitea #35's 515 fix, confirmed growing tonight):
code the binary could run bare because a pod's mission world always existed,
crashing in the port's MP join/teardown/respawn windows where mission, player,
or a roster link is briefly NULL.  Swept every GetMissionPlayer()/
GetPlayerVehicle()/GetCurrentMission() chain in the tree (23+7 sites).

GUARDED (the 515 pattern -- authentic behavior whenever the object exists,
skip/degrade + loud log when mid-teardown):

1. DPLRenderer::SortAndReloadNameBitmaps -- runs in the round-STOP path (the
   end-of-round circle), raw mission+player+entity-manager derefs.
2. DPLRenderer::LoadOrdinalBitmaps -- called from (1); raw
   GetMissionPlayer()->GetInstance() evaluated on EVERY machine (the camera-
   director test derefs before it branches).
3. DPLRenderer::LoadNameBitmaps -- same window, raw GetPlayerCount() chain.
4. Reservoir::Reservoir master-gate block -- THE CAPTURED GAMES-NIGHT CRASH
   ([crash] btl4+0x4990d = this ctor inlined into CreateReservoirSubsystem,
   AV reading NULL+0x1d0):  linkedSinks.Resolve() derefed raw; +0x1d0 is
   exactly the masterScale FILD of the resolved master heat-sink bank
   (heatSinkCount @0x1D0 -- the offset the crash named).  Observed trigger: a
   DUPLICATE-PILOT egg (the seat-ghost bug put one identity in two seats)
   poisoning the ID registry, so Resolve() nulled during viewpoint-entity
   construction and at least two pods died loading the same egg.  Now: loud
   '[spawn] FATAL-AVOIDED' + degrade to the inactive-copy shape.  The
   duplicate-pilot ROOT CAUSE is the seat-ghost fix (tracked separately);
   this guard makes the failure survivable and self-identifying either way.

CLASSIFIED SAFE, not touched: btl4mppr/btplayer/mechmppr/btl4gau3 chains
(guarded or null-tolerant by design); APP.cpp's five Check(player) sites
(mission state machine -- player exists by construction, original engine
ordering); CAMMGR/CULTURAL GetCurrentMission chains (construction-time).

VERIFIED: build clean (0 errors, no new unresolved externs); solo smoke on the
guarded exe -- zero FATAL-AVOIDED lines (guards silent on the healthy path),
subsystems simulating, no faults.  STILL OWED: a full launch->stop rig cycle
(exercises the guarded round-stop path live) and the duplicate-pilot-egg repro
-- both blocked on port 1500 while the operator's console session is up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:03:31 -05:00
arcattackandClaude Opus 5 12d6862c1f keylight: unship it -- opt-in (BT_KEYLIGHT=1), gone from all player docs
Operator decision: the player base is not on Windows 11, so the RGB keyboard
mirror should not be shipped or promised.  The feature is NOT deleted -- Cyd's
implementation stays intact behind the gate for whoever wants it -- it is
simply off by default and invisible:

  * PadRIO gate flips from opt-out (BT_KEYLIGHT=0 disables) to OPT-IN
    (BT_KEYLIGHT=1 enables).  Default boots produce zero keylight lines, and a
    stub build and a real build now behave identically for players: nothing.
  * Removed from every user-facing artifact: the README paragraph, the
    CONTROLS.md section, the CONTROLS.html footer line.
  * environ.ini template: the entry stays (it is the discoverable home for the
    opt-in) but now says OFF-unless-1, Windows 11 22H2+, and why it is
    unshipped.
  * mkdist's cut-time stub warning removed -- it existed to protect a README
    promise that no longer exists.
  * context/glass-cockpit.md records the decision, the opt-in, and the
    build-time SDK stub gate in one place.

VERIFIED: default boot logs zero keylight lines; BT_KEYLIGHT=1 engages the
feature (the stub on this machine: its one honest log line + the key map).
All three console suites pass; checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:06:00 -05:00
arcattackandClaude Opus 5 8edce41f88 bindings: one-time migration to the board-2 layout -- the two-population fork is gone
Operator decision: "make this version devour their custom settings and re-output
the correct binding file with the updates -- I'd like to eliminate competing
code paths."  Before this, the preserved-bindings.txt convention meant upgraders
stayed frozen on whatever layout their file was written under, forever: two
player populations, two sets of true documentation, and every future default
improvement reaching only fresh installs.

THE MIGRATION (PadBindingProfile::MigrateBindingsFile, run once at load):
  * A file carrying the new "# bindings-board 2" marker is left alone -- the
    check is the first thing that runs, so this is a no-op forever after.
  * Otherwise: every row matching the UNION OF EVERY DEFAULT SET EVER SHIPPED
    (85 canonical rows, harvested from c2ee729..1cda880 and embedded as a
    table; comments stripped, whitespace collapsed, uppercased) is dropped --
    those rows were never a player's choice, they were just the old board.
  * Rows the player actually authored are CARRIED with their original text and
    comments, and WIN over the new defaults: the losing default row is emitted
    commented out as "# (yours wins) ...", keyed by the control ("KEY T",
    "PAD A", "PADAXIS LX") so key rows only displace key rows.
  * The joystick wizard's marker-delimited section is preserved VERBATIM (the
    same markers L4JOY rewrites between), so HOTAS players migrate without
    re-running the wizard.
  * The previous file is saved as bindings.old.txt first.  Restoring it over
    bindings.txt just re-migrates next boot -- deliberate: the old WORLD is not
    restorable, only the player's own rows persist.  That is the point.

VERIFIED end-to-end with the real exe (three planted scenarios):
  A. old-world file (8 old-default rows + 2 authored rows incl. a rate tweak +
     a wizard section): both authored rows carried with comments, both
     conflicting new defaults stood down with "(yours wins)", wizard section
     byte-preserved, backup written, log line states all counts, and the
     migrated file parses with ZERO malformed lines.
  B. already-migrated file: byte-identical md5 after a full boot, no migration
     line, no backup touched.  (First attempt at this check clobbered its own
     evidence by running case C first -- re-run properly.)
  C. no file: fresh defaults now carry the marker as line 1, no migration.
  The operator's real bindings.txt was stashed before testing and restored
  untouched; it will migrate on their own next launch, as intended.

Docs flipped to the new reality: the README's "your keys DO NOT change"
upgrade promise is replaced by the migration story (customs + stick kept, old
stock keys replaced, bindings.old.txt escape hatch); CONTROLS.md gains the same
note; context/glass-cockpit.md records the mechanism, the 85-row table's
provenance, and why restoring the backup re-migrates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:19:13 -05:00
arcattackandClaude Opus 5 150961176c merge fix-ups: the four review findings + a build portability gate the merge exposed
Applied on top of the glass-cockpit-refit merge, all from the pre-merge review:

1. README CONTROLS TABLE REWRITTEN for the new default board.  It still taught
   "W/S throttle, A/D turn" while the defaults moved flight to the numpad and
   handed the letter rows to the MFD banks -- a fresh install would read
   controls that do not work.  The new table teaches the numpad flight block,
   the panel-on-your-keys groups, F7 for control mode (was M), backtick for the
   camera (was V), the coolant valves on 1-3/QWE with the detent warning kept,
   and PgUp/PgDn volume.  UPGRADERS ARE TOLD EXPLICITLY that their preserved
   bindings.txt keeps their old keys, and that deleting it opts into the board.

2. BACKTICK VIEW-TOGGLE GUARDED.  The bindings-row-wins rule (KeyHasBinding)
   covered V and J/K/L but not backtick itself, which polled unconditionally --
   and BACKTICK is a nameable, unbound key, so binding it to a button
   double-dispatched (button + camera).  Both halves of the view poll are now
   guarded.

3. ENVIRON.INI REFUSES ONE-SHOTS.  The loader applied ANY key=value line, and
   BT_JOYCONFIG in the file would defeat the #66 one-shot in a sneaky way: the
   wizard DELETES the process var after running, so in every relaunched child
   there is no real env var left to win over the file -- capture wizard at
   every mission start.  BT_JOYCONFIG is now skipped with the reason in the
   template header, which also warns that test/debug hooks (BT_MP_FORCE_DMG
   and friends) do not belong in a file that silently persists forever.

4. VOLUME DOCUMENTED EVERYWHERE IT WAS MISSING.  CONTROLS.md gains the
   PgUp/PgDn row (the old "- / = (see below)" cell pointed at a note that never
   mentioned volume) and the note that the keys moved so they no longer share a
   key with anything in any layout; the bindings template header reserves
   PgUp/PgDn informally.

5. L4KEYLIGHT BUILD GATE (found by building the merge on this machine).  The
   C++/WinRT Dynamic Lighting TU does not compile against SDK 10.0.19041 --
   its bundled cppwinrt fails inside winrt/base itself (C2039 'wait_for').
   CMake now detects an SDK older than 10.0.22000 and builds a dormant stub
   with the same five-function scalar interface (logs once at Start, identical
   behaviour otherwise) -- extending the feature's own runtime philosophy
   ("no Dynamic Lighting -> log once, stay dormant") to build time.  Dynamic
   Lighting is a Windows 11 22H2 feature, so nothing real is lost on an older
   build machine, and machines with a newer SDK build the real mirror
   unchanged.

VERIFIED on the merged tree (4.11.572):
  * build clean -- 0 compile errors; the 40 /FORCE-tolerated unresolved
    externals are byte-identical to every pre-merge build (20
    CreateStreamedSubsystem + 20 DefaultData; the earlier claim that all 40
    were CSS was shorthand -- corrected here for the record).
  * solo boot: environ.ini template written + loaded, mode resolver announces
    the surround, historical bands L276/R276/T223/B336 confirmed, keylight
    stub logs its dormant line, BT_SHOT capture shows the under-glass button
    treatment and the corrected hat labels, 0 faults.
  * PgUp/PgDn volume PROVEN LIVE via injected keys: 5%->10%->15%->10%, saved
    to volume.cfg.  (Side catch: content\volume.cfg had been sitting at 0.00
    from an old test -- anyone using this tree had a silent game; reset.)
  * full relay cycle on the merged exe: 2 pods staged, launch pair, mission,
    StopMission, round RESET -- all clean.
  * all three console suites still pass; checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:22 -05:00
CydandClaude Opus 5 9d536294e4 Mouse buttons are bindable; mouse LOOK scoped (not built)
BUTTONS (shipped).  Win32 hands mouse buttons out as virtual keys, and the
binding path already stores VKs and polls GetAsyncKeyState -- so this needed no
verb, no parser change and no new machinery, just names:

    key MOUSE4 button 0x40        # side buttons
    key MOUSE5 button 0x46
    key MOUSEMIDDLE button 0x41

MOUSELEFT/MOUSERIGHT are named too, with a warning in the file header and the
docs: they are how the player presses cockpit buttons (left = press, right =
latch), and the poll is focus-guarded but not click-aware, so binding either
ALSO fires on every panel click.  The side and middle buttons are the free
ones.  Axes work as well (`key MOUSE4 axis Throttle slew + 0.7`).

One guard: the RGB lamp mirror skips mouse VKs when building its map -- a
keyboard lamp array has no mouse buttons to paint (BTPadBindingIsMouseKey).

Verified live: a bindings.txt carrying two mouse rows loads 76 keys (74 + 2),
and a real injected XBUTTON1 press produced `[emitter] FIRED #1`.  Clean A/B --
the earlier run whose SendInput failed the struct-size check logged no fire at
all.  Shipped commented-out examples in the default profile so the capability
is discoverable.

LOOK (scoped only).  docs/MOUSELOOK_PLAN.md.  Mouse MOVEMENT is a real feature,
not a table entry, and the hard part is not reading the device:

 - The cursor is already spoken for.  The glass cockpit's premise is that all
   72 buttons are mouse targets; mouse-look wants a captured, hidden, recentred
   cursor.  Both cannot be true, so this is a MODE question first -- four
   models compared, recommending hold-to-look (costs nothing when unbound,
   cannot strand a player, behaves the same in surround and exploded layouts).
 - The channels are POSITIONAL (JoystickX/Y are -1..1 deflections the mapper
   reads per control mode); a mouse gives deltas.  Accumulate-into-a-virtual-
   stick reuses every existing rule; a rate model would need new mapper
   semantics.
 - Control mode changes what it MEANS: BASIC steers with the stick, MID/ADV
   twist the torso -- so mouse-look steers in one and aims in the other.

Proposed grammar (new row type, so old builds skip it with a warning),
where the code goes, ~250 lines, and a verification plan whose load-bearing
item is "panel clicks still work when a mouselook binding exists but is not
engaged".  Also flagged: the pod had NO mouse, so nothing here is
reconstruction -- every choice is design, judged on feel, and it must stay out
of the pod build's path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:06:58 -05:00
CydandClaude Opus 5 5f88a4eeb2 The keyboard becomes the button board (RP412 bindings port) + a controls page
Ported RP412's bindings design and made it the DEFAULT: the letter and number
rows are the MFD button banks laid out WHERE THEY SIT ON THE PANEL, flight
moves to the numpad so the board stays free, F1-F12 are the map's two columns,
and G/B stay unbound as the physical gap between the lower clusters.  Expressed
in BT's OWN grammar (slew/deflect/set, the Turn channel) -- bindings.txt is a
documented compatibility surface and was not touched.

Coverage: 61 of 72 addresses on the keyboard, and the 11 absent ones are
exactly those the pod never wired (0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E
intercom/door).  All 72 stay clickable on the panel.

It lands on BT's addresses unreasonably well: 1-4 + QWER are the ENTIRE coolant
system (Condensers 1-6, flush, balance), F6/F7 the display and control-mode
cycles, F9-F12 Generators A-D, F4 the crouch button reconstructed earlier
today.

THE DELIBERATE COST.  A bound key is removed from the authentic 1995
typed-hotkey channel so it cannot double-dispatch, and this board binds nearly
everything -- so 5 (Quad page), z (Eng1), t/y/u/i/o (pilot select) and +/-
(target zoom) are given up.  Unbind a key to get its 1995 meaning back.
Documented in the file header, the markdown and the page.

NEW RULE: A BINDINGS ROW WINS OVER A BUILT-IN CONVENIENCE KEY
(PadRIO::KeyHasBinding).  V and J/K/L are board buttons now, and those polls
read GetAsyncKeyState directly -- without this they would have fired BOTH the
button and the built-in.  View toggle lives on BACKTICK alone.

CONTROLS.MAP rewritten to mirror the board so glass/pod/dev still feel
identical (the 2026-07-21 settlement): 90 bindings, 0 parse complaints.

HAT LABELS CORRECTED [T1].  INPUT_PATH_AUDIT flagged 0x41-0x44 and was right.
Settled from the streamed mapping (BT_CTRLMAP_LOG): elem 66 -> subsys 17
attrID 14, i.e. 0x42 is the TORSO subsystem, not a look; 0x44/0x43/0x41 are the
mapper's LookLeft/LookRight/LookBehind (attrID 10/11/12).  The .RES has no
"TORSO CENTER" string -- its names are LookBehind/Down/Forward/Left/Right -- so
the audit's wording was loose but its substance correct.  Swept L4GLASSWIN,
L4PADPANEL and L4VB16.

docs/CONTROLS.html: interactive keyboard (hover a key for its address and
meaning, MFD clusters outlined), pad diagram, panel map, the under-glass
press-target figure, radar placement thumbnails and the environ.ini table --
modelled on RP412's page, rewritten for BT's addresses and hazards.  mkdist
wraps the fragment in a doctype/charset shell and ships it beside CONTROLS.txt.

Verified: bindings.txt regenerates and loads (74 keys, 0 errors); CONTROLS.MAP
90 bindings, 0 errors; 72/72 panel addresses still dispatch; surround /
exploded / dock / pod / dev boot and simulate with zero faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 03:23:27 -05:00
CydandClaude Opus 5 56d9971b0e -fit: pin the backbuffer to the CANVAS, not the client (the display was wrong)
Field report with a screenshot: `btl4.exe -fit` renders tiny MFDs hugging the
corners, an over-wide world view, and a horizontally squashed scene.

CAUSE.  L4VIDEO pins the windowed backbuffer to the CLIENT RECT (Gitea #56, so
a device Reset cannot silently re-derive it).  That is correct precisely while
client == canvas -- true on a normal boot, and FALSE under -fit, which sizes the
window to the whole monitor BEFORE the device exists.  The backbuffer came out
3440x1440 against a 1452x1059 canvas, and everything downstream disagreed:

  - BTApplyWorldViewport and BTDrawCockpitPanels lay out from the BACKBUFFER,
    so the canvas they drew was 3440 wide: bands unchanged, MFDs still 320x240
    (hence tiny), view rect 2888x881 instead of 900x500;
  - the world projection used the aspect of the INTENDED view (1.8) inside a
    3.28 viewport, stretching the scene;
  - Present then scaled that oversized backbuffer into the 1974x1440 letterbox,
    squashing it horizontally by 0.574;
  - and every button was hit-tested through gBTCockpitCanvasW/H while being
    drawn in backbuffer space -- the exact #50 divergence the pinning comment
    warns about, unnoticed because nothing had clicked under -fit.

FIX: when the cockpit surround is up, the backbuffer IS the canvas, whatever
the window is doing.  Pin to gBTCockpitCanvasW/H and let the letterbox do the
scaling -- which is what the rest of the design already assumed.  Drag-resize
was always right for this reason (the device is created while client == canvas
and a later resize does not recreate it); only -fit, which changes the client
first, exposed it.

MY EARLIER VERIFICATION WAS WRONG.  I called -fit good from a screenshot that
was centred and letterboxed, without checking that the backbuffer matched the
canvas or that a single button could be clicked.  Both are checked now.

Verified: -fit on 3440x1440 renders correct proportions (captured), and 72/72
buttons dispatch under -fit; surround/exploded/dock/pod/dev all boot and
simulate with zero faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:58:34 -05:00
CydandClaude Opus 5 02b1b50e45 RGB keyboard lamp mirror + the shipped controls reference
KEYBOARD LAMP MIRROR (L4KEYLIGHT).  Ported from RP412, itself a port of vRIO's
KeyboardLampMirror.  Keys bound to a lamp address in bindings.txt glow with the
panel palette through Windows Dynamic Lighting -- yellow for the map's side
columns (0x10-0x1F), red for the rest -- flashing in step with the on-screen
buttons (its LampLevel copy matches the FIXED BTLampBrightnessOf).  Per-key
boards light each bound key; zone-lit boards mirror the strongest lamp
board-wide.  All WinRT runs on a private worker thread: watcher, claim, and a
100 ms paint loop that repaints only on change.

RP412's packing hazard does NOT transfer: it forces default struct packing there
because its engine is /Zp1, which would break the WinRT ABI.  BT411's BT_OPTS
carries no /Zp, so only the dialect flags are needed -- /std:c++17
/permissive- per-file, since the project otherwise builds C++14 /permissive.
The scalars-only interface is kept anyway so the isolation survives if packing
is ever added.

Wired in PadRIO: map built from bindings.keyBindings (ActionButton binds only,
first-binding-wins per key), fed from PadRIO::SetLamp, stopped in the dtor
(which hands the LEDs back to Windows).  BT_KEYLIGHT=0 opts out; a machine
without Dynamic Lighting logs once and stays dormant.

Verified live: claimed this machine's 24-zone keyboard and mirrors 25 bound
keys; BT_KEYLIGHT=0 and the pod profile produce zero keylight lines and run
clean.

SHIPPED CONTROLS REFERENCE.  docs/CONTROLS.md carries the keyboard table plus
the full 72-BUTTON POD MAP, grouped by the display each bank surrounds, with the
coolant-valve detent warning (1-5-50-CLOSED, one press past max shuts the loop)
and the jam/eject procedure.  mkdist flattens it to ASCII as CONTROLS.txt at the
zip root, the README's own idiom.  players/README.txt gained pointers to it, to
environ.ini, to -fit, and to the RGB mirror.

KB: context/glass-cockpit.md and docs/GLASS_COCKPIT.md updated for the whole
branch -- layout modes, L4RIOBANK and the under-glass rule, the letterbox fit
and its ordering trap, player-tunable displays, the measured legend grid, the
closed dead-button backlog, and this mirror.

Verified: five-mode regression (surround/exploded/dock/pod/dev) boots and
simulates with zero faults; mkdist writes a 5149-byte pure-ASCII CONTROLS.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:24:30 -05:00
CydandClaude Opus 5 61563c9efe Glass cockpit refit: one mode resolver, one button geometry, and a cockpit that scales
Replicates the RP412 cockpit line into BT411's own architecture -- porting the
geometry and keeping our renderers, so BT_SHOT single-frame verification stays
intact.

MODE RESOLVER.  Where the secondary displays go was decided in TWO places with
duplicated precedence, and the boot banner read NEITHER -- it announced
"per-display cockpit windows" for every glass boot, surround included.  The
split had also broken BT_COCKPIT=0: documented as the dock-bottom opt-out, the
profile block converted it to BT_GLASS_PANELS=1, so the docked strip was
UNREACHABLE under the glass profile.  One resolver now, consumed by the banner,
the pad-panel decision and the window sizing; BT_GLASS_PANELS is explicit-only;
dock/window modes auto-raise BT_PAD_PANEL so the button field always has a home.

L4RIOBANK -- one button geometry.  Both renderers carried their own copy and had
drifted: an MFD button was 156x138 reaching under the glass in the exploded
window and a 76x24 sliver entirely OUTSIDE the glass in the surround.  One
module owns it now, both are consumers, placement stays per-renderer.  The pod's
under-glass rule (RP412 L4MFDVIEW): reach half the glass in behind the display,
leave a lamp strip clearing the edge, paint buttons first and imagery over --
so the lamp reads as a bar and practically the whole display is the press
target.  The strip scales off the display's SHORT axis (the map is portrait)
with a readable floor.  Retired L4GLASSWIN's three local placers and seven
layout constants.

LAMP FLASH DECODE was wrong [T1].  BTLampBrightnessOf returned max(state1,
state2) and blanked on the alternate phase; RIO::LampState (L4RIO.h [T0]) says
solid shows state 1 and flashing ALTERNATES the two.  Agrees only when one state
is Off -- true for the Panic lamp, which is why it survived -- but L4LAMP.cpp:252
commands flashFast + state1Dim + state2Bright, a dim->bright pulse that rendered
as a hard bright->off blink.  Three copies existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline.

THE COCKPIT SCALES.  The fixed canvas was stretched into whatever the client
area was, so a window dragged to a different shape squashed the instruments (the
projection was aspect-corrected in task #20; the panels never were).  Now one
uniform scale, centred, leftover black.  D3D9 applies it as a Present
destination rect, which DISCARD forbids -- so the WINDOWED swap effect becomes
COPY when the surround is up and multisampling is off.  The click mapping had to
follow (mapping against the full client drifts the hit test off every button by
the bar width), as did the world aspect (under a uniform scale it is the view
rect's own).  -fit / -windowed-fullscreen: borderless over the monitor.

 - ordering trap: the first WM_SIZE beats the device, so a -fit boot logged
   aspect=3.14 and applied it on frame 1.  The letterbox INTENT is decided in
   btl4main; L4VIDEO only confirms or withdraws it.

PLAYER-TUNABLE DISPLAYS.  BT_MFD_SCALE (+ _UL/_UC/_UR/_LL/_LR), BT_RADAR_SCALE,
BT_RADAR_POS (CENTER/LEFT/RIGHT/MIDLEFT/MIDRIGHT).  The surround BANDS derive
from the resolved sizes -- that is why the sizes could not stay constants: the
band a display hangs in has to grow with it or the canvas clips it.  100%
reproduces the historical L276 R276 T223 B336 exactly.  A corner map goes flush
to the CANVAS edge and the lower MFD slides beside it (measuring off the view
edge overlapped them by 232px).

MAP LEGEND GRID -- measured, not inherited.  scratchpad/measurelegend.py over a
native capture: top 3, cell 102, pitch 107 of 640.  RP412's map is 13 + 6x102 @
105 -- same cell height, different top and pitch, so its numbers do NOT
transfer.  Our old even division had the pitch right by luck and sat 3px high of
the labels.

environ.ini.  It was read ~300 lines into WinMain, AFTER the platform-profile
block had run its getenv()s -- so every setting the profile reads was silently
ignored FROM THE FILE and only worked as a real env var.  It also putenv()'d
comments verbatim.  Now loaded immediately after the first-breath line, comments
skipped, the real environment WINS over the file, and a fully documented default
is written on first run (the bindings.txt convention: untracked, so
extract-over-top never clobbers a player's settings).

VERIFICATION HARNESS (new, reusable): BT_RIOBANK_LOG=1 dumps every bank;
checkbank.py proves no address is SHADOWED (an address whose rect is covered by
earlier buttons is dead however big it looks -- the overlapping under-glass banks
make that a live hazard); clickbank.py posts a real click at every button centre.

Verified: 72/72 placed, 0 shadowed, 72/72 dispatched in BOTH modes, after a
resize, at 150%/135%, and at 75%+BOTTOMRIGHT; wide/tall drags and -fit
undistorted on a 3440x1440; exploded/dock/pod/dev un-regressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:23:40 -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 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 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 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 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
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 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
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 390d4ddce7 Issue #36: direct-axis RELEASE EDGE -- centering an axis now releases its channel
Night-2 (RajelAran): 'the game is not seeing axis release for turn --
the mech may keep turning when control is released.'  Root cause: the
direct-mode axis write was gated on raw != 0, so an axis returning
INSIDE the deadzone just stopped writing and the channel LATCHED at
the last deflection -- fatal on channels with no keyboard spring (the
Turn composite in the twin-stick profile).

Fix: per-binding previous-value tracking (pad + joystick paths); the
outside->inside transition writes 0 exactly once, then the centered
axis leaves the channel to other inputs (composition preserved; slew
bindings excluded by design -- a lever stays where slewed).  Plus the
#24 rule, axis edition: a pad/stick that DIES while deflected releases
its direct channels (throttle keeps the last lever position -- safer
than an all-stop mid-fight).

Verified: build + 45s glass mission with an idle XInput pad (no
spurious writes, no regression).  The deflect-release cycle itself
needs a human hand -- awaiting RajelAran's next session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:40:27 -05:00
arcattackandClaude Opus 4.8 c2ee7299b2 Generic joystick / HOTAS / pedals support: DirectInput 8 layer + capture wizard
Tester ask: configure non-Xbox controllers.  New L4JOY layer (DI8):
enumerates every non-XInput game device (up to 4), DIJOYSTATE2 polling
with range normalization, reacquire-on-loss, 3s hot-plug re-enum.
XInput devices are excluded via the documented RawInput IG_ VID/PID
check (live-verified skipping a real XBOX360 pad -- no double-feed).

bindings.txt grows joydev/joyaxis/joybutton/joyhat rows (device slots
by name substring or ordinal; axes X..RZ/SL0/SL1 with invert/slew/
deadzone; hats as 4-direction buttons with diagonal chords).  PadRIO
runs them through the existing channel/event machinery -- per-binding
edge arrays release automatically on detach (the #24 rule), and a
direct throttle axis maps full lever travel onto the 0..1 channel
while the gait detent still snaps a lever parked in the walk/run dead
band.

BT_JOYCONFIG=1 (joyconfig.bat) runs a console capture wizard: move
each control when prompted (stick/twist/throttle/fire), invert derived
from the move direction, hat look cluster auto-added, output written
between markers in bindings.txt with the rest of the file preserved.

Legacy L4DINPUT DIJoystick (L4CONTROLS=DIJOYSTICK) untouched.
Verified: grammar accept/reject per line, XInput-exclusion live,
30s in-mission no-device polling clean.  Awaiting a tester with real
stick hardware for axis verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:36:32 -05:00
arcattackandClaude Opus 4.8 a8a6da6760 Solo mission clock: end the mission when the game length expires (port shim)
Field 2026-07-23: a 20-minute solo mission was still running at 53
minutes.  In the arcade the OPERATOR CONSOLE owned the game clock and
sent StopMission at zero; the engine only computes the HUD countdown
(secondsRemainingInGame) -- nothing consumes it.  The desktop FE-solo
mode has no console, so timed solo missions ran forever.

Shim at the countdown site (Application::Execute): when no
console/relay owns the session (BT_RELAY unset), post the console's
own StopMissionMessage when the clock runs out -- the normal end flow
(EndingMission -> review -> FE relaunch) takes over.  length=0 (raw
solo) stays endless; console-driven sessions untouched.  One-shot
latch resets outside RunningMission.

Awaiting live verification (a 60s-length solo egg must end itself).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:00:41 -05:00
arcattackandClaude Opus 4.8 a999e5c49f Audio-dropout fix: OpenAL source-pool lifecycle (uninit aliasing, atomic-delete leak, stranded partials)
Field report: audio cutting in and out toward the end of the match.
Three structural defects in the port's source-pool lifecycle:

1. SourceSet.sources[] was uninitialized heap garbage until first
   acquisition -- AL names are small ints, so a recycled-heap slot could
   alias a LIVE source owned by another sound; alIsSource() skipped
   generation, two sounds shared one source, and whichever released
   first deleted the other's mid-play.  SourceSet ctor now zero-inits.
2. ReleaseSourceSet's bulk alDeleteSources(count, sources) is ATOMIC on
   invalid names (AL spec) -- one empty/-1 slot and NOTHING deleted;
   after the first pool exhaustion every partial release leaked and the
   pool never recovered.  Now per-slot guarded delete, slots parked at 0.
3. Failed acquisitions stranded partial sets forever (dropped transients
   are never released by anything).  ReleaseChannels() handback at both
   failure sites (StartRequest + dormant-resume) + a dtor backstop.

Verified: 2-node MP smoke (mission + lobby-loop relaunch clean, census
live/acquireFails 0/0) + 100s solo combat smoke (52 audio triggers,
zero AL errors).  Awaiting live playtest confirmation at scale.
KB: context/wintesla-port.md audio-dropout section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:23:07 -05:00
arcattackandClaude Opus 4.8 cbebb0f1c1 Audio source-pool census + fix the unbuilt jam-log literal
Field report (playtest night + operator): audio cutting in and out
toward the end of the match -- the classic shape of source-pool
pressure (sources release only at entity teardown; long matches
accumulate looping occupants like wreck burn/smoke until transients
lose the voice fight; OpenAL mixing capacity is finite).  L4AUDRND now
tracks live/deleted/failed sources: a 30s '[audio] source census' line
plus an ALWAYS-logged line on every acquisition failure (the smoking
gun).  Soak test next to confirm the leak/occupation slope.

Also: the ffa3933 jam-entry log had a raw newline in a string literal
(committed unbuilt -- process violation, caught by this build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:59:39 -05:00
arcattackandClaude Opus 4.8 12a1fea823 Score replication: peers' scores now reach every pod (rank fix)
Field report (playtest night, voice channel): everyone's HUD showed
them in 1st place.  Root cause: the engine's score-replication
machinery was complete -- Player::WriteUpdateRecord ships currentScore,
the replicant ReadUpdateRecord applies it, CalcRanking sums every
scoring player -- but nothing marked the player's update record dirty
on a score change (the engine only ForceUpdates at mission end for the
fade sync).  Every peer's replicant player therefore stayed at score 0
and each machine ranked its own (only-nonzero) player first.

Fix: ForceUpdate() after every score application in the BTPlayer
handlers (main, inflicted, and the severed-vehicle branch) -- the
existing update-record path does the rest.

Verified 2-node (force-damage kill scoring): the victim's machine ranks
the killer's REPLICANT at its replicated score (24) above its own
master (0); standings consistent on both nodes.  BT_RANK_LOG=1 kept as
the 1 Hz per-player ranking dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:39:19 -05:00
arcattackandClaude Opus 4.8 c8cba8c764 Connection audit: every connect/disconnect scenario handled + verified
Systematic actor-death x lifecycle-phase sweep (operator request after
the WaitingForEgg zombie).  New handling, all live-verified:

1. PRE-EGG console-pad loss (the real zombie phase): before the egg the
   pod's only link is the console pad -- the game socket doesn't exist,
   so the earlier RelayGameDown hook could never fire (the verification
   run itself caught this).  Detection now lives in
   HostDisconnectedMessageHandler ConsoleHostType (relayMode +
   pre-scene -> BTRelayRejoinNow); mesh keeps 1995 behavior.
2. MID-MISSION relay loss: the STOP is relay-sent (1995: the console
   owned the clock), so a pod losing its relay mid-match played a
   peer-less FOREVER-mission.  RelayGameDown (scene presented) now
   posts StopMissionMessage at +15s -> normal end -> lobby relaunch.
3. POST-RELEASE pod death stalled the round (survivors wait forever on
   the dead peer's connection gate): relay _abort_round -> route -11
   REJOIN to survivors -> everyone re-seats in seconds (reset-first
   ordering makes the drop cascade re-trigger-proof).
4. BTRelayRejoinNow carries BT_SEAT_CLAIM (mirrored tag): without it
   the rejoiner's own reclaim-hold blocked assignment -> ROSTER FULL
   loop for the 90s window (found by verification round 2).
5. Beacon NAT keepalive (SIO_KEEPALIVE_VALS 60s/10s) so long
   between-rounds waits can't be silently unseated.
6. BT_LOG_APPEND=1 preserves the log across lobby-loop generations
   (truncation erased round-1 verification evidence).

Verified non-issues: relay pods never bind the -net listener (no
double-launch collision on the internet path); mesh bind-fail exits
cleanly.  Full matrix + evidence: context/multiplayer.md;
scratchpad/audit_verify.sh is the repeatable rig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:23:23 -05:00
arcattackandClaude Opus 4.8 56ca2f5c13 Mission loads 72s -> 4s: the wait screen was GPU-syncing every idle frame
The operator challenged the "inevitable ~minute" load (Task Manager
showed idle CPU) and was right.  Measured chain:

- A/B same box/egg: dev 7s, +glass 30s, +BT_DEV_GAUGES 72s+.
- Drain census: identical ~560-event make streams; 190 events/s in dev
  vs a metronomic 7/s in the console shape -- the loop, not the events.
- Stack sampling: 4/6 samples inside ExecuteIdle's wait-screen paint --
  GDI GetDC/FillRect on the D3DPOOL_DEFAULT offscreen surface forces a
  full GPU pipeline sync (~140ms/frame at glass window size).  The
  2026-07-22 flicker fix traded a cosmetic bug for a 25x load throttle,
  worst on the operator box (bigger window + gauge-window GPU work),
  with idle CPU because a GPU-sync stall isn't compute.

Fix (ExecuteIdle): the staging surface is D3DPOOL_SYSTEMMEM (GetDC is
pure CPU; survives Reset for free) uploaded via UpdateSurface, and a
change gate skips the whole idle frame unless the spinner phase
(12.5Hz) or state text changed -- between paints the loop runs free.

Result: console-shape load 72s+ -> 4.1s (BEGIN -> READY), drain at 345
events/s; every pod benefits (same code), and the all-ready launch gate
now waits on a seconds-scale slowest loader.  Permanent pre-run
diagnostics kept: the drain census + >50ms slow-event lines (EVENT.cpp,
stand down at scene-live; slow-event identity captured BEFORE Process
-- Receiver::Receive(Event*) deletes the event).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:51:14 -05:00
arcattackandClaude Opus 4.8 76eaddc4a8 Issue #25: composite Turn channel -- twin-stick gamepad turning
Gamepad players expect stick X = turn; the pod-faithful default maps it
to torso twist, and the pedal pair couldn't take a single signed axis.
New bindings channel "Turn": a signed composite that decomposes into
the pedal pair at the RIO publish (+ = right pedal, - = left), ADDING
to direct pedal bindings so mixed rigs compose, clamped 0..1 per pedal.
The default bindings.txt stays pod-faithful and documents the
twin-stick alternative inline:

    padaxis LX axis Turn
    padaxis RX axis JoystickX

Also covers the HOTAS ask (any signed axis can now drive turning).
Verified: regenerated defaults carry the doc lines; a twin-stick-edited
bindings.txt parses and boots clean.  Awaiting live pad verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:55:45 -05:00
arcattackandClaude Opus 4.8 4e0fcbf328 Issue #26: runtime master volume (-/= keys, persisted)
The game audio plays hot with no in-game control -- testers couldn't
hear voice chat without the Windows mixer (playtest night).  The -/=
keys now step the OpenAL listener gain (the master scale every source
inherits) in 5% steps, clamped 0..150%, edge-detected in the per-frame
poll with a foreground guard; the value persists to content\volume.cfg
and reloads at boot (BT_AUDIO_VOLUME env still wins when set; default
stays 0.6).  README updated.

Verified: volume.cfg 0.25 -> boot log "master gain=0.25"; key stepping
needs a live focused session (awaiting live verification).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:53:07 -05:00
arcattackandClaude Opus 4.8 44f7c39ee1 Issue #24: release held pad buttons on controller disconnect (look latch)
XInput button emission is edge-based against previousPadButtons; the
disconnect path zeroed that memory WITHOUT emitting release edges, so
any button held when a controller (wrapper) died stayed pressed
game-side forever -- and the zeroing also erased what a reconnect would
have released.  Field signature (playtest night): a PS3-pad-in-xbox-
mode wrapper died mid-hat-hold -> the look-view machine (hold-based,
mppr) latched a side view with the authored look pitch ("stuck side
view + pitch at ground, no recovery"); the keyboard-only run was clean.

On the connected->disconnected transition PadRIO now emits
EmitButton(address, 0) for every binding whose mask was held, then
forgets.  Not reproducible headless (needs a real hardware unplug
mid-hold) -- awaiting the reporter's controller for live verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:49:41 -05:00
arcattackandClaude Opus 4.8 8368163b04 Lobby loop: auto-rejoin between rounds + seat reclaim + live mission edits
The between-rounds arcade flow (first-playtest-night field request): at
mission end every pod exited, seats dropped, and every round required
everyone to re-run join.bat with no way to adjust the mission.

1. AUTO-REJOIN: StopMission (operator END / mission clock -- NOT a
   window close) sets gBTMissionStoppedByConsole; after the matchlog
   upload btl4main relaunches the pod into the join wait with the same
   cmdline (BT_CALLSIGN/BT_MECH ride the inherited environment).

2. SEAT RECLAIM: the assigned tag is mirrored file-scope
   (BTRelaySelfTag) and re-presented as the 3rd SEAT_REQUEST payload
   field (BT_SEAT_CLAIM); the relay holds a dropped seat for its tag
   for 90s and a returning player gets their EXACT seat back -- static
   ordering across rounds, the real-pod model.  Non-claim joiners skip
   reclaim-held seats.

3. LIVE MISSION EDITS: the console gains "Apply mission settings"
   (enabled while a session runs) writing arena/time/weather/length
   into the session egg; the relay re-reads the file at round reset and
   egg release (roster shape locked mid-session).

Verified 2-pod 3-round: pods self-relaunched after each clock end; the
SECOND-to-rejoin still reclaimed its original seat (ordering held); the
between-rounds edit landed ("mission length now 75s").  Known edge
noted in the KB: a stale ready flag from a not-yet-exited old conn can
satisfy the launch gate mid-rejoin -- operators launch on green dots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:28:56 -05:00
arcattackandClaude Opus 4.8 7b3e76db38 Load-time attribution: the minute is the renderers' entity-make streams
Measured (loadphase markers + queue blocker dump): renderer LoadMission
31ms, model loads 0.2s -- the teal->green minute is the LoadingMission
ready-gate (IsPriorityEmpty counts timed events too) waiting out the
THREE renderers' entity-make streams: every entity/subsystem creation
posts message 3 to each Renderer receiver (classIDs 8/30/36 =
video/audio/gauge), hundreds deep per queue, draining across the whole
window (the audio renderer's share is why sound starts mid-load).

Instrumentation kept: [loadphase] markers (renderer span + busy-pass
counter) always on; GeneralEventQueue::DumpBlockers + the class-named
DumpData fallback behind BT_LOAD_DIAG=1 (hundreds of lines per load).
Optimizing the drain (batching/budget) is an open perf item --
deliberately untouched before the 8-player night.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:41:36 -05:00
arcattackandClaude Opus 4.8 0ef5a3c4e1 Load-phase markers: attribute the teal->green minute
The measured 58s uncontended load has ~0.2s of model loads -- the rest
is unattributed.  Stamp the renderer LoadMission span and count the
CheckLoad passes that find the event queue still busy (each delays the
ready transition by its 1s re-post), so the next live run names the
cost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:28:01 -05:00
arcattackandClaude Opus 4.8 54fd4a4ab0 Timestamps for the launch-chain logs (operator request)
Every relay/console print now carries a wall-clock prefix (_StampedOut
stdout wrapper; the GUI monitor regexes use search() so the prefix is
transparent), and the pod's READY/pend lines carry wall time + tick --
post-mortems measure delays instead of guessing (the 3m40s load
reconstruction needed heartbeat-counting archaeology).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:10:38 -05:00
arcattackandClaude Opus 4.8 9bec4b2050 READY light: pods report load-complete to the operator roster
The roster showed "registered" for a still-loading pod and a ready one
alike -- with multi-minute contended loads the operator couldn't tell
who they'd be waiting on.  When the app ladder reaches WaitingForLaunch
the pod sends one empty route -10 frame on its live relay game socket
(BTRelayNotifyReady; socket mirrored at HELLO); the relay announces
SEAT n READY (k/m loaded) and the console GUI lights the seat
bright-green with a "N pod(s) LOADED+READY" banner.

Verified LIVE by the operator: blue (seated) -> teal (registered) ->
green (ready) in order, twice.  Also resolved the load-speed mystery:
loads are ~30s uncontended; the 1-2+ minute cases were assistant
background test runs competing with the user's interactive sessions on
the same machine (memory note added -- no game-spawning tests while the
user is testing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:57:03 -05:00
arcattackandClaude Opus 4.8 53c86fa1dd Launch pend counter: no timeout, no queue entry (mid-load launch crash 2)
The take-1 deferral (re-post at +1s, capped at 120) turned a SLOW load
into a crash: loads on a busy operator box measure 1-2+ minutes, the
cap expired, and the original "Not ready to run" Fail/abort fired
(field report: sound after ~1 min, crash ~2 min later; the log shows
exactly 120 deferrals all in LoadingMission).

A mid-load RunMission now just bumps gBTRunMissionPendCount -- nothing
enters the event queue and there is NO timeout to outlive -- and
CheckLoadMessageHandler drains the counter immediately after advancing
LoadingMission -> WaitingForLaunch, re-posting that many stack
RunMissionMessages (the engine's own no-console launch pattern at the
same site, which also demonstrates Post spools stack messages safely).

Verified (two-round rig, round 1): both relay launches pended through
the load, fired at load completion, the requested mech spawned and the
scene went live.  Round reset + round-2 rejoin remain verified from the
prior run; why loads take 1-2+ min on a busy box is left as an open
perf question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:11:03 -05:00
arcattackandClaude Opus 4.8 3a9b35fc06 Round reset + device-creation retry + load-time pump (rejoin trilogy)
1. ROUND RESET (the "picked Hellbringer, got a Blackhawk" field bug):
   trim/prefs finalize the session egg for ONE round, and that was
   permanent for the relay's lifetime -- a rejoin after a mission or
   crash got the stale finalized egg and its new callsign/mech request
   was recorded but never applied.  _maybe_reset_round() (both drop
   paths) restores the original egg/roster/launch state once every pod
   is gone; live beacons re-key by tag position (conn.seat_tag).
   Verified two-round e2e: blkhawk mission -> exit -> RESET -> lok1
   request -> egg rewritten -> the Hellbringer spawned.

2. DEVICE-CREATION RETRY: CreateDevice(HARDWARE_VERTEXPROCESSING) fails
   transiently when a just-exited instance still holds the adapter
   (caught live; also the parked "exit 139" join crash).  The old
   double-failure path called PostQuitMessage(1) -- which does NOT stop
   execution -- and the NULL mDevice deref right after was the real
   crash.  Now: HW->SW retry with 500ms backoff and a 20s deadline,
   then a clean error box + ExitProcess.

3. LOAD-TIME PUMP: mission load is thousands of back-to-back
   synchronous model loads; the starved message pump ghosted the window
   "Not Responding" mid-load.  BTLoadPump() (hooked from
   d3d_OBJECT::LoadObject) pumps + repaints the wait screen at most
   every 150ms pre-run; no-op once the scene is live.

Also: round-reset originals captured after the roster parse (the first
cut crashed the relay constructor -- caught by the two-round test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:57:50 -05:00
arcattackandClaude Opus 4.8 638cfca18d Defer RunMission until the mission load is ready (silent launch crash)
The relay fires the RunMission pair a fixed 20s after the egg ACK -- but
the ACK happens at egg RECEIPT, and a long mission load (glass + dev
gauges on a busy box measured ~50s) is still in LoadingMission when the
launch lands.  L4Application::RunMissionMessageHandler's default case
Fail()ed on that state -- an abort() the 1995 code could afford because
the real console launched operator-paced -- which presented as a SILENT
crash at mission start (c0000409, no WER record; cdb stack:
ucrtbase!abort <- RunMissionMessageHandler <- RoutePacket; 3/3
reproducible; matches the field report's 464 crash that died
mid-LOD-load).

The default case now DEFERS: re-post the message to ourselves at +1s
(the VehicleDead re-post pattern) until the ladder reaches
WaitingForLaunch, bounded at 120 deferrals so a truly wedged load still
surfaces the original Fail.  Verified: the crashing console-shaped run
deferred 104x through its ~50s load, then launched, rendered (scene
LIVE) and ran clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:19:28 -05:00
arcattackandClaude Opus 4.8 66f22fb923 Wait screen: hard cutoff at first real scene present (handoff flicker)
The launch handoff (LoadingMission -> RunningMission) could interleave
the idle wait-overlay presents with the first real game frames (user
report: "the wait screen is still there when game graphics come up").
gBTSceneHasPresented latches at the render path's Present; ExecuteIdle
stands down permanently once set -- the overlay can never paint over
(or alternate with) live graphics.  Permanent one-shot/1Hz [waitscreen]
diagnostics record the active paint path, the scene-live moment, and
idle paints.

Verified: 70-frame 0.5s capture across the handoff -- 54 wait frames,
then game frames with at most ONE ambiguous frame at the cut (was
repeated interleaving); no black frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:49:40 -05:00
arcattackandClaude Opus 4.8 c421ec4af8 Wait screen: paint into the backbuffer, not after Present (load flicker)
The pre-mission overlay painted the window DC AFTER ExecuteIdle's
Present -- every cycle flashed the black D3D frame before the GDI
redraw, visible as flicker while the game loads (user report).  The
overlay now lands IN the presented frame: the backbuffer refuses GetDC
on this driver (D3DERR_INVALIDCALL without LOCKABLE_BACKBUFFER, not
worth forcing game-wide), so the text+spinner GDI-paint goes into a
cached offscreen plain surface (GetDC always legal there) and
StretchRects onto the backbuffer before Present.  D3DPOOL_DEFAULT cache
released at every device-Reset site; window-DC path remains for the
pre-device seat wait and as the fallback; one-shot [waitscreen] boot log
says which path is live.

Verified: 14 rapid PrintWindow captures of the waiting state all carry
the text (pre-fix runs alternated black frames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:33:41 -05:00
arcattackandClaude Opus 4.8 10eee05b20 Launch with whoever connects + live roster display in the console
The operator no longer has to match the roster row count to the exact
player count (or watch the "empty seats will STALL" warning).  Two
mechanisms:

1. PRESENCE BEACONS: the pod keeps its seat-request TCP connection open
   for the process lifetime; the relay reads a live beacon as "seated"
   and a pre-claim FIN frees the seat (no ghost seats -- a ghost stalls
   every pod at the connection gate).  Reservation expiry spares
   beaconed seats.

2. TRIM-ON-LAUNCH (manual/GUI mode): operator LAUNCH before the roster
   fills shrinks the session to the seats present -- egg [pilots]/pages
   trimmed, seat ids REMAPPED by position (pods re-derive their host id
   from their tag's roster position, so walk-up prefs and beacons re-key
   consistently), roster/expected_ids shrink, eggs release, the launch
   pair fires as ACKs land.

GUI: roster rows now update LIVE from the walk-up requests (SEAT n
PRESENT/FREED lines -> callsign/mech cells + a blue "seated" status
light), and LAUNCH MISSION enables from the first seated player with a
"starts with whoever is here" banner.

Verified 2-node: 2-of-4 trim ran the mission with the requested mechs;
and the mid-roster GAP case -- three joined, the middle player quit
pre-launch (beacon FIN freed the seat), trim remapped seat 4->3 with the
survivor's callsign/mech following, both survivors registered under the
remapped ids and ran the mission.  Also: stdin reader guarded against
sys.stdin=None spawn shapes (the GUI QProcess pipe is the supported
operator channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:40:47 -05:00
arcattackandClaude Opus 4.8 fcedc046cf Walk-up callsign/mech request: join menu -> relay -> the session egg
The 1995 front-desk conversation, over the internet.  join.bat/
join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim:
CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the
normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST
as {callsign NUL mech NUL} (empty payload = roster defaults, wire-
compatible), and the relay validates the mech tag, HOLDS egg delivery
until every pod's console pad is connected, rewrites the egg via
eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6
fallback) and streams it to all pads -- so every player's egg copy
carries every player's callsign.

The hold is gated on CONSOLE-PAD count, not game-side registration: a
pod HELLOs only after parsing the egg's roster, so a registration gate
deadlocks (hit live in the first run; pods animate in WAITING FOR
MISSION ASSIGNMENT during the hold).

Verified 2-node e2e (env-injected requests): thor/vulture requested over
roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote
both seats (relay log), the delivered egg carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both
requested mechs.  Join-menu layout screenshot-verified; the menu's
click-through relaunch awaits live human verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:58:51 -05:00
arcattackandClaude Opus 4.8 753540de96 MP match forensics: per-peer matchlog + relay auto-upload + matchcheck
For the 8-player playtest.  Damage authority is VICTIM-side, so no single
peer sees the whole match: every -net instance now writes a compact
machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net;
BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode
pod dials the relay game port (the seat-request throwaway-dial pattern,
envelope route -9) and streams the file back -- the relay saves every
peer's copy under matchlogs/ on the operator's machine, so testers send
nothing by hand.  tools/matchcheck.py <dir> reconciles all of them:
damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG,
victim-side authoritative), kill/death replication across observers,
PLAYER_DEAD counts, scores, version skew, replicant-application and
unattributed-damage anomalies, mid-mission disconnects.

Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG,
post-base, zone + post-application damageLevel), MechWeapon::
SendDamageMessage (FIRE), projectile impact/splash/collision dispatch
(PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at
MovementMode 9, NOT the transition: a replicant arrives at 9 straight
from the type-6 record header and never runs the transition branch),
BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone
crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host
handlers (PEER_UP/PEER_DOWN).  Every line t=/w=/st=-stamped + fflushed.

Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same
amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run
logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn
cycles victim-side and the kill SCOREs shooter-side; relay-mode run
auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck
produced the full report with exactly one (correct) anomaly -- the force
hook's claim-less damage, the unattributed-damage detector working.
Relay gained a route-specific 8MB cap for the upload frame (game frames
stay capped at 1600).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:22:10 -05:00
arcattackandClaude Opus 4.8 f0918c66b0 Join wait: animate through EVERY pre-mission state
Two follow-ups to the waiting-screen work, both user-reported:

1. "Animation frozen after connecting": once the console seats the player
   and sends the egg, the state advances LoadingMission -> WaitingForLaunch
   -- where NOTHING presents until the operator's RunMission, so the last
   idle frame froze on screen with the mission audio already running.
   ExecuteForeground now keeps the idle clear + overlay alive through
   LoadingMission/WaitingForLaunch/LaunchingMission (no early return -- the
   load/replication work continues), and ExecuteIdle picks per-state text:
     WaitingForEgg    "WAITING FOR MISSION ASSIGNMENT"
     Loading/Launching "LOADING MISSION / stand by"
     WaitingForLaunch "MISSION READY / waiting for the operator to launch"

2. The roster-full retry pause was a raw Sleep(3000) -- with the relay up
   and answering SEAT_FULL instantly, the process spent ~95% of its time
   unpumped (IsHungAppWindow TRUE, spinner frozen).  Now 30 pumped 100ms
   slices with the reason on screen ("session is FULL -- waiting for a
   seat to free up").

Verified live against the running operator relay: roster-full wait shows
the reason text, spinner advances across 500ms captures, IsHungAppWindow
false on every sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:40:55 -05:00
arcattackandClaude Opus 4.8 a1c9a71898 Join wait: non-blocking dial -- the spinner no longer stutters
User-reported after the waiting-screen fix: "the animation keeps hanging."
Cause: RelayDialTcp used a BLOCKING connect() -- against a down/unreachable
operator each attempt froze the window thread for the OS SYN-retry window
(seconds), so the spinner ran only during the 2s sleep between attempts.

RelayDialTcp now connects NON-blocking and select()s in 100ms slices,
pumping the message loop + repainting the waiting screen every slice (both
in-flight and between retry attempts).  The success path already returned a
non-blocking socket (FIONBIO), so caller semantics are unchanged; the other
dial sites (console dial, game-socket dial) inherit the smooth behavior.

Verified: 4 window captures at 400ms intervals during a dead-host dial all
differ in the spinner region (uniform diff energy == continuous animation)
and IsHungAppWindow reads false on every sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:24:30 -05:00
arcattackandClaude Opus 4.8 f5e0ffcd14 Join wait: animated waiting screen instead of "Not Responding"
The relay seat-wait loop (RelayRequestSeat -- the join/join_lan "patient
walk-up") blocked the window thread's message pump for its whole 30-minute
patience window: dial (2s timeout) -> Sleep(2000) -> repeat, with status
going only to the parent bat console.  Windows flagged the frozen game
window "Not Responding" until the operator started the session
(user-reported).

- BTWaitScreenPaint (L4VIDEO.cpp): a GDI waiting screen on the main window
  -- headline + detail line in the console green + a 12-segment marching
  spinner phased on the tick clock.  Pure GDI: flicker-free during the
  seat wait (no D3D presenting yet), and re-painted after each
  ExecuteIdle Present so the WaitingForEgg phase (previously a bare black
  frame) shows "WAITING FOR MISSION ASSIGNMENT" too.
- RelayWaitTick (L4NET.CPP): PeekMessage pump + paint.  The outer dial
  loop's Sleep(2000) becomes 20 pumped 100ms slices ("WAITING FOR THE
  OPERATOR'S SESSION" / "session at <ip:port> -- close this window to
  cancel"); the inner seat-reply wait pumps per 200ms select slice.

Verified live against a dead relay address: window-only PrintWindow capture
shows the animated screen, and user32 IsHungAppWindow (the API behind "Not
Responding") reports FALSE throughout the wait.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:16:13 -05:00