Retail EoR has no music system: the linked winmm MIDI player has zero callers, 'music' appears zero times in the 65 MB decomp, SoundType has no music member, InitPrefs registers no music key, and the install ships no music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings Music knob are deleted rather than left as an API modelling dead code — the string-keyed signature was the tell, since every other entry point is DID-keyed. Old settings.json files carrying a 'music' key still load; the reader ignores unknown keys and the next save drops it. The Ambient slider is now surfaced, because slice A5 gave it something to drive, and its default returns to retail's 1.0 from an invented 0.8 — InitPrefs defaults every sound preference to unity. The panel rule is unchanged: no slider that does nothing. r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong sections (falloff, pan, voice pool, selection, music, ambient) so a future reader reaches the lane notes instead of the Ghidra-era reads that this campaign spent its first two slices undoing. TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1 MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing assumed a music system that does not exist. The ADPCM count remains unmeasured and is named as the open question. Deferred deliberately: #321's sound-cache decode-dedup race. It is a pre-existing concurrency flake rather than audio-parity behaviour, and shipping a speculative fix to a race I have not reproduced is exactly the shortcut this project's no-workarounds rule exists to prevent. Campaign A is code-complete; the plan carries the closeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
356 lines
23 KiB
Markdown
356 lines
23 KiB
Markdown
# Campaign A — Audio retail-feel parity
|
||
|
||
**Status: CODE-COMPLETE 2026-08-08 — awaiting the user listening gate.** All six slices landed (see the ledger). 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 | `e42b9948` | 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 | `8bc458fb` | 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 | `6eaa490b` | 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 | `7c4dd1ad` | 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 | **COMPLETE** 2026-08-08 | `1b083463` | Full Release suite 11,739 passed / 4 skipped. Deleted the music API (`PlayMusic`/`StopMusic`/`MusicVolume` + the `AudioSettings.Music` knob); exposed the Ambient slider now that A5 drives it; reset the invented 0.8 ambient default to retail's 1.0; SUPERSEDED banner on `r05-audio-sound.md` listing its five wrong sections; TS-9 re-scoped to the measured one-wave blast radius. **Deferred:** #321's decode-dedup race (a pre-existing concurrency flake, not audio-parity behaviour — not fixed speculatively without reproducing it). |
|
||
|
||
---
|
||
|
||
## CAMPAIGN CLOSEOUT (code-complete 2026-08-08)
|
||
|
||
Six slices, six commits, `c69b3bde` → A6. The full Release suite ends at
|
||
**11,739 passed / 4 skipped / 0 failed**, up from 11,563 at campaign start;
|
||
the audio subsystem went from 42 tests (one of which pinned the wrong model)
|
||
to ~215 conformance tests written against byte-decoded values.
|
||
|
||
**What was wrong, and is now right:**
|
||
|
||
| Was | Now |
|
||
|---|---|
|
||
| Probability treated as a selection weight, and skipped entirely for the 4,183/4,184 single-entry sounds | Retail's uniform `(n−1)` pick plus an independent Bernoulli silence gate |
|
||
| OpenAL 3-D spatialization, `2/d` falloff, no cutoff | Retail's CPU 2-D model: `25·vol/d²` past a 5 m knee, −50 dB no-allocate floor (~94 m), ±15 dB sine pan with a 5 m dead zone |
|
||
| Voices evicted by gain | Evicted by DAT-authored float priority, strictly-less, ring order |
|
||
| `0xF750` unparsed — every server cue silent | Parsed, guid-queued-and-replayed, played at the wire volume |
|
||
| No interface sound bank | Bank DID resolved from the dats' EnumIDMap chain; portal cues + 21 AdminEnvirons stingers live |
|
||
| No ambient system at all | Region-authored, per-land-cell, 3×3 ring, deadline-queue one-shots with terrain crossfade |
|
||
| A music API | Deleted — retail has no music system |
|
||
|
||
**Process notes worth carrying forward:**
|
||
|
||
1. **Binary Ninja could not be trusted anywhere in this subsystem.** Five
|
||
float compares render inverted or with elided constants; `GetAttenuation`
|
||
prints `* 0f`, which ports as silence at every distance. Every load-bearing
|
||
value here came from byte-decoding the PDB-paired binary. The lane notes
|
||
record the verified values; a future reader should decode rather than
|
||
re-read the pseudo-C.
|
||
2. **Two research notes were wrong and were corrected in place** — lane 1's
|
||
30 m decibel row (contradicted its own gain column) and lane 5's
|
||
transposed `GetByEnum` arguments (which would have made the UI bank
|
||
unresolvable). Both were caught by recomputing rather than copying.
|
||
3. **The reviews earned their cost.** A2's review caught a pan mapping that
|
||
saturated to full separation where retail gives 15 dB. A5's caught a
|
||
coordinate-frame error that would have made the entire ambient system
|
||
silent with nothing logged — the tests passed, the build was green, and it
|
||
would have failed only at the listening gate.
|
||
4. **An architecture guard caught a design error the tests could not**:
|
||
`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an
|
||
`Action<float>` frame hook and forced the typed `IAmbientFramePhase`.
|
||
|
||
**Still owed:** the user listening gate (A2 falloff, A4 cues, A5 ambients) and
|
||
the connected gates for A3/A4. Open rows: AP-173, AP-174, TS-64, TS-65, TS-66,
|
||
TS-67, TS-9 (re-scoped), #321.
|