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

@ -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()
{