acdream/docs/research/2026-08-08-audio-retail-soundmanager-core.md
Erik e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with
m_3D = 0 and the DirectSound 3D listener the client sets up is dead code;
spatialization is two CPU scalars per voice, frozen at emission. This
slice ports that math and demotes OpenAL to a voice bank.

RetailSoundMixer (new, Core) carries the byte-decoded curve from
SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2,
clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g),
with a hard -50 dB floor at which retail does not start the voice at all
(audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's
(int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero,
forced to dead centre when (int)distance < 5, with no front/back and no
elevation cue. Every AL source is now source-relative with rolloff 0 and
the global distance model is None: AL's InverseDistanceClamped was
first-power (2/d), quieter than retail up close and far louder at range
with no cutoff whatsoever. That was the largest audible divergence in the
subsystem (AP-28, retired here).

RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan
for a free or finished slot, then evict the first slot whose DAT priority
is strictly lower, else drop. Eviction compared GAIN before, so a loud
unimportant sound could silence a quiet important one. It lives in Core
because the engine's play path talks to native AL handles and could not
be tested; the pool now has 12 conformance tests.

The listener keeps using the camera position, which the decode shows is
retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided
camera Position to SoundManager) — only the heading extraction changes,
since retail reads one compass bearing and never a forward/up basis. An
earlier draft of the plan called this a defect; corrected in the plan so
it is not fixed backwards.

Opus review found and this commit fixes: a linear pan-to-azimuth mapping
that saturated to full separation at 30 degrees (OpenAL Soft's own
speaker angle) where retail gives 15 dB — now inverts the constant-power
pan law, so full deflection reaches 0.776 of the arc and both channels
stay live; the stale FUN_00550ad0 / gain-eviction class header, which
contradicted the register row this commit writes; missing discriminating
tests for clamp order and pan truncation; dead PlayingGain state whose
comment invented a retail symbol; and a third in-tree copy of
Position::heading, now delegating to MoveToMath.PositionHeading.

MasterVolume folds into the mixer's one multiply instead of AL listener
gain, so the cutoff, radius and dB quantisation move with the slider.

Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy),
TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk,
applied on the ambient path only) filed. Research note corrected twice
where its summary contradicted its own decode (30 m dB, floor vs trunc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:58:50 +02:00

714 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lane 1 — retail `SoundManager` core, decoded, vs acdream's OpenAL engine
Date: 2026-08-08. Read-only research note.
Sources
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail structs)
- **Raw byte decode** of `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, PDB-paired,
image base `0x00400000`) via capstone — **required**, because the BN pseudo-C for
`GetAttenuation` and the pan block has FPU-elided constants (it prints `* 0f` where the
binary has `fmul dword [VOL_MIN_DIST_SQ]`) and one misattributed stack slot. Every
constant below is read out of the binary, not inferred.
Compared against
- `src/AcDream.App/Audio/OpenAlAudioEngine.cs`
- `src/AcDream.App/Audio/OpenAlResourceLifetime.cs`
- `src/AcDream.App/Audio/AudioHookSink.cs`
- `src/AcDream.Core/Audio/AudioModel.cs`, `SoundCookbook.cs`
---
## 0. Address map (all VAs, 2013 EoR build)
| Symbol | VA |
|---|---|
| `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` | `0x0054FEC0` |
| `SoundManager::GetAttenuation(float dist, float vol, int* outDb, int ambient)` | `0x00550020` |
| `SoundManager::PlayProbability(float)` | `0x005500E0` |
| `SoundBufRef::SoundBufRef(DataID)` | `0x00550110` |
| `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` | `0x00550170` |
| `SoundManager::ShutDown` | `0x005502B0` |
| `SoundManager::SetPlayerPosition(const Position*)` | `0x005503C0` |
| `SoundManager::Cleanup` (tailcall → ShutDown) | `0x005503E0` |
| `SoundManager::InitPrefs` | `0x005503F0` |
| `SoundManager::Init(HWND)` | `0x00550640` |
| `SoundManager::GetSound(SoundType, CSoundTable*, SoundData*)` | `0x00550680` |
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*)` | `0x00550730` |
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*, prio, prob, vol)` | `0x005507A0` |
| `SoundManager::PlayAmbientSound(SoundType, table, Position*, vol)` | `0x00550820` |
| `SoundManager::PlayAmbientSoundFromCenter(SoundType, table, vol)` | `0x005508B0` |
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` |
| `SoundManager::PlaySoundFromCenter(DataID, float vol)` | `0x005509E0` |
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float vol)` | `0x00550AF0` |
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*)` | `0x00550B70` |
| `SoundManager::CreateSound(DataID)` | `0x00550BF0` |
| `SoundManager::DestroySound(DataID)` | `0x00550C60` |
| `SoundBuf::ReleaseAll` | `0x00552670` |
| `SoundBuf::CopyWaveToBuffer(WaveFile*)` | `0x005526D0` |
| `SoundBuf::Stop` | `0x00552830` |
| `SoundBuf::GetStatus` | `0x00552850` |
| `SoundBuf::~SoundBuf` (tailcall → ReleaseAll) | `0x005528A0` |
| `SoundBuf::SoundBuf(const SoundBuf&)` (DuplicateSoundBuffer) | `0x005528B0` |
| `SoundBuf::Create(int bStatic)` | `0x00552930` |
| `SoundBuf::Restore` | `0x00552B90` |
| `SoundBuf::SoundBuf(DataID, tagval, bStatic, b3D)` | `0x00552D00` |
| `SoundBuf::Play(int pan, int volDb)` | `0x00552D50` |
| `SoundOK` | `0x00552E10` |
| `GetDirectSound` | `0x00552E30` |
| `SoundCleanup` | `0x00552E40` |
| `SoundSetup(HWND)` | `0x00552E70` |
| `CDirSound::DirectSoundOK` | `0x00553D00` |
| `CDirSound::CDirSound(HWND)` | `0x00553D10` |
| `CDirSound::~CDirSound` | `0x00553E40` |
| `Ambient::Play(AmbientSound*)` | `0x005517A0` |
| `Ambient::UseTime` | `0x00551880` |
| `SmartBox::set_viewer(const Position*, int type)` | `0x00452C40` |
| `SmartBox::update_viewer` | `0x00453CE0` |
| `Position::heading(const Position&)` | `0x005A9520` |
| `Position::distance(const Position&)` | `0x005A94B0` |
| `Frame::get_heading` | `0x00535760` |
### Statics
| Symbol | VA | Type / value |
|---|---|---|
| `SoundManager::VOL_MIN` | `0x0081F060` | `int32 = -50` (**decibels**) |
| `SoundManager::effect_sounds_enabled` | `0x0081F064` | `bool = 1` |
| `SoundManager::effect_sound_volume` | `0x0081F068` | `float = 1.0` |
| `SoundManager::ambient_sounds_enabled` | `0x0081F06C` | `bool = 1` |
| `SoundManager::ambient_sound_volume` | `0x0081F070` | `float = 1.0` |
| `SoundManager::interface_sounds_enabled` | `0x0081F074` | `bool = 1` |
| `SoundManager::interface_sound_volume` | `0x0081F078` | `float = 1.0`**write-only, never read** |
| `SoundManager::s_bPlaySoundOnlyWhenActive` | `0x0081F07C` | `bool = 1` |
| `SoundManager::player_position_` | `0x0081F0E0` | `Position` (listener). `.frame` at `0x0081F0E8` |
| `SoundManager::s_SoundFeatures` | `0x0086F3A4` | `uint32 = 0`; enum table at `0x0086F3E8` |
| `SoundManager::curr_playing_buffer_` | `0x0086F3A8` | `int32 = 0` — ring cursor |
| `SoundManager::s_bInittedPrefs` | `0x0086F3AC` | `bool = 0` |
| `SoundManager::sound_hash_` | `0x0086F4A0` | `IntrusiveHashTable<DataID, SoundBufRef*>`, ctor arg `0x40` |
| `SoundManager::playing_sounds_` | `0x0086F510` | `SoundPlayingData[0x10]`**16 voices**, stride `0x10` |
| `SoundBuf::useDatabase` | `0x0081F220` | `int32 = 1` |
| `VOL_MIN_DIST` | `0x007CAEAC` (rodata) | `float = 5.0` (metres) |
| `VOL_MIN_DIST_SQ` | `0x0086F404` (.data) | `float = 25.0`, static-init `5f*5f` |
| `INV_LOG_OF_2` | `0x0086F408` (.data) | `double = 1/ln 2 = 1.4426950408889634`, static-init `1.0 / fyl2x(2.0, ln2)` |
| dB-per-octave const | `0x007CAF48` (rodata) | `double = 6.0206` (= `20·log10 2`) |
| **pan scale** | `0x007CAF58` (rodata) | `double = -15.0` |
| probability scale | `0x007CAF50` (rodata) | `float = 3.05185094e-05` (= `1/32767`) |
| DEG→RAD | `0x0079B504` | `float = 0.0174532924` |
| RAD→DEG | `0x0079B6C8` | `double = 57.29577951308232` |
| heading base | `0x0079B6C0` | `double = 450.0` (= `360 + 90`) |
| pan deadzone | `0x007991B0` | `double = 5.0` (metres) |
### Struct layouts (from `acclient.h`, offsets confirmed against the binary)
```c
struct SoundData { // 0x10
DataID sound_id_; // +0x00
float priority_; // +0x04 <-- eviction key
float probability_; // +0x08
float volume_; // +0x0C
};
struct SoundBufRef { // 0x24, operator new(0x24)
DataID m_hashKey; // +0x00
SoundBufRef* m_hashNext; // +0x04
SoundData data_; // +0x08 .. +0x17
int links_; // +0x18 refcount (CreateSound/DestroySound)
SoundBuf* sound_buf_; // +0x1C template buffer, duplicated per play
int buffer_num_; // +0x20 init 0xFFFFFFFF, unused
};
struct SoundPlayingData { // 0x10
SoundBuf* buffer; // +0x00
float priority; // +0x04
long double start_time; // +0x08 (8 bytes, Timer::cur_time) <-- WRITTEN, NEVER READ
};
struct SoundBuf { // 0x20, operator new(0x20)
CDirSound* m_pCDirSound; // +0x00
IDirectSoundBuffer* m_pBuf; // +0x04
IDirectSound3DBuffer* m_p3DBuf; // +0x08
char* m_filename; // +0x0C
int m_tagval; // +0x10
unsigned m_bufsize; // +0x14
int m_3D; // +0x18
DataID m_gid; // +0x1C
};
struct CDirSound { // 0x24, operator new(0x24)
tWAVEFORMATEX m_defaultFormat; // +0x00 (18B, padded to 0x14)
HWND m_hWindow; // +0x14
IDirectSound* m_pDirectSoundObj; // +0x18
IDirectSound3DListener* m_lpDs3dListener; // +0x1C
IDirectSoundBuffer* m_3DSoundBuffer; // +0x20 (primary)
};
struct SoundManager { }; // pure statics, no instance
```
---
## 1. Pseudocode, function by function
### `SoundSetup(HWND)` / `CDirSound::CDirSound` / `SoundOK` / `SoundCleanup`
```
SoundSetup(hwnd):
if hwnd == 0:
Device::Error("SoundSetup requires a valid HWND! Sound will be disabled.",
"SoundSetup Error")
return 0
delete pDirSound # tear down any previous device
pDirSound = new CDirSound(hwnd) # 0x24 bytes
return (pDirSound && pDirSound->m_pDirectSoundObj != 0)
CDirSound::CDirSound(hwnd):
m_pDirectSoundObj = m_lpDs3dListener = m_3DSoundBuffer = null
m_hWindow = hwnd
if DirectSoundCreate(NULL, &m_pDirectSoundObj, NULL) != DS_OK: return
if m_pDirectSoundObj->SetCooperativeLevel(hwnd, DSSCL_PRIORITY /*2*/) != DS_OK:
m_pDirectSoundObj = null; return
# primary buffer
DSBUFFERDESC s = {0}; s.dwSize = 0x24
s.dwFlags = 0x11 # DSBCAPS_PRIMARYBUFFER | DSBCAPS_CTRL3D
if CreateSoundBuffer(&s, &m_3DSoundBuffer, NULL) != DS_OK: return
if m_3DSoundBuffer->QueryInterface(IID_IDirectSound3DListener, &m_lpDs3dListener) != DS_OK: return
m_lpDs3dListener->SetRolloffFactor(0.01f /*0x3C23D70A*/, DS3D_IMMEDIATE)
m_lpDs3dListener->SetOrientation(front=(-1,0,0), top=(0,1,0), DS3D_IMMEDIATE)
m_lpDs3dListener->CommitDeferredSettings()
m_defaultFormat = { PCM, 2ch, 16-bit, 11025 Hz (0x2B11),
nBlockAlign 4, nAvgBytesPerSec 44100 (0xAC44), cbSize 0 }
m_3DSoundBuffer->SetFormat(&m_defaultFormat)
m_3DSoundBuffer->Play(0, 0, DSBPLAY_LOOPING /*1*/) # primary buffer runs forever
SoundOK() -> pDirSound && pDirSound->m_pDirectSoundObj != 0
GetDirectSound()-> pDirSound
SoundCleanup() -> delete pDirSound; pDirSound = null
```
**The 3D listener exists but gameplay never uses it.** `SoundBufRef::SoundBufRef` creates
its template `SoundBuf` with `b3D = 0`, so `SoundBuf::Create` takes the non-3D branch and
sets `m_3D = 0`. Every gameplay/UI/ambient voice is a **2D buffer with CPU-computed pan
and volume**. `IDirectSound3DBuffer` is only reachable through a path nothing in
SoundManager takes.
### `SoundManager::Init` / `InitPrefs` / `ShutDown` / `Cleanup`
```
Init(hwnd):
SoundSetup(InitPrefs()) # note: InitPrefs() returns void; hwnd reaches
# SoundSetup through ecx (__fastcall) — a compiler
# artifact, semantics are InitPrefs(); SoundSetup(hwnd)
midiSetup()
if SoundOK() == 0:
effect_sounds_enabled = 0 # hard-disable effects when there is no device
return
srand(time(0))
InitPrefs(): # exact preference names, in registration order
RegisterPreference(&effect_sound_volume, "Sound Volume") # float, default 1.0
RegisterPreference(&ambient_sound_volume, "Ambient Sound Volume") # float, default 1.0
RegisterPreference(&interface_sound_volume, "Interface Sound Volume") # float, default 1.0
RegisterPreference(&s_SoundFeatures, "Sound Features", enumTable=0x86F3E8, kind=2) # uint, default 0
RegisterPreference(&effect_sounds_enabled, "Sound Disabled") # bool, default 1
RegisterPreference(&ambient_sounds_enabled, "Ambient Sound Disabled") # bool, default 1
RegisterPreference(&interface_sounds_enabled, "Interface Sound Disabled") # bool, default 1
RegisterPreference(&s_bPlaySoundOnlyWhenActive, "Play Sound Only When Active") # bool, default 1
s_bInittedPrefs = 1
ShutDown(): # Cleanup() is a tailcall to this
for each SoundBufRef in sound_hash_: SoundBuf::Stop(ref->sound_buf_)
for i in 0..15:
slot = playing_sounds_[(curr_playing_buffer_ + i) mod 16]
if slot.buffer: Stop(slot.buffer); ~SoundBuf(slot.buffer); delete slot.buffer
midiCleanup(); SoundCleanup()
if s_bInittedPrefs: UnregisterPreference(all 8)
```
Note the enable flags are named `..._Disabled` in the preference store but the backing
variables are `..._enabled` with default 1. Whoever reads the pref file must invert or the
pref writer already stores the inverted sense — do not assume the on-disk polarity.
### `SoundManager::CreateSound` / `DestroySound` — refcounted registration
```
CreateSound(DataID id):
ref = sound_hash_.find(id)
if ref: ref->links_ += 1; return # refcount bump
ref = new SoundBufRef(id) # allocates + creates the template SoundBuf now
sound_hash_.add(ref)
SoundBufRef::SoundBufRef(id):
m_hashKey = id; m_hashNext = null
SoundData::SoundData(&data_) # zero-init
links_ = 1; buffer_num_ = 0xFFFFFFFF
sound_buf_ = new SoundBuf(id, tagval=0, bStatic=1, b3D=0) # 2D, DSBCAPS_STATIC
DestroySound(DataID id):
ref = sound_hash_.find(id); if !ref: return
if ref->links_-- == 1: # last reference
ref = sound_hash_.remove(id)
~SoundBuf(ref->sound_buf_); delete ref->sound_buf_
delete ref
```
A sound that was never `CreateSound`'d cannot be played: every `PlaySound*` walks
`sound_hash_` and silently returns when the id is absent. The wave is decoded and copied
into a DirectSound buffer eagerly at `CreateSound` time (see `SoundBuf::Create`), never on
first play.
### `SoundManager::GetSound` — variant selection (**biased**)
```
GetSound(SoundType stype, CSoundTable* table, out SoundData* d) -> SoundBufRef*:
if table == 0: return 0
if !CSoundTable::Lookup(table, stype, &std): return 0
n = std->num_stdatas_ # [+0x7C]
if n <= 0: return 0
roll = Random::RollDice(0.0f, 1.0f) # [0, 1]
idx = (int)( (float)(n - 1) * roll ) # <-- (n-1), TRUNCATED
if (unsigned)idx >= n: return 0
row = &std->data_[idx] # 16-byte rows at [+0x80]
d->sound_id_ = row[0]; d->priority_ = row[4]
d->probability_ = row[8]; d->volume_ = row[0xC]
if d->sound_id_ == 0: return 0
return sound_hash_.find(d->sound_id_)
```
`idx = floor(roll · (n1))`, **not** `floor(roll · n)`. Consequences:
- `n = 1` → always row 0.
- `n = 2` → row 0 unless the roll is exactly 1.0; row 1 has probability ≈ 1/32768.
- `n = 3` → rows 0 and 1 at ~50% each; row 2 ≈ 1/32768.
The last row of every multi-row sound entry is effectively dead in retail. This is retail
behaviour, not a decomp artifact — the `fild (n-1)` / `fmul st(1)` / `_ftol2` sequence is
unambiguous at `0x005506C8..0x005506E2`.
### `SoundManager::PlayProbability`
```
PlayProbability(float prob):
return ((float)rand() * (1.0f/32767.0f)) < prob # play iff strictly less
```
`probability_` is an **independent gate applied after** the index pick — not a selection
weight. Applied by every `SoundType`-keyed overload and by
`PlaySoundA(DataID, obj, prio, prob, vol)`; **not** applied by
`PlaySoundA(DataID, CPhysicsObj*)` or `PlaySoundFromCenter(DataID, vol)`.
### `SoundManager::GetAttenuation` — the falloff, exact
Byte-level decode of `0x00550020`:
```
GetAttenuation(float dist, float vol, int* outDb, int ambient) -> int:
# 1. distance term
if dist < VOL_MIN_DIST /*5.0 m*/:
g = vol
else:
g = (VOL_MIN_DIST_SQ /*25.0*/ * vol) / (dist * dist) # exact inverse-square,
# continuous at 5 m
# 2. clamp above
if g > 1.0: g = 1.0
# 3. one, and only one, master multiply
g *= (ambient != 0) ? ambient_sound_volume : effect_sound_volume
# 4. silent gate
if g <= 0.0: *outDb = VOL_MIN /*-50*/; return 0 # DO NOT PLAY
# 5. linear gain -> integer decibels
# fldln2; fyl2x => ln(g)
# * INV_LOG_OF_2 (1/ln2) => log2(g)
# * 6.0206 (20*log10 2) => 20*log10(g)
db = (int) ceil( 20.0 * log10(g) )
*outDb = db
if db >= VOL_MIN /*-50*/: return 1 # PLAY at db decibels
*outDb = VOL_MIN; return 0 # DO NOT PLAY
```
Notes that matter:
- The distance model is **inverse-square with a 5-metre reference**, expressed as
`25/d²`, clamped to unity, and it is **hard-cut at 50 dB**. Solving
`ceil(20·log10(25·vol·master/d²)) ≥ 50``25·vol·master/d² > 10^(51/20)`:
the audible radius is **≈ 94.2 m** at `vol·master = 1.0`, **≈ 66.6 m** at 0.5,
**≈ 29.8 m** at 0.1. Beyond that the sound is *never started* — no voice, no slot.
- Output is **integer decibels quantised by `ceil`** — a 1 dB stair-step as you walk
toward a source, not a smooth ramp.
- `dist` is `Position::distance` (`0x005A94B0`): `sqrt(dx²+dy²+dz²)` of
`Position::get_offset`, which resolves the landblock delta first — a true cross-landblock
3D metric distance in metres, **including Z**.
- `vol` here is whatever the caller passed. Three callers pre-multiply by a master
volume, so the master lands **twice** (see §4 quirk).
### `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` — pan
Byte-level decode of `0x00550170`. **BN's pseudo-C is wrong here**: it reuses stack slot
`[esp+0xC]` and reports the `< 5.0` test as an *angle* test. In the binary the `_ftol2`
at `0x005501F2` converts `[esp+4]` = **distance**, and the x87 stack still holds the
angle. It is a *distance* deadzone.
```
PlaySoundInternal(ref, const Position* soundPos, float vol, int ambient):
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
listenerHeading = Frame::get_heading(&player_position_.frame) # degrees
dist = Position::distance(soundPos, &player_position_) # metres, 3D
headingSoundToListener = Position::heading(soundPos, &player_position_) # degrees
pan = 0
if s_SoundFeatures != 1: # 1 == panning disabled
delta = fmod(headingSoundToListener - listenerHeading, 360.0)
if !(delta <= 180.0): delta -= 360.0 # normalise to (-180, 180]
if abs((int)dist) >= 5: # <-- DISTANCE deadzone, 5 m
pan = (int)( sin(delta * 0.0174532924f) * -15.0 ) # -15..+15
# else pan stays 0: anything inside 5 m plays dead centre
if GetAttenuation(dist, vol, &db, ambient):
PlaySoundInternal(ref, pan, db)
```
`Position::heading(this, other)` (`0x005A9520`) and `Frame::get_heading` (`0x00535760`)
share one convention: `fmod(450.0 atan2(dy, dx)·57.29578, 360.0)` — i.e. **compass
degrees, clockwise from +Y (north)**, `+X` (east) = 90°. `Position::heading` returns the
heading **from `this` toward `other`**, so `heading(soundPos, listenerPos)` is the
*reverse* bearing; combining that reversal with the `-15.0` scale yields the correct
handedness. Worked check: sound due east, listener facing north ⇒ delta = 90° ⇒
`pan = -15·sin(-90°) = +15` = full right in DirectSound. ✓
Equivalent forward formulation for a port:
> `pan_dB = 15 · sin(bearing_of_source_relative_to_listener_facing)`, zero inside 5 m.
**There is no front/back and no elevation cue.** A source dead ahead and a source directly
behind both give `pan = 0`; Z contributes to distance but never to pan.
### `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` — the 16-voice pool
Byte-level decode of `0x0054FEC0` (this, not `FUN_00550AD0`, is the voice allocator):
```
PlaySoundInternal(ref, pan, volDb):
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
now = Timer::cur_time # 8-byte double
# PASS 1 — ring scan from curr_playing_buffer_ for a reusable slot
for i in 0 .. 15:
s = (curr_playing_buffer_ + i) mod 16 # signed-safe & 0x8000000F fixup
buf = playing_sounds_[s].buffer
if buf == null: goto CLAIM # never used
if buf->m_pBuf == null: goto DESTROY_CLAIM # broken buffer
if (SoundBuf::GetStatus(buf) & DSBSTATUS_PLAYING) == 0:
goto DESTROY_CLAIM # finished
# slot is genuinely busy, keep scanning
# PASS 2 — all 16 busy: priority eviction, same ring order
for j in 0 .. 15:
s = (curr_playing_buffer_ + j) mod 16
if playing_sounds_[s].priority < ref->data_.priority_: # STRICTLY less
SoundBuf::Stop(playing_sounds_[s].buffer)
goto DESTROY_CLAIM
return # nothing lower-priority -> DROP the new sound
DESTROY_CLAIM:
~SoundBuf(buf); delete buf # a voice is a real DS buffer; it is freed
CLAIM:
v = new SoundBuf(0x20)
if v: SoundBuf::SoundBuf(v, ref->sound_buf_) # IDirectSound::DuplicateSoundBuffer
playing_sounds_[s].buffer = v
playing_sounds_[s].priority = ref->data_.priority_
playing_sounds_[s].start_time = now # written, never read anywhere
curr_playing_buffer_ = (s + 1) mod 16
SoundBuf::Play(v, pan, volDb)
```
Answers to the slot questions:
- **Count:** exactly 16 (`playing_sounds_[0x10]`, `& 0x8000000F` masking).
- **Selection:** round-robin from `curr_playing_buffer_`; first slot that is empty, has a
null `m_pBuf`, or is no longer `DSBSTATUS_PLAYING`.
- **Eviction:** *priority only*, `slot.priority < new.priority`, first match in ring order.
**Equal priority never evicts.** Volume/gain is not consulted. `start_time` is recorded
but never read, so age only enters through the ring cursor.
- **Overflow:** the new sound is silently dropped.
### `SoundBuf::Create` / `CopyWaveToBuffer` / `Restore` / `Play` / `Stop` / `GetStatus`
```
SoundBuf::SoundBuf(DataID gid, int tagval, int bStatic, int b3D):
m_pBuf = m_p3DBuf = m_filename = null; m_bufsize = 0
m_tagval = tagval; m_3D = b3D; m_gid = gid
m_pCDirSound = GetDirectSound()
if m_pCDirSound: Create(bStatic)
SoundBuf::Create(int bStatic) -> int:
ds = m_pCDirSound->m_pDirectSoundObj; if !ds: return 0
if m_3D == 0 || m_pCDirSound->m_lpDs3dListener == null:
flags = 0x100E0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLPAN | CTRLFREQUENCY
m_3D = 0
else:
flags = 0x100B0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLFREQUENCY | CTRL3D
if bStatic: flags |= DSBCAPS_STATIC /*0x2*/
if SoundBuf::useDatabase /*1*/:
obj = DBObj::Get(QualifiedDataID(m_gid, 0x0F)) # 0x0F = Wave
wave = obj + 0x38
DSBUFFERDESC s = {0}; s.dwSize = 0x24; s.dwFlags = flags
... lpwfxFormat = wave fmt; dwBufferBytes = wave data size ...
if ds->CreateSoundBuffer(&s, &m_pBuf, NULL) == DS_OK:
m_bufsize = size
if CopyWaveToBuffer(wave): return 1
return 0
SoundBuf::CopyWaveToBuffer(WaveFile* w) -> int:
Lock(0, m_bufsize, &p1,&n1, &p2,&n2, 0)
if global ACM stream `phas` != null: acmStreamPrepareHeader/Convert/UnprepareHeader
else: memcpy p1 (+ wrap into p2)
Unlock(...)
SoundBuf::SoundBuf(const SoundBuf& src): # per-play voice
zero everything; m_pCDirSound = GetDirectSound()
if m_pCDirSound->m_pDirectSoundObj->DuplicateSoundBuffer(src.m_pBuf, &m_pBuf) == DS_OK:
copy m_bufsize, m_tagval, m_3D, m_gid
if m_3D: m_pBuf->QueryInterface(IID_IDirectSound3DBuffer, &m_p3DBuf)
SoundBuf::Play(int pan, int volDb) -> int:
if pan < -15: pan = -15
elif pan > 15: pan = 15
if pan != 0 && m_pBuf && m_3D == 0: m_pBuf->SetPan(pan * 100) # hundredths of dB
if volDb < VOL_MIN /*-50*/: volDb = VOL_MIN
if m_pBuf: m_pBuf->SetVolume(volDb * 100) # hundredths of dB
if m_pBuf->SetCurrentPosition(0) == DS_OK:
hr = m_pBuf->Play(0, 0, 0) # dwFlags 0 — NO LOOPING
if hr == DSERR_BUFFERLOST /*0x88780096*/ && Restore():
hr = m_pBuf->Play(0, 0, 0)
return hr == DS_OK
return 0
SoundBuf::Stop() -> m_pBuf ? (m_pBuf->Stop(), 1) : 0
SoundBuf::GetStatus() -> m_pBuf && GetStatus(&st)==DS_OK ? st : -1 # bit0 = DSBSTATUS_PLAYING
SoundBuf::Restore() -> re-fetch the wave from the DAT and re-run the Create/CopyWave path
SoundBuf::ReleaseAll()-> delete[] m_filename; Release m_pBuf, m_p3DBuf; memset; m_gid = INVALID
```
Units to keep straight: retail's internal volume/pan are **whole decibels**; DirectSound's
`SetVolume`/`SetPan` take **hundredths of a decibel**, hence the `× 100`. Retail's floor is
`-50 dB` (`-5000`), half of DirectSound's `DSBVOLUME_MIN = -10000`. Pan saturates at
`±15 dB` (`±1500`) out of DirectSound's `±10000`, so retail's stereo image is **narrow by
construction** — a hard-panned sound is only 15 dB down in the far ear, never silent.
### Listener: `SetPlayerPosition`
```
SetPlayerPosition(const Position* p):
player_position_.objcell_id = p->objcell_id
Frame::operator=(&player_position_.frame, &p->frame)
```
Who writes it, and when:
| Caller | Source | Cadence |
|---|---|---|
| `SmartBox::set_viewer` (`0x00452D36`) | `SmartBox::viewer` | see below |
| `CreatureMode::Render` (`0x00452A83`, `0x00452AAE`) | `creature_view_frame`, then restores the saved `player_position_` | per creature-mode frame |
`SmartBox::set_viewer(pos, type)` copies `pos` into `SmartBox::viewer` and then hands
`&this->viewer` to `SoundManager::SetPlayerPosition`, `LScape::set_sky_position`, and
`SceneTool::SetupCamera` — so **the audio listener is the same Position the camera uses.**
Its writers:
- `SmartBox::update_viewer` (`0x00453CE0`), called from `SmartBox::DrawNoBlit`
(`0x00454C34`) — **once per rendered frame**. It runs the third-person camera through a
`CTransition` sphere sweep (`viewer_sphere`) and sets the viewer to the *collided camera
position* (`type = 0`); on sweep failure it falls back to
`set_viewer(&player->m_position, 1)`.
- `SmartBox::PlayerPositionUpdated` / `TeleportPlayer` / `BlipPlayer`
`set_viewer(&player->m_position, 1)`, event-driven.
So: **listener = camera viewer position + that Position's `Frame` heading, in world/cell
space (objcell_id + Frame), refreshed every rendered frame.** Only two things are read out
of it: the frame origin (distance) and `Frame::get_heading` (pan). No up vector, no
velocity ⇒ **no doppler, no roll/pitch influence, no elevation cue**.
### Looping and the ambient driver
`SoundBuf::Play` always passes `dwFlags = 0`. **Nothing in SoundManager ever loops.** The
only looped buffer in the client is `CDirSound`'s primary buffer.
Sustained ambience is a **re-trigger scheduler**:
```
Ambient::Play(AmbientSound* a):
if !a->CanHear(): a->on_queue = 0; return
if a->PlayNow():
if a->GetSoundPos(&pos): Ambient::PlaySoundA(stype, table, &pos, a->GetVolume())
else: PlayAmbientSoundFromCenter(stype, table, a->GetVolume())
Insert(&sound_queue, Timer::cur_time + a->GetPlayInterval(), a) # PQueueArray<double>
a->on_queue = 1
Ambient::UseTime(): # SmartBox::UseTime -> per game tick
if !ambient_sounds_enabled: return
while sound_queue not empty and sound_queue.top().key <= Timer::cur_time:
pop and Ambient::Play(it)
Ambient::PlaySoundA(stype, table, pos, vol):
pos ? PlayAmbientSound(stype, table, pos, vol) : PlayAmbientSoundFromCenter(stype, table, vol)
```
`ConstantSound` (has `current_volume`) and `IntermitSound` (has `play_chance`,
`min_dist[8]`, `max_dist[8]`, `num_dir`, `sound_dir[8]`) are the two `AmbientSound`
subclasses supplying `GetVolume` / `GetPlayInterval` / `CanHear` / `PlayNow`.
`Ambient::AddSound` gates on `Ambient::ambient_sound_max_dist_sq` and weights with
`Ambient::CalcWeight` (which uses `ambient_sound_min_dist_sq` / `..._max_dist_sq`).
### Pitch / frequency
`DSBCAPS_CTRLFREQUENCY (0x20)` is requested on **every** buffer, and
`IDirectSoundBuffer::SetFrequency` is **never called anywhere in the binary**. Retail has
**no pitch or frequency variation** on sound effects. There is no `PitchMin`/`PitchMax`
concept in `SoundData` — the four fields are `sound_id_`, `priority_`, `probability_`,
`volume_`, full stop.
### Per-frame voice maintenance
`SetPan` and `SetVolume` are called from exactly one place: `SoundBuf::Play`. There is no
SoundManager tick, no `UseTime`, no reposition pass. **A voice keeps the pan and volume it
was born with for its entire lifetime.** If a drudge emits a footstep and then runs past
you, that footstep does not move. If a source is beyond ≈94 m the sound is never started
at all rather than started quietly.
---
## 2. acdream today
`OpenAlAudioEngine.Play3DWave` is the only live 3D path (called from
`AudioHookSink.Play`, i.e. animation `SoundHook` / `SoundTableHook` / `SoundTweakedHook`).
It:
1. computes `effectiveGain = volume * SfxVolume`, drops if `< 0.001f`;
2. uploads/reuses an AL buffer (LRU byte-budgeted, 48 MiB);
3. picks a slot: first free-or-not-playing in ring order, else first with
`PlayingGain < effectiveGain`, else drop;
4. sets `Gain = effectiveGain`, `Pitch`, `Position`, `SourceRelative = false`,
`Looping = false`, plays;
5. records `PlayingGain`, `PriorityBase = clamp((int)priority, 0, 7)`, advances the cursor.
Sources are configured once (`Configure3DSource`): `MaxDistance = 1000`,
`RolloffFactor = 1`, `ReferenceDistance = 2`, and the global model is
`DistanceModel.InverseDistanceClamped` (`SelectRetailDistanceModel`).
`SetListener` is called per frame from `WorldRenderFrameBuilder.Apply` with the camera
position and a real forward/up pair derived from `camera.InverseView`;
`MasterVolume` is pushed into `AL_GAIN` on the listener.
`AudioFalloff.AttenuationAt` and `AudioFalloff.PanFromRelative` in
`AcDream.Core/Audio/AudioModel.cs` are **dead code**`grep` across `src/` and `tests/`
finds no caller.
---
## 3. Divergence table
| # | Aspect | Retail (verified) | acdream | Severity |
|---|---|---|---|---|
| D1 | Voice-allocator citation | `SoundManager::PlaySoundInternal(SoundBufRef*,int,int)` at **`0x0054FEC0`** | comment cites `FUN_00550AD0` / `chunk_00550000.c:527`; `0x00550AD0` is inside `IntrusiveHashTable<DataID,SoundBufRef*>::ctor` (`0x00550A60`) — **wrong function** | doc bug, fix the citation |
| D2 | Eviction key | `slot.priority < new.priority` (float `SoundData.priority_` from the SoundTable), strictly less; equal never evicts; gain never consulted | `slot.PlayingGain < effectiveGain` (volume × SfxVolume); `PriorityBase` stored but unused | **behavioural — loud-and-unimportant beats quiet-and-important** |
| D3 | Priority type/range | `float`, unclamped, straight from the DAT | `clamp((int)priority, 0, 7)`; model comments it "0..7" | flattens the ordering |
| D4 | Distance model | inverse **square** with 5 m reference: `min(1, 25·vol/d²)` | OpenAL `InverseDistanceClamped`, ref 2 m, rolloff 1 ⇒ `2/max(d,2)` — inverse **first power**, 2 m reference | **behavioural, large** |
| D5 | Dead falloff helper | — | `AttenuationAt(d, minDistance = 1.0f)`: right shape, wrong reference (1 m vs 5 m), and never called | dead + wrong |
| D6 | Audible cutoff | hard drop when `ceil(20·log10 g) < 50 dB`**≈94.2 m** at vol·master 1.0 (≈66.6 m at 0.5, ≈29.8 m at 0.1); the voice is never allocated | no distance cutoff; only `effectiveGain < 0.001f` (≈ 60 dB, distance-independent) | far sounds audible that retail silences; wasted voices |
| D7 | Gain quantisation | `ceil` to whole decibels, floor 50 dB | continuous float gain | subtle; retail stair-steps |
| D8 | Pan computation | CPU-side, angular: `pan_dB = (int)(15·sin(Δheading))`, `Δheading` = normalise180(heading(src→listener) listenerHeading); saturates at ±15 dB; **zero when `(int)distance < 5`**; no front/back, no elevation | OpenAL panner from full 3D vectors: front/back distinguished, elevation contributes, no 5 m deadzone, full stereo separation | **behavioural — our image is wider and 3D; retail's is a narrow 15 dB angular pan** |
| D9 | Dead pan helper | — | `PanFromRelative(relativeX, panRange = 20f)`: linear in relative X, invented 20 m constant, no retail counterpart, never called | dead + wrong |
| D10 | Pan disable switch | `s_SoundFeatures == 1` ⇒ pan forced 0 | none | missing pref |
| D11 | Volume knobs | 3 sliders (`"Sound Volume"`, `"Ambient Sound Volume"`, `"Interface Sound Volume"`) + 4 bools; **no master, no music volume**; exactly one multiply, inside `GetAttenuation` | `MasterVolume` (AL listener gain), `SfxVolume`, `MusicVolume = 0.7`, `AmbientVolume = 0.8` (last two unused) | different taxonomy; defaults 0.7/0.8 are invented |
| D12 | Retail quirk: squared master | `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as `vol`, and `GetAttenuation` multiplies by `effect_sound_volume` again ⇒ **effect volume squared**. Same for `PlayAmbientSound*`, which pre-multiply by `ambient_sound_volume`**ambient volume squared** | single multiply | port decision needed — faithful = squared |
| D13 | Retail quirk: dead knob | `interface_sound_volume` is registered and never read; interface sounds are scaled by **`effect_sound_volume`** (`GetAttenuation` called with `ambient = 0`) | n/a | do not implement an interface-volume slider that works |
| D14 | Active-app gate | `s_bPlaySoundOnlyWhenActive` (default **1**) + `Device::m_bIsActiveApp` checked in every entry point and in both `PlaySoundInternal` overloads | none — acdream keeps playing when unfocused | missing pref/behaviour |
| D15 | Listener source | `SmartBox::viewer` = collided third-person camera Position (falls back to `player->m_position`), per rendered frame; only origin + `Frame::get_heading` are read | camera position **plus** real forward/up from `InverseView`, per frame | ours is richer than retail — and that richness is what creates D8's front/back cue. Not "fixed orientation" as suspected; it is live |
| D16 | Doppler / velocity | none (no listener or source velocity ever set) | none set either (AL defaults 0) | ✓ match |
| D17 | Per-frame reposition | none. Pan+volume frozen at emission; `SoundPlayingData.start_time` written, never read | source position also set once — but OpenAL re-evaluates distance/pan against the live listener every frame, so our voices **do** sweep as the listener moves | **behavioural: retail voices are frozen in the listener frame; ours are world-static and continuously re-panned** |
| D18 | Looping | never (`Play(0,0,0)`); sustained ambience = `Ambient` PQueue re-trigger on `Timer::cur_time + GetPlayInterval()`, drained in `Ambient::UseTime` per tick, gated by `CanHear`/`PlayNow` | `Looping = false` always ✓, but `SoundEntry.Loop` exists unused and `StartAmbient` only reserves a handle — **no ambient layer at all** | whole subsystem missing (not a math divergence) |
| D19 | Pitch | `SetFrequency` never called; `SoundData` has no pitch fields | `SoundEntry.PitchMin/PitchMax` invented; `pitch` plumbed but always 1.0 | ✓ matches in effect; model carries fictional fields |
| D20 | Variant selection | `idx = (int)(RollDice(0,1) · (n1))` — last row unreachable (p≈1/32768); then `probability_` is an **independent gate** `rand()/32767 < prob` | `SoundCookbook.Roll` treats `Probability` as a **cumulative weight** with a silence tail | **behavioural — wrong distribution both ways** |
| D21 | Probability applied where | `SoundType` overloads + the 5-arg DID overload; **not** `PlaySoundA(DataID,obj)` nor `PlaySoundFromCenter(DataID,vol)` | uniform | minor |
| D22 | Buffer residency | refcounted `CreateSound`/`DestroySound`; wave decoded + copied to a DS buffer eagerly at register time; a play of an unregistered id is a silent no-op; each voice is a `DuplicateSoundBuffer` freed on slot reuse | lazy upload on first play, 48 MiB LRU; 16 persistent AL sources | legitimate modern adaptation; note only that our LRU can evict what retail pins, and we can hitch on first play |
| D23 | Device init | `DirectSoundCreate` + `SetCooperativeLevel(DSSCL_PRIORITY)`; primary buffer `DSBCAPS_PRIMARYBUFFER|CTRL3D`, format PCM 2ch/16-bit/11025 Hz; 3D listener rolloff 0.01, front (1,0,0), top (0,1,0) — **all unused because every gameplay buffer is `m_3D = 0`** | OpenAL-Soft default device, 3D sources | fine; do not port the 3D listener |
### D4/D6 numbers side by side (vol = master = 1.0)
> **Corrected 2026-08-08 at the A2 code review:** the 30 m row read 35 dB, which
> contradicted both its own gain column (0.0278) and the formula —
> `ceil(20·log10 0.027778) = ceil(-31.13) = -31`. It is now 31. The conformance
> tests in `RetailSoundMixerTests` recompute every row from the decoded formula
> rather than reading this table, which is how the slip surfaced.
| distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB |
|---|---|---|---|---|
| 2 m | 1.000 | 0 | 1.000 | 0.0 |
| 5 m | 1.000 | 0 | 0.400 | 8.0 |
| 10 m | 0.250 | 12 | 0.200 | 14.0 |
| 20 m | 0.0625 | 24 | 0.100 | 20.0 |
| 30 m | 0.0278 | 31 | 0.0667 | 23.5 |
| 50 m | 0.0100 | 40 | 0.0400 | 28.0 |
| 90 m | 0.00309 | 50 (last audible) | 0.0222 | 33.1 |
| ≥94.2 m | — | **not played** | 0.0212 | 33.5 |
| 200 m | — | **not played** | 0.0100 | 40.0 |
Retail is *louder near* and *silent far*; acdream is *quieter near* and *audible
everywhere*. This is the single largest audible divergence.
---
## 4. Port-ready summary (what a faithful `RetailSoundMixer` needs)
```
Constants
VOL_MIN_DIST = 5.0f metres
VOL_MIN_DIST_SQ = 25.0f
VOL_MIN = -50 decibels
PAN_SCALE = -15.0 (applied to sin of the reversed bearing)
PAN_DEADZONE = 5 metres, compared against (int)distance
VOICES = 16
dB(g) = ceil(20 * log10(g)) // g in (0,1]
Per play (3D):
dist = |listener.origin - source.origin| // 3D, cross-landblock, metres
g = dist < 5 ? vol : 25*vol/(dist*dist)
g = min(g, 1)
g *= isAmbient ? ambientVolume : effectVolume // ONE multiply
if g <= 0: drop
db = ceil(20*log10(g)); if db < -50: drop
delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees )
pan = (int)(-15 * sin(delta * pi/180)) # TRUNCATE toward zero (retail _ftol2),
# NOT floor: they differ by 1 dB for
# negative pans. Corrected 2026-08-08 at
# the A2 review; §1 was already right.
if (int)dist < 5: pan = 0
allocate voice: ring scan from cursor for free/finished;
else first slot with slotPriority < newPriority;
else DROP
gain_linear = 10^(db/20); pan_linear = ±(1 - 10^(-|pan|/20)) style 15 dB max separation
play once (no loop); never touch pan/gain again for this voice
```
For OpenAL specifically: set `AL_SOURCE_RELATIVE = true` and place the source at a
synthetic listener-relative point that reproduces the 15 dB pan (or use the stereo-panning
extension), with `AL_ROLLOFF_FACTOR = 0` so OpenAL's distance model is out of the loop and
the retail dB/pan pair is authoritative. Trying to bend `InverseDistanceClamped` into
`25/d²` is not possible — OpenAL's inverse model is first-power only; `AL_INVERSE_DISTANCE`
with rolloff cannot produce a squared curve, and `AL_EXPONENT_DISTANCE` with
`AL_ROLLOFF_FACTOR = 2` gives `(d/ref)^-2` which *does* match `25/d²` for `ref = 5`
that is the one-line fix if we want to keep the gain in AL rather than on the CPU.
(`AL_EXPONENT_DISTANCE_CLAMPED`, `AL_REFERENCE_DISTANCE = 5`, `AL_ROLLOFF_FACTOR = 2`,
`AL_MAX_DISTANCE = 94.2` reproduces D4 and D6 together; the ±15 dB pan and the 5 m pan
deadzone still have to be CPU-side.)
## 5. Decomp hazards found (worth a memory note)
1. **BN elides x87 memory constants.** `GetAttenuation`'s pseudo-C prints
`((long double)0f) * arg2 / (arg1*arg1)` and `... * ((long double)0.0) * 6.0206`. The
binary has `fmul dword [VOL_MIN_DIST_SQ]` (25.0) and `fmul qword [INV_LOG_OF_2]`
(1/ln 2). Reading the pseudo-C alone yields *zero gain at all distances*.
2. **BN misattributes reused stack slots.** In `PlaySoundInternal(pos)` it reports the
`< 5.0` comparison as an angle test on `var_4`; the binary converts `[esp+4]` =
**distance**. Porting the pseudo-C gives a 5-degree pan deadzone instead of a 5-metre
one.
3. `SoundManager` has **no instance** (`struct SoundManager {}` in `acclient.h`) — every
field is a file-scope static. Do not look for a `this`.