The relay fires the RunMission pair a fixed 20s after the egg ACK -- but
the ACK happens at egg RECEIPT, and a long mission load (glass + dev
gauges on a busy box measured ~50s) is still in LoadingMission when the
launch lands. L4Application::RunMissionMessageHandler's default case
Fail()ed on that state -- an abort() the 1995 code could afford because
the real console launched operator-paced -- which presented as a SILENT
crash at mission start (c0000409, no WER record; cdb stack:
ucrtbase!abort <- RunMissionMessageHandler <- RoutePacket; 3/3
reproducible; matches the field report's 464 crash that died
mid-LOD-load).
The default case now DEFERS: re-post the message to ourselves at +1s
(the VehicleDead re-post pattern) until the ladder reaches
WaitingForLaunch, bounded at 120 deferrals so a truly wedged load still
surfaces the original Fail. Verified: the crashing console-shaped run
deferred 104x through its ~50s load, then launched, rendered (scene
LIVE) and ran clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The launch handoff (LoadingMission -> RunningMission) could interleave
the idle wait-overlay presents with the first real game frames (user
report: "the wait screen is still there when game graphics come up").
gBTSceneHasPresented latches at the render path's Present; ExecuteIdle
stands down permanently once set -- the overlay can never paint over
(or alternate with) live graphics. Permanent one-shot/1Hz [waitscreen]
diagnostics record the active paint path, the scene-live moment, and
idle paints.
Verified: 70-frame 0.5s capture across the handoff -- 54 wait frames,
then game frames with at most ONE ambiguous frame at the cut (was
repeated interleaving); no black frames.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-mission overlay painted the window DC AFTER ExecuteIdle's
Present -- every cycle flashed the black D3D frame before the GDI
redraw, visible as flicker while the game loads (user report). The
overlay now lands IN the presented frame: the backbuffer refuses GetDC
on this driver (D3DERR_INVALIDCALL without LOCKABLE_BACKBUFFER, not
worth forcing game-wide), so the text+spinner GDI-paint goes into a
cached offscreen plain surface (GetDC always legal there) and
StretchRects onto the backbuffer before Present. D3DPOOL_DEFAULT cache
released at every device-Reset site; window-DC path remains for the
pre-device seat wait and as the fallback; one-shot [waitscreen] boot log
says which path is live.
Verified: 14 rapid PrintWindow captures of the waiting state all carry
the text (pre-fix runs alternated black frames).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rows are capacity + per-seat defaults now -- joiners auto-populate them
live and launch trims to whoever is seated, so hand-managing rows is
busywork. A Seats spinner (1-8) maintains the row count in relay mode;
Add pilot / Remove selected remain for mesh mode, where explicit
addresses matter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
join out -- ROSTER FULL on every seat request, field report). Local
instances seat-request like every joiner now; trim-on-launch makes
exact seat ownership moot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The operator no longer has to match the roster row count to the exact
player count (or watch the "empty seats will STALL" warning). Two
mechanisms:
1. PRESENCE BEACONS: the pod keeps its seat-request TCP connection open
for the process lifetime; the relay reads a live beacon as "seated"
and a pre-claim FIN frees the seat (no ghost seats -- a ghost stalls
every pod at the connection gate). Reservation expiry spares
beaconed seats.
2. TRIM-ON-LAUNCH (manual/GUI mode): operator LAUNCH before the roster
fills shrinks the session to the seats present -- egg [pilots]/pages
trimmed, seat ids REMAPPED by position (pods re-derive their host id
from their tag's roster position, so walk-up prefs and beacons re-key
consistently), roster/expected_ids shrink, eggs release, the launch
pair fires as ACKs land.
GUI: roster rows now update LIVE from the walk-up requests (SEAT n
PRESENT/FREED lines -> callsign/mech cells + a blue "seated" status
light), and LAUNCH MISSION enables from the first seated player with a
"starts with whoever is here" banner.
Verified 2-node: 2-of-4 trim ran the mission with the requested mechs;
and the mid-roster GAP case -- three joined, the middle player quit
pre-launch (beacon FIN freed the seat), trim remapped seat 4->3 with the
survivor's callsign/mech following, both survivors registered under the
remapped ids and ran the mission. Also: stdin reader guarded against
sys.stdin=None spawn shapes (the GUI QProcess pipe is the supported
operator channel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Launch-local-instances path pinned BT_SELF, which claims a seat
directly and SKIPS the seat request -- the join menu (and the callsign/
mech choice it carries) never ran, so a local test instance couldn't
exercise the walk-up flow. Relay-mode local instances now launch
zero-arg with BT_FE_JOIN=1: the JOIN-GAME menu opens, the relay assigns
the seat, and the choice lands in the egg like any join.bat player.
Each instance gets a distinct relaunch port via BT_FE_JOINPORT
(btl4main JoinRelay case; default 1501) -- two -net listeners can't
share a port on one box. Mesh-mode locals keep the direct path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 1995 front-desk conversation, over the internet. join.bat/
join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim:
CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the
normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST
as {callsign NUL mech NUL} (empty payload = roster defaults, wire-
compatible), and the relay validates the mech tag, HOLDS egg delivery
until every pod's console pad is connected, rewrites the egg via
eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6
fallback) and streams it to all pads -- so every player's egg copy
carries every player's callsign.
The hold is gated on CONSOLE-PAD count, not game-side registration: a
pod HELLOs only after parsing the egg's roster, so a registration gate
deadlocks (hit live in the first run; pods animate in WAITING FOR
MISSION ASSIGNMENT during the hold).
Verified 2-node e2e (env-injected requests): thor/vulture requested over
roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote
both seats (relay log), the delivered egg carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both
requested mechs. Join-menu layout screenshot-verified; the menu's
click-through relaunch awaits live human verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Distributed test zips go stale and stray. BT_EXPIRE (CMake option,
DEFAULT ON so a zip can never accidentally ship without it) makes the
exe refuse to start BT_EXPIRE_DAYS (default 14) after its build day:
boot logs the remaining window, an expired build logs + shows a
"test build expired -- ask the operator for the current build" box and
exits. btversion.h now stamps BT_BUILD_UNIX (UTC-midnight-rounded so
the only-on-change header churns once per day, not every build) +
BT_BUILD_DATE. Dev is unaffected: every build re-stamps the day.
-DBT_EXPIRE=OFF builds a non-expiring exe; mkdist reads the cache and
marks such a zip "-noexpire" + prints a warning, and the tester README
expiry note is {EXPIRE}-marker-gated like the steam lines. Second
compile-gate exception after BT_STEAM (convention updated in
glass-cockpit.md). Quality gate, not DRM -- a clock rollback defeats it.
Verified live: default build logs "test build window: 14 day(s) left";
a -DBT_EXPIRE_DAYS=0 build logs TEST BUILD EXPIRED and parks on the
explanation box (window title checked) without starting the game;
restored to 14 and rebuilt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For the 8-player playtest. Damage authority is VICTIM-side, so no single
peer sees the whole match: every -net instance now writes a compact
machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net;
BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode
pod dials the relay game port (the seat-request throwaway-dial pattern,
envelope route -9) and streams the file back -- the relay saves every
peer's copy under matchlogs/ on the operator's machine, so testers send
nothing by hand. tools/matchcheck.py <dir> reconciles all of them:
damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG,
victim-side authoritative), kill/death replication across observers,
PLAYER_DEAD counts, scores, version skew, replicant-application and
unattributed-damage anomalies, mid-mission disconnects.
Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG,
post-base, zone + post-application damageLevel), MechWeapon::
SendDamageMessage (FIRE), projectile impact/splash/collision dispatch
(PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at
MovementMode 9, NOT the transition: a replicant arrives at 9 straight
from the type-6 record header and never runs the transition branch),
BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone
crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host
handlers (PEER_UP/PEER_DOWN). Every line t=/w=/st=-stamped + fflushed.
Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same
amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run
logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn
cycles victim-side and the kill SCOREs shooter-side; relay-mode run
auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck
produced the full report with exactly one (correct) anomaly -- the force
hook's claim-less damage, the unattributed-damage detector working.
Relay gained a route-specific 8MB cap for the upload frame (game frames
stay capped at 1600).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the keymap whack-a-mole: three input layers exist (PadRIO/
bindings.txt under glass; btinput/CONTROLS.MAP under DEV; the bring-up
drive bridge also under DEV, which clobbers e.g. Alt-reverse every frame),
and DEV was the DEFAULT -- so every launcher that forgot BT_PLATFORM=glass
booted the old stack: the play bats, then play_steam, then the operator
console's local instances, each fixed with the same one line.
Omission can't resurrect it now: with no BT_PLATFORM, desktop boots GLASS
(and the menu on a zero-arg launch -- bare double-click of btl4.exe opens
the mission menu). BT_PLATFORM=dev explicitly opts into the legacy DEV
profile for debugging. BT_PLATFORM=pod is the cabinet profile; the real
pod's SETENV.BAT must set it (contract recorded in glass-cockpit.md --
the old "bare boot == cabinet shape" menu-guard assumption is retired).
Verified: no-env mission launch boots GLASS; bare no-env launch opens the
MENU; BT_PLATFORM=dev boots DEV.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The console's one-click local-instance launcher predates the unified build
and never set the platform env -- instances booted the DEV profile (no
PadRIO), so the legacy arrow-drive bridge owned the keyboard instead of the
bindings.txt keymap (arrows drove fwd/back/turn, Alt-reverse dead). The
join bats already set it; now the operator's QProcess env does too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the waiting-screen work, both user-reported:
1. "Animation frozen after connecting": once the console seats the player
and sends the egg, the state advances LoadingMission -> WaitingForLaunch
-- where NOTHING presents until the operator's RunMission, so the last
idle frame froze on screen with the mission audio already running.
ExecuteForeground now keeps the idle clear + overlay alive through
LoadingMission/WaitingForLaunch/LaunchingMission (no early return -- the
load/replication work continues), and ExecuteIdle picks per-state text:
WaitingForEgg "WAITING FOR MISSION ASSIGNMENT"
Loading/Launching "LOADING MISSION / stand by"
WaitingForLaunch "MISSION READY / waiting for the operator to launch"
2. The roster-full retry pause was a raw Sleep(3000) -- with the relay up
and answering SEAT_FULL instantly, the process spent ~95% of its time
unpumped (IsHungAppWindow TRUE, spinner frozen). Now 30 pumped 100ms
slices with the reason on screen ("session is FULL -- waiting for a
seat to free up").
Verified live against the running operator relay: roster-full wait shows
the reason text, spinner advances across 500ms captures, IsHungAppWindow
false on every sample.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-reported after the waiting-screen fix: "the animation keeps hanging."
Cause: RelayDialTcp used a BLOCKING connect() -- against a down/unreachable
operator each attempt froze the window thread for the OS SYN-retry window
(seconds), so the spinner ran only during the 2s sleep between attempts.
RelayDialTcp now connects NON-blocking and select()s in 100ms slices,
pumping the message loop + repainting the waiting screen every slice (both
in-flight and between retry attempts). The success path already returned a
non-blocking socket (FIONBIO), so caller semantics are unchanged; the other
dial sites (console dial, game-socket dial) inherit the smooth behavior.
Verified: 4 window captures at 400ms intervals during a dead-host dial all
differ in the spinner region (uniform diff energy == continuous animation)
and IsHungAppWindow reads false on every sample.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay seat-wait loop (RelayRequestSeat -- the join/join_lan "patient
walk-up") blocked the window thread's message pump for its whole 30-minute
patience window: dial (2s timeout) -> Sleep(2000) -> repeat, with status
going only to the parent bat console. Windows flagged the frozen game
window "Not Responding" until the operator started the session
(user-reported).
- BTWaitScreenPaint (L4VIDEO.cpp): a GDI waiting screen on the main window
-- headline + detail line in the console green + a 12-segment marching
spinner phased on the tick clock. Pure GDI: flicker-free during the
seat wait (no D3D presenting yet), and re-painted after each
ExecuteIdle Present so the WaitingForEgg phase (previously a bare black
frame) shows "WAITING FOR MISSION ASSIGNMENT" too.
- RelayWaitTick (L4NET.CPP): PeekMessage pump + paint. The outer dial
loop's Sleep(2000) becomes 20 pumped 100ms slices ("WAITING FOR THE
OPERATOR'S SESSION" / "session at <ip:port> -- close this window to
cancel"); the inner seat-reply wait pumps per 200ms select slice.
Verified live against a dead relay address: window-only PrintWindow capture
shows the animated screen, and user32 IsHungAppWindow (the API behind "Not
Responding") reports FALSE throughout the wait.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The col-1 stack (MAP 8 + SCENARIO 2 + TIME 4 + WEATHER 3 + LENGTH 4 with
headers/gaps) needs 630px; at 620 the last LENGTH rows collided with the
'click to select' footer band (client.bottom-30) -- user-reported from a
screenshot. 672 fits the stack + footer. The LAUNCH/steam buttons and
frame rect derive from MenuClientH and follow automatically.
Also: 'No Return (one life)' clipped at the column edge -> '(1 life)'.
Verified by screen capture of the live menu: LENGTH fully above the
footer, scenario label unclipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lynx supplied a photo of the period credits page (preserved at
reference/manual/credits_page.webp). It confirms, corrects and completes
the oral-history identifications:
- ECH = Eric Huffman (Audio Director / 3D audio framework) -- corrects the
uncertain "Eric Nichols" guess
- JMA = Joanna Mason (Simulation Code and Cameras) -- inferred mapping
confirmed
- CPB = Chris Brewer: "Secondary/Auxiliary Screens and Low Level I/O" --
the exact MFD gauge/mode layer this project reconstructs
- GDU = Gabe Underwood: "Simulation Code and 'Mech Internal Systems" --
the heat/coolant/generator/seek economy
- Full roster: Jordan Weisman (producer/designer), David McCoy (art
director), J.M. Albertson (technical director), Bill Redmann (corporate
director of technology), plus Corson, Edsall, Ciolek, Olsen, the artists
(Burlington, Molitor, McCoy, Story; Allen Workshop: Bonilla, Peters) and
sound (Bing McCoy, Effinger, Huffman).
- 10 software engineers + 6 artists == Lynx's "maybe 8 dev, 6 art" memory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Source archaeology (the per-file modification tables in the surviving
headers) surfaced six programmer initials: JM, ECH, CPB, GDU, JMA, GAH.
Read to VWE veterans on the project Discord (2026-07-21), VGL Lynx --
who watched the first multiplayer Thor walk terrain on a Red Planet map --
identified them: Jim Albertson, Eric Nichols (uncertain), Chris Brewer,
Gabe Underwood, Joanna Mason (mapping inferred), Garth Hermanson; hardware
initials MC = Marek Ciolek (AC unresolved), via Cyd.
Team size per Lynx: dev ~8, art ~6, MUNGA by a smaller parallel internal
team with crossovers, no outsourcing. Identifications marked T3 (oral
history, as-typed spellings) against the T0 initials; a corrections
invitation is included. Linked from context/project-overview.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tester's ER small laser died permanently (recharge arc + ready dot dark,
no fire) after idle seek-gear clicking. Root cause, byte-verified + headless-
reproduced -- a latent bug in the SHIPPED ARCADE BINARY:
- The charge integrates toward the GENERATOR voltage (@004ba838), not the
gear target; "full" is only detected inside the +-1% snap window around
seekVoltage[idx] (@004ba738), and overcharge reads ZERO (the byte-verified
_DAT_004ba830 = 0.0 clamp).
- Toggle seek while a charge is in flight and the level can land ABOVE the
new gear's window: rechargeLevel pins to 0, the ==1.0 Loaded test can
never fire, and charging continues to the generator ceiling where EVERY
gear reads overcharged. Permanent brick; generator reselect cannot help.
- The arcade dodged it by circumstance (locked 60 fps, rare seek use); the
port's clickable seek button hits it in seconds of casual clicking.
Fix (marked divergence in the Loading tick): overcharge counts as fully
charged -> Loaded. The arcade's own discharge algebra says full == the
gear's seek voltage, so an overcharged weapon IS full.
Verified: the same 2.5-min BT_SEEKTEST abuse that bricked the laser pre-fix
now fires 38 overcharge rescues and ends Loaded/pct=1. [seek] log now also
prints the per-gear voltage table + gen voltages (diagnosis instrumentation).
Ruled out on the way (documented in #21): overheat fuse (no firing occurred;
a 4-min autofire+coolant-abuse run wouldn't fuse), generator detach (gen
cycling + healthy siblings), seek-table corruption (table {6000,7000,8000,
9900} healthy).
KB: decomp-reference.md records the arcade-latent-bug analysis.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Third instance of the silent-swallow pattern (unregistered handler id ->
Receiver NullHandler). The Mech handler table @0x50BDF8 binds
{0x16, "BalanceCoolant" -> @0049f728}; the reconstruction never registered it.
Disasm-verified body (@0049f728): press-only guard (msg+0xc > 0; the binary
has NO novice lockout on this one), set EVERY condenser's valveState@0x1D0
to 1 (equal weights), then the shared redistribute @0049f788 -- which the
port ALREADY had faithfully as BTRecomputeCondenserValves (flow =
valve/total + condenserAlarm pulse; MoveValve calls it). So the fix is:
- mech.hpp/mech.cpp: BalanceCoolantMessageID=0x16 + handler + MESSAGE_ENTRY
- heatfamily_reslice.cpp: BTBalanceCondenserValves bridge (Condenser is a
complete type there; sets valves to 1 + redistributes)
- mech4.cpp: BT_BALTEST scripted harness (MoveValve press alternating with
a Balance press)
VERIFIED headless (BT_BALTEST=1 BT_VALVE_LOG=1):
boot -> all valves 1, flows 0.1667 each
MoveValve-> condenser#1 valve 5, flows 0.5 / 0.1x5 (the priority boost)
BALANCE -> all valves 1, flows 0.1667 each (equalized)
Awaiting the tester's live ADV-mode confirmation.
KB: decomp-reference.md Mech-table row marked wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause -- NOT issue #2 (the experience mis-seed pins everyone to VETERAN,
which PASSES the novice lockout; the gates were never the blocker). Two
unwired handlers, the same silent-swallow pattern ToggleCooling had
(Receiver::Receive -> NullHandler for an unregistered id):
- Myomers (handler table @0x51158C {9,"ToggleSeekVoltage"@004b8a48}): the
reconstruction had an EMPTY stub (the address falls in the untagged decomp
gap 0x4b8837..0x4b8a8c) and never registered the id. Body reconstructed
faithfully from the raw disassembly (tools/disas2.py 0x4b8a48): novice
lockout (@4ac9c8) -> heat-model gate (@4ad7d4) -> press-only (msg+0xc>0) ->
currentSeekVoltageIndex@0x320 = (idx+1) % (maxSeekVoltageIndex@0x32C + 1).
Modulo from zero, min index not consulted, no ResetFiringState.
- Emitter (energy-weapon table @0x511DB8 {0xb,"ToggleSeekVoltage"@004ba478}):
the faithful body already existed as the orphaned "AdvanceSeekVoltage"
(ZERO callers) -- converted to the registered message handler (same guards;
cycle + ResetFiringState when not wrapping, disasm re-verified). PPC and
GaussRifle copy-inherit the set; the ammo branch keeps its own id 0xb ==
EjectAmmo (still unwired, tracked separately).
Both registered via MESSAGE_ENTRY on top of the inherited chains. Permanent
diagnostics: [seek] gear log (BT_SEEK_LOG) + BT_SEEKTEST scripted dispatch
harness (mech4.cpp, the cooltoggle pattern).
VERIFIED headless: [seek] Myomers -> 3,0,1,2,3... and [seek] PPC_1 ->
3,0,1,2,3... (modulo-4 wrap, matching the binary's arithmetic). Awaiting
the tester's live ADV-mode confirmation for the real MFD button route (the
routing layer is the same streamed-EventMapping path ToggleCooling verified).
KB: decomp-reference.md message-table rows marked wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user hit the full session menu from play_solo -- HOST/JOIN LAN modes,
NET PORT and the Steam buttons don't belong in the solo flow (LAN goes
through the join bats + operator; Steam through play_steam.bat).
BT_FE_SOLO=1 (set by play_solo.bat + the btoperator generated bat):
- the MODE cycle keeps only the two solo entries (kModes lists them first:
SOLO MISSION, RAW SOLO) via the GroupSize clamp
- NET PORT row and the HOST/JOIN STEAM buttons are not laid out
- a stale fe_last.ini mode of hostlan/joinlan is rejected by the existing
LoadPersisted GroupSize clamp, so the selection can never land on a
hidden mode
play_steam.bat keeps the full menu.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
play_solo hardcoded -egg ARENA1.EGG, bypassing the miniconsole front end
that already carries the full pickers: SOLO/RAW-SOLO/HOST-LAN/JOIN-LAN
modes, 8 maps, all 18 mechs, 4 experience levels, colours/badges/patches,
drop zones, advanced damage, time-of-day, weather and mission length.
The bat now launches zero-arg (BT_PLATFORM=glass -> the menu); the menu
relaunches the exe with the generated egg and the cockpit/gauge env
(BT_START_INSIDE/BT_DEV_GAUGES) inherits into the mission child. Menu-boot
verified with the bat's exact env; the solo menu->mission->menu cycle was
verified at FE landing. README + the btoperator generated-bat template
updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The steam bat missed the two client flags the join bats set -- steam-launched
missions booted 3rd-person (the same field bug fixed for join.bat earlier).
Env inherits through the menu's mission relaunch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unification flattened BT_STEAM into the always-on exe; the user flagged
the constraint that makes it the one legitimate compile gate: the Steamworks
SDK license bars a public release with Steam inside, so a release build must
exclude it entirely.
- CMakeLists: option(BT_STEAM OFF) restored around the Steamworks block --
an OFF configure touches no SDK headers, no steam_api.lib, no DLL copy.
BT_GLASS stays retired (always defined); ONE build dir either way.
Dev checkout: build/ configured once with -DBT_STEAM=ON.
- mkdist: reads BT_STEAM from build/CMakeCache.txt. Steam-OFF zips exclude
play_steam.bat + steam_api.dll (explicitly filtering a STALE DLL left by
an earlier ON build), strip the README steam section ({STEAM} line
markers), and take a -nosteam name suffix so the flavors never clobber
(found live: both stamped 4.11.436 and the release zip overwrote the dev
zip).
Verified both flavors:
ON (dev build): steam smoke degrades to Winsock without the client;
zip carries play_steam.bat + steam_api.dll + README steam lines.
OFF (scratch build-relcheck, deleted after): 0 compile errors; exe dir =
btl4.exe + OpenAL32.dll only; boots + simulates; BT_STEAM_NET=1 inert
(transport not in the binary); end-to-end zip has zero steam bits and
a clean README.
KB: context/glass-cockpit.md unified-build section records the license gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first zips (pre-mkdist) shipped d3dx9_43.dll / msvcp140.dll /
vcruntime140.dll next to the exe -- testers without the VC++ / DirectX
redistributables cannot load the exe otherwise. mkdist missed them (the
clean-extract boot test passed only because this machine has them system-
wide). Carried into redist/ (recovered from BT411_4.11.341.zip) and
packaged next to the exe.
Also: players/README.txt -- the tester instructions (updated for the
unified build: one exe, clickable cockpit surround, trigger-config regroup,
the settled keymap, play_steam.bat) -- stamped with the version and placed
at the zip root, matching the original zips' shape.
Clean-extract verified on BT411_4.11.435.zip: root has README + 4 bats,
exe dir carries all 4 DLLs, glass-profile boot simulates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three build dirs (build\ pod, build-glass\, build-steam\) were compile-
time splits of the same game; the tax was real: a stale steam exe shipped 2
days behind, the felt keymap flipped with the boot flavor, and cockpit
clicks were silently dead in the pod build (the trigger-config incident).
Now everything compiles into the single build\ exe:
- CMakeLists: BT_GLASS/BT_STEAM options removed; the glass TUs (PadRIO,
bindings, panel, glass windows, plasma) + FE/console + Steam transport
compile unconditionally; both macros always defined (seam markers).
- Steam: steam_api.lib linked with /DELAYLOAD:steam_api.dll + delayimp --
the DLL maps only when SteamAPI is first called, and every call site
gates on BT_STEAM_NET=1 (BTSteamNet_* short-circuit on steamActive), so
machines without the DLL run everything but Steam sessions.
- CABINET GUARD (btl4main): the miniconsole menu previously claimed any
zero-arg launch -- unified, that would hijack the pod cabinet's boot
shape. The menu now requires BT_PLATFORM=glass (or BT_FE_MENU=1);
platform parse moved above the fe_menu_mode computation. A bare boot
behaves exactly like the old pod build (DEV profile, wait for egg).
- Bats (play_solo/join/join_lan/play_steam) + the btoperator.py template:
single build\ path + BT_PLATFORM=glass. mkdist: one exe (+DLL scan
picks up OpenAL32 + steam_api). build-glass\/build-steam\/build-glass2\
(a stale Jul-20 cache) deleted.
Verified matrix on the unified exe:
bare zero-arg boot == old pod exe (same DEV profile + egg-load path;
the segfault on a missing egg is pre-existing, baselined vs the zip exe)
glass + BT_BTNTEST scripted click -> [cfgmap] ENTER/EXIT (trigger config)
runs with steam_api.dll ABSENT (delay-load proven)
glass zero-arg -> miniconsole menu
2-node loopback MP: 145 ticks each, cross-connected
BT_STEAM_NET=1 without Steam client -> "staying on Winsock", game runs
KB: context/glass-cockpit.md compile-gates section rewritten for the
unified model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilt build-steam fresh (was stale at Jul 20 -- predated today's gauge/
trigger-config/keymap fixes); the DLL scan picks up steam_api.dll
automatically. Clean-extract verified: the steam exe boots to the front-end
menu (Steam client only needed for live lobbies).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Packages the player bats (zip root), BOTH exes (build\ pod fallback +
build-glass\ preferred -- the bats auto-select glass = clickable cockpit /
trigger config / bindings.txt keymap), the runtime DLLs next to each exe
(OpenAL32.dll -- the first clean-extract boot test failed without it), and
the git-TRACKED content/ files only (the working tree carries dev junk that
must not ship). Output: dist/BT411-<date>-<hash>.zip (dist/ gitignored).
Verified: clean-extract boot test -- glass exe boots the GLASS profile and
simulates; pod exe boots as fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
join.bat + join_lan.bat + the btoperator.py generator template now prefer
build-glass\Release\btl4.exe when present and set BT_PLATFORM=glass with it
-- so MP sessions on a dev checkout get PadRIO (mouse-clickable cockpit
buttons: trigger config, MFD banks, radar rails) and the bindings.txt keymap,
identical to solo play. Player zips (which ship only build\) fall back to
the pod build exactly as before.
Verified: 2-node loopback MP smoke on the GLASS build (the documented
procedure: A -egg MP.EGG -net 1501 + B -net 1601 + tools/btconsole.py) --
both nodes cross-connected (GameMachineHost 1502/1602), ~150 sim ticks
each, no errors. btoperator.py syntax-checked; regenerated bats carry the
same guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context/glass-cockpit.md: record the BT_PLATFORM=glass/PadRIO click gating
(the dead-clicks root cause), the verified live regroup loop, the [cfgmap]/
BT_BTNTEST diagnostics, and the one-keymap settlement (babf5b1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user standardized on the "newer" layout (numpad aim cluster, arrows =
throttle/pedals, Alt = reverse thrust). That layout lived only in
bindings.txt (PadRIO, GLASS-profile boots); CONTROLS.MAP (btinput, DEV/pod
boots) lacked the numpad cluster entirely and had NO reverse binding -- so
the felt keymap flip-flopped with the boot flavor.
Added to CONTROLS.MAP + the byte-identical compiled-in default profile
(btinput.cpp sDefaultProfile):
Alt -> button 0x3F (throttle-head REVERSE THRUST)
NumPad8/2 -> JoystickY aim up/down
NumPad4/6 -> JoystickX torso twist
NumPad7/9 -> pedals (turn)
NumPad5 -> AllStop
Shift -> throttle up (alt)
Parse-verified: [input] loaded 61 binding(s) from CONTROLS.MAP, no warnings.
Both engines now carry the same layout, so DEV/pod boots (MP join bats) and
GLASS boots (play_solo) feel identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user held the MFD PROGRAM button and nothing happened (weapons kept
firing). Diagnosis, verified end-to-end:
- The cockpit mouse handler DID register the click ([cockpit] CLICK addr=0x8
press) and 0x8 IS a streamed PROGRAM element (EVENT msg 0x9 -> weapon,
Mfd1 quad page mask).
- But every launch ran the DEV platform profile (no BT_PLATFORM) ->
L4CONTROLS=KEYBOARD -> no PadRIO instance -> PadRIO::SetScreenButton
no-ops (activeInstance NULL) -> the press never entered the RIO queue ->
ConfigureMappables (id 9) never dispatched -> the mode never flipped ->
fire keys kept firing. The config machinery itself was never broken.
- With BT_PLATFORM=glass (the GLASS profile: L4CONTROLS=PAD -> PadRIO), the
full chain now proves out headlessly:
[btntest] PRESS 0x8 -> [cfgmap] ENTER session on ERMLaser_1
mode 0x450421 -> 0x448421 (NonMapping 0x10000 -> Mapping 0x8000)
[btntest] RELEASE 0x8 -> [cfgmap] EXIT (mode restored)
Landed:
- mechweap.cpp: [cfgmap] BT_FIRE_LOG diagnostics in the real
ConfigureMappables/ChooseButton handlers (they were silent -- the G-key
harness had logs but the authentic button path had none).
- L4PADRIO.cpp: BT_BTNTEST="addr,pressPoll,releasePoll" scripted screen-
button harness through the REAL click seam (EmitButton -> RIO queue ->
manager drain -> buttonGroup mapping) for headless verification.
- play_solo.bat: prefer the glass build when present AND set
BT_PLATFORM=glass so PadRIO exists and cockpit clicks work.
Side finding (the user's keymap "flip-flop"): CONTROLS.MAP (btinput, DEV/pod
profile) and bindings.txt (PadRIO, GLASS profile) are two parallel binding
engines; which one runs depends on the platform profile, so the felt keymap
changes with the boot flavor. Unification pending (user decision).
Awaiting live verification of the full hold-PROGRAM + tap-fire regroup flow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The weapon panel's btjoy trigger-config display (ConfigMapGauge) never rendered
because the reconstruction labelled @004c6ee0 as "SetColor(int)" and, finding no
caller, concluded the gauge was authentically dormant (task #6), gating it
behind a BT_CONFIGMAP dev env.
The user challenged this (the PROGRAM/TRIGGER CONFIG button implies the display
visualizes your config). Re-verification found the old analysis failed twice:
the "no caller" search looked for direct calls to a VIRTUAL, and its slot math
used the wrong vtable copy. The real ConfigMapGauge vtable @0051a1b8, matched
against the T0 GaugeBase virtual roster (GAUGE.h), pins @004c6ee0 at slot 9
(+0x24) == GaugeBase::LinkToEntity, and @0x94 is the linkedEntity -- the
Execute gate is "not yet linked", not "disabled". The engine broadcasts
LinkToEntity(viewpointEntity) to every gauge at viewpoint bind (APP.cpp:1277 ->
GaugeRenderer::LinkToEntity GAUGREND.cpp:3011), arming the gate -- the joystick
DOES render in the shipped game, showing per-trigger mapping state (solid =
mapped, matching the DOSBox reference).
Port fix: SetColor(int color) -> LinkToEntity(Entity*), color -> linkedEntity,
BT_CONFIGMAP dev enable deleted (the authentic path lights it). Render-verified
with no env override: the joystick + mapped-trigger lamp draw on weapon panels.
KB swept per the correction mandate: gauges-hud.md, decomp-reference.md,
open-questions.md, GAUGE_COMPOSITE.md, VEHICLE_SUBSYSTEMS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context/gauges-hud.md: record [this+0xA0]=fillColor(green)/[this+0xA4]=
backgroundColor(black), the zone colours, and the swapped-colour bug that
rendered the remainder green. Commit 4fbc911.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the tiled-render fix: the two colour params were swapped. The
binary caller (@004c8269) pushes the colours 0xff then 0; they land at
[this+0xA0]=fillColor (0xff green) and [this+0xA4]=backgroundColor (0 black).
The binary uses the GREEN for zone 1 (tile SetColor / dots) and zone 2 (the
over-degrade fill), and BLACK only for zone 3 (the unfilled remainder
[valPix,width]).
The port had them reversed -- zone 3 used fillColor -- so the whole remainder
of the bar (most of it, since valPix is small when cold) rendered solid green
instead of black. Swapped: zone 1 + zone 2 = fillColor (green), zone 3 =
backgroundColor (black). Render-verified: black background + green dotted tick
scale + a green hatch fill growing from the left, matching the reference.
Both builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context/gauges-hud.md: new section on the TEMP/STATUS bar's three-zone tiled
render (@004c4340) and the port bug where Execute solid-filled from warnPix and
ignored the tile image. Commit 4d4436e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-weapon TEMP/STATUS bar (HorizTwoPartBar) rendered as a solid block
starting at the warn-threshold pixel -- it "started in the middle" and was
never striped. The port's Execute had been rewritten with DrawFilledRectangle
and never used the interned tile image, unlike the (correct) VertTwoPartBar
which tiles via DrawTiledBitmap.
Restored the binary's three-zone render (disasm @004c4340), along X:
[0, warnPix) DrawTiledBitmap(tileImage) -- the striped/dotted pattern
[warnPix, valPix) backgroundColor solid (only when value > low)
[valPix, width) fillColor solid
So the bar now fills the striped tile from the beginning and matches the
reference (hatched fill block on the left + dotted tick scale). Render-
verified vs the DOSBox reference capture. Both builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context/gauges-hud.md: new section on BallisticWeaponCluster::DrawWarningLamp
(@004c9b50) swapping the ammo NumericDisplayInteger colours by the fire-ready
dot state (green-on-black when absent, black-cut-out when present), and the
port bug where only the non-virtual base existed. Commit 66dcdf2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base-page missile/round count sat on the fire-ready "dot" (the cluster
image blitted green when charged) as green digits on an opaque black box --
a messy dark rectangle punched into the solid green dot.
The binary swaps the ammo count's colours with the dot state. Verified from
the decomp: BallisticWeaponCluster overrides DrawWarningLamp (@004c9b50) --
it chains the base (@004c932c, which blits the dot in on-colour 0xff /
off-colour 0) and then calls the ammo NumericDisplayInteger's SetColors:
dot ABSENT -> SetColors(0, 0xff) == green digits on black
dot PRESENT -> SetColors(0xff, 0) == BLACK digits cut out of the green dot
so the count is always legible (a stencil against the solid dot -- the same
black-on-green look as the loop/generator squares). SetColors ForceUpdate()s
the numeric so it repaints over the freshly drawn dot.
The port had only the base WeaponCluster::DrawWarningLamp (non-virtual, no
swap), so the count stayed green-on-black and clashed with the green dot.
Made DrawWarningLamp virtual and added the BallisticWeaponCluster override.
Render-verified (STREAK "0024" now black-cut-out of the solid dot); energy
weapons (no ammo count) unaffected. Both builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The eng-page generator-voltage bar (evolt.pcc, ScalarBarGauge @004c721c) and
the MyomerCluster seek-voltage graph were fed by GeneratorVoltageConnection,
which walked the same dead path as the generator-letter lamp:
ResolveLink(AttributePointerOf(subsystem,"InputVoltage")) -> read resolved
+0x1DC == Generator::outputVoltage. The BT_DEV_GAUGES audio attribute rows
shifted the chained attribute ids so AttributePointerOf no longer lands on
voltageSource@0x1D0 -> the bar always read 0.
New BTGeneratorVoltage(subsystem) bridge (powersub.cpp) reads the NAMED member
via PoweredSubsystem::ResolveVoltageSource()->Generator::MeasuredVoltage()
(outputVoltage@0x1DC), bypassing the attribute table -- same pattern as
BTPowerSourceFrame/BTCoolingLoopFrame. Both GeneratorVoltageConnection callers
(the ScalarBarGauge param + the MyomerCluster seekValue) now pass subsystem_in.
[voltfeed]-verified: Myomers -> 10000V from GeneratorD (was 0 on the dead
path). Both pod + glass builds clean. Eng-page render awaiting live playtest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User confirmed live (pod build, solo ARENA1 cockpit) that the loop-number
and generator-letter boxes render correctly on the weapon MFD panels.
Fix commit e634709.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context/gauges-hud.md: new section recording the color-drop + shadow-field +
attribute-table-shift fixes for the btploop/btpbus lamps (commit e634709),
with the lesson that gauge value feeds must read named members through a
complete-type bridge, not AttributePointerOf+ResolveLink. Notes the eng-page
GeneratorVoltageConnection as the remaining sibling on the old path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SubsystemCluster per-weapon MFD panels render two image-strip lamps in
the TEMP/STATUS area: the cooling-loop number (btploop, 1..6) and the
generator letter (btpbus, A..D). Both drew as completely empty boxes.
Three stacked databinding bugs, all now fixed:
1. Color drop (btl4gau2.cpp): AnimatedSubsystemLamp/AnimatedSourceLamp
ctors dropped the bg/fg color params the binary passes (bg=0xff,fg=0,
same as the neighboring temp bar) and hardcoded 0,0 -> OneOfSeveral::
Execute did SetColor(0)+DrawBitMapOpaque(0), i.e. black-on-black.
Restored 0xff,0 (verified vs @004c70a4 / caller SubsystemCluster
@004c8140).
2. Shadow-field trap (btl4gau2.hpp, gotcha #2): both lamp classes
re-declared `int selected;` while already inheriting it from
OneOfSeveral (@0xAC). The CoolingLoop/PowerSource connection wrote the
derived shadow copy while OneOfSeveral::Execute read the base @0xAC
(always 0) -> every lamp stuck on frame 0 ("OFF"). Removed the
redeclarations so &selected binds the inherited member. Loop numbers
now render (verified: PPC->4, Myomers->5, ERMLaser->1/6, etc.).
3. Attribute-table shift (powersub.cpp + btl4gau2.cpp, gotcha #8): the
generator-letter lamp resolved its source via
ResolveLink(AttributePointerOf(subsystem,"InputVoltage")), but the
BT_DEV_GAUGES audio attribute rows shifted the chained attribute ids so
AttributePointerOf no longer landed on voltageSource@0x1D0 -> the link
always resolved to 0 (OFF) even though the master PoweredSubsystem ctor
DID bind voltageSource to its Generator. New BTPowerSourceFrame bridge
reads the NAMED member via PoweredSubsystem::ResolveVoltageSource() and
returns Generator::generatorNumber (@0x1E0), bypassing the attribute
table -- the same pattern as the BTCoolingLoopFrame fix. Generator
letters now render (verified: PPC_1->GeneratorA="A", Myomers->
GeneratorD="D", SRM6->GeneratorB="B").
Result: the "4 A / 1 B / 5 D" boxes render with both the loop number and
generator letter, matching the reference. Kept BT_LOOP_LOG-gated
[loopfeed]/[busfeed] feed diagnostics; both pod + glass builds clean.
Awaiting live playtest verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>