diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs
new file mode 100644
index 0000000..aa352ab
--- /dev/null
+++ b/src/AcDream.Core/Physics/PositionManager.cs
@@ -0,0 +1,55 @@
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Per-frame combiner for remote-entity motion: animation root motion
+/// + InterpolationManager catch-up correction. Pure function — no
+/// side effects, no hidden state.
+///
+/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
+/// rootOffset = CPartArray::Update(dt) // animation
+/// PositionManager::adjust_offset(rootOffset) // adds correction
+/// frame.origin += rootOffset
+///
+/// In acdream the animation root motion is sourced from
+/// AnimationSequencer.CurrentVelocity (body-local velocity from the
+/// active locomotion cycle). We rotate that by the body's orientation
+/// to get a world-space delta, then add the InterpolationManager's
+/// world-space correction.
+///
+public sealed class PositionManager
+{
+ ///
+ /// Compute the per-frame world-space delta to add to body.Position.
+ ///
+ /// Per-frame delta time, seconds.
+ /// Body's current world-space position.
+ ///
+ /// Body-local velocity from the active animation cycle
+ /// (from AnimationSequencer.CurrentVelocity); pass
+ /// Vector3.Zero if the entity has no sequencer or is on a
+ /// non-locomotion cycle.
+ ///
+ /// Body orientation; used to rotate seqVel from body-local to world.
+ /// The remote's InterpolationManager (for AdjustOffset call).
+ /// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp.
+ public Vector3 ComputeOffset(
+ double dt,
+ Vector3 currentBodyPosition,
+ Vector3 seqVel,
+ Quaternion ori,
+ InterpolationManager interp,
+ float maxSpeed)
+ {
+ // Step 1: animation root motion (body-local → world).
+ Vector3 rootMotionLocal = seqVel * (float)dt;
+ Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
+
+ // Step 2: interpolation correction (world-space already).
+ Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
+
+ // Step 3: combined delta.
+ return rootMotionWorld + correction;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
new file mode 100644
index 0000000..7839bc6
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PositionManagerTests — 6 tests covering ComputeOffset.
+//
+// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
+// Pure-function combiner: animation root motion (seqVel × dt, rotated by
+// body orientation) + InterpolationManager.AdjustOffset correction.
+// ─────────────────────────────────────────────────────────────────────────────
+
+public sealed class PositionManagerTests
+{
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private static PositionManager Make() => new();
+
+ private static InterpolationManager EmptyInterp() => new();
+
+ // =========================================================================
+ // Test 1: stationary remote — both sources zero, no motion
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: Vector3.Zero,
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 4f);
+
+ Assert.Equal(Vector3.Zero, offset);
+ }
+
+ // =========================================================================
+ // Test 2: animation only, identity orientation, forward velocity
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_AnimationOnly_Forward_BodyAdvances()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ // seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0)
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 4f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 0f);
+
+ Assert.Equal(0f, offset.X, precision: 4);
+ Assert.Equal(0.4f, offset.Y, precision: 4);
+ Assert.Equal(0f, offset.Z, precision: 4);
+ }
+
+ // =========================================================================
+ // Test 3: animation only, 180° yaw around Z — body moves south (-Y)
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ // 180° around Z flips +Y → -Y
+ Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
+
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 4f, 0f),
+ ori: ori,
+ interp: interp,
+ maxSpeed: 0f);
+
+ Assert.Equal(0f, offset.X, precision: 4);
+ Assert.Equal(-0.4f, offset.Y, precision: 4);
+ }
+
+ // =========================================================================
+ // Test 4: interp only, no animation — body chases queue
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue()
+ {
+ var pm = Make();
+ var interp = new InterpolationManager();
+
+ // Enqueue target 1m ahead on +X; body starts at origin
+ interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
+
+ // Expected catch-up: catchUpSpeed = maxSpeed × 2 = 4 × 2 = 8 m/s
+ // step = 8 × 0.1 = 0.8m (< dist = 1m so no overshoot clamp)
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: Vector3.Zero,
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 4f);
+
+ Assert.Equal(0.8f, offset.X, precision: 3);
+ Assert.Equal(0f, offset.Y, precision: 3);
+ Assert.Equal(0f, offset.Z, precision: 3);
+ }
+
+ // =========================================================================
+ // Test 5: both sources active — combined delta
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_BothActive_Combined()
+ {
+ var pm = Make();
+ var interp = new InterpolationManager();
+
+ // Enqueue target 1m ahead on +X
+ interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
+
+ // rootMotion = (0, 4, 0) × 0.1 = (0, 0.4, 0)
+ // correction ≈ (0.8, 0, 0)
+ // combined ≈ (0.8, 0.4, 0)
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 4f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 4f);
+
+ Assert.Equal(0.8f, offset.X, precision: 3);
+ Assert.Equal(0.4f, offset.Y, precision: 3);
+ Assert.Equal(0f, offset.Z, precision: 3);
+ }
+
+ // =========================================================================
+ // Test 6: local-to-world rotation — +90° yaw around Z
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_LocalToWorldRotation_Yaw90()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ // +90° CCW around Z in right-handed coordinates:
+ // body-local +Y → world -X
+ Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
+
+ // seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0)
+ // after Transform by ori → (-1, 0, 0) approximately
+ Vector3 offset = pm.ComputeOffset(
+ dt: 1.0,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 1f, 0f),
+ ori: ori,
+ interp: interp,
+ maxSpeed: 0f);
+
+ Assert.Equal(-1f, offset.X, precision: 4);
+ Assert.Equal(0f, offset.Y, precision: 4);
+ Assert.Equal(0f, offset.Z, precision: 4);
+ }
+}