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>
31 KiB
Retail ambient sounds — the authoring / DAT data path (Lane 3)
Research-only note. Oracles, in the order the project's rules require:
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.- 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 perclaude-memory/reference_pe_byte_decode.md. references/DatReaderWriter/(production dat reader) andreferences/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_idis aSoundTableDID in0x20000000–0x2000FFFF(DB_TYPE_STABLE). "STB" = Sound TaBle.0x22in 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_soundsis present in the PDB but ICF-folded onto a bareret— 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_continuousderivation andAmbient::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):
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 | — |
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)→IntermitSoundis_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:
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 0x20000000–0x2000FFFF.
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 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):
is_continuousderivation. DRW deliberately stores only the on-disk fields. We computeIsContinuous => BaseChance == 0fat load. §3.1.- Index → object resolution. DRW hands back raw
StbIndexandTerrainType.SceneTypesindices with0xFFFFFFFFsentinels. Retail resolves them once at unpack; we need the equivalent resolve step (or resolve on lookup, which is whatGetSTBDescdoes anyway) and must honour0xFFFFFFFF == none. - The whole
Ambientruntime:AmbientSTBDescshared state (sound_tablecache,stb_not_foundnegative 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 ambientoverreferences/ACViewer/returns only render-pass / ambient-light hits). CLandBlock/LScapegather — the terrain-word decode, the 8×8 land-cell walk with the 9-wide row stride, thering == 13×3 landblock gate, and theCellManager::ChangePositiontrigger point. All ours.Random::RollDice(min_rate, max_rate)for the intermittent interval. Verify our RNG matches retail'sRollDicesemantics before wiringmin_rate/max_rate.
8. Answers to the posed questions
-
Complete chain.
RegionDBObj0x13000000+regionNumber→SoundInfo (CSoundDesc)→AmbientSTBDesc[]; each descriptor'sstb_idis aSoundTableDID in0x20000000–0x2000FFFF, fetched viaDBObj::Get(QualifiedDataID(id, cacheType=0x22)); each descriptor carries NAmbientSoundDesc { stype, volume, base_chance, min_rate, max_rate }, andbase_chance == 0selectsConstantSoundwhile non-zero selectsIntermitSound. Selection is reached indirectly:TerrainInfo.TerrainTypes[t].SceneTypes[s]→SceneInfo.SceneTypes[idx]→.StbIndex→SoundInfo.STBDesc[stbIdx]. -
Outdoor selection. Per land cell, from the landblock's 9×9
uint16terrain array:terrainType = (w >> 2) & 0x1F,sceneOrdinal = w >> 11; bounds-checked againstNumSceneType(terrainType); resolved byCRegionDesc::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 ownobjcell_id. -
Indoor. Nowhere.
CEnvCell::add_ambient_soundsis an ICF-folded empty stub, and the EnvCell dat record has no sound field. Interiors flaggedseen_outsidestill get the outdoor set. -
Lifecycle. Built in
CellManager::ChangePositiononly whenload_pos.objcell_id != newPos.objcell_id || curr_cell == NULL, in the exact orderInitSounds→CEnvCell::add_ambient_sounds(no-op) →LScape::add_ambient_sounds(if outdoor-ish) →UpdatePlayQueue→ReleaseSoundTables. Teardown isCellManager::Reset→Ambient::FlushSoundTables; final destruction isAmbient::Destroy. -
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_countasymmetry 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 |