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>
27 KiB
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 (perreference_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@0x006AC760reads*(u32*)buf == 0xf750, then passesbuf+4(guid),buf+8(sound),buf+0xc(float volume). - ACE:
Network/GameMessages/Messages/GameMessageSound.cs—base(GameMessageOpcode.Sound /*0xF750*/, GameMessageGroup.SmartboxQueue, 16), writesWriteGuid(guid),(uint)soundId,float volume. Opcode confirmed atGameMessageOpcode.cs:60. - holtburger:
crates/holtburger-protocol/src/messages/effects/types.rsPlaySoundData { target: Guid, sound_id: u32, volume: f32 }, routed fromGameOpcode::Soundingame_message/unpack.rs:186. holtburger parses it and then does nothing with it — no consumer anywhere inholtburger-core/apps(it's a TUI, no audio). So holtburger is a layout oracle only, not a behaviour oracle here. - ACE's
Soundenum (ACE.Entity/Enum/Sound.cs) is byte-for-byte the retailSoundTypeenum (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:
- Object identity is required and the message is deferrable. If the guid
isn't in
CObjectMaintyet, retail queues the blob against that guid and replays it onCreateObject. Our implementation must do the same or a "creature spawns and immediately grunts" sequence will silently drop the grunt. - No sound table → nothing plays.
play_soundearly-returns onsound_table == nullptr. The server can sendSoundfor any object; only objects that carry a SoundTable make noise. - Server-driven sounds use the WIRE volume, not the SoundTable entry
volume. The 3-arg
PlaySoundA(SoundType, obj, vol)passesarg3straight toPlaySoundInternal. The 2-arg overload used by animation hooks (SoundTableHook::Execute) instead passes the entry'svolume_. That asymmetry is real and must be preserved.GetAttenuation(dist, vol, &out, isAmbient)then multiplies by the user'seffect_sound_volume/ambient_sound_volumepref and clamps toVOL_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:
CPhysicsObj::InitDefaults(CSetup*)@0x005139D0→setup->default_stable_id(the DAT Setup field).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 withstable_id == 0leaves 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 —
SoundTableHookwithSound_Footstep1/2/Sound_Walk1authored into the walk/run animation frames), not physics-tick driven. Nothing inCTransition/SPHEREPATH/COLLISIONINFOplays a sound. - Collision sounds are server-driven (route 1).
Sound_Collision (0x2F)is emitted by the server — ACE does it inWorldObjects/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, noWorldSessionevent, no routing.- Our
Core.Netknows the neighbours:0xF74APickupEvent,0xF74BSetState,0xF74EVectorUpdate,0xF751PlayerTeleport,0xF754PlayPhysicsScript,0xF755PlayPhysicsScriptType.0xF750is 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.ComposeOptionalAudiocreates the engine + sink and callsregistrations.Register(audioSink)(line 513) →AnimationHookRouter(src/AcDream.Core/Physics/AnimationHookRouter.cs).AnimationHookFrameQueue.cs:117andPhysicsScriptRunner.cs:308both 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-625passes two callbacks intoEntityEffectController: a remove (content.Audio?.EntitySoundTables.Remove(ownerId)) and a set (... .Set(ownerId, did)).- The
didcomes fromEntityEffectProfile.CurrentSoundTableDid(src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs), which mirrors retail's precedence exactly and cites it:- ctor from
Setup→NormalizeSoundTableDid((uint)setup.DefaultSoundTable)(retailCPhysicsObj::InitDefaults0x005139D0); ApplyNetworkDescription(PhysicsSpawnData)→NormalizeSoundTableDid(physics.SoundTableId.GetValueOrDefault())(retailCPhysicsObj::set_description0x00514F40), unconditionally replacing the Setup value — same "wire wins, zero means none" rule as retail.NormalizeSoundTableDidgates on(did & 0xFF000000) == 0x20000000.
- ctor from
SoundTableIdis parsed off the wire insrc/AcDream.Core.Net/Messages/CreateObject.cs:766intoPhysicsSpawnData.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 allN. - retail's
probabilityis an independent play/don't-play gate on the already-chosen entry; ours treats it as a selection weight. For the commonN=1, probability=1.0case 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 withprobability < 1should 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
PlaySoundFromCenterequivalent (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
AdminEnvironssound mapping (0x65..0x7C) even though we own theAdminEnvironsstate 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 onedtin 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)