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

@ -32,9 +32,11 @@ What does NOT go here:
canonical runtime/world, selection/radar, landblock publishers, and
portal/sky/particle resources.
I.7 now owns streaming/session/hydration/local-player/teleport startup and
every late edge explicitly. `GameWindow.OnUpdate` and `GameWindow.OnRender`
are typed orchestration handoffs and the class is 2,479 raw lines. I.8I.9
plus the remaining shutdown/closeout
every late edge explicitly. I.8a now owns the focused session factory,
command targets, and sole gameplay input subscriber before frame publication.
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
handoffs and the class is 2,149 raw lines. I.8bI.9 plus the remaining
shutdown/closeout
cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -734,11 +734,13 @@ owners without callbacks to the window. The old item-use and selection wrapper
methods are deleted. I.7 now owns the complete streaming/session/hydration/
local-player/teleport construction phase and every late edge through named
exact-owner tokens; the former 432-line inline body and spawn-claim memo are
gone. `GameWindow` is 2,479 raw lines after I.7. I.8L remain active, with
atomic frame-root publication and terminal session start next. The App gate
passes 3,420 tests / 3 intentional skips and the complete Release suite passes
7,792 tests / 5 intentional skips. The clean solution build retains only the
17 test-project warnings tracked by #228; the I.7 behavior, architecture, and
gone. I.8a moves the exact live-session reset/router graph, combat and
diagnostic command targets, and sole gameplay input subscriber into Phase 7
before frame publication. `GameWindow` is 2,149 raw lines after I.8a. I.8bL
remain active, with atomic frame-root publication next. The App gate passes
3,424 tests / 3 intentional skips and the complete Release suite passes 7,796
tests / 5 intentional skips. The clean solution build retains only the 17
test-project warnings tracked by #228; the I.8a behavior, architecture, and
adversarial reviews are clean.
### 4.4 Exit criteria

View file

@ -104,10 +104,11 @@ controllers, selection/radar, landblock publishers, and portal/sky/particle
resources have transactional phase ownership and exact-owner late bindings.
I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
including explicit reversible ownership for every late edge. `GameWindow` is
now 2,479 raw lines. I.8I.9, shutdown, canonical soak snapshots, and closeout
checkpoints JL remain. The App gate passes 3,420 tests / 3 intentional skips,
the complete Release suite passes 7,792 tests / 5 skips, and all three I.7
corrected-diff reviews are clean.
now 2,149 raw lines after I.8a, which composes the session factory, command
targets, and sole gameplay input subscriber before frame publication. I.8bI.9,
shutdown, canonical soak snapshots, and closeout checkpoints JL remain. The
App gate passes 3,424 tests / 3 intentional skips, the complete Release suite
passes 7,796 tests / 5 skips, and the I.8a corrected-diff review is clean.
This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -59,11 +59,12 @@ runtime/world, presentation/effect controllers, selection/radar, landblock
publishers, and portal/sky/particle resources have transactional phase
ownership and exact-owner late bindings. I.7 now owns the complete streaming,
session, hydration, local-player, combat, and teleport startup graph, including
named reversible late edges. `GameWindow` is 2,479 raw lines. I.8I.9 and
checkpoints JL remain active. Issue #232 tracks process-residency variance in
the soak without loosening its leak threshold. The App gate passes 3,420 tests
/ 3 skips, the complete Release suite passes 7,792 tests / 5 skips, and all
three I.7 corrected-diff reviews are clean.
named reversible late edges. I.8a adds the focused live-session runtime factory
and attaches command/input owners before frame publication. `GameWindow` is
2,149 raw lines. I.8bI.9 and checkpoints JL remain active. Issue #232 tracks
process-residency variance in the soak without loosening its leak threshold.
The App gate passes 3,424 tests / 3 skips, the complete Release suite passes
7,796 tests / 5 skips, and the I.8a corrected-diff review is clean.
Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -293,6 +293,10 @@ ledger only after the complete pipeline is live.
frame-root publication and terminal session start is the active cut. Its
detailed session-binding, atomic-root, and terminal-start plan is
[`2026-07-22-gamewindow-slice-8-checkpoint-i8-frame-roots-session-start.md`](2026-07-22-gamewindow-slice-8-checkpoint-i8-frame-roots-session-start.md).
I.8a is complete: the session factory, command targets, and sole gameplay
input subscriber now belong to Phase 7 and publish before any frame root;
`GameWindow.cs` is 2,149 raw lines. I.8b atomic frame-root composition is the
active cut.
Checkpoint I remains active until I.9 closes the complete nine-phase pipeline.
## 6. Automated acceptance

