diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index 62bddc45..f68eadc0 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -1353,6 +1353,30 @@ internal sealed class LiveEntityNetworkUpdateController
return;
}
+ // #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition
+ // (0x00453fd0) arms the ConstraintManager leash for every remote
+ // MoveOrTeleport call that returns nonzero (did NOT hard-teleport —
+ // the remotePlacementRequired branch above already handled and
+ // returned on the hard-teleport case), anchored to the object's OWN
+ // current position, generically for player AND NPC remotes (the
+ // disassembly's "this == player" branch loads identical constants
+ // either way — see ConstraintDistance). ConstraintManager.ConstrainTo
+ // captures ConstraintPosOffset = distance(anchor, host.Position) at
+ // call time; since the anchor here IS host.Position (read live,
+ // matching every other PositionManager/TargetManager consumer's
+ // notion of "this object's position"), this always (re)starts the
+ // leash at zero displacement on a fresh accepted Position, matching
+ // retail's per-packet re-anchor.
+ // docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
+ if (rmState.Host is { } remoteConstraintHost)
+ {
+ AcDream.Core.Physics.Position anchor = remoteConstraintHost.Position;
+ remoteConstraintHost.PositionManager.ConstrainTo(
+ anchor,
+ AcDream.Core.Physics.Motion.ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId),
+ AcDream.Core.Physics.Motion.ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId));
+ }
+
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index f2b73494..688da3bf 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -1288,6 +1288,31 @@ public sealed class PlayerMovementController
{
_physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick();
+ // #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
+ // teardown+rearm below — see that comment for the retail citation.
+ RearmConstraintLeashAtCurrentPosition();
+ }
+
+ ///
+ /// #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition
+ /// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash
+ /// immediately after TeleportPlayer's teardown, anchored to the
+ /// RECEIVED position (here, the body's just-snapped current position).
+ /// Shared by the teleport path (after UnConstrain) and the deferred
+ /// player-mode-entry commit path (),
+ /// which never ran UnConstrain because nothing could have armed the
+ /// leash before the controller had a .
+ /// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
+ ///
+ private void RearmConstraintLeashAtCurrentPosition()
+ {
+ if (PositionManager is not { } positionManager)
+ return;
+ AcDream.Core.Physics.Position anchor = _body.CellPosition;
+ positionManager.ConstrainTo(
+ anchor,
+ AcDream.Core.Physics.Motion.ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId),
+ AcDream.Core.Physics.Motion.ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId));
}
private void SetPositionCore(
@@ -1332,11 +1357,23 @@ public sealed class PlayerMovementController
_mouseMovementEventPending = false;
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport
- // tears down any active stick. (StopInterpolating/UnConstrain have no
- // armed acdream counterparts — no local-player InterpolationManager,
- // constraint leash unarmed per #167.)
+ // tears down any active stick. (StopInterpolating has no armed
+ // acdream counterpart — no local-player InterpolationManager.)
+ // #167 (Campaign P P5): teleport_hook's UnConstrain (@0x00514f02) runs
+ // right after UnStick — previously a no-op because nothing armed the
+ // leash. Now that inbound positions arm it (both remote UpdatePosition
+ // and this player teleport/blip path), the teardown must actually run
+ // so a teleport doesn't inherit a stale leash from wherever the player
+ // was constrained before. Retail's "Player, teleport-newer" branch
+ // then immediately RE-arms the leash anchored to the new (received)
+ // position (SmartBox::HandleReceivedPosition 0x00453fd0) — velocity is
+ // already zeroed above by StopCompletelyAtPhysicsObjectBoundary.
if (publishSharedState)
+ {
PositionManager?.UnStick();
+ PositionManager?.UnConstrain();
+ RearmConstraintLeashAtCurrentPosition();
+ }
// Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking
@@ -1367,6 +1404,13 @@ public sealed class PlayerMovementController
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
+ // #167 (Campaign P P5): retail "Player, normal" branch of
+ // SmartBox::HandleReceivedPosition (0x00453fd0) — ConstrainTo anchored
+ // to the received position, with NO teardown call (this is the
+ // BlipPlayer path: motion, velocity, and PositionManager stick
+ // relationships all deliberately survive the blip per the class
+ // comment above, and the leash is no different).
+ RearmConstraintLeashAtCurrentPosition();
}
private Vector3 ComputeRenderPosition()
@@ -1840,6 +1884,12 @@ public sealed class PlayerMovementController
// complete Frame after PartArray. Interpolation may replace it;
// Sticky/Constraint then compose according to their retail rules.
PositionManager?.AdjustOffset(pmDelta, tickDt);
+ // #167 (Campaign P P5): push the read side of TS-35's
+ // jump_is_allowed gate. MotionInterpreter only has a PhysicsBody
+ // (no host reference), so the per-tick pump — the single owner of
+ // this write, right beside the taper call it mirrors — is the seam
+ // that keeps the stub property current for the local player.
+ _body.IsFullyConstrained = PositionManager?.IsFullyConstrained() ?? false;
if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
index 06ae0028..4300bf4e 100644
--- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
@@ -268,6 +268,13 @@ internal sealed class RuntimeRemotePhysicsUpdater
terrainNormalNpc,
inContact: rm.Body.InContact);
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
+ // #167 (Campaign P P5): push the read side of TS-35's
+ // jump_is_allowed gate. Retail reads IsFullyConstrained through
+ // CPhysicsObj/PositionManager/ConstraintManager directly; acdream's
+ // MotionInterpreter only has a PhysicsBody, so the per-tick pump
+ // (the single owner of this write, matching the taper call just
+ // above) is the seam that keeps the stub property current.
+ rm.Body.IsFullyConstrained = npcHost.PositionManager.IsFullyConstrained();
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
else
@@ -679,6 +686,11 @@ internal sealed class RuntimeRemotePhysicsUpdater
positionDelta,
inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
+ // #167 (Campaign P P5): see the identical push in Tick's grounded
+ // npcHost branch — Hidden objects still keep their PositionManager
+ // (and therefore their leash) alive per retail.
+ if (rm.Host is { } hiddenHost)
+ rm.Body.IsFullyConstrained = hiddenHost.PositionManager.IsFullyConstrained();
ApplyPositionManagerDelta(rm.Body, positionDelta);
// Hidden suppresses CPartArray::Update, but process_hooks remains the
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index 478c951a..73edd6db 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -1,7 +1,10 @@
using System;
+using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Gameplay;
@@ -868,4 +871,166 @@ public class PlayerMovementControllerTests
Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4);
}
+
+ // ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──
+ //
+ // docs/research/2026-07-30-constraint-leash-constants.md. The player's
+ // PositionManager is handed in by EnterPlayerModeNow in production; these
+ // tests wire an EntityPhysicsHost the same way so SetPosition/BlipPosition's
+ // new ConstrainTo/UnConstrain calls have somewhere real to land.
+
+ private static (PlayerMovementController Controller, EntityPhysicsHost Host)
+ MakeControllerWithHost()
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ var hosts = new Dictionary();
+ const uint selfGuid = 0x5000000Au;
+ var host = new EntityPhysicsHost(
+ selfGuid,
+ getPosition: () => controller.CellPosition,
+ getVelocity: () => controller.BodyVelocity,
+ getRadius: () => 0.5f,
+ inContact: () => controller.BodyInContact,
+ minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(),
+ curTime: () => 0.0,
+ physicsTimerTime: () => 0.0,
+ getObjectA: id => hosts.TryGetValue(id, out var h) ? h : null,
+ handleUpdateTarget: _ => { },
+ interruptCurrentMovement: () => { });
+ hosts[selfGuid] = host;
+ controller.PositionManager = host.PositionManager;
+ return (controller, host);
+ }
+
+ [Fact]
+ public void SetPosition_Teleport_ArmsConstraintAnchoredToReceivedPositionOutdoor()
+ {
+ var (controller, _) = MakeControllerWithHost();
+
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // low16 < 0x0100 -> outdoor
+
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+ Assert.True(cm.IsConstrained);
+ Assert.Equal(10.0f, cm.ConstraintDistanceStart); // ACE-inversion pin: NOT 5
+ Assert.Equal(50.0f, cm.ConstraintDistanceMax);
+ Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
+ // Anchored to self (the just-received position) -> zero offset at arm time.
+ Assert.Equal(0f, cm.ConstraintPosOffset, 3);
+ // R3-W6's teleport idle (StopCompletelyAtPhysicsObjectBoundary) still
+ // zeroes velocity — the new UnConstrain/ConstrainTo calls compose with
+ // it rather than replacing it.
+ Assert.Equal(Vector3.Zero, controller.BodyVelocity);
+ }
+
+ [Fact]
+ public void SetPosition_Teleport_IndoorCellUsesTheTighterBand()
+ {
+ var (controller, _) = MakeControllerWithHost();
+
+ controller.SetPosition(
+ new Vector3(10f, 10f, 5f),
+ 0x01000105u); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
+
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+ Assert.Equal(5.0f, cm.ConstraintDistanceStart);
+ Assert.Equal(20.0f, cm.ConstraintDistanceMax);
+ }
+
+ [Fact]
+ public void SetPosition_Teleport_TearsDownAndRearmsAPreviouslyFullyConstrainedLeash()
+ {
+ var (controller, _) = MakeControllerWithHost();
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+
+ // Synthetically over-strain the leash with a tight band (production
+ // arming uses the real 10/50 m band; a tight synthetic band here just
+ // makes "was fully constrained before this teleport" cheap to reach).
+ cm.ConstrainTo(controller.CellPosition, startDistance: 0.1f, maxDistance: 0.2f);
+ cm.AdjustOffset(new MotionDeltaFrame { Origin = new Vector3(5f, 0f, 0f) }, quantum: 0.1);
+ Assert.True(controller.PositionManager.IsFullyConstrained());
+
+ controller.SetPosition(new Vector3(150f, 150f, 50f), 0x0001);
+
+ // retail teleport_hook's UnConstrain, followed by the fresh re-arm at
+ // the new position, clears the stale over-strained state.
+ Assert.False(controller.PositionManager.IsFullyConstrained());
+ Assert.Equal(0f, cm.ConstraintPosOffset, 3);
+ }
+
+ [Fact]
+ public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity()
+ {
+ var (controller, _) = MakeControllerWithHost();
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.Update(ObjectTick, new MovementInput(Forward: true));
+ Vector3 velocityBeforeBlip = controller.BodyVelocity;
+ Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving
+
+ controller.BlipPosition(
+ new Vector3(150f, 150f, 50f),
+ 0x0001,
+ new Vector3(150f, 150f, 50f));
+
+ // BlipPlayer (retail 0x00453940) survives motion/velocity/stick — the
+ // leash is no different: ConstrainTo runs with NO preceding UnConstrain
+ // and no StopCompletely.
+ Assert.Equal(velocityBeforeBlip, controller.BodyVelocity);
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+ Assert.True(cm.IsConstrained);
+ Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
+ Assert.Equal(0f, cm.ConstraintPosOffset, 3);
+ }
+
+ [Fact]
+ public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
+ {
+ var (controller, _) = MakeControllerWithHost();
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+ cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 10f);
+
+ controller.AttachAnimationRootMotionSource((dt, frame) =>
+ {
+ frame.Origin = new Vector3(5f, 0f, 0f); // wildly large per-tick root motion, on purpose
+ });
+
+ // Tick 1: the gate reads the OFFSET RECORDED BEFORE this tick (0, from
+ // ConstrainTo, below start) -- passes through close to the raw 5 m.
+ Vector3 beforeTick1 = controller.Position;
+ controller.Update(ObjectTick, new MovementInput());
+ float displacement1 = (controller.Position - beforeTick1).Length();
+ Assert.True(displacement1 > 4.0f,
+ $"first tick should pass through near-unscaled, got {displacement1}");
+
+ // Tick 2: ConstraintPosOffset is now ~5 (recorded from tick 1), inside
+ // the (1,10) band -- the linear taper now visibly brakes the SAME 5 m
+ // raw input.
+ Vector3 beforeTick2 = controller.Position;
+ controller.Update(ObjectTick, new MovementInput());
+ float displacement2 = (controller.Position - beforeTick2).Length();
+ Assert.True(displacement2 > 0f);
+ Assert.True(displacement2 < 4.0f,
+ $"second tick should be tapered well below the raw 5 m input, got {displacement2}");
+ }
+
+ [Fact]
+ public void Update_ConstraintOverstrained_PushesIsFullyConstrainedOntoBodyAndBlocksJump()
+ {
+ var (controller, _) = MakeControllerWithHost();
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ ConstraintManager cm = controller.PositionManager!.Constraint!;
+ cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 2f);
+
+ controller.AttachAnimationRootMotionSource((dt, frame) =>
+ {
+ frame.Origin = new Vector3(10f, 0f, 0f); // one huge tick, past max
+ });
+
+ controller.Update(ObjectTick, new MovementInput());
+
+ Assert.True(controller.PositionManager!.IsFullyConstrained());
+ WeenieError result = controller.Motion.jump_is_allowed(1.0f, out _);
+ Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
index 586e774d..795e25c4 100644
--- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
@@ -564,6 +564,82 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(Vector3.Zero, cycle);
}
+ // Campaign P Slice P5 (2026-07-30, #167): the remote per-tick pump pushes
+ // ConstraintManager.IsFullyConstrained onto PhysicsBody.IsFullyConstrained
+ // exactly like the local player's per-tick pump
+ // (PlayerMovementControllerTests). The App-layer arm site
+ // (LiveEntityNetworkUpdateController's remote UpdatePosition acceptance)
+ // isn't reachable from here, so this test arms the leash directly through
+ // the bound host's PositionManager — exactly the call shape that site
+ // makes — and proves RuntimeRemotePhysicsUpdater.Tick's push keeps
+ // Body.IsFullyConstrained current.
+ [Fact]
+ public void RemotePhysicsTickPushesIsFullyConstrainedFromTheArmedLeash()
+ {
+ using var lifetime = new RuntimeEntityObjectLifetime();
+ RuntimeEntityRecord record =
+ lifetime.Entities.AddActive(Spawn(0x70000027u, 1));
+ var remote = new RemoteMotion();
+ remote.Body.Position = new Vector3(10f, 20f, 5f);
+ remote.Body.Orientation = Quaternion.Identity;
+ remote.Body.TransientState = TransientStateFlags.Active
+ | TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable;
+ // SetRemoteMotion binds the reader that resolves record.PhysicsHost —
+ // do not also call BindCanonicalRuntime here, it is already bound.
+ lifetime.Physics.SetRemoteMotion(record, remote);
+ lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
+
+ EntityPhysicsHost host = new(
+ record.ServerGuid,
+ getPosition: () => new Position(
+ record.FullCellId, remote.Body.Position, remote.Body.Orientation),
+ getVelocity: () => remote.Body.Velocity,
+ getRadius: () => 0.48f,
+ inContact: () => remote.Body.InContact,
+ minterpMaxSpeed: () => null,
+ curTime: () => 0d,
+ physicsTimerTime: () => 0d,
+ getObjectA: _ => null,
+ handleUpdateTarget: _ => { },
+ interruptCurrentMovement: () => { });
+ // Mirrors EntityPhysicsHostComposition.InstallOrRebind + the
+ // production MarkFullPhysicsHostBound call
+ // (LiveEntityMotionRuntimeController.EnsureRemoteMotionBindings).
+ lifetime.Physics.InstallOrRebindPhysicsHost(record, host);
+ remote.MarkFullPhysicsHostBound();
+ Assert.Same(host, remote.Host);
+
+ // Same call shape as the LiveEntityNetworkUpdateController arm site
+ // this slice adds: anchored to the object's own position, tight
+ // synthetic band so a single tick's raw root-motion pass-through (the
+ // interp queue is empty, so RemoteMotionCombiner.ComposeOffset leaves
+ // it unmodified) overshoots 90% of max.
+ host.PositionManager.ConstrainTo(host.Position, startDistance: 1f, maxDistance: 2f);
+ Assert.False(remote.Body.IsFullyConstrained); // stub default, not yet pushed
+
+ var updater = new RuntimeRemotePhysicsUpdater(lifetime.Physics);
+ Assert.True(updater.Tick(
+ record,
+ remote,
+ objectScale: 1f,
+ sequencer: null,
+ dt: 0.1f,
+ objectClockEpoch: record.ObjectClockEpoch,
+ new MotionDeltaFrame
+ {
+ Origin = new Vector3(10f, 0f, 0f), // one huge tick, past max
+ Orientation = Quaternion.Identity,
+ },
+ radius: 0.48f,
+ height: 1.835f,
+ liveCenterX: 1,
+ liveCenterY: 1));
+
+ Assert.True(host.PositionManager.IsFullyConstrained());
+ Assert.True(remote.Body.IsFullyConstrained);
+ }
+
[Fact]
public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse()
{