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

@ -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<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
}
}