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

@ -326,8 +326,9 @@ public sealed class GameWindow :
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
public readonly AcDream.Core.Social.FriendsState Friends = new();
public readonly AcDream.Core.Social.SquelchState Squelch = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
= System.Array.Empty<(uint Id, uint Amount)>();
private readonly DesiredComponentSnapshotState _desiredComponents = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents =>
_desiredComponents.Items;
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
@ -1010,6 +1011,8 @@ public sealed class GameWindow :
|| _playerModeController is not null
|| _playerModeAutoEntry is not null
|| _localPlayerTeleport is not null
|| _liveSessionHost is not null
|| _gameplayInputActions is not null
|| _sessionPlayerBindings is not null)
{
throw new InvalidOperationException(
@ -1032,6 +1035,8 @@ public sealed class GameWindow :
_playerModeController = result.PlayerMode;
_playerModeAutoEntry = result.PlayerModeAutoEntry;
_localPlayerTeleport = result.LocalTeleport;
_liveSessionHost = result.SessionHost;
_gameplayInputActions = result.GameplayActions;
_sessionPlayerBindings = result.RuntimeBindings;
}
@ -1287,6 +1292,11 @@ public sealed class GameWindow :
_datLock,
_runtimeSettings,
settingsDevTools,
_worldEnvironment,
_worldSceneDebugState,
_runtimeDiagnosticCommands,
_liveCombatModeCommands,
_hostQuiescence,
_physicsEngine,
_physicsDataCache,
_worldGameState,
@ -1314,6 +1324,9 @@ public sealed class GameWindow :
_localPlayerSkills,
_viewportAspect,
_playerApproachCompletions,
_characterOptions,
_shortcutSnapshots,
_desiredComponents,
_pointerPosition,
_movementInput,
_inputCapture,
@ -1325,6 +1338,14 @@ public sealed class GameWindow :
_combatAttackOperations,
Combat,
_combatFeedback,
Chat,
LocalPlayer,
SpellBook,
ItemMana,
Friends,
Squelch,
TurbineChat,
_externalContainers,
_portalTunnelFallback,
Console.WriteLine),
this).Compose(
@ -1593,84 +1614,9 @@ public sealed class GameWindow :
sessionPlayer.PlayerModeAutoEntry),
cameraFrame);
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
_liveSessionHost = CreateLiveSessionHost();
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
Action<string>? debugToast = debugVm is null
? null
: message => debugVm.AddToast(message);
AcDream.Core.Chat.ChatLog chat = Chat;
var combatCommand =
new AcDream.App.Combat.LiveCombatModeCommandController(
new AcDream.App.Combat.LiveSessionCombatModeAuthority(
_liveSessionHost),
new AcDream.App.Combat.LocalPlayerCombatEquipmentSource(
Objects,
_localPlayerIdentity),
Combat,
new AcDream.App.Combat.ItemInteractionCombatModeIntentSink(
_itemInteractionController!),
Console.WriteLine,
debugToast,
text => chat.OnSystemMessage(text, 0x1Au));
_liveCombatModeCommands.Bind(combatCommand);
var nearbyDiagnostics =
new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper(
new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource(
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_liveWorldOrigin,
_worldState,
_physicsEngine),
Console.WriteLine);
var runtimeDiagnostics =
new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(
_worldEnvironment,
_worldSceneDebugState,
_cameraPointerInput,
nearbyDiagnostics,
debugToast);
_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);
if (_inputDispatcher is { } dispatcher)
{
var pointer = _cameraPointerInput
?? throw new InvalidOperationException(
"Gameplay action routing requires the composed pointer owner.");
var commands = new AcDream.App.Input.GameplayInputCommandController(
new AcDream.App.Input.RetainedGameplayWindowCommands(
_retailUiRuntime),
new AcDream.App.Input.DevToolsGameplayCommands(
_devToolsFramePresenter),
runtimeDiagnostics,
new AcDream.App.Input.PlayerModeGameplayCommands(
_localPlayerMode,
sessionPlayer.PlayerMode),
new AcDream.App.Input.ItemTargetModeCommands(
_itemInteractionController!),
new AcDream.App.Input.GameplayCameraModeCommands(
_cameraController!),
combatCommand,
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
sessionPlayer.GameplayInput,
pointer,
_retailUiRuntime,
_selectionInteractions,
commands);
_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(
dispatcher,
Combat,
targets,
_hostQuiescence,
Console.WriteLine);
_gameplayInputActions.Attach();
}
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionHost.Start(_options);
sessionPlayer.SessionHost.Start(_options);
switch (liveStart.Status)
{
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
@ -1683,272 +1629,6 @@ public sealed class GameWindow :
}
}
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
Routing: new(
CreateLiveSessionEventRouter,
session => new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session))),
Reset: CreateLiveSessionResetBindings(),
Selection: new(
SetPlayerIdentity: id => _playerServerGuid = id,
SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id),
SetChatIdentity: Chat.SetLocalPlayerGuid,
MarkPersistent: _worldState.MarkPersistent,
SetVanishProbeIdentity: id =>
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
ClearCombat: Combat.Clear),
EnteredWorld: new(
SetActiveCharacter: _runtimeSettings.SetActiveCharacter,
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
SyncToolbar: SyncToolbarWindowButtons,
LoadCharacterSettings: _runtimeSettings.LoadCharacterContext,
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
Chat.OnSystemMessage(
"connected — character list received",
chatType: 1)));
private AcDream.App.Net.LiveSessionResetBindings
CreateLiveSessionResetBindings() => new()
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
TeleportTransit = _localPlayerTeleportSink.ResetSession,
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = _runtimeSettings.RestoreDefaultCharacterContext,
EquippedChildren = () => _equippedChildRenderer?.Clear(),
ExternalContainer = () => _externalContainers.Reset(),
InteractionAndSelection = ResetSessionInteraction,
SelectionPresentation = () => _retailSelectionScene?.Reset(),
ObjectTable = Objects.Clear,
Spellbook = SpellBook.Clear,
MagicRuntime = () => _magicRuntime?.Reset(),
CombatAttack = () => _combatAttackController?.ResetSession(),
CombatState = Combat.Clear,
ItemMana = ItemMana.Clear,
LocalPlayer = LocalPlayer.Clear,
Friends = Friends.Clear,
Squelch = Squelch.Clear,
TurbineChat = TurbineChat.Reset,
ParticleVisibility = _particleVisibility.Reset,
InboundEventFifo = _inboundEntityEvents.Clear,
LiveLiveness = () => _liveEntityLiveness?.Clear(),
LiveRuntime = ResetSessionLiveRuntime,
SessionIdentity = ResetSessionIdentity,
RemoteTeleport = () => _remoteTeleportController?.Clear(),
NetworkEffects = () => _entityEffects?.ClearNetworkState(),
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
};
private void ResetSessionMouseCapture()
=> _gameplayInputFrame?.ResetSession();
private void ResetSessionPlayerPresentation()
{
_playerModeController?.ResetSession();
_spawnClaimHydration?.Reset();
}
private void ResetSessionIdentity()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.RequireConvergence(_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.
_playerServerGuid = 0u;
_vitalsVm?.SetLocalPlayerGuid(0u);
Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_runtimeSettings.ResetActiveCharacterKey();
_characterOptions.Reset();
_localPlayerSkills.ResetSession();
_liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty<AcDream.Core.Items.ShortcutEntry>();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
_paperdollFramePresenter?.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.
_liveWorldOrigin.Reset();
}
private void ResetSessionLiveRuntime()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_liveEntities);
}
private void ResetSessionInteraction()
{
if (_selectionInteractions is { } interactions)
interactions.ResetSession();
else
{
_itemInteractionController?.ResetSession();
_selection.Reset();
}
}
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
AcDream.Core.Net.WorldSession session)
{
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats,
Console.WriteLine);
}
return new AcDream.App.Net.LiveSessionEventRouter(
session,
(_liveEntitySessionEvents
?? throw new InvalidOperationException(
"Live entity session routing was not composed."))
.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
_worldEnvironment.ApplyAdminEnvirons,
_worldEnvironment.SynchronizeFromServer),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
}
private AcDream.App.Net.LiveInventorySessionBindings
CreateLiveInventorySessionBindings() => new(
Objects,
LocalPlayer,
PlayerGuid: () => _playerServerGuid,
OnShortcuts: list => Shortcuts = list,
OnUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
ItemMana,
OnDesiredComponents: components => DesiredComponents = components,
ExternalContainers: _externalContainers);
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable)
{
var skillCreditResolver =
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
return new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
if (_localPlayerSkills.IsComplete && _playerController is not null)
{
Console.WriteLine(
$"player: applied server skills run={_localPlayerSkills.RunSkill} " +
$"jump={_localPlayerSkills.JumpSkill}");
}
},
OnConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions.Options =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
}
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
Chat,
TurbineChat,
Friends,
Squelch);
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
AcDream.Core.Net.WorldSession session) => new(
ClientCommands: new AcDream.App.UI.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: _runtimeSettings.ToggleFrameRate,
ToggleUiLock: () =>
_runtimeSettings.SetUiLocked(!_runtimeSettings.Gameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => Chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _runtimeSettings.Gameplay.AcceptLootPermits,
SetAcceptLootPermits: _runtimeSettings.SetAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
Chat,
TurbineChat,
PlayerGuid: () => _playerServerGuid,
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: Console.WriteLine);
private void OnUpdate(double dt)
{
using var _updStage = _frameProfiler.BeginStage(
@ -2032,16 +1712,6 @@ public sealed class GameWindow :
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
}
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{
session.SendSetAfkMode(away);
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging