acdream/docs/plans/2026-08-08-audio-parity-campaign.md
Erik 2cf94dbcd2 feat(audio): Ctrl+M instant mute + inn-chatter investigation closed as server content
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>
2026-08-09 14:09:24 +02:00

24 KiB
Raw Blame History

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.SoundInfoAmbientSTBDesc[] 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 == 0ConstantSound: 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 designCEnvCell::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 CanHears, 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 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 (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.

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.