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>
Menu (btl4fe, modeled on RP412 RP_L4/RPL4FE.cpp): full catalogs as
always-visible clickable LISTS in three columns with friendly names --
8 maps, the canonical 8 certified mechs, colors, times, weather, lengths,
modes -- bright/dim green with a selection chevron, double-buffered paint,
real EDIT controls for pilot name + LAN peers (green-on-black via
WM_CTLCOLOREDIT), framed LAUNCH / HOST STEAM / JOIN STEAM buttons, and the
loadout persisted across the per-mission relaunch (fe_last.ini, gitignored
implicitly by *.ini? no -- plain file beside the eggs). ENTER/ESC kept.
Lobby room (RP412 RPL4LOBBY look): pilot roster with loadouts (mech, color),
framed L A U N C H M I S S I O N (owner) + LEAVE LOBBY buttons, clickable.
Dist: FLAT layout (the RP412 convention) -- ONE btl4.exe entry point at the
folder root beside the content tree (the shape TeslaConsole manages), DLLs +
launchers at root, content flattened from git-tracked files. Verified: the
staged flat tree boots the Mission Console zero-arg from its own root; the
click-driven menu launches a full marshaled mission (row click -> length=60
in the egg, LAUNCH click -> ladder -> 41+ ticks, fe_last.ini written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per Cyd: the two 4x4 hex keypads leave the glass panel (engine keypad units
stay reachable via bindings.txt); the four board columns (Thr/Sec/Scr/Joy)
move to the center between the lower MFD stacks and rise into the vacated
internal-keypad space -- 72 controls, ~5 rows shorter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every field bug this session needed the relay's OWN view to diagnose,
but the app only shows it in the (post-mortem-unreadable) GUI log pane.
Now _console_output also appends to content/operator_relay.log (fresh
per session; stderr is merged so relay tracebacks are captured too).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FIELD BUG (2026-07-18): operator's local instance crashed + the mission
never launched. Root cause (confirmed by code inspection + stub test,
NOT the earlier mis-read of stale logs): the relay assigns a no-BT_SELF
joiner 'the lowest free seat'. The operator's local instance uses an
EXPLICIT seat (BT_SELF) but takes ~15s to boot; a remote join.bat
requests a seat immediately and gets assigned the operator's seat 1
first. The operator's later HELLO for seat 1 -> 'already registered'
-> dropped ('closed by relay') -> its seat sits empty -> the
All-connections-completed gate never fires -> game never starts.
Fix: the operator's LOCAL seats are RESERVED. btoperator passes the
Local roster tags as ; the
relay excludes those host_ids from seat ASSIGNMENT (an explicit HELLO
still claims them). Verified by stub test: a racing joiner is assigned
host 3 (seat 1 skipped), the operator's HELLO for the reserved seat 1
registers fine.
Also fixed the UX bug that produced the EARLIER 4-window mess: Local
now defaults checked only on the operator's own seat (row 0), not every
relay roster row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Coverage audit of this session's ~30 commits against the docs found
gaps; fixed:
- README §Multiplayer was stale (only the old mesh flow, 'combat in
progress'): now leads with the relay + operator-console path
(internet play, join.bat/play_solo.bat, patient wait) and lists what
the mesh path has verified (4-pod, camera seat, callsigns, clock);
README controls line updated (turn/twist/pitch, pad, CONTROLS.MAP)
- decomp-reference env table: added BT_CAM_LOG, BT_SPEC_LOG (were in
code, not the hub); BT_INPUT_LOG note gains the twist/elev axis
- pod-hardware §input remap: the AXIS SIGN CONVENTION finding
(positive sim = CCW/left; bridge negates once per channel;
screenshot-forensics lesson) was only in commit cb82d8c -- now durable
- multiplayer.md: PATIENT WALK-UP + MISSION CLOCK sections (features
shipped in f9f230c/f57d25f, only incidentally referenced before)
checkctx CLEAN (20 topics, 68 glossary keys). All session features
now have topic-file coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
33 master commits in (relay TCP/UDP + PySide6 operator console, CONTROLS.MAP
+XInput binding engine, camera seats, torso pitch aim, sign fixes, the 1995
manual, version stamping, 18-mech certification). Conflicts: .gitignore +
CLAUDE.md router rows (combined).
SEMANTIC RECONCILIATION (the one real overlap): masters btinput binding
engine (ungated, CONTROLS.MAP) and the glass PadRIO (gated, bindings.txt)
would both read the keyboard/pad in a glass+PAD session. btinput now joins
the stand-down convention: BTInputPoll yields (and BTInputSuppressKey claims
NOTHING, so authentic hotkeys flow) when an operational cockpit device owns
the input path -- BTRIODevicePresent, BT_KEY_BRIDGE force-override honored,
forced harness exempt. One input system per mode: btinput on pod/dev
desktops, PadRIO on glass. The mechmppr/mech4 bridge merges composed clean
(masters negate-once sign fix inside our device-gated bridge). The D1 relay
keeps its own raw sockets by design (an alternative LAN wire; Steam and
relay are separate modes).
Verified post-merge: all 3 configs build; glass boots with [input] binding
engine standing down + PadRIO owning input (30 ticks); pod forced-walk
speedDemand=61.501 with btinput ACTIVE; 2-node loopback MP full 31/31
mission, 76/76 ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dumped each mech's subsystem->coolant-loop (BT_SPEC_LOG in mech4: walks
the roster once, resolves each subsystem's linked-condenser number via
BTCoolingLoopFrame) and diffed all 6 manual mechs' COOLANT LOOPS tables.
RESULT -- the structure survives the 4.0->4.10 gap remarkably well:
- BACKBONE identical on ALL 6: Generator A/B/C/D on loops 1/2/3/5,
Sensors(our Avionics) on loop 2, Myomers on loop 5, LRMs on 1&3,
autocannon on 4, big energy weapon on 6 -- exact match
- weapon LOADOUT identical on 5 of 6 (Thor/Vulture/MadCat/Owens/
Blackhawk); Loki is the one full rework (4.0 PPCx2/AFC100/SRM6 ->
4.10 AFC50x2/ER Medium x2/SRM4)
- the consistent 4.0->4.10 change is a small-laser REDISTRIBUTION: the
2 ER Small Lasers moved off the sensor/heavy loops onto the energy
loops 4&6 (Thor/MadCat/Vulture); Owens near-perfect (loops 1&3 exact)
- Loop 0 = correctly UNCOOLED infrastructure (condensers, reservoir,
gyro, torso, HUD, ammo bins, ...) -- not a coolant loop
CONCLUSION: the coolant-loop reconstruction is faithful; every diff is
4.0->4.10 balance tuning, not a bug. BT_SPEC_LOG retained.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Audited our live streamed subsystem config against the manual's per-mech
stat sheets (new BT_SPEC_LOG dump: torso speed/limits, heat-sink count,
reservoir capacity, at subsystem ctor).
RESULT: structure + semantics align perfectly; the tuning NUMBERS
differ -- because the manual is Tesla 4.0 and our content is release
4.10. Point-release balance drift, NOT a reconstruction bug:
- Owens + Blackhawk are the two fixed-torso mechs in BOTH (speed 0,
limit ~0, torsoHorizontalEnabled=0); the other four twist-enabled --
our reads correctly identify every case
- torso speed/limit + heat-sink count exist per-mech exactly as the
manual documents; only the values were retuned 4.0->4.10 (e.g. torso
speed MadCat 80->50, heat sinks Loki 38->15)
- reservoir coolantCapacity reads a flat 20 = the coolant THERMAL
capacity (game units), NOT the manual's per-mech 'liters' (a
display/flavor quantity with no single subsystem field) -- different
measures, not a bug
CONCLUSION: do NOT retune content to the manual -- BTL4.RES is the
authentic 4.10 shipping data; the 4.0 manual's numbers are an earlier
pass. The manual CONFIRMS our structural fidelity.
BT_SPEC_LOG retained for re-auditing. Follow-up (structural, less
drift-prone): the per-mech coolant-loop weapon/generator assignments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User live-tested: 'if i push the left arrow the mech turns right' --
correct, and it invalidated my earlier screenshot-forensics 'D=right
verified' claim (the yaw telemetry actually agreed with the user all
along: D gave POSITIVE yaw = CCW = LEFT; the sim uses math convention,
positive = counter-clockwise, for turn AND twist).
On the pod the RIO Ranger owned the hardware sign; the desktop bridge
now negates in ONE place per channel family:
- key_turn = -gBTDrive.turn (forced/BT_GOTO harness demands stay
sim-frame, un-negated)
- stickPosition.x = -gBTTwistAxis (Standard/Veteran torso stick)
- SetFreeAimSlew call-site negations REMOVED (the bridge negation now
flows through; double-flip removed)
Matches the manual (p8): 'pulling your joystick to the right torso
twists your Mech to the right.' User verified live: arrows steer
correctly (Blackhawk, basic mode) and MadCat torso twist is correct
in middle mode.
LESSON (recorded): large-rotation screenshot comparisons are AMBIGUOUS
(both directions put 'new scenery at an edge'); trust tracked
landmarks, numeric telemetry with an established convention, or the
user's live observation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reference/manual/Tesla40_BT_manual.pdf -- the genuine Tesla 4.0 BT
player manual, 34pp. Primary-source alignment audit (pod-hardware.md
SManual): the reconstruction matches on EVERY checked control behavior:
- control modes are named BAS/MID/ADV in print (mechanics identical to
our Basic/Standard/Veteran incl. the ADV no-auto-slow turn clamp)
- stick right = torso right in MID/ADV (today's sign fix); Blackhawk
and Owens named as THE fixed-torso exceptions; twist arc ~120
(per-mech: Loki/Thor tables say 110 deg limit, 60 deg/s)
- continuous throttle lever; reverse = HOLD the red throttle button
(our 0x3F hold-state model)
- Hot Box MFD = callsign buttons + CLOSEST (our hotbox button 8 ->
ChooseNearestPilot); radar travel-oriented w/ 60-deg wedge sweeping
on twist; map zoom +/- beside the secondary screen
- experience gates the hunting aids (standard sim only); expert adds
movement heat
- per-mech stat sheets + coolant-loop tables = a systematic
cross-check source for our streamed subsystem resources
NEW LEADS the manual surfaces: CROUCH (button by the secondary screen;
duckState attr + SQUAT clips exist, no input drives them), EJECT
(button beside the joystick; EjectButton01 sounds exist), the hot-box
viewscreen framing (deferred PNAME marker chain).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: 'left/right is reversed'. Investigation with screenshot
+ numeric verification found it channel-specific:
- A/D leg steering: CORRECT (D tap -> scenery slides left = turns
right; verified via cockpit screenshots)
- Torso TWIST (turning-torso mechs, e.g. MadCat): CORRECT all along
(E -> currentTwist +0.27 = right -- consistent with weeks of
validated play; an interim blanket negation that broke this is
reverted)
- Fixed-torso FREE-AIM slew (Blackhawk & friends): REVERSED -- E/right
panned the view LEFT. The slew consumer pans opposite the twist
convention; on the pod the RIO layer owned the hardware sign. Fix:
negate stick_x ONLY at the two SetFreeAimSlew call sites
(Standard + Veteran branches).
Verified after fix: MadCat E -> currentTwist +0.270 (right); Blackhawk
E -> view pans right. New BT_INPUT_LOG diags: [input] twist
in/currentTwist (Standard branch) + mechYaw in the Basic elev line.
Note for testers: the tester was almost certainly flying a fixed-torso
mech -- 'reversed' reports should always record WHICH mech.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mechs could always tilt their aim up/down -- Torso models the full
vertical axis (currentElevation, rate, VerticalLimitTop/Bottom,
recenter) and EVERY 1995 control mode routes stickPosition.y into
Torso::SetAnalogElevationAxis -- but the desktop bridge hard-zeroed
stick Y, so the axis was dead on a keyboard rig. The one pod control
the remap left unwired.
- btinput: JoystickY axis -> elevTarget/elevActive/elevAbsolute
- mech4 shim: sElev integrator (same walk/spring model as the twist;
X recenters pitch too via gBTElevRecenter)
- mechmppr bridge: feeds stickPosition.y every bridged frame (the old
unconditional zero removed); both mode branches covered
- CONTROLS.MAP (+ numpad profile + compiled default): R/F = aim
up/down, pad LeftStickY = elevation (was unused)
- torso.hpp: CurrentElevation()/ElevationVelocity() accessors (diag)
- [mppr] trace gains stickY (note: the trace reads AFTER the next
frame's device push re-zeroes the stick -- input flows regardless)
Verified live: R held -> torso elevation climbs at the authored rate
and clamps at 0.349066 rad = exactly 20.0 deg (the Blackhawk's
VerticalLimitTop); release holds the aim. The eyepoint correctly
stays level -- pitch aims the GUNS and reads on the HUD's vertical
elevation tape, as in the pod.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stubbed dpl-instance ranking display is reborn as screen-space
quads in CameraShipHUDRenderable::Render:
- followed-player callsign banner (bottom center; the old direct
index was correct -- Execute already converts to the 0-based
texture slot)
- the RANKING WINDOW: one row per scoring player, [ordinal][callsign]
in rank order right of center; visibility = the Director's
authentic flash logic (10s on / 15s off, solid final 30s); rows
follow LIVE playerRank pointers so they re-sort as scores change
- discovery: each 128x32 ordinal bitmap packs TWO ordinals side by
side ('1st|2nd', '3rd|4th' -- why 4 bitmaps serve 8 players);
draw = texture rank/2 with a u-half selected by rank parity
- alpha-blended A4R4G4B4 white-on-transparent textures x green
diffuse = the authentic green look
BT_SHOT moved AFTER the 2D pass -- it captured the backbuffer pre-HUD,
so overlays were on screen but invisible to screenshots (cost one
false debugging round). GetOrdinalTexture accessor added.
Screenshot-verified: '1st MAVERICK' standings + MAVERICK banner over
live auto-directed coverage; 2-node mech smoke PASS (un-regressed;
the new render path only executes when a CameraDirector HUD exists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spectator/broadcast seat works end-to-end: a hostType=1 +
vehicle=camera pilot page boots a CameraShip whose 62-camera arena1
network loads from the BTL4.RES type-27 resource (the engine's
CreateStreamedCameraInstances existed all along -- the file probe falls
back to it), the BTCameraDirector locks onto the first live mech, and
the ship TRACKS it (sees=1, goal advancing) and CUTS between authored
camera positions (screenshot-verified: wide arena shot -> close
tracking shot).
The missing piece was the Mech override of PlayerLinkMessageHandler
(@0049f624), which the reconstruction never filled. The engine base
resolves mech->player; BT's override adds:
1. the REVERSE link player->playerVehicle = this on EVERY node
(replicants included) -- how the camera director and scoreboard
find a REMOTE player's mech; without it a spectator parks forever
('NO goal entity')
2. clears NonScoringPlayerFlag (0x4000 = bit 14): a pilot with a
vehicle is a SCORING player -- this admits REPLICATED players to
the ranking pass, so cross-node rank/score displays work
3. master only: seeds the heat bank's ambientTemperature (bank
@0x1d4) from the mission [mission] temperature= (BTMission+0xf4)
-- correcting the heat family's 'frozen 300' deviation note (it
was never frozen; THIS is the writer)
Bridge BTSetBankAmbientTemperature lives in heatfamily_reslice.cpp
(mech.cpp cannot include subsystem headers -- local-stub collision).
BT_CAM_LOG diagnostics: camera-network count, director pick + Players
group census, ship follow state, link dispatch/receive.
Verified live: 2-seat (mech + camera) relay session -- replicated
player shows +veh, director goal locked w/ 30s timer, ship tracking a
moving mech through camera cuts; standard 2-node mech smoke PASS
(un-regressed). Lesson re-learned: a 'clean' build filtered on 'error
C' missed a linker file-lock failure -- one whole test cycle ran
against a stale exe (the /FORCE gotcha's cousin; grep -i error, not
error C).
Remaining (task open): ranking-window overlay draw (L4VIDRND stubs),
operator-app camera-seat row, shot polish.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 'cameras\ directory missing, ask Nick' entry (a03b16f, same day)
was WRONG: the file path is only the DEV OVERRIDE (the RP-style loose
.CAM files -- present for RP in the nick-games 410SRC archive, which
prompted the re-check). The shipped binary falls back to a BTL4.RES
resource TYPE 27 (0x1b) named after the map (FUN_0042be3c probe ->
FUN_0042c078 res loader; per-cam binary records, parsers @0042ad54/
@0042b9f8) -- and our res has ALL 8 maps' networks (arena1 = 62
cameras; cavern/dbase/grass ~57KB each). First arena1 record parses
clean (pos+quat, type 2).
The spectator/broadcast seat needs no external data: remaining work is
the type-27 loader in the WinTesla CAMMGR (file flavor only today),
the stubbed ranking-window draw, and a camera-vehicle boot test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CameraShip spectator system is engine-complete (CameraDirector
auto-follows the score leader, CAMSHIP cuts between fixed map camera
positions like TV coverage, ranking window flashes 10s/15s + final
30s), but the per-map cameras\<mapname> notation files are absent from
the archive -- added to the get-from-Nick list with the reconstruction
alternatives (author our own / chase-cam fallback) and the two code
gaps (stubbed ranking-window draw, camera vehicle never boot-tested).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User: 'isn't the game supposed to time out tho?' Yes -- and it was
HALF-implemented: the egg's [mission] length drives the pods' countdown
and the final-30s ranking window, and the full StopMission -> fade ->
end chain exists, but nothing ever fired it: in 1995 the CONSOLE sent
StopMission at expiry. Our console never did, so missions ran forever.
- btconsole relay: arms the clock when RunMission #2 fires; sends
Application::StopMissionMessage (clientID 4, msgID 6 per APP.h:383,
exitCode NullExitCodeID) at length + 2s grace; stdin 'stop' command
ends the mission early (works in auto and manual modes).
- btoperator: END MISSION button (enabled once launched; resets per
session).
Verified live: 40s test mission -- relay logged the armed clock, sent
StopMission on time, the pod ran the authentic end chain and exited
cleanly ('[boot] RunMissions returned'). Standard eggs carry
length=600, so real sessions are now authentic 10-minute pod missions
with the score display in the last 30 seconds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User request after the first internet join attempt: 'should we modify
the program so it sits and retries with a waiting-for-session?' The
arcade model is a pod that waits for the operator, not one that
aborts.
- RelayRequestSeat: retry loop (2s dials, 30 min cap) with live status
into the join.bat console window (BTRelayWaitStatus: AttachConsole to
the parent cmd; silent without one) -- banner + progress dots, a
distinct 'game is FULL, waiting for a free seat' state, and 'seat
assigned -- joining!' on success. The unreachable MessageBox now
only fires at the 30-minute give-up.
- BT_RELAY=auto discovery: same patient loop ('searching for a game on
this network...'), LAN-only guidance in the give-up box.
Verified live: pod launched against a dead relay, waited 20+s, relay
started, pod seat-requested/registered/UDP-up automatically with no
user action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A zip recipient can dork around offline -- no -net argument means the
game self-launches a solo mission (all maps ship in content/). Solo
gets its own exit message (btl4.log pointer, no server language).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (first internet-join attempt): both join bats 'hard
crash' on the remote machine. Root cause was procedural -- the host
session was not running -- but the failure PRESENTATION was the bug:
Release Fail() is a bare abort(), so a dead relay = silent process
death + instantly-closing console window.
- L4NET: the two player-facing dead-ends (LAN discovery no-answer,
seat request unreachable/full) now show a MessageBox saying what
happened and what to do before exiting (verified live against a
dead relay: 'BattleTech -- can't join the game' appears).
- join bats: echo + pause after exit so the window stays readable;
join_lan.bat header now says it is LAN-only (the remote user ran
both -- join_lan can never work over the internet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'Launch local instances' did nothing visible when no roster row had
Local checked (log pane quietly said 'launched 0') or when the session
was not started yet (instances would dial a dead relay and fail after
the bounded retry). Both now pop a clear dialog saying what to do.
Found live: user pressed the button with both Local boxes unchecked
and no session running -- 'nothing happened'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The egg generator's known-good list was just bhk1+madcat (the only two
ever exercised as egg vehicles); the operator roster therefore leaned on
those. Swept every ModelList candidate as a solo egg vehicle: 18/18
boot a mission, spawn, and animate with no crash -- avatar, blkhawk,
loki, owens, sunder, thor, vulture and all short variants (ava1, lok1/2,
mad1/2, own1, snd1, thr1, vul1) join bhk1/madcat as first-class picks.
eggmodel now lists the canonical 8 first for the operator dropdown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 pods, relay-assigned seats, 4-pilot MP4.EGG (eggmodel-generated incl.
callsign bitmaps): 4 distinct seats, full ladder, every node replicates
all 3 foreign mechs (host-qualified [repl] sets are exact complements),
4 distinct spawn positions (no dropzone stacking), UDP at 4-pod fan-out
(relay tx=3x rx, zero drops), all pods alive through the session.
Per-host [net-rx] census symmetric: 36 NewDynamicEntity makes (mech +
subsystem roster) from every host to every node.
DIAGNOSTIC TRAP that cost a false bug-hunt: EntityID::operator int()
returns localID ONLY, so the [repl] log's (long)GetEntityID() collapsed
different hosts' mechs onto one number and read as 'missing
replicants'. The log now streams the EntityID object (host:local via
its operator<<).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The game rewrites LAST.EGG at every run, so every build stamped '+'
(false-dirty) even from a pristine checkout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.10 = the 1995 arcade release; 4.11 = the win32 reconstruction; the
build number is the git commit count -- monotonic, zero-maintenance,
and every exe pins to exact source via the short hash ('+' = built
from an uncommitted tree). tools/btversion.cmake regenerates
build/btversion.h on every build (write-if-changed, no rebuild churn);
the stamp shows in the boot banner (btl4.log head, replacing the stale
'v4.10' line) and the window title (incl. the MP node tag).
Verified: build stamps 4.11.311 (980c9cd+), banner + title correct on
a live boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod's controls reach the game through the RIO serial board; on a
desktop that hardware is a keyboard shim of hardcoded GetAsyncKeyState
reads. New binding engine (game/reconstructed/btinput.cpp) maps PC keys
and an XInput pad onto the authentic channels through a user-editable
file (content/CONTROLS.MAP, community-suggested grammar, corrected):
key/pad -> button <addr> real buttonGroup emissions, mirroring the
RIO convention exactly (press a+1 w/ mode
mask saved, release -a-1 w/ saved mask;
L4CTRL.cpp:2470-2520) -- aux/preset banks,
hotbox, panic, reverse thrust all reachable
key/pad -> axis Throttle rate (lever), pedals/JoystickX
deflect (turn/twist) into the existing
virtual-controls integrators (feel, gait
detent, spring-centering all unchanged)
keypad pilot|external <n> keyboardGroup key values ('0'-'F')
pckey <char> any authentic 1995 typed hotkey
action <name> port dev controls (view, all-stop, ...)
Keys claimed by a binding are SUPPRESSED from the legacy WM_CHAR/KEYUP
feed -- ends the historic double-dispatch ('w' drove AND selected pilot
0; F5's key-up value 0x74 aliased to the 't' hotkey; letter key-ups fed
the developer fake-event dispatcher). Unbound keys keep their authentic
meaning. The dual-use 'V' is split: V = view toggle, B = look behind.
Default profile = WASD classic (compiled-in twin; delete the file to
restore). CONTROLS_NUMPAD.MAP ships the corrected community layout
(keypads are NOT buttons 0x50-0x6F -- that space doesn't exist; no
clickable cockpit exists so everything needs a binding; keyboard fire
buttons added; 0x36/0x37 are hotbox not 'config'; missiles moved off
Ctrl). XInput loads dynamically (1_4 -> 9_1_0), disconnected-pad
probing rate-limited; pad sticks write the axes as absolute positions
(the spring is physical), triggers/buttons per the file.
Verified live: bindings load (43), W and NumPad8 drive (speedDemand 0 ->
61.5), X all-stop (spd -> 0), aux-bank emission (D1 -> [input] 0x2f
PRESS/release under the numpad profile), suppression both directions
(posted 'r' reaches [keych], posted 'w' swallowed), full-keyspace
WM_CHAR+WM_KEYUP fuzz x3 survived with sim advancing, XInput graceful
with no pad, 2-node relay session un-regressed (full ladder + UDP).
Pad-in-hand testing still needs a physical controller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1995 console let the operator type player callsigns; the pods render
them from 1bpp BITMAPS streamed in the egg ([largebitmap]/[smallbitmap]
lists + hex raster pages, bitmapindex per pilot; large 128x32 on the
score/ranking display via DPLRenderer name textures, small 64x16 on the
kill-feed and radar/target labels). The textual name= pilot key is dead
in the shipped binary -- its pilot parse reads bitmapindex only (decomp
part_014). Historical bug surfaced by this work: every egg in the repo
listed only ONE name bitmap (Aeolus) while pilot 2 pointed at index 2,
so player 2+'s callsign has never rendered (null-checked, silently
blank).
eggmodel.py: EggDoc.set_callsigns rewrites the lists + one raster page
per callsign per size + per-pilot name=/bitmapindex= (duplicate names
get disambiguated page tags); get_callsign_rows decodes pages back
(exact inverse of the engine's nibble-stream parse, GRAPH2D.cpp:340,
MSB-first bit order :558); rasterize_callsign/make_callsigns render
text via Qt (auto-shrink to fit, NoAntialias, 1bpp threshold) -- the
only PySide6-dependent corner of the module. validate() now catches
dangling bitmapindexes and missing raster pages.
btoperator.py: Callsign column in the roster (free text, PLAYERn
default); every save rasterizes the column into the egg exactly like
the original console did.
MP_BHMC.EGG template repaired: authentic Aeolus raster preserved
bit-exact, Boreas page generated and listed.
Verified: eggmodel self-test 20/20 (bit-exact raster round-trip, dup
disambiguation, dangling-index catch); ASCII-art decode of the
app-generated VIPER/MONGOOSE rasters reads correctly; live 2-node
relay session on the generated egg -- full ladder, launch, zero
name-bitmap load warnings; operator GUI e2e PASS after the column
shift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pod launched with no BT_SELF asks the relay for a seat before joining:
new control frames SEAT_REQUEST (-6) -> relay reserves the lowest roster
seat not claimed or reserved (60s reservation so simultaneous joiners
can't race onto one seat) -> SEAT_ASSIGN (-7, int32 hostID + NUL tag)
becomes relaySelf; everything downstream (egg self-match, HELLO) runs
exactly as if BT_SELF had been set. Roster full -> SEAT_FULL (-8) ->
clean Fail(). A real HELLO pops the reservation; a pod drop frees the
seat. Explicit BT_SELF still claims a specific seat (the operator's
local launches use it).
Client: L4NetworkManager::RelayRequestSeat (throwaway TCP dial to the
relay game port, 10s reply window). Relay: SEAT_REQUEST branch in
_handle_game_frame. Operator exporter collapsed from per-seat
join_as_playerN.bat to ONE universal join.bat (+ join_lan.bat with
BT_RELAY=auto) -- every player gets the same file, first come first
served, the arcade walk-up-to-a-pod model. players/ regenerated.
Verified: 8/8 seat stub tests (distinct seats in roster order, FULL on
exhaustion, claimed+reserved stays FULL, HELLO claim accepted, duplicate
HELLO refused); 2-node localhost e2e with NO BT_SELF on either pod ->
both seated, full ladder, RunMission pair, UDP flowing post-launch;
regression smoke: explicit-BT_SELF relay session clean with zero seat
requests, classic mesh (no BT_RELAY) un-regressed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found live during the port-forward verification: external port-checker nodes
(and by extension any internet scanner) that connect to the exposed console
port were streamed the egg AND counted as pods ('1/2 pods have it' from a
node in Turkey) -- a lingering scanner could hold a phantom roster slot or
nudge the ready/launch state.
Real pods send AcknowledgeEggFileMessage (clientID=0, msgID=4) after parsing
the egg (L4NET StartConnecting); scanners never speak the protocol. The
launch gate (auto timer AND manual-launch READY) now counts only console
connections that ACKED:
- RelayConsoleConn.acked; _console_read detects the ACK, logs
'pod ACK from <addr> (n/m ready)', drives _check_launch_gate.
- _eggs_out (egg_sent count) replaced by _pods_ready (acked count);
gate logic extracted to _check_launch_gate (called on each ACK).
- btoperator monitor regex updated to the ACK line.
Verified: scanner-simulation (3 lingering non-protocol connections + 1 real
ACK on a 3-pilot roster -> gate NOT tripped, ACK counted 1/3); full operator
e2e PASS (registered -> READY held -> operator launch -> both pods LAUNCHED).
(The two intermediate e2e failures during this work were a stale port-check
relay squatting on 1500 -- a test-rig cleanup bug (Get-Process CommandLine
filter matches nothing; use Get-CimInstance Win32_Process), not the
hardening.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator now holds the lobby: with 'Manual launch' checked (default),
the relay stops at 'all pods have the egg; WAITING FOR OPERATOR LAUNCH'
instead of auto-firing on a 20s timer -- the app shows 'ALL PODS READY --
press LAUNCH MISSION' with the roster lights, and the big green button
fires the mission when the operator is ready (everyone seated, voice chat
confirmed). A 'Restart session' button cycles the same mission/roster for
the next round (players just re-run their join script).
- btconsole.py --relay --manual-launch: launch armed by the line 'launch'
on stdin (a daemon reader thread; the app's Launch button writes it via
QProcess). The settle window is still honoured relative to egg delivery
(max(now, eggs_done + 20s)) -- firing RunMission before the pods reach
WaitingForLaunch Fail()s them, the same hazard the auto timer guards.
Auto mode (no flag) unchanged -- CLI/test recipes unaffected.
- btoperator.py: Manual-launch checkbox (Network box, default on), LAUNCH
MISSION button (enabled by the relay's READY line, disabled after fire),
Restart session button, status headline shows the ready state.
- operator_e2e.py: now exercises the gate -- waits for READY, asserts the
button enabled, presses it, verifies the operator-gated LAUNCH.
Verified: scripted e2e PASS -- registered -> READY (held) -> operator
launch -> settle honoured -> both pods LAUNCHED (operator-gated=True).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LAN players now find the game with zero configuration -- the 90s arcade
model (cabinets locate the operator station) restored:
- Client (L4NET RelayDiscover): BT_RELAY=auto broadcasts 'BTR1DISC' on
udp/15999 (255.255.255.255 AND 127.0.0.1 -- broadcast doesn't reliably
loop back on Windows; covers same-box sessions), 5 x 1s attempts; the
answering relay's SOURCE IP + its advertised console port become the
relay address. Explicit <host>:<port> path unchanged; mesh untouched.
- Relay (btconsole.py): best-effort discovery responder on udp/15999
answers 'BTR1HERE' + <u16 consolePort>; degrades gracefully (logged) if
the port is taken.
- Operator app: Export player scripts now writes a join_as_playerN_lan.bat
pair (BT_RELAY=auto) next to each internet script -- LAN guests
double-click and are found; internet guests use the public-host script.
Verified 2-node: both pods BT_RELAY=auto -> probe answered through a real
interface (not just loopback) -> discovered 172.19.x.x:1500 -> full session
to RunningMission. KB: multiplayer.md D1 section updated (+ the operator
console entry).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator station the archive never preserved: build missions with
authoritative dropdowns, run the console/relay, watch pods arrive, launch --
LAN mesh or internet relay -- plus one-click local instances for testing.
tools/eggmodel.py (headless, importable, self-tested 14/14):
- Order-preserving egg parse/emit: the editor manages [mission]/[pilots]/
per-pilot pages; bitmap rasters, ordinals and role pages survive untouched.
- EVERY value set read LIVE from content/BTL4.RES (the resscan parser,
importable): maps = MakeMessageStream(14) INTERSECT ExistanceBoxStream(26)
(the game aborts unless both exist); colors/badges/patches from the
VehicleTable resource (type 25, NUL-line NotationFile text with ;-comments);
vehicles = ModelList names, known-good mechs (bhk1, madcat) surfaced first.
- validate(): the full required-key ruleset from MISSION.cpp/btl4mssn.cpp
(missing map/time/weather/scenario, per-pilot hostType/vehicle/dropzone/
color/patch/badge/experience/role, role page model=, illegal values,
duplicate addresses) -- kills the hand-edited-egg crash class, incl. the
color=Red-vs-Crimson gotcha that bit the dev eggs.
tools/btoperator.py (PySide6, dark Fusion theme):
- Mission form + pilot roster table (all dropdowns from eggmodel), relay-tag
auto-numbering, add/remove pilots, egg New/Open/Save/Validate.
- Mode switch: Relay (internet; runs btconsole --relay) / Mesh (LAN legacy;
dial-out console at address-port minus 1 per the +1 rule).
- Session panel: QProcess drives btconsole.py; stdout parsed live into
per-pilot state lights (waiting/egg/registered/LAUNCHED) + relay stats.
- Local launcher: spawns btl4.exe per roster row with BT_RELAY/BT_SELF (or
mesh -net port), BT_DEV_GAUGES/BT_START_INSIDE toggles.
- Export player scripts: per-pilot join_as_playerN.bat with the public
relay hostname (a remote player's whole setup = run one .bat).
- Wire/protocol code stays in btconsole.py/eggmodel.py -- the GUI is only
UI + process management; CLI + test harnesses share the same tested core.
Verified: eggmodel self-test 14/14; scripted end-to-end
(scratchpad/operator_e2e.py) drives the real window through its own methods --
start relay session -> launch 2 local instances -> monitor observes the
mission LAUNCH (user-confirmed live on screen: both pods in-mission).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ~60Hz entity update records now ride UDP in relay mode, so a lost/late
datagram is dropped (dead reckoning absorbs the gap) instead of head-of-line-
blocking the reliable TCP stream -- the fix for internet rubber-banding.
Reliable traffic (make/damage/death/control/egg/launch) stays on TCP.
This RESTORES the authentic 1995 NETNUB split, it doesn't invent one:
Receiver::Message defaults messageFlags=ReliableFlag; the update path clears
it (flags=0, ENTITY.cpp:590); Mode(UnreliableMode) fires at LoadingMission
(APP.cpp:704 -- the 2007 port's Mode() was a no-op, now it STORES the mode).
Routing gate = (mode==UnreliableMode && !(flags & ReliableFlag)) -- flag AND
mode, so the map-stream creation messages (also flags=0 but flowing during the
still-Reliable CreatingMission window) ride TCP for free.
Client (L4NET):
- Mode() stores currentNetworkMode (was ignored).
- ConnectRelayUdp: UDP socket connect()ed to the relay game port; HELLO
(outbound punch-through, relay learns our endpoint); udpUp on HELLO-ACK.
BT_RELAY_TCP_ONLY=1 disables the channel (UDP-blocked nets degrade to the
pure-TCP checkpoint automatically -- no ACK => everything stays on TCP).
- RelayUdpSendFrame: {route, fromHost, seq} envelope + frame, best-effort.
- RelayUdpKeepalive: ~15s NAT-hold + ~1s HELLO retry until acked.
- CheckRelayUdp: HELLO-ACK sets udpUp; per-sender seq gate drops stale/
out-of-order datagrams; frames handed up envelope-stripped like TCP.
- Send / ExclusiveBroadcast: route unreliable-window traffic to UDP, else TCP.
- CheckBuffers polls the UDP channel first, then TCP; keepalive tick.
(The relay-side UDP forwarder + endpoint learning + --udp-drop hook landed in
phase 1.)
Verified 2-node localhost relay: during a drive the update stream flows on UDP
(relay udp tx 2->190+, TCP frozen at 53) and the peer's replicant tracks the
master to sub-2u; 15% forced drop (--udp-drop 15) stays coherent (tracked to
0.1u); TCP-only fallback confirmed (udp-known drops the TCP-only pod); no
crashes; mesh mode un-regressed. KB: context/multiplayer.md gains the D1
RELAY MODE section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod can now run entirely over the relay: one outbound TCP connection
carries both the console protocol (egg/launch) and all game traffic
(envelope-multiplexed), so internet play works behind NAT with no port
forwarding. All new code is behind 'if (relayMode)' (BT_RELAY unset => mesh
mode byte-for-byte untouched; verified un-regressed).
Env gates (parsed once in the ctor, after WSAStartup so gethostbyname works):
- BT_RELAY=<host>:<consolePort> (host may be a DNS name; game port = +1)
- BT_SELF=<exact [pilots] entry> (NIC matching can't identify us across NAT)
L4NET changes:
- CreateConsoleHost: relay branch dials OUT to the relay console port
(bounded retry; console-less continue on a re-dial) instead of listening.
- StartConnecting: relay self-match by BT_SELF string; peers become VIRTUAL
hosts (INVALID_SOCKET, NoNetworkConnectionStatus) flipped online by the
relay's PEER_UP; then ConnectRelayGame dials the game port + HELLOs. HostID
assignment / remoteHostCount / the connection gate / app ladder unchanged.
- CheckRelay (new): the receive seam -- drains {route,length} envelopes,
synthesizes HostConnected/Disconnected from PEER_UP/DOWN (same messages the
mesh accept path routes), returns game frames envelope-stripped (they
self-identify via NetworkPacketHeader.fromHost; consumers route by payload).
- Send: game-host traffic -> relay unicast envelope (console host excluded --
its legacy protocol is relay-TERMINATED, not routed).
- ExclusiveBroadcast: build ONCE, send ONCE with the broadcast route -- the
relay fans out, killing the mesh's (N-1)x upload duplication.
- RelaySendAll (new): partial-send-safe transmit (required on the multiplexed
socket -- a partial write would desync framing for all peers).
- CheckBuffers: polls CheckRelay first; skips recv on virtual game hosts.
- RelayGameDown: relay-loss synthesizes all-peers-disconnected (match
continues peer-less; pod never exits mid-match).
- Mode(): now STORES the reliable/unreliable mode (was ignored) for the UDP
phase's authentic mode+flag routing.
Verified 2-node localhost relay (MP_RELAY.EGG, tagged [pilots]):
- both pods reach 'All connections completed!' via PEER_UP, then
RunningMission; both mechs' MakeMessages cross the relay (paint x2 each) and
bidirectional 148-byte update records flow (net-tx/net-rx traces; relay
stats tcp rx==tx, registered [2,3]); cockpit/HUD render; no crashes.
- driving one node transmits pose/damage/death frames through the relay.
- mesh smoke (no BT_RELAY): 2-node session still forms + simulates unchanged.
Plan: ~/.claude/plans/partitioned-snuggling-piglet.md. Next: UDP channel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The console emulator gains a relay/dispatcher mode for internet play (D1):
pods dial OUT to this process (NAT-friendly) instead of the console dialing
into each pod. Legacy dial-out mode is untouched (verified: an unmodified
2-node mesh session still forms end-to-end through it).
--relay <consolePort> <egg> [--bind ADDR] [--udp-drop PCT], single
selectors-based event loop:
- console listener (consolePort): streams the NUL-line egg to each pod on
connect; GLOBAL launch timer -- RunMission x2 to ALL pods at +20s/+4s after
the LAST pod has its egg (fixes the per-pod skew of the legacy tool).
- game TCP listener (consolePort+1): envelope router {route i32, length u32}.
First frame must be HELLO ('BTR1' + hostID validated against the egg
[pilots] roster); PEER_UP/PEER_DOWN exchange; route>=2 unicast (route
rewritten to sender), -1 broadcast-except-sender (the client sends each
broadcast ONCE -- kills the N-1x upload duplication). Frame cap 1600
(NETWORKMANAGER_BUFFER_SIZE); protocol violations drop the connection.
- game UDP socket (consolePort+1/udp): endpoint learned per-datagram
(NAT-rebind tolerant), HELLO->HELLO-ACK, verbatim forward by route,
TCP-wrap fallback when the target's UDP endpoint is unknown; --udp-drop
test hook for the robustness phase.
Verified: scratchpad/relay_stub_test.py (self-contained; spawns the relay +
stub clients) -- 24/24 checks pass: egg delivery, registration, PEER events,
unicast route-rewrite, broadcast fan-out (sender excluded), UDP ack/forward/
fallback, and all reject paths (bad magic, dup id, out-of-roster, oversize).
Plan: ~/.claude/plans/partitioned-snuggling-piglet.md (D1 relay + UDP).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/GLASS_COCKPIT.md gains the step-4 ledger (seam/transport/lobby + THE
FAKEIP LESSON); NEW context/steam-networking.md topic (+ router row) and
docs/STEAM_TEST.md (the 2-machine live procedure = the step-4 exit criterion
on user hardware). context/glass-cockpit.md: steps 3+4 status. Final purity
re-check: pod build clean, forced-walk mission runs, ZERO glass/steam tags in
the pod log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NEW gated (BT_STEAM) game/glass/btl4lobby: an ISteamMatchmaking room replacing
the arcade Site-Management screen -- host creates (lobby data btl4=1), members
join by filter, pilots publish name/mech/color, the host ENTER mints the
roster and signals GO.
THE FAKEIP LESSON (live-verified, prior-art assumption DISPROVEN): a fresh
process gets a DIFFERENT FakeIP, so menu-time fake addresses go stale by
mission time -- the first cycle timed out connecting to its own stale address.
Redesign: roster addresses are opaque ipv4-shaped TOKENS (169.254.77.N with
ports 1501/1502) minted by the host at GO, mapped to Steam IDENTITIES;
connections ride ConnectP2P(identity, channel) with console=0/game=1 virtual
ports; the map reaches mission processes via env (BT_FE_MYFAKE +
BT_FE_STEAMMAP); the GetMyAddress seam feeds the pod its own token for roster
self-match; CheckSocket reports peers by token. L4STEAMNET reworked (no
FakeIP allocation at all); the marshal gained the Steam-wire branch.
Verified single-machine (ON/ON, live Steam): menu -> HOST STEAM LOBBY ->
room -> GO -> token map minted -> egg roster carries the token -> mission
process up with 1 roster token incl. self -> pod self-matches -> stock
console ladder -> mission RUNS (46 ticks). Remaining: the live 2-account
cross-machine session (docs/STEAM_TEST.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NEW gated (BT_STEAM) TU L4STEAMNET: ISteamNetworkingSockets with FakeIP --
pseudo-SOCKET handles (0x5EA0xxxx) behind the L4NET BTNet* seam; the engine
TCP byte stream rides reliable-NoNagle Steam messages re-assembled into
per-connection rings (nonblocking recv semantics preserved: empty ->
WSAEWOULDBLOCK, peer-closed -> 0); two FakeIP ports mirror the arcade
console/game channel convention; listen sockets + accept queues via the
global status-changed callback; graceful degrade to Winsock at every step.
Seam completions in L4NET.CPP: CheckSocket reports a pseudo-socket peer as
its FAKE ipv4:port (the lobby egg roster matches unchanged); OpenConnection
TCP_OPEN delegates FakeIP targets to BTSteamNet_Connect. WinMain installs
on BT_STEAM_NET=1 (lobby launch path / by hand). CMake: gated SDK include/
lib/dll-copy. Both pumps (SteamAPI_RunCallbacks + sockets RunCallbacks)
drive it -- the FakeIP result rides the GENERAL dispatch (first attempt with
only the sockets pump timed out).
Verified live (ON/ON build, Steam client running): SteamAPI_Init OK,
steam_appid.txt(480) auto-written, FakeIP allocated
(169.254.36.58, console 54464 / game 54465); without BT_STEAM_NET or
without Steam the game stays pure Winsock.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BTNetSend/Recv/Accept/Close wrap the 9 raw wire sites in L4NET.CPP (send
@x3, recv, accept @x2, closesocket @x2). OFF builds are PURE CODE MOTION
(bodies are the original calls; the statics inline back); under BT_STEAM
each wrapper first offers the op to the Steam transport (BTSteamNet_*,
lands next) -- handles stay SOCKET-typed so L4Host and every call site
above the seam are identical on both wires. Connection-ESTABLISHMENT
delegates (OpenConnection/listener arms) land with the transport. The 3
BT_NET_TRACE blocks stay at call-site level, untouched.
Regression (pod build, 2-node loopback MP + btconsole relay): mesh forms
through BTNetAccept (console + peer accepts logged), both nodes reach the
full 31/31-subsystem running mission, 71/76 ticks -- byte-identical wire
behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The official Valve SDK zip (provided by Cyd), trimmed to what the gated
build consumes: public/ (steam_api.h, ISteamNetworkingSockets,
ISteamMatchmaking, the full public header set) + redistributable_bin/
(steam_api.lib/.dll all platforms) + Readme. glmgr/steamworksexample/
tools dropped. Used ONLY under -DBT_STEAM=ON (step 4); the pod and plain
glass builds never reference it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>