Retail's UI sound bank was absent, so three families of cue were silent: the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere (chanting, drums, whispers, thunder — what players remember as dungeon 'music'), and every other interface slot. The bank's DID is not a literal anywhere in retail: GetUISoundTable @0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum @0x00413940 resolves it through two EnumIDMap hops off the portal dat header's master map. UiSoundTableResolver walks that chain the way RetailCursorResolver already walks it for cursors. Against the shipped dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_* slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms the walk independently of the decode. UiSoundTableResolutionTests pins the walk, the DID, and the content, and skips when dats are absent. Two corrections to the research along the way. The lane-5 note recorded GetByEnum's arguments transposed: the 0x22 it called a fileType is the CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real second-hop key is 0x10000003; walking it the other way finds nothing. And its claim that the interface volume pref applies is wrong — GetAttenuation with ambient=0 multiplies by the EFFECT knob, so retail's interface_sound_volume stays the dead knob lane 1 byte-decoded it to be. EnvironSoundCueMap is an explicit 21-case table read straight out of Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit 0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B with no 0x7C case. Verified case-by-case against the decomp rather than from the lane note, whose tail table was ambiguous. Cues are attached where retail plays them: the teleport-animation boundary for the portal pair, and the AdminEnvirons handler for the stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what PlayUiWave already implements after A2. Retires TS-54. Narrows AP-115 to its notice-presentation residual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
19 KiB
Markdown
310 lines
19 KiB
Markdown
# 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 · (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
|
||
<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 | — | — | — |
|
||
| A6 | — | — | — |
|