# Campaign A — Audio retail-feel parity **Status: CODE-COMPLETE 2026-08-08 — awaiting the user listening gate.** All six slices landed (see the ledger). 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 `, 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 | `e42b9948` | 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 | `8bc458fb` | 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 | **COMPLETE** 2026-08-08 | `6eaa490b` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. | | A5 | **COMPLETE** 2026-08-08 | `7c4dd1ad` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** | | A6 | **COMPLETE** 2026-08-08 | `dd2cb92b` | Full Release suite 11,739 passed / 4 skipped. Deleted the music API (`PlayMusic`/`StopMusic`/`MusicVolume` + the `AudioSettings.Music` knob); exposed the Ambient slider now that A5 drives it; reset the invented 0.8 ambient default to retail's 1.0; SUPERSEDED banner on `r05-audio-sound.md` listing its five wrong sections; TS-9 re-scoped to the measured one-wave blast radius. **Deferred:** #321's decode-dedup race (a pre-existing concurrency flake, not audio-parity behaviour — not fixed speculatively without reproducing it). | --- ## CAMPAIGN CLOSEOUT (code-complete 2026-08-08) Six slices, six commits, `c69b3bde` → A6. The full Release suite ends at **11,739 passed / 4 skipped / 0 failed**, up from 11,563 at campaign start; the audio subsystem went from 42 tests (one of which pinned the wrong model) to ~215 conformance tests written against byte-decoded values. **What was wrong, and is now right:** | Was | Now | |---|---| | Probability treated as a selection weight, and skipped entirely for the 4,183/4,184 single-entry sounds | Retail's uniform `(n−1)` pick plus an independent Bernoulli silence gate | | OpenAL 3-D spatialization, `2/d` falloff, no cutoff | Retail's CPU 2-D model: `25·vol/d²` past a 5 m knee, −50 dB no-allocate floor (~94 m), ±15 dB sine pan with a 5 m dead zone | | Voices evicted by gain | Evicted by DAT-authored float priority, strictly-less, ring order | | `0xF750` unparsed — every server cue silent | Parsed, guid-queued-and-replayed, played at the wire volume | | No interface sound bank | Bank DID resolved from the dats' EnumIDMap chain; portal cues + 21 AdminEnvirons stingers live | | No ambient system at all | Region-authored, per-land-cell, 3×3 ring, deadline-queue one-shots with terrain crossfade | | A music API | Deleted — retail has no music system | **Process notes worth carrying forward:** 1. **Binary Ninja could not be trusted anywhere in this subsystem.** Five float compares render inverted or with elided constants; `GetAttenuation` prints `* 0f`, which ports as silence at every distance. Every load-bearing value here came from byte-decoding the PDB-paired binary. The lane notes record the verified values; a future reader should decode rather than re-read the pseudo-C. 2. **Two research notes were wrong and were corrected in place** — lane 1's 30 m decibel row (contradicted its own gain column) and lane 5's transposed `GetByEnum` arguments (which would have made the UI bank unresolvable). Both were caught by recomputing rather than copying. 3. **The reviews earned their cost.** A2's review caught a pan mapping that saturated to full separation where retail gives 15 dB. A5's caught a coordinate-frame error that would have made the entire ambient system silent with nothing logged — the tests passed, the build was green, and it would have failed only at the listening gate. 4. **An architecture guard caught a design error the tests could not**: `ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an `Action` frame hook and forced the typed `IAmbientFramePhase`. **A4 correction (2026-08-08, from a user question):** the enter cue was hung on the `EnterTunnel` event — the first tunnel-family frame — instead of the sequencer's `PlayEnterSound`, which is `Begin()` and is what retail's `BeginTeleportAnimation` @ `0x004D638E` plays. That delayed it by a whole TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events (`PlayEnterSound` had been emitted and dropped by every consumer since R6), and `PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals` pins the moments. The exit cue was already correct. **Listening-gate round 2 (2026-08-08, inn-chatter finding):** interior soundscapes (inn talk-and-laughter) are NOT dat-authored — proven by three installed-dat scans pinned in `EnvCellSoundEmitterInventoryTests`: no interior static carries an ambient-slot table, no Setup in the whole portal dat references one, yet 23 ambient-only soundscape banks exist. They are WIRE-BOUND: the server attaches them to emitter objects and fires the slots over 0xF750 (ACE: `EmoteType.Sound` heartbeat emotes). Our A3 path is the receiver and is live; a probed session (`ACDREAM_PROBE_SOUND_WIRE=1`) received ZERO 0xF750 events across a town walkabout, so the silence is ACE world-content, not a client drop. Also added this round: `Ctrl+M` instant mute (`AcdreamToggleAudioMute` → AL listener gain, unused since A2 — silences playing voices immediately without touching the retail mixing math or any persisted setting). **Still owed:** the user listening gate (A2 falloff, A4 cues, A5 ambients) and the connected gates for A3/A4. Open rows: AP-173, AP-174, TS-64, TS-65, TS-66, TS-67, TS-9 (re-scoped), #321.