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>
739 lines
31 KiB
Markdown
739 lines
31 KiB
Markdown
# 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[]
|
||
│ ├── <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 `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<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` (0x20000000–0x2000FFFF, `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 `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 |
|