acdream/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
Erik b16f820643 feat(render): Campaign V slice V6h — the Vulkan composition host
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>
2026-07-28 11:47:37 +02:00

463 lines
17 KiB
C#

using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Composition;
public sealed class HostInputCameraCompositionTests
{
[Fact]
public void ProductionPhasePreservesTheCompleteHostInputCameraOrder()
{
using var fixture = new Fixture();
HostInputCameraResult result = fixture.Phase().Compose(fixture.Platform);
Assert.Equal(
Enum.GetValues<HostInputCameraCompositionPoint>(),
fixture.Points);
Assert.Same(fixture.Publication.GpuFrames, result.GpuFrameFlights);
Assert.Same(fixture.Publication.GpuDevice, result.GpuDevice);
Assert.Same(fixture.Publication.Keyboard, result.KeyboardSource);
Assert.Same(fixture.Publication.Mouse, result.MouseSource);
Assert.Same(fixture.Publication.Dispatcher, result.InputDispatcher);
Assert.Same(fixture.Publication.Camera, result.CameraController);
Assert.Same(fixture.Publication.Pointer, result.CameraPointerInput);
Assert.Equal(1280f / 720f, fixture.ViewportAspect.Aspect, precision: 5);
Assert.Equal((1280, 720), fixture.Factory.Viewport.Size);
}
[Theory]
[MemberData(nameof(FaultPointValues))]
public void FailureAtEveryProductionBoundaryStopsTheExactSuffix(
int faultPointValue)
{
var faultPoint = (HostInputCameraCompositionPoint)faultPointValue;
using var fixture = new Fixture(faultPoint);
Assert.Throws<InvalidOperationException>(() =>
fixture.Phase().Compose(fixture.Platform));
HostInputCameraCompositionPoint[] expected =
Enum.GetValues<HostInputCameraCompositionPoint>()
.TakeWhile(point => point <= faultPoint)
.ToArray();
Assert.Equal(expected, fixture.Points);
fixture.Publication.AssertPublishedThrough(faultPoint);
}
public static TheoryData<int> FaultPointValues()
{
var data = new TheoryData<int>();
foreach (HostInputCameraCompositionPoint point in
Enum.GetValues<HostInputCameraCompositionPoint>())
{
data.Add((int)point);
}
return data;
}
[Fact]
public void GameWindowUsesTheExactPlatformPreludeAndPhaseOneType()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("GameWindowPlatformAcquisition.Acquire(", source,
StringComparison.Ordinal);
Assert.Contains("new HostInputCameraCompositionPhase(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_gl = GL.GetApi(_window!)", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_input = _window!.CreateInput()", source,
StringComparison.Ordinal);
}
private sealed class Fixture : IDisposable
{
private readonly HostInputCameraCompositionPoint? _failure;
public Fixture(HostInputCameraCompositionPoint? failure = null)
{
_failure = failure;
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
Input = new InputContext(keyboard, mouse);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, Input);
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();
MovementState = new RuntimeLocalPlayerMovementState();
Movement = new DispatcherMovementInputSource(MovementState, Capture);
CameraInput = new DispatcherCameraInputSource();
PlayerMode = new LocalPlayerModeState();
Chase = new ChaseCameraInputState();
Pointer = new PointerPositionState();
Factory = new Factory();
Publication = new Publication();
}
public List<HostInputCameraCompositionPoint> Points { get; } = [];
public InputContext Input { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> Platform { get; }
public ViewportAspectState ViewportAspect { get; }
public FramebufferResizeController Framebuffer { get; }
public CaptureSource Capture { get; }
public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource Movement { get; }
public DispatcherCameraInputSource CameraInput { get; }
public LocalPlayerModeState PlayerMode { get; }
public ChaseCameraInputState Chase { get; }
public PointerPositionState Pointer { get; }
public Factory Factory { get; }
public Publication Publication { get; }
public HostInputCameraCompositionPhase Phase() => new(
new HostInputCameraDependencies(
Framebuffer,
new Vector2D<int>(1280, 720),
new HostQuiescenceGate(),
Capture,
KeyBindings.RetailDefaults(),
Movement,
CameraInput,
PlayerMode,
Chase,
Pointer,
new DiagnosticLog()),
Publication,
Factory,
point =>
{
Points.Add(point);
if (_failure == point)
throw new InvalidOperationException($"fault at {point}");
});
public void Dispose()
{
Publication.Dispose();
MovementState.Dispose();
}
}
private sealed class Publication :
IGameWindowHostInputCameraPublication,
IDisposable
{
public GpuFrameFlightController? GpuFrames { get; private set; }
public IGpuDevice? GpuDevice { get; private set; }
public GpuDeviceFrameLifetime? GpuFrameLifetime { get; private set; }
public SilkKeyboardSource? Keyboard { get; private set; }
public SilkMouseSource? Mouse { get; private set; }
public IMouseLookCursor? Cursor { get; private set; }
public InputDispatcher? Dispatcher { get; private set; }
public CameraController? Camera { get; private set; }
public CameraPointerInputController? Pointer { get; private set; }
public void PublishGpuFrameFlights(GpuFrameFlightController value) =>
GpuFrames = PublishOnce(GpuFrames, value);
public void PublishGpuDevice(IGpuDevice value) =>
GpuDevice = PublishOnce(GpuDevice, value);
public void PublishGpuFrameLifetime(GpuDeviceFrameLifetime value) =>
GpuFrameLifetime = PublishOnce(GpuFrameLifetime, value);
public void PublishKeyboardSource(SilkKeyboardSource value) =>
Keyboard = PublishOnce(Keyboard, value);
public void PublishMouseSource(SilkMouseSource value) =>
Mouse = PublishOnce(Mouse, value);
public void PublishMouseLookCursor(IMouseLookCursor value) =>
Cursor = PublishOnce(Cursor, value);
public void PublishInputDispatcher(InputDispatcher value) =>
Dispatcher = PublishOnce(Dispatcher, value);
public void PublishCameraController(CameraController value) =>
Camera = PublishOnce(Camera, value);
public void PublishCameraPointerInput(CameraPointerInputController value) =>
Pointer = PublishOnce(Pointer, value);
public void AssertPublishedThrough(HostInputCameraCompositionPoint point)
{
Assert.Equal(
point >= HostInputCameraCompositionPoint.GpuFrameFlightsPublished,
GpuFrames is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.GpuDevicePublished,
GpuDevice is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.KeyboardPublished,
Keyboard is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.MousePublished,
Mouse is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.MouseLookCursorPublished,
Cursor is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.DispatcherPublished,
Dispatcher is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.CameraPublished,
Camera is not null);
Assert.Equal(
point >= HostInputCameraCompositionPoint.CameraPointerPublished,
Pointer is not null);
}
public void Dispose()
{
Pointer?.Dispose();
Pointer = null;
Dispatcher?.Dispose();
Dispatcher = null;
Mouse?.Dispose();
Mouse = null;
Keyboard?.Dispose();
Keyboard = null;
GpuDevice?.Dispose();
GpuDevice = null;
GpuFrames?.Dispose();
GpuFrames = null;
}
private static T PublishOnce<T>(T? current, T value)
where T : class
{
if (current is not null)
throw new InvalidOperationException("duplicate publication");
return value;
}
}
private sealed class Factory : IHostInputCameraCompositionFactory
{
public ViewportTarget Viewport { get; } = new();
private readonly KeyboardSurface _keyboard = new();
private readonly MouseSurface _mouse = new();
private readonly RawPointerSurface _rawPointer = new();
public IFramebufferViewportTarget CreateViewportTarget(
GameWindowGraphics graphics) => Viewport;
public GpuFrameFlightController? CreateGpuFrameFlights(
GameWindowGraphics graphics) =>
new(new FenceApi());
public IGpuDevice CreateGpuDevice(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights) =>
new RecordingGpuDevice();
public IGpuResourceRetirementQueue CreateRetirement(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) => frameFlights!;
public IRenderFrameSlotSource CreateFrameSlots(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) => frameFlights!;
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
GameWindowGraphics graphics,
IRenderFrameDiagnosticLog log) =>
new(new GlStateReader(), log);
public SilkKeyboardSource CreateKeyboardSource(
IKeyboard keyboard,
HostQuiescenceGate quiescence) =>
SilkKeyboardSource.CreateDetached(_keyboard, quiescence);
public SilkMouseSource CreateMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource? keyboard,
HostQuiescenceGate quiescence) =>
SilkMouseSource.CreateDetached(_mouse, capture, keyboard, quiescence);
public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) => new Cursor();
public InputDispatcher CreateInputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
new(new OrbitCamera(), new FlyCamera());
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraTarget(camera);
public CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList<IMouse> mice,
HostQuiescenceGate quiescence,
IInputCaptureSource capture,
LocalPlayerModeState playerMode,
CameraController camera,
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer) =>
new(
[_rawPointer],
new CursorModeTarget(),
quiescence,
capture,
playerMode,
camera,
chase,
mouse,
pointer,
new Clock());
}
private sealed class InputContext(IKeyboard keyboard, IMouse mouse)
: IInputContext
{
public nint Handle => 1;
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
public event Action<IInputDevice, bool>? ConnectionChanged
{
add { }
remove { }
}
public void Dispose() { }
}
public class NullDeviceProxy : DispatchProxy
{
protected override object? Invoke(
MethodInfo? targetMethod,
object?[]? args)
{
Type returnType = targetMethod?.ReturnType ?? typeof(void);
if (returnType == typeof(void))
return null;
if (returnType == typeof(string))
return string.Empty;
return returnType.IsValueType
? Activator.CreateInstance(returnType)
: null;
}
}
private sealed class KeyboardSurface : IKeyboardEventSurface
{
public void AddKeyDown(Action<Key> callback) { }
public void RemoveKeyDown(Action<Key> callback) { }
public void AddKeyUp(Action<Key> callback) { }
public void RemoveKeyUp(Action<Key> callback) { }
public bool IsKeyPressed(Key key) => false;
}
private sealed class MouseSurface : IMouseEventSurface
{
public void AddMouseDown(Action<MouseButton> callback) { }
public void RemoveMouseDown(Action<MouseButton> callback) { }
public void AddMouseUp(Action<MouseButton> callback) { }
public void RemoveMouseUp(Action<MouseButton> callback) { }
public void AddMouseMove(Action<Vector2> callback) { }
public void RemoveMouseMove(Action<Vector2> callback) { }
public void AddScroll(Action<float> callback) { }
public void RemoveScroll(Action<float> callback) { }
public bool IsButtonPressed(MouseButton button) => false;
}
private sealed class RawPointerSurface : IRawPointerSurface
{
public void AddMouseMove(Action<Vector2> callback) { }
public void RemoveMouseMove(Action<Vector2> callback) { }
}
private sealed class CursorModeTarget : IPointerCursorModeTarget
{
public CursorMode CursorMode { get; set; }
}
private sealed class Cursor : IMouseLookCursor
{
public bool HasSavedMode { get; private set; }
public void Hide() => HasSavedMode = true;
public void Restore() => HasSavedMode = false;
}
private sealed class Clock : IInputMonotonicClock
{
public float NowSeconds => 0;
}
private sealed class CaptureSource : IInputCaptureSource
{
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
public bool DevToolsWantCaptureKeyboard => false;
}
private sealed class ViewportTarget : IFramebufferViewportTarget
{
public (int Width, int Height) Size { get; private set; }
public void ResizeViewport(int width, int height) => Size = (width, height);
}
private sealed class CameraTarget(CameraController camera)
: IFramebufferCameraTarget
{
public void SetAspect(float aspect) => camera.SetAspect(aspect);
}
private sealed class FenceApi : IGpuFenceApi
{
public nint Insert() => 1;
public GpuFenceWaitResult Wait(
nint fence,
bool flushCommands,
ulong timeoutNanoseconds) => GpuFenceWaitResult.Signaled;
public void Delete(nint fence) { }
}
private sealed class GlStateReader : IRenderGlStateReader
{
public RenderGlStateSnapshot CaptureState() => default;
public RenderGlScissorSnapshot CaptureScissor() => default;
}
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
{
public void WriteLine(string message) { }
}
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.");
}
}