feat(physics): P5 commit 2 - arm the ConstraintManager leash on accepted positions (#167)

Wire ConstraintManager.ConstrainTo at every current acdream inbound-position
acceptance seam, matching retail SmartBox::HandleReceivedPosition
(0x00453fd0):

- Remote (player + NPC): LiveEntityNetworkUpdateController arms right after
  the hard-teleport branch (remotePlacementRequired) returns - reaching that
  point already means MoveOrTeleport did NOT hard-place - anchored to the
  object's own live IPhysicsObjHost.Position.
- Local player teleport: PlayerMovementController.SetPositionCore now runs
  UnConstrain (retail teleport_hook 0x00514ed0, previously a no-op because
  nothing armed the leash) then re-arms anchored to the just-snapped
  position, composing with the existing StopCompletelyAtPhysicsObjectBoundary
  velocity zero rather than duplicating it. CommitPreparedPosition mirrors
  the same pair for the deferred player-mode-entry commit path.
- Local player ForcePosition: PlayerMovementController.BlipPosition arms
  with NO preceding UnConstrain (retail BlipPlayer/SetPositionSimple
  survives motion/velocity/stick, and the leash is no different).

Push PhysicsBody.IsFullyConstrained from PositionManager.IsFullyConstrained
at the SAME per-tick chokepoint each pump already runs AdjustOffset
(PlayerMovementController.Update, RuntimeRemotePhysicsUpdater.Tick/TickHidden)
so TS-35's read gate in jump_is_allowed sees live state instead of a stub
that is never written.

Tests: local-player arm/teardown/rearm/taper/jump-refusal (Runtime.Tests,
PlayerMovementControllerTests), remote-tick IsFullyConstrained push
(Runtime.Tests, RuntimePhysicsStateTests). Full Core/Runtime/App suites
green with no regressions.
This commit is contained in:
Erik 2026-07-30 12:05:19 +02:00
parent 378d0b6ca0
commit 7719d25bc5
5 changed files with 330 additions and 3 deletions

View file

@ -1353,6 +1353,30 @@ internal sealed class LiveEntityNetworkUpdateController
return; 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 // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport // player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near // (acclient @ 0x00516330) — airborne no-op, far-snap, near

View file

@ -1288,6 +1288,31 @@ public sealed class PlayerMovementController
{ {
_physics.UpdatePlayerCurrCell(CellId); _physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick(); PositionManager?.UnStick();
// #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
// teardown+rearm below — see that comment for the retail citation.
RearmConstraintLeashAtCurrentPosition();
}
/// <summary>
/// #167 (Campaign P P5): retail <c>SmartBox::HandleReceivedPosition</c>
/// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash
/// immediately after <c>TeleportPlayer</c>'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 (<see cref="CommitPreparedPosition"/>),
/// which never ran UnConstrain because nothing could have armed the
/// leash before the controller had a <see cref="PositionManager"/>.
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
/// </summary>
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( private void SetPositionCore(
@ -1332,11 +1357,23 @@ public sealed class PlayerMovementController
_mouseMovementEventPending = false; _mouseMovementEventPending = false;
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager:: // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport // UnStick (@0x00514eee) right after the moveto cancel: a teleport
// tears down any active stick. (StopInterpolating/UnConstrain have no // tears down any active stick. (StopInterpolating has no armed
// armed acdream counterparts — no local-player InterpolationManager, // acdream counterpart — no local-player InterpolationManager.)
// constraint leash unarmed per #167.) // #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) if (publishSharedState)
{
PositionManager?.UnStick(); PositionManager?.UnStick();
PositionManager?.UnConstrain();
RearmConstraintLeashAtCurrentPosition();
}
// Reset the edge tracker: the stop wiped the motion state, so keys // Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next // still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking // Update (matches the pre-W6 level-triggered behavior of walking
@ -1367,6 +1404,13 @@ public sealed class PlayerMovementController
_prevPhysicsPos = pos; _prevPhysicsPos = pos;
_currPhysicsPos = pos; _currPhysicsPos = pos;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position"); 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() private Vector3 ComputeRenderPosition()
@ -1840,6 +1884,12 @@ public sealed class PlayerMovementController
// complete Frame after PartArray. Interpolation may replace it; // complete Frame after PartArray. Interpolation may replace it;
// Sticky/Constraint then compose according to their retail rules. // Sticky/Constraint then compose according to their retail rules.
PositionManager?.AdjustOffset(pmDelta, tickDt); 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) if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity) if (!pmDelta.Orientation.IsIdentity)

View file

@ -268,6 +268,13 @@ internal sealed class RuntimeRemotePhysicsUpdater
terrainNormalNpc, terrainNormalNpc,
inContact: rm.Body.InContact); inContact: rm.Body.InContact);
npcHost.PositionManager.AdjustOffset(pmDelta, dt); 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); ApplyPositionManagerDelta(rm.Body, pmDelta);
} }
else else
@ -679,6 +686,11 @@ internal sealed class RuntimeRemotePhysicsUpdater
positionDelta, positionDelta,
inContact: rm.Body.InContact); inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt); 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); ApplyPositionManagerDelta(rm.Body, positionDelta);
// Hidden suppresses CPartArray::Update, but process_hooks remains the // Hidden suppresses CPartArray::Update, but process_hooks remains the

View file

@ -1,7 +1,10 @@
using System; using System;
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Gameplay; namespace AcDream.Runtime.Tests.Gameplay;
@ -868,4 +871,166 @@ public class PlayerMovementControllerTests
Assert.True(weenie.InqRunRate(out float restored)); Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4); 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<uint, IPhysicsObjHost>();
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
}
} }

View file

@ -564,6 +564,82 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(Vector3.Zero, cycle); 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] [Fact]
public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse() public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse()
{ {