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>
647 lines
35 KiB
Markdown
647 lines
35 KiB
Markdown
# 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.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<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, 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<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 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.
|