acdream/docs/research/2026-08-08-audio-retail-dat-layer.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

35 KiB
Raw Blame History

Lane 4 — Sound DAT layer: retail vs DatReaderWriter vs acdream

Read-only audit, 2026-08-08. Three oracles used:

  1. Retail decompdocs/research/named-retail/acclient_2013_pseudo_c.txt + acclient.h (Sept 2013 EoR, PDB-named).
  2. The PDB-paired binaryC:\Users\erikn\Downloads\acclient.exe v11.4186 (check_exe_pdb.py MATCH). Used to recover every constant Binary Ninja elided through the x87 stack (0f, 0.0, garbled fyl2x chains). This was necessary: three of the four load-bearing numbers in this subsystem are invisible in the pseudo-C.
  3. The shipped dats%USERPROFILE%\Documents\Asheron's Call\*.dat, walked with an independent Python B-tree reader using retail's byte order (not DRW's). All 190 sound tables and 786 waves parsed cleanly, which is itself an external cross-check that DRW's layout is right.

Scratchpad tooling: lane4_pe.py (PE/VA reader + xref scan), lane4_datscan3.py (dat B-tree + SoundTable/Wave scanner).


0. Retail function map (VA, imagebase 0x400000)

Symbol VA
CSoundTable::UnPack 0x00551CD0
SoundTableData::UnPack 0x00552370
SoundTableData::Lookup 0x005520A0
CSoundTable::Lookup 0x00552100
SoundManager::GetSound 0x00550680
SoundManager::PlayProbability 0x005500E0
SoundManager::GetAttenuation 0x00550020
SoundManager::PlaySoundInternal(buf, Position*, vol, isAmbient) 0x00550170
SoundManager::PlaySoundInternal(buf, pan, vol) 0x0054FEC0
SoundManager::CreateSound 0x00550BF0
SoundManager::PlaySoundA (4 overloads) 0x00550730, 0x005507A0, 0x00550AF0, 0x00550B70
SoundManager::PlayAmbientSound 0x00550820
SoundManager::PlaySoundFromCenter 0x00550950, 0x005509E0
DBWave::UnPack 0x00551B90
SoundBuf::Create 0x00552930
SoundBuf::CopyWaveToBuffer 0x005526D0
Random::RollDice(float,float) 0x0042C600
Random::rand 0x0042C4C0
CSoundDesc::UnPack 0x005028D0
static init VOL_MIN_DIST_SQ = 5f*5f 0x00706490

Constants recovered from the binary (all elided by BN)

Address Type Value Meaning
0x007CAEAC f32 5.0 VOL_MIN_DIST — falloff onset, metres
0x0086F404 f32 25.0 VOL_MIN_DIST_SQ (runtime-init 5*5; BN printed 0f because it reads the zero-filled file image)
0x00794EE0 f64 2.0 log base → the fyl2x chain is log2(v)
0x007CAF48 f64 6.0206 = 20·log10(2); with the above → dB = 20·log10(v)
0x0081F060 i32 -50 SoundManager::VOL_MIN, in dB (never written; 3 read-only xrefs)
0x007CAF50 f32 1/32767 (3.0518509e-05) PlayProbability rand normaliser
0x00797D50 / 0x00797D48 f64 4.656613057e-10 / 0.99999988 Random::rand scale + hard clamp → rand ∈ [0, 0.99999988], never 1.0
0x007CAF58 f64 -15.0 pan scale (note the sign)
0x007991B0 f64 5.0 pan distance gate, metres
0x0079B504 f32 0.0174532924 deg→rad
0x0079B6B8 / 0x0079BC8C f64/f32 360.0 / 180.0 heading wrap

SoundBuf setters (0x00552D80 region): SetVolume(vol * 100) via IDirectSoundBuffer vtable +0x3C, SetPan(pan * 100) via +0x40. So GetAttenuation's integer output is decibels, ×100 = DirectSound centibels; pan is ±15 ×100 = ±1500 of DirectSound's ±10000 range.


1. SoundTable wire format — retail vs DRW vs ours

1a. Retail structure (recursive), from SoundTableData::UnPack @ 0x00552370

CSoundTable                 // DBObjType 0x22, id range 0x20000000..0x2000FFFF
  [DBObj header: u32 id]
  SoundTableData root
  [pad to 4-byte boundary, zero-filled]   // CSoundTable::UnPack tail

SoundTableData:
  u32   m_hashKey                 // the SoundType this node answers to
  u32   num_stdatas_
  SoundData[num_stdatas_]         // 16 bytes each, read ONLY if arg3 >= 0x10
      u32    sound_id_            // DataID → Wave (0x0A00xxxx); 0 = none
      float  priority_
      float  probability_
      float  volume_
  u32   numChildren
  SoundTableData[numChildren]     // RECURSIVE

Header struct (acclient.h:31197) confirms the 16-byte SoundData field order verbatim:

struct __cppobj SoundData {
  IDClass<_tagDataID,32,0> sound_id_;
  float priority_;
  float probability_;
  float volume_;
};

Two details worth writing down:

  • Default-init before reading. The freshly allocated entry array is memset to {id=0, priority=0.0f, probability=1.0f, volume=1.0f} (0x005523D90x005523E1: two 0x3F800000 stores). If the version/size argument arg3 < 0x10, retail keeps those defaults rather than failing. Probability defaults to 1.0, not 0.
  • Eager wave preload. For every non-zero sound_id_, UnPack calls SoundManager::CreateSound(id) — which refcount-bumps or allocates a SoundBufRef + SoundBuf immediately. Retail creates the DirectSound buffer for every wave a table references at table-load time, not on first play. We are lazy-on-first-play (DatSoundCache.GetWave). Benign divergence, but it's why retail never has a first-hit decode stall.

CSoundTable::Lookup(SoundType)SoundTableData::Lookup is a plain intrusive-hash probe over the children (hashKey % m_numBuckets, walk m_hashNext). So SoundType→variants lives one level down; the root's own entry array is separate.

1b. DRW's flattened parse (SoundTable.generated.cs, SoundData/SoundEntry/SoundHashData.generated.cs)

DRW reads exactly the same bytes in exactly the same order, but re-expresses the depth-2 tree as two dictionaries:

Retail field DRW field Match
root m_hashKey SoundTable.HashKey (i32)
root num_stdatas_ Hashes count (i32)
root SoundData[i].sound_id_ Hashes key (u32)
root SoundData[i].{priority_, probability_, volume_} SoundHashData.{Priority, Probability, Volume} (3× f32) exact order
root numChildren Sounds count (i32)
child m_hashKey Sounds key cast to Enums.Sound (u32)
child num_stdatas_ SoundData.Entries count (u32)
child SoundData[i] SoundEntry.{Id, Priority, Probability, Volume} (QualifiedDataId<Wave> = 1× u32, then 3× f32) exact order
child numChildren SoundData.Unknown (i32) ⚠️ read and discarded

The one structural divergence: DRW is not recursive. It assumes depth exactly 2 and swallows the grandchild count as Unknown. A depth-3 table would desynchronise the reader from that point on. Measured on the shipped dats: 0 of 190 tables have grandchildren, and Unknown is 0 everywhere, so DRW is correct on retail content. It would silently mis-parse custom content. Worth a comment in our tree, not a fix.

DRW's own tests (DatReaderWriter.Tests/DBObjs/SoundTableTests.cs, WaveTests.cs) are write-then-read round-trips only — they prove Pack/Unpack agree with each other, not with retail. The retail-layout evidence is (a) the SoundTableData::UnPack disassembly above and (b) my independent Python parse of all 190 real tables.

1c. What the real dats actually contain

Scanned client_portal.dat (926 MB, 79,694 files). Waves and sound tables live only in the portal dat (the "hits" in client_cell_1.dat at 0x0A00FFFF/0x2000xxxx are id-range collisions with cell ids, not audio).

Measurement Value
Waves (0x0A00xxxx) 786
Sound tables (0x2000xxxx) 190
Distinct SoundTypes used across all tables 123
Root num_stdatas_ always exactly 1, always sound_id_ == 0 (a dummy; retail skips it because CreateSound is gated on id != 0)
Per-SoundType entry counts 1 × 4,183 … and 2 × 1
Tables with depth > 2 0

The single multi-variant sound in the entire game:

table 0x200000A8, SoundType 31 (0x1F = Swoosh2), 2 entries:
    wave 0x0A000519  priority 0.9  probability 1.0  volume 1.0
    wave 0x0A00051E  priority 0.9  probability 1.0  volume 1.0

This single fact reframes the whole "variation" story: AC's per-object sound variation is not driven by multi-entry lists. The Swoosh1/2/3, Attack1/2/3, Wound1/2/3 SoundType triples are the variation mechanism; the entry list under each type is a singleton. Our SoundCookbook doc comment ("3 swoosh variants", "footsteps sound slightly different each step") describes a mechanism that has exactly one instance in the shipped data.

Field value distributions across all 4,184 entries:

Field Distribution Verdict
probability_ 3,498 × 1.0; 686 entries < 1.0 — 0.7 (249), 0.8 (129), 0.6 (108), 0.9 (74), 0.05 (53), 0.5 (21), 0.1 (19), 0.75 (11), 0.0001 (6), 0.02/0.03/0.01/0.003/0.15/0.2/0.3/0.95 (tail) linear chance in [0,1], honoured per-play
priority_ float, 0.0 … 1.0. Mode 0.7 (2,315), then 0.9 (311), 0.95 (251), 0.8 (242), 0.75 (192), 0.3 (169), 0.0 (156), 1.0 (80) float [0,1], never an integer 0..7
volume_ mostly ≤ 1.0, but 44 entries exceed it: 10.0 (31), 5.0 (3), 4.0 (1), 3.0 (4), 2.0 (5), 1.3 (1) unbounded linear gain, NOT a 0..1 multiplier

1d. Our side

AcDream.Core.Audio.SoundEntry (AudioModel.cs:21-31) is dead code — declared, documented, never constructed anywhere in src/ or tests/ (same for ISoundCache). The production path uses DatReaderWriter.Types.SoundEntry directly (AudioHookSink.PlayFromSoundTable). But its comments have already leaked into real code as facts:

public int    Priority   { get; init; }    // eviction ordering (0..7)      ← invented
public float  Probability{ get; init; }    // for entries with multiple alternatives  ← half-true
public float  VolumeBase { get; init; }    // 0..1 multiplier applied before falloff  ← wrong
public float  PitchMin / PitchMax           no such retail fields
public bool   Loop / Is3D                   not in SoundData; Is3D lives on SoundBuf

Consequences downstream (outside the strict DAT layer, but caused by it):

  • OpenAlAudioEngine.cs:297slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7). Retail priority is a float in [0,1]; (int)0.7f == 0. 4,100+ of 4,184 entries collapse to 0 and the 80 entries at exactly 1.0 collapse to 1. Priority ordering is effectively destroyed.
  • AudioHookSink.cs:114volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f). Retail clamps after the distance division, never the raw field (§3). Clamping the field flattens the 44 extended-range entries.

