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

27 KiB
Raw Permalink Blame History

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.csbase(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=0x6AUI_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*) @ 0x005139D0setup->default_stable_id (the DAT Setup field).
  2. CPhysicsObj::set_description(PhysicsDesc*, …) @ 0x00514F40desc->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 0xF750DispatchSB_SoundEventHandleSoundEventCPhysicsObj::play_soundPlaySoundA(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/0xF755CPhysicsObj::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::ExecutePlaySoundA(gid, obj) (raw wave DID) case SoundHook sPlay(waveId: s.Id, volume 1, priority 4) matches
SoundTableHook::ExecutePlaySoundA(sound_type_, obj) (table lookup, entry volume) case SoundTableHook stPlayFromSoundTableSoundCookbook.Roll → entry's Volume/Priority matches in shape; picker algorithm diverges (below)
SoundTweakedHook::ExecutePlaySoundA(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 SetupNormalizeSoundTableDid((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)