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,289 @@
# Campaign A — Audio retail-feel parity
**Status: PROPOSED 2026-08-08 — awaiting user go.** Research phase complete;
six-lane named-retail decode done, all load-bearing claims byte-verified
against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient —
see "BN traps" below).
**Goal:** the client sounds like retail. Every divergence between our audio
runtime and the 2013 EoR client is either fixed to the retail mechanism or
recorded in the divergence register with a reason.
**Research base (read the lane note before implementing its slice):**
| Lane | Note | Owns |
|---|---|---|
| 1 | `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | SoundManager, falloff, pan, voice pool, prefs |
| 2 | `docs/research/2026-08-08-audio-retail-ambient-runtime.md` | AmbientSound/ConstantSound/IntermitSound runtime |
| 3 | `docs/research/2026-08-08-audio-retail-ambient-authoring.md` | Region-file authoring chain, dat coverage |
| 4 | `docs/research/2026-08-08-audio-retail-dat-layer.md` | SoundTable/Wave formats, selection model, dat census |
| 5 | `docs/research/2026-08-08-audio-retail-server-sounds.md` | 0xF750 wire path, play_sound, trigger catalog |
| 6 | `docs/research/2026-08-08-audio-retail-music-absence.md` | Music (there is none), MediaMachine, AdminEnvirons |
The older `docs/research/deepdives/r05-audio-sound.md` is SUPERSEDED where it
conflicts with the lane notes (its §5.1 falloff, §6 music, and §7 ambient
sections are wrong — Ghidra-era `FUN_xxx` reads that the named decomp + byte
decode overturned). Slice A6 adds the banner.
---
## What retail's audio engine actually is (one page)
Retail is a **2D pan+gain engine**, not 3D audio. Every gameplay buffer is
created with `m_3D = 0`; the DirectSound 3D listener the client sets up is
dead code. Spatialization is CPU-side per voice at play time:
- **Gain** (`SoundManager::GetAttenuation @ 0x00550AD0` region; byte-decoded):
`g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, multiplied by ONE
master knob (`effect_sound_volume` or `ambient_sound_volume`), then
`db = ceil(20·log10 g)` with a hard floor at 50 dB — below the floor the
voice is **not allocated at all**. Audible radius ≈ 94 m at vol 1.0.
- **Pan**: `pan_dB = 15·sin(Δheading listener→source)`, saturated at
±15 dB, forced to 0 inside 5 m. No front/back, no elevation.
- **Listener** = the player physics object's position/heading
(`SoundManager::SetPlayerPosition`), NOT the camera.
- **Voice pool**: allocator is `SoundManager::PlaySoundInternal @ 0x0054FEC0`.
Eviction compares the DAT-authored **float priority** (0..1); equal
priority never evicts. (`FUN_00550AD0` cited in our code is a hash-table
constructor — wrong symbol.)
- **No loops, no pitch**: retail never sets the DSound loop flag and never
calls `SetFrequency`. "Looping" ambients are re-fired one-shots.
- **Variant selection** (`SoundTableData::Lookup` + play sites): pick
`idx = (int)(roll01 · (n1))` — uniform over all but the LAST entry
(a genuine Turbine off-by-one; the last variant is unreachable and a
faithful port keeps that) — then a SEPARATE Bernoulli gate
`rand()/32767 < probability`, else **silence**. Probability is a gate,
not a weight.
- **Volume field** is unbounded gain (dats go up to 10.0); retail clamps
only AFTER the distance divide, so >1 volumes extend audible range.
- **Prefs** (`InitPrefs @ 0x005503F0`, 8 keys): three float volumes
(effect / ambient / interface — interface is registered but **never
read**), three enable bools, `Sound Features` (==1 disables pan),
`Play Sound Only When Active`. There is NO music knob.
- **Quirk (faithful-port decision)**: effect and ambient volumes are each
applied twice (once at the play site, again inside GetAttenuation) — the
sliders are effectively **squared**.
**Sound triggers, exhaustively** (lane 5): (1) animation hooks
(SoundHook/SoundTableHook/SoundTweakedHook) — footsteps, combat swooshes,
all authored in MotionTables; (2) the server `Sound` event **0xF750**
(`guid, SoundType, vol`) — hits, wounds, wield, pickup, locks, lifestone,
spell resists; retail queues an event for a not-yet-known guid and replays
it on CreateObject, and plays at the WIRE volume, ignoring the table
entry's volume (the hook path does the opposite); (3) PhysicsScripts
(0xF754/5 — already live in acdream); (4) UI sounds via the ClientUISystem
sound table loaded by `DBObj::GetByEnum(0x22, slot 7)`; (5) region-authored
ambients (below). `CPhysicsObj::play_sound` has exactly ONE caller — the
0xF750 handler. There are NO client-local collision/jump/water sound call
sites; inventing one is a divergence.
**Ambients** (lanes 2+3): authored entirely in `region.dat`
(`Region.SoundInfo``AmbientSTBDesc[]` referenced by scene types ← terrain
types). On every **objcell change** (24 m), `CellManager::ChangePosition`
rebuilds weights by walking the **3×3 landblock ring × 64 land cells each**,
decoding each cell's terrain word to (terrainType, sceneIdx) and
accumulating per-sound inverse-square weight (1.0 inside 20 m, `(20/d)²` to
120 m, 0 beyond) plus an 8-way bearing histogram. Playback is a min-heap of
absolute deadlines ticked from the frame loop; each pop plays a one-shot
and re-arms. `base_chance == 0`**ConstantSound**: non-positional,
volume = its weight share of total (a real terrain crossfade), re-fires
every `min_rate` s. Non-zero ⇒ **IntermitSound**: authored fixed volume,
positioned at a random accumulated bearing ±11.25° at distance
`min + (maxmin)·t²`, gated by `roll ≤ base_chance`, interval
`RollDice(min_rate, max_rate)`. **Indoors is silent by design**
`CEnvCell::add_ambient_sounds` is an empty folded `ret`; EnvCell has no
sound data. No day/night/weather selection exists.
**Music does not exist** (lane 6): the linked winmm MIDI player has zero
callers (`midiPlay` = 4 textual occurrences: definition + its own queue
drainer; verified independently), "music" appears 0 times in the 65 MB
decomp, no SoundType music member, no music pref, no music files shipped.
`MediaMachine` is a UI-state media bytecode VM whose `Update_Sound` routes
LayoutDesc-authored waves/table rows to the interface bus.
### BN traps (binding on every slice — reread before porting)
Binary Ninja renders x87 memory-operand compares as unimplemented `bool p`
and elides the constants; **five** sites would have ported with inverted
polarity or zeroed math: `is_continuous`, both `CanHear`s, `PlayNow`,
`PlayProbability`, plus `GetAttenuation` printing `* 0f`. Byte-decode the
PDB-paired binary (`reference_pe_byte_decode.md` workflow) for ANY float
compare or constant in this subsystem. The lane notes contain the verified
values; if a needed constant is not in a note, decode it — do not trust
the pseudo-C rendering and do not guess.
---
## Where acdream is today
Working and retail-correct-in-shape: the animation-hook trigger path
(`AudioHookSink`, correctly the only client-local trigger), SoundTable/Wave
dat parsing (byte-exact vs retail), `SoundId` enum (golden-conformance
tested), entity→SoundTable resolution (Setup-then-wire precedence),
world-audio quiescence across portal transitions, AL buffer budget/lifetime.
Divergent or missing, ranked by audible impact:
| # | Defect | Where | Symptom |
|---|---|---|---|
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no 50 dB cutoff; listener = CAMERA; AL 3D pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve both directions; pan wrong from spring-arm offset (AP-28) |
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
| 7 | UI sound bank absent; AdminEnvirons stingers logged not played; portal enter/exit cues missing | — | TS-54, AP-115 |
| 8 | `PlayMusic`/`StopMusic`/`MusicVolume` model retail code that never runs | `IAudioEngine`, settings | Dead API + misleading settings knob |
| 9 | Dead code: `AudioFalloff` (wrong constants, unused), wrong `FUN_00550ad0` citation, invented `PitchMin/PitchMax`+`Loop`+`Is3D` fields on `SoundEntry` | `AudioModel.cs`, engine header | Traps for future readers |
Register rows in scope: **AP-28** (retire at A2), **AP-115** sound half
(retire at A4), **TS-29** (retire at A5/A6), **TS-54** (retire at A4),
**TS-9** (re-scope at A6 — dat census says exactly 1 of 786 waves is MP3).
Issue **#321** (sound-cache decode-dedup race) folds into A6.
---
## Slices
Ordering is audible-value per effort; A1A2 are the "it sounds wrong"
fixes, A3A4 the "it's silent where retail speaks" fixes, A5 the big new
system, A6 the cleanup. Each slice: grep-named → (byte-decode if any new
constant) → pseudocode check against lane note → port → conformance tests →
build/test green → commit; user listening gates where marked.
### A1 — Selection-model correctness (small; biggest audible fix)
Replace `SoundCookbook.Roll` with retail's exact model: uniform
`idx = (int)(roll01 · (n1))` (preserving the last-entry-unreachable
off-by-one), then the separate Bernoulli probability gate returning
"silence" — including for single-entry lists. Priority stays float [0,1]
end-to-end (`SoundEntry.Priority`, engine slots). Volume passes through
unclamped; clamp moves to post-attenuation (staged here, consumed by A2).
Delete the invented `PitchMin/PitchMax/Loop/Is3D` fields. Rewrite
`SoundCookbookTests` against golden values from the lane-4 note's decoded
tables; add a distribution test for the gate.
Acceptance: conformance tests green; connected sanity — creature idle
chatter audibly rare (Speak1 ≈ 5% per trigger, was 100%).
### A2 — Falloff/pan/listener/voice parity (retires AP-28)
Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
clamp-after, ceil-dB, 50 dB no-allocate floor, `15·sin(Δheading)` pan
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
pan (AL_POSITION ±x from pan only); remove `SelectRetailDistanceModel`
and listener orientation math. Listener feed moves from camera pose to
player position/heading. Eviction compares float priority (equal never
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
Keep the squared-volume quirk faithful (register row if we later soften
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
Interface mirroring retail's knobs; note interface is read by no retail
path (we wire it to the UI bus anyway — divergence row, deliberate).
Acceptance: unit tests on gain/pan tables (golden distances from lane 1
note); **user listening gate** — side-by-side with retail: walk away from
a blacksmith's hammering, confirm matching fade-out distance (~94 m) and
pan behavior.
### A3 — Server sound path (0xF750)
Parse `Sound` (guid, SoundType u32, volume f32) in the message router;
route to a new `ServerSoundController`: resolve guid → entity; unknown
guid ⇒ queue the event and replay on CreateObject (retail
`CObjectMaint` behavior); known guid without SoundTable ⇒ silent drop;
play via the SoundTable at the **wire volume** (ignore table volume —
asymmetric with the hook path, byte-verified). Position at the entity's
current origin.
Acceptance: wire-format conformance test (three-oracle layout);
connected gate — melee hits, item pickup/drop, lifestone bind audibly
fire against ACE.
### A4 — UI + interface sounds (retires TS-54, AP-115's sound half)
Load the ClientUISystem sound table (`GetByEnum` cache 0x22, enum slot 7 —
resolve the actual DID at port time from `ClientUISystem::GetUISoundTable`).
Route `PlayUi(SoundId)` through it (delete the no-op). Wire:
AdminEnvirons 0x65..0x7C → `PlaySoundFromCenter` stingers
(`WorldEnvironmentController.ApplyAdminEnvirons` already parses them);
portal enter/exit `UI_EnterPortal`/`UI_ExitPortal`; button/panel cues where
the retained UI already has command seams; `MediaDescSound` support in
`LayoutImporter` (DatReaderWriter parses it; interface bus, per lane 6).
Acceptance: connected gate — `@environs` thunder/drums audible; portal
enter/exit cues audible on recall; **user listening gate** vs retail.
### A5 — Region ambient system (retires TS-29's ambient half)
New `AmbientSoundSystem` (App layer, owned like other world controllers):
rebuild on objcell change (reuse streaming's cell-transit signal), walk
the 3×3 ring × 64 cells via the SAME terrain-word decode the scenery
pipeline uses (`SceneryGenerator`-shared helper), accumulate weight
(1.0 ≤ 20 m, `(20/d)²` ≤ 120 m) + 8-way bearing, build
Constant/Intermit instances from `AmbientSTBDesc` (`base_chance == 0`
constant — the byte-verified polarity), min-heap of absolute deadlines
ticked per frame, one-shots through the ambient volume path (squared,
faithful). ConstantSound non-positional; IntermitSound positioned at
random accumulated bearing, `min + (maxmin)·t²` distance. Teardown on
world transition via the existing quiescence edge. Indoors: NO ambients
(retail-faithful); `seen_outside` cells get the outdoor set. Delete
`StartAmbient`/`StopAmbient` from `IAudioEngine` (wrong shape — looping
handle API models a mechanism retail doesn't have).
Acceptance: unit tests on weight accumulation + scheduler with a synthetic
region; **user listening gate** — Holtburg outdoors vs retail side-by-side
(birdsong/wind character and rough cadence), dungeon silence, ambient
crossfade walking shore → grass.
### A6 — Deletions, bookkeeping, and the long tail
- Delete `PlayMusic`/`StopMusic`/`MusicVolume` and the `AudioSettings.Music`
knob (settings migration: drop the field, tolerate old json). Retail has
no music system; register row NOT needed once the API is gone (nothing
diverges — absence matches retail).
- Delete dead `AudioFalloff` (superseded by A2's ported math).
- r05 doc: SUPERSEDED banner pointing at the six lane notes; corrections
list from lane notes §12/§13.
- TS-9 re-scope: 1 MP3 wave in the shipped dats (`0x0A000393`, ~2 s) —
either a ~50-line managed MP3 decode for one asset or an accepted-loss
row with the census cited. ADPCM count to be measured the same way
before deciding.
- #321: make `DatSoundCache` decode-dedup safe under concurrent access
(single-flight per wave id) — the full-suite flake.
- Register sweep: retire AP-28/TS-29/TS-54 rows in their landing slices'
commits (rule 1); add rows for: interface-volume wired (A2), any
softened quirk, and anything discovered mid-campaign.
Acceptance: build/test green, register diff reviewed, no orphaned
API/settings references.
---
## Out of scope (explicitly)
- Client-local physics sounds (collision/jump/water) — retail has none;
the server sends them. Do not invent.
- Indoor ambient beds — retail is silent indoors.
- A music system — retail has none. (If we ever WANT music, that's a
new-feature decision for the user, not parity work.)
- HRTF/doppler/reverb — no retail counterpart.
## Rollback
Each slice is one commit (A6 possibly two); rollback is `git revert
<slice-sha>`, recorded in this doc's ledger as slices land. A2 and A5 are
the only slices touching frame-loop code paths; both are behind the
existing audio-availability guard, so `ACDREAM_NO_AUDIO=1` remains the
global kill switch.
## Ledger
| Slice | Status | Commit | Gates |
|---|---|---|---|
| A1 | — | — | — |
| A2 | — | — | — |
| A3 | — | — | — |
| A4 | — | — | — |
| A5 | — | — | — |
| A6 | — | — | — |

View file

@ -0,0 +1,739 @@
# Retail ambient sounds — the authoring / DAT data path (Lane 3)
Research-only note. Oracles, in the order the project's rules require:
1. `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named BN pseudo-C,
Sept 2013 EoR build) + `acclient.h` (verbatim retail structs) + `symbols.json`.
2. Raw byte decode of `C:\Users\erikn\Downloads\acclient.exe` (the PDB-paired
v11.4186 binary) for every place BN elided or inverted an x87 comparison.
Method per `claude-memory/reference_pe_byte_decode.md`.
3. `references/DatReaderWriter/` (production dat reader) and
`references/ACViewer/ACE/Source/ACE.DatLoader/` as the independent 2nd/3rd
parser cross-check.
Runtime tick (`Ambient::UseTime`, `Play`, `PlaySoundA`, the play queue,
`IntermitSound::GetSoundPos`) is a sibling lane's scope. This note owns
**where the data comes from** and stops at the point a sound instance exists.
---
## 0. TL;DR
* Ambient sound authoring lives **entirely in the region file** (`0x13xxxxxx`,
`DB_TYPE_REGION`). There is no separate "ambient table" dat range.
* `AmbientSTBDesc.stb_id` is a **`SoundTable` DID in `0x200000000x2000FFFF`**
(`DB_TYPE_STABLE`). "STB" = Sound TaBle. `0x22` in the retail code is the
**DBObj cache-type index**, not a dat-id prefix.
* Outdoor selection is per-**land cell** (8×8 per landblock) off the terrain
word: `terrainType = (w >> 2) & 0x1F`, `sceneOrdinal = w >> 11`. Two index
hops (terrain type → scene type → STB desc) land on the STB descriptor.
* **Indoor / EnvCell ambients do not exist as authored data.** `CEnvCell::add_ambient_sounds`
is present in the PDB but ICF-folded onto a bare `ret` — an empty stub in the
2013 build. The EnvCell dat has no sound field at all.
* Rebuild happens **once per cell change** in `CellManager::ChangePosition`, and
only for landblocks in the **3×3 ring around the viewer block**.
* Two BN pseudo-C readings in this area are **wrong** and byte-verified corrected
below: the `is_continuous` derivation and `Ambient::CalcWeight`.
---
## 1. The complete data chain
```
Region DBObj (DID 0x13000000 + regionNumber, DB_TYPE_REGION)
│ loaded by CRegionDesc::SetRegion(regionNumber) @ 0x004FE8F0
│ → DBObj::GetByEnum(regionNumber, type=0x0B, cache=0x1C)
│ → stored in the global CRegionDesc::current_region (data @ 0x0084146C)
├── sound_info : CSoundDesc
│ └── stb_desc : AmbientSTBDesc[] ← the authored ambient sound sets
│ ├── stb_id : DID → SoundTable (0x200000000x2000FFFF)
│ └── ambient_sounds : AmbientSoundDesc[]
│ { stype, volume, base_chance, min_rate, max_rate }
├── scene_info : CSceneDesc
│ └── scene_types : CSceneType[]
│ ├── <stbIndex> (u32, 0xFFFFFFFF = none) → &sound_info.stb_desc[i]
│ └── scenes : DID[] (0x12xxxxxx Scene objects, procedural
│ scenery — same record, different consumer)
└── terrain_info : CTerrainDesc
└── terrain_types : CTerrainType[] (indexed by the terrain word's type)
├── terrain_name, terrain_color
└── scene_types : u32[] (0xFFFFFFFF = none)
&scene_info.scene_types[idx]
```
Retail resolves the two index fields into **pointers at unpack time** inside
`CRegionDesc::UnPack` (@ 0x004FF440), so at runtime `CSceneType::sound_table_desc`
is a direct pointer into the shared `CSoundDesc::stb_desc` array. Consequence
worth porting deliberately: **`AmbientSTBDesc` instances are shared**, so their
`sound_table` cache and `play_count` are per-region-entry, not per-cell.
Resolution code, verbatim shape (`CRegionDesc::UnPack`, scene section @ 0x004FF713):
```c
for (i = 0; i < numSceneTypes; ++i) {
CSceneType* st = new CSceneType();
stbIdx = read_u32(); // read by the CALLER
st->sound_table_desc = (stbIdx != 0xFFFFFFFF)
? sound_info->stb_desc.m_data[stbIdx]
: NULL;
CSceneType::unpack(st, &buf, &len); // numScenes + scene DIDs
CSceneDesc::Add(scene_info, st);
}
```
and the terrain section (@ 0x004FF8AE):
```c
sceneTypeIdx = read_u32();
terrainType->scene_types[n] = (sceneTypeIdx != 0xFFFFFFFF)
? scene_info->scene_types.m_data[sceneTypeIdx]
: NULL;
```
Note the asymmetry that trips up a naive port: **`CSceneType::pack`/`unpack`
do NOT read/write the STB index** — the enclosing `CRegionDesc` does.
`CSceneType::pack_size` @ 0x005031C0 is `(scenes.m_num << 2) + 8`, i.e. it
budgets 8 bytes of header (STB index + count) while `pack` itself only writes
the count. Both DatReaderWriter and ACE.DatLoader model this correctly by
putting `StbIndex` as the first field of `SceneType`.
---
## 2. Struct layouts (verbatim from `acclient.h`)
```c
/* 3763 */ // sizeof = 0x1C
struct __cppobj AmbientSTBDesc
{
IDClass<_tagDataID,32,0> stb_id; // +0x00 SoundTable DID
int stb_not_found; // +0x04 negative cache
AC1Legacy::SmartArray<AmbientSoundDesc *> ambient_sounds; // +0x08 m_data, +0x0C m_size, +0x10 m_num
CSoundTable *sound_table; // +0x14 resolved DBObj
unsigned int play_count; // +0x18 per-rebuild
};
/* 3761 */ // sizeof = 0x18 in memory, 0x14 on disk
struct AmbientSoundDesc
{
SoundType stype; // +0x00 which slot to pull from the SoundTable
int is_continuous; // +0x04 DERIVED at unpack, NOT stored on disk
float volume; // +0x08
float base_chance; // +0x0C
float min_rate; // +0x10
float max_rate; // +0x14
};
/* 5846 */
struct __cppobj CSoundDesc
{
AC1Legacy::SmartArray<AmbientSTBDesc *> stb_desc;
};
/* 5830 */ // sizeof = 0x14
struct __cppobj CSceneType
{
PStringBase<char> scene_name; // +0x00
SmartArray<IDClass<_tagDataID,32,0>,1> scenes; // +0x04 m_data, +0x08 m_sizeAndDealloc, +0x0C m_num
AmbientSTBDesc *sound_table_desc; // +0x10
};
/* 5832 */
struct __cppobj CTerrainType
{
AC1Legacy::PStringBase<char> terrain_name; // +0x00
RGBAUnion terrain_color; // +0x04
AC1Legacy::SmartArray<CSceneType *> scene_types; // +0x08 m_data, +0x0C m_size, +0x10 m_num
};
/* 5834 */
struct __cppobj CTerrainDesc
{
LandSurf *land_surfaces;
AC1Legacy::SmartArray<CTerrainType *> terrain_types;
};
/* 5851 */
struct __cppobj CRegionDesc : SerializeUsingPackDBObj
{
unsigned int region_number;
AC1Legacy::PStringBase<char> region_name;
unsigned int version;
int minimize_pal;
unsigned int parts_mask;
FileNameDesc *file_info;
SkyDesc *sky_info;
CSoundDesc *sound_info; // ← ambient sound sets live here
CSceneDesc *scene_info;
CTerrainDesc *terrain_info;
CEncounterDesc *encounter_info;
WaterDesc *water_info;
FogDesc *fog_info;
DistanceFogDesc *dist_fog_info;
RegionMapDesc *region_map_info;
RegionMisc *region_misc;
};
```
Runtime instances (for reference; sibling lane owns their behavior):
```c
/* 3765 */
struct __cppobj Ambient
{
Position player_pos; // +0x00
float total_sound_count; // +0x24
unsigned int num_sounds; // +0x28
DArray<AmbientSound *> sounds; // +0x2C
AC1Legacy::PQueueArray<double> sound_queue;
};
/* 3759 */ // sizeof = 0x18
struct __cppobj AmbientSound
{
AmbientSoundVtbl *vfptr; // +0x00
int on_queue; // +0x04
float sound_count; // +0x08 accumulated weight this rebuild
AmbientSTBDesc *desc; // +0x0C identity key part 1
unsigned int ambient_sound_id; // +0x10 identity key part 2 (index into desc->ambient_sounds)
int constant_sound; // +0x14
};
/* 5804 */ // sizeof = 0x80
struct __cppobj IntermitSound : AmbientSound
{
float play_chance; // +0x18
float min_dist[8]; // +0x1C
float max_dist[8]; // +0x3C
unsigned int num_dir; // +0x5C
LandDefs::Direction sound_dir[8]; // +0x60
};
/* 5807 */ // sizeof = 0x1C
struct __cppobj ConstantSound : AmbientSound
{
float current_volume; // +0x18
};
```
`AmbientSound`'s own virtuals are all ICF-folded stubs (`AmbientSound::vftable`
@ 0x007CB0A4 points at `IDClass::~IDClass`, `MediaDesc::GetDuration`,
`Client::You_Must_Not_Have_Multiple_Implementations_Of_AddRef_In_A_Hierarchy`,
etc.). The class is effectively abstract; only `IntermitSound`
(vftable 0x007CB0C4) and `ConstantSound` (vftable 0x007CB0E4) do work.
---
## 3. On-disk pack layouts
Derived from `*::Pack` / `*::pack_size` / `*::UnPack` and confirmed field-for-field
by DatReaderWriter and ACE.DatLoader.
### `CSoundDesc` (region `SoundInfo`, present iff `PartsMask.HasSoundInfo`)
| offset | type | field |
|---|---|---|
| 0 | u32 | numSTBDesc |
| 4 | AmbientSTBDesc × N | — |
### `AmbientSTBDesc``pack_size = 8 + 0x14 * numSounds` (@ 0x00551300)
| offset | type | field |
|---|---|---|
| 0 | u32 | `stb_id` (SoundTable DID) |
| 4 | u32 | numAmbientSounds |
| 8 | AmbientSoundDesc × N (0x14 each) | — |
### `AmbientSoundDesc` — 20 bytes on disk (@ 0x00551220 / 0x005518F0)
| offset | type | field |
|---|---|---|
| 0 | u32 | `stype` (`SoundType`) |
| 4 | f32 | `volume` |
| 8 | f32 | `base_chance` |
| 12 | f32 | `min_rate` |
| 16 | f32 | `max_rate` |
`is_continuous` is **not on disk** — it is computed during unpack (see §3.1).
### `CSceneType` (region `SceneInfo` entries)
| offset | type | field | written by |
|---|---|---|---|
| 0 | u32 | `stbIndex` (0xFFFFFFFF = none) | `CRegionDesc::Pack` |
| 4 | u32 | numScenes | `CSceneType::pack` |
| 8 | u32 × N | Scene DIDs (`0x12xxxxxx`) | `CSceneType::pack` |
### `CTerrainType` (region `TerrainInfo` entries)
| type | field |
|---|---|
| PStringBase\<char\> + align(4) | `terrain_name` |
| u32 | `terrain_color` (ARGB) |
| u32 | numSceneTypes |
| u32 × N | scene-type indices into `SceneInfo.SceneTypes` (0xFFFFFFFF = none) |
### 3.1 CORRECTION #1`is_continuous` (BN pseudo-C is inverted)
BN renders `AmbientSTBDesc::UnPack` @ 0x005519A9 as if `is_continuous` were
`base_chance != 0`. Byte decode of the paired binary says the opposite:
```
005519a9 d9 43 0c fld dword [ebx+0x0C] ; base_chance
005519ac dc 1d 10 46 79 00 fcomp qword [0x00794610] ; = 0.0 (verified)
005519b2 df e0 fnstsw ax
005519b4 f6 c4 44 test ah, 0x44 ; C3(equal) | C2(unordered)
005519b7 7a 07 jp 0x005519C0 ; PF set ⇔ mask result == 0 ⇔ NOT equal
005519b9 b8 01 00 00 00 mov eax, 1
005519be eb 02 jmp 0x005519C2
005519c0 33 c0 xor eax, eax
005519c2 89 43 04 mov [ebx+0x04], eax ; is_continuous
```
**`is_continuous = (base_chance == 0.0f)`.**
Corroborated by `Ambient::GetSound` @ 0x005510B0 (byte-verified at 0x00551106:
`mov eax,[esp+0x10]; test eax,eax; je +0x3A` — the `je` goes to the 0x80-byte
allocation):
* `is_continuous == 0``operator new(0x80)`**`IntermitSound`**
* `is_continuous != 0``operator new(0x1C)`**`ConstantSound`**
So, authored semantics:
| `base_chance` | instance | behavior |
|---|---|---|
| `0.0` | `ConstantSound` | continuous/looping ambience, volume-weighted |
| non-zero | `IntermitSound` | random one-shots, chance-weighted |
Getting this backwards is silent: every continuous ambience becomes an
intermittent sound with a 0 play chance, i.e. total silence.
### 3.2 Field meanings (from the two subclasses)
| field | `ConstantSound` | `IntermitSound` |
|---|---|---|
| `stype` | SoundTable slot to play (typically `Sound_Ambient1..8` = `0x46..0x4D`) | same |
| `volume` | `current_volume = volume / total_sound_count * sound_count` (@ 0x00551576) | `GetVolume` returns `volume` verbatim (@ 0x00551070) |
| `base_chance` | must be 0 (that's what selects this class) | `play_chance = base_chance / total_sound_count * sound_count` (@ 0x0055133C) |
| `min_rate` | `GetPlayInterval` returns `min_rate` — the loop re-trigger period (@ 0x005510A0) | lower bound of `Random::RollDice(min_rate, max_rate)` |
| `max_rate` | unused | upper bound of the roll (@ 0x00551094) |
`Sound_Ambient1..Sound_Ambient8 = 0x46..0x4D` (`acclient.h:4641-4648`). Nothing
forces `stype` into that range — it is just the key looked up in the STB's
`CSoundTable::Sounds` dictionary.
---
## 4. Outdoor selection — `CLandBlock::add_ambient_sounds` @ 0x00530310
Faithful pseudocode:
```c
void CLandBlock::add_ambient_sounds(Ambient* ambient)
{
Position soundPos; // identity frame, then filled per cell
int n = this->side_cell_count; // 8
for (int y = 0; y < n; ++y) {
for (int x = 0; x < n; ++x) {
// sound position = the land cell's SW terrain vertex, in landblock space
const float* v = vertex_array.vertices
+ (side_vertex_count * y + x) * CVertexArray::vertex_size;
soundPos.origin = { v[0], v[1], v[2] };
soundPos.objcell_id = this->lcell[n * y + x].m_DID.id;
// terrain array is 9x9 uint16, row stride 0x12 bytes
uint16 w = *(uint16*)(this->terrain + (y * 0x12 + x * 2));
uint32 tType = (w >> 2) & 0x1F; // terrain type (5 bits)
uint32 sScene = w >> 11; // scene ordinal (5 bits, uint16 >> 11)
if (sScene < CRegionDesc::NumSceneType(current_region, tType)) {
AmbientSTBDesc* d = CRegionDesc::GetSTBDesc(current_region, tType, sScene);
if (d) Ambient::AddSound(ambient, d, &soundPos);
}
}
}
}
```
The two lookups:
```c
// CTerrainDesc::NumSceneType @ 0x00502430
uint32 NumSceneType(t) {
return (t < terrain_types.m_num) ? terrain_types[t]->scene_types.m_num : 0;
}
// CTerrainDesc::GetSTBDesc @ 0x00502400 (field offsets confirmed against acclient.h)
AmbientSTBDesc* GetSTBDesc(t, s) {
if (t >= terrain_types.m_num) return NULL;
CTerrainType* tt = terrain_types[t];
if (s >= tt->scene_types.m_num) return NULL;
CSceneType* st = tt->scene_types[s];
return st ? st->sound_table_desc : NULL; // +0x10
}
// CRegionDesc::GetSTBDesc @ 0x004FEAB0 — adds lazy SoundTable resolution
AmbientSTBDesc* GetSTBDesc(t, s) {
AmbientSTBDesc* d = terrain_info->GetSTBDesc(t, s);
if (!d) return NULL;
int ok = 0;
if (d->sound_table == NULL) ok = d->InitSoundTable();
return (d->sound_table || ok) ? d : NULL;
}
// AmbientSTBDesc::InitSoundTable @ 0x004FEA60
int InitSoundTable() {
if (stb_not_found) return 0;
if (stb_id == INVALID_DID) return 0;
sound_table = (CSoundTable*)DBObj::Get(QualifiedDataID(stb_id, /*type*/ 0x22));
if (sound_table) return 1;
stb_not_found = 1; // negative cache; never retried
return 0;
}
```
`0x22` is the DBObj cache-type index for `CSoundTable`, proven by
`CLOCache::CLOCache(cache, CSoundTable::Allocator, 0x22)` @ 0x004FB831. The
same `0x22` is used for object/setup sound tables (`CPhysicsObj` /
`SetupDesc::default_stable_id` sites @ 0x00513A36, 0x00514F9F) and by
`MediaDesc` @ 0x004658DA — so **no separate ambient dat range exists**; ambient
sound tables are ordinary `SoundTable` objects in `0x200000000x2000FFFF`.
### 4.1 Which landblocks contribute — `LScape::add_ambient_sounds` @ 0x00505810
```c
void LScape::add_ambient_sounds(Ambient* ambient)
{
for (int by = 0; by < mid_width; ++by)
for (int bx = 0; bx < mid_width; ++bx) {
int ring; LandDefs::Direction dir;
LScape::get_block_orient(this, by, bx, &ring, &dir);
if (ring != 1) continue; // <-- the gate
CLandBlock* lb = land_blocks[mid_width * by + bx];
if (lb) lb->add_ambient_sounds(ambient);
}
}
```
`LScape::get_block_orient` @ 0x00504F90 computes
`d = max(|bx - mid_radius|, |by - mid_radius|)` (Chebyshev distance in
landblocks from the viewer block) and emits `ring = 1` for `d <= 1`,
`2` for `d == 2`, `4` for `d in [3,4]`, `8` for `d > 4`.
So ambient sounds are gathered from the **3×3 landblock neighbourhood centred
on the viewer's landblock** — up to 9 × 64 = **576 `AddSound` calls** per cell
change. Every call is distance-gated inside `AddSound`, so most contribute
nothing (a landblock is 192 m across; the outer cut is 120 m).
### 4.2 CORRECTION #2`Ambient::CalcWeight` @ 0x00550DD0
BN drops the arithmetic entirely. Byte decode gives the exact function:
```
d9 44 24 04 fld dword [esp+4] ; d2 = ox² + oy² + oz²
d8 1d 54f18100 fcomp dword [0x0081F154] ; ambient_sound_max_dist_sq = 14400
df e0 / f6 c4 41 / 75 09 ; if (d2 > max) -> fld [0x00795344]=0.0; ret
d9 44 24 04 fld dword [esp+4]
d8 1d 4cf18100 fcomp dword [0x0081F14C] ; ambient_sound_min_dist_sq = 400
df e0 / f6 c4 05 / 7a 09 ; if (d2 < min) -> fld [0x007928B0]=1.0; ret
d9 05 4cf18100 fld dword [0x0081F14C] ; 400
d8 74 24 04 fdiv dword [esp+4] ; 400 / d2
```
```c
float Ambient::CalcWeight(const Vector3& offset)
{
float d2 = offset.x*offset.x + offset.y*offset.y + offset.z*offset.z;
if (d2 > 14400.0f) return 0.0f; // beyond 120 m: silent
if (d2 < 400.0f) return 1.0f; // within 20 m: full
return 400.0f / d2; // inverse-square; 0.0278 at 120 m
}
```
Verified globals (`.data`):
| address | symbol | value |
|---|---|---|
| 0x0081F148 | `Ambient::ambient_sound_min_dist` | 20.0 m |
| 0x0081F14C | `Ambient::ambient_sound_min_dist_sq` | 400.0 |
| 0x0081F150 | `Ambient::ambient_sound_max_dist` | 120.0 m |
| 0x0081F154 | `Ambient::ambient_sound_max_dist_sq` | 14400.0 |
| 0x0081F158 | `Ambient::ambient_sound_min_vol` | 0.03 |
### 4.3 `Ambient::AddSound` @ 0x00551610 (the accumulator)
```c
void Ambient::AddSound(AmbientSTBDesc* desc, const Position& soundPos)
{
if (!SoundManager::ambient_sounds_enabled) return;
Vector3 off = player_pos.get_offset(soundPos); // player-frame offset
if (off.LengthSq() >= ambient_sound_max_dist_sq) return;
float w = CalcWeight(off);
LandDefs::Direction dir = CalcDir(off);
if (w <= 0) return;
total_sound_count += w; // ONCE per cell
for (uint i = 0; i < desc->ambient_sounds.m_num; ++i)
GetSound(desc, i)->AddTo(w, off, dir); // per authored sound
}
```
Faithfulness note for the port: `total_sound_count` is bumped **once per
contributing land cell**, while each of the STB's N `AmbientSoundDesc` entries
gets `w` added to its own `sound_count`. For an STB with N > 1, the sum of
`sound_count` is therefore N × `total_sound_count`, so the "share of total"
normalisation used by `ConstantSound::UpdateSound`
(`volume / total_sound_count * sound_count`) can legitimately exceed
`volume`. Reproduce it; don't "fix" it.
`Ambient::GetSound` @ 0x005510B0 keys the instance cache on the pair
`(desc pointer, ambient_sound_id)` and **never evicts** — instances accumulate
for the life of the `Ambient`. That is what makes it correct to only
`ResetCount()` on rebuild.
---
## 5. Indoor / EnvCell: the hook exists, the data does not
`symbols.json` has:
```json
{"address": "0x00694750", "name": "CEnvCell::add_ambient_sounds",
"mangled": "?add_ambient_sounds@CEnvCell@@SAXPAVAmbient@@@Z"}
```
`SAX` = **static**, void, one `Ambient*` argument. Address `0x00694750` is
shared with `IDClass<_tagDataID,32,0>::~IDClass` and `AmbientSound::ResetCount`,
and the pseudo-C for that address is:
```c
00694750 void IDClass<_tagDataID,32,0>::~IDClass(...) __pure
00694750 { return; }
```
That is COMDAT identical-code folding onto a bare `ret`. The call site in
`CellManager::ChangePosition` @ 0x00455B0A is rendered by BN as
`IDClass<...>::~IDClass(ambient_sounds)` — passing an `Ambient*` to a DID
destructor, which is the tell that it is really the folded
`CEnvCell::add_ambient_sounds(ambient)`.
**Conclusion: in the Sept 2013 EoR client, indoor cells contribute zero
ambient sounds through this path.** Independently corroborated by the dat
format — `EnvCell` (DatReaderWriter `DBObjs/EnvCell.generated.cs`) has exactly:
`Flags`, `Surfaces`, `EnvironmentId`, `CellStructure`, `Position`,
`CellPortals`, `VisibleCells`, `StaticObjects`, `RestrictionObj`. No sound
field, no sound table, no ambient list. `LandDefs` likewise has no sound field.
Where dungeon ambience actually comes from in retail (out of this lane's scope,
but the obvious next question): server-spawned objects carrying a
`SoundTableId` / physics-script sound, i.e. the `0x22` consumers at
0x00513A36 / 0x00514F9F — object sound tables, not the `Ambient` system.
Also note `LScape::add_ambient_sounds` is skipped entirely while indoors unless
the current cell has `seen_outside != 0` (see §6), so an interior cell that
can see outdoors still hears the outdoor set.
---
## 6. Lifecycle — `CellManager::ChangePosition` @ 0x004559B0
Everything ambient-related is inside the **cell-changed** branch. There is no
per-frame ambient rebuild.
```c
void CellManager::ChangePosition(const Position* newPos, int forceReload)
{
if (newPos->objcell_id == 0) { Reset(); return; }
int reload = blocking_for_cells ? 1 : forceReload;
if (load_pos.objcell_id != newPos->objcell_id || curr_cell == NULL)
{
PreFetchCells(newPos->objcell_id, reload);
... release old curr_cell, update LScape loadpoint, grab_visible_cells ...
CEnvCell::master_incell_timestamp += 1;
CEnvCell::flush_cells();
if (curr_cell != NULL)
{
bool outdoorish = isOutdoorCell(newPos) || curr_cell->seen_outside;
if (outdoorish) { ...sunlight / SetWorldAmbientLight from LScape... }
else { SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF); }
Ambient::InitSounds(ambient_sounds, newPos); // 1
CEnvCell::add_ambient_sounds(ambient_sounds); // 2 (empty stub)
if (outdoorish)
LScape::add_ambient_sounds(lscape, ambient_sounds); // 3
Ambient::UpdatePlayQueue(ambient_sounds); // 4
Ambient::ReleaseSoundTables(ambient_sounds); // 5
}
}
load_pos = *newPos;
}
```
**(1) `Ambient::InitSounds` @ 0x005515D0** — the rebuild barrier:
```c
void Ambient::InitSounds(const Position* p)
{
player_pos = *p;
total_sound_count = 0.0f;
for (i = 0; i < num_sounds; ++i) sounds[i]->ResetCount();
}
```
`IntermitSound::ResetCount` @ 0x00550CD0 / `ConstantSound::ResetCount` @
0x00550D70 zero `sound_count` (and `desc->play_count`). Instances are **not**
destroyed — a sound that no longer has any nearby cell simply drops to
`sound_count == 0` and goes silent (`ConstantSound::UpdateSound` sets
`current_volume = 0`).
**(5) `Ambient::ReleaseSoundTables` @ 0x00455770** — the streaming release:
```c
for (i = 0; i < num_sounds; ++i) {
AmbientSTBDesc* d = sounds[i]->desc;
if (d->sound_table && d->play_count == 0) { // nothing will play from it
d->sound_table->Release();
d->sound_table = NULL; // re-fetched lazily next time
}
}
```
`play_count` is bumped in `IntermitSound::UpdateSound` / `ConstantSound::UpdateSound`
during step (4), so step (5) drops the `CSoundTable` DBObj reference for every
STB whose sounds ended up inaudible at the new position.
**Teardown — `CellManager::Reset` @ 0x00455930** calls
`Ambient::FlushSoundTables` @ 0x00452920, which is `ReleaseSoundTables` plus
a `ResetCount()` on every sound and `total_sound_count = 0`.
`Ambient::Destroy` @ 0x00551580 / `~Ambient` @ 0x00551760 delete the
`AmbientSound` instances (after re-stamping the base vftable — the usual
C++ dtor-devirtualisation artifact).
Per-frame ticking is `SmartBox``Ambient::UseTime` @ 0x00551880 (sibling lane).
Both `AddSound` and `UpdatePlayQueue` are gated on
`SoundManager::ambient_sounds_enabled`, so the user's audio option short-circuits
the whole gather.
---
## 7. DatReaderWriter coverage (what we get for free)
| retail type | DRW class | file | status |
|---|---|---|---|
| `CRegionDesc` | `DBObjs.Region` (0x130000000x1300FFFF, `HasId`) | `Generated/DBObjs/Region.generated.cs` | **Complete for our needs.** Parses `RegionNumber`, `Version`, `RegionName`, `LandDefs`, `GameTime`, `PartsMask`, then masked `SkyInfo` / `SoundInfo` / `SceneInfo`, unconditional `TerrainInfo`, masked `RegionMisc`. |
| `CSoundDesc` | `Types.SoundDesc``List<AmbientSTBDesc> STBDesc` | `Generated/Types/SoundDesc.generated.cs` | **Exact match** to retail pack (`u32 count` + N entries). |
| `AmbientSTBDesc` | `Types.AmbientSTBDesc``uint STBId`, `List<AmbientSoundDesc>` | `Generated/Types/AmbientSTBDesc.generated.cs` | **Exact match.** |
| `AmbientSoundDesc` | `Types.AmbientSoundDesc``Sound SType`, `float Volume/BaseChance/MinRate/MaxRate` | `Generated/Types/AmbientSoundDesc.generated.cs` | **Exact match** to the 20-byte on-disk record. Correctly omits `is_continuous`. |
| `CSceneDesc` | `Types.SceneDesc``List<SceneType>` | `Generated/Types/SceneDesc.generated.cs` | **Exact match.** |
| `CSceneType` | `Types.SceneType``uint StbIndex`, `List<QualifiedDataId<Scene>> Scenes` | `Generated/Types/SceneType.generated.cs` | **Exact match**, including the caller-written `StbIndex` first. Independently confirmed by `ACE.DatLoader/Entity/SceneType.cs`. |
| `CTerrainDesc` | `Types.TerrainDesc``List<TerrainType>`, `LandSurf` | `Generated/Types/TerrainDesc.generated.cs` | **Exact match.** |
| `CTerrainType` | `Types.TerrainType``TerrainName`, `ColorARGB TerrainColor`, `List<uint> SceneTypes` | `Generated/Types/TerrainType.generated.cs` | **Exact match** (indices, not resolved pointers). |
| `CSoundTable` | `DBObjs.SoundTable` (0x200000000x2000FFFF, `HasId`) → `HashKey`, `Dictionary<uint,SoundHashData> Hashes`, `Dictionary<Sound,SoundData> Sounds` | `Generated/DBObjs/SoundTable.generated.cs` | **Complete.** `Sounds[stype].Entries` is the wave list. |
| `SoundType` | `Enums.Sound` (incl. `Ambient1..8`) | `Generated/Enums/Sound.generated.cs` | Present. |
**Gaps we must write ourselves** (none of them are parsers):
1. **`is_continuous` derivation.** DRW deliberately stores only the on-disk
fields. We compute `IsContinuous => BaseChance == 0f` at load. §3.1.
2. **Index → object resolution.** DRW hands back raw `StbIndex` and
`TerrainType.SceneTypes` indices with `0xFFFFFFFF` sentinels. Retail resolves
them once at unpack; we need the equivalent resolve step (or resolve on
lookup, which is what `GetSTBDesc` does anyway) and must honour
`0xFFFFFFFF == none`.
3. **The whole `Ambient` runtime**: `AmbientSTBDesc` shared state
(`sound_table` cache, `stb_not_found` negative cache, `play_count`), the
`(desc, index)`-keyed instance cache, `IntermitSound`/`ConstantSound`,
`CalcWeight`/`CalcDir`, the play queue, the release policy. No reference
repo has any of this — ACE is a server and does not model client ambience;
ACViewer has no ambient sound handling (`grep -ri ambient` over
`references/ACViewer/` returns only render-pass / ambient-light hits).
4. **`CLandBlock`/`LScape` gather** — the terrain-word decode, the 8×8 land-cell
walk with the 9-wide row stride, the `ring == 1` 3×3 landblock gate, and the
`CellManager::ChangePosition` trigger point. All ours.
5. **`Random::RollDice(min_rate, max_rate)`** for the intermittent interval.
Verify our RNG matches retail's `RollDice` semantics before wiring
`min_rate`/`max_rate`.
---
## 8. Answers to the posed questions
1. **Complete chain.** `Region` DBObj `0x13000000+regionNumber`
`SoundInfo (CSoundDesc)``AmbientSTBDesc[]`; each descriptor's `stb_id` is
a `SoundTable` DID in `0x200000000x2000FFFF`, fetched via
`DBObj::Get(QualifiedDataID(id, cacheType=0x22))`; each descriptor carries N
`AmbientSoundDesc { stype, volume, base_chance, min_rate, max_rate }`, and
`base_chance == 0` selects `ConstantSound` while non-zero selects
`IntermitSound`. Selection is reached indirectly:
`TerrainInfo.TerrainTypes[t].SceneTypes[s]``SceneInfo.SceneTypes[idx]`
`.StbIndex``SoundInfo.STBDesc[stbIdx]`.
2. **Outdoor selection.** Per land cell, from the landblock's 9×9 `uint16`
terrain array: `terrainType = (w >> 2) & 0x1F`, `sceneOrdinal = w >> 11`;
bounds-checked against `NumSceneType(terrainType)`; resolved by
`CRegionDesc::GetSTBDesc(terrainType, sceneOrdinal)`. Not region-wide, not
per-landblock — **per land cell**, and the sound's position is that cell's
SW terrain vertex with the land cell's own `objcell_id`.
3. **Indoor.** Nowhere. `CEnvCell::add_ambient_sounds` is an ICF-folded empty
stub, and the EnvCell dat record has no sound field. Interiors flagged
`seen_outside` still get the outdoor set.
4. **Lifecycle.** Built in `CellManager::ChangePosition` only when
`load_pos.objcell_id != newPos.objcell_id || curr_cell == NULL`, in the exact
order `InitSounds``CEnvCell::add_ambient_sounds` (no-op) →
`LScape::add_ambient_sounds` (if outdoor-ish) → `UpdatePlayQueue`
`ReleaseSoundTables`. Teardown is `CellManager::Reset`
`Ambient::FlushSoundTables`; final destruction is `Ambient::Destroy`.
5. **DRW coverage.** Every on-disk structure in the chain is already parsed
exactly (Region / SoundDesc / AmbientSTBDesc / AmbientSoundDesc / SceneDesc /
SceneType / TerrainDesc / TerrainType / SoundTable / Sound enum). What we
write is the derived flag, index resolution, and the entire runtime gather +
instance model. See §7.
---
## 9. Divergence-register candidates (if/when this is implemented)
* If we ever gather ambients from more than the 3×3 landblock ring, that is a
deviation — retail's gate is `get_block_orient(...) == 1`.
* If we implement indoor ambience from any authored source, that is a **new
feature**, not a port — retail has none. Register it.
* The multi-entry-STB `total_sound_count` asymmetry in §4.3 is retail behavior;
"normalising" it is a deviation.
## 10. Retail anchors (for code comments)
| symbol | address |
|---|---|
| `CRegionDesc::SetRegion` | 0x004FE8F0 |
| `CRegionDesc::UnPack` (index→pointer resolution) | 0x004FF440 |
| `CRegionDesc::NumSceneType` | 0x004FE960 |
| `CRegionDesc::GetSTBDesc` | 0x004FEAB0 |
| `CTerrainDesc::GetSTBDesc` | 0x00502400 |
| `CTerrainDesc::NumSceneType` | 0x00502430 |
| `AmbientSTBDesc::InitSoundTable` | 0x004FEA60 |
| `AmbientSTBDesc::UnPack` | 0x005518F0 |
| `AmbientSTBDesc::Pack` / `pack_size` | 0x00551220 / 0x00551300 |
| `CSoundDesc::UnPack` | 0x005028D0 |
| `CSceneType::unpack` / `pack_size` | 0x005032C0 / 0x005031C0 |
| `CLandBlock::add_ambient_sounds` | 0x00530310 |
| `LScape::add_ambient_sounds` | 0x00505810 |
| `LScape::get_block_orient` | 0x00504F90 |
| `CEnvCell::add_ambient_sounds` (folded no-op) | 0x00694750 |
| `CellManager::ChangePosition` | 0x004559B0 |
| `CellManager::Reset` | 0x00455930 |
| `Ambient::InitSounds` | 0x005515D0 |
| `Ambient::AddSound` | 0x00551610 |
| `Ambient::GetSound` | 0x005510B0 |
| `Ambient::CalcWeight` / `CalcDir` | 0x00550DD0 / 0x00550E40 |
| `Ambient::ReleaseSoundTables` / `FlushSoundTables` | 0x00455770 / 0x00452920 |
| `IntermitSound::UpdateSound` / `GetPlayInterval` | 0x00551310 / 0x00551080 |
| `ConstantSound::UpdateSound` / `GetPlayInterval` | 0x00551540 / 0x005510A0 |

File diff suppressed because it is too large Load diff

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.

View file

@ -0,0 +1,491 @@
# Lane 6 — Retail MUSIC system (MediaMachine / MD_Data_Sound / winmm MIDI)
Read-only research note. Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(Sept 2013 EoR build, PDB-named), `acclient.h` (verbatim retail structs),
`symbols.json`, plus `references/DatReaderWriter/`, `references/ACViewer/`,
the retail install at `C:\Turbine\Asheron's Call\`, and the live
`UserPreferences.ini`.
---
## 0. Headline finding — retail EoR HAS NO MUSIC SYSTEM
This is the load-bearing result and it contradicts the standing assumption in
`docs/research/deepdives/r05-audio-sound.md` §6.
Three independent pieces of evidence, all from the exact PDB-paired 2013 build:
1. **`midiPlay` has zero callers.** The only three occurrences of address
`0x00553390` in the whole 65 MB pseudo-C are the function's own
definition/open/close lines (`acclient_2013_pseudo_c.txt:350072,350074,350133`).
The single internal call is `midiPlayNext``midiPlay`
(`:350147`), and `midiPlayNext` is itself only reachable from the
`MidiProc` buffer-done callback (`:350223`) — i.e. it only ever advances a
*queue that nothing ever fills*.
2. **Both MIDI callbacks are permanently null.** `midiEventCallback` and
`midiStartCallback` are statically initialised to 0
(`:1185597`, `:1185598`) and there is no assignment site anywhere in the
image. `MidiProc` null-checks them on every event and no-ops.
3. **No music preference and no music files.** `SoundManager::InitPrefs` /
`ShutDown` register exactly eight sound preferences —
`SoundDisabled`, `SoundVolume`, `AmbientSoundDisabled`,
`AmbientSoundVolume`, `InterfaceSoundDisabled`, `InterfaceSoundVolume`,
`SoundFeatures`, `PlaySoundOnlyWhenActive` (`:346764``:346810` region,
Unregister list at `:00550367``:005503ad`). The live
`%USERPROFILE%\Documents\Asheron's Call\UserPreferences.ini` `[Sound]`
section contains exactly those keys — **no music volume, no music toggle**.
And `C:\Turbine\Asheron's Call\` contains **zero `.mid` / `.rmi` / `.mp3` /
`.wav`** files; the only media file on disk is `turbine_logo_ac.avi`.
4. **The word "music" does not appear anywhere in the 65 MB pseudo-C**
(case-insensitive grep: 0 hits), and `SoundType` (the 0x000xCC enum,
`acclient.h:4569`) has **no music member**.
So: the client *links* a complete Microsoft-sample-derived SMF streaming
player, initialises it at startup (`SoundManager::Init``midiSetup()`,
`:346764`), tears it down at shutdown (`SoundManager::ShutDown`
`midiCleanup()`, `:346655`), and **never hands it a file**. It is dead
infrastructure — a vestige of a 1999 design decision that was cut.
What players actually hear as "music" in retail EoR is one of three things,
all of them ordinary DAT `Wave` (0x0A) PCM played through the normal
DirectSound path:
| Perceived as | Actually is | Retail mechanism |
|---|---|---|
| Login / splash score | audio track of `turbine_logo_ac.avi` | `MD_Data_Movie` → DirectShow `IGraphBuilder` |
| Dungeon "chanting/drums/whispers" atmosphere | UI SoundTable stingers | `CPlayerSystem::Handle_Admin__Environs` codes 0x650x7C |
| Outdoor/dungeon soundscape | region ambient sound rolls | `Ambient` priority queue + `AmbientSTBDesc` |
---
## 1. The MIDI subsystem (documented for completeness / correction of r05)
Free functions, all at `0x00552f60``0x00553840`. It is a near-verbatim port of
Microsoft's `MIDIPLYR` SDK sample, including the literal event name
`"Wait For Buffer Return"` (`:350321`).
| Symbol | Addr | Role |
|---|---|---|
| `midiSetup()` | `0x00553770` | `midiOutGetNumDevs`, fill `dwVolCache[16]`/`dwVolPctCache[16]` with 100, `CreateEventA("Wait For Buffer Return")`, `midiStreamOpen(&hStream, &uMIDIDeviceID, 1, MidiProc, 0, 0x30000)`. Sets `MidiIsSetup`. |
| `StreamBufferSetup(char* path)` | `0x00553030` | allocates **6 buffers × 0x400 bytes** (`LocalAlloc(LMEM_ZEROINIT, 0x423)` then 32-byte-aligned), `ConverterInit(path)`, `midiStreamProperty(…, 0x80000001)` = set time division, primes all 6 via `ConvertToBuffer` + `midiOutPrepareHeader` + `midiStreamOut`. |
| `midiPlay(path, loop, immediate, tempoMul)` | `0x00553390` | if already playing and `immediate==0` → stash into `pending`/`pending_loop`/`dwQueuedTempoMultiplier`, set `is_pending`, return (that is the "queue next track" path). Otherwise `midiStop()`, `StreamBufferSetup`, then per-channel `midiOutShortMsg(0xB0|ch, ctrl 7, vol)` for all 16 channels, `midiStreamRestart`. `tempoMul` is a percentage (default `0x64` = 100). |
| `midiPlayNext()` | `0x005534c0` | pops `pending``midiPlay(pending, pending_loop, 1, …)`. |
| `MidiProc` | `0x00553500` | `MOM_DONE` (0x3C9) refills/rotates buffers mod 6; on end-of-data (`uCallbackStatus == 0x12C`) waits for all 6 buffers back, then either `midiPlayNext()` if a track is queued or `midiStop()`. `MOM_POSITIONCB` (0x3CA) sniffs the stream: caches controller-7 volume per channel and forwards note-on/off + volume on **channels 14/15 only** to `midiEventCallback` (a game-sync hook — never installed). |
| `midiStop()` | `0x00553240` | `midiStreamStop`, `midiOutReset`, `WaitForSingleObject(hBufferReturnEvent, 0x7D0 = 2000 ms)`, `ConverterCleanup`, `FreeBuffers`, close+reopen the stream, reset `is_pending`/`pending_loop`/`dwQueuedTempoMultiplier = 100`. |
| `SetChannelVolume(ch, pct)` | `0x00552f60` | `midiOutShortMsg(0xB0|ch, ctrl 7, dwVolCache[ch]*pct/100)`. The only volume control; **no fade, no crossfade, no ramp anywhere**. |
| `ConverterInit(path)` | `0x00554530` | `CreateFileA(path, GENERIC_READ, …)` → reads `'MThd'` (`0x6468544D`), byte-swaps header, `dwFormat`/`dwTrackCount`/`dwTimeDivision`, then per track reads `'MTrk'` (`0x6B72544D`) into a 0x400 window. **Standard MIDI File from a loose disk path — never from a DAT.** |
### Corrections to `r05-audio-sound.md` §6
| r05 claim | Verdict |
|---|---|
| "Music is MIDI, streamed through midiStreamOpen" | **Correct as to mechanism**, wrong as to it being used. |
| 6 × 1024-byte buffers, "Wait For Buffer Return" event, 16-channel volume arrays | **Confirmed.** |
| "MThd/MTrk parsing at FUN_00555150" | Right idea, wrong address in the named build: `ConverterInit` @ `0x00554530`. |
| "pan (0x0A)" via `midiOutShortMsg` | **Not found.** Only controller **7 (volume)** is written (`SetChannelVolume`, `midiPlay`). No pan CC. |
| "Track selection is by the game code calling `PlayMusic(path, loop)` — driven by region/area rules" | **REFUTED.** No such caller and no region→track table exists. This sentence is the source of the `PlayMusic(string resourceName, bool loop)` shape in `IAudioEngine` — that signature is an invention, not a retail port. |
| "Recommended: convert MIDI to OGG offline" | Moot — there is no MIDI content to convert. |
**Consequence for the port:** a MIDI synth is *not* needed. Neither is an OGG
music bus. There is nothing to be faithful to.
---
## 2. `MediaMachine` — what it actually is (a per-UI-element media bytecode VM)
`MediaMachine` is **not** the music system. It is the interpreter for the
*media script* attached to every UI element **state** in the LayoutDesc DAT.
Playing a sound is one of its eleven instructions.
### Ownership chain
```
LayoutDesc (DBObj) acclient.h:33881
└─ ElementDesc : StateDesc acclient.h:33693
└─ StateDesc acclient.h:33640
└─ SmartArray<MediaDesc*> m_media ← the script
UIElement
└─ MediaMachine m_mediaMachine acclient.h:33786
├─ UIElement* m_owner
├─ SmartArray<MediaDesc*> m_array ← deep copy of the active state's m_media
└─ unsigned m_curIndex ← the program counter
```
`MediaMachine : UIListener` (`acclient.h:33873`).
### State machine (this is the whole thing)
- `MediaMachine::Reset(const SmartArray<MediaDesc*>&)` @ `0x00465d90`
(`:112681`): `Cleanup()`, then deep-copy every `MediaDesc` via
`MediaDesc::CreateMediaType(const MediaDesc*)`, set `m_curIndex = 0`, and
immediately `Update()`. Called from `UIElement::SetState`
`MediaMachine::Reset(&m_mediaMachine, &m_desc.m_media)` (`:108863`), and on
element copy (`:111688`, `:111745`).
- `MediaMachine::Update()` @ `0x00465ba0` (`:112526`) — the interpreter loop:
1. `UIListener::UnRegisterForGlobalMessage(this, 3)`.
2. While `m_curIndex < m_array.m_num`: dispatch on `m_type - 1` through an
11-entry jump table (`jump_table_465cc0`, `:112621`) to the matching
`Update_X(desc)`.
3. **The return value is "may I advance?"** — non-zero ⇒ `m_curIndex++` and
continue in the same call; **zero ⇒ break** (the instruction is still
blocking).
4. On break, `UIListener::RegisterForGlobalMessage(this, 3)` — i.e. subscribe
to the per-tick global message so the machine resumes next frame.
- `MediaMachine::ListenToGlobalMessage(msg, _)` @ `0x00465cf0`: `if (msg == 3)
Update()`. **Global message 3 is the machine's clock.** There is no
dedicated music/media tick.
- `Cleanup()` @ `0x00465af0`: virtual-deletes every owned `MediaDesc`, zeroes
the array. Called by dtor and by `Reset`.
Termination: the machine runs off the end of the array and stops (no
re-registration). A `Jump` instruction is what makes a script loop forever.
### Instruction set (`MediaDesc::m_type`, 1-based)
Verified from `MediaDesc::CreateMediaType(uint32_t)` @ `0x0069d420`
(`:675786`) and each ctor's `MediaDesc::MediaDesc(this, N)`:
| # | Type | Struct (acclient.h) | Blocking? | Semantics |
|---|---|---|---|---|
| 1 | Movie | `MD_Data_Movie` :34160 — `PStringBase m_strFileName`, `bool m_StretchToFullScreen`, `MovieTheatre*` | yes, until done | `MD_Data_Movie::Update(owner)` @ `0x0069d489`; DirectShow (`ATL::CComPtr<IGraphBuilder>` :34191). Requires owner visible-bit `(m_owner+0x554) >> 0x11 & 1`. |
| 2 | Alpha | `MD_Data_Alpha` :34118 — `DID m_file` | no | alpha mask image |
| 3 | Anim | `MD_Data_Anim` :34101 — `float m_duration`, `m_drawMode`, `SmartArray<DID> m_frames`, `double m_StartTime`, `int m_displayedFrameNum` | yes, for `m_duration` | flipbook; latches `m_StartTime` on first visit (sentinel `-1.0`) |
| 4 | Cursor | `MD_Data_Cursor` :34168 — `DID m_file`, `int m_xHotspot`, `m_yHotspot` | no | `UIElement::SetCursor` |
| 5 | Image | `MD_Data_Image` :34111 — `DID m_file`, `m_drawMode` | no | set the element's picture |
| 6 | Jump | `MD_Data_Jump` :34132 — `uint m_jumpItemIndex`, `float m_probability` | no | `RollDice(0,1)` vs probability; on pass `m_curIndex = m_jumpItemIndex - 1` (then the loop's `++` lands exactly on `m_jumpItemIndex`). **This is the loop primitive.** |
| 7 | Message | `MD_Data_Message` :34139 — `uint m_messageID`, `float m_probability` | no | `UIElement::BroadcastElementMessage(owner, m_messageID, 0, 0)` |
| 8 | Pause | `MD_Data_Pause` :34124 — `float m_minDuration`, `m_maxDuration`, `double m_endTime` | **yes** | first visit: `m_endTime = Timer::compute_time() + RollDice(min,max)`; blocks until now ≥ endTime, then resets `m_endTime = -1.0` |
| 9 | **Sound** | `MD_Data_Sound` :34146 — `DID m_file`, `SoundType m_stype` | no | see §3 |
| 10 | State | `MD_Data_State` :34153 — `uint m_stateID`, `float m_probability` | terminal | probabilistic `owner->SetState(m_stateID)`; **always returns 0** (`:112068`) so the machine stops — the new state's `Reset` takes over |
| 11 | Fade | `MD_Data_Fade` :34176 — `float m_startAlpha`, `m_endAlpha`, `m_duration`, `double m_startTime` | **yes** | see §4 |
Sentinel convention: `m_StartTime` / `m_endTime` / `m_startTime` use the
double `-1.0` (`0xBFF00000` in the high word) as "not yet started"; each
blocking instruction resets it to `-1.0` when it completes, so a `Jump` back
over it re-arms it.
### Text/serialised form
`MediaDesc::ToFileNode` / `CreateFromFileNode` (`0x0069d740` / `0x0069d7e0`)
read a `MediaType` node (keyword `"MediaType"`, `KW_MEDIATYPE` @ `:821851`)
under a `"Media"` node (`KW_MEDIA` @ `:821841`), enum-name table 14. Per-type
keywords seen in the init block around `:821780``:821960`:
`MinDuration`, `MaxDuration`, `Probability`, `SoundName`, `SoundTable`,
`StartAlpha`, `EndAlpha`, `Duration`, `StateID`, `StretchToFullScreen`,
`NoDBFile`, `PassToChildren`. `StateDesc::LoadMedia` @ `0x0069c950`
(`:674969`) appends each parsed desc; `StateDesc::ConcatenateMedia`
@ `0x0069c9b0` merges a parent state's script into a child's.
`DatReaderWriter` already models all of this:
`references/DatReaderWriter/DatReaderWriter/Generated/Types/MediaDesc.generated.cs`
(abstract + `MediaType` dispatch) and `MediaDescSound.generated.cs`
(`uint File`, `Sound Sound`). **The binary layout is `int32 mediaType, int32
type, uint32 file, uint32 sound`** — note the doubled type field, which the
generated reader reproduces.
---
## 3. `MediaMachine::Update_Sound` — the authored-sound instruction
`0x004658b0` (`:112264`). Full decode:
```
Update_Sound(MD_Data_Sound* d):
if (d == null || m_owner == null) return 0; // 0 = block (dead-end)
if (d->m_stype == Sound_Invalid) // 0
# m_file is a direct Wave DID
if (d->m_file.id != 0)
SoundManager::PlaySoundFromCenter(d->m_file, 1.0f) # volume literal 0x3F800000
return 1
else
# m_file is a SoundTable DID; look it up as DBO type 0x22
CSoundTable* st = DBObj::Get(QualifiedDataID(d->m_file.id, 0x22))
if (st != null)
SoundManager::PlaySoundFromCenter(d->m_stype, st)
return 1
return 1 # falls through, still advances
```
Two shapes, discriminated by `m_stype`:
- `m_stype == Sound_Invalid`**`m_file` is a `Wave` DID (0x0A……)**, played at
literal volume 1.0 via the `PlaySoundFromCenter(DID, float)` overload
(`0x005509e0`, `:346951`) which looks the wave up in
`SoundManager::sound_hash_`.
- `m_stype != Sound_Invalid`**`m_file` is a `SoundTable` DID (0x20……)** and
`m_stype` selects the row; `PlaySoundFromCenter(SoundType, CSoundTable*)`
(`0x00550950`, `:346927`) rolls `SoundManager::GetSound` (weighted pick over
`SoundTableData::data_[]` with priority/probability/volume) and plays it.
DBO types confirmed: `CSoundTable::GetDBOType() == 0x22` (`:349100`),
`DBWave::Get()` uses `QualifiedDataID(id, 0xF)` (`:349327`).
**Both go through the *interface* channel**, not a music channel:
`PlaySoundFromCenter` gates on `SoundManager::interface_sounds_enabled` and
`s_bPlaySoundOnlyWhenActive && Device::m_bIsActiveApp`, then
`GetAttenuation(0f, vol, &out, /*isAmbient=*/0)` and
`PlaySoundInternal(buf, /*position=*/0, vol)` — position 0 ⇒ non-positional,
"from center". So the UI/authored-media bus == the interface-sound bus, and
the retail Interface Sound Volume slider is its only volume control.
This is the closest thing retail has to "the client plays a piece of authored
audio because a UI state was entered" — and it is the mechanism the login
screen and any title-card audio would use.
---
## 4. Fades — the only fade math in the media system (and it is alpha, not audio)
`MediaMachine::Update_Fade` @ `0x00465930` (`:112307`):
```
now = Timer::compute_time()
target = owner->m_object ?? owner->m_parent->GetObjectA() # the drawable
if (d->m_startTime == -1.0) d->m_startTime = now # latch on first visit
dur = d->GetDuration() # == m_duration
if (fabs(dur) >= 0.000199999995f) # EPSILON = 2e-4 s
t = (now - d->m_startTime) / dur
else
t = 1.0f
t = clamp(t, 0.0f, 1.0f) # two-sided clamp
alpha = d->m_startAlpha + (d->m_endAlpha - d->m_startAlpha) * t # plain lerp
target->vtable[0x48](alpha) # set element alpha
if (t >= 1.0f) { d->m_startTime = -1.0; return 1 } # done → advance
return 0 # still fading → block
```
Notable: **linear** interpolation, no easing; duration epsilon
`2e-4 s`; a zero/short duration snaps to `endAlpha` in one tick.
**There is no audio fade, no crossfade, and no volume ramp anywhere in
`MediaMachine` or in the MIDI code.** `SetChannelVolume` is an instantaneous
CC-7 write. Any crossfade in an acdream music feature would be new design, not
a port.
---
## 5. Trigger map — every code site that starts "music-adjacent" audio
| Event | Site | What plays |
|---|---|---|
| UI element enters a state | `UIElement::SetState``MediaMachine::Reset` (`:108863`) → `Update_Sound` (`:112589`) | authored `MD_Data_Sound` (Wave DID or SoundTable+SoundType) |
| Per-tick continuation of a blocked script | `MediaMachine::ListenToGlobalMessage(3)``Update` (`:112642`) | next instruction in the script |
| UI element cleanup / hide | `MediaMachine::Cleanup` (`:109993`), `Update` (`:110007`) | — (stops the script, does **not** stop already-playing sounds) |
| Splash / intro | `MD_Data_Movie::Update` (`:675489`) → DirectShow on `m_strFileName` | `turbine_logo_ac.avi` (its audio track *is* the theme) |
| Teleport start (portal in) | `gmSmartBoxUI` teleport anim, `:218903` | `Sound_UI_EnterPortal` (0x6A) from `ClientUISystem::GetUISoundTable()` |
| Teleport end (portal out, `TAS_WORLD_FADE_IN`) | `:219745` | `Sound_UI_ExitPortal` (0x6B) |
| **Server-pushed atmosphere cue** | `CPlayerSystem::Handle_Admin__Environs(uint)` @ `0x0055de20` (`:362360`+) | codes below |
| Landblock / cell change | `CellManager::ChangePosition` @ `0x004559b0``Ambient::InitSounds` (`:94714`), `LScape::add_ambient_sounds` (`:94718`), `Ambient::UpdatePlayQueue` (`:94722`), `Ambient::ReleaseSoundTables` (`:94726`) | region ambient rolls (§6) |
| Per-frame ambient pump | `Ambient::UseTime` @ `0x00551880`, called from `:94200` | due entries from the ambient PQueue |
### `Handle_Admin__Environs` — the retail "set the mood" opcode
`AdminEnvirons` (acdream: `0xEA60`, `WorldSession.cs:1915`). Two disjoint
ranges:
- `1..6` → lighting/fog overrides (`LScape::m_override_*`: ambient level,
ambient colour, fog colour, fog min/max; case 6 also sets
`m_bRadarBlank = 1`). acdream already ports these.
- `0x65..0x7C`**one-shot UI-SoundTable stinger**, each
`PlaySoundFromCenter(Sound_UI_*, GetUISoundTable())`, gated on the player
physics object existing:
| code | SoundType | | code | SoundType |
|---|---|---|---|---|
| 0x65 | `Sound_UI_Roar` (0x76) | | 0x6E | `Sound_UI_Drums` (0x7F) |
| 0x66 | `Sound_UI_Bell` (0x77) | | 0x6F | `Sound_UI_GhostSpeak` (0x80) |
| 0x67 | `Sound_UI_Chant1` (0x78) | | 0x70 | `Sound_UI_Breathing` (0x81) |
| 0x68 | `Sound_UI_Chant2` (0x79) | | 0x71 | `Sound_UI_Howl` (0x82) |
| 0x69 | `Sound_UI_DarkWhispers1` (0x7A) | | 0x72 | `Sound_UI_LostSouls` (0x83) |
| 0x6A | `Sound_UI_DarkWhispers2` (0x7B) | | 0x75 | `Sound_UI_Squeal` (0x84) |
| 0x6B | `Sound_UI_DarkLaugh` (0x7C) | | 0x760x7A | `Sound_UI_Thunder1..5` (0x850x89) |
| 0x6C | `Sound_UI_DarkWind` (0x7D) | | (0x7B/0x7C) | tail of the Thunder run |
| 0x6D | `Sound_UI_DarkSpeech` (0x7E) | | | |
Codes `0x73`/`0x74` have no case (fall through, nothing plays). Note the
environ code and the `SoundType` are **offset by 0x11** but not uniformly —
the switch is explicit, so port it as an explicit table, never as arithmetic.
This is *the* trigger for what players remember as dungeon "music".
---
## 6. Region ambient soundscape — retail's real "area audio"
Not music, but it is the system a "music by region" feature would have to sit
next to, and it is the only region→audio authored data in the DATs.
```
CRegionDesc (acclient.h:53230)
└─ CSoundDesc* sound_info :53200 — SmartArray<AmbientSTBDesc*>
CSceneType :53240 — { name, scenes[], AmbientSTBDesc* sound_table_desc }
AmbientSTBDesc :35486 — { DID stb_id, bool stb_not_found,
SmartArray<AmbientSoundDesc*> ambient_sounds,
CSoundTable* sound_table, uint play_count }
AmbientSoundDesc :35496 — { SoundType stype, bool is_continuous, float volume,
float base_chance, float min_rate, float max_rate }
```
- Selection is **per land cell, from the terrain word**: in
`CLandBlock::add_ambient_sounds` (`:314270`+) each cell reads
`terrainType = (t >> 2) & 0x1F` and `sceneIdx = t >> 11`, then
`CRegionDesc::GetSTBDesc(region, terrainType, sceneIdx)`
`Ambient::AddSound(ambient, stbDesc, cellVertexPos)` (`:314293`).
- `AmbientSound` is polymorphic: `ConstantSound` (continuous, tracks
`current_volume`) and `IntermitSound` (per-`LandDefs::Direction` `min_dist[8]`
/ `max_dist[8]` arrays + `play_chance`) — `acclient.h:52830`, `:52856`.
- Scheduling is an absolute-deadline priority queue:
`Ambient::Play` @ `0x005517a0` (`:347826`) → `CanHear()`, `PlayNow()`,
`GetSoundPos()`; positional ⇒ `PlayAmbientSound`, non-positional ⇒
`PlayAmbientSoundFromCenter`; then
`PQueueArray<double>::Insert(sound_queue, Timer::cur_time + GetPlayInterval(), snd)`
and `on_queue = 1`. `Ambient::UseTime` pops due entries.
- Ambient volume path: `PlayAmbientSoundFromCenter` @ `0x005508b0`
multiplies by `SoundManager::ambient_sound_volume`, rolls
`rand() * 3.05185094e-05f` (= `1/32768`) against the entry probability, then
`GetAttenuation(0, vol, &out, /*isAmbient=*/1)`.
---
## 7. Cross-reference results
- **`references/ACViewer/`** — grep for `midi|music` over all `*.cs`: only
three hits, all unrelated (`WeenieClassName.cs`, `SoulEmote.cs`). **ACViewer
implements no music and no MIDI.** It is not an oracle here, and its silence
is itself corroboration that there is nothing to load.
- **`references/DatReaderWriter/`** — no music/MIDI DBObj type exists. Audio
surface is exactly two DBObjs:
- `Wave``DBObjType.Wave`, range **`0x0A000000``0x0A00FFFF`**, layout
`int32 headerSize, int32 dataSize, byte[] header, byte[] data`
(`DBObjs/Wave.generated.cs`). The `header` is a `WAVEFORMATEX` blob;
the body can be PCM **or MP3** (retail decodes via winmm ACM).
- `SoundTable``DBObjType.SoundTable`, range **`0x20000000``0x2000FFFF`**,
`int32 HashKey`, `Dictionary<uint, SoundHashData>`,
`Dictionary<Sound, SoundData>` with `SoundEntry { QualifiedDataId<Wave> Id,
float Priority, Probability, Volume }`.
- `MediaDesc*` types are all present (§2), including `MediaDescSound`.
- **There is no `0x25……` music table and no MIDI DAT type.** (`0x25……` is
the `RegionDesc`/`Region` family, not music.)
---
## 8. Answers to the five questions
**1. Formats and where the bytes live.**
Retail's *only* music-capable path is winmm `midiStream*` fed a **Standard MIDI
File read from a loose disk path via `CreateFileA`** — never from a DAT. No
DirectMusic, no MP3-as-music, no `0x25……` music table. That path is never
invoked and **no `.mid` ships with the client**. All audio the client actually
plays is DAT `Wave` (`0x0A000000``0x0A00FFFF`, PCM or MP3-in-WAVEFORMATEX)
selected either directly by DID or through `SoundTable`
(`0x20000000``0x2000FFFF`). The one long-form musical asset that ships is the
audio track of `turbine_logo_ac.avi`, played by DirectShow.
**2. The MediaMachine state machine.**
It is not a music machine — it is an 11-opcode media bytecode VM per UI
element state, with `m_curIndex` as PC, "may I advance?" booleans as the
blocking protocol, global message 3 as the clock, `Jump` as the loop
primitive, `State` as the terminal instruction, and `-1.0` double sentinels for
"not yet armed". Full opcode table and per-opcode decode in §2; `Update_Sound`
in §3; `Update_Fade` (linear alpha lerp, 2e-4 s duration epsilon) in §4.
**3. Trigger map.** §5. Region/landblock entry drives *ambient*, not music.
Portal in/out and the 24 `AdminEnvirons` codes `0x650x7C` drive the
atmosphere stingers. UI state entry drives authored `MD_Data_Sound`. Login/
intro music is a movie file. Nothing anywhere starts a music track.
**4. Does a modern port need a MIDI synth?** **No.** There is no MIDI content
and no code that would play it; EoR-era audio is 100% sampled. ACViewer
implements nothing here either. Building a synth (or an OGG music bus) would be
new feature design with no retail referent — and per CLAUDE.md that is a
"different feature", not a port, so it needs explicit approval.
**5. Rough port scope.**
- **Delete the false surface (smallest, highest value).**
`IAudioEngine.PlayMusic(string resourceName, bool loop)` /
`StopMusic()` in `src/AcDream.Core/Audio/AudioModel.cs:102-103`, their
no-op bodies in `src/AcDream.App/Audio/OpenAlAudioEngine.cs:386-387`, and
`MusicVolume` at `AudioModel.cs:84` / `OpenAlAudioEngine.cs` are modelled on
a retail feature **that does not exist**. `resourceName` as a *string path*
is itself a tell — every other audio entry point in the engine is DID-keyed.
A retail-faithful engine has three buses (SFX / Ambient / Interface), not
four. Removing them retires a divergence rather than creating one; if they
stay, they need a `retail-divergence-register.md` row explaining that they
model dead retail code. Also worth a look:
`src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs:260` comments
the music path as "stubbed for R5 MIDI", and retail's Settings has no music
slider at all.
- **Correct the record.** `docs/research/deepdives/r05-audio-sound.md` §6 and
its executive-summary table row ("MIDI music | winmm midiStream | Loose
`*.mid` files on disk") should carry the "infrastructure present, never
invoked, no content ships" finding — see the correction table in §1. That
table row is what produced the phantom API.
- **The genuinely missing retail behavior, in cost order:**
1. **`AdminEnvirons` sound cues** (24 codes → `Sound_UI_*` via the UI
SoundTable). acdream already parses these and already has
`RuntimeEnvironmentSoundCue`, with
`src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:214-239`
logging `audio binding pending`. This is a table + one `PlayUi` call —
the cheapest real retail-fidelity win in the whole lane.
2. **`MediaDescSound` in the LayoutDesc importer.**
`src/AcDream.App/UI/Layout/LayoutImporter.cs:464-489` reads
`MediaDescImage` and `MediaDescCursor` from each state's media list and
ignores everything else. Adding the Sound opcode (two shapes per §3, both
routed to the interface bus) gives retail's authored UI sounds for free —
`DatReaderWriter` already parses the type.
3. **Region ambient system** (§6): `AmbientSTBDesc` selection from the
terrain word on landblock change, `ConstantSound`/`IntermitSound`, the
absolute-deadline PQueue, `ambient_sound_volume`. This is the real
"area audio" and it is what `StartAmbient`/`StopAmbient`
(`OpenAlAudioEngine.cs:367-385`, currently handle-reservation only) exist
to serve. Multi-commit; belongs in a roadmap phase, not an issue.
4. **`MediaMachine` proper** (Pause/Jump/Anim/Fade/State scripting) — only
if animated UI states become a goal. Not required for audio.
- **Explicitly out of scope for a faithful port:** MIDI playback, a soundfont
synth, crossfades, a music bus, region→track tables. None exist in retail.
---
## 9. Anchors (for citation in code comments)
| Symbol | Address | pseudo-C line |
|---|---|---|
| `SoundManager::Init` (calls `midiSetup`) | `0x00550640` | 346753 |
| `SoundManager::ShutDown` (calls `midiCleanup`) | `0x005502b0` | ~346580 |
| `midiSetup` | `0x00553770` | 350282 |
| `midiPlay` (**no callers**) | `0x00553390` | 350072 |
| `midiPlayNext` | `0x005534c0` | 350138 |
| `midiStop` | `0x00553240` | 349978 |
| `midiCleanup` | `0x00553350` | 350047 |
| `MidiProc` | `0x00553500` | 350152 |
| `StreamBufferSetup` | `0x00553030` | 349843 |
| `ConverterInit` (MThd/MTrk) | `0x00554530` | 351254 |
| `SetChannelVolume` | `0x00552f60` | 349787 |
| `midiEventCallback` / `midiStartCallback` (= 0) | `0x0086fa70` / `0x0086fa74` | 11855978 |
| `MediaMachine::Update` | `0x00465ba0` | 112526 |
| `MediaMachine::Reset` | `0x00465d90` | 112681 |
| `MediaMachine::Cleanup` | `0x00465af0` | 112475 |
| `MediaMachine::ListenToGlobalMessage` | `0x00465cf0` | 112638 |
| `MediaMachine::Update_Sound` | `0x004658b0` | 112264 |
| `MediaMachine::Update_Fade` | `0x00465930` | 112307 |
| `MediaMachine::Update_Pause` | `0x00465520` | 111937 |
| `MediaMachine::Update_Jump` | `0x004655b0` | 111983 |
| `MediaDesc::CreateMediaType(uint)` | `0x0069d420` | 675786 |
| `MD_Data_Sound::MD_Data_Sound` (type 9) | `0x0069e5f0` | 677163 |
| `MD_Data_Sound::Serialize` | `0x0069e670` | 677192 |
| `StateDesc::LoadMedia` | `0x0069c950` | 674969 |
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` | 346927 |
| `SoundManager::PlaySoundFromCenter(DID, float)` | `0x005509e0` | 346951 |
| `SoundManager::PlayAmbientSoundFromCenter` | `0x005508b0` | 346893 |
| `CPlayerSystem::Handle_Admin__Environs` | `0x0055de20` | ~362360 |
| `Ambient::Play` | `0x005517a0` | 347826 |
| `Ambient::UseTime` | `0x00551880` | 347957 |
| `CellManager::ChangePosition` (ambient re-init) | `0x004559b0` | 94601 |
| `CSoundTable::GetDBOType` (= 0x22) | `0x00552560` | 349100 |
| `DBWave::Get` (QDID type 0xF) | `0x00552880` | 349327 |
| `enum SoundType` (0x000xCC) | — | `acclient.h:4569` |
| `MediaMachine` / `MediaDesc` / `MD_Data_*` structs | — | `acclient.h:33873, 33907, 3410134182` |

View file

@ -0,0 +1,482 @@
# Lane 5 — server-driven & physics-driven sounds: retail decode + acdream audit
Research-only. No repo files touched.
Oracles used:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C, PDB-named, Sept 2013 EoR)
- `docs/research/named-retail/acclient.h` (verbatim retail structs/enums)
- `references/ACE/Source/` (server side — what actually gets sent)
- `references/holtburger/` (independent client-side parser)
- **Raw byte decode** of the PDB-paired `C:\Users\erikn\Downloads\acclient.exe`
(v11.4186) for two FPU-elided/mis-polarised spots BN got wrong
(per `reference_pe_byte_decode.md`)
---
## 1. The Sound game message (0xF750)
### Wire layout (three oracles agree)
| offset | type | field |
|---|---|---|
| 0x00 | u32 | opcode `0xF750` |
| 0x04 | u32 | object GUID |
| 0x08 | u32 | `SoundType` (retail `enum SoundType`, = ACE `Sound`) |
| 0x0C | f32 | volume |
Total 16 bytes. Direction S→C.
- Retail: `CM_Physics::DispatchSB_SoundEvent` @ `0x006AC760` reads
`*(u32*)buf == 0xf750`, then passes `buf+4` (guid), `buf+8` (sound),
`buf+0xc` (float volume).
- ACE: `Network/GameMessages/Messages/GameMessageSound.cs`
`base(GameMessageOpcode.Sound /*0xF750*/, GameMessageGroup.SmartboxQueue, 16)`,
writes `WriteGuid(guid)`, `(uint)soundId`, `float volume`.
Opcode confirmed at `GameMessageOpcode.cs:60`.
- holtburger: `crates/holtburger-protocol/src/messages/effects/types.rs`
`PlaySoundData { target: Guid, sound_id: u32, volume: f32 }`, routed from
`GameOpcode::Sound` in `game_message/unpack.rs:186`. **holtburger parses it
and then does nothing with it** — no consumer anywhere in
`holtburger-core`/`apps` (it's a TUI, no audio). So holtburger is a layout
oracle only, not a behaviour oracle here.
- ACE's `Sound` enum (`ACE.Entity/Enum/Sound.cs`) is byte-for-byte the retail
`SoundType` enum (`acclient.h:4569`) — verified across the whole 0x000xC5+
range. Values we care about: `Collision=0x2F`, `Footstep1=0x37`,
`Footstep2=0x38`, `Walk1=0x39`, `Open=0x42`, `Close=0x43`,
`OpenSlam=0x44`, `CloseSlam=0x45`, `LogIn=0x50`, `LifestoneOn=0x51`,
`Fizzle=0x5E`, `Launch=0x5F`, `Explode=0x60`,
`UI_EnterPortal=0x6A``UI_Thunder6=0x8A`, `WieldObject=0x8C`,
`PickUpItem=0x8F`, `DropItem=0x90`, `ResistSpell=0x91`,
`TriggerActivated=0x95`, `SpellExpire=0x96`, `ItemManaDepleted=0x97`.
### Retail handler chain (pseudocode)
```
CM_Physics::DispatchSB_SoundEvent(SmartBox* sb, NetBlob* blob) // 0x006AC760
if (!blob || !sb) return NETBLOB_ERROR;
if (*(u32*)blob->buf != 0xF750) return NETBLOB_ERROR;
return SmartBox::HandleSoundEvent(sb, blob,
guid = *(u32*)(buf+4),
sound = *(i32*)(buf+8),
volume = *(f32*)(buf+0xC));
SmartBox::HandleSoundEvent(sb, blob, guid, sound, volume) // 0x00451FC0
CPhysicsObj* obj = CObjectMaint::GetObjectA(sb->m_pObjMaint, guid);
if (obj == nullptr) {
CObjectMaint::QueueBlobForObject(sb->m_pObjMaint, guid, blob);
return NETBLOB_QUEUED; // 4 — REPLAYED when the object arrives
}
CPhysicsObj::play_sound(obj, sound, volume);
return NETBLOB_ERROR/OK; // BN mush; play_sound is void
CPhysicsObj::play_sound(this, SoundType t, float vol) // 0x0050F460
if (this->sound_table != nullptr) // NO table → SILENTLY DROPPED
SoundManager::PlaySoundA(t, this, vol);
SoundManager::PlaySoundA(SoundType t, CPhysicsObj* obj, float vol) // 0x00550AF0
if (!effect_sounds_enabled) return;
if (s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp) return;
SoundData d;
if (obj->sound_table == nullptr) return;
GetSound(obj->sound_table, t, &d); // rolls ONE variant
if (d.buf != null && PlayProbability(d.probability_))
PlaySoundInternal(d.buf, &obj->m_position, vol, /*isAmbient=*/0);
// ^^^ WIRE volume, not d.volume_
```
Three retail facts worth writing down:
1. **Object identity is required and the message is deferrable.** If the guid
isn't in `CObjectMaint` yet, retail *queues the blob against that guid* and
replays it on `CreateObject`. Our implementation must do the same or a
"creature spawns and immediately grunts" sequence will silently drop the
grunt.
2. **No sound table → nothing plays.** `play_sound` early-returns on
`sound_table == nullptr`. The server can send `Sound` for any object; only
objects that carry a SoundTable make noise.
3. **Server-driven sounds use the WIRE volume, not the SoundTable entry
volume.** The 3-arg `PlaySoundA(SoundType, obj, vol)` passes `arg3` straight
to `PlaySoundInternal`. The 2-arg overload used by animation hooks
(`SoundTableHook::Execute`) instead passes the *entry's* `volume_`. That
asymmetry is real and must be preserved.
`GetAttenuation(dist, vol, &out, isAmbient)` then multiplies by the user's
`effect_sound_volume` / `ambient_sound_volume` pref and clamps to `VOL_MIN`.
### Where the SoundTable comes from
`CPhysicsObj::sound_table` is a `CSoundTable*` = `DBObj::Get(QualifiedDataID(did, 0x22))`
(`0x22` = 34 = `DB_TYPE_STABLE`; matches ACE `DatFileType.SoundTable = 34`).
Two writers, in retail construction order:
1. `CPhysicsObj::InitDefaults(CSetup*)` @ `0x005139D0`
`setup->default_stable_id` (the DAT Setup field).
2. `CPhysicsObj::set_description(PhysicsDesc*, …)` @ `0x00514F40`
`desc->stable_id` (the wire field in CreateObject/UpdateObject).
It **unconditionally releases** the existing table first, then installs the
wire one only if non-zero — i.e. a PhysicsDesc with `stable_id == 0` leaves
the object with *no* sound table, it does not fall back to Setup.
`SoundTableData` (retail, `acclient.c:5151`): `num_stdatas_` @ +0x7C and
`SoundData* data_` @ +0x80, each entry 16 bytes = `{ DataId sound_id, float
priority, float probability, float volume }`. Identical to DatReaderWriter's
`SoundEntry` and ACE's `SoundTableData`.
### Variant picking — retail is NOT a CDF walk (byte-verified)
`SoundManager::GetSound` @ `0x00550680`. BN elides the FPU multiply; raw bytes
(file offset `0x150680`) decode to:
```
8b 48 7c mov ecx,[eax+0x7C] ; num_stdatas_
85 c9 / 76 73 test/jbe ; num == 0 -> bail
68 00 00 80 3f push 1.0f
6a 00 push 0 ; 0.0f
e8 .. call Random::RollDice(0.0f, 1.0f) -> st0 = u
8b 77 7c mov esi,[edi+0x7C] ; num
8d 4e ff lea ecx,[esi-1] ; num - 1 <-- note the -1
db 44 24 14 fild [num-1]
d8 c9 fmul st(0), st(1) ; (num-1) * u
e8 .. call _ftol2 ; eax = TRUNC((num-1)*u)
3b c6 / 73 .. cmp eax,esi / jae ; idx >= num -> bail
c1 e0 04 shl eax,4 ; * sizeof(SoundData)
```
So **`idx = (int)((num_stdatas_ - 1) * RollDice(0.0f, 1.0f))`**, then the chosen
entry's own `probability` gates whether it plays at all.
`Random::RollDice(float,float)` @ `0x0042C600` decodes to
`lo + u01 * (hi - lo)` (with a `min == max → return min` short-circuit and a
swap if `min > max`), where `u01` comes from the combined-LCG at `0x0042C4C0`
(two Lehmer streams, modulus `0x7FFFFFAB`) — i.e. the classic L'Ecuyer
generator whose scaled output is in the open interval (0,1).
**Consequence (retail quirk, flag it):** with `N` variants the reachable index
range is `[0, N-2]`. With two variants retail effectively always plays the
first. This is an off-by-one in Turbine's picker, not a decode artifact — the
`lea ecx,[esi-1]` is unambiguous in the bytes.
`SoundManager::PlayProbability(float p)` @ `0x005500E0` — BN renders the branch
polarity **inverted**; bytes say:
```
ff 15 84 23 79 00 call rand
db 44 24 00 fild [esp]
d8 0d 50 af 7c 00 fmul [0x7CAF50] ; const = 3.051851e-05 = 1/32767
d8 5c 24 08 fcomp [esp+8] ; vs p
df e0 / f6 c4 05 fnstsw / test ah,5
7a 07 jp -> return 0
b8 01 00 00 00 mov eax,1 ; return 1
```
`test ah,5` isolates C0 (bit0) and C2 (bit2); PF is the parity of the AND
result, so `jp` is taken exactly when C0 == C2 == 0 (ordered and not-less).
Therefore **plays when `rand()/32767.0 < probability`** — the intuitive
reading, opposite to BN's `if (p) return 0` rendering. Do not port BN here.
---
## 2. Retail's full sound-trigger catalog
`SoundManager` is the only audio entry point. Every trigger reaches it through
one of six routes. (BN only prints `Sound_*` enum names where type info is
attached, so the numeric call sites are sparse in the text dump — the routes
below are from the class/vtable structure, which is complete.)
| # | Route | Retail anchor | Covers |
|---|---|---|---|
| 1 | **Server Sound event** | `0xF750``DispatchSB_SoundEvent``HandleSoundEvent``CPhysicsObj::play_sound``PlaySoundA(SoundType, obj, wireVol)` | everything in §3's ACE table: hit/wound/pain, wield/unwield, pickup/drop/receive, lock/pick, door-locked, lifestone, trigger plates, spell resist/expire, mana depleted, attribute/skill raise, projectile `Collision` |
| 2 | **Animation hooks** | `SoundHook::Execute` `0x00526A20`, `SoundTweakedHook::Execute` `0x00526A80`, `SoundTableHook::Execute` `0x00526AB0` (all `CAnimHook` subclasses, `acclient.h:6308-6310`) | **footsteps**, weapon swoosh, bow pull/release, creature attack/damage vocalisations, door open/close, eat/drink, spell chant — anything authored into an animation's hook list |
| 3 | **PhysicsScript hooks** | `0xF754`/`0xF755``CPhysicsObj::play_script` → the script's `CAnimHook` list, which can include the same three sound hooks | server-triggered effect scripts (portal, cast, destroy) that carry audio |
| 4 | **Ambient / environment** | `Ambient::Play` `0x005517A0`, `Ambient::UseTime` `0x00551880`, `Ambient::PlaySoundA` `0x00550D90`; data from `AmbientSTBDesc { stb_id, ambient_sounds, CSoundTable*, play_count }` reached via `CSceneType::sound_table_desc`; entries are `AmbientSoundDesc { SoundType stype, int is_continuous, float volume, float base_chance, float min_rate, float max_rate }` | waterfalls, birds, dungeon drips, wind — interval-queued in a `PQueueArray<double>` keyed on `Timer::cur_time`, gated on `AmbientSound::CanHear()`, positioned or from-centre depending on `GetSoundPos()` |
| 5 | **UI / interface** | `SoundManager::PlaySoundFromCenter(SoundType, ClientUISystem::GetUISoundTable())` | portal enter/exit, button press, icon pick-up/drop, slider grab/release, new-target-selected, general query/error, transient message, and the whole `Sound_UI_Roar…Thunder6` block |
| 6 | **MediaMachine** | `MediaMachine::Update_Sound` `0x004658B0` | cutscene / media-descriptor audio; `MD_Data_Sound { SoundType m_stype, DataId m_file }`. If `m_stype == Sound_Invalid` it plays `m_file` as a raw wave id; otherwise it resolves `m_file` as a SoundTable (`DBObj::Get(qdid, 0x22)`) and plays `m_stype` from it |
### Physics-event sounds: the important negative result
**`CPhysicsObj::play_sound` has exactly ONE caller in the entire binary:
`SmartBox::HandleSoundEvent`.** There is no collision, jump-land, water-entry,
or step call site. Confirmed by grepping every `play_sound` /
`SoundManager::PlaySound*` reference in the 65 MB pseudo-C dump.
That means, in retail:
- **Footsteps are animation-hook-driven** (route 2 — `SoundTableHook` with
`Sound_Footstep1/2` / `Sound_Walk1` authored into the walk/run animation
frames), *not* physics-tick driven. Nothing in `CTransition` /
`SPHEREPATH` / `COLLISIONINFO` plays a sound.
- **Collision sounds are server-driven** (route 1). `Sound_Collision (0x2F)`
is emitted by the *server* — ACE does it in
`WorldObjects/ProjectileCollisionHelper.cs:45`. The client's physics engine
never plays a collision sound on its own.
- **Jump / land / water-entry have no client-local sound trigger at all.**
There is no `Sound_*` for them in the enum and no call site. Any audible
landing thump in retail comes from the landing *animation's* hooks.
So "physics-driven sounds" in retail = "animation hooks that happen to fire
during physics-driven motion" + "server tells you". There is no third thing.
### UI sound table — the dat id
```
ClientUISystem::GetUISoundTable(this) // 0x00563FB0
if (this->soundTable == nullptr)
this->soundTable = DBObj::GetByEnum(/*fileType*/ 0x22,
/*enumIndex*/ 7,
/*cache*/ 0x10000003);
return this->soundTable;
DBObj::GetByEnum(type, idx, cache) // 0x00415490
DBCache::GetDIDFromEnumStatic(&did, type, idx); // enum -> concrete DID
return DBCache::Get(did, cache);
```
i.e. the UI sound table is **not a hard-coded DID** — it is enum slot **7** of
DB type **0x22 (`DB_TYPE_STABLE`, SoundTable)**, resolved through
`DBCache::GetDIDFromEnumStatic`. (Open item: resolving slot 7 → the actual
`0x20xxxxxx` DID needs either `GetDIDFromEnumStatic`'s static table decoded or
one cdb `dt` on a live client. Cheap either way; not done here.)
Only two UI-sound sites survive with named enums in BN's output:
`SoundManager::PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable(...))`
@ `0x004D638E` and `Sound_UI_ExitPortal` @ `0x004D7405`. The third named
cluster is a switch in **`CPlayerSystem::Handle_Admin__Environs`** @
`0x0055DE20`: environment option values `0x65..0x7C` map 1:1 onto
`Sound_UI_Roar` (0x65) … `Sound_UI_Thunder6` (0x7C), each played from-centre
through the UI sound table, alongside `LScape::m_override_*` fog/ambient
overrides and `m_bRadarBlank`. That is the server's `AdminEnvirons` hook into
the UI sound bank.
`PlaySoundFromCenter` gates on `interface_sounds_enabled` (a separate pref from
`effect_sounds_enabled` / `ambient_sounds_enabled`) and calls
`GetAttenuation(0.0f, vol, &out, 0)` — distance 0, so no attenuation, but the
interface-volume pref still applies. Retail's three volume prefs are
`Sound.SoundVolume`, `Sound.AmbientSoundVolume`, `Sound.InterfaceSoundVolume`,
plus `Sound.SoundDisabled` / `Sound.AmbientSoundDisabled` /
`Sound.InterfaceSoundDisabled` / `Sound.PlaySoundOnlyWhenActive` /
`Sound.SoundFeatures` (mono/stereo), all registered in
`SoundManager::InitPrefs` @ `0x005503F0`.
Listener position: `SoundManager::SetPlayerPosition(&sb->viewer)` @
`0x00452D36` — the **viewer** position, i.e. the camera eye, not the player's
feet. (Same coupling as our render visibility; see
`project_camera_visibility_coupling`.)
---
## 3. What ACE actually sends, and when
`GameMessageSound` send sites (65 in `references/ACE/Source/ACE.Server`).
Grouped:
| Sound | ACE site(s) | trigger |
|---|---|---|
| `HitFlesh1` (0.5f vol) | `Monster_Combat.cs:314,404`, `Player_Combat.cs:168,544` | every melee/missile hit |
| `Wound1/2/3` + pain sounds | `Monster_Combat.cs:324,408`, `Player_Combat.cs:461,563`, `Player_Move.cs:311` (fall damage) | damage taken |
| `WieldObject` / `UnwieldObject` | `Creature_Equipment.cs:360,436`, `Player_Inventory.cs:306,405,1831` | equip / unequip |
| `PickUpItem` / `DropItem` / `ReceiveItem` | `Player_Inventory.cs` (×14), `Player_Commerce.cs:105,223`, `AdminCommands.cs:2880` | every inventory move, give, buy/sell |
| `Collision` | `ProjectileCollisionHelper.cs:45` | **projectile impact** — the only `Sound.Collision` sender |
| `OpenFailDueToLock` | `Door.cs:114`, `Chest.cs:110`, `Storage.cs:71` | locked container/door |
| `Lockpicking` / `PicklockFail` / `LockSuccess` | `Lock.cs:162,178,276` | lockpick attempts |
| `LifestoneOn` | `Lifestone.cs:58` | lifestone attunement |
| `TriggerActivated` | `Hotspot.cs:221`, `Switch.cs:46`, `PressurePlate.cs:75` (UseSound) | traps / plates / switches |
| `ResistSpell` | `WorldObject_Magic.cs:194,201` | spell resisted |
| `SpellExpire` | `EnchantmentManager.cs:331,348` | enchantment drops |
| `ItemManaDepleted` | `Player_Tick.cs:684` | item runs dry |
| `RaiseTrait` | `Player_Attributes.cs:48`, `Player_Skills.cs:56`, `Player_Vitals.cs:59`, `AttributeTransferDevice.cs:98` | XP spend |
| arbitrary (emote-authored) | `EmoteManager.cs:1243``(Sound)emote.Sound` | any DAT-authored NPC emote sound |
| arbitrary (weenie `UseSound`) | `Gem.cs:181`, `GenericObject.cs:48`, `Food.cs:100` (`GetUseSound()`), `PressurePlate.cs:75` | item use |
| generic helper | `WorldObject.cs:708` `EnqueueBroadcast(new GameMessageSound(targetId, soundId, volume))`, `Player.cs:483` | everything else |
Note `Player_Death.cs:192` has the death sound **commented out** in ACE.
Two shapes of send: `EnqueueBroadcast(...)` (everyone in range hears it, guid =
the acting object) and `Session.Network.EnqueueSend(...)` (only the acting
player hears it). Both arrive as the same 0xF750; the difference is purely who
receives it. So our handler needs no special-casing — but it *does* mean a
0xF750 can name a **remote** guid, and must play at that remote object's
position.
---
## 4. acdream audit — what we have and what we don't
### 4.1 Server Sound path: **ABSENT**
- `grep -rn '0xF750' src/`**zero hits.** No parser, no message record, no
`WorldSession` event, no routing.
- Our `Core.Net` knows the neighbours: `0xF74A` PickupEvent, `0xF74B` SetState,
`0xF74E` VectorUpdate, `0xF751` PlayerTeleport, `0xF754` PlayPhysicsScript,
`0xF755` PlayPhysicsScriptType. `0xF750` is the hole in the middle.
- Already recorded as a known gap: `docs/research/2026-06-04-wire-message-catalog.md:241`
(`| 0xF750 | Sound | S->C | Movement & Physics | missing |`), with a full
entry at line 918 and a "next work" callout at line 4654. So this lane
confirms a previously-catalogued gap rather than discovering a new one — but
the *retail handler semantics* (queue-for-object, no-table drop, wire-volume
precedence) were not previously written down anywhere.
- Consequence: **every sound in §3's table is silent in acdream.** No hit
sounds, no pickup/drop, no wield, no lock, no lifestone, no trap trigger, no
spell resist/expire, no projectile collision.
### 4.2 Animation-hook path: **PRESENT and correctly shaped**
`src/AcDream.App/Audio/AudioHookSink.cs` implements `IAnimationHookSink` and
handles all three retail hook types with the right semantics:
| retail | ours | verdict |
|---|---|---|
| `SoundHook::Execute``PlaySoundA(gid, obj)` (raw wave DID) | `case SoundHook s``Play(waveId: s.Id, volume 1, priority 4)` | matches |
| `SoundTableHook::Execute``PlaySoundA(sound_type_, obj)` (table lookup, entry volume) | `case SoundTableHook st``PlayFromSoundTable``SoundCookbook.Roll` → entry's `Volume`/`Priority` | matches in shape; picker algorithm diverges (below) |
| `SoundTweakedHook::Execute``PlaySoundA(gid, obj, prio, prob, vol)` | `case SoundTweakedHook stw` → direct wave with hook's volume/priority | **missing the `prob` gate** — retail runs `PlayProbability(arg4)` before playing; we play unconditionally |
Wiring is real and reaches production:
- `ContentEffectsAudioComposition.ComposeOptionalAudio` creates the engine +
sink and calls `registrations.Register(audioSink)` (line 513) →
`AnimationHookRouter` (`src/AcDream.Core/Physics/AnimationHookRouter.cs`).
- `AnimationHookFrameQueue.cs:117` and `PhysicsScriptRunner.cs:308` both fan
into that router.
- Listener is updated per frame: `WorldRenderFrameBuilder.cs:388`
`_audio.SetListener(...)`.
- Disabled by `ACDREAM_NO_AUDIO=1` (`RuntimeOptions.cs:103`) or an unavailable
OpenAL driver, otherwise on.
**Side effect worth noting:** because `PhysicsScriptRunner` sinks into the same
router, we *already* have one indirect server→audio path — a server
`0xF754`/`0xF755` PlayScript whose PhysicsScript carries a `SoundHook` will
play. That's retail route 3, and it works today.
### 4.3 `DictionaryEntitySoundTable`: **IS populated** (the hook path is not dead)
This was the open question. Answer: yes, and from the right field.
- `LivePresentationComposition.cs:620-625` passes two callbacks into
`EntityEffectController`: a remove (`content.Audio?.EntitySoundTables.Remove(ownerId)`)
and a set (`... .Set(ownerId, did)`).
- The `did` comes from `EntityEffectProfile.CurrentSoundTableDid`
(`src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs`), which mirrors retail's
precedence exactly and cites it:
- ctor from `Setup``NormalizeSoundTableDid((uint)setup.DefaultSoundTable)`
(retail `CPhysicsObj::InitDefaults` `0x005139D0`);
- `ApplyNetworkDescription(PhysicsSpawnData)`
`NormalizeSoundTableDid(physics.SoundTableId.GetValueOrDefault())`
(retail `CPhysicsObj::set_description` `0x00514F40`), **unconditionally
replacing** the Setup value — same "wire wins, zero means none" rule as
retail.
- `NormalizeSoundTableDid` gates on `(did & 0xFF000000) == 0x20000000`.
- `SoundTableId` is parsed off the wire in
`src/AcDream.Core.Net/Messages/CreateObject.cs:766` into
`PhysicsSpawnData.SoundTableId` (`PhysicsSpawnData.cs:47`).
So creature/NPC animation sounds *do* have a table to look up. Good — no
silent-by-construction bug here.
### 4.4 Variant picker: **algorithmically divergent from retail**
`src/AcDream.Core/Audio/SoundCookbook.cs` does a **cumulative-probability CDF
walk**: sample `u ∈ [0,1)`, accumulate `entries[i].Probability`, first entry
whose running total exceeds `u` wins; falling off the end returns `null`
("silence tail") unless the probabilities sum to ≈1.
Retail (§1, byte-verified) does something completely different:
**uniform index `(int)((N-1) * u)`, then a per-entry
`rand()/32767 < entry.probability` gate.**
Practical differences:
- retail's index is uniform over `[0, N-2]` and never reaches the last entry;
ours is probability-weighted over all `N`.
- retail's `probability` is an independent **play/don't-play** gate on the
already-chosen entry; ours treats it as a **selection weight**. For the
common `N=1, probability=1.0` case both play the entry — so most sounds
sound identical — but for multi-variant tables (footsteps, swooshes,
creature vocalisations, exactly the audible ones) the distributions differ.
- Our `entries.Count == 1 → return entries[0]` shortcut also skips the
probability gate entirely; retail still rolls it. A single-entry sound with
`probability < 1` should sometimes be silent in retail and never is in ours.
This needs a divergence-register row when audio work lands, or a faithful
re-port (the faithful version is ~8 lines and strictly simpler than what we
have).
### 4.5 UI sounds: **ABSENT**
No `Sound_UI_*` / interface-sound concept anywhere in `src/`. `grep -i
'interfacesound|uisound|Sound_UI'` returns only unrelated `IsButtonPressed` /
`*ButtonPressed` input handlers. Concretely missing:
- no UI sound table load (retail: `DBObj::GetByEnum(0x22, 7)`);
- no `PlaySoundFromCenter` equivalent (non-positional, interface-volume pref);
- no button-press / icon-pickup / icon-drop / slider / new-target-selected /
general-error / transient-message cues, despite all of those UI surfaces now
existing (spell bar, vendor panel, inventory drag-drop, target selection);
- no portal enter/exit cue, despite the portal-space presentation being
complete;
- no `AdminEnvirons` sound mapping (`0x65..0x7C`) even though we own the
`AdminEnvirons` state in Runtime (J6.1).
### 4.6 Ambient / environment sounds: **ABSENT**
No `Ambient`, `AmbientSTBDesc`, `AmbientSoundDesc`, ambient-STB reader, or
interval-queued ambient scheduler. `grep -i 'ambientsound|AmbientStb'` → only
`SoundCookbook`'s doc comment. Retail's `ambient_sound_volume` /
`ambient_sounds_enabled` prefs have no counterpart either. Waterfalls, birds,
dungeon ambience: silent.
### 4.7 Physics-event sounds: **N/A — correctly absent**
Nothing calls the audio engine from the physics path in acdream, and per §2
that is *retail-correct*. There is no gap to fill here. The apparent gap
("collisions make no noise") is really §4.1: retail hears a collision because
the **server** sent `Sound.Collision`. Do not add a client-local collision
sound — it would be a divergence, not a fix.
---
## 5. Summary of gaps, ordered by audible impact
| # | Gap | Effort | Notes |
|---|---|---|---|
| 1 | `0xF750` parse + route to audio | small | Needs: `SoundEvent` record in `Core.Net/Messages`, a `WorldSession` event next to the existing `0xF754`/`0xF755` ones, and a Runtime/App consumer that resolves guid → world position + SoundTableId and calls the engine with the **wire** volume. Must implement retail's *queue-for-unknown-guid* deferral. |
| 2 | UI sound bank + `PlaySoundFromCenter` | small-medium | Blocked only on resolving DB-type-0x22 enum slot 7 → concrete DID. Needs a third volume pref (`interface`) and a non-positional play path. |
| 3 | `SoundCookbook` → retail picker | tiny | Replace CDF walk with `(int)((N-1)*u)` + per-entry probability gate; add the missing `prob` gate to `SoundTweakedHook`. Register row either way. |
| 4 | Ambient / environment sounds | medium | Needs the ambient-STB reader, `AmbientSoundDesc` scheduling (`base_chance`/`min_rate`/`max_rate`, `is_continuous`), the `CanHear` gate, and an ambient volume pref. |
| 5 | `AdminEnvirons` 0x65..0x7C → UI sounds | tiny | Falls out of #2; we already own the AdminEnvirons state. |
Open research items (cheap, not done here):
- Resolve `DBCache::GetDIDFromEnumStatic(0x22, 7)` → the concrete UI SoundTable
DID (decode the static enum table, or one `dt` in cdb).
- Pin the numeric UI-sound call sites (`Sound_UI_ButtonPress = 0x72`,
`IconPickUp = 0x6F`, `IconSuccessfulDrop = 0x70`, `IconInvalid_Drop = 0x71`,
`GrabSlider = 0x73`, `ReleaseSlider = 0x74`, `NewTargetSelected = 0x75`) to
their owning UI classes — BN prints them as bare integers, so they need a
numeric grep or a Ghidra xref on the UI sound table getter.
## 6. Retail anchors (for code comments)
```
CM_Physics::DispatchSB_SoundEvent 0x006AC760 0xF750 dispatch
SmartBox::HandleSoundEvent 0x00451FC0 guid resolve / queue / play
CPhysicsObj::play_sound 0x0050F460 sound_table null-gate
SoundManager::PlaySoundA(SoundType,obj,f)0x00550AF0 wire-volume path
SoundManager::PlaySoundA(SoundType,obj) 0x00550B70 entry-volume path (hooks)
SoundManager::GetSound 0x00550680 idx = (int)((N-1)*RollDice(0,1))
SoundManager::PlayProbability 0x005500E0 rand()/32767 < p -> play
SoundManager::PlaySoundInternal 0x00550170 position + attenuation
SoundManager::GetAttenuation 0x00550020 effect vs ambient volume pref
SoundManager::PlaySoundFromCenter 0x00550950 interface sounds
SoundManager::PlayAmbientSound 0x00550820
SoundManager::PlayAmbientSoundFromCenter 0x005508B0
SoundManager::SetPlayerPosition 0x005503C0 listener = viewer/eye
SoundManager::InitPrefs 0x005503F0 the 8 sound prefs
Random::RollDice(float,float) 0x0042C600 lo + u01*(hi-lo)
SoundHook::Execute 0x00526A20
SoundTweakedHook::Execute 0x00526A80
SoundTableHook::Execute 0x00526AB0
Ambient::Play 0x005517A0
Ambient::UseTime 0x00551880
Ambient::PlaySoundA 0x00550D90
ClientUISystem::GetUISoundTable 0x00563FB0 DBObj::GetByEnum(0x22, 7)
DBObj::GetByEnum 0x00415490
CPlayerSystem::Handle_Admin__Environs 0x0055DE20 env 0x65..0x7C -> UI sounds
MediaMachine::Update_Sound 0x004658B0
CPhysicsObj::InitDefaults 0x005139D0 Setup.default_stable_id
CPhysicsObj::set_description 0x00514F40 PhysicsDesc.stable_id
CM_Physics::DispatchSB_PlayScriptID 0x006ACC40 0xF754 (we have this)
CM_Physics::DispatchSB_PlayScriptType 0x006AC6E0 0xF755 (we have this)
```

View file

@ -0,0 +1,705 @@
# Lane 1 — retail `SoundManager` core, decoded, vs acdream's OpenAL engine
Date: 2026-08-08. Read-only research note.
Sources
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail structs)
- **Raw byte decode** of `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, PDB-paired,
image base `0x00400000`) via capstone — **required**, because the BN pseudo-C for
`GetAttenuation` and the pan block has FPU-elided constants (it prints `* 0f` where the
binary has `fmul dword [VOL_MIN_DIST_SQ]`) and one misattributed stack slot. Every
constant below is read out of the binary, not inferred.
Compared against
- `src/AcDream.App/Audio/OpenAlAudioEngine.cs`
- `src/AcDream.App/Audio/OpenAlResourceLifetime.cs`
- `src/AcDream.App/Audio/AudioHookSink.cs`
- `src/AcDream.Core/Audio/AudioModel.cs`, `SoundCookbook.cs`
---
## 0. Address map (all VAs, 2013 EoR build)
| Symbol | VA |
|---|---|
| `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` | `0x0054FEC0` |
| `SoundManager::GetAttenuation(float dist, float vol, int* outDb, int ambient)` | `0x00550020` |
| `SoundManager::PlayProbability(float)` | `0x005500E0` |
| `SoundBufRef::SoundBufRef(DataID)` | `0x00550110` |
| `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` | `0x00550170` |
| `SoundManager::ShutDown` | `0x005502B0` |
| `SoundManager::SetPlayerPosition(const Position*)` | `0x005503C0` |
| `SoundManager::Cleanup` (tailcall → ShutDown) | `0x005503E0` |
| `SoundManager::InitPrefs` | `0x005503F0` |
| `SoundManager::Init(HWND)` | `0x00550640` |
| `SoundManager::GetSound(SoundType, CSoundTable*, SoundData*)` | `0x00550680` |
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*)` | `0x00550730` |
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*, prio, prob, vol)` | `0x005507A0` |
| `SoundManager::PlayAmbientSound(SoundType, table, Position*, vol)` | `0x00550820` |
| `SoundManager::PlayAmbientSoundFromCenter(SoundType, table, vol)` | `0x005508B0` |
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` |
| `SoundManager::PlaySoundFromCenter(DataID, float vol)` | `0x005509E0` |
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float vol)` | `0x00550AF0` |
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*)` | `0x00550B70` |
| `SoundManager::CreateSound(DataID)` | `0x00550BF0` |
| `SoundManager::DestroySound(DataID)` | `0x00550C60` |
| `SoundBuf::ReleaseAll` | `0x00552670` |
| `SoundBuf::CopyWaveToBuffer(WaveFile*)` | `0x005526D0` |
| `SoundBuf::Stop` | `0x00552830` |
| `SoundBuf::GetStatus` | `0x00552850` |
| `SoundBuf::~SoundBuf` (tailcall → ReleaseAll) | `0x005528A0` |
| `SoundBuf::SoundBuf(const SoundBuf&)` (DuplicateSoundBuffer) | `0x005528B0` |
| `SoundBuf::Create(int bStatic)` | `0x00552930` |
| `SoundBuf::Restore` | `0x00552B90` |
| `SoundBuf::SoundBuf(DataID, tagval, bStatic, b3D)` | `0x00552D00` |
| `SoundBuf::Play(int pan, int volDb)` | `0x00552D50` |
| `SoundOK` | `0x00552E10` |
| `GetDirectSound` | `0x00552E30` |
| `SoundCleanup` | `0x00552E40` |
| `SoundSetup(HWND)` | `0x00552E70` |
| `CDirSound::DirectSoundOK` | `0x00553D00` |
| `CDirSound::CDirSound(HWND)` | `0x00553D10` |
| `CDirSound::~CDirSound` | `0x00553E40` |
| `Ambient::Play(AmbientSound*)` | `0x005517A0` |
| `Ambient::UseTime` | `0x00551880` |
| `SmartBox::set_viewer(const Position*, int type)` | `0x00452C40` |
| `SmartBox::update_viewer` | `0x00453CE0` |
| `Position::heading(const Position&)` | `0x005A9520` |
| `Position::distance(const Position&)` | `0x005A94B0` |
| `Frame::get_heading` | `0x00535760` |
### Statics
| Symbol | VA | Type / value |
|---|---|---|
| `SoundManager::VOL_MIN` | `0x0081F060` | `int32 = -50` (**decibels**) |
| `SoundManager::effect_sounds_enabled` | `0x0081F064` | `bool = 1` |
| `SoundManager::effect_sound_volume` | `0x0081F068` | `float = 1.0` |
| `SoundManager::ambient_sounds_enabled` | `0x0081F06C` | `bool = 1` |
| `SoundManager::ambient_sound_volume` | `0x0081F070` | `float = 1.0` |
| `SoundManager::interface_sounds_enabled` | `0x0081F074` | `bool = 1` |
| `SoundManager::interface_sound_volume` | `0x0081F078` | `float = 1.0`**write-only, never read** |
| `SoundManager::s_bPlaySoundOnlyWhenActive` | `0x0081F07C` | `bool = 1` |
| `SoundManager::player_position_` | `0x0081F0E0` | `Position` (listener). `.frame` at `0x0081F0E8` |
| `SoundManager::s_SoundFeatures` | `0x0086F3A4` | `uint32 = 0`; enum table at `0x0086F3E8` |
| `SoundManager::curr_playing_buffer_` | `0x0086F3A8` | `int32 = 0` — ring cursor |
| `SoundManager::s_bInittedPrefs` | `0x0086F3AC` | `bool = 0` |
| `SoundManager::sound_hash_` | `0x0086F4A0` | `IntrusiveHashTable<DataID, SoundBufRef*>`, ctor arg `0x40` |
| `SoundManager::playing_sounds_` | `0x0086F510` | `SoundPlayingData[0x10]`**16 voices**, stride `0x10` |
| `SoundBuf::useDatabase` | `0x0081F220` | `int32 = 1` |
| `VOL_MIN_DIST` | `0x007CAEAC` (rodata) | `float = 5.0` (metres) |
| `VOL_MIN_DIST_SQ` | `0x0086F404` (.data) | `float = 25.0`, static-init `5f*5f` |
| `INV_LOG_OF_2` | `0x0086F408` (.data) | `double = 1/ln 2 = 1.4426950408889634`, static-init `1.0 / fyl2x(2.0, ln2)` |
| dB-per-octave const | `0x007CAF48` (rodata) | `double = 6.0206` (= `20·log10 2`) |
| **pan scale** | `0x007CAF58` (rodata) | `double = -15.0` |
| probability scale | `0x007CAF50` (rodata) | `float = 3.05185094e-05` (= `1/32767`) |
| DEG→RAD | `0x0079B504` | `float = 0.0174532924` |
| RAD→DEG | `0x0079B6C8` | `double = 57.29577951308232` |
| heading base | `0x0079B6C0` | `double = 450.0` (= `360 + 90`) |
| pan deadzone | `0x007991B0` | `double = 5.0` (metres) |
### Struct layouts (from `acclient.h`, offsets confirmed against the binary)
```c
struct SoundData { // 0x10
DataID sound_id_; // +0x00
float priority_; // +0x04 <-- eviction key
float probability_; // +0x08
float volume_; // +0x0C
};
struct SoundBufRef { // 0x24, operator new(0x24)
DataID m_hashKey; // +0x00
SoundBufRef* m_hashNext; // +0x04
SoundData data_; // +0x08 .. +0x17
int links_; // +0x18 refcount (CreateSound/DestroySound)
SoundBuf* sound_buf_; // +0x1C template buffer, duplicated per play
int buffer_num_; // +0x20 init 0xFFFFFFFF, unused
};
struct SoundPlayingData { // 0x10
SoundBuf* buffer; // +0x00
float priority; // +0x04
long double start_time; // +0x08 (8 bytes, Timer::cur_time) <-- WRITTEN, NEVER READ
};
struct SoundBuf { // 0x20, operator new(0x20)
CDirSound* m_pCDirSound; // +0x00
IDirectSoundBuffer* m_pBuf; // +0x04
IDirectSound3DBuffer* m_p3DBuf; // +0x08
char* m_filename; // +0x0C
int m_tagval; // +0x10
unsigned m_bufsize; // +0x14
int m_3D; // +0x18
DataID m_gid; // +0x1C
};
struct CDirSound { // 0x24, operator new(0x24)
tWAVEFORMATEX m_defaultFormat; // +0x00 (18B, padded to 0x14)
HWND m_hWindow; // +0x14
IDirectSound* m_pDirectSoundObj; // +0x18
IDirectSound3DListener* m_lpDs3dListener; // +0x1C
IDirectSoundBuffer* m_3DSoundBuffer; // +0x20 (primary)
};
struct SoundManager { }; // pure statics, no instance
```
---
## 1. Pseudocode, function by function
### `SoundSetup(HWND)` / `CDirSound::CDirSound` / `SoundOK` / `SoundCleanup`
```
SoundSetup(hwnd):
if hwnd == 0:
Device::Error("SoundSetup requires a valid HWND! Sound will be disabled.",
"SoundSetup Error")
return 0
delete pDirSound # tear down any previous device
pDirSound = new CDirSound(hwnd) # 0x24 bytes
return (pDirSound && pDirSound->m_pDirectSoundObj != 0)
CDirSound::CDirSound(hwnd):
m_pDirectSoundObj = m_lpDs3dListener = m_3DSoundBuffer = null
m_hWindow = hwnd
if DirectSoundCreate(NULL, &m_pDirectSoundObj, NULL) != DS_OK: return
if m_pDirectSoundObj->SetCooperativeLevel(hwnd, DSSCL_PRIORITY /*2*/) != DS_OK:
m_pDirectSoundObj = null; return
# primary buffer
DSBUFFERDESC s = {0}; s.dwSize = 0x24
s.dwFlags = 0x11 # DSBCAPS_PRIMARYBUFFER | DSBCAPS_CTRL3D
if CreateSoundBuffer(&s, &m_3DSoundBuffer, NULL) != DS_OK: return
if m_3DSoundBuffer->QueryInterface(IID_IDirectSound3DListener, &m_lpDs3dListener) != DS_OK: return
m_lpDs3dListener->SetRolloffFactor(0.01f /*0x3C23D70A*/, DS3D_IMMEDIATE)
m_lpDs3dListener->SetOrientation(front=(-1,0,0), top=(0,1,0), DS3D_IMMEDIATE)
m_lpDs3dListener->CommitDeferredSettings()
m_defaultFormat = { PCM, 2ch, 16-bit, 11025 Hz (0x2B11),
nBlockAlign 4, nAvgBytesPerSec 44100 (0xAC44), cbSize 0 }
m_3DSoundBuffer->SetFormat(&m_defaultFormat)
m_3DSoundBuffer->Play(0, 0, DSBPLAY_LOOPING /*1*/) # primary buffer runs forever
SoundOK() -> pDirSound && pDirSound->m_pDirectSoundObj != 0
GetDirectSound()-> pDirSound
SoundCleanup() -> delete pDirSound; pDirSound = null
```
**The 3D listener exists but gameplay never uses it.** `SoundBufRef::SoundBufRef` creates
its template `SoundBuf` with `b3D = 0`, so `SoundBuf::Create` takes the non-3D branch and
sets `m_3D = 0`. Every gameplay/UI/ambient voice is a **2D buffer with CPU-computed pan
and volume**. `IDirectSound3DBuffer` is only reachable through a path nothing in
SoundManager takes.
### `SoundManager::Init` / `InitPrefs` / `ShutDown` / `Cleanup`
```
Init(hwnd):
SoundSetup(InitPrefs()) # note: InitPrefs() returns void; hwnd reaches
# SoundSetup through ecx (__fastcall) — a compiler
# artifact, semantics are InitPrefs(); SoundSetup(hwnd)
midiSetup()
if SoundOK() == 0:
effect_sounds_enabled = 0 # hard-disable effects when there is no device
return
srand(time(0))
InitPrefs(): # exact preference names, in registration order
RegisterPreference(&effect_sound_volume, "Sound Volume") # float, default 1.0
RegisterPreference(&ambient_sound_volume, "Ambient Sound Volume") # float, default 1.0
RegisterPreference(&interface_sound_volume, "Interface Sound Volume") # float, default 1.0
RegisterPreference(&s_SoundFeatures, "Sound Features", enumTable=0x86F3E8, kind=2) # uint, default 0
RegisterPreference(&effect_sounds_enabled, "Sound Disabled") # bool, default 1
RegisterPreference(&ambient_sounds_enabled, "Ambient Sound Disabled") # bool, default 1
RegisterPreference(&interface_sounds_enabled, "Interface Sound Disabled") # bool, default 1
RegisterPreference(&s_bPlaySoundOnlyWhenActive, "Play Sound Only When Active") # bool, default 1
s_bInittedPrefs = 1
ShutDown(): # Cleanup() is a tailcall to this
for each SoundBufRef in sound_hash_: SoundBuf::Stop(ref->sound_buf_)
for i in 0..15:
slot = playing_sounds_[(curr_playing_buffer_ + i) mod 16]
if slot.buffer: Stop(slot.buffer); ~SoundBuf(slot.buffer); delete slot.buffer
midiCleanup(); SoundCleanup()
if s_bInittedPrefs: UnregisterPreference(all 8)
```
Note the enable flags are named `..._Disabled` in the preference store but the backing
variables are `..._enabled` with default 1. Whoever reads the pref file must invert or the
pref writer already stores the inverted sense — do not assume the on-disk polarity.
### `SoundManager::CreateSound` / `DestroySound` — refcounted registration
```
CreateSound(DataID id):
ref = sound_hash_.find(id)
if ref: ref->links_ += 1; return # refcount bump
ref = new SoundBufRef(id) # allocates + creates the template SoundBuf now
sound_hash_.add(ref)
SoundBufRef::SoundBufRef(id):
m_hashKey = id; m_hashNext = null
SoundData::SoundData(&data_) # zero-init
links_ = 1; buffer_num_ = 0xFFFFFFFF
sound_buf_ = new SoundBuf(id, tagval=0, bStatic=1, b3D=0) # 2D, DSBCAPS_STATIC
DestroySound(DataID id):
ref = sound_hash_.find(id); if !ref: return
if ref->links_-- == 1: # last reference
ref = sound_hash_.remove(id)
~SoundBuf(ref->sound_buf_); delete ref->sound_buf_
delete ref
```
A sound that was never `CreateSound`'d cannot be played: every `PlaySound*` walks
`sound_hash_` and silently returns when the id is absent. The wave is decoded and copied
into a DirectSound buffer eagerly at `CreateSound` time (see `SoundBuf::Create`), never on
first play.
### `SoundManager::GetSound` — variant selection (**biased**)
```
GetSound(SoundType stype, CSoundTable* table, out SoundData* d) -> SoundBufRef*:
if table == 0: return 0
if !CSoundTable::Lookup(table, stype, &std): return 0
n = std->num_stdatas_ # [+0x7C]
if n <= 0: return 0
roll = Random::RollDice(0.0f, 1.0f) # [0, 1]
idx = (int)( (float)(n - 1) * roll ) # <-- (n-1), TRUNCATED
if (unsigned)idx >= n: return 0
row = &std->data_[idx] # 16-byte rows at [+0x80]
d->sound_id_ = row[0]; d->priority_ = row[4]
d->probability_ = row[8]; d->volume_ = row[0xC]
if d->sound_id_ == 0: return 0
return sound_hash_.find(d->sound_id_)
```
`idx = floor(roll · (n1))`, **not** `floor(roll · n)`. Consequences:
- `n = 1` → always row 0.
- `n = 2` → row 0 unless the roll is exactly 1.0; row 1 has probability ≈ 1/32768.
- `n = 3` → rows 0 and 1 at ~50% each; row 2 ≈ 1/32768.
The last row of every multi-row sound entry is effectively dead in retail. This is retail
behaviour, not a decomp artifact — the `fild (n-1)` / `fmul st(1)` / `_ftol2` sequence is
unambiguous at `0x005506C8..0x005506E2`.
### `SoundManager::PlayProbability`
```
PlayProbability(float prob):
return ((float)rand() * (1.0f/32767.0f)) < prob # play iff strictly less
```
`probability_` is an **independent gate applied after** the index pick — not a selection
weight. Applied by every `SoundType`-keyed overload and by
`PlaySoundA(DataID, obj, prio, prob, vol)`; **not** applied by
`PlaySoundA(DataID, CPhysicsObj*)` or `PlaySoundFromCenter(DataID, vol)`.
### `SoundManager::GetAttenuation` — the falloff, exact
Byte-level decode of `0x00550020`:
```
GetAttenuation(float dist, float vol, int* outDb, int ambient) -> int:
# 1. distance term
if dist < VOL_MIN_DIST /*5.0 m*/:
g = vol
else:
g = (VOL_MIN_DIST_SQ /*25.0*/ * vol) / (dist * dist) # exact inverse-square,
# continuous at 5 m
# 2. clamp above
if g > 1.0: g = 1.0
# 3. one, and only one, master multiply
g *= (ambient != 0) ? ambient_sound_volume : effect_sound_volume
# 4. silent gate
if g <= 0.0: *outDb = VOL_MIN /*-50*/; return 0 # DO NOT PLAY
# 5. linear gain -> integer decibels
# fldln2; fyl2x => ln(g)
# * INV_LOG_OF_2 (1/ln2) => log2(g)
# * 6.0206 (20*log10 2) => 20*log10(g)
db = (int) ceil( 20.0 * log10(g) )
*outDb = db
if db >= VOL_MIN /*-50*/: return 1 # PLAY at db decibels
*outDb = VOL_MIN; return 0 # DO NOT PLAY
```
Notes that matter:
- The distance model is **inverse-square with a 5-metre reference**, expressed as
`25/d²`, clamped to unity, and it is **hard-cut at 50 dB**. Solving
`ceil(20·log10(25·vol·master/d²)) ≥ 50``25·vol·master/d² > 10^(51/20)`:
the audible radius is **≈ 94.2 m** at `vol·master = 1.0`, **≈ 66.6 m** at 0.5,
**≈ 29.8 m** at 0.1. Beyond that the sound is *never started* — no voice, no slot.
- Output is **integer decibels quantised by `ceil`** — a 1 dB stair-step as you walk
toward a source, not a smooth ramp.
- `dist` is `Position::distance` (`0x005A94B0`): `sqrt(dx²+dy²+dz²)` of
`Position::get_offset`, which resolves the landblock delta first — a true cross-landblock
3D metric distance in metres, **including Z**.
- `vol` here is whatever the caller passed. Three callers pre-multiply by a master
volume, so the master lands **twice** (see §4 quirk).
### `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` — pan
Byte-level decode of `0x00550170`. **BN's pseudo-C is wrong here**: it reuses stack slot
`[esp+0xC]` and reports the `< 5.0` test as an *angle* test. In the binary the `_ftol2`
at `0x005501F2` converts `[esp+4]` = **distance**, and the x87 stack still holds the
angle. It is a *distance* deadzone.
```
PlaySoundInternal(ref, const Position* soundPos, float vol, int ambient):
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
listenerHeading = Frame::get_heading(&player_position_.frame) # degrees
dist = Position::distance(soundPos, &player_position_) # metres, 3D
headingSoundToListener = Position::heading(soundPos, &player_position_) # degrees
pan = 0
if s_SoundFeatures != 1: # 1 == panning disabled
delta = fmod(headingSoundToListener - listenerHeading, 360.0)
if !(delta <= 180.0): delta -= 360.0 # normalise to (-180, 180]
if abs((int)dist) >= 5: # <-- DISTANCE deadzone, 5 m
pan = (int)( sin(delta * 0.0174532924f) * -15.0 ) # -15..+15
# else pan stays 0: anything inside 5 m plays dead centre
if GetAttenuation(dist, vol, &db, ambient):
PlaySoundInternal(ref, pan, db)
```
`Position::heading(this, other)` (`0x005A9520`) and `Frame::get_heading` (`0x00535760`)
share one convention: `fmod(450.0 atan2(dy, dx)·57.29578, 360.0)` — i.e. **compass
degrees, clockwise from +Y (north)**, `+X` (east) = 90°. `Position::heading` returns the
heading **from `this` toward `other`**, so `heading(soundPos, listenerPos)` is the
*reverse* bearing; combining that reversal with the `-15.0` scale yields the correct
handedness. Worked check: sound due east, listener facing north ⇒ delta = 90° ⇒
`pan = -15·sin(-90°) = +15` = full right in DirectSound. ✓
Equivalent forward formulation for a port:
> `pan_dB = 15 · sin(bearing_of_source_relative_to_listener_facing)`, zero inside 5 m.
**There is no front/back and no elevation cue.** A source dead ahead and a source directly
behind both give `pan = 0`; Z contributes to distance but never to pan.
### `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` — the 16-voice pool
Byte-level decode of `0x0054FEC0` (this, not `FUN_00550AD0`, is the voice allocator):
```
PlaySoundInternal(ref, pan, volDb):
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
now = Timer::cur_time # 8-byte double
# PASS 1 — ring scan from curr_playing_buffer_ for a reusable slot
for i in 0 .. 15:
s = (curr_playing_buffer_ + i) mod 16 # signed-safe & 0x8000000F fixup
buf = playing_sounds_[s].buffer
if buf == null: goto CLAIM # never used
if buf->m_pBuf == null: goto DESTROY_CLAIM # broken buffer
if (SoundBuf::GetStatus(buf) & DSBSTATUS_PLAYING) == 0:
goto DESTROY_CLAIM # finished
# slot is genuinely busy, keep scanning
# PASS 2 — all 16 busy: priority eviction, same ring order
for j in 0 .. 15:
s = (curr_playing_buffer_ + j) mod 16
if playing_sounds_[s].priority < ref->data_.priority_: # STRICTLY less
SoundBuf::Stop(playing_sounds_[s].buffer)
goto DESTROY_CLAIM
return # nothing lower-priority -> DROP the new sound
DESTROY_CLAIM:
~SoundBuf(buf); delete buf # a voice is a real DS buffer; it is freed
CLAIM:
v = new SoundBuf(0x20)
if v: SoundBuf::SoundBuf(v, ref->sound_buf_) # IDirectSound::DuplicateSoundBuffer
playing_sounds_[s].buffer = v
playing_sounds_[s].priority = ref->data_.priority_
playing_sounds_[s].start_time = now # written, never read anywhere
curr_playing_buffer_ = (s + 1) mod 16
SoundBuf::Play(v, pan, volDb)
```
Answers to the slot questions:
- **Count:** exactly 16 (`playing_sounds_[0x10]`, `& 0x8000000F` masking).
- **Selection:** round-robin from `curr_playing_buffer_`; first slot that is empty, has a
null `m_pBuf`, or is no longer `DSBSTATUS_PLAYING`.
- **Eviction:** *priority only*, `slot.priority < new.priority`, first match in ring order.
**Equal priority never evicts.** Volume/gain is not consulted. `start_time` is recorded
but never read, so age only enters through the ring cursor.
- **Overflow:** the new sound is silently dropped.
### `SoundBuf::Create` / `CopyWaveToBuffer` / `Restore` / `Play` / `Stop` / `GetStatus`
```
SoundBuf::SoundBuf(DataID gid, int tagval, int bStatic, int b3D):
m_pBuf = m_p3DBuf = m_filename = null; m_bufsize = 0
m_tagval = tagval; m_3D = b3D; m_gid = gid
m_pCDirSound = GetDirectSound()
if m_pCDirSound: Create(bStatic)
SoundBuf::Create(int bStatic) -> int:
ds = m_pCDirSound->m_pDirectSoundObj; if !ds: return 0
if m_3D == 0 || m_pCDirSound->m_lpDs3dListener == null:
flags = 0x100E0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLPAN | CTRLFREQUENCY
m_3D = 0
else:
flags = 0x100B0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLFREQUENCY | CTRL3D
if bStatic: flags |= DSBCAPS_STATIC /*0x2*/
if SoundBuf::useDatabase /*1*/:
obj = DBObj::Get(QualifiedDataID(m_gid, 0x0F)) # 0x0F = Wave
wave = obj + 0x38
DSBUFFERDESC s = {0}; s.dwSize = 0x24; s.dwFlags = flags
... lpwfxFormat = wave fmt; dwBufferBytes = wave data size ...
if ds->CreateSoundBuffer(&s, &m_pBuf, NULL) == DS_OK:
m_bufsize = size
if CopyWaveToBuffer(wave): return 1
return 0
SoundBuf::CopyWaveToBuffer(WaveFile* w) -> int:
Lock(0, m_bufsize, &p1,&n1, &p2,&n2, 0)
if global ACM stream `phas` != null: acmStreamPrepareHeader/Convert/UnprepareHeader
else: memcpy p1 (+ wrap into p2)
Unlock(...)
SoundBuf::SoundBuf(const SoundBuf& src): # per-play voice
zero everything; m_pCDirSound = GetDirectSound()
if m_pCDirSound->m_pDirectSoundObj->DuplicateSoundBuffer(src.m_pBuf, &m_pBuf) == DS_OK:
copy m_bufsize, m_tagval, m_3D, m_gid
if m_3D: m_pBuf->QueryInterface(IID_IDirectSound3DBuffer, &m_p3DBuf)
SoundBuf::Play(int pan, int volDb) -> int:
if pan < -15: pan = -15
elif pan > 15: pan = 15
if pan != 0 && m_pBuf && m_3D == 0: m_pBuf->SetPan(pan * 100) # hundredths of dB
if volDb < VOL_MIN /*-50*/: volDb = VOL_MIN
if m_pBuf: m_pBuf->SetVolume(volDb * 100) # hundredths of dB
if m_pBuf->SetCurrentPosition(0) == DS_OK:
hr = m_pBuf->Play(0, 0, 0) # dwFlags 0 — NO LOOPING
if hr == DSERR_BUFFERLOST /*0x88780096*/ && Restore():
hr = m_pBuf->Play(0, 0, 0)
return hr == DS_OK
return 0
SoundBuf::Stop() -> m_pBuf ? (m_pBuf->Stop(), 1) : 0
SoundBuf::GetStatus() -> m_pBuf && GetStatus(&st)==DS_OK ? st : -1 # bit0 = DSBSTATUS_PLAYING
SoundBuf::Restore() -> re-fetch the wave from the DAT and re-run the Create/CopyWave path
SoundBuf::ReleaseAll()-> delete[] m_filename; Release m_pBuf, m_p3DBuf; memset; m_gid = INVALID
```
Units to keep straight: retail's internal volume/pan are **whole decibels**; DirectSound's
`SetVolume`/`SetPan` take **hundredths of a decibel**, hence the `× 100`. Retail's floor is
`-50 dB` (`-5000`), half of DirectSound's `DSBVOLUME_MIN = -10000`. Pan saturates at
`±15 dB` (`±1500`) out of DirectSound's `±10000`, so retail's stereo image is **narrow by
construction** — a hard-panned sound is only 15 dB down in the far ear, never silent.
### Listener: `SetPlayerPosition`
```
SetPlayerPosition(const Position* p):
player_position_.objcell_id = p->objcell_id
Frame::operator=(&player_position_.frame, &p->frame)
```
Who writes it, and when:
| Caller | Source | Cadence |
|---|---|---|
| `SmartBox::set_viewer` (`0x00452D36`) | `SmartBox::viewer` | see below |
| `CreatureMode::Render` (`0x00452A83`, `0x00452AAE`) | `creature_view_frame`, then restores the saved `player_position_` | per creature-mode frame |
`SmartBox::set_viewer(pos, type)` copies `pos` into `SmartBox::viewer` and then hands
`&this->viewer` to `SoundManager::SetPlayerPosition`, `LScape::set_sky_position`, and
`SceneTool::SetupCamera` — so **the audio listener is the same Position the camera uses.**
Its writers:
- `SmartBox::update_viewer` (`0x00453CE0`), called from `SmartBox::DrawNoBlit`
(`0x00454C34`) — **once per rendered frame**. It runs the third-person camera through a
`CTransition` sphere sweep (`viewer_sphere`) and sets the viewer to the *collided camera
position* (`type = 0`); on sweep failure it falls back to
`set_viewer(&player->m_position, 1)`.
- `SmartBox::PlayerPositionUpdated` / `TeleportPlayer` / `BlipPlayer`
`set_viewer(&player->m_position, 1)`, event-driven.
So: **listener = camera viewer position + that Position's `Frame` heading, in world/cell
space (objcell_id + Frame), refreshed every rendered frame.** Only two things are read out
of it: the frame origin (distance) and `Frame::get_heading` (pan). No up vector, no
velocity ⇒ **no doppler, no roll/pitch influence, no elevation cue**.
### Looping and the ambient driver
`SoundBuf::Play` always passes `dwFlags = 0`. **Nothing in SoundManager ever loops.** The
only looped buffer in the client is `CDirSound`'s primary buffer.
Sustained ambience is a **re-trigger scheduler**:
```
Ambient::Play(AmbientSound* a):
if !a->CanHear(): a->on_queue = 0; return
if a->PlayNow():
if a->GetSoundPos(&pos): Ambient::PlaySoundA(stype, table, &pos, a->GetVolume())
else: PlayAmbientSoundFromCenter(stype, table, a->GetVolume())
Insert(&sound_queue, Timer::cur_time + a->GetPlayInterval(), a) # PQueueArray<double>
a->on_queue = 1
Ambient::UseTime(): # SmartBox::UseTime -> per game tick
if !ambient_sounds_enabled: return
while sound_queue not empty and sound_queue.top().key <= Timer::cur_time:
pop and Ambient::Play(it)
Ambient::PlaySoundA(stype, table, pos, vol):
pos ? PlayAmbientSound(stype, table, pos, vol) : PlayAmbientSoundFromCenter(stype, table, vol)
```
`ConstantSound` (has `current_volume`) and `IntermitSound` (has `play_chance`,
`min_dist[8]`, `max_dist[8]`, `num_dir`, `sound_dir[8]`) are the two `AmbientSound`
subclasses supplying `GetVolume` / `GetPlayInterval` / `CanHear` / `PlayNow`.
`Ambient::AddSound` gates on `Ambient::ambient_sound_max_dist_sq` and weights with
`Ambient::CalcWeight` (which uses `ambient_sound_min_dist_sq` / `..._max_dist_sq`).
### Pitch / frequency
`DSBCAPS_CTRLFREQUENCY (0x20)` is requested on **every** buffer, and
`IDirectSoundBuffer::SetFrequency` is **never called anywhere in the binary**. Retail has
**no pitch or frequency variation** on sound effects. There is no `PitchMin`/`PitchMax`
concept in `SoundData` — the four fields are `sound_id_`, `priority_`, `probability_`,
`volume_`, full stop.
### Per-frame voice maintenance
`SetPan` and `SetVolume` are called from exactly one place: `SoundBuf::Play`. There is no
SoundManager tick, no `UseTime`, no reposition pass. **A voice keeps the pan and volume it
was born with for its entire lifetime.** If a drudge emits a footstep and then runs past
you, that footstep does not move. If a source is beyond ≈94 m the sound is never started
at all rather than started quietly.
---
## 2. acdream today
`OpenAlAudioEngine.Play3DWave` is the only live 3D path (called from
`AudioHookSink.Play`, i.e. animation `SoundHook` / `SoundTableHook` / `SoundTweakedHook`).
It:
1. computes `effectiveGain = volume * SfxVolume`, drops if `< 0.001f`;
2. uploads/reuses an AL buffer (LRU byte-budgeted, 48 MiB);
3. picks a slot: first free-or-not-playing in ring order, else first with
`PlayingGain < effectiveGain`, else drop;
4. sets `Gain = effectiveGain`, `Pitch`, `Position`, `SourceRelative = false`,
`Looping = false`, plays;
5. records `PlayingGain`, `PriorityBase = clamp((int)priority, 0, 7)`, advances the cursor.
Sources are configured once (`Configure3DSource`): `MaxDistance = 1000`,
`RolloffFactor = 1`, `ReferenceDistance = 2`, and the global model is
`DistanceModel.InverseDistanceClamped` (`SelectRetailDistanceModel`).
`SetListener` is called per frame from `WorldRenderFrameBuilder.Apply` with the camera
position and a real forward/up pair derived from `camera.InverseView`;
`MasterVolume` is pushed into `AL_GAIN` on the listener.
`AudioFalloff.AttenuationAt` and `AudioFalloff.PanFromRelative` in
`AcDream.Core/Audio/AudioModel.cs` are **dead code**`grep` across `src/` and `tests/`
finds no caller.
---
## 3. Divergence table
| # | Aspect | Retail (verified) | acdream | Severity |
|---|---|---|---|---|
| D1 | Voice-allocator citation | `SoundManager::PlaySoundInternal(SoundBufRef*,int,int)` at **`0x0054FEC0`** | comment cites `FUN_00550AD0` / `chunk_00550000.c:527`; `0x00550AD0` is inside `IntrusiveHashTable<DataID,SoundBufRef*>::ctor` (`0x00550A60`) — **wrong function** | doc bug, fix the citation |
| D2 | Eviction key | `slot.priority < new.priority` (float `SoundData.priority_` from the SoundTable), strictly less; equal never evicts; gain never consulted | `slot.PlayingGain < effectiveGain` (volume × SfxVolume); `PriorityBase` stored but unused | **behavioural — loud-and-unimportant beats quiet-and-important** |
| D3 | Priority type/range | `float`, unclamped, straight from the DAT | `clamp((int)priority, 0, 7)`; model comments it "0..7" | flattens the ordering |
| D4 | Distance model | inverse **square** with 5 m reference: `min(1, 25·vol/d²)` | OpenAL `InverseDistanceClamped`, ref 2 m, rolloff 1 ⇒ `2/max(d,2)` — inverse **first power**, 2 m reference | **behavioural, large** |
| D5 | Dead falloff helper | — | `AttenuationAt(d, minDistance = 1.0f)`: right shape, wrong reference (1 m vs 5 m), and never called | dead + wrong |
| D6 | Audible cutoff | hard drop when `ceil(20·log10 g) < 50 dB`**≈94.2 m** at vol·master 1.0 (≈66.6 m at 0.5, ≈29.8 m at 0.1); the voice is never allocated | no distance cutoff; only `effectiveGain < 0.001f` (≈ 60 dB, distance-independent) | far sounds audible that retail silences; wasted voices |
| D7 | Gain quantisation | `ceil` to whole decibels, floor 50 dB | continuous float gain | subtle; retail stair-steps |
| D8 | Pan computation | CPU-side, angular: `pan_dB = (int)(15·sin(Δheading))`, `Δheading` = normalise180(heading(src→listener) listenerHeading); saturates at ±15 dB; **zero when `(int)distance < 5`**; no front/back, no elevation | OpenAL panner from full 3D vectors: front/back distinguished, elevation contributes, no 5 m deadzone, full stereo separation | **behavioural — our image is wider and 3D; retail's is a narrow 15 dB angular pan** |
| D9 | Dead pan helper | — | `PanFromRelative(relativeX, panRange = 20f)`: linear in relative X, invented 20 m constant, no retail counterpart, never called | dead + wrong |
| D10 | Pan disable switch | `s_SoundFeatures == 1` ⇒ pan forced 0 | none | missing pref |
| D11 | Volume knobs | 3 sliders (`"Sound Volume"`, `"Ambient Sound Volume"`, `"Interface Sound Volume"`) + 4 bools; **no master, no music volume**; exactly one multiply, inside `GetAttenuation` | `MasterVolume` (AL listener gain), `SfxVolume`, `MusicVolume = 0.7`, `AmbientVolume = 0.8` (last two unused) | different taxonomy; defaults 0.7/0.8 are invented |
| D12 | Retail quirk: squared master | `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as `vol`, and `GetAttenuation` multiplies by `effect_sound_volume` again ⇒ **effect volume squared**. Same for `PlayAmbientSound*`, which pre-multiply by `ambient_sound_volume`**ambient volume squared** | single multiply | port decision needed — faithful = squared |
| D13 | Retail quirk: dead knob | `interface_sound_volume` is registered and never read; interface sounds are scaled by **`effect_sound_volume`** (`GetAttenuation` called with `ambient = 0`) | n/a | do not implement an interface-volume slider that works |
| D14 | Active-app gate | `s_bPlaySoundOnlyWhenActive` (default **1**) + `Device::m_bIsActiveApp` checked in every entry point and in both `PlaySoundInternal` overloads | none — acdream keeps playing when unfocused | missing pref/behaviour |
| D15 | Listener source | `SmartBox::viewer` = collided third-person camera Position (falls back to `player->m_position`), per rendered frame; only origin + `Frame::get_heading` are read | camera position **plus** real forward/up from `InverseView`, per frame | ours is richer than retail — and that richness is what creates D8's front/back cue. Not "fixed orientation" as suspected; it is live |
| D16 | Doppler / velocity | none (no listener or source velocity ever set) | none set either (AL defaults 0) | ✓ match |
| D17 | Per-frame reposition | none. Pan+volume frozen at emission; `SoundPlayingData.start_time` written, never read | source position also set once — but OpenAL re-evaluates distance/pan against the live listener every frame, so our voices **do** sweep as the listener moves | **behavioural: retail voices are frozen in the listener frame; ours are world-static and continuously re-panned** |
| D18 | Looping | never (`Play(0,0,0)`); sustained ambience = `Ambient` PQueue re-trigger on `Timer::cur_time + GetPlayInterval()`, drained in `Ambient::UseTime` per tick, gated by `CanHear`/`PlayNow` | `Looping = false` always ✓, but `SoundEntry.Loop` exists unused and `StartAmbient` only reserves a handle — **no ambient layer at all** | whole subsystem missing (not a math divergence) |
| D19 | Pitch | `SetFrequency` never called; `SoundData` has no pitch fields | `SoundEntry.PitchMin/PitchMax` invented; `pitch` plumbed but always 1.0 | ✓ matches in effect; model carries fictional fields |
| D20 | Variant selection | `idx = (int)(RollDice(0,1) · (n1))` — last row unreachable (p≈1/32768); then `probability_` is an **independent gate** `rand()/32767 < prob` | `SoundCookbook.Roll` treats `Probability` as a **cumulative weight** with a silence tail | **behavioural — wrong distribution both ways** |
| D21 | Probability applied where | `SoundType` overloads + the 5-arg DID overload; **not** `PlaySoundA(DataID,obj)` nor `PlaySoundFromCenter(DataID,vol)` | uniform | minor |
| D22 | Buffer residency | refcounted `CreateSound`/`DestroySound`; wave decoded + copied to a DS buffer eagerly at register time; a play of an unregistered id is a silent no-op; each voice is a `DuplicateSoundBuffer` freed on slot reuse | lazy upload on first play, 48 MiB LRU; 16 persistent AL sources | legitimate modern adaptation; note only that our LRU can evict what retail pins, and we can hitch on first play |
| D23 | Device init | `DirectSoundCreate` + `SetCooperativeLevel(DSSCL_PRIORITY)`; primary buffer `DSBCAPS_PRIMARYBUFFER|CTRL3D`, format PCM 2ch/16-bit/11025 Hz; 3D listener rolloff 0.01, front (1,0,0), top (0,1,0) — **all unused because every gameplay buffer is `m_3D = 0`** | OpenAL-Soft default device, 3D sources | fine; do not port the 3D listener |
### D4/D6 numbers side by side (vol = master = 1.0)
| distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB |
|---|---|---|---|---|
| 2 m | 1.000 | 0 | 1.000 | 0.0 |
| 5 m | 1.000 | 0 | 0.400 | 8.0 |
| 10 m | 0.250 | 12 | 0.200 | 14.0 |
| 20 m | 0.0625 | 24 | 0.100 | 20.0 |
| 30 m | 0.0278 | 35 | 0.0667 | 23.5 |
| 50 m | 0.0100 | 40 | 0.0400 | 28.0 |
| 90 m | 0.00309 | 50 (last audible) | 0.0222 | 33.1 |
| ≥94.2 m | — | **not played** | 0.0212 | 33.5 |
| 200 m | — | **not played** | 0.0100 | 40.0 |
Retail is *louder near* and *silent far*; acdream is *quieter near* and *audible
everywhere*. This is the single largest audible divergence.
---
## 4. Port-ready summary (what a faithful `RetailSoundMixer` needs)
```
Constants
VOL_MIN_DIST = 5.0f metres
VOL_MIN_DIST_SQ = 25.0f
VOL_MIN = -50 decibels
PAN_SCALE = -15.0 (applied to sin of the reversed bearing)
PAN_DEADZONE = 5 metres, compared against (int)distance
VOICES = 16
dB(g) = ceil(20 * log10(g)) // g in (0,1]
Per play (3D):
dist = |listener.origin - source.origin| // 3D, cross-landblock, metres
g = dist < 5 ? vol : 25*vol/(dist*dist)
g = min(g, 1)
g *= isAmbient ? ambientVolume : effectVolume // ONE multiply
if g <= 0: drop
db = ceil(20*log10(g)); if db < -50: drop
delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees )
pan = (int)floor(-15 * sin(delta * pi/180)) clamped [-15, 15]
if (int)dist < 5: pan = 0
allocate voice: ring scan from cursor for free/finished;
else first slot with slotPriority < newPriority;
else DROP
gain_linear = 10^(db/20); pan_linear = ±(1 - 10^(-|pan|/20)) style 15 dB max separation
play once (no loop); never touch pan/gain again for this voice
```
For OpenAL specifically: set `AL_SOURCE_RELATIVE = true` and place the source at a
synthetic listener-relative point that reproduces the 15 dB pan (or use the stereo-panning
extension), with `AL_ROLLOFF_FACTOR = 0` so OpenAL's distance model is out of the loop and
the retail dB/pan pair is authoritative. Trying to bend `InverseDistanceClamped` into
`25/d²` is not possible — OpenAL's inverse model is first-power only; `AL_INVERSE_DISTANCE`
with rolloff cannot produce a squared curve, and `AL_EXPONENT_DISTANCE` with
`AL_ROLLOFF_FACTOR = 2` gives `(d/ref)^-2` which *does* match `25/d²` for `ref = 5`
that is the one-line fix if we want to keep the gain in AL rather than on the CPU.
(`AL_EXPONENT_DISTANCE_CLAMPED`, `AL_REFERENCE_DISTANCE = 5`, `AL_ROLLOFF_FACTOR = 2`,
`AL_MAX_DISTANCE = 94.2` reproduces D4 and D6 together; the ±15 dB pan and the 5 m pan
deadzone still have to be CPU-side.)
## 5. Decomp hazards found (worth a memory note)
1. **BN elides x87 memory constants.** `GetAttenuation`'s pseudo-C prints
`((long double)0f) * arg2 / (arg1*arg1)` and `... * ((long double)0.0) * 6.0206`. The
binary has `fmul dword [VOL_MIN_DIST_SQ]` (25.0) and `fmul qword [INV_LOG_OF_2]`
(1/ln 2). Reading the pseudo-C alone yields *zero gain at all distances*.
2. **BN misattributes reused stack slots.** In `PlaySoundInternal(pos)` it reports the
`< 5.0` comparison as an angle test on `var_4`; the binary converts `[esp+4]` =
**distance**. Porting the pseudo-C gives a 5-degree pan deadzone instead of a 5-metre
one.
3. `SoundManager` has **no instance** (`struct SoundManager {}` in `acclient.h`) — every
field is a file-scope static. Do not look for a `this`.