Recovering the soundbanks took voice demand per sound from about one zone to about two and a half, and the audio path allocated an OpenAL source for every sound event and destroyed it again on release. Sources are a hard per-context resource - this driver grants 256 - so that churn doubled at exactly the moment it got more expensive. Sources are now generated once and recycled through a free list: measured, three sources generated across twelve thousand acquisitions. The BT tree reached the same conclusion the expensive way, from field logs full of failed acquisitions: raising the source budget is not the fix, because the ceiling also acts as a governor and more voices mixing is real CPU during exactly the busiest moments. Recycling is the fix, and it costs nothing. Two older bugs were sitting underneath, both reproduced against the driver rather than assumed: Releasing a set leaked it. alDeleteSources is atomic - one bad name in the array and nothing at all is deleted. ReleaseSourceSet handed it the whole fixed-size array and then parked the slots at -1, so any partial set, and any double release, leaked every source it held. Sources are now handed back one at a time and slots park at 0, which is never a valid name. A source set began life uninitialised. The constructor set only the count, and the acquire path decided whether a slot was already filled by asking OpenAL about uninitialised stack garbage. Garbage that happened to match a live name meant two sounds silently sharing one source. Pooling would have made that more likely, not less, since it keeps small names in circulation. Recycled sources are scrubbed before parking - stopped, buffer detached, looping, gain, pitch, relative flag, position and velocity reset, and the EFX filter and reverb send dropped. Without that last part a dry cockpit sound inherits the wet send of whatever 3D source held the name before it. Verified: a deliberately dirtied source comes back clean. Builds clean. Runs with memory and handle count flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
569 lines
28 KiB
Markdown
569 lines
28 KiB
Markdown
# Red Planet — the sound system, from two AWE32s to OpenAL
|
||
|
||
How a 1996 arcade pod produced true quadraphonic positional audio out of two
|
||
consumer sound cards, what the modern port kept, what it silently dropped, and
|
||
exactly where the original assets are.
|
||
|
||
**Sources.** The surviving engine in `MUNGA_L4/` (the L4AUD\* family) and the
|
||
preserved hardware layer in `MUNGA_L4/sos/`; the complete original RP 4.10 C++
|
||
source and shipping assets in `../TeslaRel410/`; and the BattleTech sibling
|
||
tree `../BT411/`, which shares this engine verbatim and has already fixed most
|
||
of what's described here.
|
||
|
||
Companion docs: `docs/audionotes.rtf` (Stephen Baynham, 2007) covers the
|
||
renderer's *control* flow — sources, sockets, the mix/running/dormant plugs.
|
||
`../BT411/docs/AUDIO_FIDELITY.md` is the 685-line fidelity audit this document
|
||
maps onto RP. This doc covers the hardware model underneath both.
|
||
|
||
Two headlines:
|
||
|
||
1. **The quadraphonic engine is still in the tree, still runs every frame, and
|
||
its output is discarded.** Nothing was deleted in the port. Four channel
|
||
gains and four time-delay offsets are computed for every sound in the world,
|
||
then dropped, because the OpenAL back-end that replaced the sound cards
|
||
never reads them.
|
||
2. **Red Planet's original soundbanks, authored sequences, source code and
|
||
hardware configuration all survive** in `../TeslaRel410/`. Nothing about the
|
||
original audio is lost. RP412 simply ships without them.
|
||
|
||
```
|
||
source azimuth
|
||
│
|
||
▼
|
||
CalculateSpatialization() ← quadrant pan + ITD, L4AUDIO.cpp:60
|
||
│
|
||
├──► frontLeftScale / frontRightScale ─┐
|
||
├──► rearLeftScale / rearRightScale │ 1996: CC7 volume to
|
||
├──► 4 × ITD delay targets ├── 4 MIDI channels across
|
||
└──► 4 × ITD pitch offsets (cents) │ 2 AWE32 cards
|
||
│
|
||
└── today: /* ... */ dead code,
|
||
OpenAL pans from AL_POSITION
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Why two cards
|
||
|
||
`AudioHardware` held exactly two, named for what they drove
|
||
(`MUNGA_L4/L4AUDHDW.h:345-346`):
|
||
|
||
```cpp
|
||
AudioCard frontCard;
|
||
AudioCard rearCard;
|
||
```
|
||
|
||
The second card was **not** for extra voices. Each AWE32 gives you one stereo
|
||
pair, and a four-corner speaker layout needs two. The allocator makes this
|
||
explicit (`MUNGA_L4/L4AUDRND.cpp:1309-1352`): front-left and front-right
|
||
channels are requested from `front_card`, rear-left and rear-right from
|
||
`rear_card`. Either card refusing kills the whole allocation and the sound
|
||
doesn't play.
|
||
|
||
Per card the engine assumed a stock EMU8000 (`MUNGA_L4/L4AUDHDW.h:80-82`):
|
||
|
||
| Constant | Value |
|
||
|---|---|
|
||
| `AWE_VOICE_COUNT` | 32 |
|
||
| `AWE_CHANNEL_COUNT` | 16 |
|
||
| `AWE_PERCUSSIVE_CHANNEL` | 9 |
|
||
|
||
64 hardware voices total, 32 MIDI channels, two independent stereo outputs.
|
||
|
||
## 2. The hardware layer: HMI SOS
|
||
|
||
Everything went through Human Machine Interfaces' **Sound Operating System**,
|
||
selected at compile time (`MUNGA_L4/L4AUDHDW.h:87`):
|
||
|
||
```cpp
|
||
#define _MIDI_DRIVER_TYPE _MIDI_AWE32
|
||
```
|
||
|
||
`MUNGA_L4/sos/` still carries the complete driver headers in both flavours the
|
||
build needed — `bc4/` for Borland C++ 4 and `wc/` for Watcom — alongside
|
||
`SOSMAWE.C`, whose header comment reads *"Module to handle AWE32 .SBK file
|
||
uploads."* That file is the bank loader: `sosMIDIAWE32SetSBKFile`,
|
||
`sosMIDIAWE32ReleaseSBKFiles`, `sosMIDIAWE32NoteOn/NoteOff`.
|
||
|
||
`AudioCard` also poked the hardware directly for MPU-401 UART setup — the
|
||
`_inp`/`_outp` port macros and `MPU_RESET_CMD`/`MPU_ENTER_UART` at
|
||
`MUNGA_L4/L4AUDHDW.cpp:14-25` are still there.
|
||
|
||
### 2.1 The actual pod hardware configuration
|
||
|
||
Card addresses were parsed by `GetEnvironmentSettings`
|
||
(`MUNGA_L4/L4AUDHDW.cpp:269-350`) out of a `BLASTER`-format string. The standard
|
||
single `BLASTER=` variable can only describe one card, so each got its own
|
||
(`MUNGA_L4/L4AUDHDW.cpp:935-936`):
|
||
|
||
```cpp
|
||
frontCard.GetEnvironmentSettings(FRONT_CARD_ENV_VAR);
|
||
rearCard.GetEnvironmentSettings(REAR_CARD_ENV_VAR);
|
||
```
|
||
|
||
Those two macros are referenced in four places and defined nowhere in this tree.
|
||
The values survive in the shipping release —
|
||
`../TeslaRel410/ALPHA_1/REL410/RP/SETENV.BAT`:
|
||
|
||
```bat
|
||
set AWE_FRONT=A220 I5 D1 H5 P330 T6
|
||
set AWE_REAR=A240 I7 D3 H6 P300 T6
|
||
```
|
||
|
||
| | Front card | Rear card |
|
||
|---|---|---|
|
||
| Base I/O | 0x220 | 0x240 |
|
||
| IRQ | 5 | 7 |
|
||
| DMA (8-bit) | 1 | 3 |
|
||
| DMA (16-bit) | 5 | 6 |
|
||
| MPU-401 | 0x330 | 0x300 |
|
||
| Type | 6 | 6 |
|
||
|
||
Two fully independent SB16/AWE32s, non-conflicting across every resource — a
|
||
genuinely awkward ISA configuration to get stable, which is presumably why
|
||
`SETENV.BAT` hardcodes it rather than probing.
|
||
|
||
**Two cards were mandatory, not optional.** `L4Application::MakeAudioRenderer`
|
||
returned `NULL` — no audio renderer at all — unless *both* variables were present
|
||
(`MUNGA_L4/L4APP.cpp:505-514`, now commented out). There was no one-card or
|
||
stereo fallback in the shipping build.
|
||
|
||
`SETENV.BAT` also drove the SB16 mixer per card via `sb16set`, and carries three
|
||
details worth recording:
|
||
|
||
- Master volume defaults to `AWE_MASTER_VOLUME=200`, overridable by an operator
|
||
file `c:\setvol.bat` — the per-cabinet volume trim.
|
||
- **Intercom mode** (`L4INTERCOM=ON`) swaps `audio\ctmix.cfg` for `audio\icom.cfg`
|
||
and adds `sb16set /li:220;0` on the **front card only**. Diffing the two configs
|
||
(`ALPHA_1/REL410/RP/AUDIO/`), the only change is line-in routing: `LIL+`/`LIR+`
|
||
into the input and output paths. The intercom fed the front card's line input.
|
||
- There are **two `:SOUNDCOMMON` labels**. DOS batch jumps to the first, so the
|
||
second block — which trims the cards differently from each other (bass 245 vs
|
||
240, treble 110 vs 135) — is unreachable dead code. Someone tuned front and rear
|
||
separately and it never shipped.
|
||
|
||
## 3. Nothing streamed — the game was a MIDI sequencer
|
||
|
||
There is no mixer and no audio thread in the original design. Sound effects were
|
||
SoundFont samples **resident in each card's onboard sample RAM**, and playing a
|
||
sound meant allocating a MIDI channel and sending note-on plus CC7 volume. The
|
||
game drove two samplers in real time.
|
||
|
||
That is why both cards were loaded with *identical* banks (`dist/AUDIO/AUDIO.INI`):
|
||
|
||
```ini
|
||
[AudioResources]
|
||
front_audio_resource=audio\audio1.res
|
||
front_audio_resource=audio\audio2.res
|
||
rear_audio_resource=audio\audio1.res
|
||
rear_audio_resource=audio\audio2.res
|
||
```
|
||
|
||
Same content in both cards' RAM, so any sound could be placed anywhere in the
|
||
ring without a reload. The cost is that the entire sound set had to fit twice
|
||
over in AWE32 sample memory.
|
||
|
||
It also explains the shape of the whole audio API. `AudioChannel` exposes
|
||
`SendNoteOn`, `SendProgramChange`, `SendPitchBend`, `SendNRPN`, `SelectBank` —
|
||
a MIDI abstraction, not a sample-playback abstraction. Every positional and DSP
|
||
decision the engine makes has to be expressed as a MIDI controller value.
|
||
|
||
## 4. The quad panner
|
||
|
||
`L4AudioSpatialization::CalculateSpatialization(azimuth)`
|
||
(`MUNGA_L4/L4AUDIO.cpp:60-290`) is the whole positional model. It is
|
||
character-for-character identical to the 1995 original at
|
||
`../TeslaRel410/CODE/RP/MUNGA_L4/L4AUDIO.CPP:150-290`, down to the `// HACK`
|
||
comments.
|
||
|
||
Azimuth is rewrapped so 0° is dead ahead and the range is ±180°, then split into
|
||
four 90° quadrants around `azimuth_max = 45°`:
|
||
|
||
```
|
||
front
|
||
FL ─────┬───── FR
|
||
│ Q1 │
|
||
│ │
|
||
Q2 │ ▲ │ Q4
|
||
(left) │ │ │ (right)
|
||
│ +az │
|
||
RL ─────┴───── RR
|
||
Q3
|
||
rear
|
||
|
||
+azimuth → left −azimuth → right
|
||
```
|
||
|
||
Within a quadrant it constant-power pans between the **two bracketing speakers
|
||
only** — a source never feeds more than two of the four, which is correct for a
|
||
four-corner layout:
|
||
|
||
```cpp
|
||
tangent_ratio = (tan(azimuthOfSource) / tan(azimuth_max)) * 0.5f;
|
||
|
||
frontLeftScale = Sqrt(0.5f + tangent_ratio);
|
||
frontRightScale = Sqrt(0.5f - tangent_ratio);
|
||
```
|
||
|
||
`tangent_ratio` runs ±0.5, so the gains trace `sqrt(0.5±t)` — sum of squares
|
||
constant at 1.0, i.e. constant acoustic power across the sweep, no hole in the
|
||
middle.
|
||
|
||
| Quadrant | Arc | Active pair |
|
||
|---|---|---|
|
||
| Q1 | −45° … +45° | front-left / front-right |
|
||
| Q2 | +45° … +135° | rear-left / front-left |
|
||
| Q3 | ±135° … 180° | rear-right / rear-left |
|
||
| Q4 | −135° … −45° | front-right / rear-right |
|
||
|
||
Q1/Q3 use `tan(azimuth_max)` as the half-width while Q2/Q4 use
|
||
`tan(DEG_90 - azimuth_max)`. With `azimuth_max = 45°` these are equal and all
|
||
four arcs are 90°, but the code is written so front/rear arcs could be widened
|
||
against the side arcs independently. `azimuth_max` is hardcoded with a
|
||
`// HACK - should come from audio.ini` comment at `L4AUDIO.cpp:103`.
|
||
|
||
Q3 relies on `tan` having period 180° to handle the wrap at ±180° — for
|
||
`az < −135` the expression `azimuthOfSource - DEG_180` goes below −315°, and the
|
||
result is only correct because tangent is periodic. It works; it is not obvious.
|
||
|
||
## 5. The ITD trick
|
||
|
||
Amplitude panning alone gives direction but not much externalization. The engine
|
||
also modelled **interaural time difference** — the sub-millisecond arrival-time
|
||
gap between your ears that the brain actually uses to localize. AUDIO.INI:
|
||
|
||
```ini
|
||
distance_between_ears=12.0
|
||
itd_difference=0.0015
|
||
```
|
||
|
||
The problem: an EMU8000 has no delay line. You cannot ask an AWE32 to play a
|
||
voice 1.5 ms late. There is no such MIDI message and no such hardware path.
|
||
|
||
The solution: **don't delay the voice — detune it.** To make a voice arrive
|
||
progressively earlier or later, momentarily shift its pitch, which shifts its
|
||
playback rate, which slides it through time. Return the pitch to normal and the
|
||
voice stays there, phase-shifted. Doppler used as a phase-steering primitive.
|
||
|
||
`CalculateSpatialization` sets a *delay target* per channel; the caller converts
|
||
the **rate of change** of that target into a cents offset
|
||
(`MUNGA_L4/L4AUDIO.cpp:414-438`):
|
||
|
||
```cpp
|
||
const Scalar itd_pitch_offset_constant =
|
||
0.003831f / 0.000002f; // period / delay
|
||
|
||
frontLeftITDPitchOffset =
|
||
itd_pitch_offset_constant *
|
||
(spatialization.frontLeftDelay - currentFrontLeftDelay) /
|
||
(Scalar)itd_delta_time;
|
||
currentFrontLeftDelay = spatialization.frontLeftDelay;
|
||
```
|
||
|
||
Only one of the two active channels gets a nonzero delay target, scaled by the
|
||
same `tangent_ratio` as the gain, so maximum offset at full pan is exactly
|
||
`itd_difference` — 1.5 ms. The rear quadrant negates the sign
|
||
(`rearLeftDelay = -(itd_delay * tangent_ratio * 2.0f)`, `L4AUDIO.cpp:214`),
|
||
flipping the lead/lag relationship behind the listener.
|
||
|
||
**On the magic constant** — a derivation, not something the source states.
|
||
`0.003831 / 0.000002` = 1915.5 cents per unit of delay slew. The exact
|
||
small-signal value for a Doppler-style rate-to-pitch conversion is `1200 / ln 2`
|
||
≈ 1731 cents. They agree within about 10%, which confirms the mechanism: a
|
||
hand-tuned first-order approximation, presumably trimmed by ear on the pod.
|
||
|
||
Three further details:
|
||
|
||
- Computed against real elapsed frame time (`Now() - lastITDFrameTime`), so the
|
||
slew is framerate-independent.
|
||
- Applied only while the target is *moving*. A stationary source contributes zero
|
||
pitch offset and sits at whatever phase it reached.
|
||
- `distance_between_ears=12.0` is commented **"-> average size of cockpit"**. BT
|
||
uses `2.0`. This is the clearest surviving fingerprint of the pod build: the
|
||
head model was scaled to the physical cabinet, because the speakers really were
|
||
in the corners around the player. **Confirmed authentic** — RP412's `AUDIO.INI`
|
||
is byte-identical to the shipping 4.10 file dated 31 August 1995
|
||
(`../TeslaRel410/ALPHA_1/REL410/RP/AUDIO/AUDIO.INI`). Every tuning constant in
|
||
this repo is the original; there has been zero config drift in thirty years.
|
||
|
||
## 6. The rest of the per-frame model
|
||
|
||
All of it expressed as MIDI, all driven from `AUDIO.INI`:
|
||
|
||
| Effect | Mechanism | INI keys |
|
||
|---|---|---|
|
||
| Distance attenuation | CC7 volume, knee + rolloff curve | `amplitude_rolloff`, `_knee`, `_distance_scale` |
|
||
| Distance muffling | AWE initial-filter-cutoff NRPN 21 (100–8000 Hz) | `high_frequency_rolloff`, `_knee`, `_distance_scale` |
|
||
| Doppler | pitch bend in cents | `doppler_range`, `speed_of_sound` |
|
||
| Reverb | CC91 send, wet exterior / dry cockpit | `global_reverb_scale` |
|
||
| Source compression | gain curve on the summed mix | `compression_scale`, `compression_exponent` |
|
||
| Clipping | hard cull sphere | `clipping_radius` |
|
||
|
||
NRPN constants are still declared at `MUNGA_L4/L4AUDHDW.h:63-68`
|
||
(`AWE_FILTER_CUTOFF_NRPN 21`, `AWE_VOL_ATTACK_TIME_NRPN 11`, `AWE_PITCH_NRPN 16`).
|
||
|
||
`AUDIOMR.INI` is a shipped variant differing from `AUDIO.INI` in exactly one
|
||
respect — compression is far more aggressive (`compression_scale=0.1`,
|
||
`compression_exponent=9.0` vs `0.92`/`8.5`). Everything else is identical.
|
||
|
||
## 7. What the port did
|
||
|
||
Both trees replaced the AWE32/SOS back-end with **OpenAL Soft**, by commenting
|
||
out rather than deleting. `MUNGA_L4/L4AUDHDW.h` is 530 lines of which the great
|
||
majority is preserved-in-amber AWE code: `AudioChannel`, `AudioCard` and
|
||
`AudioHardware` are entirely inside `/* */`. The quad CC7 volume switch survives
|
||
the same way from `MUNGA_L4/L4AUDIO.cpp:1964`.
|
||
|
||
The replacement is `SourceSet` (`MUNGA_L4/L4AUDHDW.h:9-13`):
|
||
|
||
```cpp
|
||
struct SourceSet
|
||
{
|
||
int count;
|
||
ALuint sources[5];
|
||
};
|
||
```
|
||
|
||
Four MIDI channels-per-sound became up to five OpenAL sources — one per sample
|
||
zone in the preset, not one per speaker. Placement is handed to OpenAL via
|
||
`alSource3f(..., AL_POSITION, ...)` (`MUNGA_L4/L4AUDIO.cpp:1413`).
|
||
|
||
**The consequence is the first headline.** `CalculateSpatialization` is still
|
||
called every frame from `UpdateSpatialModelImplementation`, still computes four
|
||
gains and four ITD pitch offsets. Every consumer of those values is commented out.
|
||
|
||
## 8. Where the original assets actually are
|
||
|
||
> **Update (2026-08-05):** both banks now live in `assets/RP411/AUDIO/`, and the
|
||
> WAV set and preset table are generated from them by `tools/rp_sf2extract.py`.
|
||
> The history below is kept because it is what made that possible, and because
|
||
> the `.SCP` sources are still only in TeslaRel410.
|
||
|
||
RP412 used to ship 223 loose `.wav` files loaded through libsndfile, a
|
||
hand-maintained preset table in `RP_L4/WTPresets.cpp`, and a **1-byte stub**
|
||
`AUDIO.RES`. The `front_audio_resource`/`rear_audio_resource` lines in AUDIO.INI
|
||
were never stale leftovers — they are the original authored configuration, and
|
||
the banks they name existed all along, simply never carried into the port.
|
||
|
||
Everything below is verified present in `../TeslaRel410/`:
|
||
|
||
| Asset | Location | Detail |
|
||
|---|---|---|
|
||
| **RP soundbanks** | `ALPHA_1/REL410/RP/AUDIO/AUDIO1.RES`, `AUDIO2.RES` | Genuine SoundFonts (`RIFF…sfbk`), 3,781,754 B (Oct 1996) and 3,708,348 B (May 1996) |
|
||
| Earlier bank revision | `sda4/RPLIVE/AUDIO/` | Nov 1995 / Oct 1995; AUDIO1 differs by 4 bytes |
|
||
| **Authored sequences** | `sda4/RPLIVE/AUDIO/*.SCP` | 70 files including `STATIC.SCP` |
|
||
| Sequences (partial) | `CONTENT/RP/AUDIO/*.SCP` | 62 files, no STATIC.SCP |
|
||
| **Original C++ source** | `CODE/RP/MUNGA_L4/L4AUD*.CPP` | Complete pre-port DOS source |
|
||
| Hardware config | `ALPHA_1/REL410/RP/SETENV.BAT` | The `AWE_FRONT`/`AWE_REAR` strings in §2.1 |
|
||
| Mixer configs | `ALPHA_1/REL410/RP/AUDIO/CTMIX.CFG`, `ICOM.CFG` | Normal and intercom routing |
|
||
|
||
Three things this settles:
|
||
|
||
1. **RP's banks are its own.** MD5s differ from BT's, which are byte-identical
|
||
between `TeslaRel410/ALPHA_1/REL410/BT/AUDIO/` and `BT411/content/AUDIO/` —
|
||
so the provenance chain is proven on the BT side, and RP's distinct content is
|
||
sitting unused.
|
||
2. **The `.SCP` files are build-time sources, not runtime assets.**
|
||
`CreateStaticAudioStreamResource` (`MUNGA_L4/L4AUDRES.cpp:769`) is called only
|
||
from the asset tool (`MUNGA/TOOL.cpp:100`), which compiles them into
|
||
`RPL4.RES`. RP412 ships a working `RPL4.RES`, so the authored audio *objects*
|
||
are present — what's missing is the editable source form, now recovered.
|
||
3. **RP has a reference BT lacks.** BT411's audit had to Ghidra-decompile
|
||
`BTL4OPT.EXE` to confirm F4, F9, F10, F11 and F12. For Red Planet the actual
|
||
C++ source exists, so every one of those can be verified directly rather than
|
||
inferred.
|
||
|
||
## 9. Fidelity gaps — the BT411 audit mapped onto RP412
|
||
|
||
BT411's audit graded its OpenAL port across 23 findings and has since fixed most
|
||
of them. Its sections C and D (dead attribute bindings, `ReportLeak`, torso-twist
|
||
servos) are BattleTech-entity-specific and do not transfer. Its synthesis and
|
||
spatial findings do.
|
||
|
||
**Every gap below was re-verified against RP412's own code, not assumed.** The
|
||
comment-block state of each cited line was checked programmatically.
|
||
|
||
### Engine-side — asset-independent
|
||
|
||
**Status: all fixed (2026-08-05).** Line references are to the pre-fix tree.
|
||
|
||
| # | Gap | Evidence found in RP412 | What landed |
|
||
|---|---|---|---|
|
||
| F3 | Authored distance curve computed then discarded; `AL_LINEAR_DISTANCE` used instead | `volume_scale *= GetDistanceVolumeScale()` **commented** at `L4AUDIO.cpp:1449`; `alDistanceModel(AL_LINEAR_DISTANCE)` live at `MUNGA/AUDIO.cpp:97`; `AL_MAX_DISTANCE` written at `:1081,1416,1941` | `alDistanceModel(AL_NONE)`; multiply restored on Dynamic3D; new `Static3DPatchSource::CalculateSourceVolumeScale` override; the three `AL_MAX_DISTANCE` writes dropped |
|
||
| F4 | Volume written linearly where the original used the CC7 squared law | three live `alSourcef(..., AL_GAIN, volume_scale)` at `L4AUDIO.cpp:1082,1414,1939` | `AL_GAIN, volume_scale * volume_scale` at all three |
|
||
| F9 | Brightness / HF-rolloff chain dead | `GetHighFreqCutoffScale()` had **zero callers** | new `L4AUDEFX` lowpass: Dynamic3D takes HF-rolloff × brightness, Static3D and Direct take brightness alone |
|
||
| F10 | Doppler wrong constants and wrong sign | `alDopplerFactor(0.3f)`; `GetDopplerCents()` **zero callers** | `alDopplerFactor(0.0f)` + `pitch_offset += GetDopplerCents()` on the dynamic path only |
|
||
| F11 | Reverb wet-exterior / dry-cockpit split dead | CC91 sends **commented** at `L4AUDIO.cpp:1227,1717` | EFX EAXReverb aux slot at `global_reverb_scale`; sends attached on Dynamic3D/Static3D, Direct left dry |
|
||
| F12 | Direct placement dead — everything dead-centre | all three `switch (audioPosition)` blocks **commented** | `AL_POSITION` written per the authored enum after `SetupPatch` |
|
||
| **P1** | **`AL_PITCH` never called anywhere in the tree** | `relativePitch` computed at `:1034,1408,1922` and discarded at all three | pitch applied at all three sites |
|
||
| F22 | Quad + ITD model dead | §4–5 above | **still open** — needs multichannel output (§10 step 4) |
|
||
|
||
**P1 is an RP-specific find with no BT counterpart**, and it is larger than F10
|
||
alone. RP412 had no `AL_PITCH` call at all, so the *entire* pitch chain was
|
||
inert — not just doppler but `pitch_mix_offset` / `PitchAudioControlID`, which
|
||
RP's own sequences author 97 times. Fixing F10 without this would have changed
|
||
nothing audible.
|
||
|
||
A note on note-pitch: BT411 applies `2^((note-60)/12)`, because its SF2-derived
|
||
presets carry authored key-splits. RP is different — `SAMPLEINFO` has no root-key
|
||
field, and RP's authored content predates `NoteAudioControlID` entirely (its
|
||
`AudioControlID` enum stops at `AttackTimeAudioControlID`), so every source runs
|
||
at `DEFAULT_NOTE`=60 and the factor is identically 1.0. It is applied anyway for
|
||
engine parity, clearly marked as inert for current content.
|
||
|
||
F3 was the highest-leverage single change: restoring the authored curve also
|
||
repairs the distance-blind transient cull, the voice-steal weighting, and the
|
||
mix-ducking chain, all of which were treating far sources as full-presence.
|
||
|
||
F22 is the one where RP is the *lead* repo rather than the follower. BT411
|
||
classes it low-priority because it "matters mostly for pod-hardware target" —
|
||
which is precisely what this project is.
|
||
|
||
**Verified on this machine:** `ALC_EXT_EFX` is present and all nine EFX entry
|
||
points resolve, so F9/F11 are live rather than silently inert. The driver grants
|
||
**256 mono sources** — OpenAL Soft's default budget, which BT411 raised
|
||
explicitly via context attributes. RP412 still accepts the default; worth
|
||
revisiting if voice starvation shows up in a busy match.
|
||
|
||
### Asset-side — measured against RP's own banks
|
||
|
||
RP's banks turned out to differ from BT's in ways that matter, so BT's
|
||
prevalences do not transfer. Measured directly (`tools/rp_sf2extract.py --stats`):
|
||
|
||
| | RP total |
|
||
|---|---|
|
||
| Presets | 154 (67 in bank 1, 87 in bank 2) |
|
||
| Instrument zones | **395** |
|
||
| Multi-zone presets | 130 / 154 (84%) |
|
||
| **Key-splits** | **zero** |
|
||
| Max zones in any preset | **4** |
|
||
| Looping zones | 129 |
|
||
| Zones with an authored low-pass | 154 |
|
||
| Zones with layer attenuation | 97 |
|
||
| Zones with `releaseVolEnv` | 349 |
|
||
|
||
**Two RP-specific findings that change the work:**
|
||
|
||
1. **RP's banks contain no key-splits at all.** Every multi-zone preset is a pure
|
||
*layer* stack whose zones share one key range, all covering note 60. Combined
|
||
with RP's content never authoring a note (§9 above), key ranges are
|
||
unreachable here — so zone selection is a non-issue and every zone is simply a
|
||
simultaneous voice. BT's F1, which is largely about key-splits, mostly does
|
||
not apply; what applies is the plain zone count.
|
||
2. **Max 4 zones per preset**, which fits `PRESETINFO.samples[5]` as it stands.
|
||
No structural change was needed — and the engine's long-standing
|
||
`Warn(GetVoiceCount() > 4)` ("AWE appears to only play 1st 4 voices",
|
||
`L4AUDLVL.cpp:29`) matches the bank data exactly.
|
||
|
||
| # | Gap | RP status |
|
||
|---|---|---|
|
||
| F1 | Zones dropped | **fixed** — 93 presets were short; 176 zones recovered, 219 → 395 |
|
||
| F2 | Root key / tuning dropped | **fixed** — every shipped WAV was flat 44100 Hz; 202 of 219 checked zones were off, worst ~9 semitones. Tuning is now baked into each WAV's declared rate |
|
||
| F14 | Per-zone generators | **partly fixed** — `initialAttenuation` (SBK inverted scale) and the authored `initialFilterFc`/`Q` resonant low-pass are baked into the PCM. Volume envelopes still dropped |
|
||
| F13 | Loop regions and release envelopes | **open** — needs `SAMPLEINFO` fields plus engine work (see below) |
|
||
|
||
**F13 remains the outstanding asset-side item.** It needs `loopStart`/`loopEnd`
|
||
and `releaseSec` on `SAMPLEINFO`, `AL_SOFT_loop_points` at buffer setup, and a
|
||
gain ramp on the stop path. 349 of 395 zones carry an authored release envelope
|
||
that is currently an instant cut.
|
||
|
||
The **ordering hazard** BT flagged — loop regions must land with or before
|
||
multi-zone extraction, or whole-buffer looping over a short loop region replays
|
||
an entire sample every cycle — is worth re-checking for RP now that zone counts
|
||
have gone up. RP's 129 looping zones should be measured for loop-region coverage
|
||
before F13 lands.
|
||
|
||
**The churn this work introduced — now fixed (2026-08-05).** Recovering the
|
||
zones took voice demand per sound from ~1.1 to ~2.6, roughly doubling the
|
||
allocation churn: `RequestAudioChannels` called `alGenSources` per sound event
|
||
and `ReleaseSourceSet` called `alDeleteSources` on release. BT411 hit exactly
|
||
this, and its measured conclusion was that raising the source budget was *not*
|
||
the fix — pooling was, and a net CPU win besides.
|
||
|
||
Sources are now generated once and recycled through a free list
|
||
(`RPAudioPoolAcquire` / `RPAudioPoolRelease`, `L4AUDRND.cpp`), capped at 240
|
||
against the driver's 256-mono grant. Steady-state play costs no allocation:
|
||
measured 3 sources generated across 12,000 acquisitions.
|
||
|
||
Two real bugs were sitting underneath it, both verified against this driver
|
||
rather than assumed:
|
||
|
||
- **The bulk delete was atomic and leaked whole sets.**
|
||
`alDeleteSources(3, {valid, valid, 0})` returns an error and deletes
|
||
*nothing* — both live sources survive. The old `ReleaseSourceSet` passed the
|
||
whole fixed-size array and then parked slots at `-1` (`0xFFFFFFFF`), so any
|
||
partial set, or any double release, leaked its entire allocation. Release is
|
||
now per-source, and slots park at 0, which is never a valid AL name.
|
||
- **`SourceSet.sources[]` was never initialized.** The constructor set only
|
||
`count`, and `RequestAudioChannels` decided whether a slot was already filled
|
||
by asking `alIsSource` about uninitialized stack garbage. A value that
|
||
happened to match a live name meant two sources silently sharing one — a
|
||
latent hazard that pooling would have made *more* likely, since recycling
|
||
keeps small integer names in circulation.
|
||
|
||
Recycled sources are scrubbed before being parked: stopped, buffer detached,
|
||
looping/gain/pitch/relative/position/velocity reset, **and the EFX direct filter
|
||
and reverb send cleared** — without that last part a dry cockpit sound could
|
||
inherit the wet send of the 3D source that held the name before it. Verified: a
|
||
source deliberately dirtied then released comes back with looping=0, gain=1.0,
|
||
pitch=1.0, relative=0.
|
||
|
||
## 10. A recovery path, in order
|
||
|
||
1. ~~**Engine-side fidelity first.**~~ **Done (2026-08-05).** F3, F4, F9, F10,
|
||
F11, F12 and P1 all landed; `L4AUDEFX.cpp/.h` ported and added to
|
||
`Munga_L4.vcxproj`. Builds clean on VS2022 `Release|Win32`; smoke-tested
|
||
against vRIO on COM1 with `RP412STEAM=0` — reaches gameplay and holds a
|
||
steady frame loop.
|
||
2. ~~**Wire RP's banks in.**~~ **Done (2026-08-05).** Both banks are now in
|
||
`assets/RP411/AUDIO/`, hash-identical to the 1996 originals.
|
||
`tools/rp_sf2extract.py` extracts all 395 zones with tuning, layer
|
||
attenuation and the authored low-pass baked in, and regenerates
|
||
`RP_L4/WTPresets.cpp`. Verified: 176 zones recovered with none lost (the 46
|
||
preset slots that disappeared were all empty placeholders); every extreme
|
||
baked rate (1228 Hz – 88200 Hz) accepted by libsndfile → `alBufferData` →
|
||
`alSourcePlay` on the real runtime path. **Still open here: F13.**
|
||
|
||
**Confirmed by ear (2026-08-05): markedly more bass.** That is the expected
|
||
signature of the tuning fix — the deepest layers were the worst offenders, a
|
||
collision sub-thud playing at 44100 Hz where the bank says 1228 — compounded
|
||
by the 176 recovered zones, which are disproportionately the low rumble
|
||
layers sitting under collisions and explosions.
|
||
3. **Recover the `.SCP` sources** from `sda4/RPLIVE/AUDIO/` into the asset
|
||
pipeline, so authored audio becomes editable again rather than frozen in
|
||
`RPL4.RES`.
|
||
4. **Then quad.** With the above in place:
|
||
- Ask ALC for a multichannel format instead of accepting the stereo default
|
||
(`MUNGA_L4/L4AUDRND.cpp:380`).
|
||
- Place four `AL_SOURCE_RELATIVE` sources at fixed corner positions and drive
|
||
their `AL_GAIN` from the existing `GetFrontLeftScale()` family, bypassing
|
||
OpenAL's panner.
|
||
- Feed the ITD offsets to `AL_PITCH` — or implement a real fractional delay,
|
||
which a software mixer can do and the EMU8000 could not. The detune path is
|
||
already written and is the authentic behaviour.
|
||
- Re-derive `azimuth_max` from the actual cabinet speaker angles instead of
|
||
the hardcoded 45°.
|
||
|
||
Steps 1 and 2 are where nearly all the audible improvement is. Step 4 is what
|
||
made the pod feel like the sound was in the room with you.
|
||
|
||
## 11. Verifying any of this
|
||
|
||
| File | What's in it |
|
||
|---|---|
|
||
| `MUNGA_L4/L4AUDHDW.h` | AWE/MIDI constants, `AudioCard`/`AudioHardware` (commented), `SourceSet` |
|
||
| `MUNGA_L4/L4AUDHDW.cpp` | MPU-401 port I/O, `BLASTER` parsing (:269), card init (:935) |
|
||
| `MUNGA_L4/L4AUDIO.cpp` | `CalculateSpatialization` (:60), ITD pitch (:414), dead quad CC7 path (:1964) |
|
||
| `MUNGA_L4/L4AUDRND.cpp` | renderer, OpenAL init (:380), dead quad channel allocator (:1309) |
|
||
| `MUNGA_L4/L4AUDRES.cpp` | resource manager, WAV → AL buffers, SCP compile path (:769) |
|
||
| `MUNGA_L4/L4APP.cpp:505` | the dead two-card gate on renderer creation |
|
||
| `MUNGA_L4/sos/` | HMI SOS driver headers (bc4 + wc), `SOSMAWE.C` bank uploader |
|
||
| `dist/AUDIO/AUDIO.INI` | every tuning constant — byte-identical to the 1995 original |
|
||
| `../TeslaRel410/CODE/RP/MUNGA_L4/` | the original DOS source, for anything the comments don't answer |
|
||
| `../BT411/docs/AUDIO_FIDELITY.md` | the full 23-finding audit this section maps from |
|
||
|
||
The commented-out regions are a faithful copy of the original — verified against
|
||
`TeslaRel410/CODE/RP/MUNGA_L4/L4AUDIO.CPP`, which matches character-for-character
|
||
in the spatialization and ITD paths. They were preserved deliberately and they
|
||
describe exactly how the pod's audio hardware was driven.
|