acdream had no ambient system: StartAmbient minted a handle and played nothing. Retail's is a weighted-accumulation + timer-queue engine, not looping voices. On every objcell change (24 m) CellManager::ChangePosition rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells each, decoding each cell's terrain word through the region file's terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of absolute deadlines drained from the frame tick, where each pop fires a one-shot and re-arms. A continuous bed (base_chance == 0) is non-positional, crossfaded by its share of the TOTAL weight, and re-fired every min_rate seconds — that rate is the author's intended loop period, and re-firing is how retail fakes a sustained bed with no looping voice, re-rolling the variant and the crossfade each time. An intermittent one keeps its authored volume, plays at a random accumulated compass bearing at min + (max-min)*t^2, and is dice-gated. Indoors is silent by design: CEnvCell's contributor is a folded ret and EnvCell carries no sound data. The Opus review caught four bugs before this landed, one fatal: - Cell offsets were built in ABSOLUTE world coordinates and differenced against the listener's STREAMED-frame position, so every one of 576 offsets came out ~32 km, every contribution was culled, and the whole feature was silent with nothing logged. Offsets are now landblock-local the way Position::get_offset builds them, and the streamed-frame position is carried separately for playback, where it belongs. - The cell's weight was added to the shared denominator once per DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by the table's entry count — enough to push a typical authored volume under the 0.03 audibility floor. - The drain used where retail's UseTime is strictly below, so a descriptor authored with a zero rate re-armed at the same instant and spun the frame forever. - Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms, so a newly audible ambient was silent for a full period after the crossing that made it audible. Also: beds now go through retail's single 16-voice priority pool rather than acdream's UI pool (retail has one pool; parking beds in the UI pool let an A4 portal cue chop one mid-wave and discarded the authored priority), and CalcDir's in-block test is XY-only, since CalcWeight includes Z on purpose and CalcDir excludes it on purpose. Two behaviours are knowingly incomplete and registered rather than guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor set) and TS-67 (contribution weight is computed in-plane). Retires TS-29. The frame-loop hook is a typed IAmbientFramePhase, not a callback — the first attempt used an Action<float> and the architecture guard ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
571 lines
22 KiB
C#
571 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>())
|
|
{
|
|
// Everything from the sound cache through the UI sound bank is the
|
|
// OPTIONAL audio prefix: a fault there is swallowed and audio is
|
|
// disabled, so those points are not required boundaries. A4 added
|
|
// UiSoundsCreated to that prefix.
|
|
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
|
|
and <= ContentEffectsAudioCompositionPoint.AmbientCreated)
|
|
{
|
|
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);
|
|
|
|
// The stub dats carry no EnumIDMap chain, so a real resolve would return
|
|
// 0 anyway; pass 0 explicitly so the composition test exercises the
|
|
// "bank unresolved" branch deterministically.
|
|
public UiSoundController CreateUiSounds(
|
|
OpenAlAudioEngine engine,
|
|
DatSoundCache cache,
|
|
IDatReaderWriter dats) =>
|
|
new(engine, cache, tableDid: 0u);
|
|
|
|
public AmbientSoundController CreateAmbient(
|
|
OpenAlAudioEngine engine,
|
|
DatSoundCache cache) =>
|
|
new(engine, cache);
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|