Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751 teleport, with no F751 involved: SmartBox::teleport_in_progress @0x00451C20 returns 1 the moment the login player exists with position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9 (playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime @0x00455483 ends the hold once destination cells stop blocking, Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and LoginComplete goes out at the WorldFadeIn end @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own GameActionLoginComplete comment names this contract: 'called when the client player exits portal space. It includes initial login'). acdream skipped all of it at login — every entry route (direct auto-select, character-select Enter, enter-after-create) dropped onto the sky-only 'waiting for login' backdrop until the world reveal completed. The fix engages the EXISTING F751 presentation machinery on Runtime's login reveal — no duplicated presentation code, no timers: - LocalPlayerTeleportController gains a login arm keyed off the Runtime-owned login reveal generation (RuntimeWorldTransitState .BeginLoginReveal, begun on the first accepted local-player position on every entry route). It drives the same TeleportAnimSequencer/ PortalTunnelPresentation lifecycle and the same enter/exit cues; the Place edge is a no-op at login (the first-entry conductor already committed the canonical placement — retail's analogue only flips position_update_complete), and FireLoginComplete now performs EnterWorld + the single LoginComplete send + reveal Complete, exactly like the F751 pump. worldReady is latched on BOTH canonical first placement (OnLocalPlayerFirstEntryCompleted, the repointed GraphicalSessionEventRoute completion callback that used to send LoginComplete immediately) AND destination reveal readiness. ActiveDestinationCell now also reports the login destination so the render frame's reveal-preparation arm keeps running after portal-space entry flips ChaseModeEverEntered. - PlayerModeController.TryEnterPortalSpaceForLogin performs the player-mode presentation attach (the same BuildControllerAndCamera the post-reveal auto-entry used to run) before flipping into portal space — at login no player-mode entry has happened yet. TryEnterPortalSpace itself now refuses (retryable) on a constructed-but-unpublished Runtime controller via the documented CanExecuteLiveMovement skip predicate instead of faulting — the first connected run crashed on exactly that pre-publication State write. - HouseQuery stays at first-entry completion (retail: tail-called from CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge, not a tunnel edge). - An F751 arriving mid-login-tunnel withdraws the login claim and hands the presentation to the portal pump, which owns the single LoginComplete — matching retail's one teleportInProgress flag. TS-28 narrowed: the graphical host now runs the full login wormhole; the residual is headless-only (no presentation; placement-edge send). Live gates (testaccount2/+Horan vs local ACE, Release): the character-select Enter route and the --session-config direct auto-select route both play the wormhole with Sound_UI_EnterPortal at animation begin, hold with retail's 'In Portal Space - Please Wait...' notice until readiness, fade out with the view-plane warp, send LoginComplete at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login tests), Runtime 1744/0, full solution green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
450 lines
19 KiB
C#
450 lines
19 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;
|
|
|
|
// Enter-world round (2026-08-17): the Runtime first-entry conductor
|
|
// can have CONSTRUCTED the controller without publishing it yet
|
|
// (CandidatePreparing/Sealed/Dormant). A State write in that window
|
|
// throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement
|
|
// is the documented skip-instead-of-fault predicate for exactly this
|
|
// pre-publication login window. Refuse — every caller retries on its
|
|
// own tick cadence.
|
|
if (Controller is not { CanExecuteLiveMovement: true } controller)
|
|
return false;
|
|
|
|
controller.State = PlayerState.PortalSpace;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): the login tunnel's portal-space entry.
|
|
/// The login wormhole begins before any player-mode entry has happened
|
|
/// (retail's camera set simply follows its always-present player), so
|
|
/// this first performs the SAME presentation attach the post-reveal
|
|
/// auto-entry used to run (<see cref="TryEnter"/> — chase camera, shadow,
|
|
/// animation sinks, approach lifetime), then flips into portal space.
|
|
/// <see cref="TryEnter"/> itself refuses until the Runtime first-entry
|
|
/// conductor commits, so pre-publication calls fail closed and retry.
|
|
/// </summary>
|
|
public bool TryEnterPortalSpaceForLogin()
|
|
{
|
|
_autoEntry?.Cancel();
|
|
if (!_mode.IsPlayerMode && !TryEnter("login"))
|
|
return false;
|
|
|
|
return TryEnterPortalSpace();
|
|
}
|
|
|
|
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;
|
|
// C3c: the movement controller is Runtime-owned — player-mode exit
|
|
// detaches presentation only; the publication lifecycle (generation
|
|
// reset/teardown) owns the controller's retirement.
|
|
_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();
|
|
// C3c: the Runtime generation reset retires the controller through
|
|
// RuntimeLocalPlayerMovementState.ResetSession; App detaches
|
|
// presentation only.
|
|
_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;
|
|
}
|
|
|
|
// C3c: player mode attaches presentation to the Runtime-published
|
|
// controller/host; until the first-entry conductor commits them,
|
|
// entry simply retries on a later frame.
|
|
if (_controllerSlot.Controller is not { } publishedController
|
|
|| !publishedController.IsRuntimePublished
|
|
|| playerRecord.PhysicsHost is not EntityPhysicsHost)
|
|
{
|
|
Console.WriteLine(
|
|
$"live: {loggingTag} — Runtime first-entry controller for "
|
|
+ $"0x{playerGuid:X8} not committed yet");
|
|
return false;
|
|
}
|
|
|
|
BuildControllerAndCamera(
|
|
loggingTag,
|
|
playerGuid,
|
|
playerEntity,
|
|
playerRecord);
|
|
return true;
|
|
}
|
|
|
|
private void BuildControllerAndCamera(
|
|
string loggingTag,
|
|
uint playerGuid,
|
|
WorldEntity playerEntity,
|
|
LiveEntityRecord playerRecord)
|
|
{
|
|
// C3c route-1 flip: the movement controller, physics body, host, and
|
|
// committed placement are Runtime-owned — constructed and activated
|
|
// by the first-entry conductor's publication chain before player
|
|
// mode can enter. This method attaches only App presentation
|
|
// (approach lifetime, animation bindings, camera, shadow, host
|
|
// slot). A failure here rolls back camera/shadow ONLY and never
|
|
// touches Runtime. C3c-R1 review F8: auto-entry does NOT retry a
|
|
// throw from this attach — PlayerModeAutoEntry.TryEnter disarms its
|
|
// one-shot BEFORE invoking EnterPlayerMode, so an exception here
|
|
// burns the shot; recovery is the manual Tab entry (or a session
|
|
// reset re-arming the trigger).
|
|
if (_controllerSlot.Controller is not { } controller
|
|
|| !controller.IsRuntimePublished)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Player mode ({loggingTag}) requires the Runtime-published "
|
|
+ "local movement controller; the first-entry conductor has "
|
|
+ "not committed it yet.");
|
|
}
|
|
if (playerRecord.PhysicsHost is not EntityPhysicsHost playerHost)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Player mode ({loggingTag}) requires the Runtime-committed "
|
|
+ "local physics host.");
|
|
}
|
|
|
|
IPlayerApproachCompletionSink approachLifetime =
|
|
_approachCompletions.BeginControllerLifetime();
|
|
bool lifetimeCommitted = false;
|
|
bool cameraAttempted = false;
|
|
bool shadowAttempted = false;
|
|
CameraController.CameraState priorCamera = _camera.CaptureState();
|
|
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
|
|
try
|
|
{
|
|
// Approach-completion presentation rides the Runtime-owned
|
|
// MoveToManager (created by the publication chain's own
|
|
// MakeMoveToManager).
|
|
if (controller.MoveTo is { } moveTo)
|
|
{
|
|
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);
|
|
}
|
|
|
|
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);
|
|
sequencer.Manager.HandleEnterWorld();
|
|
// C3c constructs and grounds the Runtime-owned player before
|
|
// graphical PartArray presentation attaches. Ground entry can
|
|
// therefore enqueue CMotionInterp work while no animation sink
|
|
// exists; that work has no matching PartArray completion and
|
|
// would permanently sit ahead of later MoveTo/cast motions.
|
|
// First let the newly attached PartArray drain every matching
|
|
// animation above, then retire only the unmatched pre-attach
|
|
// interpreter suffix. In retail both owners exist together,
|
|
// so this suffix cannot arise; this is the split-lifetime
|
|
// reconciliation at their single attach boundary.
|
|
controller.Motion.HandleExitWorld();
|
|
}
|
|
|
|
var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
|
|
var retailCamera = new RetailChaseCamera
|
|
{
|
|
Aspect = _viewport.Aspect,
|
|
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
|
|
};
|
|
cameraAttempted = true;
|
|
_camera.EnterChaseMode(legacyCamera, retailCamera);
|
|
|
|
shadowAttempted = true;
|
|
_shadow.SyncPose(
|
|
playerEntity,
|
|
controller.Position,
|
|
playerEntity.Rotation,
|
|
controller.CellId,
|
|
force: true);
|
|
|
|
_hostSlot.Host = playerHost;
|
|
_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); }
|
|
}
|
|
|
|
// C3c: presentation-only rollback. The Runtime-published
|
|
// controller/body/host stay live — retail has no entry-flow
|
|
// rollback. C3c-R1 review F8: this rethrow is NOT retried by
|
|
// auto-entry — the one-shot trigger disarms before invoking
|
|
// (PlayerModeAutoEntry.TryEnter), and the throw also propagates
|
|
// out of the auto-entry context before its world-reveal
|
|
// Complete() call. After a failed attach the player re-enters
|
|
// via the manual Tab path (or a session reset re-arms the
|
|
// trigger).
|
|
_mode.IsPlayerMode = false;
|
|
_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);
|
|
}
|
|
|
|
}
|