View file

@ -1,6 +1,6 @@
# GameWindow Slice 8 — Checkpoint I.8 Frame Roots and Session Start
**Status:** Active
**Status:** Active — I.8a complete; I.8b frame-root composition next
**Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`
@ -27,6 +27,15 @@ unchanged, so no divergence-register row is added.
## 2. I.8a — complete session/player composition
**Completed 2026-07-22.** `LiveSessionRuntimeFactory` now owns the exact
reset/router/command graph without a window reference. Phase 7 publishes the
session host, exact-owner combat and diagnostic command bindings, and the sole
gameplay input subscriber before Phase 8 can expose a frame root. Desired
components have a focused canonical snapshot owner. The component-lifecycle
handoff into the earlier live-presentation phase is an exact, retryable
adoption lease, so any later Phase-7 fault rolls it back without stranding or
replaying teardown.
### 2.1 Focused session runtime factory
Add `LiveSessionRuntimeFactory` under `AcDream.App/Net`. It owns the existing

View file

@ -29,8 +29,8 @@ audit.
- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
explicit single ownership and transfer seams.
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
transactional partial-acquisition rollback. I.1I.7 are complete; I.8
frame-root publication and terminal session start is active. Detailed plan:
transactional partial-acquisition rollback. I.1I.7 and I.8a are complete;
I.8b atomic frame-root publication is active. Detailed plan:
[`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md).
- [ ] J — move the exact retryable shutdown manifest to a focused lifetime
owner and prove all partial-load/reentrant/retry paths.

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

View file

