acdream/docs/plans/2026-08-08-audio-parity-campaign.md
Erik 7c4dd1ade7 feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:53:41 +02:00

19 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 @ 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 · (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 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 COMPLETE 2026-08-08 489ea6ad 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 e81bc474 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