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