@ -122,6 +122,25 @@ public sealed class LiveCombatModeCommandControllerTests
Assert.Throws<ObjectDisposedException>(() => slot.Bind(target));
}
[Fact]
public void DeferredSlotOwnedBindingReleasesExactlyAndAllowsRebind()
{
var slot = new LiveCombatModeCommandSlot();
var first = new FakeCommand();
var second = new FakeCommand();
IDisposable firstBinding = slot.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = slot.BindOwned(second);
firstBinding.Dispose();
slot.Toggle();
Assert.Equal(0, first.ToggleCount);
Assert.Equal(1, second.ToggleCount);
}
private sealed class Harness
{
public Harness()

View file

@ -15,6 +15,23 @@ namespace AcDream.App.Tests.Composition;
public sealed class InteractionUiRuntimeSourcesTests
{
[Fact]
public void DesiredComponentSnapshotNormalizesNullAndClearsExactly()
{
var state = new DesiredComponentSnapshotState();
IReadOnlyList<(uint Id, uint Amount)> items =
[(0x01020304u, 7u)];
Assert.Empty(state.Items);
state.Items = items;
Assert.Same(items, state.Items);
state.Clear();
Assert.Empty(state.Items);
state.Items = null!;
Assert.Empty(state.Items);
}
[Fact]
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
{

View file

@ -31,6 +31,24 @@ public sealed class LivePresentationCompositionTests
bindings.Adopt("late", new RetryBinding("late", calls, 0)));
}
[Fact]
public void TransferableAdoptionRollsBackExactlyAndRetriesFailure()
{
var calls = new List<string>();
var bindings = new LivePresentationRuntimeBindings();
bindings.Adopt("stable", new RetryBinding("stable", calls, 0));
IDisposable adoption = bindings.AdoptOwned(
"candidate",
new RetryBinding("candidate", calls, 1));
Assert.Throws<InvalidOperationException>(adoption.Dispose);
adoption.Dispose();
adoption.Dispose();
bindings.Dispose();
Assert.Equal(["candidate", "candidate", "stable"], calls);
}
[Fact]
public void CanonicalRuntimeSlotUsesExactOwnerBinding()
{

View file

@ -116,7 +116,19 @@ public sealed class SessionPlayerCompositionTests
"new PlayerModeController(",
"d.PortalTunnelFallback.Transfer(",
"d.TeleportSink.BindOwned(localTeleport)",
"sessionRuntimeFactory.Create(liveSession)",
"d.CombatModeCommands.BindOwned(combatCommand)",
"d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)",
"GameplayInputActionRouter.Create(",
"gameplayActions.Attach();",
"_publication.PublishSessionPlayer(result);");
Assert.DoesNotContain("GameWindow", File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Net",
"LiveSessionRuntimeFactory.cs")), StringComparison.Ordinal);
}
private sealed class RetryBinding(

View file

@ -108,6 +108,25 @@ public sealed class RuntimeDiagnosticCommandControllerTests
Assert.Throws<ObjectDisposedException>(() => slot.Bind(target));
}
[Fact]
public void DeferredSlotOwnedBindingReleasesExactlyAndAllowsRebind()
{
var slot = new RuntimeDiagnosticCommandSlot();
var first = new FakeCommands();
var second = new FakeCommands();
IDisposable firstBinding = slot.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = slot.BindOwned(second);
firstBinding.Dispose();
slot.CycleWeather();
Assert.Equal(0, first.CallCount);
Assert.Equal(1, second.CallCount);
}
[Fact]
public void NullToast_DoesNotSuppressDiagnosticStateChanges()
{

View file

@ -47,7 +47,7 @@ public sealed class GameWindowSlice8BoundaryTests
{
string body = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
"private void OnUpdate(double dt)");
AssertAppearsInOrder(
body,
@ -64,16 +64,29 @@ public sealed class GameWindowSlice8BoundaryTests
"LivePresentationResult livePresentation =",
"new LivePresentationCompositionPhase(",
"this).Compose(",
"new SessionPlayerCompositionPhase(",
"var renderFrameOrchestrator =",
"var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);",
"_liveSessionHost = CreateLiveSessionHost();",
"_liveCombatModeCommands.Bind(combatCommand);",
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);",
"_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(",
"_gameplayInputActions.Attach();",
"_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
"sessionPlayer.SessionHost.Start(_options)");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
sessionPhase,
"sessionRuntimeFactory.Create(liveSession)",
"d.CombatModeCommands.BindOwned(combatCommand)",
"d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)",
"GameplayInputActionRouter.Create(",
"gameplayActions.Attach();",
"_publication.PublishSessionPlayer(result);");
Assert.Equal(1, CountOccurrences(
body,
"sessionPlayer.SessionHost.Start(_options)"));
string phaseOne = Slice(
File.ReadAllText(Path.Combine(
FindRepoRoot(),
@ -93,7 +106,9 @@ public sealed class GameWindowSlice8BoundaryTests
"if (firstKeyboard is not null && firstMouse is not null)",
phaseOne,
StringComparison.Ordinal);
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
int start = body.IndexOf(
"sessionPlayer.SessionHost.Start(_options)",
StringComparison.Ordinal);
string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
@ -113,10 +128,17 @@ public sealed class GameWindowSlice8BoundaryTests
string source = GameWindowSource();
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
string sessionFactory = MethodBody(
"private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(",
"private AcDream.App.Net.LiveInventorySessionBindings");
"private void OnUpdate(double dt)");
string sessionFactorySource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Net",
"LiveSessionRuntimeFactory.cs"));
string sessionFactory = Slice(
sessionFactorySource,
"private LiveSessionEventRouter CreateEventRouter(",
"private LiveInventorySessionBindings CreateInventoryBindings()");
string worldPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -143,9 +165,9 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.Contains("environment.Initialize(region);", worldPhase, StringComparison.Ordinal);
AssertAppearsInOrder(
sessionFactory,
"new AcDream.App.Net.LiveEnvironmentSessionSink(",
"_worldEnvironment.ApplyAdminEnvirons,",
"_worldEnvironment.SynchronizeFromServer)");
"new LiveEnvironmentSessionSink(",
"_world.Environment.ApplyAdminEnvirons,",
"_world.Environment.SynchronizeFromServer)");
Assert.DoesNotContain("_loadedSkyDesc", source, StringComparison.Ordinal);
Assert.DoesNotContain("_loadedSkyDayIndex", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal);
@ -164,10 +186,16 @@ public sealed class GameWindowSlice8BoundaryTests
"debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;",
"debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;",
"debugVm.ToggleCollisionWires =");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
load,
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
sessionPhase,
"new RuntimeDiagnosticCommandController(",
"d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)");
}
[Fact]
@ -176,7 +204,13 @@ public sealed class GameWindowSlice8BoundaryTests
string source = GameWindowSource();
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
"private void OnUpdate(double dt)");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
string shutdown = MethodBody(
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
@ -187,9 +221,11 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.DoesNotContain("private void OnUiDragReleasedOutside(", source, StringComparison.Ordinal);
Assert.DoesNotContain("DragReleasedOutsideUi +=", source, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences(
load,
"AcDream.App.Input.GameplayInputActionRouter.Create("));
Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();"));
sessionPhase,
"GameplayInputActionRouter.Create("));
Assert.Equal(1, CountOccurrences(
sessionPhase,
"gameplayActions.Attach();"));
Assert.Equal(1, CountOccurrences(
LivePresentationSource(),
"RetainedUiGameplayBinding.Create("));
@ -214,7 +250,7 @@ public sealed class GameWindowSlice8BoundaryTests
{
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
"private void OnUpdate(double dt)");
string body = MethodBody(
"private void OnFramebufferResize(",
"private void OnClosing()");
@ -251,7 +287,7 @@ public sealed class GameWindowSlice8BoundaryTests
string run = MethodBody("public void Run()", "private void OnLoad()");
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
"private void OnUpdate(double dt)");
string shutdown = MethodBody(
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
@ -295,7 +331,7 @@ public sealed class GameWindowSlice8BoundaryTests
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"new WorldRenderCompositionPhase(",
"new SessionPlayerCompositionPhase(",
"_liveSessionHost.Start(_options)");
"sessionPlayer.SessionHost.Start(_options)");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -464,7 +500,7 @@ public sealed class GameWindowSlice8BoundaryTests
string source = GameWindowSource();
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
"private void OnUpdate(double dt)");
string shutdown = Slice(
source,
"private ResourceShutdownTransaction CreateShutdownTransaction()",

View file

@ -179,9 +179,9 @@ public sealed class GameWindowLiveEntityCompositionTests
public void SessionReset_ClosesEveryStreamingReadinessOwner()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
FindRepoRoot(), "src", "AcDream.App", "Net", "LiveSessionRuntimeFactory.cs"));
Assert.Contains("_playerModeController?.ResetSession();", source,
Assert.Contains("_interaction.PlayerMode.ResetSession();", source,
StringComparison.Ordinal);
string playerModeSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
@ -192,10 +192,10 @@ public sealed class GameWindowLiveEntityCompositionTests
Assert.Contains("_mode.ResetSession();", playerModeSource,
StringComparison.Ordinal);
Assert.Contains(
"_liveEntityNetworkUpdates?.ResetSessionState();",
"_world.NetworkUpdates.ResetSessionState();",
source,
StringComparison.Ordinal);
Assert.Contains("_liveWorldOrigin.Reset();", source, StringComparison.Ordinal);
Assert.Contains("_player.WorldOrigin.Reset();", source, StringComparison.Ordinal);
}
private static string FindRepoRoot()

View file

@ -807,6 +807,12 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
string sessionRuntime = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Net",
"LiveSessionRuntimeFactory.cs"));
Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source,
StringComparison.Ordinal);
Assert.DoesNotContain("LoginWorldReady", source,
@ -823,7 +829,11 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
StringComparison.Ordinal);
Assert.Contains("Live entity session routing was not composed.", source,
Assert.DoesNotContain("CreateLiveSessionEventRouter", source,
StringComparison.Ordinal);
Assert.Contains("_world.EntitySession.CreateSink()", sessionRuntime,
StringComparison.Ordinal);
Assert.DoesNotContain("GameWindow", sessionRuntime,
StringComparison.Ordinal);
}