acdream/docs/plans/2026-08-08-audio-parity-campaign.md
Erik dd2cb92b99 chore(audio): Campaign A slice A6 — delete what retail does not have
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>
2026-08-08 22:58:50 +02:00

356 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 · (n1))` — uniform over all but the LAST entry
(a genuine Turbine off-by-one; the last variant is unreachable and a
faithful port keeps that) — then a SEPARATE Bernoulli gate
`rand()/32767 < probability`, else **silence**. Probability is a gate,
not a weight.
- **Volume field** is unbounded gain (dats go up to 10.0); retail clamps
only AFTER the distance divide, so >1 volumes extend audible range.
- **Prefs** (`InitPrefs @ 0x005503F0`, 8 keys): three float volumes
(effect / ambient / interface — interface is registered but **never
read**), three enable bools, `Sound Features` (==1 disables pan),
`Play Sound Only When Active`. There is NO music knob.
- **Quirk (faithful-port decision)**: effect and ambient volumes are each
applied twice (once at the play site, again inside GetAttenuation) — the
sliders are effectively **squared**.
**Sound triggers, exhaustively** (lane 5): (1) animation hooks
(SoundHook/SoundTableHook/SoundTweakedHook) — footsteps, combat swooshes,
all authored in MotionTables; (2) the server `Sound` event **0xF750**
(`guid, SoundType, vol`) — hits, wounds, wield, pickup, locks, lifestone,
spell resists; retail queues an event for a not-yet-known guid and replays
it on CreateObject, and plays at the WIRE volume, ignoring the table
entry's volume (the hook path does the opposite); (3) PhysicsScripts
(0xF754/5 — already live in acdream); (4) UI sounds via the ClientUISystem
sound table loaded by `DBObj::GetByEnum(0x22, slot 7)`; (5) region-authored
ambients (below). `CPhysicsObj::play_sound` has exactly ONE caller — the
0xF750 handler. There are NO client-local collision/jump/water sound call
sites; inventing one is a divergence.
**Ambients** (lanes 2+3): authored entirely in `region.dat`
(`Region.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 + (maxmin)·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 `(n1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no 50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
| 7 | UI sound bank absent; AdminEnvirons stingers logged not played; portal enter/exit cues missing | — | TS-54, AP-115 |
| 8 | `PlayMusic`/`StopMusic`/`MusicVolume` model retail code that never runs | `IAudioEngine`, settings | Dead API + misleading settings knob |
| 9 | Dead code: `AudioFalloff` (wrong constants, unused), wrong `FUN_00550ad0` citation, invented `PitchMin/PitchMax`+`Loop`+`Is3D` fields on `SoundEntry` | `AudioModel.cs`, engine header | Traps for future readers |
Register rows in scope: **AP-28** (retire at A2), **AP-115** sound half
(retire at A4), **TS-29** (retire at A5/A6), **TS-54** (retire at A4),
**TS-9** (re-scope at A6 — dat census says exactly 1 of 786 waves is MP3).
Issue **#321** (sound-cache decode-dedup race) folds into A6.
---
## Slices
Ordering is audible-value per effort; A1A2 are the "it sounds wrong"
fixes, A3A4 the "it's silent where retail speaks" fixes, A5 the big new
system, A6 the cleanup. Each slice: grep-named → (byte-decode if any new
constant) → pseudocode check against lane note → port → conformance tests →
build/test green → commit; user listening gates where marked.
### A1 — Selection-model correctness (small; biggest audible fix)
Replace `SoundCookbook.Roll` with retail's exact model: uniform
`idx = (int)(roll01 · (n1))` (preserving the last-entry-unreachable
off-by-one), then the separate Bernoulli probability gate returning
"silence" — including for single-entry lists. Priority stays float [0,1]
end-to-end (`SoundEntry.Priority`, engine slots). Volume passes through
unclamped; clamp moves to post-attenuation (staged here, consumed by A2).
Delete the invented `PitchMin/PitchMax/Loop/Is3D` fields. Rewrite
`SoundCookbookTests` against golden values from the lane-4 note's decoded
tables; add a distribution test for the gate.
Acceptance: conformance tests green; connected sanity — creature idle
chatter audibly rare (Speak1 ≈ 5% per trigger, was 100%).
### A2 — Falloff/pan/listener/voice parity (retires AP-28)
Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
clamp-after, ceil-dB, 50 dB no-allocate floor, `15·sin(Δheading)` pan
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
pan (AL_POSITION azimuth from pan only); remove AL's distance model and the
listener orientation math.
**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft
of this plan said the listener must move "from camera pose to player
position/heading" and listed "listener = CAMERA" as a defect. That was wrong,
and it was written before lane 1 landed. Retail's listener IS the camera:
`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person
camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera
setup, refreshed once per rendered frame from `SmartBox::update_viewer` @
`0x00453CE0` (falling back to the player's own position when the sphere sweep
fails). acdream's chase camera collides too, so the position source was already
faithful; only the HEADING extraction changes, since retail reads
`Frame::get_heading` — one compass bearing — and never a forward/up basis.
Do not "fix" this back.
Eviction compares float priority (equal never
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
Keep the squared-volume quirk faithful (register row if we later soften
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
Interface mirroring retail's knobs; note interface is read by no retail
path (we wire it to the UI bus anyway — divergence row, deliberate).
Acceptance: unit tests on gain/pan tables (golden distances from lane 1
note); **user listening gate** — side-by-side with retail: walk away from
a blacksmith's hammering, confirm matching fade-out distance (~94 m) and
pan behavior.
### A3 — Server sound path (0xF750)
Parse `Sound` (guid, SoundType u32, volume f32) in the message router;
route to a new `ServerSoundController`: resolve guid → entity; unknown
guid ⇒ queue the event and replay on CreateObject (retail
`CObjectMaint` behavior); known guid without SoundTable ⇒ silent drop;
play via the SoundTable at the **wire volume** (ignore table volume —
asymmetric with the hook path, byte-verified). Position at the entity's
current origin.
Acceptance: wire-format conformance test (three-oracle layout);
connected gate — melee hits, item pickup/drop, lifestone bind audibly
fire against ACE.
### A4 — UI + interface sounds (retires TS-54, AP-115's sound half)
Load the ClientUISystem sound table (`GetByEnum` cache 0x22, enum slot 7 —
resolve the actual DID at port time from `ClientUISystem::GetUISoundTable`).
Route `PlayUi(SoundId)` through it (delete the no-op). Wire:
AdminEnvirons 0x65..0x7C → `PlaySoundFromCenter` stingers
(`WorldEnvironmentController.ApplyAdminEnvirons` already parses them);
portal enter/exit `UI_EnterPortal`/`UI_ExitPortal`; button/panel cues where
the retained UI already has command seams; `MediaDescSound` support in
`LayoutImporter` (DatReaderWriter parses it; interface bus, per lane 6).
Acceptance: connected gate — `@environs` thunder/drums audible; portal
enter/exit cues audible on recall; **user listening gate** vs retail.
### A5 — Region ambient system (retires TS-29's ambient half)
New `AmbientSoundSystem` (App layer, owned like other world controllers):
rebuild on objcell change (reuse streaming's cell-transit signal), walk
the 3×3 ring × 64 cells via the SAME terrain-word decode the scenery
pipeline uses (`SceneryGenerator`-shared helper), accumulate weight
(1.0 ≤ 20 m, `(20/d)²` ≤ 120 m) + 8-way bearing, build
Constant/Intermit instances from `AmbientSTBDesc` (`base_chance == 0`
constant — the byte-verified polarity), min-heap of absolute deadlines
ticked per frame, one-shots through the ambient volume path (squared,
faithful). ConstantSound non-positional; IntermitSound positioned at
random accumulated bearing, `min + (maxmin)·t²` distance. Teardown on
world transition via the existing quiescence edge. Indoors: NO ambients
(retail-faithful); `seen_outside` cells get the outdoor set. Delete
`StartAmbient`/`StopAmbient` from `IAudioEngine` (wrong shape — looping
handle API models a mechanism retail doesn't have).
Acceptance: unit tests on weight accumulation + scheduler with a synthetic
region; **user listening gate** — Holtburg outdoors vs retail side-by-side
(birdsong/wind character and rough cadence), dungeon silence, ambient
crossfade walking shore → grass.
### A6 — Deletions, bookkeeping, and the long tail
- Delete `PlayMusic`/`StopMusic`/`MusicVolume` and the `AudioSettings.Music`
knob (settings migration: drop the field, tolerate old json). Retail has
no music system; register row NOT needed once the API is gone (nothing
diverges — absence matches retail).
- Delete dead `AudioFalloff` (superseded by A2's ported math).
- r05 doc: SUPERSEDED banner pointing at the six lane notes; corrections
list from lane notes §12/§13.
- TS-9 re-scope: 1 MP3 wave in the shipped dats (`0x0A000393`, ~2 s) —
either a ~50-line managed MP3 decode for one asset or an accepted-loss
row with the census cited. ADPCM count to be measured the same way
before deciding.
- #321: make `DatSoundCache` decode-dedup safe under concurrent access
(single-flight per wave id) — the full-suite flake.
- Register sweep: retire AP-28/TS-29/TS-54 rows in their landing slices'
commits (rule 1); add rows for: interface-volume wired (A2), any
softened quirk, and anything discovered mid-campaign.
Acceptance: build/test green, register diff reviewed, no orphaned
API/settings references.
---
## Out of scope (explicitly)
- Client-local physics sounds (collision/jump/water) — retail has none;
the server sends them. Do not invent.
- Indoor ambient beds — retail is silent indoors.
- A music system — retail has none. (If we ever WANT music, that's a
new-feature decision for the user, not parity work.)
- HRTF/doppler/reverb — no retail counterpart.
## Rollback
Each slice is one commit (A6 possibly two); rollback is `git revert
<slice-sha>`, recorded in this doc's ledger as slices land. A2 and A5 are
the only slices touching frame-loop code paths; both are behind the
existing audio-availability guard, so `ACDREAM_NO_AUDIO=1` remains the
global kill switch.
## Ledger
| Slice | Status | Commit | Gates |
|---|---|---|---|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `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 `(n1)` 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.