2. Selection + probability — retail vs SoundCookbook.Roll

2a. Retail, exactly

SoundManager::GetSound @ 0x00550680, hand-disassembled (BN dropped both the multiply and the -1):

005506ae  mov  ecx,[eax+0x7c]          ; n = num_stdatas_
005506b1  test ecx,ecx
005506b3  jbe  return                  ; n == 0 → no sound
005506b5  push 0x3f800000 ; push 0
005506bc  call Random::RollDice        ; st0 = roll, roll ∈ [0, 0.99999988]
005506c5  mov  esi,[edi+0x7c]          ; esi = n
005506c8  lea  ecx,[esi-1]             ; ecx = n - 1        ← !!
005506d4  fild dword [esp+0x14]        ; st0 = (float)(n-1) ; st1 = roll
005506e0  fmul st, st(1)               ; st0 = (n-1) * roll
005506e2  call _ftol2                  ; idx = (int)trunc(...)
005506e9  cmp  eax, esi
005506eb  jae  return                  ; idx >= n → no sound (unreachable)
005506ed  ...  data_[idx] copied wholesale into out
00550717  if (out->sound_id_ != 0) → sound_hash_.find(id) → SoundBufRef*

Pseudocode:

GetSound(stype, table) -> SoundData:
    std = table.Lookup(stype);  if !std or std.n == 0: return none
    roll = Random::rand()                      # [0, 0.99999988], NEVER 1.0
    idx  = (int)(roll * (std.n - 1))           # truncate toward zero
    if idx >= std.n: return none               # dead branch
    return std.data[idx]                       # id, priority, probability, volume

