# Lane 4 — Sound DAT layer: retail vs DatReaderWriter vs acdream Read-only audit, 2026-08-08. Three oracles used: 1. **Retail decomp** — `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` (Sept 2013 EoR, PDB-named). 2. **The PDB-paired binary** — `C:\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: ```c 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}` (`0x005523D9`–`0x005523E1`: 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` = 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: ```csharp 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:297` — `slot.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:114` — `volume: 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 == 1` → `idx = (int)(roll * 0) = 0` ✅ correct. * `n == 2` → `idx` always 0; entry[1] is dead. * `n == 3` → `idx ∈ {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 ```csharp 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.01–0.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.6–0.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): ```csharp // SoundManager::GetSound @ 0x00550680 — uniform index, note the (n-1). static SoundEntry? Pick(IReadOnlyList 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, 18–26, 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 < 0x10` — **retail 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`: ```c struct __cppobj CSoundDesc { AC1Legacy::SmartArray 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: * `0x00514F76` — `id = desc->stable_id.id`; if non-zero, `sound_table = DBObj::Get(QualifiedDataID(id, 0x22))`. * `0x00513A00` — `id = 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.cs` → `SoundCookbookTests.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 1–5 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 decoder** → `0x0A000393` (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.