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

@ -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);
}