diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 0f0bbb0c..4b15cd0b 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,38 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
+## #387 — Window resize never recreates the Vulkan swapchain: resolution picks stretch the image instead of changing the pixel count
+
+**Status:** DONE 2026-08-13 (this commit) — pending the user's re-check.
+User report (verbatim shape): "It looks like it is doing now is just
+stretching the window, not changing the pixel count when I change the
+resolution." Confirmed real and root-caused the same session.
+
+**ROOT CAUSE — the resize event never reached the swapchain.** Campaign V
+slice V11 deleted the GL `SilkFramebufferViewportTarget` and left
+`NullFramebufferViewportTarget` on the assumption that the driver's
+OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive
+`VulkanGraphicsContext`'s frame-boundary swapchain recreation whenever the
+window resized. That assumption is driver-dependent and spec-insufficient:
+a conformant driver may keep presenting the stale-extent swapchain scaled
+to the new window indefinitely — which is exactly what this machine's
+Windows AMD driver does. Result: `OnFramebufferResize` updated only the
+camera aspect; the swapchain (and every render pass sized from its extent,
+UI included) stayed at the old pixel count and the presentation engine
+stretched it — for Options resolution picks AND manual window-edge drags
+alike. **Fix:** `SwapchainRecreateViewportTarget` (the Vulkan
+implementation of the existing `IFramebufferViewportTarget` seam) arms
+`VulkanGraphicsContext.RequestRecreate()` on every resize event; the next
+`PrepareFrame` rebuilds the swapchain at the live `FramebufferSize`, so
+event bursts collapse into one recreation and stale events cannot install
+a stale extent. Regressed by
+`tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs`
+(target contract + the controller→target end-to-end seam with the
+minimised-gate case). **Re-check:** pick a smaller Resolution — the window
+should shrink AND the image should re-render crisp at the new pixel count
+(UI elements occupy proportionally more of the window, retail-style), not
+scale down blurrily; same for a window-edge drag.
+
## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge
**Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe
diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
index 6a19956d..a96508e8 100644
--- a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
+++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
@@ -26,11 +26,17 @@ internal sealed class VulkanHostInputCameraCompositionFactory
{
public IFramebufferViewportTarget CreateViewportTarget(
GameWindowGraphics graphics) =>
- // The viewport is a pipeline dynamic state on Vulkan, set per pass by
- // the encoder from the pass extent, so there is no persistent viewport
- // to bind here. The framebuffer-resize controller still drives the
- // camera aspect through its own target.
- NullFramebufferViewportTarget.Instance;
+ // The viewport itself is a pipeline dynamic state on Vulkan, set per
+ // pass by the encoder from the pass extent — but the swapchain still
+ // has to be told the framebuffer changed. Relying on the driver's
+ // OUT_OF_DATE/SUBOPTIMAL results alone is spec-insufficient: a
+ // conformant driver (observed: Windows AMD, 2026-08-13 user gate —
+ // "just stretching the window, not changing the pixel count") may keep
+ // presenting the stale-extent swapchain scaled to the new window
+ // forever. The V11 null target assumed the driver signal; this target
+ // arms the context's existing frame-boundary recreation instead, so
+ // the next PrepareFrame recreates at the real FramebufferSize.
+ new SwapchainRecreateViewportTarget(RequireContext(graphics).RequestRecreate);
public GpuFrameFlightController? CreateGpuFrameFlights(
GameWindowGraphics graphics) => null;
@@ -110,22 +116,24 @@ internal sealed class VulkanHostInputCameraCompositionFactory
"Vulkan context.");
///
- /// Vulkan sets the viewport per pass from the pass extent, so there is no
- /// persistent viewport binding for the resize controller to update. Size is
- /// still recorded, because the swapchain recreation the host performs is
- /// what actually resizes the surface.
+ /// The Vulkan implementation of the resize seam (#387): a window resize
+ /// arms so the next
+ /// PrepareFrame rebuilds the swapchain at the current
+ /// FramebufferSize. The width/height arguments are deliberately unused —
+ /// the context re-reads the live framebuffer size at the frame boundary,
+ /// so a burst of resize events collapses into one recreation and a stale
+ /// event can never install a stale extent. Zero-area (minimised) sizes
+ /// never reach this target (
+ /// gates them), and the armed flag survives a minimised skip until a
+ /// non-zero frame runs, which is the context's own existing behavior.
///
- private sealed class NullFramebufferViewportTarget : IFramebufferViewportTarget
+ internal sealed class SwapchainRecreateViewportTarget(Action requestRecreate)
+ : IFramebufferViewportTarget
{
- public static NullFramebufferViewportTarget Instance { get; } = new();
+ private readonly Action _requestRecreate = requestRecreate
+ ?? throw new ArgumentNullException(nameof(requestRecreate));
- private NullFramebufferViewportTarget()
- {
- }
-
- public void ResizeViewport(int width, int height)
- {
- }
+ public void ResizeViewport(int width, int height) => _requestRecreate();
}
///
diff --git a/src/AcDream.App/Rendering/FramebufferResizeController.cs b/src/AcDream.App/Rendering/FramebufferResizeController.cs
index 868370f3..9b962f79 100644
--- a/src/AcDream.App/Rendering/FramebufferResizeController.cs
+++ b/src/AcDream.App/Rendering/FramebufferResizeController.cs
@@ -9,10 +9,15 @@ internal interface IFramebufferViewportTarget
}
// Campaign V slice V11 deleted SilkFramebufferViewportTarget, the GL
-// implementation of IFramebufferViewportTarget: VulkanHostInputCameraComposition
-// Factory's NullFramebufferViewportTarget is the sole surviving implementation
-// (Vulkan's swapchain recreation owns the actual viewport-equivalent resize,
-// which this seam never drove).
+// implementation of IFramebufferViewportTarget, and left a null target on the
+// assumption that the driver's OUT_OF_DATE/SUBOPTIMAL results would drive
+// swapchain recreation on resize. That assumption is driver-dependent — a
+// conformant driver may present a stale-extent swapchain scaled to the new
+// window indefinitely (observed on Windows AMD; #387, the 2026-08-13
+// "resolution pick just stretches" user gate report). The sole implementation
+// is now VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget,
+// which arms the context's frame-boundary swapchain recreation on every
+// resize event.
internal interface IFramebufferCameraTarget
{
diff --git a/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs b/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs
new file mode 100644
index 00000000..2e1c205a
--- /dev/null
+++ b/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs
@@ -0,0 +1,80 @@
+using AcDream.App.Composition;
+using AcDream.App.Input;
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Composition;
+
+///
+/// #387: the V11 null viewport target assumed the driver's OUT_OF_DATE/
+/// SUBOPTIMAL results would drive swapchain recreation on window resize; on
+/// Windows AMD the driver instead presents the stale-extent swapchain scaled
+/// to the new window (the "resolution pick just stretches" 2026-08-13 user
+/// gate report). These pin the replacement target's contract: every resize
+/// event reaching the seam arms the context's frame-boundary recreation.
+///
+public sealed class SwapchainRecreateViewportTargetTests
+{
+ [Fact]
+ public void ResizeViewport_ArmsRecreation_OncePerEvent()
+ {
+ int armed = 0;
+ IFramebufferViewportTarget target =
+ new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
+ () => armed++);
+
+ target.ResizeViewport(1024, 768);
+ Assert.Equal(1, armed);
+
+ // A second resize arms again — the context collapses bursts itself at
+ // the frame boundary (it re-reads the live FramebufferSize), so the
+ // target must never de-duplicate or latch.
+ target.ResizeViewport(800, 600);
+ Assert.Equal(2, armed);
+ }
+
+ [Fact]
+ public void ResizeViewport_IgnoresTheEventSize_ContextReadsLiveFramebuffer()
+ {
+ // The width/height are deliberately unused (doc on the target): the
+ // context reads the CURRENT framebuffer size at the frame boundary,
+ // so a stale event cannot install a stale extent. Pin that the arm
+ // fires regardless of the reported size values.
+ int armed = 0;
+ IFramebufferViewportTarget target =
+ new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
+ () => armed++);
+
+ target.ResizeViewport(int.MaxValue, 1);
+ Assert.Equal(1, armed);
+ }
+
+ [Fact]
+ public void Constructor_RejectsNullRecreateHook()
+ {
+ Assert.Throws(() =>
+ new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(null!));
+ }
+
+ [Fact]
+ public void ResizeController_DeliversResizeEventsToTheBoundTarget()
+ {
+ // The end-to-end seam FramebufferResizeController.Resize →
+ // ResizeViewport → arm: the same event flow GameWindow's
+ // OnFramebufferResize drives in production. Zero-area (minimised)
+ // events are gated by the controller and must NOT arm.
+ int armed = 0;
+ var controller = new FramebufferResizeController(new ViewportAspectState());
+ controller.BindViewport(
+ new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
+ () => armed++));
+
+ controller.Resize(1280, 720);
+ Assert.Equal(1, armed);
+
+ controller.Resize(0, 0); // minimised — gated, no arm
+ Assert.Equal(1, armed);
+
+ controller.Resize(1024, 768);
+ Assert.Equal(2, armed);
+ }
+}