acdream/docs/research/2026-08-08-audio-retail-ambient-authoring.md
Erik ffa5087527 docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.

Plan proposes slices A1-A6; awaiting user go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:55:33 +02:00

31 KiB
Raw Permalink Blame History

Retail ambient sounds — the authoring / DAT data path (Lane 3)

Research-only note. Oracles, in the order the project's rules require:

  1. docs/research/named-retail/acclient_2013_pseudo_c.txt (PDB-named BN pseudo-C, Sept 2013 EoR build) + acclient.h (verbatim retail structs) + symbols.json.
  2. Raw byte decode of C:\Users\erikn\Downloads\acclient.exe (the PDB-paired v11.4186 binary) for every place BN elided or inverted an x87 comparison. Method per claude-memory/reference_pe_byte_decode.md.
  3. references/DatReaderWriter/ (production dat reader) and references/ACViewer/ACE/Source/ACE.DatLoader/ as the independent 2nd/3rd parser cross-check.

Runtime tick (Ambient::UseTime, Play, PlaySoundA, the play queue, IntermitSound::GetSoundPos) is a sibling lane's scope. This note owns where the data comes from and stops at the point a sound instance exists.


0. TL;DR

  • Ambient sound authoring lives entirely in the region file (0x13xxxxxx, DB_TYPE_REGION). There is no separate "ambient table" dat range.
  • AmbientSTBDesc.stb_id is a SoundTable DID in 0x200000000x2000FFFF (DB_TYPE_STABLE). "STB" = Sound TaBle. 0x22 in the retail code is the DBObj cache-type index, not a dat-id prefix.
  • Outdoor selection is per-land cell (8×8 per landblock) off the terrain word: terrainType = (w >> 2) & 0x1F, sceneOrdinal = w >> 11. Two index hops (terrain type → scene type → STB desc) land on the STB descriptor.
  • Indoor / EnvCell ambients do not exist as authored data. CEnvCell::add_ambient_sounds is present in the PDB but ICF-folded onto a bare ret — an empty stub in the 2013 build. The EnvCell dat has no sound field at all.
  • Rebuild happens once per cell change in CellManager::ChangePosition, and only for landblocks in the 3×3 ring around the viewer block.
  • Two BN pseudo-C readings in this area are wrong and byte-verified corrected below: the is_continuous derivation and Ambient::CalcWeight.

1. The complete data chain

Region DBObj  (DID 0x13000000 + regionNumber, DB_TYPE_REGION)
│   loaded by CRegionDesc::SetRegion(regionNumber) @ 0x004FE8F0
│   → DBObj::GetByEnum(regionNumber, type=0x0B, cache=0x1C)
│   → stored in the global CRegionDesc::current_region  (data @ 0x0084146C)
│
├── sound_info : CSoundDesc
│     └── stb_desc : AmbientSTBDesc[]          ← the authored ambient sound sets
│           ├── stb_id      : DID → SoundTable (0x200000000x2000FFFF)
│           └── ambient_sounds : AmbientSoundDesc[]
│                 { stype, volume, base_chance, min_rate, max_rate }
│
├── scene_info : CSceneDesc
│     └── scene_types : CSceneType[]
│           ├── <stbIndex>  (u32, 0xFFFFFFFF = none) → &sound_info.stb_desc[i]
│           └── scenes      : DID[]  (0x12xxxxxx Scene objects, procedural
│                                     scenery — same record, different consumer)
│
└── terrain_info : CTerrainDesc
      └── terrain_types : CTerrainType[]   (indexed by the terrain word's type)
            ├── terrain_name, terrain_color
            └── scene_types : u32[]  (0xFFFFFFFF = none)
                              → &scene_info.scene_types[idx]

Retail resolves the two index fields into pointers at unpack time inside CRegionDesc::UnPack (@ 0x004FF440), so at runtime CSceneType::sound_table_desc is a direct pointer into the shared CSoundDesc::stb_desc array. Consequence worth porting deliberately: AmbientSTBDesc instances are shared, so their sound_table cache and play_count are per-region-entry, not per-cell.

Resolution code, verbatim shape (CRegionDesc::UnPack, scene section @ 0x004FF713):

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):

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)

/* 3763 */  // sizeof = 0x1C
struct __cppobj AmbientSTBDesc
{
  IDClass<_tagDataID,32,0> stb_id;                          // +0x00  SoundTable DID
  int stb_not_found;                                        // +0x04  negative cache
  AC1Legacy::SmartArray<AmbientSoundDesc *> ambient_sounds;  // +0x08 m_data, +0x0C m_size, +0x10 m_num
  CSoundTable *sound_table;                                 // +0x14  resolved DBObj
  unsigned int play_count;                                  // +0x18  per-rebuild
};

