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

739 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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