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:
parent
e5ade796ac
commit
2cf94dbcd2
10 changed files with 339 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
22
src/AcDream.Core/Audio/AudioDiagnostics.cs
Normal file
22
src/AcDream.Core/Audio/AudioDiagnostics.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>No interior cell in the town landblock places a static
|
||||
/// whose Setup carries an ambient-slot sound table (first test).</description></item>
|
||||
/// <item><description>No Setup in the ENTIRE portal dat (5,935 scanned)
|
||||
/// references an ambient-slot table at all (second test).</description></item>
|
||||
/// <item><description>Yet 23 sound tables carrying ONLY <c>Ambient1..8</c>
|
||||
/// slots exist (third test) — pure soundscape banks with nothing client-side
|
||||
/// pointing at them.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// 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: <c>EmoteType.Sound</c> heartbeat emotes →
|
||||
/// <c>GameMessageSound</c>). 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 (<c>ACDREAM_PROBE_SOUND_WIRE=1</c>) 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<uint, uint>(); // 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<EnvCell>(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<Setup>(stab.Id);
|
||||
tableDid = setup?.DefaultSoundTable?.DataId ?? 0u;
|
||||
setupTables[stab.Id] = tableDid;
|
||||
}
|
||||
if (tableDid == 0u)
|
||||
continue;
|
||||
|
||||
SoundTable? table = dats.Get<SoundTable>(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<string>();
|
||||
foreach (var entry in dats.Portal.Tree
|
||||
.Where(e => e.Id >= 0x02000000u && e.Id <= 0x0200FFFFu))
|
||||
{
|
||||
Setup? setup;
|
||||
try
|
||||
{
|
||||
setup = dats.Portal.Get<Setup>(entry.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (setup is null)
|
||||
continue;
|
||||
scanned++;
|
||||
|
||||
uint tableDid = setup.DefaultSoundTable?.DataId ?? 0u;
|
||||
if (tableDid == 0u)
|
||||
continue;
|
||||
SoundTable? table = dats.Get<SoundTable>(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<string>();
|
||||
foreach (var entry in dats.Portal.Tree
|
||||
.Where(e => e.Id >= 0x20000000u && e.Id <= 0x2000FFFFu))
|
||||
{
|
||||
SoundTable? table;
|
||||
try
|
||||
{
|
||||
table = dats.Get<SoundTable>(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}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue