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>
714 lines
39 KiB
Markdown
714 lines
39 KiB
Markdown
# 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 · (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<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) · (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`.
|