Then, separately and downstream, the selected entry's probability is a single Bernoulli gate. PlayProbability @ 0x005500E0:

r = rand() * (1/32767)          # rand() is C rand(), RAND_MAX 32767 → r ∈ [0, 1]
return (r < probability) ? 1 : 0

Call sites: PlaySoundA(SoundType, obj[, vol]) 0x00550AF0/0x00550B70, PlaySoundA(DataID, obj, prio, prob, vol) 0x005507A0, PlaySoundFromCenter(SoundType, table) 0x00550950, and inline (same rand()*1/32767 < prob sequence, not a call) in PlayAmbientSound 0x00550820 and PlayAmbientSoundFromCenter 0x005508B0. Fail → the sound is simply not played. There is no fallback entry, no retry.

So retail's model is: uniform index pick over the variant list, then an independent play/skip roll on the picked entry's probability_. The probability field is not a selection weight and is never normalised or accumulated.

2b. The n-1 off-by-one is real, and its blast radius is one wave

Because Random::rand is hard-clamped to 0.99999988 (0x00797D48), roll * (n-1) never reaches n-1, so idx ∈ [0, n-2] and the last entry can never be selected.

  • n == 1idx = (int)(roll * 0) = 0 correct.
  • n == 2idx always 0; entry[1] is dead.
  • n == 3idx ∈ {0,1}; entry[2] is dead.

