From 83f4c68f280291aeb01ada840b47afd1830ba53a Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:17:01 +0800 Subject: [PATCH] =?UTF-8?q?android:=20A51=20=E2=80=94=20the=20scan-out=20i?= =?UTF-8?q?mage=20keeps=20the=20pool=20buffer's=20row=20stride?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan-out image is allocated from the session's pool, whose rows are gralloc's: padded for widths gralloc does not like (1024 px -> 5120 B on Adreno), where a LINEAR Vulkan image is otherwise laid out at width * 4. Created through a DRM-format-modifier list, Turnip picks that tight pitch; the compositor reads the buffer at the buffer's stride, so every row the GPU writes lands a little further along than the row read back — the frame is sheared, repeated across the width, with the tail of the buffer never written. Only a composited frame writes this image. Direct scanout (one screen-sized layer, no filter that needs the shader, no look, no ReShade) is the guest's own buffer, which is why the fault appears exactly when NEAREST/FSR/NIS/SGSR forces vulkan_composite — measured on a 1024x768 session into a 1280x960 panel, where SurfaceFlinger reported the layer's stride as 5120 B while the composite wrote at 4096. At 1280 and 1920 wide the two pitches coincide, so no output this stack has run before could show it. The pitch is not derivable from the geometry, so A51 asks for it: the shim owns the pool and answers `gamenative_drm_scanout_pitch(w, h)` (gamenative-drm). When it answers, the image is created with an explicit LINEAR modifier at that stride instead of the modifier list, which also makes the AddFB2 export report the stride the compositor reads. The symbol is weak, so a real KMS session keeps the driver's own layout. android/patches/gamescope/0049-A51-scanout-image-at-the-pool-row-stride.patch The patch itself (rendervulkan.cpp): the weak declaration beside BInit, and the explicit-modifier create — the modifier list unlinked so the create chain carries one layout authority, with the external-memory and format-list nodes kept as the chain's tail; gated to output images with no imported dma-buf and no memory to reuse. Applies on top of the 48-patch train, and the train re-applies cleanly around it. android/check-payload-closure.sh libnativewindow.so joins the system allowlist: the ICD dlopens it for the guest's AHardwareBuffer receive path and it is present on every Android this payload runs on. The check was failing on that pre-existing edge, not on anything this change adds; RESULT is CLEAN again. --- android/check-payload-closure.sh | 4 +- ...scanout-image-at-the-pool-row-stride.patch | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 android/patches/gamescope/0049-A51-scanout-image-at-the-pool-row-stride.patch diff --git a/android/check-payload-closure.sh b/android/check-payload-closure.sh index 96cd066b2..4523ffe67 100755 --- a/android/check-payload-closure.sh +++ b/android/check-payload-closure.sh @@ -30,7 +30,9 @@ tar --zstd -xf "$ASSET" -C "$WORK" # it. It is never a legal DT_NEEDED — a file that links it can only load in a # namespace that carries the NDK stub, which is not this payload's contract — # so the DT_NEEDED scan refuses it before the allowlist is consulted. -SYSTEM_RE='^(libc|libm|libdl|liblog|libandroid|libz|libsync|libstdc\+\+|ld-android|libEGL|libGLESv1_CM|libGLESv2|libGLESv3|libvulkan)\.so$' +# libnativewindow.so is the platform library the ICD dlopens for the guest's +# AHardwareBuffer receive path; present on every Android this payload runs on. +SYSTEM_RE='^(libc|libm|libdl|liblog|libandroid|libz|libsync|libnativewindow|libstdc\+\+|ld-android|libEGL|libGLESv1_CM|libGLESv2|libGLESv3|libvulkan)\.so$' fail=0 declare -A present diff --git a/android/patches/gamescope/0049-A51-scanout-image-at-the-pool-row-stride.patch b/android/patches/gamescope/0049-A51-scanout-image-at-the-pool-row-stride.patch new file mode 100644 index 000000000..9bc18a4f8 --- /dev/null +++ b/android/patches/gamescope/0049-A51-scanout-image-at-the-pool-row-stride.patch @@ -0,0 +1,92 @@ +A51 — lay the scan-out image out at the pool buffer's row stride + +A scan-out image is allocated from the session's pool, and that buffer is +gralloc's: its rows are padded for widths gralloc does not like (1024 px -> +5120 B, 1440 px -> 5888 B on Adreno), which is the same fact the ICD already +handles for its own dma-buf slots and export-emulated images. The image here is +created by gamescope through a DRM-format-modifier *list*, so Turnip picks the +layout, and for a LINEAR modifier it picks width * 4. The compositor then reads +the buffer at the buffer's stride and the GPU wrote it at the driver's: every +row lands a little further along than the row read back, so the frame comes out +sheared, repeated across the width, with the tail of the buffer unwritten. + +Only a composited frame touches this image. A frame that direct-scans-out +(gamescope's `drm_prepare` path: one screen-sized layer, no filter that needs +the shader, no look, no ReShade) is the guest's own buffer, so it is unaffected +-- which is why the fault appears exactly when a scaling filter (NEAREST, FSR, +NIS, SGSR) forces `vulkan_composite`, and not before. + +At 1280 and 1920 wide the stride is tight, so the two pitches agree and no +output this stack has run before could show it. + +The pitch is not derivable from the geometry, so it is asked for: the shim owns +the pool and answers `gamenative_drm_scanout_pitch(w, h)` -- measuring it with +one throwaway pool allocation for a geometry it has not seen. The output image +is then created with an explicit LINEAR modifier at that stride instead of the +modifier list, which also makes the AddFB2 export report the same stride the +compositor reads. A real KMS session has no shim, the weak symbol does not +resolve, and the driver's own choice stands. + +diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp +--- a/src/rendervulkan.cpp ++++ b/src/rendervulkan.cpp +@@ -2707,6 +2707,12 @@ + } + } + ++// gamenative A51: the row stride of the scan-out pool's buffer for a geometry, ++// resolved by the shim (libgndrm_shim.so, which owns the pool and the daemon ++// connection) or 0 when there is no such session. Weak: gamescope must still ++// link and run on a real KMS device. ++extern "C" __attribute__( ( weak ) ) uint32_t gamenative_drm_scanout_pitch( uint32_t width, uint32_t height ); ++ + bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc pBackendFb ) + { + m_pBackendFb = std::move( pBackendFb ); +@@ -2906,6 +2912,46 @@ + imageInfo.tiling = tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT; + } + ++ // gamenative A51: a scan-out image is allocated from the session's pool, whose ++ // buffer carries gralloc's row stride — not width * 4 for every width (1024 px ++ // is 5120 B on Adreno). The compositor reads that buffer at the buffer's ++ // stride, so the image is laid out at it: with the driver's own tight pitch ++ // every row the GPU writes lands a little further along than the row read ++ // back, and a composited frame is sheared. Direct scanout never writes this ++ // image, so it stays clean — which is why the shear only appears once a ++ // filter (or a look, or ReShade) forces the composite. The pitch comes from ++ // the shim, which owns the pool; without one (a real KMS session) the ++ // driver's own choice stands. ++ if ( flags.bOutputImage && pDMA == nullptr && pExistingImageToReuseMemory == nullptr ++ && tiling == VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT ++ && gamenative_drm_scanout_pitch != nullptr ) ++ { ++ uint32_t uPoolPitch = gamenative_drm_scanout_pitch( width, height ); ++ if ( uPoolPitch != 0 ) ++ { ++ modifierPlaneLayouts[0] = { ++ .offset = 0, ++ .size = 0, ++ .rowPitch = uPoolPitch, ++ .arrayPitch = 0, ++ .depthPitch = 0, ++ }; ++ // A create-info chain carries the explicit layout *or* the modifier ++ // list, never both: unlink the list, making the external-memory (and ++ // format-list) nodes the chain's tail. ++ imageInfo.pNext = modifierListInfo.pNext; ++ modifierInfo = { ++ .sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_EXPLICIT_CREATE_INFO_EXT, ++ .pNext = std::exchange( imageInfo.pNext, &modifierInfo ), ++ .drmFormatModifier = DRM_FORMAT_MOD_LINEAR, ++ .drmFormatModifierPlaneCount = 1, ++ .pPlaneLayouts = modifierPlaneLayouts, ++ }; ++ vk_log.infof( "output image %ux%u at the pool's row stride %u (tight %u)", ++ width, height, uPoolPitch, width * 4u ); ++ } ++ } ++ + if ( flags.bFlippable == true && tiling != VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT ) + { + // We want to scan-out the image