refactor(app): complete session startup composition

Move the live-session reset and routing graph, combat and diagnostic command targets, and the sole gameplay input subscriber into Phase 7 before frame publication. Add exact retryable ownership for late bindings so partial startup cannot strand session or component teardown edges.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 18:49:31 +02:00
parent 7fa60971e2
commit 826f9ea9b5
22 changed files with 924 additions and 412 deletions

View file

@ -0,0 +1,386 @@
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.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.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Content;
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,
LocalPlayerControllerSlot Controller,
LocalPlayerSkillState Skills,
LiveWorldOriginState WorldOrigin,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
DesiredComponentSnapshotState DesiredComponents);
internal sealed record LiveSessionDomainRuntime(
ClientObjectTable Objects,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
CombatState Combat,
ItemManaState ItemMana,
FriendsState Friends,
SquelchState Squelch,
TurbineChatState TurbineChat,
ExternalContainerState ExternalContainers,
ChatLog Chat);
internal sealed record LiveSessionUiRuntime(
RetailUiRuntime? RetailUi,
VitalsVM? Vitals,
ChatVM? RetailChat,
CharacterSheetProvider? CharacterSheet,
MagicRuntime? Magic,
PaperdollFramePresenter? Paperdoll);
internal sealed record LiveSessionInteractionRuntime(
RuntimeSettingsController Settings,
GameplayInputFrameController GameplayInput,
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
ItemInteractionController ItemInteraction,
CombatAttackController 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);
/// <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 Action<string> _log;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
LiveSessionDomainRuntime domain,
LiveSessionUiRuntime ui,
LiveSessionInteractionRuntime interaction,
LiveSessionWorldRuntime world,
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));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
public LiveSessionHost Create(LiveSessionController controller)
{
ArgumentNullException.ThrowIfNull(controller);
return new LiveSessionHost(controller, new LiveSessionHostBindings(
Routing: new(
CreateEventRouter,
session => new LiveSessionCommandRouter(
CreateCommandBindings(session))),
Reset: CreateResetBindings(),
Selection: new(
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
SetChatIdentity: _domain.Chat.SetLocalPlayerGuid,
MarkPersistent: _world.WorldState.MarkPersistent,
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
ClearCombat: _domain.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.Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
_domain.Chat.OnSystemMessage(
"connected — character list received",
chatType: 1)));
}
private LiveSessionResetBindings CreateResetBindings() => new()
{
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
TeleportTransit = _world.Teleport.ResetSession,
SessionDialogs = () => _ui.RetailUi?.ResetSessionDialogs(),
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear,
ExternalContainer = () => _domain.ExternalContainers.Reset(),
InteractionAndSelection = _interaction.SelectionInteractions.ResetSession,
SelectionPresentation = _world.SelectionScene.Reset,
ObjectTable = _domain.Objects.Clear,
Spellbook = _domain.Spellbook.Clear,
MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession,
CombatState = _domain.Combat.Clear,
ItemMana = _domain.ItemMana.Clear,
LocalPlayer = _domain.LocalPlayer.Clear,
Friends = _domain.Friends.Clear,
Squelch = _domain.Squelch.Clear,
TurbineChat = _domain.TurbineChat.Reset,
ParticleVisibility = _world.ParticleVisibility.Reset,
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
LiveRuntime = ResetLiveRuntime,
SessionIdentity = ResetIdentity,
RemoteTeleport = _world.RemoteTeleport.Clear,
NetworkEffects = _world.EntityEffects.ClearNetworkState,
AnimationHookFrames = _world.AnimationHookFrames.Clear,
LivePresentation = _world.Presentation.Clear,
RemoteMovementDiagnostics = _world.RemoteMovementObservations.Clear,
};
private void ResetPlayerPresentation()
{
_interaction.PlayerMode.ResetSession();
_world.SpawnClaims.Reset();
}
private void ResetLiveRuntime() =>
LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_world.LiveEntities);
private void ResetIdentity()
{
LiveSessionEntityRuntimeReset.RequireConvergence(_world.LiveEntities);
// 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.
_player.Identity.ServerGuid = 0u;
_ui.Vitals?.SetLocalPlayerGuid(0u);
_domain.Chat.ResetSessionIdentity();
EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey();
_player.CharacterOptions.Reset();
_player.Skills.ResetSession();
_world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState();
_player.Shortcuts.Items = Array.Empty<ShortcutEntry>();
_player.DesiredComponents.Clear();
_ui.Paperdoll?.MarkDirty();
// 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 LiveSessionEventRouter 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);
}
return new LiveSessionEventRouter(
session,
_world.EntitySession.CreateSink(),
new LiveEnvironmentSessionSink(
_world.Environment.ApplyAdminEnvirons,
_world.Environment.SynchronizeFromServer),
CreateInventoryBindings(),
CreateCharacterBindings(skillTable),
new LiveSocialSessionBindings(
_domain.Chat,
_domain.TurbineChat,
_domain.Friends,
_domain.Squelch));
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Objects,
_domain.LocalPlayer,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: list => _player.Shortcuts.Items = list,
OnUseDone: error =>
{
_domain.ExternalContainers.ApplyUseDone(error);
_interaction.ItemInteraction.CompleteUse(error);
},
_domain.ItemMana,
OnDesiredComponents: components =>
_player.DesiredComponents.Items = components,
ExternalContainers: _domain.ExternalContainers);
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)
{
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
return new LiveCharacterSessionBindings(
_domain.Combat,
_domain.Spellbook,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_player.Skills.Update(
runSkill,
jumpSkill,
_player.Controller.Controller);
if (_player.Skills.IsComplete
&& _player.Controller.Controller is not null)
{
_log(
$"player: applied server skills " +
$"run={_player.Skills.RunSkill} " +
$"jump={_player.Skills.JumpSkill}");
}
},
OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_player.CharacterOptions.Options =
(Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
}
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.Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => _domain.Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
_domain.Objects.Get(_player.Identity.ServerGuid)?
.PublicWeenieBitfield,
ClientVersion: () =>
typeof(LiveSessionRuntimeFactory).Assembly
.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
LastOutsideCorpsePosition: () =>
_domain.LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => _domain.Chat.Clear(),
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
IsAway: () =>
_domain.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.Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
_domain.Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _ui.RetailChat?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
_player.DesiredComponents.Clear();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
_domain.Chat,
_domain.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),
Log: _log);
private static double ClientTimerNow() =>
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
}