Measured against real data (§1c): only 0x200000A8 / SoundType 31 has n == 2, so in retail wave 0x0A00051E never plays. Everything else is n == 1 and unaffected.

Per CLAUDE.md ("do not 'fix' the decompiled code"), the port should reproduce (n-1) verbatim with a comment citing 0x005506C8 and this measurement, and add a divergence-register row only if we deliberately choose n instead.

2c. Our SoundCookbook.Roll — three distinct divergences

if (entries.Count == 1) return entries[0];        // ← probability never consulted
float sample = (float)rng.NextDouble();
float cum = 0f;
for (...) { cum += entries[i].Probability; if (sample < cum) return entries[i]; }
return total > 0.999f ? entries[^1] : null;
# Divergence Retail Ours
D1 Probability is never applied on single-entry lists Bernoulli gate: rand()/32767 < probability → else silence entries.Count == 1 short-circuits before any roll → always plays
D2 Selection model uniform index (int)(roll·(n-1)); probability plays no part in selection cumulative-distribution walk weighted by probability
D3 "Silence tail" doesn't exist as a concept; silence comes from the per-entry gate invented: null when Σprobability < 1 and the sample lands past the last entry

D1 is the one that matters, because §1c says 4,183 of 4,184 entries are single-entry lists and 686 of them have probability < 1.0. Every one of those is a sound retail plays sometimes and we play always. Broken down by SoundType (20 of 123 types affected):

SoundType sub-1.0 probabilities present audible symptom
1 Speak1 0.05 ×49, 0.1 ×17, 0.010.03 ×7, 0.15/0.2/0.3/0.5 creature idle chatter fires ~20× too often — the loudest symptom by far
58 (0x3A) 0.0001 ×6, 0.003 ×2 1-in-10,000 easter-egg cues play on every trigger
12/13/14 Wound1/2/3 0.7 ×~80 each, 0.8 ×8 each 30% of wound sounds should be dropped
3/4/5 Attack1/2/3 0.6 ×64/6/1, 0.8, 0.9, 0.95 attack grunts over-fire
15 Death1 0.75 ×11
30/31/32 Swoosh1/2/3 0.60.8 weapon swings over-fire
33 Thump1 0.8 ×30
34 Smash1 0.8 ×21, 0.9 ×12, 0.6 ×9
35 Scratch1 0.9 ×42, 0.8 ×20
16, 24, 29, 41, 57 0.05 / 0.1 / 0.5 / 0.8 / 0.95 tail

SoundCookbook.Roll is the only consumer of Probability in the whole tree (AudioHookSink.cs:108); nothing else applies it. So the gate is categorically absent from acdream today.

