# 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 ```