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