/* 3761 */  // sizeof = 0x18 in memory, 0x14 on disk
struct AmbientSoundDesc
{
  SoundType stype;        // +0x00  which slot to pull from the SoundTable
  int is_continuous;      // +0x04  DERIVED at unpack, NOT stored on disk
  float volume;           // +0x08
  float base_chance;      // +0x0C
  float min_rate;         // +0x10
  float max_rate;         // +0x14
};

/* 5846 */
struct __cppobj CSoundDesc
{
  AC1Legacy::SmartArray<AmbientSTBDesc *> stb_desc;
};

/* 5830 */  // sizeof = 0x14
struct __cppobj CSceneType
{
  PStringBase<char> scene_name;                      // +0x00
  SmartArray<IDClass<_tagDataID,32,0>,1> scenes;     // +0x04 m_data, +0x08 m_sizeAndDealloc, +0x0C m_num
  AmbientSTBDesc *sound_table_desc;                  // +0x10
};

/* 5832 */
struct __cppobj CTerrainType
{
  AC1Legacy::PStringBase<char> terrain_name;         // +0x00
  RGBAUnion terrain_color;                           // +0x04
  AC1Legacy::SmartArray<CSceneType *> scene_types;   // +0x08 m_data, +0x0C m_size, +0x10 m_num
};

/* 5834 */
struct __cppobj CTerrainDesc
{
  LandSurf *land_surfaces;
  AC1Legacy::SmartArray<CTerrainType *> terrain_types;
};

/* 5851 */
struct __cppobj CRegionDesc : SerializeUsingPackDBObj
{
  unsigned int region_number;
  AC1Legacy::PStringBase<char> region_name;
  unsigned int version;
  int minimize_pal;
  unsigned int parts_mask;
  FileNameDesc *file_info;
  SkyDesc *sky_info;
  CSoundDesc *sound_info;        // ← ambient sound sets live here
  CSceneDesc *scene_info;
  CTerrainDesc *terrain_info;
  CEncounterDesc *encounter_info;
  WaterDesc *water_info;
  FogDesc *fog_info;
  DistanceFogDesc *dist_fog_info;
  RegionMapDesc *region_map_info;
  RegionMisc *region_misc;
};

Runtime instances (for reference; sibling lane owns their behavior):

/* 3765 */
struct __cppobj Ambient
{
  Position player_pos;                            // +0x00
  float total_sound_count;                        // +0x24
  unsigned int num_sounds;                        // +0x28
  DArray<AmbientSound *> sounds;                  // +0x2C
  AC1Legacy::PQueueArray<double> sound_queue;
};

/* 3759 */  // sizeof = 0x18
struct __cppobj AmbientSound
{
  AmbientSoundVtbl *vfptr;   // +0x00
  int on_queue;              // +0x04
  float sound_count;         // +0x08  accumulated weight this rebuild
  AmbientSTBDesc *desc;      // +0x0C  identity key part 1
  unsigned int ambient_sound_id;  // +0x10  identity key part 2 (index into desc->ambient_sounds)
  int constant_sound;        // +0x14
};

/* 5804 */  // sizeof = 0x80
struct __cppobj IntermitSound : AmbientSound
{
  float play_chance;                  // +0x18
  float min_dist[8];                  // +0x1C
  float max_dist[8];                  // +0x3C
  unsigned int num_dir;               // +0x5C
  LandDefs::Direction sound_dir[8];   // +0x60
};

/* 5807 */  // sizeof = 0x1C
struct __cppobj ConstantSound : AmbientSound
{
  float current_volume;               // +0x18
};

AmbientSound's own virtuals are all ICF-folded stubs (AmbientSound::vftable @ 0x007CB0A4 points at IDClass::~IDClass, MediaDesc::GetDuration, Client::You_Must_Not_Have_Multiple_Implementations_Of_AddRef_In_A_Hierarchy, etc.). The class is effectively abstract; only IntermitSound (vftable 0x007CB0C4) and ConstantSound (vftable 0x007CB0E4) do work.


3. On-disk pack layouts

Derived from *::Pack / *::pack_size / *::UnPack and confirmed field-for-field by DatReaderWriter and ACE.DatLoader.

CSoundDesc (region SoundInfo, present iff PartsMask.HasSoundInfo)

offset type field
0 u32 numSTBDesc
4 AmbientSTBDesc × N

