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>
This commit is contained in:
Erik 2026-08-09 14:09:24 +02:00
parent e5ade796ac
commit 2cf94dbcd2
10 changed files with 339 additions and 5 deletions

View file

@ -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}"));
}
/// <summary>
/// Play a sound-bearing animation hook through the INTERFACE bus — from
/// centre, distance 0, unaffected by the world-audio suspension that

View file

@ -137,6 +137,26 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
private bool _muted;
/// <summary>
/// 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.
/// </summary>
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;

View file

@ -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,

View file

@ -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;

View file

@ -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);

View file

@ -0,0 +1,22 @@
using System;
namespace AcDream.Core.Audio;
/// <summary>
/// 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).
/// </summary>
public static class AudioDiagnostics
{
/// <summary>
/// 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.
/// <para>Initial state from <c>ACDREAM_PROBE_SOUND_WIRE=1</c>.</para>
/// </summary>
public static bool ProbeWireSoundsEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SOUND_WIRE") == "1";
}

View file

@ -259,6 +259,9 @@ public enum InputAction
AcdreamSensitivityUp,
/// <summary>F10 cycles weather.</summary>
AcdreamCycleWeather,
/// <summary>Ctrl+M mutes/unmutes all audio instantly (acdream-only —
/// retail has no master volume, let alone a mute).</summary>
AcdreamToggleAudioMute,
/// <summary>F (existing) toggles between fly camera and orbit/chase mode.</summary>
AcdreamToggleFlyMode,
/// <summary>Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).</summary>

View file

@ -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));