# 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`, 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 · (n−1))`, **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 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::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) · (n−1))` — 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`.