AmbientSTBDescpack_size = 8 + 0x14 * numSounds (@ 0x00551300)

offset type field
0 u32 stb_id (SoundTable DID)
4 u32 numAmbientSounds
8 AmbientSoundDesc × N (0x14 each)

AmbientSoundDesc — 20 bytes on disk (@ 0x00551220 / 0x005518F0)

offset type field
0 u32 stype (SoundType)
4 f32 volume
8 f32 base_chance
12 f32 min_rate
16 f32 max_rate

is_continuous is not on disk — it is computed during unpack (see §3.1).

CSceneType (region SceneInfo entries)

offset type field written by
0 u32 stbIndex (0xFFFFFFFF = none) CRegionDesc::Pack
4 u32 numScenes CSceneType::pack
8 u32 × N Scene DIDs (0x12xxxxxx) CSceneType::pack

CTerrainType (region TerrainInfo entries)

type field
PStringBase<char> + align(4) terrain_name
u32 terrain_color (ARGB)
u32 numSceneTypes
u32 × N scene-type indices into SceneInfo.SceneTypes (0xFFFFFFFF = none)

3.1 CORRECTION #1 — is_continuous (BN pseudo-C is inverted)

BN renders AmbientSTBDesc::UnPack @ 0x005519A9 as if is_continuous were base_chance != 0. Byte decode of the paired binary says the opposite:

005519a9  d9 43 0c              fld   dword [ebx+0x0C]     ; base_chance
005519ac  dc 1d 10 46 79 00     fcomp qword [0x00794610]   ; = 0.0  (verified)
005519b2  df e0                 fnstsw ax
005519b4  f6 c4 44              test  ah, 0x44             ; C3(equal) | C2(unordered)
005519b7  7a 07                 jp    0x005519C0           ; PF set ⇔ mask result == 0 ⇔ NOT equal
005519b9  b8 01 00 00 00        mov   eax, 1
005519be  eb 02                 jmp   0x005519C2
005519c0  33 c0                 xor   eax, eax
005519c2  89 43 04              mov   [ebx+0x04], eax      ; is_continuous

is_continuous = (base_chance == 0.0f).

Corroborated by Ambient::GetSound @ 0x005510B0 (byte-verified at 0x00551106: mov eax,[esp+0x10]; test eax,eax; je +0x3A — the je goes to the 0x80-byte allocation):

  • is_continuous == 0operator new(0x80)IntermitSound
  • is_continuous != 0operator 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:

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:

// CTerrainDesc::NumSceneType @ 0x00502430
uint32 NumSceneType(t) {
    return (t < terrain_types.m_num) ? terrain_types[t]->scene_types.m_num : 0;
}

// CTerrainDesc::GetSTBDesc @ 0x00502400  (field offsets confirmed against acclient.h)
AmbientSTBDesc* GetSTBDesc(t, s) {
    if (t >= terrain_types.m_num) return NULL;
    CTerrainType* tt = terrain_types[t];
    if (s >= tt->scene_types.m_num) return NULL;
    CSceneType* st = tt->scene_types[s];
    return st ? st->sound_table_desc : NULL;      // +0x10
}

// CRegionDesc::GetSTBDesc @ 0x004FEAB0 — adds lazy SoundTable resolution
AmbientSTBDesc* GetSTBDesc(t, s) {
    AmbientSTBDesc* d = terrain_info->GetSTBDesc(t, s);
    if (!d) return NULL;
    int ok = 0;
    if (d->sound_table == NULL) ok = d->InitSoundTable();
    return (d->sound_table || ok) ? d : NULL;
}

// AmbientSTBDesc::InitSoundTable @ 0x004FEA60
int InitSoundTable() {
    if (stb_not_found) return 0;
    if (stb_id == INVALID_DID) return 0;
    sound_table = (CSoundTable*)DBObj::Get(QualifiedDataID(stb_id, /*type*/ 0x22));
    if (sound_table) return 1;
    stb_not_found = 1;                 // negative cache; never retried
    return 0;
}

0x22 is the DBObj cache-type index for CSoundTable, proven by CLOCache::CLOCache(cache, CSoundTable::Allocator, 0x22) @ 0x004FB831. The same 0x22 is used for object/setup sound tables (CPhysicsObj / SetupDesc::default_stable_id sites @ 0x00513A36, 0x00514F9F) and by MediaDesc @ 0x004658DA — so no separate ambient dat range exists; ambient sound tables are ordinary SoundTable objects in 0x200000000x2000FFFF.

4.1 Which landblocks contribute — LScape::add_ambient_sounds @ 0x00505810

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

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:

