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

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

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

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

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

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

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

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

39 KiB
Raw Blame History

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.0write-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 · (n1)), not floor(roll · n). Consequences:

  • n = 1 → always row 0.
  • n = 2 → row 0 unless the roll is exactly 1.0; row 1 has probability ≈ 1/32768.
  • n = 3 → rows 0 and 1 at ~50% each; row 2 ≈ 1/32768. The last row of every multi-row sound entry is effectively dead in retail. This is retail behaviour, not a decomp artifact — the fild (n-1) / fmul st(1) / _ftol2 sequence is unambiguous at 0x005506C8..0x005506E2.

SoundManager::PlayProbability

PlayProbability(float prob):
  return ((float)rand() * (1.0f/32767.0f)) < prob      # play iff strictly less

probability_ is an independent gate applied after the index pick — not a selection weight. Applied by every SoundType-keyed overload and by PlaySoundA(DataID, obj, prio, prob, vol); not applied by PlaySoundA(DataID, CPhysicsObj*) or PlaySoundFromCenter(DataID, vol).

SoundManager::GetAttenuation — the falloff, exact

Byte-level decode of 0x00550020:

GetAttenuation(float dist, float vol, int* outDb, int ambient) -> int:
  # 1. distance term
  if dist < VOL_MIN_DIST /*5.0 m*/:
      g = vol
  else:
      g = (VOL_MIN_DIST_SQ /*25.0*/ * vol) / (dist * dist)   # exact inverse-square,
                                                             # continuous at 5 m
  # 2. clamp above
  if g > 1.0: g = 1.0

  # 3. one, and only one, master multiply
  g *= (ambient != 0) ? ambient_sound_volume : effect_sound_volume

  # 4. silent gate
  if g <= 0.0: *outDb = VOL_MIN /*-50*/; return 0        # DO NOT PLAY

  # 5. linear gain -> integer decibels
  #    fldln2; fyl2x  => ln(g)
  #    * INV_LOG_OF_2 (1/ln2)  => log2(g)
  #    * 6.0206 (20*log10 2)   => 20*log10(g)
  db = (int) ceil( 20.0 * log10(g) )

  *outDb = db
  if db >= VOL_MIN /*-50*/: return 1                    # PLAY at db decibels
  *outDb = VOL_MIN; return 0                            # DO NOT PLAY

Notes that matter:

  • The distance model is inverse-square with a 5-metre reference, expressed as 25/d², clamped to unity, and it is hard-cut at 50 dB. Solving ceil(20·log10(25·vol·master/d²)) ≥ 5025·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 / BlipPlayerset_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 codegrep 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_volumeambient volume squared single multiply port decision needed — faithful = squared
D13 Retail quirk: dead knob interface_sound_volume is registered and never read; interface sounds are scaled by effect_sound_volume (GetAttenuation called with ambient = 0) n/a do not implement an interface-volume slider that works
D14 Active-app gate s_bPlaySoundOnlyWhenActive (default 1) + Device::m_bIsActiveApp checked in every entry point and in both PlaySoundInternal overloads none — acdream keeps playing when unfocused missing pref/behaviour
D15 Listener source SmartBox::viewer = collided third-person camera Position (falls back to player->m_position), per rendered frame; only origin + Frame::get_heading are read camera position plus real forward/up from InverseView, per frame ours is richer than retail — and that richness is what creates D8's front/back cue. Not "fixed orientation" as suspected; it is live
D16 Doppler / velocity none (no listener or source velocity ever set) none set either (AL defaults 0) ✓ match
D17 Per-frame reposition none. Pan+volume frozen at emission; SoundPlayingData.start_time written, never read source position also set once — but OpenAL re-evaluates distance/pan against the live listener every frame, so our voices do sweep as the listener moves behavioural: retail voices are frozen in the listener frame; ours are world-static and continuously re-panned
D18 Looping never (Play(0,0,0)); sustained ambience = Ambient PQueue re-trigger on Timer::cur_time + GetPlayInterval(), drained in Ambient::UseTime per tick, gated by CanHear/PlayNow Looping = false always ✓, but SoundEntry.Loop exists unused and StartAmbient only reserves a handle — no ambient layer at all whole subsystem missing (not a math divergence)
D19 Pitch SetFrequency never called; SoundData has no pitch fields SoundEntry.PitchMin/PitchMax invented; pitch plumbed but always 1.0 ✓ matches in effect; model carries fictional fields
D20 Variant selection idx = (int)(RollDice(0,1) · (n1)) — last row unreachable (p≈1/32768); then probability_ is an independent gate rand()/32767 < prob SoundCookbook.Roll treats Probability as a cumulative weight with a silence tail behavioural — wrong distribution both ways
D21 Probability applied where SoundType overloads + the 5-arg DID overload; not PlaySoundA(DataID,obj) nor PlaySoundFromCenter(DataID,vol) uniform minor
D22 Buffer residency refcounted CreateSound/DestroySound; wave decoded + copied to a DS buffer eagerly at register time; a play of an unregistered id is a silent no-op; each voice is a DuplicateSoundBuffer freed on slot reuse lazy upload on first play, 48 MiB LRU; 16 persistent AL sources legitimate modern adaptation; note only that our LRU can evict what retail pins, and we can hitch on first play
D23 Device init DirectSoundCreate + SetCooperativeLevel(DSSCL_PRIORITY); primary buffer `DSBCAPS_PRIMARYBUFFER CTRL3D, format PCM 2ch/16-bit/11025 Hz; 3D listener rolloff 0.01, front (1,0,0), top (0,1,0) — **all unused because every gameplay buffer is m_3D = 0`** OpenAL-Soft default device, 3D sources

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.