refactor(app): compose content effects and audio startup

Move Phase-2 startup behind typed composition/publication boundaries, replace the GameWindow-capturing PhysicsScript gate with a focused deferred source, and own animation-hook registrations reversibly. Make OpenAL construction and teardown transactional so every device, context, source, and buffer prefix is retryable without replay.
This commit is contained in:
Erik 2026-07-22 15:36:05 +02:00
parent 1d51e35c14
commit 60a1698ce7
12 changed files with 1901 additions and 152 deletions

View file

@ -52,11 +52,10 @@ namespace AcDream.App.Audio;
public sealed unsafe class OpenAlAudioEngine : IAudioEngine public sealed unsafe class OpenAlAudioEngine : IAudioEngine
{ {
// ── Backends ───────────────────────────────────────────────────────────── // ── Backends ─────────────────────────────────────────────────────────────
private readonly ALContext? _alc; private AL? _al;
private readonly AL? _al; private OpenAlResourceLifetime? _resources;
private readonly Device* _device; private bool _available;
private readonly Context* _context; private bool _disposed;
private readonly bool _available;
// ── Pools ──────────────────────────────────────────────────────────────── // ── Pools ────────────────────────────────────────────────────────────────
private const int PoolSize3D = 16; // retail 16-slot voice pool private const int PoolSize3D = 16; // retail 16-slot voice pool
@ -91,44 +90,48 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
public bool IsAvailable => _available; public bool IsAvailable => _available;
public OpenAlAudioEngine() public OpenAlAudioEngine()
: this(new SilkOpenAlResourceApiFactory())
{ {
}
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
{
ArgumentNullException.ThrowIfNull(apiFactory);
IOpenAlResourceApi api;
try try
{ {
_alc = ALContext.GetApi(soft: true); api = apiFactory.Create();
_al = AL.GetApi(soft: true); }
_device = _alc.OpenDevice(string.Empty); catch
if (_device == null) {
return;
}
_al = api.AudioApi;
_resources = new OpenAlResourceLifetime(api);
try
{
if (!_resources.TryOpenDevice())
{ {
_available = false;
return; return;
} }
_context = _alc.CreateContext(_device, null); if (!_resources.TryCreateContext())
if (_context == null)
{ {
_alc.CloseDevice(_device); DisableAfterInitializationFailure(
_device = null; new InvalidOperationException("OpenAL could not create a context."));
_available = false;
return; return;
} }
if (!_alc.MakeContextCurrent(_context)) if (!_resources.TryMakeCurrent())
{ {
_alc.DestroyContext(_context); DisableAfterInitializationFailure(
_context = null; new InvalidOperationException("OpenAL could not activate its context."));
_alc.CloseDevice(_device);
_device = null;
_available = false;
return; return;
} }
// Initialise 3D source pool. // Initialise 3D source pool.
for (int i = 0; i < PoolSize3D; i++) for (int i = 0; i < PoolSize3D; i++)
{ {
uint src = _al.GenSource(); uint src = _resources.Create3DSource();
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
_al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
_al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
_al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
_pool3D[i] = new Slot3D { SourceId = src, InUse = false }; _pool3D[i] = new Slot3D { SourceId = src, InUse = false };
} }
@ -136,61 +139,57 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
// ignore 3D position. // ignore 3D position.
for (int i = 0; i < PoolSizeUi; i++) for (int i = 0; i < PoolSizeUi; i++)
{ {
uint src = _al.GenSource(); uint src = _resources.CreateUiSource();
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
_poolUi[i] = src; _poolUi[i] = src;
} }
// Global distance model = inverse-square clamped (classic retail feel). // Global distance model = inverse-square clamped (classic retail feel).
_al.DistanceModel(DistanceModel.InverseDistanceClamped); api.SelectRetailDistanceModel();
_available = true; _available = true;
} }
catch catch (OpenAlInitializationException)
{ {
// OpenAL driver unavailable (headless CI, missing libopenal). throw;
_available = false; }
catch (Exception failure)
{
DisableAfterInitializationFailure(failure);
} }
} }
public void Dispose() public void Dispose()
{ {
if (!_available || _al is null) return; if (_disposed)
return;
_available = false;
_resources?.RetryCleanup();
_disposed = _resources is null || _resources.IsCleanupComplete;
}
internal bool IsDisposalComplete =>
_disposed || _resources is null || _resources.IsCleanupComplete;
private void DisableAfterInitializationFailure(Exception failure)
{
_available = false;
if (_resources is null)
return;
try try
{ {
for (int i = 0; i < PoolSize3D; i++) _resources.RetryCleanup();
{
var slot = _pool3D[i];
if (slot is null) continue;
_al.SourceStop(slot.SourceId);
_al.DeleteSource(slot.SourceId);
}
for (int i = 0; i < PoolSizeUi; i++)
{
_al.SourceStop(_poolUi[i]);
_al.DeleteSource(_poolUi[i]);
}
foreach (var kv in _ambientSources)
{
_al.SourceStop(kv.Value);
_al.DeleteSource(kv.Value);
}
foreach (var buf in _bufferByWaveId.Values)
{
_al.DeleteBuffer(buf);
}
if (_context != null && _alc is not null)
{
_alc.MakeContextCurrent(null);
_alc.DestroyContext(_context);
}
if (_device != null && _alc is not null)
_alc.CloseDevice(_device);
} }
catch { /* shutdown — ignore */ } catch (AggregateException cleanupFailure)
{
throw new OpenAlInitializationException(
failure,
_resources,
cleanupFailure);
}
_al = null;
} }
// ── IAudioEngine ───────────────────────────────────────────────────────── // ── IAudioEngine ─────────────────────────────────────────────────────────
@ -339,10 +338,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing; if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
uint buf = _al.GenBuffer(); uint buf = _al.GenBuffer();
_resources!.OwnBuffer(buf);
BufferFormat fmt = PickFormat(wave); BufferFormat fmt = PickFormat(wave);
if (fmt == 0) if (fmt == 0)
{ {
_al.DeleteBuffer(buf); _resources.ReleaseBuffer(buf);
_bufferByWaveId[waveId] = 0; _bufferByWaveId[waveId] = 0;
return 0; return 0;
} }

View file

@ -0,0 +1,335 @@
using AcDream.App.Rendering;
using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// Narrow native-resource surface used by the OpenAL construction transaction.
/// Runtime playback still calls Silk's typed <see cref="AL"/> API directly.
/// </summary>
internal interface IOpenAlResourceApi
{
AL? AudioApi { get; }
ALContext? ContextApi { get; }
nint OpenDevice();
nint CreateContext(nint device);
bool MakeContextCurrent(nint context);
uint GenerateSource();
void Configure3DSource(uint source);
void ConfigureUiSource(uint source);
void SelectRetailDistanceModel();
void StopSource(uint source);
void DeleteSource(uint source);
void DeleteBuffer(uint buffer);
void DestroyContext(nint context);
void CloseDevice(nint device);
}
internal interface IOpenAlResourceApiFactory
{
IOpenAlResourceApi Create();
}
internal sealed class SilkOpenAlResourceApiFactory : IOpenAlResourceApiFactory
{
public IOpenAlResourceApi Create() => new SilkOpenAlResourceApi();
}
internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
{
public SilkOpenAlResourceApi()
{
ContextApi = ALContext.GetApi(soft: true);
AudioApi = AL.GetApi(soft: true);
}
public AL AudioApi { get; }
public ALContext ContextApi { get; }
public nint OpenDevice() => (nint)ContextApi.OpenDevice(string.Empty);
public nint CreateContext(nint device) =>
(nint)ContextApi.CreateContext((Device*)device, null);
public bool MakeContextCurrent(nint context) =>
ContextApi.MakeContextCurrent((Context*)context);
public uint GenerateSource() => AudioApi.GenSource();
public void Configure3DSource(uint source)
{
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
public void ConfigureUiSource(uint source)
{
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
}
public void SelectRetailDistanceModel() =>
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
public void StopSource(uint source) => AudioApi.SourceStop(source);
public void DeleteSource(uint source) => AudioApi.DeleteSource(source);
public void DeleteBuffer(uint buffer) => AudioApi.DeleteBuffer(buffer);
public void DestroyContext(nint context) =>
ContextApi.DestroyContext((Context*)context);
public void CloseDevice(nint device) =>
ContextApi.CloseDevice((Device*)device);
}
/// <summary>
/// Owns every native handle immediately after acquisition. Cleanup is
/// all-attempted, reverse dependency ordered, retryable, and never replays a
/// successful release.
/// </summary>
internal sealed class OpenAlResourceLifetime : IRetryableResourceCleanup
{
private sealed class SourceState(uint id)
{
public uint Id { get; } = id;
public bool Released { get; set; }
}
private sealed class BufferState(uint id)
{
public uint Id { get; } = id;
public bool Released { get; set; }
}
private readonly IOpenAlResourceApi _api;
private readonly List<SourceState> _sources = [];
private readonly List<BufferState> _buffers = [];
private nint _device;
private nint _context;
private bool _contextCurrent;
private bool _cleanupActive;
public OpenAlResourceLifetime(IOpenAlResourceApi api)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
}
public nint Device => _device;
public nint Context => _context;
public bool IsCleanupComplete =>
_sources.All(static source => source.Released)
&& _buffers.All(static buffer => buffer.Released)
&& _context == 0
&& _device == 0;
public bool TryOpenDevice()
{
if (_device != 0)
throw new InvalidOperationException("The OpenAL device is already open.");
_device = _api.OpenDevice();
return _device != 0;
}
public bool TryCreateContext()
{
if (_device == 0)
throw new InvalidOperationException("An OpenAL device is required before its context.");
if (_context != 0)
throw new InvalidOperationException("The OpenAL context already exists.");
_context = _api.CreateContext(_device);
return _context != 0;
}
public bool TryMakeCurrent()
{
if (_context == 0)
throw new InvalidOperationException("An OpenAL context is required before activation.");
_contextCurrent = _api.MakeContextCurrent(_context);
return _contextCurrent;
}
public uint Create3DSource()
{
uint source = _api.GenerateSource();
_sources.Add(new SourceState(source));
_api.Configure3DSource(source);
return source;
}
public uint CreateUiSource()
{
uint source = _api.GenerateSource();
_sources.Add(new SourceState(source));
_api.ConfigureUiSource(source);
return source;
}
public void OwnBuffer(uint buffer)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
if (_buffers.Any(existing => existing.Id == buffer && !existing.Released))
throw new InvalidOperationException($"OpenAL buffer {buffer} is already owned.");
_buffers.Add(new BufferState(buffer));
}
public void ReleaseBuffer(uint buffer)
{
BufferState state = _buffers.LastOrDefault(candidate =>
candidate.Id == buffer && !candidate.Released)
?? throw new InvalidOperationException($"OpenAL buffer {buffer} is not owned.");
_api.DeleteBuffer(buffer);
state.Released = true;
}
public void RetryCleanup()
{
if (_cleanupActive || IsCleanupComplete)
return;
_cleanupActive = true;
List<Exception>? failures = null;
try
{
// Sources borrow buffers; retire all sources first. Each deletion
// is still attempted even when SourceStop reports a driver error.
for (int i = _sources.Count - 1; i >= 0; i--)
{
SourceState source = _sources[i];
if (source.Released)
continue;
Exception? stopFailure = null;
try
{
_api.StopSource(source.Id);
}
catch (Exception failure)
{
stopFailure = failure;
}
try
{
_api.DeleteSource(source.Id);
source.Released = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new AggregateException(
$"OpenAL source {source.Id} could not be released.",
stopFailure is null ? [failure] : [stopFailure, failure]));
}
}
for (int i = _buffers.Count - 1; i >= 0; i--)
{
BufferState buffer = _buffers[i];
if (buffer.Released)
continue;
try
{
_api.DeleteBuffer(buffer.Id);
buffer.Released = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"OpenAL buffer {buffer.Id} could not be released.",
failure));
}
}
bool childrenReleased =
_sources.All(static source => source.Released)
&& _buffers.All(static buffer => buffer.Released);
if (childrenReleased && _context != 0)
{
if (_contextCurrent)
{
try
{
if (!_api.MakeContextCurrent(0))
throw new InvalidOperationException(
"OpenAL rejected clearing the current context.");
_contextCurrent = false;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The current OpenAL context could not be cleared.",
failure));
}
}
if (!_contextCurrent)
{
try
{
_api.DestroyContext(_context);
_context = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The OpenAL context could not be destroyed.",
failure));
}
}
}
if (_context == 0 && _device != 0)
{
try
{
_api.CloseDevice(_device);
_device = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
"The OpenAL device could not be closed.",
failure));
}
}
}
finally
{
_cleanupActive = false;
}
if (failures is not null)
throw new AggregateException(
"OpenAL native-resource cleanup remains incomplete.",
failures);
}
}
internal sealed class OpenAlInitializationException : AggregateException,
IRetryableResourceCleanup
{
private readonly OpenAlResourceLifetime _lifetime;
public OpenAlInitializationException(
Exception initializationFailure,
OpenAlResourceLifetime lifetime,
AggregateException cleanupFailure)
: base(
"OpenAL initialization failed and native-resource cleanup remains incomplete.",
[initializationFailure, .. cleanupFailure.InnerExceptions])
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
}
public bool IsCleanupComplete => _lifetime.IsCleanupComplete;
public void RetryCleanup() => _lifetime.RetryCleanup();
}

