Docs: AUDIO_FIDELITY.md -- multi-agent fidelity audit, 37 verified findings (task #50)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-16 10:14:06 -05:00
co-authored by Claude Opus 4.8
parent 15d4278b6f
commit 05cb3f5549
+609
View File
@@ -0,0 +1,609 @@
# 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](#f1) | 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](#f2) | High | CONFIRMED | 91/115 + ~102/126 presets play >= 1 semitone off (worst -36 st; klaxons ~6x slow, footsteps 27% fast) |
| 3 | [Distance attenuation wrong](#f3) | 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](#f4) | High | CONFIRMED | Every intermediate volume ~+6 dB too loud at mid-scale; authored dynamic range compressed |
| 5 | [FootStep trigger semantics](#f5) | 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](#f6) | High | CONFIRMED | Largest dead block (38/54 match watchers, 19 subsystems); leak klaxon can never sound |
| 7 | [Missile-lock alarm dead](#f7) | High | CONFIRMED | IncomingLock + DistanceToMissile on read-only pad; accelerating lock beeper can never start |
| 8 | [Dead-watcher coverage matrix](#f8) | High | PARTIAL | 54 of ~225 attribute-gated watchers on dead attributes (~24%); itemized worklist |
| 9 | [Brightness / low-pass chain dead](#f9) | Medium | CONFIRMED | AWE NRPN-21 filter path computed then discarded; no distance muffling, ctl-5 brightness inert |
| 10 | [Doppler triply wrong](#f10) | Medium | CONFIRMED | Authored cents model dead; AL doppler at wrong constants with sign-inverted velocity — approaching sources pitch DOWN |
| 11 | [Reverb dead](#f11) | Medium | CONFIRMED | Original: CC91=38 wet exterior / 0 dry cockpit (decomp-proven); port: bone-dry everywhere |
| 12 | [Front/rear + pan placement dead](#f12) | Medium | CONFIRMED | DirectPatch position enum and internal spatialization discarded; all cockpit/static sounds dead-center |
| 13 | [Loop regions & release envelopes](#f13) | 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](#f14) | Medium | PARTIAL | Per-zone attenuation (inverted SBK scale), authored resonant low-pass on charge hum, volume envelopes all lost |
| 15 | [ConfigureActivePress mispublished](#f15) | Medium | CONFIRMED | Weapon-configure ticker can never fire; field exists but is misnamed and unpublished |
| 16 | [Torso-twist servo whir dead](#f16) | Medium | CONFIRMED | Torso publishes no attributes; whir never starts, pitch tracking dead |
| 17 | [Entity attrPad family](#f17) | Medium | CONFIRMED | Instability alarm never sounds; impact sounds never scale with speed; ReduceButton feedback dead |
| 18 | [FireCountdownStarted = cook-off warning](#f18) | 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](#f19) | Medium | PARTIAL | Unauthored curve + direct mixer writes clobbered by the live authored LocalVelocity feed every step (drops footsteps) |
| 20 | [Zoom blip unpublished](#f20) | Low | UNVERIFIED-LOW | Live member exists, one ATTRIBUTE_ENTRY missing |
| 21 | [Attack velocity / attack time dead](#f21) | Low | UNVERIFIED-LOW | Mechanism dead but authored data barely uses it today |
| 22 | [ITD / between-ears spatialization dead](#f22) | Low | UNVERIFIED-LOW | 4-channel pod model computed and discarded; matters mostly for pod-hardware target |
| 23 | [Benign inventions check](#f23) | 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](#validations).
---
## 3. Findings in Detail
### A. Sample bank / synthesis fidelity
<a name="f1"></a>
#### 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... matching `Warnings01`'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.
<a name="f2"></a>
#### 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.
<a name="f13"></a>
#### F13. Loop regions and release envelopes (Medium, CONFIRMED with an important correction)
Two verified sub-findings, one of which corrects the naive reading:
1. **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
that `sf2extract.py` reads 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).
2. **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.
3. **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.
<a name="f14"></a>
#### 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-1086` computes 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.
<a name="f21"></a>
#### 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
<a name="f3"></a>
#### 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.
<a name="f4"></a>
#### F4. Volume curve: linear AL_GAIN vs the CC7 squared law (High, CONFIRMED)
The original's volume path ended in MIDI CC7 = ftol(volume_scale*127+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.
40*log10(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).
<a name="f9"></a>
#### 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).
<a name="f10"></a>
#### F10. Doppler: authored model dead, AL doppler wrong constants AND wrong sign (Medium, CONFIRMED)
Triply wrong, decomp-verified intent:
1. 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.
2. Instead AL doppler is enabled with hardcoded `alDopplerFactor(0.3f)` (`AUDIO.cpp:99`) and
`alSpeedOfSound` never called (default 343.3) — neither constant nor curve shape matches.
3. 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.
<a name="f11"></a>
#### 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.
<a name="f12"></a>
#### 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.
<a name="f22"></a>
#### 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
<a name="f8"></a>
#### 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.
<a name="f6"></a>
#### 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).
<a name="f7"></a>
#### 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.
<a name="f5"></a>
#### 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.
<a name="f16"></a>
#### 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]).
<a name="f17"></a>
#### 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):
1. **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.
2. **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.)
3. **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.
<a name="f15"></a>
#### 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.
<a name="f18"></a>
#### 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.
<a name="f19"></a>
#### 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.
<a name="f20"></a>
#### 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).
<a name="f23"></a>
#### 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.
---
<a name="validations"></a>
## 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.py` already 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 in
`context/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-303`
DPLARG+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)
1. **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.
2. **F4 volume curve:** square volume_scale at the three AL_GAIN sites.
3. **F10 doppler:** alDopplerFactor(0); add GetDopplerCents() into the Dynamic3D pitch chain.
4. **F15 ConfigureActivePress:** publish the base attribute on the existing +0x110 member.
5. **F18 cook-off:** one ATTRIBUTE_ENTRY -> cookOffArmed.
6. **F20 zoom blip:** one ATTRIBUTE_ENTRY -> targetRangeExponent.
7. **KB hygiene:** record wPreset confirmation; fix the calibration-rate claim; delete the DPLARG
read.
### Structural — bank regeneration (ship as one unit)
8. **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.
9. **F13 release:** gain-ramp on Stop over the per-preset authored releaseVolEnv.
### Structural — spatial/EFX
10. **F9 filters:** EFX AL_FILTER_LOWPASS per source (brightness x distance HF rolloff per the
per-type decomp semantics).
11. **F11 reverb:** one EAXReverb aux slot; 3D sources wet (0.3), Direct dry.
12. **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)
13. **F6 ReportLeak** (biggest dead block; read the binary's subsystem table for the member).
14. **F7 IncomingLock/DistanceToMissile** (members @+0x3FC/+0x400; drive from the missile system).
15. **F5 FootStep contact level** (+ re-export the two decomp gap functions).
16. **F19 footstep feed:** publish correct motion components; delete the mech2.cpp broadcast.
17. **F16 Torso attrs** (confirm the binary Torso table first), **F17 attrPad family**
(instability model @0x3F0, collisionSpeed, ReduceButton).
18. **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.