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>
482 lines
27 KiB
Markdown
482 lines
27 KiB
Markdown
# 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<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)
|
||
```
|