feat(runtime): publish dormant local physics ownership

This commit is contained in:
Erik 2026-08-01 10:01:30 +02:00
parent 442cb8f97b
commit 22651c823d
10 changed files with 1617 additions and 25 deletions

View file

@ -121,6 +121,17 @@ public readonly record struct MovementResult(
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
internal enum PlayerMovementControllerPublicationLifecycle
{
StandalonePublished,
CandidatePreparing,
CandidateSealed,
RuntimeOwnedDormant,
RuntimePublished,
RuntimeRetired,
Discarded,
}
/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
@ -140,6 +151,17 @@ public sealed class PlayerMovementController
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
private readonly AcDream.Core.Physics.Motion.MovementManager
_movementManager;
private float _stepUpHeight = 0.4f;
private float _stepDownHeight = 0.4f;
private ObjectInfoState _ownPvpFlags = ObjectInfoState.None;
private System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
_sphereList;
private float _objectScale = 1f;
private PlayerState _state = PlayerState.InWorld;
private uint _localEntityId;
private AcDream.Core.Physics.Motion.PositionManager? _positionManager;
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
@ -151,7 +173,15 @@ public sealed class PlayerMovementController
/// Authoritative source is the player's <c>Setup.StepUpHeight</c> set
/// in GameWindow.cs at world-entry time.
/// </summary>
public float StepUpHeight { get; set; } = 0.4f;
public float StepUpHeight
{
get => _stepUpHeight;
set
{
EnsureConfigurationMutable();
_stepUpHeight = value;
}
}
/// <summary>
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
@ -161,7 +191,15 @@ public sealed class PlayerMovementController
/// the ground 25 cm below produced a one-frame contact-plane gap — the
/// animation system briefly flickered to falling.
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
public float StepDownHeight
{
get => _stepDownHeight;
set
{
EnsureConfigurationMutable();
_stepDownHeight = value;
}
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
@ -176,7 +214,15 @@ public sealed class PlayerMovementController
/// hardcoded <c>IsPlayer | EdgeSlide</c> — the non-PK invariant this
/// port must not break.
/// </summary>
public ObjectInfoState OwnPvpFlags { get; set; } = ObjectInfoState.None;
public ObjectInfoState OwnPvpFlags
{
get => _ownPvpFlags;
set
{
EnsureConfigurationMutable();
_ownPvpFlags = value;
}
}
/// <summary>
/// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat
@ -191,14 +237,31 @@ public sealed class PlayerMovementController
/// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's
/// (0,0,0.48) + (0,0,1.355).
/// </summary>
public System.Collections.Immutable.ImmutableArray<FlatCollisionSphere> SphereList { get; set; }
public System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
SphereList
{
get => _sphereList;
set
{
EnsureConfigurationMutable();
_sphereList = value;
}
}
/// <summary>
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
/// displacement is multiplied by this value before PositionManager
/// composition (<c>UpdatePositionInternal @ 0x00512C30</c>).
/// </summary>
public float ObjectScale { get; set; } = 1f;
public float ObjectScale
{
get => _objectScale;
set
{
EnsureConfigurationMutable();
_objectScale = value;
}
}
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
@ -207,7 +270,15 @@ public sealed class PlayerMovementController
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public PlayerState State { get; set; } = PlayerState.InWorld;
public PlayerState State
{
get => _state;
set
{
EnsurePublishedForRuntimeOperation();
_state = value;
}
}
/// <summary>
/// Horizontal projection of the authoritative body quaternion. Assigning
@ -221,6 +292,7 @@ public sealed class PlayerMovementController
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation));
set
{
EnsurePublishedForRuntimeOperation();
float wrapped = value;
while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI;
while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI;
@ -253,6 +325,7 @@ public sealed class PlayerMovementController
internal bool TryGetOutboundPosition(
out AcDream.Core.Physics.Position outboundPosition)
{
EnsurePublishedForRuntimeOperation();
AcDream.Core.Physics.Position canonical = _body.CellPosition;
outboundPosition = new AcDream.Core.Physics.Position(
canonical.ObjCellId,
@ -273,7 +346,15 @@ public sealed class PlayerMovementController
/// sweep collides with its own ShadowEntry registered at
/// GameWindow.cs:2545 — see #42.
/// </summary>
public uint LocalEntityId { get; set; }
public uint LocalEntityId
{
get => _localEntityId;
set
{
EnsureConfigurationMutable();
_localEntityId = value;
}
}
/// <summary>
/// Applies the canonical server PhysicsState to the local body. Retail's
@ -282,6 +363,7 @@ public sealed class PlayerMovementController
/// </summary>
public void ApplyPhysicsState(PhysicsStateFlags state)
{
EnsureConfigurationMutable();
_body.State = state;
_body.calc_acceleration();
}
@ -431,7 +513,8 @@ public sealed class PlayerMovementController
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private readonly RetailObjectQuantumClock _objectClock;
private RetailObjectQuantumClock _objectClock;
private PlayerMovementControllerPublicationLifecycle _publicationLifecycle;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
private Action<float, AcDream.Core.Physics.Motion.MotionDeltaFrame>?
@ -463,7 +546,14 @@ public sealed class PlayerMovementController
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
/// (0x00524300, minterp first then moveto).
/// </summary>
public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
public AcDream.Core.Physics.Motion.MovementManager Movement
{
get
{
EnsureConfigurationMutable();
return _movementManager;
}
}
/// <summary>
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
@ -486,6 +576,7 @@ public sealed class PlayerMovementController
get => Movement.MoveTo;
set
{
EnsureConfigurationMutable();
var mtm = value ?? throw new ArgumentNullException(nameof(value));
Movement.MoveToFactory = () => mtm;
Movement.MakeMoveToManager();
@ -509,12 +600,28 @@ public sealed class PlayerMovementController
/// <see cref="BlipPosition"/> arms the leash without tearing it down first
/// (retail <c>SmartBox::BlipPlayer</c> survives motion/velocity/stick).
/// </summary>
public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
public AcDream.Core.Physics.Motion.PositionManager? PositionManager
{
get
{
EnsureConfigurationMutable();
return _positionManager;
}
set
{
EnsureConfigurationMutable();
_positionManager = value;
}
}
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null)
: this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
: this(
physics,
objectClock,
PlayerMovementConstructionOptions.Fallback,
PlayerMovementControllerPublicationLifecycle.StandalonePublished)
{
}
@ -522,9 +629,23 @@ public sealed class PlayerMovementController
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options)
: this(
physics,
objectClock,
options,
PlayerMovementControllerPublicationLifecycle.StandalonePublished)
{
}
private PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options,
PlayerMovementControllerPublicationLifecycle publicationLifecycle)
{
_physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock();
_publicationLifecycle = publicationLifecycle;
_body = new PhysicsBody
{
@ -551,8 +672,8 @@ public sealed class PlayerMovementController
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
Movement.ActivatePhysicsObject = ActivateFromMovement;
_movementManager = new AcDream.Core.Physics.Motion.MovementManager(_motion);
_movementManager.ActivatePhysicsObject = ActivateFromMovement;
// R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
@ -560,6 +681,117 @@ public sealed class PlayerMovementController
_body.LastMoveWasAutonomous = true;
}
internal static PlayerMovementController CreatePublicationCandidate(
PhysicsEngine physics,
PlayerMovementConstructionOptions options) => new(
physics,
new RetailObjectQuantumClock(),
options,
PlayerMovementControllerPublicationLifecycle.CandidatePreparing);
internal PhysicsBody PhysicsBody
{
get
{
EnsureConfigurationMutable();
return _body;
}
}
internal bool OwnsPhysicsBody(PhysicsBody body) =>
ReferenceEquals(_body, body);
internal bool IsSealedPublicationCandidate => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidateSealed;
internal void SealPublicationCandidate()
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
.CandidatePreparing)
{
throw new InvalidOperationException(
"Only a preparing Runtime movement candidate can be sealed.");
}
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.CandidateSealed;
}
internal void CommitRuntimeOwnership(RetailObjectQuantumClock objectClock)
{
ArgumentNullException.ThrowIfNull(objectClock);
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle.CandidateSealed)
{
throw new InvalidOperationException(
"Only a sealed Runtime movement candidate can be published.");
}
_objectClock = objectClock;
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimeOwnedDormant;
}
internal void ActivateRuntimePublication()
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
.RuntimeOwnedDormant)
{
throw new InvalidOperationException(
"Only a dormant Runtime-owned movement controller can be activated.");
}
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimePublished;
}
internal void DiscardRuntimeCandidate()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.CandidateSealed)
{
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.Discarded;
}
}
internal void RetireRuntimePublication()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimeRetired;
}
}
private void EnsureConfigurationMutable()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle
.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
return;
}
throw new InvalidOperationException(
"A sealed, retired, or discarded Runtime movement controller cannot be mutated.");
}
private void EnsurePublishedForRuntimeOperation()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
return;
}
throw new InvalidOperationException(
"An unpublished or retired Runtime movement controller cannot execute live movement operations.");
}
/// <summary>
/// Host half of retail <c>MovementManager::PerformMovement</c>'s
/// unconditional <c>CPhysicsObj::set_active(1)</c> head. Static objects
@ -568,6 +800,7 @@ public sealed class PlayerMovementController
/// </summary>
private void ActivateFromMovement()
{
EnsureConfigurationMutable();
if ((_body.State & PhysicsStateFlags.Static) != 0)
return;
@ -582,6 +815,7 @@ public sealed class PlayerMovementController
/// </summary>
internal void SuspendObjectUpdate(float elapsedSeconds)
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
_simTimeSeconds += elapsedSeconds;
@ -596,6 +830,7 @@ public sealed class PlayerMovementController
/// </summary>
public bool BeginMouseLook(MovementInput input)
{
EnsurePublishedForRuntimeOperation();
if (_mouseLookActive || State != PlayerState.InWorld)
return false;
@ -620,6 +855,7 @@ public sealed class PlayerMovementController
float signedAdjustment,
MovementInput input)
{
EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
return;
@ -637,6 +873,7 @@ public sealed class PlayerMovementController
/// </summary>
public void StopMouseDrift(MovementInput input)
{
EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive)
return;
@ -662,6 +899,7 @@ public sealed class PlayerMovementController
/// </summary>
public bool EndMouseLook(MovementInput input)
{
EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive && !_activeInputTurnFromMouse)
return false;
@ -874,6 +1112,7 @@ public sealed class PlayerMovementController
/// </summary>
public bool PrepareForAttackRequest()
{
EnsurePublishedForRuntimeOperation();
// CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op
// while an authoritative server movement owns the player.
if (_controlledByServer)
@ -898,6 +1137,7 @@ public sealed class PlayerMovementController
/// </summary>
public bool RequestPosture(uint motion)
{
EnsurePublishedForRuntimeOperation();
if (motion is not (
MotionCommand.Ready
or MotionCommand.Crouch
@ -922,6 +1162,7 @@ public sealed class PlayerMovementController
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
EnsureConfigurationMutable();
_weenie.SetSkills(runSkill, jumpSkill);
}
@ -934,6 +1175,7 @@ public sealed class PlayerMovementController
/// </summary>
public void SetCharacterBurden(float burden)
{
EnsureConfigurationMutable();
_weenie.SetBurden(burden);
}
@ -946,6 +1188,7 @@ public sealed class PlayerMovementController
/// </summary>
public void SetCharacterStamina(int currentStamina)
{
EnsureConfigurationMutable();
_weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina);
}
@ -960,6 +1203,7 @@ public sealed class PlayerMovementController
/// </summary>
public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
{
EnsureConfigurationMutable();
_weenie.SetPlayerKillerStatus(
playerKillerStatus < 0 ? null : playerKillerStatus,
lastPkAttackTimestamp);
@ -972,7 +1216,14 @@ public sealed class PlayerMovementController
/// path remotes use. R3-W6 widens this into the full local-player
/// unification.
/// </summary>
internal MotionInterpreter Motion => _motion;
internal MotionInterpreter Motion
{
get
{
EnsureConfigurationMutable();
return _motion;
}
}
/// <summary>
/// Retail <c>CPhysicsObj::StopCompletely</c> (0x00510180) enters through
@ -981,11 +1232,14 @@ public sealed class PlayerMovementController
/// performs the required zero-duration animation completion sweep after
/// the interpreter has queued its matching pending-motion node.
/// </summary>
internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
Movement.PerformMovement(new MovementStruct
internal WeenieError StopCompletelyAtPhysicsObjectBoundary()
{
EnsureConfigurationMutable();
return _movementManager.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
});
}
/// <summary>
/// Retail <c>CPhysicsObj::DoMotion</c> (0x00510020) constructs a type-1
@ -999,6 +1253,7 @@ public sealed class PlayerMovementController
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
@ -1018,6 +1273,7 @@ public sealed class PlayerMovementController
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
@ -1048,6 +1304,7 @@ public sealed class PlayerMovementController
/// </summary>
public void SetBodyOrientation(Quaternion orientation)
{
EnsureConfigurationMutable();
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
_body.Position,
_body.Orientation,
@ -1066,6 +1323,7 @@ public sealed class PlayerMovementController
/// dispatched motions with the idle raw state.</summary>
internal void SetLastMoveWasAutonomous(bool autonomous)
{
EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = autonomous;
_controlledByServer = !autonomous;
}
@ -1093,6 +1351,7 @@ public sealed class PlayerMovementController
/// </summary>
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
{
EnsureConfigurationMutable();
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
@ -1109,6 +1368,7 @@ public sealed class PlayerMovementController
Action<float, AcDream.Core.Physics.Motion.MotionDeltaFrame> advance,
Action? processHooks = null)
{
EnsureConfigurationMutable();
_advanceAnimationRootMotion = advance
?? throw new ArgumentNullException(nameof(advance));
_processAnimationHooks = processHooks;
@ -1122,6 +1382,7 @@ public sealed class PlayerMovementController
/// </summary>
public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
{
EnsurePublishedForRuntimeOperation();
_lastSentTime = nowSeconds;
if (mouseLookEvent)
{
@ -1137,6 +1398,7 @@ public sealed class PlayerMovementController
/// </summary>
public MovementResult CaptureMovementResult(bool mouseLookEvent)
{
EnsurePublishedForRuntimeOperation();
var raw = _motion.RawState;
uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand
? null : raw.ForwardCommand;
@ -1189,6 +1451,7 @@ public sealed class PlayerMovementController
System.Numerics.Plane contactPlane,
float nowSeconds)
{
EnsurePublishedForRuntimeOperation();
_lastSentPosition = position;
_lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds;
@ -1207,6 +1470,7 @@ public sealed class PlayerMovementController
System.Numerics.Plane currentContactPlane,
float nowSeconds)
{
EnsurePublishedForRuntimeOperation();
if (!_lastSentInitialized)
return true;
@ -1266,11 +1530,14 @@ public sealed class PlayerMovementController
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
/// </summary>
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
=> SetPositionCore(
{
EnsurePublishedForRuntimeOperation();
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: true);
}
/// <summary>
/// Builds a new local controller's canonical body pose without publishing
@ -1281,15 +1548,19 @@ public sealed class PlayerMovementController
internal void PreparePositionForCommit(
Vector3 pos,
uint cellId,
Vector3 cellLocal) =>
Vector3 cellLocal)
{
EnsureConfigurationMutable();
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: false);
}
internal void CommitPreparedPosition()
{
EnsurePublishedForRuntimeOperation();
_physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick();
// #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
@ -1404,6 +1675,7 @@ public sealed class PlayerMovementController
/// </summary>
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
EnsurePublishedForRuntimeOperation();
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
@ -1435,6 +1707,7 @@ public sealed class PlayerMovementController
/// </summary>
public MovementResult TickHidden(float dt, Action? handleTargeting = null)
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
if (!float.IsFinite(dt) || dt <= 0f)
{
@ -1554,6 +1827,7 @@ public sealed class PlayerMovementController
MovementInput input,
Action? handleTargeting = null)
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
// Reject a malformed host-frame duration at the controller boundary.
// The retail object clock cannot sanitize state that input/jump/yaw