refactor(runtime): extract local teleport and player mode

Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-22 02:50:15 +02:00
parent c557038353
commit eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions

View file

@ -31,7 +31,9 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
/// mouse-look sample, and combat attack intent.
/// </summary>
internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
internal sealed class GameplayInputFrameController
: IGameplayInputFramePhase,
AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime
{
private readonly InputDispatcher? _dispatcher;
private readonly DispatcherMovementInputSource _movement;

View file

@ -0,0 +1,75 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.Types;
namespace AcDream.App.Input;
/// <summary>
/// Owns the local player's sequencer root-motion and deferred-hook seams.
/// The movement controller borrows these methods when player mode is entered;
/// no animation lifetime is stored in the window host.
/// </summary>
internal sealed class LocalPlayerAnimationController
{
private readonly LiveEntityRuntime _liveEntities;
private readonly ILocalPlayerIdentitySource _identity;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
private readonly LiveEntityAnimationPresenter _presenter;
private readonly AnimationHookFrameQueue _hooks;
public LocalPlayerAnimationController(
LiveEntityRuntime liveEntities,
ILocalPlayerIdentitySource identity,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
LiveEntityAnimationPresenter presenter,
AnimationHookFrameQueue hooks)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
_hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
}
public void AdvanceRoot(float deltaSeconds, MotionDeltaFrame output)
{
ArgumentNullException.ThrowIfNull(output);
output.Reset();
uint playerGuid = _identity.ServerGuid;
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
|| !_animations.TryGetValue(entity.Id, out var animation)
|| animation.Sequencer is not { } sequencer
|| !_liveEntities.ShouldAdvanceRootRuntime(playerGuid)
|| _liveEntities.IsHidden(playerGuid))
{
return;
}
if (_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord record))
_presenter.PrepareAnimation(record, animation);
Frame rootFrame = animation.RootMotionScratch;
rootFrame.Origin = System.Numerics.Vector3.Zero;
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
animation.PreparedSequenceFrames = animation.CaptureSequenceFrames(
sequencer.Advance(deltaSeconds, rootFrame));
animation.SequenceAdvancedBeforeAnimationPass = true;
output.Origin = rootFrame.Origin;
output.Orientation = rootFrame.Orientation;
}
public void CaptureHooks()
{
uint playerGuid = _identity.ServerGuid;
if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
&& _animations.TryGetValue(entity.Id, out var animation)
&& animation.Sequencer is { } sequencer)
{
_hooks.Capture(animation.Entity.Id, sequencer);
}
}
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Physics;
using AcDream.App.Physics;
namespace AcDream.App.Input;
@ -29,6 +30,20 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
public PlayerMovementController? Controller { get; set; }
}
internal interface ILocalPlayerPhysicsHostSource
{
EntityPhysicsHost? Host { get; }
}
/// <summary>
/// The one mutable local physics-host slot. Player-mode lifecycle owns
/// assignment; teleport and update owners receive the read-only seam.
/// </summary>
internal sealed class LocalPlayerPhysicsHostSlot : ILocalPlayerPhysicsHostSource
{
public EntityPhysicsHost? Host { get; set; }
}
internal interface ILocalPlayerModeSource
{
bool IsPlayerMode { get; }
@ -50,3 +65,59 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
ChaseModeEverEntered = false;
}
}
/// <summary>
/// Session-scoped server-authoritative movement skills. Player-description
/// delivery and player-mode construction share this owner so rebuilding the
/// local physics controller cannot fall back to stale defaults.
/// </summary>
internal sealed class LocalPlayerSkillState
{
public int RunSkill { get; private set; } = -1;
public int JumpSkill { get; private set; } = -1;
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
public void Update(
int runSkill,
int jumpSkill,
PlayerMovementController? controller)
{
if (runSkill >= 0)
RunSkill = runSkill;
if (jumpSkill >= 0)
JumpSkill = jumpSkill;
ApplyTo(controller);
}
public bool ApplyTo(PlayerMovementController? controller)
{
if (controller is null || !IsComplete)
return false;
controller.SetCharacterSkills(RunSkill, JumpSkill);
return true;
}
public void ResetSession()
{
RunSkill = -1;
JumpSkill = -1;
}
}
internal interface IViewportAspectSource
{
float Aspect { get; }
}
/// <summary>Framebuffer aspect published by the window host.</summary>
internal sealed class ViewportAspectState : IViewportAspectSource
{
public float Aspect { get; private set; } = 16f / 9f;
public void Update(int width, int height)
{
if (width > 0 && height > 0)
Aspect = width / (float)height;
}
}

View file

