diff --git a/docs/AUDIO_FIDELITY.md b/docs/AUDIO_FIDELITY.md new file mode 100644 index 0000000..c4db007 --- /dev/null +++ b/docs/AUDIO_FIDELITY.md @@ -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 + + +#### 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. + + +#### 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: + +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. + + +#### 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. + + +#### 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_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). + + +#### 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: + +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. + + +#### 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): + +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. + + +#### 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.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.