acdream/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
Erik e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with
m_3D = 0 and the DirectSound 3D listener the client sets up is dead code;
spatialization is two CPU scalars per voice, frozen at emission. This
slice ports that math and demotes OpenAL to a voice bank.

RetailSoundMixer (new, Core) carries the byte-decoded curve from
SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2,
clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g),
with a hard -50 dB floor at which retail does not start the voice at all
(audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's
(int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero,
forced to dead centre when (int)distance < 5, with no front/back and no
elevation cue. Every AL source is now source-relative with rolloff 0 and
the global distance model is None: AL's InverseDistanceClamped was
first-power (2/d), quieter than retail up close and far louder at range
with no cutoff whatsoever. That was the largest audible divergence in the
subsystem (AP-28, retired here).

RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan
for a free or finished slot, then evict the first slot whose DAT priority
is strictly lower, else drop. Eviction compared GAIN before, so a loud
unimportant sound could silence a quiet important one. It lives in Core
because the engine's play path talks to native AL handles and could not
be tested; the pool now has 12 conformance tests.

The listener keeps using the camera position, which the decode shows is
retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided
camera Position to SoundManager) — only the heading extraction changes,
since retail reads one compass bearing and never a forward/up basis. An
earlier draft of the plan called this a defect; corrected in the plan so
it is not fixed backwards.

Opus review found and this commit fixes: a linear pan-to-azimuth mapping
that saturated to full separation at 30 degrees (OpenAL Soft's own
speaker angle) where retail gives 15 dB — now inverts the constant-power
pan law, so full deflection reaches 0.776 of the arc and both channels
stay live; the stale FUN_00550ad0 / gain-eviction class header, which
contradicted the register row this commit writes; missing discriminating
tests for clamp order and pan truncation; dead PlayingGain state whose
comment invented a retail symbol; and a third in-tree copy of
Position::heading, now delegating to MoveToMath.PositionHeading.

MasterVolume folds into the mixer's one multiply instead of AL listener
gain, so the cutoff, radius and dB quantisation move with the slider.

Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy),
TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk,
applied on the ambient path only) filed. Research note corrected twice
where its summary contradicted its own decode (30 m dB, floor vs trunc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:58:50 +02:00

553 lines
22 KiB
C#

using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Audio;
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Spells;
using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.Audio;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
namespace AcDream.App.Tests.Composition;
public sealed class ContentEffectsAudioCompositionTests
{
[Fact]
public void ProductionPhasePublishesExactOwnersAndRegistersHooksInRetailOrder()
{
using var fixture = new Fixture();
ContentEffectsAudioResult result = fixture.Phase().Compose(
fixture.Platform,
fixture.Host);
Assert.Equal(
Enum.GetValues<ContentEffectsAudioCompositionPoint>(),
fixture.Points);
Assert.Same(fixture.Publication.Dats, result.Dats);
Assert.Same(fixture.Publication.PreparedAssets, result.PreparedAssets);
Assert.Equal("test.pak", fixture.Factory.PreparedAssetPath);
Assert.Same(result.Dats, fixture.Factory.PreparedAssetDats);
Assert.Same(fixture.Publication.Magic, result.MagicCatalog);
Assert.Same(fixture.Publication.Animations, result.AnimationLoader);
Assert.Same(fixture.Publication.Particles, result.ParticleSystem);
Assert.Same(fixture.Publication.ScriptRunner, result.ScriptRunner);
Assert.Same(fixture.Publication.Audio, result.Audio);
Assert.Equal(
[
result.ParticleSink,
result.LightingSink,
result.TranslucencySink,
result.Audio!.HookSink!,
],
fixture.Router.Sinks);
Assert.Equal(4, result.HookRegistrations.ActiveCount);
}
[Fact]
public void NoAudioAcquiresNothingFromTheOptionalFactoryPrefix()
{
using var fixture = new Fixture(noAudio: true);
ContentEffectsAudioResult result = fixture.Phase().Compose(
fixture.Platform,
fixture.Host);
Assert.Null(result.Audio);
Assert.Null(fixture.Publication.Audio);
Assert.Equal(0, fixture.Factory.AudioFactoryCalls);
Assert.DoesNotContain(
ContentEffectsAudioCompositionPoint.SoundCacheCreated,
fixture.Points);
Assert.Equal(3, fixture.Router.Sinks.Count);
}
[Theory]
[InlineData((int)ContentEffectsAudioCompositionPoint.SoundCacheCreated)]
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioEngineCreated)]
[InlineData((int)ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated)]
[InlineData((int)ContentEffectsAudioCompositionPoint.AudioSinkCreated)]
public void OptionalAudioPrefixFailureDisablesAudioOnlyAfterRollbackConverges(
int failurePointValue)
{
var failurePoint =
(ContentEffectsAudioCompositionPoint)failurePointValue;
using var fixture = new Fixture(failurePoint: failurePoint);
ContentEffectsAudioResult result = fixture.Phase().Compose(
fixture.Platform,
fixture.Host);
Assert.Null(result.Audio);
Assert.Null(fixture.Publication.Audio);
Assert.Equal(3, fixture.Router.Sinks.Count);
if (fixture.Factory.LastAudioEngine is { } engine)
Assert.True(engine.IsDisposalComplete);
Assert.Contains(fixture.Logs, message =>
message.Contains("audio: init failed", StringComparison.Ordinal));
}
[Fact]
public void FailureAfterAtomicAudioPublicationPropagatesToLifetimeOwner()
{
using var fixture = new Fixture(
failurePoint: ContentEffectsAudioCompositionPoint.AudioPublished);
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
fixture.Platform,
fixture.Host));
Assert.False(
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
Assert.NotNull(fixture.Publication.Audio);
Assert.False(fixture.Publication.Audio!.Engine.IsDisposalComplete);
Assert.Equal(3, fixture.Router.Sinks.Count);
}
[Theory]
[MemberData(nameof(RequiredFailurePoints))]
public void RequiredBoundaryFailureStopsAtTheExactPointAndNeverRunsASuffix(
int failurePointValue)
{
var failurePoint =
(ContentEffectsAudioCompositionPoint)failurePointValue;
using var fixture = new Fixture(failurePoint: failurePoint);
Assert.Throws<InvalidOperationException>(() => fixture.Phase().Compose(
fixture.Platform,
fixture.Host));
Assert.False(
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
Assert.Equal(
Enum.GetValues<ContentEffectsAudioCompositionPoint>()
.TakeWhile(point => point <= failurePoint),
fixture.Points);
}
public static TheoryData<int> RequiredFailurePoints()
{
var data = new TheoryData<int>();
foreach (ContentEffectsAudioCompositionPoint point in
Enum.GetValues<ContentEffectsAudioCompositionPoint>())
{
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
and <= ContentEffectsAudioCompositionPoint.AudioSinkCreated)
{
continue;
}
data.Add((int)point);
}
return data;
}
[Fact]
public void GameWindowUsesTheProductionPhaseAndNoLongerConstructsItsBodyInline()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new ContentEffectsAudioCompositionPhase(", source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
source,
StringComparison.Ordinal);
Assert.DoesNotContain("_hookRouter.Register(_particleSink)", source,
StringComparison.Ordinal);
Assert.DoesNotContain("new AcDream.App.Audio.OpenAlAudioEngine()", source,
StringComparison.Ordinal);
}
[Fact]
public void ProductionRendererConsumesOnlyThePublishedPreparedAssetSource()
{
string root = FindRepoRoot();
string contentPhase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"ContentEffectsAudioComposition.cs"));
string worldPhase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"WorldRenderComposition.cs"));
string manager = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"ObjectMeshManager.cs"));
string adapter = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"WbMeshAdapter.cs"));
string lifetime = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindowLifetime.cs"));
Assert.Contains("new PakPreparedAssetSource(path, dats, diagnostic)",
contentPhase, StringComparison.Ordinal);
Assert.Contains("content.PreparedAssets", worldPhase,
StringComparison.Ordinal);
Assert.Contains("_preparedAssets.Read(request.Asset, ct)", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("MeshExtractor", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("IDatReaderWriter", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("GfxObjMesh.Build", adapter,
StringComparison.Ordinal);
int meshStage = lifetime.IndexOf(
"new ResourceShutdownStage(\"mesh adapter\"",
StringComparison.Ordinal);
int preparedRelease = lifetime.IndexOf(
"Hard(\"prepared asset source\"",
StringComparison.Ordinal);
int datRelease = lifetime.IndexOf(
"Hard(\"DAT collection\"",
StringComparison.Ordinal);
Assert.True(meshStage >= 0);
Assert.True(preparedRelease > meshStage);
Assert.True(datRelease > preparedRelease);
}
private sealed class Fixture : IDisposable
{
private readonly ContentEffectsAudioCompositionPoint? _failurePoint;
public Fixture(
bool noAudio = false,
ContentEffectsAudioCompositionPoint? failurePoint = null)
{
_failurePoint = failurePoint;
Router = new AnimationHookRouter();
Poses = new EntityEffectPoseRegistry();
Factory = new Factory();
Publication = new Publication();
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!);
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
typeof(HostInputCameraResult));
Dependencies = new ContentEffectsAudioDependencies(
"test-dat",
"test.pak",
ResidencyBudgetOptions.Default,
new PhysicsDataCache(),
false,
GameRuntimeTestFactory.Create(),
Router,
Poses,
new DeferredEntityEffectAdvanceSource(),
new LightManager(),
new TranslucencyFadeManager(),
noAudio,
Logs.Add,
Logs.Add);
}
public List<ContentEffectsAudioCompositionPoint> Points { get; } = [];
public List<string> Logs { get; } = [];
public AnimationHookRouter Router { get; }
public EntityEffectPoseRegistry Poses { get; }
public Factory Factory { get; }
public Publication Publication { get; }
public ContentEffectsAudioDependencies Dependencies { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> Platform { get; }
public HostInputCameraResult Host { get; }
public ContentEffectsAudioCompositionPhase Phase() => new(
Dependencies,
Publication,
Factory,
point =>
{
Points.Add(point);
if (_failurePoint == point)
throw new InvalidOperationException($"fault at {point}");
});
public void Dispose()
{
Publication.Dispose();
Dependencies.Runtime.Dispose();
}
}
private sealed class Publication :
IGameWindowContentEffectsAudioPublication,
IDisposable
{
public IDatReaderWriter? Dats { get; private set; }
public IPreparedAssetSource? PreparedAssets { get; private set; }
public MagicCatalog? Magic { get; private set; }
public IAnimationLoader? Animations { get; private set; }
public LiveEntityCollisionBuilder? Collision { get; private set; }
public EmitterDescRegistry? Emitters { get; private set; }
public ParticleSystem? Particles { get; private set; }
public ParticleHookSink? ParticleSink { get; private set; }
public AnimationHookFrameQueue? HookFrames { get; private set; }
public RetailPhysicsScriptLoader? ScriptLoader { get; private set; }
public PhysicsScriptRunner? ScriptRunner { get; private set; }
public LightingHookSink? LightingSink { get; private set; }
public TranslucencyHookSink? TranslucencySink { get; private set; }
public AnimationHookRegistrationSet? Registrations { get; private set; }
public ContentAudioGraph? Audio { get; private set; }
public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value);
public void PublishPreparedAssetSource(IPreparedAssetSource value) =>
PreparedAssets = Once(PreparedAssets, value);
public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value);
public void PublishAnimationLoader(IAnimationLoader value) =>
Animations = Once(Animations, value);
public void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value) =>
Collision = Once(Collision, value);
public void PublishEmitterRegistry(EmitterDescRegistry value) =>
Emitters = Once(Emitters, value);
public void PublishParticleSystem(ParticleSystem value) =>
Particles = Once(Particles, value);
public void PublishParticleSink(ParticleHookSink value) =>
ParticleSink = Once(ParticleSink, value);
public void PublishAnimationHookFrames(AnimationHookFrameQueue value) =>
HookFrames = Once(HookFrames, value);
public void PublishPhysicsScriptLoader(RetailPhysicsScriptLoader value) =>
ScriptLoader = Once(ScriptLoader, value);
public void PublishPhysicsScriptRunner(PhysicsScriptRunner value) =>
ScriptRunner = Once(ScriptRunner, value);
public void PublishLightingSink(LightingHookSink value) =>
LightingSink = Once(LightingSink, value);
public void PublishTranslucencySink(TranslucencyHookSink value) =>
TranslucencySink = Once(TranslucencySink, value);
public void PublishHookRegistrations(AnimationHookRegistrationSet value) =>
Registrations = Once(Registrations, value);
public void PublishAudio(ContentAudioGraph value) => Audio = Once(Audio, value);
public void Dispose()
{
Registrations?.Dispose();
Registrations = null;
Audio?.Engine.Dispose();
Audio = null;
PreparedAssets?.Dispose();
PreparedAssets = null;
Dats?.Dispose();
Dats = null;
}
private static T Once<T>(T? current, T value) where T : class
{
if (current is not null)
throw new InvalidOperationException("duplicate publication");
return value;
}
}
private sealed class Factory : IContentEffectsAudioCompositionFactory
{
private readonly IDatReaderWriter _dats =
DispatchProxy.Create<IDatReaderWriter, NullProxy>();
private readonly MagicCatalog _magic =
(MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog));
private readonly IAnimationLoader _animations = new NullAnimationLoader();
private readonly IPreparedAssetSource _preparedAssets =
new NullPreparedAssetSource();
public int AudioFactoryCalls { get; private set; }
public OpenAlAudioEngine? LastAudioEngine { get; private set; }
public string? PreparedAssetPath { get; private set; }
public IDatReaderWriter? PreparedAssetDats { get; private set; }
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
IDatReaderWriter dats,
Action<string> diagnostic)
{
PreparedAssetPath = path;
PreparedAssetDats = dats;
return _preparedAssets;
}
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0;
public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries)
{
Assert.Equal(
ResidencyBudgetOptions.Default.AnimationBytes,
maximumEstimatedBytes);
Assert.Equal(
ResidencyBudgetOptions.Default.AnimationEntries,
maximumEntries);
return _animations;
}
public LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
bool dumpMotionEnabled) =>
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
typeof(LiveEntityCollisionBuilder));
public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets) =>
(LiveCollisionAssetPublisher)RuntimeHelpers.GetUninitializedObject(
typeof(LiveCollisionAssetPublisher));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new();
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters);
public ParticleHookSink CreateParticleSink(
ParticleSystem particles,
EntityEffectPoseRegistry poses) => new(particles, poses);
public AnimationHookFrameQueue CreateAnimationHookFrames(
AnimationHookRouter router,
EntityEffectPoseRegistry poses) => new(router, poses);
public AnimationHookRegistrationSet CreateHookRegistrations(
AnimationHookRouter router) => new(router);
public RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats) =>
(RetailPhysicsScriptLoader)RuntimeHelpers.GetUninitializedObject(
typeof(RetailPhysicsScriptLoader));
public PhysicsScriptRunner CreatePhysicsScriptRunner(
RetailPhysicsScriptLoader loader,
AnimationHookRouter router,
IEntityEffectAdvanceSource advanceSource) =>
new(_ => null, router, canAdvanceOwner: advanceSource.CanAdvanceOwner);
public LightingHookSink CreateLightingSink(
LightManager lighting,
EntityEffectPoseRegistry poses) => new(lighting, poses);
public TranslucencyHookSink CreateTranslucencySink(
TranslucencyFadeManager fades) => new(fades);
public DatSoundCache CreateSoundCache(
IDatReaderWriter dats,
long maximumDecodedBytes)
{
Assert.Equal(
ResidencyBudgetOptions.Default.AudioBytes,
maximumDecodedBytes);
return new DatSoundCache(dats, maximumDecodedBytes);
}
public OpenAlAudioEngine CreateAudioEngine()
{
AudioFactoryCalls++;
LastAudioEngine = new OpenAlAudioEngine(
new AudioApiFactory(new FakeAudioApi()));
return LastAudioEngine;
}
public DictionaryEntitySoundTable CreateEntitySoundTables() => new();
public AudioHookSink CreateAudioSink(
OpenAlAudioEngine engine,
DatSoundCache cache,
DictionaryEntitySoundTable entitySoundTables) =>
new(engine, cache, entitySoundTables);
}
public class NullProxy : DispatchProxy
{
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod?.ReturnType == typeof(void))
return null;
Type returnType = targetMethod?.ReturnType ?? typeof(void);
return returnType.IsValueType
? Activator.CreateInstance(returnType)
: null;
}
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private sealed class AudioApiFactory(IOpenAlResourceApi api)
: IOpenAlResourceApiFactory
{
public IOpenAlResourceApi Create() => api;
}
private sealed class FakeAudioApi : IOpenAlResourceApi
{
private uint _nextSource = 1;
public AL? AudioApi => null;
public ALContext? ContextApi => null;
public nint OpenDevice() => 1;
public nint CreateContext(nint device) => 2;
public bool MakeContextCurrent(nint context) => true;
public uint GenerateSource() => _nextSource++;
public void Configure3DSource(uint source) { }
public void ConfigureUiSource(uint source) { }
public void DisableAlDistanceAttenuation() { }
public void StopSource(uint source) { }
public void DeleteSource(uint source) { }
public void DeleteBuffer(uint buffer) { }
public void DestroyContext(nint context) { }
public void CloseDevice(nint device) { }
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}