acdream/docs/research/2026-08-08-audio-retail-soundmanager-core.md
Erik ffa5087527 docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.

Plan proposes slices A1-A6; awaiting user go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:55:33 +02:00

38 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)

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 35 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)floor(-15 * sin(delta * pi/180))  clamped [-15, 15]
  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.