From 38def07edb84ca4bb71ce609b81d63573e82c89e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 09:49:56 +0200 Subject: [PATCH] fix #424: a zero-area viewport skips the frame before any GPU work (alt-tab out of exclusive fullscreen crashed the pack-on client) GLFW auto-iconifies an exclusive-fullscreen window on focus loss; for one frame the window size reads 0x0 while the swapchain is still created, so GameWindow.OnRender's PrepareFrame guard let a zero-area RenderFrameInput through. The retail path tolerated it silently; the render-pack controller correctly rejects a zero activation extent, which surfaced the latent frame as an unhandled ArgumentOutOfRangeException during the owner's VM6/VM3 gate. RenderFrameOrchestrator.Render now returns RenderFrameOutcome.ZeroArea before BeginFrame when either dimension is <= 0 (no GPU frame, phase, measurement, diagnostics or recovery runs) and GameWindow skips NoteFrameClosed for it. Test: ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork. Verify: App hermetic lane 6,059/0 (Release). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 30 +++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 9 ++++-- .../Rendering/RenderFrameOrchestrator.cs | 23 +++++++++++++- .../Rendering/RenderFrameOrchestratorTests.cs | 25 ++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) 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() {