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

647 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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:
```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.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):
```csharp
// 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 < 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<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:
* `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 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 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.