F3 distance: alDistanceModel(AL_NONE); restored the commented authored
distance multiply in Dynamic3D::CalculateSourceVolumeScale, added the same
override to Static3D, dropped the (now inert) AL_MAX_DISTANCE writes. Far
battle audio follows the authored knee/rolloff curve again, and the volume
cull / voice steal / ducking chains are distance-aware.
F4 volume law: AL_GAIN = volume_scale^2 at all 3 per-frame sites (Direct/
Dyn3D/Static3D) -- the GM CC7 squared curve; linear was ~+6 dB at mids.
F10 doppler: alDopplerFactor(0) (AL's model ran wrong constants + sign-
inverted velocity: approaching sources pitched DOWN); the AUTHORED
dopplerCents (AUDIO.INI range/speed-of-sound model, decomp-proven consumer
part_008.c:7466) now adds into the Dynamic3D pitch chain.
F15 ConfigureActivePress: published at the MechSubsystem BASE (binary id 2,
descriptor @0x50de5c -> +0x110); renamed the misnamed vitalSubsystemIndex
member (the weapon ConfigureMappables handler already drives it 0/-1).
Removed the invented Sensor/Myomers duplicates and RESTORED their byte-
exact layouts (0x328/0x358 allocs + asserts). MechWeapon's pinned-id pad
absorbed the +1 chain shift -- which matches the binary's own numbering.
F18 cook-off warning: AmmoBin FireCountdownStarted -> the existing
cookOffArmed @0x18C (binary table @0x512600); the countdown klaxon can fire.
F20 zoom blip: L4MechControlsMapper publishes TargetRangeExponent -> the live
@0x1a4 zoom member (own table chained to MechControlsMapper).
KB: replaced the bogus divisionParameters+0x10 rate read with
SystemClock::GetTicksPerSecond() (FUN_0044e19c is GetFrameRate -- original
audio frames were CLOCK TICKS).
Regression (35s drive+fire): stable; ConfigureActivePress binds real on all 9
subsystems (idle -1, zero pad redirects); FireCountdownStarted +
TargetRangeExponent bind live members; attrnull 53 -> 41; chirp still dead;
footfalls still fire (gain now correctly squared).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'footsteps disappear when running' report traced to the authored interior-
footfall volume chain: an AudioScalarScale on LocalVelocity with the INVERTED
curve aB=[0,20] -> cB=[1,0] (through a smoother) drives the FootFallInt source
volume -- full at standstill, fading with speed, ZERO at >=20 (run). The
drop-gate evaluation (CalculateSourceVolumeScale -> ExecuteWatchers) pulls this
authored value and overrides the game-side mixer feed, so run-speed footfalls
are dropped BY DESIGN: at running speed the pod cockpit is engine roar; the
1995 sound design fades the interior footfall out. Timeline evidence:
volset(1,1 from the mixer) -> matchfire Start -> volset(0 from the authored
chain during drop evaluation) -> DROP, exactly once per stride at speed >= 20.
No code change -- this commit records the finding. User-verified state:
chirp gone, per-step static gone (velocity smoothing), footfalls audible at
walk, authentically absent at run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User pinpointed it: the per-stride 'static' was NOT the footstep sample -- it
was the ambient engine hum being interrupted in time with the footfalls.
Diagnosis (live tape): EnginePower01 was caught in a rapid Play->Stop cycle
(80x/session). The idle-hum is gated by authored LocalVelocity triggers (hum
ON in speed [1,20], OFF above 20) -- and the port's localVelocity is a RAW
per-frame position derivative that pulses with every stride (gait acceleration
+ ground-snap bob). At walking speed |v| straddles the 20 band edge, so the
trigger Start/Stop-flapped the hum on every foot plant; at run |v| >> 20 keeps
it off (matching 'goes away at higher speeds').
Fix: ~0.25s exponential filter on the PUBLISHED localVelocity.linearMotion
(fwd + vertical) -- kills the stride ripple, tracks real speed changes within
a couple strides. The physics worldLinearVelocity (StaticBounce, collision
damage pricing) is untouched; update records + the impact gate get the
smoother value (both benefit).
VERIFIED: EnginePower 1 play / 0 stops for the whole run (was 80 stop-cycles);
FootFall cycles normally per stride; gear shifts/windup lifecycle intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous 'chirp killed' verification was WRONG: it checked the 1/s playing
snapshot (which misses 0.12s pulses) instead of delivery counts -- the run had
186 ProgramButton deliveries. Corrected metric + trigcfg now prints its target
component, revealing NINE thresh=-1 Start triggers on the configure-ticker
sequence: ConfigureActivePress on Avionics + Myomers (backed real, -1) AND
SEVEN more subsystems still on the inert pad (0 = "button 0 held" -> ticker on).
Fix: a TYPED pad in the missing-attribute redirect -- ConfigureActivePress
resolves to a shared static int = -1 (the authored none-held idle) instead of
the zero pad; every other missing attr keeps the zero pad. Covers all
configurable subsystems at once with no layout changes; self-clears per
subsystem as each gets a real driven member.
VERIFIED BY DELIVERY COUNT: ProgramButton01 deliveries = 0 (was 31-186/run);
7 attrs took the -1 pad; the rest of the soundscape intact (weapon cycles,
loops, translocate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The obnoxious always-on clicking was the cockpit CONFIGURE-MODE ticker: an
authored looped sequence gated by an AudioIntegerTrigger with threshold -1 on
<subsystem>.ConfigureActivePress (the held-configure-button index; -1 = none).
The attribute was one of the inert-pad redirects -- the pad reads 0 = "button 0
held forever" -> the ticker Start-fired at load and pulsed 2.7/s eternally.
Found via [sendcfg]/[seqstart] traces: the first seq start immediately follows
the trigcfg(thresh=-1, onID=Start) construction on the pad address.
Fix: real int configureActivePress = -1 APPENDED to Sensor ("Avionics") and
Myomers, registered in their attribute tables. Both classes are byte-exact
factory allocs, so the sizeof locks AND the placement-new alloc constants are
bumped TOGETHER (Sensor 0x328->0x32C, Myomers 0x358->0x35C) -- the tripwire
caught a mid-layout insert (seekVoltage@0x330 shift) and a fixed-alloc overrun
before they shipped; member moved to the true tail. Wiring the live value
(EnterConfiguration/ExitConfiguration sets/clears the index) restores the
authored hold-to-configure tick later; idle -1 is the correct silent state.
VERIFIED: ProgramButton01 plays ZERO times (was the 2.7/s chirp);
ConfigureActivePress binds real (vtbl=FFFFFFFF = the -1); FootFallInt at 0.98
gain; stable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The output-filtered broadcast still fed EVERY volume/brightness mixer -- the
always-on ambience pulse's own volume mixer got cranked to step intensity on
every stride ('a stuck button', 3/s, loud). The send now identifies footstep
sources by authored patch (FootFallInt/Ext: bank2 37-39, bank1 106/107) through
mixer->GetTargetComponent() -> AudioSource -> PatchResource LOD bank/patch
(new accessors: GetTargetComponent, GetBankID/GetPatchID,
AudioResource::PeekAudioLevelOfDetail).
Also: step intensity curve raised (speed/25, floor 0.55 -- a mech stomp is
never subtle; it was firing at ~0.4 gain and masked under 0.9-gain layers,
heard as an unrecognizable 'orchestral hit').
Verified: FootFallInt at 0.78 gain in the live tape; the ambience pulse back
to its brief authored form (absent from 1/s snapshots).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The whole pitch system existed but was discarded: NoteAudioHandler captured the
MIDI note, ExecuteModel computed relativePitch from the control-chain cents --
and NOTHING ever called alSourcef(AL_PITCH). Every sample played at root.
Consequences fixed:
- The load-time 'chirping' ambience: an authored looped sequence (div=120
tempo=160, note 51 events on bank1:83) plays a rhythmic tick pitched ~9
semitones DOWN; at root it was a high click ('chirp'). Identified live via
the new BT_AUDIO_DUMP playing-source tape + user ear-match at cadence.
- Engine now revs: EngineMotor pitch rides the throttle (measured 1.0 -> 1.12).
- All sequence-authored patterns (alarms/klaxons/ambience) regain their pitch.
Implementation: BTNotePitchFactor(note) = 2^((note-60)/12); applied at all 3
patch-source StartImplementations (fresh attach) and all 3 ExecuteModels
(combined with the control-chain relativePitch, clamped 0.5..2.0 as before).
Uses the existing AudioSource::GetCurrentNoteValue().
Also: [seqcfg] sequence-config dump, g_bufferNames + BT_AUDIO_DUMP live tape
(1/s: every playing AL source with sample name/gain/pitch/loop),
tools/soundboard.py updated mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The blind foot-plant broadcast hit every entity-registered component; mixers
whose authored OUTPUT control is Start forwarded the send as a Start and fired
their sound on every stride (the per-step ProgramButton 'chirp'). Added
GetOutputControlID() to AudioControlMixer/Multiplier and the broadcast now
feeds only stages outputting Volume(3)/Brightness(5) -- the footstep loudness
inputs -- directly (mixers are entity-registered, no splitter hop needed).
Verified: FootFallInt01 delivers on BOTH gaits now (11/run, walk input included);
ProgramButton deliveries 188 -> 38 (the remainder are authored looped-sequence
cockpit ambience, not the broadcast); stable 28s drive+fire.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The final link: the footstep source's volume + brightness mix from two
AudioControlMixer inputs (ctl 100 walk / 101 run) behind an entity-registered
AudioControlSplitter. The object stream only ZERO-initializes those inputs
(AudioControlSend one-shot initializers; mixer ctor inits 0.0) -- the runtime
feed was BT game code (lost with the source), using the engine's game-facing
Entity::AudioSocketIterator broadcast (the only path to those anonymous
components; the splitter registers via entity->AddAudioComponent).
Reconstruction [T3]: on each foot plant (the SetBodyAnimation locomotion-clip
pulse), broadcast the step intensity (live ground speed on the authored
0..40 -> 0..1 velocity-curve family, floored at 0.35 to clear the 0.3
transient-drop gate) on the gait-appropriate input (run-family clips
0x0a..0x0f -> 101, walk family -> 100; the other input zeroed so gait
changes don't stack). Patch-source components (VDATA 1001/1002/1005) are
skipped -- AudioSource::ReceiveControl Fail()s on input-range ids.
VERIFIED: FootFallInt01.wav (bank2 patch39) delivers + plays per stride;
mixer sums nonzero (vol=1 at run speed); 30s drive+fire regression clean --
weapon charge/fire/sustain cycles, ready dings, buttons, engine loops, state
audio (0 skips), gait advancing normally, no crashes, no stuck loops.
Also this session: [seqcfg] sequence config dump (decoded the authored event
streams: ctl 8=note/6=attack-vol/1=start/2=stop patterns), BT_AUDIO_NODROP
diagnostic (force low-volume transient delivery at a 0.7 floor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two real bugs found + fixed chasing the footstep volume:
1. BTL4Application::MakeAudioRenderer read sample_rate from a raw 1995-layout
offset (divisionParameters+0x10) -- the databinding trap: it yielded
-1.6e14 (measured), poisoning Renderer::calibrationRate for the ENTIRE
audio system. Every AudioTime conversion (sequence event scheduling,
compression curve durations, Seconds_To_Frames) was garbage. Sanity-clamp
to the authored 1000 frames/sec default (BT_AUDIO_LOG logs the value).
2. AudioHead::Execute fed audioFrameCount raw OS ticks; now converts
ticks -> calibrated frames via SystemClock::GetTicksPerSecond (1000ms
fallback when the static isn't measured yet), so AudioTime consumers see
the rate they were calibrated for. With rate=1000 + ms ticks the units
now align end-to-end.
Footstep status: chain verified through pulse -> matcher -> Start -> renderer;
the volume mixer's two inputs receive only value-0 AudioControlSequence events
even with correct timing -- the authored event VALUES need decoding next
(sequence tempo/divisions ctor dump; possibly Verify()-stripped tempo garbage
or the events are resets and the true volume rides another control id).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MAJOR RESULT -- THE GAIT IS CORRECT, the enum was mislabeled:
LoadLocomotionClips (binary-verified slot map) proves namedClip==&animationClips[5]:
clips 6/7 = wwr/wwl (WALK cycle), 12/13 = rrr/rrl (RUN cycle -- what autodrive
uses at full throttle), 10/11 = wrr/wrl, 14/15 = rwr/rwl, 16-21 = reverse set.
The mech2.cpp "MechAnimationState" enum (0x3c-stride name table) does NOT match
the runtime clip ids -- it is the mislabel (12/13 are NOT StandToReverse).
The authored AudioStateTriggers on AnimationState (statecfg dump: states
1,2,5,8,9,10,11,14,15,16,17,20,21,22,23,26,27 -> Start) align EXACTLY with the
TRANSITION clips under the true map; the CYCLING strides (6/7, 12/13) have no
state triggers because the FootStep pulse attribute serves them -- our FootStep
reconstruction is the authentic design. DO NOT renumber the gait.
Footstep chain, fully mapped (all empirtical):
pulse -> AudioMatchOf(match=1) -> Start on the FootFall DirectPatchSource ->
DROPPED at volume 0. Volume path: an AudioControlMixer (2 inputs, never fed)
<- an AudioControlSplitter <- AudioControlSequence timeline events. The
sequences DO fire -- but only value-0 events (the tick-0 initializers); the
later (real-volume) events never land.
NEW ROOT-CAUSE CANDIDATE [T3]: audio clock unit mismatch. The renderer is
constructed with DefaultRendererRate=30 (calibrationRate: Seconds_To_Frames =
s*30) but AudioHead::Execute sets audioFrameCount = Now().ticks which advances
~550/s (measured via the [audioclock] probe) -- sequence event scheduling runs
~18x off the authored timing. Fix candidate: calibrate the audio renderer to
the actual Now().ticks rate (or drive audioFrameCount at the calibrated 30/s).
Also corrected en route: FUN_004b9550/95b8 are MechWeapon ConfigureMappables/
ChooseButton (mapper EnterConfiguration +0x38), NOT Myomers::ConnectToMover;
+0x31c there is fireImpulse. Myomers::speedEffect has NO binary writer found
beyond the ctor 1.0f (its 'mover coupling' stand-in is spurious).
Diagnostics added: [statecfg] AudioStateTrigger ctor dump, [split] splitter
forwards, [seqev] sequence timeline events, [audioclock] frame-rate probe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chain (every hop verified live): FootStep pulse (SetBodyAnimation, decay in
PerformAndWatch) -> AudioMatchOf match=1 -> Start(2.0) on the FootFall
DirectPatchSource... which the renderer DROPS because its volume is 0:
an AudioControlMixer (outCtl=3/Volume + a second outCtl=5 mixer) feeds the
source, and BOTH mixer inputs are 0 -- nothing ever sends them.
Eliminated as senders (all traced): AudioScaleOf scales (29 total, none target
the mixer), AudioControlMultiplier (one instance, other target), Random/
AudioSampleAndHold (RNG verified WORKING -- varied samples; RANDOM.cpp is in
the build, the self-init ctor runs).
HYPOTHESIS [T4, next session]: the mixer inputs are fed by AudioStateTriggers
on AnimationState keyed to the AUTHORED gait-clip ids (walk 2/3, run 8/9 =
per-gait footstep volume). Our runtime body SM walks in states 12<->13 --
which the recovered MechAnimationState enum names RightStandToReverse/
LeftStandToReverse. If the authored audio expects 2/3 for forward walk, the
runtime clip NUMBERING in the reconstructed gait SM is offset from the
authentic ids -- a locomotion-layer mismatch with implications beyond audio
(the audio config is an independent witness to the true clip numbering).
Verify via the AudioStateTrigger configs on the AnimationState watcher
(trigcfg dump exists) and cross-check the SetBodyAnimation clip table.
New diagnostics: [snh] SampleAndHold sends, [mix] mixer forwards with input
index + sum, [scalecfg] authored scale bindings/boundaries, [volset].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Footstep chain progress (each step empirically verified):
- The FootStep watcher is an AudioMatchOf<Logical> (AudioLogicalTrigger extends
AudioMatchOf, NOT AudioTriggerOf): authored config match=1 -> Start(2.0) on a
DirectPatchSource. [trigcfg]/[matchcfg] ctor dumps added.
- The pulse was pinned at 1: the decay lived in IntegrateMotion, which the
current gait path NEVER CALLS. Moved to Mech::PerformAndWatch (provably
per-frame) with a time-based 150ms window sized to the ~10Hz watcher poll.
The matcher now fires per stride (14/run, was 1).
- Every footstep Start is DROPPED at the renderer: the source's volumeScale is
0 (five VolumeAudioHandler sends of exactly 0 = inert/one-shot primes). The
volume arrives through the authored control chain (AudioControlMultiplier
inputs ctl 100+; one 0 input pins the product). Chain scales identified on
this entity: LocalVelocity (live), LocalAcceleration (live), Myomers.
SpeedEffect (=1.0 healthy; one authored scale maps [0,1]->[1,0] INVERTED),
UnstablePercentage (INERT PAD -- always 0!), HeatSink.CurrentTemperature.
PRIME SUSPECT: an UnstablePercentage-fed multiplier input primes 0 once and
never updates (the pad never changes), pinning footstep volume at 0.
- SF2 numbering fact recorded: 38 presets in AUDIO1 / 5 in AUDIO2 have
wPreset != file index (gap at preset 77); my table keys by wPreset. The
184x ProgramButton01 (bank1 patch83) storm at ~7/s needs an identity check
against index-numbering (it may be the wrong sample for that patch id).
Diagnostics added (BT_AUDIO_SPATIAL/BT_ATTRBIND_LOG): [trigcfg]/[matchcfg]
ctor dumps, [matchfire] with component ptr+class, [volset] VolumeAudioHandler,
[mult0] multiplier zero-products, scale sends with authored boundaries,
[fswatch] dedicated footstep watcher tracer (g_btFootStepAddr), SetupPatch
ENTRY with bank/patch/state, Simulation::DebugAudioWatcherCount.
NEXT: back UnstablePercentage with a real (live) member -- it is the mech's
stability 0..1 (the stabilityAlarm/gyro family) -- or confirm via a one-run
chain dump which multiplier input pins the FS source; then the footstep
transient should clear the 0.3 drop gate at walking speed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Instrumentation (all BT_AUDIO_LOG/BT_AUDIO_SPATIAL-gated): mech watcher-poll
probe (delayed flag + socket count via new Simulation::DebugAudioWatcherCount),
per-sim audio-socket size in ExecuteWatchers, trigger notifications (val>0),
watcher poll-rate/change probes, footstep pulse address trace.
VERIFIED working: FootStep binds to the real member (pulse addr == bound ptr,
same run); pulses fire per stride (40/run); the watcher registers on the mech
(audioWatchers=20 after audio-object creation); the mech's poll runs
(delayed=0, simFlags=0x1110); Logical == int (STYLE.H:132) matches the member
type. The idle<->moving engine crossfade (AudioMotionScale on LocalVelocity
through the restored watcher poll) is CONFIRMED audible in play.
REMAINING: the FootFall transient still doesn't reach SetupPatch -- next probe
is the trigger's streamed threshold/inverse config and the speed-scaled
transient volume at the renderer drop gate (LowAudioVolumeThreshold=0.3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: "when I start moving all the sounds fade away, no footsteps." Diagnosis
chain (all empirical, BT_AUDIO_SPATIAL/BT_ATTRBIND_LOG traces):
- The audio head DOES track the mech (listener-relative positioning verified
while driving) -- not a spatial bug.
- The idle sounds correctly STOP on leaving the standing state; the MOVING
sounds never started because every POLLED audio watcher was dead:
the reconstructed Mech::PerformAndWatch replaced the engine performance but
dropped the ExecuteWatchers() step from the engine tail (Simulation::
PerformAndWatch = Perform -> ExecuteWatchers -> WriteSimulationUpdate).
Only PUSHED StateIndicator watchers (SetState->Execute) ever fired -- which
is exactly why state sounds worked but footsteps/motion-scaled audio didn't.
FIX: poll ExecuteWatchers() (AreWatchersDelayed-gated) before the update
write. Immediately unlocks the polled family: MissileLoaded01/LaserLoaded01
ready dings, ProgramButton01, motion scales (PlayNote 15 -> 30 per run).
- FootStep (0x1e) backed REAL: was the inert attrPad (an AudioLogicalTrigger
polls it -- threshold-crossing pulse per foot plant). New footStep member,
pulsed by SetBodyAnimation on entering any locomotion clip 1..0x17 (runtime
stride alternation MEASURED as body states 12<->13 -- the enum-name numbering
does not match runtime clip ids), decayed by IntegrateMotion (~1/3 s).
- Footstep volume is speed-scaled (AudioMotionScale on LocalVelocity): at
standstill the transient start computes volume 0 and the renderer drops it
(LowAudioVolumeThreshold) -- so footfalls are audible at real walking speed.
Diagnostics kept (BT_AUDIO_SPATIAL): spatial dist/vol per source, CLIPPED/DROP/
START at the request gate, vol=0 factor breakdown, scale->0 sends, trigger
notifications, watcher poll-rate probe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runaway loop the user heard was MissileLoading01: the ProjectileWeapon
(SRM/ballistic) state machine set Firing(0)/Loading(3)/Jammed(5)/NoAmmo(7) but
NEVER a ready/Loaded level, so at idle the launcher parked in Loading(3) forever.
Its WeaponState audio (weaponAlarm, now StateIndicator-firing) therefore looped
MissileLoading indefinitely -- the loop stops only when the alarm LEAVES the
loading state. (The laser sibling was fine: the Emitter DOES reach Loaded when
its charge tops off, so LaserACharge stopped.)
Fix: in ProjectileWeaponSimulation's default (idle/ready) branch, drive the state
from readiness -- Loaded(2, charge + ammo up = silent ready) once ReadyToFire,
else Loading(3, reloading). SetLevel fires audio only on a real change, so this
yields the natural Firing -> Loading(reload) -> Loaded(ready) cycle and the
MissileLoading loop now stops when the launcher finishes reloading. Verified:
weapon cycles Loading(3, recoil>0)->Loaded(2, recoil<=0); the only remaining
continuous loop is EnginePower (correct). Fire path unchanged.
Also: master volume default kept; richer BT_AUDIO_LOG StopNote/SetupPatch traces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The raw AWE32 samples are hot and everything plays at full per-source gain, so the
mix was loud. Set alListenerf(AL_GAIN) once at device init -- it scales EVERY
source (master volume). Default 0.6; override with BT_AUDIO_VOLUME=<0.0..1.0+>
(0 = mute, 1 = full, >1 = boost).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WeaponState -> weaponAlarm (mechweap.cpp:149), and the weapon's looping audio
(LaserACharge/LaserASustain/MissileLoading, all SF2 sampleModes=1) is STARTED by an
AudioStateWatcher on the firing/charging state and STOPPED by an inverse
AudioStateTrigger that keys on old_state (== the state being LEFT).
The subsystem-state commit fired GaugeAlarm54 watchers on SetLevel but deliberately
left levelB (the oldState slot @0x10) untouched to avoid disturbing the weapon's
LevelCountB read. Consequence: the audio watcher's StateChanged(oldState,newState)
saw a stale oldState, so the inverse/STOP triggers never matched -> the charge/
sustain/loading loops played forever.
Fix: SetLevel now sets levelB := level (oldState := currentState) before the change,
exactly like StateIndicator::SetState. The stop triggers now match on leaving the
state and StopNote the loop. This is also authentic: in the binary the 0x54 alarm
+0x10 IS oldState, and the weapon's LevelCountB()/weaponIdle read of +0x10 is that
same oldState (the old static-count reading was the reconstruction's accident).
weaponIdle is only consumed in the MP replicant record-apply path, so single-player
firing is unaffected; the value is now the authentic oldState-based one.
Subsystem state audio unaffected (0 skips). Diagnostics: BT_AUDIO_LOG now traces
SetupPatch src/file/loop + StopNote.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The last cleanly-backed subsystem audio attr: GeneratorOn (AudioLogicalTrigger)
now resolves to the real generatorOn member instead of the inert pad. Static for
now (init 1, no shutdown writer drives it to 0 yet), but bound correctly.
Remaining inert attrs (ReportLeak/ConfigureActivePress/MotionState/
SpeedOfTorsoHorizontal/TargetRangeExponent) have no modeled backing member in
their size-locked subsystem layouts -- they read the inert pad (silent, no crash)
until those members are reconstructed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AmmoBin::AttributeIndex was a bare, default-constructed static with NO parent
chain, so nothing resolved on an AmmoBin -- even the inherited SimulationState
came back NULL (the audio AmmoState + SimulationState watchers were skipped).
- Chain AmmoBin::AttributeIndex to HeatWatcher::GetAttributeIndex() and give it a
real AttributePointers[] with AmmoState -> ammoAlarm (@0x194, the 6-level 0x54
StateIndicator-compatible feed alarm, already SetLevel'd Feeding/Loaded/Empty/
Dumped by the sim) so reload/empty/feed audio fires on the ammo transition.
With this every StateIndicator audio attribute across the mech + all subsystems
binds to a real indicator: audiostate skips 85 -> 0. Remaining audio gaps are the
inert Logical/Scalar/Enum subsystem attrs (ReportLeak/GeneratorOn/ConfigureActive
Press/MotionState/SpeedOfTorsoHorizontal/TargetRangeExponent), which read 0 (silent,
non-crashing) pending backing members in their size-locked layouts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The audio subsystem binds AudioStateWatchers (class 28) to per-subsystem state
attrs (GeneratorState/CondenserState/ReservoirState) BY NAME. Recovered the
watcher TYPE per attribute via a MakeObjectImplementation ClassID trace.
Root cause of the subsystem-state crash: the binary's 0x54-byte subsystem alarm
(FUN_0041b9ec, reconstructed as GaugeAlarm54) IS a StateIndicator -- its "three
sub-indicators" @0x18/0x2c/0x40 are the audio/video/gauge watcher SChains, and
level@0x14 is currentState. The reconstruction had modeled that tail as an opaque
`_tail`, so the sockets were never constructed -> AddAudioWatcher AV'd on 0xCDCDCDCD.
- GaugeAlarm54: give it the three REAL, constructed SChainOf<Component*> sockets +
AddAudioWatcher/Video/Gauge; SetLevel now fires the watchers on a level CHANGE
(empty sockets = no-op, so the ~all other alarms are unaffected). levelB (weapon
LevelCountB "reset source") is left untouched -- weapons unchanged. sizeof stays
0x54 (static_assert holds -> SChainOf<Component*> is 0x14, layout exact).
- Register the state attrs -> the subsystem's own alarm (own AttributePointers[]
chained to the parent): Generator.GeneratorState->stateAlarm, Condenser.
CondenserState->condenserAlarm, Reservoir.ReservoirState->reservoirAlarm. Those
alarms are already SetLevel'd by the sim, so the state audio fires on transition.
- AudioStateWatcher guard now validates the +0x18 SOCKET (what AddAudioWatcher
touches), not the +0 vtable -- GaugeAlarm54 is non-polymorphic at +0 (raw header)
but has a real socket at +0x18.
audiostate skips 85 -> 10 (only AmmoBin AmmoState/SimulationState left). The
Logical/Scalar/Enum subsystem attrs (ReportLeak/GeneratorOn/ConfigureActivePress/
MotionState/SpeedOfTorsoHorizontal/TargetRangeExponent) stay inert (read 0, silent,
non-crashing) -- they need backing members in size-locked layouts (a polish wave).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enabling audio unlocked L4AudioRenderer::CreateEntityAudioObjects, which binds
AudioStateWatchers to entity/subsystem state attrs BY NAME (attribute-pointer
databinding) and calls AddAudioWatcher on them. The reconstruction pointed the
Mech's state attrs at the scalar read-pad `attrPad` (fine for gauges that READ a
value, fatal for a state watcher that calls a METHOD) -> AV in SChainOf::Add on
0xCDCDCDCD. Root-caused via cdb + a BT_ATTRBIND_LOG trace in AttributeWatcher.
Mech-level state audio now REAL + driven (verified: walking fires SetupPatch +
PlayNote/alSourcePlay, patches 78/80/116):
- AnimationState (0x1f) / ReplicantAnimationState (0x20): real StateIndicators
(0x21 states covering MechAnimationState 0..0x20), driven from SetBodyAnimation
so footstep/gait/transition sounds fire on animation change.
- CollisionState (0x16): real 4-state StateIndicator driven from ProcessCollision
via collisionTemporaryState (the deferred @4aa741 per-frame zero + the
part_012.c:15406-15413 contact tail, InitialHit accumulation; the 1-vs-2 Slide
split awaits the 0x240/0x244 floats still stubbed by fieldAt()).
- @0x44c CORRECTED: "ammoState (0/1 leaking/2 dry)" was a mislabel -> it is
collisionTemporaryState (never read as ammo).
Bring-up guards [T3, temporary, self-clearing] so audio-on is STABLE while the
subsystem state models are still stubs (Generators/Condensers/Myomers/Torso/
Avionics/Reservoir/ControlsMapper -- ~15 subsystems, ~40 attrs: GeneratorState,
CondenserState, ReportLeak, ...): a NULL subsystem attr redirects to an inert pad
(was a fatal Fail); an AudioStateWatcher on an unconstructed StateIndicator (null/
0xCDCDCDCD) skips instead of AV. Both pass automatically once the subsystem is
reconstructed. Those subsystem sounds stay silent until then (their audio wave).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct the audio digest in wintesla-port.md + project-overview.md: the "no
sound ever" root cause was that BOTH engine/lib backend DLLs were no-op stubs
(libsndfile ordinal-only sf_open->NULL; OpenAL32 imports only KERNEL32). Now
real OpenAL Soft + LoadWavPCM; soundbank cracked (241 samples). Only remaining
gap = game triggering (AudioEntities).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of "no sound, ever": the two audio backend DLLs shipped in the repo
are fakes. libsndfile-1.dll exports 15 funcs BY ORDINAL ONLY (no names) and
sf_open() always returns NULL; OpenAL32.dll (72KB) imports only KERNEL32 -- no
dsound/wasapi/winmm -- so it is a pure no-op that returns fake handles
(ctx=0x00000001, alGenSources->0) and never touches the hardware. The whole
render->device->buffer->source->play chain ran clean and silent.
Fixes:
- OpenAL32.dll: replace the stub with the real OpenAL Soft 1.25.2 Win32 build
(imports AVRT/ole32/WINMM, real WASAPI backend). The exe imports the 25 AL
funcs by NAME so it is a drop-in; alGenSources now yields a live source and
alSourcePlay reaches AL_PLAYING.
- libsndfile: DROPPED entirely. It is replaced by LoadWavPCM() in L4AUDRES --
a tiny RIFF/WAVE fmt+data reader that loads our soundbank WAVs (16-bit PCM)
straight into the AL buffer. Removed the .lib/.dll from the link + copy and
git-rm'd the stub. (This also kills the "ordinal 50 could not be located in
libsndfile-1.dll" load-failure popup: adding an sf_strerror import bound to
an ordinal the 2..16-only stub could not satisfy.)
- Soundbank: 241 samples cracked from AUDIO1/2.RES (SF2 v1.0) by
tools/sf2extract.py into content/AUDIO/*.wav + the allPresets[2][128] table
(audiopresets.cpp), replacing the zero-init btstubs stub. All 241 now load
(alErr=0). BT_AUDIO_TEST plays buffer0 as proof-of-life; BT_AUDIO_LOG traces
the chain.
Remaining: in-game triggering (AudioEntities on fire/step/engine/explosion)
so PlayNote fires during play -- next audio wave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User reported never hearing any audio. Root-caused: the KB "Audio: DONE" was wrong.
The OpenAL playback chain IS fully implemented (device/buffers/sources; PlayNote
really calls alSourcePlay), but three gaps kept it silent:
1. GATED OFF (the "no sound" root cause): BTL4Application::MakeAudioRenderer returned
NULL unless the pod's AWE_FRONT/AWE_REAR AWE32-card env vars were set -- authentic
1995 pod behavior, dead on modern hardware, so the renderer was NEVER created.
FIXED: default audio ON (BT_NO_AUDIO=1 restores silence; AWE vars still force-on).
Verified: the OpenAL device now opens with no env vars ([audio] device OPENED).
2. Soundbank STUB: allPresets[2][100] (btstubs.cpp) is zero-init -> PRESET_isImplemented
false -> 0 buffers load. Sample data exists (AUDIO1/2.RES: EnginePower/LaserAFire/...)
but the event->sample map is gone (not in the decomp). STILL OPEN.
3. No triggering: the reconstructed game never creates AudioEntities on events. STILL OPEN.
So the device opens but nothing loads/plays yet -- enabling real sound needs the
soundbank reconstructed + the game triggers wired (a proper audio wave). Added a
BT_AUDIO_LOG trace harness across L4AUDRND/L4AUDRES/L4AUDLVL. KB corrected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tested the real-IP config path (not just 127.0.0.1): an egg with this machine's
real LAN IP (10.0.0.46) in [pilots] connected + replicated + moved end-to-end
between two nodes (Connected to GameMachineHost at 10.0.0.46:1602 / All
connections completed! / peer telemetry flowing). Confirms gethostbyname
local-address matching + WSAStringToAddressA IP:PORT parse work with real
addresses. Recorded the LAN recipe + noted the one remaining unknown: an actual
two-physical-machine run (firewall) -- validated single-box via the real IP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Went after the flagged ~2.9u peer-motion snap. Added a BT_SNAPLOG diagnostic
(mech4.cpp: logs the re-anchor drift eMag = |authority - dead-reckon| absorbed per
frame) and ran a controlled accel/decel soak: node B BT_AUTODRIVE + BT_DRIVE_SWEEP0
sweeping the throttle through STOP, node A observing, affinity-pinned.
Result: peer drift maxes at ~0.64u (median 0.54u, 18 events >0.5u over 48s), gently
absorbed at k~0.24/frame -- SUB-UNIT, at the noise floor. The ~2.9u figure was stale:
it predated the peer body-channel swap (96a896a, which put the peer on the same
channel the master's mirror predicts) AND was measured without the CPU-affinity fix
(packet-jitter-sparse records inflate the drift). The old "run the master mirror on a
leg-channel prediction" plan is moot now the peer is on the body channel.
No motion-code change -- just the measurement + the BT_SNAPLOG diagnostic (retained).
KB (multiplayer.md, open-questions.md) corrected to the measured value. checkctx CLEAN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigated the "console death froze peer replication" open question (2026-07-14)
with a direct 2-node repro (BT_REPL_LOG on a circling peer, affinity-pinned):
- Clean relay kill -> replicant kept circling smoothly through the kill, NO
disconnect logged, node survived. Replication did NOT freeze.
- Deliberately STUCK console (scratchpad/btconsole_stuck.py: connect, start
mission, then stop recv() while holding the socket OPEN = the exact
"receive pad full -> close never seen" mode hypothesized) -> replicant kept
moving the whole run. Replication did NOT freeze.
Conclusion: peer replication is INDEPENDENT of the console (pods replicate
peer-to-peer over the GAME socket; the console is a separate egg/mission/status
channel). The original freeze was a transient -- plausibly the same single-box
packet-jitter/CPU-contention artifact root-caused in 49d73dc -- or was fixed by
later MP work.
FIX (the one real bug found): L4NetworkManager::HostDisconnectedMessageHandler's
ConsoleHostType branch closed gameListenerSocket (the GAME listener) on a CONSOLE
disconnect -- a naming bug (the comment + commented-out OpenConnection intended a
CONSOLE re-listen, which CreateConsoleHost already does). Removed. Harmless to
established peer sockets (why a live match survives console loss) but it would
have blocked a NEW peer from joining after a console cycle. Verified: 2-node
still connects (All connections completed!) + the peer circles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Targeted pass on "stand-in / not-wired / stub / no-op" claims, verified against
CODE + runtime (not KB wording -- the trap that hid the peer-warp staleness):
- combat-damage.md STEP-6 "remaining = fix ResourceFindByName no-op -> empty
table" (reads present-tense above the "STEP 6 COMPLETE" header): STALE. The
table LOADS live ([cyl] table 'bhk1'/'madcat'/'ava1' layers=7); the name-load
BYPASSED the no-op ResourceFindByName via SearchList(type=0x14) (mech.cpp:1631).
d07ac7d. ResolveHit resolves unaimed hits.
- multiplayer.md "Mech::Reset full subsystem-reset sweep is still a bring-up TODO":
STALE. mech4.cpp:1616 loops every subsystem -> DeathReset(mode) (heat/power/ammo/
charge) + heals zones + ForceUpdate(0x1f). Per-subsystem DeathReset bodies are
authentically trivial for some classes -- not a missing sweep.
Verified ACCURATE (not stale, left as-is): GaussRifle FireWeapon no-op (a decomp
fact -- inert in the 1995 binary; btl4gau2 faithfully marks it "not yet supported");
StatusMessagePool NULL stub (kill ticker live, non-kill status messages genuinely
deferred); Myomers coupling inert (genuinely open); searchlight fog-swap (open by
decision). checkctx CLEAN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User observed peer warp spheres in 2-player mode; the KB (translocation-warp.md
frontmatter+body, multiplayer.md frontmatter+body) still called the peer path a
"local-player sphere stand-in until SimulationState/DropZoneLocation replication."
STALE: mechdmg.cpp:1074 fires BTStartWarpEffect at the peer's position gated to
ReplicantInstance, and simulationState rides every update-record header -- so an
observer DOES see a peer's un-wreck/respawn warp. Committed 160b78e ("observer
sees peer un-wreck"). The only genuine remainder is a [T3] fidelity nuance: the
peer sphere is anchored to the peer's WORLD position rather than the peer's
authentic (un-replicated) DropZoneLocation. (The staleness audit itself missed
this -- it trusted the file's own stale wording over its body + the code.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The open-questions.md block was the old completion PLAN (ea39af1) and was never
updated after the work actually landed + the residual was root-caused. It
contradicted multiplayer.md (which correctly documents both "Authentic coupled
peer motion -- DONE" and the packet-jitter resolution). Reconciled to DONE:
coupled single-source gait pipeline default-on (c52a1ad/b013742/96a896a/f094d78/
23f1532); the "needs master half" conclusion superseded by 49d73dc (residual
shakiness = single-box packet jitter, a test-rig artifact, fixed with CPU
affinity, user-confirmed -- NOT a game bug). Minor follow-ups (2.9u channel-snap,
decay constant) tracked in multiplayer.md. No code change -- KB currency only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MechWeapon::UpdateTargeting computed effectiveRange = (1 - heatLoad) * weaponRange,
reading the weapon's own inherited HeatableSubsystem heatLoad. The authentic decomp
(@004b9bdc:6983) reads *(weapon+0xE0)+0x158 = Subsystem::damageZone->damageLevel --
i.e. effectiveRange = (1 - HOST-ZONE DAMAGE) * weaponRange. Same @0xE0-DamageZone-vs-
heat misattribution already corrected in HeatSink::UpdateCoolant (heat.cpp:803).
Impact: for a charge/discharge weapon (ER laser) the weapon's OWN heatLoad swings
0..1 every fire cycle, so effectiveRange collapsed toward 0 and the weapon was
perpetually "out of range" -> Emitter::FireWeapon's `if (dist <= effectiveRange)`
gate skipped SendDamageMessage -> NO damage submission and hence NO impact explosion.
The beam still rendered (beamFlag/beamEndpoint set before the gate), so the shot LOOKED
like a hit but did nothing -- the user-reported "lackluster/absent laser hits, esp.
the ER medium, on mechs AND buildings". PPCs mostly worked only because their heatLoad
happened to sit low/stable.
Fix: read the QUALIFIED this->Subsystem::damageZone->damageLevel (the MechSubsystem
shadow is a shim -- heat.cpp:812) so an UNDAMAGED weapon holds its full, STABLE
weaponRange, and range shortens only as the weapon's host zone takes battle damage.
Verified (parked in range of a building, autofire): laser effRange 500 STABLE
(was fluctuating 0/59/340/424 -> mostly out of range); impact explosions 13 in 22s
(11 laser id=16 + 2 PPC), up from ~2. Lasers now consistently damage + spawn FX.
Also adds env-gated diagnostics used to root-cause this: [fireW] range trace +
per-weapon explID (emitter.cpp), and BT_FIRE_AT_STRUCT (mech4.cpp) which designates
the nearest world structure so weapon-vs-structure fire can be tested without the
screen aim ray.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The boresight's non-mech pick only sampled the VISUAL heightfield (BTGroundRayHit),
which on arena1 is a single flat 'sky'-named ground mesh (and class-42 BuildTables
likewise holds only 'sky') -- so shots passed THROUGH the garages/walls and only mechs
moved the HUD range axis (user-reported regression: "buildings used to move the range
axis; now only mechs do, and you can only fire at a peer").
Real fix: the boresight now ALSO ray-tests the ZONE'S STATIC COLLISION SOLID TREE --
the same geometry that already blocks the mech's walk. Authentic engine mechanism:
Mover::FindBoxedSolidHitBy already tests the static world via
zone->GetCollisionRoot()->FindBoundingBoxHitBy(line). Factored its "static world" tail
into Mover::FindStaticSolidHitBy(Line*) (static solids only, no movers), wrapped by
Mech::WorldStructurePick(start,dir,range,&hit) (builds the world-space Line, reads the
entry point back via line->FindEnd since HitByBounded clips line->length=enter).
Boresight pick order is now: closest MECH (PickRayHit, damage zones + lock) -> closest
STRUCTURE (WorldStructurePick; occludes a mech BEHIND it; designates the gBTTerrainEntity
sentinel + entry point, so the range caret reads the structure distance and NO lock ring
draws, mech4.cpp:4529) -> flat ground (BTGroundRayHit) -> sky (fire-at-nothing). Also
un-skips arena1's misnamed-'sky' flat ground so the ground tier works (btvisgnd
geometry-aware skip + [mapent]/[rendent] census).
Verified headless: a BT_WSWEEP horizontal ray-fan on arena1 tracks position (3/24 hits
near the boundary -> 17-21/24 inside the interior garage cluster -> 3/24 past it =
DISCRETE interior solids, not an enclosing box), no crash/assert/AV. Interactive aim
(BTGetAimRay) can't run headless (no window -> noRay), so the sweep is the headless proof;
interactive aim is user-verified.
Note: FindBoundingBoxUnder (the ground/containedByNode BoundingBoxTree) is DOWNWARD-only
(gravity/ground-snap: *height = FindDistanceBelowBounded), useless for a horizontal
boresight; the static SOLID tree's FindBoundingBoxHitBy is the only ray-vs-world query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Determined the authentic behavior from the decomp (HudSimulation + Emitter,
part_013.c:5619-5670 / 7689-7778) and fixed two regressions the players hit:
(A) You could only fire when a MECH was locked (couldn't fire at nothing).
(B) Buildings/structures/ground stopped moving the range axis; only mechs did.
AUTHENTIC (T1): a weapon discharges iff the target slot mech+0x388 != 0
(Emitter::FireWeapon FUN_004bace8:7727), and 0x388 is whatever the BORESIGHT
designates -- mech OR world geometry -- NOT a manual mech lock. The RANGE readout
(HUD+0x1ec) moves for ANY designated target (mech OR structure/ground); the LOCK
ring is mech-only (target has a damage-zone table, HudSim :5619). So the pod
'fired freely at nothing' and buildings moved the range caret.
ROOT CAUSES (both content-triggered, not a targeting-code change):
1. arena1's ONLY class-42 world entity is the 10000x10000 FLAT GROUND plane at
y=0, MISNAMED 'sky' -- SkippedName filtered it out by name, so BuildTables
ingested 0 geometry and gBTTerrainEntity stayed null -> nothing groundable ->
mech-only targeting. (LAST.EGG rewrite ~bb795e2 lost whatever terrain the
working scene had.)
2. A boresight that hit nothing set MECH_TARGET_ENTITY=0 -> no discharge.
FIXES:
- btvisgnd.cpp: geometry-aware skip -- keep a wide, near-flat, near-ground plane
even if name-skipped (it is the arena floor); still skip true domes/backdrops.
Adds mesh y-bounds. arena1 now ingests 1 ground instance (was 0).
- btl4vid.cpp: capture ANY world-geometry entity as the non-mech pick sentinel
(the default render case is world-only), not only Terrain-derived ones.
- mech4.cpp: FIRE-AT-NOTHING -- when nothing pickable is hit, designate a
max-range point (1200, the HUD default 0x44960000) along the boresight using
the world sentinel, so 0x388 != 0 and the weapon discharges (no zone damage /
no lock ring, since the sentinel has no damage-zone table -- mechs only).
Verified autonomous: arena1 ground ingested; [target] shows 'fire-at-nothing
(max-range designate)'; weapon discharges at empty space ([fire] explode
resolved); range axis (sShownRange->BTSetHudTargetRange) slides to the designated
distance. Diagnostic: BT_GROUND_LOG (world-geometry ingest + skip reasons).
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>
Toward a truer mirror (user: peer 'hesitates/skips on accel/decel, master has
smoother transitions'). The body channel FOLLOWS the master's replicated STATE
(96a896a) but its CADENCE (bodyCycleSpeed) is still slewed LOCALLY toward the
replicated commanded speed -- which drifts from the master's actual cadence, so
the animation phase wanders until a record snaps it (the hesitation).
FIX (replicant-gated, single-player untouched): peerMirrorSpeed member = the
actual replicated ground speed (|updateVelocity|, set in the peer branch); the
body channel walk (6/7) + reverse (0xc/0xd) cases override bodyCycleSpeed with
it (clamped to the state's band) right before the clip advance, so the peer's
clip advances at the master's REAL rate instead of a locally-drifting slew.
peerMirrorSpeed = -1 on the master/single-player -> the authentic slew runs.
BT_NO_MIRROR_CAD / BT_PEER_LEGCH revert.
Verified autonomous (through-zero sweep): no crash, body channel advances forward
(back-steps ~0), feet track (position ratio 1.05, maxStep 1.68u). Perceived
smoothness is the visual test (harness saturates).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The body-channel swap (96a896a) fixed the stop-slide but regressed turn-in-place
to a rotating statue: the body Standing case only enters walk/reverse/stand, never
the turn clip (state 4). On the master that arming comes from the LEG Standing
case cross-arming both channels (mech2.cpp:937) -- which the peer no longer runs.
FIX (peer branch): arm/exit the body turn state from the replicated turn
(replMppr->turnDemand, already derived from the replicated yaw rate with
hysteresis): standing + turning -> SetBodyAnimation(4) so the body case 4 advances
the trn clip (peer STEPS through the pivot); turn stops -> back to Standing.
Walk/reverse transitions still own their own exits.
Verified autonomous (forced spin): peer body channel holds state 4, trn clip
advances (frmAvg 0.2-0.3, frmMax 1-2), back-steps 0, no crash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers 'is this different from the original?' -- NO, it RESTORES it. Decomp
(workflows wh1h5gnmc/w1s9ou02o): the 1995 game had ONE live skeletal channel,
the BODY channel FUN_004a5678 via IntegrateMotion; the LEG channel FUN_004a5028
is DEAD CODE (zero call sites, SetLegAnimation never runs). Both master and peer
posed the whole skeleton from the body channel, the PEER FOLLOWING the master's
replicated body-anim state (bodyStateAlarm@0x728, set by the type-3 reader's
SetBodyAnimation, mech.cpp:1913) + replicated bodyTargetSpeed@0x6b4.
Our port RESURRECTED the dead leg SM as the peer's poser (mech4.cpp AdvanceLeg-
Animation) fed a LOCALLY re-derived commanded speed. The leg SM's phase-
independent pre-switch wind-down (mech2.cpp:560-568: force-jump {6,7,8,9}->stand
when legCycleSpeed<=0) made the peer SKIP the master's decel state 8 and snap to
stand while the body coasted -- the 'slid forward after legs stopped' slide, the
reverse 'slippy sliding in place', and the whole re-derivation desync class.
FIX (peer branch, gated BT_PEER_LEGCH=1 to revert): pose+travel from
AdvanceBodyAnimation(dt, mj=1) -- the body channel, which follows the replicated
state and has NO early wind-down, playing the master's exact clips (walk/decel-8/
reverse/stand). ZERO new netcode -- the state is already on the wire and already
decoded into the body channel; we were simply posing from the wrong (resurrected,
dead) channel. Single-player untouched (replicant-gated).
Verified autonomous (through-zero sweep): no crash; body channel advances
(frmAvg 0.3-0.75, states 5/6/7/10 following master); slide-in-stand events
519 -> 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uncommitted work from the speed-model + peer-motion investigation:
- btl4mppr.cpp: the L4MechControlsMapper full-throttle detent used the
unparenthesized Abs() macro (STYLE.H:118) on an expression -- Abs(throttlePos
- 1.0f) mis-expands to -(throttlePos + 1.0f), always <= 0.05, so the detent
snapped throttle to full EVERY frame. Diff into a temp first (same class as
the 7615ecd angular-resync Abs fix).
- context/pod-hardware.md: document the decomp-verified analog-continuous pod
throttle path (RIO Ranger 0-800 counts, 0.05 deadband, no notching; '5 speeds'
is false) from workflow w0odszxro.
- mech4.cpp: BT_SLIDE/[mslide] per-frame slide diagnostics (peer position moving
while legs in stand; master decel profile) -- used to prove the stop-slide is a
peer leg-SM-winds-down-early desync, not master momentum. Env-gated.
The 'pretty good' coupled-motion gameplay state is already shipped (a9ab3db).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-reported 'gliding stops': when the master halts, the peer's legs wind down
to standing but the error-absorption kept sliding the body forward to close a
small residual offset -- visible precisely because no leg motion masks it. With
the feet planted a sub-unit correction is imperceptible, so resolve it in one
frame (k=1) when replLegAdv~0 and the offset is small, instead of gliding it in.
Large offsets at rest (a real teleport) still ease.
Measured (stop-emphasis sweep): maxStep 1.77u->0.98u (now sub-unit), peer drift
2.1u->1.0u mean. The remaining <=1u snaps are the leg-vs-body channel-shape
mismatch -- likely at/near the in-spec floor for this test rig (the Python
console relay batches packets, unlike the real dedicated pod network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chasing the ~2.9u residual snap: instrumented BT_MIRDIV and isolated it to the
PEER side (master send-mirror error is accurate, mean 0.39u < 0.55u threshold;
peer drifted 3-11u from received authority). Root: the master models the peer
with the BODY channel but the peer moves with the LEG channel -- slightly
different travel/frame -- so the master deadband does not fire exactly when the
peer drifts, and the fixed ~1/3s offset decay bled slower than accel/decel drift
accumulated. A hard ground-snap (authentic FUN_004ab1c8:14985) popped instead
(records not perfectly dense on the one-box relay).
FIX: error-proportional absorption -- bleed the offset to updateOrigin at a rate
that scales with the error (k 0.15..0.6/frame): small steady offset absorbed
gently (no foot-pop), large speed-change drift caught in a few frames.
Measured (through-zero sweep): peer drift 11u->2.1u, maxStep 2.9u->1.77u, ratio
1.043->1.036. Remaining <=1.8u snaps are the leg-vs-body channel-shape mismatch;
fully closing needs a leg-channel send-mirror (second instance) -- deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User confirms 'way better'; single-player un-regressed (master walks correctly,
the bodyTargetSpeed change only affects the invisible mj=0 body channel).
Flip the authentic-coupled path ON by default:
- peer position: gait-coupled linear (was velocity dead-reckon); BT_DR_POS=1 reverts
- master send-mirror: gait projection of projectedOrigin; BT_NO_MASTER_GAITMIRROR reverts
The two-source split (position from velocity + animation from commanded speed)
that mismatched during speed changes is retired. Residual: occasional ~2.9u
snap from the leg(peer)-vs-body(mirror) channel mismatch -- second-order,
tracked for follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the coupled IntegrateMotion path the decomp workflow (wh1h5gnmc, 3
make-or-break claims CONFIRMED by adversarial verify) proved authentic:
- Peer LINEAR position is SINGLE-SOURCE gait: IntegrateMotion integrates the
body-channel cycleDistance into projectedOrigin.linear@0x260, copied verbatim
to localOrigin -- NOT velocity dead-reckon (that is angular/heading ONLY).
0x260 'motionDelta' and 'projectedOrigin' are literally the same field; the
'contradiction' was our reconstruction's two misnamed shadows.
- Master and peer run the SAME predictor; the master's SEND-mirror must run it
too (binary FUN_004a9b5c @0x4aab9c) so a gait-driven peer stays anchored. Our
mirror used the constant-velocity deadReckoner + overwrote bodyTargetSpeed
live every frame -> could not model a gait peer (the tug-of-war).
- T4 CONFIRMED: both channels pose the full skeleton (same JointedMover); keep
the peer LEG channel (our body channel is unbound on the peer).
CHANGES (all behind BT_MASTER_GAITMIRROR / BT_ROOT_POS, DEFAULT OFF -- zero
change to shipped behavior until visually confirmed):
- mech4.cpp: send-mirror advances projectedOrigin by the mj=0 body channel
travel (mirrorBodyAdv) rotated by heading + last-sent angular vel, re-seeded
to localOrigin on each send, instead of the deadReckoner; and stops the
per-frame live bodyTargetSpeed overwrite so the mirror slews toward last-sent.
- mech.hpp/mech.cpp: mirrorBodyAdv member.
Measured A/B (autonomous through-zero sweep circle, the speed-change regime):
velocity two-source (current default): ratio 1.0022 BUT user-visible glitch
coupled, NO mirror (dense): ratio 1.86 (tug-of-war)
coupled + gait mirror, dense OFF: ratio 1.14, back-steps 0.1%
coupled + gait mirror, dense ON: ratio 1.043, back-steps 0.1%, maxStep 2.9u
The single-source coupling is proven (backward-stepping 0.1% vs the split's
churn). Residual 2.9u occasional snap = leg(peer)-vs-body(mirror) channel
mismatch, second-order. Awaiting visual confirmation before default promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user's architectural question -- 'should animation and velocity even be
allowed to be uncoupled?' -- is decomp-CONFIRMED correct: the original peer
(FUN_004ab430 -> FUN_004ab1c8) drove POSITION FROM THE CLIP'S ROOT TRAVEL
between records (feet<->ground locked by construction) with a pose-sync
offset decay absorbing record corrections. Our port dead-reckons position
from velocity while the legs run on commanded speed -- the two only agree at
steady state, mismatching exactly during speed changes (the reported glitch).
This commit lands the peer half of the coupled architecture, env-gated:
- BT_ROOT_POS=1: peer position += clip travel rotated by heading (mirror of
the master world-step @3325, == IntegrateMotion tail @004ab1c8); pose
records absorbed via the authentic offset-decay (motionEventVector
mechanism) instead of snapping.
- Measured A/B (through-zero sweep circle, harshest speed-change regime):
velocity-lerp (default): step ratio 1.0022 (clean)
root-motion + sparse records: 11-17u anchor snaps (master's velocity
mirror no longer models a gait-driven peer -> under-sends)
root-motion + dense + offset-decay: evenness OK but ratio 1.86 --
authority tug-of-war (double-authoring, exactly the D5 risk).
CONCLUSION: the coupled peer requires the MASTER side of the original
architecture too (gait-driven send-gate mirror / channel-B IntegrateMotion
projection) -- a coherent rebuild for a fresh session, not a peer-only
patch. Default therefore stays velocity dead-reckon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decomp workflow w0odszxro settled the speed model: the pod throttle was ANALOG-
CONTINUOUS (RIO Ranger, 800 ADC counts, 0.05 deadband; sole detent = snap-to-1.0
within 0.05 @004d196c). '5 speeds' is FALSE -- the 1-5 keys are MFD mode pages;
the 0..5 stepper is HUD/radar zoom. BONUS: throttleState@0x4a4 is a MISNOMER --
binary census proves it is the fall-contact surface material cache (0..7, init
2=Concrete), written only at knockdown; rename pending.
Empirical rule-outs (autonomous drive-sweep harness, this commit):
- Record density: with the clock guard + incremental heading in, a per-frame
continuous demand sweep measures IDENTICAL to constant throttle on position
evenness ([repljit]), render heading ([rendhdg]), and gait cadence ([gaitev]).
- Type-3 stomps: ~0 fire in sustained sweeps (only at launch) -- not the driver.
- The 0.05-grid keyboard publish experiment is retained env-gated OFF
(BT_GRID_LEVER) -- the ADC was ~continuous, so the grid is NOT authentic and
gains nothing measurable; default publish stays continuous (authentic).
New tooling: BT_DRIVE_SWEEP[0] (forced-drive triangle sweep, optional through-
zero), BT_FORCE_STEP (0.05-grid variant), BT_GAITEV (per-frame leg-clip advance
+ state-flip + demand-change stats), [t3rx] (type-3 stomp trace).
STATUS: user still reports visible speed-change glitches in interactive play;
all harness metrics saturate at baseline -- next step is probes ON the user's
interactive session (their eyes + instruments on the same run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The discriminating user report: peer motion was smooth under the autonomous
harness but skipped when driven by KEYBOARD -- even pure walking / pure spinning.
The harness pins throttle/turn constant; the keyboard SWEEPS the throttle lever
every frame of a key-hold (mech4 sLever integrator), so mapper->speedDemand
changes every frame, and the speed-deadband gate (authentic exact !=) fires a
type-2 speed record EVERY FRAME for the whole accel/decel. The base reader
Simulation::ReadUpdateRecord stamps lastUpdate=Now() on EVERY record (flagged
'HACK - should be based upon message->timeStamp' in the 1995 source) -- so each
type-2 SHRINKS the peer reckoner's projection span (nextUpdate-lastUpdate)
without refreshing updateOrigin: the position target jumps backward toward the
stale origin, the next pose record yanks it forward -> target oscillation every
frame during any input sweep. Matches the session-long 'worst on accel/decel'.
FIX (mech.cpp Mech::ReadUpdateRecord): non-pose records (2,3,5,6,7,8) preserve
lastUpdate around the base call (keeping the real payload, simulationState).
Pose (0) and resync (4) keep their authentic clock behavior. BT_T2_CLOCK
restores the old stamping for A/B.
REPRO HARNESS (mechmppr.cpp): BT_FORCE_SWEEP=<period> triangle-sweeps the forced
throttle 0.2..0.9 -- a type-2 record per frame, the keyboard-skip repro the
constant-throttle harness could never produce.
Verified A/B, autonomous circle+sweep (the keyboard regime):
old clock: worst frame spike 10.4x avg, path/net ratio 1.069 (backtracking)
guarded: worst 4.6x, ratio 1.0022 (no backtracking)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user-keyboard regime (steering WHILE walking) fired BOTH dense record streams
at once and exposed the last divergence: our peer heading used the engine
Mover::DeadReckon slerp-toward-projection, whose angular projection reads the
SHARED lastUpdate/nextUpdate timebase. The dense type-0 pose stream resets that
timebase every frame while walking while RESTORING a stale orientation (the
authentic case-0 strip, verified against FUN_004a1232 case 0) -- so the angular
target barely advances from a stale base and the slerp DRAGS the heading back
every frame. Measured: peer yaw advancing at ~40% rate with half the frames
stepping BACKWARD. Pure-spin and pure-walk tests never showed it (single
stream) -- why autonomous looked smooth while keyboard play skipped.
AUTHENTIC FIX (decomp FUN_004ab1c8 -> FUN_004ab188/FUN_00409f58): the original
replicant integrates its heading INCREMENTALLY from the CURRENT pose -- exact
rotation of (replicated yaw rate * dt) composed on each frame -- and re-anchors
on type-4 receipt. It never slerps toward a projected angular target.
- mech4.cpp peer branch: save heading, let DeadReckon own LINEAR only, then
integrate heading incrementally (ReconQuatIntegrate); on angSyncLatch (new
type-4) re-anchor to updateOrigin.
- mech.hpp/mech.cpp: angSyncLatch member (angular analog of poseSyncLatch),
armed by ReadUpdateRecord case 4.
- SCALAR peer-yaw mirror (angMirrorYaw/Rate/Time, re-based in the type-4
writer): replaces the quaternion projectedOrigin mirror for the ANGLE
deadband -- the old one was recomputed each frame by the master's own
reckoner from timing it does not control and false-fired in pi-waves
(measured maxAng~=pi bursts -> periodic resync floods).
- Dense-rot type-4 send REMOVED (was masking the old crude projection; not
authentic; churned the shared horizon). Orientation now rides the sparse
angle/velocity deadband resyncs exactly as the binary's.
Verified live-autonomous:
- pure spin: 59/59 perfectly regular peer yaw steps; master resyncs 0/s with
mirror drift ~5e-7 (records near-silent, authentic sparse model).
- walk+turn circle (the user regime): peer sim yaw monotonic at exactly the
master's rate (0.00556/frame @ 0.327 rad/s), no backward steps, no stalls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirmed via BT_TRNRATE + direct disasm of the un-exported perf drive (0x4aa3d3)
that turn-in-place rate == walkingTurnRate (speed=0 collapses the lerp) and the trn
clip cadence is authentically FIXED -- so the reconstruction is faithful and the
residual legs-vs-body slip is authentic (decoupled by design; scaling the clip would
deviate from the decomp). Probe left env-gated for future asset-tuning checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers 'how did the original handle this?' from the decomp (subagent hunt):
the 1995 binary's replicant reckoner (FUN_004ab1c8 -> FUN_00409f58, part_000.c:9359)
integrates heading EXACTLY: build a unit axis-angle rotation quaternion from
angularVelocity*dt ({axis*sin(t/2), cos(t/2)}) and Hamilton-multiply it onto the
heading (FUN_00409d9c) -- exact for any timestep, stays on the unit sphere. It
further carries the full orientation quaternion in the FREQUENT pose record
(FUN_0040a938, 7-float pose), so the dead-reckon gap stays tiny.
Our reconstruction diverged two ways, both fixed:
1. ReconQuatIntegrate (mechrecon.hpp) -- the reconstruction of FUN_00409f58 -- was
STUBBED as , a crude small-angle VECTOR add. Restored to
the real exact axis-angle composition. (A 'no stand-ins' violation: the comment
even wrongly claimed Quaternion::Add == FUN_00409f58.)
2. The engine Mover reckoner (MOVER.cpp AcceleratedDeadReckoner/LinearDeadReckoner)
also did the vector Add on projectedOrigin.angularPosition -> over a long peer
record gap it diverged to ~180deg then snapped (the reported spin HANG/hesitation).
Routed both through a new ExactAngularProject() helper (same exact math).
3. Orientation only rode the sparse type-4 resync; during a PURE spin the linear
dense-send never fires (not translating), so the gap ballooned (~1.6s) and the now-
exact projection sat far ahead -> the slerp jumped. Added an ANGULAR dense-send
(resync every frame while |yawRate|>0.1), mirroring the original's frequent-
orientation model -> gap stays tiny -> smooth.
Verified live-autonomous (BT_AUTODRIVE+BT_FORCE_TURN + BT_RENDHDG render-rate probe):
the ~180deg divergence + multi-radian snaps are GONE (rendered maxStep 0.05-0.10 rad,
no jumps). User confirms: no frame hang/hesitation. MOVER.cpp change is strictly
more correct (exact==crude for the small per-frame master case; only large-gap peer
extrapolation changes), so walking is unaffected.
KNOWN REMAINING (separate, smaller): the turn-STEP leg animation (trn clip, mech2.cpp
advance_normally) runs at a FIXED idleStrideScale cadence that does not scale with the
rotation rate, so the legs lag/skip vs the (now-correct) body rotation. Next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Autonomous headless spin harness (BT_AUTODRIVE+BT_FORCE_TURN) + probes that
finally measure what the eye sees, not the sim heading:
- BT_RENDHDG (L4VIDEO render loop): per-RENDERED-frame peer heading step
evenness -- avgStep/maxStep/max-avg ratio. Confirmed the residual spin
'hesitation' is UNEVEN rendered rotation (max/avg ~2.8x), correlated with the
byAngle resync-flood bursts (maxAng ~= pi), NOT a render-vs-sim rate stall
(render redraws a fresh heading every frame).
- BT_SPIN / BT_ANGSIGN: resync trigger breakdown (byAngle/byVel/byRest) + the
frame-level local/update/projected angular-Y that exposed the Abs() macro bug.
- gBTReplRenderYaw: peer heading published from the sim to the render probe.
- Normalize projectedOrigin.angularPosition after the peer-mirror advance
(adding a scaled ang-vel VECTOR to a quaternion denormalizes it) -- correct,
but NOT sufficient: the pi divergence is REAL, from the crude large-angle
quaternion projection over the sparse angular-record interval during a PURE
spin (linear dense-send doesn't fire when not translating; type-4 is the only
orientation carrier and resets the horizon, so dense type-4 -> half-rate).
Residual ROOT (scoped, not yet fixed): the reckoner (MOVER.cpp:457-466) projects
rotation by ADDING vector*t to the heading quaternion -- a small-angle approx the
original kept valid via dense records; our pure-spin case has 1.6s record gaps so
it diverges to ~180deg -> uneven render. Fix path = proper quaternion integration
in the reckoner, or refresh the angular origin without the type-4 horizon reset.
Both touch the shared engine dead-reckon -> aligning before the change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause found via autonomous headless spin (BT_AUTODRIVE+BT_FORCE_TURN) +
frame-level [angsign] probe: the STYLE.H:118 macro
#define Abs(value) ((value>0) ? value : -value)
has NO parens around value, so Abs(a-b) mis-expands to (a-b>0 ? a-b : -a-b) ==
-(a+b) on the false branch, NOT |a-b|. The angular resync velocity gate passed
an EXPRESSION: Abs(localVelocity.angular.y - updateVelocity.angular.y). For a
steady spin the two are equal, so a-b==0 takes the false branch and yields
-(2*rate): on a REVERSE spin that is +2*rate > velDb, firing a type-4 resync
EVERY frame. The resync flood reset the peer's dead-reckon horizon every frame,
pinning its angular slerp at ~half rate and (as the drift periodically ran to
180deg) freezing the rotation for seconds while the leg turn-clip kept stomping.
Confirmed live-autonomous: velDrift 0 (was +2.618), byVel 0 (was 55/55), peer
rendered-rotation median dyaw/expected 1.00 (was 0.49), no multi-second dt gaps.
Fix (mech4.cpp resync gate, reconstruction side -- the engine macro is original,
the original master-perf avoided it by diffing into a temp):
- velDrift: diff into a temp, explicit (d<0?-d:d) so the macro never sees an
expression.
- angDrift: already reworked to a single-variable wrap-safe yaw delta (also
dodges the macro) -- and it corrects a prior units/wrap bug (was comparing a
raw quaternion-Y component against a radian deadband).
- velocity gate now diffs vs updateVelocity (the value the peer extrapolates
with), same scalar representation as localVelocity.
- BT_SPIN / BT_ANGSIGN / updYaw diagnostic probes (env-gated).
KNOWN RESIDUAL (smaller, follow-up): the ANGLE gate still bursts when the crude
large-angle quaternion projection in the reckoner runs updateOrigin stale between
writes and drifts ~180deg; causes occasional single-frame hitches (peer median is
still 1.0), not the freeze. Separate from this fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decomp-verified via multi-agent investigation (workflow wv1km7lvc, adversarially
checked against reference/decomp/all/part_012.c + engine T0).
ROOT CAUSE (D3 CONFIRMED): the peer's leg channel FUN_004a5028 reads its speed
demand from mapper->speedDemand (**(mech+0x128)+0x128, part_012.c:11947/11975/
12028). The reconstruction FABRICATED that input from the noisy dead-reckoned
velocity magnitude and PINNED it flat with a standSpeed*1.05 floor across the
walk threshold -- the leg stutter/skip + cadence-vs-travel desync, worst on
accel/decel. The authentic input is the replicated commanded speed
bodyTargetSpeed@0x6b4 -- the master's own throttle-commanded speedDemand
(mechmppr.cpp:749), stamped into every record (mech.cpp:2036) and read back on
the peer (mech.cpp:1833/1841/1871). It is the IDENTICAL value the master's leg
channel consumes, so the peer clears the stand->walk gate exactly when the master
does and ramps cadence continuously through accel/decel.
Confirms the 07-13/07-14 regression: bb795e2 (smooth, 07-12) fed raw derived
speed with NO floor; the floor added in the task-#64/#50 spiral is what pinned
the cadence.
CHANGES (mech4.cpp replicant branch):
- Change 1 (D3): replMppr->speedDemand = bodyTargetSpeed (was derived velocity
+ standSpeed*1.05 floor). Old path kept behind BT_REPL_VEL for A/B.
- Turning: authentic peer turning is net-driven (angular slerp in DeadReckon,
MOVER.cpp:521-525, D8). Removed the flickery bare +-0.02 yawRate->turnDemand.
The visible turn-STEP (authentically from the body channel replaying the
type-3 turn state; we run the leg channel per D2) is reproduced by arming the
trn clip from the replicated yaw rate with HYSTERESIS (enter >0.08, hold until
<0.03) so the clip no longer chatters. BT_REPL_NOTURN = decomp-strict, no step.
REFUTED (kept as-is): D1/D5 -- peer POSITION authentically comes from the engine
velocity dead-reckoner (Mover::DeadReckon, T0), NOT the gait travel; the
body-channel switch DEVIATES (double-authors position + T4 leg-bone risk) and was
NOT taken. D4 -- bodyTargetSpeed is non-zero tracking throttle (0 only on coast,
which authentically winds down to stand).
Verified live: walking + accel/decel + in-place pivot all smooth (user-confirmed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session-in-progress peer-motion work, checkpointed before bisecting the
07-13/07-14 gait regression. Contains: TCP_NODELAY on game sockets (L4NET),
every-frame dense position send while moving + came-to-rest/REST send +
per-frame BT_JIT/BT_ANIM smoothness probes (mech4). bodyTargetSpeed feed
reverted to derived-velocity default. MOVER.cpp spline experiment already
reverted. NOT a fix -- a checkpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The linear pose send-gate watched only POSITION drift + the 2s heartbeat.
When a mech stops, its localOrigin is pinned and the master's own dead-reckon
projection re-bases to it each frame, so error->0 and NO pose record fires --
leaving the peer holding the last WALKING velocity (updateVelocity). The
replicant then dead-reckons + animates a phantom walk-in-place until the 2s
heartbeat (stretched to ~30s when the master window is OS-throttled to ~8fps
while backgrounded during solo two-window testing).
Add the symmetric linear 'came-to-rest' trigger -- the exact analog of the
angular (live yaw-rate==0 && replicated yaw-rate!=0) resync trigger already
present in the type-4 gate: fire a pose record the frame the live horizontal
speed collapses while the last-SENT speed was still non-zero, so the replicant
receives velocity=0 immediately instead of waiting for the heartbeat.
Diagnosis established via BT_WIRE tx0/rx0 probes: records deliver 1:1
(239 tx =~ 240 rx), velocities match -- NOT a replication stall; the master
simply never transmitted the stop. Also confirmed the dominant choppiness is
OS window-throttling (master 8fps backgrounded vs observer 63fps), a
two-windows-on-one-box test artifact absent on real pod hardware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior floor gated on walkStrideLength*0.5, but walkStrideLength is a
stride METRIC (~22.0), not the walk velocity (~6.1) -- so the condition
sd > 11 was never true for a walking peer and the floor never fired. The
replicant's derived speedDemand (== actual velocity 6.13) sits BELOW
standSpeed (6.83), so the leg SM's stand->walk gate (standSpeed < demand)
never tripped and the peer slid forward in the Standing pose.
Gate on standSpeed instead: floor the demand to standSpeed*1.05 when the
peer is clearly walking (demand in (standSpeed*0.5, standSpeed*1.05)).
Verified live: uvel=6.13 -> spd=7.169 floored, legState enters 5 (walk)
on frame 1 with no Standing-pose slide. standSpeed=6.8277 confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-caused from a live per-frame [replmov] trace (user's session, where replication
works -- the headless rig won't spawn a replicant): the replicant's leg SM sat in
Standing (legState 0, legFrm pinned at 19) while dead-reckoning forward at spd=6.13,
until the master's speed rose to 6.99, then it snapped to StandToWalk.
Cause: the replicant derives speedDemand from the master's ACTUAL velocity, but a
mech's forward walk velocity == walkStrideLength, which sits BELOW standSpeed (bhk1:
walk 6.13 < stand ~6.8). The leg SM's stand->walk gate is ,
so a steadily-walking peer's derived demand NEVER crosses it -> the peer slides in the
standing pose until the master happens to accelerate past standSpeed. The master never
hits this: it feeds the leg SM its COMMANDED throttle speed (>> standSpeed), not the
actual velocity.
Fix: when the peer is clearly moving forward, floor the derived demand just past
standSpeed so it enters the walk cycle immediately; the walk case clamps legCycleSpeed
back to walkStrideLength, so cadence still matches travel (no foot-slip). Reverse
needs no floor (its gate is commandedSpeed < ZeroSpeed, tripped by any negative).
Diag: BT_REPL_MOV (per-frame replicant pos/vel/speed/legState/legFrm + master twin).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
records; also the 2007 call-counter clock stub + two crash fixes
User-reported: peer mechs turn/move choppy ("missing frames"). Measured per-frame
(BT_REPL_HDG): 13% of frames the replicant's heading STALLED, 11% it JUMPED 2-6x.
THREE layered defects found:
1) THE CLOCK STUB (engine substrate): TIMESTUB.cpp's GetRTC/GetHiRes were 2007
`return time++` call-counters -- the "clock" advanced per CALL, not per ms --
and TIMESTUB won the /FORCE duplicate-symbol race over the REAL QPC clock in
L4TIME.cpp (LNK4006). Every Now()-domain consumer (dead-reckon above all) ran
on call-count pseudo-time. Removed TIMESTUB from the build; L4TIME covers every
symbol. [The real clock alone did NOT cure the chop -- but it was objectively
broken and un-gated the two latent bugs below.]
2) TWO CRASH FIXES the real clock exposed:
- legAnimationState@0x3b0 never ctor-initialized (the task-#56 0xCDCDCDCD
family): the type-3 writer re-dispatches SetBodyAnimation(legAnimationState)
on the WRITER; a record emitted before the leg SM's first tick passed raw
0xCDCDCDCD as a clip index -> AV (cdb-pinned, mech2.cpp:233). Init 0.
- Replicants were SERIALIZING update records: the tail WriteSimulationUpdate ran
for every instance, and the port's replicant leg-SM accommodation (task #50)
calls ForceUpdate -> replicants emitted derived/uninitialized state into the
stream. Master-gated; replicant marks discarded (master-authoritative).
3) THE CHOP ITSELF: the master's resync send-gate compares localOrigin vs
projectedOrigin -- the PEER-ESTIMATE mirror -- but the port master never
maintained projectedOrigin (the bring-up drive replaced Mover::Perform; the
engine only updates the projection inside replicant-only DeadReckon). Stale
mirror -> |local-projected| > deadband EVERY frame -> a type-4 re-base record
EVERY frame -> the replicant hard-copied the master's deadband-quantized
heading each frame (the engine lerp never engaged; nextUpdate always behind
till) -> stall/snap beat = the chop. FIX: advance the mirror each frame by the
last-SENT angular velocity (what the peer is extrapolating) and re-base it in
the type-4 writer. Records now flow only on TRUE drift (~1 per 5 frames in a
steady spin), the replicant extrapolates smoothly between them, and the lerp
horizon finally engages. Measured: STALLS 13%->1%, JUMPS 11%->1% (residue: tiny
sub-degree backward corrections on record arrival -- inherent dead-reckon
overshoot the lerp absorbs).
Diag probes: BT_REPL_HDG (per-frame replicant heading + dead-reckon internals),
BT_REPL_TRN (replicant turn/leg state). scratchpad/clockcrash_bp.txt (cdb).
Co-Authored-By: Claude Fable 5 <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>
Two more master rules in the trn entry broke replicants:
(1) the LOCKSTEP body weld: a replicant never runs the body SM ('joints only',
mech4.cpp:1989), so the first trn entry armed the body to state 4 where it stuck
forever -- bodyAnimationState==Standing then blocked EVERY later trn entry (the
peer 'rotates as a statue'). Replicants now skip the weld gate and do not arm
the inert body channel.
(2) today's authentic full [0,standSpeed] entry range: a replicant's DERIVED speed
sweeps that band on every dead-reckoned start/stop with derived turnDemand
pinned +-1, so trn kept arming mid-locomotion and speed-exiting (jerky walking).
Replicants keep the narrow near-zero entry gate (the pre-#64b accommodation).
Masters keep the authentic dispatcher (full range + lockstep weld) unchanged. [T3
replicant accommodation / T1 master logic]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Today's authentic trn dispatcher folded the master-perf turn-STOP exit into leg/body
case 4. But the master perf (FUN_004a9b5c) runs on MasterInstance mechs, NOT
replicants -- and a replicant derives turnDemand from the noisy REPLICATED yaw rate
(mech4.cpp:1968), which dips into the +-0.05 deadband between dead-reckon updates. So
the exit kicked the peer out of trn every few frames -> the peer 'rotated as a statue'
+ jerky (user-reported regression from yesterday). Gate both turn-stop exits to
GetInstance() != ReplicantInstance; the replicant advances the trn clip from its
replicated turn signal exactly as before the exit was added. Also BT_REPL_TRN probe
(mech4.cpp) logs the replicant yawRate/turnDemand/legState.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BT_SPAWN_AT teleport set only X/Z, keeping the old (dropzone) Y -- so teleporting to
a new spot embedded the mech in sloped terrain (user-reported). Lift the teleport
+500 above the old elevation so the authentic per-frame ground SNAP (BoxTree
FindBoundingBoxUnder, no gravity) settles it onto the surface next frame (the snap
only lowers, so it must start above the surface). Diagnostic only.
Also BT_COOL_LOG (heat.cpp): logs a heat subsystem's damageLevel/heatLoad/coolantDraw
when it carries damage -- for the 'do damaged mechs leak coolant' investigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The radar map derived its rotation from EulerAngles(Quaternion), whose decomposition
is AMBIGUOUS for a yawing mech: as yaw sweeps past +/-pi the quaternion double-cover
flips it onto the pitch=roll=pi branch, so euler.yaw REVERSES -- the whole radar
counter-rotates the wrong way past 180deg (user: 'flops every 180 degrees'). Switched
both radar sites (view-matrix build + blip delta rotation) to YawPitchRoll, which
applies yaw first and yields a clean continuous 360deg heading -- the SAME fix the
compass HeadingPointer already uses (btl4gaug.cpp:2185). A/B verified live (BT_RADAR_LOG,
spinning madcat): euler.yaw folds (-0.30 vs true -2.84 rad) while ypr.yaw tracks clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Logs each SubsystemCluster::Execute's drawn CurrentTemperature vs the degradation/
failure thresholds + drawState. Confirmed (BT_DEV_GAUGES + madcat + autofire): the
per-subsystem temperature bars build lazily on the engineering MFD screen, execute
live with animating values (weapons heat toward the 1000K degradation line = the jam
threshold) and correct 1000/2000 markers, drawState=0 online. No behavior change (env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reproduced the QA report end-to-end from the decomp + raw-binary-verified constants.
The coolant-priority effect is real but fire-rate-dependent: at the ~11s combat
cadence, not-boosted jams (T=1058>1000) while boosting the AFC's condenser keeps it
below the line (T=953, no jam) -- exactly as the QA described. Max-spam (8s) overheats
regardless (why the earlier 'weak boost' read was wrong); 16s never jams. All AFC100
constants verified authentic vs BTL4.RES raw bytes (afc_dump.py). No bug, no stand-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the coolant/jam investigation. BT_AF_PERIOD pulses the trigger every <sec>s so
a slower-than-max fire rate can be tested; afc_dump.py reads the AFC100 record raw from
BTL4.RES. Both confirm the QA report is FAITHFULLY reproduced (see KB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the coolant/jam probe: boost one condenser's valve to 50 and starve the rest
to 1 (share ~0.9 vs the ~1/N baseline) -- the authentic 'coolant priority' scenario,
for measuring the per-weapon boost effect. No behavior change (env-gated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-agent decomp hunt on the QA report ('AFCs jam on 3rd shot if coolant priority
not boosted'). Conclusion: the whole coolant->jam chain is reconstructed end-to-end
and FAITHFUL -- the weapon's constant coolantFlowScale=1.0 is authentic (only
condensers' 0x15C is written from the valve, via RecomputeCondenserValves over the
condenser chain mech+0x7cc). Authentic mechanism is second-order-in-equation but
first-order-in-outcome: weapon -> its condenser -> bank; boosting a condenser's valve
share (~5x conductance swing) keeps its weapon below degradationTemp -> no jam.
Distinguished the sticky probabilistic jam (degrad 1000, JammedState 5) from the
self-clearing overheat lockout (failure 2000, weaponAlarm 7). Empirically the port is
directionally correct (~4th-shot probabilistic) but not the QA's deterministic 3rd
shot -- a calibration/scenario question pending QA clarification, NOT a stubbed path.
Do not tune the heat economy to force it without ground-truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Probes for verifying the autocannon jam/coolant mechanic against QA ground-truth
(AFCs jam on ~3rd shot uncooled). BT_JAM_LOG logs currentTemp/degradT/failT/alarm/p
at each fire attempt; BT_VALVE=<n> forces every condenser valveState so cooling can
be tested at a known level. No behavior change (both gated on env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closed the loop: BT_CRIT_PROBE=8 on solo DEV.EGG drove the full per-zone FX chain
end-to-end -- [zonefx] entity 1:321 seg 12 psfx 12/13 -- proving TakeDamage ->
mechdmg effect loop -> BTStartZoneEffect -> StartEntityEffectImplementation (segment
world-pos resolve) -> BTStartPfxAttached fires live. The 2-node fight harness didn't
engage (mechPicks=0, an MP-harness spawn/connect issue, not FX); BT_CRIT_PROBE is the
reliable solo liveness path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverting a wrong 'correction' I just made: I claimed the six L4VIDRND explosion
renderables were STUBBED and 'never draw'. WRONG -- ScalingExplosionRenderable has a
real ctor/Execute body, and the impact/fire/detonation FX were reconstructed in the
Fire VISUALS (48c9c84) + Impact-FX FORENSICS (065c114) waves. The '//STUBBED: DPL RB'
markers I grepped are benign 2007 empty ctor/dtor notes on unrelated renderables
(InnerProjectile/DPLObjectWrapper/ChildLight), not the explosion FX. Only genuine
open item: the chain is not yet liveness-verified headlessly (no solo enemy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-zone effect dispatcher was reconstructed + wired the same day the audit
flagged it as 'RECOMMENDED NEXT TARGET' -- the entry was never updated. Correct it:
dispatcher done (btl4vid.cpp:889, wired at mechdmg.cpp:1120); the real remaining
FX gap is the six still-STUBBED L4VIDRND scaling-explosion renderable bodies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The master-perf disasm (0x4aa3d3-0x4aa4ff) computes the yaw rate as
lerp(walkingTurnRate@0x574, runningTurnRate@0x578) by ground speed, not a
constant. Wired it into the drive, replacing the bring-up constant kDriveTurnRate:
- walkingTurnRate/runningTurnRate are now REAL named members. The ctor's
`Wword(0x15d)/(0x15e) = model->...TurnRate * DegreesToRadians` writes were
NO-OPS -- Wword() returns a shared static scratch cell, so the rates were
silently discarded. Now stored + read by name.
- The drive (mech4.cpp) computes authTurnRate: base = walkingTurnRate; above
reverseSpeedMax(0x538) lerp walk->run across [walkStride 0x534 .. topSpeed
0x34c] with an over-run falloff runningTurnRate/t^2; clamp >=0; zeroed in the
death/limbo/airborne leg states (1/2/3) or when deathAnimationLatched(0x650) is
set. Falls back to kDriveTurnRate if the model gave no rates.
The lerp is a RUN-speed refinement -- below reverseSpeedMax (walk / turn-in-place)
the rate is the constant walkTR base; runningTurnRate < walkingTurnRate (a running
mech is less maneuverable). Verified headless (DEV mech, BT_TURN_LOG): walkTR
1.309 rad/s (75 deg/s), runTR 0.873 (50 deg/s), turn-in-place + walk yaw at walkTR,
no crash. [T1 logic / T2 runtime]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The turn-in-place dispatcher lives in the master-perf gap 0x4a9b5c-0x4ab188 that
Ghidra never lifted. Disassembled it with objdump (scratchpad/masterperf.asm) and
hand-decoded the real logic, replacing the two [T3] stand-ins I had invented:
- Entry gate was `commandedSpeed < 0.25*standSpeed` (a near-zero guess). AUTHENTIC
(0x4aa505): arm trn when TURNING (|angVel|>1e-4, port proxy = turnDemand deadband)
AND speed in the FULL [0, standSpeed] sub-walk range AND turnCapable. trn is gated
on TURNING, not on being slow.
- Case-4 exit had a ×4 fast-forward + SetLegAnimation(5) invention. Restored the
VERBATIM decompiled leg-SM exits (part_012.c:12013): standSpeed<spd or reverse ->
Standing, else advance the pivot. Added the master-perf turn-STOP exit (0x4aa5c6):
turn stopped -> Standing + legResetLatch=1 (folded into case 4 since the port has
no separate master-perf frame).
- Body case-4 twin mirrors the authentic exits (mj=0, tracks state for lockstep).
The fast-forward was invented to dodge a turn->walk stutter that was ACTUALLY the
body channel leaking joints (mj=1) -- already root-caused + fixed by mj=0. The
trn->walk seam (legFrm7 cycle-end -> swr legFrm1) is the authentic pose-matched
boundary and the same seam the fast-forward version hit, just at the correct release
speed. Also decoded (documented, not yet wired) the authentic turn-RATE lerp
(walkingTurnRate@0x574 -> runningTurnRate@0x578 by speed; angVel = turnDemand*rate);
the bring-up drive still yaws at the constant kDriveTurnRate.
Headless-verified (BT_AUTODRIVE/BT_FORCE_TURN/BT_WALK_DELAY/BT_GAIT_TRACE): turn-in-
place enters state 4, ramp past standSpeed -> 4->stand->swr->walk, both channels in
lockstep. Feel re-verification pending. [T1 logic / T2 runtime]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User repro (100%): turn in place, push forward before the turn stops -> the gait
skips/stutters + visibly reduced bob; standstill starts always clean. Three-layer
fix, user-verified:
1) THE DISPLAY BUG (the actual visible artifact): v5's "body advances first with
mj=1, leg overwrites last, so body drift can't show" was FALSE. Whenever the two
gait channels phase-split, the BODY channel's out-of-phase joint writes leaked
into the rendered skeleton (rhythmic leg skips, averaged-down bob) while every
leg-channel trace read clean -- the leg DATA was fine, the RENDERED pose wasn't
the leg's. v6: AdvanceBodyAnimation(dt, mj=0) -- the body still advances +
projects for replication (records/cycle speeds unchanged) but no longer touches
the skeleton. One writer, structurally; matches the binary's own observable
("body phase drift is locally INVISIBLE in the binary"). BT_BODY_MJ=1 = old A/B.
2) THE SPLIT SEED: the bring-up trn trigger armed only the LEG channel, so a
turn-in-place entry guaranteed the channels re-entered walk frames apart and the
walk cycles ran permanently out of phase. Lockstep: the Standing trn entry arms
BOTH channels the same frame (body case-4 twin added, same rate/keying), gated on
both Standing. The authentic dispatcher (un-decompiled master-perf gap 0x4a9b5c-
0x4ab188, the sole reader of turnDemand/turnCapable) armed both -- the body's
case-4 machinery is dead code otherwise [T1].
3) THE POSE-MATCH INVARIANT: the engine has NO pose blending; transitions avoid
pops purely by authored pose-matched boundaries. The old trn exits cut the pivot
clip MID-STEP (legFrm 7->1 teleport). Now: entry gated on near-zero speed
(turn-IN-place); on forward command the pivot FAST-FORWARDS to completion (x4)
and the authentic finish callback lands Standing at the stand pose -> the normal
pose-matched stand->walk runs. Decompiled reverse abort kept; the standSpeed
mid-clip abort subsumed (it WAS the pose cut). [T3: 0.25*standSpeed threshold +
4x rate stand in for the gap's constants.]
Harness: BT_FORCE_TURN now reaches gBTDrive.turn (was silently inert for the
gait), BT_WALK_DELAY=<s> holds forced throttle then ramps (the turn-first repro),
BT_GAIT_TRACE=1 per-frame gait trace. Regressions: standstill start, turn-entry,
pure pivot loops, run cycle -- all clean, bob full amplitude (1.33/1.32).
KB: locomotion.md v6 section + trn reconstruction + symptom-family closure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigation (workflow) found the searchlight-driven fog swap never worked in the
ORIGINAL 1995 binary either -- it's a latent bug, not a port regression:
SearchlightSimulation (@004b841c) reads requestedOn@0x1E0 (never written) while
ToggleLamp (@004b860c) toggles commandedOn@0x1DC; no bridge exists, so lightState
@0x1D8 is perpetually 0 (lamp never lights). The self-consistent sibling
ThermalSight reads the field it toggles (0x1DC) -- the tell. The port reproduces
the bug faithfully (searchlight.cpp:189). Additionally the port never constructs
PullFogRenderable, so even absent the bug the swap wouldn't fire.
Decision: LEAVE AS-IS + document (faithful to the buggy original); the port keeps
the static lights-ON fog= values. A working swap would DEVIATE from the shipped
binary (repair the sim to read commandedOn + construct PullFogRenderable at the
btl4vid MakeMechRenderables inside pass + a toggle input) -- fully scoped in the KB
if ever wanted. No code change this commit.
Documented in rendering.md (fog section), open-questions.md (deferred subsystems),
subsystems.md (Searchlight). Core per-map/time/weather fog (task #63) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verifying the searchlight/nosearchlightfog swap (user request): it does NOT work.
The arcade swaps active fog between fog= (headlight on) and nosearchlightfog=
(off, darker/tighter) via PullFogRenderable::Execute (edge-triggered on the mech's
light attrs -> SetFogStyle searchLightOn/Off). But PullFogRenderable is NEVER
CONSTRUCTED in the port (grep: only the .h decl + .cpp def; no `new`), and
searchLightOn/Off is called from nowhere else. Empirical confirmation via the new
BT_FOG_LOG hook: over a 22s run SetFogStyle fired only style 0, zero style 2/3.
So fog is static at the searchlight-ON (fog=) values -- normal lights-on play, so
core per-map/time/weather fog is unaffected -- but the nosearchlightfog variant is
dead. Corrects my earlier "the swap is wired end-to-end" claim (code paths exist,
trigger never instantiated). KB rendering.md updated; wiring it back is a scoped
reconstruction (bind the Searchlight subsys light state to a constructed
PullFogRenderable at viewpoint setup). No behavior change this commit -- diag only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: "aren't some levels supposed to have fog?" -- they were, and none showed.
BTDPL.INI (the DPL env INI, L4DPLCFG) authors fog=near far r g b (+ often
nosearchlightfog=) on EVERY map/time/WEATHER leaf; weather is a fixed egg field
(clear/fog/soup). Shipped eggs pin cavern/night/clear -> page dsnitclear
(near 90, far 1100, dark blue). The env pipeline resolved + pushed it to D3D, but
it rendered INVISIBLE: D3DRS_FOGTABLEMODE=D3DFOG_LINEAR (table fog) derives its
factor from the perspective-NONLINEAR z-buffer, so without WFOG the 90..1100 range
collapsed to fog-factor ~1 (no fog everywhere).
Fix (L4VIDEO.cpp world pass): auto-detect D3DPRASTERCAPS_WFOG -> per-pixel W-fog
(smooth, == the arcade dpl_fog_type_pixel_lin); else VERTEX fog (eye-space but
per-vertex -> splotchy on coarse terrain tris, so only a fallback). FOGENABLE + the
fog mode are re-asserted each world-pass frame (the old one-time set got clobbered
per-pass, which is why plain table fog showed nothing). Verified on this GPU:
WFOG present -> per-pixel table fog, user-confirmed smooth.
Also corrected task #20's false "shipped maps define no fog" comment (it checked
.MAP/.RES, not BTDPL.INI). Env hooks: BT_FOGMODE=table|vertex|off, BT_WEATHER=
clear|fog|soup, BT_FOG="near far r g b" (now honored on the authored branch too);
[fog] resolved/model diagnostics. KB: rendering.md fog section added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: struck a mech ~4x, still standing -- but the [dmghit] trace showed the
DIRECT victim taking 16 applications for 8 impacts while the intended splash
bystander took 0. Root cause: BTApplySplashDamage delivered splash through the
shooter's SubsystemMessageManager::AddDamageMessage, which CONSOLIDATES every
damage message of a frame onto the FIRST hit entity (commonDamageInformation.
entityHit, messmgr.cpp:279). The direct hit registered the primary as the common
entity, so the bystander's splash was consolidated onto the primary too -- the
excluded direct victim took 8 direct + 8 mis-routed splash = 2x, and the real
bystander took nothing.
Fix: deliver splash with a DIRECT e->Dispatch to each victim, matching the T0
source (EXPLODE.cpp:246 target_entity->Dispatch); Dispatch reroutes cross-pod for
a replicant on its own. Verified (BT_DMG_LOG): 8 impacts -> 8 dmghit on the
direct victim + 8 on the bystander (was 16/0); no death; per-hit 5.83.
KB: combat-damage.md warns not to route splash through the consolidating msgmgr.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User live regression: missiles killed a mech in ~2 shots ("takes way more than 2
normally"). Root cause -- DamageZone::TakeDamage (arcade @0041e4e0 == WinTesla
DAMAGE.cpp:379) is `damageLevel += amount*scale` and IGNORES burstCount. So the
arcade's ONE cluster Missile per trigger lands its damageAmount EXACTLY ONCE; the
missileCount/burstCount split is cosmetic for zone damage (only gyro-bounce +
splash-falloff read burstCount). The port re-expresses that one cluster as N
flying rounds and was damaging on EVERY round = ~missileCount x too lethal on the
direct hit (mirrors the splash over-application fixed in 145a69f).
Fix (mislanch.cpp FireWeapon): only the salvo-LEAD round (i==0) carries damage
(damageData.damageAmount) AND the cluster splash; the other N-1 rounds are VISUAL
(damage 0) -- the tracer ripple, no extra damage. AC unaffected (one round/shot,
not a cluster).
Verified (BT_DMG_LOG [dmghit] trace): per-hit missile damage = clean 5.83, burst
1, once. Missiles-ONLY (BT_AF_MISSILE now independent of BT_AUTOFIRE), single
enemy, 45s: 8 damaging hits, top zone reaches only 0.18, NO death (was death in
~3 pulls) -- "way more than 2" restored. Earlier test deaths were BT_AUTOFIRE
firing laser+PPC+missiles combined, not missiles alone.
KB: combat-damage.md documents burstCount-ignored + the N-round cluster trap
(direct + splash both once-per-salvo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live regression: a clustered bystander died in ~2 missile salvos ("suddenly
lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per
trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst =
missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying
BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1,
each floored at 1 -- so the distance floor was applied N times = ~missileCount x
too much splash.
Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher::
FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round;
the contact + world-impact splash hooks fire only when splashBurst>0, using it as
baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the
single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so
the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher;
splash_burst defaults 0).
Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6
splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to
direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not
splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander
within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash].
KB: combat-damage.md documents the N-round cluster trap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Area-of-effect blast on missile detonation, from the T0 source EXPLODE.cpp:50-254
(@0042fad0) + the arcade decomp. Fires on ANY missile impact (world or mech) --
the +0x360 gate sits in the Missile::Perform collision branch (part_013.c:10097).
Only missiles splash; the AC's tracer is not a Missile.
Radius source CORRECTED: it is the ROUND's own GameModel (type-0xf) +0x50, seeded
from the launcher's linked AmmoBin ammoModelFile @0x1e8 (part_013.c:8778) -- NOT
the launcher's ExplosionModelFile (which resolved to 0). Live-resolved radius = 30
for both MP missile launchers; 0 for AC/Emitters.
Burst falloff = baseBurst / dist^exp floored at 1 (arcade 1.25 = decomp 0x3ff40000;
WinTesla EXPLODE.cpp:209 drifted to 1.2f). damageType/amount pass through unchanged;
only burstCount, radial damageForce, and impactPoint are set. Excludes shooter +
direct victim; dist>radius gated; delivered via msgmgr (cross-pod) or Dispatch.
New: BTResolveSplashRadius / BTApplySplashDamage (mech4.cpp), hooked at both the
world-impact and contact detonation paths; BTAmmoRoundModelResource bridge
(ammobin). Env: BT_SPLASH_LOG, BT_SPLASH_TEST (synthetic near-miss), BT_AF_MISSILE
(missile autofire). Verified: near-miss dist=15 -> 1 burst to a bystander; live
missiles detonate + exclude the direct victim; AC does not splash; no crash.
Deferred (T3): per-player enable sub-gate missile+0x360 = BTPlayer+0x264 (writers
read as a per-frame toggle, not a config flag) -- port treats SplashRadius>0 as the
enable. KB: combat-damage.md + open-questions.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The autocannon gains its authentic muzzle effect AND becomes visible when an
enemy fires it -- the ballistic-FX gap behind "I only see the AC from my own
view."
MUZZLE FLASH (the genuine shipped effect, not the cut card):
- MUZFLASH.BGF is an orphaned/cut asset (nothing references it) -- so it is NOT
rendered. The shipped projectile-gun muzzle effect is DAFC.PFX, which
BTDPL.INI documents as "the effect used on all projectile guns" (psfx 6
external / 14 internal): an orange fire-smoke blast (btfx:firesmoke1),
maxIssue 25 over ~0.2s so the emitter auto-expires = one burst per shot.
- BTFlashMuzzle (mech4.cpp) spawns it on the gun-port SEGMENT via the existing
BTStartPfxAttached path; the segment frame sprays -Z out the barrel. Hooked
at ProjectileWeapon::FireWeapon (the fire edge). Default ON (BT_MUZZLE=0
disables). AC only -- lasers show their beam, missiles their launch.
ENEMY AC FIRE NOW REPLICATES (the real find):
- ROOT CAUSE: the subsystem-record replication channel EXISTS and works (mech
ticks subsystem->PerformAndWatch(update_stream); Entity::UpdateMessageHandler
routes incoming records to GetSimulation(subsystemID-1)->ReadUpdateRecord).
The emitter (beam) and MissileLauncher (salvo mirror) both call ForceUpdate()
so their fire serializes -> enemy lasers + missiles ARE visible on the peer.
The AUTOCANNON set only `simulationFlags |= 0x1` (the +0x28 instance flag,
NOT the updateModel bit WriteSimulationUpdate walks) and had NO fire record,
so its shot never crossed the wire -- the enemy's cannon was invisible.
- FIX (the AC twin of the missile salvo mirror): ProjectileWeapon::
WriteUpdateRecord/ReadUpdateRecord (fire counter + aim) + ForceUpdate() in
FireWeapon. The replicant edge-detects the counter and mirrors ONE visual
round + DAFC muzzle flash from its own resolved muzzle; a null/untargeted aim
streaks straight out the barrel (launchVelocity) instead of toward origin.
- Verified live 2-node: the watching node logs REPLICANT AC shots + DAFC
flashes at the enemy's gun-port (seg 7), aimed shots fly to the real target;
no crashes.
TRACER: all ACs author TracerInterval=1 (every round is a tracer); the existing
amber streak is acceptable-authentic, so no tracer change was needed.
KB: open-questions.md -- CORRECTED the (wrong) earlier note that claimed no
subsystem-record channel exists; it does, the AC just wasn't using it. Logged
the methodology lesson: the coverage audit finds UNWRITTEN functions, not
"reconstructed but inert" ones (a function present but never called -- the AC
record + the missile mirror both looked done); a LIVENESS audit would catch
that class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full 5-path audit of the damage economy vs the decomp (5-finder +
adversarial-verify workflow), resolving the KB self-contradiction the
binary-coverage audit flagged.
CONFIRMED AUTHENTIC as-is (no change needed):
- Energy beam (emitter.cpp): damagePortion = authored DamageAmount x
(charge/seekV)^2; the ctor x1e7 and fire x1e-7 cancel (_DAT_004bafbc
dumped from the exe = x87 80-bit 1e-7 exactly).
- Autocannon (projweap.cpp): full authored DamageAmount from resource
+0x19C delivered unmodified; the 0.0625 at :667 is the shooter's own
gyro recoil, not the round.
- Zone-armor BASE model: damageLevel += amount x damageScale[type]
(engine DAMAGE.cpp:379, called mechdmg.cpp:427), legs x0.5, 1.0=dead.
3 STAND-INS FIXED (all byte-verified against the decomp):
- A. mechdmg.cpp:451 -- read the phantom `stance` member (no binary
offset, zero writers -> perma-0), so the leg-shot-out -> fall/death
branch was DEAD. Now MovementMode() (mech+0x40, @part_012.c:6910).
- B. mechdmg.cpp:458 -- guarded the leg partial-failure graphic on the
always-0 IsAirborne() stub where the binary calls IsDisabled()
(@0049fb54 = movementMode 2||9). On a wreck the binary SUPPRESSES the
write; the stub let it corrupt graphicAlarm 9->4/3 -- the task-#52
wreck-graphic bug, now fixed AT SOURCE (was only masked by the
IsMechDestroyed latch).
- C. mech4.cpp:1551 -- flat kShotDamage=12 fed as the kill-score
damageAmount (the KB self-contradiction: task #8 claimed it retired,
but it was live). The score handler @0x4c02e4 derives the whole kill
award from it, so every kill scored identically regardless of weapon.
Now lastInflictingDamage -- the real killing-blow magnitude, latched
in TakeDamageMessageHandler (mech.cpp:624), mirroring the per-hit path
(mech4.cpp:1207). The phantom `int stance` slot is reused for the new
Scalar member (size-neutral, no layout shift); init 0 in the ctor.
DEFERRED (task #60-D, documented): the missile CLUSTER model -- the port
fires N flying rounds (net armor total authentic) vs the binary's ONE
missile with a random burstCount cluster roll (loses cluster variance +
single-zone concentration). Blocked on an OPEN decomp semantic (does
burstCount multiply armor or only the gyro kick? settle at FUN_004bef78
-> FUN_004be078 -> EXPLODE.cpp:209-210).
VERIFIED live: clean build; 2-node fight -> clean center-mass kill (no
crash, kills 0->1); [zone-armor] dump confirms per-zone armor 50-140 +
legs x0.5. NB the displayed POINTS score still reads 0 -- a SEPARATE
open gap (scoreAward + role/team/tonnage multipliers unwired); fix C
corrected the damage INPUT to that formula.
KB swept: open-questions.md (self-contradiction resolved + task #60
summary + deferred missile item), combat-damage.md (damageScale is
type-indexed not even/odd; task-#52 source fix; kill-score section),
RECONCILE.md (missile = ONE spawn not N), stale comments in mechweap.cpp
(SendDamageMessage is LIVE), mislanch.hpp, mechdmg.cpp (FUN_0049fb54 =
IsDisabled).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MadCat (and every leg-mech) leans -8deg into a walk / -11deg into a run --
an authored jointhip (hingex) pose in the EXTERIOR gait clips that the walk/run
CYCLE clips never rebind, so it HOLDS. The 1995 game keeps a separate INTERIOR
('i'-suffix) clip set for the mech you PILOT: those shake jointshakey (cockpit
rattle) and OMIT jointhip, so your own view stays level while everyone else sees
you lean. The port had this entirely dead -- the local cockpit mech pitched
-8deg into the ground (user-reported "staring at the ground").
ROOT CAUSE (two stacked bugs):
- The authentic ctor clip-set gate (@part_012.c:10308-10320) was reconstructed
as no-op stubs: LoadLowDetailBody/LoadHighDetailBody MISLABELED the
FUN_004a80d4/86c8 GAIT-CLIP loader addresses as a "body LOD" pair, so the gate
did nothing and a separate unconditional LoadLocomotionClips always loaded
EXTERIOR clips.
- LoadLocomotionClipsExt (the interior loader) was a stub aliased to the
exterior loader.
FIX:
- Reconstructed the real 4-char INTERIOR loader (byte-exact vs @004a86c8; the
'i'-suffix table dumped from the exe @0x10d74d: swri/wwri/wwli/...; all 18
base clips confirmed present in BTL4.RES for mad/blh/ava).
- Fixed the ctor gate: getenv("L4VIEWEXT") || (instanceFlags&0xC)==4 -> exterior
(replicant / forced external view); else -> interior (local cockpit master).
Removed the mislabeling no-op stubs.
- PORT ADAPTATION (MaintainViewClipSet, per-frame in PerformAndWatch): the port
never sets the replicant COPY bit (all MP mechs build as local masters --
heat-sim/scoring/torso-watcher-connect all run on both, by design), so the
ctor gate lands everyone on interior. Pick the set by VIEWPOINT instead: the
mech you pilot keeps interior (level cockpit), every other mech flips once to
exterior (the lean). Reloads only on a viewpoint-status change; the sets
share stride data so the swap is seamless. Model pointers stashed at ctor.
VERIFIED live (BT_HIP_LOG probe): your walking mech writes jointhip=0 (level);
the peer's replica in the other pod's view leans at exactly -8.0/-11.1deg --
the authored clip values. A/B control: L4VIEWEXT=1 forces the walking master
back to 74 sustained -8/-11 writes.
KB: locomotion.md ("NO walk lean" audit conclusion CORRECTED -- the lean is
authored + sustained + clip-set-specific), asset-formats.md (.ANI parses
pitch/yaw/roll for all joint types; KeyJointPos translation; cycle clips carry
no pitch), open-questions.md (item closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MadCat torso twists, the view turns with it, and targeting follows.
Three reconstruction fronts closed:
THE ELECTRICAL WATCHDOG CHAIN (why the torso never powered up):
- PowerWatcher::UpdateWatch reconstructed (@004b181c, the REAL registered
Performance -- PTR @0050f5fc; Ghidra missed the fn start): the watchdog
MIRRORS the watched subsystem's electrical level (+0x278), brownout
downgrade when gen output <= minVoltage% x rated. @004b1804 relabeled
ResetToInitialState (slot 10) -- the old "Simulation" tag was wrong.
- The factory watcher-CONNECT pass reconstructed (vtable slot +0x38,
@004aee2c/@004b1a40 byte-identical, recovered from raw exe bytes):
watchedLink.Add(roster[watchedSubsystem]) on the master node. Was the
SubProxy::Start() no-op -- every watchdog sat at 0 forever.
- MinVoltageScale = 0.01 (a 10-byte x87 literal @0x4b1924; was 1.0f =
permanent brownout) and PowerWatcher's Derivation chains its REAL base
HeatWatcher (the HeatableSubsystem stand-in broke IsDerivedFrom for the
whole Torso/Searchlight/ThermalSight family).
- KB correction swept: derivation tag 0x50e604 = HEATWATCHER (not
"HeatSink"); the btl4gaug heat-widget gate now tests it via the
BTIsHeatWatcher bridge.
THE CROSSHAIR (task #58 forensics, 6-agent workflow + live probes):
- The VIEW is TORSO-MOUNTED: jointtorso -> jointeye -> siteeyepoint in
every twist-capable .SKL; the camera + canopy ride the same hinge
subtree through HingeRenderable's live matrix-stack compose -- ALREADY
WORKING in the port. The crosshair stays screen-centered (center IS
the boresight); the twist reads on the tape carets/compass/radar.
- The real bug was the port's gBTAimX = tan(twist) slew (the falsified
"body-mounted view" model): the camera already carried the twist, so
the crosshair counter-slid to hull-forward and the fire ray with it.
Deleted; the pick ray inherits the twist from the yawing eye basis.
- Two instrumentation traps documented (chase-eye-as-default-camera,
BT_FORCE_TORSO clobbering real joints -> the hook now only fills
unresolved ones); an over-correcting explicit eye compose was added on
those false readings and retired the same day.
CONTROLS + REPLICATION:
- Q/E spring-center on release (the axis is a twist-RATE demand; the old
hold-deflection model drifted forever); X also zeroes the axis and
pulses the authentic torso Recenter (@004b6918). M cycles control
mode via the real CycleControlMode body.
- Torso update-record DIRECTION fixed: engine truth is Write=serialize /
Read=apply; @004b6a78 is the READ (was mislabeled Write) and the
missing WRITE @004b6a1c recovered from raw disasm (recordLength 0x1C,
twist/vel/rate at +0x10/14/18) -- kills the replicant's 0xCDCDCDCD
-140-degree ghost twist.
- Marching-ghost desync: 4 Standing-case guards zero stale reverse
cycleSpeed (negative cadence passed the <= ZeroSpeed stop gate).
- Kill credit rerouted to the OBSERVED killer (lastInflictingID ->
killer's player link) -- kills count, target K/D populates.
KB: subsystems.md (watcher chain), multiplayer.md (record direction),
combat-damage.md + gauges-hud.md + cockpit-view.md (torso-mounted view
re-correction), decomp-reference.md (new addresses + tag fix),
open-questions.md (dead capability-roster loops 2-4, snapshot CD read).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:27:49 -05:00
702 changed files with 7845 additions and 620 deletions
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.