Commit Graph
131 Commits
Author SHA1 Message Date
arcattackandClaude Fable 5 53ac927d4b Destructible props LIVE: revive StateInstanceSwitchRenderable (Gitea issue #3)
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>
2026-07-19 09:23:02 -05:00
CydandClaude Fable 5 122fb7bccb Fix the backtick crash (a /FORCE-hidden linkage bug) + backtick = view toggle
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>
2026-07-18 19:52:36 -05:00
CydandClaude Fable 5 22d7a0e3b7 PadRIO: publish stick X in the WIRE sign -- the left/right inversion fix
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>
2026-07-18 19:38:53 -05:00
CydandClaude Fable 5 bfe7c223ac Panel/plasma: TOPMOST + right-edge parking -- the game window buried them
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>
2026-07-18 19:06:26 -05:00
CydandClaude Fable 5 ec3985e9da Panel: condensed layout -- keypads dropped, board columns centered + raised
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>
2026-07-18 18:47:28 -05:00
CydandClaude Fable 5 f40ba58c60 Panel: drop the two hex keypads (per Cyd) -- 72 controls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:46:15 -05:00
CydandClaude Fable 5 f889e24ce0 Merge origin/master: the D1 relay/operator line + input remap meet the glass layer
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>
2026-07-18 17:46:41 -05:00
arcattackandClaude Fable 5 0a77d8e54b Camera-seat ranking window LIVE: the 1995 broadcast overlay reimplemented
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>
2026-07-18 16:32:23 -05:00
arcattackandClaude Fable 5 f9f230c62b Patient walk-up: a pod at a dead relay WAITS for the session
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>
2026-07-18 15:04:46 -05:00
arcattackandClaude Fable 5 dfd1894fb9 Unreachable-server UX: message boxes + bat guidance instead of a 'crash'
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>
2026-07-18 14:23:29 -05:00
arcattackandClaude Fable 5 980c9cd7e5 Input remap: CONTROLS.MAP binding engine + XInput gamepad support
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>
2026-07-18 13:09:36 -05:00
arcattackandClaude Fable 5 f04e8019c2 D1: relay-assigned seats -- players never need a player number
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>
2026-07-18 12:11:03 -05:00
arcattackandClaude Fable 5 1436d27ac6 D1 phase 7: LAN auto-discovery (BT_RELAY=auto) + operator LAN scripts
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>
2026-07-18 11:12:01 -05:00
arcattackandClaude Fable 5 dbb9af2dfa D1 phase 5+6: UDP unreliable channel (restores the 1995 reliable/unreliable split)
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>
2026-07-18 10:00:26 -05:00
arcattackandClaude Fable 5 c029df6e6b D1 phase 2+3: client TCP relay mode (L4NET) + 2-node verification
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>
2026-07-18 09:01:54 -05:00
CydandClaude Fable 5 f703bb1d56 Steam: the lobby + IDENTITY-TOKEN roster -- internet MP code-complete (step 4c)
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>
2026-07-18 00:03:17 -05:00
CydandClaude Fable 5 48ede2f7d7 Net: the Steam transport -- FakeIP + SDR behind the wire seam (step 4b)
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>
2026-07-17 23:47:47 -05:00
CydandClaude Fable 5 e79e8faeed Net: the wire seam -- file-local Winsock primitive wrappers (step 4a)
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>
2026-07-17 23:41:28 -05:00
CydandClaude Fable 5 418acf07e4 Panel: the vRIO layout + coloration, plasma flip fix, button-trap guard
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>
2026-07-17 22:30:35 -05:00
CydandClaude Fable 5 db9ac947f7 Displays: on-screen cockpit buttons + desktop plasma + the glass preset (steps 2d/2e)
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>
2026-07-17 22:21:22 -05:00
CydandClaude Fable 5 9f35a8034a Controls: the keyboard bridges STAND DOWN for a live device (step 2c)
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>
2026-07-17 22:14:37 -05:00
CydandClaude Fable 5 ac57a474ef Input: the RIOBase seam + PadRIO -- the glass cockpit gets hands (step 2b)
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>
2026-07-17 22:11:16 -05:00
CydandClaude Fable 5 b26e8205e3 Controls: the .CTL positional-id off-by-one -- CONFIRMED live and FIXED (step 2a)
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>
2026-07-17 21:59:51 -05:00
arcattackandClaude Fable 5 e0474ff92a Per-pilot mech paint: wire the color/badge/patch substitution end-to-end
- 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>
2026-07-17 09:50:23 -05:00
arcattackandClaude Opus 4.8 0ca7d269d2 Audio Phase 3 (AUDIO_FIDELITY F9/F11/F12): EFX lowpass + reverb split + cockpit placement
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>
2026-07-16 11:59:39 -05:00
arcattackandClaude Opus 4.8 691e88569a Audio Phase 2 (AUDIO_FIDELITY F1/F2/F13/F14): full-zone soundbank -- key-splits,
layers, baked tuning, loop regions, release fades

Extractor rewrite (tools/sf2extract.py): every sample-bearing instrument zone
becomes a SAMPLEINFO slot -- 603 zones / 241 presets (68/115 + 94/126 multi-
zone, matching the audit exactly).  Per zone: keyRange(43), sampleModes(54),
SBK samplePitch(55) + rootKey(58) + coarse/fineTune(51/52), attenuation(48,
INVERTED SBK scale, 0.375 dB/step [T3], baked into the PCM), releaseVolEnv(38),
pan(17), shdr loop region.

F2 pitch (algebraically exact): WAV rate = round(44100 * 2^(((6000 -
rootCents) + tune)/1200)) -- EMU8000 v1 base 44100.  Cross-checks: FootFall
17300 Hz (the audit's +4.2 st), MissileLoaded low zone 88200 (-24 st),
Warnings01 8-way klaxon split w/ 3 looped zones, Death01 162 Hz extremes.

F1 zone selection: SetupPatch/PlayNote take the authored note;
attach/play only zones whose [keyLo,keyHi] contains it (detach the rest so a
rewound source can't replay a stale buffer).  Live-verified: LaserLoaded/
MissileLoaded note 36 -> low clunk zone, note 84 -> high blip zone (pitch 4);
LaserCFire plays all 3 authored layers incl the looping sustain.  Stereo-pair
zones pan via listener-relative AL_POSITION (distance model is AL_NONE).
MAX_PRESET_SAMPLES=25 (AllExplosion); fixed PRESET_isImplemented's >=5 bound.

F13 loops + releases: authored [loopStart,loopEnd] applied via
AL_SOFT_loop_points at buffer load (0 rejections; kills the latent boom-loop
on MechExplosion's 1.5% sustain slice + the ~2.4 Hz LaserBSustain wrap tick);
StopNote now honors the authored releaseVolEnv (1.1-3.9 s on ~20 looping
presets) with a dB-linear fade serviced from AudioHead::Execute; restarts
reclaim fading sources.  One-shots keep the instant stop (faithful).

Regression (40s drive+fire): stable, loop points accepted, key-splits + layers
verified in the delivery trace, chirp still dead, footfalls fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:04:34 -05:00
arcattackandClaude Opus 4.8 fc7f311771 Audio Phase 1 (AUDIO_FIDELITY F3/F4/F10/F15/F18/F20 + KB): the quick wins
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>
2026-07-16 10:43:38 -05:00
arcattackandClaude Opus 4.8 847b9b85cd Audio: target the step-intensity send at FOOTSTEP sources only (task #50)
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>
2026-07-16 08:36:28 -05:00
arcattackandClaude Opus 4.8 37d4554440 Audio: implement NOTE->PITCH + control pitch -- the AL_PITCH was never applied (task #50)
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>
2026-07-16 08:20:42 -05:00
arcattackandClaude Opus 4.8 1d019e8109 Audio: footstep pulse VERIFIED end-to-end to the matcher; volume chain traced (task #50)
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>
2026-07-15 23:52:06 -05:00
arcattackandClaude Opus 4.8 744ef8cc16 Audio: restore the dropped ExecuteWatchers poll -- polled audio watchers were ALL dead (task #50)
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>
2026-07-15 23:03:27 -05:00
arcattackandClaude Opus 4.8 ea8555480e Audio: fix stuck MissileLoading loop -- ProjectileWeapon never reached a ready state (task #50)
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>
2026-07-15 22:29:05 -05:00
arcattackandClaude Opus 4.8 01261d72f3 Audio: add master volume (listener AL_GAIN); default 0.6, BT_AUDIO_VOLUME override (task #50)
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>
2026-07-15 22:18:21 -05:00
arcattackandClaude Opus 4.8 c27af4800b Audio: fix stuck weapon charge/sustain/loading loops -- maintain GaugeAlarm54 oldState (task #50)
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>
2026-07-15 22:11:49 -05:00
arcattackandClaude Opus 4.8 5ba541ed56 Audio: subsystem STATE sounds -- GaugeAlarm54 IS a StateIndicator; register Generator/Condenser/Reservoir state (task #50)
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>
2026-07-15 21:38:15 -05:00
arcattackandClaude Opus 4.8 5b46655b82 Audio: ENABLE sound -- real OpenAL + in-tree WAV loader; both backend DLLs were no-op STUBS (task #50)
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>
2026-07-15 18:55:33 -05:00
arcattackandClaude Opus 4.8 52440e13b0 Audio: ENABLE the engine (relax dead AWE-card gate) + diagnose the real silence (task #50)
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>
2026-07-15 18:02:31 -05:00
arcattackandClaude Opus 4.8 bc4d6e5597 MP: console-death freeze NOT REPRODUCIBLE + fix the latent game-listener-close bug (task #50)
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>
2026-07-15 15:52:35 -05:00
arcattackandClaude Opus 4.8 9eff043973 Diag: render-rate rotation-evenness probe + angular-drift instrumentation (task #50 spin residual)
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>
2026-07-14 16:27:04 -05:00
arcattackandClaude Opus 4.8 d5d512e087 WIP checkpoint: TCP_NODELAY + dense-send + BT_JIT/BT_ANIM probes + FOGDAY test egg (task #50 investigation)
Session-in-progress peer-motion work, checkpointed before bisecting the
07-13/07-14 gait regression. Contains: TCP_NODELAY on game sockets (L4NET),
every-frame dense position send while moving + came-to-rest/REST send +
per-frame BT_JIT/BT_ANIM smoothness probes (mech4). bodyTargetSpeed feed
reverted to derived-velocity default. MOVER.cpp spline experiment already
reverted. NOT a fix -- a checkpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:43:35 -05:00
arcattackandClaude Opus 4.8 225fa5951b Render: fog searchlight-swap verified INERT + BT_FOG_LOG diag (task #63)
Verifying the searchlight/nosearchlightfog swap (user request): it does NOT work.
The arcade swaps active fog between fog= (headlight on) and nosearchlightfog=
(off, darker/tighter) via PullFogRenderable::Execute (edge-triggered on the mech's
light attrs -> SetFogStyle searchLightOn/Off).  But PullFogRenderable is NEVER
CONSTRUCTED in the port (grep: only the .h decl + .cpp def; no `new`), and
searchLightOn/Off is called from nowhere else.  Empirical confirmation via the new
BT_FOG_LOG hook: over a 22s run SetFogStyle fired only style 0, zero style 2/3.

So fog is static at the searchlight-ON (fog=) values -- normal lights-on play, so
core per-map/time/weather fog is unaffected -- but the nosearchlightfog variant is
dead.  Corrects my earlier "the swap is wired end-to-end" claim (code paths exist,
trigger never instantiated).  KB rendering.md updated; wiring it back is a scoped
reconstruction (bind the Searchlight subsys light state to a constructed
PullFogRenderable at viewpoint setup).  No behavior change this commit -- diag only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:36:48 -05:00
arcattackandClaude Opus 4.8 083b148bb0 Render: FOG restored -- authentic per-map/time/weather fog now renders (task #63)
User: "aren't some levels supposed to have fog?" -- they were, and none showed.
BTDPL.INI (the DPL env INI, L4DPLCFG) authors fog=near far r g b (+ often
nosearchlightfog=) on EVERY map/time/WEATHER leaf; weather is a fixed egg field
(clear/fog/soup). Shipped eggs pin cavern/night/clear -> page dsnitclear
(near 90, far 1100, dark blue). The env pipeline resolved + pushed it to D3D, but
it rendered INVISIBLE: D3DRS_FOGTABLEMODE=D3DFOG_LINEAR (table fog) derives its
factor from the perspective-NONLINEAR z-buffer, so without WFOG the 90..1100 range
collapsed to fog-factor ~1 (no fog everywhere).

Fix (L4VIDEO.cpp world pass): auto-detect D3DPRASTERCAPS_WFOG -> per-pixel W-fog
(smooth, == the arcade dpl_fog_type_pixel_lin); else VERTEX fog (eye-space but
per-vertex -> splotchy on coarse terrain tris, so only a fallback). FOGENABLE + the
fog mode are re-asserted each world-pass frame (the old one-time set got clobbered
per-pass, which is why plain table fog showed nothing). Verified on this GPU:
WFOG present -> per-pixel table fog, user-confirmed smooth.

Also corrected task #20's false "shipped maps define no fog" comment (it checked
.MAP/.RES, not BTDPL.INI). Env hooks: BT_FOGMODE=table|vertex|off, BT_WEATHER=
clear|fog|soup, BT_FOG="near far r g b" (now honored on the authored branch too);
[fog] resolved/model diagnostics. KB: rendering.md fog section added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:24:06 -05:00
arcattackandClaude Fable 5 02cdfd6576 Torso: the TWIST goes LIVE -- electrical watchdog chain, centered crosshair, coherent controls (task #57/#58)
The MadCat torso twists, the view turns with it, and targeting follows.
Three reconstruction fronts closed:

THE ELECTRICAL WATCHDOG CHAIN (why the torso never powered up):
- PowerWatcher::UpdateWatch reconstructed (@004b181c, the REAL registered
  Performance -- PTR @0050f5fc; Ghidra missed the fn start): the watchdog
  MIRRORS the watched subsystem's electrical level (+0x278), brownout
  downgrade when gen output <= minVoltage% x rated.  @004b1804 relabeled
  ResetToInitialState (slot 10) -- the old "Simulation" tag was wrong.
- The factory watcher-CONNECT pass reconstructed (vtable slot +0x38,
  @004aee2c/@004b1a40 byte-identical, recovered from raw exe bytes):
  watchedLink.Add(roster[watchedSubsystem]) on the master node.  Was the
  SubProxy::Start() no-op -- every watchdog sat at 0 forever.
- MinVoltageScale = 0.01 (a 10-byte x87 literal @0x4b1924; was 1.0f =
  permanent brownout) and PowerWatcher's Derivation chains its REAL base
  HeatWatcher (the HeatableSubsystem stand-in broke IsDerivedFrom for the
  whole Torso/Searchlight/ThermalSight family).
- KB correction swept: derivation tag 0x50e604 = HEATWATCHER (not
  "HeatSink"); the btl4gaug heat-widget gate now tests it via the
  BTIsHeatWatcher bridge.

THE CROSSHAIR (task #58 forensics, 6-agent workflow + live probes):
- The VIEW is TORSO-MOUNTED: jointtorso -> jointeye -> siteeyepoint in
  every twist-capable .SKL; the camera + canopy ride the same hinge
  subtree through HingeRenderable's live matrix-stack compose -- ALREADY
  WORKING in the port.  The crosshair stays screen-centered (center IS
  the boresight); the twist reads on the tape carets/compass/radar.
- The real bug was the port's gBTAimX = tan(twist) slew (the falsified
  "body-mounted view" model): the camera already carried the twist, so
  the crosshair counter-slid to hull-forward and the fire ray with it.
  Deleted; the pick ray inherits the twist from the yawing eye basis.
- Two instrumentation traps documented (chase-eye-as-default-camera,
  BT_FORCE_TORSO clobbering real joints -> the hook now only fills
  unresolved ones); an over-correcting explicit eye compose was added on
  those false readings and retired the same day.

CONTROLS + REPLICATION:
- Q/E spring-center on release (the axis is a twist-RATE demand; the old
  hold-deflection model drifted forever); X also zeroes the axis and
  pulses the authentic torso Recenter (@004b6918).  M cycles control
  mode via the real CycleControlMode body.
- Torso update-record DIRECTION fixed: engine truth is Write=serialize /
  Read=apply; @004b6a78 is the READ (was mislabeled Write) and the
  missing WRITE @004b6a1c recovered from raw disasm (recordLength 0x1C,
  twist/vel/rate at +0x10/14/18) -- kills the replicant's 0xCDCDCDCD
  -140-degree ghost twist.
- Marching-ghost desync: 4 Standing-case guards zero stale reverse
  cycleSpeed (negative cadence passed the <= ZeroSpeed stop gate).
- Kill credit rerouted to the OBSERVED killer (lastInflictingID ->
  killer's player link) -- kills count, target K/D populates.

KB: subsystems.md (watcher chain), multiplayer.md (record direction),
combat-damage.md + gauges-hud.md + cockpit-view.md (torso-mounted view
re-correction), decomp-reference.md (new addresses + tag fix),
open-questions.md (dead capability-roster loops 2-4, snapshot CD read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:27:49 -05:00
arcattackandClaude Fable 5 065c114590 Impact-FX FORENSICS wave: the i860 specialfx engine, per-round detonations, the ram economy closed
The "we're off the rails" reinvestigation -- a 5-thread evidence workflow
(logs / PFX data / RES model lists / decomp / port audit) + the i860 firmware
decode, then surgical fixes.  Every claim tiered; the wrong turns are on the
record in the KB alongside the corrections.

THE OLD-STYLE SPECIALFX ENGINE [T1, firmware-decoded from VREND.MNG]:
rebuilt scratchpad/i860dis.py (binutils opcode table); mapped the dispatch
(data+0xdd0c; sfx trigger 0xf040cda0, install 0xf040cdc0, step ~0xf0413698,
instance init 0xf04128d8); decoded the heat model EXACTLY: per 30Hz board
frame h *= cool_a; RGB_ch = h_old*(h_new*cook_ch - 0.25) + 0.25 (K=0.25 =
the ember floor @VA 0xF080; kill at h <= 1e-4 @0xF0A0); alpha fades cool_b
x RAW dt (PER-SECOND -- the x30 scaling made laser hits invisible); 7s cap;
y_off = the kill plane; "variance" is DEAD DATA (binary reads "variance",
INI authors "varience").  The 13 descriptors (PPCHit/LaserHit/MissileHit/
Chunks/Sparks/Fireball...) parse from BTDPL.INI and render via the BTPfx
layer -- heat bursts draw the FIERY sheet (brightness over fire), .PFX keeps
GRAYSCALE (authored colours: DNBOOM orange, DDAM gray).

HIT-PACKAGE CENSUS [T1, RES byte-verified]: ppchit=[8] lzrhit=[9] mghit=[7,11]
canhit=[11] mslhit=[10,12,1023] explode=[6]; all 8 mech death lists identical.
Effect routing corrected in BOTH consumers: <100 = specialfx, >=1000 = psfx.

"SILVER MIST x5" ROOT CAUSE: SHKWAVE.PFX (mslhit's 1023) authors maxIssue=5
relPeriod=0.2 rate=1 -- the ONE file where rate contradicts the window; the
emitter trusted rate -> 5 shells at exactly 1Hz.  Emission rate now always
maxIssue/releasePeriod.

PER-ROUND DETONATIONS [T1 @004bef78]: every missile round spawns its own
ExplosionModelFile at ITS impact (hull + terrain) -- a volley ripples 12
fireballs like the demos; rack-tube launch spread [T3] (GUIDED rounds only --
the slot-0 deflection sent every AFC100 shell 3.4deg left/2.5deg down: the
phantom "4th gold beam"); replicant salvos detonate too (launcher index rides
the visual push).  Missile damage bundles through the shooter's messmgr with
the launcher's subsystemID (mslhit fires at the consolidated point; the
binary's dedup CONFIRMED [T1 @0049b784] -- a workflow agent's per-record
claim REFUTED by direct decomp read).

DAMAGE-BAND SEMANTICS [T1]: MechDeathHandler fires the CURRENT band effect on
any damage rise (the binary's changed-flag coalescing) -- a mauled mech under
fire smokes/burns per hit; bands 3/4 are authored fire plumes.

THE RAM ECONOMY -- CLOSED (3 layers, measured live):
 1. armor = POINTS (every zone: damageScale = 1/armorPoints, armor 50-140;
    the [zone-armor] BT_DMG_LOG dump);
 2. StaticBounce prices rams with authored moverMass ~1.3e6 -> ~59,000 pts
    @10m/s; the binary dispatches it RAW but pod MP dropped it on the local
    replicant (MECH.CPP:986 warns) -- ram damage was NETWORK-INERT; our
    task-#47 replicant forwarding surfaced it as a one-shot.  Port
    normalizes x1e-3 to the point economy [T3];
 3. contact EDGE (ramLastVictim/ramContactLinger): the binary's bounce made
    separation implicit; our gait-derived velocity re-priced full rams at
    60Hz (the respawn explode-loop).  One bump = one hit; pressed = BLOCK.

PLUS: sfx size x0.5 + 1.25m visibility floor + hot-phase occlusion [T3];
particle pool 2048->8192 (missile traffic starved laser bursts -- the
"intermittent effects"); replicant beam aging (a lost beam-END record pinned
a stale beam on forever); UV-variant noise stamps (mask-safe mirror/swap);
no scroll on particle stamps (material-path only); BT_FX_TEST/fxshot.py
self-verification harness ([beam-draw]/[zone-armor]/[collide-tx] telemetry).

KB: rendering.md (specialfx engine + census + closeout), combat-damage.md
(ram economy + band semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:14:12 -05:00
arcattackandClaude Fable 5 48c9c8444f Fire VISUALS wave: the authored firesmoke sheet, vertex-alpha effect cards, the case-4 wreck dressing
The "fireballs like the demo vids" arc, decomp/content-grounded end to end,
plus the live-play UX batch verified over the same sessions:

FIRESMOKE SHEET (the PFX fireball fix): every firesmokeN_scr_tex in BTFX.VMF
maps the SAME 64x64 tileable noise image bintA (variants differ only in SCROLL
rate) and firesmoke1_mtl colours it through the "fiery" ramp (0.3,0.1,0.1)->
(0.9,0.7,0.3).  The particle layer now bakes ramp(lum(bintA)) as its sprite
colour (noise detail in alpha) and SCROLLS it at firesmoke1's authored rate
via a texture-transform; the port's radial soft-edge mask moved to a second
CLAMPed stage so the WRAPPED scroll rolls flame through the sprite without
scrolling the edge away.  Old grit x radial bake kept as the no-BINTA fallback.
Impact hits, damage-band smoke and death booms all ride this layer.

AUTHORED TEXTURE SCROLL in the model path: the BMF TEXTURE records carry
SPECIAL " SCROLL u0 v0 du dv" (tag 0x2037); the draw path always supported
per-op scrolling (SetTextureScrolling) but the BGF loader never parsed it, so
every scrolling material rendered frozen.  Wired TexRef -> MatInfo -> batch ->
L4TEXOP.doScroll: the flame cards (flamebig/fire5) now roll fire noise.

VERTEX-ALPHA EFFECT CARDS (the "twisted drill bit of fire" fix): FLAMEBIG's
verts carry authored float RGBA -- white-hot base (1.0,0.99,0.97) -> dark-red
tip fading to alpha -0.2 (the DPL clamp convention).  The loader kept a flat
batch colour and drew it OPAQUE = a solid orange spike.  Corpus sweep: exactly
14 shipped BGFs carry vertex alpha, ALL effect cards (flames, MUZFLASH,
EXDISK_A/B/C, TMST_A/B/C, beam models, DECLOUDS).  Such batches now keep the
authored per-vertex gradient and route to the alpha-blend pass, unlit,
colour = texture x gradient, alpha = the vertex fade; sky objects excluded
(drawAsSky + alphaTest passes NEITHER pass filter -- DECLOUDS stays in the
sky pass).  MUZFLASH/EXDISK render correctly for free when the muzzle-model
work lands.

WRECK DRESSING (the 1996 ExplosionScripts case-4 transcription): pieces spawn
HIDDEN and reveal 0.25s after the boom (the InstanceSwitch delay, behind the
dnboom flash); flamebig hangs over the pile, Y-BILLBOARDED at the camera
(SetOffsetYaw + a camera-pos getter -- the dpl_SetDCSReorientAxes analog);
the MakeDCSFall settle arms at the reveal with the two authored rates (hulk/
debris -0.025 t^2, fires -0.01 t^2 -- the flames ride above the sinking pile
and die with it at burial).  EMPTY-PLACEHOLDER hulk guard: THRDBR.BGF is a
153-byte zero-geometry stub that "loads fine" -- vertex-count check now routes
it to the gendbr fallback (a Thor wreck was invisible).  Hulk content census
recorded: AVADBR==MADDBR==VULDBR geometry (palette-only prefix diffs),
RAPDBR==SNDDBR==STIDBR byte-identical -- wreck variety is materials + the
dressing, not unique piles.

LIVE-PLAY BATCH: muzzle resolve uses the named segmentIndex (raw +0xdc read
was layout garbage); forward launch frame (authored MuzzleVelocity +Z vs the
mech's -Z facing); dock-bottom single window (gauge strip appended below the
world viewport, 1100x600 default, BT_DEV_GAUGES_WINDOW=1 restores the separate
window); portrait sec surface unrotated CW; ammo counters live via typed
bridges (BTAmmoBinCountPtr/BTAmmoBinFeeding/BTWeaponAmmoBin -- raw bin+0x180
and a hand-rolled link walk were garbage); fourth fire key ('4' = Pinky);
panel/arc probes de-aliased (%61 prime).

KB: rendering.md (vertex-alpha card family + scroll), combat-damage.md (hulk
census + THRDBR stub), gauges-hud.md (ammo bridges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:04:29 -05:00
arcattackandClaude Fable 5 bb795e2805 MP live-play wave: collision economy, missiles, radar transform, panel polarity, comm ticker
The interactive 2-node playtest wave -- every fix decomp-grounded and live-verified:

COLLISION ECONOMY (the ram one-shot): StaticBounce mutates worldLinearVelocity
per contact and ProcessCollisionList walks EVERY touched solid per frame; with
2007 terrain-as-solids the reflections compounded x4-x40 within one frame and a
walking bump one-shot a pristine mech for 112,375 pts (62-pt authentic economy).
Fix: frameEntryWorldVelocity restore per contact (damage always priced at the
real approach speed -- all the binary's physics ever saw); Mech::Reset zeroes
the mover motion (respawn = teleport); [collide-tx]/[mp-hdlr] telemetry.
Gotcha #16 (engine-facility drift class).

MISSILES: peer-visible salvos (the launcher record extension carries a salvo
counter + aim point; ForceUpdate actually enqueues it -- the dirty flag alone
never serialized), the authentic arc (authored MuzzleVelocity vector + the
Seeker's 200m/0.1/300 loft + gain-4 steering, decoded from @004beae4/@004bef78),
world-impact bursts (rounds detonate on cave geometry instead of phasing
through), contact-only damage (flight-cap expiry = fizzle, no more teleport
damage), live re-lead, and ballistic (unguided) shells for autocannons.
projweap's stale BTPushProjectile extern (the /FORCE signature trap, gotcha #6
corollary) crashed the Avatar's first AFC100 shot -- fixed + sweep rule recorded.

RADAR: two transcription bugs made the scope permanently empty -- FUN_0040b244
is the affine INVERSE (not a copy) and FUN_0040adec writes ONLY the 3x3 rotation
(never the translation); worldToView now Invert(view) built rotation-first.
CulturalIcons sorted out of the moving grid (the phantom red pips were map
props), visible-radius culls on all three draw passes, live pip verified at
|delta| x ppm px.  Gotcha #17 (verify the FUN_ body, not its call shape).

WEAPON PANELS (the frozen-dial hunt): the binary's *(subsystem+0x40) means
FAILED -- the recon's 'operating' name was backwards, inverting the destroyed-X
lamps, the panel look, the children enable and the ready-lamp gate (which had
NEVER executed).  Polarity chain corrected end-to-end (failedState, fed by real
damage saturation).  Root cause of the freezes: MFD page-mode gating -- the dev
composite shows ALL pages at once, so off-page dials legitimately stopped; under
BT_DEV_GAUGES the 15 page-plane bits stay active (the exclusive secondary trio
untouched).  The SEH gauge guard now names its kills; repaint-heal resets the
incremental arc after panel repaints; [panel]/[arc] probes added.

COMM/SCORE: MessageBoard LIVE (the engine already shipped the whole
Player__StatusMessage queue; wired the binary's one producer -- the kill branch,
victim's name, 6s -- plus the consumer bridge and a lazy source bind); MP DEATHS
counted via the observed-death tally (each node scores every pilot from locally
observed events, the same model as the KILLS credit) and the -2/-1 engine seed
clamped for display.

DEV UX: node-tagged window titles (-net port), gauge panel reworked (1320x480,
true 4:3 MFD cells, the portrait secondary UNROTATED upright, linear filtering,
BT_GAUGE_SCALE), fixed close spawns via BT_SPAWN_XZ, Boreas flies an Avatar
(first second-chassis live outing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:24:15 -05:00
arcattackandClaude Fable 5 dd27238ceb Gauges: the CLOSEOUT wave -- radar pips, Myomers dedup, attribute parity, ledger sweep (tasks #14-#17)
TASK #17 -- AUTHENTIC RADAR SYMBOLOGY (the cross-blip stand-in retired).
The recon proved the 'missing pip raster set' never existed as BT game code:
pips are the ENGINE's L4GaugeImage vector-stroke system (T0 source in tree,
L4GAUIMA.cpp == FUN_0046f0c0 line-for-line), the 'pip table' is L4Warehouse::
gaugeImageBin keyed by Entity::resourceID, and BTL4.RES ships 110 type-0x12
shapes (every mech/vehicle/building/tree).  The six btl4rdr stubs are wired
to the real facilities: contacts draw their authentic model silhouettes with
LOD selection, the target gets the binary's 4px-inflated box highlight, and
player-name labels resolve via Mission::GetSmallNameBitmap (the prebuilt
64x16 egg rasters keyed by the player's bitmapindex).  DECODED: the invented
'VideoObject' was Entity::owningPlayer all along (+0x190; nameID =
playerBitmapIndex@0x1E0, target = BTPlayer::objectiveMech@0x284 -- new
bridge BTPlayerObjectiveMechOf); the 'LabelledEntity' class is Landmark
(cultural.h; label path dormant -- no landmark content ships, no runtime
landmarkID writer).  The L4GREND BT_DEV_GAUGES warehouse guard is removed
(its AV had a different culprit, below); resource type 18 corrected to
GaugeImageStream in decomp-reference (was mislabeled 'ModelList').

TASK #14 -- the Myomers ODR duplicate ELIMINATED: the powersub.cpp/hpp
'Myomers' (classID 0xBC3 -- actually Sensor) is retired whole; it duplicated
?DefaultData@Myomers@@ against the real class (dumpbin-verified) and /FORCE
picked the winner by link order.  The real Myomers (0xBC6) now chains
PoweredSubsystem's handler set (ids 4-8) and publishes its SEVEN binary
attributes (@00511588: SpeedEffect/Current/Recommended/Min/MaxSeekVoltage-
Index/SeekVoltage/OutputVoltage) -- the old empty unchained index starved
the Myomer engineering panel of every resolve.

TASK #16 -- attribute parity: MechWeapon publishes the FULL binary table
@0x511890 (11 entries; ids renumbered to binary truth -- the port aliases
had squatted the binary's DistanceToTarget/TargetWithinRange ids; the
streamed TriggerState 0x13 binding unchanged).  Binary names resolved two
TODO members: pipState -> estimatedReadyTime (attr 0x1A), and the EXT-model
flag is the binary's RearFiring (0x1B).  ThermalSight LightState published.
HUD (offset conflicts) + missile-side tables (id encoding suspect)
documented for a re-dump instead of publishing blind.
  THE CRASH THIS EXPOSED [T2, cdb-verified]: gotcha #11's dense-table gap
is a LATENT AV, not a guaranteed one -- the old table's 0x0D..0x12 gap
(task #5) survived on heap luck; the renumber reshuffled allocations and
Find() AV'd on a garbage entryName in WeaponCluster's PercentDone resolve.
Fixed with five named PAD entries (the mech.cpp attrPad idiom) + a
static_assert locking the pad base to PoweredSubsystem::NextAttributeID.
Gotcha #11 amended with the proof.

TASK #15 -- stale-ledger sweep: GAUGE_COMPOSITE ('composite not yet built',
Reservoir shadow, PlayerStatus/vehicleSubSystems 'remaining', valve-dormant-
until-0xBD3, sensor guard, the superseded 'Heat MFD near-static' reframe --
all banner-corrected), gauges-hud frontmatter, L4VB16 + powersub comments.

Verified live: 50/50 config attribute bindings resolve, 0 NULLs, 0 parse
skips, mech spawns and simulates 31/31 subsystems, no cross-blip fallbacks,
the pip cache fills through entity registration without the old guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:57:45 -05:00
arcattackandClaude Fable 5 8af278a3f5 Cockpit: unlit map-independent frame colour (task #55)
User report from the MP/cavern session: the canopy frame rendered flat light
gray -- unlike the dark frames in the polar exports.  Root cause: the frame
colour was routed through the LIT material path (Diffuse+Ambient), so it
multiplied with each map's lighting -- polar's dim purple light happened to
look right, cavern-night's ambient 0.8 washed it out to gray.

The canopy is unlit interior structure: the frame batches now go through the
renderer's pure-emissive UNLIT branch (black diffuse + emissive = the frame
colour), constant on every map.  The exact original value is not recoverable
(texture-less ramp = no texel luminance to index); default (0.13,0.12,0.15)
[T3] matches the near-black frame in pod gameplay footage.
BT_COP_FRAME="r g b" overrides; BT_COP_RAMP_L retired.

Verified: cavern-night and polar-day now show the identical dark frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:42:22 -05:00
arcattackandClaude Fable 5 27959990ea View: the INI viewangle is the HORIZONTAL field -- authentic 60x47 frustum (task #55)
Community reports said the camera looked too far back and the cockpit frames
were never this revealed; the user's hypothesis (our projection widens the
view) was exactly right.  The original Tesla projection call, preserved in a
comment at DPLRenderer::SetView, is

  dpl_SetViewProjection(view, -1, -aspect, +1, +aspect, 1/tan(viewangle/2))

i.e. BTDPL.INI's viewangle=60.0 is the HORIZONTAL field of view with the
vertical derived by aspect: the pod's frustum was 60 x 46.8 deg at 4:3.  The
port fed the 60 to D3DXMatrixPerspectiveFovRH as the VERTICAL fov -> 75 x 60
at 4:3 (worse on widescreen): the world rendered ~25% farther away and far
more canopy was visible than the pod ever showed.

Fix: BTFovYFromHorizontal(viewangle, aspect) at all five projection sites
(main, per-frame resize, sky pass, WM_SIZE rebuild, fallback) -- the
horizontal field stays the authentic 60 deg at any window shape.  The aim
boresight reads the live projection (gBTAimP22) and adapts; combat
re-verified (kill chain, no NaN).  Cockpits now show the frames at the
pod's framing: viewports dominant, frames at the periphery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:17:07 -05:00
arcattackandClaude Fable 5 96b8892e7c Cockpit: the authentic punch STENCIL-CUT -- all 8 cockpits real (task #55)
The user caught the inversion (blocked viewports, see-through frames); the
answer came from the rasterizer board itself: an i860 disassembly of the
VREND.MNG firmware decoded the damageize handler (@0xf040f6f8) -- cmd 0x20
(vr_damage_action) writes the dpl_Punchize token triple onto the punch
geogroup's first three geometry chunks IN FILE ORDER:

  chunk1 = the detailed window-aperture shapes -> an invisible per-pixel MASK
  chunk2 = the coarse hull -> the ONLY colour-drawn geometry (where not masked)
  chunk3 = byte-identical hull twin -> the damage-reset record, never visible

Visible canopy = hull-minus-apertures.  Both earlier readings were wrong ways
around the same three chunks: dropping all punch left floating fragments (no
hull); hiding the duplicate pair hid the HULL and drew the MASK -- the exact
inversion observed.

Port: bgfload tags per punch patch {non-paired pmesh = mask (role 1),
first twin = hull (role 2), second twin = skip}; L4D3D DrawMesh runs
mask->stencil-1 (no colour/z-write, z-tested), hull->stencil-NOTEQUAL,
mask->stencil-0; device D24X8 -> D24S8 + stencil clear.  The cut never
touches z, so later-drawn entities show through the apertures.
BT_COP_PLATES=1 disables (diag).

In-game, all 8 mechs on pure defaults: connected dark frames with clear
viewports -- thor (the footage mech) shows the central viewport + surrounding
frame exactly as filmed.  Combat regression clean (kill + no NaN).

Arena textured punch uses the same firmware kit; the black-texel alpha path
there is a working approximation, flagged in the KB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:53:22 -05:00