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>
39 KiB
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 base0x00400000) via capstone — required, because the BN pseudo-C forGetAttenuationand the pan block has FPU-elided constants (it prints* 0fwhere the binary hasfmul 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.cssrc/AcDream.App/Audio/OpenAlResourceLifetime.cssrc/AcDream.App/Audio/AudioHookSink.cssrc/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)
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 — thefild (n-1)/fmul st(1)/_ftol2sequence is unambiguous at0x005506C8..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. Solvingceil(20·log10(25·vol·master/d²)) ≥ −50⟺25·vol·master/d² > 10^(−51/20): the audible radius is ≈ 94.2 m atvol·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. distisPosition::distance(0x005A94B0):sqrt(dx²+dy²+dz²)ofPosition::get_offset, which resolves the landblock delta first — a true cross-landblock 3D metric distance in metres, including Z.volhere 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],& 0x8000000Fmasking). - Selection: round-robin from
curr_playing_buffer_; first slot that is empty, has a nullm_pBuf, or is no longerDSBSTATUS_PLAYING. - Eviction: priority only,
slot.priority < new.priority, first match in ring order. Equal priority never evicts. Volume/gain is not consulted.start_timeis 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 fromSmartBox::DrawNoBlit(0x00454C34) — once per rendered frame. It runs the third-person camera through aCTransitionsphere sweep (viewer_sphere) and sets the viewer to the collided camera position (type = 0); on sweep failure it falls back toset_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:
- computes
effectiveGain = volume * SfxVolume, drops if< 0.001f; - uploads/reuses an AL buffer (LRU byte-budgeted, 48 MiB);
- picks a slot: first free-or-not-playing in ring order, else first with
PlayingGain < effectiveGain, else drop; - sets
Gain = effectiveGain,Pitch,Position,SourceRelative = false,Looping = false, plays; - 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 |
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 inRetailSoundMixerTestsrecompute 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)
- 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 hasfmul dword [VOL_MIN_DIST_SQ](25.0) andfmul qword [INV_LOG_OF_2](1/ln 2). Reading the pseudo-C alone yields zero gain at all distances. - BN misattributes reused stack slots. In
PlaySoundInternal(pos)it reports the< 5.0comparison as an angle test onvar_4; the binary converts[esp+4]= distance. Porting the pseudo-C gives a 5-degree pan deadzone instead of a 5-metre one. SoundManagerhas no instance (struct SoundManager {}inacclient.h) — every field is a file-scope static. Do not look for athis.