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>
1119 lines
51 KiB
Markdown
1119 lines
51 KiB
Markdown
# 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 1–8, or all eight from the
|
||
IN_VIEWER_BLOCK expansion), so the fixed arrays are safe.
|
||
|
||
`Ambient::CalcDir` (`0x550e40`) — byte-exact classification of the
|
||
listener→source offset:
|
||
|
||
```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 100–112 already performs for procedural scenery.** The ambient port
|
||
should reuse that exact decode (and the same `>> 2 & 0x1F` / `>> 11 & 0x1F`
|
||
bit fields) rather than re-deriving it. Note retail iterates only the **8×8
|
||
land cells** (`side_cell_count`), reading the **SW vertex's** terrain word
|
||
from the 9×9 grid (`side_vertex_count` = 9, row stride `0x12` = 9 × 2 bytes)
|
||
— scenery iterates 9×9 vertices, ambients iterate 8×8 cells. Do not copy the
|
||
loop bounds.
|
||
|
||
---
|
||
|
||
## 10. Q8 — cell/landblock transition: start & stop
|
||
|
||
### The rebuild, from `CellManager::ChangePosition` (`0x4559b0`)
|
||
|
||
```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 + (max−min)·t²` where the (min,max) shell is 4–10 m for in-block sources and `d±10 m` for neighbours. **Z is never offset** — same plane as the listener. |
|
||
| "every `N` seconds where `N = rand()` in `[MinRate, MaxRate]`" | Correct for intermittent. **Wrong for continuous**, which uses `min_rate` only. |
|
||
| "On landblock change … stop all ambient voices associated with the outgoing STBId and start new ones" | Rebuild trigger is **any objcell change** (24 m outdoors), not landblock. Nothing is stopped; already-armed audible sounds keep their cadence (the `on_queue == 0` guard), which is what prevents a restart click at every cell crossing. |
|
||
| §7.1 "`RegionDesc` contains a `SoundDesc` field (when `PartsMask & 0x01`)" — implies SoundDesc *is* the selection | Correct as **storage**. It is never the runtime *selector*; `CRegionDesc::sound_info` is only ever touched by pack/unpack/GetSubDataIDs. |
|
||
| — (not mentioned) | **Indoors is silent.** No EnvCell ambient contributor exists; the slot in `ChangePosition` is a folded empty `ret`. |
|
||
| — (not mentioned) | **`ambient_sound_volume` is applied twice** (in `PlayAmbient*` and again in `GetAttenuation`), so the slider is effectively squared. |
|
||
| — (not mentioned) | Final volume is quantised to **integer dB** (`ceil(20·log10 v)`) and floored at `VOL_MIN`. |
|
||
|
||
---
|
||
|
||
## 13. Port notes for acdream
|
||
|
||
Current state: `OpenAlAudioEngine.StartAmbient` (`src/AcDream.App/Audio/OpenAlAudioEngine.cs:367`)
|
||
only mints a handle; there is no ambient system. `StopAmbient` exists and
|
||
works against `_ambientSources`. `grep` finds no `AmbientSTBDesc`/`SoundDesc`
|
||
consumer anywhere in `src/`.
|
||
|
||
**The retail model does not need `StartAmbient` at all.** Every ambient is a
|
||
one-shot. The right shape is:
|
||
|
||
* A `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 + (max−min)·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
|
||
```
|