ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.
The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.
Three seams, as §5.5.9 specified:
1. Platform acquisition — already generic — publishes GameWindowGraphics
instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
take their Vulkan arm when it is null; each branch names the slice that
removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
The default factory is chosen inside the phase from the platform result.
HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
backbuffer clear pass computing the same RenderFrameFoundation from the same
clock and weather owners, then private presentation over it.
§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.
VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.
One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.
Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.
No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.
Next is V4t, the texture stack, which the world arm cannot be written without.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
394 lines
15 KiB
C#
394 lines
15 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;
|
|
|
|
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);
|
|
}
|
|
|
|
[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,
|
|
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!);
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|