feat(audio): Campaign A slice A3 — the server sound channel (0xF750)

acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.

SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.

Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.

AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.

The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 22:07:23 +02:00
parent e42b99482e
commit 8bc458fb88
13 changed files with 420 additions and 24 deletions

View file

@ -33,15 +33,22 @@ Retail is a **2D pan+gain engine**, not 3D audio. Every gameplay buffer is
created with `m_3D = 0`; the DirectSound 3D listener the client sets up is
dead code. Spatialization is CPU-side per voice at play time:
- **Gain** (`SoundManager::GetAttenuation @ 0x00550AD0` region; byte-decoded):
`g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, multiplied by ONE
master knob (`effect_sound_volume` or `ambient_sound_volume`), then
`db = ceil(20·log10 g)` with a hard floor at 50 dB — below the floor the
voice is **not allocated at all**. Audible radius ≈ 94 m at vol 1.0.
- **Pan**: `pan_dB = 15·sin(Δheading listener→source)`, saturated at
±15 dB, forced to 0 inside 5 m. No front/back, no elevation.
- **Listener** = the player physics object's position/heading
(`SoundManager::SetPlayerPosition`), NOT the camera.
- **Gain** (`SoundManager::GetAttenuation @ 0x00550020`; byte-decoded):
`g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, then multiplied by ONE
master knob (`effect_sound_volume` or `ambient_sound_volume`) — clamp
first, multiply second — then `db = ceil(20·log10 g)` with a hard floor at
50 dB, below which the voice is **not allocated at all**. Audible radius
≈ 94.2 m at vol 1.0.
- **Pan**: `pan_dB = (int)(15·sin(Δbearing))`, truncating toward zero,
saturated at ±15 dB, forced to 0 when `(int)distance < 5`. No front/back,
no elevation.
- **Listener** = `SmartBox::viewer` — the **collided third-person camera**
Position, refreshed once per rendered frame (`SmartBox::set_viewer` @
`0x00452D36`, `SmartBox::update_viewer` @ `0x00453CE0`), falling back to the
player's own position when the camera sweep fails. Only its origin and
`Frame::get_heading` are read. (An earlier draft of this plan said the
listener is the player and called acdream's camera listener a defect —
wrong, and corrected at A2. Do not re-"fix" it.)
- **Voice pool**: allocator is `SoundManager::PlaySoundInternal @ 0x0054FEC0`.
Eviction compares the DAT-authored **float priority** (0..1); equal
priority never evicts. (`FUN_00550AD0` cited in our code is a hash-table
@ -297,7 +304,7 @@ global kill switch.
|---|---|---|---|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
| A3 | — | — | — |
| A3 | **COMPLETE** 2026-08-08 | `3fae0c7d` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
| A4 | — | — | — |
| A5 | — | — | — |
| A6 | — | — | — |