Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and files AD-110 (the composed handoff edge) — register rows in this commit. Retail derivation (named decomp): - gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into CPlayerSystem::LogOffCharacter(0) when grounded (transient_state & CONTACT); the grounded three-way branch now also covers the indicator-bar end-session control (it was Options-only). - CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the existing pre-logoff flush hook), then RequestLogOff @0x00562DD0: 'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter @0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and CommandInterpreter::HandleLogOff @0x006B3330 -> Disable. - The log-off ANIMATION is server-driven: ACE broadcasts MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands) and it plays on the local player through the existing inbound unpack_movement funnel during the 3 s hold — retail plays nothing locally; Disable() is the whole client-side effect. - gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed -> BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue @0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel plays the SAME forward 40 fps animation; nothing renders backwards, and NO exit cue ever fires on logout (the char-select swap preempts the TunnelContinue/FadeOut tail). - Inbound 0xF653 echo (dispatch case 3 @0x0055C963) -> ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION KEPT (ExitWorldDisconnect @0x00541E00 removes every connection except logonRecID_ — one connection against ACE) and Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in the same batch re-shows character management (gmGamePlayUI::Update @0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it: SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList + ServerName >=6 s after the request and leaves the session AuthConnected — a second EnterWorld needs no re-handshake. Implementation: - RuntimeWorldTransitState: the canonical logout lifecycle (Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds, cancel/reset/ownership convergence). - WorldSession: RequestCharacterLogOff (non-blocking 0xF653), IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld -> InCharacterSelect + game-action sequence reset; transport untouched). - LiveSessionController: BeginCharacterLogOff (flush-first request) and CompleteCharacterLogOff — the return-to-selection transaction (ReconnectCore minus the transport swap: retire the world generation's routes, host reset, state flip, fresh generation re-bind, roster re-applied from the pushed CharacterList; failures degrade to the full StopCore teardown). - RuntimeLocalPlayerMovementState.DisableCommandInterpreter + DispatcherMovementInputSource gate: retail's Disable() — held keys produce no movement while the server LogOut motion plays; cleared by the generation reset. - LocalPlayerTeleportController: the logout pump as the third arm of the one wormhole machine (request/hold/wormhole/confirmed handoff; teleport starts refused during logout; the handoff runs the session transaction whose world reset retires the tunnel as the fresh selection state re-shows the character screen). - UI: both end-session surfaces share the retail three-way grounded gate and now run the REAL flow; Options' Exit Game keeps the app exit (window close -> the existing graceful-shutdown logoff). Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump. Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3 + 11 this round), Core.Net 1004/0, full solution green (0 failures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
436 lines
17 KiB
C#
436 lines
17 KiB
C#
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Spells;
|
|
using AcDream.Content;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionRetainedUiCompositionTests
|
|
{
|
|
private static readonly InteractionRetainedUiCompositionPoint[] UiPoints =
|
|
[
|
|
InteractionRetainedUiCompositionPoint.UiHostAcquired,
|
|
InteractionRetainedUiCompositionPoint.InputCaptureBound,
|
|
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
|
|
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
|
|
InteractionRetainedUiCompositionPoint.MouseInputWired,
|
|
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
|
|
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
|
|
InteractionRetainedUiCompositionPoint.UiProbeCreated,
|
|
InteractionRetainedUiCompositionPoint.UiRuntimeMounted,
|
|
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
|
|
];
|
|
|
|
[Fact]
|
|
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true);
|
|
|
|
InteractionRetainedUiResult result = fixture.Compose();
|
|
|
|
Assert.Same(result, fixture.Publication.Result);
|
|
Assert.Equal(
|
|
[
|
|
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
|
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
|
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
|
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
|
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
|
.. UiPoints,
|
|
InteractionRetainedUiCompositionPoint.ResultPublished,
|
|
], fixture.Points);
|
|
Assert.NotNull(result.RetainedUi);
|
|
Assert.NotNull(result.Magic);
|
|
Assert.Empty(fixture.Factory.Releases);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisabledUiAcquiresNoRetainedFrontendResource()
|
|
{
|
|
using var fixture = new Fixture(retailUi: false);
|
|
|
|
InteractionRetainedUiResult result = fixture.Compose();
|
|
|
|
Assert.Null(result.RetainedUi);
|
|
Assert.NotNull(result.Magic);
|
|
Assert.Equal(
|
|
[
|
|
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
|
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
|
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
|
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
|
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
|
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
|
|
InteractionRetainedUiCompositionPoint.ResultPublished,
|
|
], fixture.Points);
|
|
Assert.Equal(0, fixture.Factory.RetainedUiCalls);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(EnabledFailurePoints))]
|
|
public void FaultAtEveryEnabledBoundaryStopsSuffixAndRollsBackUnpublishedPrefix(
|
|
int pointValue)
|
|
{
|
|
var point = (InteractionRetainedUiCompositionPoint)pointValue;
|
|
using var fixture = new Fixture(retailUi: true, failurePoint: point);
|
|
|
|
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
|
|
|
Assert.Equal(point, fixture.Points[^1]);
|
|
Assert.False(
|
|
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
|
|
if (point == InteractionRetainedUiCompositionPoint.ResultPublished)
|
|
{
|
|
Assert.Empty(fixture.Factory.Releases);
|
|
Assert.NotNull(fixture.Publication.Result);
|
|
}
|
|
else
|
|
{
|
|
Assert.Null(fixture.Publication.Result);
|
|
Assert.Equal(ExpectedRollback(point), fixture.Factory.Releases);
|
|
}
|
|
}
|
|
|
|
public static TheoryData<int> EnabledFailurePoints()
|
|
{
|
|
var data = new TheoryData<int>();
|
|
foreach (InteractionRetainedUiCompositionPoint point in
|
|
Enum.GetValues<InteractionRetainedUiCompositionPoint>())
|
|
{
|
|
if (point != InteractionRetainedUiCompositionPoint.RetainedUiDisabled)
|
|
data.Add((int)point);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
private static string[] ExpectedRollback(
|
|
InteractionRetainedUiCompositionPoint point)
|
|
{
|
|
var acquired = new List<string> { "late bindings" };
|
|
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
|
|
acquired.Add("external container");
|
|
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
|
|
acquired.Add("item interaction");
|
|
if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
|
|
acquired.Add("magic runtime");
|
|
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
|
|
acquired.Add("retained UI lease");
|
|
acquired.Reverse();
|
|
return acquired.ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicationFailureRollsBackCompleteUnpublishedPrefix()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true, publicationFailure: true);
|
|
|
|
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
|
Assert.False(
|
|
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"retained UI lease",
|
|
"magic runtime",
|
|
"item interaction",
|
|
"external container",
|
|
"late bindings",
|
|
], fixture.Factory.Releases);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Consolidated-review round (2026-08-10), SHOULD-FIX 2: the a5a7eb4f
|
|
/// defect class — a composed <see cref="ChatVM.OnInterfaceText"/> hook
|
|
/// wired but never transferred to production — had no test, because
|
|
/// <see cref="FakeFactory.CreateRetainedUi"/> above substitutes a
|
|
/// <see cref="Stub{T}"/> <see cref="ChatVM"/> for the composition-order
|
|
/// tests and never exercises the REAL wiring in
|
|
/// <see cref="RetailInteractionRetainedUiCompositionFactory.CreateChatViewModel"/>.
|
|
/// This test calls that production method directly (it only touches
|
|
/// <see cref="InteractionRetainedUiDependencies.Communication"/>, no
|
|
/// GPU/dat/UiHost dependencies, so the existing null-heavy
|
|
/// <see cref="Fixture"/> dependencies are sufficient without a full
|
|
/// <see cref="Fixture.Compose"/>) and proves the hook both EXISTS and
|
|
/// actually reaches <see cref="RuntimeCommunicationState.SpewBox"/>,
|
|
/// not merely that it was assigned.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComposedChatViewModelWiresOnInterfaceTextToSpewBox()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true);
|
|
|
|
ChatVM chat = RetailInteractionRetainedUiCompositionFactory
|
|
.CreateChatViewModel(fixture.Dependencies);
|
|
|
|
Assert.NotNull(chat.OnInterfaceText);
|
|
|
|
const string probeText = "consolidated-review SHOULD-FIX 2 probe";
|
|
chat.ShowInterfaceText(probeText);
|
|
// ShowInterfaceText only enqueues; SpewBoxState decouples enqueue
|
|
// from visibility (see its own class remarks) -- Tick drains it.
|
|
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Tick(0);
|
|
|
|
Assert.Contains(
|
|
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Snapshot(),
|
|
entry => entry.Text == probeText);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindowUsesPhaseAndContainsNoRetainedUiConstructionBody()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Contains("new InteractionRetainedUiCompositionPhase(", source);
|
|
Assert.DoesNotContain("new AcDream.App.UI.ItemInteractionController(", source);
|
|
Assert.DoesNotContain("_retailUiLease.AcquireHost(", source);
|
|
Assert.DoesNotContain("RetailUiRuntime.CreateUninitialized(", source);
|
|
Assert.DoesNotContain("private void UseItemByGuid(", source);
|
|
Assert.DoesNotContain("private uint? PickWorldGuidAtCursor(", source);
|
|
}
|
|
|
|
private sealed class Fixture : IDisposable
|
|
{
|
|
private readonly InteractionRetainedUiCompositionPoint? _failurePoint;
|
|
|
|
public Fixture(
|
|
bool retailUi,
|
|
InteractionRetainedUiCompositionPoint? failurePoint = null,
|
|
bool publicationFailure = false)
|
|
{
|
|
_failurePoint = failurePoint;
|
|
Factory = new FakeFactory();
|
|
Publication = new Publication(publicationFailure);
|
|
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
|
|
with { RetailUi = retailUi };
|
|
GameRuntime runtime = GameRuntimeTestFactory.Create(
|
|
new NoopCombatOperations(),
|
|
new NoopCombatTargetOperations(),
|
|
new NoopCombatModeOperations(),
|
|
new NoopSpellOperations());
|
|
Dependencies = new InteractionRetainedUiDependencies(
|
|
Options: options,
|
|
Graphics: null!,
|
|
BackbufferReader: static (_, _) => [],
|
|
Window: null!,
|
|
Input: null!,
|
|
ShadersDirectory: "shaders",
|
|
Dats: null!,
|
|
DatLock: new object(),
|
|
TextureCache: null!,
|
|
DebugFont: null,
|
|
HostQuiescence: null!,
|
|
RetainedInputCapture: null!,
|
|
InputDispatcher: null,
|
|
TeleportSink:
|
|
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
|
|
KeyBindingsFilePath: "keybinds.json",
|
|
Settings: null!,
|
|
Runtime: runtime,
|
|
CombatAttackOperations: new NoopCombatOperations(),
|
|
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
|
|
SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
|
|
MagicCatalog: null!,
|
|
StackSplitQuantity: null!,
|
|
UiRegistry: null,
|
|
CombatModeCommands: null!,
|
|
PlayerIdentity: null!,
|
|
PlayerMode: null!,
|
|
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
|
|
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
|
|
ExistingVitals: null,
|
|
Toast: null,
|
|
ClientTime: static () => 0d,
|
|
Log: static _ => { },
|
|
GpuDevice: null!,
|
|
GpuFrameSource: null!,
|
|
CurrentCalendar: static () => default);
|
|
}
|
|
|
|
public InteractionRetainedUiDependencies Dependencies { get; }
|
|
public FakeFactory Factory { get; }
|
|
public Publication Publication { get; }
|
|
public List<InteractionRetainedUiCompositionPoint> Points { get; } = [];
|
|
|
|
public InteractionRetainedUiResult Compose() =>
|
|
new InteractionRetainedUiCompositionPhase(
|
|
Dependencies,
|
|
new RetailUiRuntimeLease(),
|
|
Publication,
|
|
Factory,
|
|
point =>
|
|
{
|
|
Points.Add(point);
|
|
if (_failurePoint == point)
|
|
throw new InvalidOperationException($"fault at {point}");
|
|
}).Compose();
|
|
|
|
public void Dispose() => Dependencies.Runtime.Dispose();
|
|
}
|
|
|
|
private sealed class FakeFactory : IInteractionRetainedUiCompositionFactory
|
|
{
|
|
private readonly Dictionary<object, string> _names =
|
|
new(ReferenceEqualityComparer.Instance);
|
|
|
|
public List<string> Releases { get; } = [];
|
|
public int RetainedUiCalls { get; private set; }
|
|
|
|
public IDisposable BindCombatTarget(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredSelectionUiAuthority selection) =>
|
|
new NoopDisposable();
|
|
|
|
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredLiveSessionUiAuthority session) =>
|
|
Resource<ExternalContainerLifecycleController>("external container");
|
|
|
|
public ItemInteractionController CreateItemInteraction(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings) =>
|
|
Resource<ItemInteractionController>("item interaction");
|
|
|
|
public MagicRuntime CreateMagicRuntime(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
ItemInteractionController itemInteraction) =>
|
|
Resource<MagicRuntime>("magic runtime");
|
|
|
|
public RetainedUiComposition CreateRetainedUi(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
RetailUiRuntimeLease lease,
|
|
RuntimeCombatAttackState combatAttack,
|
|
ItemInteractionController itemInteraction,
|
|
MagicRuntime magic,
|
|
Action<InteractionRetainedUiCompositionPoint> checkpoint)
|
|
{
|
|
RetainedUiCalls++;
|
|
_names.Add(lease, "retained UI lease");
|
|
foreach (InteractionRetainedUiCompositionPoint point in UiPoints)
|
|
checkpoint(point);
|
|
return new RetainedUiComposition(
|
|
Stub<UiHost>(),
|
|
Stub<RetailUiRuntime>(),
|
|
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
|
|
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
|
|
Stub<CharacterSheetProvider>(),
|
|
null);
|
|
}
|
|
|
|
public void Release(IDisposable resource)
|
|
{
|
|
string name = _names.TryGetValue(resource, out string? found)
|
|
? found
|
|
: resource switch
|
|
{
|
|
InteractionUiLateBindings => "late bindings",
|
|
RetailUiRuntimeLease => "retained UI lease",
|
|
_ => throw new InvalidOperationException(
|
|
$"Unknown test resource {resource.GetType().Name}"),
|
|
};
|
|
Releases.Add(name);
|
|
}
|
|
|
|
private T Resource<T>(string name) where T : class
|
|
{
|
|
T value = Stub<T>();
|
|
_names.Add(value, name);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
private sealed class NoopCombatOperations
|
|
: IRuntimeCombatAttackOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest() { }
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack() { }
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
}
|
|
|
|
private sealed class NoopDisposable : IDisposable
|
|
{
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class NoopSpellOperations : IRuntimeSpellCastOperations
|
|
{
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
|
|
private sealed class NoopCombatTargetOperations
|
|
: IRuntimeCombatTargetOperations
|
|
{
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
}
|
|
|
|
private sealed class NoopCombatModeOperations
|
|
: IRuntimeCombatModeOperations
|
|
{
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(CombatMode mode) { }
|
|
}
|
|
|
|
private sealed class Publication(bool fail)
|
|
: IGameWindowInteractionRetainedUiPublication
|
|
{
|
|
public InteractionRetainedUiResult? Result { get; private set; }
|
|
|
|
public void PublishInteractionRetainedUi(InteractionRetainedUiResult result)
|
|
{
|
|
if (fail)
|
|
throw new InvalidOperationException("publication failed");
|
|
Result = result;
|
|
}
|
|
}
|
|
|
|
private static T Stub<T>() where T : class =>
|
|
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|