Oracle confirmed the separate-window glass mode WAS fast and tanked ~2 builds
back, so the regression is real and expresses only on his machine. This names
the cost from inside his own session log:
[glassperf] ticks=N tickMs=T | <window> n=paints ms=cost | ...
tickMs is the synchronous UpdateWindow sweep measured INSIDE the render frame
(L4VIDEO calls the tick), so on a machine where GDI serialises against D3D
present it IS the per-frame tax, and the per-window split names the panel.
Each WM_PAINT is QPC-timed at the dispatch chokepoint (the HALFTONE
StretchDIBits whose cost is driver-dependent). Default ON like [segperf];
BT_PERF_LOG=0 opts out. Smoke-tested: lines live in a 60s panel run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
The reported "torso no longer recentres on its own" decomposed into two parts:
the auto-recentre in Mid/Adv was never authentic (it was the stuck-centerCommand
bug acting as a phantom feature since ~674 -- analysis on the ticket, awaiting
Oracle's 1995-memory verdict), but underneath it sat a REAL defect: with the
phantom gone, a glass/RIO player had no way to recentre the torso at all.
WHY. The only centerCommand writer lived inside the desktop key-bridge block,
which is OFF whenever a RIO/PadRIO is present -- glass and the pod both. The
pod's dedicated CENTER button (0x42, "the shipped .RES name", UP arrow via
bindings.txt) reached nothing: benched two scripted 0x42 holds on the RIO path,
ctrCmd=0 throughout, twist parked forever. (Keyboard X/NumPad5 recentred only
as a side effect of ALL-STOP -- you could not recentre without stopping.)
FIX, two pieces, both existing patterns:
* L4PADRIO::EmitButton -- the documented single chokepoint every button
source funnels through -- publishes the 0x42 HOLD state
(gBTTorsoCenterHeld), exactly the 0x3F ReverseThrust precedent.
* mechmppr gains ONE unified centerCommand writer, deliberately OUTSIDE the
key-bridge gate (the same placement lesson as the mode-cycle hook):
hold = torsoCenter(@0x154 databound) OR gBTTorsoCenterHeld(0x42) OR the
one-frame X pulse; asserts while held, clears on release. Single writer
== the sources can never stomp each other's clear.
BENCHED (scratchpad/night14/center42.sh, RIO path, bridge off):
before: ctrCmd=0 in all samples across two 0x42 holds; twist parked at 1.478
after: hold -> ctrCmd=1 recen=1, twist slews 2.22->0.44 at the authored
0.87 rad/s; twist input DURING the hold cancels sim-side (authentic);
release clears; no button -> aim holds (authentic Std/Vet).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
Operator call: an opt-in flag on the one machine we most need data from is
the silent-diagnostic mistake this project's doctrine exists to prevent.
Every session log now carries [segperf] beside every [rstat] window, so the
next playtest gives the 817->857 stall hunt cross-machine baselines for free
-- Oracle's stalling rig and Sauron's clean one, same night, same build.
Cost: ~2 QPC reads per segment query, microseconds per second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
A/B on identical 2-node beam-heavy runs (scratchpad/night14/segperf.sh),
BT_BEAM_SEGFRESH=1 (swept behaviour) vs =0 (pre-sweep compose at the beam
site, the only per-frame swept call):
fresh(swept) legacy(pre-sweep)
rstat blocks>50ms 0 0
maxDraw worst 730ms 646ms (mission-load spike, BOTH)
segperf dirty-passes 38/s 4/s <- the sweep DOES multiply
segperf accessor ms 0.97/s 0.57/s <- ...by 0.4 ms/s. Noise.
So the invalidation-storm hypothesis I filed on #149 is wrong by three orders
of magnitude, and Oracle's sustained 50-104ms stall window does NOT reproduce
on this rig at all. Refusing to guess a third time: the build now carries the
telemetry to answer it on the machine that actually regresses --
[segperf] calls= dirty= ms= printed beside every [rstat] window under
BT_PERF_LOG (JMOVER counters; two integer increments when unset), and
BT_BEAM_SEGFRESH=0 remains as a one-env A/B for the beam site.
Default stays FRESH (the swept accessor): its measured cost is trivial and it
is the correctness-cautious side while the peer-beam-staleness question is
unmeasured.
Next for #149: Oracle runs one session with BT_PERF_LOG=1. If [segperf] ms is
large inside his stalled windows, segment work is implicated on HIS
configuration and BT_BEAM_SEGFRESH=0 gives the immediate A/B; if it is small
(as here), the stall is elsewhere in the 817->857 delta and we hunt with his
numbers instead of my theories.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
c1a87b6 added G on the premise that a keyboard-only player could not reach the
control mode. That premise was WRONG, caught by the operator: I checked only
for `action ModeCycle` verbs and missed the CONSOLE BUTTON route.
content/bindings.txt:147 key F7 button 0x18
content/CONTROLS.MAP:136 key F7 button 0x18
0x18 IS CycleControlMode (context/pod-hardware.md:69), and it is wired:
MESSAGE_ENTRY(MechControlsMapper, CycleControlMode) ->
CycleControlModeMessageHandler -> CycleControlModeNow -- the same body the
gamepad Start and the pod console button drive. So F7 has always cycled the
mode from the keyboard, on BOTH keymaps. Every console button really is mapped.
Worse than redundant: CONTROLS.MAP:172 already binds `key G action Flush`, so
shipping G=ModeCycle in the glass default board would have made G mean different
things on the pod and on glass -- two populations with two sets of muscle memory,
which is precisely what the bindings-board migration exists to prevent.
Reverted from the shipped default template and from the local bindings.txt.
The torso-twist verification stands: the operator cycled modes and confirmed the
fix live; only the key used to do it was needlessly new.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
ModeCycle was bound ONLY to the gamepad Start (CONTROLS.MAP), and bindings.txt
carried no action bindings at all -- so a KEYBOARD-ONLY player could not reach
the control mode. That is the control the torso-twist fix has to be tested
through, and it is a shipped gameplay feature (Basic steers with the stick and
auto-centres the torso; Standard/Veteran twist the torso and steer with the
pedals), so it should not be gamepad-only.
G is free. M is taken -- it is keypad button 0x02.
DELIBERATELY the default TEMPLATE only, not a bindings-board bump. content/
bindings.txt is a per-machine runtime file (gitignored) and an existing one is
never overwritten, so:
* a FRESH extract writes the new board and gets G -- which is the zip flow;
* an EXISTING tester keeps their file, and their customizations, untouched.
Shipping it to existing files means bumping "# bindings-board" and extending
kHistoricalDefaultRows so board-2 rows are recognised as "the old board" rather
than player customizations. That is the designed path, but it rewrites every
tester's bindings file, and doing that in the same build as a large unverified
fix pile is how you lose a playtest evening. Left for a build where it is the
headline change. Testers on an existing install can add the one line by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
Nick, after re-cabling + rebooting the pod: "the order changed ... sometimes
they change when one gets turned off and back on, at least how windows SEEs
them, even if the visual desktop tool looks the same."
Both existing binding forms are boot-fragile: `monitor:2` is an ENUMERATION
INDEX and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns. Neither
survives a panel power-cycle. (His gos-displays.txt shows the same trap next
door in GameOS: -tmon takes DIRECTDRAW device indices -- not Windows monitor
numbers -- and a NULL-device merge shifts every index down by one on top.)
FIX: bind to the panel's own hardware identity. EnumDisplayDevices on a
display's MONITOR child returns a DeviceID embedding the EDID manufacturer +
product code and the connector instance; neither moves across a reboot.
DISCOVER BT_GLASS_IDS=1 logs every attached panel's stable-id AND a
ready-to-paste `cfg form = monitor🆔<fragment>`. It prints the
VOLATILE identifiers alongside on purpose: run it either side of a
power-cycle and index/device move while stable-id does not.
BIND Heat MFD=monitor:id:AUO10ED,bare
NOTHING CHANGES BY DEFAULT -- no env and no `id:` prefix means identical
behaviour; `monitor:<name|index>` and raw x,y keep working, so playtester glass
builds are untouched.
An `id:` that matches nothing WARNS and falls back to computed placement.
Silence would put a picture on the wrong glass and look exactly like the bug
this form exists to prevent.
VERIFIED on a 1-monitor dev box (the pod is offline), end to end:
* discovery printed
stable-id = \?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-...}
cfg form = monitor:id:AUO10ED
* `Heat MFD=monitor:id:AUO10ED,bare` resolved and CENTRED correctly
[glasswin] 'Heat MFD' bound to monitor 0,0 1920x1080 -> window at 640,300
* a bogus id warned instead of misplacing.
The EDID-code extractor is deliberately STRUCTURAL (3 letters + 4 hex digits,
tokenising on \ # ?) rather than positional: the first cut walked separators by
position and returned EMPTY for the `\?\DISPLAY#...` interface-name form, which
is exactly the form this machine produces. Which form you get depends on
whether EDD_GET_DEVICE_INTERFACE_NAME succeeds, so both must parse.
STILL UNPROVEN [T3] -- the pod is offline: multi-panel disambiguation when
several MFDs share one model (EDID codes collide). The documented answer is a
longer fragment from stable-id, whose UID/instance tail differs per connector,
but that needs the cab to confirm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
Two ungated one-liners, because no bench here reached the failing path and
the next playtest is a better instrument than more automation:
[glasswin] destroy entry #N windows=M -- N=2,M=0 is the double-destroy
[glasswin] saved ... (live=L remembered=R) -- live=0 IS the corruption case
(pre-cache that wrote a file
holding only the plasma line)
Also lands the benches that did NOT reproduce it, with their failure modes
recorded in the headers so the next attempt does not repeat them:
layoutsave.sh (round trip -- passes on the fixed build), layoutteardown.sh
(graceful WM_CLOSE; still never reaches the dtor chain), layoutround.sh (MP
round boundary; the relay never started the mission inside the window).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
Regression from d213c98 (the pod PadRIO/panel coupling fix).
BTGlassPanels_Destroy calls SaveLayout FIRST, unconditionally, before it
looks at whether any windows are left. d213c98 added a SECOND caller
(~LBE4ControlsManager) alongside the existing one in ~PadRIO, so on the
desktop path both run: ~LBE4ControlsManager does `delete rioPointer`, which
fires ~PadRIO -> destroy #1 saves the live windows and zeroes gWinCount ->
destroy #2 then rewrites the whole file from an empty list.
Why only the MFDs vanished, which is the detail that identifies it: external
windows (plasma) already cached a last-known rect (gExtern[].haveLast) and
were written from the cache; the per-display glass windows had no cache and
were simply skipped once their HWND was gone. Hence the reported signature,
"all the MFDs and secondary lines missing, but plasma was still there".
The pod was never affected -- no PadRIO there, so only one destroy, and it
runs BT_GLASS_LAYOUT=load off a frozen master regardless.
Two fixes, because the guard alone would leave the trap armed for the next
teardown-ordering change:
1. glass windows get the same remembered-geometry cache the extern windows
have, kept ACROSS teardown. The file is now monotonic -- a save can
update a line or add one, never drop one.
2. the teardown save is guarded on there being windows to report.
Also corrects the comment at the L4CTRL call site, which claimed the second
call was "a no-op on the PadRIO path". That claim is what made it look safe.
Verified (scratchpad/night13/layoutsave.sh -- the tester's round trip, not a
single launch, since the report was "saved fine, reset on relaunch"):
run 1 one "saved 8 window position(s)" line (was two, the second wiping)
cfg holds all 7 glass windows + plasma
run 2 "restored 7 window position(s)", cfg byte-identical after the trip
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
Nick ran the cab with the real board and the mission came up on the main view
with every MFD dark -- and not one [glasswin] line in the log.
BTGlassPanels_Create() was called only from the END OF THE PadRIO CONSTRUCTOR.
The panels began as the frames around the on-screen RIO button banks, so "the
buttons ride the device" was reasonable then; it is backwards on a real cab.
L4CONTROLS=RIO:COM1 means PadRIO is never constructed, so the MFD windows were
never created -- silently, since nothing is wrong from the display layer's
point of view.
The panels are a DISPLAY concern. Creation moves to LBE4ControlsManager,
after the L4CONTROLS parse where every device branch converges, and a
symmetric BTGlassPanels_Destroy() goes in its destructor: ~PadRIO tore them
down, ~RIO knows nothing about them, and windows outliving the surfaces they
blit would crash on the next mission cycle. Both calls are idempotent
(Create returns on gWinCount != 0), so the proven PadRIO path is unchanged
and simply arrives first. Both guarded by BT_GLASS -- L4GLASSWIN is only in
the build when the gate is on.
Verified on the cart: hardware RIO up AND all three surfaces on their intended
displays in the same run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
The cart's display config was carried by a launcher .bat, so it only came up
right if the game was started one particular way. Move it into the file the
engine already reads before anything touches the environment.
content/environ.ini <- scratchpad/pod/podprofile.ini, merged idempotently
between markers by scratchpad/pod/mergeprofile.ps1
Two gates were missing for that to be enough:
BT_FIT=1 the env spelling of -fit, so the borderless main view does not
depend on one launcher's command line (shortcut, scheduled
task and autostart all have to produce the same rig)
L4PLASMA= NONE / OFF / 0 -> no marquee at all. Leaving it unset does
NOT work: the GLASS profile force-defaults it to SCREEN, which
drops a desktop plasma window on the cab's glass. The boot
banner now reports the live state instead of always claiming
"plasma window".
Also lands the bring-up engine work this depended on: monitor:<name|index>
layout binding (device-bound, not pixel-bound -- desktop rects move when a
display re-enumerates), ",bare" implying frameless, rotation-aware radar
surface sizing, BT_GAUGE_SEC_ROT accepting 0-3 (it silently forced 3 for
anything but 1), 180-degree ExpandPlaneToBGRA, and the BT_POD_CHANMAP /
BT_POD_IDENT / BT_POD_CHANTEST identification gates.
Verified on the cart, build 4.11.813, launcher carrying none of it:
[boot] environ.ini: 9 setting(s) applied
[boot] platform profile: GLASS (PadRIO; plasma off [L4PLASMA])
[cockpit] -fit: borderless 800x600
[glasswin] radar rotation 0 (none)
... all three surfaces on their intended \.\DISPLAYn
No [plasmawin] line in an otherwise-logging run = the ctor never ran.
KB: pod-hardware.md gains the ALPHA-MR section (mapping, the two wiring
deviations, the frozen profile, the session-0 remote-work traps) and its RGB
SPLIT "OPEN: which way is the cart wired" is now SETTLED -- it is splitter
wired, the composite is what lit it. glass-cockpit.md documents monitor:
binding, BT_FIT and L4PLASMA=NONE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
Implements what the pod actually does (pod-hardware.md THE RGB SPLIT): a VGA
port's R/G/B lines each drive a separate mono MFD monitor, so a window is not
one MFD -- it is one PORT carrying up to three. BT_POD_RGB=1 collapses the
five MFD windows into the two ports the cab drives (Port A: Comm=red,
Mfd2=green, Heat=blue; Port B: Mfd1=red, Mfd3=green) and composites each
group's planes into the colour channels, leaving the radar on its own
full-colour port. Channel comes from the live port (GetEnableID), never a
hardcoded table, so an Eng-page swap follows automatically; BlankColor planes
contribute nothing, exactly as on the pod. Implies BT_POD_SURFACES (bare
640x480 pictures -- the cab's buttons are physical).
Verified locally (bare windows, "RGB COMPOSITE of 2 plane(s): Comm Mfd2") and
LIVE ON NICK'S CRASH CART over the tailnet: Port A -> DISPLAY4, Port B ->
DISPLAY2, radar -> DISPLAY1, all exact-fit. Pod scratch kit included (ssh
helper, layout cfgs for both modes, launcher; the mission-egg launcher fix --
a bare MP.EGG exits the mission loop with no relay, and BT_FE_SOLO parks at
the menu, so neither lights the panels).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nick's crash cart is the first shot at driving the real MFD panels, so the
pieces the cab needs that the desktop glass path lacked:
- BT_POD_SURFACES=1 (L4GLASSWIN): crop every display window to its SURFACE
(MFDs exactly 640x480, radar 480x640 portrait), drop the on-screen RIO
button banks -- the cab's buttons are PHYSICAL, so drawing fake ones onto a
real panel is exactly wrong -- go frameless, and skip the Flight Controls
pad entirely (6 windows, not 7). Per-window ",bare" in glass_layout.cfg
for mixed rigs (idempotent with the global mode).
- Placement receipts: each window logs which PHYSICAL monitor it landed on
("[glasswin] 'Heat MFD' surface=Heat at X,Y 640x480 bare -> monitor
\.\DISPLAYn (...)"). Nobody can see 7 surfaces at once on a cab, and over
Chrome Remote Desktop you cannot see the panels at all -- the log is the
only confirmation the map is right.
- tools/podprobe.ps1: run-on-the-pod topology probe (no install/admin) --
GPUs, every monitor's virtual-desktop rect, EDID make/model, serial ports
(the RIO board), session type, plus a PROPOSED glass_layout.cfg assigning
the six surfaces to the non-primary monitors. Self-tested here (correctly
reports a single-monitor laptop and declines to map).
Local verify: 5 MFDs at 640x480 bare + portrait radar + Flight Controls
dropped, receipts printed. KB: pod-hardware §MFD PANELS ON REAL HARDWARE
(incl. WHY the 1995 path is not the route -- NVIDIA Horizontal Span is gone
from post-XP drivers and per-adapter exclusive fullscreen is fragile over a
remote session) + glass-cockpit env table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The WIP's 'pose does not hold' was a chain of bench-instrument errors, not a
code bug: every capture ran in COCKPIT view (the pilot cannot see their own
legs; the eye-height residual masked as reversion). Joint probes prove the
park holds indefinitely (knee 1.138, root -2.219 steady); the 2-node bench
shows the observer's replicant fully crouched and held (duckmpA_031 -- the
type-3 state record carries it with zero new replication code); the second
scripted press (new BT_BTNTEST2 env) verifies RISE -> standing zeros.
MP button delivery confirmed mode-mask-clean (the one miss was round-start
jitter). Diags added, all BT_DUCK_LOG-gated: SetLegAnimation re-arm tracer,
1 Hz joint probe, RIO press mode-mask, BT_TREE_LOG topology dump, and the
squat-park log. RESIDUAL filed: pilot's own cockpit eye does not ride the
root drop (DPLEyeRenderable chain composition; cosmetic).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SPOT.BGF decoded: a 7-vert cone from the mount, ~50u forward and ~35deg DOWN
(a ground-pool lamp, not an air beam), verts tinted cyan-white, material
class 'brighten' smuggling its additive factor in DIFFUSE.r (0.25) with a
warm emissive on the night page. The loader had never met the class -- it
drew as an opaque dark-red blob. Now: brightenFactor parsed (name-gated to
brighten*), batch -> L4DRAWOP.brightenAlpha, drawn in the blend pass as an
additive veil (dest += vertexRGB x factor), unlit. [T3] tint compose
(vertex cyan vs night emissive warm) noted in the draw branch -- field
eyeball accepted the current look.
BT_SPOT_SELF=1 (bench-only): builds the cone on the own-cockpit tree so the
BT_CAM=face view can inspect it solo. KB: view toggle is BACKTICK ('V' is
the rear-view hold since #68 -- the toggle skips bound keys); stale V-toggle
claims swept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The subsystem (sim/toggle/attribute/replication) was already complete; this
lands the missing VISUALS, decoded from MakeMechRenderables @004cef28 case
0xbd8 (raw pseudocode part_014):
- COCKPIT: the 1995 searchlight is a FOG SWAP -- the @00456778/@00456814
watcher switches DPLRenderer::SetFogStyle between the authored fog= (lit)
and nosearchlightfog= (dark) sets per map/time page in BTDPL.INI. The
engine kept the whole system under its real names; completed the stubbed
plane application (currentFogNear/Far) and transcribed the watcher (with
its inverted-cache seed) into TickSearchlight. CONSEQUENCE: night now
STARTS on the authentic dark set (near-plane 5u on arena pages) -- our
builds had rendered the searchlight-ON fog permanently.
- EXTERNAL: spot.bgf beam cone hung on the searchlight SITE joint, shown/
hidden from the replicated LightOn attribute (@0045612c watcher). Site
segments now build geometry-less DCS children (posed + parentable, as the
1995 graph did) -- previously they were skipped entirely.
- searchlight.hpp: commandedOn @0x1DC identified as mountSegment (resource
segmentIndex; the cone's mount joint).
Benches: searchfog.sh (solo cockpit: first-tick dark sync, F5/0x14 press ->
SetFogStyle(2), red-fog probe end-to-end), spotcone.sh (2-node: B's button ->
lightState replication -> A logs "[spot] cone SHOWN (seg 20)"). Cone look
(size/aim on the mount) pending an eyeball pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop plasma window (L4PLASMAWIN, "BattleTech - Plasma") is created by a
different TU than the per-display panels, so it couldn't be a gWins[] entry and
sat outside the sticky-layout / ,noframe system. Add a tiny extern-window
registry to L4GLASSWIN so it rides the same glass_layout.cfg:
- BTGlassLayout_QueryWindow(title, rect, noframe) -- read a title's saved rect
and ,noframe flag (used by the plasma on creation, BEFORE sizing, since
WS_POPUP's frame extent differs from the framed tool window).
- BTGlassLayout_RegisterExtern(hwnd, title) -- register an externally-created
window so SaveLayout writes its line (with a last-known-rect cache so a
teardown before the save still preserves the line).
- BTGlassLayout_Save() -- public save trigger the plasma WndProc calls on
WM_EXITSIZEMOVE / teardown.
L4PLASMAWIN now reads its saved rect+flag on create (WS_POPUP when ,noframe,
position restored), registers itself, and saves on finished-drag/teardown. All
BT_GLASS-gated; the pod is untouched. The plasma blits directly every frame, so
it has no WM_TIMER focus-throttle.
Verified: Release links clean; drag wrote "BattleTech - Plasma=321,222,528,167",
reload with ,noframe brought it up WS_POPUP (WS_CAPTION absent) at 321,222.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two glass-panel indicator fixes reported by playtesters.
1. FLASH SLOW UNLESS FOCUSED. The per-display panel windows repaint off a
62ms WM_TIMER, and Windows coalesces/throttles timer + paint messages for a
window that is in the BACKGROUND (unfocused) -- so with the game window
holding focus the panels' lamp flash (a repaint-driven animation) crawled,
and giving a panel focus un-throttled it. (My earlier surround fix drew in
the main D3D frame and never touched these separate windows.) New
BTGlassPanels_Tick(), called once per frame from the main render loop
(L4VIDEO), drives a synchronous InvalidateRect+UpdateWindow at the ~16Hz
flash cadence -- the main loop runs every frame regardless of which window
has focus, and the forced paint bypasses the throttled timer-message path.
The WM_TIMER stays for its one-shot re-snap.
2. RED PANIC/EJECT. 0x3D is the Panic/Eject button; on the blue flight panel
it was just another blue block. The shared flight-grid geometry (L4RIOBANK)
now tags 0x3D as colorClass 0 (red); L4GLASSWIN's AdoptBank maps colorClass
0 -> ClrRed explicitly (the flight bank's ClrBlue default no longer swallows
it), and the surround (L4VB16) already renders non-blue/yellow as red -- so
the eject stands out red in both the surround and the exploded window.
Verified: Release links clean (40 tolerated /FORCE externals only); exploded
panels boot + run 13s no crash under the per-frame repaint pump; riobank dump
confirms 0x3d class=0 (red), neighbours class=2 (blue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three testers reported a black rectangle swaying with the footsteps in the
thor cockpit (Summoner = the thr1 label, same THX canopy -- no isolation).
Decode: the inside view is a PER-MECH AUTHORED type-A set, not the cop
alone (no fallback in EntitySegment::GetVideoObjectName -- authored data):
madcat/vulture/bhk1 = cop; sunder/loki/avatar = +tor; thor = +tor +MSL
(the shoulder pod, the reported rectangle); owens = +both legs +tshd.
The pod/leg pilot-facing surfaces use the SAME "<pfx>skin:blakskn_dz_*"
interior-structure material as the canopy frame, but the unlit frame
constant was keyed on the _cop FILENAME -- identical material rendered
(0.13,0.12,0.15) on the canopy and pure (0,0,0) on the pod/legs
([matlog]: owx_cop blakskn vcol=FF211F26 vs owx_lule vcol=FF000000).
Fix (bgfload.cpp): the frame-constant treatment keys on meshIsCop OR
material contains "skin:blakskn_dz_". mechfx:blakskn_mtl (tshd shadow
quads) deliberately excluded. Verified: zero pure-black px in the lower
view band across walk captures, the pod plate renders frame-toned and
blends with the bar at rest (the reported anomaly dissolves), owens legs
read as coherent structure, canopy/terrain un-regressed.
Diags added: BT_MAT_LOG=<stem> per-batch material routing dump (bgfload),
BT_HIDE_INSIDE_SEG=<substr> inside-mesh hide (btl4vid), [view] per-segment
inside-roster names. KB: cockpit-view.md exactly-one claim corrected +
the #91 section; bench scratchpad/night11/thorrect.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Playtesters (all on the glass surround) reported the cockpit lighting
sometimes going slow or nonexistent while the 3D view stayed perfectly
smooth. The RIO serial/lamp stack is byte-identical to pod-proven Red
Planet (diffed L4LAMP/L4RIO/L4SERIAL), so the wiring was fine -- the defect
is the glass UPDATE CADENCE:
The lit buttons draw every frame (BTDrawCockpitPanels, reading
PadRIO::GetLampState), but the lamp STATE sweep that fills that store
(LampManager::Update -> AssertNewLampValue -> SetLamp) rides the gauge
renderer's FOREGROUND turn, which fires only once per full gauge cycle
(foreground->background->copy). The cycle can't reach the next foreground
turn until the throttled background gauge sweep drains the ~140-instrument
active list -- and that background task starves under MP load (issue #45,
"instruments freeze while fps stays healthy"). So the lamp sweep ran
~1x/second: buttons froze / flashes stalled while the view (a separate
per-frame foreground render) stayed smooth.
Fix: BTGlassSweepLamps() runs the lamp sweep EVERY frame on the dev/glass
composite path (L4VB16.cpp, in BTDrawGaugeInset), decoupled from the gauge
cycle. It is cheap -- deduped state pushes over ~72 lamps, no raster. The
pod never enters BTDrawGaugeInset (real gauge hardware), so its authentic
bandwidth-paced serial lamp cadence is untouched. BT_GLASS_LAMP_SWEEP=0
restores the authentic once-per-cycle behaviour.
Verified: Release links clean (only the 40 tolerated /FORCE externals);
glass surround boots and runs, lamp sweep pushes state (43 [lamp] pushes,
buttons lit), ~60 fps, no crash. The under-load win is structural (the
sweep is now an unconditional per-frame call); true MP-starvation repro
needs multiple nodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator called binary-faithful; the trace agrees. The hook (bfc072b/
fa3d634) fired ALONGSIDE the key's authored per-page function, so on a
weapon eng page an armed pilot pressing UNJAM/EJECT to clear a jam would
self-destruct -- a destructive hijack of a real control. Removed; the
authored press model stands alone: pilot KEYPAD bank (desktop numpad) or
the PANIC key while armed. The flashing engEject cell is the invite lamp.
Regressions: armed PANIC press -> PUNCH-OUT (1); armed soft-key 0x0B press
-> no eject (0); streamed ammo-eject route untouched (the hook was purely
additive).
Remaining marked deviations, both flagged in code and handoff: the coolant
arm hysteresis (0.05/0.06) and the Panic-button->keypad desktop wire (the
pod's physical matrix emulated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The absolute-pointer scan missed the E8-relative call; the later byte-scan
found it (FUN_004a9b5c+0x10 -- the master performance evaluates eject
permission per frame in the shipped binary). Under deadline pressure the
disproven 'unfinished/dead code' claim leaked back into three comments and
the handoff; swept per the correction mandate.
Settled press model, now stated correctly everywhere: pilot eject = the
pilot KEYPAD bank while armed (+ the PANIC key reporting through that
matrix). The MFD soft keys NEVER pilot-eject in the binary -- every page
routes them to authored functions; the flashing engEject cell is the
INVITE LAMP. The port's page-gated eng-key eject is a MARKED CONVENIENCE
deviation (operator-requested), and the coolant hysteresis is a MARKED
smoothing deviation for a degenerate boundary oscillation -- not
completions of unfinished code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator's three captures decode as ONE mistake drawn two ways: the
cockpit compositor deliberately draws the side buttons as BIG rects tucked
UNDER the display surfaces (step 2), with only the protruding edge visible
-- the edge IS the port's lamp light (the pod's backlit keys sat beside the
CRT; there is no in-display face to light). My on-top "flash overlay"
(86a6bbb L4VB16, 5f79dce L4GLASSWIN) violated that design: drawn after
DrawDevSurface without resetting the bound texture state it rendered the
gauge atlas as green striped garbage at the button rects; with the state
reset it drew honest full faces -- solid flashing red covering that whole
portion of the MFD. Both overlays removed; the original step-2 rendering
(which already tick-animates lamp shades) is the whole story.
Pixel-verified through the full stress (4 gens dead, autofire jams, leak
warning): weapon pages pristine end to end, and the 0x27 edge lamp
square-waves 34.0 <-> 28.9 across consecutive captures -- the invite
flashes at its authentic scale.
(The eject-wipe gauge -- the OTHER corrupting draw, visible in the earliest
sighting -- stays re-stubbed pending a real RE of FUN_004c5fb8's draw op.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first visibility fix painted L4GLASSWIN -- a window the operator's
single-window layout never shows. The main-window cockpit compositor is
BTDrawCockpitPanels (L4VB16): same under-the-surface masking, so the RIO
flash was again invisible. The 3b overlay now re-draws flashing lamps
(state & 3) above the surfaces there, tick-phased; [ckflash] (BT_LAMP_LOG)
logs each animated draw. Verified in-engine: addr 0x27 state 0x37
alternating shade 1 -> 3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The glass windows paint buttons UNDER the display surface (only a sliver of
edge protrudes), so the RIO flash commands were arriving ([lamp] 0xb <- 0x37
FLASHING in every trace) while the operator "never saw it flash" -- the
blink was a few masked pixels. Lamps carrying RIO flash bits (state & 3)
now re-draw their full face above the surface with the tick-phased shade:
the gotoEngineering / engEject invites read like the pod's backlit keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page-gated eject query ran in the window-click thread (PadRIO
EmitButton) and dereferenced the TU-local application global -- NULL there
under the /FORCE duplicate-symbol layout -> c0000005 at mode-manager +0x50
the moment the operator pressed the eng eject key (field crash,
live.log [crash] record). Moved into LBE4ControlsManager::ProcessRIOEvent's
ButtonPressedEvent branch, where mode_mask is already resolved on the game
thread; PadRIO now only queues events. Same gate semantics (armed + that
bank's ENG-page mode bits); verified no-crash + no-eject on the quad page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decode that settles it: the eng-page side-key functions align key-for-
key with the engEject lamp map (selectGenA-D = 0xF-0xC, EJECT = 0x0B,
GEN-MODE = 0xA, COOLING = 0x9, BACK = 0x8; banks 2/3 at 0x27-/0x7-), and
the streamed weapon pages route the same key to EjectAmmo -- so 0x0B/0x23/
0x03 ARE the authored EJECT position. On generator pages the shipped
stream leaves the key with NO live route (msg 9, no handler anywhere):
eject was UNFINISHED in 4.10 (the evaluator was dead code -- the arcade
never armed panic at all). Intent-completion [T3, marked]: while PANIC is
armed AND that bank displays an ENGINEERING page (kBTEngModeMask bits --
the same gating the alarm lamps use), the flashing EJECT key fires the
pilot-keypad eject. Any other page: authored function untouched (the two
field regressions came from skipping the page gate).
Verified: armed + QUAD page + 0x0B press -> no eject (gate holds); panic /
numpad / Backspace unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streamed .CTL dump (BT_CTRLMAP_LOG, 109 records on the madcat) settles
the eject-input question from CONTENT: no 0x200000 record exists -- the MFD
soft keys carry their authored per-page routes unconditionally (elem 0xB on
a weapon eng page = msg 0xB EjectAmmo, the round eject), so they must never
route to pilot-eject. The invite-cell routing (b9430cc) hijacked exactly
that key -- the operator's "bottom left button self destructed" -- and is
REMOVED.
The faithful model, now fully grounded: the flashing engEject soft-key LAMP
is the INVITE indicator; the eject PRESS is the guarded PANIC key (no
button-space consumer in the image -> it reported through the pilot keypad
matrix; the 0x3D->keypad wire stays) or any pilot-keypad key while armed.
Verified: armed + soft-key 0x0B press -> NO punch-out (authored function
preserved); panic path unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field-caught by the operator within minutes: the blanket MFD key-space
routing (0x00-0x2F) made the DISPLAY navigation key eject the mech
mid-page-switch while armed. Narrowed to exactly the guarded PANIC button
(0x3D) and the three eng-page EJECT INVITE cells (0x0B/0x23/0x03, the
addresses the engEject 0x85 alarm lamp flashes on). Navigation is safe
while armed; the flashing cell still punches out (re-verified through the
click seam: armed -> press 0x0B -> PUNCH-OUT).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator's pod flow ("the engineering panel's bottom-left button flashes
EJECT, you push it") is input #3 of 3. The eng-page eject cell resolves to
MFD side-column addresses 0x0B/0x23/0x03 (kBTEngBankTop - 4 per bank), and
the binary's ONLY armed eject binding is the KeyboardPilot group (0x200000
-> mech msg 0x19; exhaustive 0x200000 scan: no other consumer) -- so on the
pod those keys report through the pilot keypad matrix. Desktop mirror: MFD
key-space presses (0x00-0x2F) and Panic (0x3D) also land as pilot-keypad
keys. Outside panic mode the binding is mode-masked dead -- normal MFD
operation untouched; armed, the flashing cell (or any keypad key -- the pod
semantic) punches out.
Verified through the click seam: gens dead -> armed -> press 0x0B ->
PUNCH-OUT.
All three eject inputs now live: Panic button, Backspace/pad LeftThumb,
and the flashing MFD eject cell.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two finds close the press path the operator field-tested:
1. The pod's guarded PANIC key reports through the PILOT KEYPAD bank -- the
binary's ONLY panic press binding is keyboardGroup[KeyboardPilot].Add(
mode 0x200000, mech, 0x19) (FUN_004d266c); there is NO buttonGroup[0x3d]
consumer (0x3d is the lamp address). The desktop Panic click now ALSO
lands as a pilot-keypad key (PadRIO::EmitButton -> EmitKeypad), so the
armed-mode binding fires; a healthy press stays a mode-masked no-op,
exactly the pod behaviour.
2. KeypressMessageID resolved to 0x17 via enum-chain arithmetic; the binary
passes LITERAL 0x19 (two mapper ids are unreconstructed). The armed
keypad send arrived at the Mech as msg 0x17 -- no handler, silently
swallowed. Pinned to 0x19 (Mech::EjectPilot's id on the OWNER receiver --
the collision is the design).
Verified headless through the REAL click seam (BT_BTNTEST=61): generators
killed -> panic-arm ON -> screen-click Panic -> [lbe4key] unit 0 mode
0x650421 -> PUNCH-OUT. Also: IsDisabled eject refusals now log; [padkey]/
[lbe4key] seam tracing under BT_PAD_LOG.
Still open (cosmetic): the flashing eject slot on the engineering MFD shows
no graphic -- the streamed page element's pixmap is unreconstructed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
USER REPORT: "when you destroy a mech, it continues to smoke for a very long
time after the mech disappears... up to 30secs to a minute."
MEASURED (new [pfx] timed census with per-def particle attribution): the
death plume (psfx 1 DDTHSMK) was re-armed on a 10s cadence as a WORLD effect
(ownerTag=0) -- so neither the burial transition nor the respawn cleanup
(StopAllEntityEffects) could touch it. The last-armed window kept emitting
over the empty spot for up to 10s after the hulk vanished, plus 6+-2s authored
particle lives, and back-to-back windows stretched the visible tail to the
reported range. The re-arm itself is a marked PORT ADDITION [T3]; the binary
fired DDTHSMK once -- its 10s window + particle lives ended right around the
~17s hulk sink. The authored design: the smoke dies WITH the burial.
FIX: the plume is spawned ATTACHED (BTStartPfxAttached, tagged to the dying
entity -- it also rides the sinking hulk now), and the burial transition calls
BTStopEntityPfx alongside the respawn cleanup that already did. Verified over
a 175s run containing FOUR wreck events (the enemy kill + three player bay-
fire deaths): every plume's emission ends at its wreck's burial or respawn,
particles fade within ~8-11s, no orphan emitters remain.
THE COUPLING (user relay of Oracle: "prolonged smoke and explosion INCLUDING
SOUND... same lever?") -- CONFIRMED as a family: the binary's audio layer
times in FRAME COUNTS converted by the engine frame-rate global:
AudioTime::Seconds_To_Frames == fmul [0x52140c]=28.0; fadd 0.5; round
(@0x42c611, @0x42dd86 -- the 0x42xxxx cluster of the ~90 global consumers)
Our engine's DefaultRendererRate said 30: every audio duration, sequence
delay and compression window ran ~7% fast. Calibrated to 28 [T1]. NOT yet
explained by this: a truly SUSTAINED/looping explosion sound (7% is not
"prolonged") -- the effect stop-path trace stays open on #114/#51.
Tooling: BTPfxParticle carries defIndex; [pfx] census (BT_PFX_LOG, 2s) prints
emitters + particles PER EFFECT SLOT with a timestamp; wrecksmoke.sh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reopen said "the pool fills and never returns a source". The 30s census in
the SAME field logs disproves it: free returns to ~227-230 between bursts and
reuses climbs ~20/s all session. free=0 on the failure line is true by
DEFINITION at the instant of a failed acquire -- the third instance of the
counter-sampling trap (live=256-vs-6, then hsparm greps, now this), read off the
alarm line instead of the trend line.
WHAT THE LOGS ACTUALLY SHOW
* The pool cycles; release-on-stop exists and works (the engine steal loop).
* During firefights CONCURRENT demand exceeds 240 and the priority steal loop
services each new sound by killing an old one -- continuously through
combat (failures spread across every decile of every combat session).
* Idle standing demand is ~13 sources. My "each component holds its SourceSet
to entity teardown, ~20-25 per mech" narrative was wrong.
* The raw ACQUIRE FAILED line count (6.8k-19k per log) is NOISE: the steal
loop retries after every failed attempt, so lines accumulate per EVENT and
most events still play via a steal. True drops were never counted.
CHANGES
1. Census now carries steals= and drops= (drops = the steal loop ran dry and
the sound NEVER played) plus a per-class drop histogram
("[audio] dropped by class: {class 1005 x4v: N} ...") -- all ungated, so
the next field logs are decisive instead of suggestive.
2. The ACQUIRE FAILED print is rate-limited to 1/30s and now names the
requesting class + voice count. 19k-line log spam distorted this triage.
3. BT_AUDIO_SOURCES=<n> now raises the POOL cap too (it previously raised the
AL context budget while the pool stayed at 240, making the field
experiment impossible to run).
MEASURED (9 mechs, missile autofire, 150s)
* cap 240: peak 130 sources, 0 fails -- demand tracks SHOOTER count, not mech
count; one shooter cannot saturate. A 6-shooter lobby pins 240.
* cap 48 (BT_AUDIO_SOURCES=64): saturation reproduced -- census
steals=223 drops=455, histogram names the classes.
* Dominant field requester (requested=4) = class 1005 Static3DPatchSource:
world-placed effect sounds, i.e. EXPLOSIONS. 1001 DirectPatchSource x1v
dominates drops at low cap; 1002 Dynamic3DPatchSource x3v present.
CONSEQUENCE FOR THE FIX ORDER: #84's stale-aim double detonation duplicates
exactly the saturating class on observer nodes. Fix#84 FIRST, then re-read the
field census; only if it still saturates does the budget experiment
(BT_AUDIO_SOURCES with frame time measured) become the play.
KB: the wrong night-9 entry in open-questions.md replaced with the corrected
diagnosis; gotcha candidate noted -- an alarm-line counter is not a trend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while checking whether a player had reported this before: the code already
carried a prior analysis of this exact error which deliberately decided NOT to
act on it, concluding the 2026-07-23 atomic-delete fix (a999e5c) had settled it
with "no field complaints since", and that the remaining exhaustion was
transient and likely sub-perceptual.
That claim did not hold, and the note was still sitting there to mislead the next
reader. All five 4.11.674 player logs are saturated: 3031/4657/5245/6275/6571
failures, first one ~10% into a match, still failing at 97%.
The prior note's core reasoning was about raising the source BUDGET -- more
voices mixing = more CPU during heavy combat -- and it is sound; BT_AUDIO_SOURCES
stays opt-in and unset. But it does not apply to pooling, which changes no
budget: idle pooled sources are stopped and detached and cost nothing to mix.
The note asks whoever touches this to measure frame time, so I did, same bench
both ways:
pre-fix 8.672 ms weighted avg over 9390 frames
post-fix 7.794 ms weighted avg over 10047 frames
Removing ~20k alGenSources/alDeleteSources driver calls per match is a net CPU
WIN, not a cost. Single run each, so indicative rather than definitive, but it
points the opposite way from the concern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every 4.11.674 player log is saturated with acquisition failures -- 3031, 4657,
5245, 6275, 6571 across five machines. In Lynx's largest session the first
failure lands 9.3% in and they continue to 96.8%: once it starts it never
recovers for the rest of the match.
CAUSE: RequestAudioChannels called alGenSources() per sound event and
ReleaseSourceSet called alDeleteSources() on release -- create and destroy per
sound. OpenAL sources are a scarce driver resource (OpenAL Soft caps a context
at 256) and a combat burst churned straight through the ceiling.
The two counters looked contradictory and were the tell: ACQUIRE FAILED always
printed live=256 while the 30s census printed live=6. Same global, sampled at
different moments -- sources spike to the cap during a burst and drain back
between them. Churn, not a steady leak.
FIX: generate sources once, up to a cap, and recycle them through a free list.
Release scrubs and parks instead of deleting. Steady-state play performs no AL
allocation at all.
The scrub is load-bearing, not hygiene: a recycled source carries whatever the
previous owner set, and the engine sets AL_LOOPING per sound
(L4AUDLVL.cpp:327). Hand a looping source to a one-shot and it plays forever --
which is the "sound stuck looping" family (#51, #5). Every reusable property is
reset at the single point where a source changes owner.
VERIFIED (scratchpad/night8/audiopool.sh + the two-node mp_burst.sh):
solo, sustained fire : 0 failures, pooled 152, reuses 21998
two nodes, 4 min : 0 failures on both, pooled 148/149, reuses ~7000 each
⚠ HONEST LIMIT: the bench does NOT reproduce the field failure -- the PRE-fix
binary also scores 0 on it (peak 49 live), because solo/2-node combat is not
dense enough to reach 256. So this verifies the pool works and allocates
nothing in steady state; it does NOT by itself prove the field failures are
gone. The five field logs remain the "before".
Peak demand is set by how many audio COMPONENTS are alive (each reserves a
SourceSet of up to 25 voices and holds them), not by audible sounds: measured
high-water 138 solo, 149 two-node. That scales with player count, so the cap is
set near the driver ceiling (240) and the pool now logs its high-water mark once
per 25-source band -- so the next playtest sizes this from field data instead of
a guess. Growth also self-limits: if a driver offers fewer sources than the cap,
alGenSources simply fails, growth stops, and the pool recycles what it has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Players: "the actual enemy mech in external view is not showing darkened armor
panels". We swapped destroyed LIMB meshes but never darkened a panel.
The 1995 game darkens armour through the MATERIALS. MakeMechRenderables
(FUN_004cef28) built, per (damage zone, material), a watcher
FUN_004573e4(material, &zone->damageLevel, 0.1f) that snapshotted the materials
content/VIDEO/particles.png (128x128 RGBA): the texture L4PARTICLES has tried
to load since the engine was written. Boot-verified: [particles] device
objects created (max=8192, texture=loaded). Every particle effect now draws
textured -- the authored look; the arcade pods displayed untextured quads
because the file never shipped. Comments updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User + old-timers were right; the earlier "no voice exists" verdict was wrong.
The authored mech audio has state watchers on Entity.SimulationState==3/4
(the binary's one-cell mech+0x40) that start sequence notes 29,16,40 -- two
klaxon hits then Warnings01 zone 8 (key 40-41) = the "reverse disabled"
voice line the testers remember. The port's cell split (graphicAlarm vs
engine simulationState, gotcha #23) meant the trigger value never arrived.
- mechdmg: mirror gimp 3/4 into SetSimulationState at the leg-half crossing
(guarded: never stomps disabled/fall/dead states). Voice verified playing
end-to-end on the bench: SetupPatch bank2 patch113 note=40 ->
Warnings01_z7.wav.
- mech.cpp: BT_GIMP_SAFE_BASE_READ on all seven Simulation::ReadUpdateRecord
sites -- gimp is monotonic per life; a record captured pre-gimp must not
stomp the cell (the loopback otherwise perpetuates the stale value and
restarts the warning on every damage event).
- diagnostics (all env-gated): [statefire]/[startreq]/[animind]/[gimp-sim]/
[simstomp]/[indstomp] + StateIndicator::DebugAudioWatcherCount + raised
spatial-log caps. These traced the whole chain and PROVED no SetState
path stomps the cell.
OPEN (follow-up): a RAW writer (bypasses SetState entirely; invisible to the
indicator-level trap) resets the cell between damage events -- under the
bench's 1 Hz metronome harness it restarted the sequence before the 1.8 s
voice note; sporadic real-play damage is unaffected (one edge -> full
sequence). Needs a cdb write-watchpoint session; candidates: a recon raw
+0x2c-equivalent write or a struct copy spanning it.
Also decoded en route: the AnimationState triggers on the limp states play
EngineShiftRev01 (the downshift foley) -- working, and NOT the voice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author's correction to the shape of yesterday's option. The intended workflow
is: place the displays where you want them WITH their frames, quit, then edit
glass_layout.cfg to turn the frame off. So a ,noframe window should be pinned,
not draggable -- removed the WM_NCHITTEST -> HTCAPTION handler that made the
surface a drag handle.
The arrangement being finished is the point: with no caption there is nothing
to drag it by, which is exactly what you want on a wall of monitors. Delete
the flag to get the frame (and the dragging) back.
Unchanged and still needed: SaveLayout writes the flag back, because any later
drag of a FRAMED window rewrites the whole file and would otherwise strip it.
Re-verified live:
- Heat MFD comes up with WS_CAPTION clear and answers WM_NCHITTEST on its
surface with HTCLIENT (pinned) -- no window reports HTCAPTION any more.
- Comm MFD (unflagged) keeps its caption.
- A framed window's WM_EXITSIZEMOVE rewrote the file and BOTH ,noframe flags
survived, with the new header explaining the arrange-first workflow.
- surround / exploded / pod / dev boot and simulate clean.
Docs updated to describe the two-stage workflow rather than surface dragging:
the file's own header, environ.ini's BT_GLASS_LAYOUT block, context/
glass-cockpit.md and the ledger (which records the removed behaviour so the
next reader does not re-add it).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Append it to a window's line and that window becomes a bare WS_POPUP -- no
caption, no border, just the display and its buttons -- for a multi-monitor
wall where the chrome is only noise:
Heat MFD=1920,0,657,539,noframe
Options are comma-separated after the four numbers and unknown ones are
ignored, so an older build reading a newer file loses the option but never the
line (the bindings.txt grammar rule, applied here).
TWO THINGS THAT WOULD HAVE MADE IT A TRAP, both handled:
- A frameless window has NO TITLE BAR TO DRAG, which is the entire point of
the sticky layout. So a noframe window is dragged BY ITS SURFACE:
WM_NCHITTEST returns HTCAPTION anywhere that is not a button, HTCLIENT over
one. Buttons stay clickable, and because Windows drives the move the drag
still ends in WM_EXITSIZEMOVE -- so it still saves.
- SaveLayout rewrites the WHOLE file, so it writes the flag back. Without
that, the first finished-drag after adding the option would have silently
stripped it.
Ordering: the flag is read BEFORE frame sizing (a quiet LoadLayout pre-pass,
then the normal pass after ComputeLayout), because AdjustWindowRect -- and
therefore the ring placement -- depends on whether a window carries chrome.
The pre-pass is quiet so the restore still logs exactly once.
Verified live: two flagged windows came up with WS_CAPTION clear while the
other five kept it (read back with GetWindowLong(GWL_STYLE)); a click still
dispatched on the frameless radar (CLICK 'Secondary / Radar' addr=0x18); and a
WM_EXITSIZEMOVE save round-tripped both flags back into the rewritten file.
surround / exploded / layout=off / pod / dev all boot and simulate clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight byte-identical field stacks from night 6, all one player, all in an
Owens: ParticleEngine::Destroy +0x11, access=0 target=0x0, from the plain
per-frame render path. Nothing in the stack touches weapons or the Owens.
Conn Man's Surface Pro 9 (Iris Xe, 128 MB shared) is simply the only GPU in
the fleet that ever actually LOSES the D3D9 device -- his two-trigger
missile+laser bursts are what provoke the timeout, not what crashes.
What crashed is our device-loss handling, which was wrong three ways at once,
in two inline copies (the scene Present and the wait-screen Present):
1. On D3DERR_DEVICELOST it called Reset() IMMEDIATELY. Reset on a
still-lost device ALWAYS fails, and V() only logs. There was no
TestCooperativeLevel gate at all.
2. It then ran ParticleEngine::Initialize against the lost device. The
creates fail there and NULL their out-params -- proven, not assumed:
the bench repro faults at target=0x0, not at a dangling address.
3. The next lost frame called ParticleEngine::Destroy again, which
Release()d those NULLs blind. Read of vtable at 0x0. Dead.
So: lost frame 1 tears down and leaves NULLs, lost frame 2 crashes. Two
frames, every time, deterministic -- which is exactly why all 8 field stacks
are byte-identical.
Reproduced before fixing. BT_DEVICELOST_TEST=<frame>,crashrepro runs the
field sequence on the bench; on the unfixed build it died at Destroy +0x11,
access=0 target=0x0, and symbolized to the same four frames as the field
logs. Same shape, same offsets-modulo-hook. That run also proved the
out-param-nulling assumption the whole diagnosis rested on.
The fix -- one shared DPLRenderer::BTResetLostDevice() replacing both inline
copies:
- Destroy() is idempotent and null-safe, and nulls after release.
- Reset() is gated on TestCooperativeLevel() != D3DERR_DEVICELOST; while
the driver still says lost, skip the frame and retry.
- The Reset HRESULT is checked; on failure, log and retry next frame
instead of driving on.
- On success, re-create via the new CreateDeviceObjects(), NOT
Initialize(): Initialize memsets the installed-effects table, so every
reset that DID succeed silently killed all particle effects for the rest
of the mission. The quieter sibling bug, fixed by the same split.
- Initialize checks its HRESULTs and defends MAXPARTICLES<=0; the draw
paths guard the NULL buffer, and ExecuteParticles keeps draining
particles while the engine is dormant so they cannot pile up.
Verified: the crashrepro shape now logs SURVIVED and play continues; three
forced full loss/reset cycles each log "[render] device reset OK"; a plain
run is assert-free.
Found while verifying, worth its own line: VIDEO\particles.png has NEVER
existed -- not in the tree, not in BTL4.RES, not anywhere in git history.
The texture load has failed on every machine since the engine was written,
and every billboard particle ever rendered was untextured quads via
SetTexture(0, NULL). RenderParticles deliberately does NOT gate on the
texture -- that would disable all particles everywhere; untextured IS the
shipped look. Filed separately; a real particle sheet is a content task.
The field verification that counts is Conn Man flying his exact crash
loadout on this build: instead of a dead process he should see at worst a
brief hitch and "[render] device reset OK" in his log. #35 stays open until
that happens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BT_GLASS_PANELS windows self-place in a pod-faithful ring and re-snap
once the main window is up. They carry WS_CAPTION so a dev can drag them,
but the drag never survived the menu->mission->menu relaunch loop.
BT_GLASS_LAYOUT (L4GLASSWIN.cpp) adds opt-in persistence to a cwd-relative
glass_layout.cfg beside bindings.txt (gitignored):
off/0/unset computed ring only, no file I/O (default, pod-faithful)
load restore saved positions on startup (per-window fallback
to computed); never writes
save/adjust restore first, then rewrite on each finished drag
(WM_EXITSIZEMOVE) and on teardown -- the round trip
One "<title>=x,y,w,h" line per window; position restored, size ignored
(frame size is deterministic from content, so an old w,h can't distort a
later geometry change). A restored window is flagged so ComputeLayout's
post-main-window re-snap leaves hand-placed windows alone. Native
analogue of TeslaRel410 pod-launch's per-rig --bridge-pos/--layout args.
Verified [T2 runtime round-trip]: load restored 7 from a seeded cfg;
save wrote all 7 with a window moved to 777,333 (GetWindowRect read the
live window); relaunch in load put that window physically at 777,333
after the re-snap fired. Release links clean (only the 40 tolerated
/FORCE externals).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The BT_GLASS_PANELS=1 per-display windows tinted the mono MFD surfaces pure
WHITE (MfdMonoTint 0x00FFFFFF) -- the cockpit surround already used phosphor
green via BT_COCKPIT_TINT (L4VB16 CkTint). New GlassMfdTint(): default the
standard green rgb(33,255,66)=0x0021FF42, overridable by the SAME
BT_COCKPIT_TINT=RRGGBB env the surround honours (used verbatim as the
0x00RRGGBB ExpandPlaneToBGRA tint -- no R5G6B5 packing, which is the
surround's D3D path).
Pixel-verified live (BT_GLASS_PANELS=1): all lit interior MFD pixels
R33 G255 B66 = 21FF42 by default (was white); BT_COCKPIT_TINT=FF8000 ->
R255 G128 B0. Radar keeps its amber palette (monoTint -1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correcting my own framing in 7e57816. The dropout players actually reported was
fixed on 2026-07-23 by a999e5c, and nobody has complained since. I read the
leftover ACQUIRE FAILED lines in the night-5 logs as the same live bug and
raised the ceiling by default. They are not the same bug.
What is still in the logs is transient exhaustion, not a leak: the census sits
at 45-60 live, spikes during a firefight, and drains straight back (226 -> 42),
which is the priority steal loop working. A voice dropped in that window is
competing with ~256 already sounding, so it is very likely sub-perceptual --
which is consistent with the thing nobody is reporting.
And raising the cap is not free. The 256 ceiling doubles as a governor: the
steal loop only steals when the incoming source outranks a running one, so a
higher cap means many more voices mixing simultaneously, and with EFX reverb and
the lowpass chains live that is real CPU -- spent precisely during heavy combat,
when the frame budget is already tightest. I verified the ceiling moves. I never
measured frame time under a real firefight, and a solo bench cannot produce one.
Shipping that to testers tonight would be gambling their framerate against a
complaint nobody is making.
So the knob stays and the default does not move. BT_AUDIO_SOURCES=<n> raises it
with no rebuild if dropouts are ever reported again -- measure frame time while
you do. Unset reports `granted mono=255 stereo=1`, exactly what testers have
been running.
Keeping the diagnostic read-back either way, since it earned its keep: asking
for 64 grants 240, because OpenAL Soft has a floor of its own, which is also why
the NULL default lands on 256. A request is not a promise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report from 2026-07-23 was "audio cutting in and out toward the end of the
match". The night-5 logs say it precisely: 2017 ACQUIRE FAILED lines in a single
match, and every last one of them at live=256.
It is not a leak, which is where I started and where the code comments still
pointed. The source census sits at a healthy 45-60 live, spikes during
firefights, and drains right back down afterwards -- 226 back to 42 in one
window -- so the engine's priority steal loop is doing exactly what it should:
AudioSourceStop/SuspendMaintenance -> ReleaseChannels -> ReleaseSourceSet ->
alDeleteSources, with the live counter following it down. The 2026-07-23 fix
(a999e5c, deleting sources one at a time instead of the spec-atomic bulk call)
was real and is what makes that drain work. It just was not the ceiling.
The ceiling was ours. MakeAudioRenderer called alcCreateContext(device, NULL) --
no attribute list at all -- so OpenAL Soft applied its default budget of 256
mono sources. That number has nothing to do with the 1995 audio hardware; it is
just what you get for not asking. Meanwhile a busy match logs about 7200
explosions, each spawning three DPLIndependantEffect voices, so the pool pins at
the cap and every acquire during that window fails outright and drops its sound.
The peak we actually observed was 226 against a 256 cap, i.e. the bursts were
already scraping the ceiling.
So ask. ALC_MONO_SOURCES at 1024 by default, BT_AUDIO_SOURCES to override for
A/B testing, and read back what the driver GRANTED rather than assuming the
request was honoured. That last part earns its keep: asking for 64 grants 240,
because OpenAL Soft has a floor of its own -- which is also the reason the NULL
default landed on 256 in the first place.
requested 1024 -> granted 1024
requested 2048 -> granted 2048
requested 64 -> granted 240 (driver floor)
Costs mixing headroom, not hardware voices -- OpenAL Soft mixes in software and
only touches voices that are actually sounding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THE CLASS (established by Gitea #35's 515 fix, confirmed growing tonight):
code the binary could run bare because a pod's mission world always existed,
crashing in the port's MP join/teardown/respawn windows where mission, player,
or a roster link is briefly NULL. Swept every GetMissionPlayer()/
GetPlayerVehicle()/GetCurrentMission() chain in the tree (23+7 sites).
GUARDED (the 515 pattern -- authentic behavior whenever the object exists,
skip/degrade + loud log when mid-teardown):
1. DPLRenderer::SortAndReloadNameBitmaps -- runs in the round-STOP path (the
end-of-round circle), raw mission+player+entity-manager derefs.
2. DPLRenderer::LoadOrdinalBitmaps -- called from (1); raw
GetMissionPlayer()->GetInstance() evaluated on EVERY machine (the camera-
director test derefs before it branches).
3. DPLRenderer::LoadNameBitmaps -- same window, raw GetPlayerCount() chain.
4. Reservoir::Reservoir master-gate block -- THE CAPTURED GAMES-NIGHT CRASH
([crash] btl4+0x4990d = this ctor inlined into CreateReservoirSubsystem,
AV reading NULL+0x1d0): linkedSinks.Resolve() derefed raw; +0x1d0 is
exactly the masterScale FILD of the resolved master heat-sink bank
(heatSinkCount @0x1D0 -- the offset the crash named). Observed trigger: a
DUPLICATE-PILOT egg (the seat-ghost bug put one identity in two seats)
poisoning the ID registry, so Resolve() nulled during viewpoint-entity
construction and at least two pods died loading the same egg. Now: loud
'[spawn] FATAL-AVOIDED' + degrade to the inactive-copy shape. The
duplicate-pilot ROOT CAUSE is the seat-ghost fix (tracked separately);
this guard makes the failure survivable and self-identifying either way.
CLASSIFIED SAFE, not touched: btl4mppr/btplayer/mechmppr/btl4gau3 chains
(guarded or null-tolerant by design); APP.cpp's five Check(player) sites
(mission state machine -- player exists by construction, original engine
ordering); CAMMGR/CULTURAL GetCurrentMission chains (construction-time).
VERIFIED: build clean (0 errors, no new unresolved externs); solo smoke on the
guarded exe -- zero FATAL-AVOIDED lines (guards silent on the healthy path),
subsystems simulating, no faults. STILL OWED: a full launch->stop rig cycle
(exercises the guarded round-stop path live) and the duplicate-pilot-egg repro
-- both blocked on port 1500 while the operator's console session is up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>