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>
310 lines
18 KiB
Markdown
310 lines
18 KiB
Markdown
# Campaign A — Audio retail-feel parity
|
||
|
||
**Status: PROPOSED 2026-08-08 — awaiting user go.** Research phase complete;
|
||
six-lane named-retail decode done, all load-bearing claims byte-verified
|
||
against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient —
|
||
see "BN traps" below).
|
||
|
||
**Goal:** the client sounds like retail. Every divergence between our audio
|
||
runtime and the 2013 EoR client is either fixed to the retail mechanism or
|
||
recorded in the divergence register with a reason.
|
||
|
||
**Research base (read the lane note before implementing its slice):**
|
||
|
||
| Lane | Note | Owns |
|
||
|---|---|---|
|
||
| 1 | `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | SoundManager, falloff, pan, voice pool, prefs |
|
||
| 2 | `docs/research/2026-08-08-audio-retail-ambient-runtime.md` | AmbientSound/ConstantSound/IntermitSound runtime |
|
||
| 3 | `docs/research/2026-08-08-audio-retail-ambient-authoring.md` | Region-file authoring chain, dat coverage |
|
||
| 4 | `docs/research/2026-08-08-audio-retail-dat-layer.md` | SoundTable/Wave formats, selection model, dat census |
|
||
| 5 | `docs/research/2026-08-08-audio-retail-server-sounds.md` | 0xF750 wire path, play_sound, trigger catalog |
|
||
| 6 | `docs/research/2026-08-08-audio-retail-music-absence.md` | Music (there is none), MediaMachine, AdminEnvirons |
|
||
|
||
The older `docs/research/deepdives/r05-audio-sound.md` is SUPERSEDED where it
|
||
conflicts with the lane notes (its §5.1 falloff, §6 music, and §7 ambient
|
||
sections are wrong — Ghidra-era `FUN_xxx` reads that the named decomp + byte
|
||
decode overturned). Slice A6 adds the banner.
|
||
|
||
---
|
||
|
||
## What retail's audio engine actually is (one page)
|
||
|
||
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 @ 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
|
||
constructor — wrong symbol.)
|
||
- **No loops, no pitch**: retail never sets the DSound loop flag and never
|
||
calls `SetFrequency`. "Looping" ambients are re-fired one-shots.
|
||
- **Variant selection** (`SoundTableData::Lookup` + play sites): pick
|
||
`idx = (int)(roll01 · (n−1))` — uniform over all but the LAST entry
|
||
(a genuine Turbine off-by-one; the last variant is unreachable and a
|
||
faithful port keeps that) — then a SEPARATE Bernoulli gate
|
||
`rand()/32767 < probability`, else **silence**. Probability is a gate,
|
||
not a weight.
|
||
- **Volume field** is unbounded gain (dats go up to 10.0); retail clamps
|
||
only AFTER the distance divide, so >1 volumes extend audible range.
|
||
- **Prefs** (`InitPrefs @ 0x005503F0`, 8 keys): three float volumes
|
||
(effect / ambient / interface — interface is registered but **never
|
||
read**), three enable bools, `Sound Features` (==1 disables pan),
|
||
`Play Sound Only When Active`. There is NO music knob.
|
||
- **Quirk (faithful-port decision)**: effect and ambient volumes are each
|
||
applied twice (once at the play site, again inside GetAttenuation) — the
|
||
sliders are effectively **squared**.
|
||
|
||
**Sound triggers, exhaustively** (lane 5): (1) animation hooks
|
||
(SoundHook/SoundTableHook/SoundTweakedHook) — footsteps, combat swooshes,
|
||
all authored in MotionTables; (2) the server `Sound` event **0xF750**
|
||
(`guid, SoundType, vol`) — hits, wounds, wield, pickup, locks, lifestone,
|
||
spell resists; retail queues an event for a not-yet-known guid and replays
|
||
it on CreateObject, and plays at the WIRE volume, ignoring the table
|
||
entry's volume (the hook path does the opposite); (3) PhysicsScripts
|
||
(0xF754/5 — already live in acdream); (4) UI sounds via the ClientUISystem
|
||
sound table loaded by `DBObj::GetByEnum(0x22, slot 7)`; (5) region-authored
|
||
ambients (below). `CPhysicsObj::play_sound` has exactly ONE caller — the
|
||
0xF750 handler. There are NO client-local collision/jump/water sound call
|
||
sites; inventing one is a divergence.
|
||
|
||
**Ambients** (lanes 2+3): authored entirely in `region.dat`
|
||
(`Region.SoundInfo` → `AmbientSTBDesc[]` referenced by scene types ← terrain
|
||
types). On every **objcell change** (24 m), `CellManager::ChangePosition`
|
||
rebuilds weights by walking the **3×3 landblock ring × 64 land cells each**,
|
||
decoding each cell's terrain word to (terrainType, sceneIdx) and
|
||
accumulating per-sound inverse-square weight (1.0 inside 20 m, `(20/d)²` to
|
||
120 m, 0 beyond) plus an 8-way bearing histogram. Playback is a min-heap of
|
||
absolute deadlines ticked from the frame loop; each pop plays a one-shot
|
||
and re-arms. `base_chance == 0` ⇒ **ConstantSound**: non-positional,
|
||
volume = its weight share of total (a real terrain crossfade), re-fires
|
||
every `min_rate` s. Non-zero ⇒ **IntermitSound**: authored fixed volume,
|
||
positioned at a random accumulated bearing ±11.25° at distance
|
||
`min + (max−min)·t²`, gated by `roll ≤ base_chance`, interval
|
||
`RollDice(min_rate, max_rate)`. **Indoors is silent by design** —
|
||
`CEnvCell::add_ambient_sounds` is an empty folded `ret`; EnvCell has no
|
||
sound data. No day/night/weather selection exists.
|
||
|
||
**Music does not exist** (lane 6): the linked winmm MIDI player has zero
|
||
callers (`midiPlay` = 4 textual occurrences: definition + its own queue
|
||
drainer; verified independently), "music" appears 0 times in the 65 MB
|
||
decomp, no SoundType music member, no music pref, no music files shipped.
|
||
`MediaMachine` is a UI-state media bytecode VM whose `Update_Sound` routes
|
||
LayoutDesc-authored waves/table rows to the interface bus.
|
||
|
||
### BN traps (binding on every slice — reread before porting)
|
||
|
||
Binary Ninja renders x87 memory-operand compares as unimplemented `bool p`
|
||
and elides the constants; **five** sites would have ported with inverted
|
||
polarity or zeroed math: `is_continuous`, both `CanHear`s, `PlayNow`,
|
||
`PlayProbability`, plus `GetAttenuation` printing `* 0f`. Byte-decode the
|
||
PDB-paired binary (`reference_pe_byte_decode.md` workflow) for ANY float
|
||
compare or constant in this subsystem. The lane notes contain the verified
|
||
values; if a needed constant is not in a note, decode it — do not trust
|
||
the pseudo-C rendering and do not guess.
|
||
|
||
---
|
||
|
||
## Where acdream is today
|
||
|
||
Working and retail-correct-in-shape: the animation-hook trigger path
|
||
(`AudioHookSink`, correctly the only client-local trigger), SoundTable/Wave
|
||
dat parsing (byte-exact vs retail), `SoundId` enum (golden-conformance
|
||
tested), entity→SoundTable resolution (Setup-then-wire precedence),
|
||
world-audio quiescence across portal transitions, AL buffer budget/lifetime.
|
||
|
||
Divergent or missing, ranked by audible impact:
|
||
|
||
| # | Defect | Where | Symptom |
|
||
|---|---|---|---|
|
||
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
|
||
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
|
||
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
|
||
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
|
||
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
|
||
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
|
||
| 7 | UI sound bank absent; AdminEnvirons stingers logged not played; portal enter/exit cues missing | — | TS-54, AP-115 |
|
||
| 8 | `PlayMusic`/`StopMusic`/`MusicVolume` model retail code that never runs | `IAudioEngine`, settings | Dead API + misleading settings knob |
|
||
| 9 | Dead code: `AudioFalloff` (wrong constants, unused), wrong `FUN_00550ad0` citation, invented `PitchMin/PitchMax`+`Loop`+`Is3D` fields on `SoundEntry` | `AudioModel.cs`, engine header | Traps for future readers |
|
||
|
||
Register rows in scope: **AP-28** (retire at A2), **AP-115** sound half
|
||
(retire at A4), **TS-29** (retire at A5/A6), **TS-54** (retire at A4),
|
||
**TS-9** (re-scope at A6 — dat census says exactly 1 of 786 waves is MP3).
|
||
Issue **#321** (sound-cache decode-dedup race) folds into A6.
|
||
|
||
---
|
||
|
||
## Slices
|
||
|
||
Ordering is audible-value per effort; A1–A2 are the "it sounds wrong"
|
||
fixes, A3–A4 the "it's silent where retail speaks" fixes, A5 the big new
|
||
system, A6 the cleanup. Each slice: grep-named → (byte-decode if any new
|
||
constant) → pseudocode check against lane note → port → conformance tests →
|
||
build/test green → commit; user listening gates where marked.
|
||
|
||
### A1 — Selection-model correctness (small; biggest audible fix)
|
||
|
||
Replace `SoundCookbook.Roll` with retail's exact model: uniform
|
||
`idx = (int)(roll01 · (n−1))` (preserving the last-entry-unreachable
|
||
off-by-one), then the separate Bernoulli probability gate returning
|
||
"silence" — including for single-entry lists. Priority stays float [0,1]
|
||
end-to-end (`SoundEntry.Priority`, engine slots). Volume passes through
|
||
unclamped; clamp moves to post-attenuation (staged here, consumed by A2).
|
||
Delete the invented `PitchMin/PitchMax/Loop/Is3D` fields. Rewrite
|
||
`SoundCookbookTests` against golden values from the lane-4 note's decoded
|
||
tables; add a distribution test for the gate.
|
||
|
||
Acceptance: conformance tests green; connected sanity — creature idle
|
||
chatter audibly rare (Speak1 ≈ 5% per trigger, was 100%).
|
||
|
||
### A2 — Falloff/pan/listener/voice parity (retires AP-28)
|
||
|
||
Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
|
||
clamp-after, ceil-dB, −50 dB no-allocate floor, `−15·sin(Δheading)` pan
|
||
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
|
||
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
|
||
pan (AL_POSITION azimuth from pan only); remove AL's distance model and the
|
||
listener orientation math.
|
||
|
||
**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft
|
||
of this plan said the listener must move "from camera pose to player
|
||
position/heading" and listed "listener = CAMERA" as a defect. That was wrong,
|
||
and it was written before lane 1 landed. Retail's listener IS the camera:
|
||
`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person
|
||
camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera
|
||
setup, refreshed once per rendered frame from `SmartBox::update_viewer` @
|
||
`0x00453CE0` (falling back to the player's own position when the sphere sweep
|
||
fails). acdream's chase camera collides too, so the position source was already
|
||
faithful; only the HEADING extraction changes, since retail reads
|
||
`Frame::get_heading` — one compass bearing — and never a forward/up basis.
|
||
Do not "fix" this back.
|
||
|
||
Eviction compares float priority (equal never
|
||
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
|
||
Keep the squared-volume quirk faithful (register row if we later soften
|
||
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
|
||
Interface mirroring retail's knobs; note interface is read by no retail
|
||
path (we wire it to the UI bus anyway — divergence row, deliberate).
|
||
|
||
Acceptance: unit tests on gain/pan tables (golden distances from lane 1
|
||
note); **user listening gate** — side-by-side with retail: walk away from
|
||
a blacksmith's hammering, confirm matching fade-out distance (~94 m) and
|
||
pan behavior.
|
||
|
||
### A3 — Server sound path (0xF750)
|
||
|
||
Parse `Sound` (guid, SoundType u32, volume f32) in the message router;
|
||
route to a new `ServerSoundController`: resolve guid → entity; unknown
|
||
guid ⇒ queue the event and replay on CreateObject (retail
|
||
`CObjectMaint` behavior); known guid without SoundTable ⇒ silent drop;
|
||
play via the SoundTable at the **wire volume** (ignore table volume —
|
||
asymmetric with the hook path, byte-verified). Position at the entity's
|
||
current origin.
|
||
|
||
Acceptance: wire-format conformance test (three-oracle layout);
|
||
connected gate — melee hits, item pickup/drop, lifestone bind audibly
|
||
fire against ACE.
|
||
|
||
### A4 — UI + interface sounds (retires TS-54, AP-115's sound half)
|
||
|
||
Load the ClientUISystem sound table (`GetByEnum` cache 0x22, enum slot 7 —
|
||
resolve the actual DID at port time from `ClientUISystem::GetUISoundTable`).
|
||
Route `PlayUi(SoundId)` through it (delete the no-op). Wire:
|
||
AdminEnvirons 0x65..0x7C → `PlaySoundFromCenter` stingers
|
||
(`WorldEnvironmentController.ApplyAdminEnvirons` already parses them);
|
||
portal enter/exit `UI_EnterPortal`/`UI_ExitPortal`; button/panel cues where
|
||
the retained UI already has command seams; `MediaDescSound` support in
|
||
`LayoutImporter` (DatReaderWriter parses it; interface bus, per lane 6).
|
||
|
||
Acceptance: connected gate — `@environs` thunder/drums audible; portal
|
||
enter/exit cues audible on recall; **user listening gate** vs retail.
|
||
|
||
### A5 — Region ambient system (retires TS-29's ambient half)
|
||
|
||
New `AmbientSoundSystem` (App layer, owned like other world controllers):
|
||
rebuild on objcell change (reuse streaming's cell-transit signal), walk
|
||
the 3×3 ring × 64 cells via the SAME terrain-word decode the scenery
|
||
pipeline uses (`SceneryGenerator`-shared helper), accumulate weight
|
||
(1.0 ≤ 20 m, `(20/d)²` ≤ 120 m) + 8-way bearing, build
|
||
Constant/Intermit instances from `AmbientSTBDesc` (`base_chance == 0` ⇒
|
||
constant — the byte-verified polarity), min-heap of absolute deadlines
|
||
ticked per frame, one-shots through the ambient volume path (squared,
|
||
faithful). ConstantSound non-positional; IntermitSound positioned at
|
||
random accumulated bearing, `min + (max−min)·t²` distance. Teardown on
|
||
world transition via the existing quiescence edge. Indoors: NO ambients
|
||
(retail-faithful); `seen_outside` cells get the outdoor set. Delete
|
||
`StartAmbient`/`StopAmbient` from `IAudioEngine` (wrong shape — looping
|
||
handle API models a mechanism retail doesn't have).
|
||
|
||
Acceptance: unit tests on weight accumulation + scheduler with a synthetic
|
||
region; **user listening gate** — Holtburg outdoors vs retail side-by-side
|
||
(birdsong/wind character and rough cadence), dungeon silence, ambient
|
||
crossfade walking shore → grass.
|
||
|
||
### A6 — Deletions, bookkeeping, and the long tail
|
||
|
||
- Delete `PlayMusic`/`StopMusic`/`MusicVolume` and the `AudioSettings.Music`
|
||
knob (settings migration: drop the field, tolerate old json). Retail has
|
||
no music system; register row NOT needed once the API is gone (nothing
|
||
diverges — absence matches retail).
|
||
- Delete dead `AudioFalloff` (superseded by A2's ported math).
|
||
- r05 doc: SUPERSEDED banner pointing at the six lane notes; corrections
|
||
list from lane notes §12/§13.
|
||
- TS-9 re-scope: 1 MP3 wave in the shipped dats (`0x0A000393`, ~2 s) —
|
||
either a ~50-line managed MP3 decode for one asset or an accepted-loss
|
||
row with the census cited. ADPCM count to be measured the same way
|
||
before deciding.
|
||
- #321: make `DatSoundCache` decode-dedup safe under concurrent access
|
||
(single-flight per wave id) — the full-suite flake.
|
||
- Register sweep: retire AP-28/TS-29/TS-54 rows in their landing slices'
|
||
commits (rule 1); add rows for: interface-volume wired (A2), any
|
||
softened quirk, and anything discovered mid-campaign.
|
||
|
||
Acceptance: build/test green, register diff reviewed, no orphaned
|
||
API/settings references.
|
||
|
||
---
|
||
|
||
## Out of scope (explicitly)
|
||
|
||
- Client-local physics sounds (collision/jump/water) — retail has none;
|
||
the server sends them. Do not invent.
|
||
- Indoor ambient beds — retail is silent indoors.
|
||
- A music system — retail has none. (If we ever WANT music, that's a
|
||
new-feature decision for the user, not parity work.)
|
||
- HRTF/doppler/reverb — no retail counterpart.
|
||
|
||
## Rollback
|
||
|
||
Each slice is one commit (A6 possibly two); rollback is `git revert
|
||
<slice-sha>`, recorded in this doc's ledger as slices land. A2 and A5 are
|
||
the only slices touching frame-loop code paths; both are behind the
|
||
existing audio-availability guard, so `ACDREAM_NO_AUDIO=1` remains the
|
||
global kill switch.
|
||
|
||
## Ledger
|
||
|
||
| Slice | Status | Commit | Gates |
|
||
|---|---|---|---|
|
||
| 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 | **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 | — | — | — |
|