# 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 0x00–0xC5+ 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` 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) ```