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

Plan proposes slices A1-A6; awaiting user go.

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

51 KiB
Raw Blame History

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:

  1. On every objcell change (outdoors: every 24 m land-cell crossing; CellManager::ChangePosition), the client rebuilds the ambient weighting from scratch.
  2. 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's SceneType.SoundTableDesc (an AmbientSTBDesc).
  3. 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 every AmbientSound object in that STB desc.
  4. 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.
  5. Playback is driven by a min-heap of absolute deadlines (double seconds, Timer::cur_time), popped once per frame from SmartBox::UseTimeAmbient::UseTime. Each pop plays a one-shot and re-inserts itself at cur_time + GetPlayInterval().
  6. There are no looping OpenAL-style voices anywhere. A "continuous" ambient is a one-shot re-fired every min_rate seconds, 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.
  7. Indoors is silent. Ambient::AddSound has exactly one caller in the whole binary: CLandBlock::add_ambient_sounds. There is no EnvCell / dungeon ambient contributor. In CellManager::ChangePosition the LScape::add_ambient_sounds call 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.
  8. No day/night, no time-of-day, no weather gating. The selection input is the baked terrain/scene map only. GameTime, SkyDesc, and DayGroup never 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
Ambient::ambient_sound_max_dist 0x81f150 120.0 m — cull radius
Ambient::ambient_sound_max_dist_sq 0x81f154 14400.0
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 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
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; retalways 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 0PlayAmbientSoundFromCenter, 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 in Ambient::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 18, 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:

  • UpdatePlayQueue arms on_queue == 0 sounds immediately — a newly audible ambient fires on the frame you cross into range, then schedules. There is no initial random delay.
  • CanHear() == false un-arms and does not reschedule. An ambient that goes inaudible silently leaves the heap; it can only come back on the next UpdatePlayQueue, i.e. the next objcell change. Already-queued sounds that stay audible are not re-armed (the on_queue == 0 guard), 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 100112 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 makes Position::get_offset look catastrophically broken (LandDefs::get_block_offset(id1, 0) returns garbage on the id2 == 0 branch — it loads id1's stack slot, not a zero). The disassembly shows BN mis-attributed the store: 0x5303C1 mov [esp+0x24], edx writes the land cell's own objcell_id (lcell[i] + 0x28) into the Position before every AddSound. 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 == 0continuous 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 + (maxmin)·t² where the (min,max) shell is 410 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 RuntimeAmbientState owner (Runtime layer, per Slice-J ownership rules) holding player_pos, total_sound_count, the AmbientSound list, and a PriorityQueue<AmbientSound, double> of absolute deadlines.
  • Rebuild hook on the existing objcell-change signal — the same edge ACDREAM_PROBE_CELL / PlayerMovementController.CellId already 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. SoundTable lookup already exists (AudioHookSink.PlayFromSoundTable / IEntitySoundTable), and SoundManager::GetSound's random-entry + probability_ roll must be reused, not bypassed.
  • Data is already available: chorizite.datreaderwriter exposes Region.SoundInfo.STBDesc, AmbientSTBDesc.{STBId, AmbientSounds}, AmbientSoundDesc.{SType, Volume, BaseChance, MinRate, MaxRate}, and SceneType.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):

  1. ResetCount must run for every existing sound before accumulation. IntermitSound::UpdateSound never clears play_chance, so a missed reset leaves a stale bearing and a stale probability alive indefinitely.
  2. The on_queue == 0 guard in UpdatePlayQueue is 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.
  3. 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.
  4. CalcDir's IN_VIEWER_BLOCK threshold is min_dist_sq * 0.5 = 200 m² (14.142 m), not min_dist (20 m). It is the only place that × 0.5 appears on the squared value.
  5. GetSoundPos's distance is min + (maxmin)·t² — a quadratic bias toward min. A linear lerp puts intermittent ambients audibly further away on average.
  6. Distance attenuation is 25/d² past a 5 m knee, clamped to 1.0, then quantised to integer dB. An OpenAL AL_INVERSE_DISTANCE_CLAMPED model with AL_REFERENCE_DISTANCE = 5 and AL_ROLLOFF_FACTOR = 1 is the same curve; verify before substituting, per the WB-formula lesson.
  7. The doubled ambient_sound_volume and the integer-dB quantisation are both retail deviations from "obvious" behaviour. If we choose not to reproduce them, each needs a row in docs/architecture/retail-divergence-register.md.
  8. 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