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>
This commit is contained in:
Erik 2026-08-08 20:55:33 +02:00
parent 6bb4cfa795
commit ffa5087527
7 changed files with 4472 additions and 0 deletions

View file

@ -0,0 +1,647 @@
# 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 §1c2c | 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.