diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a43a74d..1f75b56 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -123,6 +123,12 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
+ // Phase E.1: central fan-out for animation hooks. Audio (E.2),
+ // particles (E.3), combat (E.4), and renderer state mutators all
+ // register sinks at startup. The router is always non-null so the
+ // per-entity tick loop can just call it unconditionally.
+ private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
+
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
@@ -2923,6 +2929,22 @@ public sealed class GameWindow : IDisposable
if (ae.Sequencer is not null)
{
seqFrames = ae.Sequencer.Advance(dt);
+
+ // Phase E.1: drain animation hooks (footstep sounds, attack
+ // damage frames, particle spawns, part swaps, etc.) and fan
+ // them out to registered subsystems via the hook router.
+ // Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
+ var hooks = ae.Sequencer.ConsumePendingHooks();
+ if (hooks.Count > 0)
+ {
+ System.Numerics.Vector3 worldPos = ae.Entity.Position;
+ for (int hi = 0; hi < hooks.Count; hi++)
+ {
+ var hook = hooks[hi];
+ if (hook is null) continue;
+ _hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
+ }
+ }
}
else
{
diff --git a/src/AcDream.Core/Physics/AnimationHookRouter.cs b/src/AcDream.Core/Physics/AnimationHookRouter.cs
new file mode 100644
index 0000000..3c092ec
--- /dev/null
+++ b/src/AcDream.Core/Physics/AnimationHookRouter.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Composite — fans hooks out to any
+/// number of registered sinks. Each downstream subsystem (audio,
+/// particles, combat, etc.) registers once at startup and the router
+/// broadcasts every hook.
+///
+///
+/// Ordering matters only if a sink mutates shared state that a later
+/// sink reads; the default order is registration order. Register
+/// audio + particles before combat so they fire even if combat
+/// chooses to ignore / modify the hook.
+///
+///
+///
+/// Thread-safety: mutators () are locked; the hot
+/// path iterates a snapshot array to avoid
+/// allocation and lock contention in the render thread.
+///
+///
+public sealed class AnimationHookRouter : IAnimationHookSink
+{
+ private readonly object _gate = new();
+ private IAnimationHookSink[] _sinks = Array.Empty();
+
+ ///
+ /// Register a sink. Idempotent — adding the same instance twice is a no-op.
+ ///
+ public void Register(IAnimationHookSink sink)
+ {
+ ArgumentNullException.ThrowIfNull(sink);
+ lock (_gate)
+ {
+ foreach (var existing in _sinks)
+ if (ReferenceEquals(existing, sink)) return;
+
+ var updated = new IAnimationHookSink[_sinks.Length + 1];
+ Array.Copy(_sinks, updated, _sinks.Length);
+ updated[_sinks.Length] = sink;
+ _sinks = updated;
+ }
+ }
+
+ ///
+ /// Unregister a sink. No-op if not registered.
+ ///
+ public void Unregister(IAnimationHookSink sink)
+ {
+ if (sink is null) return;
+ lock (_gate)
+ {
+ int idx = -1;
+ for (int i = 0; i < _sinks.Length; i++)
+ if (ReferenceEquals(_sinks[i], sink)) { idx = i; break; }
+ if (idx < 0) return;
+
+ var updated = new IAnimationHookSink[_sinks.Length - 1];
+ for (int i = 0, j = 0; i < _sinks.Length; i++)
+ if (i != idx) updated[j++] = _sinks[i];
+ _sinks = updated;
+ }
+ }
+
+ ///
+ /// Snapshot of currently-registered sinks (for diagnostics / tests).
+ ///
+ public IReadOnlyList Sinks => _sinks;
+
+ ///
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
+ {
+ // Snapshot — no lock in the hot path (render thread).
+ var sinks = _sinks;
+ for (int i = 0; i < sinks.Length; i++)
+ {
+ try
+ {
+ sinks[i].OnHook(entityId, entityWorldPosition, hook);
+ }
+ catch
+ {
+ // Swallow — one misbehaving sink must not take down the
+ // entire animation tick. Individual subsystems can log
+ // their own errors internally.
+ }
+ }
+ }
+}
diff --git a/src/AcDream.Core/Physics/IAnimationHookSink.cs b/src/AcDream.Core/Physics/IAnimationHookSink.cs
new file mode 100644
index 0000000..259df82
--- /dev/null
+++ b/src/AcDream.Core/Physics/IAnimationHookSink.cs
@@ -0,0 +1,88 @@
+using System.Numerics;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Phase E.1 — the consumption point for hooks that
+/// emits during each Advance tick.
+///
+///
+/// The sequencer mirrors ACE's Sequence.execute_hooks /
+/// PhysicsObj.add_anim_hook by collecting all hooks that fire at
+/// each crossed frame boundary; a per-entity tick loop drains
+/// and forwards each
+/// hook to an IAnimationHookSink implementation.
+///
+///
+///
+/// In production the sink fans hooks out to downstream subsystems:
+///
+/// -
+/// / /
+/// → Phase E.2 audio engine
+/// (OpenAL voice allocation + 3D positional playback).
+///
+/// -
+/// /
+/// / /
+/// / /
+/// → Phase E.3 particle system.
+///
+/// -
+/// → Phase E.4 combat dispatcher
+/// (animation-hook frame = damage frame for melee / thrown).
+///
+/// -
+/// ,
+/// ,
+/// ,
+/// ,
+/// ,
+/// ,
+/// ,
+/// ,
+/// →
+/// GfxObjMesh / renderer state mutations on the target entity.
+///
+/// -
+/// → UI / controller notifications
+/// ("emote finished", "attack animation complete").
+///
+///
+///
+///
+///
+/// A is provided for headless tests and
+/// for offline mode where audio/particles aren't desired.
+///
+///
+public interface IAnimationHookSink
+{
+ ///
+ /// Called for each hook produced by .
+ ///
+ ///
+ /// Local WorldEntity id that produced this hook. The sink can use this
+ /// to attach side-effects to the right entity (e.g. 3D-positional
+ /// audio at that entity's world position).
+ ///
+ ///
+ /// Current world-space position of the entity, captured at tick time.
+ /// Pre-computed for the sink so each implementation doesn't have to
+ /// resolve it independently.
+ ///
+ /// The hook (a typed subclass of AnimationHook).
+ void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook);
+}
+
+///
+/// No-op — discards every hook.
+/// Used for tests + headless renders.
+///
+public sealed class NullAnimationHookSink : IAnimationHookSink
+{
+ public static readonly NullAnimationHookSink Instance = new();
+ private NullAnimationHookSink() { }
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs
new file mode 100644
index 0000000..51c9416
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs
@@ -0,0 +1,115 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class AnimationHookRouterTests
+{
+ private sealed class RecordingSink : IAnimationHookSink
+ {
+ public readonly List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Events = new();
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
+ {
+ Events.Add((entityId, entityWorldPosition, hook));
+ }
+ }
+
+ [Fact]
+ public void Register_SingleSink_ReceivesHook()
+ {
+ var router = new AnimationHookRouter();
+ var sink = new RecordingSink();
+ router.Register(sink);
+
+ var hook = new SoundHook { Direction = AnimationHookDir.Both };
+ router.OnHook(entityId: 42, entityWorldPosition: new Vector3(1, 2, 3), hook);
+
+ Assert.Single(sink.Events);
+ Assert.Equal(42u, sink.Events[0].EntityId);
+ Assert.Equal(new Vector3(1, 2, 3), sink.Events[0].Pos);
+ Assert.Same(hook, sink.Events[0].Hook);
+ }
+
+ [Fact]
+ public void Register_MultipleSinks_AllReceiveHook()
+ {
+ var router = new AnimationHookRouter();
+ var sinkA = new RecordingSink();
+ var sinkB = new RecordingSink();
+ var sinkC = new RecordingSink();
+ router.Register(sinkA);
+ router.Register(sinkB);
+ router.Register(sinkC);
+
+ router.OnHook(1, Vector3.Zero, new AttackHook { Direction = AnimationHookDir.Forward });
+
+ Assert.Single(sinkA.Events);
+ Assert.Single(sinkB.Events);
+ Assert.Single(sinkC.Events);
+ }
+
+ [Fact]
+ public void Register_SameSinkTwice_IsIdempotent()
+ {
+ var router = new AnimationHookRouter();
+ var sink = new RecordingSink();
+ router.Register(sink);
+ router.Register(sink);
+
+ router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both });
+
+ Assert.Single(sink.Events); // Not two events — registration deduped.
+ Assert.Single(router.Sinks);
+ }
+
+ [Fact]
+ public void Unregister_RemovesSink()
+ {
+ var router = new AnimationHookRouter();
+ var sinkA = new RecordingSink();
+ var sinkB = new RecordingSink();
+ router.Register(sinkA);
+ router.Register(sinkB);
+ router.Unregister(sinkA);
+
+ router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both });
+
+ Assert.Empty(sinkA.Events);
+ Assert.Single(sinkB.Events);
+ }
+
+ [Fact]
+ public void OnHook_SinkThrows_OtherSinksStillReceive()
+ {
+ // A misbehaving sink must not poison the dispatch.
+ var router = new AnimationHookRouter();
+ router.Register(new ThrowingSink());
+ var recording = new RecordingSink();
+ router.Register(recording);
+
+ router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both });
+
+ Assert.Single(recording.Events);
+ }
+
+ [Fact]
+ public void NullAnimationHookSink_AcceptsAnyHookWithoutCrashing()
+ {
+ // Trivially verify the null sink exists and swallows hooks.
+ var sink = NullAnimationHookSink.Instance;
+ sink.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both });
+ sink.OnHook(2, new Vector3(1, 2, 3), new AttackHook { Direction = AnimationHookDir.Forward });
+ }
+
+ private sealed class ThrowingSink : IAnimationHookSink
+ {
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
+ {
+ throw new System.Exception("bad sink");
+ }
+ }
+}