acdream/docs/research/2026-08-08-audio-retail-server-sounds.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

482 lines
27 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 5 — server-driven & physics-driven sounds: retail decode + acdream audit
Research-only. No repo files touched.
Oracles used:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C, PDB-named, Sept 2013 EoR)
- `docs/research/named-retail/acclient.h` (verbatim retail structs/enums)
- `references/ACE/Source/` (server side — what actually gets sent)
- `references/holtburger/` (independent client-side parser)
- **Raw byte decode** of the PDB-paired `C:\Users\erikn\Downloads\acclient.exe`
(v11.4186) for two FPU-elided/mis-polarised spots BN got wrong
(per `reference_pe_byte_decode.md`)
---
## 1. The Sound game message (0xF750)
### Wire layout (three oracles agree)
| offset | type | field |
|---|---|---|
| 0x00 | u32 | opcode `0xF750` |
| 0x04 | u32 | object GUID |
| 0x08 | u32 | `SoundType` (retail `enum SoundType`, = ACE `Sound`) |
| 0x0C | f32 | volume |
Total 16 bytes. Direction S→C.
- Retail: `CM_Physics::DispatchSB_SoundEvent` @ `0x006AC760` reads
`*(u32*)buf == 0xf750`, then passes `buf+4` (guid), `buf+8` (sound),
`buf+0xc` (float volume).
- ACE: `Network/GameMessages/Messages/GameMessageSound.cs`
`base(GameMessageOpcode.Sound /*0xF750*/, GameMessageGroup.SmartboxQueue, 16)`,
writes `WriteGuid(guid)`, `(uint)soundId`, `float volume`.
Opcode confirmed at `GameMessageOpcode.cs:60`.
- holtburger: `crates/holtburger-protocol/src/messages/effects/types.rs`
`PlaySoundData { target: Guid, sound_id: u32, volume: f32 }`, routed from
`GameOpcode::Sound` in `game_message/unpack.rs:186`. **holtburger parses it
and then does nothing with it** — no consumer anywhere in
`holtburger-core`/`apps` (it's a TUI, no audio). So holtburger is a layout
oracle only, not a behaviour oracle here.
- ACE's `Sound` enum (`ACE.Entity/Enum/Sound.cs`) is byte-for-byte the retail
`SoundType` enum (`acclient.h:4569`) — verified across the whole 0x000xC5+
range. Values we care about: `Collision=0x2F`, `Footstep1=0x37`,
`Footstep2=0x38`, `Walk1=0x39`, `Open=0x42`, `Close=0x43`,
`OpenSlam=0x44`, `CloseSlam=0x45`, `LogIn=0x50`, `LifestoneOn=0x51`,
`Fizzle=0x5E`, `Launch=0x5F`, `Explode=0x60`,
`UI_EnterPortal=0x6A``UI_Thunder6=0x8A`, `WieldObject=0x8C`,
`PickUpItem=0x8F`, `DropItem=0x90`, `ResistSpell=0x91`,
`TriggerActivated=0x95`, `SpellExpire=0x96`, `ItemManaDepleted=0x97`.
### Retail handler chain (pseudocode)
```
CM_Physics::DispatchSB_SoundEvent(SmartBox* sb, NetBlob* blob) // 0x006AC760
if (!blob || !sb) return NETBLOB_ERROR;
if (*(u32*)blob->buf != 0xF750) return NETBLOB_ERROR;
return SmartBox::HandleSoundEvent(sb, blob,
guid = *(u32*)(buf+4),
sound = *(i32*)(buf+8),
volume = *(f32*)(buf+0xC));
SmartBox::HandleSoundEvent(sb, blob, guid, sound, volume) // 0x00451FC0
CPhysicsObj* obj = CObjectMaint::GetObjectA(sb->m_pObjMaint, guid);
if (obj == nullptr) {
CObjectMaint::QueueBlobForObject(sb->m_pObjMaint, guid, blob);
return NETBLOB_QUEUED; // 4 — REPLAYED when the object arrives
}
CPhysicsObj::play_sound(obj, sound, volume);
return NETBLOB_ERROR/OK; // BN mush; play_sound is void
CPhysicsObj::play_sound(this, SoundType t, float vol) // 0x0050F460
if (this->sound_table != nullptr) // NO table → SILENTLY DROPPED
SoundManager::PlaySoundA(t, this, vol);
SoundManager::PlaySoundA(SoundType t, CPhysicsObj* obj, float vol) // 0x00550AF0
if (!effect_sounds_enabled) return;
if (s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp) return;
SoundData d;
if (obj->sound_table == nullptr) return;
GetSound(obj->sound_table, t, &d); // rolls ONE variant
if (d.buf != null && PlayProbability(d.probability_))
PlaySoundInternal(d.buf, &obj->m_position, vol, /*isAmbient=*/0);
// ^^^ WIRE volume, not d.volume_
```
Three retail facts worth writing down:
1. **Object identity is required and the message is deferrable.** If the guid
isn't in `CObjectMaint` yet, retail *queues the blob against that guid* and
replays it on `CreateObject`. Our implementation must do the same or a
"creature spawns and immediately grunts" sequence will silently drop the
grunt.
2. **No sound table → nothing plays.** `play_sound` early-returns on
`sound_table == nullptr`. The server can send `Sound` for any object; only
objects that carry a SoundTable make noise.
3. **Server-driven sounds use the WIRE volume, not the SoundTable entry
volume.** The 3-arg `PlaySoundA(SoundType, obj, vol)` passes `arg3` straight
to `PlaySoundInternal`. The 2-arg overload used by animation hooks
(`SoundTableHook::Execute`) instead passes the *entry's* `volume_`. That
asymmetry is real and must be preserved.
`GetAttenuation(dist, vol, &out, isAmbient)` then multiplies by the user's
`effect_sound_volume` / `ambient_sound_volume` pref and clamps to `VOL_MIN`.
### Where the SoundTable comes from
`CPhysicsObj::sound_table` is a `CSoundTable*` = `DBObj::Get(QualifiedDataID(did, 0x22))`
(`0x22` = 34 = `DB_TYPE_STABLE`; matches ACE `DatFileType.SoundTable = 34`).
Two writers, in retail construction order:
1. `CPhysicsObj::InitDefaults(CSetup*)` @ `0x005139D0`
`setup->default_stable_id` (the DAT Setup field).
2. `CPhysicsObj::set_description(PhysicsDesc*, …)` @ `0x00514F40`
`desc->stable_id` (the wire field in CreateObject/UpdateObject).
It **unconditionally releases** the existing table first, then installs the
wire one only if non-zero — i.e. a PhysicsDesc with `stable_id == 0` leaves
the object with *no* sound table, it does not fall back to Setup.
`SoundTableData` (retail, `acclient.c:5151`): `num_stdatas_` @ +0x7C and
`SoundData* data_` @ +0x80, each entry 16 bytes = `{ DataId sound_id, float
priority, float probability, float volume }`. Identical to DatReaderWriter's
`SoundEntry` and ACE's `SoundTableData`.
### Variant picking — retail is NOT a CDF walk (byte-verified)
`SoundManager::GetSound` @ `0x00550680`. BN elides the FPU multiply; raw bytes
(file offset `0x150680`) decode to:
```
8b 48 7c mov ecx,[eax+0x7C] ; num_stdatas_
85 c9 / 76 73 test/jbe ; num == 0 -> bail
68 00 00 80 3f push 1.0f
6a 00 push 0 ; 0.0f
e8 .. call Random::RollDice(0.0f, 1.0f) -> st0 = u
8b 77 7c mov esi,[edi+0x7C] ; num
8d 4e ff lea ecx,[esi-1] ; num - 1 <-- note the -1
db 44 24 14 fild [num-1]
d8 c9 fmul st(0), st(1) ; (num-1) * u
e8 .. call _ftol2 ; eax = TRUNC((num-1)*u)
3b c6 / 73 .. cmp eax,esi / jae ; idx >= num -> bail
c1 e0 04 shl eax,4 ; * sizeof(SoundData)
```
So **`idx = (int)((num_stdatas_ - 1) * RollDice(0.0f, 1.0f))`**, then the chosen
entry's own `probability` gates whether it plays at all.
`Random::RollDice(float,float)` @ `0x0042C600` decodes to
`lo + u01 * (hi - lo)` (with a `min == max → return min` short-circuit and a
swap if `min > max`), where `u01` comes from the combined-LCG at `0x0042C4C0`
(two Lehmer streams, modulus `0x7FFFFFAB`) — i.e. the classic L'Ecuyer
generator whose scaled output is in the open interval (0,1).
**Consequence (retail quirk, flag it):** with `N` variants the reachable index
range is `[0, N-2]`. With two variants retail effectively always plays the
first. This is an off-by-one in Turbine's picker, not a decode artifact — the
`lea ecx,[esi-1]` is unambiguous in the bytes.
`SoundManager::PlayProbability(float p)` @ `0x005500E0` — BN renders the branch
polarity **inverted**; bytes say:
```
ff 15 84 23 79 00 call rand
db 44 24 00 fild [esp]
d8 0d 50 af 7c 00 fmul [0x7CAF50] ; const = 3.051851e-05 = 1/32767
d8 5c 24 08 fcomp [esp+8] ; vs p
df e0 / f6 c4 05 fnstsw / test ah,5
7a 07 jp -> return 0
b8 01 00 00 00 mov eax,1 ; return 1
```
`test ah,5` isolates C0 (bit0) and C2 (bit2); PF is the parity of the AND
result, so `jp` is taken exactly when C0 == C2 == 0 (ordered and not-less).
Therefore **plays when `rand()/32767.0 < probability`** — the intuitive
reading, opposite to BN's `if (p) return 0` rendering. Do not port BN here.
---
## 2. Retail's full sound-trigger catalog
`SoundManager` is the only audio entry point. Every trigger reaches it through
one of six routes. (BN only prints `Sound_*` enum names where type info is
attached, so the numeric call sites are sparse in the text dump — the routes
below are from the class/vtable structure, which is complete.)
| # | Route | Retail anchor | Covers |
|---|---|---|---|
| 1 | **Server Sound event** | `0xF750``DispatchSB_SoundEvent``HandleSoundEvent``CPhysicsObj::play_sound``PlaySoundA(SoundType, obj, wireVol)` | everything in §3's ACE table: hit/wound/pain, wield/unwield, pickup/drop/receive, lock/pick, door-locked, lifestone, trigger plates, spell resist/expire, mana depleted, attribute/skill raise, projectile `Collision` |
| 2 | **Animation hooks** | `SoundHook::Execute` `0x00526A20`, `SoundTweakedHook::Execute` `0x00526A80`, `SoundTableHook::Execute` `0x00526AB0` (all `CAnimHook` subclasses, `acclient.h:6308-6310`) | **footsteps**, weapon swoosh, bow pull/release, creature attack/damage vocalisations, door open/close, eat/drink, spell chant — anything authored into an animation's hook list |
| 3 | **PhysicsScript hooks** | `0xF754`/`0xF755``CPhysicsObj::play_script` → the script's `CAnimHook` list, which can include the same three sound hooks | server-triggered effect scripts (portal, cast, destroy) that carry audio |
| 4 | **Ambient / environment** | `Ambient::Play` `0x005517A0`, `Ambient::UseTime` `0x00551880`, `Ambient::PlaySoundA` `0x00550D90`; data from `AmbientSTBDesc { stb_id, ambient_sounds, CSoundTable*, play_count }` reached via `CSceneType::sound_table_desc`; entries are `AmbientSoundDesc { SoundType stype, int is_continuous, float volume, float base_chance, float min_rate, float max_rate }` | waterfalls, birds, dungeon drips, wind — interval-queued in a `PQueueArray<double>` keyed on `Timer::cur_time`, gated on `AmbientSound::CanHear()`, positioned or from-centre depending on `GetSoundPos()` |
| 5 | **UI / interface** | `SoundManager::PlaySoundFromCenter(SoundType, ClientUISystem::GetUISoundTable())` | portal enter/exit, button press, icon pick-up/drop, slider grab/release, new-target-selected, general query/error, transient message, and the whole `Sound_UI_Roar…Thunder6` block |
| 6 | **MediaMachine** | `MediaMachine::Update_Sound` `0x004658B0` | cutscene / media-descriptor audio; `MD_Data_Sound { SoundType m_stype, DataId m_file }`. If `m_stype == Sound_Invalid` it plays `m_file` as a raw wave id; otherwise it resolves `m_file` as a SoundTable (`DBObj::Get(qdid, 0x22)`) and plays `m_stype` from it |
### Physics-event sounds: the important negative result
**`CPhysicsObj::play_sound` has exactly ONE caller in the entire binary:
`SmartBox::HandleSoundEvent`.** There is no collision, jump-land, water-entry,
or step call site. Confirmed by grepping every `play_sound` /
`SoundManager::PlaySound*` reference in the 65 MB pseudo-C dump.
That means, in retail:
- **Footsteps are animation-hook-driven** (route 2 — `SoundTableHook` with
`Sound_Footstep1/2` / `Sound_Walk1` authored into the walk/run animation
frames), *not* physics-tick driven. Nothing in `CTransition` /
`SPHEREPATH` / `COLLISIONINFO` plays a sound.
- **Collision sounds are server-driven** (route 1). `Sound_Collision (0x2F)`
is emitted by the *server* — ACE does it in
`WorldObjects/ProjectileCollisionHelper.cs:45`. The client's physics engine
never plays a collision sound on its own.
- **Jump / land / water-entry have no client-local sound trigger at all.**
There is no `Sound_*` for them in the enum and no call site. Any audible
landing thump in retail comes from the landing *animation's* hooks.
So "physics-driven sounds" in retail = "animation hooks that happen to fire
during physics-driven motion" + "server tells you". There is no third thing.
### UI sound table — the dat id
```
ClientUISystem::GetUISoundTable(this) // 0x00563FB0
if (this->soundTable == nullptr)
this->soundTable = DBObj::GetByEnum(/*fileType*/ 0x22,
/*enumIndex*/ 7,
/*cache*/ 0x10000003);
return this->soundTable;
DBObj::GetByEnum(type, idx, cache) // 0x00415490
DBCache::GetDIDFromEnumStatic(&did, type, idx); // enum -> concrete DID
return DBCache::Get(did, cache);
```
i.e. the UI sound table is **not a hard-coded DID** — it is enum slot **7** of
DB type **0x22 (`DB_TYPE_STABLE`, SoundTable)**, resolved through
`DBCache::GetDIDFromEnumStatic`. (Open item: resolving slot 7 → the actual
`0x20xxxxxx` DID needs either `GetDIDFromEnumStatic`'s static table decoded or
one cdb `dt` on a live client. Cheap either way; not done here.)
Only two UI-sound sites survive with named enums in BN's output:
`SoundManager::PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable(...))`
@ `0x004D638E` and `Sound_UI_ExitPortal` @ `0x004D7405`. The third named
cluster is a switch in **`CPlayerSystem::Handle_Admin__Environs`** @
`0x0055DE20`: environment option values `0x65..0x7C` map 1:1 onto
`Sound_UI_Roar` (0x65) … `Sound_UI_Thunder6` (0x7C), each played from-centre
through the UI sound table, alongside `LScape::m_override_*` fog/ambient
overrides and `m_bRadarBlank`. That is the server's `AdminEnvirons` hook into
the UI sound bank.
`PlaySoundFromCenter` gates on `interface_sounds_enabled` (a separate pref from
`effect_sounds_enabled` / `ambient_sounds_enabled`) and calls
`GetAttenuation(0.0f, vol, &out, 0)` — distance 0, so no attenuation, but the
interface-volume pref still applies. Retail's three volume prefs are
`Sound.SoundVolume`, `Sound.AmbientSoundVolume`, `Sound.InterfaceSoundVolume`,
plus `Sound.SoundDisabled` / `Sound.AmbientSoundDisabled` /
`Sound.InterfaceSoundDisabled` / `Sound.PlaySoundOnlyWhenActive` /
`Sound.SoundFeatures` (mono/stereo), all registered in
`SoundManager::InitPrefs` @ `0x005503F0`.
Listener position: `SoundManager::SetPlayerPosition(&sb->viewer)` @
`0x00452D36` — the **viewer** position, i.e. the camera eye, not the player's
feet. (Same coupling as our render visibility; see
`project_camera_visibility_coupling`.)
---
## 3. What ACE actually sends, and when
`GameMessageSound` send sites (65 in `references/ACE/Source/ACE.Server`).
Grouped:
| Sound | ACE site(s) | trigger |
|---|---|---|
| `HitFlesh1` (0.5f vol) | `Monster_Combat.cs:314,404`, `Player_Combat.cs:168,544` | every melee/missile hit |
| `Wound1/2/3` + pain sounds | `Monster_Combat.cs:324,408`, `Player_Combat.cs:461,563`, `Player_Move.cs:311` (fall damage) | damage taken |
| `WieldObject` / `UnwieldObject` | `Creature_Equipment.cs:360,436`, `Player_Inventory.cs:306,405,1831` | equip / unequip |
| `PickUpItem` / `DropItem` / `ReceiveItem` | `Player_Inventory.cs` (×14), `Player_Commerce.cs:105,223`, `AdminCommands.cs:2880` | every inventory move, give, buy/sell |
| `Collision` | `ProjectileCollisionHelper.cs:45` | **projectile impact** — the only `Sound.Collision` sender |
| `OpenFailDueToLock` | `Door.cs:114`, `Chest.cs:110`, `Storage.cs:71` | locked container/door |
| `Lockpicking` / `PicklockFail` / `LockSuccess` | `Lock.cs:162,178,276` | lockpick attempts |
| `LifestoneOn` | `Lifestone.cs:58` | lifestone attunement |
| `TriggerActivated` | `Hotspot.cs:221`, `Switch.cs:46`, `PressurePlate.cs:75` (UseSound) | traps / plates / switches |
| `ResistSpell` | `WorldObject_Magic.cs:194,201` | spell resisted |
| `SpellExpire` | `EnchantmentManager.cs:331,348` | enchantment drops |
| `ItemManaDepleted` | `Player_Tick.cs:684` | item runs dry |
| `RaiseTrait` | `Player_Attributes.cs:48`, `Player_Skills.cs:56`, `Player_Vitals.cs:59`, `AttributeTransferDevice.cs:98` | XP spend |
| arbitrary (emote-authored) | `EmoteManager.cs:1243``(Sound)emote.Sound` | any DAT-authored NPC emote sound |
| arbitrary (weenie `UseSound`) | `Gem.cs:181`, `GenericObject.cs:48`, `Food.cs:100` (`GetUseSound()`), `PressurePlate.cs:75` | item use |
| generic helper | `WorldObject.cs:708` `EnqueueBroadcast(new GameMessageSound(targetId, soundId, volume))`, `Player.cs:483` | everything else |
Note `Player_Death.cs:192` has the death sound **commented out** in ACE.
Two shapes of send: `EnqueueBroadcast(...)` (everyone in range hears it, guid =
the acting object) and `Session.Network.EnqueueSend(...)` (only the acting
player hears it). Both arrive as the same 0xF750; the difference is purely who
receives it. So our handler needs no special-casing — but it *does* mean a
0xF750 can name a **remote** guid, and must play at that remote object's
position.
---
## 4. acdream audit — what we have and what we don't
### 4.1 Server Sound path: **ABSENT**
- `grep -rn '0xF750' src/`**zero hits.** No parser, no message record, no
`WorldSession` event, no routing.
- Our `Core.Net` knows the neighbours: `0xF74A` PickupEvent, `0xF74B` SetState,
`0xF74E` VectorUpdate, `0xF751` PlayerTeleport, `0xF754` PlayPhysicsScript,
`0xF755` PlayPhysicsScriptType. `0xF750` is the hole in the middle.
- Already recorded as a known gap: `docs/research/2026-06-04-wire-message-catalog.md:241`
(`| 0xF750 | Sound | S->C | Movement & Physics | missing |`), with a full
entry at line 918 and a "next work" callout at line 4654. So this lane
confirms a previously-catalogued gap rather than discovering a new one — but
the *retail handler semantics* (queue-for-object, no-table drop, wire-volume
precedence) were not previously written down anywhere.
- Consequence: **every sound in §3's table is silent in acdream.** No hit
sounds, no pickup/drop, no wield, no lock, no lifestone, no trap trigger, no
spell resist/expire, no projectile collision.
### 4.2 Animation-hook path: **PRESENT and correctly shaped**
`src/AcDream.App/Audio/AudioHookSink.cs` implements `IAnimationHookSink` and
handles all three retail hook types with the right semantics:
| retail | ours | verdict |
|---|---|---|
| `SoundHook::Execute``PlaySoundA(gid, obj)` (raw wave DID) | `case SoundHook s``Play(waveId: s.Id, volume 1, priority 4)` | matches |
| `SoundTableHook::Execute``PlaySoundA(sound_type_, obj)` (table lookup, entry volume) | `case SoundTableHook st``PlayFromSoundTable``SoundCookbook.Roll` → entry's `Volume`/`Priority` | matches in shape; picker algorithm diverges (below) |
| `SoundTweakedHook::Execute``PlaySoundA(gid, obj, prio, prob, vol)` | `case SoundTweakedHook stw` → direct wave with hook's volume/priority | **missing the `prob` gate** — retail runs `PlayProbability(arg4)` before playing; we play unconditionally |
Wiring is real and reaches production:
- `ContentEffectsAudioComposition.ComposeOptionalAudio` creates the engine +
sink and calls `registrations.Register(audioSink)` (line 513) →
`AnimationHookRouter` (`src/AcDream.Core/Physics/AnimationHookRouter.cs`).
- `AnimationHookFrameQueue.cs:117` and `PhysicsScriptRunner.cs:308` both fan
into that router.
- Listener is updated per frame: `WorldRenderFrameBuilder.cs:388`
`_audio.SetListener(...)`.
- Disabled by `ACDREAM_NO_AUDIO=1` (`RuntimeOptions.cs:103`) or an unavailable
OpenAL driver, otherwise on.
**Side effect worth noting:** because `PhysicsScriptRunner` sinks into the same
router, we *already* have one indirect server→audio path — a server
`0xF754`/`0xF755` PlayScript whose PhysicsScript carries a `SoundHook` will
play. That's retail route 3, and it works today.
### 4.3 `DictionaryEntitySoundTable`: **IS populated** (the hook path is not dead)
This was the open question. Answer: yes, and from the right field.
- `LivePresentationComposition.cs:620-625` passes two callbacks into
`EntityEffectController`: a remove (`content.Audio?.EntitySoundTables.Remove(ownerId)`)
and a set (`... .Set(ownerId, did)`).
- The `did` comes from `EntityEffectProfile.CurrentSoundTableDid`
(`src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs`), which mirrors retail's
precedence exactly and cites it:
- ctor from `Setup``NormalizeSoundTableDid((uint)setup.DefaultSoundTable)`
(retail `CPhysicsObj::InitDefaults` `0x005139D0`);
- `ApplyNetworkDescription(PhysicsSpawnData)`
`NormalizeSoundTableDid(physics.SoundTableId.GetValueOrDefault())`
(retail `CPhysicsObj::set_description` `0x00514F40`), **unconditionally
replacing** the Setup value — same "wire wins, zero means none" rule as
retail.
- `NormalizeSoundTableDid` gates on `(did & 0xFF000000) == 0x20000000`.
- `SoundTableId` is parsed off the wire in
`src/AcDream.Core.Net/Messages/CreateObject.cs:766` into
`PhysicsSpawnData.SoundTableId` (`PhysicsSpawnData.cs:47`).
So creature/NPC animation sounds *do* have a table to look up. Good — no
silent-by-construction bug here.
### 4.4 Variant picker: **algorithmically divergent from retail**
`src/AcDream.Core/Audio/SoundCookbook.cs` does a **cumulative-probability CDF
walk**: sample `u ∈ [0,1)`, accumulate `entries[i].Probability`, first entry
whose running total exceeds `u` wins; falling off the end returns `null`
("silence tail") unless the probabilities sum to ≈1.
Retail (§1, byte-verified) does something completely different:
**uniform index `(int)((N-1) * u)`, then a per-entry
`rand()/32767 < entry.probability` gate.**
Practical differences:
- retail's index is uniform over `[0, N-2]` and never reaches the last entry;
ours is probability-weighted over all `N`.
- retail's `probability` is an independent **play/don't-play** gate on the
already-chosen entry; ours treats it as a **selection weight**. For the
common `N=1, probability=1.0` case both play the entry — so most sounds
sound identical — but for multi-variant tables (footsteps, swooshes,
creature vocalisations, exactly the audible ones) the distributions differ.
- Our `entries.Count == 1 → return entries[0]` shortcut also skips the
probability gate entirely; retail still rolls it. A single-entry sound with
`probability < 1` should sometimes be silent in retail and never is in ours.
This needs a divergence-register row when audio work lands, or a faithful
re-port (the faithful version is ~8 lines and strictly simpler than what we
have).
### 4.5 UI sounds: **ABSENT**
No `Sound_UI_*` / interface-sound concept anywhere in `src/`. `grep -i
'interfacesound|uisound|Sound_UI'` returns only unrelated `IsButtonPressed` /
`*ButtonPressed` input handlers. Concretely missing:
- no UI sound table load (retail: `DBObj::GetByEnum(0x22, 7)`);
- no `PlaySoundFromCenter` equivalent (non-positional, interface-volume pref);
- no button-press / icon-pickup / icon-drop / slider / new-target-selected /
general-error / transient-message cues, despite all of those UI surfaces now
existing (spell bar, vendor panel, inventory drag-drop, target selection);
- no portal enter/exit cue, despite the portal-space presentation being
complete;
- no `AdminEnvirons` sound mapping (`0x65..0x7C`) even though we own the
`AdminEnvirons` state in Runtime (J6.1).
### 4.6 Ambient / environment sounds: **ABSENT**
No `Ambient`, `AmbientSTBDesc`, `AmbientSoundDesc`, ambient-STB reader, or
interval-queued ambient scheduler. `grep -i 'ambientsound|AmbientStb'` → only
`SoundCookbook`'s doc comment. Retail's `ambient_sound_volume` /
`ambient_sounds_enabled` prefs have no counterpart either. Waterfalls, birds,
dungeon ambience: silent.
### 4.7 Physics-event sounds: **N/A — correctly absent**
Nothing calls the audio engine from the physics path in acdream, and per §2
that is *retail-correct*. There is no gap to fill here. The apparent gap
("collisions make no noise") is really §4.1: retail hears a collision because
the **server** sent `Sound.Collision`. Do not add a client-local collision
sound — it would be a divergence, not a fix.
---
## 5. Summary of gaps, ordered by audible impact
| # | Gap | Effort | Notes |
|---|---|---|---|
| 1 | `0xF750` parse + route to audio | small | Needs: `SoundEvent` record in `Core.Net/Messages`, a `WorldSession` event next to the existing `0xF754`/`0xF755` ones, and a Runtime/App consumer that resolves guid → world position + SoundTableId and calls the engine with the **wire** volume. Must implement retail's *queue-for-unknown-guid* deferral. |
| 2 | UI sound bank + `PlaySoundFromCenter` | small-medium | Blocked only on resolving DB-type-0x22 enum slot 7 → concrete DID. Needs a third volume pref (`interface`) and a non-positional play path. |
| 3 | `SoundCookbook` → retail picker | tiny | Replace CDF walk with `(int)((N-1)*u)` + per-entry probability gate; add the missing `prob` gate to `SoundTweakedHook`. Register row either way. |
| 4 | Ambient / environment sounds | medium | Needs the ambient-STB reader, `AmbientSoundDesc` scheduling (`base_chance`/`min_rate`/`max_rate`, `is_continuous`), the `CanHear` gate, and an ambient volume pref. |
| 5 | `AdminEnvirons` 0x65..0x7C → UI sounds | tiny | Falls out of #2; we already own the AdminEnvirons state. |
Open research items (cheap, not done here):
- Resolve `DBCache::GetDIDFromEnumStatic(0x22, 7)` → the concrete UI SoundTable
DID (decode the static enum table, or one `dt` in cdb).
- Pin the numeric UI-sound call sites (`Sound_UI_ButtonPress = 0x72`,
`IconPickUp = 0x6F`, `IconSuccessfulDrop = 0x70`, `IconInvalid_Drop = 0x71`,
`GrabSlider = 0x73`, `ReleaseSlider = 0x74`, `NewTargetSelected = 0x75`) to
their owning UI classes — BN prints them as bare integers, so they need a
numeric grep or a Ghidra xref on the UI sound table getter.
## 6. Retail anchors (for code comments)
```
CM_Physics::DispatchSB_SoundEvent 0x006AC760 0xF750 dispatch
SmartBox::HandleSoundEvent 0x00451FC0 guid resolve / queue / play
CPhysicsObj::play_sound 0x0050F460 sound_table null-gate
SoundManager::PlaySoundA(SoundType,obj,f)0x00550AF0 wire-volume path
SoundManager::PlaySoundA(SoundType,obj) 0x00550B70 entry-volume path (hooks)
SoundManager::GetSound 0x00550680 idx = (int)((N-1)*RollDice(0,1))
SoundManager::PlayProbability 0x005500E0 rand()/32767 < p -> play
SoundManager::PlaySoundInternal 0x00550170 position + attenuation
SoundManager::GetAttenuation 0x00550020 effect vs ambient volume pref
SoundManager::PlaySoundFromCenter 0x00550950 interface sounds
SoundManager::PlayAmbientSound 0x00550820
SoundManager::PlayAmbientSoundFromCenter 0x005508B0
SoundManager::SetPlayerPosition 0x005503C0 listener = viewer/eye
SoundManager::InitPrefs 0x005503F0 the 8 sound prefs
Random::RollDice(float,float) 0x0042C600 lo + u01*(hi-lo)
SoundHook::Execute 0x00526A20
SoundTweakedHook::Execute 0x00526A80
SoundTableHook::Execute 0x00526AB0
Ambient::Play 0x005517A0
Ambient::UseTime 0x00551880
Ambient::PlaySoundA 0x00550D90
ClientUISystem::GetUISoundTable 0x00563FB0 DBObj::GetByEnum(0x22, 7)
DBObj::GetByEnum 0x00415490
CPlayerSystem::Handle_Admin__Environs 0x0055DE20 env 0x65..0x7C -> UI sounds
MediaMachine::Update_Sound 0x004658B0
CPhysicsObj::InitDefaults 0x005139D0 Setup.default_stable_id
CPhysicsObj::set_description 0x00514F40 PhysicsDesc.stable_id
CM_Physics::DispatchSB_PlayScriptID 0x006ACC40 0xF754 (we have this)
CM_Physics::DispatchSB_PlayScriptType 0x006AC6E0 0xF755 (we have this)
```