Suggested retail-faithful shape (two separate steps, matching retail's split):

// SoundManager::GetSound @ 0x00550680 — uniform index, note the (n-1).
static SoundEntry? Pick(IReadOnlyList<SoundEntry> e, IRandom r) {
    if (e.Count == 0) return null;
    int idx = (int)(r.NextUnit() * (e.Count - 1));   // NextUnit() ∈ [0, 0.99999988]
    return idx < e.Count ? e[idx] : null;
}
// SoundManager::PlayProbability @ 0x005500E0 — Bernoulli gate at the play site.
static bool PlayProbability(float p, IRandom r) => r.NextUnit() < p;

Note also that retail draws from two different RNGs: Random::rand (0x0042C4C0, a dual-LCG returning a float in [0, 0.99999988]) for the index, and C library rand() scaled by 1/32767 for the probability gate. Nothing observable depends on which we use, but the quantisation differs (1/32767 grid vs ~2⁻³¹), and a probability of 0.0001 needs finer than 1/32767 granularity to be meaningful — retail's gate resolves it as rand() ∈ {0,1,2,3} out of 32768, i.e. ~1.2e-4 effective, not 1e-4.


3. Volume, distance falloff and pan (the fields' real semantics)

SoundManager::GetAttenuation @ 0x00550020, disassembled and with the three elided constants restored:

GetAttenuation(float dist, float vol, int* outDb, int isAmbient) -> bool
{
    v = (dist >= 5.0f)                       // VOL_MIN_DIST      0x007CAEAC
        ? (25.0f * vol) / (dist * dist)      // VOL_MIN_DIST_SQ   0x0086F404
        : vol;                               // flat inside 5 m
    if (v > 1.0) v = 1.0;                    // clamp AFTER the division
    v *= isAmbient ? ambient_sound_volume : effect_sound_volume;
    if (!(v > 0.0)) { *outDb = VOL_MIN; return false; }
    dB = (int)ceil( log2(v) * 6.0206 );      // == 20*log10(v)
    if (dB < VOL_MIN /* -50 */) { *outDb = VOL_MIN; return false; }
    *outDb = dB; return true;                // caller: SetVolume(dB * 100) centibels
}

Three facts our AudioFalloff gets wrong:

  • Min distance is 5 m, not 1 m. AudioFalloff.AttenuationAt(d, minDistance = 1.0f) defaults to a 1 m plateau; retail's is 5 m and the numerator is minDistance² = 25.
  • volume_ is not bounded by 1. Because the clamp is applied to 25·vol/d², a volume_ of 10 means "hold full loudness out to d = √(25·10) ≈ 15.8 m, then inverse-square". Clamping the field to [0,1] (as AudioHookSink.cs:114 does) shrinks the plateau of all 44 extended-range entries from ~15.8 m back to 5 m — a 3× audible-range loss on wound/death/impact/ambient sounds (types 12/13/14/15, 1826, 30, 33, 35, 57, 66, 95/96, 149, 152/153).
  • VOL_MIN = -50 dB is the cut-off; below it the sound is not started at all (return false → caller skips PlaySoundInternal).

Pan (PlaySoundInternal(buf, Position*, vol, isAmbient) @ 0x00550170):

heading  = Frame::get_heading(player_position_.frame)
dist     = Position::distance(soundPos, player_position_)
bearing  = Position::heading(soundPos, player_position_)     // note: from the SOUND
pan      = 0
if (s_SoundFeatures != 1) {
    a = fmod(bearing - heading, 360.0);  if (a > 180.0) a -= 360.0
    if (abs((int)dist) >= 5.0)                                // 0x007991B0
        pan = (int)( sin(a * pi/180) * -15.0 )                // 0x007CAF58, note the sign
}
if (GetAttenuation(dist, vol, &dB, isAmbient)) PlaySoundInternal(buf, pan, dB)
    → SoundBuf::Play → SetPan(pan*100), SetVolume(dB*100)

So retail pan is sin(Δbearing) scaled to ±15 → ±1500 centibels, i.e. only 15% of DirectSound's ±10000 range, and zero inside 5 m. Our AudioFalloff.PanFromRelative(relativeX, panRange = 20f) is a linear x/20 clamp on a listener-relative X — a different model with a different saturation curve and no near-field dead zone. (The -15.0 sign also needs a live A/B before trusting the left/right orientation.)

Also from PlaySoundInternal @ 0x0054FEC0: the playing-buffer ring is indexed (curr_playing_buffer_ + i) & 0x8000000F over i < 0x10retail has exactly 16 concurrent voices, and the steal decision compares the candidate slot's stored priority (SoundPlayingData.priority, the float straight from SoundData.priority_) against the incoming one. That is what priority_ is for; it is not an 0..7 eviction class.

Finally: AudioModel.cs's claim that retail is "CPU-side inverse-square, NOT DirectSound3DBuffer" is only half right. SoundBuf::Create @ 0x00552930 requests DSBCAPS_CTRL3D (0x100B0) and QueryInterfaces IDirectSound3DBuffer when m_3D is set, falling back to the 2D pan/volume path (0x100E0, CTRLVOLUME|CTRLPAN|CTRLFREQUENCY) when the 3D listener is unavailable. The attenuation math above is the 2D path.


4. Wave format — retail vs DRW vs WaveDecoder

4a. On-disk layout

DBWave::UnPack @ 0x00551B90:

u32  headerSize          // format-chunk size
u32  dataSize
byte[headerSize] header  // raw WAVEFORMATEX, no RIFF wrapper
byte[dataSize]   data

(The allocation order in the disassembly is data-buffer first, header-buffer second, but the read order is header then data — memcpy(fmt, p, headerSize); p += headerSize; memcpy(data, p, dataSize).)

DRW's Wave.Unpack reads headerSize, dataSize, header[], data[]exact match. Our WaveDecoder's documented layout is also exact.

acclient.h:1199 tWAVEFORMATEX is #pragma pack(1): wFormatTag u16, nChannels u16, nSamplesPerSec u32, nAvgBytesPerSec u32, nBlockAlign u16, wBitsPerSample u16, cbSize u16 = 18 bytes. Our WaveDecoder offsets (0, 2, 4, 14) match.

4b. What retail does with a non-PCM wave

SoundBuf::Create @ 0x00552930:

wf = DBObj::Get(QualifiedDataID(waveId, 0x0F /*DB_TYPE_WAVE*/))   // + 0x38 → WaveFile
if (wf->m_pwfmt->wFormatTag == 1) {            // PCM
    bufsize = wf->m_nDataSize
} else {                                        // anything else
    dst = { wFormatTag=1, nChannels=1, nSamplesPerSec=11025,
            nAvgBytesPerSec=22050, nBlockAlign=2, wBitsPerSample=16, cbSize=0 }
    acmStreamOpen(&phas, NULL, wf->m_pwfmt, &dst, ...)
    acmStreamSize(phas, wf->m_nDataSize, &bufsize, 0)
}
CreateSoundBuffer(bufsize) ; CopyWaveToBuffer(this, wf)
if (non-PCM) { acmStreamClose(phas); phas = NULL; }

SoundBuf::CopyWaveToBuffer @ 0x005526D0:

Lock(buf, 0, bufsize, &p1, &n1, &p2, &n2, 0)
if (phas == NULL)  memcpy(p1, wf->m_pData, ...) [+ wrap-around memcpy into p2]
else               acmStreamPrepareHeader / acmStreamConvert / acmStreamUnprepareHeader
Unlock(...)

So: retail does not decode compressed waves itself. It hands the source WAVEFORMATEX to the Windows ACM (msacm32) and converts straight into the locked DirectSound buffer, with a hard-coded destination format of PCM mono 11,025 Hz 16-bit. Everything else is a raw memcpy of the dat bytes. Our AudioModel.cs comment "we decode MP3 to PCM once at load (same as retail does for long clips)" is right in spirit — retail decodes the whole buffer once at Create time, not streaming — but the target format detail is a concrete portable fact we should match if we ever add the decoder.

4c. Do we decode MP3 at all? No — and it costs exactly one wave

grep over src/ and every *.csproj: no MP3 or ADPCM decoder, and no NAudio / NLayer / mpg123 package reference exists. WaveDecoder.Decode returns null for any wFormatTag != 1, DatSoundCache files the id in _negativeWaveIds, and the sound is permanently silent.

Measured cost, client_portal.dat:

Format tag Count
0x0001 PCM 785
0x0055 MPEGLAYER3 1

The single MP3 is wave 0x0A000393, header 30 bytes, data 5,120 bytes:

55 00        wFormatTag      = 0x0055 MPEGLAYER3
01 00        nChannels       = 1
11 2b 00 00  nSamplesPerSec  = 11025
c4 09 00 00  nAvgBytesPerSec = 2500   (20 kbps → ~2.05 s of audio)
01 00        nBlockAlign     = 1
00 00        wBitsPerSample  = 0
0c 00        cbSize          = 12
01 00        wID             = MPEGLAYER3_ID_MPEG
02 00 00 00  fdwFlags        = MPEGLAYER3_FLAG_PADDING_OFF
04 01        nBlockSize      = 260
02 00        nFramesPerBlock = 2
71 05        nCodecDelay     = 1393

Verdict on the "MP3-sourced waves are silently broken" hypothesis: true but immaterial — 1 wave of 786 (0.13%), one ~2-second mono cue. Adding an MP3 decoder is a footnote, not a P0. There are no ADPCM (0x0002) waves at all.

4d. Real-wave PCM parameter ranges (what our decoder must survive)

Parameter Distribution across the 785 PCM waves
header size 18 bytes (all of them)
channels mono 772, stereo 14
bits/sample 16 × 714, 8 × 71
sample rate 11025 (471), 22050 (164), 44100 (89), 8000 (25), 32000 (20), 16000 (10), plus 5500/6000/7333/8287/12000 singletons

WaveDecoder.Decode handles all of this correctly (it reads the real nChannels/nSamplesPerSec/wBitsPerSample and returns the raw bytes). Two things to check downstream, outside this lane: (a) 71 waves are 8-bit unsigned PCM — OpenAL needs AL_FORMAT_MONO8/STEREO8, and 8-bit PCM in WAV is unsigned while 16-bit is signed; (b) the odd rates (5500/7333/8287) are fine for OpenAL but will resample.

Minor: WaveDecoder guards header.Length < 14 and reads bits at offset 14 only when Length >= 16. Every real wave is 18 or 30 bytes, so the fallback bitsPer = 16 never fires on retail data — but note the MP3 header has wBitsPerSample == 0, which the current bitsPer == 0 ? 16 fallback would silently paper over if MP3 ever reached that line.


5. CSoundDesc — what it actually is (not the per-object table)

acclient.h:53246: CSoundDesc is a member of CRegionDesc, alongside SkyDesc, CSceneDesc, CTerrainDesc, FogDesc:

struct __cppobj CSoundDesc { AC1Legacy::SmartArray<AmbientSTBDesc *> stb_desc; };

CSoundDesc::UnPack @ 0x005028D0 = u32 count + AmbientSTBDesc[count], which DRW's SoundDesc.Unpack matches exactly.

So CSoundDesc is the region's ambient sound-table list, not the per-object sound-table pointer. It feeds PlayAmbientSound / PlayAmbientSoundFromCenter.

The per-object path is different. CPhysicsObj::sound_table (acclient.h offset via arg2->sound_table in PlaySoundA @ 0x00550B20) is resolved from a DataID on the object, with a Setup-level default:

  • 0x00514F76id = desc->stable_id.id; if non-zero, sound_table = DBObj::Get(QualifiedDataID(id, 0x22)).
  • 0x00513A00id = setup->default_stable_id.id, same resolution.
  • Sibling field phstable_id / default_phstable_id is the physics-script table, a separate thing.
  • stable_id / phstable_id are wire-serialised (0x0051DAEC, 0x0051DB03) — i.e. the server can override the Setup's default per object.

PlaySoundA(SoundType, CPhysicsObj*) therefore reads obj->sound_table, calls GetSound(stype, table), gates on PlayProbability, and plays at obj->m_position. Our IEntitySoundTable.GetSoundTableId(entityId) seam is the right shape; the resolution order to match is stable_id from the object's physics desc, falling back to the Setup's default_stable_id.


6. Test inventory — golden vs self-referential

File Verdict
SoundIdConformanceTests.cs (272 lines) Genuine golden conformance. A 205-entry table transcribed from acclient.h:4569 enum SoundType, cross-checked against ACE and DRW, with tests for exact values, no extras, dense coverage to 0xCC, and agreement with the DRW enum used at runtime. This is the model the rest of the lane should follow.
WaveDecoderTests.cs (104 lines) ⚠️ Synthetic, hand-built headers; no dat-derived golden values. It builds an 18-byte PCM header with plausible values (mono/22050/16 — a rate that exists in the dats, so plausible) and asserts our own parse. Decode_Mp3Header_ReturnsNull and Decode_AdpcmHeader_ReturnsNull pin the missing-decoder behaviour as correct — they will have to be inverted when a decoder lands. No test exercises the real 8-bit or stereo waves, or the real MP3's wBitsPerSample == 0.
DatSoundCookbookTests.csSoundCookbookTests.cs (97 lines) Entirely self-referential, and it locks in the wrong model. Roll_WeightedEntries_DistributionMatches asserts 50/30/20 split from a CDF walk; Roll_SilenceTail_ReturnsNullOccasionally asserts the invented 40%-silence tail; Roll_SingleEntry_AlwaysReturnsIt explicitly asserts the D1 bug (a 0.5-probability single entry returns unconditionally). Zero retail anchors. Every one of these five tests must change when §2 is ported.
DatSoundCacheTests.cs (245 lines) Correctly scoped and honest — LRU eviction order, byte accounting, negative-result memoisation, oversize bypass, concurrent-decode dedup. It's infrastructure, not retail behaviour, and it doesn't pretend otherwise. Only caveat: GetWave_UnsupportedFormat_... uses MakeMp3Wave and asserts the null path, same "pins a gap as correct" note as above.
DRW's SoundTableTests.cs / WaveTests.cs ⚠️ Round-trip only (write then read). Prove Pack↔Unpack symmetry, prove nothing about retail's layout.

Nothing in the tree conformance-tests the SoundTable byte layout, the selection algorithm, the probability gate, the attenuation curve, or the priority/volume semantics against retail. The only golden table is the SoundType enum.


7. Divergence list, ranked by audible impact

# Divergence Evidence Audible symptom Fix size
1 probability_ is never applied. SoundCookbook.Roll returns single-entry lists unconditionally, and 4,183 of 4,184 real entries are single-entry — 686 of those have probability < 1.0. SoundCookbook.cs:44; PlayProbability @ 0x005500E0; dat scan §1c/§2c Creature idle chatter (Speak1, 49 entries at 5%) fires ~20× too often; wound/attack/swoosh/impact sounds never drop; six 0.01%-chance easter eggs play every time. This is the single loudest wrong thing in the audio stack. small — add a Bernoulli gate at the play site
2 priority_ treated as int 0..7. Retail is a float in [0,1] driving 16-voice steal ordering. AudioModel.cs:24 comment; OpenAlAudioEngine.cs:297 (uint)Math.Clamp((int)priority,0,7); dat histogram (mode 0.7) 4,100+ entries collapse to priority 0 → voice-steal is effectively arbitrary; important sounds (death, casting) lose to footsteps. small — keep the float; port the ring compare from 0x0054FF70
3 volume_ clamped to [0,1] before falloff. Retail clamps 25·vol/d² after the division, so volume_ > 1 extends the full-volume plateau. 44 real entries exceed 1.0 (31 at 10.0). AudioHookSink.cs:114; GetAttenuation @ 0x00550020 with 0x0086F404 = 25.0 Wound/death/impact/ambient sounds audible to ~5 m instead of ~15.8 m — they feel local and thin instead of carrying. small
4 Falloff min-distance 1 m vs retail 5 m; no 50 dB cutoff; dB curve absent. AudioFalloff.AttenuationAt default minDistance = 1.0f; retail VOL_MIN_DIST = 5.0, VOL_MIN = -50 dB, dB = ceil(20·log10(v)) Everything is quieter than retail at 15 m and audible far past retail's cutoff. small
5 Pan model is linear x/20; retail is sin(Δbearing) · ±15 with a 5 m dead zone. AudioFalloff.PanFromRelative; 0x00550170 with 0x007CAF58 = -15.0 Wrong stereo image; near sounds pan when retail keeps them centred; overall pan ~6× stronger than retail's ±1500/±10000. Sign needs a live A/B. small
6 Selection is a CDF walk, not (int)(roll·(n-1)). SoundCookbook.cs:46-59; GetSound @ 0x005506C8 Nearly inaudible on retail data (one 2-entry sound exists). Matters only for retail-faithfulness and for custom content. Note retail's n-1 means the last variant is never played — port verbatim, don't "fix". small
7 Invented "silence tail" (null when Σprobability < 1). SoundCookbook.cs:53-59 With single-entry lists at probability 0.05, our code returns the entry (count==1 short-circuit) — so the tail is dead code that would misfire the moment multi-entry lists appear. delete
8 No MP3 decoder0x0A000393 (one ~2 s mono cue) is permanently silent. Retail uses winmm ACM into PCM mono/11025/16-bit. WaveDecoder.cs:87; SoundBuf::Create @ 0x00552AD0; dat scan (1 of 786) One missing sound effect. Not a P0. medium (needs a managed decoder)
9 Lazy wave load vs retail's eager CreateSound at table UnPack. SoundTableData::UnPack 0x00552451; DatSoundCache.GetWave Possible first-play hitch; retail has none. Architecturally our choice is better for the 30-bot fleet. none — document
10 DRW's SoundTable parse is non-recursive (grandchild count read into SoundData.Unknown and discarded). SoundData.generated.cs; SoundTableData::UnPack recursion at 0x00552503 Zero impact on retail data (0 of 190 tables nest deeper than 2). Would silently corrupt custom deep tables. none — comment
11 AcDream.Core.Audio.SoundEntry / ISoundCache are dead code whose invented comments (Priority 0..7, VolumeBase 0..1, PitchMin/Max, Loop, Is3D) are the documented source of divergences 2 and 3. AudioModel.cs:21-31, 110-115; no constructors anywhere in src/ or tests/ none directly delete or correct — highest value per line changed
12 AudioModel.cs claims retail never uses IDirectSound3DBuffer. It does, when m_3D is set and a 3D listener exists. SoundBuf::Create 0x0055295E (0x100B0 = DSBCAPS_CTRL3D), 0x00552B58 QueryInterface none directly; the doc misleads future work doc fix
13 Voice limit unmodelled. Retail has exactly 16 concurrent buffers with a priority-based steal. PlaySoundInternal 0x0054FEC0 (& 0x8000000F, i < 0x10) dense-combat mix density differs from retail medium

8. Things worth pinning as conformance tests

  1. SoundTableData byte layout — a golden hex fixture for one real table (e.g. 0x200000A8, the only 2-entry one) asserting id/priority/probability/ volume for both entries and the root dummy {0, …}.
  2. Pick()n == 2 must always return index 0 (the n-1 truncation), citing 0x005506C8; n == 1 returns index 0.
  3. PlayProbability(0.0f) never plays; PlayProbability(1.0f) always plays; PlayProbability(0.05f) over 100k trials lands in [4.7%, 5.3%].
  4. GetAttenuation golden rows, computed from the recovered constants: (dist, vol) → dB for (1, 1) → 0, (5, 1) → 0, (10, 1) → ceil(20·log10(0.25)) = -12, (10, 10) → ceil(20·log10(1.0)) = 0 (clamped), (50, 1) → ceil(20·log10(0.01)) = -40, (200, 1) → below 50 → inaudible, returns false.
  5. Wave header parse against the real 0x0A000393 MP3 header bytes (§4c) and against at least one real 8-bit and one real stereo wave.
  6. A dat-backed invariant test (gated on the dats being present, like the existing installed-DAT gates): every SoundTable in client_portal.dat parses, root num_stdatas_ == 1 with sound_id_ == 0, and no table has grandchildren — this is the guard that keeps DRW's flattening honest.