Two items from listening-gate round 2. Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused since A2 moved all mixing to the CPU, so it is a free master switch that silences already-playing voices instantly and restores them exactly, without touching the retail mixing math, the -50 dB allocation cutoff, or any persisted volume setting. Rebindable like every other action; console line confirms each flip. Inn chatter: the user hears talk-and-laughter ambience in retail inns and not in acdream. Three installed-dat scans (pinned as conformance tests in EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client data: no interior static in the town landblock carries an ambient-slot sound table, no Setup among all 5,935 in the portal dat references one, and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure soundscape banks with nothing client-side pointing at them. They are wire-bound: the server attaches one to an emitter object via CreateObject's sound-table field and fires the slots over 0xF750 - ACE implements exactly this (EmoteType.Sound heartbeat emotes -> GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new AudioDiagnostics owner per Code Structure Rule 5, with per-event drop reasons in AudioHookSink.PlayServerSound). A probed session against the local ACE received ZERO 0xF750 events across a town walkabout: the silence is server world-content (no emitters configured/firing), not a client drop. The first scan's assertion originally encoded the emitter-object hypothesis; the data refuted it, and the test now pins the negative so the conclusion cannot silently rot. Full Release suite green (the one failure during development was the hypothesis-pinning assertion, corrected to pin the finding). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
24 KiB
Markdown
379 lines
24 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 | `dd2cb92b` | 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`.
|
||
|
||
**A4 correction (2026-08-08, from a user question):** the enter cue was hung on the
|
||
`EnterTunnel` event — the first tunnel-family frame — instead of the sequencer's
|
||
`PlayEnterSound`, which is `Begin()` and is what retail's
|
||
`BeginTeleportAnimation` @ `0x004D638E` plays. That delayed it by a whole
|
||
TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events
|
||
(`PlayEnterSound` had been emitted and dropped by every consumer since R6), and
|
||
`PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals` pins the
|
||
moments. The exit cue was already correct.
|
||
|
||
**Listening-gate round 2 (2026-08-08, inn-chatter finding):** interior
|
||
soundscapes (inn talk-and-laughter) are NOT dat-authored — proven by three
|
||
installed-dat scans pinned in `EnvCellSoundEmitterInventoryTests`: no interior
|
||
static carries an ambient-slot table, no Setup in the whole portal dat
|
||
references one, yet 23 ambient-only soundscape banks exist. They are
|
||
WIRE-BOUND: the server attaches them to emitter objects and fires the slots
|
||
over 0xF750 (ACE: `EmoteType.Sound` heartbeat emotes). Our A3 path is the
|
||
receiver and is live; a probed session (`ACDREAM_PROBE_SOUND_WIRE=1`)
|
||
received ZERO 0xF750 events across a town walkabout, so the silence is ACE
|
||
world-content, not a client drop. Also added this round: `Ctrl+M` instant
|
||
mute (`AcdreamToggleAudioMute` → AL listener gain, unused since A2 — silences
|
||
playing voices immediately without touching the retail mixing math or any
|
||
persisted setting).
|
||
|
||
**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.
|