diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 52801566..90c2fb0f 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,36 @@ 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.
+## #424 — Client crashed on alt-tab out of exclusive fullscreen: zero-area frame reached `RenderPackActivationExtent.Validate`
+
+**Status:** ✅ FIXED 2026-08-23 (same session it was found — the owner's VM6/VM3 gate launch).
+**Component:** rendering / frame orchestration (host minimised-window guard)
+
+**Symptom:** exclusive fullscreen 2560×1440, High pack, alt-tab to the desktop →
+`Unhandled exception. System.ArgumentOutOfRangeException: Activation extent must be
+positive. (Parameter 'Width')` at `RenderPackController.ApplyAtFrameBoundary`
+← `VulkanWorldScenePhase.Render` ← `GameWindow.OnRender`. Log:
+`artifacts/owner-gate/launch.err`.
+
+**Root cause:** GLFW auto-iconifies an exclusive-fullscreen window on focus
+loss. `GameWindow.OnRender` guards minimised windows through
+`VulkanGraphicsContext.PrepareFrame()`, but that only consults a *pending*
+recreate / swapchain existence — for the one frame between iconify and the
+present that reports OUT_OF_DATE, `_window.Size` already reads 0×0 while
+the swapchain is still "created", so a zero-area `RenderFrameInput` went
+down the full pipeline. Pre-campaign the retail path tolerated a 0×0
+viewport silently; Campaign AR's pack controller (correctly) refuses a
+zero activation extent, turning the latent zero-area frame into a crash.
+
+**Fix:** `RenderFrameOrchestrator.Render` returns
+`RenderFrameOutcome.ZeroArea` (flag `SkippedZeroArea`) BEFORE
+`BeginFrame` when either viewport dimension is ≤ 0 — no GPU frame, phase,
+measurement, diagnostics or recovery runs — and `GameWindow.OnRender` skips
+`NoteFrameClosed` for such a frame. Test:
+`RenderFrameOrchestratorTests.ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork`
+(four extents). Not a workaround: a window with no area has nothing to
+render, and the rule now lives at the one seam every frame passes through.
+
## #423 — Atmospheric ray/shadow/volumetric day-group policy keys on the raw day-group index, not the DAT-classified WeatherKind
**Status:** OPEN — filed 2026-08-23 at Campaign VM VM6 (spot-check); pre-existing from Campaign AR.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 29d16fdd..93f8da2d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1748,6 +1748,7 @@ public sealed class GameWindow :
_renderLoopArmed = false;
return;
}
+ AcDream.App.Rendering.RenderFrameOutcome outcome;
try
{
_frameGraphs.Render(
@@ -1755,7 +1756,7 @@ public sealed class GameWindow :
deltaSeconds,
size.X,
size.Y),
- out _);
+ out outcome);
}
catch (AcDream.App.Rendering.Gpu.Vk.VulkanSwapchainOutOfDateException)
when (_vulkanGraphics is not null)
@@ -1765,7 +1766,11 @@ public sealed class GameWindow :
return;
}
- _vulkanGraphics?.NoteFrameClosed();
+ // A zero-area frame (window iconified between the swapchain check
+ // above and the size read — see RenderFrameOutcome.SkippedZeroArea)
+ // opened no GPU frame, so there is no present outcome to note.
+ if (!outcome.SkippedZeroArea)
+ _vulkanGraphics?.NoteFrameClosed();
_renderLoopArmed = false;
}
diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs
index df5f0e0a..67e97c04 100644
--- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs
+++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs
@@ -27,9 +27,23 @@ internal readonly record struct PrivatePresentationFrameOutcome(
bool ScreenshotCaptured);
/// The immutable result observed by post-screenshot diagnostics.
+///
+/// True when the frame was never opened because the viewport had no area
+/// (Campaign VM VM7 owner gate, 2026-08-23: alt-tabbing out of exclusive
+/// fullscreen auto-iconifies the GLFW window, so for one frame the window
+/// size reads 0x0 while the swapchain is still "created" and the host's
+/// minimised guard has not yet armed; the zero-area frame then reached
+/// RenderPackActivationExtent.Validate and crashed the client). No
+/// GPU frame, measurement, phase, diagnostics or recovery ran for such a
+/// frame, so the host must not note it as closed.
+///
internal readonly record struct RenderFrameOutcome(
WorldRenderFrameOutcome World,
- PrivatePresentationFrameOutcome Presentation);
+ PrivatePresentationFrameOutcome Presentation,
+ bool SkippedZeroArea = false)
+{
+ internal static RenderFrameOutcome ZeroArea => new(default, default, SkippedZeroArea: true);
+}
internal interface IRenderFrameLifetime
{
@@ -191,6 +205,13 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
public RenderFrameOutcome Render(RenderFrameInput input)
{
+ // A window with no area has nothing to render and no swapchain to
+ // render into; decide that BEFORE a GPU frame is opened so nothing
+ // downstream (the render-pack activation extent, the world target,
+ // the presentation viewport) ever sees a zero extent.
+ if (input.ViewportWidth <= 0 || input.ViewportHeight <= 0)
+ return RenderFrameOutcome.ZeroArea;
+
_lifetime.BeginFrame();
Exception? renderFailure = null;
try
diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs
index 0a23f6ac..30fe2144 100644
--- a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs
@@ -59,6 +59,31 @@ public sealed class RenderFrameOrchestratorTests
Assert.Equal(outcome, phases.ObservedOutcome);
}
+ [Theory]
+ [InlineData(0, 1080)]
+ [InlineData(1920, 0)]
+ [InlineData(0, 0)]
+ [InlineData(-1, 1080)]
+ public void ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork(int width, int height)
+ {
+ // Campaign VM VM7 owner gate (2026-08-23): alt-tabbing out of
+ // exclusive fullscreen auto-iconifies the window; for one frame the
+ // size reads 0x0 while the swapchain is still current, and the
+ // zero-area frame crashed the client in
+ // RenderPackActivationExtent.Validate. The rule lives here, before
+ // BeginFrame, so no phase, measurement, diagnostics or recovery can
+ // observe a zero extent, and the outcome says so for the host.
+ var calls = new List();
+ var phases = new RecordingPhases(calls);
+
+ RenderFrameOutcome outcome = Create(phases).Render(
+ Input with { ViewportWidth = width, ViewportHeight = height });
+
+ Assert.True(outcome.SkippedZeroArea);
+ Assert.Empty(calls);
+ Assert.Empty(phases.ObservedInputs);
+ }
+
[Fact]
public void BeginFailure_DoesNotAttemptAnyPhaseOrClose()
{