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>
Night-2 capture: one flapping pod cascaded 3 ROUND ABORTs in 8s; every
client relaunch-churned (~1 gen/s) until the D3D device-creation 20s
deadline fired on every machine ('could not connect direct 3d'), and
claim-less restarts minted duplicate roster seats.
Console (relay):
- Round abort clears stale console-conn ACK flags and HOLDS the next
egg release for an 8s settle window (_tick_settle re-releases when
quiet) -- the abort->instant-re-release->drop->abort cycle is broken.
- HELLO from a stale identity gets a REJOIN reply (identity resync via
the seat-request path, 5s/IP rate limit) instead of a bare drop.
- STATIC SEATS: seats remember (IP + callsign); a claim-less restart
from the same machine+name RECLAIMS its own seat (displacing its
zombie beacon) instead of minting a duplicate -- the pod contract:
same box, same seat, always.
- Operator local instances log with BT_LOG_APPEND (the night-2 crash
loop left a 2-line file; generations now leave a full trail).
Client:
- Relaunch storm damper: a generation that lived <15s sleeps 5s before
respawning (storm decays to a gentle cadence; normal multi-minute
round relaunches untouched; menu clicks explicitly never delayed).
Rig-verified: normal round + mid-MISSION drop = no abort (correct);
mid-LOAD kill = exactly ONE abort + 8s hold + seat reclaimed + no
cascade. The night-2 'host 7' identity-derivation subtlety remains
under forensics (append logs + first-breath line will capture it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field 2026-07-23 (second night): a player's join.bat failed 3x in a row
with a 0-byte join.log -- indistinguishable from 'never ran'. Two
causes of evidence loss fixed:
1. The first flushed log line now happens the INSTANT the log opens
(version, pid, args, first-process vs relaunched-generation). An
empty log after this build means the exe was killed before WinMain
-- an external cause (antivirus/loader), not ours.
2. The menu->game relaunch chain truncated join.log each generation
(BT_LOG_APPEND was unset in the player bats): the crashed
generation's evidence was erased by the next attempt. The bats now
delete the log once per bat run and append across generations.
Also explains the confusing 'The game has exited' bat message on
SUCCESSFUL launches (the menu process exits after relaunching the game
detached) -- the log now records the whole chain.
The 1-5-50-0 cycle's closed detent one press past max is what cooked
both SRM6s in the 2026-07-23 field session -- players need the warning
and the shared-supply note.
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>
Measured A/B/C (Blackhawk, sustained autofire, 150s soaks): SRM6 on
Condenser2 plateaus ~531 and COOLS at share 0.91 (boost 2), climbs to
the jam band at balanced (0.167), and runs at the 2000 failure line
starved (0.018). MoveValve reruns the redistribute every press;
veteran passes the novice guard. The field 'boosted loop 2 still
cooked' therefore means the valve was NOT at the 50 detent during the
fight -- the 1->5->50->0 cycle turns the loop OFF one press past max.
MoveValve presses now log always-on ('[valve] <name> -> detent N' +
a CLOSED warning) so the next field session records the detents.
Also: solo mission clock verified live (60s egg self-ends,
'[mission] solo game clock expired' -> RunMissions returns).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Field capture 2026-07-23: right SRM6 tripped FailureHeat and went dark
with no indication -- reported as a suspected bug. Decomp @004c9a38:
the gauge cluster's only weapon-state read is ==5 (jam lamp); state 7
has no presentation anywhere. EJECT tap revives it while rounds
remain. (Same session: first live verification of the #31 eject
wiring -- jammed left SRM6 cleared by a tap.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Disasm-faithful transcription of the export-gap handler onto
ProjectileWeapon (MESSAGE_ENTRY id 0xb, the #19 pattern). PRESS arms
the ~3s UpdateEject countdown (bin alarm -> Ejecting(3); gate 2 parks
the weapon offline mid-eject -- authentic). HOLD to completion =
DumpAmmo jettisons the bay -> NoAmmo. TAP (release early) cycles ONE
round out via FeedAmmo and returns the weapon to Loading -- clears a
jam at the cost of a round (and recovers a FailureHeat 7 while rounds
remain). Binary structure kept exactly incl. the press-no-bin
fall-through into the release block.
AmmoBin: Ejecting(3)/Ejected(4) alarm levels decoded + named
SetAmmoState accessor (databinding rule). Always-on forensics:
'[weap] EJECT tap' + '[ammo] bay DUMPED overboard (N rounds)'.
Rig-verified live (BT_EJECTTEST=1 / =hold on LRM15): taps eject one
round each and return the weapon to service (15->8 across cycles);
hold dumps all 16 -> NoAmmo. Awaiting a human press of the actual
ENG-page button on a jammed weapon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Old-timer testimony (tap = eject one round, hold = eject the bay, eject
clears a jam) checked against the binary: raw disasm of the export-gap
handler @004bb9b8 confirms it byte-for-byte. PRESS arms the ~3s
UpdateEject countdown (hold-to-completion = DumpAmmo whole bay ->
NoAmmo); RELEASE before completion (TAP) ejects ONE round (@004bd4f4)
and, with ammo remaining, sets weaponAlarm 3 + restarts the reload --
the weapon returns to service. A tap even recovers a FailureHeat
level-7 launcher while rounds remain (empty-bin gate 2 re-pins 7 only
when the bay is dry).
The earlier 'jams are mission-permanent / no unjam exists' claim read
only the HOLD path -- corrected + swept: projweap.cpp case-5/jam-log/
CheckForJam comments, players/README.txt, decomp-reference.md
(message-tables entry), open-questions.md (state-7 recovery note).
Gitea #31 updated; wiring the handler now restores the pod's actual
jam-recovery mechanic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
HandleMessage @004bcabc: message 2 -> SetLevel(weaponAlarm, 5) -- a
malfunction INDUCER, not an unjam; the state-machine comment had it
backwards. There is NO in-mission unjam in the binary (jams clear only
at ResetToInitialState = mission reset/respawn), and the EJECT cycle is
the anti-cook-off ammo jettison (DumpAmmo -> NoAmmo), not an unjam.
The old 'unjam delay' follow-up suggestion is retired as unauthentic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live verification closed the loop on issue #30: heat-jammed SRMs light
the authentic BTEJAM lamp on the weapon panel's ENG DATA page; the
default view shows nothing (authentic). Testers get told where to look.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Solo field report: both SRM launchers went inert with ammo remaining and
the default log could not answer whether they jammed or heat-failed --
every weapon-state transition log was gated behind BT_AMMO_LOG. The
edges are rare and mission-permanent, so they now log unconditionally:
[weap] JAMMED with temperature/alarm evidence at the CheckForJam hit,
and both NoAmmo edge logs (gate 1 destroyed/failHeat/disabled, gate 2
dry bin) ungated. The missing MFD presentation for these states is
issue #30.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
Field report (2026-07-23): with open seats the roster stays blue until
LAUNCH -- correct (loading cannot start before the roster is finalized,
and the LAUNCH press is what finalizes a partial roster) -- but the
operator lost all visibility into the post-launch staging, and worse,
the LAUNCH button's re-arm rode the "WAITING FOR OPERATOR" line that
only prints on a FULL-roster ACK, so in partial mode the button stayed
dead after round 1.
- The relay's 10s "launch HELD -- still loading: PLAYER n (k/m ready)"
lines now surface as the status banner ("STAGING -- PLAYER 2 still
loading (1/2 ready)") while the per-seat lights walk teal->green.
- "round RESET" re-arms the console (launched/ready/held cleared) so
every round's LAUNCH works regardless of roster fullness.
- The standby banner explains the mode: seated players are STANDING BY,
LAUNCH starts their loading, the mission fires itself at all-green.
- The seated count includes registered/ready players (it undercounted
once pods advanced past blue).
Monitor states unit-tested (held -> launch -> clear -> reset -> re-arm).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playtest matchlog forensics: across 2,591 applied-damage events in two
rounds, Ballistic (type 1) damage NEVER appeared -- Collision/Explosive/
Laser/Energy only -- despite 136 projectile impacts and autocannon/Gauss
mechs. The weapon parses its authentic damageData.damageType correctly
(mechweap.cpp:316 maps "BallisticDamage" -> 1), but BTPushProjectile
never carried the type and every projectile-impact site in mech4.cpp
hardcoded ExplosiveDamageType. So autocannon/Gauss rounds were scaled
against armor by damageScale[Explosive] instead of damageScale[Ballistic]
(the model indexes a distinct scale cell per damage type) -- every
ballistic weapon did the wrong damage all night.
Lasers/PPCs were unaffected (their SendDamageMessage path carries the
weapon's own damageData); missiles happen to BE Explosive so only
autocannon & Gauss were mis-scaled.
BTProjectile gains a damageType field, threaded from the firing weapon
(damageData.damageType) at launch through to the impact dispatch;
missiles resolve Explosive from their own streamed type, autocannon/
Gauss now resolve Ballistic. The missile-splash template stays
Explosive. Enum is anonymous so the field casts to Enumeration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
ROOT CAUSE [T1]: Mech::Reset's subsystem sweep (@0049fb74 loop-2) calls
vtable slot +0x28, which the DATA-section vtables prove is
ResetToInitialState (0050f9d8/00511d2c/00512078/0051242c all carry the
class RTIS at +0x28) -- our reconstruction mislabeled it DeathReset,
the EMPTY engine base virtual, so the whole per-subsystem reset was a
silent no-op ("ammo and weapons do not reset on respawn", playtest
night; matchlogs: 3 of 7 players fired zero projectiles after their
first death).
SECOND LAYER: the binary never refilled ammo at all -- its respawn
severed the vehicle and DropZoneReply built a NEW mech (fresh ctor =
full bins, pristine subsystems: the pod behavior testers remember).
Our port deliberately reuses the entity (the new-mech path was the
two-mech/camera-glitch family), so Reset now EMULATES ctor-freshness:
- the sweep starts at i=2 (binary-faithful: skips mapper + voltage
bus) and dispatches the engine DeathReset virtual as the vehicle;
per-class overrides chain each class's own authentic slot-10 RTIS
body (the recon RTIS namesakes are statically bound -- no common
virtual): MechWeapon @004b96ec, Emitter @004ba4d0 (charge cycle
restarts from Loading), ProjectileWeapon @004bbaf8 (unjams).
- MechSubsystem::RespawnRepair (PORT): heals the subsystem's PRIVATE
crit damage zone + status alarm -- the mech-zone heal never touched
those, which kept destroyed weapons dead across respawns.
- AmmoBin::DeathReset (PORT): refills to initialAmmoCount (@0x220, the
ctor value) + Loaded alarm + idle feed + cook-off disarmed;
ResetToInitialState stays authentically EMPTY per AMMOBIN.TCP.
Verified (2-node force-damage kill/respawn rig, BT_DEATH_LOG): every
respawn logs [respawn] ammo bin refills for both bins to streamed
capacity across 4 death cycles; mission un-regressed. KB corrected +
swept (multiplayer.md task #52 notes). Awaiting live tester
verification of depleted-bin refill + revived weapons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Live finding (round 3): firing RunMission while a pod is still loading
drowns that pod's load in the running mission's update streams -- the
same event queue serves both, so the load starves (the operator, always
last to join via the mech menu, loaded 5+ minutes or crashed while
testers took ~20s; solo loads on the same box take ~60s). The launch
timer now also waits for every registered pod's READY (route -10) so
everyone enters together, 1995-style; a held launch announces who's
still loading every 10s, and a 180s cap force-fires so a wedged pod
can't hold the night hostage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The game self-pins to core 0 by default; on the operator box core 0 is
shared with the console/relay/desktop, and the mission load drains
frame-paced -- a starved frame rate multiplied the operator's load time
to 3-5 min while internet testers loaded in ~20s (live finding, round
2). The local-instance launcher now spreads the game across the upper
cores unless BT_AFFINITY is explicitly set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When every pod disconnects at once (normal end of a multiplayer round),
a failed send inside the PEER_DOWN fanout recursively drops that peer
and mutates by_host mid-iteration -> RuntimeError -> THE RELAY DIES,
taking the round reset and the matchlog uploads with it (live crash,
first 3-player internet round). Iterate over snapshots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'SEAT 2 READY' for the first (only) player read as an off-by-one (field
report) -- host 1 is the CONSOLE in the authentic 1995 numbering, so
pods start at host 2. Human-facing lines now read PLAYER 1/2/... with
the host id in parentheses for cross-reference against the game[host=N]
wire lines; GUI monitor regexes updated (tag-keyed as before, verified
seated/ready/left in isolation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
silently replaced the WAITING-FOR-OPERATOR matcher (class-body last
assignment wins), breaking the LAUNCH button re-arm for back-to-back
missions. Renamed the old matcher RE_RELAY_WAITOP; both paths
regression-checked in isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Rows are capacity + per-seat defaults now -- joiners auto-populate them
live and launch trims to whoever is seated, so hand-managing rows is
busywork. A Seats spinner (1-8) maintains the row count in relay mode;
Add pilot / Remove selected remain for mesh mode, where explicit
addresses matter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
join out -- ROSTER FULL on every seat request, field report). Local
instances seat-request like every joiner now; trim-on-launch makes
exact seat ownership moot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The Launch-local-instances path pinned BT_SELF, which claims a seat
directly and SKIPS the seat request -- the join menu (and the callsign/
mech choice it carries) never ran, so a local test instance couldn't
exercise the walk-up flow. Relay-mode local instances now launch
zero-arg with BT_FE_JOIN=1: the JOIN-GAME menu opens, the relay assigns
the seat, and the choice lands in the egg like any join.bat player.
Each instance gets a distinct relaunch port via BT_FE_JOINPORT
(btl4main JoinRelay case; default 1501) -- two -net listeners can't
share a port on one box. Mesh-mode locals keep the direct path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Distributed test zips go stale and stray. BT_EXPIRE (CMake option,
DEFAULT ON so a zip can never accidentally ship without it) makes the
exe refuse to start BT_EXPIRE_DAYS (default 14) after its build day:
boot logs the remaining window, an expired build logs + shows a
"test build expired -- ask the operator for the current build" box and
exits. btversion.h now stamps BT_BUILD_UNIX (UTC-midnight-rounded so
the only-on-change header churns once per day, not every build) +
BT_BUILD_DATE. Dev is unaffected: every build re-stamps the day.
-DBT_EXPIRE=OFF builds a non-expiring exe; mkdist reads the cache and
marks such a zip "-noexpire" + prints a warning, and the tester README
expiry note is {EXPIRE}-marker-gated like the steam lines. Second
compile-gate exception after BT_STEAM (convention updated in
glass-cockpit.md). Quality gate, not DRM -- a clock rollback defeats it.
Verified live: default build logs "test build window: 14 day(s) left";
a -DBT_EXPIRE_DAYS=0 build logs TEST BUILD EXPIRED and parks on the
explanation box (window title checked) without starting the game;
restored to 14 and rebuilt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>