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>
51 KiB
Lane 2 — Retail ambient-sound runtime family, fully decoded
Research-only note. Sources: docs/research/named-retail/acclient_2013_pseudo_c.txt
(Binary Ninja pseudo-C, PDB-named), docs/research/named-retail/acclient.h
(verbatim retail structs), plus live byte-level disassembly of the
PDB-paired binary C:\Users\erikn\Downloads\acclient.exe (v11.4186,
CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) with capstone, used to
resolve every FPU-elided constant and every test ah, 0x41 / 0x44 / 0x05
comparison the BN decomp renders as an unimplemented bool p. Every
comparison direction and every float in this document is byte-verified, not
inferred. That matters: BN's rendering of these compares is ambiguous in
both directions, and three of them (is_continuous, CanHear, PlayNow)
would have been ported backwards from the pseudo-C alone.
0. Executive summary — what retail's ambient system actually is
Retail's ambient system is not a set of looping voices attached to a landblock. It is a weighted accumulation + timer queue:
- On every objcell change (outdoors: every 24 m land-cell crossing;
CellManager::ChangePosition), the client rebuilds the ambient weighting from scratch. - It walks the 3×3 landblock neighbourhood around the viewer (LOD ring
≤ 1), and for each of the 64 land cells in each of those 9 landblocks
reads that cell's terrain word →
(terrainType, sceneIndex)→ the region'sSceneType.SoundTableDesc(anAmbientSTBDesc). - Each hit contributes an inverse-square weight (1.0 inside 20 m,
(20/d)²out to 120 m, 0 beyond) and a compass direction to everyAmbientSoundobject in that STB desc. - Volume (constant sounds) and trigger probability (intermittent sounds) are then that sound's accumulated weight divided by the total weight of all ambients — i.e. a genuine crossfade by terrain share.
- Playback is driven by a min-heap of absolute deadlines (
doubleseconds,Timer::cur_time), popped once per frame fromSmartBox::UseTime→Ambient::UseTime. Each pop plays a one-shot and re-inserts itself atcur_time + GetPlayInterval(). - There are no looping OpenAL-style voices anywhere. A "continuous"
ambient is a one-shot re-fired every
min_rateseconds, played non-positionally (from the listener's centre), at a crossfaded volume. An "intermittent" ambient is a one-shot played positionally at a random compass bearing and distance, at its full authored volume, gated by a probability roll. - Indoors is silent.
Ambient::AddSoundhas exactly one caller in the whole binary:CLandBlock::add_ambient_sounds. There is no EnvCell / dungeon ambient contributor. InCellManager::ChangePositiontheLScape::add_ambient_soundscall is gated on the "outdoors or seen_outside" flag; when it is false nothing is added, every weight is 0, and every ambient goes inaudible. - No day/night, no time-of-day, no weather gating. The selection input
is the baked terrain/scene map only.
GameTime,SkyDesc, andDayGroupnever touch the ambient path.
1. Struct layouts (verbatim retail + byte offsets)
Offsets verified against operator new sizes and the AddDir/UpdateSound
disassembly.
struct AmbientSound // base, 0x18 bytes
{
AmbientSoundVtbl *vfptr; // +0x00
int on_queue; // +0x04 1 = has a pending deadline in the heap
float sound_count; // +0x08 accumulated weight this rebuild
AmbientSTBDesc *desc; // +0x0C
unsigned int ambient_sound_id; // +0x10 index into desc->ambient_sounds
int constant_sound; // +0x14 (written 0 at construction; never read)
};
struct __cppobj IntermitSound : AmbientSound // 0x80 bytes (operator new(0x80))
{
float play_chance; // +0x18
float min_dist[8]; // +0x1C .. +0x3B
float max_dist[8]; // +0x3C .. +0x5B
unsigned int num_dir; // +0x5C
LandDefs::Direction sound_dir[8]; // +0x60 .. +0x7F
};
struct __cppobj ConstantSound : AmbientSound // 0x1C bytes (operator new(0x1c))
{
float current_volume; // +0x18
};
struct AmbientSoundVtbl // vtable slot offsets
{
void (*ResetCount) (AmbientSound*); // +0x00
float (*GetVolume) (AmbientSound*); // +0x04
int (*CanHear) (AmbientSound*); // +0x08
int (*PlayNow) (AmbientSound*); // +0x0C
float (*GetPlayInterval) (AmbientSound*); // +0x10
void (*AddTo) (AmbientSound*, float, Vector3*, LandDefs::Direction); // +0x14
void (*UpdateSound) (AmbientSound*, float); // +0x18
int (*GetSoundPos) (AmbientSound*, Position*); // +0x1C
};
struct AmbientSTBDesc // 0x1C bytes (memset(this,0,0x1C))
{
IDClass<DataID> stb_id; // +0x00 SoundTable DID
int stb_not_found; // +0x04 negative cache
SmartArray<AmbientSoundDesc*> ambient_sounds; // +0x08 m_data, +0x0C m_size, +0x10 m_num
CSoundTable *sound_table; // +0x14 lazily loaded DBObj
unsigned int play_count; // +0x18 # audible hits since last reset
};
struct AmbientSoundDesc // 0x18 allocated; 0x14 packed on disk
{
SoundType stype; // +0x00
int is_continuous; // +0x04 DERIVED at unpack, not stored
float volume; // +0x08
float base_chance; // +0x0C
float min_rate; // +0x10
float max_rate; // +0x14
};
struct Ambient // owned by CellManager / SmartBox
{
Position player_pos; // +0x00 (Position is 0x48 bytes, origin at +0x3C)
float total_sound_count; // +0x48
unsigned int num_sounds; // +0x4C
DArray<AmbientSound*> sounds; // +0x50 data, blocksize 8, initial sizeOf 8
PQueueArray<double> sound_queue; // min-heap of absolute play deadlines
};
On-disk AmbientSTBDesc (AmbientSTBDesc::UnPack, 0x5518f0)
uint32 stb_id
uint32 count
count × {
uint32 stype
float volume
float base_chance
float min_rate
float max_rate
}
pack_size = count*0x14 + 8. is_continuous is computed, not read:
0x5519a9 fld dword [ebx+0xC] ; base_chance
0x5519ac fcomp qword [0x794610] ; = 0.0 (byte-verified)
0x5519b4 test ah, 0x44 ; C3|C2 → the x87 "equal" test
0x5519b7 jp .zero
0x5519b9 mov eax, 1
...
0x5519c2 mov dword [ebx+4], eax ; is_continuous
→ is_continuous = (base_chance == 0.0f). Matches ACE's
AmbientSoundDesc.IsContinuous => BaseChance == 0. Confirmed independently.
2. Constants (all byte-read from the binary)
| Symbol / address | Value | Units / meaning |
|---|---|---|
Ambient::ambient_sound_min_dist 0x81f148 |
20.0 | m — full-weight radius |
Ambient::ambient_sound_min_dist_sq 0x81f14c |
400.0 | m² |
Ambient::ambient_sound_max_dist 0x81f150 |
120.0 | m — cull radius |
Ambient::ambient_sound_max_dist_sq 0x81f154 |
14400.0 | m² |
Ambient::ambient_sound_min_vol 0x81f158 |
0.03 | linear (≈ −30.5 dB) audibility floor for ConstantSound |
SoundManager::ambient_sounds_enabled 0x81f06c |
1 | user pref Sound_AmbientSoundDisabled |
SoundManager::ambient_sound_volume 0x81f070 |
1.0 | user pref Sound_AmbientSoundVolume |
heading spread 0x81f1b0 |
0.392699093 rad | π/8 = 22.5° total cone (±11.25°) |
F_EPSILON 0x7cb0a0 |
0.0002 | axis-degeneracy epsilon in CalcDir |
| in-viewer-block threshold | min_dist_sq * 0.5 = 200.0 m² |
⇒ 14.142 m |
| own-block near/far | 4.0 m / 10.0 m | 5.0f − 1.0f and min_dist*0.5 |
diagonal ratio gate 0x7c5e24 |
2.0 | |y|/|x| ≤ 2 and |x|/|y| ≤ 2 ⇒ diagonal |
VOL_MIN_DIST 0x7caeac |
5.0 | m — attenuation knee |
VOL_MIN_DIST_SQ 0x86f404 |
25.0 | m² (runtime-initialised 5f*5f) |
LandDefs::square_length 0x799128 |
24.0 | m per land cell |
rand normaliser 0x7caf50 |
3.0518509e-05 | = 1/32768 (MSVC rand() range) |
LandDefs::heading(Direction) — jump table at 0x5a9a7c, radians:
| Direction | value | heading |
|---|---|---|
IN_VIEWER_BLOCK 0 (and out of range) |
0.0 | — |
NORTH_OF_VIEWER 1 |
0.0 | 0° |
SOUTH_OF_VIEWER 2 |
3.14159274 | 180° |
EAST_OF_VIEWER 3 |
1.57079637 | 90° |
WEST_OF_VIEWER 4 |
4.71238899 | 270° |
NORTHWEST_OF_VIEWER 5 |
5.49778700 | 315° |
SOUTHWEST_OF_VIEWER 6 |
3.92699075 | 225° |
NORTHEAST_OF_VIEWER 7 |
0.78539819 | 45° |
SOUTHEAST_OF_VIEWER 8 |
2.35619450 | 135° |
3. Q1 — ConstantSound vs IntermitSound
ConstantSound (base_chance == 0) |
IntermitSound (base_chance != 0) |
|
|---|---|---|
| Volume | crossfaded: volume × sound_count / total |
fixed at authored volume |
| Trigger probability | none — PlayNow is a folded mov eax,1; ret → always |
RollDice(0,1) ≤ play_chance |
play_chance |
n/a | base_chance × sound_count / total |
| Re-fire interval | fixed min_rate s |
RollDice(min_rate, max_rate) s |
| Position | none — base GetSoundPos is xor eax,eax; ret 4 → returns 0 ⇒ PlayAmbientSoundFromCenter (non-positional) |
random compass bearing + distance ⇒ PlayAmbientSound (3D) |
| Audibility | current_volume ≥ 0.03 and desc->sound_table != null |
play_chance > 0 |
| Direction tracking | none (AddTo only accumulates weight) |
accumulates up to 8 (dir, min_dist, max_dist) slots |
| Looping? | NO. Re-fired one-shot every min_rate s. |
one-shot |
ConstantSound::UpdateSound (0x551540) — verbatim
0x551540 fld [ecx+8] ; sound_count
0x551543 fcomp [0x795344] ; = 0.0
0x55154b test ah, 0x44 ; equal test
0x55154e jp .compute
current_volume = 0.0f; return; // sound_count == 0
.compute:
desc->play_count++;
current_volume = desc->ambient_sounds[id]->volume // [eax+8]
/ total_sound_count // [esp+4] = arg
* sound_count; // [ecx+8]
void UpdateSound(float total) // ConstantSound
{
if (sound_count == 0f) { current_volume = 0f; return; }
desc.play_count++;
current_volume = desc.ambient_sounds[id].volume / total * sound_count;
}
IntermitSound::UpdateSound (0x551310) — verbatim
0x551310 fld [ecx+8] ; sound_count
0x551313 fcomp [0x795344] ; = 0.0
0x55131b test ah, 0x41 ; below|equal
0x55131e jne .skip ; sound_count <= 0 → leave play_chance alone
desc->play_count++;
play_chance = desc->ambient_sounds[id]->base_chance // [eax+0xC]
/ total_sound_count
* sound_count;
.skip:
void UpdateSound(float total) // IntermitSound
{
if (sound_count <= 0f) return; // NOTE: does NOT zero play_chance
desc.play_count++;
play_chance = desc.ambient_sounds[id].base_chance / total * sound_count;
}
Gotcha: the intermittent path never clears play_chance. The only
zeroing is IntermitSound::ResetCount, which Ambient::InitSounds calls on
every rebuild before the accumulation pass. Get that ordering wrong and a
stale bearing/chance survives a cell change.
void ResetCount() // IntermitSound (0x550cd0)
{ desc.play_count = 0; sound_count = 0f; num_dir = 0; play_chance = 0f; }
void ResetCount() // ConstantSound (0x550d70)
{ desc.play_count = 0; sound_count = 0f; } // NOTE: current_volume NOT reset
4. Q2 — CanHear: the audibility test
Both are pure state tests — no distance test, no cell/indoor test, no
time-of-day test. Distance and indoor-ness enter earlier, through the weight
accumulation (Ambient::AddSound culls at 120 m; indoors nothing is added
at all, so all weights are 0).
IntermitSound::CanHear 0x550f80
fld [ecx+0x18] ; play_chance
fcomp [0x795344] ; 0.0
test ah, 0x41 ; below|equal
jne → return 0
return 1
⇒ return play_chance > 0.0f;
ConstantSound::CanHear 0x550fd0
call vtable[+4] ; GetVolume() = current_volume
fcomp [0x81f158] ; ambient_sound_min_vol = 0.03
test ah, 5 ; below
jp → .check ; (NOT below → continue)
return 0 ; (below → inaudible)
.check:
return desc->sound_table != nullptr;
⇒ return current_volume >= 0.03f && desc.sound_table != null;
The 0.03 floor is the only "silence" threshold in the system: a constant ambient whose terrain share drops below 3% of the total stops being scheduled entirely. In dB that is ceil(20·log₁₀(0.03)) = −30 dB.
AmbientSound base defaults (COMDAT-folded stubs, all byte-verified):
| slot | folded symbol | actual code | effect |
|---|---|---|---|
ResetCount |
IDClass::~IDClass 0x694750 |
ret |
no-op |
GetVolume |
MediaDesc::GetDuration 0x69ce00 |
fld [0.0]; ret |
0.0f |
CanHear |
Client::You_Must_Not_… 0x508960 |
xor eax,eax; ret |
0 |
PlayNow |
(ConstantSound slot 0x7cb0f0) FileNodeName_UInt32::GetType 0x5269f0 |
mov eax,1; ret |
1 — always play |
GetPlayInterval |
MediaDesc::GetDuration |
fld [0.0]; ret |
0.0f |
AddTo / UpdateSound |
folded | ret |
no-op |
GetSoundPos |
DBOCache::GetCollection 0x4f0ea0 |
xor eax,eax; ret 4 |
0 — "no position" |
The two that matter are ConstantSound's inherited PlayNow (always true)
and inherited GetSoundPos (returns 0 ⇒ non-positional). Do not read the
BN vtable dump's symbol names as semantics — they are unrelated functions
that happened to fold to the same bytes.
5. Q3 — GetVolume: how ambient volume is computed
float ConstantSound.GetVolume() => current_volume; // 0x550d80
float IntermitSound.GetVolume() => desc.ambient_sounds[id].volume; // 0x551070, fld [eax+8]
That is the only ambient-specific volume. The full chain to the mixer:
ConstantSound:
v0 = authoredVolume * (sound_count / total_sound_count) // crossfade
v1 = v0 * ambient_sound_volume // PlayAmbientSoundFromCenter (0x5508cf)
v2 = GetAttenuation(dist = 0, v1, out mB, isAmbient = 1)
→ no distance falloff (dist < 5 m knee)
→ clamp v ≤ 1.0
→ v *= ambient_sound_volume ← *** APPLIED A SECOND TIME ***
→ mB = (int)ceil(20*log10(v)); reject if < VOL_MIN
PlaySoundInternal(buf, null, mB) // no 3D pan
IntermitSound:
v0 = authoredVolume // NOT crossfaded
v1 = v0 * ambient_sound_volume // PlayAmbientSound (0x55083b)
PlaySoundInternal(buf, pos, v1, isAmbient = 1)
→ heading/pan from Position::heading vs listener heading
→ dist = Position::distance(pos, listener)
→ GetAttenuation(dist, v1, out mB, 1)
if (dist > 5.0f) v = v1 * 25.0f/(dist*dist); else v = v1
clamp v ≤ 1.0
v *= ambient_sound_volume ← *** SECOND TIME AGAIN ***
mB = (int)ceil(20*log10(v))
Divergence-register-worthy retail quirk: ambient_sound_volume is
applied twice on every ambient — once in PlayAmbientSound[FromCenter]
and again inside GetAttenuation(…, arg4 != 0). At the default 1.0 this is
invisible; at a 0.5 slider ambients are 0.25×, i.e. the slider is
effectively squared. A faithful port must reproduce this or record it as an
intentional divergence.
GetAttenuation (0x550020), byte-exact:
0x550020 fld [esp+4] ; dist
0x550024 fcomp [0x7caeac] ; 5.0f
0x55002c test ah, 5 ; jp .far ; dist < 5 → v = volume
0x550031 fld [esp+8] ; (near path)
.far: fld [0x86f404] ; VOL_MIN_DIST_SQ = 25.0
fmul [esp+8] ; * volume
fld [esp+4]; fmul [esp+4] ; dist*dist
fdivp ; v = 25*volume/dist²
.clamp: fcom qword [0x7928c0] ; 1.0
test ah,0x41; jne .keep; v = 1.0
fmul (arg4 ? ambient_sound_volume : effect_sound_volume)
fcom 0.0; if (v <= 0) { *out = VOL_MIN; return 0; }
fldln2; fyl2x; fmul C1; fmul C2; ceil; ftol ; → integer dB
if (*out < VOL_MIN) { *out = VOL_MIN; return 0; }
return 1
SoundManager::SetVolume later multiplies by 100 → DirectSound millibels.
There is one further gate inside PlayAmbientSound that our earlier doc
missed entirely — a second, independent probability roll against the
SoundTable entry's own probability_:
0x550861 mov eax, [esp+0x14] ; SoundData.probability_ (SoundData+8)
0x550869 call [rand]
0x550873 fild ; fmul [0x7caf50] ; rand()/32768
0x55087d fcomp [esp+8] ; vs probability_
0x550883 test ah,5 ; jp .skip
PlaySoundInternal(...)
⇒ plays only if (rand()/32768.0f) < SoundData.probability_
and SoundManager::GetSound (0x550680) itself picks a random entry
from the sound table's SoundTableData for that SoundType:
if (table != null && table.Lookup(stype, out var td) && td.num_stdatas_ > 0) {
int i = (int)(RollDice(0,1) * td.num_stdatas_); // uniform pick
if (i < td.num_stdatas_) {
data.sound_id_ = td[i].Id; data.priority_ = td[i].Priority;
data.probability_ = td[i].Probability; data.volume_ = td[i].Volume;
buf = sound_hash_.find(data.sound_id_);
}
}
Note SoundData.volume_ is loaded but never used on the ambient paths —
the ambient's own volume wins.
So an intermittent ambient fires only when both rolls pass:
RollDice(0,1) ≤ play_chance and rand()/32768 < SoundData.probability_.
6. Q4 — GetSoundPos: where an ambient is positioned
- ConstantSound — inherits the base stub → returns 0 ⇒
PlayAmbientSoundFromCenter, i.e. no position at all, no pan, no distance attenuation. It is a stereo bed centred on the listener. - IntermitSound (
0x551350) — offsets the listener's own Position (SoundManager::player_position_, copied inAmbient::Play) in the XY plane, keeping the listener's Z and objcell_id:
int GetSoundPos(ref Position pos) // 0x551350
{
int idx = (int)Math.Floor(RollDice(0f, (float)num_dir)); // pick one accumulated dir
var dir = sound_dir[idx];
const float spread = 0.392699093f; // π/8 rad = 22.5°
float angle = LandDefs.Heading(dir) // radians, N=0 CW
+ RollDice(0f, spread)
- spread * 0.5f; // ⇒ ±11.25° jitter
float min = min_dist[idx], max = max_dist[idx];
float t = RollDice(0f, 1f);
float dist = min + (max - min) * t * t; // t² — biased toward `min`
pos.frame.origin.x += MathF.Sin(angle) * dist;
pos.frame.origin.y += MathF.Cos(angle) * dist;
// pos.frame.origin.z unchanged (0x55143d re-stores the saved z)
// pos.objcell_id unchanged (the listener's cell)
return 1;
}
fsin/fcos on angle with x += sin, y += cos is AC's standard
compass convention (N = +Y, E = +X).
The (min_dist, max_dist) pairs come from AddTo/AddDir:
void AddTo(float weight, in Vector3 offset, LandDefs.Direction dir) // 0x551450
{
const float half = 20.0f * 0.5f; // ambient_sound_min_dist * 0.5 = 10 m
float dist = MathF.Sqrt(offset.LengthSquared()); // 0x551486 fsqrt — byte-verified
sound_count += weight;
if (dir != LandDefs.Direction.IN_VIEWER_BLOCK) {
AddDir(dir, dist - half, dist + half); // a 20 m-thick shell at that bearing
return;
}
// source is within 14.14 m of the listener: it could be anywhere around them
foreach (var d in new[]{ NORTH, SOUTH, EAST, WEST,
NORTHWEST, SOUTHWEST, NORTHEAST, SOUTHEAST })
AddDir(d, 4.0f, half); // 4 m .. 10 m in all 8 directions
}
void AddDir(LandDefs.Direction dir, float min, float max) // 0x550cf0
{
int i = IndexOf(sound_dir, 0, num_dir, dir); // linear scan
if (i == num_dir) { // append
sound_dir[i] = dir; max_dist[i] = max; min_dist[i] = min; num_dir++;
return;
}
if (min < min_dist[i]) min_dist[i] = min; // 0x550d41 test ah,5 → strict below
if (max > max_dist[i]) max_dist[i] = max; // 0x550d58 test ah,0x41 → strict above
}
num_dir can never exceed 8 (either one of dirs 1–8, or all eight from the
IN_VIEWER_BLOCK expansion), so the fixed arrays are safe.
Ambient::CalcDir (0x550e40) — byte-exact classification of the
listener→source offset:
LandDefs.Direction CalcDir(in Vector3 v)
{
float ax = MathF.Abs(v.x), ay = MathF.Abs(v.y);
float d2 = v.x*v.x + v.y*v.y; // XY only — Z ignored
if (d2 < 200.0f) return IN_VIEWER_BLOCK; // min_dist_sq*0.5 ⇒ 14.142 m
if (ax < 0.0002f) goto NS; // degenerate x
if (ay / ax > 2.0f) goto NS; // predominantly N/S
if (ay < 0.0002f) goto EW; // degenerate y
if (ax / ay > 2.0f) goto EW; // predominantly E/W
// both ratios <= 2 → diagonal quadrant
return v.x >= 0 ? (v.y >= 0 ? NORTHEAST : SOUTHEAST)
: (v.y >= 0 ? NORTHWEST : SOUTHWEST);
EW: return v.x < 0 ? WEST : EAST;
NS: return v.y < 0 ? SOUTH : NORTH;
}
Geometrically: an 8-way compass rose where each cardinal owns the wedge outside a 2:1 slope ratio and each diagonal owns the 2:1..1:2 band — cardinals get ~53° each, diagonals ~37° each.
Ambient::CalcWeight (0x550dd0):
float CalcWeight(in Vector3 v)
{
float d2 = v.x*v.x + v.y*v.y + v.z*v.z;
if (d2 > 14400.0f) return 0.0f; // > 120 m → cull
if (d2 < 400.0f) return 1.0f; // < 20 m → full weight
return 400.0f / d2; // (20/d)² inverse-square
}
At the 120 m cull edge the weight is 400/14400 = 0.0278.
7. Q5 — GetPlayInterval: the re-trigger cadence
float IntermitSound.GetPlayInterval() // 0x551080
=> RollDice(desc.ambient_sounds[id].min_rate, // [eax+0x10]
desc.ambient_sounds[id].max_rate); // [eax+0x14]
float ConstantSound.GetPlayInterval() // 0x5510a0
=> desc.ambient_sounds[id].min_rate; // [eax+0x10] only — max_rate unused
Random::RollDice(min, max) (0x42c600), byte-exact:
static float RollDice(float min, float max)
{
if (min == max) return min;
float lo = min, hi = max;
if (max < min) { lo = max; hi = min; } // 0x42c634: swap on inverted range
float r = UniformUnit(); // call 0x42c4c0 → [0,1)
return lo + (hi - lo) * r;
}
Units are seconds; the deadline is absolute (Timer::cur_time is a
double seconds clock) and inserted into a min-heap.
For a "continuous" ambient, min_rate is effectively the loop period the
content author chose for that wave. That is how retail fakes a loop without
a looping voice — and it is why a naive AL_LOOPING port sounds wrong
(no re-randomised table pick, no re-rolled crossfade volume, no gap).
8. Q6 — who ticks these, and in what order
SmartBox::UseTime (0x455410) is the per-frame game tick. Exact order:
if (!cell_manager->blocking_for_cells) {
if (!all_cells_available && CheckPrefetchStatus()) UpdateLoadPoint();
if (player && player->m_position.objcell_id)
CellManager::ChangePosition(&player->m_position, /*blocking*/0); // ← ambient REBUILD
...position_update_complete / has_been_teleported bookkeeping...
CObjectMaint::UseTime();
CPhysics::UseTime();
if (GameTime::current_game_time) { GameTime::UseTime(); LScape::UseTime(); }
Ambient::UseTime(ambient_sounds); // ← ambient PLAYBACK (last)
} else CheckPrefetchStatus();
SceneTool::Think();
...inbound netblob drain...
So: cell/streaming first, then object maintenance, then physics, then
game-time/sky, then ambients last in the pre-network block. ChangePosition
is called every frame but only does work when the objcell changed.
void Ambient.UseTime() // 0x551880
{
if (!SoundManager.ambient_sounds_enabled) return;
while (sound_queue.curNumNodes > 0) {
var node = sound_queue.A; // heap root = earliest deadline
if (node == null) break;
if (!(node.key < Timer.cur_time)) break; // 0x5518bc test ah,1 → strict below
sound_queue.RemoveMin(out _, out AmbientSound s);
Play(s); // plays AND re-inserts
}
}
void Ambient.UpdatePlayQueue() // 0x551a50
{
if (!SoundManager.ambient_sounds_enabled) return;
for (int i = 0; i < num_sounds; i++) {
var s = sounds[i];
s.UpdateSound(total_sound_count); // recompute volume / chance
if (s.on_queue == 0) Play(s); // (re)arm — first play is IMMEDIATE
}
}
void Ambient.Play(AmbientSound s) // 0x5517a0
{
Position pos = SoundManager.player_position_; // copy (objcell_id + frame)
if (!s.CanHear()) { s.on_queue = 0; return; } // ← drops out of the heap, no re-arm
if (s.PlayNow()) {
bool positioned = s.GetSoundPos(ref pos) != 0;
var stype = s.desc.ambient_sounds[s.ambient_sound_id].stype;
var table = s.desc.sound_table;
if (positioned) SoundManager.PlayAmbientSound(stype, table, pos, s.GetVolume());
else SoundManager.PlayAmbientSoundFromCenter(stype, table, s.GetVolume());
}
sound_queue.Insert(Timer.cur_time + s.GetPlayInterval(), s);
s.on_queue = 1;
}
Two behaviours worth calling out:
UpdatePlayQueuearmson_queue == 0sounds immediately — a newly audible ambient fires on the frame you cross into range, then schedules. There is no initial random delay.CanHear() == falseun-arms and does not reschedule. An ambient that goes inaudible silently leaves the heap; it can only come back on the nextUpdatePlayQueue, i.e. the next objcell change. Already-queued sounds that stay audible are not re-armed (theon_queue == 0guard), so their cadence carries smoothly across cell boundaries — no restart click.
9. Q7 — day/night / time-of-day: there is none
Definitively refuted. Ambient::AddSound has exactly one caller in the
binary (grep over the full 1.4 M-line pseudo-C):
314293:005303ff Ambient::AddSound(arg2, eax_7, &var_48); ← CLandBlock::add_ambient_sounds
and the STB desc it passes comes from a pure static lookup:
AmbientSTBDesc CRegionDesc.GetSTBDesc(uint terrainType, uint sceneIdx) // 0x4feab0
{
var d = terrain_info.GetSTBDesc(terrainType, sceneIdx);
if (d == null) return null;
if (d.sound_table == null) d.InitSoundTable(); // lazy DBObj::Get(stb_id, type 0x22)
return (d.sound_table != null) ? d : null;
}
AmbientSTBDesc CTerrainDesc.GetSTBDesc(uint t, uint s) // 0x502400
{
if (t >= terrain_types.m_num) return null;
var tt = terrain_types[t]; // CTerrainType
if (s >= tt.scene_types.m_num) return null; // == NumSceneType(t)
var st = tt.scene_types[s]; // CSceneType
return st?.sound_table_desc; // CSceneType + 0x10
}
GameTime, SkyDesc.present_day_group, DayGroup, SkyTimeOfDay, and the
weather/fog descs are never consulted. DayGroup carries only
day_name / chance_of_occur / sky_time / sky_objects — sky visuals only.
AmbientSTBDesc::InitSoundTable (0x4fea60):
bool InitSoundTable() {
if (stb_not_found != 0) return false;
if (stb_id == INVALID_DID) return false;
sound_table = DBObj.Get(new QualifiedDataID(stb_id, 0x22)); // 0x22 = SoundTable
if (sound_table != null) return true;
stb_not_found = 1; // negative cache, never retried
return false;
}
Where the STB desc actually lives in region.dat
CRegionDesc::sound_info (CSoundDesc) is the storage; the terrain /
scene tables are the selector. Region unpack (0x4ff746) resolves it:
for each CSceneType:
uint32 stbIndex = read();
sceneType->sound_table_desc = (stbIndex != 0xFFFFFFFF)
? soundDesc->stb_desc[stbIndex]
: nullptr;
CSceneType::unpack(...) // scene_name + scene DIDs
Which maps exactly onto the model our DatReaderWriter package already
exposes (verified: SoundDesc, AmbientSTBDesc, AmbientSoundDesc,
SceneType.StbIndex, Region.SoundInfo are all present in
chorizite.datreaderwriter/1.0.0):
Region (0x13000000)
├─ SoundInfo.STBDesc[] : AmbientSTBDesc { STBId, AmbientSounds[] }
├─ SceneInfo.SceneTypes[] : SceneType { StbIndex, Scenes[] }
└─ TerrainInfo.TerrainTypes[] : TerrainType { TerrainName, TerrainColor, SceneTypes[] }
resolve(terrainWord):
terrainType = (terrainWord >> 2) & 0x1F
sceneIdx = (terrainWord >> 11) & 0x1F
if (terrainType >= TerrainInfo.TerrainTypes.Count) → none
sceneTypeList = TerrainInfo.TerrainTypes[terrainType].SceneTypes
if (sceneIdx >= sceneTypeList.Count) → none // == NumSceneType
sceneTypeIdx = sceneTypeList[sceneIdx]
if (sceneTypeIdx >= SceneInfo.SceneTypes.Count) → none
stbIndex = SceneInfo.SceneTypes[sceneTypeIdx].StbIndex
if (stbIndex == 0xFFFFFFFF) → none
return SoundInfo.STBDesc[stbIndex]
This is the identical walk src/AcDream.Core/World/SceneryGenerator.cs
lines 100–112 already performs for procedural scenery. The ambient port
should reuse that exact decode (and the same >> 2 & 0x1F / >> 11 & 0x1F
bit fields) rather than re-deriving it. Note retail iterates only the 8×8
land cells (side_cell_count), reading the SW vertex's terrain word
from the 9×9 grid (side_vertex_count = 9, row stride 0x12 = 9 × 2 bytes)
— scenery iterates 9×9 vertices, ambients iterate 8×8 cells. Do not copy the
loop bounds.
10. Q8 — cell/landblock transition: start & stop
The rebuild, from CellManager::ChangePosition (0x4559b0)
void ChangePosition(Position pos, int blocking)
{
if (pos.objcell_id == 0) { Reset(); return; } // → Ambient::FlushSoundTables
int b = blocking_for_cells != 0 ? 1 : blocking;
if (load_pos.objcell_id != pos.objcell_id || curr_cell == null)
{
PreFetchCells(pos.objcell_id, b);
... resolve curr_cell, master_incell_timestamp++, clear world lights ...
CEnvCell.flush_cells();
if (curr_cell != null)
{
bool outdoors = (seenOutsideFlag || curr_cell.seen_outside != 0);
if (outdoors) { ...sunlight from LScape, SetWorldAmbientLight(calc_object_light) ... }
else { SetWorldAmbientLight(0.2f, 0xFFFFFFFF); }
Ambient::InitSounds(ambient_sounds, pos); // 1. reset every count, latch listener
/* 0x455b0a: call 0x694750 — a folded empty `ret`.
This is where an indoor/EnvCell ambient contributor would have gone;
in the shipped 2013 build it does nothing. */
if (outdoors)
LScape::add_ambient_sounds(lscape, ambient_sounds); // 2. accumulate
Ambient::UpdatePlayQueue(ambient_sounds); // 3. recompute + arm
Ambient::ReleaseSoundTables(ambient_sounds); // 4. free tables nobody used
}
}
load_pos = pos;
}
void Ambient.InitSounds(Position pos) // 0x5515d0
{
player_pos = pos; // objcell_id + Frame copy
total_sound_count = 0f;
for (int i = 0; i < num_sounds; i++) sounds[i].ResetCount();
}
void Ambient.AddSound(AmbientSTBDesc desc, in Position at) // 0x551610
{
if (!SoundManager.ambient_sounds_enabled) return;
Vector3 off = player_pos.GetOffset(at); // block-corrected listener→source
float d2 = off.x*off.x + off.y*off.y + off.z*off.z;
if (d2 > 14400.0f) return; // 0x551658 test ah,0x41; je
float w = CalcWeight(off);
var dir = CalcDir(off);
if (w == 0.0f) return; // 0x551689 test ah,0x44; jnp
total_sound_count += w;
for (uint i = 0; i < desc.ambient_sounds.m_num; i++)
GetSound(desc, i).AddTo(w, off, dir); // creates the object on first use
}
AmbientSound Ambient.GetSound(AmbientSTBDesc desc, uint id) // 0x5510b0
{
for (int i = 0; i < num_sounds; i++)
if (sounds[i].desc == desc && sounds[i].ambient_sound_id == id) return sounds[i];
sounds.grow_check(num_sounds);
bool cont = desc.ambient_sounds[id].is_continuous != 0;
var s = cont ? (AmbientSound)new ConstantSound() // operator new(0x1C)
: new IntermitSound(); // operator new(0x80)
s.desc = desc; s.ambient_sound_id = id; s.on_queue = 0; s.sound_count = 0;
sounds[num_sounds++] = s;
return s;
}
The contributors
void LScape.add_ambient_sounds(Ambient a) // 0x505810
{
for (int by = 0; by < mid_width; by++)
for (int bx = 0; bx < mid_width; bx++) {
get_block_orient(by, bx, out int lod, out _);
if (lod != 1) continue; // ← ring ≤ 1 only ⇒ 3×3 landblocks
land_blocks[mid_width*by + bx]?.add_ambient_sounds(a);
}
}
// get_block_orient (0x504f90): ring = max(|bx-mid_radius|, |by-mid_radius|)
// ring <= 1 → lod 1 ; <= 2 → 2 ; <= 4 → 4 ; else 8
void CLandBlock.add_ambient_sounds(Ambient a) // 0x530310
{
var p = new Position { objcell_id = 0, frame = Frame.Identity }; // Frame::cache
for (int row = 0; row < side_cell_count /*8*/; row++)
for (int col = 0; col < side_cell_count; col++) {
var v = vertex_array.vertices[side_vertex_count /*9*/ * row + col];
p.frame.origin = new Vector3(v.x, v.y, v.z); // landblock-local
p.objcell_id = lcell[side_cell_count*row + col].id; // ← 0x5303C1, [esp+0x24]
ushort w = terrain[row*9 + col];
uint t = (uint)((w >> 2) & 0x1F), s = (uint)(w >> 11);
if (s >= CRegionDesc.NumSceneType(current_region, t)) continue;
var desc = CRegionDesc.GetSTBDesc(current_region, t, s);
if (desc != null) a.AddSound(desc, p);
}
}
Decomp trap. The BN pseudo-C shows
objcell_id(var_44) set to 0 and never updated, which makesPosition::get_offsetlook catastrophically broken (LandDefs::get_block_offset(id1, 0)returns garbage on theid2 == 0branch — it loadsid1's stack slot, not a zero). The disassembly shows BN mis-attributed the store:0x5303C1 mov [esp+0x24], edxwrites the land cell's own objcell_id (lcell[i] + 0x28) into the Position before everyAddSound. Anyone porting from the pseudo-C alone would conclude the whole outdoor path is dead code.
Position::get_offset (0x509f60) then does the block correction properly:
Vector3 GetOffset(in Position target) {
var blk = LandDefs.get_block_offset(this.objcell_id, target.objcell_id);
return blk + target.frame.origin - this.frame.origin;
}
// get_block_offset (0x43e630): 0 if same landblock; else
// (bx2 - bx1) * 24.0f in x, (by2 - by1) * 24.0f in y, 0 in z
// where bx = ((id >> 24) & 0xFF) * 8, by = ((id >> 16) & 0xFF) * 8
// (i.e. land-cell units × square_length 24 m ⇒ 192 m per landblock)
The teardown
void Ambient.ReleaseSoundTables() // 0x455770 (end of every rebuild)
{
for (int i = 0; i < num_sounds; i++) {
var d = sounds[i].desc;
if (d.sound_table != null && d.play_count == 0) {
d.sound_table.Release(); // vtable +0x14
d.sound_table = null; // → CanHear() false until reloaded
}
}
}
void Ambient.FlushSoundTables() // 0x452920 (CellManager::Reset)
{
total_sound_count = 0f;
for (int i = 0; i < num_sounds; i++) {
sounds[i].ResetCount();
var d = sounds[i].desc;
if (d.sound_table != null && d.play_count == 0) { d.sound_table.Release(); d.sound_table = null; }
}
}
void Ambient.Destroy() // 0x551580 (~Ambient, world exit)
{ for (...) delete sounds[i]; num_sounds = 0; total_sound_count = 0f; }
Key semantics: play_count is the "was this audible during this cell" flag.
UpdateSound increments it whenever sound_count > 0 (intermittent) or
sound_count != 0 (constant); ResetCount zeroes it at the start of every
rebuild. So the wave/sound-table memory for an ambient you just walked out of
range of is released on the very next cell change — a per-cell LRU of exactly
one generation.
Note also that neither FlushSoundTables nor ReleaseSoundTables clears the
AmbientSound list or the heap. CellManager::Reset (objcell_id → 0, i.e.
logout / pending teleport) leaves stale deadlines in the queue; they fire,
CanHear() returns false (counts were reset), and they quietly un-arm. The
list itself is only freed by ~Ambient, so sounds[] grows monotonically to
the set of every (STBDesc, index) pair the session ever visited — and
UpdatePlayQueue iterates all of them on every cell change.
11. Full lifecycle, cell load → audible → stopped
[frame N] SmartBox::UseTime
CellManager::ChangePosition(playerPos)
objcell_id unchanged → nothing (the common case)
[frame M] player crosses a 24 m land-cell boundary
CellManager::ChangePosition
PreFetchCells; resolve curr_cell; lights
Ambient::InitSounds(playerPos)
player_pos = playerPos; total = 0
∀ sounds: ResetCount() (play_count=0, sound_count=0,
num_dir=0, play_chance=0)
[indoor? → nothing added: the only contributor is gated on `outdoors`]
LScape::add_ambient_sounds
∀ landblock in the 3×3 ring (lod == 1)
∀ 64 land cells
terrainWord → (terrainType, sceneIdx)
→ TerrainType.SceneTypes[sceneIdx]
→ SceneDesc.SceneTypes[..].StbIndex
→ SoundDesc.STBDesc[..] (AmbientSTBDesc)
→ InitSoundTable() lazily loads SoundTable DID (type 0x22)
Ambient::AddSound(desc, cellPosition)
off = playerPos.GetOffset(cellPosition) // block-corrected
if |off|² > 14400 (120 m) → skip
w = 1 (<20 m) | 400/|off|² | 0
dir = 8-way compass, or IN_VIEWER_BLOCK if |off_xy|² < 200 (14.14 m)
total += w
∀ AmbientSoundDesc in desc:
GetSound(desc, i) // create on first use:
// base_chance==0 → ConstantSound
// else → IntermitSound
.AddTo(w, off, dir)
sound_count += w
(IntermitSound only) AddDir merge:
dir != IN_VIEWER_BLOCK → (|off|-10, |off|+10) at that bearing
dir == IN_VIEWER_BLOCK → (4, 10) at all 8 bearings
Ambient::UpdatePlayQueue
∀ sounds:
UpdateSound(total)
ConstantSound : current_volume = volume * sound_count/total (0 if count==0)
IntermitSound : play_chance = base_chance* sound_count/total (skip if count<=0)
if audible: desc.play_count++
if on_queue == 0 → Play(s) // FIRES IMMEDIATELY
Ambient::ReleaseSoundTables
∀ sounds with desc.play_count == 0 → release + null the SoundTable
Ambient::Play(s):
pos = SoundManager::player_position_
if !s.CanHear() → on_queue = 0; RETURN (leaves the heap)
ConstantSound : current_volume >= 0.03 && sound_table != null
IntermitSound : play_chance > 0
if s.PlayNow()
ConstantSound : always true
IntermitSound : RollDice(0,1) <= play_chance
positioned = s.GetSoundPos(ref pos)
ConstantSound : 0 → PlayAmbientSoundFromCenter(stype, table, GetVolume())
IntermitSound : 1 → pos offset to a random accumulated bearing ±11.25°,
distance min + (max-min)·t², listener Z + cell kept
→ PlayAmbientSound(stype, table, pos, GetVolume())
both then: volume *= ambient_sound_volume
GetSound(stype, table) → random SoundTableData entry
if rand()/32768 >= entry.probability_ → SILENT this fire
GetAttenuation(dist, vol, out mB, isAmbient=1)
dist > 5 m → vol *= 25/dist²; clamp 1.0
vol *= ambient_sound_volume (SECOND application)
mB = ceil(20·log10(vol)); reject below VOL_MIN
PlaySoundInternal(buf, pos|null, mB)
sound_queue.Insert(Timer::cur_time + s.GetPlayInterval(), s)
ConstantSound : min_rate (fixed)
IntermitSound : RollDice(min_rate, max_rate)
on_queue = 1
[every frame] Ambient::UseTime
while (heap.root.key < Timer::cur_time) Play(RemoveMin())
STOP paths:
• terrain share drops → volume < 0.03 / play_chance == 0 → CanHear false → un-armed
• walk indoors → nothing accumulated → all counts 0 → all un-armed next cell change
• > 120 m from every contributing cell → weight 0 → same
• CellManager::Reset (objcell_id 0: logout / teleport pending) → FlushSoundTables
• ~Ambient → Destroy (frees the objects)
NOTE: nothing ever *stops a playing voice*. Every ambient is a one-shot;
"stopping" just means it is never scheduled again.
12. Corrections to docs/research/deepdives/r05-audio-sound.md §7
Our existing ambient section is directionally right but wrong on almost every mechanism. Concretely:
| r05 §7 claim | Reality |
|---|---|
"queries terrainType for each corner of the current cell and picks the dominant AmbientSTBDesc by STBId" |
No dominance selection. It iterates 8×8 land cells across a 3×3 landblock ring (576 cells) and accumulates weights into every STB desc it finds. STBId is a SoundTable DID, not a selector. |
| "STBId is indexed by terrain type or region-specific rule" | The selector chain is terrainWord → (terrainType, sceneIdx) → TerrainType.SceneTypes[] → SceneDesc.SceneTypes[].StbIndex → SoundDesc.STBDesc[]. Same walk SceneryGenerator.cs already does. |
"If BaseChance == 0 → continuous loop on a dedicated voice" |
No loops, no dedicated voices. A one-shot re-fired every min_rate s, non-positional, at volume authored × share. |
"roll rand() < BaseChance" |
The chance is base_chance × sound_count / total_sound_count, not base_chance. Plus a second independent roll against the SoundTable entry's probability_. |
"positioned near the listener (at a small random offset)"; code sketch listenerPos + rng.InUnitSphere() * 8f |
Random pick among up to 8 accumulated compass bearings, jittered ±11.25° (π/16), distance min + (max−min)·t² where the (min,max) shell is 4–10 m for in-block sources and d±10 m for neighbours. Z is never offset — same plane as the listener. |
"every N seconds where N = rand() in [MinRate, MaxRate]" |
Correct for intermittent. Wrong for continuous, which uses min_rate only. |
| "On landblock change … stop all ambient voices associated with the outgoing STBId and start new ones" | Rebuild trigger is any objcell change (24 m outdoors), not landblock. Nothing is stopped; already-armed audible sounds keep their cadence (the on_queue == 0 guard), which is what prevents a restart click at every cell crossing. |
§7.1 "RegionDesc contains a SoundDesc field (when PartsMask & 0x01)" — implies SoundDesc is the selection |
Correct as storage. It is never the runtime selector; CRegionDesc::sound_info is only ever touched by pack/unpack/GetSubDataIDs. |
| — (not mentioned) | Indoors is silent. No EnvCell ambient contributor exists; the slot in ChangePosition is a folded empty ret. |
| — (not mentioned) | ambient_sound_volume is applied twice (in PlayAmbient* and again in GetAttenuation), so the slider is effectively squared. |
| — (not mentioned) | Final volume is quantised to integer dB (ceil(20·log10 v)) and floored at VOL_MIN. |
13. Port notes for acdream
Current state: OpenAlAudioEngine.StartAmbient (src/AcDream.App/Audio/OpenAlAudioEngine.cs:367)
only mints a handle; there is no ambient system. StopAmbient exists and
works against _ambientSources. grep finds no AmbientSTBDesc/SoundDesc
consumer anywhere in src/.
The retail model does not need StartAmbient at all. Every ambient is a
one-shot. The right shape is:
- A
RuntimeAmbientStateowner (Runtime layer, per Slice-J ownership rules) holdingplayer_pos,total_sound_count, theAmbientSoundlist, and aPriorityQueue<AmbientSound, double>of absolute deadlines. - Rebuild hook on the existing objcell-change signal — the same edge
ACDREAM_PROBE_CELL/PlayerMovementController.CellIdalready fires on. Not on landblock streaming events. - Contributor that reuses
SceneryGenerator's terrain-word decode but iterates 8×8 cells (SW vertex per cell) over the 3×3 landblock ring, not 9×9 vertices over the streaming window. - Playback through the existing
Play3D/ one-shot path plus a non-positional variant for constant sounds.SoundTablelookup already exists (AudioHookSink.PlayFromSoundTable/IEntitySoundTable), andSoundManager::GetSound's random-entry +probability_roll must be reused, not bypassed. - Data is already available:
chorizite.datreaderwriterexposesRegion.SoundInfo.STBDesc,AmbientSTBDesc.{STBId, AmbientSounds},AmbientSoundDesc.{SType, Volume, BaseChance, MinRate, MaxRate}, andSceneType.StbIndex. No new dat parser is required.
Ordering and edge cases that will bite (each already caused a retail-shaped bug class elsewhere in this codebase):
ResetCountmust run for every existing sound before accumulation.IntermitSound::UpdateSoundnever clearsplay_chance, so a missed reset leaves a stale bearing and a stale probability alive indefinitely.- The
on_queue == 0guard inUpdatePlayQueueis load-bearing. Re-arming unconditionally restarts every ambient on every 24 m crossing — audible as a machine-gun of one-shots. Re-arming never (e.g. only on landblock change) makes newly-audible ambients silent until the next landblock. - Weight normalisation is by
total_sound_count, the sum over all ambients, not per-desc. Getting the denominator wrong changes the crossfade, not just the level. CalcDir's IN_VIEWER_BLOCK threshold ismin_dist_sq * 0.5= 200 m² (14.142 m), notmin_dist(20 m). It is the only place that× 0.5appears on the squared value.GetSoundPos's distance ismin + (max−min)·t²— a quadratic bias towardmin. A linear lerp puts intermittent ambients audibly further away on average.- Distance attenuation is
25/d²past a 5 m knee, clamped to 1.0, then quantised to integer dB. An OpenALAL_INVERSE_DISTANCE_CLAMPEDmodel withAL_REFERENCE_DISTANCE = 5andAL_ROLLOFF_FACTOR = 1is the same curve; verify before substituting, per the WB-formula lesson. - The doubled
ambient_sound_volumeand the integer-dB quantisation are both retail deviations from "obvious" behaviour. If we choose not to reproduce them, each needs a row indocs/architecture/retail-divergence-register.md. - Indoor silence is retail-correct. If the user's "incorrect ambient" complaint includes "dungeons are too quiet", that is faithful — retail is silent there too, and any indoor ambient we add is a new feature, not a port, and needs a register row.
Address citations for code comments (named symbol + address, per the project's phase-completion checklist):
Ambient::AddSound 0x551610
Ambient::InitSounds 0x5515d0
Ambient::UpdatePlayQueue 0x551a50
Ambient::Play 0x5517a0
Ambient::UseTime 0x551880
Ambient::GetSound 0x5510b0
Ambient::CalcWeight 0x550dd0
Ambient::CalcDir 0x550e40
Ambient::PlaySoundA 0x550d90
Ambient::FlushSoundTables 0x452920
Ambient::ReleaseSoundTables 0x455770
Ambient::Destroy 0x551580
IntermitSound::CanHear 0x550f80
IntermitSound::PlayNow 0x550fa0
IntermitSound::GetVolume 0x551070
IntermitSound::GetPlayInterval 0x551080
IntermitSound::UpdateSound 0x551310
IntermitSound::GetSoundPos 0x551350
IntermitSound::AddTo 0x551450
IntermitSound::AddDir 0x550cf0
IntermitSound::ResetCount 0x550cd0
ConstantSound::CanHear 0x550fd0
ConstantSound::GetVolume 0x550d80
ConstantSound::GetPlayInterval 0x5510a0
ConstantSound::UpdateSound 0x551540
ConstantSound::AddTo 0x551000
ConstantSound::ResetCount 0x550d70
AmbientSound base GetSoundPos 0x4f0ea0 (xor eax,eax; ret 4)
AmbientSound base PlayNow (CS) 0x5269f0 (mov eax,1; ret)
SoundManager::PlayAmbientSound 0x550820
SoundManager::PlayAmbientSoundFromCenter 0x5508b0
SoundManager::GetSound 0x550680
SoundManager::GetAttenuation 0x550020
SoundManager::PlaySoundInternal 0x550170 / 0x54fec0
SoundManager::SetPlayerPosition 0x5503c0
AmbientSTBDesc::UnPack 0x5518f0
AmbientSTBDesc::InitSoundTable 0x4fea60
CRegionDesc::GetSTBDesc 0x4feab0
CTerrainDesc::GetSTBDesc 0x502400
CTerrainDesc::NumSceneType 0x502430
CLandBlock::add_ambient_sounds 0x530310
LScape::add_ambient_sounds 0x505810
LScape::get_block_orient 0x504f90
CellManager::ChangePosition 0x4559b0
CellManager::Reset 0x455930
SmartBox::UseTime 0x455410
LandDefs::heading 0x5a9a30
LandDefs::get_block_offset 0x43e630
Position::get_offset 0x509f60
Random::RollDice 0x42c600