ACE intentionally creates the local player Hidden and releases that materialization state on LoginComplete. Sending LoginComplete from raw F746 receipt raced canonical placement and left the login haze visible. Route one one-shot completion callback from Runtime's local first-entry terminal edge to graphical and prepared headless hosts; retain a guarded accepted-Create edge only for content-less headless sessions. Focused Runtime login tests, all 79 Headless tests, the connected user gate, and the Release build pass.
421 lines
18 KiB
C#
421 lines
18 KiB
C#
using System.Diagnostics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Settings;
|
|
using AcDream.App.Spells;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.World;
|
|
using AcDream.Content;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Net;
|
|
|
|
internal sealed record LiveSessionPlayerRuntime(
|
|
LocalPlayerIdentityState Identity,
|
|
RuntimeLocalPlayerMovementState Controller,
|
|
LiveWorldOriginState WorldOrigin);
|
|
|
|
internal sealed record LiveSessionDomainRuntime(
|
|
GameRuntime Runtime,
|
|
RuntimeEntityObjectLifetime EntityObjects,
|
|
RuntimeCharacterState Character,
|
|
RuntimeActionState Actions,
|
|
RuntimeInventoryState Inventory,
|
|
RuntimeCommunicationState Communication);
|
|
|
|
internal sealed record LiveSessionUiRuntime(
|
|
RetailUiRuntime? RetailUi,
|
|
VitalsVM? Vitals,
|
|
CharacterSheetProvider? CharacterSheet,
|
|
MagicRuntime? Magic,
|
|
PaperdollFramePresenter? Paperdoll);
|
|
|
|
internal sealed record LiveSessionInteractionRuntime(
|
|
RuntimeSettingsController Settings,
|
|
GameplayInputFrameController GameplayInput,
|
|
PlayerModeController PlayerMode,
|
|
PlayerModeAutoEntry PlayerModeAutoEntry,
|
|
ItemInteractionController ItemInteraction,
|
|
RuntimeCombatAttackState CombatAttack,
|
|
SelectionInteractionController SelectionInteractions);
|
|
|
|
internal sealed record LiveSessionWorldRuntime(
|
|
IDatReaderWriter Dats,
|
|
GpuWorldState WorldState,
|
|
LiveEntityRuntime LiveEntities,
|
|
LiveEntitySessionController EntitySession,
|
|
WorldEnvironmentController Environment,
|
|
DeferredLocalPlayerTeleportNetworkSink Teleport,
|
|
DatSpawnClaimHydrationClassifier SpawnClaims,
|
|
EquippedChildRenderController EquippedChildren,
|
|
RetailSelectionScene SelectionScene,
|
|
ParticleVisibilityController ParticleVisibility,
|
|
RetailInboundEventDispatcher InboundEvents,
|
|
LiveEntityLivenessController Liveness,
|
|
LiveEntityNetworkUpdateController NetworkUpdates,
|
|
LiveEntityHydrationController Hydration,
|
|
RemoteTeleportController RemoteTeleport,
|
|
EntityEffectController EntityEffects,
|
|
AnimationHookFrameQueue AnimationHookFrames,
|
|
LiveEntityPresentationController Presentation,
|
|
RemoteMovementObservationTracker RemoteMovementObservations,
|
|
RenderSceneShadowRuntime? RenderSceneShadow,
|
|
RuntimePlacementPresentationSink PlacementProjection,
|
|
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
|
RuntimeFirstEntryDriveController FirstEntryDrive);
|
|
|
|
/// <summary>
|
|
/// Builds the exact per-generation route/reset graph for the canonical live
|
|
/// session. It owns no mirrored session state and retains no window callback.
|
|
/// </summary>
|
|
internal sealed class LiveSessionRuntimeFactory
|
|
{
|
|
private readonly LiveSessionPlayerRuntime _player;
|
|
private readonly LiveSessionDomainRuntime _domain;
|
|
private readonly LiveSessionUiRuntime _ui;
|
|
private readonly LiveSessionInteractionRuntime _interaction;
|
|
private readonly LiveSessionWorldRuntime _world;
|
|
private readonly LiveSessionCommandSurface _commands;
|
|
private readonly Action<string> _log;
|
|
private readonly LiveMovementStatsApplier _movementStats;
|
|
|
|
public LiveSessionRuntimeFactory(
|
|
LiveSessionPlayerRuntime player,
|
|
LiveSessionDomainRuntime domain,
|
|
LiveSessionUiRuntime ui,
|
|
LiveSessionInteractionRuntime interaction,
|
|
LiveSessionWorldRuntime world,
|
|
LiveSessionCommandSurface commands,
|
|
Action<string> log)
|
|
{
|
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
|
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
|
_ui = ui ?? throw new ArgumentNullException(nameof(ui));
|
|
_interaction = interaction
|
|
?? throw new ArgumentNullException(nameof(interaction));
|
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
|
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
|
// typed application seam; App keeps zero direct controller mutations.
|
|
_movementStats = new LiveMovementStatsApplier(
|
|
_player.Controller,
|
|
_domain.Character.MovementSkills,
|
|
_log);
|
|
}
|
|
|
|
public LiveSessionHost Create(
|
|
LiveSessionController controller,
|
|
LiveSessionConnectOptions connectOptions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(controller);
|
|
ArgumentNullException.ThrowIfNull(connectOptions);
|
|
var resetHost = new GraphicalRuntimeGenerationResetHost(
|
|
_world.LiveEntities,
|
|
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
|
|
LiveSessionResetPlan reset =
|
|
LiveSessionResetManifest.Create(
|
|
CreateResetBindings(resetHost));
|
|
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
|
Routing: new(
|
|
CreateEventRouter,
|
|
session => _commands.Attach(new LiveSessionCommandRouter(
|
|
CreateCommandBindings(session)))),
|
|
Reset: reset.Execute,
|
|
Selection: new(
|
|
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
|
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
|
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
|
|
MarkPersistent: _world.WorldState.MarkPersistent,
|
|
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
|
|
ClearCombat: _domain.Actions.Combat.Clear),
|
|
EnteredWorld: new(
|
|
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
|
|
RestoreLayout: () => _ui.RetailUi?.RestoreLayout(),
|
|
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
|
|
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
|
|
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),
|
|
Connecting: (host, port, user) =>
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
$"connecting to {host}:{port} as {user}",
|
|
chatType: 1),
|
|
Connected: () =>
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
"connected — character list received",
|
|
chatType: 1)),
|
|
connectOptions);
|
|
}
|
|
|
|
private LiveSessionResetBindings CreateResetBindings(
|
|
IRuntimeGenerationResetHost resetHost) => new()
|
|
{
|
|
MouseCapture = _interaction.GameplayInput.ResetSession,
|
|
PlayerPresentation = ResetPlayerPresentation,
|
|
TeleportPresentation =
|
|
_world.Teleport.ResetGenerationPresentation,
|
|
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
|
SettingsCharacterContext =
|
|
_interaction.Settings.RestoreDefaultCharacterContext,
|
|
EquippedChildren = _world.EquippedChildren.Clear,
|
|
InteractionPresentation =
|
|
_interaction.SelectionInteractions.ResetGenerationPresentation,
|
|
SelectionPresentation = _world.SelectionScene.Reset,
|
|
ParticleVisibility = _world.ParticleVisibility.Reset,
|
|
InboundEventFifo = _world.InboundEvents.Clear,
|
|
LiveLiveness = _world.Liveness.Clear,
|
|
RuntimeGeneration = generation =>
|
|
_domain.Runtime.ResetGeneration(generation, resetHost),
|
|
SessionIdentityPresentation = ResetIdentityPresentation,
|
|
RemoteTeleport = _world.RemoteTeleport.Clear,
|
|
NetworkEffects = _world.EntityEffects.ClearNetworkState,
|
|
AnimationHookFrames = _world.AnimationHookFrames.Clear,
|
|
LivePresentation = _world.Presentation.Clear,
|
|
RemoteMovementDiagnostics = _world.RemoteMovementObservations.Clear,
|
|
};
|
|
|
|
private void ResetPlayerPresentation()
|
|
{
|
|
_movementStats.Reset();
|
|
_interaction.PlayerMode.ResetSession();
|
|
_world.SpawnClaims.Reset();
|
|
}
|
|
|
|
private void ResetIdentityPresentation(
|
|
RuntimeGenerationToken retiringGeneration)
|
|
{
|
|
RuntimeGenerationResetSnapshot reset =
|
|
_domain.Runtime.GenerationReset.CaptureSnapshot();
|
|
if (reset.IsActive
|
|
|| reset.LastCompletedGeneration != retiringGeneration)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime generation {retiringGeneration.Value} has not "
|
|
+ "converged before App identity projection teardown.");
|
|
}
|
|
|
|
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
|
|
// components, and character option objects. CPlayerSystem::Begin
|
|
// @ 0x0055D410 resets player identity and session fields. The option
|
|
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
|
|
_ui.Vitals?.SetLocalPlayerGuid(0u);
|
|
EntityVanishProbe.PlayerGuid = 0u;
|
|
_interaction.Settings.ResetActiveCharacterKey();
|
|
_world.NetworkUpdates.ResetSessionState();
|
|
_world.Hydration.ResetSessionState();
|
|
_ui.Paperdoll?.ResetSession();
|
|
|
|
// X/Y are ignored until the next logical player CreateObject claims a
|
|
// new origin. Keeping the last values avoids inventing a synthetic
|
|
// landblock while still forcing first-spawn initialization.
|
|
_player.WorldOrigin.Reset();
|
|
}
|
|
|
|
private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
|
|
{
|
|
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
|
|
if (_ui.CharacterSheet is not null)
|
|
{
|
|
_ui.CharacterSheet.SkillTable = skillTable;
|
|
_ui.CharacterSheet.ExperienceTable =
|
|
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
|
|
}
|
|
|
|
var route = new LiveSessionEventRouter(
|
|
session,
|
|
_world.EntitySession.CreateSink(),
|
|
new LiveEnvironmentSessionSink(
|
|
_world.Environment.ApplyAdminEnvirons,
|
|
_world.Environment.SynchronizeFromServer),
|
|
CreateInventoryBindings(),
|
|
CreateCharacterBindings(skillTable),
|
|
new LiveSocialSessionBindings(
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
_domain.Communication.Friends,
|
|
_domain.Communication.Squelch));
|
|
return new GraphicalSessionEventRoute(
|
|
route,
|
|
_domain.Runtime,
|
|
_world.PlacementProjection,
|
|
_world.PlacementRetries,
|
|
_world.FirstEntryDrive,
|
|
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
|
}
|
|
|
|
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
|
_domain.Inventory.Objects,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
OnShortcuts: _domain.Inventory.Shortcuts.Load,
|
|
OnUseDone: error =>
|
|
{
|
|
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
|
_domain.Actions.Transactions.CompleteUse(error);
|
|
},
|
|
_domain.Inventory.ItemMana,
|
|
ExternalContainers: _domain.Inventory.ExternalContainers,
|
|
OnAppraisal: appraisal =>
|
|
{
|
|
if (_ui.RetailUi is { } retailUi)
|
|
retailUi.HandleAppraisal(appraisal);
|
|
else
|
|
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
|
|
});
|
|
|
|
private LiveCharacterSessionBindings CreateCharacterBindings(
|
|
SkillTable? skillTable)
|
|
{
|
|
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
|
|
return new LiveCharacterSessionBindings(
|
|
_domain.Actions.Combat,
|
|
_domain.Character,
|
|
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
|
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
|
_movementStats.Apply("skills"),
|
|
OnConfirmationRequest: request =>
|
|
_ui.RetailUi?.HandleConfirmationRequest(request),
|
|
OnConfirmationDone: done =>
|
|
_ui.RetailUi?.HandleConfirmationDone(done),
|
|
ClientTime: ClientTimerNow,
|
|
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
|
|
// reactively re-apply through the SAME seam skills already used
|
|
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
|
|
// movement owner's typed application entry — see
|
|
// LiveMovementStatsApplier.
|
|
OnMovementStatsUpdated: () => _movementStats.Apply("stats"));
|
|
}
|
|
|
|
private LiveSessionCommandBindings CreateCommandBindings(
|
|
WorldSession session) => new(
|
|
ClientCommands: new ClientCommandController.Bindings(
|
|
TeleportToLifestone: session.SendTeleportToLifestone,
|
|
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
|
TeleportToPkArena: session.SendTeleportToPkArena,
|
|
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
|
TeleportToHouse: session.SendTeleportToHouse,
|
|
TeleportToMansion: session.SendTeleportToMansion,
|
|
QueryAge: session.SendQueryAge,
|
|
QueryBirth: session.SendQueryBirth,
|
|
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
|
|
ToggleUiLock: () =>
|
|
_interaction.Settings.SetUiLocked(
|
|
!_interaction.Settings.Gameplay.LockUI),
|
|
ShowSystemMessage:
|
|
text => _domain.Communication.Chat.OnSystemMessage(text, 0u),
|
|
ShowWeenieError:
|
|
code => _domain.Communication.Chat.OnWeenieError(code, null),
|
|
PlayerPublicWeenieBitfield: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.PublicWeenieBitfield,
|
|
ClientVersion: () =>
|
|
typeof(LiveSessionRuntimeFactory).Assembly
|
|
.GetName().Version?.ToString(3)
|
|
?? "unknown",
|
|
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
|
|
LastOutsideCorpsePosition: () =>
|
|
_domain.Character.LocalPlayer.GetPosition(0x0Eu),
|
|
ShowConfirmation: (message, completed) =>
|
|
_ui.RetailUi?.ShowConfirmation(message, completed),
|
|
Suicide: session.SendSuicide,
|
|
ClearChat: _ => _domain.Communication.Chat.Clear(),
|
|
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
|
|
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
|
|
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
|
|
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
|
|
IsAway: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.Properties.GetBool(0x6Eu) == true,
|
|
SetAway: session.SendSetAfkMode,
|
|
SetAwayMessage: session.SendSetAfkMessage,
|
|
AcceptLootPermits: () =>
|
|
_interaction.Settings.Gameplay.AcceptLootPermits,
|
|
SetAcceptLootPermits: _interaction.Settings.SetAcceptLootPermits,
|
|
DisplayConsent: session.SendDisplayConsent,
|
|
ClearConsent: session.SendClearConsent,
|
|
RemoveConsent: session.SendRemoveConsent,
|
|
SendEmote: session.SendEmote,
|
|
_domain.Communication.Friends,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
_domain.Communication.Squelch,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
LastTeller: () =>
|
|
_domain.Communication.CommandTargets.LastIncomingTellSender,
|
|
ClearDesiredComponents: () =>
|
|
{
|
|
session.SendClearDesiredComponents();
|
|
},
|
|
HasOpenVendor: () => false,
|
|
FillComponentBuyList: (_, _) => { }),
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
SendTalk: session.SendTalk,
|
|
SendTell: session.SendTell,
|
|
SendChannel: session.SendChannel,
|
|
SendTurbineChat: (
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie) => session.SendTurbineChatTo(
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie),
|
|
AddShortcut: session.SendAddShortcut,
|
|
RemoveShortcut: session.SendRemoveShortcut,
|
|
AddFavorite: session.SendAddSpellFavorite,
|
|
RemoveFavorite: session.SendRemoveSpellFavorite,
|
|
SetSpellbookFilter: session.SendSpellbookFilter,
|
|
ForgetSpell: session.SendRemoveSpell,
|
|
SetDesiredComponent: session.SendSetDesiredComponentLevel,
|
|
ClearDesiredComponents: session.SendClearDesiredComponents,
|
|
RaiseAttribute: session.SendRaiseAttribute,
|
|
RaiseVital: session.SendRaiseVital,
|
|
RaiseSkill: session.SendRaiseSkill,
|
|
TrainSkill: session.SendTrainSkill,
|
|
SetCharacterOptions: session.SendSetCharacterOptions,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
Log: _log);
|
|
|
|
private static double ClientTimerNow() =>
|
|
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
|
|
}
|