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>
232 lines
8 KiB
C#
232 lines
8 KiB
C#
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}");
|
|
}
|
|
}
|