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

@ -109,6 +109,24 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
}
}
public IDisposable BindOwned(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"Live combat-mode commands are already bound.");
}
_target = target;
}
return new Binding(this, target);
}
public void Deactivate()
{
lock (_gate)
@ -126,6 +144,23 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
_target?.Toggle();
}
}
private sealed class Binding : IDisposable
{
private LiveCombatModeCommandSlot? _owner;
private readonly ILiveCombatModeCommand _expected;
public Binding(
LiveCombatModeCommandSlot owner,
ILiveCombatModeCommand expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
/// <summary>

View file

@ -406,6 +406,20 @@ internal sealed class ShortcutSnapshotState
}
}
internal sealed class DesiredComponentSnapshotState
{
private IReadOnlyList<(uint Id, uint Amount)> _items =
Array.Empty<(uint Id, uint Amount)>();
public IReadOnlyList<(uint Id, uint Amount)> Items
{
get => _items;
set => _items = value ?? Array.Empty<(uint Id, uint Amount)>();
}
public void Clear() => _items = Array.Empty<(uint Id, uint Amount)>();
}
/// <summary>
/// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in
/// Phase 7. Calls remain inert and diagnostic until that exact owner binds.

View file

@ -10,7 +10,9 @@ namespace AcDream.App.Composition;
/// </summary>
internal sealed class LivePresentationRuntimeBindings : IDisposable
{
private readonly List<(string Name, IDisposable Binding)> _bindings = [];
private sealed record Entry(string Name, IDisposable Binding);
private readonly List<Entry> _bindings = [];
private bool _deactivationStarted;
public void Adopt(string name, IDisposable binding)
@ -18,7 +20,17 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
_bindings.Add((name, binding));
_bindings.Add(new Entry(name, binding));
}
public IDisposable AdoptOwned(string name, IDisposable binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
var entry = new Entry(name, binding);
_bindings.Add(entry);
return new Adoption(this, entry);
}
public void AdoptRelease(string name, Action release) =>
@ -56,16 +68,16 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
List<Exception>? failures = null;
for (int i = _bindings.Count - 1; i >= 0; i--)
{
(string name, IDisposable binding) = _bindings[i];
Entry entry = _bindings[i];
try
{
binding.Dispose();
entry.Binding.Dispose();
_bindings.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Live-presentation binding '{name}' did not detach.",
$"Live-presentation binding '{entry.Name}' did not detach.",
failure));
}
}
@ -78,6 +90,38 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
}
}
private void ReleaseAdoption(Entry expected)
{
int index = _bindings.IndexOf(expected);
if (index < 0)
return;
expected.Binding.Dispose();
_bindings.RemoveAt(index);
}
private sealed class Adoption : IDisposable
{
private LivePresentationRuntimeBindings? _owner;
private readonly Entry _expected;
public Adoption(
LivePresentationRuntimeBindings owner,
Entry expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose()
{
LivePresentationRuntimeBindings? owner = _owner;
if (owner is null)
return;
owner.ReleaseAdoption(_expected);
_owner = null;
}
}
private sealed class DelegateBinding : IDisposable
{
private Action? _release;

View file

@ -1,6 +1,7 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
@ -12,11 +13,15 @@ using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Selection;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using Silk.NET.Windowing;
@ -28,6 +33,11 @@ internal sealed record SessionPlayerDependencies(
object DatLock,
RuntimeSettingsController Settings,
SettingsDevToolsResult SettingsDevTools,
WorldEnvironmentController WorldEnvironment,
WorldSceneDebugState WorldSceneDebugState,
RuntimeDiagnosticCommandSlot RuntimeDiagnosticCommands,
LiveCombatModeCommandSlot CombatModeCommands,
HostQuiescenceGate HostQuiescence,
PhysicsEngine PhysicsEngine,
PhysicsDataCache PhysicsDataCache,
WorldGameState WorldGameState,
@ -55,6 +65,9 @@ internal sealed record SessionPlayerDependencies(
LocalPlayerSkillState PlayerSkills,
ViewportAspectState ViewportAspect,
PlayerApproachCompletionState PlayerApproachCompletions,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
DesiredComponentSnapshotState DesiredComponents,
PointerPositionState PointerPosition,
DispatcherMovementInputSource MovementInput,
IInputCaptureSource InputCapture,
@ -66,6 +79,14 @@ internal sealed record SessionPlayerDependencies(
CombatAttackOperationsSlot CombatAttackOperations,
CombatState Combat,
CombatFeedbackSlot CombatFeedback,
ChatLog Chat,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
ItemManaState ItemMana,
FriendsState Friends,
SquelchState Squelch,
TurbineChatState TurbineChat,
ExternalContainerState ExternalContainers,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log);
@ -91,6 +112,8 @@ internal sealed record SessionPlayerResult(
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
LocalPlayerTeleportController LocalTeleport,
LiveSessionHost SessionHost,
GameplayInputActionRouter? GameplayActions,
SessionPlayerRuntimeBindings RuntimeBindings);
internal interface IGameWindowSessionPlayerPublication
@ -115,6 +138,9 @@ internal enum SessionPlayerCompositionPoint
PlayerModeBound,
PortalTransferred,
TeleportBound,
SessionHostCreated,
CommandsBound,
GameplayActionsAttached,
ResultPublished,
}
@ -694,9 +720,10 @@ internal sealed class SessionPlayerCompositionPhase
IDisposable componentLifecycleBinding =
live.ComponentLifecycle.BindOwned(teardown);
IDisposable componentLifecycleAdoption;
try
{
live.RuntimeBindings.Adopt(
componentLifecycleAdoption = live.RuntimeBindings.AdoptOwned(
"live runtime component teardown",
componentLifecycleBinding);
}
@ -705,6 +732,153 @@ internal sealed class SessionPlayerCompositionPhase
componentLifecycleBinding.Dispose();
throw;
}
var componentLifecycleLease = scope.Own(
"live runtime component teardown adoption",
componentLifecycleAdoption,
static value => value.Dispose());
AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals =
d.SettingsDevTools.DevTools?.Vitals
?? interaction.RetainedUi?.Vitals;
AcDream.UI.Abstractions.Panels.Chat.ChatVM? retailChat =
interaction.RetainedUi?.Chat;
var sessionRuntimeFactory = new LiveSessionRuntimeFactory(
new LiveSessionPlayerRuntime(
d.PlayerIdentity,
d.PlayerController,
d.PlayerSkills,
d.WorldOrigin,
d.CharacterOptions,
d.Shortcuts,
d.DesiredComponents),
new LiveSessionDomainRuntime(
d.Objects,
d.LocalPlayer,
d.Spellbook,
d.Combat,
d.ItemMana,
d.Friends,
d.Squelch,
d.TurbineChat,
d.ExternalContainers,
d.Chat),
new LiveSessionUiRuntime(
interaction.RetainedUi?.Runtime,
vitals,
retailChat,
interaction.RetainedUi?.CharacterSheet,
interaction.RetainedUi?.Magic,
live.PaperdollPresenter),
new LiveSessionInteractionRuntime(
d.Settings,
gameplayInput,
playerMode,
playerModeAutoEntry,
interaction.ItemInteraction,
interaction.CombatAttack,
live.SelectionInteractions),
new LiveSessionWorldRuntime(
content.Dats,
live.WorldState,
live.LiveEntities,
sessionEvents,
d.WorldEnvironment,
d.TeleportSink,
spawnClaimClassifier,
live.EquippedChildren,
live.SelectionScene,
d.ParticleVisibility,
d.InboundEntityEvents,
liveness,
networkUpdates,
hydration,
live.RemoteTeleport,
live.EntityEffects,
content.AnimationHookFrames,
live.Presentation,
d.RemoteMovementObservations),
d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession);
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
Action<string>? debugToast = d.SettingsDevTools.DevTools is { } devOwner
? message => devOwner.Debug.AddToast(message)
: null;
var combatCommand = new LiveCombatModeCommandController(
new LiveSessionCombatModeAuthority(sessionHost),
new LocalPlayerCombatEquipmentSource(
d.Objects,
d.PlayerIdentity),
d.Combat,
new ItemInteractionCombatModeIntentSink(
interaction.ItemInteraction),
d.Log,
debugToast,
text => d.Chat.OnSystemMessage(text, 0x1Au));
bindings.Adopt(
"live combat-mode commands",
d.CombatModeCommands.BindOwned(combatCommand));
var nearbyDiagnostics = new NearbyWorldDiagnosticDumper(
new RuntimeNearbyWorldDiagnosticSource(
d.PlayerMode,
d.PlayerController,
host.CameraController,
d.WorldOrigin,
live.WorldState,
d.PhysicsEngine),
d.Log);
var runtimeDiagnostics = new RuntimeDiagnosticCommandController(
d.WorldEnvironment,
d.WorldSceneDebugState,
host.CameraPointerInput,
nearbyDiagnostics,
debugToast);
bindings.Adopt(
"runtime diagnostic commands",
d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics));
Fault(SessionPlayerCompositionPoint.CommandsBound);
CompositionAcquisitionScope.CompositionAcquisitionLease<
GameplayInputActionRouter>? gameplayActionsLease = null;
if (host.InputDispatcher is { } dispatcher)
{
CameraPointerInputController pointer = host.CameraPointerInput
?? throw new InvalidOperationException(
"Gameplay action routing requires the composed pointer owner.");
var commands = new GameplayInputCommandController(
new RetainedGameplayWindowCommands(
interaction.RetainedUi?.Runtime),
new DevToolsGameplayCommands(
d.SettingsDevTools.DevTools?.Presenter),
runtimeDiagnostics,
new PlayerModeGameplayCommands(
d.PlayerMode,
playerMode),
new ItemTargetModeCommands(interaction.ItemInteraction),
new GameplayCameraModeCommands(host.CameraController),
combatCommand,
new GameplayWindowCommands(d.Window.Close));
var targets = new RuntimeGameplayInputPriorityTargets(
gameplayInput,
pointer,
interaction.RetainedUi?.Runtime,
live.SelectionInteractions,
commands);
GameplayInputActionRouter gameplayActions =
GameplayInputActionRouter.Create(
dispatcher,
d.Combat,
targets,
d.HostQuiescence,
d.Log);
gameplayActionsLease = scope.Own(
"gameplay input actions",
gameplayActions,
static value => value.Dispose());
gameplayActions.Attach();
}
Fault(SessionPlayerCompositionPoint.GameplayActionsAttached);
var bindingsLease = scope.Own(
"session/player runtime bindings",
@ -733,12 +907,16 @@ internal sealed class SessionPlayerCompositionPhase
playerMode,
playerModeAutoEntry,
teleportLease.Resource,
sessionHost,
gameplayActionsLease?.Resource,
bindings);
_publication.PublishSessionPlayer(result);
streamerLease.Transfer();
liveSessionLease.Transfer();
teleportLease.Transfer();
gameplayActionsLease?.Transfer();
componentLifecycleLease.Transfer();
bindingsLease.Transfer();
Fault(SessionPlayerCompositionPoint.ResultPublished);
return result;

View file

@ -57,6 +57,24 @@ internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
}
}
public IDisposable BindOwned(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"Runtime diagnostic commands are already bound.");
}
_target = target;
}
return new Binding(this, target);
}
public void Deactivate()
{
lock (_gate)
@ -103,6 +121,23 @@ internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
_target?.ToggleCollisionWireframes();
}
}
private sealed class Binding : IDisposable
{
private RuntimeDiagnosticCommandSlot? _owner;
private readonly IRuntimeDiagnosticCommands _expected;
public Binding(
RuntimeDiagnosticCommandSlot owner,
IRuntimeDiagnosticCommands expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal interface INearbyWorldDiagnosticSource

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;
}

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