Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42 KiB
Audio Fidelity Audit — btl4.exe OpenAL Port vs. Original AWE32/HMI-SOS
Date: 2026-07-15
Scope: Full fidelity audit of the OpenAL audio reconstruction against the original 1995
semantics: the authored audio object streams (content/audit_capture.log traces), the WinTesla
engine source (engine/MUNGA, engine/MUNGA_L4), the Ghidra decomp of BTL4OPT.EXE
(reference/decomp/all/part_*.c), and the SF2 v1.0 soundbanks (content/AUDIO/AUDIO1.RES,
AUDIO2.RES).
Method: Parallel findings independently generated, then adversarially verified (every count
re-derived from the banks with fresh parsers, every file:line re-read, decomp cross-checked, live
capture re-parsed). Verdicts: CONFIRMED = independently reproduced; PARTIAL = core claim holds
with material corrections (the corrected form is what appears below); UNVERIFIED-LOW = low-impact,
not independently re-verified.
Excluded (already known-fixed): note-pitch application (BTNotePitchFactor), calibration rate
1000, ExecuteWatchers poll restoration, configure-ticker -1 pad, published-velocity smoothing.
1. Executive Summary
Honestly: the control architecture is in good shape, the rendering back-end is not. The watcher/sequence/mixer graph is substantially reconstructed and live — all 118 authored state watchers bind real StateIndicators, weapons/footfalls/generators demonstrably fire, and the patch-numbering and calibration questions are settled in the port's favor. But roughly a quarter of attribute-gated watchers sit on dead attributes (the coolant-leak warning, the accelerating missile-lock beeper, the torso-twist servo, the instability alarm can never sound), and on the synthesis side nearly every axis diverges from what the AWE32/EMU8000 actually did: the SF2 extractor keeps one zone per preset (discarding key-splits, layers, and stereo pairs the engine already has plumbing for), drops all tuning metadata (~83% of presets play >= 1 semitone off, worst -36 st), the volume law is linear where the original was the squared GM CC7 curve, the authored distance/doppler/reverb/filter/pan models are all computed and then discarded in favor of wrong or absent OpenAL defaults.
Rough fidelity per dimension:
| Dimension | Est. fidelity | Basis |
|---|---|---|
| Triggers / databinding | ~75% | 54 of ~225 attribute-gated watchers dead (24%); all 118 state watchers live; footstep timing semantics wrong; one port invention actively fights an authored feed |
| Volume / attenuation | ~40% | Per-source gain curve wrong everywhere (linear vs squared), distance curve wrong shape for all 3D sounds, per-zone layer gains dropped, cull/steal/ducking side-effects |
| Looping | ~85% | Authored loop regions ~= whole sample for everything looped today (worst artifact ~5% period error / wrap click); missing 1.1-3.9 s release fades on ~20 looped presets; one latent catastrophic loop once multi-zone lands |
| Pitch / timbre | ~25% | ~83% of presets >= 1 semitone off (root-key/tuning dropped); zero filtering (brightness, distance muffling); doppler wrong sign and depth |
2. Ranked Findings (audible impact x confidence)
| # | Finding | Impact | Verdict | One-line effect |
|---|---|---|---|---|
| 1 | Multi-zone preset collapse | High | CONFIRMED | Key-splits/layers/stereo dropped: wrong klaxons, looping charge-hum instead of clunk-then-blip, explosions lose body and rumble |
| 2 | Root-key & tuning dropped | High | CONFIRMED | 91/115 + ~102/126 presets play >= 1 semitone off (worst -36 st; klaxons ~6x slow, footsteps 27% fast) |
| 3 | Distance attenuation wrong | High | CONFIRMED | Authored knee/rolloff curve dead; AL linear ramp makes mid/far battle audio far too quiet; cull/steal/ducking distance-blind |
| 4 | Volume curve linear vs CC7 squared | High | CONFIRMED | Every intermediate volume ~+6 dB too loud at mid-scale; authored dynamic range compressed |
| 5 | FootStep trigger semantics | High | CONFIRMED | Binary: per-frame root-height contact level vs threshold; port: fixed 150 ms pulse per clip transition — wrong step timing/counts |
| 6 | ReportLeak dead — coolant warning | High | CONFIRMED | Largest dead block (38/54 match watchers, 19 subsystems); leak klaxon can never sound |
| 7 | Missile-lock alarm dead | High | CONFIRMED | IncomingLock + DistanceToMissile on read-only pad; accelerating lock beeper can never start |
| 8 | Dead-watcher coverage matrix | High | PARTIAL | 54 of ~225 attribute-gated watchers on dead attributes (~24%); itemized worklist |
| 9 | Brightness / low-pass chain dead | Medium | CONFIRMED | AWE NRPN-21 filter path computed then discarded; no distance muffling, ctl-5 brightness inert |
| 10 | Doppler triply wrong | Medium | CONFIRMED | Authored cents model dead; AL doppler at wrong constants with sign-inverted velocity — approaching sources pitch DOWN |
| 11 | Reverb dead | Medium | CONFIRMED | Original: CC91=38 wet exterior / 0 dry cockpit (decomp-proven); port: bone-dry everywhere |
| 12 | Front/rear + pan placement dead | Medium | CONFIRMED | DirectPatch position enum and internal spatialization discarded; all cockpit/static sounds dead-center |
| 13 | Loop regions & release envelopes | Medium | CONFIRMED/PARTIAL | Today: minor wrap artifacts (worst ~2.4 Hz tick in beam hum) + instant cuts where 1.1-3.9 s releases were authored; latent boom-loop bug once multi-zone lands |
| 14 | Zone gain/filter/envelope generators dropped | Medium | PARTIAL | Per-zone attenuation (inverted SBK scale), authored resonant low-pass on charge hum, volume envelopes all lost |
| 15 | ConfigureActivePress mispublished | Medium | CONFIRMED | Weapon-configure ticker can never fire; field exists but is misnamed and unpublished |
| 16 | Torso-twist servo whir dead | Medium | CONFIRMED | Torso publishes no attributes; whir never starts, pitch tracking dead |
| 17 | Entity attrPad family | Medium | CONFIRMED | Instability alarm never sounds; impact sounds never scale with speed; ReduceButton feedback dead |
| 18 | FireCountdownStarted = cook-off warning | Medium | PARTIAL | Dead binding confirmed; it is the ammo COOK-OFF countdown (member exists: cookOffArmed), not a salvo cue |
| 19 | Step-intensity invention fights authored feed | Medium | PARTIAL | Unauthored curve + direct mixer writes clobbered by the live authored LocalVelocity feed every step (drops footsteps) |
| 20 | Zoom blip unpublished | Low | UNVERIFIED-LOW | Live member exists, one ATTRIBUTE_ENTRY missing |
| 21 | Attack velocity / attack time dead | Low | UNVERIFIED-LOW | Mechanism dead but authored data barely uses it today |
| 22 | ITD / between-ears spatialization dead | Low | UNVERIFIED-LOW | 4-channel pod model computed and discarded; matters mostly for pod-hardware target |
| 23 | Benign inventions check | Low | UNVERIFIED-LOW | Footstep pulse OK; AnimationState numbering unverified (17 authored triggers never fired); CollisionState 0/1 vs 4-state |
No-gap confirmations (validated, no change needed): patch numbers are SoundFont wPreset
values (the port's mapping is correct — never "fix" toward file index); the calibration rate 1000
is structurally equivalent to the original clock-tick rate (but the DPLARG+0x10 read is baseless
and the KB claim should be corrected); the port's published-velocity smoothing matches the
binary's AverageOf filter chain in kind. See Section 4.
3. Findings in Detail
A. Sample bank / synthesis fidelity
F1. Multi-zone preset collapse — key-splits, layers, stereo pairs all dropped (High, CONFIRMED)
The SF2 extractor (tools/sf2extract.py:49-62) keeps only the FIRST sample-bearing instrument
zone per preset, so the generated table (game/reconstructed/audiopresets.cpp) has
sampleNum=1/samples[0] everywhere — discarding authored multi-zone content that the engine
already supports (PRESETINFO.samples[5], GetVoiceCount=PRESET_getNumSamples, SetupPatch
buffer-per-source; engine/MUNGA_L4/L4AUDLVL.h:33-34,68-70, L4AUDLVL.cpp:30,130-167).
Verified against the banks: 68/115 bank1 and 94/126 bank2 presets are multi-zone.
- Key-splits (7+7 presets) — the authored MIDI note SELECTS the sample.
Warnings01(bank2 p113) is an 8-way split (keyRanges 16-17/19-21/23-24/26-28/29-31/33-35/36-38/40-41, 8 distinct klaxon samples, three looped); the weapon Loaded/Jam/Misfire family (bank1 p36,40,41,56,70,71,75) splits at key 59|60: low clunk/charge zone (root 36) vs high ready-blip zone (root 84). The captured streams author exactly these selector notes: five sequences send [36,36,84,84] (audit_capture.log:501,566,681-683, plus one [36,36,70,70]); the warnings sequence (log:343) sends 16,16,19,19,23... matchingWarnings01's split points exactly. - Layers (~55+84 presets) — 2-4 simultaneous samples (LaserAFire01=3, MissileLaunch01=4, AutoCanonFire01=3, MechExplosion01=16 zones in 4 key bands). Roughly two dozen bank1 / three-dozen-plus bank2 presets mix looped sustain layers under one-shot attacks — every LaserExplosion/BigExplosion/PPCExplosion has a looped tail-rumble layer the port drops.
- Stereo pairs (6+3) — pan 0/127 zones, some with different L/R samples (EngineAccel02 sid2/sid38), plus 5 bank1 L/C/R 3-zone presets.
Net effect verified live: on weapon-ready the port loops the charge hum at the wrong pitch
instead of clunk-then-blip (log:832-840: note-36 START then SetupPatch ENTRY bank=1 patch=56 file=LaserLoaded01.wav, table loop=LoopAtWill); all 8 cockpit warning klaxons play the same
sample; explosions/lasers lose their layered body and sustained rumble.
Fix: regenerate the table with ALL zones — per-zone WAV, keyLo/keyHi, loop mode, pan — into
samples[0..n] (cap 4 per the AWE comment for layers sharing a keyRange). In SetupPatch/PlayNote,
attach only zones whose keyRange contains GetCurrentNoteValue(), attach all matching layers, set
position/pan for 0/127 pairs. Table + SetupPatch work only; voice-count plumbing already exists.
F2. Root-key and tuning metadata dropped — most of the bank plays at the wrong pitch (High, CONFIRMED)
The port plays every sample as if its root were MIDI 60 at 22050 Hz (tools/sf2extract.py:24
hardcodes SAMPLE_RATE=22050; the generator scan at lines 50-62 reads only opers 41/53/54;
L4AUDIO.cpp:21-24 BTNotePitchFactor=2^((note-60)/12)). But the SF v1.0 banks carry full SBK
pitch metadata the extractor drops:
- Generator 55 (SBK-only samplePitch, per awesfx
sfitem.c/parsesf.c: root=amount/100 plus fractional cents) — present on all 603 sample zones, constant per sample. - Generator 58 (overridingRootKey) on 183+247 zones; coarseTune(51) on 138+258 zones (modal value -12); fineTune(52) on 30+9.
- EMU8000 v1 base rate is 44100 Hz (awesfx
sffile.c: v1 samplerate hardcoded 44100), so a zone's true data rate is 44100*2^(tune/1200). Recovered rates land exactly on 22050/44100/11025/5512 Hz plus integer-semitone offsets — corroborating the model. There is no rate generator: 22050 is only the modal effective rate, not a bank-wide truth.
Per-zone port error is constant across notes: err_cents = (effRoot-72)*100 - tune; zero only for the common root-60/coarse -12 configurations (why the note-pitch fix sounded right on those). Prevalence: 91/115 bank1 and 102/126 bank2 presets >= 1 semitone off. Worst verified cases: Death01/MechExplosion01 -36 st; Warnings01 -31 st (klaxons ~5.9x too slow at authored note 16); ProjectileLoaded01 -30 st; TorsoTwistExt01/CoolantDumpExt01/LaserCSustain01-03 worst-layer +24 st; LaserACharge01-04 +17 st; MissileLoaded01 -24 st; FootFallInt01 +4.2 st (footsteps ~27% fast). Authored sequence notes equal zone roots exactly (36/84 splits; Warnings roots 16-40) — final cross-check.
Fix (algebraically exact, no engine change): bake per-zone tuning into each extracted WAV's declared rate = round(44100*2^(((60-effRoot)*100+tune)/1200)); OpenAL resamples from the declared rate, so the existing note-60 pitch model reproduces original playback at every note. Note the extreme recovered rates (~162 Hz to 176.4 kHz on a few Death01/torso zones) and the dependency on per-zone WAVs (F1) for key-split/layered presets.
F13. Loop regions and release envelopes (Medium, CONFIRMED with an important correction)
Two verified sub-findings, one of which corrects the naive reading:
- Whole-buffer looping is close to faithful today. SetupPatch sets AL_LOOPING on the entire
buffer (
L4AUDLVL.cpp:158-164) while authored loops are [dwStartLoop,dwEndLoop] sub-regions thatsf2extract.pyreads but discards (line 64 vs 70). However, every looping sample in the banks is sampleModes=1 (loop continuously; none are mode-3) with loop regions covering 95-100% of the sample — the whole sample effectively IS the loop. No attack transient replays per cycle. Worst real artifacts among currently-looped zone0s: LaserBSustain family at 95.0% (348-sample lead-in + 106 tail per ~0.41 s => ~2.4 Hz discontinuity in the beam hum), LaserCSustainExt01-04 (283+41/11657), EngineMotor02/EngineAccel02/06 (324+271/35747), MechFireLoop01/SmallFireLoop01 (406+94/47586) — plus a wrap-point click (port wraps end->start instead of loopEnd->loopStart). - The genuine gap is Stop.
StopNote(L4AUDLVL.cpp:216-217) is alSourceStopv+alSourceRewindv — an instant cut — but ~20 looping presets carry an authored releaseVolEnv of 1.1-3.9 s (EnginePower/Motor/Accel/Damage ~1.55 s, EngineAccel01 3.9 s, TorsoTwist 1.2-1.4 s, LaserCharge 1.3 s, MissileTravel/Loading ~1.3 s, Coolant/Wind/Fire loops ~1.1 s) that the AWE32 applied as a fade on note-off while the loop continued. One-shots have no authored release; instant stop is faithful there. - Latent hazard: the only catastrophic (<60%) loop regions sit in currently-dropped layers:
MechExplosion01/AuxExplosion01 share sample sid39 with loop [23517,23865] of 23875 (1.5% —
a ~16 ms sustain slice at the end of the boom). Today these presets are one-shot zone0
(ForceStatic,
audiopresets.cpp:805-808,949-952); once multi-zone extraction (F1) lands, whole-buffer looping would replay the full ~1.08 s boom every cycle. Loop-region support must ship with or before F1.
Fix: extract loopStart/loopEnd per zone into the table; use AL_SOFT_loop_points (a buffer
property: alBufferiv(AL_LOOP_POINTS_SOFT,...) before attach) or split attack+loop buffers via
queueing. On Stop, ramp AL_GAIN over the per-preset authored release time before stopping.
F14. Per-zone gain / filter / envelope generators dropped (Medium, PARTIAL)
tools/sf2extract.py (reads only gens 41/53/54) and the playback path drop everything the EMU8000
applied in hardware:
- initialAttenuation on 198 bank1 / 309 bank2 zones. SBK units are INVERTED vs SF2 — 127 = full volume, lower = quieter (observed range 90-127); used for layer balance (LaserBFire01's panned side layers at 100 vs center at 120 — note: the sides are the quieter layers).
- initialFilterFc on 116 zones/bank, initialFilterQ on 56/23. Example: LaserLoaded01's
looped charge-hum zone — the very zone the extractor picks — is fc=57 Q=87, an authored resonant
low-pass the port plays unfiltered and therefore much brighter/harsher. No filter is ever
applied (
L4AUDIO.cpp:1067-1086computes a cutoff, sets nothing). - Volume envelopes (attackVolEnv 167/211 zones, releaseVolEnv 34/6, decayVolEnv 41/127) — see
F13 for the release consequence. SAMPLEINFO (
L4AUDLVL.h:22-29) has no fields for any of this.
Fix: add per-zone gain scale (mind the inverted scale: gain ~ f(127-atten); verify the exact dB mapping against awesfx before trusting "0..-9.6 dB" [T3]), bake or EFX-apply the static low-pass, and map release to a Stop-path gain ramp.
F21. Note velocity / attack time dead (Low, UNVERIFIED-LOW)
The original started every note with MIDI velocity = attackVolumeScale*127 and (when flagged) sent
AWE NRPN 11 attack time (decomp @004638a8, part_008.c:7144-7154; @00463ddc :7367-7392). The port
computes midi_velocity/midi_attack_time and discards both (L4AUDIO.cpp:980-991; PlayNote
takes no velocity); ctl 6/7 handlers store scales nothing reads. Mitigation: all 41 captured
AttackVolume events are value 1 (= default) and zero ctl=7 events, so today's audible loss is
small. Fix when convenient: multiply start-time AL_GAIN by attackVolumeScale (through the F4
squared curve) and implement attack time as a gain ramp (range 0..5.94 s per
L4AUDHDW.h:63,67).
B. Mixing and spatial model
F3. Distance attenuation: authored curve dead, wrong AL model live (High, CONFIRMED)
AUDIO.INI authors amplitude_rolloff=2.0 (exponent), knee=60, distance_scale=0.003,
clipping_radius=450 (AUDIOMR: 0.002): flat volume inside 60 units, then 1/(1+(0.003*(d-60))^2) —
still ~42% at the clip edge. The engine computes this into distanceVolumeScale on every source
execute (AUDLOC.cpp:358-377 via AUDSRC.cpp:822), but its only consumer —
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale() in
Dynamic3DPatchSource::CalculateSourceVolumeScale — is commented out behind an early return
(L4AUDIO.cpp:1470-1496, comment at :1493-1495). Instead: hardcoded
alDistanceModel(AL_LINEAR_DISTANCE) (AUDIO.cpp:98) with never-set
AL_REFERENCE_DISTANCE/AL_ROLLOFF_FACTOR (defaults 1.0) and AL_MAX_DISTANCE=clipRadius*clipScale
(L4AUDIO.cpp:1109,1461,1997) — a straight-line fade to zero.
Numbers (clippingScale=1): d=60: AL 0.87 vs authored 1.0; d=100: 0.78 vs 0.99; d=300: 0.33 vs
0.66 (-6 dB); d=440: 0.02 vs 0.44. Distant battle sounds are drastically too quiet.
Confirmed side-effects of the distance-free engine volume: (a) the LowAudioVolumeThreshold=0.3
transient-start cull (AUDREND.cpp:197,223-237) is distance-blind; (b) AudioWeighting voice-steal
(AUDSRC.h:526-536, L4AUDRND.cpp:1175) no longer favors near sources; (c) the CalculateMix
ducking chain (L4AUDRND.cpp:684-811, compression 0.92/8.5) treats far sources as full-presence.
Fix (near one-liner, highest leverage in the audit): alDistanceModel(AL_NONE), restore the
commented distance multiply (the spatial model is already updated each Execute), apply
distanceVolumeScale in Static3D's gain write too, drop the AL_MAX_DISTANCE attenuation writes.
Restores curve + cull + steal + ducking simultaneously.
F4. Volume curve: linear AL_GAIN vs the CC7 squared law (High, CONFIRMED)
The original's volume path ended in MIDI CC7 = ftol(volume_scale127+0.5) on the AWE32
(decomp @00463bbc part_008.c:7205-7218; @004641e0 :7538-7583 — the 127.0/0.5 constants
byte-verified in the EXE; the engine's own retained MIDI source at L4AUDIO.cpp:2146-2239
confirms). The GM/SoundFont-standard CC7 curve is concave: amplitude ~ (v/127)^2, i.e.
40log10(v/127) dB (standard-based inference for the binary Creative driver — flagged, not
decompilable). The port writes the identical volume_scale LINEARLY to AL_GAIN at three sites:
L4AUDIO.cpp:1110 (DirectPatch), :1459 (Dynamic3D), :1995 (Static3D).
Result: every intermediate volume plays ~+6 dB louder than the arcade at mid-scale (0.5: -6 dB vs -12 dB; 0.3: -10.5 dB vs -21 dB) — the authored dynamic range is compressed and quiet ambience/servo layers sit far too high against weapons/engine.
Fix: square volume_scale (or apply the exact concave transform, optionally quantized to 127 CC steps) at all three AL_GAIN sites. Do NOT square the master listener gain (a common factor; the port's 0.6 is calibration, not authored).
F9. Brightness / low-pass filter chain dead — no distance muffling, ctl-5 inert (Medium, CONFIRMED)
The original continuously drove the AWE32 initial-filter-cutoff NRPN 21 (100-8000 Hz per
L4AUDHDW.h:65-68, scaled by the patch's authored MaxMIDIFilterCutoff) from ExecuteModel —
decomp-verified per source type: DirectPatch = brightnessScale (gated on useBrightness;
@00463bbc, part_008.c:7191-7203); Dynamic3D = highFreqCutoffScale x brightnessScale, ungated,
all four quadrant channels (part_008.c:7496,7589-7604) — every moving 3D sound got duller with
distance; Static3D = brightnessScale only (part_008.c:7831-7884).
The port computes the values and applies nothing: L4AUDIO.cpp:1067-1086 only updates the
lastMIDIFilterCutoff bookkeeping member; Dynamic3D's ExecuteModel has no filter path; Static3D's
NRPN block is inside the comment spanning :2020-2245; no alFilter/EFX call exists anywhere
(only stock SDK headers). The authored distance HF rolloff (AUDIO.INI:23-25: exponent 2.0, knee
60, scale 0.0035; AUDIOMR 0.003) is still computed per frame into highFreqCutoffScale
(AUDLOC.cpp:386-405) — GetHighFreqCutoffScale() has zero callers. Brightness ctl 5 (2 authored
bindings, log:199,285), use_brightness_scale, per-patch maxMIDIFilterCutoff, and the reconstructed
footstep Brightness-mixer feed (mech2.cpp:318) all drive a dead sink. Everything plays
spectrally full-bright at all distances.
Fix: one EFX AL_FILTER_LOWPASS per source; AL_DIRECT_FILTER with GAINHF from highFreqCutoffScale x brightnessScale (Dynamic3D), brightnessScale alone (Static3D/DirectPatch, respecting useBrightness), mapped through the AWE curve (~100 + cutoff*7900/127 Hz).
F10. Doppler: authored model dead, AL doppler wrong constants AND wrong sign (Medium, CONFIRMED)
Triply wrong, decomp-verified intent:
- The authored model (AUDIO.INI:30-31 doppler_range=600 cents, speed_of_sound=250 u/s) is
computed every spatial update into
dopplerCents= 600*(1-250/(250-v)) with near-ear fade and +/-c clamp (AUDLOC.cpp:451-508) — and the decomp proves the original consumed it: the dynamic patch source ExecuteModel adds audioLocation+0x58 (dopplerCents) to CalculateSourcePitchOffset before the MIDI pitch-bend clamp (part_008.c:7466). In the port,GetDopplerCents()has zero callers. - Instead AL doppler is enabled with hardcoded
alDopplerFactor(0.3f)(AUDIO.cpp:99) andalSpeedOfSoundnever called (default 343.3) — neither constant nor curve shape matches. - The velocity feeds are sign-inverted relative to the position frame: AL_POSITION = source
relative to head, but AL_VELOCITY = negated source world velocity with head velocity never
subtracted (
L4AUDIO.cpp:1429-1433,1458,1460); listener velocity is also negated (AUDIO.cpp:155-158) but cancels under AL_SOURCE_RELATIVE.
Net: an approaching missile/mech pitches DOWN (~-88 cents at 60 u/s, ~-145 at 100 u/s) where the original gave +116 cents at 60 u/s approaching, and the player's own motion produces no doppler at all.
Fix (faithful, per the decomp): disable AL doppler and add GetDopplerCents() into the Dynamic3D pitch-cents chain (2^((cents_chain + dopplerCents)/1200) x note factor). The original applied doppler only on the dynamic multi-channel path — keep static/direct sources doppler-free.
F11. Reverb: authored wet-exterior / dry-cockpit split dead (Medium, CONFIRMED)
Decomp-proven original behavior: the 3D patch-source StartImplementation (FUN_00463ddc,
part_008.c:7278-7394) reads global_reverb_scale=0.3 (AUDIO.INI:47, stored at head+0x4C) and sends
MIDI CC91 = 38 on all four AWE32 channels; DirectPatchSource (FUN_004638a8, part_008.c:7104-7140)
sends CC91 = 0 — deliberate wet-exterior vs dry-cockpit contrast. In the port all send sites are
commented out (L4AUDIO.cpp:949, 1253-1263, 1759-1771), GetGlobalReverbScale has no live
callers, and no EFX effect/aux slot is ever created. Everything plays bone-dry; the arena-space
cue separating outside sounds from cockpit UI is gone. (Note: the code was already commented in
the repo's initial commit — an inherited gap from the WinTesla conversion, still a fidelity gap.)
Fix: one EFX EAXReverb aux slot at init (gain from global_reverb_scale, AWE32-like preset); AL_AUXILIARY_SEND_FILTER on Dynamic3D/Static3D sources only; Direct sources dry.
F12. Front/rear + pan placement: all cockpit and static sounds dead-center (Medium, CONFIRMED)
The original routed every DirectPatchSource by its authored 6-value position enum
(Front/Rear/FL/FR/RL/RR, L4AUDIO.h:355-362): positions 0/2/3 to the FRONT sound card, 1/4/5 to
the REAR (decomp @00463848, part_008.c:7051-7062; the pod required two cards, AWE_FRONT/AWE_REAR),
with MIDI pan CC10 = 0x3F center / 0x00 left / 0x7F right (@004638a8, part_008.c:7119-7140). The
port reads audioPosition from the stream (L4AUDIO.cpp:795) but every use is commented out
(:854-868, :902-916, :952-966); SetupPatch pins all sources AL_SOURCE_RELATIVE at (0,0,0)
(L4AUDLVL.cpp:150-156) and DirectPatch ExecuteModel never writes AL_POSITION. Similarly,
Static3DPatchSource with useInternalSpatialization (the BuildFromPage default) computes its
quadrant spatialization at start (:1613-1641) but consumes it only in commented code; the live
position member is written solely by the collision trigger (L4AUDWTR.cpp:149) — all other
internal statics also play centered. (Caveat: which authored content is off-center is inferred
from the enum + original routing code; the mechanism centers ALL of it regardless.)
Fix: since sources are already AL_SOURCE_RELATIVE, set AL_POSITION per enum in DirectPatchSource::StartImplementation (front=-Z, rear=+Z, left=-X, right=+X; MUNGA azimuth 0 maps to +Z=rear per the CalculateSpatialization +180 shift). For internal-spatialization statics, derive AL_POSITION from the already-computed azimuth. Keep distance ~1 so the distance model does not attenuate them.
F22. ITD / between-ears model dead (Low, UNVERIFIED-LOW)
itd_difference=0.0015 and distance_between_ears=2.0 feed the L4AudioSpatialization 4-channel model (per-quadrant delays, transient ITD pitch offsets, the between-ears front/rear power crossfade) — all computed, all discarded (Get*ITDPitchOffset: zero callers; the quadrant-gain consumers are the commented MIDI sends). Lost: interaural time cues and the smooth blend when a source passes through the head (it now snaps across the stereo field). Faithful minimum: blend AL_POSITION toward the origin inside distance_between_ears/2; OpenAL Soft HRTF supersedes the ITD constant for desktop; full quadrant gains only matter when driving the pod's physical 4-speaker pairs.
C. Triggers and databinding
F8. Coverage matrix: ~24% of attribute-gated watchers are dead (High, PARTIAL — corrected counts)
From the 35 s capture (118 statecfg + 54 matchcfg + 23 trigcfg + 29 scalecfg = 224 attribute-gated configs; attrbind=227, attrnull=53): 53 (~24%) sit on dead attributes, plus a 54th outside the denominator (ControlsMapper.TargetRangeExponent, class-81 delta trigger, log:330-331). Breakdown: 45/54 match watchers (38 ReportLeak on 19 subsystems — F6; 4 FireCountdownStarted — F18; 1 Torso.MotionState — F16; 2 Entity.IncomingLock — F7, whose match=0 Stop fires once spuriously), 5/29 scale watchers (2 CollisionSpeed, 1 DistanceToMissile, 1 UnstablePercentage, 1 Torso.SpeedOfTorsoHorizontal), 3/23 trig watchers (UnstablePercentage, ReduceButton, SpeedOfTorsoHorizontal). Of the 9 ConfigureActivePress watchers, 7 are on the -1 pad and 2 (Avionics, Myomers) bind real members reading the authored idle -1.
Two dead mechanisms: the engine inert pad for unpublished names (WATCHER.cpp:118-137) and
Mech::attrPad, a shared read-only-0 Scalar aliased by five Entity attrs (ptr 0E514DD4;
mech.cpp:708,730,736,738,740). All 118 state watchers bind real StateIndicators. Liveness
verified: LocalVelocity (120 trigger polls), FootStep (14 matchfires), GeneratorOn x4,
Myomers.SpeedEffect, HeatSink.CurrentTemperature, PercentDone x5, WeaponState-driven sequences
started 21/4/2 times. Entity.AnimationState's 17 authored trigger states produced zero fires
(consistent with autodrive lacking falls/jumps — unverified, see F23).
Fix path: publish missing attributes in owning subsystems' ATTRIBUTE_ENTRY tables (the proven
AmmoState/GeneratorState pattern) and replace the five attrPad aliases with driven members. Keep
scratchpad/audio_coverage.py as the regression check; include TargetRangeExponent in the
worklist.
F6. ReportLeak: the coolant-leak warning can never sound (High, CONFIRMED)
All 19 subsystems that authored a ReportLeak Logical match-watcher pair (Condenser1-6,
GeneratorA-D, Avionics, Myomers, PPC_1/2, ERMLaser_1-3, SRM6_1/2) bind ReportLeak to NULL -> the
constant-zero pad. The 38 pad-bound match watchers (1->Start, 0->Stop of the looped leak-warning
sequence 0E4ACE50 — 3-note pattern 16/19/23, tempo 160; log:343) can never fire Start; the 19
match=0 halves each fire one spurious Stop at prime. No other authored path targets the sequence;
its seqstart count is 0 for the whole session. Largest single block of dead authored audio
(38 of 54 match watchers). The underlying condition already exists in the port (heat.cpp:418
publishes CoolantMassLeakRate/coolantDraw for the LeakGauge).
Fix: register ReportLeak on the subsystem attribute tables per the decomp's original binding (read the binary's subsystem attribute table for the member it pointed at — likely the leak-rate/leaking flag — rather than inventing a threshold).
F7. Missile-lock alarm impossible (High, CONFIRMED)
Entity.IncomingLock (matchcfg 1->Start / 0->Stop of looped beeper 0E4AC548, note 58; log:299,
302-306) and Entity.DistanceToMissile (scalecfg ctlID=12 = Tempo, mapping distance 100..800 to
tempo 600..10 — the beep accelerates as the missile closes; log:300-301) both bind to
Mech::attrPad (read-only 0). The original backed these with real distinct members — binary
attribute table verified: IncomingLock @mech+0x3FC, DistanceToMissile @mech+0x400, initialized
in the mech reset (part_012.c:9446-9447) — a genuine regression, not dead content.
Fix: real backing members driven by the missile/threat system: IncomingLock set while an inbound lock/launch on the player exists; DistanceToMissile = range to nearest inbound missile (far default when none). The authored watchers then reproduce the accelerating alarm unchanged.
F5. FootStep is a contact LEVEL, not a clip-transition pulse (High, CONFIRMED)
Direct disassembly of BTL4OPT.EXE (both functions fall in decomp-export gaps): inside the mech's
per-frame performance, footStep@mech+0x394 = (rootJointTranslation.y <= *footStepThreshold) ? 1:0,
where the threshold pointer is SequenceController+0x20 = &animation_data[2] — the ANI header
word[2] that JMOVER.cpp:1415 parses and nothing else reads — and the root joint is the gamedata
"jointlocal" joint (mech+0x5C8). Evaluation is skipped (value retained) while the channel's anim
state is 0/1. Present in both performance variants: master perf entry 0x4a9b5c (code
@0x4a9e80-0x4a9eb6, leg channel) and alternate entry 0x4ab9d8 (@0x4aba86-0x4abab9, body channel).
The port instead pulses footStep=1 for a fixed 150 ms on every locomotion clip TRANSITION
(mech2.cpp:265-270, decay mech4.cpp:5219-5230) and its SequenceController never captures
animation_data[2] (seqctl.cpp:153 skips hdr[2]). Step sounds fire at clip boundaries with fixed
width instead of authored root-height contact crossings — wrong counts for clips whose root Y
crosses the threshold twice or never.
Fix: in the port's authoritative per-frame path, while leg-channel gait state not in {0,1}, set footStep from rootY vs *footStepThreshold (captured at SelectSequence from &hdr[2]); remove the 150 ms pulse/decay; replicate the state-0/1 value retention; re-export the two gap functions (0x4a9b5c, 0x4ab9d8) into the decomp.
F16. Torso-twist servo whir entirely dead (Medium, CONFIRMED)
Torso.SpeedOfTorsoHorizontal (scalecfg pitch -200..+200 cents over 0.5..0.9 + trigcfg start/stop
at 0.25) and Torso.MotionState (matchcfg ==2 -> Start@1.5) bind NULL (log:289-297) because the
reconstructed Torso publishes no attributes (torso.cpp:143-144 default-constructs an empty
AttributeIndex). TorsoTwistInt01/Ext01/Stop01 are unreachable; none played. Correction:
hud.hpp:103's "id 7 -> @0x1E4" is the HUD class's own table (a HUD read-out), NOT a Torso member —
on the binary Torso, +0x1E4 is currentElevation. Do not publish the yaw rate there.
Fix: register SpeedOfTorsoHorizontal on Torso as |yaw rate| (binary backing member plausibly twistVelocity@0x1E8 or effTwistRate@0x244 — confirm the Torso attribute table in the decomp first) and MotionState as the torso motion enum (authored trigger value 2; meaning inferred [T4]).
F17. Entity attrPad family: instability alarm, impact scaling, ReduceButton (Medium, CONFIRMED)
Three authored Entity bindings alias the shared read-only attrPad while the binary bound each to a distinct live member (binary attr table @0x50bea0: UnstablePercentage->+0x3F0, CollisionSpeed->+0x4B4, ReduceButton->+0x340):
- UnstablePercentage — volume scale (0..1) and start trigger (thresh 0.01) both stuck; the instability/gyro-stagger alarm never sounds. The driver is the mech+0x3F0 sway/overspeed accumulator the port already flags as unreconstructed for the gyro — one model fixes both.
- CollisionSpeed — authored AttackVolume [0.9,1] and Brightness [0.7,1] over impact speed [0,25] pinned at minimum; impacts play (live CollisionState) but never scale with how hard you hit. (Original +0x4B4 writer not located in the export; intent rests on the authored scales + distinct member.)
- ReduceButton — click-feedback trigger can never fire (whether the original cab drove it is unverified; the dead binding is fact).
Fix: real members: instability fraction from the 0x3F0 model; collisionSpeed = |impact
velocity| stored when collisionTemporaryState goes 0->1 (mech4.cpp:5332-5382 already has it);
ReduceButton wired to the mapper like the Look* attrs.
F15. ConfigureActivePress: weapon-configure ticker can never fire (Medium, CONFIRMED)
Binary ground truth: ConfigureActivePress is a MechSubsystem-BASE attribute (id 2, descriptor
@0x50de5c -> subsystem+0x110, registered by the MechSubsystem class stub @0x4acf10). Its only
runtime driver is MechWeapon's ConfigureMappables handler @004b9550: 0 while the configure
button is held, -1 on release — a held-session flag, not a button index. The port misnames the
correctly-driven weapon field vitalSubsystemIndex ("no confirmed reader" — the reader is the
authored audio trigger watchers at thresh=-1), omits the attribute from the chain so every weapon
binds the constant -1 pad (log:529-531), and appends undriven duplicates on Myomers/Sensor at
invented offsets 0x358/0x328 (layout departure; behaviorally harmless).
Fix: publish ConfigureActivePress at the MechSubsystem level bound to the existing +0x110 field (rename vitalSubsystemIndex -> configureActivePress); drop or ignore the Myomers/Sensor duplicates.
F18. FireCountdownStarted is the ammo COOK-OFF warning — and it's dead (Medium, PARTIAL, corrected)
AmmoBinSRM6_1/2.FireCountdownStarted binds NULL -> pad (log:674-679, 721-726); its 4 authored
start/stop match watchers can never fire. Corrected identification: the binary's AmmoBin
attribute table (@0x512600) maps FireCountdownStarted -> AmmoBin+0x18C — the member the port
already reconstructs as cookOffArmed (set when the heat alarm hits FAILURE, FUN_004bd394;
armed via HandleMessage(1), FUN_004bdb94; cleared on CookOff/empty). It is the ammo-explosion
countdown warning, NOT an SRM salvo launch cue — do not touch projweap.cpp.
Fix: one Logical ATTRIBUTE_ENTRY on AmmoBin pointing at the existing cookOffArmed member.
F19. Step-intensity invention fights a live authored feed (Medium, PARTIAL, corrected)
The mech2.cpp foot-plant broadcast (mech2.cpp:272-338) is unauthored in its curve (speed/25 with
a 0.55 floor) and delivery (patch-sniffing the footfall mixers directly, zeroing the sibling
input). The premise that the original feed was lost game code is wrong: the authored stream
wires LocalAcceleration -> ctl100 ([0,10]->[0,1]) and LocalVelocity -> ctl101 ([0,0.6]->[0,0.4])
scale watchers into the authored splitter, and the ctl101 watcher fires on every captured
footstep (log:1591, 2147, 2459, ...), zeroing the invention's just-written mixer input right
after the FootStep matchfire — in log:1574-1598 this drops the footstep start entirely
("[spatial] DROP transient start src=0E6BC948 vol=0").
Fix: remove the game-code broadcast; make the port's published LocalVelocity/LocalAcceleration attributes carry the motion components the authored AudioMotionScale watchers extract (one currently reads 0 while linear speed is 30.7 in the same poll — the port publishes the wrong/flat component) and let the wholly-authored scale->splitter->mixer chain drive footfall volume and brightness.
F20. Zoom blip: TargetRangeExponent unpublished (Low, UNVERIFIED-LOW)
ControlsMapper.TargetRangeExponent binds NULL (log:330-331) so the authored AudioScalarDeltaTrigger
(blip per zoom step) is dead — yet the live member exists and slews every frame
(btl4mppr.cpp:313-314, 391-407). Pure bookkeeping: add the ATTRIBUTE_ENTRY (requires giving
BTL4ControlsMapper its own AttributePointers[] chained to MechControlsMapper's index).
F23. Benign-invention check (Low, UNVERIFIED-LOW)
(1) FootStep 150 ms pulse fired the authored watcher fine in-session (superseded by F5 anyway). (2) AnimationState numbering is the one worth closing: none of the 17 authored trigger states {1,2,5,8-11,14-17,20-23,26,27} fired in 35 s of walking/turning; if the port's runtime clip numbering differs from the binary's AnimationState numbering, all 17 sounds are silently mis-keyed — diff the binary's state writer vs the port's clip ids, then force a fall/jump and confirm. (3) CollisionState is driven 0/1 while authored as 4-state (NoCollision/InitialHit/Slide/Rest) — nothing lost in THIS stream (listens on 1) but Slide/Rest scrape audio elsewhere would be dead. (4) Master listener gain 0.6 (env-tunable) is a uniform port knob; no relative distortion.
4. Validations and KB corrections (no fidelity gap)
- Patch numbering = wPreset (settled). The original SetupPatch @00465ba8 sends the authored
patch_ID verbatim as a MIDI Program Change (+ CC0 bank, + CC121 reset); the SBK resolves program
N by wPreset.
tools/sf2extract.pyalready keys on phdr wPreset. Live traces confirm (patch 83 -> ProgramButton01, patch 56 -> LaserLoaded01). Any future tooling must key on wPreset, never phdr record order — bank1 has 38 presets where wPreset != file index. Record incontext/decomp-reference.md. - Calibration rate origin (KB correction). FUN_0044e19c is
ApplicationManager::GetFrameRate(); the original renderer rate was the system CLOCK TICK rate
(18.2065 Hz / 28 Hz), i.e. audio "frames" were clock ticks. The port's
btl4app.cpp:283-303DPLARG+0x10 read is garbage-with-a-clamp; 1000 (Windows ticks/ms) is structurally equivalent and correct, but delete the bogus read and pass SystemClock::GetTicksPerSecond() with a comment. - Published-velocity smoothing is faithful in kind. Mech::Execute (FUN_004ab430) runs the localVelocity components through AverageOf running-average filters (part_012.c:15169-15179); annotate the port's smoothing with this evidence -> T1.
5. Recommended Fix Order
Quick wins (small, isolated, high leverage)
- F3 distance:
alDistanceModel(AL_NONE)+ un-comment the distance multiply + apply in Static3D + drop AL_MAX_DISTANCE attenuation writes. Near one-line; also fixes cull, voice steal, and ducking. - F4 volume curve: square volume_scale at the three AL_GAIN sites.
- F10 doppler: alDopplerFactor(0); add GetDopplerCents() into the Dynamic3D pitch chain.
- F15 ConfigureActivePress: publish the base attribute on the existing +0x110 member.
- F18 cook-off: one ATTRIBUTE_ENTRY -> cookOffArmed.
- F20 zoom blip: one ATTRIBUTE_ENTRY -> targetRangeExponent.
- KB hygiene: record wPreset confirmation; fix the calibration-rate claim; delete the DPLARG read.
Structural — bank regeneration (ship as one unit)
- F1 + F2 + F13 + F14 extractor rewrite: emit ALL zones (per-zone WAV, keyLo/keyHi, loop points, pan, attenuation), bake per-zone tuning into each WAV's declared rate (44100*2^(((60-effRoot)*100+tune)/1200)), extend SAMPLEINFO, key-range selection in SetupPatch/PlayNote, AL_SOFT_loop_points (alBufferiv). Loop regions must land with or before multi-zone (the MechExplosion 1.5% loop layer otherwise becomes a repeating-boom bug). This one unit fixes most of the pitch and layering fidelity at once.
- F13 release: gain-ramp on Stop over the per-preset authored releaseVolEnv.
Structural — spatial/EFX
- F9 filters: EFX AL_FILTER_LOWPASS per source (brightness x distance HF rolloff per the per-type decomp semantics).
- F11 reverb: one EAXReverb aux slot; 3D sources wet (0.3), Direct dry.
- F12 placement: AL_POSITION from the DirectPatch position enum; internal-spatialization statics positioned from their computed azimuth. (F22 ITD/HRTF optional after.)
Structural — attribute reconstruction (decomp-guided, per finding)
- F6 ReportLeak (biggest dead block; read the binary's subsystem table for the member).
- F7 IncomingLock/DistanceToMissile (members @+0x3FC/+0x400; drive from the missile system).
- F5 FootStep contact level (+ re-export the two decomp gap functions).
- F19 footstep feed: publish correct motion components; delete the mech2.cpp broadcast.
- F16 Torso attrs (confirm the binary Torso table first), F17 attrPad family (instability model @0x3F0, collisionSpeed, ReduceButton).
- F23(2) AnimationState numbering audit, then F21 attack velocity/time as polish.
Regression harness: re-run the audit capture after each block and assert with
scratchpad/audio_coverage.py that the dead-watcher count monotonically drops; A/B pitch/volume
spot checks against the recovered-rate table for the bank regeneration.