acdream/docs/plans/2026-08-08-audio-parity-campaign.md
Erik e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
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>
2026-08-08 21:58:50 +02:00

17 KiB
Raw Blame History

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 @ 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.
  • 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 · (n1)) — 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.SoundInfoAmbientSTBDesc[] 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 == 0ConstantSound: 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 + (maxmin)·t², gated by roll ≤ base_chance, interval RollDice(min_rate, max_rate). Indoors is silent by designCEnvCell::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 (n1) 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; A1A2 are the "it sounds wrong" fixes, A3A4 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 · (n1)) (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 + (maxmin)·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
A4
A5
A6