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>
35 KiB
Lane 4 — Sound DAT layer: retail vs DatReaderWriter vs acdream
Read-only audit, 2026-08-08. Three oracles used:
- Retail decomp —
docs/research/named-retail/acclient_2013_pseudo_c.txt+acclient.h(Sept 2013 EoR, PDB-named). - The PDB-paired binary —
C:\Users\erikn\Downloads\acclient.exev11.4186 (check_exe_pdb.pyMATCH). Used to recover every constant Binary Ninja elided through the x87 stack (0f,0.0, garbledfyl2xchains). This was necessary: three of the four load-bearing numbers in this subsystem are invisible in the pseudo-C. - The shipped dats —
%USERPROFILE%\Documents\Asheron's Call\*.dat, walked with an independent Python B-tree reader using retail's byte order (not DRW's). All 190 sound tables and 786 waves parsed cleanly, which is itself an external cross-check that DRW's layout is right.
Scratchpad tooling: lane4_pe.py (PE/VA reader + xref scan),
lane4_datscan3.py (dat B-tree + SoundTable/Wave scanner).
0. Retail function map (VA, imagebase 0x400000)
| Symbol | VA |
|---|---|
CSoundTable::UnPack |
0x00551CD0 |
SoundTableData::UnPack |
0x00552370 |
SoundTableData::Lookup |
0x005520A0 |
CSoundTable::Lookup |
0x00552100 |
SoundManager::GetSound |
0x00550680 |
SoundManager::PlayProbability |
0x005500E0 |
SoundManager::GetAttenuation |
0x00550020 |
SoundManager::PlaySoundInternal(buf, Position*, vol, isAmbient) |
0x00550170 |
SoundManager::PlaySoundInternal(buf, pan, vol) |
0x0054FEC0 |
SoundManager::CreateSound |
0x00550BF0 |
SoundManager::PlaySoundA (4 overloads) |
0x00550730, 0x005507A0, 0x00550AF0, 0x00550B70 |
SoundManager::PlayAmbientSound |
0x00550820 |
SoundManager::PlaySoundFromCenter |
0x00550950, 0x005509E0 |
DBWave::UnPack |
0x00551B90 |
SoundBuf::Create |
0x00552930 |
SoundBuf::CopyWaveToBuffer |
0x005526D0 |
Random::RollDice(float,float) |
0x0042C600 |
Random::rand |
0x0042C4C0 |
CSoundDesc::UnPack |
0x005028D0 |
static init VOL_MIN_DIST_SQ = 5f*5f |
0x00706490 |
Constants recovered from the binary (all elided by BN)
| Address | Type | Value | Meaning |
|---|---|---|---|
0x007CAEAC |
f32 | 5.0 | VOL_MIN_DIST — falloff onset, metres |
0x0086F404 |
f32 | 25.0 | VOL_MIN_DIST_SQ (runtime-init 5*5; BN printed 0f because it reads the zero-filled file image) |
0x00794EE0 |
f64 | 2.0 | log base → the fyl2x chain is log2(v) |
0x007CAF48 |
f64 | 6.0206 | = 20·log10(2); with the above → dB = 20·log10(v) |
0x0081F060 |
i32 | -50 | SoundManager::VOL_MIN, in dB (never written; 3 read-only xrefs) |
0x007CAF50 |
f32 | 1/32767 (3.0518509e-05) | PlayProbability rand normaliser |
0x00797D50 / 0x00797D48 |
f64 | 4.656613057e-10 / 0.99999988 | Random::rand scale + hard clamp → rand ∈ [0, 0.99999988], never 1.0 |
0x007CAF58 |
f64 | -15.0 | pan scale (note the sign) |
0x007991B0 |
f64 | 5.0 | pan distance gate, metres |
0x0079B504 |
f32 | 0.0174532924 | deg→rad |
0x0079B6B8 / 0x0079BC8C |
f64/f32 | 360.0 / 180.0 | heading wrap |
SoundBuf setters (0x00552D80 region): SetVolume(vol * 100) via
IDirectSoundBuffer vtable +0x3C, SetPan(pan * 100) via +0x40. So
GetAttenuation's integer output is decibels, ×100 = DirectSound
centibels; pan is ±15 ×100 = ±1500 of DirectSound's ±10000 range.
1. SoundTable wire format — retail vs DRW vs ours
1a. Retail structure (recursive), from SoundTableData::UnPack @ 0x00552370
CSoundTable // DBObjType 0x22, id range 0x20000000..0x2000FFFF
[DBObj header: u32 id]
SoundTableData root
[pad to 4-byte boundary, zero-filled] // CSoundTable::UnPack tail
SoundTableData:
u32 m_hashKey // the SoundType this node answers to
u32 num_stdatas_
SoundData[num_stdatas_] // 16 bytes each, read ONLY if arg3 >= 0x10
u32 sound_id_ // DataID → Wave (0x0A00xxxx); 0 = none
float priority_
float probability_
float volume_
u32 numChildren
SoundTableData[numChildren] // RECURSIVE
Header struct (acclient.h:31197) confirms the 16-byte SoundData field
order verbatim:
struct __cppobj SoundData {
IDClass<_tagDataID,32,0> sound_id_;
float priority_;
float probability_;
float volume_;
};
Two details worth writing down:
- Default-init before reading. The freshly allocated entry array is
memset to
{id=0, priority=0.0f, probability=1.0f, volume=1.0f}(0x005523D9–0x005523E1: two0x3F800000stores). If the version/size argumentarg3 < 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 callsSoundManager::CreateSound(id)— which refcount-bumps or allocates aSoundBufRef+SoundBufimmediately. Retail creates the DirectSound buffer for every wave a table references at table-load time, not on first play. We are lazy-on-first-play (DatSoundCache.GetWave). Benign divergence, but it's why retail never has a first-hit decode stall.
CSoundTable::Lookup(SoundType) → SoundTableData::Lookup is a plain
intrusive-hash probe over the children (hashKey % m_numBuckets, walk
m_hashNext). So SoundType→variants lives one level down; the root's own
entry array is separate.
1b. DRW's flattened parse (SoundTable.generated.cs, SoundData/SoundEntry/SoundHashData.generated.cs)
DRW reads exactly the same bytes in exactly the same order, but re-expresses the depth-2 tree as two dictionaries:
| Retail field | DRW field | Match |
|---|---|---|
root m_hashKey |
SoundTable.HashKey (i32) |
✅ |
root num_stdatas_ |
Hashes count (i32) |
✅ |
root SoundData[i].sound_id_ |
Hashes key (u32) |
✅ |
root SoundData[i].{priority_, probability_, volume_} |
SoundHashData.{Priority, Probability, Volume} (3× f32) |
✅ exact order |
root numChildren |
Sounds count (i32) |
✅ |
child m_hashKey |
Sounds key cast to Enums.Sound (u32) |
✅ |
child num_stdatas_ |
SoundData.Entries count (u32) |
✅ |
child SoundData[i] |
SoundEntry.{Id, Priority, Probability, Volume} (QualifiedDataId<Wave> = 1× u32, then 3× f32) |
✅ exact order |
child numChildren |
SoundData.Unknown (i32) |
⚠️ read and discarded |
The one structural divergence: DRW is not recursive. It assumes depth
exactly 2 and swallows the grandchild count as Unknown. A depth-3 table
would desynchronise the reader from that point on. Measured on the shipped
dats: 0 of 190 tables have grandchildren, and Unknown is 0 everywhere,
so DRW is correct on retail content. It would silently mis-parse custom
content. Worth a comment in our tree, not a fix.
DRW's own tests (DatReaderWriter.Tests/DBObjs/SoundTableTests.cs,
WaveTests.cs) are write-then-read round-trips only — they prove
Pack/Unpack agree with each other, not with retail. The retail-layout
evidence is (a) the SoundTableData::UnPack disassembly above and (b) my
independent Python parse of all 190 real tables.
1c. What the real dats actually contain
Scanned client_portal.dat (926 MB, 79,694 files). Waves and sound tables
live only in the portal dat (the "hits" in client_cell_1.dat at
0x0A00FFFF/0x2000xxxx are id-range collisions with cell ids, not audio).
| Measurement | Value |
|---|---|
Waves (0x0A00xxxx) |
786 |
Sound tables (0x2000xxxx) |
190 |
| Distinct SoundTypes used across all tables | 123 |
Root num_stdatas_ |
always exactly 1, always sound_id_ == 0 (a dummy; retail skips it because CreateSound is gated on id != 0) |
| Per-SoundType entry counts | 1 × 4,183 … and 2 × 1 |
| Tables with depth > 2 | 0 |
The single multi-variant sound in the entire game:
table 0x200000A8, SoundType 31 (0x1F = Swoosh2), 2 entries:
wave 0x0A000519 priority 0.9 probability 1.0 volume 1.0
wave 0x0A00051E priority 0.9 probability 1.0 volume 1.0
This single fact reframes the whole "variation" story: AC's per-object
sound variation is not driven by multi-entry lists. The Swoosh1/2/3,
Attack1/2/3, Wound1/2/3 SoundType triples are the variation mechanism;
the entry list under each type is a singleton. Our SoundCookbook doc
comment ("3 swoosh variants", "footsteps sound slightly different each
step") describes a mechanism that has exactly one instance in the shipped
data.
Field value distributions across all 4,184 entries:
| Field | Distribution | Verdict |
|---|---|---|
probability_ |
3,498 × 1.0; 686 entries < 1.0 — 0.7 (249), 0.8 (129), 0.6 (108), 0.9 (74), 0.05 (53), 0.5 (21), 0.1 (19), 0.75 (11), 0.0001 (6), 0.02/0.03/0.01/0.003/0.15/0.2/0.3/0.95 (tail) | linear chance in [0,1], honoured per-play |
priority_ |
float, 0.0 … 1.0. Mode 0.7 (2,315), then 0.9 (311), 0.95 (251), 0.8 (242), 0.75 (192), 0.3 (169), 0.0 (156), 1.0 (80) | float [0,1], never an integer 0..7 |
volume_ |
mostly ≤ 1.0, but 44 entries exceed it: 10.0 (31), 5.0 (3), 4.0 (1), 3.0 (4), 2.0 (5), 1.3 (1) | unbounded linear gain, NOT a 0..1 multiplier |
1d. Our side
AcDream.Core.Audio.SoundEntry (AudioModel.cs:21-31) is dead code —
declared, documented, never constructed anywhere in src/ or tests/
(same for ISoundCache). The production path uses
DatReaderWriter.Types.SoundEntry directly
(AudioHookSink.PlayFromSoundTable). But its comments have already leaked
into real code as facts:
public int Priority { get; init; } // eviction ordering (0..7) ← invented
public float Probability{ get; init; } // for entries with multiple alternatives ← half-true
public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff ← wrong
public float PitchMin / PitchMax ← no such retail fields
public bool Loop / Is3D ← not in SoundData; Is3D lives on SoundBuf
Consequences downstream (outside the strict DAT layer, but caused by it):
OpenAlAudioEngine.cs: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→idxalways 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
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):
// 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 isminDistance²= 25. volume_is not bounded by 1. Because the clamp is applied to25·vol/d², avolume_of 10 means "hold full loudness out tod = √(25·10) ≈ 15.8 m, then inverse-square". Clamping the field to [0,1] (asAudioHookSink.cs:114does) 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 dBis the cut-off; below it the sound is not started at all (return false→ caller skipsPlaySoundInternal).
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:
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_idis the physics-script table, a separate thing. stable_id/phstable_idare 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
SoundTableDatabyte 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, …}.Pick()—n == 2must always return index 0 (then-1truncation), citing0x005506C8;n == 1returns index 0.PlayProbability(0.0f)never plays;PlayProbability(1.0f)always plays;PlayProbability(0.05f)over 100k trials lands in [4.7%, 5.3%].GetAttenuationgolden rows, computed from the recovered constants:(dist, vol) → dBfor(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.- Wave header parse against the real
0x0A000393MP3 header bytes (§4c) and against at least one real 8-bit and one real stereo wave. - A dat-backed invariant test (gated on the dats being present, like the
existing installed-DAT gates): every SoundTable in
client_portal.datparses, rootnum_stdatas_ == 1withsound_id_ == 0, and no table has grandchildren — this is the guard that keeps DRW's flattening honest.