@ -45,6 +45,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func<bool> _isPlayerEntityPresent;
private readonly Func<bool> _isPlayerControllerReady;
private readonly Func<bool> _isWorldReady;
private readonly Func<bool> _isPlayerModeActive;
private readonly Action _enterPlayerMode;
private bool _armed;
@ -76,13 +77,15 @@ public sealed class PlayerModeAutoEntry
Func<bool> isPlayerEntityPresent,
Func<bool> isPlayerControllerReady,
Func<bool> isWorldReady,
Action enterPlayerMode)
Action enterPlayerMode,
Func<bool>? isPlayerModeActive = null)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
_isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
_isPlayerModeActive = isPlayerModeActive ?? (() => false);
}
/// <summary>True iff <see cref="TryEnter"/> would still fire if the
@ -112,6 +115,11 @@ public sealed class PlayerModeAutoEntry
public bool TryEnter()
{
if (!_armed) return false;
if (_isPlayerModeActive())
{
_armed = false;
return false;
}
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;

View file

@ -0,0 +1,572 @@
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;
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
{
private readonly LocalPlayerModeState _mode;
private readonly LocalPlayerControllerSlot _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 PhysicsDataCache _physicsDataCache;
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 LocalPlayerSkillState _skills;
private readonly IViewportAspectSource _viewport;
private PlayerModeAutoEntry? _autoEntry;
private IPlayerApproachCompletionSink? _approachLifetime;
public PlayerModeController(
LocalPlayerModeState mode,
LocalPlayerControllerSlot controllerSlot,
LocalPlayerPhysicsHostSlot hostSlot,
ChaseCameraInputState chase,
CameraController camera,
PhysicsEngine physics,
LiveEntityRuntime liveEntities,
ILocalPlayerIdentitySource identity,
LiveWorldOriginState origin,
ILiveEntityMotionRuntimeBindings motionBindings,
IDatReaderWriter dats,
object datLock,
PhysicsDataCache physicsDataCache,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
LocalPlayerAnimationController animation,
LocalPlayerShadowSynchronizer shadow,
IPlayerApproachCompletionLifetimeOwner approachCompletions,
ILocalPlayerTeleportInputLifetime input,
ILiveInWorldSource session,
MovementTruthDiagnosticController movementDiagnostics,
LocalPlayerSkillState 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));
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
_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.MaterializedWorldEntities.TryGetValue(
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);
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.GetMaxSpeed(),
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 = EntityPhysicsHost.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 (_skills.ApplyTo(controller))
{
Console.WriteLine(
$"live: {loggingTag} — applied server skills "
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
}
ApplyStepHeights(controller, playerEntity);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
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);
}
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 =
EntityPhysicsHost.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 = EntityPhysicsHost.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)
{
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock)
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
playerEntity.SourceGfxObjOrSetupId);
if (setup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight
: 0.4f;
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
? setup.StepDownHeight
: 0.4f;
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})");
return;
}
controller.StepUpHeight = 0.4f;
controller.StepDownHeight = 0.4f;
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;
}
}

View file

@ -1092,7 +1092,10 @@ public sealed class PlayerMovementController
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
// off this collapses to a single bool-compare + assignment — zero
// logging cost.
private void UpdateCellId(uint newCellId, string reason)
private void UpdateCellId(
uint newCellId,
string reason,
bool publishRenderRoot = true)
{
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
{
@ -1109,7 +1112,8 @@ public sealed class PlayerMovementController
// jump-looping near the cottage doorway clobbered the render root every tick → the render
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
_physics.UpdatePlayerCurrCell(newCellId);
if (publishRenderRoot)
_physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
@ -1127,11 +1131,47 @@ 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(
pos,
cellId,
cellLocal,
publishSharedState: true);
/// <summary>
/// Builds a new local controller's canonical body pose without publishing
/// the renderer's global current cell or mutating the incarnation-stable
/// PositionManager. Player-mode entry commits those shared edges only
/// after camera, shadow, and host preparation succeeds.
/// </summary>
internal void PreparePositionForCommit(
Vector3 pos,
uint cellId,
Vector3 cellLocal) =>
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: false);
internal void CommitPreparedPosition()
{
_physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick();
}
private void SetPositionCore(
Vector3 pos,
uint cellId,
Vector3 cellLocal,
bool publishSharedState)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
UpdateCellId(
_body.CellPosition.ObjCellId,
"teleport",
publishSharedState);
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact
@ -1164,7 +1204,8 @@ public sealed class PlayerMovementController
// tears down any active stick. (StopInterpolating/UnConstrain have no
// armed acdream counterparts — no local-player InterpolationManager,
// constraint leash unarmed per #167.)
PositionManager?.UnStick();
if (publishSharedState)
PositionManager?.UnStick();
// 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