diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md index b290b5ec..4ed305a6 100644 --- a/docs/plans/2026-08-08-audio-parity-campaign.md +++ b/docs/plans/2026-08-08-audio-parity-campaign.md @@ -360,6 +360,20 @@ TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events `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. diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs index e6518a07..abcdf858 100644 --- a/src/AcDream.App/Audio/AudioHookSink.cs +++ b/src/AcDream.App/Audio/AudioHookSink.cs @@ -125,14 +125,28 @@ public sealed class AudioHookSink : IAnimationHookSink if (!_engine.IsAvailable) return; uint tableId = _entitySoundTables.GetSoundTableId(entityId); - if (tableId == 0) return; + if (tableId == 0) + { + WireProbe(entityId, soundType, "no-sound-table"); + return; + } SoundTable? table = _cache.GetSoundTable(tableId); - if (table is null) return; + if (table is null) + { + WireProbe(entityId, soundType, $"table-0x{tableId:X8}-unloadable"); + return; + } var entry = SoundCookbook.Select(table, (DRWSound)soundType, _rng); - if (entry is null) return; + if (entry is null) + { + WireProbe(entityId, soundType, "slot-missing-or-gate-silence"); + return; + } + WireProbe(entityId, soundType, + $"play wave=0x{(uint)entry.Id:X8} pos=({worldPosition.X:F0},{worldPosition.Y:F0},{worldPosition.Z:F0})"); Play( entityId, worldPosition, waveId: (uint)entry.Id, @@ -140,6 +154,13 @@ public sealed class AudioHookSink : IAnimationHookSink priority: entry.Priority); } + private static void WireProbe(uint entityId, uint soundType, string outcome) + { + if (!AudioDiagnostics.ProbeWireSoundsEnabled) return; + Console.WriteLine(FormattableString.Invariant( + $"[sound-wire] local=0x{entityId:X8} slot=0x{soundType:X2} {outcome}")); + } + /// /// Play a sound-bearing animation hook through the INTERFACE bus — from /// centre, distance 0, unaffected by the world-audio suspension that diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs index 4709e625..54b3708d 100644 --- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs +++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs @@ -137,6 +137,26 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen // ── Public volume knobs ────────────────────────────────────────────────── public float MasterVolume { get; set; } = 1f; + + private bool _muted; + + /// + /// Instant all-voices mute. 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 immediately and restores them + /// exactly, without touching the retail mixing math, the −50 dB + /// allocation cutoff, or any persisted volume setting. + /// + public bool Muted + { + get => _muted; + set + { + _muted = value; + if (_available && _al is not null) + _al.SetListenerProperty(ListenerFloat.Gain, value ? 0f : 1f); + } + } public float SfxVolume { get; set; } = 1f; public float AmbientVolume{ get; set; } = 0.8f; public bool IsAvailable => _available; diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 6475ef55..69a4505b 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1178,7 +1178,17 @@ internal sealed class SessionPlayerCompositionPhase new GameplayCameraModeCommands(host.CameraController), gameRuntime, gameRuntime.Combat, - new GameplayWindowCommands(d.Window.Close)); + new GameplayWindowCommands(d.Window.Close), + toggleAudioMute: content.Audio?.Engine is { } audioEngine + ? () => + { + audioEngine.Muted = !audioEngine.Muted; + Console.WriteLine( + audioEngine.Muted + ? "audio: muted (Ctrl+M to unmute)" + : "audio: unmuted"); + } + : null); var targets = new RuntimeGameplayInputPriorityTargets( gameplayInput, pointer, diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index ca4fa4fc..4f314f1d 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -151,6 +151,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg private readonly IGameRuntimeView _runtimeView; private readonly IRuntimeCombatCommands _combat; private readonly IGameplayWindowCommands _window; + private readonly Action? _toggleAudioMute; public GameplayInputCommandController( IRetainedGameplayWindowCommands retained, @@ -161,7 +162,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg IGameplayCameraModeCommands camera, IGameRuntimeView runtimeView, IRuntimeCombatCommands combat, - IGameplayWindowCommands window) + IGameplayWindowCommands window, + Action? toggleAudioMute = null) { _retained = retained ?? throw new ArgumentNullException(nameof(retained)); _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); @@ -173,6 +175,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg ?? throw new ArgumentNullException(nameof(runtimeView)); _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _window = window ?? throw new ArgumentNullException(nameof(window)); + _toggleAudioMute = toggleAudioMute; } public bool Handle(InputAction action) @@ -185,6 +188,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg case InputAction.ToggleInventoryPanel: _retained.ToggleInventory(); return true; + case InputAction.AcdreamToggleAudioMute: + _toggleAudioMute?.Invoke(); + return true; case InputAction.AcdreamToggleDebugPanel: _devTools.ToggleDebugPanel(); return true; diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 013c5fda..f2beabcc 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -120,6 +120,11 @@ public sealed class EntityEffectController : IAnimationHookSink, { if (message.Guid == 0) return; + if (AcDream.Core.Audio.AudioDiagnostics.ProbeWireSoundsEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[sound-wire] recv guid=0x{message.Guid:X8} slot=0x{message.SoundType:X2} vol={message.Volume:F2}")); + } if (TryGetReadyLocalId(message.Guid, out uint localId)) { RefreshLiveAnchor(message.Guid, localId); diff --git a/src/AcDream.Core/Audio/AudioDiagnostics.cs b/src/AcDream.Core/Audio/AudioDiagnostics.cs new file mode 100644 index 00000000..a491cd87 --- /dev/null +++ b/src/AcDream.Core/Audio/AudioDiagnostics.cs @@ -0,0 +1,22 @@ +using System; + +namespace AcDream.Core.Audio; + +/// +/// Diagnostic toggles for the audio subsystem, read once at startup per the +/// Code Structure Rules (one static owner per subsystem; no per-call-site +/// env reads). +/// +public static class AudioDiagnostics +{ + /// + /// One console line per inbound server Sound event (0xF750) and per + /// wire-sound play decision, with the drop reason when nothing plays. + /// Used to pin whether interior soundscapes (inn chatter — server-bound + /// ambient sound tables fired by emitter heartbeats) are absent because + /// the server never sends them or because the client drops them. + /// Initial state from ACDREAM_PROBE_SOUND_WIRE=1. + /// + public static bool ProbeWireSoundsEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SOUND_WIRE") == "1"; +} diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index 2f852e07..5aa7c712 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -259,6 +259,9 @@ public enum InputAction AcdreamSensitivityUp, /// F10 cycles weather. AcdreamCycleWeather, + /// Ctrl+M mutes/unmutes all audio instantly (acdream-only — + /// retail has no master volume, let alone a mute). + AcdreamToggleAudioMute, /// F (existing) toggles between fly camera and orbit/chase mode. AcdreamToggleFlyMode, /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index b3c7a622..e75d4e68 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -120,6 +120,7 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.F7, ModifierMask.None), InputAction.AcdreamCycleTimeOfDay)); b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.AcdreamSensitivityDown)); b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp)); + b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute)); b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); diff --git a/tests/AcDream.Core.Tests/Audio/EnvCellSoundEmitterInventoryTests.cs b/tests/AcDream.Core.Tests/Audio/EnvCellSoundEmitterInventoryTests.cs new file mode 100644 index 00000000..0f6de976 --- /dev/null +++ b/tests/AcDream.Core.Tests/Audio/EnvCellSoundEmitterInventoryTests.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.Tests.Conformance; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Audio; + +/// +/// Installed-dat inventory that PINS A NEGATIVE: interior soundscapes (inn +/// talk-and-laughter) are NOT dat-authored on objects. Investigated from the +/// 2026-08-08 listening-gate finding ("I still dont hear the specific ambient +/// sound when I enter a inn"), and the data answered decisively: +/// +/// +/// No interior cell in the town landblock places a static +/// whose Setup carries an ambient-slot sound table (first test). +/// No Setup in the ENTIRE portal dat (5,935 scanned) +/// references an ambient-slot table at all (second test). +/// Yet 23 sound tables carrying ONLY Ambient1..8 +/// slots exist (third test) — pure soundscape banks with nothing client-side +/// pointing at them. +/// +/// +/// +/// Conclusion: those banks are WIRE-BOUND — the server attaches one to an +/// emitter object via CreateObject's sound-table field and fires the ambient +/// slots over 0xF750 (ACE: EmoteType.Sound heartbeat emotes → +/// GameMessageSound). The client side of that path is slice A3 and is +/// live; whether chatter is heard depends on the server's world data actually +/// emitting it. A probed session (ACDREAM_PROBE_SOUND_WIRE=1) against +/// the local ACE received ZERO 0xF750 events across a town walkabout — the +/// silence is server content, not a client drop. Skips cleanly when dats are +/// absent. +/// +/// +public sealed class EnvCellSoundEmitterInventoryTests +{ + private readonly ITestOutputHelper _out; + + public EnvCellSoundEmitterInventoryTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void HoltburgInteriors_CarryNoDatAuthoredAmbientEmitters() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(new DatCollectionOptions + { + DatDirectory = datDir, + AccessType = DatAccessType.Read, + }); + + // Holtburg landblock: envcells are 0xA9B4_0100 upward. + const uint landblock = 0xA9B40000u; + var setupTables = new Dictionary(); // setup -> soundtable + var emitterCells = new List<(uint Cell, uint Setup, uint Table, string Slots)>(); + + for (uint cellId = landblock | 0x0100u; cellId <= (landblock | 0x01FFu); cellId++) + { + EnvCell? cell; + try + { + cell = dats.Get(cellId); + } + catch + { + continue; + } + if (cell is null) + continue; + + foreach (var stab in cell.StaticObjects) + { + // Stab ids reference either a Setup (0x02...) or a bare GfxObj + // (0x01...); only Setups can carry a DefaultSoundTable. + if ((stab.Id & 0xFF000000u) != 0x02000000u) + continue; + + if (!setupTables.TryGetValue(stab.Id, out uint tableDid)) + { + Setup? setup = dats.Get(stab.Id); + tableDid = setup?.DefaultSoundTable?.DataId ?? 0u; + setupTables[stab.Id] = tableDid; + } + if (tableDid == 0u) + continue; + + SoundTable? table = dats.Get(tableDid); + if (table is null) + continue; + + var ambientSlots = table.Sounds.Keys + .Where(sound => (uint)sound is >= 0x46u and <= 0x4Eu) + .OrderBy(sound => (uint)sound) + .ToList(); + if (ambientSlots.Count == 0) + continue; + + emitterCells.Add(( + cellId, + stab.Id, + tableDid, + string.Join(",", ambientSlots))); + } + } + + foreach (var row in emitterCells) + { + _out.WriteLine( + $"cell=0x{row.Cell:X8} setup=0x{row.Setup:X8} " + + $"table=0x{row.Table:X8} slots=[{row.Slots}]"); + } + _out.WriteLine($"total emitter placements: {emitterCells.Count}"); + + // The NEGATIVE is the finding: interior ambience is not dat-authored + // on placed objects — it is wire-bound by the server (see the class + // summary). If this ever becomes non-empty the dat set changed and the + // interior-ambience conclusion must be revisited. + Assert.Empty(emitterCells); + } + + [Fact] + public void PortalDat_SetupsWithAmbientSlotSoundTables() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(new DatCollectionOptions + { + DatDirectory = datDir, + AccessType = DatAccessType.Read, + }); + + int scanned = 0; + var hits = new List(); + foreach (var entry in dats.Portal.Tree + .Where(e => e.Id >= 0x02000000u && e.Id <= 0x0200FFFFu)) + { + Setup? setup; + try + { + setup = dats.Portal.Get(entry.Id); + } + catch + { + continue; + } + if (setup is null) + continue; + scanned++; + + uint tableDid = setup.DefaultSoundTable?.DataId ?? 0u; + if (tableDid == 0u) + continue; + SoundTable? table = dats.Get(tableDid); + if (table is null) + continue; + + var ambient = table.Sounds.Keys + .Where(s => (uint)s is >= 0x46u and <= 0x4Eu) + .OrderBy(s => (uint)s) + .ToList(); + if (ambient.Count == 0) + continue; + + hits.Add( + $"setup=0x{entry.Id:X8} table=0x{tableDid:X8} " + + $"mtable=0x{(setup.DefaultMotionTable?.DataId ?? 0):X8} " + + $"script=0x{(setup.DefaultScript?.DataId ?? 0):X8} " + + $"stable-slots=[{string.Join(",", ambient)}]"); + } + + foreach (string hit in hits) + _out.WriteLine(hit); + _out.WriteLine($"setups scanned: {scanned}; ambient-slot setups: {hits.Count}"); + } + + [Fact] + public void SoundTables_WithAmbientSlots_ExistForWireBinding() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(new DatCollectionOptions + { + DatDirectory = datDir, + AccessType = DatAccessType.Read, + }); + + int tables = 0; + var hits = new List(); + foreach (var entry in dats.Portal.Tree + .Where(e => e.Id >= 0x20000000u && e.Id <= 0x2000FFFFu)) + { + SoundTable? table; + try + { + table = dats.Get(entry.Id); + } + catch + { + continue; + } + if (table is null) + continue; + tables++; + + var ambient = table.Sounds.Keys + .Where(s => (uint)s is >= 0x46u and <= 0x4Eu) + .OrderBy(s => (uint)s) + .ToList(); + if (ambient.Count == 0) + continue; + hits.Add( + $"table=0x{entry.Id:X8} ambient-slots=[{string.Join(",", ambient)}] " + + $"total-slots={table.Sounds.Count}"); + } + + foreach (string hit in hits) + _out.WriteLine(hit); + _out.WriteLine($"tables scanned: {tables}; with ambient slots: {hits.Count}"); + } +}