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>
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>
The opaque BLACK rectangle under a destroyed truck was basev:bvx9_mtl (the
MECHMD wreck's 4-vtx scorch base): the material authors NO diffuse, ambient
(0,0,0), texture bexp9_tex (MAP 'bexp' slice 8, BEXP.BSL) + ramp cdusty.
collectMaterials' AMBIENT fallback took the all-zero ambient as a REAL
colour (hasDiffuse=true, black) and tinted the whole quad black regardless
of its texture. An all-zero ambient means UNSET, not 'tint black' (a black
tint renders geometry invisible -- never authored intent): the fallback now
requires r+g+b > 0.001. Pixel-verified (BT_SHOT ram run): the scorch base
draws as the authored charred burn patch under the wreck debris.
Also: cross-library TEXTURE registry (globalTexMaps, BT_TEX_XLIB=0
disables) mirroring the existing globalRamps -- material libs reference
textures DEFINED in other libs (bexp9_tex is defined in BTARENA/BTFX, not
BASEV.BMF); a per-file texMaps miss left such batches untextured. Same
first-wins sweep of the indexed BMFs on first miss.
Regression scope: only materials authoring a zero ambient AND no diffuse
change (they now ramp/texture instead of black); cross-lib refs that
previously resolved keep their same-file definitions (checked first).
Awaiting human verification in a live session (issue #3 stays open).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The playtest falsified the first revival: the truck vanished with no wreck.
Cause: the rubble (and any additional cultural object) was built as a
DPLStaticChildRenderable / DCSInstanceRenderable added at TOP LEVEL of
mRenderables -- those classes read the bare matrix stack (identity at top
level), so the rubble drew at the WORLD ORIGIN, not at the icon (and before
the consolidation exclusion it was consolidated there permanently).
Fix: every cultural video object (intact AND rubble) is a RootRenderable
(Static) -- placed from myEntity->localToWorld, registered into the frame
pass lists by the same per-frame Execute path the intact model draws
through. The revived state switch then genuinely swaps them.
PIXEL-VERIFIED via BT_SHOT screenshot timeline (full-speed BT_GOTO ram,
ARENA1 MECHMOVR truck): intact truck visible -> ram/death -> the MECHMD
wreck debris renders scattered at the truck's exact spot, intact model
gone, mech walks the footprint. ARENA1 census: 57 rubble-typed objects
across the 124 icons (MECHMD/bpip1d/ab07d_FR...).
NOT yet closed (open on #3): (a) the wreck's scorch base quad draws as an
opaque BLACK rectangle (13 plain ops on MECHMD -- material/ramp resolve,
not a blend flag); (b) the death explosion (psfx 1008) still not visibly
confirmed; (c) the authored burning-fire state visual unreconstructed.
Diag added: [cultobj] census (BT_CULT_LOG) -- per-object file/type/op flags.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Playtest report: trucks play a collision sound on impact but never change
state -- no explosion swap, no destroyed model, collision volume persists.
Investigation (BT_CULT_LOG census + BT_GOTO ram harness): the SIM was never
broken. ARENA1 map-streams 124 CulturalIcon entities, each with a damage
zone, explosion resource and removeOnDeath; the mech crunch dispatch
(ProcessCollision -> BTDispatchCollisionDamage) reaches them; ~19 walking
bumps burn the zone -> CulturalIcon::TakeDamageMessageHandler spawns the
Explosion (psfx effect 1008 observed), posts the delayed BurningState and
deletes the collision boxes. All of that ran correctly and invisibly.
The ACTUAL bug: the 2007 WinTesla port fully stubbed
StateInstanceSwitchRenderable ('STUBBED: DPL RB 1/14/07') -- ctor never
registered on the state dial, Execute never toggled anything -- so the
intact->rubble visual swap on BurningState NEVER fired, for every cultural
icon in the game. The destroyed truck kept its intact model: exactly the
reported 'no state change'.
Revival (D3D9-native, 1995 semantics verbatim):
- StateInstanceSwitchRenderable now controls the draw COMPONENT via the
SetDrawObj in-place drawable swap (the mech RemakeEntity mechanism):
visible = captured d3d_OBJECT, hidden = NULL. Initial state in the ctor,
AddVideoWatcher on the SimulationState dial (SetState fires video
watchers), toggle only on a real change.
- L4VIDEO cultural case passes the draw component + object (the dead
dpl_INSTANCE param is gone); [video] Object hides at BurningState,
Rubble shows.
- REQUIRED: cultural objects stay OUT of static-mesh consolidation
(RecurseStaticObject, same exclusion class as banded LODs/shadows) --
a merged static draws forever regardless of DrawObj.
- Permanent env diag BT_CULT_LOG: creation census (zones/explosion/flags/
pos), TakeDamage trace, death transition, [cultvis] switch actions.
Verified live (BT_GOTO=-620,-328 ram, ARENA1): 19 crunches -> DYING ->
explosion 1008 at the icon -> [cultvis] HIDE x2 (intact) + SHOW (rubble)
-> contacts cease, mech walks into the former footprint. Boot + render
un-regressed (statics consolidate as before minus the 124 icons).
KB: context/rendering.md new section (the full chain + the consolidation
gotcha). MP replicant path shares the same SetState watcher (cross-pod
verification pending -- noted in the issue).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The crash (any typed key in a glass session; found via the backtick report):
the merge-reconciliation stand-down declared extern BTRIODevicePresent at
BLOCK scope inside the extern-C BTInputSuppressKey -- the declaration
inherited C linkage, _BTRIODevicePresent went UNRESOLVED, and /FORCE bound
the call to garbage (cdb: wild call into Sensor::DefaultData from
LBE4ControlsManager::Execute's suppression check). The tolerated-LNK2019
batch hid the new unresolved external -- the exact CLAUDE.md /FORCE trap.
Fix: the extern moved to file scope (C++ linkage); link verified clean of
_BTRIODevicePresent.
Backtick feature (per Cyd: backtick = 1st/3rd person): PadRIO edge-detects
VK_OEM_3 in its poll (focus-guarded, message-path-free) and the mech4 view
block consumes it beside the V action (which stands down with the binding
engine in glass mode). Verified live: two real presses -> [view] COCKPIT
eyepoint -> [view] external chase, game alive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-reported: glass left/right inverted. Closed with live sign algebra:
the joystick-group wire push was traced end to end (new env-gated
BT_CTRLMAP_LOG push trace + record mode-mask in the install log) --
measured wire stickX=-1 -> turnDemand=-1, the SAME demand the
user-verified dev D-key-RIGHT produces (stickX=+1 -> turnDemand=-1 via the
bridge's negate-once, cb82d8c). The mapper interprets the authentic RIO
WIRE convention: stick right = NEGATIVE JoystickX (the vRIO/RIOJoy
calibration convention). PadRIO now publishes X negated (all sources --
keyboard deflect, pad LX, panel -- uniformly); Y stays screen-sign;
L4PADFLIP still flips both. Also: PadRIO honors BT_KEY_NOFOCUS=1 (the
btinput harness override) for automation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First dist feedback ('there is no glass panel'): both windows opened at the
top-left with no-activate and the game window then covered them. The pod's
physical panel is always visible -- so is the glass one now: the button
panel parks at the screen's right edge, the plasma bottom-right, both
WS_EX_TOPMOST. Verified live: panel left=2616 top=12 topmost, plasma
bottom-right topmost, game window below.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per Cyd: the two 4x4 hex keypads leave the glass panel (engine keypad units
stay reachable via bindings.txt); the four board columns (Thr/Sec/Scr/Joy)
move to the center between the lower MFD stacks and rise into the vacated
internal-keypad space -- 72 controls, ~5 rows shorter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
33 master commits in (relay TCP/UDP + PySide6 operator console, CONTROLS.MAP
+XInput binding engine, camera seats, torso pitch aim, sign fixes, the 1995
manual, version stamping, 18-mech certification). Conflicts: .gitignore +
CLAUDE.md router rows (combined).
SEMANTIC RECONCILIATION (the one real overlap): masters btinput binding
engine (ungated, CONTROLS.MAP) and the glass PadRIO (gated, bindings.txt)
would both read the keyboard/pad in a glass+PAD session. btinput now joins
the stand-down convention: BTInputPoll yields (and BTInputSuppressKey claims
NOTHING, so authentic hotkeys flow) when an operational cockpit device owns
the input path -- BTRIODevicePresent, BT_KEY_BRIDGE force-override honored,
forced harness exempt. One input system per mode: btinput on pod/dev
desktops, PadRIO on glass. The mechmppr/mech4 bridge merges composed clean
(masters negate-once sign fix inside our device-gated bridge). The D1 relay
keeps its own raw sockets by design (an alternative LAN wire; Steam and
relay are separate modes).
Verified post-merge: all 3 configs build; glass boots with [input] binding
engine standing down + PadRIO owning input (30 ticks); pod forced-walk
speedDemand=61.501 with btinput ACTIVE; 2-node loopback MP full 31/31
mission, 76/76 ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stubbed dpl-instance ranking display is reborn as screen-space
quads in CameraShipHUDRenderable::Render:
- followed-player callsign banner (bottom center; the old direct
index was correct -- Execute already converts to the 0-based
texture slot)
- the RANKING WINDOW: one row per scoring player, [ordinal][callsign]
in rank order right of center; visibility = the Director's
authentic flash logic (10s on / 15s off, solid final 30s); rows
follow LIVE playerRank pointers so they re-sort as scores change
- discovery: each 128x32 ordinal bitmap packs TWO ordinals side by
side ('1st|2nd', '3rd|4th' -- why 4 bitmaps serve 8 players);
draw = texture rank/2 with a u-half selected by rank parity
- alpha-blended A4R4G4B4 white-on-transparent textures x green
diffuse = the authentic green look
BT_SHOT moved AFTER the 2D pass -- it captured the backbuffer pre-HUD,
so overlays were on screen but invisible to screenshots (cost one
false debugging round). GetOrdinalTexture accessor added.
Screenshot-verified: '1st MAVERICK' standings + MAVERICK banner over
live auto-directed coverage; 2-node mech smoke PASS (un-regressed;
the new render path only executes when a CameraDirector HUD exists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User request after the first internet join attempt: 'should we modify
the program so it sits and retries with a waiting-for-session?' The
arcade model is a pod that waits for the operator, not one that
aborts.
- RelayRequestSeat: retry loop (2s dials, 30 min cap) with live status
into the join.bat console window (BTRelayWaitStatus: AttachConsole to
the parent cmd; silent without one) -- banner + progress dots, a
distinct 'game is FULL, waiting for a free seat' state, and 'seat
assigned -- joining!' on success. The unreachable MessageBox now
only fires at the 30-minute give-up.
- BT_RELAY=auto discovery: same patient loop ('searching for a game on
this network...'), LAN-only guidance in the give-up box.
Verified live: pod launched against a dead relay, waited 20+s, relay
started, pod seat-requested/registered/UDP-up automatically with no
user action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (first internet-join attempt): both join bats 'hard
crash' on the remote machine. Root cause was procedural -- the host
session was not running -- but the failure PRESENTATION was the bug:
Release Fail() is a bare abort(), so a dead relay = silent process
death + instantly-closing console window.
- L4NET: the two player-facing dead-ends (LAN discovery no-answer,
seat request unreachable/full) now show a MessageBox saying what
happened and what to do before exiting (verified live against a
dead relay: 'BattleTech -- can't join the game' appears).
- join bats: echo + pause after exit so the window stays readable;
join_lan.bat header now says it is LAN-only (the remote user ran
both -- join_lan can never work over the internet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod's controls reach the game through the RIO serial board; on a
desktop that hardware is a keyboard shim of hardcoded GetAsyncKeyState
reads. New binding engine (game/reconstructed/btinput.cpp) maps PC keys
and an XInput pad onto the authentic channels through a user-editable
file (content/CONTROLS.MAP, community-suggested grammar, corrected):
key/pad -> button <addr> real buttonGroup emissions, mirroring the
RIO convention exactly (press a+1 w/ mode
mask saved, release -a-1 w/ saved mask;
L4CTRL.cpp:2470-2520) -- aux/preset banks,
hotbox, panic, reverse thrust all reachable
key/pad -> axis Throttle rate (lever), pedals/JoystickX
deflect (turn/twist) into the existing
virtual-controls integrators (feel, gait
detent, spring-centering all unchanged)
keypad pilot|external <n> keyboardGroup key values ('0'-'F')
pckey <char> any authentic 1995 typed hotkey
action <name> port dev controls (view, all-stop, ...)
Keys claimed by a binding are SUPPRESSED from the legacy WM_CHAR/KEYUP
feed -- ends the historic double-dispatch ('w' drove AND selected pilot
0; F5's key-up value 0x74 aliased to the 't' hotkey; letter key-ups fed
the developer fake-event dispatcher). Unbound keys keep their authentic
meaning. The dual-use 'V' is split: V = view toggle, B = look behind.
Default profile = WASD classic (compiled-in twin; delete the file to
restore). CONTROLS_NUMPAD.MAP ships the corrected community layout
(keypads are NOT buttons 0x50-0x6F -- that space doesn't exist; no
clickable cockpit exists so everything needs a binding; keyboard fire
buttons added; 0x36/0x37 are hotbox not 'config'; missiles moved off
Ctrl). XInput loads dynamically (1_4 -> 9_1_0), disconnected-pad
probing rate-limited; pad sticks write the axes as absolute positions
(the spring is physical), triggers/buttons per the file.
Verified live: bindings load (43), W and NumPad8 drive (speedDemand 0 ->
61.5), X all-stop (spd -> 0), aux-bank emission (D1 -> [input] 0x2f
PRESS/release under the numpad profile), suppression both directions
(posted 'r' reaches [keych], posted 'w' swallowed), full-keyspace
WM_CHAR+WM_KEYUP fuzz x3 survived with sim advancing, XInput graceful
with no pad, 2-node relay session un-regressed (full ladder + UDP).
Pad-in-hand testing still needs a physical controller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pod launched with no BT_SELF asks the relay for a seat before joining:
new control frames SEAT_REQUEST (-6) -> relay reserves the lowest roster
seat not claimed or reserved (60s reservation so simultaneous joiners
can't race onto one seat) -> SEAT_ASSIGN (-7, int32 hostID + NUL tag)
becomes relaySelf; everything downstream (egg self-match, HELLO) runs
exactly as if BT_SELF had been set. Roster full -> SEAT_FULL (-8) ->
clean Fail(). A real HELLO pops the reservation; a pod drop frees the
seat. Explicit BT_SELF still claims a specific seat (the operator's
local launches use it).
Client: L4NetworkManager::RelayRequestSeat (throwaway TCP dial to the
relay game port, 10s reply window). Relay: SEAT_REQUEST branch in
_handle_game_frame. Operator exporter collapsed from per-seat
join_as_playerN.bat to ONE universal join.bat (+ join_lan.bat with
BT_RELAY=auto) -- every player gets the same file, first come first
served, the arcade walk-up-to-a-pod model. players/ regenerated.
Verified: 8/8 seat stub tests (distinct seats in roster order, FULL on
exhaustion, claimed+reserved stays FULL, HELLO claim accepted, duplicate
HELLO refused); 2-node localhost e2e with NO BT_SELF on either pod ->
both seated, full ladder, RunMission pair, UDP flowing post-launch;
regression smoke: explicit-BT_SELF relay session clean with zero seat
requests, classic mesh (no BT_RELAY) un-regressed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LAN players now find the game with zero configuration -- the 90s arcade
model (cabinets locate the operator station) restored:
- Client (L4NET RelayDiscover): BT_RELAY=auto broadcasts 'BTR1DISC' on
udp/15999 (255.255.255.255 AND 127.0.0.1 -- broadcast doesn't reliably
loop back on Windows; covers same-box sessions), 5 x 1s attempts; the
answering relay's SOURCE IP + its advertised console port become the
relay address. Explicit <host>:<port> path unchanged; mesh untouched.
- Relay (btconsole.py): best-effort discovery responder on udp/15999
answers 'BTR1HERE' + <u16 consolePort>; degrades gracefully (logged) if
the port is taken.
- Operator app: Export player scripts now writes a join_as_playerN_lan.bat
pair (BT_RELAY=auto) next to each internet script -- LAN guests
double-click and are found; internet guests use the public-host script.
Verified 2-node: both pods BT_RELAY=auto -> probe answered through a real
interface (not just loopback) -> discovered 172.19.x.x:1500 -> full session
to RunningMission. KB: multiplayer.md D1 section updated (+ the operator
console entry).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ~60Hz entity update records now ride UDP in relay mode, so a lost/late
datagram is dropped (dead reckoning absorbs the gap) instead of head-of-line-
blocking the reliable TCP stream -- the fix for internet rubber-banding.
Reliable traffic (make/damage/death/control/egg/launch) stays on TCP.
This RESTORES the authentic 1995 NETNUB split, it doesn't invent one:
Receiver::Message defaults messageFlags=ReliableFlag; the update path clears
it (flags=0, ENTITY.cpp:590); Mode(UnreliableMode) fires at LoadingMission
(APP.cpp:704 -- the 2007 port's Mode() was a no-op, now it STORES the mode).
Routing gate = (mode==UnreliableMode && !(flags & ReliableFlag)) -- flag AND
mode, so the map-stream creation messages (also flags=0 but flowing during the
still-Reliable CreatingMission window) ride TCP for free.
Client (L4NET):
- Mode() stores currentNetworkMode (was ignored).
- ConnectRelayUdp: UDP socket connect()ed to the relay game port; HELLO
(outbound punch-through, relay learns our endpoint); udpUp on HELLO-ACK.
BT_RELAY_TCP_ONLY=1 disables the channel (UDP-blocked nets degrade to the
pure-TCP checkpoint automatically -- no ACK => everything stays on TCP).
- RelayUdpSendFrame: {route, fromHost, seq} envelope + frame, best-effort.
- RelayUdpKeepalive: ~15s NAT-hold + ~1s HELLO retry until acked.
- CheckRelayUdp: HELLO-ACK sets udpUp; per-sender seq gate drops stale/
out-of-order datagrams; frames handed up envelope-stripped like TCP.
- Send / ExclusiveBroadcast: route unreliable-window traffic to UDP, else TCP.
- CheckBuffers polls the UDP channel first, then TCP; keepalive tick.
(The relay-side UDP forwarder + endpoint learning + --udp-drop hook landed in
phase 1.)
Verified 2-node localhost relay: during a drive the update stream flows on UDP
(relay udp tx 2->190+, TCP frozen at 53) and the peer's replicant tracks the
master to sub-2u; 15% forced drop (--udp-drop 15) stays coherent (tracked to
0.1u); TCP-only fallback confirmed (udp-known drops the TCP-only pod); no
crashes; mesh mode un-regressed. KB: context/multiplayer.md gains the D1
RELAY MODE section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod can now run entirely over the relay: one outbound TCP connection
carries both the console protocol (egg/launch) and all game traffic
(envelope-multiplexed), so internet play works behind NAT with no port
forwarding. All new code is behind 'if (relayMode)' (BT_RELAY unset => mesh
mode byte-for-byte untouched; verified un-regressed).
Env gates (parsed once in the ctor, after WSAStartup so gethostbyname works):
- BT_RELAY=<host>:<consolePort> (host may be a DNS name; game port = +1)
- BT_SELF=<exact [pilots] entry> (NIC matching can't identify us across NAT)
L4NET changes:
- CreateConsoleHost: relay branch dials OUT to the relay console port
(bounded retry; console-less continue on a re-dial) instead of listening.
- StartConnecting: relay self-match by BT_SELF string; peers become VIRTUAL
hosts (INVALID_SOCKET, NoNetworkConnectionStatus) flipped online by the
relay's PEER_UP; then ConnectRelayGame dials the game port + HELLOs. HostID
assignment / remoteHostCount / the connection gate / app ladder unchanged.
- CheckRelay (new): the receive seam -- drains {route,length} envelopes,
synthesizes HostConnected/Disconnected from PEER_UP/DOWN (same messages the
mesh accept path routes), returns game frames envelope-stripped (they
self-identify via NetworkPacketHeader.fromHost; consumers route by payload).
- Send: game-host traffic -> relay unicast envelope (console host excluded --
its legacy protocol is relay-TERMINATED, not routed).
- ExclusiveBroadcast: build ONCE, send ONCE with the broadcast route -- the
relay fans out, killing the mesh's (N-1)x upload duplication.
- RelaySendAll (new): partial-send-safe transmit (required on the multiplexed
socket -- a partial write would desync framing for all peers).
- CheckBuffers: polls CheckRelay first; skips recv on virtual game hosts.
- RelayGameDown: relay-loss synthesizes all-peers-disconnected (match
continues peer-less; pod never exits mid-match).
- Mode(): now STORES the reliable/unreliable mode (was ignored) for the UDP
phase's authentic mode+flag routing.
Verified 2-node localhost relay (MP_RELAY.EGG, tagged [pilots]):
- both pods reach 'All connections completed!' via PEER_UP, then
RunningMission; both mechs' MakeMessages cross the relay (paint x2 each) and
bidirectional 148-byte update records flow (net-tx/net-rx traces; relay
stats tcp rx==tx, registered [2,3]); cockpit/HUD render; no crashes.
- driving one node transmits pose/damage/death frames through the relay.
- mesh smoke (no BT_RELAY): 2-node session still forms + simulates unchanged.
Plan: ~/.claude/plans/partitioned-snuggling-piglet.md. Next: UDP channel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NEW gated (BT_STEAM) game/glass/btl4lobby: an ISteamMatchmaking room replacing
the arcade Site-Management screen -- host creates (lobby data btl4=1), members
join by filter, pilots publish name/mech/color, the host ENTER mints the
roster and signals GO.
THE FAKEIP LESSON (live-verified, prior-art assumption DISPROVEN): a fresh
process gets a DIFFERENT FakeIP, so menu-time fake addresses go stale by
mission time -- the first cycle timed out connecting to its own stale address.
Redesign: roster addresses are opaque ipv4-shaped TOKENS (169.254.77.N with
ports 1501/1502) minted by the host at GO, mapped to Steam IDENTITIES;
connections ride ConnectP2P(identity, channel) with console=0/game=1 virtual
ports; the map reaches mission processes via env (BT_FE_MYFAKE +
BT_FE_STEAMMAP); the GetMyAddress seam feeds the pod its own token for roster
self-match; CheckSocket reports peers by token. L4STEAMNET reworked (no
FakeIP allocation at all); the marshal gained the Steam-wire branch.
Verified single-machine (ON/ON, live Steam): menu -> HOST STEAM LOBBY ->
room -> GO -> token map minted -> egg roster carries the token -> mission
process up with 1 roster token incl. self -> pod self-matches -> stock
console ladder -> mission RUNS (46 ticks). Remaining: the live 2-account
cross-machine session (docs/STEAM_TEST.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NEW gated (BT_STEAM) TU L4STEAMNET: ISteamNetworkingSockets with FakeIP --
pseudo-SOCKET handles (0x5EA0xxxx) behind the L4NET BTNet* seam; the engine
TCP byte stream rides reliable-NoNagle Steam messages re-assembled into
per-connection rings (nonblocking recv semantics preserved: empty ->
WSAEWOULDBLOCK, peer-closed -> 0); two FakeIP ports mirror the arcade
console/game channel convention; listen sockets + accept queues via the
global status-changed callback; graceful degrade to Winsock at every step.
Seam completions in L4NET.CPP: CheckSocket reports a pseudo-socket peer as
its FAKE ipv4:port (the lobby egg roster matches unchanged); OpenConnection
TCP_OPEN delegates FakeIP targets to BTSteamNet_Connect. WinMain installs
on BT_STEAM_NET=1 (lobby launch path / by hand). CMake: gated SDK include/
lib/dll-copy. Both pumps (SteamAPI_RunCallbacks + sockets RunCallbacks)
drive it -- the FakeIP result rides the GENERAL dispatch (first attempt with
only the sockets pump timed out).
Verified live (ON/ON build, Steam client running): SteamAPI_Init OK,
steam_appid.txt(480) auto-written, FakeIP allocated
(169.254.36.58, console 54464 / game 54465); without BT_STEAM_NET or
without Steam the game stays pure Winsock.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BTNetSend/Recv/Accept/Close wrap the 9 raw wire sites in L4NET.CPP (send
@x3, recv, accept @x2, closesocket @x2). OFF builds are PURE CODE MOTION
(bodies are the original calls; the statics inline back); under BT_STEAM
each wrapper first offers the op to the Steam transport (BTSteamNet_*,
lands next) -- handles stay SOCKET-typed so L4Host and every call site
above the seam are identical on both wires. Connection-ESTABLISHMENT
delegates (OpenConnection/listener arms) land with the transport. The 3
BT_NET_TRACE blocks stay at call-site level, untouched.
Regression (pod build, 2-node loopback MP + btconsole relay): mesh forms
through BTNetAccept (console + peer accepts logged), both nodes reach the
full 31/31-subsystem running mission, 71/76 ticks -- byte-identical wire
behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per Cyd: the on-screen panel now mirrors vRIO (C:\VWE\vrio -- CockpitLayout.cs
+ PanelCanvas.cs, itself the RIOJoy profile-editor layout from the original
Win32 RIO mockup): five 4x2 MFD clusters (addresses descending), four 1x8
board columns (Throttle/Secondary/Screen/Joystick), the two 4x4 hex keypads
(0x50/0x60 -- cells emit real keypad KeyEvents: internal=pilot unit, external=
operator unit), physical names (Panic/Main/Hat*/Pinky...), vRIO colors: red
lamp cells + yellow Secondary/Screen + neutral-blue keypads, flash half-
periods 500/250/125ms, right-click latch (gold outline). 104 controls.
PlasmaWindow: the gauge renderer writes the 128x32 buffer TOP-DOWN -- the
bottom-up flip rendered the marquee upside down (user-reported); copy straight.
Button-trap guard (the e2c21c4 pattern): the authentic base
ConfigureMappableMessageHandler FAIL trap is abort() under DEBUGOFF -- any
aux/zoom button without its L4 override reconstructed KILLED the game on
press. Default now: loud [FAIL] log + ignore; BT_BUTTON_TRAP=1 restores the
hard trap. Verified live: synthetic click on a Secondary cell -> 2 [FAIL]
lines (press+release through panel->PadRIO->ProcessRIOEvent->streamed
mapping), game survives and keeps ticking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NEW gated TUs: L4PADPANEL (GDI top-level window, all-W APIs: the pod button
banks -- upper/lower aux, 12 secondary, specials/hat/handle, 63 buttons --
clickable via PadRIO::SetScreenButton with lamp-lit faces from GetLampState,
10 Hz repaint; created by the PadRIO ctor on BT_PAD_PANEL=1) and L4PLASMAWIN
(PlasmaWindow : Video8BitBuffered -- the gauge renderer draws the 128x32
plasma into its pixelBuffer through the SAME code path as the serial device;
Update() blits orange-on-black at L4PLASMASCALE, default x4). Gated seams:
L4GREND.cpp L4PLASMA=SCREEN branch; btl4main.cpp -platform glass preset
(PAD,KEYBOARD + BT_DEV_GAUGES + SCREEN plasma + panel; env always overrides;
non-glass builds log+fall back to DEV); run.cmd glass token. MFD surfaces
need NO new code: the existing dock-bottom / BT_DEV_GAUGES_WINDOW=1 /
BT_DEV_GAUGES_DOCK=1 modes are the display story.
Verified live: -platform glass boots GLASS profile, panel + plasma windows
up, pad detected, dev gauges awake, mission loop clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New engine query BTRIODevicePresent() (L4CTRL.cpp, ungated -- pod-correct for
the serial RIO too): device exists AND IsOperational(). Both dev input
bridges -- the mech4.cpp mapper-attr writes and the mechmppr.cpp BT_KEY_BRIDGE
block -- now gate their WRITES on it: BT_KEY_BRIDGE unset = auto (bridge only
when no device), 0 = force off (the documented pod setting, honored), else
force on. The BT_FORCE_THROTTLE headless harness always rides the bridge.
The mapper demand READ (turn = turnDemand) always runs.
Verified live (glass build, PAD): OS-injected LSHIFT hold slews the PadRIO
throttle 0 -> 0.33 -> 1.0 with pre==thr every frame -- the value arrives via
the ENGINE push through the streamed .CTL binding, not the bridge -- and
speedDemand=61.501 comes out of the authentic InterpretControls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
L4RIO.h splits the abstract cockpit-control surface (RIOBase: enums, the five
analog Scalars, GetNextEvent/SetLamp, no-op serial ops, NEW IsOperational) out
of the serial RIO (RIO : PCSerialPacket, RIOBase -- byte-for-byte behavior kept,
ctor assigns as before); LBE4ControlsManager holds a RIOBase* and gains the
gated L4CONTROLS=PAD factory arm (BT_GLASS; OFF build logs+ignores the token).
NEW gated TUs: L4PADRIO (XInput+keyboard synthesize the surface; 3s hot-plug
re-probe; focus-guarded keys; per-poll AnalogEvent heartbeat; lampState[] +
static SetScreenButton/GetLampState for the panel) and L4PADBINDINGS
(content\bindings.txt profile, self-documenting default written on first run;
deflect/slew/set axis model; addresses validated against ButtonCount).
Verified live (glass build, L4CONTROLS=PAD): bindings written+parsed 44/10/5,
XInput pad detected, 121 streamed mappings install via stock PrimaryRIO path,
2157 frames clean. Pod build (gates OFF) compiles the split with zero
behavioral delta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streamed L4 mapping resource carries the binary's positional attribute ids
(stick=3, throttle=4) but MechControlsMapper chained from Subsystem::NextAttributeID
== 2 -- every streamed record resolved ONE MEMBER LATE (attr 4 -> pedalsPosition,
verified via the new permanent BT_CTRLMAP_LOG diagnostic in CreateStreamedMappings).
A latent real-pod bug: the serial RIO throttle would drive the pedals member; the
dev keyboard bridge masked it. Fix per the mechweap/mech attrPad idiom: ids pinned
to the binary numbering, id-2 gap padded, static_assert-locked. Torso + weapon
chains verified already aligned. Regression: BT_FORCE_THROTTLE headless walk clean
(speedDemand=61.501 through authentic InterpretControls, gait cycles, no faults).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Mech::resourceNameA/B/C -> real CString members (the binary's 16-byte
CStringRepresentation; deep-copy bind from the MakeMessage = FUN_00402a98,
implicit member dtors) + paint-name accessors
- SetupMaterialSubstitutionList reads the real egg names ([paint] log);
TearDown clears the callback first (FUN_004d11e8)
- dpl_SetMaterialNameCallback is real now (L4VIDEO registry); bgfload
MaterialResolver::resolve() applies it to every material name -- the
port analogue of the dpl board rewriting names at load
- MP_BHMC.EGG: color=Red -> Crimson (vehicletable has no Red; binary Fail()ed)
Verified live 2-node MP: crimson MadCat with hip hazard stripes + yellow VGL
leg emblems; white Blackhawk + emblems; replicants painted on both nodes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New L4AUDEFX bridge (OpenAL Soft ALC_EXT_EFX): one EAXReverb aux slot at the
authentic AUDIO.INI global_reverb_scale (0.3) + a scratch AL_FILTER_LOWPASS
(params copied at attach).
F9 filters (was: computed then thrown away -- everything full-bright at all
distances): Dynamic3D ExecuteModel drives GAINHF from highFreqCutoffScale x
brightnessScale x maxMIDIFilterCutoff, UNGATED (decomp part_008.c:7496,
7589-7604 -- every moving 3D sound dulls with distance per the AUDIO.INI
knee-60/exp-2.0 model); Static3D from brightness x max (:7831-7884); Direct
inside its existing gated NRPN-rate block. AWE 100-8000 Hz curve -> EFX
5 kHz-reference gainhf via a 2-pole approximation [T3 curve, endpoints exact].
F11 reverb (was: bone-dry everywhere): 3D patch sources attach an aux send at
Start, exactly where the original sent CC91 = global_reverb_scale
(part_008.c:7278-7394); Direct cockpit sources keep CC91=0 -- dry. The
wet-exterior vs dry-cockpit contrast is back.
F12 placement (was: every cockpit sound dead-center): DirectPatchSource
Start places sources by the authored 6-value position enum (front/rear card
+ pan CC10, decomp @00463848/@004638a8) as listener-relative directions,
composed with the zone L/C/R pan. New PatchResource GetBankID/GetPatchID
pass-throughs (the LOD accessor is protected).
Regression (35s drive+fire): EFX READY, stable, deliveries unregressed, no AL
errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F3 distance: alDistanceModel(AL_NONE); restored the commented authored
distance multiply in Dynamic3D::CalculateSourceVolumeScale, added the same
override to Static3D, dropped the (now inert) AL_MAX_DISTANCE writes. Far
battle audio follows the authored knee/rolloff curve again, and the volume
cull / voice steal / ducking chains are distance-aware.
F4 volume law: AL_GAIN = volume_scale^2 at all 3 per-frame sites (Direct/
Dyn3D/Static3D) -- the GM CC7 squared curve; linear was ~+6 dB at mids.
F10 doppler: alDopplerFactor(0) (AL's model ran wrong constants + sign-
inverted velocity: approaching sources pitched DOWN); the AUTHORED
dopplerCents (AUDIO.INI range/speed-of-sound model, decomp-proven consumer
part_008.c:7466) now adds into the Dynamic3D pitch chain.
F15 ConfigureActivePress: published at the MechSubsystem BASE (binary id 2,
descriptor @0x50de5c -> +0x110); renamed the misnamed vitalSubsystemIndex
member (the weapon ConfigureMappables handler already drives it 0/-1).
Removed the invented Sensor/Myomers duplicates and RESTORED their byte-
exact layouts (0x328/0x358 allocs + asserts). MechWeapon's pinned-id pad
absorbed the +1 chain shift -- which matches the binary's own numbering.
F18 cook-off warning: AmmoBin FireCountdownStarted -> the existing
cookOffArmed @0x18C (binary table @0x512600); the countdown klaxon can fire.
F20 zoom blip: L4MechControlsMapper publishes TargetRangeExponent -> the live
@0x1a4 zoom member (own table chained to MechControlsMapper).
KB: replaced the bogus divisionParameters+0x10 rate read with
SystemClock::GetTicksPerSecond() (FUN_0044e19c is GetFrameRate -- original
audio frames were CLOCK TICKS).
Regression (35s drive+fire): stable; ConfigureActivePress binds real on all 9
subsystems (idle -1, zero pad redirects); FireCountdownStarted +
TargetRangeExponent bind live members; attrnull 53 -> 41; chirp still dead;
footfalls still fire (gain now correctly squared).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The output-filtered broadcast still fed EVERY volume/brightness mixer -- the
always-on ambience pulse's own volume mixer got cranked to step intensity on
every stride ('a stuck button', 3/s, loud). The send now identifies footstep
sources by authored patch (FootFallInt/Ext: bank2 37-39, bank1 106/107) through
mixer->GetTargetComponent() -> AudioSource -> PatchResource LOD bank/patch
(new accessors: GetTargetComponent, GetBankID/GetPatchID,
AudioResource::PeekAudioLevelOfDetail).
Also: step intensity curve raised (speed/25, floor 0.55 -- a mech stomp is
never subtle; it was firing at ~0.4 gain and masked under 0.9-gain layers,
heard as an unrecognizable 'orchestral hit').
Verified: FootFallInt at 0.78 gain in the live tape; the ambience pulse back
to its brief authored form (absent from 1/s snapshots).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The whole pitch system existed but was discarded: NoteAudioHandler captured the
MIDI note, ExecuteModel computed relativePitch from the control-chain cents --
and NOTHING ever called alSourcef(AL_PITCH). Every sample played at root.
Consequences fixed:
- The load-time 'chirping' ambience: an authored looped sequence (div=120
tempo=160, note 51 events on bank1:83) plays a rhythmic tick pitched ~9
semitones DOWN; at root it was a high click ('chirp'). Identified live via
the new BT_AUDIO_DUMP playing-source tape + user ear-match at cadence.
- Engine now revs: EngineMotor pitch rides the throttle (measured 1.0 -> 1.12).
- All sequence-authored patterns (alarms/klaxons/ambience) regain their pitch.
Implementation: BTNotePitchFactor(note) = 2^((note-60)/12); applied at all 3
patch-source StartImplementations (fresh attach) and all 3 ExecuteModels
(combined with the control-chain relativePitch, clamped 0.5..2.0 as before).
Uses the existing AudioSource::GetCurrentNoteValue().
Also: [seqcfg] sequence-config dump, g_bufferNames + BT_AUDIO_DUMP live tape
(1/s: every playing AL source with sample name/gain/pitch/loop),
tools/soundboard.py updated mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Footstep chain progress (each step empirically verified):
- The FootStep watcher is an AudioMatchOf<Logical> (AudioLogicalTrigger extends
AudioMatchOf, NOT AudioTriggerOf): authored config match=1 -> Start(2.0) on a
DirectPatchSource. [trigcfg]/[matchcfg] ctor dumps added.
- The pulse was pinned at 1: the decay lived in IntegrateMotion, which the
current gait path NEVER CALLS. Moved to Mech::PerformAndWatch (provably
per-frame) with a time-based 150ms window sized to the ~10Hz watcher poll.
The matcher now fires per stride (14/run, was 1).
- Every footstep Start is DROPPED at the renderer: the source's volumeScale is
0 (five VolumeAudioHandler sends of exactly 0 = inert/one-shot primes). The
volume arrives through the authored control chain (AudioControlMultiplier
inputs ctl 100+; one 0 input pins the product). Chain scales identified on
this entity: LocalVelocity (live), LocalAcceleration (live), Myomers.
SpeedEffect (=1.0 healthy; one authored scale maps [0,1]->[1,0] INVERTED),
UnstablePercentage (INERT PAD -- always 0!), HeatSink.CurrentTemperature.
PRIME SUSPECT: an UnstablePercentage-fed multiplier input primes 0 once and
never updates (the pad never changes), pinning footstep volume at 0.
- SF2 numbering fact recorded: 38 presets in AUDIO1 / 5 in AUDIO2 have
wPreset != file index (gap at preset 77); my table keys by wPreset. The
184x ProgramButton01 (bank1 patch83) storm at ~7/s needs an identity check
against index-numbering (it may be the wrong sample for that patch id).
Diagnostics added (BT_AUDIO_SPATIAL/BT_ATTRBIND_LOG): [trigcfg]/[matchcfg]
ctor dumps, [matchfire] with component ptr+class, [volset] VolumeAudioHandler,
[mult0] multiplier zero-products, scale sends with authored boundaries,
[fswatch] dedicated footstep watcher tracer (g_btFootStepAddr), SetupPatch
ENTRY with bank/patch/state, Simulation::DebugAudioWatcherCount.
NEXT: back UnstablePercentage with a real (live) member -- it is the mech's
stability 0..1 (the stabilityAlarm/gyro family) -- or confirm via a one-run
chain dump which multiplier input pins the FS source; then the footstep
transient should clear the 0.3 drop gate at walking speed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: "when I start moving all the sounds fade away, no footsteps." Diagnosis
chain (all empirical, BT_AUDIO_SPATIAL/BT_ATTRBIND_LOG traces):
- The audio head DOES track the mech (listener-relative positioning verified
while driving) -- not a spatial bug.
- The idle sounds correctly STOP on leaving the standing state; the MOVING
sounds never started because every POLLED audio watcher was dead:
the reconstructed Mech::PerformAndWatch replaced the engine performance but
dropped the ExecuteWatchers() step from the engine tail (Simulation::
PerformAndWatch = Perform -> ExecuteWatchers -> WriteSimulationUpdate).
Only PUSHED StateIndicator watchers (SetState->Execute) ever fired -- which
is exactly why state sounds worked but footsteps/motion-scaled audio didn't.
FIX: poll ExecuteWatchers() (AreWatchersDelayed-gated) before the update
write. Immediately unlocks the polled family: MissileLoaded01/LaserLoaded01
ready dings, ProgramButton01, motion scales (PlayNote 15 -> 30 per run).
- FootStep (0x1e) backed REAL: was the inert attrPad (an AudioLogicalTrigger
polls it -- threshold-crossing pulse per foot plant). New footStep member,
pulsed by SetBodyAnimation on entering any locomotion clip 1..0x17 (runtime
stride alternation MEASURED as body states 12<->13 -- the enum-name numbering
does not match runtime clip ids), decayed by IntegrateMotion (~1/3 s).
- Footstep volume is speed-scaled (AudioMotionScale on LocalVelocity): at
standstill the transient start computes volume 0 and the renderer drops it
(LowAudioVolumeThreshold) -- so footfalls are audible at real walking speed.
Diagnostics kept (BT_AUDIO_SPATIAL): spatial dist/vol per source, CLIPPED/DROP/
START at the request gate, vol=0 factor breakdown, scale->0 sends, trigger
notifications, watcher poll-rate probe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runaway loop the user heard was MissileLoading01: the ProjectileWeapon
(SRM/ballistic) state machine set Firing(0)/Loading(3)/Jammed(5)/NoAmmo(7) but
NEVER a ready/Loaded level, so at idle the launcher parked in Loading(3) forever.
Its WeaponState audio (weaponAlarm, now StateIndicator-firing) therefore looped
MissileLoading indefinitely -- the loop stops only when the alarm LEAVES the
loading state. (The laser sibling was fine: the Emitter DOES reach Loaded when
its charge tops off, so LaserACharge stopped.)
Fix: in ProjectileWeaponSimulation's default (idle/ready) branch, drive the state
from readiness -- Loaded(2, charge + ammo up = silent ready) once ReadyToFire,
else Loading(3, reloading). SetLevel fires audio only on a real change, so this
yields the natural Firing -> Loading(reload) -> Loaded(ready) cycle and the
MissileLoading loop now stops when the launcher finishes reloading. Verified:
weapon cycles Loading(3, recoil>0)->Loaded(2, recoil<=0); the only remaining
continuous loop is EnginePower (correct). Fire path unchanged.
Also: master volume default kept; richer BT_AUDIO_LOG StopNote/SetupPatch traces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The raw AWE32 samples are hot and everything plays at full per-source gain, so the
mix was loud. Set alListenerf(AL_GAIN) once at device init -- it scales EVERY
source (master volume). Default 0.6; override with BT_AUDIO_VOLUME=<0.0..1.0+>
(0 = mute, 1 = full, >1 = boost).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WeaponState -> weaponAlarm (mechweap.cpp:149), and the weapon's looping audio
(LaserACharge/LaserASustain/MissileLoading, all SF2 sampleModes=1) is STARTED by an
AudioStateWatcher on the firing/charging state and STOPPED by an inverse
AudioStateTrigger that keys on old_state (== the state being LEFT).
The subsystem-state commit fired GaugeAlarm54 watchers on SetLevel but deliberately
left levelB (the oldState slot @0x10) untouched to avoid disturbing the weapon's
LevelCountB read. Consequence: the audio watcher's StateChanged(oldState,newState)
saw a stale oldState, so the inverse/STOP triggers never matched -> the charge/
sustain/loading loops played forever.
Fix: SetLevel now sets levelB := level (oldState := currentState) before the change,
exactly like StateIndicator::SetState. The stop triggers now match on leaving the
state and StopNote the loop. This is also authentic: in the binary the 0x54 alarm
+0x10 IS oldState, and the weapon's LevelCountB()/weaponIdle read of +0x10 is that
same oldState (the old static-count reading was the reconstruction's accident).
weaponIdle is only consumed in the MP replicant record-apply path, so single-player
firing is unaffected; the value is now the authentic oldState-based one.
Subsystem state audio unaffected (0 skips). Diagnostics: BT_AUDIO_LOG now traces
SetupPatch src/file/loop + StopNote.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The audio subsystem binds AudioStateWatchers (class 28) to per-subsystem state
attrs (GeneratorState/CondenserState/ReservoirState) BY NAME. Recovered the
watcher TYPE per attribute via a MakeObjectImplementation ClassID trace.
Root cause of the subsystem-state crash: the binary's 0x54-byte subsystem alarm
(FUN_0041b9ec, reconstructed as GaugeAlarm54) IS a StateIndicator -- its "three
sub-indicators" @0x18/0x2c/0x40 are the audio/video/gauge watcher SChains, and
level@0x14 is currentState. The reconstruction had modeled that tail as an opaque
`_tail`, so the sockets were never constructed -> AddAudioWatcher AV'd on 0xCDCDCDCD.
- GaugeAlarm54: give it the three REAL, constructed SChainOf<Component*> sockets +
AddAudioWatcher/Video/Gauge; SetLevel now fires the watchers on a level CHANGE
(empty sockets = no-op, so the ~all other alarms are unaffected). levelB (weapon
LevelCountB "reset source") is left untouched -- weapons unchanged. sizeof stays
0x54 (static_assert holds -> SChainOf<Component*> is 0x14, layout exact).
- Register the state attrs -> the subsystem's own alarm (own AttributePointers[]
chained to the parent): Generator.GeneratorState->stateAlarm, Condenser.
CondenserState->condenserAlarm, Reservoir.ReservoirState->reservoirAlarm. Those
alarms are already SetLevel'd by the sim, so the state audio fires on transition.
- AudioStateWatcher guard now validates the +0x18 SOCKET (what AddAudioWatcher
touches), not the +0 vtable -- GaugeAlarm54 is non-polymorphic at +0 (raw header)
but has a real socket at +0x18.
audiostate skips 85 -> 10 (only AmmoBin AmmoState/SimulationState left). The
Logical/Scalar/Enum subsystem attrs (ReportLeak/GeneratorOn/ConfigureActivePress/
MotionState/SpeedOfTorsoHorizontal/TargetRangeExponent) stay inert (read 0, silent,
non-crashing) -- they need backing members in size-locked layouts (a polish wave).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of "no sound, ever": the two audio backend DLLs shipped in the repo
are fakes. libsndfile-1.dll exports 15 funcs BY ORDINAL ONLY (no names) and
sf_open() always returns NULL; OpenAL32.dll (72KB) imports only KERNEL32 -- no
dsound/wasapi/winmm -- so it is a pure no-op that returns fake handles
(ctx=0x00000001, alGenSources->0) and never touches the hardware. The whole
render->device->buffer->source->play chain ran clean and silent.
Fixes:
- OpenAL32.dll: replace the stub with the real OpenAL Soft 1.25.2 Win32 build
(imports AVRT/ole32/WINMM, real WASAPI backend). The exe imports the 25 AL
funcs by NAME so it is a drop-in; alGenSources now yields a live source and
alSourcePlay reaches AL_PLAYING.
- libsndfile: DROPPED entirely. It is replaced by LoadWavPCM() in L4AUDRES --
a tiny RIFF/WAVE fmt+data reader that loads our soundbank WAVs (16-bit PCM)
straight into the AL buffer. Removed the .lib/.dll from the link + copy and
git-rm'd the stub. (This also kills the "ordinal 50 could not be located in
libsndfile-1.dll" load-failure popup: adding an sf_strerror import bound to
an ordinal the 2..16-only stub could not satisfy.)
- Soundbank: 241 samples cracked from AUDIO1/2.RES (SF2 v1.0) by
tools/sf2extract.py into content/AUDIO/*.wav + the allPresets[2][128] table
(audiopresets.cpp), replacing the zero-init btstubs stub. All 241 now load
(alErr=0). BT_AUDIO_TEST plays buffer0 as proof-of-life; BT_AUDIO_LOG traces
the chain.
Remaining: in-game triggering (AudioEntities on fire/step/engine/explosion)
so PlayNote fires during play -- next audio wave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User reported never hearing any audio. Root-caused: the KB "Audio: DONE" was wrong.
The OpenAL playback chain IS fully implemented (device/buffers/sources; PlayNote
really calls alSourcePlay), but three gaps kept it silent:
1. GATED OFF (the "no sound" root cause): BTL4Application::MakeAudioRenderer returned
NULL unless the pod's AWE_FRONT/AWE_REAR AWE32-card env vars were set -- authentic
1995 pod behavior, dead on modern hardware, so the renderer was NEVER created.
FIXED: default audio ON (BT_NO_AUDIO=1 restores silence; AWE vars still force-on).
Verified: the OpenAL device now opens with no env vars ([audio] device OPENED).
2. Soundbank STUB: allPresets[2][100] (btstubs.cpp) is zero-init -> PRESET_isImplemented
false -> 0 buffers load. Sample data exists (AUDIO1/2.RES: EnginePower/LaserAFire/...)
but the event->sample map is gone (not in the decomp). STILL OPEN.
3. No triggering: the reconstructed game never creates AudioEntities on events. STILL OPEN.
So the device opens but nothing loads/plays yet -- enabling real sound needs the
soundbank reconstructed + the game triggers wired (a proper audio wave). Added a
BT_AUDIO_LOG trace harness across L4AUDRND/L4AUDRES/L4AUDLVL. KB corrected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigated the "console death froze peer replication" open question (2026-07-14)
with a direct 2-node repro (BT_REPL_LOG on a circling peer, affinity-pinned):
- Clean relay kill -> replicant kept circling smoothly through the kill, NO
disconnect logged, node survived. Replication did NOT freeze.
- Deliberately STUCK console (scratchpad/btconsole_stuck.py: connect, start
mission, then stop recv() while holding the socket OPEN = the exact
"receive pad full -> close never seen" mode hypothesized) -> replicant kept
moving the whole run. Replication did NOT freeze.
Conclusion: peer replication is INDEPENDENT of the console (pods replicate
peer-to-peer over the GAME socket; the console is a separate egg/mission/status
channel). The original freeze was a transient -- plausibly the same single-box
packet-jitter/CPU-contention artifact root-caused in 49d73dc -- or was fixed by
later MP work.
FIX (the one real bug found): L4NetworkManager::HostDisconnectedMessageHandler's
ConsoleHostType branch closed gameListenerSocket (the GAME listener) on a CONSOLE
disconnect -- a naming bug (the comment + commented-out OpenConnection intended a
CONSOLE re-listen, which CreateConsoleHost already does). Removed. Harmless to
established peer sockets (why a live match survives console loss) but it would
have blocked a NEW peer from joining after a console cycle. Verified: 2-node
still connects (All connections completed!) + the peer circles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Autonomous headless spin harness (BT_AUTODRIVE+BT_FORCE_TURN) + probes that
finally measure what the eye sees, not the sim heading:
- BT_RENDHDG (L4VIDEO render loop): per-RENDERED-frame peer heading step
evenness -- avgStep/maxStep/max-avg ratio. Confirmed the residual spin
'hesitation' is UNEVEN rendered rotation (max/avg ~2.8x), correlated with the
byAngle resync-flood bursts (maxAng ~= pi), NOT a render-vs-sim rate stall
(render redraws a fresh heading every frame).
- BT_SPIN / BT_ANGSIGN: resync trigger breakdown (byAngle/byVel/byRest) + the
frame-level local/update/projected angular-Y that exposed the Abs() macro bug.
- gBTReplRenderYaw: peer heading published from the sim to the render probe.
- Normalize projectedOrigin.angularPosition after the peer-mirror advance
(adding a scaled ang-vel VECTOR to a quaternion denormalizes it) -- correct,
but NOT sufficient: the pi divergence is REAL, from the crude large-angle
quaternion projection over the sparse angular-record interval during a PURE
spin (linear dense-send doesn't fire when not translating; type-4 is the only
orientation carrier and resets the horizon, so dense type-4 -> half-rate).
Residual ROOT (scoped, not yet fixed): the reckoner (MOVER.cpp:457-466) projects
rotation by ADDING vector*t to the heading quaternion -- a small-angle approx the
original kept valid via dense records; our pure-spin case has 1.6s record gaps so
it diverges to ~180deg -> uneven render. Fix path = proper quaternion integration
in the reckoner, or refresh the angular origin without the type-4 horizon reset.
Both touch the shared engine dead-reckon -> aligning before the change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>