View file

@ -0,0 +1,105 @@
using AcDream.App.Rendering;
using AcDream.Core.Physics;
namespace AcDream.App.Composition;
/// <summary>
/// Lifetime owner for animation-hook router edges. Registration order remains
/// retail-observable dispatch order; teardown runs in reverse and retains only
/// unregister operations that actually failed.
/// </summary>
internal sealed class AnimationHookRegistrationSet : IDisposable,
IRetryableResourceCleanup
{
private sealed class Entry(IAnimationHookSink sink)
{
public IAnimationHookSink Sink { get; } = sink;
public bool Removed { get; set; }
}
private readonly Action<IAnimationHookSink> _register;
private readonly Action<IAnimationHookSink> _unregister;
private readonly List<Entry> _entries = [];
private bool _disposing;
private bool _closed;
public AnimationHookRegistrationSet(AnimationHookRouter router)
: this(
(router ?? throw new ArgumentNullException(nameof(router))).Register,
router.Unregister)
{
}
internal AnimationHookRegistrationSet(
Action<IAnimationHookSink> register,
Action<IAnimationHookSink> unregister)
{
_register = register ?? throw new ArgumentNullException(nameof(register));
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
}
public bool IsCleanupComplete =>
_entries.All(static entry => entry.Removed);
public int ActiveCount =>
_entries.Count(static entry => !entry.Removed);
public void Register(IAnimationHookSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
ObjectDisposedException.ThrowIf(_closed, this);
if (_entries.Any(entry =>
!entry.Removed && ReferenceEquals(entry.Sink, sink)))
{
return;
}
_register(sink);
_entries.Add(new Entry(sink));
}
public void Dispose()
{
_closed = true;
RetryCleanup();
}
public void RetryCleanup()
{
if (_disposing || IsCleanupComplete)
return;
_closed = true;
_disposing = true;
List<Exception>? failures = null;
try
{
for (int i = _entries.Count - 1; i >= 0; i--)
{
Entry entry = _entries[i];
if (entry.Removed)
continue;
try
{
_unregister(entry.Sink);
entry.Removed = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Animation-hook sink '{entry.Sink.GetType().Name}' could not be unregistered.",
failure));
}
}
}
finally
{
_disposing = false;
}
if (failures is not null)
throw new AggregateException(
"Animation-hook registration cleanup remains incomplete.",
failures);
}
}

