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>