diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md new file mode 100644 index 00000000..c99cc067 --- /dev/null +++ b/docs/plans/2026-08-08-audio-parity-campaign.md @@ -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 · (n−1))` — 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 + (max−min)·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 `(n−1)` 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; A1–A2 are the "it sounds wrong" +fixes, A3–A4 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 · (n−1))` (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 + (max−min)·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 +`, 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 | — | — | — | diff --git a/docs/research/2026-08-08-audio-retail-ambient-authoring.md b/docs/research/2026-08-08-audio-retail-ambient-authoring.md new file mode 100644 index 00000000..7d3ef64b --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-ambient-authoring.md @@ -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 `0x20000000–0x2000FFFF`** + (`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 (0x20000000–0x2000FFFF) +│ └── ambient_sounds : AmbientSoundDesc[] +│ { stype, volume, base_chance, min_rate, max_rate } +│ +├── scene_info : CSceneDesc +│ └── scene_types : CSceneType[] +│ ├── (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 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 stb_desc; +}; + +/* 5830 */ // sizeof = 0x14 +struct __cppobj CSceneType +{ + PStringBase scene_name; // +0x00 + SmartArray,1> scenes; // +0x04 m_data, +0x08 m_sizeAndDealloc, +0x0C m_num + AmbientSTBDesc *sound_table_desc; // +0x10 +}; + +/* 5832 */ +struct __cppobj CTerrainType +{ + AC1Legacy::PStringBase terrain_name; // +0x00 + RGBAUnion terrain_color; // +0x04 + AC1Legacy::SmartArray scene_types; // +0x08 m_data, +0x0C m_size, +0x10 m_num +}; + +/* 5834 */ +struct __cppobj CTerrainDesc +{ + LandSurf *land_surfaces; + AC1Legacy::SmartArray terrain_types; +}; + +/* 5851 */ +struct __cppobj CRegionDesc : SerializeUsingPackDBObj +{ + unsigned int region_number; + AC1Legacy::PStringBase 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 sounds; // +0x2C + AC1Legacy::PQueueArray 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\ + 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 `0x20000000–0x2000FFFF`. + +### 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` (0x13000000–0x1300FFFF, `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 STBDesc` | `Generated/Types/SoundDesc.generated.cs` | **Exact match** to retail pack (`u32 count` + N entries). | +| `AmbientSTBDesc` | `Types.AmbientSTBDesc` → `uint STBId`, `List` | `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` | `Generated/Types/SceneDesc.generated.cs` | **Exact match.** | +| `CSceneType` | `Types.SceneType` → `uint StbIndex`, `List> 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`, `LandSurf` | `Generated/Types/TerrainDesc.generated.cs` | **Exact match.** | +| `CTerrainType` | `Types.TerrainType` → `TerrainName`, `ColorARGB TerrainColor`, `List SceneTypes` | `Generated/Types/TerrainType.generated.cs` | **Exact match** (indices, not resolved pointers). | +| `CSoundTable` | `DBObjs.SoundTable` (0x20000000–0x2000FFFF, `HasId`) → `HashKey`, `Dictionary Hashes`, `Dictionary 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 `0x20000000–0x2000FFFF`, 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 | diff --git a/docs/research/2026-08-08-audio-retail-ambient-runtime.md b/docs/research/2026-08-08-audio-retail-ambient-runtime.md new file mode 100644 index 00000000..b5ddc7a0 --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-ambient-runtime.md @@ -0,0 +1,1119 @@ +# Lane 2 — Retail ambient-sound runtime family, fully decoded + +Research-only 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), plus **live byte-level disassembly** of the +PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, +CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`) with capstone, used to +resolve every FPU-elided constant and every `test ah, 0x41 / 0x44 / 0x05` +comparison the BN decomp renders as an unimplemented `bool p`. **Every +comparison direction and every float in this document is byte-verified, not +inferred.** That matters: BN's rendering of these compares is ambiguous in +both directions, and three of them (`is_continuous`, `CanHear`, `PlayNow`) +would have been ported backwards from the pseudo-C alone. + +--- + +## 0. Executive summary — what retail's ambient system actually is + +Retail's ambient system is **not** a set of looping voices attached to a +landblock. It is a *weighted accumulation + timer queue*: + +1. On **every objcell change** (outdoors: every 24 m land-cell crossing; + `CellManager::ChangePosition`), the client rebuilds the ambient weighting + from scratch. +2. It walks the **3×3 landblock neighbourhood** around the viewer (LOD ring + ≤ 1), and for **each of the 64 land cells** in each of those 9 landblocks + reads that cell's terrain word → `(terrainType, sceneIndex)` → the + region's `SceneType.SoundTableDesc` (an `AmbientSTBDesc`). +3. Each hit contributes an inverse-square **weight** (1.0 inside 20 m, + `(20/d)²` out to 120 m, 0 beyond) and a **compass direction** to every + `AmbientSound` object in that STB desc. +4. Volume (constant sounds) and trigger probability (intermittent sounds) + are then that sound's accumulated weight **divided by the total weight + of all ambients** — i.e. a genuine crossfade by terrain share. +5. Playback is driven by a **min-heap of absolute deadlines** (`double` + seconds, `Timer::cur_time`), popped once per frame from + `SmartBox::UseTime` → `Ambient::UseTime`. Each pop plays a **one-shot** + and re-inserts itself at `cur_time + GetPlayInterval()`. +6. **There are no looping OpenAL-style voices anywhere.** A "continuous" + ambient is a one-shot re-fired every `min_rate` seconds, played + *non-positionally* (from the listener's centre), at a crossfaded volume. + An "intermittent" ambient is a one-shot played *positionally* at a random + compass bearing and distance, at its full authored volume, gated by a + probability roll. +7. **Indoors is silent.** `Ambient::AddSound` has exactly **one** caller in + the whole binary: `CLandBlock::add_ambient_sounds`. There is no EnvCell / + dungeon ambient contributor. In `CellManager::ChangePosition` the + `LScape::add_ambient_sounds` call is gated on the "outdoors or + seen_outside" flag; when it is false nothing is added, every weight is 0, + and every ambient goes inaudible. +8. **No day/night, no time-of-day, no weather gating.** The selection input + is the baked terrain/scene map only. `GameTime`, `SkyDesc`, and `DayGroup` + never touch the ambient path. + +--- + +## 1. Struct layouts (verbatim retail + byte offsets) + +Offsets verified against `operator new` sizes and the AddDir/UpdateSound +disassembly. + +```c +struct AmbientSound // base, 0x18 bytes +{ + AmbientSoundVtbl *vfptr; // +0x00 + int on_queue; // +0x04 1 = has a pending deadline in the heap + float sound_count; // +0x08 accumulated weight this rebuild + AmbientSTBDesc *desc; // +0x0C + unsigned int ambient_sound_id; // +0x10 index into desc->ambient_sounds + int constant_sound; // +0x14 (written 0 at construction; never read) +}; + +struct __cppobj IntermitSound : AmbientSound // 0x80 bytes (operator new(0x80)) +{ + float play_chance; // +0x18 + float min_dist[8]; // +0x1C .. +0x3B + float max_dist[8]; // +0x3C .. +0x5B + unsigned int num_dir; // +0x5C + LandDefs::Direction sound_dir[8]; // +0x60 .. +0x7F +}; + +struct __cppobj ConstantSound : AmbientSound // 0x1C bytes (operator new(0x1c)) +{ + float current_volume; // +0x18 +}; + +struct AmbientSoundVtbl // vtable slot offsets +{ + void (*ResetCount) (AmbientSound*); // +0x00 + float (*GetVolume) (AmbientSound*); // +0x04 + int (*CanHear) (AmbientSound*); // +0x08 + int (*PlayNow) (AmbientSound*); // +0x0C + float (*GetPlayInterval) (AmbientSound*); // +0x10 + void (*AddTo) (AmbientSound*, float, Vector3*, LandDefs::Direction); // +0x14 + void (*UpdateSound) (AmbientSound*, float); // +0x18 + int (*GetSoundPos) (AmbientSound*, Position*); // +0x1C +}; + +struct AmbientSTBDesc // 0x1C bytes (memset(this,0,0x1C)) +{ + IDClass stb_id; // +0x00 SoundTable DID + int stb_not_found; // +0x04 negative cache + SmartArray ambient_sounds; // +0x08 m_data, +0x0C m_size, +0x10 m_num + CSoundTable *sound_table; // +0x14 lazily loaded DBObj + unsigned int play_count; // +0x18 # audible hits since last reset +}; + +struct AmbientSoundDesc // 0x18 allocated; 0x14 packed on disk +{ + SoundType stype; // +0x00 + int is_continuous; // +0x04 DERIVED at unpack, not stored + float volume; // +0x08 + float base_chance; // +0x0C + float min_rate; // +0x10 + float max_rate; // +0x14 +}; + +struct Ambient // owned by CellManager / SmartBox +{ + Position player_pos; // +0x00 (Position is 0x48 bytes, origin at +0x3C) + float total_sound_count; // +0x48 + unsigned int num_sounds; // +0x4C + DArray sounds; // +0x50 data, blocksize 8, initial sizeOf 8 + PQueueArray sound_queue; // min-heap of absolute play deadlines +}; +``` + +### On-disk `AmbientSTBDesc` (`AmbientSTBDesc::UnPack`, `0x5518f0`) + +``` +uint32 stb_id +uint32 count +count × { + uint32 stype + float volume + float base_chance + float min_rate + float max_rate +} +``` +`pack_size = count*0x14 + 8`. **`is_continuous` is computed, not read:** + +``` +0x5519a9 fld dword [ebx+0xC] ; base_chance +0x5519ac fcomp qword [0x794610] ; = 0.0 (byte-verified) +0x5519b4 test ah, 0x44 ; C3|C2 → the x87 "equal" test +0x5519b7 jp .zero +0x5519b9 mov eax, 1 +... +0x5519c2 mov dword [ebx+4], eax ; is_continuous +``` +→ **`is_continuous = (base_chance == 0.0f)`**. Matches ACE's +`AmbientSoundDesc.IsContinuous => BaseChance == 0`. Confirmed independently. + +--- + +## 2. Constants (all byte-read from the binary) + +| Symbol / address | Value | Units / meaning | +|---|---|---| +| `Ambient::ambient_sound_min_dist` `0x81f148` | **20.0** | m — full-weight radius | +| `Ambient::ambient_sound_min_dist_sq` `0x81f14c` | **400.0** | m² | +| `Ambient::ambient_sound_max_dist` `0x81f150` | **120.0** | m — cull radius | +| `Ambient::ambient_sound_max_dist_sq` `0x81f154` | **14400.0** | m² | +| `Ambient::ambient_sound_min_vol` `0x81f158` | **0.03** | linear (≈ −30.5 dB) audibility floor for ConstantSound | +| `SoundManager::ambient_sounds_enabled` `0x81f06c` | 1 | user pref `Sound_AmbientSoundDisabled` | +| `SoundManager::ambient_sound_volume` `0x81f070` | 1.0 | user pref `Sound_AmbientSoundVolume` | +| heading spread `0x81f1b0` | **0.392699093** rad | π/8 = 22.5° total cone (±11.25°) | +| `F_EPSILON` `0x7cb0a0` | 0.0002 | axis-degeneracy epsilon in `CalcDir` | +| in-viewer-block threshold | `min_dist_sq * 0.5` = **200.0** m² | ⇒ **14.142 m** | +| own-block near/far | **4.0 m / 10.0 m** | `5.0f − 1.0f` and `min_dist*0.5` | +| diagonal ratio gate `0x7c5e24` | **2.0** | \|y\|/\|x\| ≤ 2 **and** \|x\|/\|y\| ≤ 2 ⇒ diagonal | +| `VOL_MIN_DIST` `0x7caeac` | 5.0 | m — attenuation knee | +| `VOL_MIN_DIST_SQ` `0x86f404` | 25.0 | m² (runtime-initialised `5f*5f`) | +| `LandDefs::square_length` `0x799128` | 24.0 | m per land cell | +| rand normaliser `0x7caf50` | 3.0518509e-05 | = 1/32768 (MSVC `rand()` range) | + +`LandDefs::heading(Direction)` — jump table at `0x5a9a7c`, radians: + +| Direction | value | heading | +|---|---|---| +| `IN_VIEWER_BLOCK` 0 (and out of range) | 0.0 | — | +| `NORTH_OF_VIEWER` 1 | 0.0 | 0° | +| `SOUTH_OF_VIEWER` 2 | 3.14159274 | 180° | +| `EAST_OF_VIEWER` 3 | 1.57079637 | 90° | +| `WEST_OF_VIEWER` 4 | 4.71238899 | 270° | +| `NORTHWEST_OF_VIEWER` 5 | 5.49778700 | 315° | +| `SOUTHWEST_OF_VIEWER` 6 | 3.92699075 | 225° | +| `NORTHEAST_OF_VIEWER` 7 | 0.78539819 | 45° | +| `SOUTHEAST_OF_VIEWER` 8 | 2.35619450 | 135° | + +--- + +## 3. Q1 — ConstantSound vs IntermitSound + +| | **ConstantSound** (`base_chance == 0`) | **IntermitSound** (`base_chance != 0`) | +|---|---|---| +| Volume | **crossfaded**: `volume × sound_count / total` | **fixed** at authored `volume` | +| Trigger probability | none — `PlayNow` is a folded `mov eax,1; ret` → **always** | `RollDice(0,1) ≤ play_chance` | +| `play_chance` | n/a | `base_chance × sound_count / total` | +| Re-fire interval | **fixed** `min_rate` s | `RollDice(min_rate, max_rate)` s | +| Position | **none** — base `GetSoundPos` is `xor eax,eax; ret 4` → returns 0 ⇒ `PlayAmbientSoundFromCenter` (non-positional) | random compass bearing + distance ⇒ `PlayAmbientSound` (3D) | +| Audibility | `current_volume ≥ 0.03` **and** `desc->sound_table != null` | `play_chance > 0` | +| Direction tracking | none (`AddTo` only accumulates weight) | accumulates up to 8 `(dir, min_dist, max_dist)` slots | +| Looping? | **NO.** Re-fired one-shot every `min_rate` s. | one-shot | + +### `ConstantSound::UpdateSound` (`0x551540`) — verbatim + +``` +0x551540 fld [ecx+8] ; sound_count +0x551543 fcomp [0x795344] ; = 0.0 +0x55154b test ah, 0x44 ; equal test +0x55154e jp .compute + current_volume = 0.0f; return; // sound_count == 0 +.compute: + desc->play_count++; + current_volume = desc->ambient_sounds[id]->volume // [eax+8] + / total_sound_count // [esp+4] = arg + * sound_count; // [ecx+8] +``` + +```csharp +void UpdateSound(float total) // ConstantSound +{ + if (sound_count == 0f) { current_volume = 0f; return; } + desc.play_count++; + current_volume = desc.ambient_sounds[id].volume / total * sound_count; +} +``` + +### `IntermitSound::UpdateSound` (`0x551310`) — verbatim + +``` +0x551310 fld [ecx+8] ; sound_count +0x551313 fcomp [0x795344] ; = 0.0 +0x55131b test ah, 0x41 ; below|equal +0x55131e jne .skip ; sound_count <= 0 → leave play_chance alone + desc->play_count++; + play_chance = desc->ambient_sounds[id]->base_chance // [eax+0xC] + / total_sound_count + * sound_count; +.skip: +``` + +```csharp +void UpdateSound(float total) // IntermitSound +{ + if (sound_count <= 0f) return; // NOTE: does NOT zero play_chance + desc.play_count++; + play_chance = desc.ambient_sounds[id].base_chance / total * sound_count; +} +``` + +**Gotcha:** the intermittent path never clears `play_chance`. The only +zeroing is `IntermitSound::ResetCount`, which `Ambient::InitSounds` calls on +*every* rebuild before the accumulation pass. Get that ordering wrong and a +stale bearing/chance survives a cell change. + +```csharp +void ResetCount() // IntermitSound (0x550cd0) +{ desc.play_count = 0; sound_count = 0f; num_dir = 0; play_chance = 0f; } + +void ResetCount() // ConstantSound (0x550d70) +{ desc.play_count = 0; sound_count = 0f; } // NOTE: current_volume NOT reset +``` + +--- + +## 4. Q2 — `CanHear`: the audibility test + +Both are **pure state tests** — no distance test, no cell/indoor test, no +time-of-day test. Distance and indoor-ness enter earlier, through the weight +accumulation (`Ambient::AddSound` culls at 120 m; indoors nothing is added +at all, so all weights are 0). + +``` +IntermitSound::CanHear 0x550f80 + fld [ecx+0x18] ; play_chance + fcomp [0x795344] ; 0.0 + test ah, 0x41 ; below|equal + jne → return 0 + return 1 +⇒ return play_chance > 0.0f; + +ConstantSound::CanHear 0x550fd0 + call vtable[+4] ; GetVolume() = current_volume + fcomp [0x81f158] ; ambient_sound_min_vol = 0.03 + test ah, 5 ; below + jp → .check ; (NOT below → continue) + return 0 ; (below → inaudible) +.check: + return desc->sound_table != nullptr; +⇒ return current_volume >= 0.03f && desc.sound_table != null; +``` + +The 0.03 floor is the only "silence" threshold in the system: a constant +ambient whose terrain share drops below 3% of the total stops being +scheduled entirely. In dB that is ceil(20·log₁₀(0.03)) = **−30 dB**. + +`AmbientSound` base defaults (COMDAT-folded stubs, all byte-verified): + +| slot | folded symbol | actual code | effect | +|---|---|---|---| +| `ResetCount` | `IDClass::~IDClass` `0x694750` | `ret` | no-op | +| `GetVolume` | `MediaDesc::GetDuration` `0x69ce00` | `fld [0.0]; ret` | 0.0f | +| `CanHear` | `Client::You_Must_Not_…` `0x508960` | `xor eax,eax; ret` | 0 | +| `PlayNow` | (ConstantSound slot `0x7cb0f0`) `FileNodeName_UInt32::GetType` `0x5269f0` | `mov eax,1; ret` | **1 — always play** | +| `GetPlayInterval` | `MediaDesc::GetDuration` | `fld [0.0]; ret` | 0.0f | +| `AddTo` / `UpdateSound` | folded | `ret` | no-op | +| `GetSoundPos` | `DBOCache::GetCollection` `0x4f0ea0` | `xor eax,eax; ret 4` | **0 — "no position"** | + +The two that matter are `ConstantSound`'s inherited `PlayNow` (always true) +and inherited `GetSoundPos` (returns 0 ⇒ non-positional). Do not read the +BN vtable dump's symbol names as semantics — they are unrelated functions +that happened to fold to the same bytes. + +--- + +## 5. Q3 — `GetVolume`: how ambient volume is computed + +```csharp +float ConstantSound.GetVolume() => current_volume; // 0x550d80 +float IntermitSound.GetVolume() => desc.ambient_sounds[id].volume; // 0x551070, fld [eax+8] +``` + +That is the *only* ambient-specific volume. The full chain to the mixer: + +``` +ConstantSound: + v0 = authoredVolume * (sound_count / total_sound_count) // crossfade + v1 = v0 * ambient_sound_volume // PlayAmbientSoundFromCenter (0x5508cf) + v2 = GetAttenuation(dist = 0, v1, out mB, isAmbient = 1) + → no distance falloff (dist < 5 m knee) + → clamp v ≤ 1.0 + → v *= ambient_sound_volume ← *** APPLIED A SECOND TIME *** + → mB = (int)ceil(20*log10(v)); reject if < VOL_MIN + PlaySoundInternal(buf, null, mB) // no 3D pan + +IntermitSound: + v0 = authoredVolume // NOT crossfaded + v1 = v0 * ambient_sound_volume // PlayAmbientSound (0x55083b) + PlaySoundInternal(buf, pos, v1, isAmbient = 1) + → heading/pan from Position::heading vs listener heading + → dist = Position::distance(pos, listener) + → GetAttenuation(dist, v1, out mB, 1) + if (dist > 5.0f) v = v1 * 25.0f/(dist*dist); else v = v1 + clamp v ≤ 1.0 + v *= ambient_sound_volume ← *** SECOND TIME AGAIN *** + mB = (int)ceil(20*log10(v)) +``` + +**Divergence-register-worthy retail quirk:** `ambient_sound_volume` is +applied **twice** on every ambient — once in `PlayAmbientSound[FromCenter]` +and again inside `GetAttenuation(…, arg4 != 0)`. At the default 1.0 this is +invisible; at a 0.5 slider ambients are 0.25×, i.e. the slider is +effectively squared. A faithful port must reproduce this or record it as an +intentional divergence. + +`GetAttenuation` (`0x550020`), byte-exact: + +``` +0x550020 fld [esp+4] ; dist +0x550024 fcomp [0x7caeac] ; 5.0f +0x55002c test ah, 5 ; jp .far ; dist < 5 → v = volume +0x550031 fld [esp+8] ; (near path) +.far: fld [0x86f404] ; VOL_MIN_DIST_SQ = 25.0 + fmul [esp+8] ; * volume + fld [esp+4]; fmul [esp+4] ; dist*dist + fdivp ; v = 25*volume/dist² +.clamp: fcom qword [0x7928c0] ; 1.0 + test ah,0x41; jne .keep; v = 1.0 + fmul (arg4 ? ambient_sound_volume : effect_sound_volume) + fcom 0.0; if (v <= 0) { *out = VOL_MIN; return 0; } + fldln2; fyl2x; fmul C1; fmul C2; ceil; ftol ; → integer dB + if (*out < VOL_MIN) { *out = VOL_MIN; return 0; } + return 1 +``` +`SoundManager::SetVolume` later multiplies by 100 → DirectSound millibels. + +There is **one further gate** inside `PlayAmbientSound` that our earlier doc +missed entirely — a second, independent probability roll against the +**SoundTable entry's own** `probability_`: + +``` +0x550861 mov eax, [esp+0x14] ; SoundData.probability_ (SoundData+8) +0x550869 call [rand] +0x550873 fild ; fmul [0x7caf50] ; rand()/32768 +0x55087d fcomp [esp+8] ; vs probability_ +0x550883 test ah,5 ; jp .skip + PlaySoundInternal(...) +⇒ plays only if (rand()/32768.0f) < SoundData.probability_ +``` +and `SoundManager::GetSound` (`0x550680`) itself picks a **random entry** +from the sound table's `SoundTableData` for that `SoundType`: + +```csharp +if (table != null && table.Lookup(stype, out var td) && td.num_stdatas_ > 0) { + int i = (int)(RollDice(0,1) * td.num_stdatas_); // uniform pick + if (i < td.num_stdatas_) { + data.sound_id_ = td[i].Id; data.priority_ = td[i].Priority; + data.probability_ = td[i].Probability; data.volume_ = td[i].Volume; + buf = sound_hash_.find(data.sound_id_); + } +} +``` +Note `SoundData.volume_` is **loaded but never used** on the ambient paths — +the ambient's own volume wins. + +So an intermittent ambient fires only when **both** rolls pass: +`RollDice(0,1) ≤ play_chance` **and** `rand()/32768 < SoundData.probability_`. + +--- + +## 6. Q4 — `GetSoundPos`: where an ambient is positioned + +* **ConstantSound** — inherits the base stub → returns **0** ⇒ + `PlayAmbientSoundFromCenter`, i.e. **no position at all**, no pan, no + distance attenuation. It is a stereo bed centred on the listener. +* **IntermitSound** (`0x551350`) — offsets the **listener's own Position** + (`SoundManager::player_position_`, copied in `Ambient::Play`) in the XY + plane, keeping the listener's Z and objcell_id: + +```csharp +int GetSoundPos(ref Position pos) // 0x551350 +{ + int idx = (int)Math.Floor(RollDice(0f, (float)num_dir)); // pick one accumulated dir + var dir = sound_dir[idx]; + + const float spread = 0.392699093f; // π/8 rad = 22.5° + float angle = LandDefs.Heading(dir) // radians, N=0 CW + + RollDice(0f, spread) + - spread * 0.5f; // ⇒ ±11.25° jitter + + float min = min_dist[idx], max = max_dist[idx]; + float t = RollDice(0f, 1f); + float dist = min + (max - min) * t * t; // t² — biased toward `min` + + pos.frame.origin.x += MathF.Sin(angle) * dist; + pos.frame.origin.y += MathF.Cos(angle) * dist; + // pos.frame.origin.z unchanged (0x55143d re-stores the saved z) + // pos.objcell_id unchanged (the listener's cell) + return 1; +} +``` +`fsin`/`fcos` on `angle` with `x += sin`, `y += cos` is AC's standard +compass convention (N = +Y, E = +X). + +The `(min_dist, max_dist)` pairs come from `AddTo`/`AddDir`: + +```csharp +void AddTo(float weight, in Vector3 offset, LandDefs.Direction dir) // 0x551450 +{ + const float half = 20.0f * 0.5f; // ambient_sound_min_dist * 0.5 = 10 m + float dist = MathF.Sqrt(offset.LengthSquared()); // 0x551486 fsqrt — byte-verified + sound_count += weight; + + if (dir != LandDefs.Direction.IN_VIEWER_BLOCK) { + AddDir(dir, dist - half, dist + half); // a 20 m-thick shell at that bearing + return; + } + // source is within 14.14 m of the listener: it could be anywhere around them + foreach (var d in new[]{ NORTH, SOUTH, EAST, WEST, + NORTHWEST, SOUTHWEST, NORTHEAST, SOUTHEAST }) + AddDir(d, 4.0f, half); // 4 m .. 10 m in all 8 directions +} + +void AddDir(LandDefs.Direction dir, float min, float max) // 0x550cf0 +{ + int i = IndexOf(sound_dir, 0, num_dir, dir); // linear scan + if (i == num_dir) { // append + sound_dir[i] = dir; max_dist[i] = max; min_dist[i] = min; num_dir++; + return; + } + if (min < min_dist[i]) min_dist[i] = min; // 0x550d41 test ah,5 → strict below + if (max > max_dist[i]) max_dist[i] = max; // 0x550d58 test ah,0x41 → strict above +} +``` +`num_dir` can never exceed 8 (either one of dirs 1–8, or all eight from the +IN_VIEWER_BLOCK expansion), so the fixed arrays are safe. + +`Ambient::CalcDir` (`0x550e40`) — byte-exact classification of the +listener→source offset: + +```csharp +LandDefs.Direction CalcDir(in Vector3 v) +{ + float ax = MathF.Abs(v.x), ay = MathF.Abs(v.y); + float d2 = v.x*v.x + v.y*v.y; // XY only — Z ignored + if (d2 < 200.0f) return IN_VIEWER_BLOCK; // min_dist_sq*0.5 ⇒ 14.142 m + if (ax < 0.0002f) goto NS; // degenerate x + if (ay / ax > 2.0f) goto NS; // predominantly N/S + if (ay < 0.0002f) goto EW; // degenerate y + if (ax / ay > 2.0f) goto EW; // predominantly E/W + // both ratios <= 2 → diagonal quadrant + return v.x >= 0 ? (v.y >= 0 ? NORTHEAST : SOUTHEAST) + : (v.y >= 0 ? NORTHWEST : SOUTHWEST); +EW: return v.x < 0 ? WEST : EAST; +NS: return v.y < 0 ? SOUTH : NORTH; +} +``` +Geometrically: an 8-way compass rose where each cardinal owns the wedge +outside a 2:1 slope ratio and each diagonal owns the 2:1..1:2 band — +cardinals get ~53° each, diagonals ~37° each. + +`Ambient::CalcWeight` (`0x550dd0`): + +```csharp +float CalcWeight(in Vector3 v) +{ + float d2 = v.x*v.x + v.y*v.y + v.z*v.z; + if (d2 > 14400.0f) return 0.0f; // > 120 m → cull + if (d2 < 400.0f) return 1.0f; // < 20 m → full weight + return 400.0f / d2; // (20/d)² inverse-square +} +``` +At the 120 m cull edge the weight is 400/14400 = 0.0278. + +--- + +## 7. Q5 — `GetPlayInterval`: the re-trigger cadence + +```csharp +float IntermitSound.GetPlayInterval() // 0x551080 + => RollDice(desc.ambient_sounds[id].min_rate, // [eax+0x10] + desc.ambient_sounds[id].max_rate); // [eax+0x14] + +float ConstantSound.GetPlayInterval() // 0x5510a0 + => desc.ambient_sounds[id].min_rate; // [eax+0x10] only — max_rate unused +``` + +`Random::RollDice(min, max)` (`0x42c600`), byte-exact: + +```csharp +static float RollDice(float min, float max) +{ + if (min == max) return min; + float lo = min, hi = max; + if (max < min) { lo = max; hi = min; } // 0x42c634: swap on inverted range + float r = UniformUnit(); // call 0x42c4c0 → [0,1) + return lo + (hi - lo) * r; +} +``` + +Units are **seconds**; the deadline is absolute (`Timer::cur_time` is a +`double` seconds clock) and inserted into a min-heap. + +For a "continuous" ambient, `min_rate` is effectively the **loop period** the +content author chose for that wave. That is how retail fakes a loop without +a looping voice — and it is why a naive `AL_LOOPING` port sounds wrong +(no re-randomised table pick, no re-rolled crossfade volume, no gap). + +--- + +## 8. Q6 — who ticks these, and in what order + +`SmartBox::UseTime` (`0x455410`) is the per-frame game tick. Exact order: + +``` +if (!cell_manager->blocking_for_cells) { + if (!all_cells_available && CheckPrefetchStatus()) UpdateLoadPoint(); + if (player && player->m_position.objcell_id) + CellManager::ChangePosition(&player->m_position, /*blocking*/0); // ← ambient REBUILD + ...position_update_complete / has_been_teleported bookkeeping... + CObjectMaint::UseTime(); + CPhysics::UseTime(); + if (GameTime::current_game_time) { GameTime::UseTime(); LScape::UseTime(); } + Ambient::UseTime(ambient_sounds); // ← ambient PLAYBACK (last) +} else CheckPrefetchStatus(); +SceneTool::Think(); +...inbound netblob drain... +``` + +So: cell/streaming first, then object maintenance, then physics, then +game-time/sky, then ambients **last** in the pre-network block. `ChangePosition` +is called every frame but only does work when the objcell changed. + +```csharp +void Ambient.UseTime() // 0x551880 +{ + if (!SoundManager.ambient_sounds_enabled) return; + while (sound_queue.curNumNodes > 0) { + var node = sound_queue.A; // heap root = earliest deadline + if (node == null) break; + if (!(node.key < Timer.cur_time)) break; // 0x5518bc test ah,1 → strict below + sound_queue.RemoveMin(out _, out AmbientSound s); + Play(s); // plays AND re-inserts + } +} + +void Ambient.UpdatePlayQueue() // 0x551a50 +{ + if (!SoundManager.ambient_sounds_enabled) return; + for (int i = 0; i < num_sounds; i++) { + var s = sounds[i]; + s.UpdateSound(total_sound_count); // recompute volume / chance + if (s.on_queue == 0) Play(s); // (re)arm — first play is IMMEDIATE + } +} + +void Ambient.Play(AmbientSound s) // 0x5517a0 +{ + Position pos = SoundManager.player_position_; // copy (objcell_id + frame) + if (!s.CanHear()) { s.on_queue = 0; return; } // ← drops out of the heap, no re-arm + if (s.PlayNow()) { + bool positioned = s.GetSoundPos(ref pos) != 0; + var stype = s.desc.ambient_sounds[s.ambient_sound_id].stype; + var table = s.desc.sound_table; + if (positioned) SoundManager.PlayAmbientSound(stype, table, pos, s.GetVolume()); + else SoundManager.PlayAmbientSoundFromCenter(stype, table, s.GetVolume()); + } + sound_queue.Insert(Timer.cur_time + s.GetPlayInterval(), s); + s.on_queue = 1; +} +``` + +Two behaviours worth calling out: + +* **`UpdatePlayQueue` arms `on_queue == 0` sounds immediately** — a newly + audible ambient fires on the frame you cross into range, then schedules. + There is no initial random delay. +* **`CanHear() == false` un-arms and does not reschedule.** An ambient that + goes inaudible silently leaves the heap; it can only come back on the next + `UpdatePlayQueue`, i.e. the next objcell change. Already-queued sounds + that stay audible are *not* re-armed (the `on_queue == 0` guard), so their + cadence carries smoothly across cell boundaries — no restart click. + +--- + +## 9. Q7 — day/night / time-of-day: **there is none** + +Definitively refuted. `Ambient::AddSound` has exactly one caller in the +binary (`grep` over the full 1.4 M-line pseudo-C): + +``` +314293:005303ff Ambient::AddSound(arg2, eax_7, &var_48); ← CLandBlock::add_ambient_sounds +``` + +and the STB desc it passes comes from a pure static lookup: + +```csharp +AmbientSTBDesc CRegionDesc.GetSTBDesc(uint terrainType, uint sceneIdx) // 0x4feab0 +{ + var d = terrain_info.GetSTBDesc(terrainType, sceneIdx); + if (d == null) return null; + if (d.sound_table == null) d.InitSoundTable(); // lazy DBObj::Get(stb_id, type 0x22) + return (d.sound_table != null) ? d : null; +} + +AmbientSTBDesc CTerrainDesc.GetSTBDesc(uint t, uint s) // 0x502400 +{ + if (t >= terrain_types.m_num) return null; + var tt = terrain_types[t]; // CTerrainType + if (s >= tt.scene_types.m_num) return null; // == NumSceneType(t) + var st = tt.scene_types[s]; // CSceneType + return st?.sound_table_desc; // CSceneType + 0x10 +} +``` + +`GameTime`, `SkyDesc.present_day_group`, `DayGroup`, `SkyTimeOfDay`, and the +weather/fog descs are **never** consulted. `DayGroup` carries only +`day_name / chance_of_occur / sky_time / sky_objects` — sky visuals only. + +`AmbientSTBDesc::InitSoundTable` (`0x4fea60`): + +```csharp +bool InitSoundTable() { + if (stb_not_found != 0) return false; + if (stb_id == INVALID_DID) return false; + sound_table = DBObj.Get(new QualifiedDataID(stb_id, 0x22)); // 0x22 = SoundTable + if (sound_table != null) return true; + stb_not_found = 1; // negative cache, never retried + return false; +} +``` + +### Where the STB desc actually lives in `region.dat` + +`CRegionDesc::sound_info` (`CSoundDesc`) is the **storage**; the terrain / +scene tables are the **selector**. Region unpack (`0x4ff746`) resolves it: + +``` +for each CSceneType: + uint32 stbIndex = read(); + sceneType->sound_table_desc = (stbIndex != 0xFFFFFFFF) + ? soundDesc->stb_desc[stbIndex] + : nullptr; + CSceneType::unpack(...) // scene_name + scene DIDs +``` + +Which maps **exactly** onto the model our `DatReaderWriter` package already +exposes (verified: `SoundDesc`, `AmbientSTBDesc`, `AmbientSoundDesc`, +`SceneType.StbIndex`, `Region.SoundInfo` are all present in +`chorizite.datreaderwriter/1.0.0`): + +``` +Region (0x13000000) +├─ SoundInfo.STBDesc[] : AmbientSTBDesc { STBId, AmbientSounds[] } +├─ SceneInfo.SceneTypes[] : SceneType { StbIndex, Scenes[] } +└─ TerrainInfo.TerrainTypes[] : TerrainType { TerrainName, TerrainColor, SceneTypes[] } + +resolve(terrainWord): + terrainType = (terrainWord >> 2) & 0x1F + sceneIdx = (terrainWord >> 11) & 0x1F + if (terrainType >= TerrainInfo.TerrainTypes.Count) → none + sceneTypeList = TerrainInfo.TerrainTypes[terrainType].SceneTypes + if (sceneIdx >= sceneTypeList.Count) → none // == NumSceneType + sceneTypeIdx = sceneTypeList[sceneIdx] + if (sceneTypeIdx >= SceneInfo.SceneTypes.Count) → none + stbIndex = SceneInfo.SceneTypes[sceneTypeIdx].StbIndex + if (stbIndex == 0xFFFFFFFF) → none + return SoundInfo.STBDesc[stbIndex] +``` + +**This is the identical walk `src/AcDream.Core/World/SceneryGenerator.cs` +lines 100–112 already performs for procedural scenery.** The ambient port +should reuse that exact decode (and the same `>> 2 & 0x1F` / `>> 11 & 0x1F` +bit fields) rather than re-deriving it. Note retail iterates only the **8×8 +land cells** (`side_cell_count`), reading the **SW vertex's** terrain word +from the 9×9 grid (`side_vertex_count` = 9, row stride `0x12` = 9 × 2 bytes) +— scenery iterates 9×9 vertices, ambients iterate 8×8 cells. Do not copy the +loop bounds. + +--- + +## 10. Q8 — cell/landblock transition: start & stop + +### The rebuild, from `CellManager::ChangePosition` (`0x4559b0`) + +```csharp +void ChangePosition(Position pos, int blocking) +{ + if (pos.objcell_id == 0) { Reset(); return; } // → Ambient::FlushSoundTables + int b = blocking_for_cells != 0 ? 1 : blocking; + if (load_pos.objcell_id != pos.objcell_id || curr_cell == null) + { + PreFetchCells(pos.objcell_id, b); + ... resolve curr_cell, master_incell_timestamp++, clear world lights ... + CEnvCell.flush_cells(); + if (curr_cell != null) + { + bool outdoors = (seenOutsideFlag || curr_cell.seen_outside != 0); + if (outdoors) { ...sunlight from LScape, SetWorldAmbientLight(calc_object_light) ... } + else { SetWorldAmbientLight(0.2f, 0xFFFFFFFF); } + + Ambient::InitSounds(ambient_sounds, pos); // 1. reset every count, latch listener + /* 0x455b0a: call 0x694750 — a folded empty `ret`. + This is where an indoor/EnvCell ambient contributor would have gone; + in the shipped 2013 build it does nothing. */ + if (outdoors) + LScape::add_ambient_sounds(lscape, ambient_sounds); // 2. accumulate + Ambient::UpdatePlayQueue(ambient_sounds); // 3. recompute + arm + Ambient::ReleaseSoundTables(ambient_sounds); // 4. free tables nobody used + } + } + load_pos = pos; +} +``` + +```csharp +void Ambient.InitSounds(Position pos) // 0x5515d0 +{ + player_pos = pos; // objcell_id + Frame copy + total_sound_count = 0f; + for (int i = 0; i < num_sounds; i++) sounds[i].ResetCount(); +} + +void Ambient.AddSound(AmbientSTBDesc desc, in Position at) // 0x551610 +{ + if (!SoundManager.ambient_sounds_enabled) return; + Vector3 off = player_pos.GetOffset(at); // block-corrected listener→source + float d2 = off.x*off.x + off.y*off.y + off.z*off.z; + if (d2 > 14400.0f) return; // 0x551658 test ah,0x41; je + float w = CalcWeight(off); + var dir = CalcDir(off); + if (w == 0.0f) return; // 0x551689 test ah,0x44; jnp + total_sound_count += w; + for (uint i = 0; i < desc.ambient_sounds.m_num; i++) + GetSound(desc, i).AddTo(w, off, dir); // creates the object on first use +} + +AmbientSound Ambient.GetSound(AmbientSTBDesc desc, uint id) // 0x5510b0 +{ + for (int i = 0; i < num_sounds; i++) + if (sounds[i].desc == desc && sounds[i].ambient_sound_id == id) return sounds[i]; + sounds.grow_check(num_sounds); + bool cont = desc.ambient_sounds[id].is_continuous != 0; + var s = cont ? (AmbientSound)new ConstantSound() // operator new(0x1C) + : new IntermitSound(); // operator new(0x80) + s.desc = desc; s.ambient_sound_id = id; s.on_queue = 0; s.sound_count = 0; + sounds[num_sounds++] = s; + return s; +} +``` + +### The contributors + +```csharp +void LScape.add_ambient_sounds(Ambient a) // 0x505810 +{ + for (int by = 0; by < mid_width; by++) + for (int bx = 0; bx < mid_width; bx++) { + get_block_orient(by, bx, out int lod, out _); + if (lod != 1) continue; // ← ring ≤ 1 only ⇒ 3×3 landblocks + land_blocks[mid_width*by + bx]?.add_ambient_sounds(a); + } +} +// get_block_orient (0x504f90): ring = max(|bx-mid_radius|, |by-mid_radius|) +// ring <= 1 → lod 1 ; <= 2 → 2 ; <= 4 → 4 ; else 8 + +void CLandBlock.add_ambient_sounds(Ambient a) // 0x530310 +{ + var p = new Position { objcell_id = 0, frame = Frame.Identity }; // Frame::cache + for (int row = 0; row < side_cell_count /*8*/; row++) + for (int col = 0; col < side_cell_count; col++) { + var v = vertex_array.vertices[side_vertex_count /*9*/ * row + col]; + p.frame.origin = new Vector3(v.x, v.y, v.z); // landblock-local + p.objcell_id = lcell[side_cell_count*row + col].id; // ← 0x5303C1, [esp+0x24] + ushort w = terrain[row*9 + col]; + uint t = (uint)((w >> 2) & 0x1F), s = (uint)(w >> 11); + if (s >= CRegionDesc.NumSceneType(current_region, t)) continue; + var desc = CRegionDesc.GetSTBDesc(current_region, t, s); + if (desc != null) a.AddSound(desc, p); + } +} +``` + +> **Decomp trap.** The BN pseudo-C shows `objcell_id` (`var_44`) set to 0 and +> never updated, which makes `Position::get_offset` look catastrophically +> broken (`LandDefs::get_block_offset(id1, 0)` returns garbage on the +> `id2 == 0` branch — it loads `id1`'s stack slot, not a zero). The +> disassembly shows BN mis-attributed the store: `0x5303C1 mov [esp+0x24], edx` +> writes the **land cell's own objcell_id** (`lcell[i] + 0x28`) into the +> Position before every `AddSound`. Anyone porting from the pseudo-C alone +> would conclude the whole outdoor path is dead code. + +`Position::get_offset` (`0x509f60`) then does the block correction properly: + +```csharp +Vector3 GetOffset(in Position target) { + var blk = LandDefs.get_block_offset(this.objcell_id, target.objcell_id); + return blk + target.frame.origin - this.frame.origin; +} +// get_block_offset (0x43e630): 0 if same landblock; else +// (bx2 - bx1) * 24.0f in x, (by2 - by1) * 24.0f in y, 0 in z +// where bx = ((id >> 24) & 0xFF) * 8, by = ((id >> 16) & 0xFF) * 8 +// (i.e. land-cell units × square_length 24 m ⇒ 192 m per landblock) +``` + +### The teardown + +```csharp +void Ambient.ReleaseSoundTables() // 0x455770 (end of every rebuild) +{ + for (int i = 0; i < num_sounds; i++) { + var d = sounds[i].desc; + if (d.sound_table != null && d.play_count == 0) { + d.sound_table.Release(); // vtable +0x14 + d.sound_table = null; // → CanHear() false until reloaded + } + } +} + +void Ambient.FlushSoundTables() // 0x452920 (CellManager::Reset) +{ + total_sound_count = 0f; + for (int i = 0; i < num_sounds; i++) { + sounds[i].ResetCount(); + var d = sounds[i].desc; + if (d.sound_table != null && d.play_count == 0) { d.sound_table.Release(); d.sound_table = null; } + } +} + +void Ambient.Destroy() // 0x551580 (~Ambient, world exit) +{ for (...) delete sounds[i]; num_sounds = 0; total_sound_count = 0f; } +``` + +Key semantics: **`play_count` is the "was this audible during this cell" flag.** +`UpdateSound` increments it whenever `sound_count > 0` (intermittent) or +`sound_count != 0` (constant); `ResetCount` zeroes it at the start of every +rebuild. So the wave/sound-table memory for an ambient you just walked out of +range of is released on the very next cell change — a per-cell LRU of exactly +one generation. + +Note also that neither `FlushSoundTables` nor `ReleaseSoundTables` clears the +`AmbientSound` list or the heap. `CellManager::Reset` (objcell_id → 0, i.e. +logout / pending teleport) leaves stale deadlines in the queue; they fire, +`CanHear()` returns false (counts were reset), and they quietly un-arm. The +list itself is only freed by `~Ambient`, so `sounds[]` grows monotonically to +the set of every `(STBDesc, index)` pair the session ever visited — and +`UpdatePlayQueue` iterates all of them on every cell change. + +--- + +## 11. Full lifecycle, cell load → audible → stopped + +``` +[frame N] SmartBox::UseTime + CellManager::ChangePosition(playerPos) + objcell_id unchanged → nothing (the common case) + +[frame M] player crosses a 24 m land-cell boundary + CellManager::ChangePosition + PreFetchCells; resolve curr_cell; lights + Ambient::InitSounds(playerPos) + player_pos = playerPos; total = 0 + ∀ sounds: ResetCount() (play_count=0, sound_count=0, + num_dir=0, play_chance=0) + [indoor? → nothing added: the only contributor is gated on `outdoors`] + LScape::add_ambient_sounds + ∀ landblock in the 3×3 ring (lod == 1) + ∀ 64 land cells + terrainWord → (terrainType, sceneIdx) + → TerrainType.SceneTypes[sceneIdx] + → SceneDesc.SceneTypes[..].StbIndex + → SoundDesc.STBDesc[..] (AmbientSTBDesc) + → InitSoundTable() lazily loads SoundTable DID (type 0x22) + Ambient::AddSound(desc, cellPosition) + off = playerPos.GetOffset(cellPosition) // block-corrected + if |off|² > 14400 (120 m) → skip + w = 1 (<20 m) | 400/|off|² | 0 + dir = 8-way compass, or IN_VIEWER_BLOCK if |off_xy|² < 200 (14.14 m) + total += w + ∀ AmbientSoundDesc in desc: + GetSound(desc, i) // create on first use: + // base_chance==0 → ConstantSound + // else → IntermitSound + .AddTo(w, off, dir) + sound_count += w + (IntermitSound only) AddDir merge: + dir != IN_VIEWER_BLOCK → (|off|-10, |off|+10) at that bearing + dir == IN_VIEWER_BLOCK → (4, 10) at all 8 bearings + Ambient::UpdatePlayQueue + ∀ sounds: + UpdateSound(total) + ConstantSound : current_volume = volume * sound_count/total (0 if count==0) + IntermitSound : play_chance = base_chance* sound_count/total (skip if count<=0) + if audible: desc.play_count++ + if on_queue == 0 → Play(s) // FIRES IMMEDIATELY + Ambient::ReleaseSoundTables + ∀ sounds with desc.play_count == 0 → release + null the SoundTable + +Ambient::Play(s): + pos = SoundManager::player_position_ + if !s.CanHear() → on_queue = 0; RETURN (leaves the heap) + ConstantSound : current_volume >= 0.03 && sound_table != null + IntermitSound : play_chance > 0 + if s.PlayNow() + ConstantSound : always true + IntermitSound : RollDice(0,1) <= play_chance + positioned = s.GetSoundPos(ref pos) + ConstantSound : 0 → PlayAmbientSoundFromCenter(stype, table, GetVolume()) + IntermitSound : 1 → pos offset to a random accumulated bearing ±11.25°, + distance min + (max-min)·t², listener Z + cell kept + → PlayAmbientSound(stype, table, pos, GetVolume()) + both then: volume *= ambient_sound_volume + GetSound(stype, table) → random SoundTableData entry + if rand()/32768 >= entry.probability_ → SILENT this fire + GetAttenuation(dist, vol, out mB, isAmbient=1) + dist > 5 m → vol *= 25/dist²; clamp 1.0 + vol *= ambient_sound_volume (SECOND application) + mB = ceil(20·log10(vol)); reject below VOL_MIN + PlaySoundInternal(buf, pos|null, mB) + sound_queue.Insert(Timer::cur_time + s.GetPlayInterval(), s) + ConstantSound : min_rate (fixed) + IntermitSound : RollDice(min_rate, max_rate) + on_queue = 1 + +[every frame] Ambient::UseTime + while (heap.root.key < Timer::cur_time) Play(RemoveMin()) + +STOP paths: + • terrain share drops → volume < 0.03 / play_chance == 0 → CanHear false → un-armed + • walk indoors → nothing accumulated → all counts 0 → all un-armed next cell change + • > 120 m from every contributing cell → weight 0 → same + • CellManager::Reset (objcell_id 0: logout / teleport pending) → FlushSoundTables + • ~Ambient → Destroy (frees the objects) + NOTE: nothing ever *stops a playing voice*. Every ambient is a one-shot; + "stopping" just means it is never scheduled again. +``` + +--- + +## 12. Corrections to `docs/research/deepdives/r05-audio-sound.md` §7 + +Our existing ambient section is directionally right but wrong on almost +every mechanism. Concretely: + +| r05 §7 claim | Reality | +|---|---| +| "queries `terrainType` for each corner of the current cell and **picks the dominant** `AmbientSTBDesc` by STBId" | No dominance selection. It iterates **8×8 land cells across a 3×3 landblock ring** (576 cells) and **accumulates weights into every** STB desc it finds. STBId is a SoundTable DID, not a selector. | +| "STBId is indexed by terrain type or region-specific rule" | The selector chain is `terrainWord → (terrainType, sceneIdx) → TerrainType.SceneTypes[] → SceneDesc.SceneTypes[].StbIndex → SoundDesc.STBDesc[]`. Same walk `SceneryGenerator.cs` already does. | +| "If `BaseChance == 0` → **continuous loop on a dedicated voice**" | No loops, no dedicated voices. A one-shot re-fired every `min_rate` s, **non-positional**, at volume `authored × share`. | +| "roll `rand() < BaseChance`" | The chance is `base_chance × sound_count / total_sound_count`, not `base_chance`. Plus a **second** independent roll against the SoundTable entry's `probability_`. | +| "positioned near the listener (at a small random offset)"; code sketch `listenerPos + rng.InUnitSphere() * 8f` | Random pick among up to 8 **accumulated compass bearings**, jittered ±11.25° (π/16), distance `min + (max−min)·t²` where the (min,max) shell is 4–10 m for in-block sources and `d±10 m` for neighbours. **Z is never offset** — same plane as the listener. | +| "every `N` seconds where `N = rand()` in `[MinRate, MaxRate]`" | Correct for intermittent. **Wrong for continuous**, which uses `min_rate` only. | +| "On landblock change … stop all ambient voices associated with the outgoing STBId and start new ones" | Rebuild trigger is **any objcell change** (24 m outdoors), not landblock. Nothing is stopped; already-armed audible sounds keep their cadence (the `on_queue == 0` guard), which is what prevents a restart click at every cell crossing. | +| §7.1 "`RegionDesc` contains a `SoundDesc` field (when `PartsMask & 0x01`)" — implies SoundDesc *is* the selection | Correct as **storage**. It is never the runtime *selector*; `CRegionDesc::sound_info` is only ever touched by pack/unpack/GetSubDataIDs. | +| — (not mentioned) | **Indoors is silent.** No EnvCell ambient contributor exists; the slot in `ChangePosition` is a folded empty `ret`. | +| — (not mentioned) | **`ambient_sound_volume` is applied twice** (in `PlayAmbient*` and again in `GetAttenuation`), so the slider is effectively squared. | +| — (not mentioned) | Final volume is quantised to **integer dB** (`ceil(20·log10 v)`) and floored at `VOL_MIN`. | + +--- + +## 13. Port notes for acdream + +Current state: `OpenAlAudioEngine.StartAmbient` (`src/AcDream.App/Audio/OpenAlAudioEngine.cs:367`) +only mints a handle; there is no ambient system. `StopAmbient` exists and +works against `_ambientSources`. `grep` finds no `AmbientSTBDesc`/`SoundDesc` +consumer anywhere in `src/`. + +**The retail model does not need `StartAmbient` at all.** Every ambient is a +one-shot. The right shape is: + +* A `RuntimeAmbientState` owner (Runtime layer, per Slice-J ownership rules) + holding `player_pos`, `total_sound_count`, the `AmbientSound` list, and a + `PriorityQueue` of absolute deadlines. +* Rebuild hook on the existing **objcell-change** signal — the same edge + `ACDREAM_PROBE_CELL` / `PlayerMovementController.CellId` already fires on. + Not on landblock streaming events. +* Contributor that reuses `SceneryGenerator`'s terrain-word decode but + iterates **8×8 cells** (SW vertex per cell) over the **3×3 landblock ring**, + not 9×9 vertices over the streaming window. +* Playback through the existing `Play3D` / one-shot path plus a + non-positional variant for constant sounds. `SoundTable` lookup already + exists (`AudioHookSink.PlayFromSoundTable` / `IEntitySoundTable`), and + `SoundManager::GetSound`'s random-entry + `probability_` roll must be + reused, not bypassed. +* Data is already available: `chorizite.datreaderwriter` exposes + `Region.SoundInfo.STBDesc`, `AmbientSTBDesc.{STBId, AmbientSounds}`, + `AmbientSoundDesc.{SType, Volume, BaseChance, MinRate, MaxRate}`, and + `SceneType.StbIndex`. **No new dat parser is required.** + +Ordering and edge cases that will bite (each already caused a retail-shaped +bug class elsewhere in this codebase): + +1. `ResetCount` **must** run for every existing sound before accumulation. + `IntermitSound::UpdateSound` never clears `play_chance`, so a missed reset + leaves a stale bearing and a stale probability alive indefinitely. +2. The `on_queue == 0` guard in `UpdatePlayQueue` is load-bearing. Re-arming + unconditionally restarts every ambient on every 24 m crossing — audible as + a machine-gun of one-shots. Re-arming never (e.g. only on landblock + change) makes newly-audible ambients silent until the next landblock. +3. Weight normalisation is by `total_sound_count`, the sum over **all** + ambients, not per-desc. Getting the denominator wrong changes the + crossfade, not just the level. +4. `CalcDir`'s IN_VIEWER_BLOCK threshold is `min_dist_sq * 0.5` = **200 m²** + (14.142 m), *not* `min_dist` (20 m). It is the only place that `× 0.5` + appears on the squared value. +5. `GetSoundPos`'s distance is `min + (max−min)·t²` — a quadratic bias + toward `min`. A linear lerp puts intermittent ambients audibly further + away on average. +6. Distance attenuation is `25/d²` past a **5 m** knee, clamped to 1.0, then + quantised to integer dB. An OpenAL `AL_INVERSE_DISTANCE_CLAMPED` model + with `AL_REFERENCE_DISTANCE = 5` and `AL_ROLLOFF_FACTOR = 1` is the same + curve; verify before substituting, per the WB-formula lesson. +7. The doubled `ambient_sound_volume` and the integer-dB quantisation are + both retail deviations from "obvious" behaviour. If we choose not to + reproduce them, each needs a row in + `docs/architecture/retail-divergence-register.md`. +8. Indoor silence is retail-correct. If the user's "incorrect ambient" + complaint includes "dungeons are too quiet", that is faithful — retail is + silent there too, and any indoor ambient we add is a **new feature**, not + a port, and needs a register row. + +Address citations for code comments (named symbol + address, per the +project's phase-completion checklist): + +``` +Ambient::AddSound 0x551610 +Ambient::InitSounds 0x5515d0 +Ambient::UpdatePlayQueue 0x551a50 +Ambient::Play 0x5517a0 +Ambient::UseTime 0x551880 +Ambient::GetSound 0x5510b0 +Ambient::CalcWeight 0x550dd0 +Ambient::CalcDir 0x550e40 +Ambient::PlaySoundA 0x550d90 +Ambient::FlushSoundTables 0x452920 +Ambient::ReleaseSoundTables 0x455770 +Ambient::Destroy 0x551580 +IntermitSound::CanHear 0x550f80 +IntermitSound::PlayNow 0x550fa0 +IntermitSound::GetVolume 0x551070 +IntermitSound::GetPlayInterval 0x551080 +IntermitSound::UpdateSound 0x551310 +IntermitSound::GetSoundPos 0x551350 +IntermitSound::AddTo 0x551450 +IntermitSound::AddDir 0x550cf0 +IntermitSound::ResetCount 0x550cd0 +ConstantSound::CanHear 0x550fd0 +ConstantSound::GetVolume 0x550d80 +ConstantSound::GetPlayInterval 0x5510a0 +ConstantSound::UpdateSound 0x551540 +ConstantSound::AddTo 0x551000 +ConstantSound::ResetCount 0x550d70 +AmbientSound base GetSoundPos 0x4f0ea0 (xor eax,eax; ret 4) +AmbientSound base PlayNow (CS) 0x5269f0 (mov eax,1; ret) +SoundManager::PlayAmbientSound 0x550820 +SoundManager::PlayAmbientSoundFromCenter 0x5508b0 +SoundManager::GetSound 0x550680 +SoundManager::GetAttenuation 0x550020 +SoundManager::PlaySoundInternal 0x550170 / 0x54fec0 +SoundManager::SetPlayerPosition 0x5503c0 +AmbientSTBDesc::UnPack 0x5518f0 +AmbientSTBDesc::InitSoundTable 0x4fea60 +CRegionDesc::GetSTBDesc 0x4feab0 +CTerrainDesc::GetSTBDesc 0x502400 +CTerrainDesc::NumSceneType 0x502430 +CLandBlock::add_ambient_sounds 0x530310 +LScape::add_ambient_sounds 0x505810 +LScape::get_block_orient 0x504f90 +CellManager::ChangePosition 0x4559b0 +CellManager::Reset 0x455930 +SmartBox::UseTime 0x455410 +LandDefs::heading 0x5a9a30 +LandDefs::get_block_offset 0x43e630 +Position::get_offset 0x509f60 +Random::RollDice 0x42c600 +``` diff --git a/docs/research/2026-08-08-audio-retail-dat-layer.md b/docs/research/2026-08-08-audio-retail-dat-layer.md new file mode 100644 index 00000000..73daa957 --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-dat-layer.md @@ -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` = 1× u32, then 3× f32) | ✅ exact order | +| child `numChildren` | `SoundData.Unknown` (i32) | ⚠️ **read and discarded** | + +**The one structural divergence: DRW is not recursive.** It assumes depth +exactly 2 and swallows the grandchild count as `Unknown`. A depth-3 table +would desynchronise the reader from that point on. Measured on the shipped +dats: **0 of 190 tables have grandchildren**, and `Unknown` is 0 everywhere, +so DRW is correct on retail content. It would silently mis-parse custom +content. Worth a comment in our tree, not a fix. + +DRW's own tests (`DatReaderWriter.Tests/DBObjs/SoundTableTests.cs`, +`WaveTests.cs`) are **write-then-read round-trips only** — they prove +Pack/Unpack agree with each other, not with retail. The retail-layout +evidence is (a) the `SoundTableData::UnPack` disassembly above and (b) my +independent Python parse of all 190 real tables. + +### 1c. What the real dats actually contain + +Scanned `client_portal.dat` (926 MB, 79,694 files). Waves and sound tables +live **only** in the portal dat (the "hits" in `client_cell_1.dat` at +`0x0A00FFFF`/`0x2000xxxx` are id-range collisions with cell ids, not audio). + +| Measurement | Value | +|---|---| +| Waves (`0x0A00xxxx`) | **786** | +| Sound tables (`0x2000xxxx`) | **190** | +| Distinct SoundTypes used across all tables | 123 | +| Root `num_stdatas_` | **always exactly 1, always `sound_id_ == 0`** (a dummy; retail skips it because `CreateSound` is gated on `id != 0`) | +| Per-SoundType entry counts | **`1` × 4,183 … and `2` × 1** | +| Tables with depth > 2 | 0 | + +The single multi-variant sound in the entire game: + +``` +table 0x200000A8, SoundType 31 (0x1F = Swoosh2), 2 entries: + wave 0x0A000519 priority 0.9 probability 1.0 volume 1.0 + wave 0x0A00051E priority 0.9 probability 1.0 volume 1.0 +``` + +This single fact reframes the whole "variation" story: **AC's per-object +sound variation is not driven by multi-entry lists.** The `Swoosh1/2/3`, +`Attack1/2/3`, `Wound1/2/3` *SoundType triples* are the variation mechanism; +the entry list under each type is a singleton. Our `SoundCookbook` doc +comment ("3 swoosh variants", "footsteps sound slightly different each +step") describes a mechanism that has exactly one instance in the shipped +data. + +Field value distributions across all 4,184 entries: + +| Field | Distribution | Verdict | +|---|---|---| +| `probability_` | 3,498 × 1.0; **686 entries < 1.0** — 0.7 (249), 0.8 (129), 0.6 (108), 0.9 (74), 0.05 (53), 0.5 (21), 0.1 (19), 0.75 (11), **0.0001 (6)**, 0.02/0.03/0.01/0.003/0.15/0.2/0.3/0.95 (tail) | **linear chance in [0,1]**, honoured per-play | +| `priority_` | float, 0.0 … 1.0. Mode 0.7 (2,315), then 0.9 (311), 0.95 (251), 0.8 (242), 0.75 (192), 0.3 (169), 0.0 (156), 1.0 (80) | **float [0,1]**, never an integer 0..7 | +| `volume_` | mostly ≤ 1.0, but **44 entries exceed it**: 10.0 (31), 5.0 (3), 4.0 (1), 3.0 (4), 2.0 (5), 1.3 (1) | **unbounded linear gain**, NOT a 0..1 multiplier | + +### 1d. Our side + +`AcDream.Core.Audio.SoundEntry` (`AudioModel.cs:21-31`) is **dead code** — +declared, documented, never constructed anywhere in `src/` or `tests/` +(same for `ISoundCache`). The production path uses +`DatReaderWriter.Types.SoundEntry` directly +(`AudioHookSink.PlayFromSoundTable`). But its comments have already leaked +into real code as facts: + +```csharp +public int Priority { get; init; } // eviction ordering (0..7) ← invented +public float Probability{ get; init; } // for entries with multiple alternatives ← half-true +public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff ← wrong +public float PitchMin / PitchMax ← no such retail fields +public bool Loop / Is3D ← not in SoundData; Is3D lives on SoundBuf +``` + +Consequences downstream (outside the strict DAT layer, but caused by it): + +* `OpenAlAudioEngine.cs:297` — `slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7)`. + Retail priority is a float in [0,1]; `(int)0.7f == 0`. **4,100+ of 4,184 + entries collapse to 0** and the 80 entries at exactly 1.0 collapse to 1. + Priority ordering is effectively destroyed. +* `AudioHookSink.cs:114` — `volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f)`. + Retail clamps **after** the distance division, never the raw field (§3). + Clamping the field flattens the 44 extended-range entries. + +--- + +## 2. Selection + probability — retail vs `SoundCookbook.Roll` + +### 2a. Retail, exactly + +`SoundManager::GetSound` @ `0x00550680`, hand-disassembled (BN dropped both +the multiply and the `-1`): + +``` +005506ae mov ecx,[eax+0x7c] ; n = num_stdatas_ +005506b1 test ecx,ecx +005506b3 jbe return ; n == 0 → no sound +005506b5 push 0x3f800000 ; push 0 +005506bc call Random::RollDice ; st0 = roll, roll ∈ [0, 0.99999988] +005506c5 mov esi,[edi+0x7c] ; esi = n +005506c8 lea ecx,[esi-1] ; ecx = n - 1 ← !! +005506d4 fild dword [esp+0x14] ; st0 = (float)(n-1) ; st1 = roll +005506e0 fmul st, st(1) ; st0 = (n-1) * roll +005506e2 call _ftol2 ; idx = (int)trunc(...) +005506e9 cmp eax, esi +005506eb jae return ; idx >= n → no sound (unreachable) +005506ed ... data_[idx] copied wholesale into out +00550717 if (out->sound_id_ != 0) → sound_hash_.find(id) → SoundBufRef* +``` + +Pseudocode: + +``` +GetSound(stype, table) -> SoundData: + std = table.Lookup(stype); if !std or std.n == 0: return none + roll = Random::rand() # [0, 0.99999988], NEVER 1.0 + idx = (int)(roll * (std.n - 1)) # truncate toward zero + if idx >= std.n: return none # dead branch + return std.data[idx] # id, priority, probability, volume +``` + +Then, **separately and downstream**, the *selected* entry's probability is a +single Bernoulli gate. `PlayProbability` @ `0x005500E0`: + +``` +r = rand() * (1/32767) # rand() is C rand(), RAND_MAX 32767 → r ∈ [0, 1] +return (r < probability) ? 1 : 0 +``` + +Call sites: `PlaySoundA(SoundType, obj[, vol])` `0x00550AF0`/`0x00550B70`, +`PlaySoundA(DataID, obj, prio, prob, vol)` `0x005507A0`, +`PlaySoundFromCenter(SoundType, table)` `0x00550950`, and inline (same +`rand()*1/32767 < prob` sequence, not a call) in `PlayAmbientSound` +`0x00550820` and `PlayAmbientSoundFromCenter` `0x005508B0`. Fail → **the +sound is simply not played**. There is no fallback entry, no retry. + +So retail's model is: **uniform index pick over the variant list, then an +independent play/skip roll on the picked entry's `probability_`.** The +probability field is *not* a selection weight and is never normalised or +accumulated. + +### 2b. The `n-1` off-by-one is real, and its blast radius is one wave + +Because `Random::rand` is hard-clamped to 0.99999988 (`0x00797D48`), `roll * +(n-1)` never reaches `n-1`, so **`idx ∈ [0, n-2]` and the last entry can +never be selected.** + +* `n == 1` → `idx = (int)(roll * 0) = 0` ✅ correct. +* `n == 2` → `idx` always 0; entry[1] is dead. +* `n == 3` → `idx ∈ {0,1}`; entry[2] is dead. + +Measured against real data (§1c): only `0x200000A8` / SoundType 31 has +`n == 2`, so in retail **wave `0x0A00051E` never plays.** Everything else is +`n == 1` and unaffected. + +Per CLAUDE.md ("do not 'fix' the decompiled code"), the port should +reproduce `(n-1)` verbatim with a comment citing `0x005506C8` and this +measurement, and add a divergence-register row **only if** we deliberately +choose `n` instead. + +### 2c. Our `SoundCookbook.Roll` — three distinct divergences + +```csharp +if (entries.Count == 1) return entries[0]; // ← probability never consulted +float sample = (float)rng.NextDouble(); +float cum = 0f; +for (...) { cum += entries[i].Probability; if (sample < cum) return entries[i]; } +return total > 0.999f ? entries[^1] : null; +``` + +| # | Divergence | Retail | Ours | +|---|---|---|---| +| D1 | **Probability is never applied on single-entry lists** | Bernoulli gate: `rand()/32767 < probability` → else silence | `entries.Count == 1` short-circuits before any roll → always plays | +| D2 | **Selection model** | uniform index `(int)(roll·(n-1))`; probability plays no part in selection | cumulative-distribution walk weighted *by* probability | +| D3 | **"Silence tail"** | doesn't exist as a concept; silence comes from the per-entry gate | invented: `null` when Σprobability < 1 and the sample lands past the last entry | + +D1 is the one that matters, because §1c says 4,183 of 4,184 entries are +single-entry lists and **686 of them have `probability < 1.0`**. Every one +of those is a sound retail plays *sometimes* and we play *always*. Broken +down by SoundType (20 of 123 types affected): + +| SoundType | sub-1.0 probabilities present | audible symptom | +|---|---|---| +| 1 `Speak1` | 0.05 ×49, 0.1 ×17, 0.01–0.03 ×7, 0.15/0.2/0.3/0.5 | **creature idle chatter fires ~20× too often** — the loudest symptom by far | +| 58 (0x3A) | **0.0001 ×6**, 0.003 ×2 | 1-in-10,000 easter-egg cues play on every trigger | +| 12/13/14 `Wound1/2/3` | 0.7 ×~80 each, 0.8 ×8 each | 30% of wound sounds should be dropped | +| 3/4/5 `Attack1/2/3` | 0.6 ×64/6/1, 0.8, 0.9, 0.95 | attack grunts over-fire | +| 15 `Death1` | 0.75 ×11 | | +| 30/31/32 `Swoosh1/2/3` | 0.6–0.8 | weapon swings over-fire | +| 33 `Thump1` | 0.8 ×30 | | +| 34 `Smash1` | 0.8 ×21, 0.9 ×12, 0.6 ×9 | | +| 35 `Scratch1` | 0.9 ×42, 0.8 ×20 | | +| 16, 24, 29, 41, 57 | 0.05 / 0.1 / 0.5 / 0.8 / 0.95 tail | | + +`SoundCookbook.Roll` is the **only** consumer of `Probability` in the whole +tree (`AudioHookSink.cs:108`); nothing else applies it. So the gate is +categorically absent from acdream today. + +Suggested retail-faithful shape (two separate steps, matching retail's +split): + +```csharp +// SoundManager::GetSound @ 0x00550680 — uniform index, note the (n-1). +static SoundEntry? Pick(IReadOnlyList e, IRandom r) { + if (e.Count == 0) return null; + int idx = (int)(r.NextUnit() * (e.Count - 1)); // NextUnit() ∈ [0, 0.99999988] + return idx < e.Count ? e[idx] : null; +} +// SoundManager::PlayProbability @ 0x005500E0 — Bernoulli gate at the play site. +static bool PlayProbability(float p, IRandom r) => r.NextUnit() < p; +``` + +Note also that retail draws from **two different RNGs**: `Random::rand` +(`0x0042C4C0`, a dual-LCG returning a float in [0, 0.99999988]) for the +index, and C library `rand()` scaled by 1/32767 for the probability gate. +Nothing observable depends on which we use, but the quantisation differs +(1/32767 grid vs ~2⁻³¹), and a probability of 0.0001 needs finer than +1/32767 granularity to be meaningful — retail's gate resolves it as +`rand() ∈ {0,1,2,3}` out of 32768, i.e. ~1.2e-4 effective, not 1e-4. + +--- + +## 3. Volume, distance falloff and pan (the fields' real semantics) + +`SoundManager::GetAttenuation` @ `0x00550020`, disassembled and with the +three elided constants restored: + +``` +GetAttenuation(float dist, float vol, int* outDb, int isAmbient) -> bool +{ + v = (dist >= 5.0f) // VOL_MIN_DIST 0x007CAEAC + ? (25.0f * vol) / (dist * dist) // VOL_MIN_DIST_SQ 0x0086F404 + : vol; // flat inside 5 m + if (v > 1.0) v = 1.0; // clamp AFTER the division + v *= isAmbient ? ambient_sound_volume : effect_sound_volume; + if (!(v > 0.0)) { *outDb = VOL_MIN; return false; } + dB = (int)ceil( log2(v) * 6.0206 ); // == 20*log10(v) + if (dB < VOL_MIN /* -50 */) { *outDb = VOL_MIN; return false; } + *outDb = dB; return true; // caller: SetVolume(dB * 100) centibels +} +``` + +Three facts our `AudioFalloff` gets wrong: + +* **Min distance is 5 m, not 1 m.** `AudioFalloff.AttenuationAt(d, + minDistance = 1.0f)` defaults to a 1 m plateau; retail's is 5 m and the + numerator is `minDistance²` = 25. +* **`volume_` is not bounded by 1.** Because the clamp is applied to + `25·vol/d²`, a `volume_` of 10 means "hold full loudness out to + `d = √(25·10) ≈ 15.8 m`, then inverse-square". Clamping the field to + [0,1] (as `AudioHookSink.cs:114` does) shrinks the plateau of all 44 + extended-range entries from ~15.8 m back to 5 m — a 3× audible-range loss + on wound/death/impact/ambient sounds (types 12/13/14/15, 18–26, 30, 33, + 35, 57, 66, 95/96, 149, 152/153). +* **`VOL_MIN = -50 dB`** is the cut-off; below it the sound is not started + at all (`return false` → caller skips `PlaySoundInternal`). + +Pan (`PlaySoundInternal(buf, Position*, vol, isAmbient)` @ `0x00550170`): + +``` +heading = Frame::get_heading(player_position_.frame) +dist = Position::distance(soundPos, player_position_) +bearing = Position::heading(soundPos, player_position_) // note: from the SOUND +pan = 0 +if (s_SoundFeatures != 1) { + a = fmod(bearing - heading, 360.0); if (a > 180.0) a -= 360.0 + if (abs((int)dist) >= 5.0) // 0x007991B0 + pan = (int)( sin(a * pi/180) * -15.0 ) // 0x007CAF58, note the sign +} +if (GetAttenuation(dist, vol, &dB, isAmbient)) PlaySoundInternal(buf, pan, dB) + → SoundBuf::Play → SetPan(pan*100), SetVolume(dB*100) +``` + +So retail pan is `sin(Δbearing)` scaled to **±15 → ±1500 centibels**, i.e. +only 15% of DirectSound's ±10000 range, and **zero inside 5 m**. Our +`AudioFalloff.PanFromRelative(relativeX, panRange = 20f)` is a linear +`x/20` clamp on a listener-relative X — a different model with a different +saturation curve and no near-field dead zone. (The `-15.0` sign also needs a +live A/B before trusting the left/right orientation.) + +Also from `PlaySoundInternal` @ `0x0054FEC0`: the playing-buffer ring is +indexed `(curr_playing_buffer_ + i) & 0x8000000F` over `i < 0x10` — +**retail has exactly 16 concurrent voices**, and the steal decision compares +the candidate slot's stored `priority` (`SoundPlayingData.priority`, the +float straight from `SoundData.priority_`) against the incoming one. That is +what `priority_` is for; it is not an 0..7 eviction class. + +Finally: `AudioModel.cs`'s claim that retail is "CPU-side inverse-square, +NOT DirectSound3DBuffer" is only half right. `SoundBuf::Create` @ +`0x00552930` requests `DSBCAPS_CTRL3D` (`0x100B0`) and QueryInterfaces +`IDirectSound3DBuffer` when `m_3D` is set, falling back to the 2D +pan/volume path (`0x100E0`, `CTRLVOLUME|CTRLPAN|CTRLFREQUENCY`) when the +3D listener is unavailable. The attenuation math above is the 2D path. + +--- + +## 4. Wave format — retail vs DRW vs `WaveDecoder` + +### 4a. On-disk layout + +`DBWave::UnPack` @ `0x00551B90`: + +``` +u32 headerSize // format-chunk size +u32 dataSize +byte[headerSize] header // raw WAVEFORMATEX, no RIFF wrapper +byte[dataSize] data +``` + +(The allocation order in the disassembly is data-buffer first, header-buffer +second, but the *read* order is header then data — `memcpy(fmt, p, +headerSize); p += headerSize; memcpy(data, p, dataSize)`.) + +DRW's `Wave.Unpack` reads `headerSize, dataSize, header[], data[]` — **exact +match**. Our `WaveDecoder`'s documented layout is also exact. ✅ + +`acclient.h:1199` `tWAVEFORMATEX` is `#pragma pack(1)`: +`wFormatTag u16, nChannels u16, nSamplesPerSec u32, nAvgBytesPerSec u32, +nBlockAlign u16, wBitsPerSample u16, cbSize u16` = 18 bytes. Our +`WaveDecoder` offsets (0, 2, 4, 14) match. ✅ + +### 4b. What retail does with a non-PCM wave + +`SoundBuf::Create` @ `0x00552930`: + +``` +wf = DBObj::Get(QualifiedDataID(waveId, 0x0F /*DB_TYPE_WAVE*/)) // + 0x38 → WaveFile +if (wf->m_pwfmt->wFormatTag == 1) { // PCM + bufsize = wf->m_nDataSize +} else { // anything else + dst = { wFormatTag=1, nChannels=1, nSamplesPerSec=11025, + nAvgBytesPerSec=22050, nBlockAlign=2, wBitsPerSample=16, cbSize=0 } + acmStreamOpen(&phas, NULL, wf->m_pwfmt, &dst, ...) + acmStreamSize(phas, wf->m_nDataSize, &bufsize, 0) +} +CreateSoundBuffer(bufsize) ; CopyWaveToBuffer(this, wf) +if (non-PCM) { acmStreamClose(phas); phas = NULL; } +``` + +`SoundBuf::CopyWaveToBuffer` @ `0x005526D0`: + +``` +Lock(buf, 0, bufsize, &p1, &n1, &p2, &n2, 0) +if (phas == NULL) memcpy(p1, wf->m_pData, ...) [+ wrap-around memcpy into p2] +else acmStreamPrepareHeader / acmStreamConvert / acmStreamUnprepareHeader +Unlock(...) +``` + +So: **retail does not decode compressed waves itself.** It hands the source +`WAVEFORMATEX` to the Windows ACM (`msacm32`) and converts straight into the +locked DirectSound buffer, with a **hard-coded destination format of PCM +mono 11,025 Hz 16-bit**. Everything else is a raw `memcpy` of the dat bytes. +Our `AudioModel.cs` comment "we decode MP3 to PCM once at load (same as +retail does for long clips)" is right in spirit — retail decodes the whole +buffer once at `Create` time, not streaming — but the target format detail +is a concrete portable fact we should match if we ever add the decoder. + +### 4c. Do we decode MP3 at all? No — and it costs exactly one wave + +`grep` over `src/` and every `*.csproj`: **no MP3 or ADPCM decoder, and no +NAudio / NLayer / mpg123 package reference exists.** `WaveDecoder.Decode` +returns `null` for any `wFormatTag != 1`, `DatSoundCache` files the id in +`_negativeWaveIds`, and the sound is permanently silent. + +Measured cost, `client_portal.dat`: + +| Format tag | Count | +|---|---| +| `0x0001` PCM | **785** | +| `0x0055` MPEGLAYER3 | **1** | + +The single MP3 is **wave `0x0A000393`**, header 30 bytes, data 5,120 bytes: + +``` +55 00 wFormatTag = 0x0055 MPEGLAYER3 +01 00 nChannels = 1 +11 2b 00 00 nSamplesPerSec = 11025 +c4 09 00 00 nAvgBytesPerSec = 2500 (20 kbps → ~2.05 s of audio) +01 00 nBlockAlign = 1 +00 00 wBitsPerSample = 0 +0c 00 cbSize = 12 +01 00 wID = MPEGLAYER3_ID_MPEG +02 00 00 00 fdwFlags = MPEGLAYER3_FLAG_PADDING_OFF +04 01 nBlockSize = 260 +02 00 nFramesPerBlock = 2 +71 05 nCodecDelay = 1393 +``` + +**Verdict on the "MP3-sourced waves are silently broken" hypothesis: true +but immaterial — 1 wave of 786 (0.13%), one ~2-second mono cue.** Adding an +MP3 decoder is a footnote, not a P0. There are **no ADPCM (`0x0002`) waves +at all.** + +### 4d. Real-wave PCM parameter ranges (what our decoder must survive) + +| Parameter | Distribution across the 785 PCM waves | +|---|---| +| header size | 18 bytes (all of them) | +| channels | mono 772, **stereo 14** | +| bits/sample | 16 × 714, **8 × 71** | +| sample rate | 11025 (471), 22050 (164), 44100 (89), 8000 (25), 32000 (20), 16000 (10), plus 5500/6000/7333/8287/12000 singletons | + +`WaveDecoder.Decode` handles all of this correctly (it reads the real +`nChannels`/`nSamplesPerSec`/`wBitsPerSample` and returns the raw bytes). +Two things to check downstream, outside this lane: (a) 71 waves are **8-bit +unsigned PCM** — OpenAL needs `AL_FORMAT_MONO8`/`STEREO8`, and 8-bit PCM in +WAV is *unsigned* while 16-bit is signed; (b) the odd rates +(5500/7333/8287) are fine for OpenAL but will resample. + +Minor: `WaveDecoder` guards `header.Length < 14` and reads bits at offset 14 +only when `Length >= 16`. Every real wave is 18 or 30 bytes, so the fallback +`bitsPer = 16` never fires on retail data — but note the MP3 header has +`wBitsPerSample == 0`, which the current `bitsPer == 0 ? 16` fallback would +silently paper over if MP3 ever reached that line. + +--- + +## 5. `CSoundDesc` — what it actually is (not the per-object table) + +`acclient.h:53246`: `CSoundDesc` is a member of **`CRegionDesc`**, alongside +`SkyDesc`, `CSceneDesc`, `CTerrainDesc`, `FogDesc`: + +```c +struct __cppobj CSoundDesc { AC1Legacy::SmartArray stb_desc; }; +``` + +`CSoundDesc::UnPack` @ `0x005028D0` = `u32 count` + `AmbientSTBDesc[count]`, +which DRW's `SoundDesc.Unpack` matches exactly. ✅ + +So **`CSoundDesc` is the region's ambient sound-table list, not the +per-object sound-table pointer.** It feeds `PlayAmbientSound` / +`PlayAmbientSoundFromCenter`. + +The per-object path is different. `CPhysicsObj::sound_table` (`acclient.h` +offset via `arg2->sound_table` in `PlaySoundA` @ `0x00550B20`) is resolved +from a **DataID on the object**, with a Setup-level default: + +* `0x00514F76` — `id = desc->stable_id.id`; if non-zero, + `sound_table = DBObj::Get(QualifiedDataID(id, 0x22))`. +* `0x00513A00` — `id = setup->default_stable_id.id`, same resolution. +* Sibling field `phstable_id` / `default_phstable_id` is the *physics-script* + table, a separate thing. +* `stable_id` / `phstable_id` are wire-serialised + (`0x0051DAEC`, `0x0051DB03`) — i.e. the server can override the Setup's + default per object. + +`PlaySoundA(SoundType, CPhysicsObj*)` therefore reads +`obj->sound_table`, calls `GetSound(stype, table)`, gates on +`PlayProbability`, and plays at `obj->m_position`. Our +`IEntitySoundTable.GetSoundTableId(entityId)` seam is the right shape; the +resolution order to match is **`stable_id` from the object's physics desc, +falling back to the Setup's `default_stable_id`**. + +--- + +## 6. Test inventory — golden vs self-referential + +| File | Verdict | +|---|---| +| `SoundIdConformanceTests.cs` (272 lines) | ✅ **Genuine golden conformance.** A 205-entry table transcribed from `acclient.h:4569 enum SoundType`, cross-checked against ACE and DRW, with tests for exact values, no extras, dense coverage to `0xCC`, and agreement with the DRW enum used at runtime. This is the model the rest of the lane should follow. | +| `WaveDecoderTests.cs` (104 lines) | ⚠️ **Synthetic, hand-built headers; no dat-derived golden values.** It builds an 18-byte PCM header with plausible values (mono/22050/16 — a rate that exists in the dats, so plausible) and asserts our own parse. `Decode_Mp3Header_ReturnsNull` and `Decode_AdpcmHeader_ReturnsNull` **pin the missing-decoder behaviour as correct** — they will have to be inverted when a decoder lands. No test exercises the real 8-bit or stereo waves, or the real MP3's `wBitsPerSample == 0`. | +| `DatSoundCookbookTests.cs` → `SoundCookbookTests.cs` (97 lines) | ❌ **Entirely self-referential, and it locks in the wrong model.** `Roll_WeightedEntries_DistributionMatches` asserts 50/30/20 split from a CDF walk; `Roll_SilenceTail_ReturnsNullOccasionally` asserts the invented 40%-silence tail; `Roll_SingleEntry_AlwaysReturnsIt` explicitly asserts the D1 bug (a 0.5-probability single entry returns unconditionally). Zero retail anchors. Every one of these five tests must change when §2 is ported. | +| `DatSoundCacheTests.cs` (245 lines) | ✅ **Correctly scoped and honest** — LRU eviction order, byte accounting, negative-result memoisation, oversize bypass, concurrent-decode dedup. It's infrastructure, not retail behaviour, and it doesn't pretend otherwise. Only caveat: `GetWave_UnsupportedFormat_...` uses `MakeMp3Wave` and asserts the null path, same "pins a gap as correct" note as above. | +| DRW's `SoundTableTests.cs` / `WaveTests.cs` | ⚠️ Round-trip only (write then read). Prove Pack↔Unpack symmetry, prove nothing about retail's layout. | + +**Nothing in the tree conformance-tests the SoundTable byte layout, the +selection algorithm, the probability gate, the attenuation curve, or the +priority/volume semantics against retail.** The only golden table is the +SoundType enum. + +--- + +## 7. Divergence list, ranked by audible impact + +| # | Divergence | Evidence | Audible symptom | Fix size | +|---|---|---|---|---| +| **1** | **`probability_` is never applied.** `SoundCookbook.Roll` returns single-entry lists unconditionally, and 4,183 of 4,184 real entries are single-entry — 686 of those have probability < 1.0. | `SoundCookbook.cs:44`; `PlayProbability` @ `0x005500E0`; dat scan §1c/§2c | Creature idle chatter (`Speak1`, 49 entries at 5%) fires ~20× too often; wound/attack/swoosh/impact sounds never drop; six 0.01%-chance easter eggs play every time. **This is the single loudest wrong thing in the audio stack.** | small — add a Bernoulli gate at the play site | +| **2** | **`priority_` treated as `int` 0..7.** Retail is a float in [0,1] driving 16-voice steal ordering. | `AudioModel.cs:24` comment; `OpenAlAudioEngine.cs:297` `(uint)Math.Clamp((int)priority,0,7)`; dat histogram (mode 0.7) | 4,100+ entries collapse to priority 0 → voice-steal is effectively arbitrary; important sounds (death, casting) lose to footsteps. | small — keep the float; port the ring compare from `0x0054FF70` | +| **3** | **`volume_` clamped to [0,1] before falloff.** Retail clamps `25·vol/d²` after the division, so `volume_ > 1` extends the full-volume plateau. 44 real entries exceed 1.0 (31 at 10.0). | `AudioHookSink.cs:114`; `GetAttenuation` @ `0x00550020` with `0x0086F404 = 25.0` | Wound/death/impact/ambient sounds audible to ~5 m instead of ~15.8 m — they feel local and thin instead of carrying. | small | +| **4** | **Falloff min-distance 1 m vs retail 5 m; no −50 dB cutoff; dB curve absent.** | `AudioFalloff.AttenuationAt` default `minDistance = 1.0f`; retail `VOL_MIN_DIST = 5.0`, `VOL_MIN = -50` dB, `dB = ceil(20·log10(v))` | Everything is quieter than retail at 1–5 m and audible far past retail's cutoff. | small | +| **5** | **Pan model is linear `x/20`; retail is `sin(Δbearing) · ±15` with a 5 m dead zone.** | `AudioFalloff.PanFromRelative`; `0x00550170` with `0x007CAF58 = -15.0` | Wrong stereo image; near sounds pan when retail keeps them centred; overall pan ~6× stronger than retail's ±1500/±10000. Sign needs a live A/B. | small | +| **6** | **Selection is a CDF walk, not `(int)(roll·(n-1))`.** | `SoundCookbook.cs:46-59`; `GetSound` @ `0x005506C8` | Nearly inaudible on retail data (one 2-entry sound exists). Matters only for retail-faithfulness and for custom content. Note retail's `n-1` means the last variant is **never** played — port verbatim, don't "fix". | small | +| **7** | **Invented "silence tail"** (`null` when Σprobability < 1). | `SoundCookbook.cs:53-59` | With single-entry lists at probability 0.05, our code returns the entry (count==1 short-circuit) — so the tail is dead code that would misfire the moment multi-entry lists appear. | delete | +| **8** | **No MP3 decoder** → `0x0A000393` (one ~2 s mono cue) is permanently silent. Retail uses winmm ACM into PCM mono/11025/16-bit. | `WaveDecoder.cs:87`; `SoundBuf::Create` @ `0x00552AD0`; dat scan (1 of 786) | One missing sound effect. **Not a P0.** | medium (needs a managed decoder) | +| **9** | **Lazy wave load vs retail's eager `CreateSound` at table UnPack.** | `SoundTableData::UnPack` `0x00552451`; `DatSoundCache.GetWave` | Possible first-play hitch; retail has none. Architecturally our choice is better for the 30-bot fleet. | none — document | +| **10** | **DRW's SoundTable parse is non-recursive** (grandchild count read into `SoundData.Unknown` and discarded). | `SoundData.generated.cs`; `SoundTableData::UnPack` recursion at `0x00552503` | Zero impact on retail data (0 of 190 tables nest deeper than 2). Would silently corrupt custom deep tables. | none — comment | +| **11** | **`AcDream.Core.Audio.SoundEntry` / `ISoundCache` are dead code whose invented comments (`Priority 0..7`, `VolumeBase 0..1`, `PitchMin/Max`, `Loop`, `Is3D`) are the documented source of divergences 2 and 3.** | `AudioModel.cs:21-31, 110-115`; no constructors anywhere in `src/` or `tests/` | none directly | delete or correct — highest value per line changed | +| **12** | **`AudioModel.cs` claims retail never uses `IDirectSound3DBuffer`.** It does, when `m_3D` is set and a 3D listener exists. | `SoundBuf::Create` `0x0055295E` (`0x100B0` = `DSBCAPS_CTRL3D`), `0x00552B58` QueryInterface | none directly; the doc misleads future work | doc fix | +| **13** | **Voice limit unmodelled.** Retail has exactly 16 concurrent buffers with a priority-based steal. | `PlaySoundInternal` `0x0054FEC0` (`& 0x8000000F`, `i < 0x10`) | dense-combat mix density differs from retail | medium | + +--- + +## 8. Things worth pinning as conformance tests + +1. `SoundTableData` byte layout — a golden hex fixture for one real table + (e.g. `0x200000A8`, the only 2-entry one) asserting id/priority/probability/ + volume for both entries and the root dummy `{0, …}`. +2. `Pick()` — `n == 2` must always return index 0 (the `n-1` truncation), + citing `0x005506C8`; `n == 1` returns index 0. +3. `PlayProbability(0.0f)` never plays; `PlayProbability(1.0f)` always plays; + `PlayProbability(0.05f)` over 100k trials lands in [4.7%, 5.3%]. +4. `GetAttenuation` golden rows, computed from the recovered constants: + `(dist, vol) → dB` for `(1, 1) → 0`, `(5, 1) → 0`, + `(10, 1) → ceil(20·log10(0.25)) = -12`, + `(10, 10) → ceil(20·log10(1.0)) = 0` (clamped), + `(50, 1) → ceil(20·log10(0.01)) = -40`, + `(200, 1) → below −50 → inaudible, returns false`. +5. Wave header parse against the real `0x0A000393` MP3 header bytes (§4c) + and against at least one real 8-bit and one real stereo wave. +6. A dat-backed invariant test (gated on the dats being present, like the + existing installed-DAT gates): every SoundTable in `client_portal.dat` + parses, root `num_stdatas_ == 1` with `sound_id_ == 0`, and no table has + grandchildren — this is the guard that keeps DRW's flattening honest. diff --git a/docs/research/2026-08-08-audio-retail-music-absence.md b/docs/research/2026-08-08-audio-retail-music-absence.md new file mode 100644 index 00000000..3c119688 --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-music-absence.md @@ -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 0x00–0xCC 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 0x65–0x7C | +| 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 m_media ← the script +UIElement + └─ MediaMachine m_mediaMachine acclient.h:33786 + ├─ UIElement* m_owner + ├─ SmartArray 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&)` @ `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` :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 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) | | 0x76–0x7A | `Sound_UI_Thunder1..5` (0x85–0x89) | + | 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 +CSceneType :53240 — { name, scenes[], AmbientSTBDesc* sound_table_desc } +AmbientSTBDesc :35486 — { DID stb_id, bool stb_not_found, + SmartArray 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::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`, + `Dictionary` with `SoundEntry { QualifiedDataId 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 `0x65–0x7C` 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` | 1185597–8 | +| `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` (0x00–0xCC) | — | `acclient.h:4569` | +| `MediaMachine` / `MediaDesc` / `MD_Data_*` structs | — | `acclient.h:33873, 33907, 34101–34182` | diff --git a/docs/research/2026-08-08-audio-retail-server-sounds.md b/docs/research/2026-08-08-audio-retail-server-sounds.md new file mode 100644 index 00000000..214c89f4 --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-server-sounds.md @@ -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 0x00–0xC5+ + 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` 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) +``` diff --git a/docs/research/2026-08-08-audio-retail-soundmanager-core.md b/docs/research/2026-08-08-audio-retail-soundmanager-core.md new file mode 100644 index 00000000..c1f8599d --- /dev/null +++ b/docs/research/2026-08-08-audio-retail-soundmanager-core.md @@ -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`, 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 · (n−1))`, **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 + 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::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) · (n−1))` — 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`.