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>
623 lines
26 KiB
C#
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;
|
|
}
|
|
}
|