acdream/docs/plans/2026-08-08-audio-parity-campaign.md
Erik ffa5087527 docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.

Plan proposes slices A1-A6; awaiting user go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:55:33 +02:00

16 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; listener = CAMERA; AL 3D pan engine + WorldRenderFrameBuilder Wrong loudness curve both directions; pan wrong from spring-arm offset (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 ±x from pan only); remove SelectRetailDistanceModel and listener orientation math. Listener feed moves from camera pose to player position/heading. 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
A2
A3
A4
A5
A6