acdream/src/AcDream.App/Input/PlayerModeController.cs
Erik c0afcacbb2 fix(physics): movement-parity fixes - adjusted catch-up cap, autorun retail semantics, AP-30 retired
Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:04:17 +02:00

623 lines
26 KiB
C#

using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Input;
/// <summary>
/// Sole writer for local player-mode, movement-controller, physics-host, and
/// chase-camera lifetimes. The window host wires this owner but never mutates
/// those slots directly.
/// </summary>
internal sealed class PlayerModeController :
ILocalPlayerTeleportModeOperations,
IDevToolsPlayerModeTarget
{
private readonly LocalPlayerModeState _mode;
private readonly RuntimeLocalPlayerMovementState _controllerSlot;
private readonly LocalPlayerPhysicsHostSlot _hostSlot;
private readonly ChaseCameraInputState _chase;
private readonly CameraController _camera;
private readonly PhysicsEngine _physics;
private readonly LiveEntityRuntime _liveEntities;
private readonly ILocalPlayerIdentitySource _identity;
private readonly LiveWorldOriginState _origin;
private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
private readonly LiveCollisionAssetPublisher _collisionAssets;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
private readonly LocalPlayerAnimationController _animation;
private readonly LocalPlayerShadowSynchronizer _shadow;
private readonly IPlayerApproachCompletionLifetimeOwner _approachCompletions;
private readonly ILocalPlayerTeleportInputLifetime _input;
private readonly ILiveInWorldSource _session;
private readonly MovementTruthDiagnosticController _movementDiagnostics;
private readonly RuntimeMovementSkillState _skills;
private readonly IViewportAspectSource _viewport;
private PlayerModeAutoEntry? _autoEntry;
private IPlayerApproachCompletionSink? _approachLifetime;
public PlayerModeController(
LocalPlayerModeState mode,
RuntimeLocalPlayerMovementState controllerSlot,
LocalPlayerPhysicsHostSlot hostSlot,
ChaseCameraInputState chase,
CameraController camera,
PhysicsEngine physics,
LiveEntityRuntime liveEntities,
ILocalPlayerIdentitySource identity,
LiveWorldOriginState origin,
ILiveEntityMotionRuntimeBindings motionBindings,
IDatReaderWriter dats,
object datLock,
LiveCollisionAssetPublisher collisionAssets,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
LocalPlayerAnimationController animation,
LocalPlayerShadowSynchronizer shadow,
IPlayerApproachCompletionLifetimeOwner approachCompletions,
ILocalPlayerTeleportInputLifetime input,
ILiveInWorldSource session,
MovementTruthDiagnosticController movementDiagnostics,
RuntimeMovementSkillState skills,
IViewportAspectSource viewport)
{
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_controllerSlot = controllerSlot ?? throw new ArgumentNullException(nameof(controllerSlot));
_hostSlot = hostSlot ?? throw new ArgumentNullException(nameof(hostSlot));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_collisionAssets = collisionAssets ??
throw new ArgumentNullException(nameof(collisionAssets));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_animation = animation ?? throw new ArgumentNullException(nameof(animation));
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_approachCompletions = approachCompletions
?? throw new ArgumentNullException(nameof(approachCompletions));
_input = input ?? throw new ArgumentNullException(nameof(input));
_session = session ?? throw new ArgumentNullException(nameof(session));
_movementDiagnostics = movementDiagnostics
?? throw new ArgumentNullException(nameof(movementDiagnostics));
_skills = skills ?? throw new ArgumentNullException(nameof(skills));
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
}
public PlayerMovementController? Controller => _controllerSlot.Controller;
public Matrix4x4 Projection => _camera.Active.Projection;
public void BindAutoEntry(PlayerModeAutoEntry autoEntry)
{
ArgumentNullException.ThrowIfNull(autoEntry);
if (_autoEntry is not null)
throw new InvalidOperationException("Player-mode auto-entry is already bound.");
_autoEntry = autoEntry;
}
public void Toggle()
{
if (!_session.IsInWorld)
return;
_autoEntry?.Cancel();
if (_mode.IsPlayerMode)
Exit();
else
_ = TryEnter("Tab");
}
public void EnterFromAutoEntry()
{
if (TryEnter("auto-entry"))
{
Console.WriteLine(
$"live: auto-entered player mode for 0x{_identity.ServerGuid:X8}");
}
}
public bool TryEnterPortalSpace()
{
// Portal activation supersedes the login-only guard. Otherwise the
// later auto-entry tick can rebuild this just-created controller and
// replace PortalSpace with the default InWorld state.
_autoEntry?.Cancel();
if (Controller is null && !TryEnter("teleport"))
return false;
if (Controller is not { } controller)
return false;
controller.State = PlayerState.PortalSpace;
return true;
}
public void EnterWorld()
{
if (Controller is { } controller)
controller.State = PlayerState.InWorld;
}
public void Exit()
{
var failures = new List<Exception>();
try { _input.EndMouseLook(); }
catch (Exception error) { failures.Add(error); }
try { _camera.ExitChaseMode(); }
catch (Exception error) { failures.Add(error); }
try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); }
_mode.IsPlayerMode = false;
_controllerSlot.Controller = null;
_hostSlot.Host = null;
_chase.Legacy = null;
_chase.Retail = null;
if (failures.Count != 0)
throw new AggregateException("Player-mode exit was incomplete.", failures);
}
public void ToggleFlyOrChase()
{
_autoEntry?.Cancel();
if (_camera.IsFlyMode
&& _mode.IsPlayerMode
&& _chase.Legacy is { } legacy)
{
_chase.Retail ??= new RetailChaseCamera
{
Aspect = legacy.Aspect,
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
};
_camera.EnterChaseMode(legacy, _chase.Retail);
return;
}
_camera.ToggleFly();
}
public void ResetSession()
{
_autoEntry?.Cancel();
var failures = new List<Exception>();
try { _camera.ExitChaseMode(); }
catch (Exception error) { failures.Add(error); }
try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); }
_mode.ResetSession();
_controllerSlot.Controller = null;
_hostSlot.Host = null;
_chase.Legacy = null;
_chase.Retail = null;
try { _movementDiagnostics.ResetSession(); }
catch (Exception error) { failures.Add(error); }
try { _shadow.ResetSession(); }
catch (Exception error) { failures.Add(error); }
if (failures.Count != 0)
throw new AggregateException("Player-mode session reset was incomplete.", failures);
}
private bool TryEnter(string loggingTag)
{
uint playerGuid = _identity.ServerGuid;
if (!_liveEntities.TryGetWorldEntity(
playerGuid,
out WorldEntity? playerEntity))
{
Console.WriteLine(
$"live: {loggingTag} — player entity 0x{playerGuid:X8} not found yet");
return false;
}
if (!_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord playerRecord))
{
Console.WriteLine(
$"live: {loggingTag} — player record 0x{playerGuid:X8} not found yet");
return false;
}
BuildControllerAndCamera(
loggingTag,
playerGuid,
playerEntity,
playerRecord);
return true;
}
private void BuildControllerAndCamera(
string loggingTag,
uint playerGuid,
WorldEntity playerEntity,
LiveEntityRecord playerRecord)
{
IPlayerApproachCompletionSink approachLifetime =
_approachCompletions.BeginControllerLifetime();
bool lifetimeCommitted = false;
bool cameraAttempted = false;
bool shadowAttempted = false;
CameraController.CameraState priorCamera = _camera.CaptureState();
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
try
{
var controller = new PlayerMovementController(
_physics,
playerRecord.ObjectClock,
PlayerMovementConstructionOptions.From(_skills.Snapshot));
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
// MoveToManager facade over the local CPhysicsObj seams.
PlayerMovementController capturedController = controller;
EntityPhysicsHost playerHost = null!;
controller.Movement.MoveToFactory = () =>
{
var moveTo = new MoveToManager(
capturedController.Motion,
stopCompletely: () =>
capturedController.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new Position(
capturedController.CellId,
capturedController.Position,
capturedController.BodyOrientation),
getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw),
setHeading: (heading, _) => capturedController.Yaw =
MoveToMath.YawFromHeading(heading),
getOwnRadius: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Radius,
getOwnHeight: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Height,
contact: () => capturedController.BodyInContact,
isInterpolating: () => false,
getVelocity: () => capturedController.BodyVelocity,
getSelfId: () => playerGuid,
setTarget: (context, target, radius, quantum) =>
playerHost.SetTarget(context, target, radius, quantum),
clearTarget: playerHost.ClearTarget,
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: playerHost.TargetManager.SetTargetQuantum,
curTime: () => capturedController.SimTimeSeconds);
moveTo.MoveToComplete = error =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason=complete err={error}");
if (error == WeenieError.None)
approachLifetime.PublishNaturalCompletion();
else
approachLifetime.PublishCancellation(error);
};
moveTo.MoveToCancelled = error =>
approachLifetime.PublishCancellation(error);
moveTo.StickTo = (target, radius, height) =>
playerHost.PositionManager.StickTo(target, radius, height);
moveTo.Unstick = () => playerHost.PositionManager.UnStick();
return moveTo;
};
MovementManager exactMovement = controller.Movement;
var configuredHost = new EntityPhysicsHost(
playerGuid,
getPosition: () => new Position(
playerRecord.FullCellId,
playerRecord.WorldEntity?.Position ?? capturedController.Position,
capturedController.BodyOrientation),
getVelocity: () => capturedController.BodyVelocity,
getRadius: () => _motionBindings.GetSetupCylinder(
playerGuid,
playerEntity).Radius,
inContact: () => capturedController.BodyInContact,
minterpMaxSpeed: () => capturedController.Motion.GetAdjustedMaxSpeed(),
curTime: () => capturedController.SimTimeSeconds,
physicsTimerTime: () => capturedController.SimTimeSeconds,
getObjectA: _motionBindings.ResolvePhysicsHost,
handleUpdateTarget: info =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(
$"[autowalk-target] object=0x{info.ObjectId:X8} "
+ $"status={info.Status} context={info.ContextId} "
+ $"target=({info.TargetPosition.Frame.Origin.X:F2},"
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
}
exactMovement.HandleUpdateTarget(info);
},
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
WeenieError.ActionCancelled));
playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
exactMovement.MakeMoveToManager();
controller.Motion.UnstickFromObject = () =>
playerHost.PositionManager.UnStick();
controller.PositionManager = playerHost.PositionManager;
controller.Motion.InterruptCurrentMovement = () =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
&& exactMovement.IsMovingTo())
{
Console.WriteLine("[autowalk-end] reason=interrupt");
}
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
};
if (RuntimeMovementSkillProjection.ApplyTo(
_skills,
controller))
{
Console.WriteLine(
$"live: {loggingTag} — applied server skills "
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
}
ApplyStepHeights(controller, playerEntity, playerGuid);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
Action? drainPriorAnimationQueue = null;
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
&& animation.Sequencer is { } sequencer)
{
controller.AttachCycleVelocityAccessor(() => sequencer.CurrentVelocity);
controller.ObjectScale = animation.Scale;
controller.AttachAnimationRootMotionSource(
_animation.AdvanceRoot,
_animation.CaptureHooks);
controller.Motion.RemoveLinkAnimations =
sequencer.Manager.HandleEnterWorld;
controller.Motion.InitializeMotionTables =
sequencer.Manager.InitializeState;
controller.Motion.CheckForCompletedMotions =
sequencer.Manager.CheckForCompletedMotions;
controller.Motion.DefaultSink =
new MotionTableDispatchSink(sequencer);
drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld;
}
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
// construction. Our split owners preserve that lifetime with a
// narrow preparation lease: SetPosition's synchronous type-5
// completion reaches this candidate MotionInterpreter, while the
// public controller slot remains unpublished until every other
// player-mode edge has prepared successfully.
using IDisposable motionPreparation =
_controllerSlot.BeginMotionPreparation(
controller,
drainPriorAnimationQueue);
ResolveResult initial = _physics.Resolve(
playerEntity.Position,
initialCellId,
Vector3.Zero,
100f);
var (placementRadius, placementHeight) =
_motionBindings.GetSetupCylinder(playerGuid, playerEntity);
if (placementRadius < 0.05f)
{
placementRadius = 0.48f;
placementHeight = 1.835f;
}
ResolveResult placement = _physics.ResolvePlacement(
initial.Position,
initial.CellId,
placementRadius,
placementHeight,
controller.StepUpHeight,
controller.StepDownHeight,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
playerEntity.Id);
if (placement.Ok)
initial = placement;
controller.PreparePositionForCommit(
initial.Position,
initial.CellId,
CellLocalForSeed(initial.Position, initial.CellId));
controller.SetBodyOrientation(playerEntity.Rotation);
var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
var retailCamera = new RetailChaseCamera
{
Aspect = _viewport.Aspect,
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
};
cameraAttempted = true;
_camera.EnterChaseMode(legacyCamera, retailCamera);
EntityPhysicsHost stableAfterCamera =
EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
if (!ReferenceEquals(stableAfterCamera, playerHost))
{
throw new InvalidOperationException(
"The local physics host changed during chase-camera activation.");
}
shadowAttempted = true;
_shadow.SyncPose(
playerEntity,
initial.Position,
playerEntity.Rotation,
initial.CellId,
force: true);
// Publish the incarnation-stable CPhysicsObj delegates only after all
// DAT, placement, shadow, and camera preparation has succeeded. A
// late preparation failure therefore cannot expose an abandoned
// controller through LiveEntityRecord.PhysicsHost.
EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind(
_liveEntities,
playerRecord,
configuredHost);
if (!ReferenceEquals(publishedHost, playerHost))
{
throw new InvalidOperationException(
"The local physics host changed between preparation and commit.");
}
playerEntity.SetPosition(initial.Position);
playerEntity.ParentCellId = initial.CellId;
controller.CommitPreparedPosition();
_hostSlot.Host = publishedHost;
_controllerSlot.Controller = controller;
_chase.Legacy = legacyCamera;
_chase.Retail = retailCamera;
_mode.IsPlayerMode = true;
_mode.ChaseModeEverEntered = true;
_approachLifetime = approachLifetime;
lifetimeCommitted = true;
}
catch (Exception error)
{
var failures = new List<Exception> { error };
if (shadowAttempted)
{
try { _shadow.Restore(playerEntity, priorShadow); }
catch (Exception cleanupError) { failures.Add(cleanupError); }
}
if (cameraAttempted)
{
try { _camera.RestoreState(priorCamera); }
catch (Exception cleanupError) { failures.Add(cleanupError); }
}
_mode.IsPlayerMode = false;
_controllerSlot.Controller = null;
_hostSlot.Host = null;
_chase.Legacy = null;
_chase.Retail = null;
if (failures.Count != 1)
throw new AggregateException(
"Player-mode entry failed and rollback was incomplete.",
failures);
throw;
}
finally
{
if (!lifetimeCommitted)
_approachCompletions.RetireControllerLifetime(approachLifetime);
}
}
private void RetireApproachLifetime()
{
if (_approachLifetime is not { } lifetime)
return;
_approachLifetime = null;
_approachCompletions.RetireControllerLifetime(lifetime);
}
private void ApplyStepHeights(
PlayerMovementController controller,
WorldEntity playerEntity,
uint playerGuid)
{
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock)
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
playerEntity.SourceGfxObjOrSetupId);
if (setup is not null)
_collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
// TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight
// (0x005180d0/0x005180f0) return setup->step_up_height * this->scale
// — apply the same ObjScale multiply the remote/ordinary paths now
// use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for
// parity on a non-1.0-scale player (a rare but real case — e.g. a
// disguise/size-changing effect). Human ObjScale is 1.0 in the
// overwhelming common case, so this is a no-op there.
float scale =
_liveEntities.Snapshots.TryGetValue(playerGuid, out var sp)
&& sp.ObjScale is { } objScale && objScale > 0f
? objScale
: (playerEntity.Scale > 0f ? playerEntity.Scale : 1f);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight * scale
: 0.4f;
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
? setup.StepDownHeight * scale
: 0.4f;
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim —
// retail CPhysicsObj::transition (0x00512dc0) seeds the sweep
// from CPartArray::GetSphere, not a (radius, height) capsule
// reconstruction. Empty (no Setup, or a Setup with no sphere
// rows) leaves SphereList at its default empty value, which
// ResolveWithTransition treats as "use the legacy scalar
// reconstruction."
controller.SphereList = setup?.Spheres is { Count: > 0 } spheres
? spheres
.Select(s => new FlatCollisionSphere(s.Origin, s.Radius))
.ToImmutableArray()
: ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
+ $"StepDown={controller.StepDownHeight:F3} m "
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), "
+ $"Spheres={controller.SphereList.Length}");
return;
}
controller.StepUpHeight = 0.4f;
controller.StepDownHeight = 0.4f;
controller.SphereList = ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}
private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity)
{
if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn)
&& spawn.Position is { LandblockId: not 0u } position)
{
return position.LandblockId;
}
int landblockX = _origin.CenterX
+ (int)MathF.Floor(playerEntity.Position.X / 192f);
int landblockY = _origin.CenterY
+ (int)MathF.Floor(playerEntity.Position.Y / 192f);
return ((uint)landblockX << 24)
| ((uint)landblockY << 16)
| 0x0001u;
}
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
{
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
var origin = new Vector3(
(landblockX - _origin.CenterX) * 192f,
(landblockY - _origin.CenterY) * 192f,
0f);
return worldPosition - origin;
}
}