View file

@ -0,0 +1,466 @@
using AcDream.App.Audio;
using AcDream.App.Physics;
using AcDream.App.Rendering;
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 DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
namespace AcDream.App.Composition;
internal sealed record ContentAudioGraph(
DatSoundCache SoundCache,
OpenAlAudioEngine Engine,
DictionaryEntitySoundTable EntitySoundTables,
AudioHookSink? HookSink);
internal sealed record ContentEffectsAudioResult(
IDatReaderWriter Dats,
MagicCatalog MagicCatalog,
IAnimationLoader AnimationLoader,
LiveEntityCollisionBuilder CollisionBuilder,
EmitterDescRegistry EmitterRegistry,
ParticleSystem ParticleSystem,
ParticleHookSink ParticleSink,
AnimationHookFrameQueue AnimationHookFrames,
RetailPhysicsScriptLoader PhysicsScriptLoader,
PhysicsScriptRunner ScriptRunner,
LightingHookSink LightingSink,
TranslucencyHookSink TranslucencySink,
AnimationHookRegistrationSet HookRegistrations,
ContentAudioGraph? Audio);
internal sealed record ContentEffectsAudioDependencies(
string DatDirectory,
PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled,
Spellbook SpellBook,
AnimationHookRouter HookRouter,
EntityEffectPoseRegistry EffectPoses,
DeferredEntityEffectAdvanceSource EntityEffectAdvance,
LightManager Lighting,
TranslucencyFadeManager TranslucencyFades,
bool NoAudio,
Action<string> Log,
Action<string> Error);
internal interface IGameWindowContentEffectsAudioPublication
{
void PublishDatCollection(IDatReaderWriter value);
void PublishMagicCatalog(MagicCatalog value);
void PublishAnimationLoader(IAnimationLoader value);
void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value);
void PublishEmitterRegistry(EmitterDescRegistry value);
void PublishParticleSystem(ParticleSystem value);
void PublishParticleSink(ParticleHookSink value);
void PublishAnimationHookFrames(AnimationHookFrameQueue value);
void PublishPhysicsScriptLoader(RetailPhysicsScriptLoader value);
void PublishPhysicsScriptRunner(PhysicsScriptRunner value);
void PublishLightingSink(LightingHookSink value);
void PublishTranslucencySink(TranslucencyHookSink value);
void PublishHookRegistrations(AnimationHookRegistrationSet value);
void PublishAudio(ContentAudioGraph value);
}
internal interface IContentEffectsAudioCompositionFactory
{
IDatReaderWriter OpenDatCollection(string datDirectory);
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog);
IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats);
LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
bool dumpMotionEnabled);
EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats);
ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters);
ParticleHookSink CreateParticleSink(
ParticleSystem particles,
EntityEffectPoseRegistry poses);
AnimationHookFrameQueue CreateAnimationHookFrames(
AnimationHookRouter router,
EntityEffectPoseRegistry poses);
RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats);
PhysicsScriptRunner CreatePhysicsScriptRunner(
RetailPhysicsScriptLoader loader,
AnimationHookRouter router,
IEntityEffectAdvanceSource advanceSource);
LightingHookSink CreateLightingSink(
LightManager lighting,
EntityEffectPoseRegistry poses);
TranslucencyHookSink CreateTranslucencySink(TranslucencyFadeManager fades);
AnimationHookRegistrationSet CreateHookRegistrations(AnimationHookRouter router);
DatSoundCache CreateSoundCache(IDatReaderWriter dats);
OpenAlAudioEngine CreateAudioEngine();
DictionaryEntitySoundTable CreateEntitySoundTables();
AudioHookSink CreateAudioSink(
OpenAlAudioEngine engine,
DatSoundCache cache,
DictionaryEntitySoundTable entitySoundTables);
}
internal sealed class RetailContentEffectsAudioCompositionFactory
: IContentEffectsAudioCompositionFactory
{
public IDatReaderWriter OpenDatCollection(string datDirectory) =>
RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
MagicCatalog.Load(dats);
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) =>
spellBook.InstallMetadata(catalog.SpellTable);
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) =>
new RetailAnimationLoader(dats);
public LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
bool dumpMotionEnabled) =>
new(
physicsData,
new LiveEntityDefaultPoseResolver(
id => dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
animationLoader,
dumpMotionEnabled));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) =>
new(dats);
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 RetailPhysicsScriptLoader CreatePhysicsScriptLoader(IDatReaderWriter dats) =>
new(dats);
public PhysicsScriptRunner CreatePhysicsScriptRunner(
RetailPhysicsScriptLoader loader,
AnimationHookRouter router,
IEntityEffectAdvanceSource advanceSource) =>
new(
loader.LoadPhysicsScript,
router,
canAdvanceOwner: advanceSource.CanAdvanceOwner);
public LightingHookSink CreateLightingSink(
LightManager lighting,
EntityEffectPoseRegistry poses) =>
new(lighting, poses);
public TranslucencyHookSink CreateTranslucencySink(
TranslucencyFadeManager fades) =>
new(fades);
public AnimationHookRegistrationSet CreateHookRegistrations(
AnimationHookRouter router) =>
new(router);
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
public OpenAlAudioEngine CreateAudioEngine() => new();
public DictionaryEntitySoundTable CreateEntitySoundTables() => new();
public AudioHookSink CreateAudioSink(
OpenAlAudioEngine engine,
DatSoundCache cache,
DictionaryEntitySoundTable entitySoundTables) =>
new(engine, cache, entitySoundTables);
}
internal enum ContentEffectsAudioCompositionPoint
{
DatCollectionPublished,
MagicCatalogPublished,
SpellMetadataInstalled,
AnimationLoaderPublished,
CollisionBuilderPublished,
EmitterRegistryPublished,
ParticleSystemPublished,
ParticleSinkPublished,
AnimationHookFramesPublished,
HookRegistrationsPublished,
ParticleHookRegistered,
PhysicsScriptLoaderPublished,
PhysicsScriptRunnerPublished,
LightingSinkPublished,
LightingHookRegistered,
TranslucencySinkPublished,
TranslucencyHookRegistered,
SoundCacheCreated,
AudioEngineCreated,
EntitySoundTablesCreated,
AudioSinkCreated,
AudioPublished,
AudioHookRegistered,
}
/// <summary>
/// Production Phase 2. The sole DAT mapping and every effect/audio owner are
/// published through typed lifetime slots. Router edges have one reversible
/// owner, and optional audio is swallowed only while its entire unpublished
/// native prefix has converged cleanup.
/// </summary>
internal sealed class ContentEffectsAudioCompositionPhase :
IContentEffectsAudioCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult>
{
private readonly ContentEffectsAudioDependencies _dependencies;
private readonly IGameWindowContentEffectsAudioPublication _publication;
private readonly IContentEffectsAudioCompositionFactory _factory;
private readonly Action<ContentEffectsAudioCompositionPoint>? _faultInjection;
public ContentEffectsAudioCompositionPhase(
ContentEffectsAudioDependencies dependencies,
IGameWindowContentEffectsAudioPublication publication,
IContentEffectsAudioCompositionFactory? factory = null,
Action<ContentEffectsAudioCompositionPoint>? faultInjection = null)
{
_dependencies = dependencies
?? throw new ArgumentNullException(nameof(dependencies));
_publication = publication
?? throw new ArgumentNullException(nameof(publication));
_factory = factory ?? new RetailContentEffectsAudioCompositionFactory();
_faultInjection = faultInjection;
}
public ContentEffectsAudioResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
HostInputCameraResult host)
{
ArgumentNullException.ThrowIfNull(platform);
ArgumentNullException.ThrowIfNull(host);
var mainScope = new CompositionAcquisitionScope();
try
{
IDatReaderWriter dats = mainScope.Acquire(
"DAT collection",
() => _factory.OpenDatCollection(_dependencies.DatDirectory),
static value => value.Dispose()).Publish(
_publication.PublishDatCollection);
Fault(ContentEffectsAudioCompositionPoint.DatCollectionPublished);
MagicCatalog magic = _factory.LoadMagicCatalog(dats);
_publication.PublishMagicCatalog(magic);
Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished);
_factory.InstallSpellMetadata(_dependencies.SpellBook, magic);
_dependencies.Log(
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);
IAnimationLoader animations = _factory.CreateAnimationLoader(dats);
_publication.PublishAnimationLoader(animations);
Fault(ContentEffectsAudioCompositionPoint.AnimationLoaderPublished);
LiveEntityCollisionBuilder collision = _factory.CreateCollisionBuilder(
_dependencies.PhysicsDataCache,
dats,
animations,
_dependencies.DumpMotionEnabled);
_publication.PublishLiveEntityCollisionBuilder(collision);
Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished);
EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats);
_publication.PublishEmitterRegistry(emitters);
Fault(ContentEffectsAudioCompositionPoint.EmitterRegistryPublished);
ParticleSystem particles = _factory.CreateParticleSystem(emitters);
_publication.PublishParticleSystem(particles);
Fault(ContentEffectsAudioCompositionPoint.ParticleSystemPublished);
ParticleHookSink particleSink = _factory.CreateParticleSink(
particles,
_dependencies.EffectPoses);
particleSink.DiagnosticSink = message =>
_dependencies.Error($"vfx: {message}");
_publication.PublishParticleSink(particleSink);
Fault(ContentEffectsAudioCompositionPoint.ParticleSinkPublished);
AnimationHookFrameQueue hookFrames =
_factory.CreateAnimationHookFrames(
_dependencies.HookRouter,
_dependencies.EffectPoses);
_publication.PublishAnimationHookFrames(hookFrames);
Fault(ContentEffectsAudioCompositionPoint.AnimationHookFramesPublished);
AnimationHookRegistrationSet registrations = mainScope.Acquire(
"animation hook registrations",
() => _factory.CreateHookRegistrations(_dependencies.HookRouter),
static value => value.Dispose()).Publish(
_publication.PublishHookRegistrations);
Fault(ContentEffectsAudioCompositionPoint.HookRegistrationsPublished);
registrations.Register(particleSink);
Fault(ContentEffectsAudioCompositionPoint.ParticleHookRegistered);
RetailPhysicsScriptLoader scriptLoader =
_factory.CreatePhysicsScriptLoader(dats);
_publication.PublishPhysicsScriptLoader(scriptLoader);
Fault(ContentEffectsAudioCompositionPoint.PhysicsScriptLoaderPublished);
PhysicsScriptRunner scriptRunner = _factory.CreatePhysicsScriptRunner(
scriptLoader,
_dependencies.HookRouter,
_dependencies.EntityEffectAdvance);
_publication.PublishPhysicsScriptRunner(scriptRunner);
Fault(ContentEffectsAudioCompositionPoint.PhysicsScriptRunnerPublished);
LightingHookSink lightingSink = _factory.CreateLightingSink(
_dependencies.Lighting,
_dependencies.EffectPoses);
_publication.PublishLightingSink(lightingSink);
Fault(ContentEffectsAudioCompositionPoint.LightingSinkPublished);
registrations.Register(lightingSink);
Fault(ContentEffectsAudioCompositionPoint.LightingHookRegistered);
TranslucencyHookSink translucencySink =
_factory.CreateTranslucencySink(_dependencies.TranslucencyFades);
_publication.PublishTranslucencySink(translucencySink);
Fault(ContentEffectsAudioCompositionPoint.TranslucencySinkPublished);
registrations.Register(translucencySink);
Fault(ContentEffectsAudioCompositionPoint.TranslucencyHookRegistered);
ContentAudioGraph? audio = _dependencies.NoAudio
? null
: ComposeOptionalAudio(dats, registrations);
mainScope.Complete();
return new ContentEffectsAudioResult(
dats,
magic,
animations,
collision,
emitters,
particles,
particleSink,
hookFrames,
scriptLoader,
scriptRunner,
lightingSink,
translucencySink,
registrations,
audio);
}
catch (Exception failure)
{
mainScope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
private ContentAudioGraph? ComposeOptionalAudio(
IDatReaderWriter dats,
AnimationHookRegistrationSet registrations)
{
var audioScope = new CompositionAcquisitionScope();
ContentAudioGraph graph;
CompositionAcquisitionScope.CompositionAcquisitionLease<OpenAlAudioEngine>
engineLease;
try
{
DatSoundCache cache = _factory.CreateSoundCache(dats);
Fault(ContentEffectsAudioCompositionPoint.SoundCacheCreated);
engineLease = audioScope.Acquire(
"OpenAL audio engine",
_factory.CreateAudioEngine,
static value => value.Dispose());
OpenAlAudioEngine engine = engineLease.Resource;
Fault(ContentEffectsAudioCompositionPoint.AudioEngineCreated);
DictionaryEntitySoundTable soundTables =
_factory.CreateEntitySoundTables();
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
AudioHookSink? sink = null;
if (engine.IsAvailable)
{
sink = _factory.CreateAudioSink(engine, cache, soundTables);
Fault(ContentEffectsAudioCompositionPoint.AudioSinkCreated);
}
graph = new ContentAudioGraph(cache, engine, soundTables, sink);
}
catch (Exception failure)
{
try
{
audioScope.RollbackAndThrow(failure);
}
catch (Exception rolledBack) when (!HasIncompleteCleanup(rolledBack))
{
_dependencies.Log(
$"audio: init failed: {rolledBack.Message} — audio disabled");
return null;
}
throw new System.Diagnostics.UnreachableException();
}
try
{
_publication.PublishAudio(graph);
engineLease.Transfer();
audioScope.Complete();
}
catch (Exception failure)
{
audioScope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
Fault(ContentEffectsAudioCompositionPoint.AudioPublished);
if (graph.HookSink is { } audioSink)
{
registrations.Register(audioSink);
Fault(ContentEffectsAudioCompositionPoint.AudioHookRegistered);
_dependencies.Log("audio: OpenAL engine ready (16 voices, 3D positional)");
}
else
{
_dependencies.Log(
"audio: OpenAL unavailable (driver missing / headless) — audio disabled");
}
return graph;
}
private static bool HasIncompleteCleanup(Exception failure)
{
if (failure is IRetryableResourceCleanup cleanup
&& !cleanup.IsCleanupComplete)
{
return true;
}
if (failure is AggregateException aggregate)
return aggregate.InnerExceptions.Any(HasIncompleteCleanup);
return failure.InnerException is { } inner && HasIncompleteCleanup(inner);
}
private void Fault(ContentEffectsAudioCompositionPoint point) =>
_faultInjection?.Invoke(point);
}

View file

@ -15,7 +15,8 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow : public sealed class GameWindow :
IDisposable, IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>, IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowHostInputCameraPublication IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -251,6 +252,10 @@ public sealed class GameWindow :
// register sinks at startup. The router is always non-null so the // register sinks at startup. The router is always non-null so the
// per-entity tick loop can just call it unconditionally. // per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new(); private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
private AcDream.App.Composition.AnimationHookRegistrationSet?
_hookRegistrations;
private readonly AcDream.App.Rendering.Vfx.DeferredEntityEffectAdvanceSource
_entityEffectAdvance = new();
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver // Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
// failed to init; all three are set together. // failed to init; all three are set together.
@ -693,6 +698,92 @@ public sealed class GameWindow :
AcDream.App.Input.CameraPointerInputController value) => AcDream.App.Input.CameraPointerInputController value) =>
PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input"); PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input");
void IGameWindowContentEffectsAudioPublication.PublishDatCollection(
IDatReaderWriter value) =>
PublishCompositionOwner(ref _dats, value, "DAT collection");
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
AcDream.App.Spells.MagicCatalog value) =>
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
AcDream.Core.Physics.IAnimationLoader value) =>
PublishCompositionOwner(ref _animLoader, value, "animation loader");
void IGameWindowContentEffectsAudioPublication.PublishLiveEntityCollisionBuilder(
AcDream.App.Physics.LiveEntityCollisionBuilder value) =>
PublishCompositionOwner(
ref _liveEntityCollisionBuilder,
value,
"live-entity collision builder");
void IGameWindowContentEffectsAudioPublication.PublishEmitterRegistry(
AcDream.Core.Vfx.EmitterDescRegistry value) =>
PublishCompositionOwner(ref _emitterRegistry, value, "emitter registry");
void IGameWindowContentEffectsAudioPublication.PublishParticleSystem(
AcDream.Core.Vfx.ParticleSystem value) =>
PublishCompositionOwner(ref _particleSystem, value, "particle system");
void IGameWindowContentEffectsAudioPublication.PublishParticleSink(
AcDream.Core.Vfx.ParticleHookSink value) =>
PublishCompositionOwner(ref _particleSink, value, "particle hook sink");
void IGameWindowContentEffectsAudioPublication.PublishAnimationHookFrames(
AcDream.App.Rendering.Vfx.AnimationHookFrameQueue value) =>
PublishCompositionOwner(
ref _animationHookFrames,
value,
"animation-hook frame queue");
void IGameWindowContentEffectsAudioPublication.PublishPhysicsScriptLoader(
AcDream.Content.Vfx.RetailPhysicsScriptLoader value) =>
PublishCompositionOwner(
ref _physicsScriptLoader,
value,
"physics-script loader");
void IGameWindowContentEffectsAudioPublication.PublishPhysicsScriptRunner(
AcDream.Core.Vfx.PhysicsScriptRunner value) =>
PublishCompositionOwner(ref _scriptRunner, value, "physics-script runner");
void IGameWindowContentEffectsAudioPublication.PublishLightingSink(
AcDream.Core.Lighting.LightingHookSink value) =>
PublishCompositionOwner(ref _lightingSink, value, "lighting hook sink");
void IGameWindowContentEffectsAudioPublication.PublishTranslucencySink(
AcDream.Core.Rendering.TranslucencyHookSink value) =>
PublishCompositionOwner(
ref _translucencySink,
value,
"translucency hook sink");
void IGameWindowContentEffectsAudioPublication.PublishHookRegistrations(
AcDream.App.Composition.AnimationHookRegistrationSet value) =>
PublishCompositionOwner(
ref _hookRegistrations,
value,
"animation-hook registrations");
void IGameWindowContentEffectsAudioPublication.PublishAudio(
ContentAudioGraph value)
{
ArgumentNullException.ThrowIfNull(value);
if (_soundCache is not null
|| _audioEngine is not null
|| _entitySoundTables is not null
|| _audioSink is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns audio state.");
}
_soundCache = value.SoundCache;
_audioEngine = value.Engine;
_entitySoundTables = value.EntitySoundTables;
_audioSink = value.HookSink;
}
private static void PublishCompositionOwner<T>( private static void PublishCompositionOwner<T>(
ref T? destination, ref T? destination,
T value, T value,
@ -748,6 +839,23 @@ public sealed class GameWindow :
WorldRenderDiagnostics worldRenderDiagnostics = WorldRenderDiagnostics worldRenderDiagnostics =
hostInputCamera.WorldRenderDiagnostics; hostInputCamera.WorldRenderDiagnostics;
ContentEffectsAudioResult contentEffectsAudio =
new ContentEffectsAudioCompositionPhase(
new ContentEffectsAudioDependencies(
_datDir,
_physicsDataCache,
_animationDiagnostics.DumpMotionEnabled,
SpellBook,
_hookRouter,
_effectPoses,
_entityEffectAdvance,
Lighting,
_translucencyFades,
_options.NoAudio,
Console.WriteLine,
Console.Error.WriteLine),
this).Compose(platform, hostInputCamera);
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.DepthTest);
@ -798,80 +906,6 @@ public sealed class GameWindow :
Console.WriteLine("world-hud font: no system monospace font found"); Console.WriteLine("world-hud font: no system monospace font found");
} }
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder(
_physicsDataCache,
new AcDream.App.Physics.LiveEntityDefaultPoseResolver(
id => _dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
_animLoader,
_animationDiagnostics.DumpMotionEnabled));
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
// Phase E.3 particles: always-on, no driver dependency. Registered
// with the hook router so CreateParticle / DestroyParticle /
// StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
_particleSink.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
_hookRouter,
_effectPoses);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
// resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline.
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g.
// the "fading wall" secret-passage doors) routes here.
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
_hookRouter.Register(_translucencySink);
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (!_options.NoAudio)
{
try
{
_soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
_audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
_entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
if (_audioEngine.IsAvailable)
{
_audioSink = new AcDream.App.Audio.AudioHookSink(
_audioEngine, _soundCache, _entitySoundTables);
_hookRouter.Register(_audioSink);
Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
}
else
{
Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
}
}
catch (Exception ex)
{
Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
}
}
// Settings are runtime state, not devtools state. Apply the immutable // Settings are runtime state, not devtools state. Apply the immutable
// pre-window snapshot once after camera/audio exist and before any // pre-window snapshot once after camera/audio exist and before any
// optional frontend or world/render factory consumes it. // optional frontend or world/render factory consumes it.
@ -1091,7 +1125,8 @@ public sealed class GameWindow :
uint centerLandblockId = 0xA9B4FFFFu; uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u); var region = contentEffectsAudio.Dats.Get<DatReaderWriter.DBObjs.Region>(
0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable; var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256) if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
@ -1138,7 +1173,7 @@ public sealed class GameWindow :
var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas( var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas(
() => AcDream.App.Rendering.TerrainAtlas.Build( () => AcDream.App.Rendering.TerrainAtlas.Build(
_gl, _gl,
_dats, contentEffectsAudio.Dats,
_bindlessSupport)); _bindlessSupport));
// A.5 T22.5: apply anisotropic level from quality preset. Build() // A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low // hard-codes 16x; override here to match the resolved quality so Low
@ -1188,7 +1223,11 @@ public sealed class GameWindow :
Path.Combine(shadersDir, "mesh_modern.frag")); Path.Combine(shadersDir, "mesh_modern.frag"));
Console.WriteLine("[N.5] mesh_modern shader loaded"); Console.WriteLine("[N.5] mesh_modern shader loaded");
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport, _gpuFrameFlights!); _textureCache = new TextureCache(
_gl,
contentEffectsAudio.Dats,
_bindlessSupport,
_gpuFrameFlights!);
// Two persistent GL sampler objects (Repeat + ClampToEdge) so // Two persistent GL sampler objects (Repeat + ClampToEdge) so
// the sky pass can pick wrap mode per submesh without mutating // the sky pass can pick wrap mode per submesh without mutating
// shared per-texture wrap state. See SamplerCache + the // shared per-texture wrap state. See SamplerCache + the
@ -1567,7 +1606,7 @@ public sealed class GameWindow :
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance; var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter( _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
_gl, _gl,
_dats, contentEffectsAudio.Dats,
wbLogger, wbLogger,
_gpuFrameFlights!); _gpuFrameFlights!);
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager."); Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
@ -1825,6 +1864,7 @@ public sealed class GameWindow :
_entitySoundTables?.Set(ownerId, did); _entitySoundTables?.Set(ownerId, did);
}); });
_entityEffects = entityEffects; _entityEffects = entityEffects;
_entityEffectAdvance.Bind(entityEffects);
entityEffects.DiagnosticSink = message => entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}"); Console.Error.WriteLine($"vfx: {message}");
var partArrayLifecycle = var partArrayLifecycle =
@ -1855,7 +1895,7 @@ public sealed class GameWindow :
remoteTeleportPresentation.Begin); remoteTeleportPresentation.Begin);
_equippedChildRenderer.ProjectionPoseReady += guid => _equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid); _liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects); _hookRegistrations!.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
@ -2032,7 +2072,11 @@ public sealed class GameWindow :
Path.Combine(shadersDir, "sky.vert"), Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag"))); Path.Combine(shadersDir, "sky.frag")));
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
_gl, _dats, skyShader, _textureCache!, _samplerCache); _gl,
contentEffectsAudio.Dats,
skyShader,
_textureCache!,
_samplerCache);
// Phase G.1 particle renderer — renders rain / snow / spell auras // Phase G.1 particle renderer — renders rain / snow / spell auras
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs // spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
@ -2041,9 +2085,9 @@ public sealed class GameWindow :
_particleRenderer = new ParticleRenderer( _particleRenderer = new ParticleRenderer(
_gl, _gl,
shadersDir, shadersDir,
_particleSystem, contentEffectsAudio.ParticleSystem,
_textureCache, _textureCache,
_dats, contentEffectsAudio.Dats,
_wbMeshAdapter, _wbMeshAdapter,
_retailAlphaQueue); _retailAlphaQueue);
@ -3540,6 +3584,28 @@ public sealed class GameWindow :
[ [
new("live entity runtime", () => _liveEntities?.Clear()), new("live entity runtime", () => _liveEntities?.Clear()),
]), ]),
new ResourceShutdownStage("effect dispatch edges",
[
new("entity-effect advance source", () =>
{
if (_entityEffects is { } effects)
_entityEffectAdvance.Unbind(effects);
_entityEffectAdvance.Deactivate();
}),
new("animation-hook registrations", () =>
{
AnimationHookRegistrationSet? registrations = _hookRegistrations;
if (registrations is null)
return;
registrations.Dispose();
if (!registrations.IsCleanupComplete)
{
throw new InvalidOperationException(
"Animation-hook registration cleanup remains pending.");
}
_hookRegistrations = null;
}),
]),
new ResourceShutdownStage("live entity dependents", new ResourceShutdownStage("live entity dependents",
[ [
new("live lights", () => new("live lights", () =>
@ -3559,7 +3625,22 @@ public sealed class GameWindow :
_animationHookFrames?.Clear(); _animationHookFrames?.Clear();
_effectPoses.Clear(); _effectPoses.Clear();
}), }),
new("audio", () => _audioEngine?.Dispose()), new("audio", () =>
{
AcDream.App.Audio.OpenAlAudioEngine? engine = _audioEngine;
if (engine is null)
return;
engine.Dispose();
if (!engine.IsDisposalComplete)
{
throw new InvalidOperationException(
"OpenAL native-resource cleanup remains pending.");
}
_audioEngine = null;
_audioSink = null;
_soundCache = null;
_entitySoundTables = null;
}),
]), ]),
new ResourceShutdownStage("submitted GPU work", new ResourceShutdownStage("submitted GPU work",
[ [

View file

@ -0,0 +1,63 @@
namespace AcDream.App.Rendering.Vfx;
internal interface IEntityEffectAdvanceSource
{
bool CanAdvanceOwner(uint ownerLocalId);
}
/// <summary>
/// Focused late-binding edge between the Phase-2 PhysicsScript runner and the
/// Phase-6 live effect authority. Before live presentation exists, scripts are
/// unconstrained exactly as the former null-safe callback was; after binding,
/// the canonical effect owner decides whether its current pose can advance.
/// </summary>
internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSource
{
private readonly object _gate = new();
private IEntityEffectAdvanceSource? _target;
private bool _deactivated;
public void Bind(IEntityEffectAdvanceSource target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Entity-effect script advancement is already bound.");
}
_target = target;
}
}
public void Unbind(IEntityEffectAdvanceSource target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public bool CanAdvanceOwner(uint ownerLocalId)
{
lock (_gate)
{
return _deactivated
|| _target?.CanAdvanceOwner(ownerLocalId) != false;
}
}
}

View file

@ -22,7 +22,8 @@ namespace AcDream.App.Rendering.Vfx;
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads /// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
/// (<c>0x005132B0</c>, <c>0x00513300</c>). /// (<c>0x005132B0</c>, <c>0x00513300</c>).
/// </remarks> /// </remarks>
public sealed class EntityEffectController : IAnimationHookSink public sealed class EntityEffectController : IAnimationHookSink,
IEntityEffectAdvanceSource
{ {
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner; private readonly PhysicsScriptRunner _runner;

View file

@ -0,0 +1,168 @@
using AcDream.App.Audio;
using Silk.NET.OpenAL;
namespace AcDream.App.Tests.Audio;
public sealed class OpenAlResourceLifetimeTests
{
[Fact]
public void SuccessfulEngineConstructionOwnsAndReleasesEveryNativePrefixOnce()
{
var api = new RecordingApi();
var engine = new OpenAlAudioEngine(new Factory(api));
Assert.True(engine.IsAvailable);
Assert.Equal(20, api.GeneratedSources.Count);
Assert.Equal(16, api.Configured3D.Count);
Assert.Equal(4, api.ConfiguredUi.Count);
engine.Dispose();
engine.Dispose();
Assert.True(engine.IsDisposalComplete);
Assert.Equal(20, api.DeletedSources.Count);
Assert.Equal(
Enumerable.Range(1, 20).Reverse().Select(value => (uint)value),
api.DeletedSources);
Assert.Equal(1, api.ClearCurrentCalls);
Assert.Equal(1, api.DestroyContextCalls);
Assert.Equal(1, api.CloseDeviceCalls);
}
[Fact]
public void ConfigurationFailureRollsBackTheExactGeneratedSourcePrefix()
{
var api = new RecordingApi
{
ConfigureFailureSource = 3,
};
var engine = new OpenAlAudioEngine(new Factory(api));
Assert.False(engine.IsAvailable);
Assert.True(engine.IsDisposalComplete);
Assert.Equal([3u, 2u, 1u], api.DeletedSources);
Assert.Equal(1, api.DestroyContextCalls);
Assert.Equal(1, api.CloseDeviceCalls);
}
[Fact]
public void IncompleteInitializationCleanupRemainsRetryableWithoutReplay()
{
var api = new RecordingApi
{
ConfigureFailureSource = 3,
DeleteFailureSource = 2,
};
OpenAlInitializationException failure = Assert.Throws<OpenAlInitializationException>(
() => new OpenAlAudioEngine(new Factory(api)));
Assert.False(failure.IsCleanupComplete);
Assert.Equal([3u, 1u], api.DeletedSources);
Assert.Equal(0, api.DestroyContextCalls);
Assert.Equal(0, api.CloseDeviceCalls);
api.DeleteFailureSource = null;
failure.RetryCleanup();
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal([3u, 1u, 2u], api.DeletedSources);
Assert.Equal(1, api.DeletedSources.Count(source => source == 3u));
Assert.Equal(1, api.DeletedSources.Count(source => source == 1u));
Assert.Equal(1, api.DestroyContextCalls);
Assert.Equal(1, api.CloseDeviceCalls);
}
[Fact]
public void ContextCreationFailureClosesTheDeviceBeforeReturningUnavailable()
{
var api = new RecordingApi { ContextResult = 0 };
var engine = new OpenAlAudioEngine(new Factory(api));
Assert.False(engine.IsAvailable);
Assert.True(engine.IsDisposalComplete);
Assert.Empty(api.GeneratedSources);
Assert.Equal(0, api.DestroyContextCalls);
Assert.Equal(1, api.CloseDeviceCalls);
}
private sealed class Factory(IOpenAlResourceApi api) : IOpenAlResourceApiFactory
{
public IOpenAlResourceApi Create() => api;
}
private sealed class RecordingApi : IOpenAlResourceApi
{
private uint _nextSource = 1;
public AL? AudioApi => null;
public ALContext? ContextApi => null;
public nint DeviceResult { get; set; } = 101;
public nint ContextResult { get; set; } = 202;
public uint? ConfigureFailureSource { get; set; }
public uint? DeleteFailureSource { get; set; }
public List<uint> GeneratedSources { get; } = [];
public List<uint> Configured3D { get; } = [];
public List<uint> ConfiguredUi { get; } = [];
public List<uint> DeletedSources { get; } = [];
public int ClearCurrentCalls { get; private set; }
public int DestroyContextCalls { get; private set; }
public int CloseDeviceCalls { get; private set; }
public nint OpenDevice() => DeviceResult;
public nint CreateContext(nint device) => ContextResult;
public bool MakeContextCurrent(nint context)
{
if (context == 0)
ClearCurrentCalls++;
return true;
}
public uint GenerateSource()
{
uint source = _nextSource++;
GeneratedSources.Add(source);
return source;
}
public void Configure3DSource(uint source)
{
Configured3D.Add(source);
ThrowIfConfiguredFailure(source);
}
public void ConfigureUiSource(uint source)
{
ConfiguredUi.Add(source);
ThrowIfConfiguredFailure(source);
}
public void SelectRetailDistanceModel() { }
public void StopSource(uint source) { }
public void DeleteSource(uint source)
{
if (DeleteFailureSource == source)
throw new InvalidOperationException("synthetic delete failure");
DeletedSources.Add(source);
}
public void DeleteBuffer(uint buffer) { }
public void DestroyContext(nint context) => DestroyContextCalls++;
public void CloseDevice(nint device) => CloseDeviceCalls++;
private void ThrowIfConfiguredFailure(uint source)
{
if (ConfigureFailureSource == source)
throw new InvalidOperationException("synthetic configure failure");
}
}
}

View file

@ -0,0 +1,77 @@
using System.Numerics;
using AcDream.App.Composition;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Composition;
public sealed class AnimationHookRegistrationSetTests
{
[Fact]
public void CleanupUnregistersInReverseAndNeverReplaysSuccessfulEdges()
{
var calls = new List<string>();
var first = new Sink("first");
var second = new Sink("second");
var third = new Sink("third");
bool failSecond = true;
var registrations = new AnimationHookRegistrationSet(
sink => calls.Add($"add:{((Sink)sink).Name}"),
sink =>
{
string name = ((Sink)sink).Name;
calls.Add($"remove:{name}");
if (name == "second" && failSecond)
throw new InvalidOperationException("synthetic removal failure");
});
registrations.Register(first);
registrations.Register(second);
registrations.Register(third);
Assert.Throws<AggregateException>(registrations.Dispose);
Assert.False(registrations.IsCleanupComplete);
failSecond = false;
registrations.Dispose();
registrations.Dispose();
Assert.True(registrations.IsCleanupComplete);
Assert.Equal(
[
"add:first", "add:second", "add:third",
"remove:third", "remove:second", "remove:first",
"remove:second",
],
calls);
}
[Fact]
public void DuplicateRegistrationIsIdempotentAndClosedSetRejectsNewEdges()
{
int adds = 0;
int removes = 0;
var sink = new Sink("only");
var registrations = new AnimationHookRegistrationSet(
_ => adds++,
_ => removes++);
registrations.Register(sink);
registrations.Register(sink);
registrations.Dispose();
Assert.Equal(1, adds);
Assert.Equal(1, removes);
Assert.Throws<ObjectDisposedException>(() =>
registrations.Register(new Sink("late")));
}
private sealed class Sink(string name) : IAnimationHookSink
{
public string Name { get; } = name;
public void OnHook(
uint entityId,
Vector3 entityWorldPosition,
AnimationHook hook)
{
}
}
}

View file

@ -0,0 +1,408 @@
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.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 DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
using Silk.NET.OpenGL;
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.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.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.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);
}
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<GL, IInputContext>(null!, null!);
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
typeof(HostInputCameraResult));
Dependencies = new ContentEffectsAudioDependencies(
"test-dat",
new PhysicsDataCache(),
false,
new Spellbook(),
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<GL, 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();
}
private sealed class Publication :
IGameWindowContentEffectsAudioPublication,
IDisposable
{
public IDatReaderWriter? Dats { 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 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;
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();
public int AudioFactoryCalls { get; private set; }
public OpenAlAudioEngine? LastAudioEngine { get; private set; }
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0;
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => _animations;
public LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
bool dumpMotionEnabled) =>
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
typeof(LiveEntityCollisionBuilder));
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) => new(dats);
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 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 SelectRetailDistanceModel() { }
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.");
}
}

