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>
This commit is contained in:
parent
6bb4cfa795
commit
ffa5087527
7 changed files with 4472 additions and 0 deletions
289
docs/plans/2026-08-08-audio-parity-campaign.md
Normal file
289
docs/plans/2026-08-08-audio-parity-campaign.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# 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 · (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; 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; 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 ±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 + (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 | — | — | — |
|
||||
| A2 | — | — | — |
|
||||
| A3 | — | — | — |
|
||||
| A4 | — | — | — |
|
||||
| A5 | — | — | — |
|
||||
| A6 | — | — | — |
|
||||
Loading…
Add table
Add a link
Reference in a new issue