diff --git a/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
new file mode 100644
index 0000000..913b7bf
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Per-entity render state for animated entities (characters, creatures,
+/// equipped items). Holds AC-specific per-instance customizations the WB
+/// atlas cache doesn't carry: AnimPartChange override map +
+/// HiddenParts bitmask. Also holds a reference to acdream's existing
+/// — Phase N.4 explicitly does not touch
+/// the sequencer; we just route through it at draw time.
+///
+///
+/// Lifecycle: created by EntitySpawnAdapter.OnCreate (Task 17) when
+/// a server CreateObject is processed; destroyed by
+/// EntitySpawnAdapter.OnRemove on RemoveObject. The mesh
+/// data backing each part is cached in WB's ObjectMeshManager;
+/// per-instance customizations don't go through the atlas — they overlay
+/// at draw time.
+///
+///
+public sealed class AnimatedEntityState
+{
+ private readonly Dictionary _partGfxObjOverrides = new();
+ private ulong _hiddenMask = 0;
+
+ /// Reference to acdream's existing animation sequencer.
+ /// Phase N.4 doesn't touch the sequencer; the draw dispatcher consumes
+ /// per-part transforms it produces per frame.
+ public AnimationSequencer Sequencer { get; }
+
+ public AnimatedEntityState(AnimationSequencer sequencer)
+ {
+ System.ArgumentNullException.ThrowIfNull(sequencer);
+ Sequencer = sequencer;
+ }
+
+ /// Set the HiddenParts bitmask for this entity. Bit
+ /// i set hides part i at draw time.
+ public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
+
+ /// True if part partIdx should be skipped at draw
+ /// time. Returns false for part indices outside [0, 63].
+ public bool IsPartHidden(int partIdx)
+ {
+ if (partIdx < 0 || partIdx >= 64) return false;
+ return (_hiddenMask & (1ul << partIdx)) != 0;
+ }
+
+ /// Override the GfxObj id for a Setup part. Used for
+ /// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
+ /// GfxObj.
+ public void SetPartOverride(int partIdx, ulong gfxObjId)
+ => _partGfxObjOverrides[partIdx] = gfxObjId;
+
+ /// Look up the GfxObj override for a part. Returns false if
+ /// no override is set (caller should fall back to Setup default).
+ public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
+ => _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
+
+ /// Resolve the GfxObj id for :
+ /// override if set, else . Used by the
+ /// draw dispatcher to pick the right cached mesh data per part.
+ public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
+ => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
new file mode 100644
index 0000000..d603ccd
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
@@ -0,0 +1,62 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AnimPartChangeTests
+{
+ [Fact]
+ public void SetPartOverride_ResolvedAtLookup()
+ {
+ var state = MakeState();
+
+ state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul);
+
+ Assert.True(state.TryGetPartOverride(5, out var got));
+ Assert.Equal(0x01001234ul, got);
+ Assert.False(state.TryGetPartOverride(6, out _));
+ }
+
+ [Fact]
+ public void SetPartOverride_TwiceForSamePart_TakesLatest()
+ {
+ var state = MakeState();
+
+ state.SetPartOverride(0, 0x01000001ul);
+ state.SetPartOverride(0, 0x01999999ul);
+
+ Assert.True(state.TryGetPartOverride(0, out var got));
+ Assert.Equal(0x01999999ul, got);
+ }
+
+ [Fact]
+ public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault()
+ {
+ var state = MakeState();
+
+ Assert.Equal(0x01000001ul,
+ state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
+ }
+
+ [Fact]
+ public void ResolvePartGfxObj_WithOverride_ReturnsOverride()
+ {
+ var state = MakeState();
+ state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul);
+
+ Assert.Equal(0x01999999ul,
+ state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
+ }
+
+ private static AnimatedEntityState MakeState() => new(MakeSequencer());
+
+ private static AnimationSequencer MakeSequencer()
+ => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
+
+ private sealed class NullAnimationLoader : IAnimationLoader
+ {
+ public Animation? LoadAnimation(uint id) => null;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs
new file mode 100644
index 0000000..aae14dd
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs
@@ -0,0 +1,45 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AnimatedEntityStateTests
+{
+ [Fact]
+ public void DefaultState_HasNoOverridesAndNoHiddenParts()
+ {
+ var state = MakeState();
+
+ Assert.False(state.IsPartHidden(0));
+ Assert.False(state.IsPartHidden(63));
+ Assert.False(state.TryGetPartOverride(0, out _));
+ }
+
+ [Fact]
+ public void Sequencer_AccessibleAsProperty()
+ {
+ var sequencer = MakeSequencer();
+ var state = new AnimatedEntityState(sequencer);
+
+ Assert.Same(sequencer, state.Sequencer);
+ }
+
+ [Fact]
+ public void Construct_WithNullSequencer_ThrowsArgumentNull()
+ {
+ Assert.Throws(
+ () => new AnimatedEntityState(null!));
+ }
+
+ private static AnimatedEntityState MakeState() => new(MakeSequencer());
+
+ private static AnimationSequencer MakeSequencer()
+ => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
+
+ private sealed class NullAnimationLoader : IAnimationLoader
+ {
+ public Animation? LoadAnimation(uint id) => null;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
new file mode 100644
index 0000000..63c29f7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
@@ -0,0 +1,56 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class HiddenPartsTests
+{
+ [Theory]
+ [InlineData(0b0000_0000ul, 0, false)]
+ [InlineData(0b0000_0001ul, 0, true)]
+ [InlineData(0b1000_0000ul, 7, true)]
+ [InlineData(0b1000_0000ul, 6, false)]
+ [InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)]
+ public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected)
+ {
+ var state = MakeState();
+ state.HideParts(mask);
+ Assert.Equal(expected, state.IsPartHidden(partIdx));
+ }
+
+ [Fact]
+ public void IsPartHidden_NegativeIdx_ReturnsFalse()
+ {
+ var state = MakeState();
+ state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
+ Assert.False(state.IsPartHidden(-1));
+ }
+
+ [Fact]
+ public void IsPartHidden_PartIdxOver64_ReturnsFalse()
+ {
+ var state = MakeState();
+ state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
+ Assert.False(state.IsPartHidden(64));
+ }
+
+ [Fact]
+ public void HideParts_DefaultsToNoneHidden()
+ {
+ var state = MakeState();
+ for (int i = 0; i < 64; i++)
+ Assert.False(state.IsPartHidden(i));
+ }
+
+ private static AnimatedEntityState MakeState() => new(MakeSequencer());
+
+ private static AnimationSequencer MakeSequencer()
+ => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
+
+ private sealed class NullAnimationLoader : IAnimationLoader
+ {
+ public Animation? LoadAnimation(uint id) => null;
+ }
+}