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

1119 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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::UseTime``Ambient::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.
```c
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]
```
```csharp
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:
```
```csharp
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.
```csharp
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
```csharp
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`:
```csharp
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 in `Ambient::Play`) in the XY
plane, keeping the listener's Z and objcell_id:
```csharp
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`:
```csharp
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:
```csharp
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`):
```csharp
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
```csharp
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:
```csharp
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.
```csharp
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:
```csharp
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`):
```csharp
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`)
```csharp
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;
}
```
```csharp
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
```csharp
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:
```csharp
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
```csharp
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 + (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
```