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 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>
low-pass into the zone WAVs
232 zones author initialFilterFc(8)/initialFilterQ(9) (SBK 0..127) -- the
fixed resonant low-pass the EMU8000 applied in hardware; the port played
them unfiltered (flagship: the LaserLoaded charge hum, fc=57 Q=87, much
brighter/harsher than the arcade). The extractor now applies an RBJ 2-pole
low-pass per zone: cutoff = the AWE NRPN curve (100 + fc*7900/127 Hz),
resonance = SBK Q -> 0..+12 dB peak [T3 curve, endpoints exact], designed at
the zone's baked rate so note-60 playback reproduces the hardware's absolute
cutoff (pitch-shifted notes carry the filter -- same limitation as the
rate-baked tuning). Synthetic sweep verified: flat lows, +5.4 dB at the
authored 3.6 kHz cutoff, -22 dB @10 kHz, -46 dB @14 kHz.
Whole bake pipeline (filter -> attenuation) now runs in float with ONE int16
conversion + peak normalization: an int-per-stage draft hard-clipped 165 of
232 zones (resonance overshoot, worst 3% of samples); now only pre-existing
source-material clipping remains (14 files). 160 WAVs re-baked; the preset
table is unchanged (no engine rebuild).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stdlib-only (tkinter + winsound) sample browser: filter box, per-button
[bank:patch] tags parsed from audiopresets.cpp so an ear-identified sound maps
straight back to the game's (bankID, patchID), Play-All sweep, STOP/ESC.
For verifying sample identity/pitch (the 22050 Hz extraction guess) by ear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Weapon fire (and ~all one-shots) played FOREVER: sf2extract flagged a sample as
looping whenever the SF2 shdr had loop points -- but almost every SoundFont sample
has loop points; whether to actually loop is the instrument's sampleModes generator
(igen oper 54): 0/2 = one-shot, 1 = loop, 3 = loop-until-release. The old heuristic
looped 239/241 samples, so SetupPatch set AL_LOOPING=1 on fire/explosion/step sounds.
Read sampleModes at the same igen zone as sampleID and loop only on 1/3. Now
166 one-shot / 75 loop: LaserAFire/BigExplosion/BasicClick -> ForceStatic (one-shot),
EnginePower/EngineMotor/coolant -> LoopAtWill (correctly sustained). Regenerated
audiopresets.cpp; the WAV PCM is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of "no sound, ever": the two audio backend DLLs shipped in the repo
are fakes. libsndfile-1.dll exports 15 funcs BY ORDINAL ONLY (no names) and
sf_open() always returns NULL; OpenAL32.dll (72KB) imports only KERNEL32 -- no
dsound/wasapi/winmm -- so it is a pure no-op that returns fake handles
(ctx=0x00000001, alGenSources->0) and never touches the hardware. The whole
render->device->buffer->source->play chain ran clean and silent.
Fixes:
- OpenAL32.dll: replace the stub with the real OpenAL Soft 1.25.2 Win32 build
(imports AVRT/ole32/WINMM, real WASAPI backend). The exe imports the 25 AL
funcs by NAME so it is a drop-in; alGenSources now yields a live source and
alSourcePlay reaches AL_PLAYING.
- libsndfile: DROPPED entirely. It is replaced by LoadWavPCM() in L4AUDRES --
a tiny RIFF/WAVE fmt+data reader that loads our soundbank WAVs (16-bit PCM)
straight into the AL buffer. Removed the .lib/.dll from the link + copy and
git-rm'd the stub. (This also kills the "ordinal 50 could not be located in
libsndfile-1.dll" load-failure popup: adding an sf_strerror import bound to
an ordinal the 2..16-only stub could not satisfy.)
- Soundbank: 241 samples cracked from AUDIO1/2.RES (SF2 v1.0) by
tools/sf2extract.py into content/AUDIO/*.wav + the allPresets[2][128] table
(audiopresets.cpp), replacing the zero-init btstubs stub. All 241 now load
(alErr=0). BT_AUDIO_TEST plays buffer0 as proof-of-life; BT_AUDIO_LOG traces
the chain.
Remaining: in-game triggering (AudioEntities on fire/step/engine/explosion)
so PlayNote fires during play -- next audio wave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The residual RANDOM peer shakiness on accel/decel was proven (BT_RXJIT record
inter-arrival probe) to be TEST-RIG packet jitter, not the game: two Debug btl4
nodes on one box contend for CPU, so Windows batches their TCP delivery -- records
arrive in bursts (max 56-226ms gaps, burstiness 3-7x) instead of even ~17ms. The
peer dead-reckons across the gaps then snaps -> random shake. Pinning the nodes to
disjoint cores restored even ~17ms delivery (burstiness ~1.0) and the shakiness
vanished (user-confirmed 'that was it'). Real pods = dedicated machines, no
contention -> never see it. So NO un-authentic jitter buffer -- the coupled
body-channel peer (96a896a/f094d78/23f1532) is the authentic + correct finish.
- mech.cpp: BT_RXJIT record-arrival-jitter probe (env-gated).
- tools/mp_launch.sh: 2-node launcher that pins nodes to disjoint cores (bakes in
the fair-delivery condition; documents why).
- context/multiplayer.md: the finding, so it is not re-litigated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The console emulator's recv loop only caught socket.timeout -- any reset/abort killed
the thread UNHANDLED, its buffered stdout died with it, and with both threads gone the
process exited silently. The 2-node session then froze replication BOTH ways (each pod
holding a dead ConsoleHost socket; the engine never logged a console disconnect). Now:
OSErrors are caught + logged and the thread idles alive; prints flush. Run with
python -u and > console.log to capture the death reason on any recurrence. The engine-
side question (why a dead console freezes peer replication + the disconnect handler's
gameListenerSocket-vs-consoleListenerSocket close) is logged in open-questions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0xBBE heat-sink BANK was built as a plain HeatSink, so the numeric-R
cockpit gauge binding HeatSink/AmbientTemperature (L4GAUGE.CFG:4552) had
no publisher -> the LAST unresolved config attribute.
Reconstruct AggregateHeatSink : HeatSink (ctor @4ae8d0, own GUID 0x50e590),
byte-exact + static_assert-locked (heatSinkCount@0x1D0, ambientTemperature
@0x1D4=300, helper@0x1D8 0xC link node, sizeof 0x1E4 == factory alloc @9993).
Publish HeatSinkCount + AmbientTemperature via a dense-prefix attribute table
chained to HeatSink::NextAttributeID (shared HeatSink table unchanged, so
CoolantMass/CoolantCapacity keep resolving). Move CreateHeatSinkBankSubsystem
into heatfamily_reslice.cpp (needs the class def) and build the real class at
factory case 0xBBE; mech.cpp unchanged.
DELIBERATE DEVIATION (documented in the class): keep the base HeatSinkSimulation
the HeatSink ctor installs; do NOT reimplement the authentic Performance @4ae73c
-- it derefs a raw self+0xE0 -> [+0x158] that does not map in our compiled layout
(AV/NaN, and runs for EVERY mech), and ambientTemperature is a frozen constant so
the gauge reads 300 either way. Authentic relaxation model deferred.
Verified: [attr] HeatSink/AmbientTemperature OK; all 50 config attribute
bindings resolve (0 NULL); no every-mech crash; combat TARGET DESTROYED,
FIRED #41+, un-regressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the cockpit-gauge attribute-pointer system in GAUGE_COMPOSITE.md
(§Attribute wave) and CLAUDE.md §10: how a Subsystem/Attribute or bare-Attribute
binding resolves (ParseAttribute -> FindSubsystem/GetName -> GetAttributePointer
-> activeAttributeIndex), what a class must publish (AttributeID enum +
AttributePointers[] + GetAttributeIndex chained + DefaultData wiring), the
DENSE-TABLE HAZARD (Build leaves gap slots uninitialized, Find strcmps every
slot -> tables must be a dense prefix), and the base-primitive vs BT-specific
widget split. Records the 4 committed increments (HeatSink table, Mech table,
vertBar, segmentArcRatio) and the follow-ups (oneOfSeveralPixInt/map/PlayerStatus/
vehicleSubSystems, the #if0'd HeatSink-bank AmbientTemperature, the Reservoir
coolantCapacity shadow).
Promote the two reverse-engineering helpers from the session scratchpad into
tools/ (durable): disas2.py (capstone + PE parse -- recovers x87 float math
Ghidra drops; FUN_004dcd94=round, FUN_004dcd00=fabs) and vtdump.py (dump a class
vtable to find an override the assert-anchored decomp never exported, e.g.
SegmentArcRatio::Execute). Doc references updated scratchpad/ -> tools/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clean, self-contained extraction of the BattleTech-specific work from the
reverse-engineering workspace -- engine + game + content + build, with nothing
from Red Planet or the raw archive dumps. Builds green (Win32) and runs the
single-player drive->animate->target->fire->damage->destroy loop out of the box.
Layout:
engine/ MUNGA + MUNGA_L4 shared 2007 engine, carrying our BT render/loader
work (bgfload/L4D3D/L4VIDEO: BSL bit-slice decode, LOD/ground/shadow
models) + image codec; the minimal rp/ headers the audio HAL needs
game/ reconstructed BT logic + surviving-original BT source + fwd shims
+ WinMain launcher
content/ full runtime tree (BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI)
docs/ format specs + reconstruction ledgers
reference/ raw Ghidra pseudocode (recon source-of-truth) + decomp exporter
tools/ MP console emulator + map/resource scanners
One top-level CMake builds munga_engine lib + bt410_l4 game lib + btl4.exe.
All paths relativized (186 fwd shims + ~437 CMake abs paths -> repo-relative);
DXSDK is the one external, overridable via -DDXSDK. Verified: builds to a
byte-identical 2.27MB exe and runs combat (TARGET DESTROYED, 0 crashes) against
the bundled content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>