From 7c104954afe25d8c585e59c18018ecbd36aa05d3 Mon Sep 17 00:00:00 2001 From: themuffinator Date: Thu, 6 Aug 2026 19:09:41 +0100 Subject: [PATCH] Record why the menu soft focus covers only the in-game menu The soft focus was extended to the connection dialog, the level-loading screen, and the console. All three are 2D-only frames, which required dropping the backEnd.doneSurfaces gate in every backend. That gate is not just naming a scene: it is the only signal a backend has that the render target holds something the composite may read back, and nothing else in any of the three renderers post-processes a frame without it. Removing it faulted the device inside the ICD on the first connect screen. Those layers are withdrawn and the guards are restored, so the executable behaviour matches 8f79c27 again. What is kept is the reasoning, in the code and in MENU_SOFT_FOCUS.md, so the guards are not read as redundant and removed a second time: - backEnd.doneSurfaces in all three backends. - vk.cmd->last_pipeline = VK_NULL_HANDLE in both Vulkan backends. vk_menu_blur_draw binds descriptor set 0 through vk.pipeline_layout_post_process, and binding through an incompatible layout disturbs the sets bound for vk.pipeline_layout. Nulling the cache is what forces the next draw to rebind them; handing the frame's pipeline back instead faults the device the same way. - The console cannot be a layer at all. It is the only one that finishes the frame's own post-processing via re.FinishBloom, and neither ordering around that call works: before it, bloom's blend-pass descriptors are still bound when the console draws and it does not appear; after it, the Vulkan backends sit in RENDER_PASS_POST_BLOOM. Either ordering also composites over live gameplay while the request fades out, which dims the warmup ready-up prompt. Source gates now hold all of that, so a future change has to argue with a failing test rather than with a comment. cl_menuBlur's cvar help and the changelog no longer promise the withdrawn layers. Co-Authored-By: Claude Opus 5 --- code/client/cl_main.cpp | 3 +- code/client/cl_scrn.cpp | 10 ++- code/renderer/tr_arb.c | 5 ++ code/rendererrtx/vk.c | 13 +++- code/renderervk/vk.c | 13 +++- docs/fnql/CHANGELOG.md | 9 ++- docs/fnql/MENU_SOFT_FOCUS.md | 24 ++++++-- docs/fnql/TECHNICAL.md | 2 +- tests/menu_blur_source_tests.py | 104 +++++++++++++++++++++++++++++++- 9 files changed, 170 insertions(+), 13 deletions(-) diff --git a/code/client/cl_main.cpp b/code/client/cl_main.cpp index 1fab445..d1772cb 100644 --- a/code/client/cl_main.cpp +++ b/code/client/cl_main.cpp @@ -5439,7 +5439,8 @@ void CL_Init( void ) { " 0 - leave the scene sharp\n" " 0..1 - scales both the blur radius and how far the softened copy\n" " replaces the sharp frame (1 is the default full soft focus)\n" - "Gameplay is never affected; the effect exists only while a menu is open." ); + "Gameplay is never affected; the effect exists only while a menu is open.\n" + "The connect screen and the console are not softened." ); cl_cinematicAspect = Cvar_Get( "cl_cinematicAspect", "1", CVAR_ARCHIVE ); Cvar_CheckRange( cl_cinematicAspect, "0", "1", CV_INTEGER ); Cvar_SetDescription( cl_cinematicAspect, diff --git a/code/client/cl_scrn.cpp b/code/client/cl_scrn.cpp index 9d01c2c..20a0178 100644 --- a/code/client/cl_scrn.cpp +++ b/code/client/cl_scrn.cpp @@ -1205,7 +1205,9 @@ static void SCR_DrawScreenField( stereoFrame_t stereoFrame ) { // Soften everything drawn so far - the 3D scene and the cgame HUD over it - // so the in-game menu that draws next is the only sharp thing on screen. A // fullscreen menu has no scene behind it to soften, and the connect and - // loading screens are not in-game menus. + // loading screens are not in-game menus. Both of those, and the console, + // are 2D-only frames: the backends require a 3D pass this frame and fault + // without one, so they cannot be softened. See MENU_SOFT_FOCUS.md. menuBlurStrength = SCR_UpdateMenuBlurStrength( uiVisible && !uiFullscreen && cls.state == CA_ACTIVE ); if ( menuBlurStrength > 0.0f && re.DrawMenuBlur ) { @@ -1223,7 +1225,11 @@ static void SCR_DrawScreenField( stereoFrame_t stereoFrame ) { CL_WebHost_DrawBrowserSurface(); } - // console draws next + // The console is deliberately not softened. It is the one layer that + // finishes the frame's post-processing itself, and every ordering tried + // around that either left it drawing with the descriptor sets bloom's blend + // pass had bound - so it did not appear at all - or dimmed the live HUD + // underneath it while the request faded out. See MENU_SOFT_FOCUS.md. Con_DrawConsole (); // debug graph can be drawn on top of anything diff --git a/code/renderer/tr_arb.c b/code/renderer/tr_arb.c index 54845e5..66a6468 100644 --- a/code/renderer/tr_arb.c +++ b/code/renderer/tr_arb.c @@ -5798,6 +5798,11 @@ void FBO_MenuBlur( float strength ) FBO_MenuBlurDecline( "the framebuffer post-processing path is not active" ); return; } + /* backEnd.doneSurfaces means a 3D pass has run this frame, and it is load + * bearing: it is the only signal the backends have that the render target + * holds a scene this composite may read back. Dropping it to soften the + * 2D-only connection and loading screens faulted the device on the first + * such frame. See docs/fnql/MENU_SOFT_FOCUS.md. */ if ( !backEnd.doneSurfaces || backEnd.framePostProcessed || ri.CL_IsMinimized() ) { return; } diff --git a/code/rendererrtx/vk.c b/code/rendererrtx/vk.c index c559959..0801bb0 100644 --- a/code/rendererrtx/vk.c +++ b/code/rendererrtx/vk.c @@ -21352,6 +21352,11 @@ qboolean vk_menu_blur( float strength ) vk_menu_blur_decline( "the framebuffer post-processing path is not active" ); return qfalse; } + /* backEnd.doneSurfaces means a 3D pass has run this frame, and it is load + * bearing: it is the only signal the backends have that the render target + * holds a scene this composite may read back. Dropping it to soften the + * 2D-only connection and loading screens faulted the device on the first + * such frame. See docs/fnql/MENU_SOFT_FOCUS.md. */ if ( !backEnd.doneSurfaces || ri.CL_IsMinimized() ) { return qfalse; } @@ -21417,7 +21422,13 @@ qboolean vk_menu_blur( float strength ) vk_menu_blur_draw( vk.menu_blur_composite_pipeline, vk.menu_blur_descriptor[1], 0.0f, 0.0f, plan.alpha ); - /* Direct post-process binds bypass the normal descriptor/pipeline caches. */ + /* Direct post-process binds bypass the normal descriptor/pipeline caches. + * The null pipeline is load-bearing, not just a hint: vk_menu_blur_draw + * binds descriptor set 0 with vk.pipeline_layout_post_process, and binding + * through an incompatible layout disturbs the sets bound for + * vk.pipeline_layout. Nulling the cache is what forces the next draw to + * rebind them; handing the frame's pipeline back instead lets that draw + * proceed with disturbed descriptor sets and faults the device. */ vk.cmd->last_pipeline = VK_NULL_HANDLE; vk.cmd->depth_range = DEPTH_RANGE_COUNT; vk.cmd->descriptor_set.start = 0; diff --git a/code/renderervk/vk.c b/code/renderervk/vk.c index b6f3d01..a724363 100644 --- a/code/renderervk/vk.c +++ b/code/renderervk/vk.c @@ -13111,6 +13111,11 @@ qboolean vk_menu_blur( float strength ) vk_menu_blur_decline( "the framebuffer post-processing path is not active" ); return qfalse; } + /* backEnd.doneSurfaces means a 3D pass has run this frame, and it is load + * bearing: it is the only signal the backends have that the render target + * holds a scene this composite may read back. Dropping it to soften the + * 2D-only connection and loading screens faulted the device on the first + * such frame. See docs/fnql/MENU_SOFT_FOCUS.md. */ if ( !backEnd.doneSurfaces || ri.CL_IsMinimized() ) { return qfalse; } @@ -13176,7 +13181,13 @@ qboolean vk_menu_blur( float strength ) vk_menu_blur_draw( vk.menu_blur_composite_pipeline, vk.menu_blur_descriptor[1], 0.0f, 0.0f, plan.alpha ); - /* Direct post-process binds bypass the normal descriptor/pipeline caches. */ + /* Direct post-process binds bypass the normal descriptor/pipeline caches. + * The null pipeline is load-bearing, not just a hint: vk_menu_blur_draw + * binds descriptor set 0 with vk.pipeline_layout_post_process, and binding + * through an incompatible layout disturbs the sets bound for + * vk.pipeline_layout. Nulling the cache is what forces the next draw to + * rebind them; handing the frame's pipeline back instead lets that draw + * proceed with disturbed descriptor sets and faults the device. */ vk.cmd->last_pipeline = VK_NULL_HANDLE; vk.cmd->depth_range = DEPTH_RANGE_COUNT; vk.cmd->descriptor_set.start = 0; diff --git a/docs/fnql/CHANGELOG.md b/docs/fnql/CHANGELOG.md index eee594b..fdeb02a 100644 --- a/docs/fnql/CHANGELOG.md +++ b/docs/fnql/CHANGELOG.md @@ -24,8 +24,9 @@ release, CI resets `Unreleased` for the next cycle. - Soften the scene and HUD behind an open in-game menu, so the menu reads as the foreground instead of competing with the live view. Set `cl_menuBlur 0` to keep the frame sharp, or a value between 0 and 1 for a lighter effect. - Gameplay and the scoreboard are never softened. Available on all three - renderers; requires the framebuffer path (`r_fbo 1`). + Gameplay, the scoreboard, the connect screen, and the console are never + softened. Available on all three renderers; requires the framebuffer path + (`r_fbo 1`). ### Audio - _None yet._ @@ -37,6 +38,10 @@ release, CI resets `Unreleased` for the next cycle. ### Fixes - Keep the mouse cursor usable in the in-game menu, scoreboard, and other cgame/UI overlays while supersampling is enabled. +- Light maps that ship external lightmap atlases (`maps//lm_*`) the same + way as maps with lightmaps stored inside the BSP. They previously ignored + `r_mapOverBrightBits`, `r_mapOverBrightCap`, and `r_mapGreyScale`, took + `r_intensity` and gamma a second time, and were blurred by `r_picmip`. ### Documentation and Tooling - _None yet._ diff --git a/docs/fnql/MENU_SOFT_FOCUS.md b/docs/fnql/MENU_SOFT_FOCUS.md index eeeb860..6d4d88a 100644 --- a/docs/fnql/MENU_SOFT_FOCUS.md +++ b/docs/fnql/MENU_SOFT_FOCUS.md @@ -16,6 +16,8 @@ This replaces an earlier `cl_menuDepthOfField` effect that was implemented only One cvar gates the whole feature. The fade in and out is a fixed 140 ms and is not configurable: shorter reads as a flicker when a menu is toggled, longer starts to feel like input lag on the menu itself. +It is reachable without the console: the WebUI settings overlay carries it as a slider in the **Game → Interface** group (`SECTION_GROUPS.game` in `code/client/webui/fnql-settings.js`), and `CL_WebHost_BuildConfigCvarJson` publishes it so the overlay can read the current value back. The slider's `min`/`max` mirror the cvar's `Cvar_CheckRange` bounds and its `0.05` step resolves to the two decimals `formatRange` writes; a source gate holds those three in agreement, since a slider that cannot reach the cvar's bounds is the failure that would otherwise go unnoticed. + Because strength scales radius *and* composite weight together, a partial value is a gently softened frame rather than a cross-fade between a sharp and a blurred copy of the same image, which would read as a ghosted double exposure. ## Trigger @@ -26,12 +28,24 @@ The client owns the decision, in `SCR_DrawScreenField`. The effect is requested - the menu is not fullscreen — a fullscreen menu has no scene behind it to soften; - `cls.state == CA_ACTIVE` — the connect and level-loading screens are not in-game menus. -Note what is *not* included. `KEYCATCH_CGAME` overlays such as the scoreboard are drawn over live gameplay that the player is still reading, so they stay sharp. The WebUI browser draws its own full-surface overlay and is handled separately. - `SCR_UpdateMenuBlurStrength` ramps toward the requested strength on wall-clock time rather than per frame, so the pull into focus takes the same 140 ms at 60 and at 250 fps. A negative `cls.realtime` delta is a timer reset and a delta over a second is a hitch or a restored window; neither counts as elapsed fade time. The request is issued after the scene *and* the cgame HUD have been drawn and before `UI_REFRESH`, so the HUD is softened along with the world and only the menu itself stays sharp. +### What cannot be softened, and why + +`KEYCATCH_CGAME` overlays — the scoreboard and the spectator join page — stay sharp. They are drawn over live gameplay the player is still reading, and more decisively, cgame draws the scene, the HUD, and its overlays in a single `CG_DRAW_ACTIVE_FRAME` call, so a request queued after it would soften the overlay itself rather than the frame behind it. That would need a cgame-side hook that does not exist. + +**The connection dialog and the level-loading screen** were tried and withdrawn. Both are 2D-only frames, and every backend gates the composite on `backEnd.doneSurfaces` — "a 3D pass has run this frame". Dropping that gate so those screens could be softened faulted the device inside the ICD on the first such frame, before the loading screen was even reached. The flag is doing more than naming a scene: it is the only signal a backend has that the render target holds something this composite may read back, and nothing else in any of the three renderers post-processes a frame without one. Re-enabling it needs that invariant established with the Vulkan validation layers on, not inferred from reading. + +**The console** was tried and withdrawn as well. It is the only layer that finishes the frame's own post-processing, calling `re.FinishBloom` from inside `Con_DrawSolidConsole` so it is not itself bloomed, and that call cannot be moved out of the way: + +- With the request issued **before** it, bloom runs between the softening and the console. The descriptor sets its blend pass leaves bound made the console's own draw read the wrong descriptors, and the console did not appear at all. +- With bloom finished **first**, the Vulkan backends are left in `RENDER_PASS_POST_BLOOM`, which targets an attachment the pyramid does not sample. +- Either ordering also composites over live gameplay for the 140 ms the request takes to fade out after the console closes, which reads as the HUD — the warmup ready-up prompt most visibly — briefly losing opacity for no reason the player can see. + +It cannot simply share the menu's insertion point either: it draws over the menu and the WebUI browser surface, both of which land after that point, so the layers underneath it would stay sharp. Softening only the console's own rectangle would avoid the gameplay-dimming problem, but `re.DrawMenuBlur` composites the whole frame and the Vulkan composite pipelines bake their scissor, so the rect would have to become dynamic state in both of them. + ## Sampling plan `code/renderercommon/tr_menu_blur.h` owns the plan. The three renderer backends drive it rather than each inventing a blur, which is what keeps them looking the same. @@ -61,6 +75,8 @@ The effect samples the finished frame, so it cannot run from the frontend. Each Three attachments, one render pass, three framebuffers, three descriptors, and three pipelines are created. One render pass object serves all three targets because render-pass compatibility depends on attachment format and sample count, not extent; three *pipelines* are still needed because viewport and scissor are baked into a pipeline in this codebase. `VK_NUM_MENU_BLUR_IMAGES` is budgeted into both `MAX_ATTACHMENTS_IN_POOL` and the combined-image-sampler descriptor pool. +`vk_menu_blur` ends by setting `vk.cmd->last_pipeline` to `VK_NULL_HANDLE`, and that is load-bearing rather than a hint. `vk_menu_blur_draw` binds descriptor set 0 through `vk.pipeline_layout_post_process`, and binding through a layout incompatible with `vk.pipeline_layout` disturbs the sets bound for it. Nulling the cache is what forces the next draw to rebind them. Handing the frame's pipeline back instead — which looks like the tidier mirror of what `vk_bloom` does for its own binds — lets that draw proceed with disturbed descriptor sets and faults the device inside the ICD. + The Vulkan attachments are allocated unconditionally whenever the FBO path is active, not gated on `cl_menuBlur`. The attachment pool is fixed-size and populated at renderer init, where the renderer cannot usefully consult a client cvar that the player may change at any moment. The cost is roughly 3 MB at 1080p and 12 MB at 4K. Both SPIR-V blobs are checked in beside the shader source in `shaders/spirv/shader_data.c`, generated with the same layout `bin2hex` produces. @@ -75,6 +91,6 @@ The effect requires the framebuffer post-processing path. With `r_fbo 0` there i `tests/menu_blur_tests.cpp` covers the plan: inert disabled plans for every rejected input including NaN strength and undersized targets; two halvings with truncation that never collapses to zero; total sigma tracking target height and not width; the 1:2:3:4 ramp; strength scaling sigma and alpha together with a constant pass count and clamping above 1; tap-spacing conversion round-tripping through each kernel variance and collapsing to zero on degenerate input; and the property that both kernels reach the same total sigma from the same plan. -`tests/menu_blur_source_tests.py` gates the structure: that all three backends include the shared header and call both plan functions; that each passes its own kernel variance and not the other's; that a single archived cvar gates the effect and the binned depth-of-field path is gone from every file that referenced it; that the trigger excludes fullscreen menus, non-active states, and cgame overlays; that each backend queues a render command and ends its surface first; that the GL path performs exactly two linear blits inside `FBO_MenuBlur`; that both Vulkan backends allocate, budget, and release every object they create; that the shader's weights and tap positions still match the tabulated kernel variance; and that the two Vulkan shader sources are byte-identical. +`tests/menu_blur_source_tests.py` gates the structure: that all three backends include the shared header and call both plan functions; that each passes its own kernel variance and not the other's; that a single archived cvar gates the effect and the binned depth-of-field path is gone from every file that referenced it; that the in-game menu trigger excludes fullscreen menus and non-active states, that no trigger consults `KEYCATCH_CGAME`, and that each layer has its own ramp; that exactly one layer is requested, with no connect, loading or console layer and no client-side `re.FinishBloom` call crept back in; that all three backends still gate on `backEnd.doneSurfaces` and both Vulkan backends null `last_pipeline` after their post-process binds; that each backend queues a render command and ends its surface first; that the GL path performs exactly two linear blits inside `FBO_MenuBlur`; that both Vulkan backends allocate, budget, and release every object they create; that the shader's weights and tap positions still match the tabulated kernel variance; and that the two Vulkan shader sources are byte-identical. -Runtime promotion still needs a windowed retail-asset check on each renderer: open an in-game menu during live play and confirm the scene and HUD soften while the menu stays sharp, that the fade is smooth in both directions, that `cl_menuBlur 0` leaves the frame untouched, and that intermediate values look softened rather than double-exposed. `r_fbo 0` and a `vid_restart` with the menu open are the two configuration cases worth checking explicitly. +Runtime promotion still needs a windowed retail-asset check on each renderer: open an in-game menu during live play and confirm the scene and HUD soften while the menu stays sharp, that the fade is smooth in both directions, that `cl_menuBlur 0` leaves the frame untouched, and that intermediate values look softened rather than double-exposed. Confirm too that the layers deliberately left out stay out: connecting to a server and dropping the console during live play must both leave the frame completely untouched, and the warmup ready-up prompt must hold full opacity throughout. `r_fbo 0` and a `vid_restart` with the menu open are the two configuration cases worth checking explicitly. diff --git a/docs/fnql/TECHNICAL.md b/docs/fnql/TECHNICAL.md index 08f75b6..41473f2 100644 --- a/docs/fnql/TECHNICAL.md +++ b/docs/fnql/TECHNICAL.md @@ -358,7 +358,7 @@ Renderer-focused verification lives beside the release packaging flow: keeps raster-module evidence separate from strict native-RT evidence. - [`docs/fnql/LIQUID_RENDERING.md`](./LIQUID_RENDERING.md) documents the opt-in liquid scene capture and overlay, GLx/Vulkan parity contract, client visual impulse feed, compatibility boundaries, screen-space limitations, and possible higher tiers. - [`docs/fnql/UNDERWATER_VIEW.md`](./UNDERWATER_VIEW.md) documents the opt-in submerged-view compositor: the visual-only submersion feed and its `REF_API_VERSION` boundary, the shared wave/dispersion/absorption model, per-backend pass placement across the OpenGL-lineage, Vulkan, and RTX renderers, and the post-process limits. -- [`docs/fnql/MENU_SOFT_FOCUS.md`](./MENU_SOFT_FOCUS.md) documents the `cl_menuBlur` in-game menu soft focus: the client trigger and wall-clock fade, the shared Gaussian-pyramid sampling plan that keeps the OpenGL-lineage, Vulkan, and RTX backends matched, why the backends exchange sigma rather than tap offsets, and the developer-visible decline reasons. +- [`docs/fnql/MENU_SOFT_FOCUS.md`](./MENU_SOFT_FOCUS.md) documents the `cl_menuBlur` in-game menu soft focus: the client trigger and wall-clock fade, why the connect screen, the console, and cgame overlays cannot be softened, the shared Gaussian-pyramid sampling plan that keeps the OpenGL-lineage, Vulkan, and RTX backends matched, why the backends exchange sigma rather than tap offsets, and the developer-visible decline reasons. - [`docs/fnql/GLX_FINAL_CONTRACT.md`](./GLX_FINAL_CONTRACT.md) is the accepted target ADR for the final GLx replacement renderer: stable C ABI, GLx-owned draw behavior, five product tiers, deterministic pass order, and a scene-linear color pipeline. - [`docs/fnql/GLX_COLORSPACE_AUDIT.md`](./GLX_COLORSPACE_AUDIT.md) records the audited sRGB/linear texture classes, framebuffer-sRGB policy, blending expectations, and screenshot capture color space for GLx color-pipeline work. - [`docs/fnql/DLIGHT_SHADOWMAP_ROADMAP.md`](./DLIGHT_SHADOWMAP_ROADMAP.md) tracks the GLx/Vulkan dynamic-light shadow-map roadmap, current implementation status, test command, launch helper, and RenderDoc checkpoints. diff --git a/tests/menu_blur_source_tests.py b/tests/menu_blur_source_tests.py index 66675ed..1887be8 100644 --- a/tests/menu_blur_source_tests.py +++ b/tests/menu_blur_source_tests.py @@ -109,12 +109,41 @@ def test_single_cvar_gates_the_effect(self) -> None: self.assertNotIn("menuDepthOfField", read_text(path)) self.assertNotIn("MenuDepthOfField", read_text(path)) + def test_settings_menu_exposes_the_control(self) -> None: + """The cvar has to be reachable without the console: a slider on a live + settings route, and published in the snapshot the overlay reads.""" + script = read_text("code/client/webui/fnql-settings.js") + snapshot = read_text("code/client/cl_webui.cpp") + main = read_text("code/client/cl_main.cpp") + + self.assertIn( + "{ name: 'cl_menuBlur', title: 'In-Game Menu Soft Focus', " + "type: 'range', min: 0, max: 1, step: 0.05,", + script, + ) + # Without this the overlay renders the row but never learns the value. + self.assertIn('"cl_menuBlur",', snapshot) + + # The row lives on a route the retail settings UI actually navigates to, + # not in an orphaned group that nothing appends. + groups = script[script.index("var SECTION_GROUPS = {"):] + self.assertLess(groups.index("game: ["), groups.index("'cl_menuBlur'")) + self.assertIn("'game': 'game',", script) + + # A slider that cannot reach the cvar's bounds, or that overshoots them, + # is the regression worth catching - the two ranges have to agree. + self.assertIn('Cvar_CheckRange( cl_menuBlur, "0", "1", CV_FLOAT );', main) + def test_only_an_in_game_menu_triggers_it(self) -> None: """Gameplay must never be softened, and a fullscreen menu has no scene behind it to soften.""" scrn = read_text("code/client/cl_scrn.cpp") - self.assertIn("SCR_UpdateMenuBlurStrength(\n\t\tuiVisible && !uiFullscreen && cls.state == CA_ACTIVE );", scrn) + self.assertIn( + "SCR_UpdateMenuBlurStrength(\n" + "\t\tuiVisible && !uiFullscreen && cls.state == CA_ACTIVE );", + scrn, + ) self.assertIn("if ( menuBlurStrength > 0.0f && re.DrawMenuBlur ) {", scrn) self.assertIn("re.DrawMenuBlur( menuBlurStrength );", scrn) @@ -124,6 +153,65 @@ def test_only_an_in_game_menu_triggers_it(self) -> None: self.assertIn("delta = previousTime ? cls.realtime - previousTime : 0;", scrn) self.assertIn("if ( delta < 0 || delta > 1000 ) {", scrn) + def test_only_one_layer_is_requested(self) -> None: + """The connection dialog, the level-loading screen and the console are + all 2D-only frames. Requesting the effect there faulted the device: the + backends need a 3D pass this frame to have something to read back. The + in-game menu is the only layer with a scene behind it.""" + body = function_body( + read_text("code/client/cl_scrn.cpp"), + "static void SCR_DrawScreenField( stereoFrame_t stereoFrame )", + ) + + self.assertEqual(body.count("re.DrawMenuBlur("), 1) + self.assertEqual(body.count("SCR_UpdateMenuBlurStrength("), 1) + for absent in ( + "connectBlurStrength", + "consoleBlurStrength", + "KEYCATCH_CONSOLE", + # Nothing may finish bloom on the client's behalf either: that moves + # when the frame resolves, and the console does it for itself. + "re.FinishBloom", + ): + with self.subTest(absent=absent): + self.assertNotIn(absent, body) + + def test_backends_require_a_3d_pass(self) -> None: + """backEnd.doneSurfaces is the only signal a backend has that the render + target holds a scene this composite may read back. Dropping it to soften + the 2D-only connect and loading screens faulted the device on the first + such frame, so all three keep it.""" + gl = function_body( + read_text("code/renderer/tr_arb.c"), "void FBO_MenuBlur( float strength )" + ) + self.assertIn( + "if ( !backEnd.doneSurfaces || backEnd.framePostProcessed " + "|| ri.CL_IsMinimized() ) {", + gl, + ) + + for base in VULKAN_BACKENDS: + body = function_body( + read_text(base + "/vk.c"), "qboolean vk_menu_blur( float strength )" + ) + with self.subTest(base=base): + self.assertIn( + "if ( !backEnd.doneSurfaces || ri.CL_IsMinimized() ) {", body + ) + + def test_cgame_overlays_stay_sharp(self) -> None: + """cgame draws the scene, the HUD, and its own overlays in one + CG_DRAW_ACTIVE_FRAME call, so any request queued after it would soften + the overlay itself rather than the frame behind it.""" + body = function_body( + read_text("code/client/cl_scrn.cpp"), + "static void SCR_DrawScreenField( stereoFrame_t stereoFrame )", + ) + + trigger = body.index("SCR_UpdateMenuBlurStrength(") + end = body.index(";", trigger) + self.assertNotIn("KEYCATCH_CGAME", body[trigger:end]) + def test_refexport_carries_the_strength(self) -> None: public = read_text("code/renderercommon/tr_public.h") self.assertIn("void\t(*DrawMenuBlur)( float strength );", public) @@ -186,6 +274,20 @@ def test_vulkan_backends_build_the_pyramid(self) -> None: # Descriptor pool budget must cover the pyramid too. self.assertIn("VK_NUM_MENU_BLUR_IMAGES + ( 1 + VK_NUM_BLOOM_PASSES * 2 )", source) + def test_vulkan_backends_null_the_pipeline_cache(self) -> None: + """vk_menu_blur_draw binds descriptor set 0 through + vk.pipeline_layout_post_process, and binding via an incompatible layout + disturbs the sets bound for vk.pipeline_layout. Nulling the cache is what + forces the next draw to rebind them; handing the frame's pipeline back + instead lets that draw run with disturbed sets and faults the device.""" + for base in VULKAN_BACKENDS: + body = function_body( + read_text(base + "/vk.c"), "qboolean vk_menu_blur( float strength )" + ) + with self.subTest(base=base): + self.assertIn("vk.cmd->last_pipeline = VK_NULL_HANDLE;", body) + self.assertNotIn("restore_pipeline", body) + def test_vulkan_backends_release_everything_they_create(self) -> None: """A leaked render pass or pipeline survives vid_restart and then points at a destroyed attachment."""