Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
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 @ 0x00550AD0region; byte-decoded):g = dist < 5m ? vol : 25·vol/dist², clamped to 1.0, multiplied by ONE master knob (effect_sound_volumeorambient_sound_volume), thendb = 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. - Voice pool: allocator is
SoundManager::PlaySoundInternal @ 0x0054FEC0. Eviction compares the DAT-authored float priority (0..1); equal priority never evicts. (FUN_00550AD0cited 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): pickidx = (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 gaterand()/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 CanHears, 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/MusicVolumeand theAudioSettings.Musicknob (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
DatSoundCachedecode-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 | — | — | — |
| A4 | — | — | — |
| A5 | — | — | — |
| A6 | — | — | — |