View file

@ -54,7 +54,8 @@ public sealed class GameWindowSlice8BoundaryTests
"GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();", "GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();",
"new HostInputCameraCompositionPhase(", "new HostInputCameraCompositionPhase(",
"this).Compose(platform);", "this).Compose(platform);",
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "new ContentEffectsAudioCompositionPhase(",
"this).Compose(platform, hostInputCamera);",
"_runtimeSettings.ApplyStartup(", "_runtimeSettings.ApplyStartup(",
"new RuntimeSettingsStartupTargets(", "new RuntimeSettingsStartupTargets(",
"_uiHost = _retailUiLease.AcquireHost(", "_uiHost = _retailUiLease.AcquireHost(",
@ -342,6 +343,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"", "new ResourceShutdownStage(\"live entities\"",
"new ResourceShutdownStage(\"effect dispatch edges\"",
"new ResourceShutdownStage(\"live entity dependents\"", "new ResourceShutdownStage(\"live entity dependents\"",
"new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"", "new ResourceShutdownStage(\"render frontends\"",

View file

@ -0,0 +1,43 @@
using AcDream.App.Rendering.Vfx;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectAdvanceSourceTests
{
[Fact]
public void DefaultsOpenThenForwardsOneExactOwnerAndUnbindsOnlyThatOwner()
{
var source = new DeferredEntityEffectAdvanceSource();
var first = new Target(false);
var other = new Target(true);
Assert.True(source.CanAdvanceOwner(42));
source.Bind(first);
Assert.False(source.CanAdvanceOwner(42));
source.Unbind(other);
Assert.False(source.CanAdvanceOwner(42));
source.Unbind(first);
Assert.True(source.CanAdvanceOwner(42));
source.Bind(other);
Assert.True(source.CanAdvanceOwner(42));
}
[Fact]
public void RejectsASecondOwnerAndDeactivationIsTerminalAndOpenForShutdown()
{
var source = new DeferredEntityEffectAdvanceSource();
var first = new Target(false);
source.Bind(first);
Assert.Throws<InvalidOperationException>(() => source.Bind(new Target(true)));
source.Deactivate();
Assert.True(source.CanAdvanceOwner(1));
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
}
private sealed class Target(bool result) : IEntityEffectAdvanceSource
{
public bool CanAdvanceOwner(uint ownerLocalId) => result;
}
}