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>
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>
#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>
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>
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>
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>
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 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>
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>
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>
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>
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>
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>
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 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>
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>
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>
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>
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>
The user held the MFD PROGRAM button and nothing happened (weapons kept
firing). Diagnosis, verified end-to-end:
- The cockpit mouse handler DID register the click ([cockpit] CLICK addr=0x8
press) and 0x8 IS a streamed PROGRAM element (EVENT msg 0x9 -> weapon,
Mfd1 quad page mask).
- But every launch ran the DEV platform profile (no BT_PLATFORM) ->
L4CONTROLS=KEYBOARD -> no PadRIO instance -> PadRIO::SetScreenButton
no-ops (activeInstance NULL) -> the press never entered the RIO queue ->
ConfigureMappables (id 9) never dispatched -> the mode never flipped ->
fire keys kept firing. The config machinery itself was never broken.
- With BT_PLATFORM=glass (the GLASS profile: L4CONTROLS=PAD -> PadRIO), the
full chain now proves out headlessly:
[btntest] PRESS 0x8 -> [cfgmap] ENTER session on ERMLaser_1
mode 0x450421 -> 0x448421 (NonMapping 0x10000 -> Mapping 0x8000)
[btntest] RELEASE 0x8 -> [cfgmap] EXIT (mode restored)
Landed:
- mechweap.cpp: [cfgmap] BT_FIRE_LOG diagnostics in the real
ConfigureMappables/ChooseButton handlers (they were silent -- the G-key
harness had logs but the authentic button path had none).
- L4PADRIO.cpp: BT_BTNTEST="addr,pressPoll,releasePoll" scripted screen-
button harness through the REAL click seam (EmitButton -> RIO queue ->
manager drain -> buttonGroup mapping) for headless verification.
- play_solo.bat: prefer the glass build when present AND set
BT_PLATFORM=glass so PadRIO exists and cockpit clicks work.
Side finding (the user's keymap "flip-flop"): CONTROLS.MAP (btinput, DEV/pod
profile) and bindings.txt (PadRIO, GLASS profile) are two parallel binding
engines; which one runs depends on the platform profile, so the felt keymap
changes with the boot flavor. Unification pending (user decision).
Awaiting live verification of the full hold-PROGRAM + tap-fire regroup flow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playtest: "after I destroyed the mech the red blip was still on my radar." The
authentic engine adds/removes radar contacts through the interest feed
(GaugeRenderer::NotifyOfNewInterestingEntity / NotifyOfBecomingUninterestingEntity),
and the game decides what's a contact -- a dying mech becoming uninteresting is
removed, so the blip drops. The port DROPPED that feed and rebuilds the radar grid
every frame from EVERY dynamic mover (RebuildEntityGrid), so it never removed the
dead one and the red blip lingered on the frozen wreck (the wreck stays as an entity
by design; only its model sinks/buries).
Fix: in RebuildEntityGrid, skip a destroyed/disabled mover (GetSimulationState() ==
2 || 9 -- the movementMode wreck latch, unified with simulationState) so it leaves
the moving/red-contact grid. Live movers unaffected; the blip drops on death.
Verified (BT_MAP_LOG): the killed enemy (ent cls=0xbb9) is drawn ONCE at the
death-transition frame (the 1-frame lag before movementMode latches to 9) then never
again for the rest of the session, while the owner keeps drawing every cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Playtest feedback: the red MFD lamp buttons were obstructed (only a thin sliver
showed under the surface, crammed against the DISPLAY/PROGRAM legends), and the blue
THROTTLE/JOYSTICK flight blocks overlapped the bottom MFDs.
- Red lamps now sit FULLY OUTSIDE the MFD (kCkRedGap=3 off the edge, kCkRedH=24 tall)
instead of reaching kCkRedCell into the display -- nothing occludes them and they
read as real buttons, clear of the DISPLAY/PROGRAM legends. topBand grows to 223.
- Flight blocks drop below the MFD's bottom red lamps (belowLamp) -- no overlap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under BT_DEV_GAUGES the DEFAULT is now the pod-faithful cockpit surround: the 3D
world CENTERED with the six gauge surfaces composited AROUND it in the single main
window at 1/2 native scale, plus clickable RIO button lamps. Iterated as a visual
reference with the user, then landed in the engine.
- L4VB16.cpp/.h: BTCockpitLayout + BTCockpitComputeLayout (layout single source of
truth, computed from the backbuffer canvas); BTDrawCockpitPanels (band fill ->
button lamp quads -> 9-entry surface loop, phosphor-green mono / amber palette
sec, Eng-sibling slot map -> GDI-atlas flight labels; D3DSBT_ALL state-block
isolation = the floating-rocks trap); BTCockpitMouseDown/Up (client->bb hit-test
+ glass press/release/right-latch contract); BTApplyWorldViewport + DevGaugeDocked
cockpit branches; shared BTLampBrightnessOf inline. Hooked at the top of
BTDrawGaugeInset (before BT_SHOT, so shots include it).
- btl4main.cpp: glass preset (cockpit default, per-display panels opt-in); mode
resolution -> gBTGaugeCockpit with work-area-clamped window sizing (-res = view
size); WndProc WM_L/RBUTTON routing.
- L4VIDEO.cpp: BTWorldAspectOf cockpit branch (view rect on-screen aspect).
- L4VIDRND.cpp: CameraShipHUDRenderable::Render made viewport-relative.
Buttons = the L4GLASSWIN address banks x0.5 (Heat 0x2F, Mfd2 0x27, Comm 0x37, Mfd1
0x0F, Mfd3 0x07 red; radar rails 0x10-0x1B yellow; flight 0x38-0x47 blue, labeled).
Full rect is the hit target; the surface draws over it so only the lamp edge shows.
Precedence: BT_GLASS_PANELS=1 stands cockpit down > BT_COCKPIT=1 > _WINDOW/_DOCK
opt-out > cockpit default; BT_COCKPIT=0 = dock-bottom. Green tunable via
BT_COCKPIT_TINT. Renders in ALL builds; only the PadRIO click/lamp seam is
BT_GLASS-gated.
Verified (awaiting playtest): both build/ + build-glass/ compile (0 error C);
glass BT_SHOT matches the mockup; 500-click storm during a live mission survived
(Gitea #18 Receiver-gap fix holds); BT_COCKPIT=0 dock-bottom unregressed;
BT_GLASS_PANELS=1 stands cockpit down; pod build renders the panels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a per-display glass Engineering panel button (0x21 on an Eng page)
hard-crashed via a wild call through an uninitialised pointer (eip=cdcdcdcd
debug / 0x01048748 release), zero btl4 frames above Receiver::Receive.
Root cause is the dense message-handler-table gap hazard (reconstruction-
gotchas #11), the message-side twin of the attribute-table one -- NOT a /FORCE
unresolved external (the glass link log carried only the known-benign mech3.obj
CreateStreamedSubsystem set). Receiver::MessageHandlerSet::Find(id) returns
messageHandlers[id-1].entryHandler for any id <= entryCount with no populated-
check, and Receive() calls it when != NullHandler. The streamed Eng-page .CTL
dispatches subsystem msg id 3/0xb to whatever subsystem is shown; a weapon
(Emitter) registers only PoweredSubsystem 4-8 + MechWeapon 9-10, so id 3 is a
gap below entryCount 10. Build did new HandlerEntry[entryCount] and left gaps
uninitialised -> (this->*garbage)(msg). The 1995 binary has the identical non-
zeroing new[] (part_002.c Build) and survived only on fresh-heap-zero luck: a
weapon receiving id 3 was always meant to IGNORE it (id 3 = a Condenser/
Reservoir action in a different subsystem branch; the uniform Eng-page button
template makes buttons for unimplemented actions authentically inert). The new
per-display windows just made the id reachable live.
Fix (engine, class-wide, faithful): Build now null-inits every slot
(entryID=0/entryName=""/entryHandler=NullHandler) before copying inherited /
placing supplied, so a gap is deterministically NullHandler -> Receive drops it
(the authentic "Receiver ignores unhandled messages"), with an empty never-NULL
name so the name-based Find() strcmp can't deref a gap. Correct dispatch
contract, not a glass-path guard -> protects the whole dead-button class (#14).
Also lands the [glasswin] CLICK crash-forensics log (names the button before
dispatch).
Verified under cdb: click-soaked every MFD bank (Engineering/L+R Weapons/Heat/
Comm, ~130 clicks through Quad<->Eng page cycles) -> zero AVs, reconstructed
mapper preset selects still fire ([mode] preset (1,1)/(2,1)...); pod build +
solo mission un-regressed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One window per pod display (Heat/Engineering/Comm/Weapons x2/radar +
Flight Controls), each carrying its surface with its RIO button bank
arranged pod-faithfully; CPU-expanded surfaces, buttons under the
imagery. Runtime gate BT_GLASS_PANELS (=0 = the legacy single panel).
Merged on top of today's landed fix pile (audio-crash, experience,
parallax); the L4PADRIO overlap with the gait-detent commit resolved
preserving BOTH (his window refactor + our at-rest band snap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# context/glass-cockpit.md
PARALLAX (#16): the pick/fire ray was anchored at mech.y+5.0 (a port
improvisation) while the sight line ran from the eyepoint (y=6.23) --
two parallel rays whose constant offset grew into the reported low-miss
as range closed (measured ry +0.072 @50u -> +1.54 @2.7u). The decomp's
sight and pick share the eye origin (HudSimulation @4b7830 chain). Fix:
the viewpoint mech's cockpit eye owns the aim-camera publish in BOTH
views, origin = its own eye translation; leveling + deliberate elevation
untouched; chase view now converges to cockpit ballistics (V cannot
change where shots land). After: pick pinned to the crosshair (ry <=
5e-6) from 50u to point-blank; 26 laser + 8 missile center-mass hits at
3/4-screen. Awaiting the reporters' approach-test.
VERDICT INSTRUMENTATION (#4 closed authentic, #5 verdict posted):
BT_RANGE_LOG per-frame pick tracing + BTGroundRayHitExact analytic
cross-check (0/8000 arena fall-throughs; cavern 6/8400 single-frame
grazes -- the 'crazy sliding' is the authentic world-pick + 500 m/s
slide over depth discontinuities); BT_AUD_TAIL StopNote/fade timing +
BT_FIRE_PULSE single-shot driver (the energy 'buzz' is the AUTHORED
charging loop: crescendo through recharge, 1.309s authored release,
measured within one frame). CAVERN.EGG: solo cavern test egg.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The weekend's crash family root-caused under cdb: SourceSet.sources[5]
receives the AllExplosion death preset's 25 streamed voices --
RequestAudioChannels wrote 20 OpenAL source ids past the array, smashing
the neighbouring heap object (Release: a vtable overwritten with a source
id -> delayed silent AV at AudioControlEvent::Send; Debug: CRT heap-
corruption abort in the death Explosion's audio teardown). Explains all
three #12 crash flavors (solo enemy kill -- stack-confirmed; MP self-
death; MP peer PEER_DOWN cascade -- same generic teardown).
Fix: sources[] sized to AUDIO_SOURCESET_CAPACITY=25, static_assert
lockstep with MAX_PRESET_SAMPLES, + defence-in-depth clamps at the ctor
and acquisition sites. Soak: 26+ deaths across glass AND pod builds
under cdb, zero faults, full wreck lifecycle every time. Gotchas S21:
the fixed-array-vs-streamed-count overflow class + sweep note.
Awaiting human verification: the MP death-and-survive session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Break the desktop glass cockpit's secondary displays out of the single
combined pad panel + D3D gauge strip into ONE window per pod display, each
carrying that display's surface with its RIO button bank, arranged
pod-faithfully around the main view. New TU engine/MUNGA_L4/L4GLASSWIN.*
(BT_GLASS-only), selected by the -platform glass preset via the new runtime
gate BT_GLASS_PANELS (=0 falls back to the legacy single panel + dock).
- Surfaces are CPU-expanded from the shared gauge buffer
(SVGA16::ExpandPlaneToBGRA, no D3D) and StretchDIBits'd in -- the D3D
dev-composite path (dock / window / overlay) stands down while active.
- Buttons sit UNDER the imagery: big hidden click targets that reach into
the display, with only a small protruding edge showing as a lamp light.
Red MFD banks tile the top/bottom; radar rails run the sides + a bottom
row; a Flight Controls window hosts the no-display banks.
- Windows: Heat / Engineering / Comm / Left Weapons / Right Weapons / radar
+ Flight Controls; edge-to-edge (no in-client margin/title; OS caption
labels each).
- Fix blank surfaces: application/ghWnd are duplicate-defined and bind
non-deterministically under /FORCE, so the fresh L4GLASSWIN TU read NULL.
Reach the renderer/window via BTResolveGaugeRenderer/BTResolveMainWindow
defined in btl4main.cpp off the real btl4App/hWnd pointers. New gotcha:
reconstruction-gotchas.md S6 duplicate-GLOBAL corollary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Human re-test on the fresh build: zero-stop OK, but the lever could PARK inside
the walk/run hysteresis band (walkStrideLength@0x534 .. reverseSpeedMax@0x538)
where the gait SM has no stable state -- walk<->run hunt, EngineShiftFwd/Rev
retriggering ("mixed walk/run mode + repeated acceleration sample"). The
pod-build keyboard lever already snaps out of the band to the NEARER edge at
key-rest (mech4.cpp GAIT DETENT); the PadRIO slew channel lacked it.
Faithful port, not an invention: mech4.cpp publishes the band in lever units
every driven frame (gBTGaitDetentLo/Hi seam, same guards + the same +0.002
engage margin); L4PADRIO Poll() applies the identical nearer-edge snap to the
Throttle channel when the slew rests (no slew key held, no pad slew axis
deflected). Sweeping through the band while held stays continuous (the
authentic moving lever, one authentic shift).
Verified (DEV.EGG glass, graduated parks each ~0.10 of the sweep,
BT_GAIT_TRACE+BT_MPPR_TRACE): band [0.358026,0.50388) = demand 22.02-30.87;
in-band parks snap both directions (0.4137->lo, 0.465126->hi); no at-rest
sample in the band; exactly ONE walk->run clip (state 10) up and ONE run->walk
(state 14) down across the whole sweep; zero-stop unaffected; pod build
rebuilt + BT_AUTODRIVE smoke clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Human-verified (epilectrik, live): the panel is steady on the SAME monitor
as the game. Two root causes, both cured:
1. D3D-vs-GDI presentation contention -- the game's ~60Hz presents fought
the panel's GDI redraws on a shared display (single-monitor users had
no escape; moving the panel to another monitor cleaned it, the tell).
WS_EX_LAYERED + opaque SetLayeredWindowAttributes: DWM now composites
the panel independently of the D3D swap chain.
2. Sampling beat -- the 100ms repaint timer sampled the authentic RIO lamp
flash half-periods (125/250/500ms) into an irregular 'broken lamp'
strobe. Timer -> 62ms (~half the fastest half-period): flashing lamps
now blink at their even, authored tempo.
(Stacked on the earlier double-buffer/erase-suppression cleanup.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel repainted control-by-control straight onto the window DC on a
100ms timer. Compose to a memory bitmap + single BitBlt; erase suppressed.
NOT the whole fix: the reporter's flicker is monitor-dependent (clean on a
display without the game window) = D3D-vs-GDI presentation contention, plus
a 100ms-timer-vs-125ms-lamp-flash sampling beat -- directions filed on #13.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- hud.hpp/hud.cpp/CLASSMAP.md: the binary HUD AttributePointers table starts at
@5110b8 with a FULL id-3 FlickerRate record (the old transcription read it as a
label and shifted every offset one slot). Re-based member names/offsets:
rotationOfTorsoHorizontal@0x1DC (<- Torso+0x1D8 twist), limits@0x1E0/0x1E4,
speed@0x1E8, rangeToTarget@0x1EC, @0x1F0 unbound, Visible@0x1F4 (init 1),
Lock@0x1F8 (init 0), HotBoxVector P3D@0x1FC, ThreatVector P3D@0x208,
CompassHeading Scalar@0x214 (= yaw euler[0] + twist, HudSimulation :5676).
Cross-checked vs ctor @004b7f94 + HudSimulation @004b7830 (Lock rule writes
@0x1F8 :5622/:5633; range default 1200 + 500 m/s slide @0x1EC). SetCompassHeading
renamed SetThreatVector (@004b7810 writes @0x208 = the threat vector; no port
caller). No behavioral change: the reticle feeds via port globals and the CFG
never binds HUD attrs. Resolves the gauges-hud open question (Gitea #10 entry b).
- L4VIDEO.cpp: BT_SHOT capture moved AFTER BTDrawGaugeInset -- the 2026-07-18
reorder had left it before the dock blit, so BT_DEV_GAUGES_DOCK screenshots
silently omitted the gauge panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
table re-decoded to the ModeMFD bits + desktop J/K/L page cycle
The preset system was unwired by ONE defect in the message layer: the
SetPresetMode @004d1b24 table @0051dbf0 had been transcribed from the
section dump as BIG-endian dwords ({0x1e,0x01000000} instead of
{0x1e,0x01}), so a preset press set a garbage high bit -- and for group 1
items 3-4 / group 2 items 0-2 stomped the LIVE NonMapping / Intercom /
ModeSecondary* bits -- while the real page bits never moved. Ground
truth (section_dump.txt:72901-72908, little-endian + BTL4MODE.HPP [T0]):
set = ModeMFD{1,2,3}{Quad,Eng1-4} = 1<<(group*5+item), bits 0-14, fully
disjoint from the #6 secondary trio (bits 18-20); group 2 is MFD3, NOT a
duplicate of the secondary views.
What the 15 presets show (l4gauge.cfg): group = the MFD (Mfd1 lower left
/ Mfd2 upper center / Mfd3 lower right), item 0 = the btquad.pcx Quad
overview (up to 4 vehicleSubSystems cluster panels), items 1-4 = the
full-screen engineering-detail pages (bteng.pcx + prepEngr screens
group*4+1..4 + the cluster eng children: GENERATOR SELECT A-D, POWER
graph, COOLING loop, DAMAGE, ammo). Empty screens are authored per mech
(Blackhawk: 3/11/12 empty).
Authentic dispatch (streamed "L4" .CTL EventMappings, BT_CTRLMAP_LOG
dump): each MFD owns the 8-button RIO bank around it, MODE-MASK-gated --
Mfd1 = 0x08-0x0F, Mfd2 = 0x20-0x27, Mfd3 = 0x00-0x07. Quad page ->
direct-select buttons for the POPULATED eng pages (mapper msgs
Aux1Eng1-4 0x4-0x7 / Aux2* 0x9-0xC / Aux3* 0xE-0x11); eng page -> one
back-to-Quad button (0x3/0x8/0xD) + per-subsystem controls. These
records already install and fire on desktop (btinput passes the live
manager mask), so the NUMPAD profile's 0x20-0x27 keys page MFD2
authentically.
Port wiring (the #6 pattern): keys J/K/L -> actions Mfd1/2/3Cycle ->
gBTPresetCycle -> L4MechControlsMapper::CyclePresetModeNow(group) -- a
documented desktop shim (24 mode-dependent pod buttons don't fit a
keyboard) that cycles Quad -> populated Eng pages -> Quad, visiting
exactly the pod-reachable set; the body is the authentic SetPresetMode.
Dev-composite: BTDrawGaugeSurfaces now draws the Eng1-3 planes at their
sibling cells and skips any mono plane whose channel is BlankColor,
honoring the mode-driven reconfigure (RemapGraphicsPort) -- each dev
cell shows the ACTIVE page like the pod monitor. This supersedes and
removes the 2026-07-12 GAUGREND "frozen-dial" scaffold (forced all 15
page bits active under BT_DEV_GAUGES; it pinned the shared Eng plane on
the highest screen and ate the page flips).
Pixel-verified (BT_PRESET_TEST + BT_DEV_GAUGES_DOCK + BT_SHOT,
Blackhawk): all three MFDs page Quad -> SYSTEM NN eng details -> back to
Quad in lockstep with the [mode] preset mask log; group 0 skips the
authored-empty screen 3, group 2 skips 11/12. Un-regressed: N display
cycle (0x450421->0x490421->0x510421, page bits intact), M control mode,
CONTROLS.MAP 52 bindings parse clean.
Diags: BT_MODE_LOG ([mode] preset), BT_PRESET_TEST=<frame>.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NotifyOfDisplayModeChange override (vtbl+0x4C @4d1ae4) + wire desktop 'N'
The secondary screen's schematic selector was mislabeled: @004d1ae4 (the
bits-18..20 ModeSecondary* mask swap) was reconstructed as a non-virtual
"SetControlMode" that nothing called, so the desktop stayed pinned on the
Damage view. The binary's L4 vtable @0051e440 pins the truth:
+0x48 = @004d1acc <- CycleControlModeMessageHandler (FUN_004afbe0):
forwards to the base RET no-op @004b048c. A BAS/MID/ADV
control-mode change never touches the secondary view
(empirically confirmed: BT_MODECYCLE_TEST cycles the CONTROL
MODE lamp, mask bits 18-20 unchanged, schematic stays ARMOR).
+0x4C = @004d1ae4 <- CycleDisplayModeMessageHandler (FUN_004afcac):
THE Damage/Critical/Heat selector, indexed by displayMode
(table @0051dbe4 = ModeSecondaryDamage/Critical/Heat).
Authentic pod inputs (streamed type-6 .CTL EventMappings, dumped via the
new BT_CTRLMAP_LOG EVENT records): secondary-panel button 0x15 -> msg
0x15 CycleDisplayMode (manual p13, the "'Mech status Info center" bottom
left of the secondary screen), button 0x18 -> msg 0x14 CycleControlMode
(manual p6, top right), 0x10/0x11 -> ZoomIn/Out. The DOS keyboard
fallbacks (Keypress 0x13d/0x13e = extended F3/F4) are dead under the
WinTesla VK map, hence the desktop pin.
Port wiring (the M/ModeCycle pattern): key N / pad RightThumb -> action
DisplayCycle -> gBTDisplayCycle -> CycleDisplayModeNow() -- the same body
the pod console button message drives. Both .MAP profiles + the
compiled-in default updated.
Verified live (docked gauges + BT_SHOT, BT_VIEWCYCLE_TEST): the sec
panel cycles ARMOR DAMAGE silhouette -> CRITICAL DAMAGE subsystem list
-> HEAT DAMAGE colored list, mask 0x450421 -> 0x490421 -> 0x510421; M
control-mode cycling un-regressed (BAS/MID/ADV lamp cycles, view pinned).
Diags: BT_MODE_LOG, BT_VIEWCYCLE_TEST=<frame>, BT_MODECYCLE_TEST=<frame>,
BT_CTRLMAP_LOG now dumps EVENT records. KB: gauges-hud secondary-view
section rewritten, CLASSMAP +0x48/+0x4C slots, decomp-reference env
gates, GAUGE_COMPOSITE phase-4 entry resolved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-through on the pitch fix: the view pitched but shots still flew
level (user report). Cause: the task-#48 AIM BORESIGHT leveling in the
eye's aim-camera publish (L4VIDRND) strips ALL pitch from the pick-ray
direction -- correct for the TERRAIN body pitch it was built against, but
it also erased the pilot's deliberate R/F elevation.
Fix: mech4's per-frame eye compose publishes the torso elevation
(gBTEyeElev); the publisher re-applies it onto the LEVELED forward
(fwd = (cos e * level, sin e)) before building the basis -- terrain pitch
stays stripped, the deliberate aim survives into BTGetAimRay. Sign
matches the pixel-calibrated view compose (+ = up).
Harness evidence: pinned-down elevation visibly pitches the chase eye
into the terrain (the ray basis moves); a full headless aimed-kill could
not be driven (the autofire gate needs a designated target and the
random spawns wedged short of the truck row) -- aim-at-truck kill needs
the human pass. Awaiting human verification (issue #8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(b) The 'tiny explosion' on the ram kill is AUTHORED: crunch res 31 = 'stephit'
(one video object, effect 1008 = ddam5 damage smoke) -- a step/ram squash is
small by design. The FULL explosion res 32 = 'trkdead' = psfx 15 dtrkboom
(fiery omni burst) + psfx 16 dtrkburn (the burning-wreck fire), reached by
WEAPON kills -- which the 1995 binary dispatches [T1]:
- MechWeapon::SendDamageMessage @004b9728 (part_013.c:6765) gates only
"target NOT derived from Mech@0x50bdb4 OR aimed zone set": a non-Mech
boresight target takes the zone=-1 damage UNCONDITIONALLY;
- Missile contact @004be078 dispatches at the struck solid's OWNER entity
with no class test at all.
Port wiring: Mech::WorldStructurePick returns the struck solid's owning entity
(BoxedSolid::GetOwningSimulation; TERRAIN.cpp:107/246 build every static solid
with its Terrain/CulturalIcon/Door entity as owner); the mech4 pick block
designates IT instead of the gBTTerrainEntity sentinel (sentinel = fallback);
the projectile contact path grew the non-mech damage-zoned else-branch
(@004be078 mirror, direct Dispatch). Plain terrain ignores the damage
(ENTITY.cpp:885 zone==-1 guard); an icon's handler maps -1 -> 0 and dies.
Truck armor is WeaponDamagePoints=1 (TRK.DMG): one laser = one dead truck.
(c) The burning fire is dtrkburn.pfx, authored INTO the death package (every
icon family carries 1016: trkdead/bigdead/meddead/msldead/twrdead). There is
NO looping BurningState fire in the binary: the damage-zone effect watcher
ctor @0042a984 has exactly one call site (the Mech ctor, part_012.c:10405), so
icon ExplosionTables are inert, and CulturalIcon has no Performance.
Documented as authentic -- nothing invented.
Verified live (BT_SHOT pixel capture): 26 laser kills, [cult] TakeDamage
type=3 -> DYING res=32 -> DPLIndependantEffect 1015+1016 at the icon origin;
frames show the orange dtrkboom fireball, dtrkburn flames on the fresh wreck,
and the rubble aftermath; ram harness re-run still fires crunch res 31.
Diags: BT_FIRE_AT_ICON (designate nearest ahead icon; live-icon census in
CULTURAL.cpp), BT_FX_TEST="1015,1016".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MECHMD's scorch base (basev:bvx9_mtl, bexp9_tex = BEXP.BSL RGBA4444
slice 8, ramp cdusty) drew as a hard-edged dark square for two stacked
reasons:
1. The blanket 'truecolor BSL slice (channel >= 6) never ramps' gate
blocked its cdusty ramp. Corpus scan: only 4 shipped textures use
truecolor slices; bexp9/bdet9 are grayscale in RGB (100% / 98.8%
r==g==b) and their materials author ramps -- only bdam8 (damage
sheet) is truly coloured. bgfload rampableSlice() now probes the
decoded slice: a truecolor slice ramps iff effectively gray (>=95%),
keeping bdam8's colour protected.
2. The RGBA4444 authored alpha channel -- a binary 0/240 cutout mask
(the splat silhouette, 78.5% transparent) -- was never alpha-tested,
so the quad's transparent surround rendered as an opaque rectangle.
New BgfDrawBatch.texAlpha (channel >= 8) routes these batches
through the PUNCH alpha-test draw states, WITHOUT the black-texel
keying (which would hole the near-black charred centre). Side
benefit: tree9 (tree/leaf cards) + bdet9 (trans-rail lattice) get
their authored cutouts too.
Pixel-verified (ram-kill run, BT_SHOT): irregular char splat, dark
brown (71,44,34) lifting to near-terrain tan (115,100,91 vs terrain
131,119,108), no rectangle; pre-death frames unregressed (vehicles
stay lit/diffuse -- hasNormals gate untouched). Awaiting human
verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>