{"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:

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.

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:

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:

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 SmartBoxAmbient::UseTime @ 0x00551880 (sibling lane).

Both AddSound and UpdatePlayQueue are gated on SoundManager::ambient_sounds_enabled, so the user's audio option short-circuits the whole gather.


7. DatReaderWriter coverage (what we get for free)

retail type DRW class file status
CRegionDesc DBObjs.Region (0x130000000x1300FFFF, HasId) Generated/DBObjs/Region.generated.cs Complete for our needs. Parses RegionNumber, Version, RegionName, LandDefs, GameTime, PartsMask, then masked SkyInfo / SoundInfo / SceneInfo, unconditional TerrainInfo, masked RegionMisc.
CSoundDesc Types.SoundDescList<AmbientSTBDesc> STBDesc Generated/Types/SoundDesc.generated.cs Exact match to retail pack (u32 count + N entries).
AmbientSTBDesc Types.AmbientSTBDescuint STBId, List<AmbientSoundDesc> Generated/Types/AmbientSTBDesc.generated.cs Exact match.
AmbientSoundDesc Types.AmbientSoundDescSound 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.SceneDescList<SceneType> Generated/Types/SceneDesc.generated.cs Exact match.
CSceneType Types.SceneTypeuint StbIndex, List<QualifiedDataId<Scene>> Scenes Generated/Types/SceneType.generated.cs Exact match, including the caller-written StbIndex first. Independently confirmed by ACE.DatLoader/Entity/SceneType.cs.
CTerrainDesc Types.TerrainDescList<TerrainType>, LandSurf Generated/Types/TerrainDesc.generated.cs Exact match.
CTerrainType Types.TerrainTypeTerrainName, ColorARGB TerrainColor, List<uint> SceneTypes Generated/Types/TerrainType.generated.cs Exact match (indices, not resolved pointers).
CSoundTable DBObjs.SoundTable (0x200000000x2000FFFF, HasId) → HashKey, Dictionary<uint,SoundHashData> Hashes, Dictionary<Sound,SoundData> Sounds Generated/DBObjs/SoundTable.generated.cs Complete. Sounds[stype].Entries is the wave list.
SoundType Enums.Sound (incl. Ambient1..8) Generated/Enums/Sound.generated.cs Present.

Gaps we must write ourselves (none of them are parsers):

  1. is_continuous derivation. DRW deliberately stores only the on-disk fields. We compute IsContinuous => BaseChance == 0f at load. §3.1.
  2. Index → object resolution. DRW hands back raw StbIndex and TerrainType.SceneTypes indices with 0xFFFFFFFF sentinels. Retail resolves them once at unpack; we need the equivalent resolve step (or resolve on lookup, which is what GetSTBDesc does anyway) and must honour 0xFFFFFFFF == none.
  3. The whole Ambient runtime: AmbientSTBDesc shared state (sound_table cache, stb_not_found negative cache, play_count), the (desc, index)-keyed instance cache, IntermitSound/ConstantSound, CalcWeight/CalcDir, the play queue, the release policy. No reference repo has any of this — ACE is a server and does not model client ambience; ACViewer has no ambient sound handling (grep -ri ambient over references/ACViewer/ returns only render-pass / ambient-light hits).
  4. CLandBlock/LScape gather — the terrain-word decode, the 8×8 land-cell walk with the 9-wide row stride, the ring == 1 3×3 landblock gate, and the CellManager::ChangePosition trigger point. All ours.
  5. Random::RollDice(min_rate, max_rate) for the intermittent interval. Verify our RNG matches retail's RollDice semantics before wiring min_rate/max_rate.

8. Answers to the posed questions

  1. Complete chain. Region DBObj 0x13000000+regionNumberSoundInfo (CSoundDesc)AmbientSTBDesc[]; each descriptor's stb_id is a SoundTable DID in 0x200000000x2000FFFF, fetched via DBObj::Get(QualifiedDataID(id, cacheType=0x22)); each descriptor carries N AmbientSoundDesc { stype, volume, base_chance, min_rate, max_rate }, and base_chance == 0 selects ConstantSound while non-zero selects IntermitSound. Selection is reached indirectly: TerrainInfo.TerrainTypes[t].SceneTypes[s]SceneInfo.SceneTypes[idx].StbIndexSoundInfo.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 InitSoundsCEnvCell::add_ambient_sounds (no-op) → LScape::add_ambient_sounds (if outdoor-ish) → UpdatePlayQueueReleaseSoundTables. Teardown is CellManager::ResetAmbient::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