Compare commits

...
70 Commits
Author SHA1 Message Date
swung0x48 9eae98581f [Test] (SelfTest, POST): probe upload/draw ordering in persistently mapped vertex arenas - native GLES controls distinguish mapped destination corruption from staging and synchronization failures
Add the persistent buffer ordering probe to the GLES POST's known-driver-bug inventory. Queue updates and draws into independent FBOs before reading them back, covering SubData and copies from both coherent persistent and ordinary staging buffers. Retry fresh mapped allocations for intermittent corruption.

Require passing never-mapped and fully serialized controls before reporting a finding. Include finish-before, map-then-unmap and barrier diagnostics, preserve caller GL state, and treat setup, allocation and GL errors as inconclusive. This adds detection and reporting only; no rendering workaround is enabled.

Validation: all 46 DriverBugProbesTest tests pass, including 11 new ordering, control, collector and cleanup cases. The Android API 26 / NDK 27 native probe detects all three upload paths on Mali-G1-Ultra r54p1 with clean controls and no GL errors. llvmpipe reports no finding after 240 mapped FBO checks per upload path.
2026-09-07 00:44:08 -04:00
swung0x48 d7655247f7 [Fix, Test] (DirectGLES, Integration): rebind VAOs when an adopted buffer is respecified - the immediate retire path forgot the buffer-id generation, so cached vertex and element bindings kept the deleted store
Advance the buffer-id generation when Ops_Respecify retires immutable storage on the context thread, matching the existing adoption and deferred-retirement paths.

Add pixel regression coverage for unchanged VBO/IBO attachments across same-size redefinition, growth and shrinkage, including bound and unbound VAOs sharing a vertex arena and an index arena returning to shadow storage.
2026-09-06 22:55:06 -04:00
swung0x48 50fb13430f [Merge] (MG_State, ShaderTranspiler, tools/cts): land the write-map landing and open-ended fp64 storage block fixes that take KHR-Single-GL45.subgroups to 100% on Magma 2026-09-05 05:43:34 -04:00
swung0x48 d4f8adcf6d [Tools, Test] (tools/cts, MG_Test): make the CTS runner's chunk timeout idle-based so a healthy 20-minute invocation is no longer killed and its in-flight case mis-recorded as a crash, and link MSVC test executables with /WHOLEARCHIVE so the dllimport gl* references in GetProcAddress.cpp resolve 2026-09-05 05:36:50 -04:00
swung0x48 795e08f7e6 [Fix, Test] (ShaderTranspiler): flatten fp64 storage blocks whose last member is a runtime array - the pass declined them, so the fp64 demotion re-derived ArrayStride 4 over the application's 8/16/32-byte double buffer and every double/dvecN data[] SSBO read raw words 2026-09-05 05:36:49 -04:00
swung0x48 1e7ecab4db [Fix, Test] (MG_State, BufferObject): land a non-persistent write map's staged bytes into a GPU-resident store at unmap and explicit flush instead of dropping them - SSBO binding and large-store adoption make resident stores reachable through glMapBufferRange, so every per-draw re-initialisation was silently lost 2026-09-05 05:36:48 -04:00
swung0x48 81b17c0b75 [Test] (ShaderTranspiler, Integration): pin the five reworked shapes - the read-only capture stage through the real link, the seeded carrier's ESSL declaration, the metadata-driven block redeclaration and its decline, the control-stage-less evaluation decline, and a carrier clearing an i64vec4 2026-08-28 06:22:12 -04:00
swung0x48 d1edf765f5 [Fix] (Link, Async): carry the resolved gl_PointSize capture request into the SPIR-V handoff and let a deferred verdict name its own severity - the demotion read the request off a reflection slice phase A never fills it into, so its forced carrier was dead code, and its decline reason replayed at a level no shipped build keeps 2026-08-28 06:22:12 -04:00
swung0x48 97e07190ac [Fix] (ShaderTranspiler): seed a forced point-size carrier nothing writes and decline a control stage whose live clip/cull distance would still print gl_PointSize - an ES front end deletes a never-written output, and SPIRV-Cross redeclares that block from member decorations rather than access 2026-08-28 06:22:11 -04:00
swung0x48 a4dcdf989e [Fix] (ShaderTranspiler): stop the point-size carrier landing on a location a live varying owns, and decline the evaluation stage a synthesized pass-through control stage cannot feed - an i64vec4 counted as one location, and a located input carrier trips both backends' pass-through guard 2026-08-28 06:22:11 -04:00
swung0x48 1c113e4b26 [Fix] (ShaderTranspiler): count a 64-bit integer vector as two locations in the XFB flattener's LocationSpan - it read 64-bitness off the element's float type alone, so an i64vec3/4 member packed the members after it onto locations it already owns 2026-08-28 06:22:11 -04:00
swung0x48 bf9cfb3079 [Test] (Integration): PointSizeDemotionScenario - the demoted value chain is client-invisible in both configurations, with pinned per-backend lanes and a log-latch arming guard 2026-08-28 06:22:10 -04:00
swung0x48 e1818d497a [Test] (ShaderTranspiler): pin the point-size demotion's shapes - capability stripped, carriers named and located past the program's varyings, byte-identical no-ops, whole-struct-copy decline, and the two new L1 key bits 2026-08-28 06:21:31 -04:00
swung0x48 d7f66722d1 [Fix] (ShaderTranspiler, Link, DirectGLES, DirectVulkan): demote tessellation/geometry gl_PointSize to an ordinary varying where the device cannot host the built-in - the value survives for gl_in reads and by-name capture, both backends' declines stay for shapes the pass refuses, and the verdict rides the L1 key 2026-08-28 06:21:30 -04:00
swung0x48 92dc41ebf9 [Test] (DirectVulkan, SelfTest): pin the probe's fence-timeout teardown against a fake driver, the never-worse arming refusal, and paused-span patch/instanced counting against an unpaused control 2026-08-28 06:19:41 -04:00
swung0x48 feea131d8b [Fix] (DirectVulkan, SelfTest): honour the primitives-generated probe's leak-on-timeout contract in both of its callers, count paused-span draws the frontend cannot price, refuse a substitute measured worse than the stream query, and derive the POST row's failure clause from the measurement 2026-08-28 06:19:40 -04:00
swung0x48 19f4402fbf [Test] (DirectVulkan): pin the primitives-generated probe's verdict and override mapping, and hold the reroute's GL answers and arming observable on the integration lane 2026-08-28 06:19:40 -04:00
swung0x48 1350031368 [Fix] (DirectVulkan): count GL_PRIMITIVES_GENERATED for transform-feedback-inactive draws - a bring-up probe measures the silent stream query and reroutes such draws through the dedicated primitives-generated query or a clipping-statistics pool, reported from the Vulkan POST 2026-08-28 06:19:39 -04:00
swung0x48 0ee3384b22 [Fix] (Espryt): land a SubData into an adopted store as a GPU-ordered copy - the in-place coherent write tore the frames still reading the old section bytes 2026-08-28 04:50:07 -04:00
swung0x48 ba3f8d6774 [Test] (Integration): pin the adopted mesh-arena store - cross-frame SubData visibility, readback identity, and the GPU-written readback 2026-08-28 04:19:11 -04:00
swung0x48 3327784fd0 [Fix] (Espryt): adopt mesh-arena-sized stores into coherent persistent maps at definition, and stop the flush tiers from re-synchronizing them 2026-08-28 04:19:11 -04:00
swung0x48 ff426da3a9 [Fix, Test] (MG_State, DirectVulkan): order host writes to an adopted store after recorded GPU work - a SubData issued after a dispatch landed in coherent memory before the deferred dispatch executed, so its increments overwrote the newer bytes; un-skip the DirectVulkan half of the SubData-after-dispatch scenario 2026-08-28 03:01:15 -04:00
swung0x48 08419a1fe6 [Chore] (Config): point the two stale comments at the renamed feature fields 2026-08-28 00:47:35 -04:00
swung0x48 5d51372c44 [Chore] (Config): triage backend-scoped env toggles under MOBILEGL_ESPRYT_ and MOBILEGL_MAGMA_ prefixes 2026-08-28 00:38:17 -04:00
swung0x48 7fd4550968 [Fix] (Espryt): copy before any FBO attach in the packed16 probe - attaching the array relayouts it to the plain order and was neutralizing the subject 2026-08-28 00:22:16 -04:00
swung0x48 971537058e [Fix] (Espryt): probe every allocation recipe of the packed16 shape - the Mali layout heuristic inverts between contexts, so one recipe cannot speak for the storage 2026-08-28 00:22:16 -04:00
swung0x48 dd98c450ad [Test] (SelfTest): model the params-before-upload escape so a params-first probe regression reads as a red test 2026-08-28 00:22:15 -04:00
swung0x48 5dbbbbd7eb [Fix] (Espryt): allocate the packed16 probe's textures uploads-first, the order a minted backend texture performs and the one the Mali layout choice keys on 2026-08-28 00:22:15 -04:00
swung0x48 5d140a41ce [Fix] (Espryt): re-arm the packed16 probe on the allocation-scoped mirror the device actually has and let any mirrored level trigger the widening 2026-08-28 00:22:15 -04:00
swung0x48 bf376b230f [CI] (Integration): rerun the buffer scenarios with the map flush disabled so the upload-ring tier keeps coverage 2026-08-28 00:15:15 -04:00
swung0x48 29599dcf90 [Fix] (DirectGLES): flush queued buffer ranges through a range-invalidating map first, so a partial write into a busy store is priced by the range 2026-08-27 23:54:19 -04:00
swung0x48 734fab9f90 [Test] (Integration): scope the SubData-after-dispatch readback case to DirectGLES, naming the DirectVulkan upload-ordering gap it exposed 2026-08-27 23:04:52 -04:00
swung0x48 2cd1809c29 [Fix] (Review): rebuild the packed16 probe on the CTS's three-level chains and let the POST row answer for the widening knob actually in force 2026-08-27 22:46:13 -04:00
swung0x48 faed498476 [Test] (IntegrationTest): shield the packed16 renderbuffer clear from an inherited scissor 2026-08-27 22:46:13 -04:00
swung0x48 8282eecbfa [Fix] (Espryt): store RGB565/RGB5_A1/RGBA4 as 8-bit channels where a POST probe measures the Mali packed16 array-mip field-order mirror 2026-08-27 22:46:12 -04:00
swung0x48 f4f3afb0b6 [Test] (Integration): pin that a queued SubData survives an immediate readback of a GPU-written buffer 2026-08-27 22:19:45 -04:00
swung0x48 a28da07641 [Fix] (DirectGLES): queue app buffer updates and flush them through a staged-copy upload ring instead of stalling in glBufferSubData 2026-08-27 22:19:44 -04:00
swung0x48 200c21336f [Fix] (Readback): size a cube-face pack buffer by one face, and give a cube view's face its owner layer 2026-08-27 22:05:23 -04:00
swung0x48 2c3fc583d5 [Test] (Readback): pin the one-face pixel pack buffer and a cube view's per-face layers 2026-08-27 21:58:51 -04:00
swung0x48 645a12d8bc [Fix] (Readback): read the cube face a readback names, and let glGetTextureSubImage name one 2026-08-27 21:54:47 -04:00
swung0x48 8e6acc5528 [Test] (Readback): pin that a cube map's readback answers the face it was asked for 2026-08-27 21:54:46 -04:00
swung0x48 525ffe0f14 [Test] (Review): pin the inverted override mapping, the probe's controls, and that the pinned-on lane is really armed 2026-08-27 21:06:01 -04:00
swung0x48 ad28d2b744 [Fix, Test] (Review): strip a block's member-level locations too, restore the probe's colour mask, and let the POST verdict follow the override 2026-08-27 20:15:31 -04:00
swung0x48 eab622388f [Test] (TranslationCache): pin the two interface-block location-strip flags as L2 key material 2026-08-27 20:07:49 -04:00
swung0x48 75e573c923 [Fix] (SelfTest): give the located-interface-block probe its own entry-point gate instead of the storage probe's 2026-08-27 19:56:26 -04:00
swung0x48 0d0ef13619 [Fix] (DirectGLES): run the interface-block location strip last, where its deliberately Vulkan-invalid module reaches no validator 2026-08-27 19:44:18 -04:00
swung0x48 23565fcacd [Fix, Test] (DirectGLES, SelfTest, MG_Test): probe the located-interface-block defect with its controls and cover the strip in both gates 2026-08-27 19:38:39 -04:00
swung0x48 5dc26e3e2c [Fix] (DirectGLES, ShaderTranspiler): drop the location qualifier from inter-stage interface blocks on a driver that loses their payload 2026-08-27 19:18:35 -04:00
swung0x48 90564aa82e [Test] (Review): give the geometry point-size case its own capability probe 2026-08-27 15:42:04 -04:00
swung0x48 7520607d47 [Fix] (Review): decline a point-size program the device cannot run, bound the compile-failure source dump, and correct the pass-through rationale 2026-08-27 15:42:04 -04:00
swung0x48 57635a9198 [Test] (Tessellation): unit and headless-GPU coverage for capturing a patch draw's per-vertex payload 2026-08-27 15:06:02 -04:00
swung0x48 c19d0f0b75 [Fix] (DirectVulkan): mirror gl_PointSize for capture and enable the feature its stages need 2026-08-27 15:06:02 -04:00
swung0x48 e42e7d00f5 [Fix] (DirectGLES): request the point-size extension a tessellation or geometry stage's ESSL needs 2026-08-27 15:06:01 -04:00
swung0x48 62695ee3c2 [Test] (Review): pin the single-sample sample mask, the completeness-flip sampled set, and the integer multisample placeholders 2026-08-27 13:17:56 -04:00
swung0x48 02cc0ce83c [Fix] (Review): scope the sample mask to a multisample target, version the sampled-set memo on completeness, clamp the mask word count, give the multisample placeholder every numeric domain, unwind the fixup revert one pass at a time, and bound the validator log 2026-08-27 13:03:51 -04:00
swung0x48 9e52a0b23e [Test] (DirectVulkan): pin the incomplete-default-texture draw, the unbound multisample sampler, and the unwritten redeclared gl_Position 2026-08-27 12:52:19 -04:00
swung0x48 9dee53337f [Fix] (DirectVulkan): give an unbound multisample sampler a placeholder, clear a multisample texture through a load-op pass, and plumb glSampleMaski into the pipeline 2026-08-27 12:52:19 -04:00
swung0x48 28c5badf8f [Fix] (SPIR-V): keep the entry-point interface consistent when the clip fixup and the XFB position mirror inject through gl_Position 2026-08-27 12:52:18 -04:00
swung0x48 01116f7b41 [Fix] (DirectVulkan): treat an incomplete sampled texture as unbound in the collect path too, and stop dereferencing a declined texture sync 2026-08-27 12:52:18 -04:00
swung0x48 05d627ba2d [Fix] (Xfb): never issue a transform feedback capture-point bind the application did not ask for, and error-check the driver span instead of losing it silently 2026-08-27 12:28:32 -04:00
swung0x48 ea5d52f126 [Test] (Tessellation): assert the patch-array cross-stage link outright now that the fork pin carries the guard 2026-08-27 10:52:00 -04:00
swung0x48 3c70b4fc0f [Fix] (Program): read an API colour index of zero as no override, matching the IO resolver and ProgramInterface 2026-08-27 10:52:00 -04:00
swung0x48 b6d6316333 [Fix] (Blend): decline a GL_SRC1_* factor on an incapable GLES driver even when blending is off 2026-08-27 10:51:59 -04:00
swung0x48 3c9ab5a68f [Test] (Blend): make the dual-source scenario's capability gate skip the case, not just the helper 2026-08-27 10:48:22 -04:00
swung0x48 532b5e9cc5 [Fix] (Program): let two fragment outputs share a colour number when their colour index differs 2026-08-27 10:48:22 -04:00
swung0x48 06605ed0ea [Test] (Tessellation): include the standard headers the link test uses directly 2026-08-27 10:48:21 -04:00
swung0x48 e1d5bdc4a5 [Fix] (Blend): decline an unsupported dual-source blend instead of throwing through the GL ABI 2026-08-27 10:48:21 -04:00
swung0x48 66867a41ba [Fix] (Program): count the tessellation control stage as a transform-feedback capture stage 2026-08-27 10:48:20 -04:00
swung0x48 ebff4b21f7 [Fix] (Query): tie the two tessellation pipeline-statistics targets to the GL_ARB_tessellation_shader advertisement 2026-08-27 10:48:20 -04:00
swung0x48 d4e7378868 [Chore] (ShaderTranspiler): pin glslang fork at d89cf443 (patch-array io-resize guard for the evaluation stage) 2026-08-27 10:22:37 -04:00
122 changed files with 18558 additions and 547 deletions
+3 -3
View File
@@ -44,12 +44,12 @@ require 'key:MOBILEGL_BACKEND_TYPE' "$plugin_resource_text" 'V2 backend variable
require 'defaultValue:DirectGLES' "$plugin_resource_text" 'V2 DirectGLES default'
require 'DirectVulkan' "$plugin_resource_text" 'V2 DirectVulkan option'
require 'key:MOBILEGL_DISABLE_TIMERQUERY' "$plugin_resource_text" 'V2 timer-query toggle'
require 'key:MOBILEGL_DISABLE_SUBGROUP' "$plugin_resource_text" 'V2 Vulkan subgroup toggle'
require 'key:MOBILEGL_MAGMA_DISABLE_SUBGROUP' "$plugin_resource_text" 'V2 Vulkan subgroup toggle'
require 'key:MOBILEGL_MAGMA_R11G11B10F_FALLBACK' "$plugin_resource_text" 'V2 Magma format fallback toggle'
require 'key:MOBILEGL_MAGMA_FRAMESINFLIGHT' "$plugin_resource_text" 'V2 Magma frames-in-flight setting'
require 'key:MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER' "$plugin_resource_text" 'V2 sampler workaround toggle'
require 'key:MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER' "$plugin_resource_text" 'V2 sampler workaround toggle'
require 'key:MOBILEGL_COHERENT_AS_FLUSH' "$plugin_resource_text" 'V2 coherent-as-flush toggle'
require 'key:MOBILEGL_USE_ANGLE' "$plugin_resource_text" 'V2 ANGLE toggle'
require 'key:MOBILEGL_ESPRYT_USE_ANGLE' "$plugin_resource_text" 'V2 ANGLE toggle'
if [[ $(grep -Fc 'fclPlugin_V2' <<<"$plugin_manifest") -ne 1 ]]; then
echo '::error::Plugin manifest must expose exactly one V2 descriptor' >&2
+4 -4
View File
@@ -417,12 +417,12 @@ jobs:
- name: Retrace and validate
env:
MOBILEGL_USE_ANGLE: ${{ matrix.backend.name == 'DirectGLES' && '1' || '0' }}
MOBILEGL_ESPRYT_USE_ANGLE: ${{ matrix.backend.name == 'DirectGLES' && '1' || '0' }}
MOBILEGL_TRACE_ANGLE_VARIANT: ${{ matrix.case.name == 'minecraft-1.21.4-fabric-iris-bliss-in-world' && '90a62123d794' || 'ec889e6ea831' }}
MOBILEGL_MAGMA_R11G11B10F_FALLBACK: ${{ matrix.backend.name == 'DirectVulkan' && '1' || '0' }}
MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_DERIVE_NUM_SUBGROUPS: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_ITERATIONRP_FIX_BARRIER: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
run: |
apk_file="android-retrace-apks/MobileGL-plugin-trace-release-${GITHUB_SHA}.apk"
test -f "${apk_file}"
+16 -6
View File
@@ -265,16 +265,26 @@ jobs:
# crash stack without burning a CI round on an in-workflow debugger.
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
MOBILEGL_DERIVE_NUM_SUBGROUPS: "1"
MOBILEGL_ITERATIONRP_FIX_BARRIER: "1"
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1"
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1"
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
# Second, filtered pass: with the range-invalidating map flush disabled,
# the buffer scenarios run on the upload ring's staged-copy tier - which
# the default pass never reaches (the map tier absorbs every flush on
# Mesa), so without this the Mali fallback tier would have zero CI
# coverage. The flag is NOT baked into the ctest ENVIRONMENT properties,
# so an inline env reaches the test processes (unlike the ICD pin above).
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L integration-gpu --no-tests=error
MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest -V -L integration-gpu \
-R 'Buffer|Readback|Atomic|Ssbo|Arena' --no-tests=error
else
ctest --output-on-failure -L integration-gpu --no-tests=error
MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest --output-on-failure -L integration-gpu \
-R 'Buffer|Readback|Atomic|Ssbo|Arena' --no-tests=error
fi
- name: Upload core dumps
@@ -644,9 +654,9 @@ jobs:
fi
if [ '${{ matrix.backend }}' = 'DirectVulkan' ] \
&& [ '${{ matrix.case }}' = 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' ]; then
export MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1
export MOBILEGL_DERIVE_NUM_SUBGROUPS=1
export MOBILEGL_ITERATIONRP_FIX_BARRIER=1
export MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1
export MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS=1
export MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER=1
fi
# The blended depth-write quirk auto-enables only on Qualcomm, which no CI
# runner has, so force it on for the OIT case it exists to fix. ForceOn
+4
View File
@@ -285,6 +285,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
@@ -306,13 +307,16 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp
MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp
MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp
MobileGL/MG_Util/Texture/PixelStoreProcessor.cpp
MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp
+104 -25
View File
@@ -69,34 +69,34 @@ namespace MobileGL::MG_Config {
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
// MOBILEGL_ENABLE_GLES_TEXTURE_VIEW: advertise GL_ARB_texture_view on DirectGLES when
// MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW: advertise GL_ARB_texture_view on DirectGLES when
// the host ES driver has EXT/OES_texture_view. Off by default: the host extension is
// present on Adreno 830 and the functional half of KHR-GL4{2,3}.texture_view still fails
// there, because the view's ES internalformat is normalized independently of the storage
// it aliases (see BackendObject_DirectGLES::BuildAdvertisedExtensions). The flag exists
// so that work can be done without editing the gate.
Bool EnableGlesTextureView = false;
Bool EsprytEnableTextureView = false;
// MOBILEGL_ENABLE_SPIRV_VALIDATION: validate generated and transformed SPIR-V.
// Disabled by default because validation is a diagnostics-only cost.
Bool EnableSpirvValidation = false;
// MOBILEGL_USE_ANGLE: load ANGLE EGL/GLES libraries.
Bool UseAngle = false;
// MOBILEGL_ESPRYT_USE_ANGLE: load ANGLE EGL/GLES libraries.
Bool EsprytUseAngle = false;
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS)
// MOBILEGL_TRACE_ANGLE_VARIANT: signed trace-APK ANGLE build short hash.
String TraceAngleVariant;
#endif
// MOBILEGL_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support,
// MOBILEGL_MAGMA_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support,
// including the opt-in emulated compute path below.
Bool DisableSubgroup = false;
Bool MagmaDisableSubgroup = false;
// MOBILEGL_MAGMA_EMULATE_SUBGROUP: implement GL_KHR_shader_subgroup's compute
// stage on a 32-lane VIRTUAL subgroup lowered to workgroup-shared memory
// (ShaderTranspiler::EmulateSubgroupsPass). Strictly a last resort: it only ever
// engages when this flag is set AND the device has no native subgroup support at
// all - a device with real subgroup operations always uses them natively,
// whatever their width (the known iterationRP defect is patched by
// FixIterationRPSubgroupScratch below instead). Off by default.
// MagmaFixIterationRPSubgroupScratch below instead). Off by default.
Bool MagmaEmulateSubgroup = false;
// MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: patch iterationRP's own bug - the
// MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: patch iterationRP's own bug - the
// pack declares `shared vec2 prefixSumCache[32]` for a 512-invocation exposure
// reduction and indexes it by gl_SubgroupID, so any device with sub-16-lane
// subgroups (8-lane lavapipe -> 64 subgroups) writes shared memory out of
@@ -106,12 +106,12 @@ namespace MobileGL::MG_Config {
// so every other shader passes through byte-identical - as does iterationRP
// itself on >= 16-lane devices. Auto is ON; ForceOff replays the pack's bug
// verbatim.
QuirkOverride FixIterationRPSubgroupScratch = QuirkOverride::Auto;
// MOBILEGL_ITERATIONRP_FIX_BARRIER: repair Program 203's missing workgroup
QuirkOverride MagmaFixIterationRPSubgroupScratch = QuirkOverride::Auto;
// MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: repair Program 203's missing workgroup
// rendezvous between its two reductions over prefixSumCache. Off by default and
// fingerprint-gated by FixIterationRPBarrierPass when enabled.
Bool IterationRPFixBarrier = false;
// MOBILEGL_DERIVE_NUM_SUBGROUPS: replace compute gl_NumSubgroups loads with
Bool MagmaIterationRPFixBarrier = false;
// MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: replace compute gl_NumSubgroups loads with
// ceil(workgroup invocations / gl_SubgroupSize) on the NATIVE subgroup path
// (ShaderTranspiler::DeriveNumSubgroupsPass). Auto is ON: GL requires
// gl_SubgroupID < gl_NumSubgroups, Adreno's builtin reports 1 while the same
@@ -119,7 +119,7 @@ namespace MobileGL::MG_Config {
// whenever the pipeline can request REQUIRE_FULL_SUBGROUPS (which the renderer
// does whenever local_size_x is a multiple of the native width). ForceOff returns
// to the raw driver builtin.
QuirkOverride DeriveNumSubgroups = QuirkOverride::Auto;
QuirkOverride MagmaDeriveNumSubgroups = QuirkOverride::Auto;
// MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension
// string. `double` in a shader always WORKS - it is narrowed to 32 bits before any
// module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension
@@ -132,16 +132,39 @@ namespace MobileGL::MG_Config {
Bool MagmaR11G11B10FFallback = false;
// MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3.
Uint32 MagmaFramesInFlight = 3;
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// resolves certain rendering bugs on ANGLE + llvmpipe.
Bool AvoidSamplerMipmapMinFilter = false;
// MOBILEGL_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
Bool EsprytAvoidSamplerMipmapMinFilter = false;
// MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
// emulating GL_TEXTURE_LOD_BIAS, instead of adding the bias uniform to it. Injecting
// the uniform turns a compile-time-constant LOD into a runtime expression, which
// sends ANGLE + llvmpipe down a mip-selection path that dereferences a NULL
// descriptor and kills the process. Deviates from spec (Vulkan adds the bias to
// OpImageSampleExplicitLod), so it is an avoidance for that stack only.
Bool AvoidExplicitLodBias = false;
Bool EsprytAvoidExplicitLodBias = false;
// MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS: emit a tessellation/geometry program's
// inter-stage interface blocks WITHOUT their layout(location=) qualifier, letting ES
// match them by block name and member sequence instead. The Mali ES driver delivers
// nothing at all through a located block once a tessellation or geometry stage is in
// the pipeline; the driver POST measures that and turns this on by itself, so Auto is
// the right setting everywhere. ForceOn exists so the emulation can be exercised on a
// healthy driver - which is what the integration lane does, since llvmpipe and
// lavapipe carry a located block correctly and would otherwise never run this code -
// and ForceOff is the negative control. See StripIoBlockLocationsPass.
QuirkOverride EsprytUnlocatedIoBlocks = QuirkOverride::Auto;
// MOBILEGL_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs
// on a device that advertises neither EXT/OES_tessellation_point_size /
// geometry_point_size (DirectGLES) nor shaderTessellationAndGeometryPointSize
// (DirectVulkan). Auto arms it exactly where the detection says the capability is
// absent, which is the right setting everywhere. ForceOn exists so the demotion can
// be exercised on a healthy driver - llvmpipe and lavapipe host the built-in
// natively and would otherwise never run this code, which is what the pinned
// integration lane uses - and ForceOff restores the plain declines (escape hatch /
// negative control). Cross-backend by design: the demotion runs in the shared
// phase-B chain, so one switch covers both. See DemotePointSizePass.
QuirkOverride PointSizeDemotion = QuirkOverride::Auto;
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
@@ -151,15 +174,38 @@ namespace MobileGL::MG_Config {
Bool CoherentAsFlush = false;
// MOBILEGL_TRACE_SKIP_AUTODESTROY: skip teardown in the ELF destructor (Init.cpp).
Bool TraceSkipAutodestroy = false;
// MOBILEGL_DISABLE_UBO_RING: force the DirectGLES global-UBO upload back to the
// MOBILEGL_ESPRYT_DISABLE_UBO_RING: force the DirectGLES global-UBO upload back to the
// per-draw glBufferSubData path instead of the persistent-mapped ring allocator
// (negative control / driver-bug escape hatch).
Bool DisableUboRing = false;
// MOBILEGL_DISABLE_UNPACK_RING: force DirectGLES texture uploads back to
Bool EsprytDisableUboRing = false;
// MOBILEGL_ESPRYT_DISABLE_UNPACK_RING: force DirectGLES texture uploads back to
// glTexSubImage from the client pointer instead of staging them through the
// persistent-mapped unpack-PBO ring (negative control / driver-bug escape
// hatch).
Bool DisableUnpackRing = false;
Bool EsprytDisableUnpackRing = false;
// MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING: force DirectGLES app buffer updates
// (glBufferSubData / map flushes) back to the immediate driver upload instead
// of queueing them for the staged-copy flush through the persistent-mapped
// upload ring (negative control / driver-bug escape hatch; the immediate
// upload stalls on drivers that resolve the WAR hazard on the CPU, e.g. Mali).
Bool EsprytDisableUploadRing = false;
// MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH: skip the glMapBufferRange(WRITE |
// INVALIDATE_RANGE) tier of the DirectGLES pending-range flush and go straight
// to the upload ring's staged glCopyBufferSubData (negative control / escape
// hatch for a driver whose range-invalidating map misbehaves). The map tier is
// what keeps a partial write into a large in-flight buffer priced by the RANGE:
// on Mali both the immediate glBufferSubData and a staged copy into a busy
// mutable store ghost the whole destination on the CPU.
Bool EsprytDisableInvalidateFlush = false;
// MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION: keep mesh-arena-sized buffer stores
// (>= 16MiB) on the CPU-shadow model instead of backing them with the backend's
// persistently+coherently mapped storage at definition time (negative control /
// escape hatch). Frontend-scoped: it engages only where the active backend
// provides AcquirePersistentMap. With adoption on, an app SubData into a busy
// 128MB arena is a plain memcpy into GPU-visible memory; every driver-mediated
// route for the same write stalls the thread or ghost-copies the whole arena on
// this class of Mali driver, and the arena stops costing its size again in RAM.
Bool DisableLargeBufferAdoption = false;
// MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES
// depth/stencil reads and always go through the shader-sampling emulation. Core GL
// ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does,
@@ -180,10 +226,10 @@ namespace MobileGL::MG_Config {
// gl_FragDepth writers, and fully color-masked attachments are exempt (see
// PipelineFactory::ShouldSuppressDepthWrite). Auto detects Qualcomm.
QuirkOverride MagmaDisableBlendedDepthWriteQuirk = QuirkOverride::Auto;
// MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS: leave the Vulkan robustBufferAccess device
// MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS: leave the Vulkan robustBufferAccess device
// feature off. It is enabled by default to match GL's defined out-of-range fetch
// behavior; this escape hatch exists to measure or dodge its GPU cost on a device.
Bool DisableRobustBufferAccess = false;
Bool MagmaDisableRobustBufferAccess = false;
// MOBILEGL_MAGMA_MULTIDRAW_MODE: preferred DirectVulkan multi-draw dispatch tier
// ("ext" | "indirect" | "unroll", see MultiDrawMode). Clamped to device support;
// unset picks the best supported tier.
@@ -224,7 +270,7 @@ namespace MobileGL::MG_Config {
// miscompiled shader: if a device ever renders differently with the cache
// on, one run with this falsy says so.
QuirkOverride ShaderTranslationCache = QuirkOverride::Auto;
// MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION: DirectGLES' gl_ViewportIndex routing
// MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION: DirectGLES' gl_ViewportIndex routing
// emulation - the builtin becomes a flat varying, the fragment stage gets a
// per-pass gate, and a routed draw is REPLAYED once per distinct viewport state
// with the real glViewport/glScissor/glDepthRangef set for it. Auto is ON, and
@@ -236,7 +282,40 @@ namespace MobileGL::MG_Config {
// the pre-emulation path, extension passthrough where it exists and
// LowerViewportIndexPass' demote-to-a-plain-global where it does not - and is
// the negative control the emulation is measured against.
QuirkOverride ViewportArrayEmulation = QuirkOverride::Auto;
QuirkOverride EsprytViewportArrayEmulation = QuirkOverride::Auto;
// MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE: DirectGLES stores GL_RGB565/GL_RGB5(A1)/GL_RGBA4
// images as 8-bit-per-channel ES storage (GL_RGB8/GL_RGBA8) instead of the driver's
// native 16-bit packed formats. Auto defers to a POST driver-bug probe
// (SelfTest::CopyImageMirrorsPacked16FieldOrder): some Mali drivers store SOME
// packed16 allocations with a MIRRORED field order (allocation-scoped and
// shape/context dependent - the failing 30x30x12 GL_TEXTURE_2D_ARRAYs are mirrored
// at every level), so glCopyImageSubData - a raw texel-block move - lands R/G/B/A
// reversed whenever exactly one endpoint sits in a mirrored allocation
// (KHR-GL4x.copy_image.functional rgb5/rgb5_a1/rgba4 x every *2d_array* pair).
// With no 16-bit packed ES image left there is no field order to disagree about; the
// client word still round-trips exactly, because the canonical shadow is already
// UNorm8 and an n-bit field encodes to UNorm8 and back losslessly for n <= 8.
// ForceOn widens on any driver (the llvmpipe suites use it to exercise the widened
// path); ForceOff keeps the native narrow storage even where the probe fires - the
// negative control that replays the corruption. Costs 2x the memory of the affected
// formats where it engages, which is why Auto is probe-gated rather than always-on.
QuirkOverride EsprytWidenPacked16Storage = QuirkOverride::Auto;
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE: DirectVulkan's GL_PRIMITIVES_GENERATED
// reroute for draws made while transform feedback is INACTIVE. The stream query
// (VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT primitivesNeeded) is defined to count
// them, but a Mali driver - and Mesa lavapipe - answers 0 unless a capture span is
// open, which is exactly the shape the CTS uses to measure the tessellator, so ~29
// tessellation tests per tree size a capture buffer from the 0 and die on the
// zero-length map. Auto defers to a device probe at renderer bring-up
// (SelfTest::RunPrimitivesGeneratedNoXfbProbe), which measures two substitutes on
// the same capture-less draws and arms the best proven one: the dedicated
// VK_EXT_primitives_generated_query (exact semantics by definition; lavapipe passes
// it, rasterizer discard included), else a clipping-invocations pipeline-statistics
// pool (see the verdict vocabulary for its rasterizer-discard split). ForceOn pins
// the reroute structurally wherever a pool can exist (the arming-observable lane,
// immune to the probe's verdict moving), and ForceOff is the negative control that
// replays the driver's silence.
QuirkOverride MagmaPrimGenQueryReroute = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+23 -15
View File
@@ -162,34 +162,39 @@ namespace MobileGL::MG_ConfigLoader {
inline void InitFeatures() {
auto& features = MG_Config::Features;
features.DisableTimerQuery = QueryEnvFlag("MOBILEGL_DISABLE_TIMERQUERY");
features.EnableGlesTextureView = QueryEnvFlag("MOBILEGL_ENABLE_GLES_TEXTURE_VIEW");
features.EsprytEnableTextureView = QueryEnvFlag("MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW");
features.EnableSpirvValidation = QueryEnvFlag("MOBILEGL_ENABLE_SPIRV_VALIDATION");
features.UseAngle = QueryEnvFlag("MOBILEGL_USE_ANGLE");
features.EsprytUseAngle = QueryEnvFlag("MOBILEGL_ESPRYT_USE_ANGLE");
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS)
QueryEnvVariable("MOBILEGL_TRACE_ANGLE_VARIANT", features.TraceAngleVariant, "");
#endif
features.DisableSubgroup = QueryEnvFlag("MOBILEGL_DISABLE_SUBGROUP");
features.MagmaDisableSubgroup = QueryEnvFlag("MOBILEGL_MAGMA_DISABLE_SUBGROUP");
features.MagmaEmulateSubgroup = QueryEnvFlag("MOBILEGL_MAGMA_EMULATE_SUBGROUP");
features.FixIterationRPSubgroupScratch =
QueryEnvQuirkOverride("MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
features.IterationRPFixBarrier = QueryEnvFlag("MOBILEGL_ITERATIONRP_FIX_BARRIER");
features.DeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_DERIVE_NUM_SUBGROUPS");
features.MagmaFixIterationRPSubgroupScratch =
QueryEnvQuirkOverride("MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
features.MagmaIterationRPFixBarrier = QueryEnvFlag("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER");
features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS");
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
features.AvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.AvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytAvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytUnlocatedIoBlocks = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
features.DisableUnpackRing = QueryEnvFlag("MOBILEGL_DISABLE_UNPACK_RING");
features.EsprytDisableUboRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UBO_RING");
features.EsprytDisableUnpackRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UNPACK_RING");
features.EsprytDisableUploadRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING");
features.EsprytDisableInvalidateFlush = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH");
features.DisableLargeBufferAdoption = QueryEnvFlag("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION");
features.EsprytForceDepthStencilReadbackEmulation =
QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION");
features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS");
features.MagmaDisableBlendedDepthWriteQuirk =
QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE");
features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS");
features.MagmaDisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS");
features.MagmaMultiDrawMode = QueryEnvMultiDrawMode("MOBILEGL_MAGMA_MULTIDRAW_MODE");
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
@@ -197,8 +202,11 @@ namespace MobileGL::MG_ConfigLoader {
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
features.ShaderTranslationCache = QueryEnvQuirkOverride("MOBILEGL_SHADER_CACHE");
features.ViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION");
features.EsprytViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION");
features.EsprytWidenPacked16Storage =
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE");
features.MagmaPrimGenQueryReroute = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE");
}
inline void InitBackendType() {
+18
View File
@@ -495,6 +495,24 @@ namespace MobileGL {
// halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat)
// still see one consistent world.
Bool SupportsFloat64VertexAttributes = false;
// Whether a TESSELLATION stage of this backend may access gl_PointSize - i.e.
// whether a module declaring OpCapability TessellationPointSize can reach the
// driver at all. DirectVulkan sets both this and the geometry twin from the one
// shaderTessellationAndGeometryPointSize feature; DirectGLES sets them
// independently from the EXT/OES_tessellation_point_size /
// geometry_point_size extension pairs (PointSizeTier), which really do come
// separately. When absent, ProgramSpirvTask demotes the built-in to an ordinary
// varying program-wide (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram); MOBILEGL_POINT_SIZE_DEMOTION
// overrides the detection in either direction at backend init.
//
// Defaults TRUE, deliberately against the house "assume absent" rule: false
// ARMS a rewrite, so the conservative no-backend answer (standalone compiles,
// unit tests) is the one that leaves modules untouched. A backend that never
// sets it gets standard modules and, at worst, the old honest declines.
Bool SupportsTessellationPointSize = true;
// The geometry-stage twin (OpCapability GeometryPointSize).
Bool SupportsGeometryPointSize = true;
SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024;
Uint32 SubgroupSize = 0;
Uint32 SubgroupSupportedStages = 0;
@@ -1198,8 +1198,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// Until that reconciliation exists, advertising here would be the same lie the comment
// above refuses to tell, just with an extra prerequisite met. Set
// MOBILEGL_ENABLE_GLES_TEXTURE_VIEW=1 to re-enable it for that work.
if (textureViewSupported && MG_Config::Features.EnableGlesTextureView) {
// MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 to re-enable it for that work.
if (textureViewSupported && MG_Config::Features.EsprytEnableTextureView) {
extensions.push_back(E_GL_ARB_texture_view);
}
// Only advertised when the host ES driver actually filters anisotropically: the sampler
@@ -1479,6 +1479,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Follows the line above, and must: OpenGL ES has no double-precision vertex format and no
// fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to land here.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
// Whether a tessellation / geometry stage's ESSL may name gl_PointSize at all: the two
// extension pairs the loader probed, independently, because they really do come
// separately. False arms the shared phase-B demotion
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), whose ESSL then
// never names the built-in in those stages and needs no extension.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends both are absent so the demotion can be
// exercised on a healthy driver (the pinned integration lane); =0 restores the
// detected answer's declines.
m_dynamicParameters.SupportsTessellationPointSize =
m_GLESCapabilities.TessellationPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
m_dynamicParameters.SupportsGeometryPointSize =
m_GLESCapabilities.GeometryPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
m_dynamicParameters.SupportsTessellationPointSize = false;
m_dynamicParameters.SupportsGeometryPointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the driver's extensions");
m_dynamicParameters.SupportsTessellationPointSize = true;
m_dynamicParameters.SupportsGeometryPointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers;
m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments;
m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances;
+318 -38
View File
@@ -393,6 +393,65 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// The capture points the CAPTURE PROGRAM uses, and nothing else.
//
// This used to go through SyncBufferBindingPoints, which walks the application's
// GLOBAL touched-binding-point high-water mark and binds 0 to every point with no
// frontend buffer. deqp/glcts permanently raises that mark to
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS by clearing all of them after each test
// case, so every capture using fewer points than that - i.e. every INTERLEAVED_ATTRIBS
// capture - had glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, 0) issued for the
// unused tail immediately before glBeginTransformFeedback. The Mali G1-Ultra driver
// then recorded NOTHING: no GL error, GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN 0, the
// application's buffer left holding its pre-draw bytes. Confirmed on device - the
// separate/interleaved split in KHR-GL46.transform_feedback follows exactly whether
// all four points were left bound.
//
// Those binds were never needed for correctness either. A capture only writes the
// points the program's buffer mode uses (GL 4.6 core 13.2.2), so a point past
// bufferCount cannot be written whatever is left bound there, and a point the program
// DOES use with no buffer bound is already an error the frontend raised at
// glBeginTransformFeedback. The rule this encodes: never issue a capture-point bind
// the application did not ask for.
//
// Scoping it to the program (rather than skipping redundant binds behind the shadow)
// is what makes it ORDER-INDEPENDENT: the shadow has to drop to unknown whenever a
// transform feedback OBJECT is bound, since the points belong to the object, and the
// clears came straight back for the next capture in the process.
void SyncTransformFeedbackBindingPoints(SizeT bufferCount) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
const SizeT pointCount = std::min<SizeT>(
bufferCount, MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS);
for (SizeT i = 0; i < pointCount; ++i) {
auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, i);
const auto& obj = point.GetBoundObject();
// A stride-0 slot (two consecutive gl_NextBuffer entries) captures nothing and
// needs no binding; anything else with no buffer never got past the frontend.
if (!obj) continue;
auto* backendResource = EnsureBufferResource(obj);
if (!backendResource || backendResource->id == 0) {
MGLOG_E_ONCE("No backend buffer for GL_TRANSFORM_FEEDBACK_BUFFER capture point %zu; the capture "
"will not reach the application's buffer.",
i);
continue;
}
const auto& range = point.GetRange();
const auto backendBufferId = backendResource->id;
if (range.start == 0 && range.end >= obj->GetSize()) {
BindBufferBaseCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(i), backendBufferId);
} else {
const auto start = std::min(range.start, obj->GetSize());
const auto end = std::min(range.end, obj->GetSize());
BindBufferRangeCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(i), backendBufferId,
static_cast<GLintptr>(start), static_cast<GLsizeiptr>(end - start));
}
}
}
// Called once the storage-buffer points are bound and the draw/dispatch is about to
// go out: whatever the shader writes there lands in the ES driver's buffers, behind
// the frontend's CPU shadow. Flagging them makes the next MapBuffer/GetBufferSubData
@@ -654,6 +713,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint backendId = 0;
SizeT start = 0;
SizeT end = 0;
// WHICH capture buffer of the program this is. The list is COMPACTED - a
// capture buffer with no bound buffer object contributes no entry - so the
// position in the vector is not the program's buffer index, and everything
// that asks the program about a target (its stride, which varyings land in
// it) has to ask about this index instead. A capture list beginning with
// gl_NextBuffer is the shape that makes them differ: buffer 0 has stride 0
// and nothing bound, so target 0 describes buffer 1.
SizeT bufferIndex = 0;
};
// Per frontend transform feedback object. The default object (name 0) maps to
@@ -698,6 +765,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
return *g_currentXfbState;
}
// EVERY way this path can lose a capture used to be silent: three unlogged early
// returns before the driver Begin, an unchecked glBeginTransformFeedback, and two
// `continue`s in the readback. The application sees a buffer that kept its
// pre-draw bytes, GL_NO_ERROR, and GL_LINK_STATUS true - which is how one defect
// reached ~320 conformance bodies across four families before anyone could say
// which of the branches fired. Nothing below changes what MobileGL DOES on a
// healthy capture; it only makes a lost one name itself in /sdcard/MG/latest.log.
//
// MGLOG_E_ONCE (not _D) on purpose: these have to be readable in an INFO-level
// artifact, the same reason the backend link failure at Managers.cpp is MGLOG_E.
constexpr Int kMaxDrainedXfbErrors = 32;
// The ES error raised by the call just issued, GL_NO_ERROR if it succeeded. Drains
// the rest of the queue so the next probe cannot read this one as its own.
GLenum TakeXfbDriverError() {
const GLenum first = g_GLESFuncs.glGetError();
if (first == GL_NO_ERROR) return GL_NO_ERROR;
for (Int i = 0; i < kMaxDrainedXfbErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
return first;
}
Bool AreTransformFeedbackObjectsSupported() {
return g_GLESFuncs.glGenTransformFeedbacks != nullptr &&
g_GLESFuncs.glBindTransformFeedback != nullptr &&
@@ -712,6 +801,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the backend already owns (coherent persistent map) need nothing: reads resolve
// against that storage directly.
void ReadbackCapturedRanges(Vector<XfbCaptureTarget>& targets) {
if (g_GLESFuncs.glMapBufferRange == nullptr || g_GLESFuncs.glUnmapBuffer == nullptr) {
MGLOG_E_ONCE("EndTransformFeedback: the ES driver exposes no glMapBufferRange/glUnmapBuffer, so "
"captured data can never reach the application's buffers");
}
if (targets.empty()) {
// The span closed with nothing to mirror back. Either the deferred Begin
// never ran (a span with no draw - legal) or it ran and found no bound
// capture buffer, which is not.
MGLOG_D("EndTransformFeedback: capture span closed with no recorded targets");
}
if (g_GLESFuncs.glMapBufferRange != nullptr && g_GLESFuncs.glUnmapBuffer != nullptr) {
for (const auto& target : targets) {
if (!target.buffer || target.buffer->IsBackendPersistentMapped()) continue;
@@ -721,8 +820,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
static_cast<GLintptr>(target.start),
static_cast<GLsizeiptr>(size), GL_MAP_READ_BIT);
if (mapped == nullptr) {
MGLOG_E_ONCE("EndTransformFeedback: failed to map backend buffer %u for capture readback",
target.backendId);
// Silent before: the capture landed in the ES buffer and the
// application's next glMapBuffer read the untouched shadow, which
// is indistinguishable from "the draw wrote nothing".
MGLOG_E_ONCE("EndTransformFeedback: failed to map backend buffer %u [%zu, %zu) for "
"capture readback (ES error %s); the captured data will NOT be visible to "
"the application",
target.backendId, target.start, target.end,
MG_Util::ConvertGLEnumToString(TakeXfbDriverError()).c_str());
continue;
}
target.buffer->WritebackFromBackend({mapped, size}, target.start);
@@ -755,15 +860,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
GL_DYNAMIC_COPY);
g_scatterBufferSize = required;
}
// Point 0 carries every captured varying (the ES capture is INTERLEAVED); the
// other points must be cleared or the driver would still write the app's buffers.
// Point 0 carries every captured varying: the gl_NextBuffer / gl_SkipComponents
// entries are consumed at link time and never reach the driver, so the ES
// program is declared INTERLEAVED over a single buffer and point 0 is the only
// point it can write (GL 4.6 core 13.2.2).
//
// The other points are therefore left exactly as they are. Clearing them - which
// this used to do, across the application's whole touched high-water mark - is
// both unnecessary (the ES program cannot write an unused point) and the precise
// trigger for the Mali G1-Ultra capture loss: see
// SyncTransformFeedbackBindingPoints for the mechanism and the device evidence.
// KHR-GL46.transform_feedback.capture_special_interleaved_test is the case that
// reaches this path.
BufferImpl::BindBufferRangeCached(GL_TRANSFORM_FEEDBACK_BUFFER, 0, g_scatterBufferId, 0,
static_cast<GLsizeiptr>(required));
const SizeT pointCount =
MG_State::pGLContext->GetTouchedBufferBindingPointCount(BufferTarget::TransformFeedback);
for (SizeT i = 1; i < pointCount; ++i) {
BufferImpl::BindBufferBaseCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<Uint>(i), 0);
}
return true;
}
@@ -777,10 +887,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_GLESFuncs.glMapBufferRange == nullptr || g_GLESFuncs.glUnmapBuffer == nullptr) return;
const SizeT packedStride = program->GetTransformFeedbackPackedStride();
const SizeT vertices = std::min<SizeT>(
static_cast<SizeT>(MG_State::pGLContext->GetTransformFeedbackCapturedVertices()),
xfb.scatterCapacityVertices);
if (packedStride == 0 || vertices == 0) return;
const SizeT modelledVertices =
static_cast<SizeT>(MG_State::pGLContext->GetTransformFeedbackCapturedVertices());
const SizeT vertices = std::min<SizeT>(modelledVertices, xfb.scatterCapacityVertices);
if (packedStride == 0 || vertices == 0) {
// The scatter path redirected the DRIVER's capture into the scratch buffer,
// so bailing here leaves the application's buffers holding their pre-draw
// bytes - a total data loss, not a no-op. The vertex count is the CPU model
// (AccountTransformFeedbackPrimitives), which is 0 for any draw mode
// CountPrimitivesForDraw does not know and for the instanced/indirect entry
// points that never call it.
MGLOG_E_ONCE("EndTransformFeedback: scattered capture discarded - packedStride=%zu, "
"CPU-modelled captured vertices=%zu, scratch capacity=%zu. The capture buffers keep "
"their pre-draw contents.",
packedStride, modelledVertices, xfb.scatterCapacityVertices);
return;
}
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, g_scatterBufferId);
const void* packed = g_GLESFuncs.glMapBufferRange(BufferImpl::TempBufferTarget, 0,
@@ -798,14 +920,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (SizeT targetIndex = 0; targetIndex < xfb.targets.size(); ++targetIndex) {
const auto& target = xfb.targets[targetIndex];
if (!target.buffer) continue;
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(targetIndex));
// By BUFFER index, not by position in the compacted list - see XfbCaptureTarget.
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(target.bufferIndex));
if (stride == 0) continue;
const SizeT rangeBytes = target.end - target.start;
Vector<Uint8> staged(rangeBytes);
Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes);
for (const auto& varying : program->GetTransformFeedbackVaryings()) {
if (varying.bufferIndex != targetIndex) continue;
if (varying.bufferIndex != target.bufferIndex) continue;
for (SizeT v = 0; v < vertices; ++v) {
const SizeT dstOffset = v * stride + varying.offsetBytes;
if (dstOffset + varying.byteSize > rangeBytes) break;
@@ -860,9 +983,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
// not captured, and opening the span would also subject it to the capture
// primitive-mode rule the paused draw is exempt from.
if (!xfb.pending || xfb.paused) return;
xfb.pending = false;
const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram();
if (!program) return;
if (!program) {
// The pending flag is deliberately NOT consumed here. It used to be cleared
// before this check, so a single draw that could not see the capture program
// retired the span permanently: every later draw of the same span found
// pending==false, the driver Begin never happened, and End found started==false
// and skipped the readback - a whole capture lost with no GL error anywhere.
// The frontend only reaches a draw with an active span after glBeginTransformFeedback
// stored a program, so this is a "cannot happen" that must stay recoverable.
MGLOG_E_ONCE("StartPendingTransformFeedback: an active capture span has no capture program; the "
"driver span stays closed and this draw is not captured");
return;
}
xfb.pending = false;
// Snapshot what the driver is about to capture into. GL forbids rebinding the
// capture buffers while the span is open, so this stays valid until End, and
@@ -879,10 +1013,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT start = std::min(range.start, bufferObject->GetSize());
const SizeT end = std::min(range.end, bufferObject->GetSize());
if (end <= start) continue;
xfb.targets.push_back({bufferObject, backendResource->id, start, end});
xfb.targets.push_back({bufferObject, backendResource->id, start, end, i});
}
BufferImpl::SyncBufferBindingPoints(BufferTarget::TransformFeedback, GL_TRANSFORM_FEEDBACK_BUFFER);
BufferImpl::SyncTransformFeedbackBindingPoints(bufferCount);
// A layout with holes or several interleaved buffers is not expressible on ES:
// capture gap-free into scratch storage and place the records at End instead.
@@ -891,31 +1025,99 @@ namespace MobileGL::MG_Backend::DirectGLES {
xfb.scatterCapacityVertices = 0;
if (program->NeedsScatteredTransformFeedbackCapture()) {
SizeT capacityVertices = ~SizeT(0);
for (SizeT i = 0; i < xfb.targets.size(); ++i) {
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(i));
for (const auto& target : xfb.targets) {
// By BUFFER index. Reading the stride at the target's POSITION made a
// capture list beginning with gl_NextBuffer - buffer 0 has stride 0 and
// nothing bound, so target 0 describes buffer 1 - read stride 0, skip every
// target, and leave the capacity at zero.
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(target.bufferIndex));
if (stride == 0) continue;
capacityVertices =
std::min<SizeT>(capacityVertices, (xfb.targets[i].end - xfb.targets[i].start) / stride);
capacityVertices = std::min<SizeT>(capacityVertices, (target.end - target.start) / stride);
}
if (capacityVertices == ~SizeT(0)) capacityVertices = 0;
if (BindScatterCaptureBuffer(program->GetTransformFeedbackPackedStride(), capacityVertices)) {
xfb.scattered = true;
xfb.scatterProgram = program;
xfb.scatterCapacityVertices = capacityVertices;
} else {
// NO SPAN RATHER THAN A SPAN THAT WRITES SOMEWHERE ELSE. The ES program for a
// scattered capture is a single-buffer INTERLEAVED one (the gl_NextBuffer /
// gl_SkipComponents entries are consumed at link time and never reach the
// driver), so it writes capture point 0 and nothing else. Point 0 here is
// either unbound or - the dangerous case - still holds whatever an earlier
// capture in this process bound there, because the frontend's own
// glBindBufferBase is state-only and nothing else in the backend touches the
// indexed points. Opening the span would then have the driver capture over an
// application buffer that has nothing to do with this draw, and the frontend
// shadow would never learn of it.
//
// Leaving the span closed reproduces exactly what the old high-water clear
// loop achieved by binding 0 here and letting the driver refuse the Begin -
// the capture records nothing - without issuing a capture-point bind the
// application did not ask for, which is the thing that loses captures whole
// on Mali (see SyncTransformFeedbackBindingPoints).
MGLOG_E_ONCE("StartPendingTransformFeedback: no scratch storage for a scattered capture "
"(packed stride %zu, capacity %zu vertices); leaving the driver span CLOSED so the "
"capture cannot land in a stale binding. Nothing will be captured.",
program->GetTransformFeedbackPackedStride(), capacityVertices);
xfb.targets.clear();
return;
}
}
// A capture program with buffers bound must have produced at least one target;
// an empty list means End has nothing to mirror back and the application will
// read its buffer's pre-draw bytes however well the GPU captured.
if (xfb.targets.empty()) {
MGLOG_E_ONCE("StartPendingTransformFeedback: opening a capture span with NO capture targets "
"(program declares %zu capture buffer(s), none of them resolved to a bound backend "
"buffer with a non-empty range); nothing will be read back",
bufferCount);
}
g_GLESFuncs.glBeginTransformFeedback(xfb.primitiveMode);
// Unchecked before. Every ES error condition here (already active, a current
// program with no capture set, a capture point the program uses with no buffer)
// ends the same way: the driver records nothing, GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
// reads 0 and the application sees no error at all - MobileGL's own error state is
// separate from the driver's, so a driver rejection here is invisible to it.
if (const GLenum beginError = TakeXfbDriverError(); beginError != GL_NO_ERROR) {
// The mode is printed as a number as well as a name: GL_POINTS is 0, which the
// enum converter spells "GL_FALSE", and a reader chasing a lost capture should
// not have to know that.
MGLOG_E_ONCE("StartPendingTransformFeedback: the ES driver REJECTED "
"glBeginTransformFeedback(%s / 0x%04x) with %s - nothing will be captured. Backend "
"program %u, %zu capture buffer(s), %zu target(s), mode=%s.",
MG_Util::ConvertGLEnumToString(xfb.primitiveMode).c_str(),
static_cast<unsigned>(xfb.primitiveMode),
MG_Util::ConvertGLEnumToString(beginError).c_str(),
PrgramImpl::g_lastUsedBackendProgramId, bufferCount,
xfb.targets.size(),
MG_Util::ConvertGLEnumToString(program->GetTransformFeedbackBufferMode()).c_str());
}
xfb.started = true;
}
void EndTransformFeedback() {
auto& xfb = CurrentXfb();
const Bool wasPending = xfb.pending;
xfb.pending = false;
xfb.paused = false;
if (!xfb.started) return;
if (!xfb.started) {
// A span that never drew is legal and captures nothing by definition; one that
// is STILL pending here drew nothing the backend saw, which for a span the
// application expected data from is the whole bug in one line.
MGLOG_D("EndTransformFeedback: closing a span the driver never opened (pending=%d)",
wasPending ? 1 : 0);
return;
}
xfb.started = false;
g_GLESFuncs.glEndTransformFeedback();
if (const GLenum endError = TakeXfbDriverError(); endError != GL_NO_ERROR) {
MGLOG_E_ONCE("EndTransformFeedback: the ES driver rejected glEndTransformFeedback with %s - the "
"driver's capture state and MobileGL's have diverged",
MG_Util::ConvertGLEnumToString(endError).c_str());
}
if (xfb.scattered) {
ScatterCapturedRecords(xfb);
xfb.scattered = false;
@@ -943,6 +1145,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BindTransformFeedback(GLuint name) {
g_currentXfbState = nullptr; // name changes; operator[] below may also rehash
// The capture buffer bindings are the OBJECT's, not the context's: the bind below
// swaps all of them for whatever the target object holds, which the redundant-bind
// shadow has never seen.
BufferImpl::InvalidateTransformFeedbackBindingShadows();
if (!AreTransformFeedbackObjectsSupported()) {
// Without driver objects there is only the default span; keep the frontend
// name so the bookkeeping below stays consistent.
@@ -979,6 +1185,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_currentXfbName = 0;
g_scatterBufferId = 0;
g_scatterBufferSize = 0;
BufferImpl::InvalidateTransformFeedbackBindingShadows();
}
} // namespace XfbImpl
@@ -1772,6 +1979,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// clear-then-draw pair on an unchanged parameter block early-outs and the draw inherits
// the clear's undoctored mask.
static Uint32 g_syncedColorMaskAlphaWidenMask = 0;
// Scratch for the dual-source-blend decline path in the blend block below. File-scope
// rather than a local so the ordinary draw pays nothing for it: it is written only on a
// driver with no GL_EXT_blend_func_extended that is also handed a GL_SRC1_* factor, and
// SyncRenderState runs on the GL thread only.
static Array<PerBufferBlendState, MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS>
g_dualSourceDeclinedBlendStates;
void InvalidateSyncedRenderState() {
g_forceFullRenderStateResync = true;
g_hasSyncedRenderState = false;
@@ -1931,31 +2144,85 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& ToGLBoolean = [](Bool b) -> GLboolean { return b ? GL_TRUE : GL_FALSE; };
// Which draw buffers the blend block below DECLINED (see it for why). Needed again at
// the shadow write-back at the end of this function: the span memcpy there clones the
// FRONTEND block, which for a declined draw buffer is not what the driver was handed.
Uint32 dualSourceDeclinedMask = 0;
if (blendSpanDirty) { // Blend State
using FBO = MG_State::GLState::FramebufferObject;
const auto& targetStates = parameters.BlendStates;
auto& syncedStates = g_syncedRenderStateParameters.BlendStates;
// Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with
// glBindFragDataLocationIndexed) needs GL_EXT_blend_func_extended; GLES core has none.
// Detected at load and surfaced in the POST. There is no fallback, so if a draw actually
// enables blending with a SRC1 factor on a driver that lacks it, hard-fail here at use
// time rather than let the driver reject glBlendFuncSeparate and silently mis-blend.
// Detected at load and surfaced in the POST. There is no fallback that BLENDS
// correctly, so a draw that asks for a SRC1 factor on a driver without the extension
// gets the blend DECLINED: that draw buffer is pushed with blending off and neutral
// One/Zero factors, and the loss is logged once. The two rejected alternatives are
// both worse - pushing GL_SRC1_* at glBlendFuncSeparate leaves the driver to raise
// GL_INVALID_ENUM and keep whatever factors were there before (a silent mis-blend
// against stale state), and throwing, which is what this did until now, takes the
// whole process down over one unsupported blend factor. Declining is defined,
// survivable and visible in the log.
//
// NOT gated on Enabled, deliberately, and the same way the Vulkan twin is not gated
// on effectiveBlendEnabled: what has to be kept away from the driver is the FACTOR
// ENUM, and the factor push below never consults Enabled - one glBlendFuncSeparate
// serves every draw buffer when they agree, and the per-index arm diffs factors
// alone. So `glDisable(GL_BLEND); glBlendFunc(GL_SRC1_ALPHA, ...)` followed by any
// draw OR clear would otherwise hand a GL_SRC1_ALPHA to a driver that answers
// GL_INVALID_ENUM, leaving a spurious error in ITS queue for the next internal
// no-error probe to read as its own, and leaving this shadow recording factors the
// ES context rejected. Blending being off makes the picture unaffected; it does not
// make the enum acceptable.
const auto* effectiveBlendStates = &parameters.BlendStates;
if (!g_GLESCapabilities.SupportsDualSourceBlend) {
Uint32 declinedWithBlendingOnMask = 0;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
const auto& s = targetStates[i];
if (s.Enabled &&
(IsDualSourceBlendFactor(s.SrcFactorRGB) || IsDualSourceBlendFactor(s.DstFactorRGB) ||
IsDualSourceBlendFactor(s.SrcFactorAlpha) || IsDualSourceBlendFactor(s.DstFactorAlpha))) {
THROW_EXCEPTION(
"Dual-source blending (GL_SRC1_* blend factor) was used on draw buffer " +
std::to_string(i) +
", but the GLES driver does not expose GL_EXT_blend_func_extended (see the "
"dual-source blend row in the driver POST). No fallback exists; the draw "
"cannot proceed.");
const auto& s = parameters.BlendStates[i];
if (IsDualSourceBlendFactor(s.SrcFactorRGB) || IsDualSourceBlendFactor(s.DstFactorRGB) ||
IsDualSourceBlendFactor(s.SrcFactorAlpha) || IsDualSourceBlendFactor(s.DstFactorAlpha)) {
dualSourceDeclinedMask |= 1u << i;
if (s.Enabled) declinedWithBlendingOnMask |= 1u << i;
}
}
if (dualSourceDeclinedMask != 0) {
// Two masks in the message because they mean different things to whoever
// reads the log: the second one is where a PICTURE was lost. A draw buffer
// in the first mask but not the second had blending off anyway, so nothing
// was blended and nothing was dropped - only the unusable enum was kept out
// of the driver.
MGLOG_E_ONCE(
"SyncRenderState: a GL_SRC1_* (dual-source) blend factor was set on draw buffer "
"mask 0x%x, but the GLES driver does not expose GL_EXT_blend_func_extended (see "
"the dual-source blend row in the driver POST). Those draw buffers are pushed "
"with neutral One/Zero factors instead. Blending was actually ENABLED on mask "
"0x%x, and only there is anything lost: the fragment's first output is written "
"unblended and the second source is dropped.",
dualSourceDeclinedMask, declinedWithBlendingOnMask);
g_dualSourceDeclinedBlendStates = parameters.BlendStates;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
if ((dualSourceDeclinedMask & (1u << i)) == 0) continue;
auto& s = g_dualSourceDeclinedBlendStates[i];
// Both halves, for the same reason the Vulkan arm neutralises both: the
// enable so nothing blends against a source the driver cannot produce,
// the factors so no GL_SRC1_* enum is ever handed over. Clearing Enabled
// on a buffer that was already off is a no-op, which is what makes one
// ungated rule serve both cases.
s.Enabled = false;
s.SrcFactorRGB = BlendFactor::One;
s.DstFactorRGB = BlendFactor::Zero;
s.SrcFactorAlpha = BlendFactor::One;
s.DstFactorAlpha = BlendFactor::Zero;
}
effectiveBlendStates = &g_dualSourceDeclinedBlendStates;
}
}
// The rest of the block reads the EFFECTIVE state. The per-field writes it makes
// into `syncedStates` are provisional - the span memcpy at the end of this function
// overwrites the whole blend span with the frontend's own bytes - so the declined
// draw buffers are put back there, see the write-back below.
const auto& targetStates = *effectiveBlendStates;
Bool allEnabled = true;
Bool allDisabled = true;
@@ -2355,6 +2622,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (blendSpanDirty) {
std::memcpy(syncedBytesMut + kBlendSpanBegin, currentBytes + kBlendSpanBegin,
kBlendSpanEnd - kBlendSpanBegin);
// ...except for a draw buffer whose dual-source blend was DECLINED, where the
// frontend block is precisely what did NOT reach the driver. The shadow has to hold
// what was pushed or the next diff compares against state the ES context never got:
// going from a SRC1 factor to an ordinary one leaves Enabled equal on both sides,
// the enable block finds nothing to do, and blending stays off from the decline.
// The span stays permanently "dirty" against the frontend as a result, which costs
// one memcmp plus this block per render-state VERSION change - the top-of-function
// version early-out still skips repeat draws entirely.
for (Uint i = 0; i < MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; ++i) {
if ((dualSourceDeclinedMask & (1u << i)) == 0) continue;
g_syncedRenderStateParameters.BlendStates[i] = g_dualSourceDeclinedBlendStates[i];
}
}
if (tailSpanDirty) {
std::memcpy(syncedBytesMut + kBlendSpanEnd, currentBytes + kBlendSpanEnd,
@@ -10357,6 +10636,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// frame's ring high-water marks for slot reclamation.
BufferImpl::UboRingOnPresent();
BufferImpl::UnpackRingOnPresent();
BufferImpl::UploadRingOnPresent();
BufferImpl::TrimBufferPool();
}
+594 -26
View File
@@ -54,7 +54,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the driver fifteen more rectangles to rasterize against, and nothing in MobileGL has ever
// programmed the indexed state it would need.
Bool ViewportArrayEmulationEnabled() {
return MG_Config::Features.ViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
return MG_Config::Features.EsprytViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
}
Bool g_anyProgramRoutesViewportIndex = false;
@@ -75,13 +75,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
static Bool ShouldAvoidSamplerMipmapMinFilterOnAngleLlvmpipe() {
// IsAngleLlvmpipeRenderer combined with the
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle,
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle,
// both resolved in FillInGLESCapabilities.
return g_GLESCapabilities.AvoidSamplerMipmapMinFilter;
}
static Bool ShouldAvoidExplicitLodBiasOnAngleLlvmpipe() {
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS
// feature toggle, both resolved in FillInGLESCapabilities.
return g_GLESCapabilities.AvoidExplicitLodBias;
}
@@ -648,6 +648,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT. 64 covers every type with room to
// spare and keeps consecutive staged blocks off each other's cache lines.
constexpr SizeT kUnpackRingAlignment = 64;
// glCopyBufferSubData carries no offset-alignment requirement at all; 64
// keeps staged blocks cache-line separated, same as the unpack ring.
constexpr SizeT kUploadRingInitialBytes = 4u * 1024u * 1024u;
constexpr SizeT kUploadRingMaxBytes = 64u * 1024u * 1024u;
constexpr SizeT kUploadRingAlignment = 64;
struct PersistentRingStore {
Uint id = 0;
@@ -705,6 +710,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
kUnpackRingMaxBytes,
kUnpackRingAlignment,
"Texture unpack ring"};
// Staging ring for app buffer updates whose destination store may still be
// referenced by in-flight GPU work. Mali's glBufferSubData resolves that WAR
// hazard by BLOCKING in the call (osup_sync_object_wait) until every
// referencing job retires - under Minecraft 26.3's per-frame UBO and
// chunk-mesh SubData streams that serialized whole frames (~1 fps while
// chunks stream in). Staging the bytes here and issuing a
// glCopyBufferSubData instead keeps the hazard on the GPU timeline where it
// is just job ordering, and the CPU never waits.
PersistentRing g_uploadRing{{},
{},
{},
kUploadRingInitialBytes,
kUploadRingMaxBytes,
kUploadRingAlignment,
"Buffer upload ring"};
// The ES context the ring's id/map belonged to is gone (or was never
// seen): drop every handle without GL calls and re-arm creation. The
@@ -763,6 +783,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource.storageInitialized = true;
resource.pendingRespecify = false;
resource.pendingRanges.clear();
resource.pendingResidentWrites.clear();
resource.syncedChangeSerial = bufferObject.GetChangeSerial();
// A GROWN store keeps its indexed bindings, and BindBufferBaseCached skips a
// rebind whenever the shadow already records this id at that index - so on a
@@ -794,6 +815,161 @@ namespace MobileGL::MG_Backend::DirectGLES {
bufferObject.MappedData() + start);
}
// Ring machinery shared with the UBO/unpack rings; defined further down in
// this same unnamed namespace.
Bool RingAllocate(PersistentRing& ring, SizeT size, SizeT& outOffset);
Bool RingAvailable(PersistentRing& ring);
// True when a pending-range flush can go through the staging ring right
// now: kill switch off, the ES copy entry point resolved, and the ring's
// own availability gate (EXT_buffer_storage + fences + live context) up.
Bool UploadRingUsableNow() {
if (MG_Config::Features.EsprytDisableUploadRing) return false;
if (!g_GLESFuncs.glCopyBufferSubData) return false;
return RingAvailable(g_uploadRing);
}
// A partial range below this goes through the staging ring instead of a
// range-invalidating map: the map's page-substitution fast path needs a
// sizeable (page-coverable) range to engage, and below it the driver
// falls back to waiting out the WAR hazard on the CPU.
constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u;
// Push every queued range of `resource` from the shadow into the backend
// store, without ever letting a driver resolve the WAR hazard against
// in-flight frames at the WHOLE BUFFER's expense. Three tiers:
//
// 1. glMapBufferRange(WRITE | INVALIDATE_RANGE) + memcpy. The entire
// mapped range is rewritten from the authoritative shadow, so
// declaring its old bytes dead is exact - and it lets the driver
// swap fresh pages in for JUST that range. This is the only tier
// whose cost scales with the RANGE on this Mali driver: both the
// immediate glBufferSubData (pre-queueing) and a staged
// glCopyBufferSubData into a busy MUTABLE store ghost the whole
// destination with a worker-thread memcpy - Minecraft 26.3 streams
// ~1MB section meshes into 128MB arenas about nine times a frame
// during a camera pan, and 9 x 128MB of ghosting per frame is
// ~380ms, the measured 2-4 fps. (Backing the arenas with immutable
// stores also kills the ghost, but eagerly commits every arena's
// full extent - +hundreds of MB - which LMK'd the whole device.)
// 2. The staging ring + glCopyBufferSubData: the copy is ordered on
// the GPU timeline, no CPU wait (MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH
// forces this tier as the map path's negative control).
// 3. Direct glBufferSubData (potentially stalling) when neither the
// map entry points nor the ring exist.
//
// The ranges are flushed AS QUEUED (VecRange1D::Add already merges
// near-adjacent ones): bytes, not flush calls, are the cost axis here,
// and collapsing a scattered flush into its union re-copied nearly whole
// chunk-mesh arenas every frame.
// The caller owns syncedChangeSerial; this only drains the queue.
void FlushPendingRangesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
VecRange1D ranges;
{
const std::lock_guard<std::mutex> lock(resource.pendingMutex);
if (resource.pendingRanges.empty()) return;
ranges = std::move(resource.pendingRanges);
resource.pendingRanges.clear();
}
// Clamp against BOTH extents: the readback flush may run while the
// frontend size and the backend store disagree (a pending respecify
// resolves that later; bytes past either end have nowhere to land).
const SizeT limit = std::min(bufferObject.GetSize(), resource.storageSize);
const Bool mapUsable = !MG_Config::Features.EsprytDisableInvalidateFlush &&
g_GLESFuncs.glMapBufferRange && g_GLESFuncs.glUnmapBuffer;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& range : ranges) {
const SizeT end = std::min(range.end, limit);
const SizeT start = std::min(range.start, end);
const SizeT size = end - start;
if (size == 0) continue;
// The invalidating map's fast path is SHAPE-dependent on this Mali
// driver: a whole-buffer invalidation renames the store outright,
// and a large range gets fresh pages - but a small unaligned range
// of a busy store makes the map WAIT (osup_sync_object_wait, ~9%
// of a Minecraft 26.3 replay). So: whole buffer -> orphan-map;
// large range -> range-invalidating map; small range -> the staged
// ring copy, whose worst case (a whole-destination ghost) is only
// ever the small destination itself.
//
// The map covers EXACTLY the queued range: only those bytes are the
// shadow's to rewrite. Widening to page bounds looked free and was
// not - the widened bytes clobbered GPU-written data (an SSBO
// counter beside the app's SubData) with the stale shadow.
const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize;
if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) {
BindBufferId(TempBufferTarget, resource.id);
const GLbitfield access =
GL_MAP_WRITE_BIT |
(wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT);
void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start,
(GLsizeiptr)size, access);
if (dst) {
Memcpy(dst, bufferObject.MappedData() + start, size);
g_GLESFuncs.glUnmapBuffer(TempBufferTarget);
continue;
}
}
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size);
BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id);
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)start, (GLsizeiptr)size);
} else {
UploadRangeNow(resource, bufferObject, start, end);
}
}
}
// Land the app bytes queued for an ADOPTED store on the GPU timeline: staged
// into the upload ring and delivered by glCopyBufferSubData. The destination
// is the IMMUTABLE persistent store, which the driver can neither rename nor
// ghost, so the copy is plain job ordering - after every in-flight reader,
// before the next consumer - which is exactly glBufferSubData's contract.
// (The in-place host write these bytes replaced tore the frames still
// reading the old vertex data: one-frame wrong geometry during fast camera
// movement.) Fallback: direct glBufferSubData - the adopted store carries
// DYNAMIC_STORAGE, and immutability again forbids the whole-store ghost.
void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
Vector<GLESBufferResource::PendingResidentWrite> writes;
{
const std::lock_guard<std::mutex> lock(resource.pendingMutex);
if (resource.pendingResidentWrites.empty()) return;
writes = std::move(resource.pendingResidentWrites);
resource.pendingResidentWrites.clear();
}
const SizeT limit = resource.storageSize;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& write : writes) {
if (write.offset >= limit) continue;
const SizeT size = std::min(write.bytes.size(), limit - write.offset);
if (size == 0) continue;
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
Memcpy(g_uploadRing.store.mappedPtr + ringOffset, write.bytes.data(), size);
BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id);
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)write.offset,
(GLsizeiptr)size);
} else {
BindBufferId(TempBufferTarget, resource.id);
g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)write.offset, (GLsizeiptr)size,
write.bytes.data());
}
}
}
// EXT_buffer_storage bit values (same numeric values as the desktop ARB
// tokens); defined locally so this compiles regardless of which GLES headers
// expose the EXT tokens.
@@ -879,6 +1055,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return ptr;
@@ -904,6 +1081,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id != 0 && CanTouchGLNow() &&
resource->contextGeneration == g_bufferContextGeneration) {
NoteBufferIdDeleted(resource->id);
// Frontend VAO bindings survive respecification; force their
// backend twins to bind the replacement buffer name.
++g_bufferBackendIdGeneration;
g_GLESFuncs.glDeleteBuffers(1, &resource->id);
resource->id = 0;
resource->immutableStorage = false;
@@ -916,12 +1096,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration) {
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (bufferObject.GetSize() == 0) {
@@ -929,6 +1111,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = false;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
RespecifyStorageNow(*resource, bufferObject);
@@ -941,11 +1124,45 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration ||
!StorageMatches(*resource, bufferObject)) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add({offset, offset + size});
return;
}
UploadRangeNow(*resource, bufferObject, offset, offset + size);
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
// An adopted zero-copy persistent store already HAS the bytes (the
// frontend wrote them through the coherent mapping); a driver upload
// here would be a self-copy that re-synchronizes what coherent mapping
// made free.
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// An immediate glBufferSubData resolves the WAR hazard against frames
// still referencing this store on the CPU on some drivers - Mali parks
// the thread in osup_sync_object_wait until every referencing job
// retires, which serialized Minecraft 26.3's per-frame UBO/chunk-mesh
// update streams into ~1 fps. Queue the range instead (the shadow
// already holds the bytes) and let draw-time sync push the merged
// ranges through the staging ring.
if (MG_Config::Features.EsprytDisableUploadRing) {
UploadRangeNow(*resource, bufferObject, offset, offset + size);
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add({offset, offset + size});
}
// App bytes for an ADOPTED store: queue them untouched-by-the-mapping; the
// draw-time sync (or a readback) lands them GPU-ordered through
// DrainResidentWritesNow. No GL here, so the op is thread-agnostic.
void Ops_ResidentSubData(BufferObject& bufferObject, SizeT offset, DataPtr data) {
auto* resource = ResourceOf(bufferObject);
if (!resource || data.size == 0) return;
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
auto& write = resource->pendingResidentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void Ops_FlushMappedRange(BufferObject& bufferObject, Range1D range,
@@ -956,6 +1173,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration ||
!StorageMatches(*resource, bufferObject)) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add(range);
return;
}
// An adopted zero-copy persistent store already HAS the bytes: the
// frontend shadow IS the coherent mapping the app (or UploadSubData)
// wrote into, so publishing is free. The self-copy that used to run
// here mapped a buffer this backend keeps persistently mapped (an
// INVALID_OPERATION whose fallback was a WAR-stalling
// glBufferSubData).
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// Same WAR-hazard rule as Ops_SubData: an immediate synchronized upload
// (mapped or glBufferSubData) can park the thread on Mali until the
// frames still referencing this store retire. Queue the range for the
// staged flush at draw-time sync; the negative-control kill switch
// keeps the immediate paths below.
if (!MG_Config::Features.EsprytDisableUploadRing) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add(range);
return;
}
@@ -998,8 +1238,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!resource || resource->id == 0 || !resource->storageInitialized) return;
if (!CanTouchGLNow() || resource->contextGeneration != g_bufferContextGeneration) return;
if (resource->persistentMapped) {
// Host writes to a persistent map must not race shader writes already queued
// on this context. There is no backend copy to read back in this case.
// Queued resident SubData bytes land first (GPU-ordered), then the
// finish makes them - and any shader writes already queued on this
// context - visible through the coherent mapping the reads use.
// There is no backend copy to read back in this case.
DrainResidentWritesNow(*resource, bufferObject);
if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish();
return;
}
@@ -1007,6 +1250,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT size = std::min<SizeT>(bufferObject.GetSize(), resource->storageSize);
if (size == 0) return;
// Queued app writes must land in the backend store before it is read
// back, or the writeback below would revert them in the shadow.
FlushPendingRangesNow(*resource, bufferObject);
BindBufferId(TempBufferTarget, resource->id);
void* mapped = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast<GLsizeiptr>(size),
GL_MAP_READ_BIT);
@@ -1061,6 +1308,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Ops_SubData(bufferObject, offset, size);
BumpBufferMutationEpoch();
}
void Ops_ResidentSubDataTracked(BufferObject& bufferObject, SizeT offset, DataPtr data) {
Ops_ResidentSubData(bufferObject, offset, data);
BumpBufferMutationEpoch();
}
void Ops_FlushMappedRangeTracked(BufferObject& bufferObject, Range1D range,
Flags<BufferMappingAccessBit> appAccess) {
Ops_FlushMappedRange(bufferObject, range, appAccess);
@@ -1085,6 +1336,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const BufferBackendOps g_glesBufferBackendOps = {
.Respecify = Ops_RespecifyTracked,
.SubData = Ops_SubDataTracked,
.ResidentSubData = Ops_ResidentSubDataTracked,
.FlushMappedRange = Ops_FlushMappedRangeTracked,
.OnDestroy = Ops_OnDestroyTracked,
.AcquirePersistentMap = Ops_AcquirePersistentMapTracked,
@@ -1101,7 +1353,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
// See the declaration: re-mints of a live resource's driver id. Written only on
// the context thread (both re-mint sites run there), read only by the VAO sync.
// the context thread (all re-mint sites run there), read only by the VAO sync.
Uint64 g_bufferBackendIdGeneration = 0;
void RegisterBufferBackendOps() {
@@ -1186,7 +1438,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id == 0) return false;
// Zero-copy coherent persistent store: EnsureBufferResource's own early-out —
// the app writes straight into the mapped GPU storage, nothing to sync.
if (resource->persistentMapped) return resource->persistentPtr != nullptr;
// Except queued resident SubData bytes, which land through the sync path
// (same unlocked emptiness probe as pendingRanges below).
if (resource->persistentMapped) {
return resource->persistentPtr != nullptr && resource->pendingResidentWrites.empty();
}
// A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push
// (persistent maps mutate the shadow without bumping the change serial).
if (frontend->IsMapped()) return false;
@@ -1218,6 +1474,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
resource->contextGeneration = g_bufferContextGeneration;
// The persistent map (and its pointer) died with the old context; the
// frontend re-acquires a fresh one on its next map.
@@ -1247,6 +1504,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// persistently mapped immutable store, so there is nothing to (re)upload at
// draw time. This is where the per-draw whole-buffer glBufferSubData used to run.
if (resource->persistentMapped && resource->persistentPtr && resource->id != 0) {
DrainResidentWritesNow(*resource, *bufferObject);
return resource;
}
@@ -1268,6 +1526,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject->GetChangeSerial();
} else {
@@ -1295,11 +1554,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize != bufferObject->GetSize()) {
RespecifyStorageNow(*resource, *bufferObject);
} else if (!resource->pendingRanges.empty()) {
for (const auto& range : resource->pendingRanges) {
const SizeT end = std::min(range.end, bufferObject->GetSize());
UploadRangeNow(*resource, *bufferObject, std::min(range.start, end), end);
}
resource->pendingRanges.clear();
FlushPendingRangesNow(*resource, *bufferObject);
resource->syncedChangeSerial = bufferObject->GetChangeSerial();
} else if (resource->syncedChangeSerial != bufferObject->GetChangeSerial()) {
// Ops could not track some writes (e.g. the ops table was
@@ -1383,10 +1638,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
constexpr SizeT kMaxIndexedBufferBindings = 64;
IndexedBufferBinding g_indexedUBOBindings[kMaxIndexedBufferBindings];
IndexedBufferBinding g_indexedSSBOBindings[kMaxIndexedBufferBindings];
// Transform feedback gets a shadow for a reason the other two do not have: the
// capture points are synced from the application's TOUCHED high-water mark, which
// deqp/glcts permanently raises to GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS by
// clearing every point after each test case. Without a shadow every capture that
// uses fewer points than that (i.e. every INTERLEAVED_ATTRIBS capture) re-issued a
// redundant glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, 0) for the unused
// tail immediately before glBeginTransformFeedback - calls a plain GL application
// never makes there, and the only thing MobileGL does differently from one.
//
// Unlike the UBO/SSBO points these are NOT context state: they belong to the bound
// transform feedback OBJECT, so XfbImpl::BindTransformFeedback drops the whole
// shadow to unknown on every object switch (InvalidateTransformFeedbackBindingShadows).
IndexedBufferBinding g_indexedXFBBindings[kMaxIndexedBufferBindings];
IndexedBufferBinding* IndexedBindingShadow(GLenum glTarget, Uint index) {
if (index >= kMaxIndexedBufferBindings) return nullptr; // out of range: never cache
if (glTarget == GL_UNIFORM_BUFFER) return &g_indexedUBOBindings[index];
if (glTarget == GL_SHADER_STORAGE_BUFFER) return &g_indexedSSBOBindings[index];
if (glTarget == GL_TRANSFORM_FEEDBACK_BUFFER) return &g_indexedXFBBindings[index];
return nullptr;
}
@@ -1403,6 +1672,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (auto& binding : g_indexedSSBOBindings) {
if (binding.id == id) binding = {};
}
for (auto& binding : g_indexedXFBBindings) {
if (binding.id == id) binding = {};
}
if (g_boundPixelPackBufferKnown && g_boundPixelPackBufferId == id) {
g_boundPixelPackBufferId = 0;
}
@@ -1419,9 +1691,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (auto& binding : g_indexedSSBOBindings) {
if (binding.id == id) binding.known = false;
}
for (auto& binding : g_indexedXFBBindings) {
if (binding.id == id) binding.known = false;
}
}
} // namespace
// The capture points belong to the bound transform feedback object, so a bind (or a
// delete, which reverts to the default object) replaces all of them at once with
// state this shadow has never seen. Distrust rather than scrub: the driver's bindings
// are whatever the newly bound object holds, which is NOT necessarily base(0), and
// scrubbing would let a later bind of 0 be false-skipped.
void InvalidateTransformFeedbackBindingShadows() {
for (auto& binding : g_indexedXFBBindings) binding.known = false;
}
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id) {
auto* s = IndexedBindingShadow(glTarget, index);
if (s && s->known && s->isBase && s->id == id) return;
@@ -1439,6 +1723,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void InvalidateIndexedBufferBindingCache() {
for (auto& b : g_indexedUBOBindings) b = {};
for (auto& b : g_indexedSSBOBindings) b = {};
for (auto& b : g_indexedXFBBindings) b = {};
}
void TrimBufferPool() {
@@ -1591,6 +1876,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
"ring offset mask below requires power-of-two ring sizes");
static_assert((kUnpackRingInitialBytes & (kUnpackRingInitialBytes - 1)) == 0,
"ring offset mask below requires power-of-two ring sizes");
static_assert((kUploadRingInitialBytes & (kUploadRingInitialBytes - 1)) == 0,
"ring offset mask below requires power-of-two ring sizes");
const SizeT offset = static_cast<SizeT>(store.head & (store.size - 1));
if (offset + alignedSize <= store.size && store.head + alignedSize - store.tail <= store.size) {
store.head += alignedSize;
@@ -1730,7 +2017,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} // namespace
Bool UboRingAvailable() {
if (MG_Config::Features.DisableUboRing) return false;
if (MG_Config::Features.EsprytDisableUboRing) return false;
return RingAvailable(g_uboRing);
}
@@ -1743,7 +2030,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void UboRingOnPresent() { RingOnPresent(g_uboRing); }
Bool UnpackRingAvailable() {
if (MG_Config::Features.DisableUnpackRing) return false;
if (MG_Config::Features.EsprytDisableUnpackRing) return false;
return RingAvailable(g_unpackRing);
}
@@ -1759,6 +2046,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
SizeT UnpackRingMaxBytes() { return kUnpackRingMaxBytes; }
void UnpackRingOnPresent() { RingOnPresent(g_unpackRing); }
void UploadRingOnPresent() { RingOnPresent(g_uploadRing); }
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -2455,7 +2744,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
}
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) {
// TEMP-EXP (leak texture deletes): /sdcard/MG/exp_leak_texture_deletes.
// Discriminator for the mali-mem-purge hiccup theory: never hand the
// driver a texture free, so the purge daemon has nothing to reclaim.
static const Bool s_expLeakTextureDeletes = [] {
FILE* f = std::fopen("/sdcard/MG/exp_leak_texture_deletes", "rb");
if (!f) return false;
std::fclose(f);
return true;
}();
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures &&
!s_expLeakTextureDeletes) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
if (m_bufferImageSplitViewId != 0) {
g_GLESFuncs.glDeleteTextures(1, &m_bufferImageSplitViewId);
@@ -3231,6 +3530,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (format != TextureInternalFormat::RGB5 && format != TextureInternalFormat::RGB5A1) {
return data;
}
// With the storage widened to 8-bit-per-channel (the packed16 field-order quirk)
// there is no driver requantization left for the repack to pre-empt - the shadow's
// UNorm8 bytes ARE the stored bytes - and the packed 16-bit client type this leg
// retargets to is not a legal upload for a GL_RGB8/GL_RGBA8 store at all.
if (TextureImpl::UsesWidenedPacked16NormStorage(format)) {
return data;
}
const Bool hasAlpha = format == TextureInternalFormat::RGB5A1;
const GLenum packedType = hasAlpha ? GL_UNSIGNED_SHORT_5_5_5_1 : GL_UNSIGNED_SHORT_5_6_5;
// Idempotent across a region's level loop: glType is shared, so later levels arrive with
@@ -5753,6 +6059,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint g_fragColorBroadcastCount = 1;
Uint32 g_unormFallbackClampOutputMask = 0;
Uint g_lastUsedBackendProgramId = 0;
// Every error-queue drain in the program build path is bounded by this: a lost
// context never answers GL_NO_ERROR, and the build runs on the thread that would
// then spin forever.
constexpr Int kMaxDrainedProgramErrors = 32;
StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl> g_backendProgramObjects;
BackendProgramObjectImpl::BackendProgramObjectImpl() {
@@ -6267,7 +6577,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
const std::set<String>& xfbCaptureBlockNames, const ImageFormatBakeInputs& imageFormatBake,
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
const std::map<String, String>& outputBlockRenames, const Bool stripInputBlockLocations,
const Bool stripOutputBlockLocations,
const Int atomicCounterEsslBindingTop, const Bool enableSpirvValidation, String& outSource,
std::set<String>& outFlattenedXfbBlockNames, Vector<Int>& outAtomicCounterGlBindings,
String& outError) const {
@@ -6304,7 +6615,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Int advertisedMaxSamples =
std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples);
// Armed by the EMULATION as well as by the missing extension, and the emulation is on
// by default (MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// by default (MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// reason to keep the builtin: it only ever gave the SHADER a compilable name, while
// the driver's INDEXED viewport state was never programmed by anything in MobileGL
// (SyncRenderState pushes index 0 and stops), so an extension-capable driver
@@ -6658,6 +6969,54 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &atomicCounterSpirv;
}
// The second half of the inter-stage interface-block repair, and the one that
// actually closes the 420pack group: this driver drops the payload of a block that
// carries an explicit layout(location=) whenever a tessellation or geometry stage
// is in the pipeline, so the qualifier comes off and ES matches the block by name
// and member sequence instead. The names those two sides agree on are the ones the
// rename above just fixed, which is why this runs AFTER it and not before.
//
// The caller arms the two directions; both are false unless the driver POST
// measured the defect AND this program has a stage that can hit it. Adopted only
// when this stage really had a located block, for the reason the array-input split
// documents: the optimizer hands back a re-serialised copy either way.
//
// LAST IN THE CHAIN, and that position is load-bearing. Vulkan SPIR-V REQUIRES a
// Location on every user-defined Input/Output variable
// ([VUID-StandaloneSpirv-Location-04915]), so the module this produces is
// deliberately no longer valid Vulkan SPIR-V - it is an ESSL-emission intermediate
// that goes straight into SPIRV-Cross and reaches no driver as SPIR-V. Running it
// here means no later pass validates what it produced; the pass itself skips
// validation for the same reason (see StripIoBlockLocationsForEssl). Anywhere
// earlier and every remaining pass would latch a validation failure on a module
// that is doing exactly what it was asked to.
Vector<unsigned int> strippedIoBlockLocationSpirv;
if (stripInputBlockLocations || stripOutputBlockLocations) {
Bool strippedAny = false;
if (MG_Util::ShaderTranspiler::ShaderCompiler::StripIoBlockLocationsForEssl(
*effectiveSpirv, stripInputBlockLocations, stripOutputBlockLocations,
strippedAny, strippedIoBlockLocationSpirv, enableSpirvValidation) &&
!strippedIoBlockLocationSpirv.empty() && strippedAny) {
effectiveSpirv = &strippedIoBlockLocationSpirv;
// THE ARMING SIGNAL, and it is INFO on purpose: the per-stage line below is
// MGLOG_D, which is compiled out of every build CI and the device runs, so
// nothing outside a debug build could tell an armed repair from a silently
// un-armed one. Latched, so it costs one line per process rather than one
// per stage of every program. The integration lane that pins the emulation
// on asserts on exactly this line - see UnlocatedIoBlockScenario.
MGLOG_I_ONCE("DirectGLES is emitting inter-stage interface blocks WITHOUT their "
"layout(location) qualifier, because this driver loses a located "
"block's payload across a tessellation or geometry boundary.");
MGLOG_D("Program %u stage %s: interface-block location qualifiers dropped "
"(%s), because this driver loses a located block's payload across a "
"tessellation or geometry boundary.",
m_backendProgramId, MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
stripInputBlockLocations
? (stripOutputBlockLocations ? "consumed and produced" : "consumed")
: "produced");
}
}
MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv,
MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
@@ -6812,10 +7171,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
const String outMembers =
ExtractPerVertexBlockMembers(tessEvalStageEssl, /*input=*/true).value_or(String());
const String source = BuildPassthroughTessControlEssl(ResolveBackendEsslVersion(), patchVertices,
inMembers, outMembers,
m_passthroughTessControlOuterLevel,
m_passthroughTessControlInnerLevel);
String source = BuildPassthroughTessControlEssl(ResolveBackendEsslVersion(), patchVertices,
inMembers, outMembers,
m_passthroughTessControlOuterLevel,
m_passthroughTessControlInnerLevel);
// The mirrored member lists can carry gl_PointSize - the neighbour stage declared it,
// so matching it is the whole point - and a redeclaration is exactly as illegal as a
// reference in ESSL without the extension. Same directive, same never-speculative
// rule as the per-stage loop; a driver with neither spelling gets nothing added and
// fails below with its own message, which is the honest outcome for a shape it
// cannot express.
const char* passthroughPointSizeExtension =
source.find("gl_PointSize") != String::npos
? PointSizeExtensionName(g_GLESCapabilities.TessellationPointSizeSupport, /*tessellation=*/true)
: nullptr;
source = RequestPointSizeExtension(Move(source), passthroughPointSizeExtension);
const GLuint backendShaderId = g_GLESFuncs.glCreateShader(GL_TESS_CONTROL_SHADER);
if (backendShaderId == 0) {
@@ -6975,6 +7345,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendProgramUsable = false;
return;
}
if (stateProgramObject->PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that
// pins MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because
// every rendering assertion stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectGLES is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this driver cannot host the built-in "
"in those stages.");
}
MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId);
for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) {
if (!ref.shader) continue;
@@ -7042,6 +7421,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
stagePipelineIndices[index] = InterStagePipelineIndex(stage);
if (CanDeclareBlocksInBothDirections(stage)) anyStageCanDeclareBlocksInBothDirections = true;
}
// A SECOND, INDEPENDENT interface-block repair riding the same gate, because it
// needs the same question answered: "does this program have a stage where an
// inter-stage block can go wrong?". CanDeclareBlocksInBothDirections is true for
// exactly the tessellation and geometry stages, which is also exactly the set of
// stages whose presence makes this driver drop a LOCATED block's payload (a
// vertex-to-fragment located block is fine on the same driver, measured). The two
// repairs are otherwise unrelated: the rename fixes a name collision inside ONE
// stage, this drops a qualifier from EVERY block of the program - so it does not
// wait for the collision probe to find anything.
const Bool ioBlockLocationStripArmed =
!g_GLESCapabilities.SupportsLocatedInterStageIoBlocks &&
anyStageCanDeclareBlocksInBothDirections;
if (anyStageCanDeclareBlocksInBothDirections) {
for (SizeT index = 0; index < shaderSpirvs.size(); ++index) {
MG_Util::ShaderTranspiler::ShaderCompiler::ProbeIoBlockNamesForEssl(
@@ -7236,6 +7627,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
esslKeyInputs.inputBlockRenames = &inputBlockRenames;
esslKeyInputs.outputBlockRenames = &outputBlockRenames;
// ...and THIS STAGE's share of the interface-block LOCATION strip, planned the
// same way and for the same reason. The gate has three parts, all of which have
// to hold before a single block loses its qualifier:
// * the driver POST measured the defect (never a renderer-string quirk list);
// * this program has a stage that can hit it - a located block between a
// vertex and a fragment stage works on the affected driver, so a program
// with neither tessellation nor geometry keeps its ESSL byte for byte;
// * for THIS stage and THIS direction, this program HAS a stage on that side
// of it. That is the same test the rename plan above makes, and the same
// approximation: it asks "is some stage of this program earlier/later than
// me", not "is the exact partner of every one of my blocks here". The two
// coincide for every program MobileGL builds, because a separable pipeline
// is flattened into one composite carrying every stage that has a shader
// (GLContext::GetProgramForDraw) and a program bound with glUseProgram has
// no partner program at all - so a stage set with a gap in it does not
// arise. Should one ever arise, this must become the nearest-stage
// resolution the rename plan computes, or the two ends of the gap would
// disagree about the qualifier.
// The direction tests deliberately mirror that plan rather than inventing a
// second rule for the same question.
Bool stripInputBlockLocations = false;
Bool stripOutputBlockLocations = false;
if (ioBlockLocationStripArmed && stagePipelineIndices[index] >= 0) {
const Int myPipelineIndex = stagePipelineIndices[index];
for (const Int otherPipelineIndex : stagePipelineIndices) {
if (otherPipelineIndex < 0) continue;
if (otherPipelineIndex < myPipelineIndex) stripInputBlockLocations = true;
if (otherPipelineIndex > myPipelineIndex) stripOutputBlockLocations = true;
}
}
esslKeyInputs.stripInputBlockLocations = stripInputBlockLocations;
esslKeyInputs.stripOutputBlockLocations = stripOutputBlockLocations;
esslKeyInputs.enableSpirvValidation = enableSpirvValidation;
auto& esslCache = MG_Util::ShaderTranspiler::GetEsslTranslationCache();
@@ -7262,6 +7686,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!TranspileSpirvToEssl(spirvCode, glShaderType, xfbCaptureBlockNames,
imageFormatBake, storageBlockBindingOverrides,
inputBlockRenames, outputBlockRenames,
stripInputBlockLocations, stripOutputBlockLocations,
m_atomicCounterEsslBindingTop,
enableSpirvValidation, source,
stageFlattenedXfbBlockNames,
@@ -7339,6 +7764,40 @@ namespace MobileGL::MG_Backend::DirectGLES {
source.find("gl_ViewportIndex") != String::npos;
source = RequestViewportArrayExtension(std::move(source), needsViewportArrayExtension);
// The fourth header-level rewrite, and the same shape as the third: ESSL has no
// gl_PointSize in a tessellation or geometry stage at ANY version - 320 makes the
// stages core and still leaves the built-in behind EXT/OES_..._point_size - while
// SPIRV-Cross prints it bare. Without the directive the stage fails to compile
// with "`gl_PointSize' undeclared", which takes the whole program to program 0:
// the draw renders nothing AND glBeginTransformFeedback is rejected, so a capture
// of anything at all off that program silently comes back empty. The token probe
// keeps the line off every other program and PointSizeExtensionName returns
// nullptr - i.e. nothing is emitted - on a driver advertising neither spelling.
if (source.find("gl_PointSize") != String::npos) {
const Bool tessellationStage = glShaderType == GL_TESS_CONTROL_SHADER ||
glShaderType == GL_TESS_EVALUATION_SHADER;
if (tessellationStage || glShaderType == GL_GEOMETRY_SHADER) {
const auto tier = tessellationStage ? g_GLESCapabilities.TessellationPointSizeSupport
: g_GLESCapabilities.GeometryPointSizeSupport;
const char* pointSizeExtension = PointSizeExtensionName(tier, tessellationStage);
if (pointSizeExtension == nullptr) {
// Latched, and an ERROR rather than a warning: what follows is a
// driver compile failure whose text names a built-in the application
// never mis-spelled, and the reason is a missing driver capability
// rather than anything in the shader. Saying so here is the whole
// difference between a legible skip and an unexplained black draw.
MGLOG_E_ONCE("This driver advertises neither the EXT nor the OES %s_point_size "
"extension, so its ESSL has no gl_PointSize in a %s stage; program %u "
"will fail to compile. Point size from a non-vertex stage is not "
"available on this device.",
tessellationStage ? "tessellation" : "geometry",
tessellationStage ? "tessellation" : "geometry",
stateProgramObject->GetExternalIndex());
}
source = RequestPointSizeExtension(std::move(source), pointSizeExtension);
}
}
source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject);
// The completion half of the format bake, for the formats SPIRV-Cross throws on
// rather than prints (r8ui and the rest of its desktop-only set). Empty for every
@@ -7460,12 +7919,44 @@ namespace MobileGL::MG_Backend::DirectGLES {
// carried 294 INFO lines and zero ERROR lines while two generated shaders
// were being rejected outright, and the lane could not say why it was
// rendering an empty translucent layer. A shader the driver refuses is
// never noise, and one line per refused shader is bounded by program count.
// never noise.
//
// A BOUNDED EXCERPT of the source goes with it. The driver log names a line
// and a column in text that exists nowhere but here, so without any source at
// all the only way to read "`gl_PointSize' undeclared" is to rebuild the whole
// library at DEBUG - but the full dump cannot go at E either. This is not
// "one line per refused shader": SyncToBackend's rebuild gate keys on
// per-draw state (the enabled-draw-buffer count among it), so a program used
// across passes with different draw-buffer counts re-transpiles, re-compiles
// and re-fails on every alternation, i.e. per frame. At E - live at the
// production INFO level - each of those records would push the whole
// post-SPIRV-Cross ESSL through the global log mutex with a forced flush onto
// /sdcard/MG/latest.log, the file users are asked to share. The excerpt keeps
// the record O(1); the full text is still there at D, printed against this
// same backend shader id by the "Setting shader source" line above, so
// nothing needs to be dumped twice.
constexpr SizeT kMaxLoggedSourceBytes = 2048;
String truncatedSource;
const char* sourceForLog = source.c_str();
if (source.size() > kMaxLoggedSourceBytes) {
// Back up to a line boundary when there is one inside the window, so the
// excerpt ends on a whole statement rather than mid-token. Built only on
// this branch: a stage that fits keeps its own buffer and is not copied.
SizeT cut = kMaxLoggedSourceBytes;
if (const SizeT lastNewline = source.rfind('\n', cut);
lastNewline != String::npos && lastNewline > 0) {
cut = lastNewline + 1;
}
truncatedSource = source.substr(0, cut);
truncatedSource += "... [" + std::to_string(source.size() - cut) +
" more bytes; the whole stage is printed at the DEBUG level]\n";
sourceForLog = truncatedSource.c_str();
}
MGLOG_E("Shader compilation failed. State program ID: %u, stage: %s, backend shader ID: "
"%u, driver log: %s",
"%u, driver log: %s\nSource:\n%s",
stateProgramObject->GetExternalIndex(),
MG_Util::ConvertGLEnumToString(glShaderType).c_str(), backendShaderId,
log.data());
log.data(), sourceForLog);
m_backendProgramUsable = false;
// Nothing will ever attach this one, so nothing else can free it.
g_GLESFuncs.glDeleteShader(backendShaderId);
@@ -7523,6 +8014,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// program before it links. SPIRV-Cross keeps user output names verbatim in
// the transpiled ESSL (`out vec4 result_0;` stays `result_0`), so the
// frontend's requested names carry over unchanged.
SizeT declaredXfbVaryingCount = 0;
if (stateProgramObject->GetTransformFeedbackVaryingCount() > 0 &&
g_GLESFuncs.glTransformFeedbackVaryings != nullptr) {
const auto& xfbVaryings = stateProgramObject->GetTransformFeedbackVaryings();
@@ -7532,6 +8024,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// for; it has the variable that replaced it. Everything else - including a
// member of a block that was left alone - keeps the application's spelling.
// Storage first, pointers after: xfbNames holds pointers into these strings.
//
// Same rule for a demoted gl_PointSize: the capture stage's ESSL no longer
// spells the built-in at all - the value lives in the carrier the demotion
// named - so the driver-side request has to follow it there. Only when the
// capture stage IS a demoted one (geometry, else evaluation): a program whose
// capture stage is the vertex shader keeps the built-in and its spelling,
// whatever happened to a control stage behind it.
Bool captureStageDemoted = false;
if (stateProgramObject->PointSizeDemoted()) {
for (const ShaderStage linkedStage : linkedStages) {
if (linkedStage == ShaderStage::TessEval || linkedStage == ShaderStage::Geometry) {
captureStageDemoted = true;
break;
}
}
}
Vector<String> rewrittenXfbNames(xfbVaryings.size());
for (SizeT nameIndex = 0; nameIndex < xfbVaryings.size(); ++nameIndex) {
String flatName;
@@ -7539,6 +8047,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::ShaderCompiler::RewriteXfbCaptureNameForFlattenedBlock(
xfbVaryings[nameIndex].name, flattenedXfbBlockNames, flatName)) {
rewrittenXfbNames[nameIndex] = std::move(flatName);
} else if (captureStageDemoted && xfbVaryings[nameIndex].name == "gl_PointSize") {
rewrittenXfbNames[nameIndex] =
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME;
} else {
rewrittenXfbNames[nameIndex] = xfbVaryings[nameIndex].name;
}
@@ -7548,9 +8059,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
MGLOG_D("Declaring %zu transform feedback varyings on program %u", xfbNames.size(),
m_backendProgramId);
// Bounded: a lost context never answers GL_NO_ERROR, and this runs on the
// thread that would then spin forever.
for (Int i = 0; i < kMaxDrainedProgramErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
g_GLESFuncs.glTransformFeedbackVaryings(m_backendProgramId, static_cast<GLsizei>(xfbNames.size()),
xfbNames.data(),
stateProgramObject->GetTransformFeedbackBufferMode());
// Unchecked before. A rejected capture set leaves the program linking happily
// with NO capture set at all, and then every draw of every span records
// nothing while the application reads its buffer's pre-draw bytes and
// GL_NO_ERROR - the signature four conformance families were stuck on.
if (const GLenum xfbError = g_GLESFuncs.glGetError(); xfbError != GL_NO_ERROR) {
String declared;
for (const auto& xfbName : rewrittenXfbNames) {
if (!declared.empty()) declared += ", ";
declared += xfbName;
}
MGLOG_E("The ES driver REJECTED the transform feedback capture set for backend program %u with "
"%s (mode %s): [%s]. Every capture made with GL program %u will record nothing.",
m_backendProgramId, MG_Util::ConvertGLEnumToString(xfbError).c_str(),
MG_Util::ConvertGLEnumToString(
stateProgramObject->GetTransformFeedbackBufferMode()).c_str(),
declared.c_str(), stateProgramObject->GetExternalIndex());
}
declaredXfbVaryingCount = xfbNames.size();
}
// Link program
@@ -7593,6 +8126,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
} else {
MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId);
// A link that SUCCEEDS can still have dropped the capture set: ESSL rejects a
// requested name the transpiled shader does not actually declare by simply not
// capturing it, and a program whose last vertex-processing stage was rewritten
// by a SPIR-V pass (viewport-index lowering, gl_PerVertex handling, the
// synthesized pass-through tessellation control stage) can end up spelling its
// outputs differently from the frontend's request. Asking the driver what it
// ACTUALLY linked is the only way to tell that apart from a driver that just
// captures nothing - which is the whole ambiguity the empty-capture failures
// across geometry_shader / tessellation_shader / gpu_shader5 / DSA sat on.
if (declaredXfbVaryingCount > 0) {
GLint linkedXfbVaryings = 0;
GLint linkedXfbBufferMode = 0;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_TRANSFORM_FEEDBACK_VARYINGS,
&linkedXfbVaryings);
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_TRANSFORM_FEEDBACK_BUFFER_MODE,
&linkedXfbBufferMode);
for (Int i = 0; i < kMaxDrainedProgramErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
const GLenum requestedMode = stateProgramObject->GetTransformFeedbackBufferMode();
if (static_cast<SizeT>(std::max(linkedXfbVaryings, 0)) != declaredXfbVaryingCount ||
static_cast<GLenum>(linkedXfbBufferMode) != requestedMode) {
MGLOG_E("Backend program %u (GL program %u) linked with a capture set the driver does not "
"agree with: asked for %zu varying(s) in mode %s, the driver reports %d varying(s) "
"in mode %s. Captures made with it will be empty or wrongly laid out.",
m_backendProgramId, stateProgramObject->GetExternalIndex(), declaredXfbVaryingCount,
MG_Util::ConvertGLEnumToString(requestedMode).c_str(), linkedXfbVaryings,
MG_Util::ConvertGLEnumToString(
static_cast<GLenum>(linkedXfbBufferMode)).c_str());
} else {
MGLOG_D("Backend program %u capture set confirmed by the driver: %d varying(s), mode %s",
m_backendProgramId, linkedXfbVaryings,
MG_Util::ConvertGLEnumToString(
static_cast<GLenum>(linkedXfbBufferMode)).c_str());
}
}
}
// The driver program was relinked IN PLACE, so its GL name no longer identifies
// the executable behind it - and that name is exactly what Use()'s
+42 -5
View File
@@ -230,7 +230,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// still holding what glViewport/glScissor/glDepthRange broadcast to all sixteen - collapses
// to a single pass with an all-ones gate mask, i.e. one draw and no behaviour change at all.
//
// Whether emulation runs. Off only under MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION falsy, which
// Whether emulation runs. Off only under MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION falsy, which
// restores the pre-emulation path as a negative control.
Bool ViewportArrayEmulationEnabled();
// Whether ANY program built in this process has come out with a viewport gate. Sticky once
@@ -461,6 +461,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the owning thread replaying them: guard both fields with pendingMutex.
Bool pendingRespecify = false;
VecRange1D pendingRanges;
// App bytes for an ADOPTED store, awaiting their GPU-ordered landing (ring
// stage + glCopyBufferSubData at the next sync; see
// BufferBackendOps::ResidentSubData). The frontend keeps such writes out of
// the coherent mapping - an in-place host write tears the in-flight frames
// still reading the old bytes. Guarded by pendingMutex like pendingRanges.
struct PendingResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<PendingResidentWrite> pendingResidentWrites;
std::mutex pendingMutex;
// Buffer-mutation epoch (see CurrentBufferMutationEpoch) at which this
// resource last probed IsBufferDrawClean == true, 0 = never (epochs start
@@ -544,12 +554,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
// BackendVertexArrayObject::SyncToBackend.
extern Uint64 g_bufferBackendIdGeneration;
// Redundant-bind cache for INDEXED buffer bindings (glBindBufferBase/Range on
// GL_UNIFORM_BUFFER / GL_SHADER_STORAGE_BUFFER): skips the GL call when the
// (id, range) already at that index matches, like the array-buffer/texture/
// sampler caches already do. Invalidated on MakeCurrent (context may reset).
// GL_UNIFORM_BUFFER / GL_SHADER_STORAGE_BUFFER / GL_TRANSFORM_FEEDBACK_BUFFER):
// skips the GL call when the (id, range) already at that index matches, like the
// array-buffer/texture/sampler caches already do. Invalidated on MakeCurrent
// (context may reset).
// Binds the transform feedback capture points [0, bufferCount) from the frontend
// state, and touches nothing else - in particular it never binds a zero the
// application did not ask for. See the definition for why that matters on Mali.
void SyncTransformFeedbackBindingPoints(SizeT bufferCount);
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id);
void BindBufferRangeCached(GLenum glTarget, Uint index, Uint id, GLintptr offset, GLsizeiptr size);
void InvalidateIndexedBufferBindingCache();
// The transform feedback capture points are per-transform-feedback-OBJECT state, so
// every glBindTransformFeedback swaps all of them under the shadow above. XfbImpl
// calls this on each bind/delete.
void InvalidateTransformFeedbackBindingShadows();
// Re-issues the GL_ATOMIC_COUNTER_BUFFER binding points a program's shaders declare as
// GL_SHADER_STORAGE_BUFFER bindings at the reserved slots the transpiled ESSL was built
// against (BackendProgramObjectImpl::GetAtomicCounterBindings /
@@ -618,7 +637,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// which is what to watch if this ring ever shows up in an RSS regression: it
// grows on demand from 4 MiB and is capped, not unbounded.
//
// False when the feature is disabled (MOBILEGL_DISABLE_UNPACK_RING),
// False when the feature is disabled (MOBILEGL_ESPRYT_DISABLE_UNPACK_RING),
// EXT_buffer_storage / fences are missing, the ES context is not current, or
// ring creation already failed under this context. Callers then upload from
// the client pointer exactly as before.
@@ -633,6 +652,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Largest single staging request the ring can ever satisfy.
SizeT UnpackRingMaxBytes();
void UnpackRingOnPresent();
// --- Buffer upload ring ---------------------------------------------------
// The same persistent-mapped bump allocator, staging APP BUFFER UPDATES
// (glBufferSubData / non-persistent map flushes) whose destination store may
// still be referenced by in-flight GPU work. Mali resolves that WAR hazard by
// BLOCKING the calling glBufferSubData (osup_sync_object_wait) until every
// referencing job retires - Minecraft 26.3 rewrites its chunk-section and
// dynamic-transform UBOs and streams chunk meshes with per-frame SubData, and
// each such call serialized against the whole GPU queue (~1 fps while chunks
// stream in, and again on every camera pan). App SubData ranges are queued on
// the resource instead (the frontend shadow already holds the bytes) and
// draw-time sync drains them: bytes staged into this ring, then one
// glCopyBufferSubData per merged range - the copy is ordered on the GPU
// timeline, so the hazard costs no CPU wait. Reclamation contract identical
// to the other two rings. MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING restores the
// historical immediate-upload path (negative control / escape hatch).
void UploadRingOnPresent();
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -1624,6 +1660,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
Bool stripInputBlockLocations, Bool stripOutputBlockLocations,
Int atomicCounterEsslBindingTop, Bool enableSpirvValidation,
String& outSource,
std::set<String>& outFlattenedXfbBlockNames,
+81
View File
@@ -11,8 +11,10 @@
#include "Managers.h"
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/SelfTest/DriverBugProbes.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <Config.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -125,6 +127,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
requestedInternalFormat,
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
}
// Outside the caveat branch on purpose: the driver CAN create the native narrow
// storage - the capability probes say so - it just cannot be trusted as a raw-copy
// endpoint. Texture and renderbuffer targets both come through here, which is what
// keeps a renderbuffer -> texture copy of these formats same-ES-format when the
// widening engages.
if (TextureImpl::UsesWidenedPacked16NormStorage(internalFormat)) {
options |= PixelFormatNormalizeOptionBit::WidenPacked16Norm;
}
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
}
} // namespace
@@ -182,6 +192,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
return options;
}
Bool UsesWidenedPacked16NormStorage(TextureInternalFormat internalFormat) {
switch (internalFormat) {
// TextureInternalFormat::RGB5 is both GL_RGB5 and GL_RGB565 - the GL-to-MG
// converter folds the two spellings onto one logical format.
case TextureInternalFormat::RGB5:
case TextureInternalFormat::RGB5A1:
case TextureInternalFormat::RGBA4:
break;
default:
return false;
}
switch (MG_Config::Features.EsprytWidenPacked16Storage) {
case MG_Config::QuirkOverride::ForceOn:
return true;
case MG_Config::QuirkOverride::ForceOff:
return false;
case MG_Config::QuirkOverride::Auto:
break;
}
// Behind the backend gate on purpose: the memoized probe latches its first answer
// for the whole process, and before the backend is up the GL function table may
// not be resolved yet - a probe run then would latch "cannot tell" as "clean"
// forever. Once the backend exists, the first narrow-format image this process
// creates runs the probe on a live context.
if (pActiveBackendObject == nullptr) {
return false;
}
return MG_Util::SelfTest::CopyImageMirrorsPacked16FieldOrder(g_GLESFuncs);
}
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType, TextureTarget target) {
#ifdef TRACY_ENABLE
@@ -713,6 +753,47 @@ namespace MobileGL::MG_Backend::DirectGLES {
return glslCode;
}
const char* PointSizeExtensionName(MG_External::GLESCapabilities::PointSizeTier tier, Bool tessellation) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
switch (tier) {
case Tier::ExtensionEXT:
return tessellation ? "GL_EXT_tessellation_point_size" : "GL_EXT_geometry_point_size";
case Tier::ExtensionOES:
return tessellation ? "GL_OES_tessellation_point_size" : "GL_OES_geometry_point_size";
default:
return nullptr;
}
}
String RequestPointSizeExtension(String glslCode, const char* extensionName) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// The gl_ViewportIndex story, one built-in over: ESSL 320 makes the tessellation and
// geometry STAGES core but leaves gl_PointSize out of their gl_PerVertex entirely,
// and SPIRV-Cross - which only ever sees a SPIR-V BuiltIn PointSize decoration -
// prints the identifier with no directive behind it. Same hard rule as the two
// neighbours: never emitted speculatively, because `#extension` on a name the driver
// does not advertise is a compile error of its own.
if (extensionName == nullptr || glslCode.find(extensionName) != String::npos) {
return glslCode;
}
const String directive = String("#extension ") + extensionName + " : require\n";
// Right after the #version line, the one position that must stay first;
// ForceSupporterOutput's scan for the LAST #extension directive still finds
// whichever one that ends up being.
const SizeT versionPos = glslCode.find("#version");
if (versionPos == String::npos) {
return directive + glslCode;
}
const SizeT lineEnd = glslCode.find('\n', versionPos);
if (lineEnd == String::npos) {
return glslCode + "\n" + directive;
}
glslCode.insert(lineEnd + 1, directive);
return glslCode;
}
String BakeImageFormatQualifiers(String glslCode,
const UnorderedMap<String, String>& esslFormatByUniformName) {
#ifdef TRACY_ENABLE
+26 -1
View File
@@ -46,6 +46,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex);
// Whether this format's ES storage is widened to 8-bit-per-channel because the
// driver stores some packed16 allocations with a mirrored field order
// (PixelFormatNormalizeOptionBit::WidenPacked16Norm). True only for
// GL_RGB565/GL_RGB5(_A1)/GL_RGBA4, and only where the POST probe measured the
// divergence (or MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE forces it). The transfer paths
// consult it too: the packed-norm re-upload leg must stand down when the ES storage
// is no longer 16-bit packed.
Bool UsesWidenedPacked16NormStorage(TextureInternalFormat internalFormat);
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType,
TextureTarget target = TextureTarget::Unknown);
@@ -273,6 +282,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// error, so this is never emitted speculatively. A no-op when not needed or already
// present.
String RequestViewportArrayExtension(String glslCode, Bool needed);
// Adds `#extension <extensionName> : require` when a TESSELLATION or GEOMETRY stage's
// emitted ESSL names gl_PointSize. Desktop GL has that built-in in gl_PerVertex for every
// vertex-processing stage; ESSL does NOT have it in those two at any version - not even
// 320, where the stages themselves are core - until EXT/OES_tessellation_point_size resp.
// EXT/OES_geometry_point_size is requested. SPIRV-Cross prints the identifier bare and
// asks for nothing, exactly as it does for gl_ViewportIndex, so without this the stage
// fails to compile with "`gl_PointSize' undeclared" and the WHOLE program is replaced by
// program 0 - the draw renders nothing and any transform-feedback capture it was carrying
// is rejected outright. `extensionName` is the caller's answer, nullptr when the driver
// advertises neither spelling, because requesting an unadvertised extension is itself a
// compile error. A no-op when nullptr or already present.
String RequestPointSizeExtension(String glslCode, const char* extensionName);
// The extension name RequestPointSizeExtension should be given for `tier`, or nullptr for
// PointSizeTier::None. `tessellation` picks the tessellation spellings over the geometry
// ones; the two extensions are separate and neither implies the other.
const char* PointSizeExtensionName(MG_External::GLESCapabilities::PointSizeTier tier, Bool tessellation);
// Writes a format layout qualifier into the image declarations named in
// `esslFormatByUniformName` that still have none. The completion half of the image-format
// bake, and ONLY that: the SPIR-V pass (BakeImageFormatsPass) is what normally puts the
@@ -507,7 +532,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// avoidExplicitLodBias leaves lookups that already carry an explicit LOD untouched,
// so their constant level stays constant; only the implicit-LOD forms take the bias.
// Off by default and only ever set on ANGLE + llvmpipe, where injecting the uniform
// into a constant LOD crashes the driver (MOBILEGL_AVOID_EXPLICIT_LOD_BIAS).
// into a constant LOD crashes the driver (MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS).
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias = false);
} // namespace PrgramImpl
@@ -624,7 +624,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (nonZeroIndirectBaseInstanceSupported) {
extensions.push_back(E_GL_ARB_base_instance);
}
if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) {
if (shaderSubgroupSupported && !MG_Config::Features.MagmaDisableSubgroup) {
extensions.push_back(E_GL_KHR_shader_subgroup);
}
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the Vulkan
@@ -1081,6 +1081,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// report VK_FALSE, so on every real mobile device this is false and the demotion runs
// exactly as it always has.
m_dynamicParameters.SupportsShaderFloat64 = m_vulkanCaps.SupportsShaderFloat64;
// shaderTessellationAndGeometryPointSize, both stage families from the one feature.
// False arms the shared phase-B point-size demotion, whose modules then carry no
// TessellationPointSize/GeometryPointSize capability and build without the feature.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends it is absent so the demotion can be
// exercised on a healthy driver (lavapipe advertises the feature); =0 restores the
// detected answer's declines.
{
Bool supportsStagePointSize = m_vulkanCaps.SupportsTessellationAndGeometryPointSize;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
supportsStagePointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the device feature");
supportsStagePointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.SupportsTessellationPointSize = supportsStagePointSize;
m_dynamicParameters.SupportsGeometryPointSize = supportsStagePointSize;
}
// Never, on any device, and DELIBERATELY NOT COUPLED to the line above even though it
// once tracked the same feature. It used to, because a `dvec` input needed Float64 to
// exist in the module at all; a 64-bit vertex FETCH was already impossible
@@ -70,7 +70,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const RendererInfo& GetRendererIdentity();
// The full OpenGL extension list Magma advertises (glGetString(GL_EXTENSIONS)) for
// a device with the given raw capabilities. The MOBILEGL_DISABLE_SUBGROUP and
// a device with the given raw capabilities. The MOBILEGL_MAGMA_DISABLE_SUBGROUP and
// MOBILEGL_DISABLE_TIMERQUERY escape hatches are applied inside, so callers pass
// the detected device support (passing an already-gated value is harmless).
Vector<GLExtension> BuildAdvertisedExtensions(Bool shaderSubgroupSupported, Bool timerQueriesSupported,
@@ -1206,6 +1206,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
SharedPtr<VkTimerQueryManager::TimestampRecord> end;
// Kind::Occlusion - pool slots recorded between Begin/End; summed at result time.
Vector<Uint32> occlusionSlots;
// Kind::XfbGenerated - reroute-pool slots for the span's XFB-INACTIVE
// draws, where the renderer's reroute is armed (the affected driver's
// stream query counts nothing without an open capture; see
// VulkanRenderer::BeginXfbQueryForDraw). Summed alongside the stream
// slots above, which keep the span's XFB-active draws.
Vector<Uint32> rerouteSlots;
// Renderer generation the records were written under (see
// g_rendererGeneration). A stale generation resolves as available
// with a final zero result: the records' pool indices and frame
@@ -1215,11 +1221,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// stale queries are always safe to delete.
Uint64 rendererGeneration = 0;
// Kind::XfbGenerated - the frontend's paused-draw primitive counter when the
// query began. VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT counts only what the
// capture saw, so a draw made while the span was paused is invisible to it -
// but GL_PRIMITIVES_GENERATED counts what the last vertex processing stage
// emitted regardless. The delta closes that gap at result time.
// query began. On the affected drivers VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT
// counts only what the capture saw, so a draw made while the span was paused is
// invisible to it - but GL_PRIMITIVES_GENERATED counts what the last vertex
// processing stage emitted regardless. The delta closes that gap at result time.
Uint64 pausedPrimitiveSnapshot = 0;
// ...unless the GPU already counted those paused draws when the span opened -
// through the reroute pool (VulkanRenderer::BeginXfbQueryForDraw reroutes every
// draw with no open capture, paused ones included) or, where the probe measured
// the stream query as counting capture-less draws, through the stream slot the
// paused draw still takes. Adding the CPU delta on top would count them twice,
// and the CPU counter is the weaker source anyway: only 3 of the ~15 draw entry
// points write it and it answers 0 for GL_PATCHES.
Bool pausedPrimitivesCountedByGpu = false;
};
} // namespace
@@ -1313,12 +1327,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (query->kind == VulkanTimerQuery::Kind::XfbWritten ||
query->kind == VulkanTimerQuery::Kind::XfbGenerated) {
Uint64 primitives = 0;
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots,
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots, query->rerouteSlots,
query->kind == VulkanTimerQuery::Kind::XfbGenerated,
primitives)) {
return false;
}
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated && MG_State::pGLContext != nullptr) {
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated &&
!query->pausedPrimitivesCountedByGpu && MG_State::pGLContext != nullptr) {
primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() -
query->pausedPrimitiveSnapshot;
}
@@ -1367,6 +1382,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
query->rendererGeneration = GetRendererGeneration();
query->pausedPrimitiveSnapshot =
MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0;
// Read AFTER StartXfbQueryCapture, which is where a failed reroute-pool creation
// disarms: the answer is then what this span will actually do for every draw.
query->pausedPrimitivesCountedByGpu = generated && pVulkanRenderer->ArePausedDrawsGpuCounted();
return query;
}
@@ -1377,7 +1395,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return;
}
pVulkanRenderer->StopXfbQueryCapture(
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots);
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots,
query->rerouteSlots);
}
BackendQueryHandle BeginOcclusionQuery() {
@@ -203,6 +203,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.rasterizationSamples, sizeof(payload.rasterizationSamples)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.sampleShadingEnable, sizeof(payload.sampleShadingEnable)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.minSampleShading, sizeof(payload.minSampleShading)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.sampleMask, sizeof(payload.sampleMask)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.subpass, sizeof(payload.subpass)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.topology, sizeof(payload.topology)));
XXHASH_VERIFY(
@@ -443,6 +444,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Ignored by Vulkan unless sampleShadingEnable is set, but written unconditionally so the
// struct's bytes match the hash the payload was keyed by.
ms.minSampleShading = payload.minSampleShading;
// GL_SAMPLE_MASK / glSampleMaski. Left at nullptr - which Vulkan reads as all-ones - until
// now, so glSampleMaski was a silent no-op on this backend while DirectGLES forwarded it.
// The pointer has to outlive the vkCreateGraphicsPipelines call, which the payload does.
ms.pSampleMask = payload.sampleMask;
VkPipelineDepthStencilStateCreateInfo depthStencil{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};
depthStencil.depthTestEnable = payload.depthTestEnable ? VK_TRUE : VK_FALSE;
@@ -44,6 +44,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// (VUID-VkPipelineMultisampleStateCreateInfo-sampleShadingEnable-00784).
Bool sampleShadingEnable = false;
Float minSampleShading = 0.0f;
// glEnable(GL_SAMPLE_MASK) + glSampleMaski, the fixed-function coverage mask, already
// reduced to what GL says this draw gets (VulkanRenderer::ResolveEffectiveSampleMask:
// all-ones unless the target is genuinely multisampled). Pipeline state like the two
// above - Vulkan has no dynamic sample mask before VK_EXT_extended_dynamic_state3 -
// so it is hashed with them, and all-ones has to keep producing the pipeline a null
// pSampleMask always did.
//
// TWO words, though GL only ever fills the first. GL_MAX_SAMPLE_MASK_WORDS is clamped
// to 1 on both backends, so glSampleMaski writes index 0 and nothing else - but the
// count Vulkan READS is ceil(rasterizationSamples / 32), which is 2 on a 64-sample
// target, and GetAdvertisedMaxSamples does not cap the driver's sample count. A
// single Uint32 here let such a pipeline read one word past the member (the next
// struct field). The second word is all-ones: full coverage for samples 32..63, which
// is the only honest answer when GL has no state describing them.
Uint32 sampleMask[2] = {0xffffffffu, 0xffffffffu};
Uint32 subpass = 0;
VkPrimitiveTopology topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
Bool primitiveRestartEnable = false;
@@ -77,6 +77,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
};
// Where a gl_PerVertex built-in output lives, resolved from the module's annotations.
// Named for gl_Position because the clip-space fixup is what it was written for, and it
// is still the only shape that pass accepts - but the transform-feedback capture pass
// resolves gl_PointSize through the same struct, in which case `vectorTypeId` /
// `vectorPtrTypeId` hold the SCALAR float type and its Output pointer rather than a vec4.
struct PositionTargetInfo {
Uint32 variableId = 0;
Uint32 vectorTypeId = 0;
@@ -102,6 +107,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
// gl_PointSize's counterpart to IsVec4Float32. The two are the only shapes any
// gl_PerVertex member this file resolves can have, and each resolver takes whichever
// one its built-in is declared with, so a mismatched type declines rather than
// producing a mirror the driver would reject.
Bool IsFloat32Scalar(spvtools::opt::IRContext* context, Uint32 typeId, Uint32* outFloatTypeId) {
auto* floatInst = context->get_def_use_mgr()->GetDef(typeId);
if (!floatInst || floatInst->opcode() != spv::Op::OpTypeFloat) return false;
if (floatInst->GetSingleWordInOperand(0) != 32) return false;
if (outFloatTypeId) *outFloatTypeId = typeId;
return true;
}
// Which of the two shapes above a resolver should accept. A plain function pointer
// rather than a std::function: every call site is one of the two free functions.
using BuiltInTypeCheckFn = Bool (*)(spvtools::opt::IRContext*, Uint32, Uint32*);
spvc_basetype MapReflectInterfaceToSpvcBasetype(const SpvReflectInterfaceVariable& variable) {
if (variable.type_description == nullptr) {
return SPVC_BASETYPE_UNKNOWN;
@@ -381,9 +403,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return used;
}
void ValidateTransformedSpirv(const Vector<Uint>& spirv, ShaderStage shaderStage, Uint programExternalIndex) {
// What a failed validation says, for a caller that wants to put it in its own message.
struct SpirvValidationFailure {
String message;
Int result = 0;
SizeT index = 0;
};
// Returns whether the module validates. The result used to be discarded everywhere: the
// call was DEBUG-or-env gated and only logged, so an invalid module produced by a backend
// transform went straight to vkCreateShaderModule. That is not a survivable outcome on
// this hardware - Mali r54 SIGSEGVs building the pipeline instead of returning an error,
// the same "not a validating entry point" behaviour PipelineFactory already documents for
// vkCreateGraphicsPipelines - so the callers that feed the driver now act on it.
//
// This function does NOT log the failure at E any more. It used to, unlatched, on the
// stated grounds that "reaching here already requires the validation switch to be armed,
// which bounds the volume" - and that premise died when the two GetOrCreateProgram call
// sites became unconditional: MGLOG_E is live at the production INFO level, and Log.h's
// own rule is that anything at W or E on a repeatable path must be latched or demoted.
// The failure text now travels back through `outFailure` so the LATCHED call-site
// messages carry the VUID instead of an unlatched inner one repeating it; what stays here
// is the D-level detail and the process-wide counter the test lanes assert on.
Bool ValidateTransformedSpirv(const Vector<Uint>& spirv, ShaderStage shaderStage, Uint programExternalIndex,
SpirvValidationFailure* outFailure = nullptr) {
if (outFailure != nullptr) *outFailure = {};
if (spirv.empty()) {
return;
return true;
}
spv_const_binary_t binary = {spirv.data(), spirv.size()};
@@ -405,18 +451,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic);
if (result != SPV_SUCCESS) {
// MGLOG_E, unlatched: reaching here already requires the validation switch to
// be armed, which bounds the volume, and each VUID names a different defect.
// (Parked at MGLOG_I until the Log.h level ordering was fixed, when E was
// compiled out of every INFO build.) The latch is what a test harness asserts on.
const char* message =
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>";
const SizeT index = diagnostic != nullptr ? diagnostic->position.index : 0;
// The test-lane signal (ShaderCompiler.h documents harnesses snapshotting it and
// asserting on the delta). Bumped for every failed validation, including one a
// caller goes on to recover from: a transform that produced an invalid module is
// a real defect whether or not this run survived it.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_E(
if (outFailure != nullptr) {
*outFailure = {String(message), static_cast<Int>(result), index};
}
MGLOG_D(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
static_cast<Int>(result),
diagnostic != nullptr ? diagnostic->position.index : 0,
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>");
index,
message);
}
MOBILEGL_ASSERT(
result == SPV_SUCCESS,
@@ -432,6 +484,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvDiagnosticDestroy(diagnostic);
spvValidatorOptionsDestroy(options);
spvContextDestroy(context);
return result == SPV_SUCCESS;
}
void ReflectStageInterfaceVariable(const SpvReflectInterfaceVariable& variable,
@@ -743,8 +796,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
Bool ResolveDirectPositionTarget(spvtools::opt::IRContext* context, Uint32 variableId,
PositionTargetInfo* outTarget) {
Bool ResolveDirectBuiltInTarget(spvtools::opt::IRContext* context, Uint32 variableId,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
auto* varInst = context->get_def_use_mgr()->GetDef(variableId);
if (!varInst || varInst->opcode() != spv::Op::OpVariable) return false;
if (varInst->GetSingleWordInOperand(0) != static_cast<Uint32>(spv::StorageClass::Output)) return false;
@@ -756,7 +809,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
PositionTargetInfo target{};
target.variableId = variableId;
target.vectorTypeId = ptrTypeInst->GetSingleWordInOperand(1);
if (!IsVec4Float32(context, target.vectorTypeId, &target.floatTypeId)) return false;
if (!typeCheck(context, target.vectorTypeId, &target.floatTypeId)) return false;
target.vectorPtrTypeId = varInst->type_id();
target.isMember = false;
@@ -771,15 +824,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return context->get_type_mgr()->GetTypeInstruction(&ptrType);
}
Bool ResolveMemberPositionTarget(spvtools::opt::IRContext* context, Uint32 structTypeId, Uint32 memberIndex,
PositionTargetInfo* outTarget) {
Bool ResolveMemberBuiltInTarget(spvtools::opt::IRContext* context, Uint32 structTypeId, Uint32 memberIndex,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
auto* structInst = context->get_def_use_mgr()->GetDef(structTypeId);
if (!structInst || structInst->opcode() != spv::Op::OpTypeStruct) return false;
if (memberIndex >= structInst->NumInOperands()) return false;
const Uint32 vectorTypeId = structInst->GetSingleWordInOperand(memberIndex);
Uint32 floatTypeId = 0;
if (!IsVec4Float32(context, vectorTypeId, &floatTypeId)) return false;
if (!typeCheck(context, vectorTypeId, &floatTypeId)) return false;
const Uint32 vectorPtrTypeId = FindOutputVectorPointerTypeId(context, vectorTypeId);
if (vectorPtrTypeId == 0) return false;
@@ -807,27 +860,130 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
Bool FindPositionTarget(spvtools::opt::IRContext* context, PositionTargetInfo* outTarget) {
// The OUTPUT variable (or gl_PerVertex member) carrying `builtIn`, if the module
// declares one of the expected type. Annotations are the search space deliberately:
// they survive the link-time sanitize chain's interface delisting, which is the whole
// reason EnsureEntryPointInterface exists.
Bool FindBuiltInTarget(spvtools::opt::IRContext* context, spv::BuiltIn builtIn,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
Vector<Pair<Uint32, Uint32>> memberCandidates;
constexpr auto kDecorationBuiltIn = static_cast<Uint32>(spv::Decoration::BuiltIn);
constexpr auto kBuiltInPosition = static_cast<Uint32>(spv::BuiltIn::Position);
const auto wantedBuiltIn = static_cast<Uint32>(builtIn);
for (auto& inst : context->module()->annotations()) {
if (inst.opcode() == spv::Op::OpDecorate) {
if (inst.NumInOperands() < 3) continue;
if (inst.GetSingleWordInOperand(1) != kDecorationBuiltIn) continue;
if (inst.GetSingleWordInOperand(2) != kBuiltInPosition) continue;
if (ResolveDirectPositionTarget(context, inst.GetSingleWordInOperand(0), outTarget)) return true;
if (inst.GetSingleWordInOperand(2) != wantedBuiltIn) continue;
if (ResolveDirectBuiltInTarget(context, inst.GetSingleWordInOperand(0), typeCheck, outTarget)) {
return true;
}
} else if (inst.opcode() == spv::Op::OpMemberDecorate) {
if (inst.NumInOperands() < 4) continue;
if (inst.GetSingleWordInOperand(2) != kDecorationBuiltIn) continue;
if (inst.GetSingleWordInOperand(3) != kBuiltInPosition) continue;
if (inst.GetSingleWordInOperand(3) != wantedBuiltIn) continue;
memberCandidates.emplace_back(inst.GetSingleWordInOperand(0), inst.GetSingleWordInOperand(1));
}
}
for (const auto& [structTypeId, memberIndex] : memberCandidates) {
if (ResolveMemberPositionTarget(context, structTypeId, memberIndex, outTarget)) return true;
if (ResolveMemberBuiltInTarget(context, structTypeId, memberIndex, typeCheck, outTarget)) return true;
}
return false;
}
Bool FindPositionTarget(spvtools::opt::IRContext* context, PositionTargetInfo* outTarget) {
return FindBuiltInTarget(context, spv::BuiltIn::Position, IsVec4Float32, outTarget);
}
// Put `variableId` back on `entryPoint`'s interface list if it is not already there.
//
// SPIR-V requires every Input/Output global an entry point statically uses to be listed on
// its OpEntryPoint, and spirv-val enforces it ("Interface variable id <N> is used by entry
// point 'main' id <M>, but is not listed as an interface"). The link-time sanitize chain
// DELISTS a variable nothing referenced yet - ShaderCompiler::SanitizeAndOptimizeBinary
// runs CreateAggressiveDCEPass(false), which may never delete an Output, followed by
// CreateRemoveUnusedInterfaceVariablesPass, which rebuilds the operand list from the
// variables actually referenced. A TES that redeclares `out gl_PerVertex { vec4
// gl_Position; }` and never writes it therefore reaches the backend with the OpVariable
// and its BuiltIn Position decoration intact and its interface slot gone. Any pass that
// then injects a reference has to put the slot back, or it hands the driver a module no
// validator accepts - and Mali r54 answers that with a SIGSEGV inside pipeline creation
// rather than an error return.
//
// No SPIR-V version gate here, unlike GlFragCoordYFlipPass's identical call for its
// injected PRIVATE global: Input and Output belong on the interface in every version,
// and only 1.4 widened it to the other storage classes.
Bool EnsureEntryPointInterface(spvtools::opt::IRContext* context, spvtools::opt::Instruction& entryPoint,
Uint32 variableId) {
// In-operands: 0 = execution model, 1 = entry function id, 2 = name, 3.. = interface.
constexpr Uint32 kFirstInterfaceOperand = 3;
if (variableId == 0) return false;
for (Uint32 operand = kFirstInterfaceOperand; operand < entryPoint.NumInOperands(); ++operand) {
if (entryPoint.GetSingleWordInOperand(operand) == variableId) return false;
}
entryPoint.AddOperand({SPV_OPERAND_TYPE_ID, {variableId}});
context->AnalyzeUses(&entryPoint);
return true;
}
// Is `pointerId` the position target itself, or an access chain rooted at it?
Bool PointerReachesPositionTarget(spvtools::opt::IRContext* context, Uint32 pointerId,
const PositionTargetInfo& target) {
auto* defUse = context->get_def_use_mgr();
for (Uint32 current = pointerId; current != 0;) {
if (current == target.variableId) return true;
const auto* inst = defUse->GetDef(current);
if (inst == nullptr) return false;
switch (inst->opcode()) {
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
case spv::Op::OpPtrAccessChain:
case spv::Op::OpInBoundsPtrAccessChain:
case spv::Op::OpCopyObject:
current = inst->GetSingleWordInOperand(0);
break;
default:
return false;
}
}
return false;
}
// Does anything in the module write the position target?
//
// Deliberately conservative - it answers "assume yes" for every shape it cannot read
// exactly, because a false "no" would silently drop the clip-space fixup from a shader
// that does write gl_Position, while a false "yes" only reinstates the behaviour this
// pass has always had. Scans every function rather than just the entry point's: a shader
// that assigns gl_Position inside a helper is still a shader that writes it, and passing
// the pointer to a call is a write as far as this can tell.
Bool ModuleWritesPositionTarget(spvtools::opt::IRContext* context, const PositionTargetInfo& target) {
for (auto& function : *context->module()) {
for (auto& block : function) {
for (const auto& inst : block) {
switch (inst.opcode()) {
case spv::Op::OpStore:
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
if (PointerReachesPositionTarget(context, inst.GetSingleWordInOperand(0), target)) {
return true;
}
break;
case spv::Op::OpFunctionCall:
// In-operand 0 is the callee; the rest are arguments.
for (Uint32 argument = 1; argument < inst.NumInOperands(); ++argument) {
if (PointerReachesPositionTarget(context, inst.GetSingleWordInOperand(argument),
target)) {
return true;
}
}
break;
default:
break;
}
}
}
}
return false;
}
@@ -914,6 +1070,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
PositionTargetInfo target{};
if (!FindPositionTarget(context(), &target)) return Status::SuccessWithoutChange;
// Nothing to remap in a Position the shader never writes. Declining is not just
// an optimisation: the fixup is load-modify-store, so on an unwritten Position it
// converts "undefined, never written" into "written with whatever the load
// returned", and the store is a reference to a variable the link-time sanitize
// chain has already delisted from the entry-point interface. glslang emits the
// OpVariable for every DECLARED interface block, so a redeclared-but-unwritten
// `out gl_PerVertex` is a shape real shaders have.
if (!ModuleWritesPositionTarget(context(), target)) {
MGLOG_D("gl-to-vulkan-position-fix: the shader never writes gl_Position; leaving it alone");
return Status::SuccessWithoutChange;
}
auto* floatType = context()->get_type_mgr()->GetType(target.floatTypeId);
if (!floatType) return Status::SuccessWithoutChange;
@@ -945,6 +1113,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* function = context()->GetFunction(entryPoint.GetSingleWordInOperand(1));
if (!function) continue;
Bool modifiedThisEntryPoint = false;
for (auto& bb : *function) {
for (auto instIter = bb.begin(); instIter != bb.end(); ++instIter) {
auto* inst = &*instIter;
@@ -953,10 +1122,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
(model != spv::ExecutionModel::Geometry && inst->opcode() == spv::Op::OpReturn);
if (!needsFixup) continue;
modified |= InsertPositionFixup(context(), inst, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
modifiedThisEntryPoint |=
InsertPositionFixup(context(), inst, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
}
}
// Per entry point, and only for one this pass actually injected into: the
// injected load/store is a static use of the position variable, so the
// variable has to be on THIS entry point's interface list.
if (modifiedThisEntryPoint) {
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
}
modified |= modifiedThisEntryPoint;
}
if (!modified) return Status::SuccessWithoutChange;
@@ -1235,6 +1412,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool needsPositionMirror = false;
Uint32 positionBufferIndex = 0;
Uint32 positionOffset = 0;
// gl_PointSize is a gl_PerVertex MEMBER, never a variable of its own, so the
// debug-name lookup below can never resolve it - it used to fall through to
// "no SPIR-V variable named 'gl_PointSize'" and leave the frontend's reserved
// slot unwritten, or, when it was the only capture, leave the module with no
// Xfb execution mode at all and the whole span declined.
Bool needsPointSizeMirror = false;
Uint32 pointSizeBufferIndex = 0;
Uint32 pointSizeOffset = 0;
for (const auto& varying : m_varyings) {
if (varying.name == "gl_Position") {
needsPositionMirror = true;
@@ -1242,6 +1427,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
positionOffset = varying.offsetBytes;
continue;
}
if (varying.name == "gl_PointSize") {
// A demoted module (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) no longer ACCESSES the
// built-in member - the value lives in the carrier variable the demotion
// named - so the capture binds to the carrier directly. The mirror below
// must not run for it: reading the now-unwritten member would capture
// garbage, and the read itself is the capability access the demotion
// exists to remove. Detected off the module's own debug names, so a
// composite built from another program's stage answers for the module it
// actually contains.
const auto carrierIt = idsByName.find(
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME);
if (carrierIt != idsByName.end()) {
decorateForXfb(carrierIt->second, varying.bufferIndex, varying.offsetBytes);
modified = true;
continue;
}
needsPointSizeMirror = true;
pointSizeBufferIndex = varying.bufferIndex;
pointSizeOffset = varying.offsetBytes;
continue;
}
if (varying.blockMemberIndex >= 0) {
// glslang names the block's instance variable and its struct type
// separately; an anonymous instance leaves only the type named, so
@@ -1306,8 +1513,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (needsPositionMirror) {
modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex,
positionOffset, decorateForXfb);
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint,
spv::BuiltIn::Position, IsVec4Float32,
"gl_Position", positionBufferIndex, positionOffset,
decorateForXfb);
}
if (needsPointSizeMirror) {
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint,
spv::BuiltIn::PointSize, IsFloat32Scalar,
"gl_PointSize", pointSizeBufferIndex,
pointSizeOffset, decorateForXfb);
}
if (!modified) return Status::SuccessWithoutChange;
@@ -1347,19 +1562,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return 0;
}
// gl_Position and gl_PointSize are captured the same way and differ only in which
// built-in is looked up and what type it has, so one injector serves both. Anything
// else in gl_PerVertex would need its own type check before it could be added here.
template <typename DecorateFn>
Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) {
Bool MirrorPerVertexBuiltInForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
spv::BuiltIn builtIn, BuiltInTypeCheckFn typeCheck,
const char* glslName, Uint32 bufferIndex, Uint32 offsetBytes,
const DecorateFn& decorateForXfb) {
const Uint32 entryPointModel = entryPoint.GetSingleWordInOperand(0);
using namespace spvtools::opt;
PositionTargetInfo target{};
if (!FindPositionTarget(context(), &target)) {
MGLOG_E("XfbCaptureDecoratePass: gl_Position capture requested but no position output found");
if (!FindBuiltInTarget(context(), builtIn, typeCheck, &target)) {
MGLOG_E("XfbCaptureDecoratePass: %s capture requested but no such output found", glslName);
return false;
}
if (!target.isMember) {
// Standalone gl_Position variable: decorate it directly.
// Standalone built-in variable: decorate it directly. It still has to be
// on the interface - a transform-feedback decoration on a variable the entry
// point does not list captures nothing, and the sanitize chain delists an
// unwritten one (see EnsureEntryPointInterface).
decorateForXfb(target.variableId, bufferIndex, offsetBytes);
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
return true;
}
@@ -1413,6 +1637,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
injected = true;
}
}
// The mirror was listed on the entry point above, but the loop just added a READ
// of the SOURCE block through an access chain, and the interface rule covers
// reads exactly as it covers writes. A built-in capture on a shader whose
// block the sanitize chain delisted - a TES that redeclares `out gl_PerVertex`
// and never writes it, which is what the tessellation_control_to_tessellation_
// evaluation.gl_MaxPatchVertices_Position_PointSize bodies do - produced an
// invalid module here for the same reason the position fixup did.
if (injected) {
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
}
return injected;
}
@@ -3236,11 +3470,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
if (program.PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that pins
// MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because every
// rendering assertion above it stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectVulkan is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this device cannot host the built-in "
"in those stages.");
}
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
if (enableSpirvValidation) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
}
// Unconditional now: the two ValidateTransformedSpirv calls below run in every build,
// not only when the switch is armed, so the validator's static tables have to be pinned
// against process exit in every build too.
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
const ShaderStage fixupStage = PickClipFixupStage(stages);
@@ -3264,6 +3508,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
TransformSpirvForVulkanPositionFix(*fixupInput, moduleSpirvs[i], flags);
// These two passes INJECT references - a store for the clip fixup, an access
// chain and a load for the gl_Position capture mirror - and a reference to a
// variable the link-time sanitize chain delisted from the entry-point interface
// is invalid SPIR-V that Mali r54 turns into a SIGSEGV inside pipeline creation
// rather than an error return. EnsureEntryPointInterface keeps them honest; this
// is the backstop.
//
// The fallback UNWINDS ONE PASS AT A TIME, which matters because the two passes
// are not equally optional. Rewinding straight to `spv` would also throw away the
// XfbBuffer/XfbStride/Offset decorations, the TransformFeedback capability and the
// Xfb execution mode - while the renderer decides to call
// vkCmdBeginTransformFeedbackEXT purely from GL state and never looks at the
// module. That ships a pipeline whose last pre-rasterization stage has no Xfb mode
// into a transform-feedback span, violating
// VUID-vkCmdBeginTransformFeedbackEXT-None-04128 on exactly the driver class this
// guard exists for. So: try the post-XFB, pre-clip-fixup module first, which keeps
// capture working and costs only the clip-space remap.
//
// Once per program on a cache miss, and only for the single stage that carries the
// fixups - not per draw and not per module.
SpirvValidationFailure fixupFailure{};
if (!ValidateTransformedSpirv(moduleSpirvs[i], stages[i], program.GetExternalIndex(),
&fixupFailure)) {
SpirvValidationFailure xfbFailure{};
if (fixupInput != &spv &&
ValidateTransformedSpirv(*fixupInput, stages[i], program.GetExternalIndex(), &xfbFailure)) {
MGLOG_E_ONCE("ProgramFactory: the clip fixup produced an invalid module for program %u "
"stage %d (%s); keeping the capture-decorated one, so this program draws "
"without the clip-space remap",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
fixupFailure.message.c_str());
moduleSpirvs[i] = *fixupInput;
} else {
MGLOG_E_ONCE("ProgramFactory: the clip/XFB fixups produced an invalid module for program %u "
"stage %d (%s); keeping the untransformed one",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
fixupFailure.message.c_str());
moduleSpirvs[i] = spv;
}
}
} else {
moduleSpirvs[i] = spv;
}
@@ -3472,15 +3756,63 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& moduleSpv = moduleSpirvs[i];
if (moduleSpv.empty()) continue;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (enableSpirvValidation) {
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
// Last look at the exact bytes the driver receives, in EVERY build rather than only
// in DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed. This one only reports:
// by here the descriptor bindings have been remapped and the layout about to be
// reflected describes the remapped module, so there is no module left that is both
// valid and consistent with it to fall back to. The recovery lives one step earlier,
// at the clip/XFB fixups (see the revert there) - which is where a transform can
// introduce a reference to a delisted interface variable, the failure this whole
// guard exists for. Anything that reaches this line names itself in the log of a
// shipping build instead of dying anonymously inside the driver.
SpirvValidationFailure finalFailure{};
if (!ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex(), &finalFailure)) {
MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d - "
"a backend transform after the clip/XFB fixups broke it (%s)",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
finalFailure.message.c_str());
}
// Does the stage the driver will treat as the last pre-rasterization one actually
// carry Xfb? Asked of the FINAL bytes, so it answers for whatever the whole transform
// chain produced - a rewound clip/XFB backstop, a capture pass that resolved no
// varying and changed nothing, anything later that might strip it. The renderer picks
// its capture commands from GL state alone and would otherwise open a span against a
// pipeline that cannot feed it.
if (stages[i] == fixupStage && (flags & ProgramFactory::CompileOptionBit::XfbCapture) &&
program.GetTransformFeedbackVaryingCount() > 0 &&
!MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresTransformFeedback(moduleSpv)) {
MGLOG_E_ONCE("ProgramFactory: program %u was built as a transform-feedback capture variant but its "
"stage %d carries no Xfb execution mode; its capture spans will be declined rather "
"than recorded against a pipeline that cannot feed them",
program.GetExternalIndex(), static_cast<Int>(stages[i]));
entry.xfbCaptureDeclined = true;
}
// Does this stage need a device feature the device did not give us? Asked ONLY when
// the feature is off, so a device that has it - the common case - pays nothing: the
// whole test is short-circuited before the module is parsed.
//
// gl_PointSize is an ordinary per-vertex output in desktop GL and any
// vertex-processing stage may write it, but Vulkan puts the built-in behind
// shaderTessellationAndGeometryPointSize in the tessellation and geometry stages
// (VUID-RuntimeSpirv-PointSize-06439). glslang emits TessellationPointSize /
// GeometryPointSize from the application's own access, so this program is legal GL
// that this device cannot run - the same shape the DirectGLES arm reports when a
// driver advertises neither EXT nor OES point-size extension, and it deserves the
// same named message rather than a pipeline the driver may fault on.
if (!m_tessellationAndGeometryPointSizeEnabled &&
(stages[i] == ShaderStage::TessControl || stages[i] == ShaderStage::TessEval ||
stages[i] == ShaderStage::Geometry) &&
MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(
moduleSpv)) {
MGLOG_E_ONCE("ProgramFactory: program %u stage %d accesses gl_PointSize, but this device does not "
"support shaderTessellationAndGeometryPointSize; its draws are refused rather than "
"built into a pipeline the driver may fault on. Point size from a non-vertex stage "
"is not available on this device.",
program.GetExternalIndex(), static_cast<Int>(stages[i]));
entry.pointSizeCapabilityUnsupported = true;
}
#endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smci.codeSize = moduleSpv.size() * sizeof(Uint);
@@ -3758,11 +4090,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// PassthroughTessControlTest.MatchesTheFrontendPerVertexBlock is the latch, and it now
// links the program at both 430 and 460.
//
// Only gl_Position is written. gl_PointSize is declared but left alone deliberately:
// writing it from a tessellation stage requires the shaderTessellationAndGeometryPointSize
// feature, which this renderer does not enable, so a program whose evaluation stage reads
// gl_in[].gl_PointSize gets an undefined point size instead of the vertex stage's - a gap
// this trades for not making every tessellated pipeline depend on an optional feature.
// Only gl_Position is written, and gl_PointSize is declared without being forwarded. That
// is a KNOWN GAP, not a design: GL 4.6 core 11.2.2 says the fixed-function pass-through
// hands the input patch to the evaluation stage unmodified, so an evaluation stage
// reading gl_in[].gl_PointSize should see the vertex stage's value and instead sees
// whatever this stage left in gl_out[] - which is nothing. A capture of it (the mirror in
// XfbCaptureDecoratePass) faithfully records that nothing.
//
// The reason this comment used to give - "the renderer does not enable
// shaderTessellationAndGeometryPointSize" - stopped being true when
// VulkanRenderer::CreateLogicalDeviceAndQueues started taking the feature wherever the
// device advertises it. Closing the gap is therefore possible now, but it is not free:
// the forwarding store has to be gated on that feature, because on a device without it
// the store is exactly the invalid usage the build-time refusal
// (VkProgramObject::pointSizeCapabilityUnsupported) exists to keep away from the driver -
// and this synthesized stage is not the application's, so refusing the program because
// MobileGL's own pass-through named a built-in would be the wrong trade. Nothing pins
// the shape either: every case in TessellationXfbCaptureScenario builds an explicit
// control stage, so a TES-without-TCS test has to come with the fix.
const String perVertexBody = BuildPerVertexMemberDeclarations(perVertexMembers);
source += "in gl_PerVertex {\n" + perVertexBody + "} gl_in[gl_MaxPatchVertices];\n";
source += "out gl_PerVertex {\n" + perVertexBody + "} gl_out[];\n";
@@ -3862,14 +4207,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
const Vector<Uint>& spirv = binary.value().front();
{
// Still switch-gated, unlike the two in GetOrCreateProgram: this stage is synthesized
// by MobileGL from a fixed template rather than transformed from application SPIR-V,
// so a failure here is a MobileGL bug to catch in a validating lane, not something a
// shipping build can be handed by an application. The message is latched all the same
// - the pass-through cache is keyed on patchVertices, so a broken template would
// otherwise re-report once per distinct patch size.
Bool validateThisOne = false;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
validateThisOne = true;
#else
if (m_enableSpirvValidation) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
}
validateThisOne = m_enableSpirvValidation;
if (validateThisOne) MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
#endif
SpirvValidationFailure passthroughFailure{};
if (validateThisOne &&
!ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0, &passthroughFailure)) {
MGLOG_E_ONCE("ProgramFactory: the synthesized pass-through tessellation control stage for "
"patchVertices=%u does not validate (%s)",
patchVertices, passthroughFailure.message.c_str());
}
}
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smci.codeSize = spirv.size() * sizeof(Uint);
@@ -202,6 +202,32 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// tessellation stages are present or neither
// (VUID-VkGraphicsPipelineCreateInfo-pStages-00730). So the draw path has to supply
// the pass-through stage GL describes; see GetOrCreatePassthroughTessControlStage.
// True when this program was built AS a transform-feedback capture variant but its
// last pre-rasterization module does NOT carry the Xfb execution mode - so the
// renderer must decline the capture span instead of issuing
// vkCmdBeginTransformFeedbackEXT against it
// (VUID-vkCmdBeginTransformFeedbackEXT-None-04128).
//
// Two ways to get here, and neither is visible from GL state, which is all
// BeginXfbCaptureForDraw otherwise consults: the clip/XFB validation backstop had to
// rewind past the capture decoration, or XfbCaptureDecoratePass resolved none of the
// requested varyings and returned without changing anything (its own MGLOG_E path)
// while its runner still reported success. Both used to ship a non-Xfb module under
// an Xfb-flagged cache entry - the flag and the layout are part of the program cache
// key, so it was sticky for every later captured draw of the program, not a glitch.
Bool xfbCaptureDeclined = false;
// The program has a tessellation or geometry module declaring TessellationPointSize /
// GeometryPointSize on a device whose shaderTessellationAndGeometryPointSize feature
// is off, so a pipeline built from it is invalid usage
// (VUID-RuntimeSpirv-PointSize-06439). Its draws are refused in SetupDraw rather than
// handed to the driver - the same contract PipelineFactory's half-tessellated refusal
// implements one level up, and the counterpart of the DirectGLES arm that reports a
// driver with neither point-size extension by name.
//
// Sticky by construction, which is what makes ONE log line honest: the flag lives on
// the cache entry, so every later draw of the same program variant reads the same
// answer instead of re-deciding it.
Bool pointSizeCapabilityUnsupported = false;
Bool needsPassthroughTessControl = false;
// ...and the pass-through this renderer can synthesize carries gl_Position and
// nothing else, so it is only correct when the evaluation stage's inputs are
@@ -426,12 +452,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
explicit ProgramFactory(VkDevice device, const VulkanRendererConfig& config, Uint32 maxBindings,
Bool shaderDrawParametersEnabled,
Bool unformattedFloatStorageImagesEnabled,
Bool tessellationAndGeometryPointSizeEnabled,
Bool enableSpirvValidation,
UpdateAfterBindLimits updateAfterBindLimits,
SubgroupLoweringPolicy subgroupPolicy)
: m_device(device), m_maxBindings(maxBindings), m_config(config),
m_shaderDrawParametersEnabled(shaderDrawParametersEnabled),
m_unformattedFloatStorageImagesEnabled(unformattedFloatStorageImagesEnabled),
m_tessellationAndGeometryPointSizeEnabled(tessellationAndGeometryPointSizeEnabled),
m_enableSpirvValidation(enableSpirvValidation),
m_updateAfterBindLimits(updateAfterBindLimits),
m_subgroupPolicy(subgroupPolicy) {
@@ -591,6 +619,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// True only when the logical device enabled both
// shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat.
Bool m_unformattedFloatStorageImagesEnabled = false;
// True when the logical device enabled shaderTessellationAndGeometryPointSize. When it is
// FALSE a program whose tessellation or geometry module declares TessellationPointSize /
// GeometryPointSize is refused at build time (see VkProgramObject::
// pointSizeCapabilityUnsupported) instead of being handed to the driver as invalid usage.
Bool m_tessellationAndGeometryPointSizeEnabled = false;
// Startup snapshot used only by internally synthesized shader modules, which do not
// originate from a ProgramLinkTask.
Bool m_enableSpirvValidation = false;
@@ -37,6 +37,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// glGenTextures ever hands this out, and nothing looks a placeholder up by name - so the
// id only has to stay clear of the application's, exactly like the sampled fallback's.
constexpr Uint kUnboundStorageImageExternalIndex = 0xFFFFFF01u;
// The multisample sampled fallbacks: one per (target, numeric domain), because unlike the
// single-sampled fallback they cannot be reinterpreted into another domain at view time
// (see GetFallbackMultisampleTexture). Six reserved ids, contiguous from this base for the
// same reason as the two above - they must not collide with anything glGenTextures can
// hand out.
constexpr Uint kFallbackMultisampleExternalIndexBase = 0xFFFFFF02u;
constexpr Uint kFallbackMultisampleExternalIndexCount = 6u;
// MobileGL's own stand-in textures, by the reserved ids above. Nothing an application can
// do reaches one, so anything keyed on the GL object an application bound - image-unit
// aliasing above all - has to leave them alone.
Bool IsPlaceholderTexture(const MG_State::GLState::ITextureObject* texture) {
if (texture == nullptr) return false;
const Uint index = static_cast<Uint>(texture->GetExternalIndex());
return index == kFallbackTexture2DExternalIndex || index == kUnboundStorageImageExternalIndex ||
(index >= kFallbackMultisampleExternalIndexBase &&
index < kFallbackMultisampleExternalIndexBase + kFallbackMultisampleExternalIndexCount);
}
// The R32 member of each numeric class. Every one of the three is a MANDATORY-support
// format for uniform texel buffers, storage texel buffers and storage images alike
@@ -360,6 +378,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_textureManager = nullptr;
m_samplerManager = nullptr;
m_fallbackTexture2D.reset();
m_fallbackMultisampleTextures.clear();
}
void UniformManager::BeginFrame(Uint32 frameIndex) {
@@ -496,7 +515,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
texture = nullptr;
}
if (texture == nullptr) {
fallbackHolder = GetFallbackTexture(preferredTarget);
// The binding's sampler class, read here rather than through the `numericDomain`
// local further down (it is declared after this point): the multisample placeholder
// has to be built in the class the shader will read it in.
fallbackHolder = GetFallbackTexture(preferredTarget, programObj.samplerNumericDomainByBinding[binding]);
texture = fallbackHolder.get();
if (texture == nullptr) {
MGLOG_E_ONCE("ResolveSamplerDescriptor: no fallback texture available for binding=%u ('%s') "
@@ -1367,18 +1389,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return outImageInfo.imageView != VK_NULL_HANDLE;
}
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackTexture(TextureTarget target) const {
// The fallback is a single-sampled 2D image, so it can only stand in for a sampler that
// would accept one. A multisample sampler in particular cannot: its descriptor demands a
// multisample view, and handing it this one is invalid Vulkan, not a degraded picture.
// Report that there is no fallback and let the caller decline the draw - aborting the
// process over an unbound sampler is never the right answer.
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const {
// A multisample sampler cannot be served by the single-sampled 2D image below - its
// descriptor demands a multisample view - so it gets its own placeholder rather than no
// placeholder at all. Without one, ResolveSamplerDescriptor declined and
// BindProgramUniformBuffers dropped the WHOLE draw, which is how every
// sample_variables.*.samples_0 body failed: the CTS's resolve program declares both a
// sampler2D and a sampler2DMS and deliberately points the unused one at an empty texture
// unit, and at samples_0 the unused one is the sampler2DMS. GL says sampling an
// incomplete texture is undefined, not fatal, so the draw has to happen.
if (target == TextureTarget::Texture2DMultisample ||
target == TextureTarget::Texture2DMultisampleArray) {
return GetFallbackMultisampleTexture(target, numericDomain);
}
if (target != TextureTarget::Texture2D && target != TextureTarget::TextureRectangle) {
MGLOG_E_ONCE("UniformManager::GetFallbackTexture: no fallback exists for target=%d",
static_cast<Int>(target));
return nullptr;
}
// The single-sampled fallback stays domain-agnostic: it is storage-image capable, so its
// image carries VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT and ResolveSampledImageViewFormat can
// hand an integer sampler an R8G8B8A8_UINT view of these same RGBA8 texels. A multisample
// image can never carry that bit, which is why the arm above needs one object per domain.
if (m_fallbackTexture2D == nullptr) {
auto fallbackTexture = MakeShared<MG_State::GLState::TextureObject2D>(kFallbackTexture2DExternalIndex);
fallbackTexture->SetInternalFormat(TextureInternalFormat::RGBA8);
@@ -1396,6 +1430,83 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return m_fallbackTexture2D;
}
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackMultisampleTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const {
// ONE PLACEHOLDER PER NUMERIC DOMAIN, unlike the single-sampled fallback.
//
// A descriptor whose image format is in a different numeric class than the sampler that
// reads it needs a format-reinterpreting view, and building one needs
// VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT on the image. A multisample image can never have it:
// SyncTextureResource computes storageImageCapable as `!isMultisampleTexture && ...`, and
// the only other source of the bit is the sRGB twin, which RGBA8 is not. So an RGBA8
// placeholder handed to a usampler2DMS made GetOrCreateSampledImageView bail with "needs
// mutable image format", ResolveSamplerDescriptor return false, and the draw be dropped -
// the exact outcome the placeholder exists to prevent, just reached later. Matching the
// image's own format to the sampler's class instead means no reinterpreting view is
// needed at all.
const Bool arrayed = target == TextureTarget::Texture2DMultisampleArray;
TextureInternalFormat internalFormat = TextureInternalFormat::RGBA8;
Uint32 domainSlot = 0;
switch (numericDomain) {
case SamplerNumericDomain::SignedInteger:
internalFormat = TextureInternalFormat::RGBA8I;
domainSlot = 1;
break;
case SamplerNumericDomain::UnsignedInteger:
internalFormat = TextureInternalFormat::RGBA8UI;
domainSlot = 2;
break;
case SamplerNumericDomain::Float:
case SamplerNumericDomain::Unknown:
default:
// Unknown reads as float, matching PlaceholderFormatForNumericDomain's own default:
// a shader whose sampler class could not be reflected is far likelier to be a plain
// sampler2DMS than an integer one, and a float view is the only one buildable without
// the mutable bit anyway.
break;
}
const Uint32 key = (arrayed ? kFallbackMultisampleExternalIndexCount / 2 : 0u) + domainSlot;
auto cached = m_fallbackMultisampleTextures.find(key);
if (cached != m_fallbackMultisampleTextures.end()) {
return cached->second;
}
const TextureUploadTarget uploadTarget = arrayed ? TextureUploadTarget::Texture2DMultisampleArray
: TextureUploadTarget::Texture2DMultisample;
const Uint externalIndex = kFallbackMultisampleExternalIndexBase + key;
SharedPtr<MG_State::GLState::TextureObjectMipmap> texture;
if (arrayed) {
texture = MakeShared<MG_State::GLState::TextureObject2DMultisampleArray>(externalIndex);
} else {
texture = MakeShared<MG_State::GLState::TextureObject2DMultisample>(externalIndex);
}
texture->SetInternalFormat(internalFormat);
// TWO samples, never one. VUID-RuntimeSpirv-samples-08726 forbids an OpTypeImage with
// MS = 1 from reading a VK_SAMPLE_COUNT_1_BIT image, which is exactly the hazard
// VkTextureManager::SyncTextureResource's one-sample floor exists to avoid; a placeholder
// that re-created it would be worse than none.
texture->SetSamples(2);
texture->SetFixedSampleLocations(true);
// No upload, and MarkStorageDirty(dirty = false) to say so: a multisample image cannot be
// written by a transfer at all - it deliberately carries no TRANSFER_DST usage - so unlike
// the 2D fallback this one cannot be given (0, 0, 0, 1) content. Its texels are undefined,
// which is precisely what GL 4.6 core 8.17 promises for a texelFetch on a multisample
// texture that is not complete. The point of the placeholder is that the DRAW happens.
texture->AllocateStorage(uploadTarget, 0, {.texelSize = {1, 1, 1}, .byteSize = 0});
texture->TruncateMipmapLevels(uploadTarget, 1);
texture->MarkStorageDirty(uploadTarget, 0, false);
// Worth knowing if it ever fires: an integer multisample format can legitimately support
// no count above one on a device (framebufferIntegerColorSampleCounts is allowed to be
// VK_SAMPLE_COUNT_1_BIT), and SyncTextureResource's round-down would then hand this
// placeholder a single-sampled image, which is the samples-08726 shape the SetSamples(2)
// above exists to avoid. It already warns from there; nothing better is available - a
// one-sample integer image is still a draw, and declining is the outcome this whole
// placeholder replaced.
MGLOG_D("UniformManager::GetFallbackMultisampleTexture: created placeholder target=%d domain=%d format=%d",
static_cast<Int>(target), static_cast<Int>(numericDomain), static_cast<Int>(internalFormat));
return m_fallbackMultisampleTextures.emplace(key, Move(texture)).first->second;
}
VkBufferView UniformManager::AcquireUnboundTexelBufferView(VkFormat declaredFormat,
SamplerNumericDomain numericDomain, Bool storage) {
MOBILEGL_ASSERT(m_bufferManager != nullptr, "AcquireUnboundTexelBufferView: buffer manager is null");
@@ -1551,25 +1662,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding];
MG_State::GLState::ITextureObject* texture =
textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get();
// The sampler in effect, resolved BEFORE the completeness test below rather than after:
// GL's completeness rules are a property of (texture, sampler in effect), so the test
// cannot be asked without it.
const auto& samplerOverride = textureUnit.GetSamplerObject();
const MG_State::GLState::SamplerObject* effectiveSampler =
samplerOverride ? samplerOverride.get()
: (texture != nullptr ? texture->GetSamplerObject().get() : nullptr);
// Undefined default texture (name 0, no image) resolves as "unbound", exactly
// like ResolveSamplerTextureRaw reports it.
if (MG_State::GLState::IsUndefinedDefaultTexture(texture)) {
texture = nullptr;
}
// ...and so does a texture that fails the completeness rules for the filter in effect,
// because that is precisely what ResolveSamplerDescriptor does with it. The two used to
// disagree: this one asked only whether the default texture was UNDEFINED, so a default
// texture that had been given a base level but no mip chain - which is what the GL-CTS
// state reset between test cases leaves behind, and what any application that uploads to
// texture 0 has - stayed in the sampled set while the descriptor path swapped it for the
// fallback. SetupDraw then synced a texture no descriptor would use, the sync declined
// (GL calls it incomplete), and the null it returned was dereferenced one line later.
// Keeping the two predicates identical is the invariant; CollectSampledTextures exists to
// pre-sync exactly the textures the descriptors will hold.
if (MG_State::GLState::SamplesAsIncompleteTexture(texture, effectiveSampler)) {
texture = nullptr;
}
if (texture == nullptr) {
// ResolveSamplerDescriptor will substitute the fallback texture for this binding;
// include it in the sampled set so the pre-render-pass sync/transition pass covers
// its first use instead of leaving that work to happen inside an active pass.
if (preferredTarget != TextureTarget::Texture2D &&
preferredTarget != TextureTarget::TextureRectangle) {
// Ask GetFallbackTexture rather than re-listing the targets it serves: that list grew
// a multisample arm and the two must not drift apart.
texture = GetFallbackTexture(preferredTarget, programObj.samplerNumericDomainByBinding[binding]).get();
if (texture == nullptr) {
return false;
}
texture = GetFallbackTexture(preferredTarget).get();
// The substitution changed the texture, so the "no override" arm of the effective
// sampler has to follow it to the fallback's own.
if (!samplerOverride) {
effectiveSampler = texture != nullptr ? texture->GetSamplerObject().get() : nullptr;
}
}
const auto& samplerOverride = textureUnit.GetSamplerObject();
outTexture = texture;
outSampler = samplerOverride ? samplerOverride.get()
: (texture != nullptr ? texture->GetSamplerObject().get() : nullptr);
outSampler = effectiveSampler;
return true;
}
@@ -1765,9 +1900,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!ResolveSampledBinding(program, programObj, samplerBinding, samplerElement,
sampledTexture, sampledSampler) ||
sampledTexture == nullptr || sampledSampler == nullptr ||
MG_State::GLState::SamplesAsIncompleteTexture(sampledTexture, sampledSampler)) {
IsPlaceholderTexture(sampledTexture)) {
// ResolveSamplerDescriptor uses a fallback in these cases, which cannot
// alias the image-unit binding of the original texture.
// alias the image-unit binding of the original texture. The unbound and
// incomplete cases both arrive here AS that fallback now that
// ResolveSampledBinding applies the completeness rule itself, so the test is
// "is this one of ours" rather than a second completeness check.
continue;
}
// Multisample source images intentionally omit TRANSFER_SRC usage. Keep their existing
@@ -179,7 +179,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw(
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element);
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(TextureTarget target) const;
// `numericDomain` is the sampler's class, and it matters only for the multisample arm -
// see GetFallbackMultisampleTexture for why the single-sampled fallback can ignore it.
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const;
// The multisample arm of GetFallbackTexture. One object per (target, numeric domain) and
// no upload path: a multisample image cannot be written by a transfer, so its texels stay
// undefined - which is what GL promises for a texelFetch on an incomplete multisample
// texture - and it cannot carry MUTABLE_FORMAT, so its format has to match the sampler's
// class outright rather than being reinterpreted at view time.
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackMultisampleTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const;
// ---- placeholders for UNBOUND image-backed descriptors -------------------------
// GL lets a program declare `samplerBuffer`, `imageBuffer` or `image2D` and bind nothing
// to the unit it names: the fetch is then undefined (GL 4.6 core 8.9 for an incomplete
@@ -293,6 +303,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkTextureManager* m_textureManager = nullptr;
VkSamplerManager* m_samplerManager = nullptr;
mutable SharedPtr<MG_State::GLState::ITextureObject> m_fallbackTexture2D;
// Keyed by (arrayed, numeric domain); see GetFallbackMultisampleTexture. Lazily populated,
// never evicted - at most six tiny 1x1 images - and torn down with the manager.
mutable UnorderedMap<Uint32, SharedPtr<MG_State::GLState::ITextureObject>> m_fallbackMultisampleTextures;
// See AcquireUnboundTexelBufferView / GetUnboundStorageImageTexture. Both are lazily
// populated, never evicted (a program's declared formats are a fixed, tiny set) and torn
// down with the manager. The texel views are keyed by format AND by storage-vs-sampled
@@ -28,6 +28,7 @@
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Math/HalfFloat.h"
#include "MG_Util/Metrics/TextureMetrics.h"
#include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/Texture/PixelStoreProcessor.h"
#include <Config.h>
#include <algorithm>
@@ -3159,6 +3160,7 @@ void main() {
m_programFactory = MakeUnique<ProgramFactory>(m_device, m_config, maxProgramBindings,
m_shaderDrawParametersFeatureEnabled,
m_unformattedFloatStorageImagesEnabled,
m_tessellationAndGeometryPointSizeFeatureEnabled,
MG_Config::Features.EnableSpirvValidation,
m_updateAfterBindLimits, subgroupPolicy);
MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed.");
@@ -3276,6 +3278,17 @@ void main() {
vkDestroyQueryPool(m_device, m_xfbQueryPool, nullptr);
m_xfbQueryPool = VK_NULL_HANDLE;
}
if (m_primGenReroutePool != VK_NULL_HANDLE) {
vkDestroyQueryPool(m_device, m_primGenReroutePool, nullptr);
m_primGenReroutePool = VK_NULL_HANDLE;
}
m_primGenRerouteActiveSlots.clear();
m_primGenRerouteSlotCursor = 0;
m_primGenRerouteSlotOpen = false;
// Not sticky across renderers: the next bring-up re-decides both (from the
// per-process probe memo, so it re-decides without re-probing).
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
m_primGenStreamCountsXfbInactiveDraws = false;
m_bufferManager.Shutdown();
// Device is idle (vkDeviceWaitIdle above); query pools can be destroyed.
@@ -4767,16 +4780,43 @@ void main() {
// build in GetOrCreatePipeline - any new GL-state read there must be added here:
// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides
// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest,
// PrimitiveRestart(+FixedIndex), SampleShading, plus the depth write mask
// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask
// - patch vertices, polygon mode, cull face mode, depth func, logic op,
// min sample shading
// min sample shading, the glSampleMaski word
// - front/back stencil ops + compare funcs (ref/mask are dynamic state)
// - per draw buffer up to the render pass's colour span: indexed blend enable,
// blend factors/equations, indexed colour write mask (broadcast from index 0
// when the device lacks independentBlend - the same read the payload does)
// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are
// pinned by the render-pass hash key, exactly as the version-keyed memo relied on.
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount) const {
// The fixed-function sample mask this draw actually gets, and the ONE place that decides it.
//
// GL 4.6 core 17.3.3 puts SAMPLE_MASK/SAMPLE_MASK_VALUE among the multisample fragment
// operations and says they make no change "if MULTISAMPLE is disabled, or if the value of
// SAMPLE_BUFFERS is not one" - so on a single-sample draw framebuffer the mask is a no-op.
// Vulkan has no such rule: pSampleMask is ANDed with coverage at every rasterizationSamples,
// and at one sample that coverage is bit 0 alone. Handing the raw GL word straight through
// therefore turned `glEnable(GL_SAMPLE_MASK); glSampleMaski(0, 0x2);` followed by a draw to
// the default framebuffer - the ordinary MSAA-render-then-present shape, and what dEQP's
// multisample cases leave enabled - into a fully discarded, black draw. All-ones restores
// the null-pSampleMask meaning the pipeline had before the mask was plumbed at all.
//
// SAMPLE_BUFFERS is the load-bearing half: MultisampleEnabled defaults to TRUE, so the
// capability check alone would gate nothing. It is here for spec completeness - GL lets
// glDisable(GL_MULTISAMPLE) switch the whole step off on a multisample target too.
//
// Both callers - the payload and ComputePipelineStateHash's memo word - go through this, so
// the memo key cannot describe a different mask than the pipeline was built with.
Uint32 VulkanRenderer::ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const {
constexpr Uint32 kFullCoverage = 0xffffffffu;
if (rasterizationSamples == VK_SAMPLE_COUNT_1_BIT) return kFullCoverage;
if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage;
if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage;
return MG_State::pGLContext->GetRenderStateParameters().SampleMaskValue;
}
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const {
// One bulk fetch instead of ~17 per-field accessor calls into MG_State: every
// input below is a plain field of RenderStateParameters, and each accessor this
// replaces (IsCapabilityEnabled / Get*) is a verified pure read of that same
@@ -4795,6 +4835,13 @@ void main() {
capabilityBits |= p.PrimitiveRestartFixedIndexEnabled ? 1ull << 7 : 0;
capabilityBits |= p.DepthMask ? 1ull << 8 : 0;
capabilityBits |= p.SampleShadingEnabled ? 1ull << 9 : 0;
// The EFFECTIVE mask enable, not the raw GL bit: at one sample GL says the whole
// multisample fragment-operations step makes no change, so the pipeline is built with
// full coverage and the memo word has to say so too. Keying on the raw bit here while
// the payload gates on the sample count would let one FBO's cached pipeline answer for
// another whose sample count reads the mask differently.
const Bool sampleMaskEffective = ResolveEffectiveSampleMask(rasterizationSamples) != 0xffffffffu;
capabilityBits |= sampleMaskEffective ? 1ull << 10 : 0;
Uint64 hash = CombinePipelineStateWord(0x243F6A8885A308D3ull, capabilityBits);
// glMinSampleShading. Hashed by BITS, not by value: this memo compares hashes rather than
// versions, so an unhashed float would let a pipeline built at one rate be handed back
@@ -4804,6 +4851,13 @@ void main() {
std::memcpy(&minSampleShadingBits, &p.MinSampleShadingValue, sizeof(minSampleShadingBits));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(minSampleShadingBits));
}
// glSampleMaski's word, for the same reason glMinSampleShading's bits are hashed above:
// this memo compares hashes, not versions, so a mask that moved between two otherwise
// identical draws has to key a different pipeline. Hashed unconditionally rather than only
// while GL_SAMPLE_MASK is enabled - the enable bit is already in capabilityBits, and
// folding one more word costs nothing on a path that only recomputes when the
// pipeline-state version moved.
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ResolveEffectiveSampleMask(rasterizationSamples)));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(p.PatchVertices));
// The default tessellation levels belong here for the same reason PatchVertices does:
// when a program has an evaluation stage and no control stage, both are compiled into the
@@ -4927,10 +4981,13 @@ void main() {
// The version only guards recomputing the hash - unchanged version, unchanged bytes.
const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount) {
m_pipelineStateHash = ComputePipelineStateHash(renderPassEntry.colorAttachmentCount);
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount ||
m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) {
m_pipelineStateHash =
ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount);
m_pipelineStateHashVersion = renderStateVersion;
m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount;
m_pipelineStateHashSampleCount = renderPassEntry.sampleCount;
m_pipelineStateHashValid = true;
}
const Uint64 pipelineStateHash = m_pipelineStateHash;
@@ -5199,6 +5256,8 @@ void main() {
.sampleShadingEnable = m_sampleRateShadingFeatureEnabled &&
MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleShading),
.minSampleShading = MG_State::pGLContext->GetMinSampleShadingValue(),
// Word 1 keeps its all-ones initialiser: GL has no state for samples 32..63.
.sampleMask = {ResolveEffectiveSampleMask(renderPassEntry.sampleCount), 0xffffffffu},
.subpass = 0,
.topology = vkTopology,
.primitiveRestartEnable = primitiveRestartEnabled,
@@ -5533,18 +5592,32 @@ void main() {
}
}
// Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with
// glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected at
// device creation and surfaced in the POST; if a shader actually issues a draw with a SRC1
// factor on a device that lacks it, there is no fallback, so hard-fail here at use time
// rather than silently mistranslating the blend equation.
if (effectiveBlendEnabled && !m_dualSrcBlendFeatureEnabled &&
// glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected
// at device creation and surfaced in the POST; there is no fallback that BLENDS correctly,
// so a draw that asks for a SRC1 factor on a device without the feature gets the blend
// DECLINED - this attachment is baked with blending off and neutral One/Zero factors, and
// the loss is logged once. Both the factors AND the enable have to be neutralised:
// VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three
// siblings forbid a VK_BLEND_FACTOR_SRC1_* in the struct without the feature whatever
// blendEnable says, so clearing only the enable would still be invalid pipeline state.
// The previous behaviour, throwing, took the whole process down over one unsupported
// blend factor; this is defined, survivable and visible in the log, and it matches what
// the non-blendable-format arm above already does.
if (!m_dualSrcBlendFeatureEnabled &&
(IsDualSourceBlendFactor(srcRGB) || IsDualSourceBlendFactor(dstRGB) ||
IsDualSourceBlendFactor(srcAlpha) || IsDualSourceBlendFactor(dstAlpha))) {
THROW_EXCEPTION(
"Dual-source blending (GL_SRC1_* blend factor) was used on color attachment " +
std::to_string(i) +
", but the Vulkan device does not support the dualSrcBlend feature (see the "
"dualSrcBlend row in the driver POST). No fallback exists; the draw cannot proceed.");
MGLOG_E_ONCE(
"GetOrCreatePipeline: dual-source blending (GL_SRC1_* blend factor) was requested on "
"color attachment %u, but the Vulkan device does not support the dualSrcBlend feature "
"(see the dualSrcBlend row in the driver POST). Blending is DECLINED on that "
"attachment - the fragment's first output is written unblended and the second source "
"is dropped (program=%u)",
i, program.GetExternalIndex());
effectiveBlendEnabled = false;
srcRGB = BlendFactor::One;
dstRGB = BlendFactor::Zero;
srcAlpha = BlendFactor::One;
dstAlpha = BlendFactor::Zero;
}
payload.colorBlendAttachments[i] = MakeColorBlendAttachmentState(
effectiveBlendEnabled,
@@ -6047,6 +6120,16 @@ void main() {
snap.programFactoryEpoch = m_programFactory->GetCacheStructureEpoch();
}
const auto& programObj = *programObjPtr;
// Pinned for BeginXfbCaptureForDraw, which otherwise decides from GL state alone and has
// no way to know the bound pipeline's last pre-rasterization module lost (or never got)
// its Xfb execution mode. See VkProgramObject::xfbCaptureDeclined.
m_currentDrawXfbCaptureDeclined = programObj.xfbCaptureDeclined;
// A refused program cannot reach here today - the full path refuses before it ever
// records a snapshot - but declining the fast path costs one compare and means the
// refusal does not depend on that ordering staying true.
if (programObj.pointSizeCapabilityUnsupported) {
return false;
}
// The pipeline and the vertex-input pre-flight depend on the VAO only through
// its resolved LAYOUT (layoutHash folds the attribute formats, bindings and the
@@ -6209,10 +6292,13 @@ void main() {
// instead of missing forever on a monotonic version. A miss falls through
// to the full lookup.
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != snap.renderPassColorCount) {
m_pipelineStateHash = ComputePipelineStateHash(snap.renderPassColorCount);
m_pipelineStateHashColorCount != snap.renderPassColorCount ||
m_pipelineStateHashSampleCount != snap.renderPassSampleCount) {
m_pipelineStateHash =
ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount);
m_pipelineStateHashVersion = renderStateVersion;
m_pipelineStateHashColorCount = snap.renderPassColorCount;
m_pipelineStateHashSampleCount = snap.renderPassSampleCount;
m_pipelineStateHashValid = true;
}
const auto memoTransformFlags =
@@ -6447,6 +6533,16 @@ void main() {
}
}
const auto& programObj = *resolvedProgramObj;
// Pinned for BeginXfbCaptureForDraw, which otherwise decides from GL state alone and has
// no way to know the bound pipeline's last pre-rasterization module lost (or never got)
// its Xfb execution mode. See VkProgramObject::xfbCaptureDeclined.
m_currentDrawXfbCaptureDeclined = programObj.xfbCaptureDeclined;
// The build already said why, once, naming the program and the stage. Refusing here -
// before any pipeline is built from it - is what makes that message a decline rather
// than a note attached to invalid usage the driver still receives.
if (programObj.pointSizeCapabilityUnsupported) {
return false;
}
// For the snapshot's memoised entry pointer: if anything below inserts into the
// program cache (blit/aux program compiles), the epoch moves and the snapshot
// stores no pointer for this draw - the fast path then re-looks-up once.
@@ -6485,11 +6581,31 @@ void main() {
const Uint64 programLifetimeId = program.GetLifetimeId();
const Uint32 programVersion = program.GetBackendStateVersion();
const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
// The bind generation alone stopped covering this set the moment ResolveSampledBinding
// started asking SamplesAsIncompleteTexture: membership now depends on the effective
// sampler PARAMETERS (MIN_FILTER decides whether the mip chain is read at all) and on
// the texture SHAPE, and neither moves the bind generation. A texture that flips
// incomplete -> complete under a fixed binding - one glTexParameteri, one
// glSamplerParameteri, a BASE_LEVEL/MAX_LEVEL change, or an upload that fills the
// chain - would keep replaying the FALLBACK out of this memo, so the real texture
// never got its pre-pass sync, its pending-clear materialisation or its sampled-layout
// transition, and the descriptor path would then transition it from INSIDE the open
// render pass, which the subpass declares no self-dependency for.
//
// The sampling-resolution generation is exactly the counter for that family and is
// deliberately coarse (any texture, any sampler), so this one term covers every input
// the predicate reads that the bind generation does not: TextureObjectBase::
// BumpShapeVersion and SamplerObject::BumpVersion both bump it, while WHICH sampler
// object a unit carries goes through TextureUnit::SetSamplerObject and moves the bind
// generation instead. Same term the SetupDrawSnapshot fast path and the LOD memo
// already carry.
const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
const Bool sampledSetUnchanged =
m_lastSampledSetValid && m_lastSampledSetProgramLifetimeId == programLifetimeId &&
m_lastSampledSetProgramVersion == programVersion &&
m_lastSampledSetTransformFlags == transformFlags &&
m_lastSampledSetBindGeneration == bindGeneration;
m_lastSampledSetBindGeneration == bindGeneration &&
m_lastSampledSetSamplingGeneration == samplingGeneration;
if (!sampledSetUnchanged) {
const Bool hasSampledTextures = m_uniformManager->CollectSampledTextures(
program, programObj, sampledTextures, &m_sampledBindingRecordsScratch);
@@ -6499,6 +6615,7 @@ void main() {
m_lastSampledSetProgramVersion = programVersion;
m_lastSampledSetTransformFlags = transformFlags;
m_lastSampledSetBindGeneration = bindGeneration;
m_lastSampledSetSamplingGeneration = samplingGeneration;
}
// Complete a freshly-made LOD decision (see above): its params sum
// can only be taken once the sampled set is known. A genuine
@@ -6544,9 +6661,21 @@ void main() {
}
auto* textureResource = m_textureManager->SyncTextureAndGetDescriptor(*sampledTexture);
MOBILEGL_ASSERT(textureResource != nullptr,
"%s: SyncTextureAndGetDescriptor failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
if (textureResource == nullptr) {
// SyncTextureAndGetDescriptor has a real failure channel - an incomplete or
// otherwise unbackable texture declines and returns nullptr with its own log
// line - and the assert that used to be the only guard here is compiled out of
// every build past DEBUG. The next line dereferenced it, so a sampler left
// pointing at a texture GL calls incomplete was a SIGSEGV inside SetupDraw
// rather than a degraded draw. Leave the slot null and carry on: the descriptor
// resolve substitutes the fallback texture for exactly these bindings
// (ResolveSamplerDescriptor's SamplesAsIncompleteTexture branch), and the fast
// path at the top of SetupDraw already treats a null resource as "re-resolve".
MGLOG_E_ONCE("SetupDraw: no texture resource for sampled textureId=%d; leaving the binding to the "
"descriptor resolve's fallback",
sampledTexture->GetExternalIndex());
continue;
}
sampledResources[sampledIndex] = textureResource;
MGLOG_D("SetupDraw: sampled textureId=%d layout(before)=%s(%d)",
sampledTexture->GetExternalIndex(), VkImageLayoutToString(textureResource->layout),
@@ -6610,9 +6739,14 @@ void main() {
MOBILEGL_ASSERT(ready, "%s: TransitionTextureForSampling failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
auto* transitionedResource = m_textureManager->SyncTextureAndGetDescriptor(*sampledTexture);
MOBILEGL_ASSERT(transitionedResource != nullptr,
"%s: post-transition SyncTextureAndGetDescriptor failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
if (transitionedResource == nullptr) {
// Same declined-sync channel as the first loop, and the same reason not to
// dereference it: StampResourceRecordingUse below takes a reference.
MGLOG_E_ONCE("SetupDraw: no texture resource after transitioning sampled textureId=%d; leaving the "
"binding to the descriptor resolve's fallback",
sampledTexture->GetExternalIndex());
continue;
}
// Pre-pass stream bookkeeping: the draw about to be recorded reads
// this image, so later out-of-pass work on it can no longer jump
// ahead of the recording.
@@ -6780,6 +6914,7 @@ void main() {
snap.drawUsesDepthStencil = drawUsesDepthStencil;
snap.renderPassExtent = renderPassEntry->extent;
snap.renderPassColorCount = renderPassEntry->colorAttachmentCount;
snap.renderPassSampleCount = renderPassEntry->sampleCount;
snap.pipeline = pipeline;
// The layout identity the fast path's aux-memo compare answers against.
// A memo hit here, not a rebuild: the pre-flight above resolved this
@@ -7579,7 +7714,8 @@ void main() {
Bool VulkanRenderer::ClearDepthSliceWithRenderPass(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture, Uint32 mipLevel,
Uint32 depthSlice, const VkClearValue& clearValue) {
Uint32 depthSlice, const VkClearValue& clearValue,
VkImageLayout finalLayout) {
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture);
if (resource == nullptr || resource->image == VK_NULL_HANDLE) return false;
if (m_frameContext.GetCurrentFrameIndex() >= m_deferredDepthMipmapCleanup.size()) return false;
@@ -7592,7 +7728,11 @@ void main() {
VkAttachmentDescription colorAttachment{};
colorAttachment.format = resource->format;
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
// The image's own count, not a hardcoded one: a render-pass attachment must match the
// image it is given (VUID-VkFramebufferCreateInfo-pAttachments-00880), and this helper is
// now also the multisample path - a multisample image carries no TRANSFER_DST usage, so a
// load-op clear is the only legal way to clear it at all.
colorAttachment.samples = resource->sampleCount;
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
@@ -7600,7 +7740,7 @@ void main() {
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
// Hand the slice back in the layout the caller already tracks for the whole image, so its
// closing barrier stays truthful and resource->layout is never touched from in here.
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
colorAttachment.finalLayout = finalLayout;
VkAttachmentReference colorRef{};
colorRef.attachment = 0;
@@ -7668,9 +7808,26 @@ void main() {
"MaterializePendingClearForTexture requires no active render pass on the target buffer");
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture);
MOBILEGL_ASSERT(resource != nullptr,
"MaterializePendingClearForTexture: SyncTextureAndGetDescriptor failed for textureId=%d",
texture.GetExternalIndex());
if (resource == nullptr) {
// Declined sync (an incomplete texture, say). Nothing to clear into, and every line
// below dereferences this - the assert that used to stand here is compiled out of
// every build past DEBUG.
MGLOG_E_ONCE("MaterializePendingClearForTexture: no texture resource for textureId=%d; the queued clears "
"stay queued",
texture.GetExternalIndex());
return false;
}
// A multisample image is not a transfer target: SyncTextureResource deliberately withholds
// TRANSFER_DST/TRANSFER_SRC from every one of them, so the vkCmdClearColorImage below -
// and the TRANSFER_DST transition ahead of it - are invalid usage
// (VUID-vkCmdClearColorImage-image-00002) on exactly the shape a
// glClearBufferfv-then-sample sequence produces. Clear it the one way that is legal at
// any sample count instead: a throwaway render pass whose whole content is its load-op
// clear, which is also what the 3D-slice case below already does.
if (resource->sampleCount != VK_SAMPLE_COUNT_1_BIT) {
return MaterializeMultisamplePendingClear(commandBuffer, texture, *resource, pendingClears);
}
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags srcAccessMask = 0;
@@ -7810,6 +7967,77 @@ void main() {
return true;
}
Bool VulkanRenderer::MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture,
VkTextureManager::TextureResource& resource,
const Vector<PendingClearEntry>& pendingClears) {
// Colour only. GL can queue a depth/stencil clear on a multisample texture too, and the
// load-op idiom would serve it just as well, but this helper attaches its view as a
// COLOUR attachment; declining is honest and leaves the queue intact for a later path.
if ((resource.aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d is a multisample depth/stencil texture; "
"its queued clear cannot be materialised out of a render pass yet",
texture.GetExternalIndex());
return false;
}
Bool allCleared = true;
for (const auto& pendingClear : pendingClears) {
if (pendingClear.key.mipLevel >= resource.mipLevels) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d pending clear mip=%u out of range %u",
texture.GetExternalIndex(), pendingClear.key.mipLevel, resource.mipLevels);
allCleared = false;
continue;
}
auto clearPayload = pendingClear.payload;
PreCompensateSrgbClearColor(clearPayload, resource.format);
VkClearValue clearValue{};
clearValue.color = MakeVkClearColorValue(clearPayload, ColorFormatLacksAlpha(&texture));
// A multisample texture has exactly one level and, for the 2D target, one layer; the
// array target's layers are cleared one at a time, which is what this helper's
// per-layer view gives us.
const Uint32 firstLayer = pendingClear.key.baseArrayLayer;
const Uint32 layerCount = std::max(pendingClear.key.layerCount, 1u);
for (Uint32 layer = firstLayer; layer < firstLayer + layerCount; ++layer) {
if (layer >= resource.arrayLayers) break;
// COLOR_ATTACHMENT_OPTIMAL, not TRANSFER_DST: the image never has transfer usage,
// and a render target is where it came from and where it is going.
if (!ClearDepthSliceWithRenderPass(commandBuffer, texture, pendingClear.key.mipLevel, layer,
clearValue, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL)) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d layer %u could not be cleared",
texture.GetExternalIndex(), layer);
allCleared = false;
}
}
}
if (!allCleared) {
return false;
}
// The load-op clear left every touched layer in COLOR_ATTACHMENT_OPTIMAL (each pass's
// finalLayout), so that - not the tracked layout on entry - is what the closing barrier
// has to start from.
// TransitionImageLayout takes the tracked layout by reference and updates it, so seeding
// it is both how the barrier learns its source and how resource->layout ends up right.
resource.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
const Bool ok = VkTextureManager::TransitionImageLayout(
commandBuffer, resource.image, resource.layout,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_ALL_GRAPHICS_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
resource.aspect, 0, resource.mipLevels);
if (!ok) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: failed to transition textureId=%d to the sampled layout",
texture.GetExternalIndex());
return false;
}
m_clearManager->PopPendingClear(&texture);
MGLOG_D("MaterializeMultisamplePendingClear: textureId=%d pending clear materialised through a load-op pass",
texture.GetExternalIndex());
return true;
}
Bool VulkanRenderer::MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer, const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer) {
if (renderbuffer == nullptr) {
@@ -10487,19 +10715,30 @@ void main() {
"GetTexImage: failed to materialize pending clear for textureId=%d",
textureObject->GetExternalIndex());
// WHICH FACE the caller asked for. glGetTexImage names one face of a cube map through the
// TARGET token (GL_TEXTURE_CUBE_MAP_NEGATIVE_X and friends, GL 4.6 core 8.11), and a cube
// map's six faces are its VkImage's six ARRAY LAYERS - so unless the token is turned into a
// baseArrayLayer, every face token reads layer 0 and the whole cube answers as +X. The
// image's own target cannot supply this: a plain GL_TEXTURE_CUBE_MAP is not an array target,
// so the layer arithmetic below leaves it at one layer starting at zero, which is precisely
// the layer this face index has to displace. Same conversion, same reason, as
// VkClearManager's / VkRenderPassManager's ResolveAttachmentBaseArrayLayer, which resolve an
// ATTACHMENT's face; this is the readback's copy of it. Zero for every other target,
// including a cube map ARRAY - that one arrives as TextureUploadTarget::CubeMapArray with
// its layer-faces already counted in the level's z, not as a face token.
const Bool isCubeFaceTarget = textureUploadTarget >= TextureUploadTarget::CubeMapPositiveX &&
textureUploadTarget <= TextureUploadTarget::CubeMapNegativeZ;
const Int glCubeFaceLayer = isCubeFaceTarget
? static_cast<Int>(textureUploadTarget) - static_cast<Int>(TextureUploadTarget::CubeMapPositiveX)
: 0;
if ((resource->aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
if (format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL || format == GL_STENCIL_INDEX) {
const auto levelSize =
textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, static_cast<Uint>(level));
const Bool isCubeFace = textureUploadTarget >= TextureUploadTarget::CubeMapPositiveX &&
textureUploadTarget <= TextureUploadTarget::CubeMapNegativeZ;
// Storage space: `resource` is the storage texture's, so a view's level and
// layer have to be shifted into its numbering (see ToStorageMipLevel).
const Int glArrayLayer = isCubeFace
? static_cast<Int>(textureUploadTarget) -
static_cast<Int>(TextureUploadTarget::CubeMapPositiveX)
: 0;
const Uint32 arrayLayer = ToStorageArrayLayer(textureObject.get(), glArrayLayer);
const Uint32 arrayLayer = ToStorageArrayLayer(textureObject.get(), glCubeFaceLayer);
const Uint32 storageLevel = ToStorageMipLevel(textureObject.get(), level);
// A 1D array's levelSize.y() is its LAYER count, and those layers are the rows
// GL wants back - but in Vulkan they are array layers of a one-row image, not
@@ -10594,7 +10833,10 @@ void main() {
// Storage space, as above: a texture view reads its own level 0 out of whichever level
// and layer of the parent it opened onto.
copyRegion.imageSubresource.mipLevel = ToStorageMipLevel(textureObject.get(), level);
copyRegion.imageSubresource.baseArrayLayer = ToStorageArrayLayer(textureObject.get(), 0);
// glCubeFaceLayer, not 0: the cube face the target token named (see above). Non-zero for
// exactly one shape - a plain cube map read one face at a time - and layerCount is 1 there,
// so the copy stays inside the six layers the image has.
copyRegion.imageSubresource.baseArrayLayer = ToStorageArrayLayer(textureObject.get(), glCubeFaceLayer);
copyRegion.imageSubresource.layerCount = static_cast<Uint32>(arrayLayers);
copyRegion.imageExtent = {static_cast<Uint32>(width),
is1dArrayImage ? 1u : static_cast<Uint32>(height),
@@ -10919,6 +11161,19 @@ void main() {
if (!program || program->GetTransformFeedbackVaryingCount() == 0) {
return false;
}
// The bound pipeline's last pre-rasterization stage has to have been declared with Xfb
// (VUID-vkCmdBeginTransformFeedbackEXT-None-04128). Everything above this line reads GL
// state, which cannot answer that: a program can be built as a capture variant and still
// end up with a module carrying no Xfb mode - the clip/XFB validation backstop rewinding
// past the decoration, or XfbCaptureDecoratePass resolving none of the requested varyings
// and changing nothing. Declining the span leaves the capture buffers untouched, which is
// the same nothing the driver would have written, without the undefined behaviour.
if (m_currentDrawXfbCaptureDeclined) {
MGLOG_E_ONCE("BeginXfbCaptureForDraw: declining the capture span - the bound program's last "
"pre-rasterization stage carries no Xfb execution mode, so recording one would be "
"undefined behaviour rather than a capture");
return false;
}
const SizeT bufferCount = std::min<SizeT>(program->GetTransformFeedbackBufferCount(), 4);
if (bufferCount == 0) {
return false;
@@ -11056,7 +11311,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDraw(commandBuffer,
payload.params.vertexCount,
@@ -11142,23 +11397,81 @@ void main() {
}
s_vkResetQueryPool(m_device, m_xfbQueryPool, 0, kXfbQuerySlots);
}
// The reroute pool, on the first GENERATED span that needs it. A creation
// failure disarms rather than failing the capture: the stream path still
// answers (with the driver's defect), which beats answering nothing.
if (kind == 1 && m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool == VK_NULL_HANDLE) {
VkQueryPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
poolInfo.queryCount = kXfbQuerySlots;
if (m_primGenRerouteKind == MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt) {
// The query Vulkan defines for this GL target; counts vertex stream 0
// when begun with plain vkCmdBeginQuery.
poolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
} else {
poolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
// The clipping-stage INVOCATION counter: one per primitive reaching
// primitive clipping (GL's CLIPPING_INPUT_PRIMITIVES) - post-tess/GS,
// pre-clip, and per spec still counted under rasterizer discard, which
// is exactly the set GL_PRIMITIVES_GENERATED is defined over. The
// stage's OUTPUT count (CLIPPING_PRIMITIVES_BIT) would be wrong:
// clipping may drop or split primitives.
poolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
}
if (vkCreateQueryPool(m_device, &poolInfo, nullptr, &m_primGenReroutePool) != VK_SUCCESS) {
MGLOG_E_ONCE("StartXfbQueryCapture: reroute pool creation failed; the "
"PRIMITIVES_GENERATED reroute is disarmed and XFB-inactive draws keep "
"the stream query");
m_primGenReroutePool = VK_NULL_HANDLE;
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
} else {
s_vkResetQueryPool(m_device, m_primGenReroutePool, 0, kXfbQuerySlots);
}
}
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = true;
if (kind == 1) {
m_primGenRerouteActiveSlots.clear();
}
return true;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots) {
Bool VulkanRenderer::ArePausedDrawsGpuCounted() const {
// Exactly the gate BeginXfbQueryForDraw applies per draw, so a span told "armed"
// really does get a reroute slot for every draw with no open capture - a paused
// span's draws included.
const Bool rerouteArmed = m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE;
// Otherwise the paused draw takes a stream slot, which is an exact count of it
// on a driver the probe measured as counting capture-less draws.
return rerouteArmed || m_primGenStreamCountsXfbInactiveDraws;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots,
Vector<Uint32>& outRerouteSlots) {
if (kind > 1) {
return;
}
outSlots = Move(m_xfbQueryActiveSlots[kind]);
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = false;
outRerouteSlots.clear();
if (kind == 1) {
outRerouteSlots = Move(m_primGenRerouteActiveSlots);
m_primGenRerouteActiveSlots.clear();
}
}
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives) {
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives) {
outPrimitives = 0;
if (slots.empty() || m_xfbQueryPool == VK_NULL_HANDLE) {
const Bool haveStreamSlots = !slots.empty() && m_xfbQueryPool != VK_NULL_HANDLE;
// Reroute slots only ever accumulate the GENERATED target (see
// BeginXfbQueryForDraw); WRITTEN never opens one.
const Bool haveRerouteSlots =
wantGenerated && !rerouteSlots.empty() && m_primGenReroutePool != VK_NULL_HANDLE;
if (!haveStreamSlots && !haveRerouteSlots) {
return true;
}
auto& frame = m_frameContext.GetCurrent();
@@ -11170,44 +11483,108 @@ void main() {
return false;
}
}
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
if (haveStreamSlots) {
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
}
}
}
if (haveRerouteSlots) {
for (const Uint32 slot : rerouteSlots) {
// Both reroute pool kinds answer one 64-bit primitive count per slot.
Uint64 generated = 0;
const VkResult result = vkGetQueryPoolResults(
m_device, m_primGenReroutePool, slot, 1, sizeof(generated), &generated,
sizeof(generated), VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += generated;
}
}
}
return true;
}
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer) {
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive) {
m_xfbQuerySlotOpen = false;
m_primGenRerouteSlotOpen = false;
if ((!m_xfbQueryCaptureActive[0] && !m_xfbQueryCaptureActive[1]) || m_xfbQueryPool == VK_NULL_HANDLE) {
return;
}
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
for (Uint32 kind = 0; kind < 2; ++kind) {
if (m_xfbQueryCaptureActive[kind]) {
m_xfbQueryActiveSlots[kind].push_back(slot);
// Every draw with no OPEN capture is the stream query's silent case, and that
// includes a draw made while the GL span is merely PAUSED (the pause closes the
// capture, so BeginXfbCaptureForDraw already answered false for it). Paused
// draws are rerouted like any other: the frontend's CPU paused-primitive
// counter cannot stand in for them - it is written by only 3 of the ~15 draw
// entry points (never the instanced, indirect or multi-draw ones) and answers 0
// for GL_PATCHES by design, since the tessellator's amplification is not
// knowable on the CPU - which is exactly the CTS's shape. Double counting is
// prevented on the other side instead: a GENERATED span opened while this
// reroute is armed ignores that CPU counter entirely (see
// ArePausedDrawsGpuCounted and DirectVulkan.cpp's XfbGenerated resolve), so
// every XFB-inactive draw in the span is priced exactly once, by this pool.
const Bool rerouteGenerated = m_xfbQueryCaptureActive[1] &&
m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE && !xfbActive;
// The stream slot stays for WRITTEN whatever the reroute does (with capture
// inactive its primitivesWritten is 0, which is the correct WRITTEN answer),
// and for GENERATED wherever this draw is not rerouted - so one GL query span
// may accumulate stream slots (XFB-active draws) and reroute slots
// (XFB-inactive draws) side by side.
const Bool wantStreamSlot =
m_xfbQueryCaptureActive[0] || (m_xfbQueryCaptureActive[1] && !rerouteGenerated);
if (wantStreamSlot) {
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
if (m_xfbQueryCaptureActive[0]) {
m_xfbQueryActiveSlots[0].push_back(slot);
}
if (m_xfbQueryCaptureActive[1] && !rerouteGenerated) {
m_xfbQueryActiveSlots[1].push_back(slot);
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
if (rerouteGenerated) {
// Latched at INFO on purpose: it is the pinned integration lane's arming
// observable (the shape UnlocatedIoBlockScenario asserts), and the builds
// CI runs compile INFO in.
MGLOG_I_ONCE("PRIMITIVES_GENERATED reroute engaged: an XFB-inactive draw accumulates "
"through the %s pool",
m_primGenRerouteKind ==
MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations statistics");
const Uint32 slot = m_primGenRerouteSlotCursor;
m_primGenRerouteSlotCursor = (m_primGenRerouteSlotCursor + 1) % kXfbQuerySlots;
// Same recycle-at-begin discipline as the stream pool. Both pool kinds
// are begun with plain vkCmdBeginQuery (a PRIMITIVES_GENERATED_EXT
// query begun this way counts vertex stream 0).
s_vkResetQueryPool(m_device, m_primGenReroutePool, slot, 1);
vkCmdBeginQuery(commandBuffer, m_primGenReroutePool, slot, 0);
m_primGenRerouteActiveSlots.push_back(slot);
m_primGenRerouteSlotOpen = true;
m_primGenRerouteOpenSlot = slot;
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
void VulkanRenderer::EndXfbQueryForDraw(VkCommandBuffer commandBuffer) {
if (!m_xfbQuerySlotOpen) {
return;
if (m_xfbQuerySlotOpen) {
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
if (m_primGenRerouteSlotOpen) {
vkCmdEndQuery(commandBuffer, m_primGenReroutePool, m_primGenRerouteOpenSlot);
m_primGenRerouteSlotOpen = false;
}
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
Bool VulkanRenderer::BeginOcclusionForDraw(VkCommandBuffer commandBuffer) {
@@ -11257,7 +11634,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDrawIndexed(commandBuffer,
payload.params.indexCount,
@@ -12971,12 +13348,24 @@ void main() {
// Match GL's robust buffer-fetch behavior where the Vulkan device supports it. This covers
// out-of-range fetches; arbitrary GL vertex strides/offsets still need the explicit tight
// repack in VertexInputStateFactory when they violate Vulkan's address-alignment rules.
// MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS leaves it off to measure or dodge its GPU cost.
deviceFeatures.robustBufferAccess = MG_Config::Features.DisableRobustBufferAccess
// MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS leaves it off to measure or dodge its GPU cost.
deviceFeatures.robustBufferAccess = MG_Config::Features.MagmaDisableRobustBufferAccess
? VK_FALSE
: supportedDeviceFeatures.robustBufferAccess;
deviceFeatures.geometryShader = supportedDeviceFeatures.geometryShader;
deviceFeatures.tessellationShader = supportedDeviceFeatures.tessellationShader;
// gl_PointSize is an ORDINARY per-vertex output in desktop GL - a tessellation
// evaluation or geometry shader may write it, and a program may capture it by name -
// but in Vulkan the PointSize built-in is only usable from those two stages when this
// feature is on (VUID-RuntimeSpirv-PointSize-06439; SPIR-V spells the requirement as
// the TessellationPointSize / GeometryPointSize capabilities, which glslang emits from
// any such write). Left off, every one of those programs is invalid usage that a lenient
// driver silently gives an undefined point size and a strict one faults on. Nothing here
// asks for it speculatively: the feature is taken only where the device advertises it.
deviceFeatures.shaderTessellationAndGeometryPointSize =
supportedDeviceFeatures.shaderTessellationAndGeometryPointSize;
m_tessellationAndGeometryPointSizeFeatureEnabled =
deviceFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
// Sampled-read barriers may only name the shader stages whose device feature is
// actually enabled (VUID-vkCmdPipelineBarrier-srcStageMask-04090/-04091), so the
// mask is assembled here, next to the feature decision, and handed to consumers.
@@ -13078,6 +13467,13 @@ void main() {
// occlusion result still satisfies any-samples-style consumers.
deviceFeatures.occlusionQueryPrecise = supportedDeviceFeatures.occlusionQueryPrecise;
m_occlusionQueryPreciseEnabled = deviceFeatures.occlusionQueryPrecise == VK_TRUE;
m_tessellationShaderFeatureEnabled = deviceFeatures.tessellationShader == VK_TRUE;
// Backs the GL_PRIMITIVES_GENERATED reroute's statistics tier (see the
// m_primGenReroute* members): a VK_QUERY_TYPE_PIPELINE_STATISTICS pool may only
// be created with this feature enabled. Enabled wherever the device has it - the
// feature alone costs nothing; pools exist only where the reroute is armed.
deviceFeatures.pipelineStatisticsQuery = supportedDeviceFeatures.pipelineStatisticsQuery;
m_pipelineStatisticsQueryFeatureEnabled = deviceFeatures.pipelineStatisticsQuery == VK_TRUE;
VkDeviceCreateInfo deviceCreateInfo{};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
@@ -13335,13 +13731,13 @@ void main() {
subgroupPropertyQuery.pNext = &subgroupProperties;
getPhysicalDeviceProperties2(m_physicalDevice.handle, &subgroupPropertyQuery);
// Mirrors the loader's HasUsableShaderSubgroupSupport gate, including the
// MOBILEGL_DISABLE_SUBGROUP escape hatch, so the module lowerings can never
// MOBILEGL_MAGMA_DISABLE_SUBGROUP escape hatch, so the module lowerings can never
// disagree with the advertised capabilities.
const Bool usableSubgroups =
subgroupProperties.subgroupSize > 0 &&
(subgroupProperties.supportedStages & VK_SHADER_STAGE_COMPUTE_BIT) != 0 &&
(subgroupProperties.supportedOperations & VK_SUBGROUP_FEATURE_BASIC_BIT) != 0;
if (usableSubgroups && !MG_Config::Features.DisableSubgroup) {
if (usableSubgroups && !MG_Config::Features.MagmaDisableSubgroup) {
m_nativeSubgroupSize = subgroupProperties.subgroupSize;
m_nativeSubgroupSupported = true;
}
@@ -13405,6 +13801,38 @@ void main() {
MGLOG_I("Enabled optional device extension: %s", VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME);
}
}
// VK_EXT_primitives_generated_query - the query Vulkan defines for GL's
// GL_PRIMITIVES_GENERATED precisely because the stream query above needs no
// capture by spec but drivers disagree. Taken with BOTH the base feature and the
// rasterizer-discard feature or not at all: without the latter, a discarding draw
// inside the query is invalid usage, and GL applications toggle discard freely.
// Only the PRIMITIVES_GENERATED reroute consumes it (see ArmPrimGenReroute).
m_primitivesGeneratedQueryFeatureEnabled = false;
m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT primitivesGeneratedQueryFeatures{};
primitivesGeneratedQueryFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (IsExtensionSupported(availableExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME) &&
getPhysicalDeviceFeatures2 != nullptr) {
VkPhysicalDeviceFeatures2 featureQuery{};
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
featureQuery.pNext = &primitivesGeneratedQueryFeatures;
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
if (primitivesGeneratedQueryFeatures.primitivesGeneratedQuery == VK_TRUE &&
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions,
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
enabledDeviceExtensions.push_back(VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithNonZeroStreams = VK_FALSE;
primitivesGeneratedQueryFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
deviceCreateInfo.pNext = &primitivesGeneratedQueryFeatures;
m_primitivesGeneratedQueryFeatureEnabled = true;
m_primitivesGeneratedQueryDiscardFeatureEnabled = true;
MGLOG_I("Enabled optional device extension: %s",
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
}
// VK_EXT_provoking_vertex. Two independent features live behind one extension:
// provokingVertexLast -> flat varyings, gl_Layer/gl_ViewportIndex and
// the input-assembler capture order.
@@ -13754,6 +14182,123 @@ void main() {
m_timerQuerySupported = m_timestampValidBits > 0 && m_timestampPeriodNs > 0.0f;
MGLOG_I("Timer queries %s (timestampValidBits=%u, timestampPeriod=%f ns/tick)",
m_timerQuerySupported ? "supported" : "not supported", m_timestampValidBits, m_timestampPeriodNs);
// Last, because it records on m_graphicsQueue: decide the PRIMITIVES_GENERATED
// reroute for XFB-inactive draws. Nothing else has touched the queue yet.
ArmPrimGenReroute();
}
void VulkanRenderer::ArmPrimGenReroute() {
using namespace MG_Util::SelfTest;
m_primGenRerouteKind = PrimGenRerouteKind::None;
const MG_Config::QuirkOverride overrideSetting = MG_Config::Features.MagmaPrimGenQueryReroute;
// Without stream queries the GENERATED path never opens a slot at all, so
// there is nothing to reroute - whatever the override says.
if (!m_xfbQueriesSupported || !m_hostQueryResetEnabled) {
return;
}
const Bool primitivesGeneratedQueryUsable =
m_primitivesGeneratedQueryFeatureEnabled && m_primitivesGeneratedQueryDiscardFeatureEnabled;
PrimitivesGeneratedNoXfbVerdict verdict = PrimitivesGeneratedNoXfbVerdict::Inconclusive;
// The probe only matters under Auto (ForceOn bypasses the verdict, ForceOff
// never asks), and the answer is a device property - so it is memoized per
// process rather than re-paid on every renderer recreation.
if (overrideSetting == MG_Config::QuirkOverride::Auto) {
static const PrimitivesGeneratedNoXfbMeasurement s_measurement = [&]() {
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = m_device;
probeContext.queue = m_graphicsQueue;
probeContext.queueFamilyIndex =
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily);
probeContext.transformFeedbackQueriesUsable = m_xfbQueriesSupported;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = m_pipelineStatisticsQueryFeatureEnabled;
probeContext.tessellationEnabled = m_tessellationShaderFeatureEnabled;
auto& fns = probeContext.fns;
fns.vkCreateCommandPool = vkCreateCommandPool;
fns.vkDestroyCommandPool = vkDestroyCommandPool;
fns.vkAllocateCommandBuffers = vkAllocateCommandBuffers;
fns.vkBeginCommandBuffer = vkBeginCommandBuffer;
fns.vkEndCommandBuffer = vkEndCommandBuffer;
fns.vkCreateQueryPool = vkCreateQueryPool;
fns.vkDestroyQueryPool = vkDestroyQueryPool;
fns.vkCmdResetQueryPool = vkCmdResetQueryPool;
fns.vkCmdBeginQuery = vkCmdBeginQuery;
fns.vkCmdEndQuery = vkCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = s_vkCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = s_vkCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = vkCreateRenderPass;
fns.vkDestroyRenderPass = vkDestroyRenderPass;
fns.vkCreateFramebuffer = vkCreateFramebuffer;
fns.vkDestroyFramebuffer = vkDestroyFramebuffer;
fns.vkCmdBeginRenderPass = vkCmdBeginRenderPass;
fns.vkCmdEndRenderPass = vkCmdEndRenderPass;
fns.vkCreateShaderModule = vkCreateShaderModule;
fns.vkDestroyShaderModule = vkDestroyShaderModule;
fns.vkCreatePipelineLayout = vkCreatePipelineLayout;
fns.vkDestroyPipelineLayout = vkDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = vkCreateGraphicsPipelines;
fns.vkDestroyPipeline = vkDestroyPipeline;
fns.vkCmdBindPipeline = vkCmdBindPipeline;
fns.vkCmdDraw = vkCmdDraw;
fns.vkCreateFence = vkCreateFence;
fns.vkDestroyFence = vkDestroyFence;
fns.vkQueueSubmit = vkQueueSubmit;
fns.vkWaitForFences = vkWaitForFences;
fns.vkGetQueryPoolResults = vkGetQueryPoolResults;
fns.vkDeviceWaitIdle = vkDeviceWaitIdle;
return RunPrimitivesGeneratedNoXfbProbe(probeContext);
}();
verdict = EvaluatePrimitivesGeneratedNoXfbVerdict(s_measurement);
if (s_measurement.fenceWaitTimedOut) {
// The probe's submission never signaled within its bound, so it left its
// command pool, query pools, render pass, framebuffer, shader modules,
// pipeline layout, pipelines and fence alive on purpose. This device is the
// renderer's own and outlives them, so nothing here may destroy them or
// wait the device idle - the queue may still be executing that submission,
// and an idle wait is the hang the bound exists to prevent. They leak for
// the process's life; a device this sick has bigger problems.
MGLOG_W("PRIMITIVES_GENERATED probe timed out waiting on its own submission (%s); its "
"Vulkan objects are deliberately leaked and XFB-inactive draws keep the stream "
"query", s_measurement.failureReason.c_str());
} else if (!s_measurement.ran) {
MGLOG_W("PRIMITIVES_GENERATED probe did not run (%s); XFB-inactive draws keep the "
"stream query", s_measurement.failureReason.c_str());
} else {
const auto logShape = [](const char* name,
const MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement&
shape) {
MGLOG_I("PRIMITIVES_GENERATED probe %s: drawn=%d stream=%llu/%llu pgq=%llu(%d) "
"stat=%llu(%d)",
name, shape.drawn ? 1 : 0,
static_cast<unsigned long long>(shape.streamGenerated),
static_cast<unsigned long long>(shape.expectedPrimitives),
static_cast<unsigned long long>(shape.primitivesGeneratedExt),
shape.primitivesGeneratedExtMeasured ? 1 : 0,
static_cast<unsigned long long>(shape.statisticsClippingInput),
shape.statisticsMeasured ? 1 : 0);
};
logShape("triangles", s_measurement.trianglesPlain);
logShape("triangles+discard", s_measurement.trianglesDiscard);
logShape("patches+discard", s_measurement.patchesDiscard);
}
}
// A driver whose stream query counts capture-less draws counts a PAUSED span's
// draws through the stream slot they take, so that span's result must not have
// the frontend's CPU paused counter added on top of it either (the pre-reroute
// accounting did exactly that, double counting every paused draw the CPU could
// price). Measured, not assumed: the forced arms never ask the probe and leave
// this false.
m_primGenStreamCountsXfbInactiveDraws = verdict == PrimitivesGeneratedNoXfbVerdict::StreamCounts;
m_primGenRerouteKind = ChoosePrimitivesGeneratedReroute(
overrideSetting, verdict, primitivesGeneratedQueryUsable, m_pipelineStatisticsQueryFeatureEnabled);
if (m_primGenRerouteKind != PrimGenRerouteKind::None) {
MGLOG_I("PRIMITIVES_GENERATED for XFB-inactive draws will accumulate through a %s pool%s",
m_primGenRerouteKind == PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations pipeline-statistics",
overrideSetting == MG_Config::QuirkOverride::ForceOn ? " (forced on)" : "");
}
}
void VulkanRenderer::CreateAllocator() {
@@ -24,6 +24,7 @@
#include "MG_Util/Math/VectorTypes.h"
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
#include <vk_mem_alloc.h>
#include "../VkIncludes.h"
@@ -563,7 +564,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Native subgroup topology, queried at device creation for the compute-module
// subgroup repairs (SubgroupSupportPolicy.h) and the REQUIRE_FULL_SUBGROUPS
// stage flag; 0 / false when the device has no usable compute subgroups or
// MOBILEGL_DISABLE_SUBGROUP forced them off.
// MOBILEGL_MAGMA_DISABLE_SUBGROUP forced them off.
Uint32 m_nativeSubgroupSize = 0;
Bool m_nativeSubgroupSupported = false;
Bool m_computeFullSubgroupsFeatureEnabled = false;
@@ -584,6 +585,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// needs no feature). Both cached at device creation and drive a hard-fail-at-draw when absent.
Bool m_dualSrcBlendFeatureEnabled = false;
Bool m_primitiveTopologyListRestartFeatureEnabled = false;
// shaderTessellationAndGeometryPointSize gates the PointSize built-in in a tessellation
// or geometry stage, which desktop GL treats as an ordinary per-vertex output (writable,
// and capturable by name through transform feedback). Cached at device creation and
// handed to ProgramFactory, which refuses a program whose tessellation or geometry module
// declares the matching SPIR-V capability while this is false - SetupDraw then skips its
// draws (VkProgramObject::pointSizeCapabilityUnsupported) rather than building a pipeline
// that is invalid usage.
Bool m_tessellationAndGeometryPointSizeFeatureEnabled = false;
// VK_EXT_custom_border_color. Vulkan's four predefined VkBorderColor values cover only
// transparent/opaque black and opaque white; GL_TEXTURE_BORDER_COLOR is an arbitrary vec4 (or
// an arbitrary ivec4/uvec4 through the "I" entry points). Without this extension a border
@@ -709,15 +718,74 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Uint32> m_xfbQueryActiveSlots[2];
Bool m_xfbQuerySlotOpen = false;
Uint32 m_xfbQueryOpenSlot = 0;
// GL_PRIMITIVES_GENERATED reroute for draws made while transform feedback is
// INACTIVE. The stream pool's primitivesNeeded is defined to count those draws
// too, but a Mali driver (and Mesa lavapipe) answers 0 unless a capture span
// is open (the CTS's tessellator-measuring shape). Where the bring-up probe
// finds that defect with a working control - or
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE forces it - such draws accumulate the
// GENERATED count through this pool instead, whose type the arming picks:
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT where the device hosts the dedicated
// query with its rasterizer-discard feature (exact semantics by definition -
// the extension exists because GL needs this count without a capture), else a
// VK_QUERY_TYPE_PIPELINE_STATISTICS pool over clipping-stage invocations (one
// per primitive reaching primitive clipping - after every vertex processing
// stage, before rasterizer discard - which is the same set).
// XFB-ACTIVE draws keep the stream slot (exact today, and WRITTEN needs it);
// every draw with no open capture - a PAUSED span's draws included - takes a
// reroute slot, and the span then ignores the frontend's CPU paused-primitive
// counter rather than adding it on top (see IsPrimGenRerouteArmed): that
// counter is written by only 3 of the ~15 draw entry points and answers 0 for
// GL_PATCHES, so it cannot price the draws this reroute exists to repair. One
// GL query span may therefore hold slots of both pools.
Bool m_pipelineStatisticsQueryFeatureEnabled = false;
// VK_EXT_primitives_generated_query: base feature, and the
// ...WithRasterizerDiscard feature without which a discarding draw inside the
// query is invalid usage (so the reroute never picks the dedicated pool on a
// base-only device - GL applications toggle discard freely).
Bool m_primitivesGeneratedQueryFeatureEnabled = false;
Bool m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
// tessellationShader was enabled at device creation (it is taken whenever the
// device advertises it); gates the probe's PATCHES shape.
Bool m_tessellationShaderFeatureEnabled = false;
MG_Util::SelfTest::PrimGenRerouteKind m_primGenRerouteKind =
MG_Util::SelfTest::PrimGenRerouteKind::None;
// The bring-up probe measured this device's stream query as counting draws made
// with no capture span open (the StreamCounts verdict) - so it counts the
// PAUSED-span ones too, through the stream slot they take when nothing is
// rerouted. Only the probe can know this, so it stays false wherever the probe
// is not consulted (the forced arms), which keeps those lanes' accounting as it
// was.
Bool m_primGenStreamCountsXfbInactiveDraws = false;
VkQueryPool m_primGenReroutePool = VK_NULL_HANDLE;
Uint32 m_primGenRerouteSlotCursor = 0;
Vector<Uint32> m_primGenRerouteActiveSlots;
Bool m_primGenRerouteSlotOpen = false;
Uint32 m_primGenRerouteOpenSlot = 0;
// Runs the bring-up probe (memoized per process) and decides
// m_primGenRerouteKind. Called at the end of device creation: it records on
// m_graphicsQueue, which nothing else is using yet.
void ArmPrimGenReroute();
public:
// Whether a GENERATED span opened now will have the draws made while the GL
// span is PAUSED counted on the GPU - through the reroute pool, which takes
// every draw with no open capture, or (where the reroute is not armed because
// the stream query was measured to count capture-less draws) through the stream
// slot such a draw still takes. The frontend's CPU paused-primitive counter
// must not be added on top of either: it would double count, and it cannot
// price the draws that matter anyway - only 3 of the ~15 draw entry points
// write it and it answers 0 for GL_PATCHES. Read once per span, after
// StartXfbQueryCapture (whose pool creation may disarm the reroute).
Bool ArePausedDrawsGpuCounted() const;
// kind: 0 = PRIMITIVES_WRITTEN, 1 = PRIMITIVES_GENERATED.
Bool StartXfbQueryCapture(Uint32 kind);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots, Vector<Uint32>& outRerouteSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives);
private:
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer);
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive);
void EndXfbQueryForDraw(VkCommandBuffer commandBuffer);
VkCommandPool m_commandPool = VK_NULL_HANDLE;
@@ -768,9 +836,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// version: the version is monotonic and bumps on every pipeline-state
// change, so an unchanged (version, colorAttachmentCount) proves the state
// bytes are unchanged and the hash can be reused without re-reading them.
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount) const;
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const;
// The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see
// the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload
// and the pipeline-state memo word so the two cannot disagree.
Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const;
Uint m_pipelineStateHashVersion = 0;
Uint32 m_pipelineStateHashColorCount = 0;
// The sample count the cached hash was computed at. A pipeline-state input now depends on
// it (the effective sample mask), so a draw that changes only the target's sample count
// has to recompute rather than reuse.
VkSampleCountFlagBits m_pipelineStateHashSampleCount = VK_SAMPLE_COUNT_1_BIT;
Uint64 m_pipelineStateHash = 0;
Bool m_pipelineStateHashValid = false;
// GetShaderTransformFlags memo. NOT pure in the pre-transform alone: the
@@ -812,7 +889,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Skip the per-draw CollectSampledTextures walk (~5% of the render thread) when the sampled
// texture SET is provably unchanged from the previous draw: same program (lifetime id +
// backend-state version, which covers sampler-uniform reassignment / relink) and transform
// flags, and no texture bind/unbind/delete since (GetTextureBindGeneration). On a hit,
// flags, no texture bind/unbind/delete since (GetTextureBindGeneration), and nothing that
// moves a texture's shape or a sampler's parameters since (GetSamplingResolutionGeneration
// - membership depends on mipmap-completeness, which both of those decide). On a hit,
// m_sampledTexturesScratch still holds the previous draw's list and steps 2-4 (feedback /
// layout probe / transition) re-run on it, so layout correctness is unaffected - only the GL
// walk is skipped. The program lifetime id (never reused, unlike the GL name) and the
@@ -823,6 +902,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 m_lastSampledSetProgramVersion = 0;
ProgramFactory::CompileOptionFlags m_lastSampledSetTransformFlags = {};
Uint64 m_lastSampledSetBindGeneration = 0;
Uint64 m_lastSampledSetSamplingGeneration = 0;
// Set from the draw's resolved VkProgramObject on both the full and the fast setup paths;
// read by BeginXfbCaptureForDraw, which has only GL state otherwise. See
// VkProgramObject::xfbCaptureDeclined.
Bool m_currentDrawXfbCaptureDeclined = false;
// Memo for the per-draw explicit-LOD-0 eligibility probe
// (ProgramSamplesOnlySingleLevelTextures): same key family as the
@@ -921,6 +1005,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// probe the pipeline memo after a state change without re-fetching the
// render-pass entry (the pass itself is pinned by renderPassHash above).
Uint32 renderPassColorCount = 0;
// Pinned with the colour count and for the same reason: the fast path recomputes the
// pipeline-state value hash from the snapshot, and that hash reads the sample count.
VkSampleCountFlagBits renderPassSampleCount = VK_SAMPLE_COUNT_1_BIT;
VkPipeline pipeline = VK_NULL_HANDLE;
// layoutHash of the snapshotting draw's vertex-input state. The pipeline and
// the vertex-input pre-flight depend on the VAO only through this (plus the
@@ -1279,13 +1366,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1,
GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1,
GLenum filter);
// Clears one z slice of a VK_IMAGE_TYPE_3D colour image. See the call site in
// MaterializePendingClearForTexture for why a transfer clear cannot do this.
// Clears one layer of a colour image through a throwaway render pass whose entire content
// is its LOAD_OP_CLEAR. Two callers, both of which a transfer clear cannot serve: a z
// slice of a VK_IMAGE_TYPE_3D image (vkCmdClearColorImage cannot name one), and a
// MULTISAMPLE image (which carries no TRANSFER_DST usage at all). `finalLayout` is the
// layout the caller already tracks for the whole image, so this never has to touch
// resource->layout.
Bool ClearDepthSliceWithRenderPass(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture, Uint32 mipLevel,
Uint32 depthSlice, const VkClearValue& clearValue);
Uint32 depthSlice, const VkClearValue& clearValue,
VkImageLayout finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
Bool MaterializePendingClearForTexture(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture);
// The multisample arm of the above. Split out rather than branched inline because it
// shares none of the transfer path: a multisample image carries no TRANSFER_DST usage, so
// neither the TRANSFER_DST transition nor vkCmdClearColorImage is legal on one.
Bool MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture,
VkTextureManager::TextureResource& resource,
const Vector<PendingClearEntry>& pendingClears);
Bool MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer,
const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer);
@@ -39,18 +39,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
inline Bool ShouldEmulateSubgroups(const Bool nativeSubgroupSupported) {
return MG_Config::Features.MagmaEmulateSubgroup && !nativeSubgroupSupported &&
!MG_Config::Features.DisableSubgroup;
!MG_Config::Features.MagmaDisableSubgroup;
}
inline Bool ShouldFixIterationRPSubgroupScratch() {
// Auto is ON: the patch is fingerprint-gated to iterationRP's reduction and
// grows one under-declared array; every other module passes through untouched.
return MG_Config::Features.FixIterationRPSubgroupScratch !=
return MG_Config::Features.MagmaFixIterationRPSubgroupScratch !=
MG_Config::QuirkOverride::ForceOff;
}
inline Bool ShouldFixIterationRPBarrier() {
return MG_Config::Features.IterationRPFixBarrier;
return MG_Config::Features.MagmaIterationRPFixBarrier;
}
inline Bool ShouldDeriveNumSubgroups() {
@@ -58,6 +58,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// contract to hold, and the derived ceil() value is the one the renderer can pin
// with REQUIRE_FULL_SUBGROUPS - the driver builtin is the value with no
// cross-driver guarantee (Adreno returns 1 for an 8-subgroup dispatch).
return MG_Config::Features.DeriveNumSubgroups != MG_Config::QuirkOverride::ForceOff;
return MG_Config::Features.MagmaDeriveNumSubgroups != MG_Config::QuirkOverride::ForceOff;
}
} // namespace MobileGL::MG_Backend::DirectVulkan
+32 -2
View File
@@ -146,6 +146,20 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_TRIANGLES: return static_cast<Uint64>(count / 3);
case GL_TRIANGLE_STRIP:
case GL_TRIANGLE_FAN: return count >= 3 ? static_cast<Uint64>(count - 2) : 0;
// Adjacency primitives (GL 4.6 core table 10.1). Only a geometry stage can consume
// them, and it is the ADJACENT-free primitive count that reaches it: 4 vertices per
// line, 6 per triangle, one per step for the strips. Answering 0 here - which is what
// the default arm did - made AccountTransformFeedbackPrimitives bail before it had
// recorded anything, so an adjacency capture advanced neither the captured-vertex
// counter the scattered-capture path is bounded by nor the geometry-capture-draw flag
// that routes the transform feedback queries to the driver's own counter.
case GL_LINES_ADJACENCY: return static_cast<Uint64>(count / 4);
case GL_LINE_STRIP_ADJACENCY: return count >= 4 ? static_cast<Uint64>(count - 3) : 0;
case GL_TRIANGLES_ADJACENCY: return static_cast<Uint64>(count / 6);
case GL_TRIANGLE_STRIP_ADJACENCY: return count >= 6 ? static_cast<Uint64>((count - 4) / 2) : 0;
// GL_PATCHES is deliberately absent: the tessellator's amplification is not knowable
// on the CPU, and answering 0 is what defers GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
// to the driver's own counter, which is the only correct source for a patch capture.
default: return 0;
}
}
@@ -172,11 +186,17 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_LINES:
case GL_LINE_STRIP:
case GL_LINE_LOOP:
// An adjacency primitive delivers the same line/triangle to the geometry stage; the
// adjacent vertices are context, not part of the primitive.
case GL_LINES_ADJACENCY:
case GL_LINE_STRIP_ADJACENCY:
verticesPerPrimitive = 2;
break;
case GL_TRIANGLES:
case GL_TRIANGLE_STRIP:
case GL_TRIANGLE_FAN:
case GL_TRIANGLES_ADJACENCY:
case GL_TRIANGLE_STRIP_ADJACENCY:
verticesPerPrimitive = 3;
break;
default:
@@ -381,11 +401,21 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_POINTS:
compatible = mode == GL_POINTS;
break;
// The adjacency modes belong here too (GL 4.6 core table 13.1, ES 3.2 table 12.1).
// This arm is only reached when the program has NO geometry or tessellation
// evaluation stage, and without a geometry stage the adjacent vertices are simply
// ignored (GL 4.6 core 10.1) - the primitive assembled IS a plain line or triangle,
// so the combination is legal and must capture. Omitting them raised a spurious
// GL_INVALID_OPERATION and dropped the draw entirely, leaving the capture buffer
// with its pre-draw bytes. The geometry-stage input table above already carries the
// same four arms; this is the second table catching up with it.
case GL_LINES:
compatible = mode == GL_LINES || mode == GL_LINE_STRIP || mode == GL_LINE_LOOP;
compatible = mode == GL_LINES || mode == GL_LINE_STRIP || mode == GL_LINE_LOOP ||
mode == GL_LINES_ADJACENCY || mode == GL_LINE_STRIP_ADJACENCY;
break;
case GL_TRIANGLES:
compatible = mode == GL_TRIANGLES || mode == GL_TRIANGLE_STRIP || mode == GL_TRIANGLE_FAN;
compatible = mode == GL_TRIANGLES || mode == GL_TRIANGLE_STRIP || mode == GL_TRIANGLE_FAN ||
mode == GL_TRIANGLES_ADJACENCY || mode == GL_TRIANGLE_STRIP_ADJACENCY;
break;
default:
break;
+34 -9
View File
@@ -64,21 +64,43 @@ namespace MobileGL::MG_Impl::GLImpl {
// and none of them has any state beyond "which object is counting".
UnorderedMap<GLenum, GLuint> g_activePipelineStatisticsQueryIds;
// Whether MobileGL puts GL_ARB_tessellation_shader in its extension string. Read from the
// ADVERTISED list rather than from a capability bit for the same reason
// BackendSupportsTextureViews does (GL_Texture.cpp): it makes "MobileGL claims tessellation
// support" and "the tessellation-conditional API surface is open" the same fact by
// construction, so the day a backend starts advertising the string the surface below opens
// with it and no second edit is owed.
Bool AdvertisesTessellationShaderExtension() {
const auto& activeBackendObject = MG_Backend::pActiveBackendObject;
if (!activeBackendObject) return false;
const auto& extensions = activeBackendObject->GetRendererInfo().RendererGLInfo.Extensions;
return std::find(extensions.begin(), extensions.end(), E_GL_ARB_tessellation_shader) != extensions.end();
}
// The eleven pipeline-statistics counters (GL 4.6 core table 4.3 / ARB_pipeline_statistics_query).
// A 4.6 core context has to ACCEPT all of them at glBeginQuery - the extension is core
// since 4.6 and there is no query by which an application could learn otherwise before
// calling. MobileGL instruments none of them, and says so the way GL 4.6 core 4.2.1
// provides for: GL_QUERY_COUNTER_BITS answers zero for these targets, which is the
// spec's own signal that the counter is unsupported and its results indeterminate. That
// is an honest zero, not an advertised capability - the alternative, GL_INVALID_ENUM on a
// core entry point, is both non-conformant AND less informative.
// A 4.6 core context ACCEPTS the nine unconditional ones at glBeginQuery - there is no query
// by which an application could learn otherwise before calling. MobileGL instruments none of
// them, and says so the way GL 4.6 core 4.2.1 provides for: GL_QUERY_COUNTER_BITS answers
// zero for these targets, which is the spec's own signal that the counter is unsupported and
// its results indeterminate. That is an honest zero, not an advertised capability - the
// alternative, GL_INVALID_ENUM on a core entry point, is both non-conformant AND less
// informative.
//
// The two TESSELLATION targets are the exception, because ARB_pipeline_statistics_query
// makes them conditional on tessellation support rather than unconditional, and the only
// thing an application (or the conformance suite) can read to decide whether an
// implementation has it is the GL_ARB_tessellation_shader string. MobileGL does not emit it
// today, so these two answer GL_INVALID_ENUM: an API surface that accepts a
// tessellation-conditional token while withholding the string that announces the condition
// is self-contradictory, and it is the contradiction the suite catches
// (KHR-GL46.pipeline_statistics_query_tests_ARB.api_coverage_unsupported_calls, whose
// support probe is gl4cPipelineStatisticsQueryTests.cpp:1166-1176). The gate is the
// advertisement itself, not a hardcoded "no", so this is one switch and not two.
Bool IsPipelineStatisticsQueryTarget(GLenum target) {
switch (target) {
case GL_VERTICES_SUBMITTED:
case GL_PRIMITIVES_SUBMITTED:
case GL_VERTEX_SHADER_INVOCATIONS:
case GL_TESS_CONTROL_SHADER_PATCHES:
case GL_TESS_EVALUATION_SHADER_INVOCATIONS:
case GL_GEOMETRY_SHADER_INVOCATIONS:
case GL_GEOMETRY_SHADER_PRIMITIVES_EMITTED:
case GL_FRAGMENT_SHADER_INVOCATIONS:
@@ -86,6 +108,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_CLIPPING_INPUT_PRIMITIVES:
case GL_CLIPPING_OUTPUT_PRIMITIVES:
return true;
case GL_TESS_CONTROL_SHADER_PATCHES:
case GL_TESS_EVALUATION_SHADER_INVOCATIONS:
return AdvertisesTessellationShaderExtension();
default:
return false;
}
+70 -21
View File
@@ -5078,9 +5078,22 @@ namespace MobileGL::MG_Impl::GLImpl {
// The half of the GetTexImage/GetTextureImage error set (GL 4.6 core 8.11) that depends on the
// resolved texture object rather than on how it was named. Shared because the by-name entry
// point does not route through GetTexImage_State and so used to enforce none of it.
// A cube map's six faces are six independent images, and both readback spellings name one of
// them: glGetTexImage through the TARGET token, glGetTextureSubImage through zoffset. Both then
// have to tell the size checks below that ONE image is coming back, not six.
static Bool IsCubeMapFaceUploadTarget(TextureUploadTarget target) {
return target >= TextureUploadTarget::CubeMapPositiveX && target <= TextureUploadTarget::CubeMapNegativeZ;
}
// `imagesQueried` is how many of the texture's upload-target images the query hands back, and
// exists for the destination-size check at the bottom. Zero means "all of them", which is what
// the whole-level forms return - every face of a cube map. glGetTextureSubImage naming ONE cube
// face passes 1: sizing that request against six faces' worth would reject the only buffer a
// single-face read has any reason to pass.
Bool ValidateTextureImageQuery(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, GLint level,
TextureInputFormat textureInputFormat, TexturePixelDataType texturePixelDataType,
GLsizei bufSize, const void* pixels, const char* caller) {
GLsizei bufSize, const void* pixels, const char* caller,
SizeT imagesQueried = 0) {
if (!TextureImpl::ValidateTextureObject(textureObject)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -5196,12 +5209,14 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
// Tightly packed, and summed over every face because a cube map query returns all
// six. Pack pixel-store state only ever grows this, so a request rejected here
// could not have fit under any packing.
// Tightly packed, and summed over every face because a whole-level cube map query
// returns all six - unless the caller named a single face, which is what a non-zero
// imagesQueried says. Pack pixel-store state only ever grows this, so a request
// rejected here could not have fit under any packing.
const SizeT imageCount = imagesQueried != 0 ? imagesQueried : uploadTargets.size();
const SizeT required = MG_Util::CalculateInputTextureImageSize(textureInputFormat,
texturePixelDataType, texelSize) *
uploadTargets.size();
imageCount;
if (bufSize >= 0 && static_cast<SizeT>(bufSize) < required) {
MG_State::pGLContext->RecordError(
@@ -5274,9 +5289,14 @@ namespace MobileGL::MG_Impl::GLImpl {
isProxy ? TextureImpl::pProxyTextureManager->GetProxyTextureObject(textureUploadTarget)
: bindingSlot.GetBoundObject();
// glGetTexImage has no bufSize argument: -1 stands for "no client-side limit".
// glGetTexImage has no bufSize argument: -1 stands for "no client-side limit". That skips
// the destination-size branch but NOT the pixel-pack-buffer one, which measures the same
// `required` against the bound PBO's real size - so a cube FACE query has to say it returns
// one image here too, or a PBO sized for the one face this call packs is refused as too
// small while the copy that follows writes exactly that much into it.
return ValidateTextureImageQuery(textureObject, level, textureInputFormat, texturePixelDataType, -1, pixels,
"GetTexImage_State");
"GetTexImage_State",
IsCubeMapFaceUploadTarget(textureUploadTarget) ? 1u : 0u);
}
// What this helper can and cannot answer.
@@ -6423,6 +6443,23 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// The half glGetTextureImage and glGetTextureSubImage share: which of the two readbacks answers,
// for ONE named upload target. Factored out so the sub-image form can name a cube FACE - the
// by-name spelling of the face token glGetTexImage takes - instead of re-deriving the target and
// silently landing on the +X face the way the delegation it replaces did.
static void GetTextureImageForUploadTarget(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
TextureUploadTarget uploadTarget, GLint level, GLenum format,
GLenum type, GLsizei bufSize, void* pixels, const char* caller) {
if (MG_Backend::pActiveBackendObject != nullptr &&
MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan &&
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) {
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type,
bufSize, pixels);
return;
}
CopyTextureImageToClientOrPBO_State(textureObject, uploadTarget, level, format, type, bufSize, pixels, caller);
}
void GetTextureImage(GLuint texture, GLint level, GLenum format, GLenum type, GLsizei bufSize, void* pixels) {
auto textureObject = GetTextureObjectByName(texture, __func__);
if (!textureObject) return;
@@ -6431,16 +6468,8 @@ namespace MobileGL::MG_Impl::GLImpl {
__func__)) {
return;
}
const auto uploadTarget = GetPrimaryUploadTarget(textureObject);
if (MG_Backend::pActiveBackendObject != nullptr &&
MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan &&
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) {
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type,
bufSize, pixels);
return;
}
CopyTextureImageToClientOrPBO_State(textureObject, uploadTarget, level, format, type, bufSize, pixels,
__func__);
GetTextureImageForUploadTarget(textureObject, GetPrimaryUploadTarget(textureObject), level, format, type,
bufSize, pixels, __func__);
}
void GetCompressedTextureImage(GLuint texture, GLint level, GLsizei bufSize, void* pixels) {
@@ -6484,9 +6513,19 @@ namespace MobileGL::MG_Impl::GLImpl {
}
const auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, static_cast<Uint>(level));
const Bool isFullLevelRead = xoffset == 0 && yoffset == 0 && zoffset == 0 &&
width == texelSize.x() && height == texelSize.y() &&
depth == texelSize.z();
// On a cube map, z is the FACE axis. A cube map's level is stored per face, so its level
// size reads z = 1 whichever face named it - but GL 4.6 core 8.11.4 addresses the six faces
// of a cube map through zoffset/depth, exactly the six layers a face token names for
// glGetTexImage. Without this arm the z range was measured against that 1 and only zoffset 0
// (the +X face) was expressible; the other five were rejected as a partial read.
//
// Only ONE face at a time. depth > 1 would have to concatenate faces into the destination,
// which is the same unimplemented multi-image packing the check below still refuses.
const Bool isSingleCubeFaceRead = textureObject->GetTarget() == TextureTarget::TextureCubeMap &&
depth == 1 && zoffset < 6;
const Bool isFullLevelRead = xoffset == 0 && yoffset == 0 && width == texelSize.x() &&
height == texelSize.y() &&
(isSingleCubeFaceRead || (zoffset == 0 && depth == texelSize.z()));
if (!isFullLevelRead) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -6495,7 +6534,17 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
GetTextureImage(texture, level, format, type, bufSize, pixels);
const TextureUploadTarget readUploadTarget =
isSingleCubeFaceRead ? static_cast<TextureUploadTarget>(
static_cast<Int>(TextureUploadTarget::CubeMapPositiveX) + zoffset)
: uploadTarget;
if (!ValidateTextureImageQuery(textureObject, level, MG_Util::ConvertGLEnumToTextureInputFormat(format),
MG_Util::ConvertGLEnumToTexturePixelDataType(type), bufSize, pixels, __func__,
isSingleCubeFaceRead ? 1u : 0u)) {
return;
}
GetTextureImageForUploadTarget(textureObject, readUploadTarget, level, format, type, bufSize, pixels,
__func__);
}
// A buffer texture carries none of the sampler or level state these queries report. Reached by
+126 -4
View File
@@ -58,6 +58,9 @@ add_executable(MobileGLIntegrationTest
Scenarios/DrawParametersScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/UnwrittenPositionOutputScenario.cpp
Scenarios/SampleMaskScopeScenario.cpp
Scenarios/SampledSetStalenessScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
Scenarios/SnormAttachmentScenario.cpp
Scenarios/PipelineFailureScenario.cpp
@@ -88,6 +91,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
Scenarios/UnlocatedIoBlockScenario.cpp
Scenarios/TessellationDrawModeScenario.cpp
Scenarios/GeometryDrawModeScenario.cpp
Scenarios/PostLinkAttachScenario.cpp
@@ -97,15 +101,21 @@ add_executable(MobileGLIntegrationTest
Scenarios/VertexAttribBindingScenario.cpp
Scenarios/XfbCaptureBufferReuseScenario.cpp
Scenarios/XfbPrimitiveQueryScenario.cpp
Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
Scenarios/XfbRepeatedCaptureScenario.cpp
Scenarios/TessellationXfbCaptureScenario.cpp
Scenarios/PointSizeDemotionScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
Scenarios/CopyImagePacked16Scenario.cpp
Scenarios/TextureViewScenario.cpp
Scenarios/PackedWordReadbackScenario.cpp
Scenarios/LayeredAttachmentBarrierScenario.cpp
Scenarios/LayeredAttachmentShapeScenario.cpp
Scenarios/LayeredTextureReadbackScenario.cpp
Scenarios/AtomicCounterScenario.cpp
Scenarios/LargeArenaAdoptionScenario.cpp
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/SpirvShaderBinaryScenario.cpp
@@ -115,6 +125,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/IntegerBorderColorScenario.cpp
Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp
Scenarios/RenderbufferBlendFormatScenario.cpp
Scenarios/DualSourceBlendScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -280,9 +291,9 @@ if (MOBILEGL_ITEST_VK_ICD)
if (MOBILEGL_ITEST_VK_ICD MATCHES "lvp_icd|lavapipe")
message(STATUS "Integration tests: lavapipe ICD - forcing the iterationRP repairs on")
list(APPEND MGL_ITEST_VULKAN_ENV
"MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1"
"MOBILEGL_DERIVE_NUM_SUBGROUPS=1"
"MOBILEGL_ITERATIONRP_FIX_BARRIER=1")
"MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1"
"MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS=1"
"MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER=1")
endif()
endif()
@@ -345,7 +356,37 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_OPTIMISTIC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1"
"MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV})
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV})
# MOBILEGL_LOG_FILE_PATH alongside the pin, because the arming assertion needs somewhere to
# read the library's own report from. The strip's arming signal is a latched MGLOG_I and there
# is no other way for a test process to learn that it fired - MG_Config is not reachable from
# this module on Android, where it links the shipping library. The path is per-lane so nothing
# else appends to it, and the case only trusts the bytes written after it started.
mgl_itest_join_environment(MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/unlocated-io-blocks.log"
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1" ${MGL_ITEST_COMMON_ENV})
# Same shape as the UnlocatedIoBlocks entry: the log path is where the reroute's latched
# MGLOG_I lands, and the arming case only trusts the bytes written after it started.
mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log"
${MGL_ITEST_VULKAN_ENV})
# The point-size demotion pinned on, per backend, with a per-lane log file for the arming
# assertion - the same MOBILEGL_LOG_FILE_PATH reasoning as the UnlocatedIoBlocks lane above.
# Two lanes because the demotion runs in the SHARED phase-B chain and each backend then
# consumes it differently (Espryt respells the driver-side capture request, Magma binds the
# SPIR-V Xfb decorations to the carrier).
mgl_itest_join_environment(MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-gles.log"
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-vulkan.log"
${MGL_ITEST_VULKAN_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
@@ -412,6 +453,23 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT}"
)
# UnlocatedIoBlockScenario with the interface-block location strip PINNED ON, for the same
# reason the depth/stencil entry above pins its emulation: without it this scenario is
# UNFALSIFIABLE on the machines this suite runs on. llvmpipe carries a located interface block
# correctly, so the driver POST that arms the strip on Mali answers "healthy" here and the
# emulation never runs - the ambient registration would be exercising the un-stripped path
# twice and calling it coverage. With the variable set, the blocks really are emitted with no
# location and the assertion is about the spelling the device gets.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.UnlocatedIoBlocks."
TEST_FILTER "UnlocatedIoBlockScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}"
)
# AsyncCompileScenario, with asynchronous compilation PINNED ON per backend.
#
# Not a duplicate of what the two ambient registrations already run: they run whatever
@@ -508,3 +566,67 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT}"
)
# PrimitivesGeneratedNoXfbScenario again, with the GL_PRIMITIVES_GENERATED statistics
# reroute PINNED ON. The ambient DirectVulkan registration runs the same cases under the
# bring-up probe's Auto verdict, so between the two entries both accounting paths answer
# the same GL questions and must produce the same numbers - the "two pools must agree"
# gate this machine can hold that the affected device cannot. The pinned entry is also
# the only one whose arming case runs: it asserts the renderer's latched MGLOG_I, so a
# silently-disarmed reroute (an inverted override mapping, a lost gate) fails here
# instead of leaving every equality case vacuously green. DirectVulkan only - the flag
# steers nothing on DirectGLES.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PrimGenReroute."
TEST_FILTER "PrimitivesGeneratedNoXfbScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
)
# The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient
# registrations above cover the narrow storage - on every CI driver the widening's POST
# probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the
# storage every AFFECTED device will actually run would otherwise execute nowhere at all:
# no CI driver has the Mali bug that arms it. This lane is what proves the widened storage
# is client-invisible (same packed words in and out on every leg the 18 failing CTS bodies
# used, the renderbuffer one included). DirectGLES only - the flag steers nothing on
# DirectVulkan, which has always stored these formats widened.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.WidenedPacked16."
TEST_FILTER "CopyImagePacked16Scenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT}"
)
# PointSizeDemotionScenario with the demotion PINNED ON, per backend, for the reason every
# pinned lane above exists: llvmpipe and lavapipe both HOST gl_PointSize in tessellation and
# geometry stages, so the ambient registrations run these captures through the built-in and
# the demotion - the path every affected Mali device actually takes - would execute nowhere.
# The ambient runs stay the negative control: same scenario, same CPU-computed bytes, native
# path. Both backends, because the demotion is shared phase-B work with two different
# consumers (the ESSL capture respelling vs the SPIR-V Xfb carrier binding).
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
@@ -225,4 +225,43 @@ void main() {
EXPECT_EQ(values[1], reseed[1] + 2 * kInvocations) << "the re-seeded value at offset 4 did not reach the shader";
}
// A CPU glBufferSubData issued AFTER a dispatch, read back with NO further GPU work in
// between. Each backend has its own way to invert this pair, and both are pinned here.
// DirectGLES queues app SubData ranges for the draw-time staged-copy flush (the upload
// ring) instead of uploading in place, and readback of a GPU-written buffer overwrites
// the frontend shadow with the driver copy - so if the readback path forgets to flush the
// queued range first, the newer CPU write is REVERTED by the readback and offset 0 reads
// the dispatch's value instead of the reseed. DirectVulkan adopts the buffer into
// coherent GPU memory the moment the dispatch resolves its descriptor, so the SubData
// write lands in the very bytes the GPU reads - while the dispatch still sits recorded in
// the deferred frame command buffer. Unless the frontend retires that pending work before
// writing the adopted store (BufferObject::UploadSubData), the dispatch executes ON TOP
// of the reseed and offset 0 reads reseed + increments instead of the reseed. Offset 4
// pins the other direction for both: the upload must leave bytes outside its range - the
// dispatch's results - untouched.
TEST_F(AtomicCounterScenario, SubDataAfterDispatchSurvivesAnImmediateReadback) {
if (!Ready() || IsSkipped()) return;
const GLuint zero = MakeCounterBuffer(0, {0u, 0u});
MakeCounterBuffer(1, {0u});
ASSERT_EQ(FirstGLError(), 0u);
Dispatch();
const unsigned int reseed = 4242u;
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, zero);
glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 0, sizeof(reseed), &reseed);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "re-seeding the counter buffer raised a GL error";
const std::vector<unsigned int> values = ReadCounters(zero, 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(values[0], reseed)
<< "offset 0 read back " << values[0] << "; the dispatch's value (" << kInvocations
<< ") means the readback ran before the queued SubData range was flushed and reverted it";
EXPECT_EQ(values[1], 2 * kInvocations)
<< "offset 4 read back " << values[1] << "; the SubData flush must leave bytes outside its "
<< "range untouched";
}
} // namespace MGITest
@@ -0,0 +1,375 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImagePacked16Scenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - glCopyImageSubData PRESERVES 16-BIT PACKED WORDS ACROSS AN ARRAY MIP LEVEL.
//
// The shape is lifted verbatim from the 18 Espryt bodies of KHR-GL4x.copy_image.functional
// that survived every earlier wave: the three internal formats MobileGL can keep as 16-bit
// packed ES storage - GL_RGB5 (stored GL_RGB565), GL_RGB5_A1, GL_RGBA4 - crossed with the
// target pairs that put a GL_TEXTURE_2D_ARRAY's MIP LEVEL 1 on one side of the copy. On the
// affected Mali the mirrored *_REV field order is a property of WHOLE ALLOCATIONS (shape-
// and context-dependent; the failing 30x30x12 arrays carry it at every level, the small
// arrays of the suite's passing iterations do not), and glCopyImageSubData - a raw
// texel-block move - between a mirrored allocation and a plain one lands the fields
// reversed: src word 0x0047 arrives as 0x8C20 (its 5_5_5_1 -> 1_5_5_5_REV re-encoding),
// 0x0007 as 0x3800, byte-exact on every failing body. Uploads and readbacks of the same
// image are clean (the driver decodes its own layout consistently), which is why only the
// copy path ever crossed the two layouts and why the CTS's "source image was not modified"
// checks always passed.
//
// The array is 30x30x12 with THREE levels and the flat endpoint is 7x7 with three levels
// (7/3/1) because that is the allocation the failures pin - the CTS builds every functional
// texture with FUNCTIONAL_TEST_N_LEVELS = 3 (makeTextureComplete(0, 2)) - and any deviation
// from the measured shape might sit on the clean side of whatever allocation heuristic picks
// the driver's layout.
//
// The repair under test is the packed16 storage widening
// (PixelFormatNormalizeOptionBit::WidenPacked16Norm): where the POST probe
// (SelfTest::CopyImageMirrorsPacked16FieldOrder) measures the mirror - or
// MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE forces it - the three formats are stored as
// GL_RGB8/GL_RGBA8, leaving no 16-bit packed image for a copy to disagree about. The client
// word still round-trips exactly: the canonical shadow is already UNorm8, and an n-bit field
// encodes to UNorm8 and back losslessly for every n <= 8.
//
// This scenario runs in BOTH configurations, and both must hand back identical client words:
// * the ambient registrations take the narrow path on a clean driver (llvmpipe has no
// mirror, so Auto keeps the native 16-bit storage - the pre-existing behaviour stays
// covered);
// * the DirectGLES.WidenedPacked16. registration pins MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1,
// which is the storage every affected device will actually run - without it the repair
// is unfalsifiable off-device, because no CI driver has the bug that arms it.
// The Mali mirror itself CANNOT be reproduced here; only the on-device CTS run can show the
// widening killing the 18 bodies. What this scenario pins is that the widened storage is
// client-invisible: same words in, same words out, on every leg the failing bodies used.
//
// DirectVulkan is the control - Magma has always resolved these formats to RGBA8 - so a
// failure on both backends means the scenario is wrong, and a failure on DirectGLES alone
// means the widening (or the narrow path it replaces) is.
#include <algorithm>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kBaseSize = 30; // array level 0; level 1 is 15x15
constexpr int kLevel1Size = kBaseSize / 2;
constexpr int kLayers = 12;
constexpr int kFlatSize = 7; // the plain-2D / renderbuffer endpoint, level 0
// Copies cover the whole flat endpoint and land at (8, 8) inside the 15x15 level so
// that offsets are honoured, not just texel (0, 0): 8 + 7 == 15 reaches the far edge.
constexpr int kRegion = kFlatSize;
constexpr int kArrayOffset = 8;
struct PackedFormatCase {
GLenum internalFormat; // the spelling the CTS uses
GLenum transferFormat;
GLenum transferType;
const char* name;
};
// Per-texel varying words, every field inside its width, so a swapped field order (or
// a mis-addressed row) cannot cancel out the way a uniform fill would let it.
GLushort MakeWord(GLenum type, int i) {
switch (type) {
case GL_UNSIGNED_SHORT_5_6_5: {
const int r = i % 32, g = (i * 7 + 3) % 64, b = (i * 5 + 11) % 32;
return static_cast<GLushort>((r << 11) | (g << 5) | b);
}
case GL_UNSIGNED_SHORT_4_4_4_4: {
const int r = i % 16, g = (i * 3 + 1) % 16, b = (i * 7 + 5) % 16, a = (i * 5 + 2) % 16;
return static_cast<GLushort>((r << 12) | (g << 8) | (b << 4) | a);
}
case GL_UNSIGNED_SHORT_5_5_5_1: {
const int r = i % 32, g = (i * 7 + 3) % 32, b = (i * 3 + 11) % 32, a = i % 2;
return static_cast<GLushort>((r << 11) | (g << 6) | (b << 1) | a);
}
default:
return 0;
}
}
std::vector<GLushort> MakeWords(GLenum type, int count, int seed) {
std::vector<GLushort> words(static_cast<size_t>(count));
for (int i = 0; i < count; ++i) {
words[static_cast<size_t>(i)] = MakeWord(type, i + seed);
}
return words;
}
class CopyImagePacked16Scenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
// 16-bit rows are 2-byte aligned; the default 4-byte row alignment would pad
// every odd-width row of the 15x15 level and shear the comparisons.
glPixelStorei(GL_UNPACK_ALIGNMENT, 2);
glPixelStorei(GL_PACK_ALIGNMENT, 2);
if (!CopyImageSubDataUsable()) {
GTEST_SKIP() << "glCopyImageSubData is unavailable on backend " << Gl().BackendName();
}
}
void TearDown() override {
if (!Ready()) return;
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
for (const GLuint texture : m_textures) {
glDeleteTextures(1, &texture);
}
m_textures.clear();
if (m_renderbuffer != 0) {
glDeleteRenderbuffers(1, &m_renderbuffer);
m_renderbuffer = 0;
}
if (m_fbo != 0) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &m_fbo);
m_fbo = 0;
}
}
bool CopyImageSubDataUsable() {
GLuint probe[2] = {0, 0};
glGenTextures(2, probe);
for (const GLuint texture : probe) {
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 1, 1, 1);
}
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
while (glGetError() != GL_NO_ERROR) {
}
glCopyImageSubData(probe[0], GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, probe[1], GL_TEXTURE_2D_ARRAY, 0, 0, 0,
0, 1, 1, 1);
const bool usable = glGetError() == GL_NO_ERROR;
glDeleteTextures(2, probe);
return usable;
}
// The CTS's own mutable shape: glTexImage3D per level, filter NEAREST, THREE levels
// (30/15/7) with the chain clamped to them. Level 2 carries its own fill so nothing
// below can pass by reading a level that was never written.
GLuint MakeArrayTexture(const PackedFormatCase& format, const std::vector<GLushort>& level0,
const std::vector<GLushort>& level1) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 2);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, static_cast<GLint>(format.internalFormat), kBaseSize, kBaseSize,
kLayers, 0, format.transferFormat, format.transferType, level0.data());
glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, static_cast<GLint>(format.internalFormat), kLevel1Size,
kLevel1Size, kLayers, 0, format.transferFormat, format.transferType, level1.data());
const int level2Size = kLevel1Size / 2;
const auto level2 = MakeWords(format.transferType, level2Size * level2Size * kLayers, 211);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 2, static_cast<GLint>(format.internalFormat), level2Size,
level2Size, kLayers, 0, format.transferFormat, format.transferType, level2.data());
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
return texture;
}
// Three levels (7/3/1) like the CTS's plain endpoints; `texels` is level 0, the one
// every assertion reads.
GLuint MakeFlatTexture(const PackedFormatCase& format, const std::vector<GLushort>& texels) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 2);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(format.internalFormat), kFlatSize, kFlatSize, 0,
format.transferFormat, format.transferType, texels.data());
for (int level = 1; level <= 2; ++level) {
const int size = std::max(kFlatSize >> level, 1);
const auto fill = MakeWords(format.transferType, size * size, 97 + level);
glTexImage2D(GL_TEXTURE_2D, level, static_cast<GLint>(format.internalFormat), size, size, 0,
format.transferFormat, format.transferType, fill.data());
}
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
std::vector<GLushort> ReadTexImage(GLenum target, GLuint texture, int level,
const PackedFormatCase& format, size_t texelCount) {
std::vector<GLushort> words(texelCount, 0);
glBindTexture(target, texture);
glGetTexImage(target, level, format.transferFormat, format.transferType, words.data());
glBindTexture(target, 0);
return words;
}
// Every word of `got` inside the kRegion-square at (x0, y0) of a width-wide layer-0
// image equals the corresponding source word, and every word outside it still holds
// `fill`'s. Failures name the texel and both words, which is what turns a field-order
// regression into a one-line diagnosis.
void ExpectRegion(const std::vector<GLushort>& got, int width, int x0, int y0,
const std::vector<GLushort>& source, int sourceWidth, int sourceX0, int sourceY0,
const std::vector<GLushort>& fill, const char* what) {
for (int y = 0; y < width; ++y) {
for (int x = 0; x < width && static_cast<size_t>(y * width + x) < got.size(); ++x) {
const bool inRegion =
x >= x0 && x < x0 + kRegion && y >= y0 && y < y0 + kRegion;
const GLushort actual = got[static_cast<size_t>(y * width + x)];
const GLushort expected =
inRegion ? source[static_cast<size_t>((sourceY0 + y - y0) * sourceWidth + sourceX0 +
(x - x0))]
: fill[static_cast<size_t>(y * width + x)];
EXPECT_EQ(actual, expected)
<< what << ": texel (" << x << ", " << y << ")"
<< (inRegion ? " (copied)" : " (untouched)") << " holds 0x" << std::hex << actual
<< ", expected 0x" << expected;
if (actual != expected) return; // one texel names the defect; 224 more would bury it
}
}
}
std::vector<GLuint> m_textures;
GLuint m_renderbuffer = 0;
GLuint m_fbo = 0;
};
const PackedFormatCase kFormats[] = {
{GL_RGB5, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, "rgb5"},
{GL_RGB5_A1, GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, "rgb5_a1"},
{GL_RGBA4, GL_RGBA, GL_UNSIGNED_SHORT_4_4_4_4, "rgba4"},
};
// texture_2d (the ES image behind GL_TEXTURE_RECTANGLE too) -> the array's level 1:
// the array-as-destination direction of 12 of the 18 failing bodies.
TEST_F(CopyImagePacked16Scenario, FlatImageLandsInArrayMipLevelIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const auto flat = MakeWords(format.transferType, kFlatSize * kFlatSize, 131);
const GLuint array = MakeArrayTexture(format, level0, level1);
const GLuint source = MakeFlatTexture(format, flat);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(source, GL_TEXTURE_2D, 0, 0, 0, 0, array, GL_TEXTURE_2D_ARRAY, 1, kArrayOffset,
kArrayOffset, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
const auto got = ReadTexImage(GL_TEXTURE_2D_ARRAY, array, 1, format,
static_cast<size_t>(kLevel1Size) * kLevel1Size * kLayers);
ExpectRegion(got, kLevel1Size, kArrayOffset, kArrayOffset, flat, kFlatSize, 0, 0, level1,
(std::string("2d->2d_array level 1, ") + format.name).c_str());
// The source must not have moved - the CTS asserts this before it ever looks at
// the destination, and it is what pins the corruption to the copy itself.
const auto sourceAfter =
ReadTexImage(GL_TEXTURE_2D, source, 0, format, static_cast<size_t>(kFlatSize) * kFlatSize);
ExpectRegion(sourceAfter, kFlatSize, 0, 0, flat, kFlatSize, 0, 0, flat,
(std::string("source after 2d->2d_array, ") + format.name).c_str());
}
}
// The array's level 1 -> texture_2d: the array-as-source direction of the other 6
// bodies (2d_array -> 3d and 2d_array -> rectangle both read the level-1 array).
TEST_F(CopyImagePacked16Scenario, ArrayMipLevelLandsInFlatImageIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const auto fill = MakeWords(format.transferType, kFlatSize * kFlatSize, 131);
const GLuint array = MakeArrayTexture(format, level0, level1);
const GLuint destination = MakeFlatTexture(format, fill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(array, GL_TEXTURE_2D_ARRAY, 1, kArrayOffset, kArrayOffset, 0, destination,
GL_TEXTURE_2D, 0, 0, 0, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
const auto got = ReadTexImage(GL_TEXTURE_2D, destination, 0, format,
static_cast<size_t>(kFlatSize) * kFlatSize);
ExpectRegion(got, kFlatSize, 0, 0, level1, kLevel1Size, kArrayOffset, kArrayOffset, fill,
(std::string("2d_array level 1 -> 2d, ") + format.name).c_str());
}
}
// renderbuffer -> the array's level 1: the leg the remaining 3 bodies use, and the one
// that requires the renderbuffer's ES storage to move together with the textures' -
// glCopyImageSubData needs both endpoints in the same driver format, so a widening that
// reached textures alone would break exactly here.
TEST_F(CopyImagePacked16Scenario, RenderbufferLandsInArrayMipLevelIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const GLuint array = MakeArrayTexture(format, level0, level1);
if (m_renderbuffer == 0) glGenRenderbuffers(1, &m_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, format.internalFormat, kFlatSize, kFlatSize);
if (m_fbo == 0) glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< format.name << ": the renderbuffer is not attachable";
// Field values picked to encode exactly in the narrow fields AND in their
// UNorm8 expansions, so the expected word is the same whichever storage the
// configuration picked - which is the point of the whole scenario.
const int maxG = format.transferType == GL_UNSIGNED_SHORT_5_6_5 ? 63 : 31;
const int max = format.transferType == GL_UNSIGNED_SHORT_4_4_4_4 ? 15 : 31;
const int maxGreen = format.transferType == GL_UNSIGNED_SHORT_4_4_4_4 ? 15 : maxG;
const GLfloat clearColor[4] = {static_cast<GLfloat>(8 % (max + 1)) / max,
static_cast<GLfloat>(maxGreen / 2) / maxGreen,
static_cast<GLfloat>(max - 2) / max, 1.0f};
// The context is shared with every scenario in this process; a scissor left on
// would clip the clear and hand the copy undefined renderbuffer texels.
glDisable(GL_SCISSOR_TEST);
glClearBufferfv(GL_COLOR, 0, clearColor);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(m_renderbuffer, GL_RENDERBUFFER, 0, 0, 0, 0, array, GL_TEXTURE_2D_ARRAY, 1,
kArrayOffset, kArrayOffset, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
GLushort clearedWord = 0;
switch (format.transferType) {
case GL_UNSIGNED_SHORT_5_6_5:
clearedWord = static_cast<GLushort>((8 << 11) | ((maxGreen / 2) << 5) | (max - 2));
break;
case GL_UNSIGNED_SHORT_5_5_5_1:
clearedWord = static_cast<GLushort>((8 << 11) | ((maxGreen / 2) << 6) | ((max - 2) << 1) | 1);
break;
case GL_UNSIGNED_SHORT_4_4_4_4:
clearedWord = static_cast<GLushort>((8 << 12) | ((maxGreen / 2) << 8) | ((max - 2) << 4) | 15);
break;
default:
break;
}
std::vector<GLushort> expectedRegion(static_cast<size_t>(kRegion) * kRegion, clearedWord);
const auto got = ReadTexImage(GL_TEXTURE_2D_ARRAY, array, 1, format,
static_cast<size_t>(kLevel1Size) * kLevel1Size * kLayers);
ExpectRegion(got, kLevel1Size, kArrayOffset, kArrayOffset, expectedRegion, kRegion, 0, 0, level1,
(std::string("renderbuffer -> 2d_array level 1, ") + format.name).c_str());
}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,238 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A DUAL-SOURCE BLEND DRAW HAS TO SURVIVE ON EVERY DRIVER.
//
// GL_SRC1_COLOR / GL_ONE_MINUS_SRC1_COLOR / GL_SRC1_ALPHA / GL_ONE_MINUS_SRC1_ALPHA
// (ARB_blend_func_extended, core since 3.3) need a backend capability that not every device has:
// GL_EXT_blend_func_extended on the ES driver, or the dualSrcBlend device feature on Vulkan. When
// the capability IS there both backends translate the factors properly, and that has always
// worked. When it is NOT, both backends used to THROW_EXCEPTION at draw time - and
// MG_Util/Types.h's THROW_EXCEPTION is a plain `throw`, with no catch anywhere in MG_Impl or
// MG_Backend, so the exception unwound out through the C GL ABI and killed the process. An
// application asking for a blend factor the device cannot do is a picture problem, never a reason
// to take the process down.
//
// Both are now a DECLINE: the attachment is drawn with blending off and neutral One/Zero factors,
// and the loss is logged once. So a dual-source draw has exactly two defined outcomes, and this
// scenario pins that it lands on one of them and never on a crash:
//
// capability present - src0 * src1 + dst * (1 - src1)
// capability absent - src0, written straight through
//
// What each CI lane actually reaches: lavapipe has dualSrcBlend, so the DirectVulkan lane runs the
// whole sequence and measures the blend. Mesa's GLES front end on llvmpipe has no
// GL_EXT_blend_func_extended, so the ESSL stage carrying `layout(index = 1)` never compiles and the
// program renders nothing - the DirectGLES lane therefore SKIPS on the capability probe in SetUp
// rather than measuring a picture the driver never produced. The DECLINE arm itself - the path this
// scenario exists for - is unit-tested against stubbed capabilities in
// MG_Test/Framebuffer/FramebufferTest.cpp (DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensionIsMissing),
// which is the only place it can be reached without a driver that lacks the extension.
//
// The Vulkan half has a second edge the last case covers: the dual-source VUIDs
// (VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three siblings)
// forbid a VK_BLEND_FACTOR_SRC1_* anywhere in VkPipelineColorBlendAttachmentState without the
// feature, whatever blendEnable says - so leaving the factors in place while clearing the enable
// would still be invalid pipeline state.
#include <cstdint>
#include <string>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kExtent = 16;
constexpr const char* kVertexSource = R"(#version 330 core
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break;
case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break;
}
}
)";
// Two outputs on the SAME location, indices 0 and 1: the shader-side spelling of
// dual-source output (GLSL 3.30 4.4.2, the `index` layout qualifier). No
// glBindFragDataLocationIndexed needed, which keeps the program buildable through the
// harness's compile-and-link helper.
constexpr const char* kDualSourceFragmentSource = R"(#version 330 core
uniform vec4 uSrc0;
uniform vec4 uSrc1;
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main()
{
fragColor0 = uSrc0;
fragColor1 = uSrc1;
}
)";
class DualSourceBlendScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glGenRenderbuffers(1, &m_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kExtent, kExtent);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
std::string error;
m_program = CompileProgram(kVertexSource, kDualSourceFragmentSource, &error);
m_programError = error;
glViewport(0, 0, kExtent, kExtent);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
// Capability probe, not an assertion. A GL link that succeeded is not proof that
// the BACKEND can run the program: DirectGLES transpiles to ESSL lazily at first
// use, and GLSL ES has no `index` layout qualifier outside
// GL_EXT_blend_func_extended, so on a driver without it the stage never compiles
// and the draw renders nothing. One unblended white draw tells the two apart, and
// the cases skip rather than measure a picture the driver never produced.
if (m_program != 0) {
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/1.0f, /*src1=*/1.0f);
glFinish();
const Image probe = ReadPixels(kExtent, kExtent);
m_programRenders =
!probe.Empty() && static_cast<int>(probe.At(kExtent / 2, kExtent / 2).r) > 245;
}
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
void TearDown() override {
if (!Ready()) return;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_renderbuffer != 0) glDeleteRenderbuffers(1, &m_renderbuffer);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
}
void Draw(float src0, float src1) {
glUseProgram(m_program);
glUniform4f(glGetUniformLocation(m_program, "uSrc0"), src0, src0, src0, 1.0f);
glUniform4f(glGetUniformLocation(m_program, "uSrc1"), src1, src1, src1, 1.0f);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glUseProgram(0);
}
// Both cases share this gate: nothing below can be measured on a backend that cannot
// run a dual-source fragment program at all. Returns the skip reason, empty when the
// program runs - NOT a void helper that calls GTEST_SKIP itself, because GTEST_SKIP
// expands to a `return` and would leave only the HELPER, letting the case run its
// assertions anyway and report Failed instead of Skipped.
std::string WhyTheProgramCannotRun() const {
if (m_program == 0) {
return "this driver cannot build a dual-source fragment shader: " + m_programError;
}
if (!m_programRenders) {
return "this backend links a dual-source fragment program but renders nothing with it "
"(GLSL ES has no `index` layout qualifier without GL_EXT_blend_func_extended)";
}
return {};
}
GLuint m_renderbuffer = 0;
GLuint m_fbo = 0;
GLuint m_vao = 0;
unsigned int m_program = 0;
bool m_programRenders = false;
std::string m_programError;
};
} // namespace
// The whole point of the scenario: this sequence used to be a process kill on any device
// without the capability, and it has to be a picture either way.
//
// dst is black, src0 is white and src1 is mid-grey, with SRC1_COLOR / ONE_MINUS_SRC1_COLOR.
// blended = 1.0 * 0.5 + 0.0 * 0.5 = 0.5 -> ~128
// declined = 1.0 -> 255
// Anything else means the factors were mistranslated rather than either honoured or declined.
TEST_F(DualSourceBlendScenario, DualSourceBlendDrawProducesOneOfTheTwoDefinedResults) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyTheProgramCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/0.0f, /*src1=*/0.0f);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
EXPECT_EQ(FirstGLError(), 0u) << "glBlendFunc must accept the GL_SRC1_* factors - they are core since 3.3";
Draw(/*src0=*/1.0f, /*src1=*/0.5f);
glFinish();
glDisable(GL_BLEND);
EXPECT_EQ(FirstGLError(), 0u) << "the dual-source draw left a GL error behind";
const Image image = ReadPixels(kExtent, kExtent);
ASSERT_FALSE(image.Empty());
const Rgba8 centre = image.At(kExtent / 2, kExtent / 2);
const int red = static_cast<int>(centre.r);
const bool blended = red > 100 && red < 160;
const bool declined = red > 245;
EXPECT_TRUE(blended || declined)
<< "got " << centre << ", which is neither the dual-source blend (~128) nor the declined "
<< "straight-through source (255) - the SRC1 factors were mistranslated";
Gl().EndFrame();
}
// The same factors with blending DISABLED. Nothing may blend, and on the Vulkan side nothing
// may reach VkPipelineColorBlendAttachmentState carrying a VK_BLEND_FACTOR_SRC1_* on a device
// without dualSrcBlend - the VUIDs bind to the struct, not to blendEnable. The picture is the
// source either way, so this case is really "no crash, no error, no surprise".
TEST_F(DualSourceBlendScenario, DualSourceFactorsWithBlendingDisabledJustWriteTheSource) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyTheProgramCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/0.0f, /*src1=*/0.0f);
glBlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
Draw(/*src0=*/1.0f, /*src1=*/0.25f);
glFinish();
EXPECT_EQ(FirstGLError(), 0u) << "a draw with SRC1 factors and blending off left a GL error behind";
const Image image = ReadPixels(kExtent, kExtent);
ASSERT_FALSE(image.Empty());
const Rgba8 centre = image.At(kExtent / 2, kExtent / 2);
EXPECT_GT(static_cast<int>(centre.r), 245)
<< "got " << centre << ": blending is disabled, so the source has to be written straight through";
Gl().EndFrame();
}
} // namespace MGITest
@@ -137,7 +137,7 @@ namespace MGITest {
// invocations, i.e. an advertised subgroup width in [16, 256]. A device
// outside that window (lavapipe's 8-lane subgroups give 64 subgroups) cannot
// run the fixture's verbatim reduction at all, so the scenario SKIPS there -
// the pack itself replays through the FixIterationRPSubgroupScratch patch, which
// the pack itself replays through the MagmaFixIterationRPSubgroupScratch patch, which
// this probe deliberately does not model. The width only gates the domain;
// lane placement and group counts still come from observed values alone.
bool SubgroupWidthInSourceDomain() const {
@@ -0,0 +1,375 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - MESH-ARENA-SIZED BUFFERS, END TO END.
//
// A buffer store of at least 16MiB is adopted into the backend's persistently and
// coherently mapped GPU storage the moment it is defined (BufferObject::
// TryAdoptLargeStorage): the CPU shadow is dropped and every later write lands
// directly in GPU-visible memory with no per-write driver call. Minecraft 26.3
// streams chunk meshes into 128MB vertex arenas with plain glNamedBufferSubData -
// on Mali, every driver-mediated route for that write into a busy mutable store
// either parks the calling thread or ghost-copies the whole arena on a driver
// worker (~167ms per touched arena: the recurring in-world hiccup this adoption
// removed). Every existing buffer scenario uses stores far below the threshold,
// so without this file the adopted path would have zero coverage.
//
// What is pinned, deliberately through the same API mix Minecraft uses:
// * a glBufferSubData written AFTER the arena was drawn (in flight) reaches the
// next draw - the write-visibility contract adoption must not weaken;
// * GetBufferSubData reads back the latest CPU write - the shadow IS the map;
// * a compute-shader write through an SSBO binding of the same arena is read
// back - the GPU-written path for adopted stores (glFinish + direct read).
#include <array>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Comfortably past the 16MiB adoption threshold, and the vertex payload sits
// deep inside the store so an implementation that quietly clamped or aliased
// the adopted range would miss it.
constexpr GLsizeiptr kArenaBytes = GLsizeiptr(24) * 1024 * 1024;
constexpr GLintptr kVertexOffset = GLintptr(20) * 1024 * 1024;
constexpr const char* kVertexSource = R"(#version 430 core
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec3 a_color;
out vec3 v_color;
void main() {
v_color = a_color;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 430 core
in vec3 v_color;
out vec4 o_color;
void main() { o_color = vec4(v_color, 1.0); }
)";
constexpr const char* kMarkerComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Arena { uint word; };
void main() { word = 0xC0FFEEu; }
)";
struct Vertex {
float x, y;
float r, g, b;
};
// A full-viewport quad, colored uniformly so one center readback speaks for
// the whole draw.
std::vector<Vertex> QuadVertices(float r, float g, float b) {
return {
{-1.f, -1.f, r, g, b}, {1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b},
{-1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b}, {-1.f, 1.f, r, g, b},
};
}
class LargeArenaAdoptionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = LinkProgram(kVertexSource, kFragmentSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_arena);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
// The NULL-data definition is the adoption point (and Minecraft's
// arena-creation idiom).
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
ConfigureVertexArray(m_vao);
}
void ConfigureVertexArray(GLuint vao) {
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset + 2 * sizeof(float)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_arena != 0) glDeleteBuffers(1, &m_arena);
if (m_program != 0) glDeleteProgram(m_program);
if (m_compute != 0) glDeleteProgram(m_compute);
m_vao = 0;
m_arena = 0;
m_program = 0;
m_compute = 0;
}
unsigned int CompileStage(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
return shader;
}
unsigned int LinkProgram(const char* vs, const char* fs) {
const GLuint v = CompileStage(GL_VERTEX_SHADER, vs);
if (v == 0) return 0;
const GLuint f = CompileStage(GL_FRAGMENT_SHADER, fs);
if (f == 0) {
glDeleteShader(v);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, v);
glAttachShader(program, f);
glLinkProgram(program);
glDeleteShader(v);
glDeleteShader(f);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
void UploadQuad(float r, float g, float b) {
const auto vertices = QuadVertices(r, g, b);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
}
void DrawQuad(GLuint vao = 0) {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glBindVertexArray(vao != 0 ? vao : m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
std::array<unsigned char, 4> CenterPixel() {
std::array<unsigned char, 4> px = {0, 0, 0, 0};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
px.data());
return px;
}
unsigned int m_program = 0;
unsigned int m_compute = 0;
unsigned int m_vao = 0;
unsigned int m_arena = 0;
std::string m_buildLog;
};
} // namespace
// The Minecraft shape: the arena is drawn, the frame retires, and a
// glBufferSubData rewrites the SAME vertex bytes while the previous frame's
// draw may still be in flight. The next draw must show the NEW bytes.
TEST_F(LargeArenaAdoptionScenario, SubDataAfterAnInFlightDrawReachesTheNextDraw) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[0], 200) << "the first draw from the adopted arena never landed";
EXPECT_LT(px[1], 50);
Gl().EndFrame();
UploadQuad(0.f, 1.f, 0.f);
DrawQuad();
px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[1], 200) << "the cross-frame rewrite of the adopted arena did not reach the draw; "
"the old color means the write went to bytes the draw no longer reads";
EXPECT_LT(px[0], 50) << "the draw still shows the previous frame's bytes";
}
// Respecifying a frontend buffer preserves its VAO attachments even when the
// backend replaces the adopted store's GL name. Keep every attribute binding
// unchanged so a stale backend VAO cannot be repaired by a frontend rebind.
TEST_F(LargeArenaAdoptionScenario, RespecifiedVertexArenaKeepsVaoBindings) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
ASSERT_GT(CenterPixel()[0], 200);
ASSERT_EQ(FirstGLError(), 0u);
GLuint otherVao = 0;
glGenVertexArrays(1, &otherVao);
ConfigureVertexArray(otherVao);
DrawQuad(otherVao);
EXPECT_GT(CenterPixel()[0], 200);
EXPECT_EQ(FirstGLError(), 0u);
constexpr std::array<GLsizeiptr, 3> sizes = {
kArenaBytes, kArenaBytes + 4096, kArenaBytes - 4096,
};
constexpr std::array<std::array<float, 3>, 3> colors = {{
{0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, {1.f, 0.f, 0.f},
}};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
UploadQuad(colors[i][0], colors[i][1], colors[i][2]);
// The unbound VAO can retain the deleted store; the current VAO's
// attachments can be cleared by deletion. Both must be repaired.
for (GLuint vao : {m_vao, otherVao}) {
SCOPED_TRACE(vao);
DrawQuad(vao);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
for (std::size_t channel = 0; channel < 3; ++channel) {
if (colors[i][channel] != 0.f) {
EXPECT_GT(px[channel], 200) << "VAO did not fetch the replacement vertex store";
} else {
EXPECT_LT(px[channel], 50) << "VAO still fetched the previous vertex store";
}
}
}
}
glDeleteVertexArrays(1, &otherVao);
}
TEST_F(LargeArenaAdoptionScenario, RespecifiedIndexArenaKeepsVaoBinding) {
if (!Ready() || IsSkipped()) return;
auto vertices = QuadVertices(1.f, 0.f, 0.f);
const auto green = QuadVertices(0.f, 1.f, 0.f);
vertices.insert(vertices.end(), green.begin(), green.end());
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
GLuint indices = 0;
glGenBuffers(1, &indices);
glBindVertexArray(m_vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indices);
// Redefine through COPY_WRITE_BUFFER so the element binding slot never
// changes. The small final store also exercises returning to shadow storage.
glBindBuffer(GL_COPY_WRITE_BUFFER, indices);
constexpr std::array<GLsizeiptr, 4> sizes = {
kArenaBytes, kArenaBytes, kArenaBytes + 4096, 4096,
};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
const GLuint first = (i % 2) == 0 ? 0u : 6u;
const std::array<GLuint, 6> elements = {
first, first + 1, first + 2, first + 3, first + 4, first + 5,
};
glBufferData(GL_COPY_WRITE_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, sizeof(elements), elements.data());
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[first == 0 ? 0 : 1], 200) << "VAO did not fetch the replacement index store";
EXPECT_LT(px[first == 0 ? 1 : 0], 50) << "VAO still fetched the previous index store";
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glDeleteBuffers(1, &indices);
}
// The shadow IS the mapping: a readback straight after a CPU write must hand
// back exactly those bytes.
TEST_F(LargeArenaAdoptionScenario, ReadbackSeesTheLatestCpuWrite) {
if (!Ready() || IsSkipped()) return;
const auto vertices = QuadVertices(0.25f, 0.5f, 0.75f);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
std::vector<Vertex> read(vertices.size());
glGetBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(read.size() * sizeof(Vertex)), read.data());
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(0, std::memcmp(read.data(), vertices.data(), read.size() * sizeof(Vertex)))
<< "GetBufferSubData of the adopted arena returned different bytes than the SubData wrote";
}
// A GPU write through an SSBO binding of the adopted arena must be visible to
// a CPU readback - the path that waits out the GPU and reads the coherent
// mapping directly.
TEST_F(LargeArenaAdoptionScenario, GpuWriteIntoTheArenaIsReadBack) {
if (!Ready() || IsSkipped()) return;
GLint maxComputeStorageBlocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &maxComputeStorageBlocks);
if (maxComputeStorageBlocks < 1) {
GTEST_SKIP() << "no compute shader storage blocks on this driver";
}
const GLuint compute = CompileStage(GL_COMPUTE_SHADER, kMarkerComputeSource);
ASSERT_NE(compute, 0u) << m_buildLog;
m_compute = glCreateProgram();
glAttachShader(m_compute, compute);
glLinkProgram(m_compute);
glDeleteShader(compute);
GLint linked = 0;
glGetProgramiv(m_compute, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const unsigned int seed = 0u;
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(seed), &seed);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, m_arena, 0, sizeof(unsigned int));
glUseProgram(m_compute);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_BUFFER_UPDATE_BARRIER_BIT);
unsigned int marker = 0;
glGetBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(marker), &marker);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(marker, 0xC0FFEEu)
<< "the compute write into the adopted arena did not reach the CPU readback";
}
} // namespace MGITest
@@ -32,14 +32,20 @@
// too, so the per-slice branch that exists for exactly this case was unreachable and every
// slice above z = 0 came back VK_NULL_HANDLE.
//
// The seven cases below are those shapes - layered 3D, one 3D slice, layered cube-map array with
// its depth and packed depth-stencil attachments, and (cases 6 and 7) a layered cube MAP and 1D
// ARRAY whose queued glClear is consumed outside a render pass. Each one asserts LAYER ROUTING,
// The first seven cases below are those shapes - layered 3D, one 3D slice, layered cube-map array
// with its depth and packed depth-stencil attachments, and (cases 6 and 7) a layered cube MAP and
// 1D ARRAY whose queued glClear is consumed outside a render pass. Each one asserts LAYER ROUTING,
// not merely survival: what a layer receives is a function of its own index, so an attachment that
// collapsed onto layer 0, or attached one face of a cube, fails on the layers it did not reach
// rather than passing quietly. Every texture is seeded with a poison value first, so "the draw
// never landed here" reads differently from "the wrong layer landed here".
//
// Case (8) is the same collapse one step downstream, and case (6) is what found it: the READBACK
// of a cube map ignored the face it was asked for and answered +X for all six. Every case here
// that reads a layered target back depends on the readback addressing the layer it names, so it
// belongs beside them - and case (6) had to be written around it, which is the strongest argument
// there is that it was never pinned.
//
// One of them turned out not to be a DirectVulkan bug at all. glFramebufferTexture on
// GL_DEPTH_STENCIL_ATTACHMENT is a shorthand the front end splits into a depth and a stencil
// attachment, and the split dropped the call's `layered` flag - so a layered colour attachment
@@ -96,6 +102,10 @@ namespace MGITest {
// mismatch means a real miss rather than rounding.
constexpr Rgba8 kClearColor{17, 68, 187, 255};
// The six cube faces in the order GL numbers them, which is also the order Vulkan keeps
// them in as array layers (GL 4.6 core 8.5.3 / VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT).
const char* const kFaceNames[6] = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"};
// What pass `pass` paints on layer `layer`. r and g name the LAYER (so a mis-routed write
// says which layer it came from) and b names the PASS (so "the second draw was not
// rejected" is distinguishable from "the first draw never happened").
@@ -357,6 +367,27 @@ void main()
return texture;
}
// A cube map whose six faces are UPLOADED with their own colours - the same
// ExpectedColor(face, 0) the painted cube of case (8) ends up holding, so both can be
// checked with one expectation. Uploaded rather than rendered means the CPU shadow and
// the image agree, which is the premise the BY-NAME readback needs; see case (8).
GLuint MakeFaceColoredCubeMap() {
const GLuint texture = TrackTexture();
glBindTexture(GL_TEXTURE_CUBE_MAP, texture);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_RGBA8, kExtent, kExtent);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
for (int face = 0; face < 6; ++face) {
const std::vector<Rgba8> seed(static_cast<std::size_t>(kExtent) * kExtent,
ExpectedColor(face, 0));
glTexSubImage2D(static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, 0, 0, kExtent,
kExtent, GL_RGBA, GL_UNSIGNED_BYTE, seed.data());
}
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
return texture;
}
// An RGBA8 1D array, every layer poisoned. glTexImage2D's HEIGHT is the layer count -
// that is what GL_TEXTURE_1D_ARRAY means, and it is why reading the level size's z
// gives 1 however many layers there are.
@@ -487,6 +518,34 @@ void main()
}
}
// Every texel of one cube FACE is that face's own colour. When it is not, the message
// says whose colour answered instead - which is the whole point here: a readback that
// ignores the face token does not return garbage, it returns another face's perfectly
// plausible texels, and "+X's colour came back for -Y" is the sentence that names the
// defect. `what` is the spelling under test, since three of them read the same faces.
void ExpectFaceColor(const std::vector<Rgba8>& texels, int face, const char* what) {
const Rgba8 expected = ExpectedColor(face, 0);
for (std::size_t i = 0; i < texels.size(); ++i) {
const Rgba8 actual = texels[i];
if (actual == expected) continue;
std::string blame;
if (actual.r == kPoison && actual.g == kPoison) {
blame = " - the poison, so nothing was ever written to this face";
} else {
for (int other = 0; other < 6; ++other) {
if (other != face && actual == ExpectedColor(other, 0)) {
blame = std::string(" - which is face ") + kFaceNames[other] + "'s colour";
break;
}
}
}
ADD_FAILURE() << what << ": face " << kFaceNames[face] << " texel " << i << " is "
<< Describe(actual) << ", expected " << Describe(expected) << blame;
// One message per face is enough to say what happened.
break;
}
}
::testing::AssertionResult FramebufferIsComplete() {
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status == GL_FRAMEBUFFER_COMPLETE) return ::testing::AssertionSuccess();
@@ -883,13 +942,16 @@ void main()
// Every face, read back through an FBO that names THAT face.
//
// Not glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face): measured against a tree
// where only +X had been cleared, that spelling returned the cleared colour for all
// six faces, so it cannot see per-face state on DirectVulkan and the case built on it
// was unfalsifiable. glFramebufferTexture2D + glReadPixels names one face and nothing
// else, and the pending clear is long gone by now (materialised and popped above), so
// this readback cannot alter what it is measuring.
static const char* const kFaceNames[6] = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"};
// Not glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face): when this case was written
// that spelling could not see per-face state on DirectVulkan at all - measured against
// a tree where only +X had been cleared it returned the cleared colour for all six
// faces - so a case built on it would have been unfalsifiable. That is a readback
// defect rather than an attachment one, and case (8) below is where it is pinned and
// fixed; this case keeps the independent spelling deliberately, because it must go on
// measuring the CLEAR whatever the readback does. glFramebufferTexture2D +
// glReadPixels names one face and nothing else, and the pending clear is long gone by
// now (materialised and popped above), so this readback cannot alter what it is
// measuring.
for (int face = 0; face < 6; ++face) {
const GLuint faceFbo = TrackFramebuffer();
glBindFramebuffer(GL_FRAMEBUFFER, faceFbo);
@@ -958,5 +1020,114 @@ void main()
Gl().EndFrame();
}
// (8) THE CUBE FACE TOKEN A READBACK IS GIVEN, AND WHETHER IT HONOURS IT.
//
// Case (6) above had to route around glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face)
// entirely: measured against a tree where only the +X face had been cleared, that spelling
// returned +X's colour for all six face tokens. This case is that observation turned into
// an assertion, and it is about the READBACK, not the attachment.
//
// THE DEFECT. DirectVulkan's GetTextureImage derived its copy geometry from the IMAGE's
// target alone. A plain GL_TEXTURE_CUBE_MAP is not one of the array targets, so the layer
// count collapsed to one - correct, one face IS one layer - but nothing ever turned the
// face the TARGET TOKEN named into the copy's baseArrayLayer, which stayed 0. All six face
// tokens therefore read array layer 0 and answered +X: five of a cube map's six faces were
// unreadable through the entry point GL provides for reading them. Nothing announces it -
// the call succeeds, raises no error, and hands back entirely plausible texels from the
// wrong face. The conversion it was missing already existed twice over, as the clear and
// render-pass managers' ResolveAttachmentBaseArrayLayer.
//
// glGetTextureSubImage is the same question asked by name: GL 4.6 core 8.11.4 addresses a
// cube map's faces through zoffset. That spelling was not merely reading the wrong face,
// it could not read ANY face - measured pre-fix, all six returned INVALID_OPERATION on
// both backends. Two independent reasons, and it took both to make even zoffset 0 fail:
// the z range was measured against the level's z, which is one face's 1, so five of the
// six looked like a partial read; and the destination-size check summed all six faces, so
// the one face's worth of buffer a single-face read has any reason to pass was rejected as
// too small.
//
// Each face is painted its OWN colour, so a collapse onto layer 0 does not merely read
// "wrong": the failure names the face that answered. The cube is poisoned first and then
// painted through the GPU, so an answer served from the stale CPU shadow is also called out
// by name rather than passing. And the per-face FBO + glReadPixels read is the control: it
// names one face and nothing else, so if IT disagrees the defect is in how the faces were
// written and this case is measuring the wrong thing.
//
// DirectGLES attaches the named face to a scratch FBO and reads that, so it answers the
// face token correctly throughout - a red there means this case is wrong. Its by-name
// readback is a different matter and gets a texture of its own; see the third block.
TEST_F(LayeredAttachmentShapeScenario, CubeMapFaceReadbackAnswersTheFaceItWasAskedFor) {
if (!Ready()) return;
const GLuint cube = MakePoisonedCubeMap();
ASSERT_EQ(FirstGLError(), 0u) << "creating the RGBA8 cube map failed";
// Paint every face its own colour through an FBO that names that one face. A clear
// rather than a draw, so nothing here depends on a shader stage being present.
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glViewport(0, 0, kExtent, kExtent);
GLuint faceFbos[6] = {};
for (int face = 0; face < 6; ++face) {
faceFbos[face] = TrackFramebuffer();
glBindFramebuffer(GL_FRAMEBUFFER, faceFbos[face]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), cube, 0);
glDrawBuffer(GL_COLOR_ATTACHMENT0);
glReadBuffer(GL_COLOR_ATTACHMENT0);
ASSERT_TRUE(FramebufferIsComplete()) << "cube face " << kFaceNames[face] << " is not attachable";
const Rgba8 want = ExpectedColor(face, 0);
glClearColor(want.r / 255.0f, want.g / 255.0f, want.b / 255.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "painting the six faces errored";
// The control. If this is red, the faces do not hold six different values and the two
// readbacks below are being measured against a premise that is not true.
for (int face = 0; face < 6; ++face) {
glBindFramebuffer(GL_FRAMEBUFFER, faceFbos[face]);
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, kExtent, kExtent, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u) << "the control read of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "control: per-face FBO + glReadPixels");
}
// The subject: the face TOKEN.
glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
for (int face = 0; face < 6; ++face) {
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glGetTexImage(static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
EXPECT_EQ(FirstGLError(), 0u) << "glGetTexImage of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "glGetTexImage(GL_TEXTURE_CUBE_MAP_<face>)");
}
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
// The same question by name, where zoffset is the face.
//
// On a cube map UPLOADED face by face rather than the painted one above, because the
// by-name readback has no backend entry outside DirectVulkan and answers from the CPU
// shadow there - a separate, pre-existing gap that has nothing to do with which face
// gets read. Asking it about GPU-painted content would make this red on DirectGLES for
// a reason the case is not about; asking it about uploaded content leaves exactly one
// thing either backend can get wrong, which is the face. DirectVulkan still answers
// this one out of the image, so the layer collapse is just as visible here.
const GLuint uploaded = MakeFaceColoredCubeMap();
ASSERT_EQ(FirstGLError(), 0u) << "uploading the six faces failed";
for (int face = 0; face < 6; ++face) {
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glGetTextureSubImage(uploaded, 0, 0, 0, face, kExtent, kExtent, 1, GL_RGBA, GL_UNSIGNED_BYTE,
static_cast<GLsizei>(texels.size() * sizeof(Rgba8)), texels.data());
EXPECT_EQ(FirstGLError(), 0u) << "glGetTextureSubImage of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "glGetTextureSubImage(zoffset = face)");
}
Gl().EndFrame();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,522 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PointSizeDemotionScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - THE gl_PointSize DEMOTION IS CLIENT-INVISIBLE, AND IT ACTUALLY ARMS.
//
// On a device that hosts the built-in in tessellation/geometry stages (llvmpipe and
// lavapipe both do), gl_PointSize travels as itself; on one that does not (the Mali
// devices this exists for), phase B demotes it to an ordinary varying
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram) and the capture
// machinery follows it there. This scenario runs in BOTH configurations and must hand
// back identical bytes: the ambient registrations take the native path, and the
// PointSizeDemotion. registrations pin MOBILEGL_POINT_SIZE_DEMOTION=1 so the demotion
// runs on the same healthy drivers - CopyImagePacked16Scenario's dual-configuration
// contract, applied to a value chain instead of a storage format.
//
// The VALUE is the whole contract: every case writes gl_PointSize in one stage, reads it
// back out of gl_in[] in the next, and captures it by name under rasterizer discard, so
// one wrong link anywhere in VS -> TCS -> TES -> GS -> capture lands in the readback.
// The RASTERIZED size is deliberately not asserted anywhere: with the built-in unhosted
// it falls back to 1.0 by spec on both targets, which is exactly the honest residue the
// demotion documents (point_rendering-style bodies keep failing truthfully).
//
// The assertions are on the captured BYTES against a CPU-computed reference, never on
// the absence of a GL error: every failure this guards against is silent.
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr float kPoison = -987654.0f;
const char* const kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
// The full chain, with per-vertex VARIATION seeded in the vertex stage so a control
// invocation that read or wrote the wrong slot changes the sum: 2,3,4 arrive, 3,4,5
// leave, the evaluation stage sums its patch to 12, the geometry stage doubles what
// it read to 24.
const char* const kChainVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 2.0 + float(gl_VertexID);
}
)";
const char* const kChainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main()
{
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* const kChainTessEvalSource = R"(#version 460 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* const kChainGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
// The geometry-only chain: no tessellation required of the stack at all.
const char* const kPointVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 7.0;
}
)";
const char* const kPointGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
EmitVertex();
EndPrimitive();
}
)";
// A capture stage that only READS the incoming point size and never writes its own.
// Legal GL, and the shape that separates "the demotion arms" from "the demotion knows
// a capture is coming": with the built-in gone, only the capture request can put a
// carrier back for a by-name capture to bind to.
const char* const kReadOnlyGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main()
{
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* const kEchoFragmentSource = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(g_echo, 0.0, 0.0, 1.0);
}
)";
class PointSizeDemotionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
DrainErrors();
}
void TearDown() override {
if (Ready()) {
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true) + "\n--- source ---\n" + source;
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()),
varyings.data(), GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span over `vertexCount` vertices of `drawMode`, recorded as
// GL_POINTS. The buffer is poison-filled first so bytes the capture never wrote
// name themselves.
std::vector<float> RunCaptureSpan(GLuint program, GLenum drawMode, GLsizei vertexCount,
std::size_t capturedFloats) {
const std::vector<float> poison(capturedFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(drawMode, 0, vertexCount);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> readback(capturedFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
readback.data());
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult ComponentIs(const std::vector<float>& data,
std::size_t index, float expected,
float epsilon = 1e-4f) {
if (index >= data.size()) {
return ::testing::AssertionFailure()
<< "component " << index << " is past the capture buffer";
}
const float actual = data[index];
if (actual == kPoison) {
return ::testing::AssertionFailure()
<< "component " << index << " still holds the poison value - the capture "
<< "never reached these bytes (expected " << expected << ")";
}
if (std::isnan(actual) || std::abs(actual - expected) > epsilon) {
return ::testing::AssertionFailure()
<< "component " << index << " is " << actual << ", expected " << expected;
}
return ::testing::AssertionSuccess();
}
// The library log, for the arming case. Same machinery and same reasoning as
// UnlocatedIoBlockScenario: MOBILEGL_LOG_FILE_PATH is read at log-init, the file
// is appended to by every process in the lane, and only bytes appended after the
// snapshot may satisfy an assertion.
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
std::string m_buildLog;
private:
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
};
// The five-stage chain. 24.0 can only arrive if the vertex mirror, both control-stage
// redirects (read AND write), the evaluation stage's three gl_in reads and the
// geometry stage's read all carried the right value - one wrong link and the sum
// moves. point_mode with every level at 1 emits three points; the first record proves
// the mechanism, exactly as TessellationXfbCaptureScenario reasons.
TEST_F(PointSizeDemotionScenario, TheValueSurvivesTheFiveStageChainIntoTheCapture) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_GEOMETRY_SHADER, kChainGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 24.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same chain without a geometry stage: the capture then binds to the evaluation
// stage's value (the sum, 12.0) - which is also the boundary where a demoted program
// switches its capture carrier from the Io chain to the capture name.
TEST_F(PointSizeDemotionScenario, TheEvaluationStageOwnsTheCaptureWithoutAGeometryStage) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 12.0f));
// The GL query surface keeps the truthful spelling whatever the backends renamed
// underneath: reflection is a phase-A product and the demotion happens after it.
char varyingName[64] = {};
GLsizei nameLength = 0;
GLsizei varyingSize = 0;
GLenum varyingType = 0;
glGetTransformFeedbackVarying(program, 0, sizeof(varyingName), &nameLength, &varyingSize,
&varyingType, varyingName);
EXPECT_STREQ(varyingName, "gl_PointSize");
EXPECT_EQ(varyingType, static_cast<GLenum>(GL_FLOAT));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The geometry-only chain: gl_in[0].gl_PointSize read straight off the vertex stage,
// no tessellation involved - the VS -> GS boundary of the demotion on its own.
TEST_F(PointSizeDemotionScenario, AGeometryOnlyChainCarriesTheVertexValue) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE CAPTURE-REQUEST PATH, END TO END - the half no unit test can reach, because the
// request travels from glTransformFeedbackVaryings through phase A's resolved capture
// set and the phase-B handoff before it reaches the demotion.
//
// The geometry stage READS gl_in[0].gl_PointSize and never writes gl_PointSize, which
// is enough to arm the demotion (glslang declares GeometryPointSize on a read) but not
// enough to create an output carrier on its own. Only the capture request can, and if
// that request never arrives the program does not merely lose the point-size column:
// DirectGLES respells the driver-side capture to a name no stage declares and the
// WHOLE capture set fails to link, while DirectVulkan mirrors a built-in the demotion
// just removed and can unwind far enough to drop the Xfb execution mode. Either way
// g_echo - an ordinary varying with nothing to do with point size - comes back poison,
// which is what this asserts. gl_PointSize itself is captured but never asserted: no
// stage writes it, so GL leaves its value undefined.
TEST_F(PointSizeDemotionScenario, ACaptureSurvivesAStageThatOnlyReadsThePointSize) {
if (!Ready()) return;
// The NATIVE Espryt path cannot do this at all, and never could: with the built-in
// hosted, the geometry stage's ESSL simply does not declare gl_PointSize unless it
// writes it, so the driver rejects the capture request with "varying undeclared"
// and the program becomes unusable. That is a pre-existing ES limitation the
// demotion happens to REPAIR - the carrier is a real, seeded, declared varying -
// so this case has something to assert only where the demotion is armed. Magma
// consumes SPIR-V and answers on both paths, which keeps the negative control.
if (Gl().BackendName() == "DirectGLES" &&
AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "Espryt cannot capture a gl_PointSize its capture stage never "
"writes without the demotion; the PointSizeDemotion. ctest entry "
"runs this same case with MOBILEGL_POINT_SIZE_DEMOTION=1";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kReadOnlyGeometrySource},
{GL_FRAGMENT_SHADER, kEchoFragmentSource}},
{"g_echo", "gl_PointSize"});
ASSERT_NE(program, 0u)
<< "the capture set failed to link. On a demoting configuration this is the "
"capture request never reaching the demotion, so the point-size capture was "
"respelled to a carrier no stage declares. Build log: "
<< m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 2);
EXPECT_TRUE(ComponentIs(captured, 0, 7.0f))
<< "the unrelated varying captured alongside gl_PointSize did not survive; the "
"point-size capture took the whole set with it";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE ONE CASE THAT CAN FAIL WHEN THE DEMOTION SILENTLY STOPS BEING ARMED.
//
// Everything above captures the right bytes on llvmpipe and lavapipe whether the
// demotion ran or not - these machines host the built-in - so those cases pin that
// the demotion does no HARM and can say nothing about whether it happened. The
// arming is where the cheap mistake lives: MOBILEGL_POINT_SIZE_DEMOTION maps onto
// the two Supports*PointSize capability bits INVERTED (forcing the demotion on
// means declaring the built-in UNHOSTED), and a swap of those arms - or a dropped
// env bit anywhere between ConfigLoader, the backend init, CompileEnv and the L1
// key - would disable the device repair with every rendering case still green.
//
// Same machinery as UnlocatedIoBlockScenario's arming case: the environment says
// the demotion is pinned on, therefore the library must SAY it demoted something.
// The observable is the latched MGLOG_I each backend emits when it first builds a
// demoted program; both spell "demoted to an ordinary varying", so this one case
// covers both pinned lanes without a backend gate.
TEST_F(PointSizeDemotionScenario, TheDemotionIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the demotion pinned ON for the whole process, which "
"is what the PointSizeDemotion. ctest entries do with "
"MOBILEGL_POINT_SIZE_DEMOTION=1; with the variable unset the detected "
"capabilities decide, and on this machine the built-in is hosted - so "
"there would be nothing to observe";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_POINT_SIZE_DEMOTION is pinned on but MOBILEGL_LOG_FILE_PATH "
"is not set, so the library has nowhere to record that it demoted "
"anything; the PointSizeDemotion. ctest entries set both";
}
// Taken BEFORE the program is built, so the line this looks for can only be one
// this process wrote.
const std::uintmax_t before = LibraryLogSize();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
// Drawn as well as built, so a stack that defers its backend program to first
// use still reaches the build the latched line fires in - and the capture must
// STILL be right through the carrier.
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f))
<< "the pinned-on lane did not even capture correctly";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("demoted to an ordinary varying"), std::string::npos)
<< "MOBILEGL_POINT_SIZE_DEMOTION is pinned ON, a geometry program reading and "
"writing gl_PointSize was built and captured, and no backend ever reported "
"demoting it. The demotion is not armed - check the override mapping in the "
"backend inits (it is inverted on purpose), the CompileEnv accessors, and "
"ProgramSpirvTask's verdict plumbing. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,554 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - GL_PRIMITIVES_GENERATED COUNTS DRAWS MADE WITH TRANSFORM FEEDBACK
// INACTIVE.
//
// GL 4.6 core 13.4: the query counts what the last vertex processing stage emits,
// capture or no capture. The CTS leans its whole tessellation suite on that - the
// tessellator's output is MEASURED by an XFB-inactive PATCHES draw under
// rasterizer discard inside a GENERATED query, and the capture buffers of ~29
// tessellation tests are sized from the answer - so a backend that answers 0
// hands them a zero-byte buffer and an INVALID_OPERATION off its zero-length map.
//
// DirectVulkan serves the query from the transform-feedback stream query's
// primitivesNeeded, which VK_EXT_transform_feedback defines to count whether or
// not a capture span is open. Both the Mali-G1-Ultra driver AND Mesa lavapipe
// disagree with that definition: with no vkCmdBeginTransformFeedbackEXT recorded,
// the pair reads back 0. Where the bring-up probe measures that defect with a
// working control - or MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1 pins it on - the
// renderer accumulates XFB-inactive draws through the best proven substitute
// pool: VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (which lavapipe hosts and passes,
// rasterizer discard included), else pipeline statistics over clipping-stage
// invocations (GL's CLIPPING_INPUT_PRIMITIVES). These cases assert the GL-visible
// answer, so on this machine they hold the reroute to the same numbers the
// healthy stream path must produce - the "two pools must agree" assertion - and
// on a healthy driver they pin the stream path itself.
//
// DirectVulkan only: DirectGLES has no GPU counter for an XFB-inactive draw at
// all (ES has no PRIMITIVES_GENERATED without a capture), and its CPU accounting
// is a different mechanism with its own tests.
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <initializer_list>
#include <iterator>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
GLuint CompileShaderStage(GLenum type, const char* source, std::string* log) {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// A capture-capable vertex-only program: the varying gives glBeginTransformFeedback
// something to capture for the mixed-span case; the XFB-inactive cases draw with the
// same program and simply never begin a span.
const char* const kVertexSource = R"(#version 430 core
out vec4 vs_out_value;
void main() {
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
vs_out_value = vec4(1.0);
gl_Position = vec4(corners[gl_VertexID % 3], 0.0, 1.0);
}
)";
// A passthrough tessellation pipeline whose all-1 levels emit exactly one
// triangle per patch - the count the tessellation cases assert.
const char* const kTessVertexSource = R"(#version 430 core
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 430 core
layout(vertices = 1) out;
void main() {
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
)";
const char* const kTessEvalSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
// The same tessellation pipeline with something to capture, so that
// glBeginTransformFeedback accepts it: the paused-span PATCHES case needs an
// open (but paused) capture span AND a tessellator in one program.
const char* const kTessEvalCaptureSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
out vec4 te_out_value;
void main() {
te_out_value = vec4(1.0);
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
class PrimitivesGeneratedNoXfbScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != std::string("DirectVulkan")) {
GTEST_SKIP() << "the stream-query defect and its reroute are DirectVulkan's; "
<< Gl().BackendName()
<< " answers this query from a different mechanism";
}
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenQueries(2, m_queries);
ASSERT_NE(m_queries[0], 0u);
ASSERT_NE(m_queries[1], 0u);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (m_queries[0] != 0 || m_queries[1] != 0) glDeleteQueries(2, m_queries);
m_queries[0] = m_queries[1] = 0;
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
ScenarioTest::TearDown();
}
// captureVarying: the name to record with glTransformFeedbackVaryings, or
// nullptr for a program that can never open a capture span.
GLuint BuildProgram(std::initializer_list<std::pair<GLenum, const char*>> stages,
const char* captureVarying) {
std::vector<GLuint> shaders;
for (const auto& [type, source] : stages) {
const GLuint shader = CompileShaderStage(type, source, &m_buildLog);
if (shader == 0) {
for (const GLuint built : shaders) glDeleteShader(built);
return 0;
}
shaders.push_back(shader);
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
if (captureVarying != nullptr) {
glTransformFeedbackVaryings(program, 1, &captureVarying, GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
for (const GLuint shader : shaders) glDeleteShader(shader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
m_buildLog = buffer.data();
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
GLuint BuildCaptureProgram() {
return BuildProgram({{GL_VERTEX_SHADER, kVertexSource}}, "vs_out_value");
}
GLuint BuildTessellationProgram(bool withCaptureVarying = false) {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
while (glGetError() != GL_NO_ERROR) {
}
if (maxTessGenLevel < 1) return 0;
return BuildProgram(
{{GL_VERTEX_SHADER, kTessVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER,
withCaptureVarying ? kTessEvalCaptureSource : kTessEvalSource}},
withCaptureVarying ? "te_out_value" : nullptr);
}
// A capture span that is open but PAUSED. The pause closes the capture, so
// every draw inside it is XFB-inactive at the backend - the stream query's
// silent case - while the GL span stays active. `program` must be the one
// that is bound: GL requires the same program at resume.
void BeginPausedSpan() {
glGenBuffers(1, &m_captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 64 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginTransformFeedback(GL_TRIANGLES);
glPauseTransformFeedback();
}
void EndPausedSpan() {
glResumeTransformFeedback();
glEndTransformFeedback();
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
if (m_captureBuffer != 0) glDeleteBuffers(1, &m_captureBuffer);
m_captureBuffer = 0;
}
// GENERATED query around `record()`, answered with GL_QUERY_RESULT.
GLuint QueryGenerated(const std::function<void()>& record) {
glBeginQuery(GL_PRIMITIVES_GENERATED, m_queries[1]);
record();
glEndQuery(GL_PRIMITIVES_GENERATED);
GLuint generated = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[1], GL_QUERY_RESULT, &generated);
return generated;
}
static GLenum DrainGLErrors() {
const GLenum first = glGetError();
while (glGetError() != GL_NO_ERROR) {
}
return first;
}
const std::string& BuildLog() const { return m_buildLog; }
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
GLuint m_vao = 0;
GLuint m_queries[2] = {0, 0}; // [0]=written, [1]=generated
GLuint m_captureBuffer = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// The plain shape: no capture object was ever bound, no span begun, no
// rasterizer discard - just a GENERATED query around two triangles. On a
// healthy driver the stream query answers it; on an affected one the armed
// reroute must produce the same 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsADrawMadeWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 2u)
<< "GL_PRIMITIVES_GENERATED must count a draw made while transform feedback is "
"inactive (GL 4.6 core 13.4)";
}
// THE CTS SHAPE (esextcTessellationShaderUtils.cpp, captureTessellationData):
// rasterizer discard ON, transform feedback INACTIVE, the draw inside a
// GENERATED query. This is the exact query whose 0 sizes ~29 tessellation
// tests' capture buffers on the affected device.
//
// On lavapipe this case holds through the dedicated
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT reroute (its discard feature is
// what makes a discarded draw countable there - llvmpipe's clipping
// statistics AND stream query both read 0 under discard).
//
// The value-conditioned skip below is deliberate and narrow, for a stack
// with NO counter that survives discard: there this case is unfalsifiable,
// and a red would indict MobileGL for a hole the bring-up probe already
// measures and reports (StatisticsSubstitutePlainOnly / Unfixable). The
// exact-zero answer IS the capability signal - any wrong nonzero count
// still fails - and on every driver that counts discarded draws at all the
// full assertion runs. The device probe list holds this shape on the Mali.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsUnderRasterizerDiscardWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(DrainGLErrors(), 0u);
if (generated == 0u) {
GTEST_SKIP() << "no counter this backend can reach (stream query, dedicated "
"primitives-generated query, clipping statistics) survives "
"rasterizer discard for an XFB-inactive draw on this stack - the "
"shape is unfalsifiable here; the bring-up probe measures the same "
"hole and the POST row reports it";
}
EXPECT_EQ(generated, 2u)
<< "rasterizer discard drops primitives after clipping and must not hide them from "
"GL_PRIMITIVES_GENERATED - this is the exact shape the CTS measures the "
"tessellator with";
}
// The tessellation flavour: a PATCHES draw whose all-1 levels emit exactly
// one triangle - the count the CTS's getAmountOfVerticesGeneratedByTessellator
// protocol derives everything from. Undiscarded, so that the answer is
// holdable on this machine through whichever accounting path is armed (the
// discard interaction is the case above's business, measured separately).
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsATessellatedPatchWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram();
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u)
<< "a triangles-domain patch with every level 1 tessellates to exactly one "
"triangle, and GL_PRIMITIVES_GENERATED must say so with no capture active";
}
// One query span holding BOTH kinds of draw: an XFB-inactive draw, then a
// captured one, then another XFB-inactive one. The GENERATED answer must
// accumulate across the two accounting paths the armed reroute splits them
// into (stream slots for the captured draw, statistics slots for the
// others), and WRITTEN must stay exactly the captured draw's count - the
// pairing the stream path exists to keep exact. Undiscarded, so the
// accumulation invariant is holdable on this machine (see the discard
// case's comment); the triangles rasterize into the harness framebuffer,
// which nothing here reads.
TEST_F(PrimitivesGeneratedNoXfbScenario, ASpanMixingActiveAndInactiveDrawsAccumulatesBoth) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 3 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, m_queries[0]);
const GLuint generated = QueryGenerated([]() {
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive
glBeginTransformFeedback(GL_TRIANGLES);
glDrawArrays(GL_TRIANGLES, 0, 3); // captured
glEndTransformFeedback();
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive again
});
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
GLuint written = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[0], GL_QUERY_RESULT, &written);
glDeleteBuffers(1, &captureBuffer);
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 3u) << "one triangle before the span, one inside it, one after";
EXPECT_EQ(written, 1u) << "only the draw inside the span writes anything";
}
// ===================== DRAWS INSIDE A PAUSED SPAN =====================
//
// glPauseTransformFeedback closes the capture without closing the span, so a
// draw made while paused is XFB-INACTIVE at the backend - the stream query is
// exactly as silent for it as for a draw with no span at all - while
// GL_PRIMITIVES_GENERATED must still count what the last vertex processing
// stage emitted (GL 4.6 core 13.4; the WRITTEN query is the one the pause
// silences). The frontend does keep a CPU counter for paused draws, but it can
// price only 3 of the ~15 draw entry points and answers 0 for GL_PATCHES, so
// these draws are the reroute's business like any other - and the trap on the
// other side is counting them TWICE, once in each accounting.
//
// Each case measures the SAME draw twice: once with no span open at all (the
// capability control - what this stack can count) and once inside the paused
// span, and requires the two to agree. That differential is what makes these
// cases falsifying rather than vacuous: a stack where no counter reaches a
// capture-less draw fails the control and skips, while a stack that counts the
// unpaused draw and answers 0 for the paused one - which is what excluding
// paused draws from the reroute produced - fails, instead of skipping into
// green.
// The draw the CPU counter CAN price: if the span both reroutes it and adds the
// CPU delta, this reads 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsACpuPricedDrawExactlyOnce) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") == AmbientQuirk::Off) {
GTEST_SKIP() << "the negative control replays the pre-probe accounting, whose paused "
"draws are CPU-counted on top of whatever the stream query says";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half of the comparison proves nothing; the "
"bring-up probe measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u) << "the control itself: one triangle is one primitive";
EXPECT_EQ(paused, unpaused)
<< "one triangle drawn while the capture span is paused is still one primitive "
"generated - counted once, by whichever accounting owns it, never by two of them "
"(a reroute slot AND the frontend's CPU paused counter reads 2)";
}
// The draw the CPU counter CANNOT price: GL_PATCHES, whose amplification is not
// knowable on the CPU (CountPrimitivesForDraw answers 0 for it by design) - and
// the CTS's tessellator-measuring shape. Excluding paused draws from the
// reroute left this counted by nothing at all on the affected device.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsATessellatedPatchExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram(/*withCaptureVarying=*/true);
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less patch draw "
"on this stack, so the paused half proves nothing; the bring-up probe "
"measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u)
<< "the control itself: a triangles-domain patch with every level 1 tessellates to "
"exactly one triangle";
EXPECT_EQ(paused, unpaused)
<< "pausing the capture does not stop the tessellator from generating that triangle, "
"and the frontend's CPU paused counter answers 0 for GL_PATCHES - so a paused "
"patch draw left out of the reroute is counted by nothing at all";
}
// The other half of the same hole: the instanced entry points never reach the
// frontend's paused accounting either, so a paused instanced draw excluded from
// the reroute is likewise counted by nothing.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsAnInstancedDrawExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused =
QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half proves nothing";
}
EXPECT_EQ(unpaused, 4u) << "the control itself: four instances of one triangle";
EXPECT_EQ(paused, unpaused)
<< "four instances generate four primitives whether or not the capture span is "
"paused, and no instanced entry point reaches the frontend's paused accounting";
}
// THE ONE CASE THAT CAN FAIL WHEN THE REROUTE SILENTLY STOPS BEING ARMED -
// the UnlocatedIoBlockScenario shape, for the same reason: every case above
// is green here whether the reroute ran or not (that is the "two pools
// agree" point), so none of them can say the pinned lane actually exercised
// a reroute pool. This one asserts a LIBRARY OBSERVABLE against the
// environment: with MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE pinned on, an
// XFB-inactive draw inside a GENERATED span must make the renderer say -
// through its latched MGLOG_I - that it engaged the reroute. It reads
// MG_Config not at all (on Android this module links the shipping library)
// and trusts only the log bytes appended after it started.
TEST_F(PrimitivesGeneratedNoXfbScenario, TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the reroute pinned ON for the whole process, which "
"is what the PrimGenReroute. ctest entry does with "
"MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1; unset, the bring-up probe "
"decides and this machine's verdict is its own business";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned on but "
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
"record that it rerouted anything; the PrimGenReroute. ctest "
"entry sets both";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
// Taken BEFORE the draw, so the line this looks for can only be one this
// process wrote for this span. The latch fires on the FIRST rerouted
// draw, which is inside the query below.
const std::uintmax_t before = LibraryLogSize();
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u) << "the pinned-on lane did not even count correctly";
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("PRIMITIVES_GENERATED reroute engaged"), std::string::npos)
<< "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned ON, an XFB-inactive draw ran inside "
"a GENERATED query, and the renderer never reported engaging the reroute. The "
"quirk is not armed - check the override mapping "
"(ChoosePrimitivesGeneratedReroute) and the arming gate in "
"VulkanRenderer::BeginXfbQueryForDraw. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,178 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SampleMaskScopeScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - GL_SAMPLE_MASK IS A MULTISAMPLE FRAGMENT OPERATION, SO IT DOES NOTHING AT ONE SAMPLE.
//
// GL 4.6 core 17.3.3 groups alpha-to-coverage, sample coverage and the sample mask together and
// says they make no change "if MULTISAMPLE is disabled, or if the value of SAMPLE_BUFFERS is not
// one". SAMPLE_BUFFERS is 0 for a single-sample framebuffer, so on one the mask is inert whatever
// glSampleMaski last wrote.
//
// Vulkan has no such rule. VkPipelineMultisampleStateCreateInfo::pSampleMask is ANDed with
// rasterization coverage at every rasterizationSamples, and at one sample that coverage is bit 0
// alone - so a mask with bit 0 clear discards every fragment of every primitive. Plumbing
// glSampleMaski straight into pSampleMask therefore turned an ordinary and legal GL sequence into
// a fully black draw:
//
// glEnable(GL_SAMPLE_MASK); glSampleMaski(0, 0x2); // while an MSAA target is bound
// ... render ...
// glBindFramebuffer(GL_FRAMEBUFFER, 0); draw a fullscreen quad to present
//
// Neither piece of state is per-framebuffer, so nothing resets it when the target changes, and
// dEQP/GL-CTS multisample cases leave exactly these masks behind. That is the MSAA-then-present
// shape every application uses.
//
// The cases below are single-sample by construction (the scenario harness's colour FBO), so each
// one asserts that the mask changed nothing.
#include <string>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kFboSize = 32;
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
constexpr const char* kGreenFragmentSource = R"(#version 430 core
out vec4 o_color;
void main() {
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
class SampleMaskScopeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
std::string error;
m_program = CompileProgram(kQuadVertexSource, kGreenFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
}
void TearDown() override {
if (!Ready()) return;
// Process-wide GL state: leaving it set would hand the next scenario in this
// process the very bug under test.
glDisable(GL_SAMPLE_MASK);
glSampleMaski(0, 0xFFFFFFFFu);
glBindVertexArray(0);
glUseProgram(0);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
DestroyColorFbo(m_target);
ScenarioTest::TearDown();
}
void ExpectQuadStillPaints(const char* what) {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(m_vao);
glUseProgram(m_program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << what << ": the draw raised a GL error";
const Image image = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(image.Empty()) << what << ": the readback came back empty";
EXPECT_TRUE(RegionIsMostly(image, 0, kFboSize - 1, 0, kFboSize - 1, "green", 0.0, what))
<< what << ": an all-black target means the sample mask discarded every fragment, "
<< "which GL says it cannot do on a single-sample framebuffer";
}
ColorFbo m_target{};
GLuint m_vao = 0;
unsigned int m_program = 0;
};
} // namespace
// The exact reported shape: bit 0 clear, so the single sample of a single-sample target is
// masked off if the mask is applied at all.
TEST_F(SampleMaskScopeScenario, AMaskWithBitZeroClearDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x2);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK enabled with mask 0x2");
}
// Zero is the strongest form of the same thing, and the mask value the CTS's mask_zero cases
// set.
TEST_F(SampleMaskScopeScenario, AZeroMaskDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x0);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK enabled with mask 0");
}
// Control: the same mask word with the capability disabled has never had any effect, so this
// one passed before the fix too. It is here so a regression that ignores the enable bit
// instead of the sample count is still caught.
TEST_F(SampleMaskScopeScenario, ADisabledSampleMaskDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glDisable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x0);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK disabled with mask 0");
}
// The mask is state, not a draw parameter, so a second draw after the first must not inherit
// a pipeline built while the memo word and the payload disagreed. Two draws either side of a
// mask change, both to the same single-sample target, both required to paint.
TEST_F(SampleMaskScopeScenario, ChangingTheMaskBetweenSingleSampleDrawsKeepsBothPainting) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0xFFFFFFFFu);
ExpectQuadStillPaints("first draw, full mask");
glSampleMaski(0, 0x2);
ASSERT_EQ(FirstGLError(), 0u) << "changing the sample mask raised a GL error";
ExpectQuadStillPaints("second draw, mask 0x2");
}
// GL_MAX_SAMPLE_MASK_WORDS must be 1 on both backends: MobileGL stores one word and
// SampleMaski_State raises GL_INVALID_VALUE for any maskNumber above 0, so advertising more
// makes dEQP's per-case gluStateReset - which issues glSampleMaski up to the advertised count
// - fail every case. DirectGLES clamped; DirectVulkan forwarded the raw device limit.
TEST_F(SampleMaskScopeScenario, TheAdvertisedSampleMaskWordCountMatchesWhatSampleMaskiAccepts) {
if (!Ready() || IsSkipped()) return;
GLint words = 0;
glGetIntegerv(GL_MAX_SAMPLE_MASK_WORDS, &words);
ASSERT_EQ(FirstGLError(), 0u) << "querying GL_MAX_SAMPLE_MASK_WORDS raised a GL error";
EXPECT_EQ(words, 1) << "every word below the advertised count must be writable, and only word 0 is";
for (GLint word = 0; word < words; ++word) {
glSampleMaski(static_cast<GLuint>(word), 0xFFFFFFFFu);
EXPECT_EQ(FirstGLError(), 0u) << "glSampleMaski(" << word << ", ...) was refused although "
<< "GL_MAX_SAMPLE_MASK_WORDS advertises " << words << " words";
}
}
} // namespace MGITest
@@ -0,0 +1,231 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SampledSetStalenessScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A TEXTURE THAT BECOMES COMPLETE WITHOUT A REBIND MUST RE-ENTER THE SAMPLED SET.
//
// DirectVulkan does not bind a texture GL calls incomplete: it substitutes a fallback so the
// sampler reads (0,0,0,1) instead of losing the draw. That decision is made twice per draw - once
// by CollectSampledTextures, which builds the list SetupDraw syncs, materialises pending clears
// for and transitions to a sampled layout BEFORE the render pass opens, and once by the descriptor
// resolve inside the pass. Both ask SamplesAsIncompleteTexture.
//
// The per-draw memo that lets the first of those be skipped was keyed only on the program, the
// transform flags and the texture BIND generation. Completeness is not a function of any of them:
// it moves on a filter change (glTexParameteri / glSamplerParameteri), on a level-range change,
// and on an upload that fills the mip chain - none of which bind anything. So a texture that went
// incomplete -> complete under a fixed binding kept being answered out of the memo as "not in the
// set", and the work SetupDraw does for the set never happened for it:
//
// * its queued clear was never materialised, so the draw sampled pre-clear content - wrong
// pixels, no validation layer needed, which is what the case below detects; and
// * its layout transition moved into the descriptor resolve, which records
// vkCmdPipelineBarrier inside an already-open render pass whose subpass declares no
// self-dependency - the exact hazard CollectSampledTextures exists to prevent.
//
// The fix adds the sampling-resolution generation to that memo key, which is the counter the
// codebase already maintains for "what a unit resolves to changed without a bind" and which both
// TextureObjectBase::BumpShapeVersion and SamplerObject::BumpVersion move.
#include <cstddef>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kFboSize = 32;
constexpr int kTexSize = 8;
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// texelFetch, not texture(): the point is WHICH image is sampled, and a fetch cannot be
// explained away by filtering.
constexpr const char* kSampleFragmentSource = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() {
o_color = texelFetch(u_tex, ivec2(0, 0), 0);
}
)";
class SampledSetStalenessScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
std::string error;
m_program = CompileProgram(kQuadVertexSource, kSampleFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
// The sampled texture: ONE level, and no glTexParameteri at all, so MIN_FILTER
// keeps its initial GL_NEAREST_MIPMAP_LINEAR and GL calls it mipmap-incomplete.
glGenTextures(1, &m_texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
std::vector<unsigned char> green(static_cast<std::size_t>(kTexSize * kTexSize * 4), 0);
for (std::size_t i = 0; i < green.size(); i += 4) {
green[i + 1] = 255;
green[i + 3] = 255;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
green.data());
ASSERT_EQ(FirstGLError(), 0u) << "defining the sampled texture raised a GL error";
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glUseProgram(0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
DestroyColorFbo(m_target);
ScenarioTest::TearDown();
}
// One draw of the fullscreen quad sampling texel (0,0) of whatever unit 0 holds, and
// NO readback. That matters: a readback submits and waits, which ends the command
// buffer and resets the per-draw memos with it - so a case that read back between its
// two draws would never leave a stale entry to catch. The two draws here have to land
// in one recording.
void DrawOnly() {
BindFbo(m_target);
glBindVertexArray(m_vao);
glUseProgram(m_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
const GLint location = glGetUniformLocation(m_program, "u_tex");
if (location != -1) glUniform1i(location, 0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << "the sampling draw raised a GL error";
}
ColorFbo m_target{};
GLuint m_vao = 0;
GLuint m_texture = 0;
unsigned int m_program = 0;
};
} // namespace
// The full sequence, ordered so the ONLY state change between the two draws is the filter.
TEST_F(SampledSetStalenessScenario, AQueuedClearIsMaterialisedWhenAFilterChangeCompletesTheTexture) {
if (!Ready() || IsSkipped()) return;
// 1. Queue a clear on the texture through an FBO and take it straight back out, with no
// draw in between - the "attach -> clear -> detach" shape that leaves the clear
// pending for whoever samples the texture next.
GLuint clearFbo = 0;
glGenFramebuffers(1, &clearFbo);
glBindFramebuffer(GL_FRAMEBUFFER, clearFbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f};
glClearBufferfv(GL_COLOR, 0, red);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &clearFbo);
ASSERT_EQ(FirstGLError(), 0u) << "queueing the clear raised a GL error";
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
// 2. Draw while the texture is still incomplete. The backend substitutes its fallback,
// and the per-draw memo records the resulting sampled set.
DrawOnly();
// 3. Make it complete. No bind, no upload, no program change - one filter write, which is
// exactly the state the old memo key could not see.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "changing the filter raised a GL error";
// 4. Draw again, into the same recording, and only now read back. The texture is in the
// sampled set now, so its queued clear has to be materialised before the pass opens and
// the fetch has to see RED. Reading the green the texture was uploaded with means the
// clear was never materialised, i.e. the texture never entered the set - the stale-memo
// bug. Black means the fallback was still being handed out.
DrawOnly();
const Image afterFlip = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(afterFlip.Empty()) << "the readback came back empty";
EXPECT_TRUE(RegionIsMostly(afterFlip, 0, kFboSize - 1, 0, kFboSize - 1, "red", 0.0,
"the draw after the completeness flip"))
<< "green means the queued clear was never materialised, so the texture never re-entered "
"the sampled set after the filter change; blue means the draw did not happen at all";
}
// The same flip driven from a SAMPLER OBJECT rather than the texture's own parameters. It is
// the other half of what feeds the completeness predicate, it moves the same generation, and
// it likewise binds nothing.
TEST_F(SampledSetStalenessScenario, AQueuedClearIsMaterialisedWhenASamplerObjectCompletesTheTexture) {
if (!Ready() || IsSkipped()) return;
GLuint sampler = 0;
glGenSamplers(1, &sampler);
// Bound BEFORE the first draw, still carrying the mipmapping default, so binding it is
// not what changes between the two draws.
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_LINEAR);
glBindSampler(0, sampler);
ASSERT_EQ(FirstGLError(), 0u) << "binding the sampler object raised a GL error";
GLuint clearFbo = 0;
glGenFramebuffers(1, &clearFbo);
glBindFramebuffer(GL_FRAMEBUFFER, clearFbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f};
glClearBufferfv(GL_COLOR, 0, red);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &clearFbo);
ASSERT_EQ(FirstGLError(), 0u) << "queueing the clear raised a GL error";
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawOnly();
// One parameter write on an ALREADY-BOUND sampler object.
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "changing the sampler filter raised a GL error";
DrawOnly();
const Image afterFlip = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(afterFlip.Empty()) << "the readback came back empty";
EXPECT_TRUE(RegionIsMostly(afterFlip, 0, kFboSize - 1, 0, kFboSize - 1, "red", 0.0,
"the draw after the sampler-object flip"))
<< "green means the queued clear was never materialised after the sampler parameter change";
glBindSampler(0, 0);
glDeleteSamplers(1, &sampler);
}
} // namespace MGITest
@@ -0,0 +1,896 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TessellationXfbCaptureScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - WHAT A TESSELLATION EVALUATION STAGE OWES A TRANSFORM FEEDBACK CAPTURE.
//
// XfbRepeatedCaptureScenario already pins that a capture from a GL_PATCHES draw records
// AT ALL. Everything below is the part of the same pipeline it does not reach, and every
// case here is the reduced form of a conformance body that fails on a device:
//
// * CAPTURING THE BUILT-INS BY NAME. glTransformFeedbackVaryings("gl_Position") /
// ("gl_PointSize") on a program whose last vertex-processing stage is the evaluation
// shader. Nothing in the tree captured a built-in from a tessellation stage, and the
// two backends reach it by completely different routes - DirectGLES has to name a
// real ESSL output on the driver's own glTransformFeedbackVaryings, DirectVulkan has
// to decorate a SPIR-V built-in that lives inside gl_PerVertex.
//
// * THE PER-VERTEX PAYLOAD THE CONTROL STAGE HANDS OVER. gl_PointSize and a
// user-declared per-vertex interface block, both read back out of gl_in[] by the
// evaluation stage and only then captured. This is the shape of
// KHR-GL4x.tessellation_shader.tessellation_control_to_tessellation_evaluation.
// gl_MaxPatchVertices_Position_PointSize, which is 216 of the ~240 conformance bodies
// the family still fails: gl_Position arrives, and everything travelling beside it in
// the same patch does not.
//
// The assertions are on the captured BYTES against a CPU-computed reference, never on the
// absence of a GL error: every failure this guards against is silent.
#include <cmath>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Nothing a capture can legitimately produce, so a component that still reads it
// names the failure instead of looking like an ordinary numeric mismatch.
constexpr float kPoison = -987654.0f;
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
class TessellationXfbCaptureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static GLint MaxPatchVertices() {
GLint value = 0;
glGetIntegerv(GL_MAX_PATCH_VERTICES, &value);
DrainErrors();
return value;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, std::string>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true) + "\n--- source ---\n" + source;
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span over a single patch. Returns the capture buffer read back as
// floats; `capturedFloats` is the whole buffer, poison-filled beforehand.
std::vector<float> RunPatchCaptureSpan(GLuint program, GLenum captureMode, std::size_t capturedFloats) {
const std::vector<float> poison(capturedFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedFloats * sizeof(float)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(captureMode);
glDrawArrays(GL_PATCHES, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> readback(capturedFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedFloats * sizeof(float)), readback.data());
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult ComponentIs(const std::vector<float>& data, std::size_t index,
float expected, float epsilon = 1e-4f) {
if (index >= data.size()) {
return ::testing::AssertionFailure() << "component " << index << " is past the capture buffer";
}
const float actual = data[index];
if (actual == kPoison) {
return ::testing::AssertionFailure()
<< "component " << index << " still holds the poison value - the capture never reached "
<< "these bytes (expected " << expected << ")";
}
if (std::isnan(actual) || std::abs(actual - expected) > epsilon) {
return ::testing::AssertionFailure()
<< "component " << index << " is " << actual << ", expected " << expected;
}
return ::testing::AssertionSuccess();
}
// Defined below the shader builders it uses. `withPointSize` is the conformance
// body's own should_pass_pointsize_data axis.
void RunPerVertexPayloadCase(bool withPointSize);
// Why the gl_PointSize cases cannot be run here, or empty when they can.
//
// gl_PointSize from a tessellation stage is a real DRIVER capability on both
// targets - GL_EXT/OES_tessellation_point_size on an ES driver, the
// shaderTessellationAndGeometryPointSize feature on a Vulkan device - and desktop GL
// has no query that reports either, so this probes for it by running a program.
//
// The probe is deliberately NOT a gl_PointSize capture: it captures an ordinary user
// varying out of a tessellation evaluation stage that ALSO writes gl_PointSize, and
// compares that against the identical program without the write. A backend that
// cannot express the built-in loses the whole stage (DirectGLES fails to compile it
// and binds program 0; DirectVulkan cannot build the pipeline), so the plain varying
// comes back untouched too - which is a capability answer, not a capture answer. If
// BOTH come back untouched the probe itself is meaningless and it returns empty, so
// the cases run and FAIL rather than skipping on an unrelated breakage.
//
// Returns the reason as a string instead of skipping directly: GTEST_SKIP expands to
// a `return`, so a void helper would leave only the helper and let the case run its
// assertions anyway and report Failed instead of Skipped.
std::string WhyPointSizeCasesCannotRun();
// The geometry stage's own answer, and it has to BE its own answer: the two ESSL
// extensions are independent (Loader models them as two PointSizeTier fields fed by
// four distinct strings, and neither implies the other), so a driver with
// tessellation point size and no geometry point size passes the probe above and
// still cannot run the case below. Same two-program shape, one stage over.
//
// It also replaces a guard that could never fire: GL_MAX_GEOMETRY_OUTPUT_VERTICES is
// a hardcoded frontend constant (256) with no capability behind it, so "does this
// stack have a geometry stage at all" can only be answered by trying to build one -
// which is what this does, exactly as IoBlockNameCollisionScenario does for the same
// reason.
std::string WhyGeometryPointSizeCaseCannotRun();
std::vector<GLuint> m_programs;
std::string m_buildLog;
GLuint m_vao = 0;
};
// ---------------------------------------------------------------------------------
// Built-ins captured BY NAME from the evaluation stage.
// ---------------------------------------------------------------------------------
const char* const kMinimalVertexSource = R"(#version 420 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kMinimalTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
void main()
{
gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// Values no stale buffer would hold by accident. The two sources differ ONLY by
// gl_PointSize, so the pair isolates it: on a backend that lowers to ESSL the
// built-in is not even declared in a tessellation stage without
// GL_EXT_tessellation_point_size, and the whole shader then fails to compile.
const char* const kPositionTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(11.0, 12.0, 13.0, 14.0);
}
)";
const char* const kPositionAndPointSizeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(11.0, 12.0, 13.0, 14.0);
gl_PointSize = 5.0;
}
)";
// The two probe programs. They differ by one statement; both capture `probe_value`,
// which has nothing to do with point size.
const char* const kPointSizeProbeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
out float probe_value;
void main()
{
probe_value = 42.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 3.0;
}
)";
const char* const kPointSizeFreeProbeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
out float probe_value;
void main()
{
probe_value = 42.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
std::string TessellationXfbCaptureScenario::WhyPointSizeCasesCannotRun() {
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const auto probeCaptures = [&](const char* tessEvalSource) {
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, tessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"probe_value"});
if (program == 0) return false;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 3);
DrainErrors();
return captured[0] == 42.0f;
};
const bool withPointSize = probeCaptures(kPointSizeProbeTessEvalSource);
if (withPointSize) return {};
if (!probeCaptures(kPointSizeFreeProbeTessEvalSource)) {
// The control failed too, so nothing here is about point size.
return {};
}
return "this backend cannot express gl_PointSize in a tessellation stage at all - the same "
"program captures an ordinary varying with the gl_PointSize write removed and captures "
"nothing with it present (an ES driver without GL_EXT/OES_tessellation_point_size, or a "
"Vulkan device without shaderTessellationAndGeometryPointSize)";
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPositionByNameFromTheEvaluationStage) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
// point_mode with every level at 1 emits three points, all carrying the same
// constant; only the first record has to be right for the mechanism to be proven.
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 4 * 3);
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPositionAndGlPointSizeByNameFromTheEvaluationStage) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
if (const std::string reason = WhyPointSizeCasesCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionAndPointSizeTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position", "gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 5 * 3);
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_TRUE(ComponentIs(captured, 4, 5.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------
// The per-vertex payload the control stage hands to the evaluation stage.
// ---------------------------------------------------------------------------------
// The conformance body's own shapes, reduced to one patch and parameterised by the
// output patch size so the caller can run the real GL_MAX_PATCH_VERTICES. The
// `withPointSize` axis is the conformance body's own `should_pass_pointsize_data`,
// which it varies together with point_mode - and which decides whether the whole
// program even involves the per-vertex built-in that ESSL gates behind an extension.
std::string PayloadVertexSource(bool withPointSize) {
return R"(#version 420 core
out gl_PerVertex {
vec4 gl_Position;
)" + std::string(withPointSize ? " float gl_PointSize;\n" : "") +
R"(};
void main()
{
}
)";
}
std::string PayloadTessControlSource(int outputVertices, bool withPointSize) {
const std::string perVertexTail = withPointSize ? " float gl_PointSize;\n" : "";
return R"(#version 420 core
layout(vertices = )" + std::to_string(outputVertices) +
R"() out;
in gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_in[gl_MaxPatchVertices];
out gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_out[];
out OUT_TC
{
vec2 value1;
ivec4 value2;
} result[];
void main()
{
)" + std::string(withPointSize
? " gl_out[gl_InvocationID].gl_PointSize = 1.0 / float(gl_InvocationID + 1);\n"
: "") +
R"( gl_out[gl_InvocationID].gl_Position = vec4(float(gl_InvocationID * 4 + 0), float(gl_InvocationID * 4 + 1),
float(gl_InvocationID * 4 + 2), float(gl_InvocationID * 4 + 3));
result[gl_InvocationID].value1 = vec2(1.0 / float(gl_InvocationID + 1), 1.0 / float(gl_InvocationID + 2));
result[gl_InvocationID].value2 = ivec4(gl_InvocationID + 1, gl_InvocationID + 2,
gl_InvocationID + 3, gl_InvocationID + 4);
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
}
)";
}
// Deliberately NEVER writes gl_Position, exactly as the conformance shader does not:
// the redeclared block is there so the evaluation stage can READ gl_in[], and an
// output nothing stores is what UnwrittenPositionOutputScenario pins separately.
std::string PayloadTessEvalSource(int inputVertices, bool withPointSize) {
const std::string perVertexTail = withPointSize ? " float gl_PointSize;\n" : "";
return R"(#version 420 core
layout(isolines, equal_spacing, ccw, point_mode) in;
in gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_in[gl_MaxPatchVertices];
out gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(};
in OUT_TC
{
vec2 value1;
ivec4 value2;
} tc_data[];
)" + std::string(withPointSize ? "out float te_pointsize;\n" : "") +
R"(out vec4 te_position;
out vec2 te_value1;
out flat ivec4 te_value2;
void main()
{
)" + std::string(withPointSize ? " te_pointsize = 0.0;\n" : "") +
R"( te_position = vec4 (0.0);
te_value1 = vec2 (0.0);
te_value2 = ivec4(0);
for (int n = 0; n < )" + std::to_string(inputVertices) +
R"(; ++n)
{
)" + std::string(withPointSize ? " te_pointsize += gl_in [n].gl_PointSize;\n" : "") +
R"( te_position += gl_in [n].gl_Position;
te_value1 += tc_data[n].value1;
te_value2 += tc_data[n].value2;
}
}
)";
}
// The reduced conformance body. `withPointSize` selects between its two halves;
// everything else - one input vertex, an output patch of GL_MAX_PATCH_VERTICES, a
// user per-vertex block travelling beside gl_PerVertex, the capture taken off the
// evaluation stage - is the same on both.
void TessellationXfbCaptureScenario::RunPerVertexPayloadCase(bool withPointSize) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
if (withPointSize) {
if (const std::string reason = WhyPointSizeCasesCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
}
const GLint patchVertices = MaxPatchVertices();
ASSERT_GE(patchVertices, 32) << "GL_MAX_PATCH_VERTICES is below the guaranteed minimum";
// One input vertex per patch, an output patch of GL_MAX_PATCH_VERTICES vertices:
// the control stage runs that many invocations and every one of them contributes.
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
std::vector<const char*> varyings = {"te_position", "te_value1", "te_value2"};
if (withPointSize) varyings.push_back("te_pointsize");
const GLuint program =
BuildCaptureProgram({{GL_VERTEX_SHADER, PayloadVertexSource(withPointSize)},
{GL_TESS_CONTROL_SHADER, PayloadTessControlSource(patchVertices, withPointSize)},
{GL_TESS_EVALUATION_SHADER, PayloadTessEvalSource(patchVertices, withPointSize)},
{GL_FRAGMENT_SHADER, kFragmentSource}},
varyings);
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
float referencePointSize = 0.0f;
float referencePosition[4] = {0.0f, 0.0f, 0.0f, 0.0f};
float referenceValue1[2] = {0.0f, 0.0f};
int referenceValue2[4] = {0, 0, 0, 0};
for (int n = 0; n < patchVertices; ++n) {
referencePointSize += 1.0f / static_cast<float>(n + 1);
for (int c = 0; c < 4; ++c) {
referencePosition[c] += static_cast<float>(n * 4 + c);
referenceValue2[c] += n + 1 + c;
}
referenceValue1[0] += 1.0f / static_cast<float>(n + 1);
referenceValue1[1] += 1.0f / static_cast<float>(n + 2);
}
// isolines with every level at 1 emits two points; the record stride is
// vec4 + vec2 + ivec4 [+ float] components.
const std::size_t stride = withPointSize ? 11 : 10;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, stride * 4);
for (int c = 0; c < 4; ++c) {
EXPECT_TRUE(ComponentIs(captured, static_cast<std::size_t>(c), referencePosition[c], 1e-2f))
<< "te_position." << c << " (gl_in[].gl_Position)";
}
for (int c = 0; c < 2; ++c) {
EXPECT_TRUE(ComponentIs(captured, static_cast<std::size_t>(4 + c), referenceValue1[c], 1e-3f))
<< "te_value1." << c << " (the user per-vertex block the control stage wrote)";
}
for (int c = 0; c < 4; ++c) {
const std::size_t index = static_cast<std::size_t>(6 + c);
ASSERT_LT(index, captured.size());
int actual = 0;
std::memcpy(&actual, &captured[index], sizeof(actual));
EXPECT_EQ(actual, referenceValue2[c])
<< "te_value2." << c << " (the user per-vertex block's integer member)";
}
if (withPointSize) {
EXPECT_TRUE(ComponentIs(captured, 10, referencePointSize, 1e-3f))
<< "te_pointsize (gl_in[].gl_PointSize)";
}
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
TEST_F(TessellationXfbCaptureScenario, TheEvaluationStageSeesTheUserPerVertexBlockOfItsPatch) {
RunPerVertexPayloadCase(false);
}
// ---------------------------------------------------------------------------------
// The same built-in, one stage over.
// ---------------------------------------------------------------------------------
// ESSL gates gl_PointSize behind a per-stage extension in BOTH non-vertex
// vertex-processing stages - EXT/OES_tessellation_point_size for the two tessellation
// stages, EXT/OES_geometry_point_size for the geometry one - and they are separate
// extensions that do not imply each other, so the geometry arm is a second code path
// rather than the same one. Nothing else in the tree writes gl_PointSize from a geometry
// shader, so without this case the arm ships untested.
const char* const kPointSizeGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float gs_value;
void main()
{
gs_value = 7.0;
gl_Position = gl_in[0].gl_Position;
gl_PointSize = 4.0;
EmitVertex();
}
)";
// The control: identical but for the gl_PointSize write, so the pair answers "can this
// stack host a geometry stage that names the built-in" without asking anything about
// capture.
const char* const kPointSizeFreeGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float gs_value;
void main()
{
gs_value = 7.0;
gl_Position = gl_in[0].gl_Position;
EmitVertex();
}
)";
std::string TessellationXfbCaptureScenario::WhyGeometryPointSizeCaseCannotRun() {
const auto probeCaptures = [&](const char* geometrySource) {
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_GEOMETRY_SHADER, geometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gs_value"});
if (program == 0) return false;
const std::vector<float> poison(1, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(sizeof(float)), poison.data(),
GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
float captured = kPoison;
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, static_cast<GLsizeiptr>(sizeof(float)),
&captured);
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
DrainErrors();
return captured == 7.0f;
};
if (probeCaptures(kPointSizeGeometrySource)) return {};
if (!probeCaptures(kPointSizeFreeGeometrySource)) {
// The control failed too, so this stack cannot run a capturing geometry stage at
// all - which is not what this case is about, and is the question the dead
// GL_MAX_GEOMETRY_OUTPUT_VERTICES guard was trying to ask. Skipping rather than
// failing loses nothing: XfbRepeatedCaptureScenario pins plain geometry capture
// and goes red on its own if that is what actually broke.
return "this backend cannot capture from a geometry stage at all, with or without gl_PointSize";
}
return "this backend cannot express gl_PointSize in a geometry stage - the same program captures an "
"ordinary varying with the gl_PointSize write removed and captures nothing with it present "
"(an ES driver without GL_EXT/OES_geometry_point_size, which is a SEPARATE extension from the "
"tessellation one, or a Vulkan device without shaderTessellationAndGeometryPointSize)";
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPointSizeByNameFromTheGeometryStage) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyGeometryPointSizeCaseCannotRun(); !reason.empty()) {
GTEST_SKIP() << reason << " (" << Gl().BackendName() << ", " << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_GEOMETRY_SHADER, kPointSizeGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gs_value", "gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
const std::vector<float> poison(2, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(poison.size() * sizeof(float)),
poison.data(), GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> captured(2, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(captured.size() * sizeof(float)), captured.data());
EXPECT_TRUE(ComponentIs(captured, 0, 7.0f)) << "gs_value - an ordinary varying, which is lost too when "
"the stage carrying it fails to compile";
EXPECT_TRUE(ComponentIs(captured, 1, 4.0f)) << "gl_PointSize";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
}
// ---------------------------------------------------------------------------------
// The conformance body's own READBACK, which is not glGetBufferSubData.
// ---------------------------------------------------------------------------------
// Every case above reads the capture back with glGetBufferSubData because that is the
// shortest path to the bytes. The conformance bodies do something else: they respecify
// the buffer through the GENERIC GL_TRANSFORM_FEEDBACK_BUFFER binding with glBufferData
// while it is simultaneously bound to indexed capture point 0, and then read it with
// glMapBufferRange / glUnmapBuffer - twice, once per iteration of the same case, with no
// fresh buffer in between. On a device the tessellation bodies stop at exactly that map
// call, so the sequence itself is worth pinning: none of the map path's error conditions
// may fire, and the mapped bytes must be the captured ones.
TEST_F(TessellationXfbCaptureScenario, MapsTheCaptureBufferAfterEachOfTwoPatchDraws) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "binding the capture point";
constexpr std::size_t kFloats = 4 * 3;
constexpr GLsizeiptr kBytes = static_cast<GLsizeiptr>(kFloats * sizeof(float));
for (int iteration = 0; iteration < 2; ++iteration) {
// Respecified through the generic binding, exactly as the conformance body does,
// while the same buffer is still bound to capture point 0.
const std::vector<float> poison(kFloats, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kBytes, poison.data(), GL_STATIC_DRAW);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glBufferData, iteration " << iteration;
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glBeginTransformFeedback, iteration " << iteration;
glDrawArrays(GL_PATCHES, 0, 1);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glDrawArrays, iteration " << iteration;
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glEndTransformFeedback, iteration " << iteration;
const auto* mapped =
static_cast<const float*>(glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kBytes,
GL_MAP_READ_BIT));
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glMapBufferRange, iteration " << iteration;
ASSERT_NE(mapped, nullptr) << "iteration " << iteration;
const std::vector<float> captured(mapped, mapped + kFloats);
EXPECT_EQ(glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER), GL_TRUE) << "iteration " << iteration;
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "glUnmapBuffer, iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f)) << "iteration " << iteration;
glUseProgram(0);
}
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same patch with gl_PointSize travelling in gl_PerVertex beside gl_Position.
// In ESSL gl_PointSize does not EXIST in a tessellation stage unless
// GL_EXT_tessellation_point_size is requested, so a backend that lowers to ESSL
// without asking for it does not merely lose the value - the stage fails to compile
// and the whole program is replaced by program 0.
TEST_F(TessellationXfbCaptureScenario, TheEvaluationStageSeesGlPointSizeAcrossItsPatch) {
RunPerVertexPayloadCase(true);
}
// ---------------------------------------------------------------------------------
// The same capture through a PROGRAM PIPELINE OBJECT.
// ---------------------------------------------------------------------------------
// The conformance body runs each of its configurations twice: once with a monolithic
// program object and once with a pipeline of four separable programs, the capture
// declared on the separable EVALUATION program. That second shape goes through the
// hidden composite the pipeline object builds for the draw, and it is the only place a
// tessellation capture and the composite meet - so the capture list has to survive being
// taken from a program that is not the one bound.
TEST_F(TessellationXfbCaptureScenario, CapturesFromASeparableEvaluationProgramInAPipelineObject) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
// One separable program per stage. Only the evaluation program carries the capture
// list, because it is the one whose outputs are captured.
const auto buildSeparable = [&](GLenum stage, const char* source,
const std::vector<const char*>& varyings) -> GLuint {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true);
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
glAttachShader(program, shader);
if (!varyings.empty()) {
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
glDeleteShader(shader);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
};
m_buildLog.clear();
const GLuint vertexProgram = buildSeparable(GL_VERTEX_SHADER, kMinimalVertexSource, {});
ASSERT_NE(vertexProgram, 0u) << "separable vertex program: " << m_buildLog;
const GLuint controlProgram = buildSeparable(GL_TESS_CONTROL_SHADER, kMinimalTessControlSource, {});
ASSERT_NE(controlProgram, 0u) << "separable control program: " << m_buildLog;
const GLuint evalProgram =
buildSeparable(GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource, {"gl_Position"});
ASSERT_NE(evalProgram, 0u) << "separable evaluation program: " << m_buildLog;
const GLuint fragmentProgram = buildSeparable(GL_FRAGMENT_SHADER, kFragmentSource, {});
ASSERT_NE(fragmentProgram, 0u) << "separable fragment program: " << m_buildLog;
GLuint pipeline = 0;
glGenProgramPipelines(1, &pipeline);
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vertexProgram);
glUseProgramStages(pipeline, GL_TESS_CONTROL_SHADER_BIT, controlProgram);
glUseProgramStages(pipeline, GL_TESS_EVALUATION_SHADER_BIT, evalProgram);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fragmentProgram);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "assembling the pipeline object";
constexpr std::size_t kFloats = 4 * 3;
const std::vector<float> poison(kFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(kFloats * sizeof(float)),
poison.data(), GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "glBeginTransformFeedback on a pipeline object";
glDrawArrays(GL_PATCHES, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> captured(kFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(kFloats * sizeof(float)), captured.data());
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glBindProgramPipeline(0);
glDeleteProgramPipelines(1, &pipeline);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
}
} // namespace
} // namespace MGITest
@@ -119,6 +119,73 @@ void main() {
imageStore(u_unbound, int(index), uvec4(7u));
g_data[index] = index + 1u;
}
)";
// A plain sampler2D on a unit the test leaves alone. Two cases point at it: a unit with
// nothing bound at all, and a unit whose DEFAULT texture (name 0) has been given a base
// level and no mip chain - GL calls the second one incomplete for the initial
// NEAREST_MIPMAP_LINEAR filter, and both must resolve to the fallback rather than to a
// texture the backend then fails to back.
constexpr const char* kSampler2DFragmentSource = R"(#version 430 core
uniform sampler2D u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texture(u_unbound, vec2(0.0));
}
o_color = color;
}
)";
// The multisample spelling of the same thing. GL_ARB_sample_variables' own conformance
// cases declare a sampler2D and a sampler2DMS side by side and deliberately point the
// unused one at an empty unit, so whichever of the two is unused has to have a
// placeholder - a multisample descriptor demands a multisample view, so the 2D fallback
// cannot stand in for it.
constexpr const char* kSampler2DMSFragmentSource = R"(#version 430 core
uniform sampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texelFetch(u_unbound, ivec2(0), 0);
}
o_color = color;
}
)";
// The integer spellings of the same thing. These are the ones a plain RGBA8 multisample
// placeholder cannot serve: a multisample image can never carry MUTABLE_FORMAT, so the
// reinterpreting view an integer sampler would need over UNORM texels is unbuildable and
// the descriptor resolve used to fail, losing the draw after the placeholder had already
// been created.
constexpr const char* kUsampler2DMSFragmentSource = R"(#version 430 core
uniform usampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kIsampler2DMSFragmentSource = R"(#version 430 core
uniform isampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kImage2DFragmentSource = R"(#version 430 core
@@ -369,6 +436,65 @@ void main() {
ExpectDrawStillRuns(kImage2DFragmentSource, "image2D");
}
// ---- sampler2D / sampler2DMS (VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) ----------------
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D");
}
// The regression this file exists for, in its sharpest form: a sampler pointing at a texture
// unit whose DEFAULT texture object has an image but no mip chain.
//
// DirectVulkan resolved such a binding twice, through two different predicates that
// disagreed. The collect pass (CollectSampledTextures -> ResolveSampledBinding), which
// pre-syncs and transitions every texture the draw will sample, asked only whether the
// default texture was UNDEFINED - texture 0 with an image is not - and kept it. The
// descriptor pass (ResolveSamplerDescriptor) asked the real GL question, whether it
// SAMPLES AS INCOMPLETE for the filter in effect, and swapped it for the fallback. So the
// collect pass synced a texture no descriptor would ever hold, VkTextureManager declined it
// ("mipmap not complete") and returned nullptr, and SetupDraw dereferenced that nullptr -
// a SIGSEGV inside the draw, not a degraded picture.
//
// The GL-CTS reaches this on its own: its between-case state reset gives the default 2D
// texture a base level, so the FIRST case in a process survived and every later one with an
// unbound sampler2D died. That is the whole of the 380-record sample_variables crash family
// on Mali-G1-Ultra. Any application that uploads to texture 0 has the same shape.
TEST_F(UnboundImageDescriptorScenario, ASamplerOnAUnitWhoseDefaultTextureIsIncompleteDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
// Unit 0 is where the sampler's default uniform value points. Give the DEFAULT texture
// object bound there a FORMAT and a zero-sized level - which is what a bare
// glTexImage2D(..., 0, 0, ...) with no data does, and what the GL-CTS's between-case
// state reset issues for every texture target. That combination is the whole point:
// * it is DEFINED, so IsUndefinedDefaultTexture (the collect path's old test) is false
// and the texture stays in the sampled set;
// * it is INCOMPLETE, so SamplesAsIncompleteTexture (the descriptor path's test) is
// true and the descriptor holds the fallback instead;
// * and it has no valid mip level, so the sync declines and hands back nullptr.
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "defining a zero-sized level 0 on the default texture raised a GL error";
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D on an incomplete default texture");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DMSFragmentSource, "sampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundUnsignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kUsampler2DMSFragmentSource, "usampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kIsampler2DMSFragmentSource, "isampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, AFormatlessWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
@@ -0,0 +1,526 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - AN INTER-STAGE INTERFACE BLOCK STILL FINDS ITS OTHER END WITH ITS LOCATION
// QUALIFIER REMOVED.
//
// The Mali-G1-Ultra ES driver delivers NOTHING through an interface block that carries an
// explicit layout(location=) once a tessellation or geometry stage is in the pipeline: the
// stages compile, the program links with an empty info log, the draw runs, and the consuming
// stage reads zeroes. Measured with no MobileGL in the process - a bare EGL/GLES 3.2 program
// built from the five ESSL stages MobileGL emits reproduces it, and removing the qualifier
// from the blocks (and changing nothing else) makes the same program carry its payload. The
// locations are not the application's in the first place: these shaders declare none, and
// glslang's cross-stage IO resolver invents them.
//
// DirectGLES answers by dropping the decoration for those programs (StripIoBlockLocationsPass),
// leaving ES to match the blocks by block name and member sequence. THAT is what this scenario
// guards: with the strip forced on, a five-stage pipeline whose four block boundaries carry no
// location must still deliver its payload end to end. It is the assertion the affected device
// cannot make about itself in CI, and the one the healthy machines here CAN make - which is
// the opposite of IoBlockNameCollisionScenario's position, where the machines that run it
// cannot reproduce the defect at all.
//
// The strip is armed for this suite by MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1 on the ctest
// entry, because llvmpipe carries a located block correctly and the driver POST would
// therefore never turn the emulation on here. The SAME cases also run under the ambient
// registrations with the emulation off, so both spellings of the interface are covered and a
// regression in either shows up.
//
// Colour code, so a failure names its own cause:
// green - the payload crossed all four stage boundaries, which is the pass.
// blue - the clear colour: nothing was drawn at all (the program did not link, or the
// backend program was rejected and every draw became a no-op).
// red - the pipeline ran but the plain (non-block) varying did not arrive, i.e. the
// failure is not about interface blocks.
// black - the pipeline ran, the plain varying arrived, and the BLOCK payload came back
// zeroed. That is what an interface whose two ends stopped matching looks like.
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// NOTHING in these five stages declares a location. Every location the emitted ESSL
// carries is invented by the cross-stage resolver, which is exactly the shape the
// affected driver mishandles and exactly what the strip removes.
//
// Two members per block, of different types, because an interface that is matched by
// name and member sequence rather than by location has to agree on the sequence too -
// a repair that silently reordered or dropped a member would still light up green with
// one member in the block.
const char* const kVertexSource = R"(#version 420 core
out VsData {
vec4 payload;
vec2 tint;
} vs_out;
out float vs_tcs_alive;
void main()
{
vs_out.payload = vec4(0.0, 1.0, 0.0, 1.0);
vs_out.tint = vec2(0.25, 0.5);
vs_tcs_alive = 1.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
in VsData {
vec4 payload;
vec2 tint;
} tcs_in[];
in float vs_tcs_alive[];
out TcsData {
vec4 payload;
vec2 tint;
} tcs_out[];
out float tcs_tes_alive[];
void main()
{
tcs_out[gl_InvocationID].payload = tcs_in[gl_InvocationID].payload;
tcs_out[gl_InvocationID].tint = tcs_in[gl_InvocationID].tint;
tcs_tes_alive[gl_InvocationID] = vs_tcs_alive[gl_InvocationID];
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
)";
// Distinct block names, so this case is about the LOCATION and nothing else; the
// one-name-in-both-directions shape is the case below.
const char* const kDistinctTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
vec2 tint;
} tes_in[];
in float tcs_tes_alive[];
out TesData {
vec4 payload;
vec2 tint;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_out.tint = tes_in[0].tint;
tes_gs_alive = tcs_tes_alive[0];
}
)";
// The 420pack shape: ONE name for the block this stage consumes and the block it
// produces. Legal desktop GLSL, and the case where the two repairs have to compose -
// the rename gives the two blocks one spelling per producing stage, the strip takes
// their locations off, and the interfaces still have to meet.
const char* const kCollidingTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
vec2 tint;
} tes_in[];
in float tcs_tes_alive[];
out TcsData {
vec4 payload;
vec2 tint;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_out.tint = tes_in[0].tint;
tes_gs_alive = tcs_tes_alive[0];
}
)";
// One geometry source per evaluation stage, because the block it consumes is named
// after the block the evaluation stage produced.
const char* const kDistinctGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TesData {
vec4 payload;
vec2 tint;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
vec2 tint;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_out.tint = gs_in[0].tint;
gs_fs_alive = tes_gs_alive[0];
gl_Position = vec4(corner, 0.0, 1.0);
EmitVertex();
}
void main()
{
EmitCorner(vec2(-1.0, -1.0));
EmitCorner(vec2(-1.0, 1.0));
EmitCorner(vec2( 1.0, -1.0));
EmitCorner(vec2( 1.0, 1.0));
}
)";
const char* const kCollidingGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TcsData {
vec4 payload;
vec2 tint;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
vec2 tint;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_out.tint = gs_in[0].tint;
gs_fs_alive = tes_gs_alive[0];
gl_Position = vec4(corner, 0.0, 1.0);
EmitVertex();
}
void main()
{
EmitCorner(vec2(-1.0, -1.0));
EmitCorner(vec2(-1.0, 1.0));
EmitCorner(vec2( 1.0, -1.0));
EmitCorner(vec2( 1.0, 1.0));
}
)";
// Green ONLY when both block members arrived: a repair that kept the first member and
// lost the second would otherwise pass. Red when the plain varying is missing too, so
// "the pipeline is broken" and "the block is broken" cannot be confused.
const char* const kFragmentSource = R"(#version 420 core
in GsData {
vec4 payload;
vec2 tint;
} fs_in;
in float gs_fs_alive;
out vec4 fragColor;
void main()
{
if (gs_fs_alive <= 0.5) {
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
} else if (abs(fs_in.tint.x - 0.25) > 0.01 || abs(fs_in.tint.y - 0.5) > 0.01) {
fragColor = vec4(0.0, 0.0, 0.0, 1.0);
} else {
fragColor = fs_in.payload;
}
}
)";
class UnlocatedIoBlockScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
if (!BackendHostsTessellationAndGeometry()) {
GTEST_SKIP() << "no tessellation/geometry stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "); there is no five-stage pipeline to "
<< "carry a block through";
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
// Same calibration IoBlockNameCollisionScenario uses, and for the same reason:
// GL_MAX_TESS_GEN_LEVEL is a real backend answer while GL_MAX_GEOMETRY_* are
// frontend constants, so a stack with no five-stage pipeline is recognised by
// trying to build one, not by asking.
static bool BackendHostsTessellationAndGeometry() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
while (glGetError() != GL_NO_ERROR) {
}
return maxTessGenLevel >= 1 && maxGeometryOutputVertices >= 4;
}
GLuint BuildPipeline(const char* tessEvalSource, const char* geometrySource) {
const GLenum stages[] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER,
GL_FRAGMENT_SHADER};
const char* const sources[] = {kVertexSource, kTessControlSource, tessEvalSource,
geometrySource, kFragmentSource};
GLuint shaders[5] = {0, 0, 0, 0, 0};
bool ok = true;
for (int i = 0; i < 5; ++i) {
shaders[i] = glCreateShader(stages[i]);
glShaderSource(shaders[i], 1, &sources[i], nullptr);
glCompileShader(shaders[i]);
GLint compiled = 0;
glGetShaderiv(shaders[i], GL_COMPILE_STATUS, &compiled);
if (!compiled) {
m_buildLog = InfoLog(shaders[i], true);
ok = false;
break;
}
}
if (!ok) {
for (const GLuint shader : shaders) {
if (shader != 0) glDeleteShader(shader);
}
return 0;
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (!linked) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
// Clears to BLUE, so "the draw painted nothing" is a colour of its own rather
// than something that could be mistaken for a zeroed payload.
Rgba8 DrawAndReadCentre(GLuint program) const {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
glDrawArrays(GL_PATCHES, 0, 1);
Rgba8 pixel{};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &pixel);
return pixel;
}
static bool IsGreen(const Rgba8& pixel) {
return pixel.r < 64 && pixel.g > 192 && pixel.b < 64;
}
const std::string& BuildLog() const { return m_buildLog; }
// The library log this process is writing, or an empty path when none was
// configured. MOBILEGL_LOG_FILE_PATH is read at log-init, before anything this
// fixture can reach, so the ctest entry sets it and this only reads it back.
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
// How many bytes the library log already holds. Everything this fixture asserts on
// is searched from here forward, because the file is APPENDED to by every process
// in the lane and a line left behind by an earlier one would otherwise satisfy the
// assertion without this process having done anything at all.
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
static GLenum FirstGLError() {
const GLenum first = glGetError();
while (glGetError() != GL_NO_ERROR) {
}
return first;
}
private:
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> log(static_cast<std::size_t>(length > 1 ? length : 1), '\0');
if (isShader) {
glGetShaderInfoLog(object, static_cast<GLsizei>(log.size()), nullptr, log.data());
} else {
glGetProgramInfoLog(object, static_cast<GLsizei>(log.size()), nullptr, log.data());
}
return std::string(log.data());
}
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
TEST_F(UnlocatedIoBlockScenario, BlocksCarryTheirPayloadThroughFiveStages) {
if (!Ready()) return;
const GLuint program = BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource);
if (program == 0) {
GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on "
<< Gl().BackendName() << ", so there is no block to carry through: "
<< BuildLog();
}
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre))
<< "a four-boundary interface-block chain did not deliver its payload: " << centre
<< " (blue: nothing drew; red: the plain varying was lost too; black: a block "
"member arrived wrong, i.e. the interface stopped matching)";
}
// The two repairs together. The rename is what makes the evaluation stage's two
// TcsData blocks one spelling per producing stage; the strip then takes the locations
// off the names the rename just settled. Either one alone leaves a working program on
// these machines, so this case is here to catch the two of them disagreeing.
TEST_F(UnlocatedIoBlockScenario, BlocksNamedInBothDirectionsStillMeetWithoutLocations) {
if (!Ready()) return;
if (BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource) == 0) {
GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on "
<< Gl().BackendName() << ", so there is no block to carry through: "
<< BuildLog();
}
const GLuint program = BuildPipeline(kCollidingTessEvalSource, kCollidingGeometrySource);
ASSERT_NE(program, 0u)
<< "an interface block name reused across the two directions of one stage is legal "
"desktop GLSL, but the program did not build: "
<< BuildLog();
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre))
<< "the renamed-and-unlocated interface chain lost its payload: " << centre;
}
// THE ONE CASE THAT CAN FAIL WHEN THE REPAIR SILENTLY STOPS BEING ARMED.
//
// Everything above renders green on llvmpipe whether the blocks were stripped or not -
// this machine carries a located block correctly - so those cases pin that the strip
// does no HARM and can say nothing about whether it happened. That leaves the arming
// itself untested, and the arming is where the cheap mistake lives: Loader.cpp maps
// MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS onto the capability INVERTED (forcing the
// emulation on means declaring located blocks UNSUPPORTED), and a one-line swap of
// those two arms would disable the device repair with every test here still green.
//
// So this case asserts a LIBRARY OBSERVABLE against the environment, the shape
// AsyncCompileScenario::ExtensionStringMatchesTheConfiguration uses: the environment
// says the emulation is pinned on, therefore the library must SAY it stripped
// something. The observable is the latched MGLOG_I DirectGLES emits the first time the
// pass fires (Managers.cpp); it is INFO rather than DEBUG precisely so that this
// assertion is possible in the builds CI runs.
//
// Two things it deliberately does NOT do: it does not read MG_Config (on Android this
// module links the shipping library, which exports nothing internal - the reason
// ViewportArrayScenario's control moved to the environment), and it does not trust the
// whole log file, only the bytes appended after this test started.
TEST_F(UnlocatedIoBlockScenario, TheEmulationIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the emulation pinned ON for the whole process, which "
"is what the UnlocatedIoBlocks. ctest entry does with "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1; with the variable unset the "
"driver POST decides, and on this machine it decides the blocks are "
"fine - so there would be nothing to observe";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned on but "
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
"record that it stripped anything; the UnlocatedIoBlocks. ctest entry "
"sets both";
}
if (Gl().BackendName() != std::string("DirectGLES")) {
GTEST_SKIP() << "the strip is DirectGLES's; " << Gl().BackendName()
<< " hands the module to the driver as SPIR-V, where Location is how "
"interfaces are matched";
}
// Taken BEFORE the program is built, so the line this looks for can only be one
// this process wrote. The latch means it is emitted at the FIRST stage of the
// FIRST affected program, which is inside the build below.
const std::uintmax_t before = LibraryLogSize();
const GLuint program = BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource);
if (program == 0) {
GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on "
<< Gl().BackendName() << ", so nothing would arm the strip: " << BuildLog();
}
// Drawn as well as built, so a stack that defers its backend program to first use
// still reaches the transpile this is asserting about.
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre)) << "the pinned-on lane did not even render correctly: " << centre;
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("WITHOUT their layout(location) qualifier"), std::string::npos)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned ON, a five-stage program with four "
"interface-block boundaries was built and drawn, and DirectGLES never reported "
"stripping a single location. The emulation is not armed - check the override "
"mapping in Loader.cpp (it is inverted on purpose) and the arming gate in "
"Managers.cpp. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,276 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnwrittenPositionOutputScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A SHADER REDECLARES gl_PerVertex AND NEVER WRITES gl_Position.
//
// Legal, ordinary GLSL, and until now a process kill on DirectVulkan. The chain, all of it
// inside MobileGL's own SPIR-V plumbing:
//
// 1. glslang emits every DECLARED interface variable, used or not, and lists it on
// OpEntryPoint. So `out gl_PerVertex { vec4 gl_Position; };` with no write still produces
// the OpVariable, the OpMemberDecorate BuiltIn Position, and an interface slot.
// 2. At link, ShaderCompiler::SanitizeAndOptimizeBinary runs AggressiveDCE(remove_outputs =
// false) - which may never delete an Output - and then RemoveUnusedInterfaceVariables,
// which rebuilds the interface list from the variables instructions actually reference.
// The OpVariable and its BuiltIn decoration SURVIVE; the interface slot is DELISTED.
// 3. At pipeline build, ProgramFactory picks the last pre-rasterisation stage and runs two
// passes over it. GlToVulkanPositionFixPass finds the position target through the
// surviving ANNOTATION and injects a load-modify-STORE through it. When gl_Position is in
// the transform-feedback capture list, XfbCaptureDecoratePass::MirrorPositionForCapture
// also injects an access chain and a LOAD through it.
// 4. Either injection is a static use of a variable that is no longer on the entry point's
// interface, which is invalid SPIR-V ("Interface variable id <N> is used by entry point
// 'main' id <M>, but is not listed as an interface"). Mali r54 does not reject such a
// module - it faults inside pipeline creation and takes the process down.
//
// Measured on a Mali-G1-Ultra as 216 KHR-GL44/45/46.tessellation_shader.tessellation_control_
// to_tessellation_evaluation.gl_MaxPatchVertices_Position_PointSize_* crashes; the CTS's TES
// there is exactly the shape below. It is not tessellation-specific and not XFB-specific: a
// vertex shader is enough, which is what these cases use.
//
// Every test captures a USER varying through transform feedback under GL_RASTERIZER_DISCARD.
// Position is undefined in the first two by construction, so it is never asserted on - what is
// asserted is that the capture came back at all, which it can only do if the driver accepted
// the module and built a pipeline.
#include <cstddef>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr std::size_t kCaptureFloats = 4;
constexpr GLsizeiptr kCaptureBytes = static_cast<GLsizeiptr>(kCaptureFloats * sizeof(float));
// The defect's shape: gl_PerVertex redeclared, gl_Position never assigned.
constexpr const char* kUnwrittenPositionVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out gl_PerVertex {
vec4 gl_Position;
};
out vec4 vs_out_value;
void main() {
vs_out_value = vs_in_value;
}
)";
// The control that isolates the redeclaration: identical but for the one assignment.
// This one keeps its interface slot through the sanitize chain, so both injections were
// always legal on it - it must stay working.
constexpr const char* kWrittenPositionVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out gl_PerVertex {
vec4 gl_Position;
};
out vec4 vs_out_value;
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
vs_out_value = vs_in_value;
}
)";
// The second control, and the one the CTS calls data_pass_through: no gl_PerVertex
// redeclaration at all, so there is no Position annotation for the passes to find and
// nothing to delist. It was never affected and proves the crash needs the redeclaration.
constexpr const char* kNoPositionBlockVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out vec4 vs_out_value;
void main() {
vs_out_value = vs_in_value;
}
)";
GLuint CompileVertexShader(const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(GL_VERTEX_SHADER);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// `captureNames` is what goes to glTransformFeedbackVaryings. Passing gl_Position in it
// is what puts MirrorPositionForCapture on the path.
GLuint BuildCaptureProgram(const char* vertexSource, const std::vector<const char*>& captureNames,
std::string* log) {
const GLuint vertexShader = CompileVertexShader(vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glTransformFeedbackVaryings(program, static_cast<GLsizei>(captureNames.size()), captureNames.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
glDeleteShader(vertexShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
class UnwrittenPositionOutputScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
const float vertex[kCaptureFloats] = {1.0f, 2.0f, 3.0f, 4.0f};
glBufferData(GL_ARRAY_BUFFER, kCaptureBytes, vertex, GL_STATIC_DRAW);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glUseProgram(0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
ScenarioTest::TearDown();
}
// Links `vertexSource` with `captureNames`, runs one captured point, and checks that
// the USER varying came back. `captureStride` is how many floats one captured vertex
// occupies, so the user varying can be read out from behind a captured gl_Position.
void ExpectUserVaryingIsCaptured(const char* vertexSource, const std::vector<const char*>& captureNames,
std::size_t captureStride, std::size_t userVaryingOffset,
const char* what) {
std::string log;
const GLuint program = BuildCaptureProgram(vertexSource, captureNames, &log);
ASSERT_NE(program, 0u) << what << ": the capture program failed to build: " << log;
const GLsizeiptr captureBytes = static_cast<GLsizeiptr>(captureStride * sizeof(float));
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffer);
// Pre-fill with a value the shader cannot produce, so "captured nothing" is
// distinguishable from "captured the wrong thing".
const std::vector<float> poison(captureStride, -1.0f);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, captureBytes, poison.data(), GL_DYNAMIC_READ);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
ASSERT_EQ(FirstGLError(), 0u) << what << ": setting up the capture buffer raised a GL error";
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBindVertexArray(m_vao);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glBindVertexArray(0);
glUseProgram(0);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << what << ": the captured draw raised a GL error";
std::vector<float> readback(captureStride, -2.0f);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffer);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, readback.data());
for (std::size_t i = 0; i < kCaptureFloats; ++i) {
EXPECT_FLOAT_EQ(readback[userVaryingOffset + i], static_cast<float>(i + 1))
<< what << ": captured float " << i << " came back as "
<< readback[userVaryingOffset + i]
<< "; the pre-fill value means the draw never produced a vertex, which is what an "
"invalid shader module looks like from out here";
}
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteBuffers(1, &xfbBuffer);
glDeleteProgram(program);
}
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
} // namespace
// The clip fixup's half: PositionZRemap is on for every draw, so the fixup runs on this
// program and used to inject a store through the delisted block.
TEST_F(UnwrittenPositionOutputScenario, ARedeclaredButUnwrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kUnwrittenPositionVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"redeclared, never written");
}
// The XFB half: capturing gl_Position adds an access chain and a LOAD through the same
// delisted block, which the interface rule covers exactly as it covers the store. Position
// itself is undefined here - only the user varying behind it is asserted.
TEST_F(UnwrittenPositionOutputScenario, CapturingAnUnwrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
// DirectVulkan only, and not because the defect was backend-specific in principle - the
// injection this pins lives in DirectVulkan's ProgramFactory, and DirectGLES cannot
// reach the case at all: capturing gl_Position BY NAME off a shader that never writes it
// comes back empty there, because the ESSL the transpiler emits has no such output for
// the capture list to name. That is a known, separate DirectGLES gap (the same one that
// blocks gl_Position/gl_PointSize capture in the tessellation capture segment), tracked
// outside this scenario; asserting it here would only re-report it.
if (Gl().BackendName() != "DirectVulkan") {
GTEST_SKIP() << "capturing an unwritten gl_Position by name is a separate, known "
<< "DirectGLES gap; this case pins the DirectVulkan injection";
}
ExpectUserVaryingIsCaptured(kUnwrittenPositionVertexSource, {"gl_Position", "vs_out_value"},
kCaptureFloats * 2, kCaptureFloats, "capturing an unwritten gl_Position");
}
// Control: the same shader with the one assignment restored. Its block is never delisted,
// so it exercises the path the fixup is actually for and must keep working.
TEST_F(UnwrittenPositionOutputScenario, AWrittenRedeclaredPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kWrittenPositionVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"redeclared and written");
}
TEST_F(UnwrittenPositionOutputScenario, CapturingAWrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kWrittenPositionVertexSource, {"gl_Position", "vs_out_value"},
kCaptureFloats * 2, kCaptureFloats, "capturing a written gl_Position");
}
// Control: no gl_PerVertex redeclaration, so no Position annotation and nothing to delist.
TEST_F(UnwrittenPositionOutputScenario, AShaderWithNoPositionBlockStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kNoPositionBlockVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"no gl_PerVertex block");
}
} // namespace MGITest
@@ -525,7 +525,7 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
//
// Everything above is a claim about pixels, and a claim about pixels cannot tell an
// emulation that works from a backend that was going to be right anyway. This case builds
// the SAME program in a process started with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0
// the SAME program in a process started with MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0
// (the NoViewportArrayEmulation. ctest entry) and requires case 1's
// result to COLLAPSE: with no routing, every geometry invocation rasterizes against
// viewport 0's rectangle, so the last invocation paints the whole surface and every cell
@@ -551,10 +551,10 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
// entry for it, so the control still runs in every ctest run; anywhere else - the
// ambient ctest entries, or the binary run straight from a device shell - the
// emulation is on and this case skips.
if (AmbientQuirkFromEnvironment("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION") != AmbientQuirk::Off) {
if (AmbientQuirkFromEnvironment("MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION") != AmbientQuirk::Off) {
GTEST_SKIP() << "this is the negative control for the emulation and needs it off for the "
"whole process; the NoViewportArrayEmulation. ctest entry runs it with "
"MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0";
"MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0";
}
IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide);
@@ -0,0 +1,657 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbRepeatedCaptureScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A CAPTURE MUST STILL RECORD WHEN IT IS NOT THE FIRST ONE IN THE PROCESS,
// AND THE CAPTURE STAGE MAY BE ANY OF THE FOUR THAT CAN BE THE LAST ONE.
//
// The conformance suite exposed a whole family of transform feedback failures that no
// existing scenario could reproduce, because every one of them ran ONE capture, from a
// VERTEX stage, in a freshly initialised process. What the suite actually does is
// different in three ways at once, and each of them turned out to matter:
//
// * it runs case after case in ONE GL context, resetting state between them - and the
// reset is not a fresh context. Its transform feedback part
// (framework/opengl/gluStateReset.cpp resetStateGLCore) unbinds the generic
// GL_TRANSFORM_FEEDBACK_BUFFER and then clears every indexed capture point from 0 to
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS, which permanently raises MobileGL's
// touched-binding-point high-water mark. Every later capture that uses fewer points
// than that - i.e. every INTERLEAVED_ATTRIBS capture - then had the unused tail
// re-cleared on the driver immediately before glBeginTransformFeedback.
// ReplayDeqpStateReset below is that reset, reduced to the calls that touch capture
// state, so a defect that only appears from the second capture onwards is reachable
// here instead of only on a device.
//
// * the capture stage is frequently a GEOMETRY or a TESSELLATION EVALUATION shader,
// never a plain vertex shader. The tree had zero coverage for either: none of the
// Xfb* scenarios mentioned tessellation and neither TessellationDrawModeScenario nor
// GeometryDrawModeScenario mentioned transform feedback.
//
// * the capture program frequently has NO FRAGMENT STAGE at all, because it draws
// under GL_RASTERIZER_DISCARD and never rasterises anything. That is legal in
// desktop GL and the shape most "use transform feedback as a readback channel"
// tests are built on.
//
// Every case here asserts the captured BYTES, never just the absence of a GL error: the
// failure this guards against writes nothing and raises nothing, so a buffer that kept
// its poison is the only thing that distinguishes it from success.
#include <cmath>
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Nothing a capture can legitimately produce, so a component that still reads it
// names the failure ("the capture never reached these bytes") instead of looking
// like an ordinary numeric mismatch.
constexpr int kPoison = -987654;
const char* const kPassthroughVertexSource = R"(#version 420 core
layout(location = 0) in int vs_in_value;
flat out int vs_out_value;
void main()
{
vs_out_value = vs_in_value;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// The primitive_counter shape: one flat int per emitted vertex, several vertices
// per input primitive, so the capture is geometry-AMPLIFIED and the CPU-side
// primitive model cannot predict its length.
const char* const kPointAmplifyingGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 2) out;
flat in int vs_out_value[];
flat out int gs_out_value;
void main()
{
for (int i = 0; i < 2; ++i)
{
gs_out_value = vs_out_value[0];
gl_Position = gl_in[0].gl_Position;
EmitVertex();
EndPrimitive();
}
}
)";
// Adjacency input. Only a geometry stage can consume it, and CountPrimitivesForDraw
// used to answer 0 for every adjacency mode, which silently excluded the whole draw
// from the capture accounting.
const char* const kAdjacencyGeometrySource = R"(#version 420 core
layout(lines_adjacency) in;
layout(points, max_vertices = 1) out;
flat in int vs_out_value[];
flat out int gs_out_value;
void main()
{
gs_out_value = vs_out_value[1];
gl_Position = gl_in[1].gl_Position;
EmitVertex();
EndPrimitive();
}
)";
const char* const kTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
flat in int vs_out_value[];
patch out int tcs_out_value;
void main()
{
tcs_out_value = vs_out_value[0];
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw) in;
patch in int tcs_out_value;
flat out int tes_out_value;
void main()
{
tes_out_value = tcs_out_value;
gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kFragmentSource = R"(#version 420 core
flat in int gs_out_value;
out vec4 fragColor;
void main()
{
fragColor = vec4(float(gs_out_value), 0.0, 0.0, 1.0);
}
)";
class XfbRepeatedCaptureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
const int values[kInputVertices] = {10, 11, 12, 13};
glBufferData(GL_ARRAY_BUFFER, sizeof(values), values, GL_STATIC_DRAW);
glVertexAttribIPointer(0, 1, GL_INT, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vbo = 0;
m_vao = 0;
ScenarioTest::TearDown();
}
static constexpr int kInputVertices = 4;
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 2;
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
// The transform-feedback-relevant half of deqp's resetStateGLCore, in its order.
// It runs between EVERY pair of conformance cases, and running one capture
// through it is the difference between "the first capture in the process" and
// every other one.
static void ReplayDeqpStateReset() {
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
GLint maxSeparateAttribs = 0;
glGetIntegerv(GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS, &maxSeparateAttribs);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
for (GLint index = 0; index < maxSeparateAttribs; ++index) {
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(index), 0);
}
DrainErrors();
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const char* varying) {
return BuildCaptureProgram(stages, std::vector<const char*>{varying});
}
// Builds a capture program out of `stages` capturing `varyings` interleaved.
// Returns 0 and fills m_buildLog on failure.
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true);
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span. `captureMode` is the transform feedback primitive mode,
// `drawMode`/`count` the draw. Returns the capture buffer's contents.
std::vector<int> RunCaptureSpan(GLuint program, GLenum captureMode, GLenum drawMode, GLsizei count,
std::size_t capturedInts) {
std::vector<int> poison(capturedInts, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedInts * sizeof(int)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// The capture point is the ONLY thing bound; the generic
// GL_TRANSFORM_FEEDBACK_BUFFER binding comes along for the ride, exactly as
// the conformance tests rely on (GL 4.6 core 6.1.1).
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(captureMode);
glDrawArrays(drawMode, 0, count);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<int> readback(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedInts * sizeof(int)), readback.data());
glUseProgram(0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult CapturedNothing(const std::vector<int>& data) {
for (std::size_t i = 0; i < data.size(); ++i) {
if (data[i] != kPoison) {
return ::testing::AssertionFailure() << "component " << i << " is " << data[i];
}
}
return ::testing::AssertionSuccess();
}
static ::testing::AssertionResult CapturedIs(const std::vector<int>& data,
const std::vector<int>& expected) {
if (data.size() != expected.size()) {
return ::testing::AssertionFailure()
<< "captured " << data.size() << " value(s), expected " << expected.size();
}
for (std::size_t i = 0; i < data.size(); ++i) {
if (data[i] != expected[i]) {
::testing::AssertionResult failure = ::testing::AssertionFailure();
failure << "component " << i << " is " << data[i] << ", expected " << expected[i];
if (data[i] == kPoison) {
failure << " (the capture never reached these bytes)";
}
return failure;
}
}
return ::testing::AssertionSuccess();
}
std::vector<GLuint> m_programs;
std::string m_buildLog;
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
// THE REGRESSION GUARD FOR THE WHOLE FAMILY. Two geometry-stage captures in one
// process with the conformance suite's own state reset between them; the assertion
// that matters is on the SECOND one, which is the one every device run failed while
// whichever body happened to land first in its process passed.
TEST_F(XfbRepeatedCaptureScenario, ASecondGeometryCaptureAfterADeqpStateResetStillRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
// Two vertices emitted per input point, so the capture is amplified beyond what
// the CPU primitive model can predict from the draw alone.
const std::vector<int> expected = {10, 10, 11, 11, 12, 12, 13, 13};
for (int capture = 0; capture < 3; ++capture) {
// A fresh program per capture, because that is what a fresh conformance case
// builds - and it is what makes the driver recycle program and buffer names.
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "capture " << capture << " program failed to build: " << m_buildLog;
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_POINTS, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected))
<< "capture " << capture << " of 3 in this process"
<< (capture == 0 ? "" : " (every earlier one was followed by a deqp-shaped state reset)");
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "capture " << capture;
glDeleteProgram(program);
m_programs.pop_back();
ReplayDeqpStateReset();
glBindVertexArray(m_vao);
}
}
// The tessellation half, which had no coverage anywhere in the tree: a capture taken
// from a GL_PATCHES draw, whose last vertex-processing stage is the evaluation shader
// and whose record count only the tessellator knows.
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAPatchesDrawRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
// One input patch of one vertex, all levels at 1: the tessellator emits exactly
// one triangle, so three captured vertices all carrying the first input value.
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource}},
"tes_out_value");
ASSERT_NE(program, 0u) << "patch capture program failed to build: " << m_buildLog;
const std::vector<int> expected = {10, 10, 10};
const std::vector<int> captured = RunCaptureSpan(program, GL_TRIANGLES, GL_PATCHES, 1, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// A capture program with NO FRAGMENT STAGE, drawn under GL_RASTERIZER_DISCARD. Legal
// in desktop GL, and the shape most transform-feedback-as-readback tests use; the
// program above only differs from it by the fragment shader, so a failure here is
// specifically about the missing stage.
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAFragmentlessProgramRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "fragmentless capture program failed to build: " << m_buildLog;
const std::vector<int> expected = {10, 10, 11, 11, 12, 12, 13, 13};
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_POINTS, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// An ADJACENCY draw feeding the capture. CountPrimitivesForDraw answered 0 for all
// four adjacency modes, which made the transform feedback accounting skip the draw
// entirely - so neither the captured-vertex counter nor the geometry-capture-draw
// flag moved, and anything downstream of either was working from "nothing happened".
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAnAdjacencyDrawRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kAdjacencyGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "adjacency capture program failed to build: " << m_buildLog;
// Four vertices of GL_LINES_ADJACENCY are one line primitive; the shader emits
// the second vertex of the four, which is the line's first real endpoint.
const std::vector<int> expected = {11};
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_LINES_ADJACENCY, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// An adjacency draw with NO geometry stage. GL 4.6 core table 13.1 admits
// GL_LINES_ADJACENCY and GL_LINE_STRIP_ADJACENCY under capture mode GL_LINES (and the
// triangle pair under GL_TRIANGLES): without a geometry shader the adjacent vertices
// are ignored and the primitive assembled is a plain line, so the combination is legal
// and must capture. MobileGL's active-capture primitive-mode table listed only the
// non-adjacency modes, so this raised GL_INVALID_OPERATION and dropped the draw
// entirely - the buffer kept its pre-draw bytes and the application saw an error the
// spec does not allow. Distinct from ACaptureFromAnAdjacencyDrawRecords above, which
// HAS a geometry stage and therefore bypasses that table completely.
TEST_F(XfbRepeatedCaptureScenario, AVertexOnlyAdjacencyCaptureRecords) {
if (!Ready()) GTEST_SKIP();
const GLuint program =
BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}}, "vs_out_value");
ASSERT_NE(program, 0u) << "vertex-only capture program failed to build: " << m_buildLog;
// Four vertices of GL_LINES_ADJACENCY are one line whose real endpoints are the
// middle pair, so the capture is those two vertices in order.
const std::vector<int> expected = {11, 12};
const std::vector<int> captured =
RunCaptureSpan(program, GL_LINES, GL_LINES_ADJACENCY, kInputVertices, expected.size());
// THE GUARD FOR THE DEFECT ITSELF, and it is backend-independent: the frontend
// validator must not reject the combination. It used to record
// GL_INVALID_OPERATION and return before the draw was ever issued.
EXPECT_EQ(glGetError(), GL_NO_ERROR)
<< "a capture-mode/draw-mode pair GL 4.6 core table 13.1 admits must raise no error";
// Whether the capture then RECORDS is a backend question, and the two answer it
// differently. ES 3.2 (10.1) supports the adjacency primitive types only for a
// pipeline with a geometry shader, so DirectGLES has nothing to forward this draw
// to; desktop GL and Vulkan both assemble the plain line and capture it. Asserting
// the data unconditionally would be asserting that DirectGLES emulates a whole ES
// restriction away, which is a separate piece of work and not what this guards.
if (Gl().BackendName() == "DirectGLES") {
GTEST_SKIP() << "DirectGLES cannot forward a geometry-shader-less adjacency draw: ES 3.2 10.1 "
"supports the adjacency primitive types only with a geometry stage. The frontend "
"no longer rejects the draw (checked above), which is the defect this covers.";
}
EXPECT_TRUE(CapturedIs(captured, expected));
}
// A CAPTURE MUST NEVER LAND IN A BUFFER THE APPLICATION DID NOT BIND FOR IT.
//
// A capture list may legally begin with gl_NextBuffer, which leaves capture buffer 0
// with stride 0 and nothing to capture - so glBeginTransformFeedback does not require a
// buffer at point 0 and the application binds only point 1. The driver-side program is
// a single-buffer interleaved capture (the pseudo-varyings are consumed at link time),
// so it writes capture point 0, and MobileGL redirects that into scratch storage and
// scatters the records afterwards.
//
// Two ways that went wrong, both fixed here: the scratch was sized by reading each
// target's stride at its POSITION in a list that skips unbound buffers, which for this
// layout read stride 0 for everything and produced a zero capacity; and when the
// scratch then failed to bind, the span opened anyway onto whatever capture point 0
// still held from an earlier capture in the process - silently overwriting an unrelated
// application buffer. The first span below exists purely to leave such a binding behind.
TEST_F(XfbRepeatedCaptureScenario, ACaptureListBeginningWithGlNextBufferSparesTheEarlierBuffer) {
if (!Ready()) GTEST_SKIP();
const std::size_t capturedInts = 4;
const GLsizeiptr captureBytes = static_cast<GLsizeiptr>(capturedInts * sizeof(int));
// Span A: an ordinary capture, so capture point 0 is left holding bufferA.
const GLuint programA =
BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}}, "vs_out_value");
ASSERT_NE(programA, 0u) << "plain capture program failed to build: " << m_buildLog;
std::vector<int> poison(capturedInts, kPoison);
GLuint bufferA = 0;
glGenBuffers(1, &bufferA);
glBindBuffer(GL_ARRAY_BUFFER, bufferA);
glBufferData(GL_ARRAY_BUFFER, captureBytes, poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, bufferA);
glBindVertexArray(m_vao);
glUseProgram(programA);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, kInputVertices);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
std::vector<int> afterA(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, afterA.data());
const std::vector<int> spanAExpected = {10, 11, 12, 13};
ASSERT_TRUE(CapturedIs(afterA, spanAExpected)) << "the setup span itself did not capture";
// Span B: gl_NextBuffer first, so buffer 0 captures nothing and only point 1 is bound.
const GLuint programB = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}},
{"gl_NextBuffer", "vs_out_value"});
if (programB == 0) {
GTEST_SKIP() << "gl_NextBuffer capture lists are not linkable on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "): " << m_buildLog;
}
GLuint bufferB = 0;
glGenBuffers(1, &bufferB);
glBindBuffer(GL_ARRAY_BUFFER, bufferB);
glBufferData(GL_ARRAY_BUFFER, captureBytes, poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// Point 0 released, point 1 is the only destination this capture asks for.
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, bufferB);
glUseProgram(programB);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, kInputVertices);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
// THE ASSERTION THAT MATTERS: bufferA was not a destination of this capture, so it
// must still read exactly what span A left in it. A failure here is the corruption.
std::vector<int> bufferAAfterB(capturedInts, 0);
glBindBuffer(GL_ARRAY_BUFFER, bufferA);
glGetBufferSubData(GL_ARRAY_BUFFER, 0, captureBytes, bufferAAfterB.data());
glBindBuffer(GL_ARRAY_BUFFER, 0);
EXPECT_TRUE(CapturedIs(bufferAAfterB, spanAExpected))
<< "the gl_NextBuffer capture wrote into the buffer the PREVIOUS span had bound";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
// ...and, where the backend places this layout at all, the buffer it WAS asked to
// write gets the records. That placement is the DirectGLES scatter path, whose
// scratch sizing used to read each target's stride at its POSITION in a list that
// skips unbound capture buffers - which for a leading gl_NextBuffer read stride 0
// for every target and sized the scratch at zero. DirectVulkan does not implement a
// leading-gl_NextBuffer layout at all (it captures nothing into bufferB); that is a
// pre-existing gap of its own, and the assertion above - that it corrupts nothing
// while declining - is what matters for it.
const bool backendPlacesLeadingNextBuffer = Gl().BackendName() != "DirectVulkan";
if (backendPlacesLeadingNextBuffer) {
std::vector<int> bufferBAfter(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, bufferBAfter.data());
EXPECT_TRUE(CapturedIs(bufferBAfter, spanAExpected));
}
// Unbound and deleted BEFORE any skip: a capture point left pointing at a buffer
// this test deleted would follow the process into the next scenario.
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, 0);
glDeleteBuffers(1, &bufferA);
glDeleteBuffers(1, &bufferB);
if (!backendPlacesLeadingNextBuffer) {
GTEST_SKIP() << "DirectVulkan does not place a capture list beginning with gl_NextBuffer; it "
"captures nothing, which the no-corruption assertion above has already covered.";
}
}
// The control for all of the above: a span that never draws must leave the capture
// buffer alone. Without it "the buffer kept its poison" could be read as the correct
// outcome of some path rather than as the bug, and the tightened early returns in
// StartPendingTransformFeedback have to keep this legal case legal.
TEST_F(XfbRepeatedCaptureScenario, ASpanThatNeverDrawsLeavesTheCaptureBufferAlone) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "capture program failed to build: " << m_buildLog;
const std::size_t capturedInts = 8;
std::vector<int> poison(capturedInts, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedInts * sizeof(int)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
glEndTransformFeedback();
glUseProgram(0);
std::vector<int> readback(capturedInts, 0);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedInts * sizeof(int)), readback.data());
EXPECT_TRUE(CapturedNothing(readback));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glDeleteBuffers(1, &xfbBuffer);
}
} // namespace
} // namespace MGITest
@@ -8,6 +8,8 @@
#include "BufferObject.h"
#include <Config.h>
#include <atomic>
namespace MobileGL::MG_State::GLState {
@@ -65,6 +67,14 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::NotifyContentWrite(SizeT offset, SizeT size) {
if (size == 0) {
// An empty write moves the serial and nothing else, exactly as NotifySubData
// and NotifyFlushMappedRange do: it wrote no byte, so it must not promote an
// undefined store to "has content" - that would cost the next orphaning
// respecification a full-size upload of bytes the application never wrote.
++m_changeSerial;
return;
}
m_hasDefinedContent = true;
if (m_resource.IsGpuResident()) {
// The write already landed in coherent GPU memory; the backend has no separate
@@ -109,7 +119,10 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
// The store a live mapping wrote into is about to be replaced, so landing those
// bytes into it would copy a whole mapped range (an adopted arena's map is the
// arena) into storage the next line hands back.
ReleaseMemory(false);
RedefineStorage(size);
if (data && size > 0) {
Memcpy(m_resource.Bytes(), data, size);
@@ -126,6 +139,7 @@ namespace MobileGL::MG_State::GLState {
// distinguishes the two cases, and it is cleared just above.
m_storageFlags = GL_DYNAMIC_STORAGE_BIT | GL_MAP_READ_BIT | GL_MAP_WRITE_BIT;
NotifyRespecify();
TryAdoptLargeStorage();
}
void BufferObject::Resize(SizeT size) {
@@ -133,7 +147,9 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::AllocateImmutableStorage(SizeT size, const void* data, GLbitfield storageFlags) {
ReleaseMemory();
// Same as Respecify: the bytes a live mapping staged have nowhere to land, the
// store they belong to is being replaced.
ReleaseMemory(false);
RedefineStorage(size);
if (data) {
Memcpy(m_resource.Bytes(), data, size);
@@ -144,6 +160,33 @@ namespace MobileGL::MG_State::GLState {
m_isImmutableStorage = true;
m_storageFlags = storageFlags;
NotifyRespecify();
TryAdoptLargeStorage();
}
// Back a LARGE store with the backend's persistently+coherently mapped GPU
// storage the moment it is (re)defined, without waiting for the app to map it.
// Minecraft 26.3 streams chunk meshes into 128MB vertex arenas with plain
// glNamedBufferSubData - the one write API that carries no synchronization
// hint - and on Mali every route that hands the driver a write into a busy
// MUTABLE store either parks the calling thread (glBufferSubData, and
// glMapBufferRange even with GL_MAP_UNSYNCHRONIZED_BIT) or ghost-copies the
// whole destination on a driver worker (staged glCopyBufferSubData, and a
// range-invalidating map: ~167ms per touched arena, the recurring in-world
// hiccup). An adopted coherent map is the one shape with NO per-write driver
// call at all: every SubData lands as a plain memcpy into GPU-visible memory,
// and the shadow copy is dropped (a 128MB arena stops costing 128MB of RAM).
// Only attempted for stores the size of mesh arenas: small buffers keep the
// shadow model whose draw-time flush already prices them correctly.
void BufferObject::TryAdoptLargeStorage() {
constexpr SizeT kLargeBufferAdoptBytes = 16u * 1024u * 1024u;
if (MG_Config::Features.DisableLargeBufferAdoption) return;
if (m_size < kLargeBufferAdoptBytes) return;
if (m_resource.IsGpuResident()) return;
if (m_isMapped) return;
if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return;
if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) {
m_resource.AdoptPersistentMap(base);
}
}
void BufferObject::UploadData(DataPtr data, SizeT atOffset) {
@@ -160,24 +203,45 @@ namespace MobileGL::MG_State::GLState {
m_usage = usage;
}
void BufferObject::ReleaseMemory() {
void BufferObject::ReleaseMemory(Bool landStagedWrites) {
if (!m_isMapped) return;
if (m_mappingAccess & BufferMappingAccessBit::Write) { // if we wrote to the buffer
// A persistent GPU-resident map wrote straight into coherent GPU memory, so
// there is nothing to copy back and no range to push down on unmap.
if (!m_resource.IsGpuResident() &&
!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
m_mappedRange.end - m_mappedRange.start);
if (landStagedWrites &&
(m_mappingAccess & BufferMappingAccessBit::Write)) { // if we wrote to the buffer
if (!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
const SizeT mappedLength = m_mappedRange.end - m_mappedRange.start;
if (m_resource.IsGpuResident()) {
// A persistent map of an adopted store wrote straight into coherent
// GPU memory: nothing to copy back, no range to push down. A
// NON-persistent write map is a different thing: the application
// wrote a staging copy (glMapBuffer and glMapBufferRange hand one out
// regardless of where the store lives), and GL requires those bytes
// to be visible to every later command the moment glUnmapBuffer
// returns. Residency used to come only from a coherent persistent
// map, which never has a staging copy, so the copy-back was simply
// skipped for a resident store; residency now also comes from a
// shader storage binding (EnsureGpuResidentStorage at draw time) and
// from large-store adoption (TryAdoptLargeStorage), both of which an
// application then re-initialises through an ordinary map/write/unmap.
// Skipping the copy-back dropped every one of those writes. Land the
// staged bytes through the same route glBufferSubData takes into an
// adopted store - the backend's flush op is for stores it keeps a
// separate copy of and must not run here.
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
LandBytesIntoResidentStore(m_mappedRange.start,
{m_stagingData.data() + m_stagingBias, mappedLength});
}
} else {
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
mappedLength);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
m_stagingData.clear();
}
m_stagingData.clear();
m_isMapped = false;
m_mappingAccess = BufferMappingAccessBit::Null;
m_mappedRange = {0, 0};
@@ -197,8 +261,21 @@ namespace MobileGL::MG_State::GLState {
MOBILEGL_ASSERT(end <= m_mappedRange.end, "Flush range out of bounds: mappedRange.end (%zu) < end (%zu)",
m_mappedRange.end, end);
// FLUSH_EXPLICIT maps are never GPU-resident (only coherent maps are adopted), so
// the staged bytes must be copied into the shadow before the backend reads them.
// A FLUSH_EXPLICIT map can sit on an adopted store: the map itself never adopts
// (only a coherent persistent one does), but a shader storage binding or
// large-store adoption may have made the buffer resident before the map. The
// flushed bytes then take the same landing as any other CPU write into an
// adopted store - a persistent map already wrote them in place and only has
// to publish the change, a non-persistent map staged them and has to land
// them. The backend's flush op is for stores it keeps a separate copy of.
if (m_resource.IsGpuResident()) {
if (m_mappingAccess & BufferMappingAccessBit::Persistent) {
NotifyContentWrite(start, length);
} else {
LandBytesIntoResidentStore(start, {m_stagingData.data() + m_stagingBias + offset, length});
}
return;
}
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + m_stagingBias + offset, length);
}
@@ -253,10 +330,51 @@ namespace MobileGL::MG_State::GLState {
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
data.size, m_size);
// An adopted store's Bytes() IS the memory in-flight frames are reading, and
// GL orders a glBufferSubData after those already-submitted reads: the write
// has to take the resident landing, never a plain host write into the mapping.
// Shadow-backed stores need none of this: the Memcpy below touches only the
// shadow, and the backend's SubData op does its own ordering against in-flight
// work.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(atOffset, data);
return;
}
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
NotifyContentWrite(atOffset, data.size);
}
// A backend that can land the bytes on the GPU timeline takes them here, untouched
// by the mapping - an in-place host write into coherent memory tore the frames
// still reading the old bytes (Minecraft patches LIVE chunk sections this way).
// The bytes are then not current in the mapping until the backend's ordered copy
// executes, so reads reconcile through the same gate GPU-written buffers use.
//
// Without that op the write lands in place, after retiring the GPU writes this store
// is known to be waiting on: a backend that defers work (DirectVulkan's frame command
// buffer) may still be holding a recorded-but-unsubmitted dispatch that GL orders this
// write AFTER, and writing the mapping now would land the bytes underneath that
// dispatch - its increments then execute on top of the newer data and invert the call
// order. That gate only knows about work that WROTE the store (MarkGpuWritten); work
// that merely READS it - a draw sourcing an adopted vertex arena - is not tracked here,
// so a backend without the op still owes the ordering against its own recorded reads.
// NotifyContentWrite on a resident store only bumps the serial: the backend has no
// separate copy to sync, so no transfer op runs.
void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) {
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
return;
}
SyncGpuWrites();
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
NotifyContentWrite(offset, bytes.size);
}
void BufferObject::FillSubData(DataPtr pattern, SizeT atOffset, SizeT size) {
MOBILEGL_ASSERT(pattern.data != nullptr && pattern.size > 0,
"FillSubData requires a non-empty pattern.");
@@ -269,9 +387,29 @@ namespace MobileGL::MG_State::GLState {
"Cannot fill data while buffer is non-persistently mapped.");
if (size == 0) return;
// A clear is ordered after all earlier GPU writes. Partial clears additionally need the
// retained shadow bytes; whole-store clears need the same synchronization before writing
// an adopted persistent mapping that the GPU may still be accessing.
// An adopted store takes the same landing as UploadSubData: the in-place write
// below would tear in-flight readers of the mapping. The pattern is expanded
// first because the landing takes the final bytes, not a repeat rule - which is
// why only a backend that actually takes them comes through here. Without that
// op the landing would memcpy the expansion into the mapping the loop below
// fills in place anyway, so a whole-arena clear would allocate a whole arena
// for nothing.
if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
Vector<Uint8> expanded(size);
if (pattern.size == 1) {
Memset(expanded.data(), *static_cast<const Uint8*>(pattern.data), size);
} else {
for (SizeT at = 0; at < size; at += pattern.size) {
Memcpy(expanded.data() + at, pattern.data, pattern.size);
}
}
LandBytesIntoResidentStore(atOffset, {expanded.data(), size});
return;
}
// A clear is ordered after all earlier GPU writes; partial clears additionally need
// the retained shadow bytes, and a resident store the backend cannot take the bytes
// for is written in place, which needs the same synchronization the landing does.
SyncGpuWrites();
Uint8* dst = m_resource.Bytes() + atOffset;
@@ -305,6 +443,14 @@ namespace MobileGL::MG_State::GLState {
size, m_size);
src->SyncGpuWrites();
// An adopted DESTINATION takes the same landing as UploadSubData: the in-place
// write below would tear in-flight readers of the mapping, and pending recorded
// GPU writes to it must retire before the copy lands or they would execute on
// top of it.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(dstOffset, {src->m_resource.Bytes() + srcOffset, size});
return;
}
Memcpy(m_resource.Bytes() + dstOffset, src->m_resource.Bytes() + srcOffset, size);
NotifyContentWrite(dstOffset, size);
}
@@ -340,6 +486,16 @@ namespace MobileGL::MG_State::GLState {
if (m_resource.IsGpuResident()) {
return true;
}
// Adoption releases the CPU shadow, and a live mapping may BE that shadow: a
// persistent map that did not itself adopt (a FLUSH_EXPLICIT one, or a read map)
// handed the application shadow + offset, and GL keeps that pointer valid while
// the buffer is drawn with - which is exactly when this runs, on the storage
// binding walk. Freeing it under the application is a use-after-free, so a mapped
// buffer keeps the shadow model until it is unmapped; the binding that follows
// adopts then. Same rule as TryAdoptLargeStorage.
if (m_isMapped) {
return false;
}
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
return false;
}
@@ -358,7 +514,20 @@ namespace MobileGL::MG_State::GLState {
// The app is about to look at the bytes; a shader may have rewritten them since
// the shadow was last authoritative. Also needed for a write map without an
// invalidate bit, whose staging copy is seeded from the shadow.
SyncGpuWrites();
//
// One map shape looks at nothing: a non-persistent write map that discards the
// range it maps gets a staging copy the seeding below skips, so no reader of the
// store exists between here and the unmap. Reconciling an ADOPTED store would
// still cost the backend's full drain-and-wait (its queued landings are made
// visible to the CPU by finishing the pipeline), once per map, on exactly the
// streaming arena the adoption exists to keep cheap. The outstanding-write flag
// stays set, so the first read that DOES look at the bytes still pays for it.
const Bool discardsWhatItMaps =
(access & BufferMappingAccessBit::Write) && !(access & BufferMappingAccessBit::Persistent) &&
(access & (BufferMappingAccessBit::InvalidateRange | BufferMappingAccessBit::InvalidateBuffer));
if (!(m_resource.IsGpuResident() && discardsWhatItMaps)) {
SyncGpuWrites();
}
m_isMapped = true;
m_mappingAccess = access;
m_mappedRange = range;
@@ -80,6 +80,19 @@ namespace MobileGL {
void (*Respecify)(BufferObject& bufferObject) = nullptr;
// Contents update of [offset, offset + size) from the shadow.
void (*SubData)(BufferObject& bufferObject, SizeT offset, SizeT size) = nullptr;
// Contents update of an ADOPTED (GPU-resident) store. `data` holds the app's
// bytes, valid for the duration of the call only (a write map's staging
// store is freed the moment the unmap that lands it returns); the frontend
// has NOT touched the resident mapping. GL orders a
// glBufferSubData after already-submitted GPU reads of the store, and an
// in-place host write into the coherent mapping tears the frames still
// reading the old bytes (Minecraft patches LIVE chunk sections this way -
// the tear shows as one-frame wrong geometry/UVs during fast movement). The
// backend lands the bytes on the GPU timeline instead: after in-flight
// readers, before the next consumer. The frontend marks the buffer
// gpu-write-pending so reads reconcile through ReadbackFromGpu. Backends
// without this op keep the legacy ordered in-place host write.
void (*ResidentSubData)(BufferObject& bufferObject, SizeT offset, DataPtr data) = nullptr;
// Write-map flush (glUnmapBuffer / glFlushMappedBufferRange). Carries the
// app's real mapping flags so the backend can honour INVALIDATE_* /
// UNSYNCHRONIZED semantics per call instead of merging them.
@@ -146,9 +159,14 @@ namespace MobileGL {
// Adopt backend host-visible coherent GPU storage as the source of truth
// (used for GPU-written targets like transform feedback capture, so
// MapBuffer/GetBufferSubData read real GPU results). No-op when already
// resident or when the backend declines.
// resident, while the buffer is mapped (adoption releases the shadow a
// mapping may have handed the application), or when the backend declines.
Bool EnsureGpuResidentStorage();
void ReleaseMemory();
// Unmap. A write map's staged bytes land in the store on the way out, unless
// the caller is about to replace that store (a respecification) and passes
// false - landing them there would copy a whole mapped range into storage
// being handed back on the next line.
void ReleaseMemory(Bool landStagedWrites = true);
void FlushMemoryRange(SizeT offset, SizeT length);
// Pushes the persistently-mapped write range to the backend; called by
@@ -211,6 +229,10 @@ namespace MobileGL {
// Sizes the store for a (re)definition, renewing an adopted GPU-resident
// mapping across it. See the definition for why the renewal is not optional.
void RedefineStorage(SizeT size);
// Backend-initiated coherent adoption for mesh-arena-sized stores; see the
// definition for the driver behavior that makes every other write route to
// a busy large mutable store a frame-scale stall.
void TryAdoptLargeStorage();
void NotifyRespecify();
void NotifySubData(SizeT offset, SizeT size);
void NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess);
@@ -219,6 +241,12 @@ namespace MobileGL {
// so this only bumps the change serial; otherwise it dispatches a backend
// SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size);
// The one route CPU-sourced bytes take into an ADOPTED (GPU-resident) store:
// glBufferSubData, a buffer clear, a buffer copy, and the landing of a
// non-persistent write map at unmap / explicit flush all go through it, so
// the routes cannot drift apart again. Carries no mapping asserts on
// purpose - the unmap landing runs while the buffer is still mapped.
void LandBytesIntoResidentStore(SizeT offset, DataPtr bytes);
static Uint64 AllocateLifetimeId();
+8 -1
View File
@@ -710,8 +710,15 @@ namespace MobileGL::MG_State {
// must not, or failing the link and killing every draw. A capture stage with an
// empty list is not a reason to look further down: it is the answer, and
// glBeginTransformFeedback's INVALID_OPERATION is the correct consequence.
//
// The order is the pipeline read backwards and includes the tessellation CONTROL
// stage, which is a vertex-processing stage too (GL 4.6 core 11): it can only be
// the last one in a pipeline that has a TCS but no evaluation or geometry stage,
// which is why it sits after TessEval. Same four stages, same order, as
// ProgramLinkTask::ResolveTransformFeedbackVaryings - see rule (2).
for (const ShaderStage captureStage:
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::Vertex}) {
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::TessControl,
ShaderStage::Vertex}) {
if (!compositeHasStage[static_cast<SizeT>(captureStage)]) continue;
const auto& captureProgram = pipeline->GetStageProgram(captureStage);
if (!captureProgram) continue;
@@ -517,7 +517,9 @@ namespace MobileGL::MG_State::GLState {
};
} // namespace
void ProgramLinkTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
void ProgramLinkTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramLinkTask::SubmitAfter(const Vector<SharedPtr<ShaderCompileTask>>& deps) {
// +1 for the guard this function releases itself. Without it, a dependency that
@@ -855,6 +857,18 @@ namespace MobileGL::MG_State::GLState {
spirvHandoff.reflection.uniformReflection = artifacts.uniformReflection;
spirvHandoff.reflection.blockReflection = artifacts.blockReflection;
spirvHandoff.reflection.tProgramBlockIndexToGl = artifacts.tProgramBlockIndexToGl;
// The capture set is NOT part of that slice (see the handoff's own comment), and the
// point-size demotion needs exactly one bit out of it: whether anything asked to
// capture gl_PointSize. Derived here, where ResolveTransformFeedbackVaryings has
// just filled artifacts.xfbVaryings and before the join moves them away, because a
// capture stage that only READS the built-in still has to declare the carrier the
// capture binds to - and phase B has no other way to learn that.
for (const ProgramObject::XfbVarying& varying : artifacts.xfbVaryings) {
if (varying.name == "gl_PointSize") {
spirvHandoff.captureRequestsPointSize = true;
break;
}
}
// Phase B pairs this with its own SpirvArtifacts to insert the completed front end.
// A COPY, because the GL-thread join moves `artifacts` out of this node before phase B
// runs - and with the TProgram dropped, because a memo must never hold a glslang arena.
@@ -896,6 +910,11 @@ namespace MobileGL::MG_State::GLState {
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
// The second and third capability bits, under exactly the same rule: each arms a
// phase-B rewrite of the cached modules (the point-size demotion), read from the
// same env snapshot that phase B will consult, so key and bytes cannot disagree.
keyInputs.demoteTessellationPointSize = env.DemotesTessellationPointSize();
keyInputs.demoteGeometryPointSize = env.DemotesGeometryPointSize();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
@@ -1856,7 +1875,13 @@ namespace MobileGL::MG_State::GLState {
// them to the draw-buffer range fails the link of every such program.
if (artifacts.program->getIntermediate(EShLangFragment) == nullptr) return true;
UnorderedMap<Int, String> colorNumberOwners;
// Keyed on (colour number, COLOUR INDEX), not on the colour number alone. Two fragment
// outputs may share a location as long as their index differs - that pair IS dual-source
// blending (GL 4.6 core 11.1.3 / ARB_blend_func_extended, core since 3.3), spelled either
// `layout(location = 0, index = 0)` + `layout(location = 0, index = 1)` in the shader or
// through two glBindFragDataLocationIndexed calls. Aliasing on the number alone made every
// such program fail to link with "alias color number 0", which is the whole feature.
UnorderedMap<Int64, String> colorSlotOwners;
const Int outputCount = artifacts.program->getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const auto& output = artifacts.program->getPipeOutput(index);
@@ -1869,6 +1894,46 @@ namespace MobileGL::MG_State::GLState {
const Int location = explicitLocation != in.explicitFragDataLocation.end()
? static_cast<Int>(explicitLocation->second)
: static_cast<Int>(output.layoutLocation());
// The colour INDEX, under the one precedence rule the whole codebase uses: a NON-ZERO
// glBindFragDataLocationIndexed index wins, and a zero (or absent) one falls back to
// the shader's own layout(index = N).
//
// Zero has to mean "no override" rather than "index 0", because glBindFragDataLocation
// IS glBindFragDataLocationIndexed with index 0 (GL_Program.cpp) and writes a real 0
// into this map. Reading that 0 as an override made a blanket
// `glBindFragDataLocation(prog, 0, "b")` over a shader that declares
// `layout(location = 0, index = 1) out vec4 b;` collapse b onto slot (0,0) next to the
// index-0 output and fail the link as an alias - while the IO resolver had left b's
// qualifier at 1, the SPIR-V still carried Index 1, and glGetProgramResourceLocationIndex
// still answered 1. Validation was rejecting a program the backend had already emitted
// correctly, which is the one case where this branch can change the answer at all: this
// runs AFTER ShaderCompiler::LinkProgram/mapIO, so for every other shape the qualifier
// already carries the resolver's verdict.
//
// The two other consumers spell the same rule: TMglGlslIoResolver only writes the API
// index into the qualifier when it is non-zero, and ProgramInterface falls back to
// type.layoutIndex when GetFragmentDataIndex answers 0. All three now agree.
//
// Against the spec (GL 4.6 core 15.2.3): where a fragment output's index is given by a
// shader layout qualifier, that value is used and anything bound through
// BindFragDataLocation(Indexed) is IGNORED - the same precedence layout(location) has
// over glBindAttribLocation. That is stricter than "non-zero API wins", and the two
// differ in exactly one shape: an explicit `index = 0` in the shader against an API
// index of 1, where the spec keeps 0 and this codebase takes 1. That divergence lives
// in the resolver (it decides what is emitted); it is pre-existing, out of scope here,
// and deliberately not re-litigated in a third place - matching the resolver is what
// keeps validation checking what was actually built.
Int colorIndex = 0;
if (const auto explicitIndex = in.explicitFragDataIndex.find(outputName);
explicitIndex != in.explicitFragDataIndex.end()) {
colorIndex = static_cast<Int>(explicitIndex->second);
}
if (colorIndex == 0) {
if (const glslang::TType* outputType = output.getType();
outputType != nullptr && outputType->getQualifier().hasIndex()) {
colorIndex = static_cast<Int>(outputType->getQualifier().layoutIndex);
}
}
const Int span = std::max<Int>(output.size, 1);
if (location < 0 || location + span > in.maxFragmentOutputColorNumber) {
@@ -1881,10 +1946,16 @@ namespace MobileGL::MG_State::GLState {
}
for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) {
auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName);
const Int64 slot = (static_cast<Int64>(colorIndex) << 32) |
static_cast<Int64>(static_cast<Uint32>(colorNumber));
auto [owner, inserted] = colorSlotOwners.emplace(slot, outputName);
if (!inserted) {
artifacts.infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.",
owner->second, outputName, colorNumber);
artifacts.infoLog =
colorIndex == 0
? std::format("Fragment outputs '{}' and '{}' alias color number {}.", owner->second,
outputName, colorNumber)
: std::format("Fragment outputs '{}' and '{}' alias color number {} at index {}.",
owner->second, outputName, colorNumber, colorIndex);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
@@ -1910,10 +1981,18 @@ namespace MobileGL::MG_State::GLState {
return true;
}
// Capture happens at the last vertex-processing stage (geometry, then
// tessellation evaluation, then vertex).
// Capture happens at the last vertex-processing stage (geometry, then tessellation
// evaluation, then tessellation CONTROL, then vertex). All four are vertex-processing
// stages in GL 4.6 core 11 - the control shader included - and in a separable program
// whose only stage is a TCS it is the last one that exists, so it is the capture stage
// and such a program MUST link (GL 4.6 core 7.3/11.1.2.1; the conformance suite spells
// the API split out at esextcTessellationShaderXFB.cpp:390-416, where a non-ES context
// takes should_succeed=true). TessControl sits AFTER TessEvaluation so a complete
// pipeline still captures at the evaluation stage and only a TCS-only program falls
// through to it. If MobileGL ever serves an ES context this arm has to be gated on the
// advertised API: ES requires the very same link to FAIL.
const glslang::TIntermediate* captureIntermediate = nullptr;
for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) {
for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangTessControl, EShLangVertex}) {
captureIntermediate = artifacts.program->getIntermediate(stage);
if (captureIntermediate != nullptr) {
break;
@@ -117,6 +117,19 @@ namespace MobileGL::MG_State::GLState {
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// Whether the RESOLVED transform-feedback capture set names gl_PointSize - the
// one fact about `artifacts.xfbVaryings` phase B needs, carried as a derived
// bool rather than by widening the slice above, which is deliberately the five
// (now eight) fields BuildGlobalUboRouting consumes and nothing else.
//
// It has to be here and cannot be re-derived: the point-size demotion forces the
// capture-capable stage to declare its carrier even when that stage never WRITES
// the built-in (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram's
// `captureRequestsPointSize`), and by phase B the only record of the request is
// this bit. No new L1 key material: the key already covers
// `requestedXfbVaryings`, of which this is a function.
Bool captureRequestsPointSize = false;
// L1 shader-translation memo key for this program's SPIR-V (see
// MG_Util/ShaderTranspiler/TranslationCache.h). Built HERE, at the tail of phase
// A, and not by phase B - two reasons, both structural:
@@ -201,8 +214,10 @@ namespace MobileGL::MG_State::GLState {
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
// join, on the GL thread, where a serial implementation would have printed it.
// Logging straight from a worker interleaves mid-line with the GL thread's output and
// lands out of order relative to the glLinkProgram that caused it.
void DeferLog(String line);
// lands out of order relative to the glLinkProgram that caused it. `level` is the
// severity the replay uses; DEBUG (the default) is compiled out of every shipped
// build, so a line that has to survive one names its own.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
// Counts down to zero exactly once. Starts at deps + 1: the extra guard is released
// by SubmitAfter itself, so a dependency that settles while the edges are still being
@@ -554,7 +554,8 @@ namespace MobileGL::MG_State::GLState {
// asked for is the safer of the two readings.
if (task->in.requestedXfbVaryings.empty()) {
for (const ShaderStage captureStage:
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::Vertex}) {
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::TessControl,
ShaderStage::Vertex}) {
Bool stagePresent = false;
for (const auto& shader : m_shaders) {
if (!shader || shader->GetShaderStage() != captureStage) continue;
@@ -603,6 +603,11 @@ namespace MobileGL::MG_State::GLState {
// other question about the global UBO's layout - and it is one: it decides how wide a
// `double` uniform's slot is.
Bool UsesNativeFloat64() const { return Spirv().nativeFloat64; }
// Whether gl_PointSize was demoted out of this program's tessellation/geometry
// modules into the ordinary carrier varying. Joins phase B: it is a fact about the
// generated modules, and its readers (the backends' capture-name respelling) already
// hold the phase-B join.
Bool PointSizeDemoted() const { return Spirv().pointSizeDemoted; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
@@ -1429,6 +1434,18 @@ namespace MobileGL::MG_State::GLState {
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
// Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host
// the built-in there. Per PROGRAM by construction - a consumer whose producer
// kept the built-in would read garbage - and recorded here rather than
// re-derived because it cannot be: the rewrite's whole point is that the final
// bytes no longer declare the capability that armed it. The backends read it to
// respell a "gl_PointSize" transform-feedback capture as the carrier
// (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface
// deliberately keeps answering "gl_PointSize": demotion happens after phase A,
// so every query keeps the truthful GL spelling.
Bool pointSizeDemoted = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -16,10 +16,29 @@
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <atomic>
#include <cstring>
namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
namespace {
// The MGLOG_*_ONCE latch, moved to the SOURCE of a deferred line. It cannot live at
// the replay: Async::ApplyDeferredDiagnostics is ONE site shared by every job in the
// tree, so a latch there would silence unrelated lines. And it has to exist: a shader
// pack hands the same refusal to program after program, and a per-program WARN on a
// path like that is exactly the repeated production logging the house rule forbids.
// First occurrence at WARN - the one a bug report needs - every later one back at
// DEBUG, which shipped builds compile out.
Int FirstTimeWarnLevel(std::atomic_flag& latch) {
return latch.test_and_set(std::memory_order_relaxed) ? MOBILEGL_LOG_LEVEL_DEBUG
: MOBILEGL_LOG_LEVEL_WARN;
}
std::atomic_flag g_pointSizeDeclineReported;
std::atomic_flag g_pointSizeOptimizerFailureReported;
} // namespace
void ProgramSpirvTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramSpirvTask::SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing");
@@ -128,8 +147,15 @@ namespace MobileGL::MG_State::GLState {
// with (ProgramLinkTask::BuildSpirvCacheKey reads the same env) or a memo written under
// one answer could be handed back under the other.
const Bool nativeFloat64 = m_phaseA->in.env != nullptr && m_phaseA->in.env->ConsumesFloat64Natively();
// The point-size demotion verdicts, read from the SAME snapshot for the same reason
// - and the same bits BuildSpirvCacheKey put in the L1 key, so a memo written under
// one answer can never be handed back under the other.
const Bool demoteTessellationPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesTessellationPointSize();
const Bool demoteGeometryPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesGeometryPointSize();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
@@ -188,7 +214,9 @@ namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation, const Bool nativeFloat64) {
const Bool enableSpirvValidation, const Bool nativeFloat64,
const Bool demoteTessellationPointSize,
const Bool demoteGeometryPointSize) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -267,6 +295,50 @@ namespace MobileGL::MG_State::GLState {
}
}
artifacts.spirvStatus = allOptimized;
// The point-size demotion, program-wide and after the sanitize chain, so it works
// on the final shared bytes both backends consume and nothing downstream can trim
// the carriers it declares. Only the env half of the verdict lives here (and in the
// L1 key); whether the program actually declares the capability is probed inside,
// so the common case on an affected device - a program that never touches point
// size in those stages - pays one module parse per stage and no rewrite.
artifacts.pointSizeDemoted = false;
if (allOptimized && (demoteTessellationPointSize || demoteGeometryPointSize)) {
// Read off the HANDOFF's own derived bit, not off `handoff.reflection`: that
// field is the routing slice phase A fills with eight named members, and
// xfbVaryings is not one of them - reading it there answered "no capture ever
// asks for gl_PointSize" on every production link, which left a read-only
// capture stage without the carrier its capture binds to.
const Bool captureRequestsPointSize = handoff.captureRequestsPointSize;
ShaderCompiler::PointSizeDemotionOutcome outcome;
if (!ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
artifacts.generatedSpirv, handoff.shaderTypes, demoteTessellationPointSize,
demoteGeometryPointSize, captureRequestsPointSize, outcome,
!deferOutputValidationForDirectVulkan, enableSpirvValidation)) {
// Optimizer failure: modules untouched, so the capability is still declared
// and the backends' existing refusals stay in charge - honest, just slower.
DeferLog(std::format("ProgramObject {}: point-size demotion failed in the optimizer; the "
"program keeps its built-in and the device's declines apply",
externalIndex),
FirstTimeWarnLevel(g_pointSizeOptimizerFailureReported));
} else if (outcome.demoted) {
artifacts.pointSizeDemoted = true;
DeferLog(std::format("ProgramObject {}: gl_PointSize demoted to an ordinary varying across "
"the tessellation/geometry chain (value preserved for capture and "
"gl_in reads; rasterized size falls back to 1.0)",
externalIndex));
} else if (!outcome.declineDetail.empty()) {
// THE MOST VALUABLE LINE THIS FEATURE PRODUCES: which module shape the pass
// refused, and therefore why an affected device is still about to lose the
// program. Nothing else records it - `declineDetail` has no other runtime
// surface - so at the deferred channel's DEBUG default it was formatted and
// then dropped by every INFO build, i.e. every device and every CI artifact.
DeferLog(std::format("ProgramObject {}: point-size demotion declined ({}); the program "
"keeps its built-in and the device's declines apply",
externalIndex, outcome.declineDetail),
FirstTimeWarnLevel(g_pointSizeDeclineReported));
}
}
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
@@ -67,12 +67,14 @@ namespace MobileGL::MG_State::GLState {
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
Bool nativeFloat64, Bool demoteTessellationPointSize,
Bool demoteGeometryPointSize);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
// ProgramLinkTask::DeferLog.
void DeferLog(String line);
// ProgramLinkTask::DeferLog, and the same severity rule: DEBUG is compiled out of
// every shipped build, so a line that has to survive one names its own level.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
SharedPtr<ProgramLinkTask> m_phaseA;
};
@@ -384,12 +384,15 @@ namespace MobileGL::MG_State::GLState {
// the log, for every failing shader. The info log is what names the offending
// line; the source is recoverable from the application.
const SizeT firstLineEnd = artifacts.infoLog.find('\n');
diagnostics.logLines.push_back(std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos ? artifacts.infoLog.length()
: firstLineEnd)));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG,
std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos
? artifacts.infoLog.length()
: firstLineEnd))});
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
@@ -97,27 +97,31 @@ namespace MobileGL::MG_State::GLState {
return m_storageOwner->HasFixedSampleLocations();
}
Uint TextureObjectView::ViewLayerIndex(TextureUploadTarget viewTarget) const {
if (GetTarget() != TextureTarget::TextureCubeMap) {
// One target, one layer: the view's origin is the whole answer.
return m_viewMinLayer;
}
for (Uint i = 0; i < static_cast<Uint>(m_uploadTargets.size()); ++i) {
if (m_uploadTargets[i] == viewTarget) return m_viewMinLayer + i;
}
return m_viewMinLayer;
}
TextureUploadTarget TextureObjectView::ToOwnerUploadTarget(TextureUploadTarget viewTarget) const {
const auto& ownerTargets = m_storageOwner->GetUploadTargets();
MOBILEGL_ASSERT(!ownerTargets.empty(), "TextureObjectView: storage owner has no upload target");
if (ownerTargets.size() == 1) {
// The owner keeps every layer in one blob, so there is nothing to choose.
// The owner keeps every layer in one blob, so there is nothing to choose HERE - which
// is exactly why a cube-map view over such an owner has to have its face carried by
// LayerByteOffset instead. See ViewLayerIndex.
return ownerTargets[0];
}
// The owner is a cube map: six independent blobs, one per face, and the view's layer
// index selects among them. A cube-map view of a cube map maps face to face; any other
// view target addresses layers, which for a cube-map owner ARE its faces.
// The owner is a cube map: six independent blobs, one per face, and the layer this view
// target names selects among them. A cube-map view of a cube map maps face to face; any
// other view target addresses layers, which for a cube-map owner ARE its faces.
const Uint faceCount = static_cast<Uint>(ownerTargets.size());
Uint face = m_viewMinLayer;
if (GetTarget() == TextureTarget::TextureCubeMap) {
for (Uint i = 0; i < m_uploadTargets.size(); ++i) {
if (m_uploadTargets[i] == viewTarget) {
face = m_viewMinLayer + i;
break;
}
}
}
return ownerTargets[std::min(face, faceCount - 1)];
return ownerTargets[std::min(ViewLayerIndex(viewTarget), faceCount - 1)];
}
IntVec3 TextureObjectView::ToViewLevelSize(const IntVec3& ownerLevelSize) const {
@@ -151,7 +155,13 @@ namespace MobileGL::MG_State::GLState {
}
SizeT TextureObjectView::LayerByteOffset(TextureUploadTarget viewTarget, Uint mipmapLevel) const {
if (m_viewMinLayer == 0 || m_ownerMipmap == nullptr) return 0;
if (m_ownerMipmap == nullptr) return 0;
// The FACE is part of this, not just the view's origin: a cube-map view over a layered
// owner (a 2D array or a cube-map ARRAY) has only one blob to address, so the face its
// target token names lives here or nowhere. It used to live nowhere, and all six face
// tokens read the view's first layer-face - silently, with texels from a real layer.
const Uint layerIndex = ViewLayerIndex(viewTarget);
if (layerIndex == 0) return 0;
const LayerAxis ownerAxis = LayerAxisOf(m_storageOwner->GetTarget());
if (ownerAxis == LayerAxis::None) {
// A cube-map owner keeps each face in its OWN blob, and ToOwnerUploadTarget already
@@ -173,23 +183,26 @@ namespace MobileGL::MG_State::GLState {
? static_cast<SizeT>(std::max(ownerSize.x(), 0))
: static_cast<SizeT>(std::max(ownerSize.x(), 0)) *
static_cast<SizeT>(std::max(ownerSize.y(), 0));
const SizeT offset = static_cast<SizeT>(m_viewMinLayer) * layerTexels * bytesPerTexel;
const SizeT offset = static_cast<SizeT>(layerIndex) * layerTexels * bytesPerTexel;
return offset < ownerBytes ? offset : 0;
}
IntVec3 TextureObjectView::ToOwnerRegionOffset(const IntVec3& viewOffset) const {
if (m_viewMinLayer == 0) return viewOffset;
IntVec3 TextureObjectView::ToOwnerRegionOffset(TextureUploadTarget viewTarget, const IntVec3& viewOffset) const {
const Uint layerIndex = ViewLayerIndex(viewTarget);
if (layerIndex == 0) return viewOffset;
IntVec3 offset = viewOffset;
// The dirty region is recorded in the OWNER's blob coordinates - that is the space its
// upload path walks - so the view's layer origin has to be added here even though
// upload path walks - so the layer this view target names has to be added here even though
// MapMipmapData hands back an already-shifted POINTER. The two are not double-counting:
// one moves the bytes, the other tells the owner which of its layers moved.
// one moves the bytes, the other tells the owner which of its layers moved. They must agree
// on the layer, which is why both ask ViewLayerIndex rather than reading m_viewMinLayer -
// on a cube-map view the face is half the answer.
switch (LayerAxisOf(m_storageOwner->GetTarget())) {
case LayerAxis::Y:
offset.y() += static_cast<Int>(m_viewMinLayer);
offset.y() += static_cast<Int>(layerIndex);
break;
case LayerAxis::Z:
offset.z() += static_cast<Int>(m_viewMinLayer);
offset.z() += static_cast<Int>(layerIndex);
break;
case LayerAxis::None:
break;
@@ -300,7 +313,7 @@ namespace MobileGL::MG_State::GLState {
IntVec3 size) {
if (m_ownerMipmap == nullptr) return;
m_ownerMipmap->MarkStorageDirtyRegion(ToOwnerUploadTarget(uploadTarget), ToOwnerLevel(mipmapLevel),
ToOwnerRegionOffset(offset), size);
ToOwnerRegionOffset(uploadTarget, offset), size);
}
MipmapDirtyRegion TextureObjectView::GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
@@ -103,6 +103,18 @@ namespace MobileGL::MG_State::GLState {
// target - arrays and cube-map arrays included - keeps all its layers in one blob, so
// the mapping is "the owner's only target" unless one of the two sides is a cube map.
TextureUploadTarget ToOwnerUploadTarget(TextureUploadTarget viewTarget) const;
// WHICH of the owner's layers a given view-side upload target names, in the owner's layer
// numbering. For every view target but a cube map that is just this view's layer origin -
// one target, one layer. A GL_TEXTURE_CUBE_MAP view addresses SIX of the owner's layers at
// once (GL 4.6 core 8.18), so the face its target token names is an index on top of that
// origin, and this is the only place that can express it when the owner keeps every layer
// in one blob: ToOwnerUploadTarget has a single blob to choose from there, so the face
// would otherwise vanish and all six tokens would read the view's first layer.
//
// Every place that turns this view into owner-side bytes goes through here - the blob
// choice, the byte offset, and the dirty region - so the three cannot disagree about which
// layer a face is.
Uint ViewLayerIndex(TextureUploadTarget viewTarget) const;
Uint ToOwnerLevel(Uint viewLevel) const { return m_viewMinLevel + viewLevel; }
// The owner's level extent rewritten into this view's shape: the owner's layer axis is
// collapsed to one slice and the view's own layer count is imposed on the view's layer
@@ -114,8 +126,10 @@ namespace MobileGL::MG_State::GLState {
// and a single row for a 1D array; a cube-map owner returns 0 because its faces are
// separate blobs that ToOwnerUploadTarget already selects between.
SizeT LayerByteOffset(TextureUploadTarget viewTarget, Uint mipmapLevel) const;
// A dirty-region origin moved from the view's layer space into the owner's.
IntVec3 ToOwnerRegionOffset(const IntVec3& viewOffset) const;
// A dirty-region origin moved from the view's layer space into the owner's. Takes the view
// target for the same reason LayerByteOffset does: on a cube-map view the target names the
// face, and the region has to name the same owner layer the bytes were written to.
IntVec3 ToOwnerRegionOffset(TextureUploadTarget viewTarget, const IntVec3& viewOffset) const;
SharedPtr<ITextureObject> m_storageOwner;
// Non-owning; m_storageOwner keeps it alive and is never a view, so this is set once in
@@ -99,9 +99,14 @@ namespace MobileGL::MG_State::GLState {
// is deliberately left unbound so it samples as (0,0,0,1)). Deliberately coarse - ANY
// texture, ANY sampler - so that no mutation can slip past a per-unit binding memo; the
// setters that feed it all early-out when the value is unchanged, so the redundant
// glTexParameteri calls applications issue every frame do not churn it. Kept separate
// from the bind generation because the sampled texture SET is unaffected by these, and
// the Vulkan backend's set memo keys on that one.
// glTexParameteri calls applications issue every frame do not churn it.
//
// Kept separate from the bind generation because the two answer different questions, NOT
// because the sampled texture SET is immune to this one - it is not, and the claim that
// it was is what this comment used to say. DirectVulkan leaves an incomplete texture out
// of the set entirely and substitutes a fallback, so completeness decides membership, and
// its sampled-set memo carries THIS generation alongside the bind one. Any memo of a
// resolved per-unit binding - or of which textures a draw samples at all - needs both.
Uint64 GetSamplingResolutionGeneration() const { return m_samplingResolutionGeneration; }
void BumpSamplingResolutionGeneration() { ++m_samplingResolutionGeneration; }
@@ -29,7 +29,9 @@ using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITEONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::PointSizeExtensionName;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestPointSizeExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms;
@@ -1289,6 +1291,80 @@ void main() { gl_ViewportIndex = 1; imageStore(uni_image, ivec2(0), uvec4(1u));
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
}
// --- tessellation / geometry gl_PointSize directive ---------------------------------------------
//
// ESSL 320 makes the tessellation and geometry STAGES core and still leaves gl_PointSize out of
// their gl_PerVertex entirely - it is only there under EXT/OES_tessellation_point_size resp.
// EXT/OES_geometry_point_size. SPIRV-Cross only ever sees a SPIR-V BuiltIn PointSize decoration
// and prints the identifier bare, so without this directive the stage fails to compile with
// "`gl_PointSize' undeclared", which takes the WHOLE program to program 0: the draw renders
// nothing and glBeginTransformFeedback on that program is rejected outright, so a capture of
// anything at all off it silently comes back empty. That is the shape of the 108 conformance
// bodies (36 per API tree) in tessellation_control_to_tessellation_evaluation.gl_MaxPatch-
// Vertices_Position_PointSize whose point_mode half puts gl_PointSize in the patch.
TEST(PointSizeExtensionNameTest, NamesBothSpellingsOfBothExtensions) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionEXT, true), "GL_EXT_tessellation_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionOES, true), "GL_OES_tessellation_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionEXT, false), "GL_EXT_geometry_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionOES, false), "GL_OES_geometry_point_size");
}
// The two extensions are separate and neither implies the other, so the tessellation answer must
// never be handed to a geometry stage or the other way round - an `#extension` naming a string
// the driver does not advertise is itself a compile error on a strict compiler.
TEST(PointSizeExtensionNameTest, NoTierMeansNoDirective) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
EXPECT_EQ(PointSizeExtensionName(Tier::None, true), nullptr);
EXPECT_EQ(PointSizeExtensionName(Tier::None, false), nullptr);
}
TEST(RequestPointSizeExtensionTest, TheDirectiveGoesRightAfterTheVersionLine) {
const String source = R"(#version 320 es
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_Position = vec4(0.0); gl_PointSize = 5.0; }
)";
const String out = RequestPointSizeExtension(source, "GL_EXT_tessellation_point_size");
EXPECT_TRUE(Contains(out, "#version 320 es\n#extension GL_EXT_tessellation_point_size : require\n")) << out;
}
// The nullptr contract, and the reason it exists: a driver that advertises neither spelling gets
// NOTHING added rather than a directive it would reject on top of the error it already has.
TEST(RequestPointSizeExtensionTest, ANullNameMeansNotEmitted) {
const String source = R"(#version 320 es
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_Position = vec4(0.0); gl_PointSize = 5.0; }
)";
EXPECT_EQ(RequestPointSizeExtension(source, nullptr), source);
}
TEST(RequestPointSizeExtensionTest, AnAlreadyPresentDirectiveIsNotDuplicated) {
const String source = R"(#version 320 es
#extension GL_OES_tessellation_point_size : require
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_PointSize = 5.0; }
)";
const String out = RequestPointSizeExtension(source, "GL_OES_tessellation_point_size");
EXPECT_EQ(out, source);
EXPECT_EQ(CountOf(out, "GL_OES_tessellation_point_size"), 1u) << out;
}
// Shares its insertion point with the viewport-array and image-format directives, so a stage
// needing more than one must end up with all of them and with #version still first.
TEST(RequestPointSizeExtensionTest, CoexistsWithTheOtherHeaderDirectives) {
const String source = R"(#version 320 es
layout(points) in;
layout(points, max_vertices = 1) out;
void main() { gl_ViewportIndex = 1; gl_PointSize = 2.0; EmitVertex(); }
)";
const String out = RequestPointSizeExtension(RequestViewportArrayExtension(source, true),
"GL_EXT_geometry_point_size");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_geometry_point_size : require\n")) << out;
}
// --- pass-through tessellation control stage --------------------------------------------------
//
// Desktop GL makes the tessellation control stage optional and takes the levels from
@@ -1303,6 +1379,20 @@ namespace {
const FloatVec2 kDefaultInner(1.0f, 1.0f);
} // namespace
// The synthesized stage mirrors its neighbours' gl_PerVertex, so it can be the thing that
// declares gl_PointSize - and in ESSL a redeclaration is exactly as illegal as a reference
// without the extension. The directive has to survive being applied to its output.
TEST(RequestPointSizeExtensionTest, CoversAMirroredPassthroughControlStage) {
const String out = RequestPointSizeExtension(
BuildPassthroughTessControlEssl(320, 4, " highp vec4 gl_Position; highp float gl_PointSize; ",
" highp vec4 gl_Position; highp float gl_PointSize; ", kDefaultOuter,
kDefaultInner),
"GL_EXT_tessellation_point_size");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_tessellation_point_size : require\n")) << out;
EXPECT_TRUE(Contains(out, "float gl_PointSize")) << out;
}
TEST(PassthroughTessControlEsslTest, DeclaresThePatchSizeAndWritesEveryTessLevel) {
const String out = BuildPassthroughTessControlEssl(320, 4, "", "", kDefaultOuter, kDefaultInner);
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
@@ -7,6 +7,7 @@
// End of Source File Header
#include <gtest/gtest.h>
#include <cstdlib>
#include <cstring>
#include <map>
#include <string>
@@ -14,9 +15,11 @@
#include <algorithm>
#include <Init.h>
#include <MG_Backend/DirectGLES/BackendObject_DirectGLES.h>
#include <MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/SelfTest/DriverBugProbes.h>
// ProbeIndirectInstanceIdIncludesBaseInstance is driven against a fake GLES driver:
// a GLESFunctionsTable populated with captureless lambdas backed by the file-scope
@@ -25,6 +28,13 @@
// ANGLE-style baseInstance-leaking driver, or a failing one.
namespace {
struct FakeDriverState {
// What each of the located-interface-block probe's draws reads back, in the order the
// probe makes them: unlocated control, located subject, located vertex-to-fragment
// control. Empty means "conforming driver" - every read returns the payload - which is
// what keeps this probe invisible to every other test in this file.
std::vector<bool> ioBlockPayloadArrives;
std::size_t ioBlockReads = 0;
std::size_t ioBlockDraws = 0;
// Behavior knobs, configured per test before running the probe.
GLint maxVertexSsboBlocks = 4;
GLint glesMajorVersion = 3;
@@ -433,6 +443,52 @@ namespace {
};
funcs.glBindFramebuffer = [](GLenum, GLuint) {};
funcs.glBindRenderbuffer = [](GLenum, GLuint) {};
// ---- what the located-interface-block probe draws with -------------------------
// Enough of a rasterizer for ProbeLocatedIoBlocksLosePayload to reach a verdict: it
// builds three programs, draws each to a 1x1 target and reads the pixel back, and the
// fake decides what each read returns. Default behaviour is a CONFORMING driver, so
// every test that predates this one sees the probe reach "not affected" and no
// capability it asserts on moves.
funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; };
funcs.glViewport = [](GLint, GLint, GLsizei, GLsizei) {};
funcs.glClearColor = [](GLfloat, GLfloat, GLfloat, GLfloat) {};
funcs.glClear = [](GLbitfield) {};
funcs.glPixelStorei = [](GLenum, GLint) {};
funcs.glColorMask = [](GLboolean, GLboolean, GLboolean, GLboolean) {};
funcs.glIsEnabled = [](GLenum) -> GLboolean { return GL_FALSE; };
funcs.glGetBooleanv = [](GLenum, GLboolean* data) {
if (data == nullptr) return;
for (int i = 0; i < 4; ++i) data[i] = GL_TRUE;
};
funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) {
if (data != nullptr) *data = 0;
};
funcs.glGetProgramInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) {
if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0';
if (length != nullptr) *length = 0;
};
funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) {
if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0';
if (length != nullptr) *length = 0;
};
funcs.glDrawArrays = [](GLenum, GLint, GLsizei) { ++g_fake.ioBlockDraws; };
// One entry of ioBlockPayloadArrives is consumed per draw, in the order the probe makes
// them: the unlocated CONTROL, then the located SUBJECT, then the located
// vertex-to-fragment second control. Past the end of the list the driver is conforming.
funcs.glReadPixels = [](GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void* pixels) {
auto* out = static_cast<unsigned char*>(pixels);
if (out == nullptr) return;
const std::size_t index = g_fake.ioBlockReads++;
const bool arrives = index < g_fake.ioBlockPayloadArrives.size()
? g_fake.ioBlockPayloadArrives[index]
: true;
// 0.25 and 0.5 as the probe's vertex stage wrote them; zeroes are what a stage that
// received nothing reads.
out[0] = arrives ? 0x40 : 0x00;
out[1] = arrives ? 0x80 : 0x00;
out[2] = 0x00;
out[3] = 0xff;
};
funcs.glRenderbufferStorage = [](GLenum, GLenum, GLsizei, GLsizei) {};
funcs.glFramebufferRenderbuffer = [](GLenum, GLenum, GLenum, GLuint) {};
funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) {
@@ -1347,3 +1403,151 @@ TEST(BaseInstanceCapabilities, RequiresTheExtensionAndAllThreeEntryPoints) {
MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(missingEntryPointCaps, funcs));
EXPECT_FALSE(missingEntryPointCaps.SupportsBaseInstance);
}
// ===================== LOCATED INTER-STAGE INTERFACE BLOCKS =====================
//
// The capability that decides whether DirectGLES strips the layout(location) qualifier off a
// tessellation/geometry program's interface blocks, and the environment override that forces
// it either way.
//
// THE MAPPING IS INVERTED ON PURPOSE and that is exactly why it is pinned here: the variable
// is named for the EMULATION ("emit them unlocated"), the capability is named for the DRIVER
// ("located blocks work"), so forcing the emulation ON must set the capability to FALSE. A
// one-line swap of those two arms would leave every other test in the tree green - the unit
// tests drive the pass directly, and the integration lane runs on llvmpipe, which carries a
// located block correctly either way - while silently disabling the repair on the only device
// that needs it.
namespace {
void SetEnvVarForTest(const char* name, const char* value) {
#if defined(_WIN32)
_putenv_s(name, value);
#else
setenv(name, value, 1);
#endif
}
void UnsetEnvVarForTest(const char* name) {
#if defined(_WIN32)
_putenv_s(name, "");
#else
unsetenv(name);
#endif
}
// Sets MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS (or clears it), re-reads the configuration the
// way process start would, and runs the capability fill against the fake driver.
MobileGL::MG_External::GLESCapabilities CapabilitiesWithOverride(
const MobileGL::MG_External::GLESFunctionsTable& funcs, const char* value) {
if (value == nullptr) {
UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
} else {
SetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS", value);
}
MobileGL::MG_ConfigLoader::Init();
MobileGL::MG_External::GLESCapabilities caps;
EXPECT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
return caps;
}
} // namespace
TEST(LocatedIoBlockCapability, TheOverrideMapsOntoTheCapabilityInverted) {
const auto funcs = MakeFakeGLESFunctions();
// ONE TEST, THREE ARMS, IN THIS ORDER, because the Auto arm consults a probe that is
// memoized for the lifetime of the process - splitting them into three test cases would
// make the answer depend on which one gtest happened to run first.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
// ForceOn - "emit the blocks unlocated". The driver is NOT probed, and the capability must
// come out FALSE. This is the assertion the inversion swap breaks.
{
const auto caps = CapabilitiesWithOverride(funcs, "1");
EXPECT_FALSE(caps.SupportsLocatedInterStageIoBlocks)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1 forces the emulation ON, which means "
"declaring that this driver's located interface blocks do NOT work. A true here "
"means the strip is disabled in the one configuration that exists to enable it.";
}
// ForceOff - the negative control. Also unprobed, and the capability must come out TRUE so
// the strip stays off.
{
const auto caps = CapabilitiesWithOverride(funcs, "0");
EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=0 forces located blocks ON, i.e. the "
"emulation off; a false here would strip on every driver regardless of the probe.";
}
// Auto - the setting every real run uses. The capability is the probe's verdict, negated:
// "the blocks lose their payload" is the same statement as "located blocks are not
// supported". On this fake the probe finds a conforming driver, so the capability is true.
{
const auto caps = CapabilitiesWithOverride(funcs, nullptr);
EXPECT_EQ(caps.SupportsLocatedInterStageIoBlocks,
!MobileGL::MG_Util::SelfTest::LocatedIoBlocksLosePayload(funcs).detected)
<< "with the variable unset the capability must follow the driver probe and nothing "
"else";
EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks)
<< "the fake driver carries the probe's payload, so Auto must leave the strip off";
}
UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
MobileGL::MG_ConfigLoader::Init();
}
// The probe's own verdict logic, driven directly rather than through the memoized accessor so
// each shape gets its own answer. Its two controls are the whole design: without them a driver
// that cannot run the shape at all, or one whose interface blocks are broken generally, would
// be reported as having this very specific defect - and would have its locations stripped for
// nothing.
TEST(LocatedIoBlockProbe, ReportsTheDefectOnlyWhenTheUnlocatedControlCarriesThePayload) {
const auto funcs = MakeFakeGLESFunctions();
using MobileGL::MG_Util::SelfTest::ProbeLocatedIoBlocksLosePayload;
// A CONFORMING driver: every draw delivers. No finding.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, true, true};
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected);
// THE AFFECTED DRIVER: the unlocated control delivers, the located subject does not, and
// the located vertex-to-fragment control does. That last one is what scopes the repair to
// tessellation/geometry programs.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, false, true};
{
const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs);
EXPECT_TRUE(measurement.detected);
EXPECT_FALSE(measurement.alsoAffectsVertexToFragment);
}
// ...and a driver that loses the payload even without a geometry stage says so, because the
// repair does not reach that shape and the report must not imply it does.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, false, false};
{
const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs);
EXPECT_TRUE(measurement.detected);
EXPECT_TRUE(measurement.alsoAffectsVertexToFragment);
}
// THE CONTROL FAILING IS NOT A FINDING. A driver that cannot carry an UNLOCATED block
// either has something else wrong with it, and stripping locations would repair nothing
// while changing every tessellation and geometry program on it.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {false, false, false};
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected);
// Neither is a driver the probe cannot even draw on: an inconclusive probe must leave the
// capability exactly as it was before the probe existed.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
auto crippled = MakeFakeGLESFunctions();
crippled.glReadPixels = nullptr;
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(crippled).detected);
EXPECT_EQ(g_fake.ioBlockDraws, 0u) << "an entry-point-gated probe must not draw at all";
}
+900
View File
@@ -1568,6 +1568,15 @@ namespace {
int respecifyCalls = 0;
int flushCalls = 0;
Bool provideMap = true; // false => backend declines, exercising the shadow fallback
// Only recorded by the variant of the ops table that offers ResidentSubData: the
// bytes a CPU write handed the backend for a GPU-ordered landing into an adopted
// store, held back from `gpu` until a readback "retires" them.
struct ResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<ResidentWrite> residentWrites;
int readbackCalls = 0;
};
ZeroCopyMockBackend* g_zeroCopyMock = nullptr;
@@ -1605,6 +1614,39 @@ namespace {
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
};
// The same backend with the GPU-ordered landing ops a staging-ring backend offers: a
// CPU write into an adopted store is queued (the mapping is NOT written through), and
// a readback is what lands the queue before the application reads.
void ZeroCopyMock_ResidentSubData(MG_State::GLState::BufferObject&, SizeT offset, DataPtr data) {
if (!g_zeroCopyMock) return;
auto& write = g_zeroCopyMock->residentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void ZeroCopyMock_ReadbackFromGpu(MG_State::GLState::BufferObject&) {
if (!g_zeroCopyMock) return;
++g_zeroCopyMock->readbackCalls;
for (const auto& write : g_zeroCopyMock->residentWrites) {
// Reported, not asserted: an ASSERT here would return out of the readback and
// leave the remaining landings unapplied, which reads as a different failure.
EXPECT_LE(write.offset + write.bytes.size(), g_zeroCopyMock->gpu.size());
if (write.offset + write.bytes.size() > g_zeroCopyMock->gpu.size()) continue;
Memcpy(g_zeroCopyMock->gpu.data() + write.offset, write.bytes.data(), write.bytes.size());
}
g_zeroCopyMock->residentWrites.clear();
}
const MG_State::GLState::BufferBackendOps kResidentSubDataMockOps = {
.Respecify = ZeroCopyMock_Respecify,
.SubData = ZeroCopyMock_SubData,
.ResidentSubData = ZeroCopyMock_ResidentSubData,
.FlushMappedRange = ZeroCopyMock_Flush,
.OnDestroy = ZeroCopyMock_OnDestroy,
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
};
struct ScopedBackendOps {
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
MG_State::GLState::SetBufferBackendOps(ops);
@@ -2074,3 +2116,861 @@ TEST_F(BufferTest, RedefiningANonAdoptedBufferIsUnchanged) {
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// A NON-persistent write map of an ADOPTED store. glMapBuffer / glMapBufferRange hand
// the application a staging copy regardless of where the store lives, and GL requires
// the bytes it wrote there to be visible to every later command once glUnmapBuffer
// returns. Residency used to come only from a coherent persistent map - which writes
// in place and never has a staging copy - so the unmap simply skipped the copy-back
// for a resident store. Residency now also comes from a shader storage binding
// (EnsureGpuResidentStorage at draw time) and from large-store adoption, both of which
// an application then re-initialises through an ordinary map/write/unmap: the
// conformance suite re-seeds every SSBO that way before each draw, and every re-seed
// after the first draw was dropped on the floor. These pin the landing for each map
// shape, on the backend that writes the coherent mapping in place and on the one that
// takes the bytes for a GPU-ordered landing, plus the shadow path as the control.
namespace {
constexpr SizeT kAdoptedInts = 16;
// A buffer of kAdoptedInts sequential ints, adopted by the mock backend exactly as an
// SSBO binding does at draw time. The per-write counters are zeroed afterwards so a
// test only sees the traffic of the map it makes.
SharedPtr<MG_State::GLState::BufferObject> MakeAdoptedBuffer(ZeroCopyMockBackend& mock, GLenum target,
GLuint& buffer) {
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
EXPECT_NE(bufferObject, nullptr);
if (bufferObject == nullptr) return nullptr;
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(mock.gpu.data()));
mock.subDataCalls = 0;
mock.flushCalls = 0;
mock.respecifyCalls = 0;
return bufferObject;
}
const GLint* GpuInts(const ZeroCopyMockBackend& mock) {
return reinterpret_cast<const GLint*>(mock.gpu.data());
}
} // namespace
TEST_F(BufferTest, ANonPersistentReadWriteRangeMapOfAnAdoptedStoreLandsAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// The conformance suite's shape: the whole store, READ|WRITE, then a full rewrite.
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// A non-persistent map is a staging copy, seeded from the adopted store...
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(i));
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1000 + static_cast<GLint>(i);
// ...that the store does not see until the unmap.
EXPECT_EQ(GpuInts(mock)[0], 0);
bufferObject->ReleaseMemory();
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], 1000 + static_cast<GLint>(i)) << "int " << i;
}
EXPECT_EQ(std::memcmp(bufferObject->MappedData(), mock.gpu.data(), mock.gpu.size()), 0);
// The landing publishes the change for cached consumers...
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
// ...but dispatches no transfer op: the backend keeps no separate copy of an
// adopted store, and its flush op would only upload the mapping onto itself.
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.acquireMapCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlMapBufferWriteOnlyAndReadWriteOfAnAdoptedStoreLandAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// glMapBuffer(GL_WRITE_ONLY): the staging copy is still seeded (no invalidate bit),
// so a partial write keeps the untouched ints.
Uint64 serial = bufferObject->GetChangeSerial();
auto* writeOnly = static_cast<GLint*>(bufferObject->AcquireMemory(true, false, true));
ASSERT_NE(writeOnly, nullptr);
EXPECT_NE(static_cast<void*>(writeOnly), static_cast<void*>(mock.gpu.data()));
writeOnly[0] = 100;
writeOnly[1] = 200;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 2);
EXPECT_EQ(GpuInts(mock)[kAdoptedInts - 1], static_cast<GLint>(kAdoptedInts - 1));
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
// glMapBuffer(GL_READ_WRITE): reads see the previous landing, and the next one lands too.
serial = bufferObject->GetChangeSerial();
auto* readWrite = static_cast<GLint*>(bufferObject->AcquireMemory(true, true, true));
ASSERT_NE(readWrite, nullptr);
EXPECT_EQ(readWrite[0], 100);
EXPECT_EQ(readWrite[1], 200);
readWrite[2] = 300;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 300);
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, AWriteMapInvalidatingAnAdoptedStoreLandsTheWholeRangeAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateBuffer));
ASSERT_NE(mapped, nullptr);
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
// The whole range is undefined by contract, so the application rewrites all of it.
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = -static_cast<GLint>(i) - 1;
bufferObject->ReleaseMemory();
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], -static_cast<GLint>(i) - 1) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A range map at an offset off the alignment grid: the staging store is biased by the
// offset's phase (see AcquireMemoryRange), and the landing has to read from the biased
// start and write to the mapped offset - not from data(), not to 0.
TEST_F(BufferTest, ARangeMapAtAnUnalignedOffsetOfAnAdoptedStoreLandsInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 3..6, i.e. byte offset 12 - inside the first alignment, so the bias is non-zero.
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
ASSERT_NE(range.start % MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT, 0u);
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// Seeded from the right place...
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(kFirst + i));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 500 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// ...and landed in the right place, with everything outside the range untouched.
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 500 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// FLUSH_EXPLICIT on an adopted store: only the flushed bytes land, at the flush, and the
// unmap lands nothing more - the application promised to flush what it wanted kept.
TEST_F(BufferTest, AnExplicitFlushOfAWriteMapOfAnAdoptedStoreLandsOnlyTheFlushedBytes) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 2..13 mapped (offset 8, off the grid again), all of them rewritten...
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 12;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 700 + static_cast<GLint>(i);
// ...but only ints 5..8 (map-relative ints 3..6) flushed.
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 4;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
const Uint64 flushSerial = bufferObject->GetChangeSerial();
EXPECT_GT(flushSerial, baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
auto expectOnlyFlushedBytesLanded = [&](const char* when) {
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const Bool flushed = i >= kFirst + kFlushFirst && i < kFirst + kFlushFirst + kFlushCount;
const GLint expected = flushed ? 700 + static_cast<GLint>(i - kFirst) : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << when << ": int " << i;
}
};
expectOnlyFlushedBytesLanded("after the flush");
bufferObject->ReleaseMemory();
expectOnlyFlushedBytesLanded("after the unmap");
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The other kind of backend: one that takes the bytes for a GPU-ordered landing instead
// of letting the frontend write the coherent mapping in place. The unmap hands it the
// mapped offset and the bias-adjusted bytes, leaves the mapping alone, and marks a GPU
// write outstanding so the next read reconciles through the readback.
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheUnmappedBytesForAGpuOrderedLanding) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 5;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 900 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// The op got exactly the mapped range's bytes at the mapped offset...
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, range.start);
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], 900 + static_cast<GLint>(i)) << "int " << i;
// ...the mapping itself was not written through...
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the pending flag makes the next read pull the landing back first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_TRUE(mock.residentWrites.empty());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 900 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
// A second read has nothing outstanding to reconcile.
bufferObject->AcquireMemory(false, true, false);
EXPECT_EQ(mock.readbackCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAnExplicitlyFlushedRangeTheSameWay) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 800 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 5;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, (kFirst + kFlushFirst) * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kFlushCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
EXPECT_EQ(handed[0], 800 + static_cast<GLint>(kFlushFirst));
EXPECT_EQ(handed[1], 800 + static_cast<GLint>(kFlushFirst + 1));
EXPECT_EQ(mock.flushCalls, 0);
// The unmap of a FLUSH_EXPLICIT map adds nothing.
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The CTS idiom end to end through the GL entry points: an SSBO made resident by a
// draw, re-seeded with glMapBufferRange(READ|WRITE) + glUnmapBuffer.
TEST_F(BufferTest, MapBufferRangeAndUnmapBufferReseedAnAdoptedShaderStorageBuffer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
for (GLint pass = 1; pass <= 3; ++pass) {
auto* mapped = static_cast<GLint*>(
MapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)),
GL_MAP_READ_BIT | GL_MAP_WRITE_BIT));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = pass * 100 + static_cast<GLint>(i);
EXPECT_TRUE(UnmapBuffer(GL_SHADER_STORAGE_BUFFER));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], pass * 100 + static_cast<GLint>(i)) << "pass " << pass << " int " << i;
}
}
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The control: a store the backend declined to adopt keeps the shadow model exactly as
// before - the staging copy is written back into the shadow and the backend's flush op
// carries the range down.
TEST_F(BufferTest, ANonPersistentWriteMapOfAShadowBackedStoreStillFlushesThroughTheBackend) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
mock.flushCalls = 0;
mock.subDataCalls = 0;
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 600 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.flushCalls, 1);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
const auto* shadow = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 600 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(shadow[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// The other three CPU-sourced writes that share the unmap landing's route into an
// adopted store - glBufferSubData, a clear, and a copy - on both kinds of backend: the
// one that lets the frontend write the coherent mapping in place, and the one that takes
// the bytes for a GPU-ordered landing, where the offset it is handed is the only thing
// deciding where they end up.
TEST_F(BufferTest, GlBufferSubDataIntoAnAdoptedStoreLandsInPlaceWithoutABackendTransfer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAGlBufferSubDataAtItsOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), sizeof(updated));
EXPECT_EQ(std::memcmp(mock.residentWrites[0].bytes.data(), updated, sizeof(updated)), 0);
// The mapping itself is left alone until the backend's ordered copy runs.
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlClearBufferSubDataRepeatsItsPatternThroughAnAdoptedStoreInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// A four-byte pattern, so the repeat - not a memset - is what fills the range.
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheExpandedClearPattern) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// The backend takes the FINAL bytes, so the pattern arrives already repeated.
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], value) << "int " << i;
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
namespace {
// A plain (never adopted) buffer of kAdoptedInts ints, each `bias` above its index,
// bound to `target` as the source of a copy.
GLuint MakeCopySource(GLenum target, GLint bias) {
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> bytes(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) bytes[i] = bias + static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), bytes.data(), GL_STATIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
return buffer;
}
} // namespace
TEST_F(BufferTest, GlCopyBufferSubDataIntoAnAdoptedStoreLandsAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
// Deliberately different source and destination offsets: only the destination one
// may decide where the bytes land.
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kDstFirst && i < kDstFirst + kCount)
? 900 + static_cast<GLint>(kSrcFirst + i - kDstFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesACopyAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kDstFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(handed[i], 900 + static_cast<GLint>(kSrcFirst + i)) << "int " << i;
}
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
// A PERSISTENT map of an adopted store is the one write shape that needs no landing at
// all: it wrote the coherent mapping in place. Its explicit flush therefore publishes the
// change and dispatches nothing - not the backend's flush op (whose upload would be the
// mapping onto itself) and not the resident landing op (whose bytes are already there).
TEST_F(BufferTest, AnExplicitFlushOfAPersistentMapOfAnAdoptedStoreOnlyPublishesTheChange) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
// The application writes the store itself: the map IS the adopted memory.
EXPECT_EQ(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data() + range.start));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 400 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_TRUE(bufferObject->HasDefinedContent());
// Every byte the map wrote is in the store, flushed or not - it was written there.
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(GpuInts(mock)[kFirst + i], 400 + static_cast<GLint>(i)) << "int " << i;
}
const Uint64 flushSerial = bufferObject->GetChangeSerial();
bufferObject->ReleaseMemory();
EXPECT_EQ(bufferObject->GetChangeSerial(), flushSerial); // the unmap adds nothing
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A flush of nothing wrote no byte, so it may not report the store as written: an
// orphaning respecification prices a "has content" store as a full-size upload.
TEST_F(BufferTest, AZeroLengthExplicitFlushOfAnAdoptedStoreLeavesItUndefined) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), nullptr,
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
ASSERT_FALSE(bufferObject->HasDefinedContent());
ASSERT_TRUE(bufferObject->EnsureGpuResidentStorage());
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)},
BufferMappingAccessBit::Write |
BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit);
ASSERT_NE(mapped, nullptr);
bufferObject->FlushMemoryRange(0, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_FALSE(bufferObject->HasDefinedContent());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A write map that discards the range it maps reads nothing of the store: its staging
// copy is not seeded from it. Reconciling an adopted store at map time would run the
// backend's drain-and-wait for no reader, once per map, on the streaming arena the
// adoption exists to keep cheap - so it is deferred, not dropped: the first map that DOES
// read the bytes still pays for it, and every queued landing is still applied, in order.
TEST_F(BufferTest, AWriteMapThatDiscardsWhatItMapsDoesNotReconcileAnAdoptedStoreAtMapTime) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// An earlier write is queued for its GPU-ordered landing...
const GLint firstInt = 55;
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the map that discards its range does not wait for it.
constexpr SizeT kFirst = 8;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateRange));
ASSERT_NE(mapped, nullptr);
EXPECT_EQ(mock.readbackCalls, 0);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 300 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
ASSERT_EQ(mock.residentWrites.size(), 2u);
// The first read reconciles both landings, oldest first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_EQ(readBack[0], firstInt);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(readBack[kFirst + i], 300 + static_cast<GLint>(i)) << "int " << i;
// The control: a map that keeps what it maps still reconciles before seeding.
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto* seeded = static_cast<GLint*>(
bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(seeded, nullptr);
EXPECT_EQ(mock.readbackCalls, 2);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(seeded[i], 300 + static_cast<GLint>(i)) << "int " << i;
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Adoption releases the CPU shadow, and a persistent map that did not itself adopt
// (FLUSH_EXPLICIT is excluded from adoption) handed the application a pointer into that
// shadow which GL keeps valid while the buffer is drawn with - which is exactly when a
// storage binding asks for residency. So a mapped buffer keeps the shadow model.
TEST_F(BufferTest, AStorageBindingDoesNotAdoptTheStoreWhileTheApplicationHoldsAMapping) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
const auto* shadowBase = bufferObject->MappedData();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(static_cast<const void*>(mapped), static_cast<const void*>(shadowBase + range.start));
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.acquireMapCalls, 0);
// The application's pointer is still the store's: it survived the binding.
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(shadowBase));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 250 + static_cast<GLint>(i);
bufferObject->FlushMemoryRange(0, kCount * sizeof(GLint));
EXPECT_EQ(mock.flushCalls, 1);
const auto* shadowInts = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(shadowInts[kFirst + i], 250 + static_cast<GLint>(i));
// Unmapped, the next binding adopts as usual.
bufferObject->ReleaseMemory();
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Respecifying a store hands any adoption back and replaces the bytes, so the staged
// bytes of a map that is still live have nowhere to land: copying a whole mapped range
// into storage that is released on the next line is pure waste.
TEST_F(BufferTest, RespecifyingAStoreWhileItIsMappedDoesNotLandTheStagedBytesIntoIt) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, RespecifyingAShadowBackedStoreWhileItIsMappedPushesNoRangeDown) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_ARRAY_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts, 7);
BufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
mock.flushCalls = 0;
mock.subDataCalls = 0;
mock.respecifyCalls = 0;
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
+9
View File
@@ -64,6 +64,15 @@ set(LINK_LIBRARIES
include(GoogleTest)
gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
if (MSVC)
# The GL headers declare gl* as dllimport on Windows, so any test that pulls
# GetProcAddress.cpp out of the static library references __imp_gl*, which only
# resolves when the in-library entry-point definitions are part of the link.
# Applies to every test executable below, the way the DirectVulkan and
# integration test targets already do it for themselves.
add_link_options(/WHOLEARCHIVE:MobileGL_s)
endif()
add_subdirectory(BackendLoader)
add_subdirectory(Buffer)
# The heap-address-is-not-an-identity invariant the backends' per-object memos
@@ -1034,14 +1034,69 @@ namespace {
if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a};
}
// What the blend block of SyncRenderState pushed. Enough to answer the two questions the
// dual-source cases ask: is blending on for a draw buffer, and which factor enums reached
// the driver.
struct RecordedBlend {
Bool enabled = false;
Bool enableSeen = false;
Bool factorsSeen = false;
GLenum srcRGB = 0, dstRGB = 0, srcAlpha = 0, dstAlpha = 0;
};
RecordedBlend g_driverBlend[kRecordedDrawBuffers];
void ResetRecordedBlend() {
for (auto& recorded : g_driverBlend) recorded = {};
}
void RecordBlendEnable(Bool enabled) {
for (auto& recorded : g_driverBlend) {
recorded.enabled = enabled;
recorded.enableSeen = true;
}
}
void RecordBlendFactors(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
for (auto& recorded : g_driverBlend) {
recorded.factorsSeen = true;
recorded.srcRGB = srcRGB;
recorded.dstRGB = dstRGB;
recorded.srcAlpha = srcAlpha;
recorded.dstAlpha = dstAlpha;
}
}
void StubViewport(GLint, GLint, GLsizei, GLsizei) {}
void StubScissor(GLint, GLint, GLsizei, GLsizei) {}
void StubEnable(GLenum) {}
void StubDisable(GLenum) {}
void StubEnablei(GLenum, GLuint) {}
void StubDisablei(GLenum, GLuint) {}
void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {}
void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {}
void StubEnable(GLenum cap) {
if (cap == GL_BLEND) RecordBlendEnable(true);
}
void StubDisable(GLenum cap) {
if (cap == GL_BLEND) RecordBlendEnable(false);
}
void StubEnablei(GLenum cap, GLuint index) {
if (cap == GL_BLEND && index < kRecordedDrawBuffers) {
g_driverBlend[index].enabled = true;
g_driverBlend[index].enableSeen = true;
}
}
void StubDisablei(GLenum cap, GLuint index) {
if (cap == GL_BLEND && index < kRecordedDrawBuffers) {
g_driverBlend[index].enabled = false;
g_driverBlend[index].enableSeen = true;
}
}
void StubBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
RecordBlendFactors(srcRGB, dstRGB, srcAlpha, dstAlpha);
}
void StubBlendFuncSeparatei(GLuint index, GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
if (index >= kRecordedDrawBuffers) return;
g_driverBlend[index].factorsSeen = true;
g_driverBlend[index].srcRGB = srcRGB;
g_driverBlend[index].dstRGB = dstRGB;
g_driverBlend[index].srcAlpha = srcAlpha;
g_driverBlend[index].dstAlpha = dstAlpha;
}
void StubBlendEquationSeparate(GLenum, GLenum) {}
void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {}
void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
@@ -1066,7 +1121,9 @@ namespace {
// into a driver that this process never made current.
class ScopedRenderStateDriverStubs {
public:
ScopedRenderStateDriverStubs():
// dualSourceBlendSupported models GL_EXT_blend_func_extended on the ES driver, which is
// the one capability in here that a real device is commonly WITHOUT.
explicit ScopedRenderStateDriverStubs(Bool dualSourceBlendSupported = true):
m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) {
auto& gl = MG_Backend::DirectGLES::g_GLESFuncs;
gl = MG_External::GLESFunctionsTable{};
@@ -1102,9 +1159,10 @@ namespace {
caps.SupportsIndexedColorMask = true;
caps.SupportsSrgbWriteControl = false;
caps.SupportsPolygonMode = false;
caps.SupportsDualSourceBlend = true;
caps.SupportsDualSourceBlend = dualSourceBlendSupported;
ResetRecordedColorMasks();
ResetRecordedBlend();
// The viewport and scissor blocks fall back to querying the surface size when the
// frontend's rectangle is degenerate, and there is no surface in this process.
MG_Impl::GLImpl::Viewport(0, 0, 4, 4);
@@ -1119,6 +1177,11 @@ namespace {
// The shadow now describes pushes that went to the stubs, not to any driver.
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// Blend state is per-CONTEXT and the context outlives the fixture, so a case that
// enabled blending or asked for an exotic factor has to put it back or every later
// case in this binary inherits it.
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_ONE, GL_ZERO);
}
private:
@@ -1253,6 +1316,143 @@ TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffe
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// --- Dual-source blending without GL_EXT_blend_func_extended ------------------------------------
//
// GL_SRC1_* blend factors are core GL since 3.3, GLES core has nothing equivalent, and the ES
// driver may or may not carry GL_EXT_blend_func_extended. When it does, the factors translate and
// blend properly - the positive case below. When it does not, the blend block used to
// THROW_EXCEPTION, which is a plain `throw` (MG_Util/Types.h) with no catch anywhere in MG_Impl or
// MG_Backend, so it unwound out through the C GL ABI and killed the process over one unsupported
// blend factor. It now DECLINES: the draw buffer is pushed with blending off and neutral One/Zero
// factors, and the loss is logged once.
//
// Both halves are asserted at the seam that matters - what the ES driver is actually handed -
// because a GL_SRC1_* enum reaching a driver without the extension is the other failure mode: the
// driver answers GL_INVALID_ENUM, keeps whatever factors were set before, and mis-blends silently.
TEST_F(FramebufferTest, DualSourceBlendFactorsReachTheDriverWhenTheExtensionIsThere) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/true);
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "GL_SRC1_* is core since 3.3; glBlendFunc must take it";
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled) << "nothing may decline a blend the driver can do";
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].srcAlpha, static_cast<GLenum>(GL_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR));
}
TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensionIsMissing) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/false);
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR)
<< "the FRONTEND accepts the factor whatever the driver can do - the decline is a backend decision";
ResetRecordedBlend();
// The whole point: this used to be `throw std::runtime_error` straight through the GL ABI.
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false));
ASSERT_TRUE(g_driverBlend[0].enableSeen) << "the blend enable still has to be pushed";
EXPECT_FALSE(g_driverBlend[0].enabled) << "a blend the driver cannot do is declined, not attempted";
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA))
<< "draw buffer " << i << ": no GL_SRC1_* enum may reach a driver without the extension";
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].srcAlpha, static_cast<GLenum>(GL_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
}
// The decline is scoped to the offending factor, not to blending as a whole: an ordinary
// blend on the same driver still goes through, and the SAME sync that declined the first one
// is what has to push it.
MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled);
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// The half the first version of the decline missed: the FACTOR push is not gated on Enabled, so
// GL_BLEND being OFF does not keep a GL_SRC1_* enum away from a driver that cannot parse it. This
// is the sequence - `glDisable(GL_BLEND); glBlendFunc(GL_SRC1_ALPHA, ...)` then any draw or clear -
// and it needs no dual-source shader at all, which is why it survived both the enabled-path unit
// case above and the integration scenario (that one skips on exactly the extension-less lanes this
// concerns, because its probe needs a dual-source program to render).
//
// What a leaked enum costs: the driver answers GL_INVALID_ENUM and keeps its previous factors, so
// the error sits in the ES context's own queue for the next internal `glGetError() == GL_NO_ERROR`
// probe to read as its own failure, and this backend's shadow records factors the context rejected.
TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/false);
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
ResetRecordedBlend();
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false));
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_FALSE(g_driverBlend[i].enabled) << "draw buffer " << i << ": blending was never enabled";
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA))
<< "draw buffer " << i
<< ": a GL_SRC1_* enum must not reach a driver without the extension even with GL_BLEND off";
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].srcAlpha, static_cast<GLenum>(GL_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
}
// A clear reaches the same block by the same route (SyncRenderState(forColorClear=true)), and
// the flag only steers the alpha-widen colour mask, so it must not reopen this either.
MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
ResetRecordedBlend();
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true));
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_COLOR)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR)) << "draw buffer " << i;
}
// And the shadow records what was PUSHED, not what the frontend holds - otherwise the next
// switch to an ordinary factor diffs against state the ES context never received.
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled) << "the enable has to be pushed - the shadow said 'off' because it was";
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// The capable driver is unaffected by the ungating: GL_BLEND off with SRC1 factors set is a state
// an application may legitimately hold, and the factors still have to reach a driver that parses
// them - otherwise the next glEnable(GL_BLEND) would blend against neutralised state.
TEST_F(FramebufferTest, DualSourceFactorsWithBlendingDisabledStillReachACapableDriver) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/true);
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_FALSE(g_driverBlend[0].enabled);
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// --- glFramebufferTexture error conditions (GL 4.6 core 9.2.8) ---------------------------------
//
// Four of them were missing from the bound-target path while its DSA sibling
+28
View File
@@ -23,6 +23,17 @@ target_link_libraries(
${LINK_LIBRARIES}
)
if (MSVC)
# This test compiles library sources of its own; pulling the whole static
# library in as well (the directory-wide MG_Test link option) would define
# them twice, so that option is dropped for this one target.
get_target_property(_program_util_link_options ProgramUtilTest LINK_OPTIONS)
if (_program_util_link_options)
list(REMOVE_ITEM _program_util_link_options /WHOLEARCHIVE:MobileGL_s)
set_target_properties(ProgramUtilTest PROPERTIES LINK_OPTIONS "${_program_util_link_options}")
endif()
endif()
add_executable(
ProgramTest
ProgramTest.cpp
@@ -180,6 +191,22 @@ target_link_libraries(
${LINK_LIBRARIES}
)
add_executable(
TessellationLinkTest
TessellationLinkTest.cpp
)
target_include_directories(TessellationLinkTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
TessellationLinkTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ProgramPipelineCompositeTest
ProgramPipelineCompositeTest.cpp
@@ -229,6 +256,7 @@ gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramPipelineCompositeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(XfbBlockVaryingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(TessellationLinkTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the
# compile pool so there is something in flight to race against.
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
+125
View File
@@ -4023,6 +4023,131 @@ void main() { mgColor = vec4(1.0); }
)";
} // namespace
// Two fragment outputs on ONE location with DIFFERENT colour indices is not an aliasing error -
// it is dual-source blending (GL 4.6 core 11.1.3 / ARB_blend_func_extended, core since 3.3), and
// the GL_SRC1_* blend factors have nothing to read without it. The link-time aliasing check keyed
// on the colour number alone, so every such program failed to link with "alias color number 0"
// and the whole feature was unreachable from shader-side GLSL.
TEST_F(ProgramTest, FragmentOutputsMayShareALocationWhenTheirColorIndexDiffers) {
constexpr const char* dualSourceFs = R"(#version 460 core
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main() { fragColor0 = vec4(1.0); fragColor1 = vec4(0.5); }
)";
const GLuint program = LinkStages({{GL_VERTEX_SHADER, kPassthroughVs}, {GL_FRAGMENT_SHADER, dualSourceFs}});
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
ASSERT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
// Both outputs are active and both sit on colour number 0 - which is the shape that used to be
// refused. (glGetFragDataIndex still answers 0 for the index-1 output: it reports only what
// glBindFragDataLocationIndexed bound, and reflecting the shader-side qualifier is a separate
// gap, so it is deliberately not asserted here.)
EXPECT_EQ(GetFragDataLocation(program, "fragColor0"), 0);
EXPECT_EQ(GetFragDataLocation(program, "fragColor1"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The check it must NOT stop making: two outputs on the same colour number AND the same index
// really do alias, and that link has to fail. Aliased through glBindFragDataLocation rather than
// through two `layout(location = 0)` qualifiers on purpose - the qualifier form is caught by
// glslang at COMPILE time, so it would never reach the link-time rule this pins.
TEST_F(ProgramTest, FragmentOutputsSharingAColorNumberAtTheSameIndexStillFailToLink) {
constexpr const char* twoOutputFs = R"(#version 460 core
out vec4 fragColorA;
out vec4 fragColorB;
void main() { fragColorA = vec4(1.0); fragColorB = vec4(0.5); }
)";
const GLuint program = CreateProgram();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &kPassthroughVs, nullptr);
CompileShader(vs);
AttachShader(program, vs);
DeleteShader(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &twoOutputFs, nullptr);
CompileShader(fs);
AttachShader(program, fs);
DeleteShader(fs);
BindFragDataLocation(program, 0, "fragColorA");
BindFragDataLocation(program, 0, "fragColorB");
LinkProgram(program);
GLint linkStatus = GL_TRUE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_FALSE);
char infoLog[512] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
EXPECT_NE(std::string(infoLog).find("alias color number"), std::string::npos) << infoLog;
// ...and the same pair separated by the colour INDEX links, which is the whole point of the
// key being a pair.
BindFragDataLocationIndexed(program, 0, 1, "fragColorB");
LinkProgram(program);
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
// An API colour index of ZERO is "no override", not "index 0". glBindFragDataLocation is
// glBindFragDataLocationIndexed with index 0 (GL_Program.cpp), so the blanket-bind pattern -
// portable code that binds every output name it knows about, without caring about dual-source -
// writes a real 0 into the frag-data index map for an output whose shader qualifier says 1.
// Reading that 0 as an override collapsed both outputs onto slot (0,0) and failed the link as an
// alias, while the IO resolver had left the qualifier at 1 and the emitted SPIR-V still carried
// Index 1 - validation rejecting a program the backend had already built correctly.
//
// The rule pinned here is the codebase's (non-zero API index wins, zero falls back to the shader
// qualifier), which is also what GL 4.6 core 15.2.3 gives for THIS shape: a shader layout
// qualifier is used and the bound value ignored.
TEST_F(ProgramTest, AnApiColorIndexOfZeroDoesNotOverrideTheShaderIndexQualifier) {
constexpr const char* dualSourceFs = R"(#version 460 core
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main() { fragColor0 = vec4(1.0); fragColor1 = vec4(0.5); }
)";
const GLuint program = CreateProgram();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &kPassthroughVs, nullptr);
CompileShader(vs);
AttachShader(program, vs);
DeleteShader(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &dualSourceFs, nullptr);
CompileShader(fs);
AttachShader(program, fs);
DeleteShader(fs);
// The blanket bind: colour number 0, index 0, on the output the shader put at index 1.
BindFragDataLocation(program, 0, "fragColor0");
BindFragDataLocation(program, 0, "fragColor1");
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
// The explicit indexed form with a NON-zero index is still an override, and still links.
BindFragDataLocationIndexed(program, 0, 1, "fragColor1");
LinkProgram(program);
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE);
EXPECT_EQ(GetFragDataIndex(program, "fragColor1"), 1);
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
TEST_F(ProgramTest, GetProgramivReportsTheGeometryStageLinkProperties) {
constexpr const char* gs = R"(#version 460 core
layout(triangles, invocations = 3) in;
@@ -0,0 +1,285 @@
// MobileGL - MobileGL/MG_Test/Program/TessellationLinkTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Link-time properties of programs that carry a tessellation control stage. GPU-free:
// everything asserted here is a property of the link, not of any driver.
//
// Two independent defects live here, both found by KHR-GL4x.tessellation_shader:
//
// (1) The transform-feedback capture stage. GL 4.6 core 11 makes the tessellation CONTROL
// shader a vertex-processing stage like the other three, so in a separable program whose
// only stage is a TCS it is the LAST vertex-processing stage and therefore the capture
// stage - such a program must link with transform-feedback varyings requested. MobileGL
// searched {geometry, tessellation evaluation, vertex} only and refused the link with
// "Transform feedback varyings requested but the program has no vertex-processing stage",
// failing KHR-GL4x.tessellation_shader.single.xfb_captures_data_from_correct_stage on all
// three API versions (esextcTessellationShaderXFB.cpp:390-416 passes should_succeed=true
// for a non-ES context; ES demands the opposite, which is why the new arm is documented as
// desktop-GL-only at the search site).
//
// (2) `patch out T name[N]` against `patch in T name[N]`. Legal, identically spelled on both
// sides, and rejected until the glslang fork was re-pinned at d89cf443 - see the last case,
// which carries the diagnosis and now guards the pin.
#include <gtest/gtest.h>
#include <ios>
#include <string>
#include <utility>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class TessellationLinkTest: public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
};
std::string ShaderLog(GLuint shader) {
char log[4096] = "";
GetShaderInfoLog(shader, sizeof(log), nullptr, log);
return std::string(log);
}
std::string LinkLog(GLuint program) {
char log[4096] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}
GLint Programiv(GLuint program, GLenum pname) {
GLint value = -1;
GetProgramiv(program, pname, &value);
return value;
}
// Attaches one compiled shader of each requested stage. Compilation is asserted, so a
// failure here is a shader bug in the test rather than a link result.
GLuint MakeProgram(const std::vector<std::pair<GLenum, const char*>>& stages, Bool separable) {
const GLuint program = CreateProgram();
if (separable) {
ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
}
for (const auto& [type, source]: stages) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
GLint compiled = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << "stage 0x" << std::hex << type << "\n" << ShaderLog(shader);
AttachShader(program, shader);
}
return program;
}
// The conformance suite's own tessellation control shader
// (esextcTessellationShaderXFB.cpp:360-381), with the ES-only ${...} expansions dropped -
// on a desktop context they expand to nothing.
constexpr const char* kCtsTessControl = R"(#version 460 core
layout (vertices=4) out;
in BLOCK_INOUT { vec4 value; } user_in[];
out BLOCK_INOUT { vec4 value; } user_out[];
void main()
{
gl_out [gl_InvocationID].gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
user_out [gl_InvocationID].value = vec4(2.0, 3.0, 4.0, 5.0);
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
}
)";
// A tessellation control shader IS a vertex-processing stage (GL 4.6 core 11), and in a
// TCS-only separable program it is the last one - so it is the capture stage and the link
// must succeed with the block member resolved against ITS outputs.
TEST_F(TessellationLinkTest, TcsOnlySeparableProgramWithXfbVaryingsLinks) {
const GLuint program = MakeProgram({{GL_TESS_CONTROL_SHADER, kCtsTessControl}}, /*separable=*/true);
const GLchar* const varyings[1] = {"BLOCK_INOUT.value"};
TransformFeedbackVaryings(program, 1, varyings, GL_SEPARATE_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
// The request resolved rather than being quietly dropped: the interface reports it back.
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 1);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_BUFFER_MODE), GL_SEPARATE_ATTRIBS);
GLchar name[128] = {'\0'};
GLsizei length = 0;
GLsizei size = 0;
GLenum type = 0;
GetTransformFeedbackVarying(program, 0, sizeof(name), &length, &size, &type, name);
EXPECT_EQ(std::string(name, name + (length < 0 ? 0 : length)), "BLOCK_INOUT.value");
EXPECT_EQ(type, static_cast<GLenum>(GL_FLOAT_VEC4));
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// Control: the same program with no capture request. It linked before the fix too, which is
// what keeps this case honest about WHICH half of the link moved.
TEST_F(TessellationLinkTest, TcsOnlySeparableProgramWithoutXfbVaryingsLinks) {
const GLuint program = MakeProgram({{GL_TESS_CONTROL_SHADER, kCtsTessControl}}, /*separable=*/true);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 0);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// A program with no vertex-processing stage at all still has to be refused - the fix widened
// the search, it did not remove the check.
TEST_F(TessellationLinkTest, FragmentOnlySeparableProgramWithXfbVaryingsStillFailsToLink) {
constexpr const char* fs = R"(#version 460 core
out vec4 color;
void main() { color = vec4(1.0); }
)";
const GLuint program = MakeProgram({{GL_FRAGMENT_SHADER, fs}}, /*separable=*/true);
const GLchar* const varyings[1] = {"color"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE);
EXPECT_NE(LinkLog(program).find("no vertex-processing stage"), std::string::npos) << LinkLog(program);
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
constexpr const char* kPassthroughVs = R"(#version 460 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
constexpr const char* kTcsWithPatchScalar = R"(#version 460 core
layout (vertices = 3) out;
patch out vec4 tcs_patch;
out vec4 tcs_per_vertex[];
void main() {
tcs_patch = vec4(1.0);
tcs_per_vertex[gl_InvocationID] = vec4(2.0);
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0; gl_TessLevelOuter[1] = 1.0; gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
constexpr const char* kTesWithPatchScalar = R"(#version 460 core
layout (triangles) in;
patch in vec4 tcs_patch;
in vec4 tcs_per_vertex[];
out vec4 tes_out;
void main() {
tes_out = tcs_patch + tcs_per_vertex[0];
gl_Position = gl_in[0].gl_Position;
}
)";
// The capture stage of a COMPLETE pipeline is unchanged by the widened search: tessellation
// control sits AFTER tessellation evaluation in the order, so a program that has both still
// resolves its capture names against the EVALUATION stage's outputs. Both halves are pinned -
// an evaluation output resolves, a control output does not.
TEST_F(TessellationLinkTest, CompletePipelineStillCapturesAtTheEvaluationStage) {
{
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
const GLchar* const varyings[1] = {"tes_out"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 1);
}
{
// tcs_per_vertex is an output of the CONTROL stage, which is not the capture stage
// here. Resolving it would mean capturing at the wrong stage, so the link must fail.
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
const GLchar* const varyings[1] = {"tcs_per_vertex"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE) << LinkLog(program);
}
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
// A patch-qualified SCALAR crosses the TCS/TES boundary today. It is the control for the
// array case below: same qualifier, same stages, only the arrayness differs.
TEST_F(TessellationLinkTest, PatchQualifiedScalarLinksAcrossTheTessellationStages) {
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// `patch out int a[N]` against `patch in int a[N]`: legal GLSL, identical spellings, and until
// the glslang fork was re-pinned at d89cf443 refused with "Array sizes must be compatible"
// while printing the two sides as the same type. That is what failed
// KHR-GL4x.tessellation_shader.tessellation_shader_tc_barriers.* on all three API versions.
//
// The defect was one asymmetric clause in glslang, never in MobileGL:
// 3rdparty/glslang/glslang/MachineIndependent/linkValidate.cpp, TIntermediate::isIoResizeArray.
// The TessControl arm is guarded with `&& ! type.getQualifier().patch`; the TessEvaluation arm
// was not. A patch-qualified array therefore answered false on the control side and true on the
// evaluation side, and the caller's dimension arithmetic (linkValidate.cpp:1200-1218) computed
// (numDim - firstDim) == (unitNumDim - unitFirstDim) as (1 - 0) == (1 - 1), i.e. false. The
// fork now mirrors the control arm, so both sides answer false, the comparison falls to
// sameArrayness, and identical int[16] declarations match.
//
// A hard assertion, with no escape hatch: this case carried a message-matched GTEST_SKIP while
// the fix was outstanding, and leaving it in after the pin moved would turn a rolled-back fork
// into a silent skip instead of the failure it should be.
TEST_F(TessellationLinkTest, PatchQualifiedArrayLinksAcrossTheTessellationStages) {
constexpr const char* tcs = R"(#version 460 core
layout (vertices = 3) out;
patch out int tcs_patch_result[16];
void main() {
for (int i = 0; i < 16; ++i) { tcs_patch_result[i] = i; }
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0; gl_TessLevelOuter[1] = 1.0; gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
constexpr const char* tes = R"(#version 460 core
layout (triangles) in;
patch in int tcs_patch_result[16];
out vec4 tes_out;
void main() {
tes_out = vec4(float(tcs_patch_result[0] + tcs_patch_result[15]));
gl_Position = gl_in[0].gl_Position;
}
)";
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, tcs},
{GL_TESS_EVALUATION_SHADER, tes}},
/*separable=*/true);
LinkProgram(program);
const std::string log = LinkLog(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE)
<< "a patch-qualified array must cross the TCS/TES boundary; if this says \"Array sizes "
"must be compatible\" the glslang fork pin has lost the isIoResizeArray patch guard.\n"
<< log;
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
} // namespace
+70 -2
View File
@@ -827,10 +827,11 @@ TEST_F(QueryTest, DisableTimerQueryFeatureMatchesEnvironment) {
// ---------------------------------------------------------------------------------------------
TEST_F(QueryTest, PipelineStatisticsTargetsAreAcceptedAndReportZeroCounterBits) {
// The NINE unconditional targets. The two tessellation ones are conditional on tessellation
// support and have their own test below.
static constexpr GLenum kTargets[] = {
GL_VERTICES_SUBMITTED, GL_PRIMITIVES_SUBMITTED,
GL_VERTEX_SHADER_INVOCATIONS, GL_TESS_CONTROL_SHADER_PATCHES,
GL_TESS_EVALUATION_SHADER_INVOCATIONS, GL_GEOMETRY_SHADER_INVOCATIONS,
GL_VERTEX_SHADER_INVOCATIONS, GL_GEOMETRY_SHADER_INVOCATIONS,
GL_GEOMETRY_SHADER_PRIMITIVES_EMITTED, GL_FRAGMENT_SHADER_INVOCATIONS,
GL_COMPUTE_SHADER_INVOCATIONS, GL_CLIPPING_INPUT_PRIMITIVES,
GL_CLIPPING_OUTPUT_PRIMITIVES,
@@ -872,6 +873,73 @@ TEST_F(QueryTest, PipelineStatisticsTargetsAreAcceptedAndReportZeroCounterBits)
}
}
// GL_TESS_CONTROL_SHADER_PATCHES / GL_TESS_EVALUATION_SHADER_INVOCATIONS are the two
// pipeline-statistics targets ARB_pipeline_statistics_query makes CONDITIONAL on tessellation
// support, and the extension string is the only thing an application can read to decide whether
// an implementation has it. So the target and the string have to move together: accepting a
// tessellation-conditional token while withholding the string that announces the condition is
// self-contradictory, and the conformance suite catches exactly that contradiction
// (KHR-GL46.pipeline_statistics_query_tests_ARB.api_coverage_unsupported_calls demands
// GL_INVALID_ENUM for every target its own probe calls unsupported, and its probe for these two
// is `compatibility(4,0) || GL_ARB_tessellation_shader` - a CORE context fails the first half).
//
// Written against the advertisement rather than against today's answer on purpose: the day a
// backend starts emitting GL_ARB_tessellation_shader this test keeps passing and keeps pinning
// the coupling, and it fails loudly if only one of the two halves moves.
TEST_F(QueryTest, TessellationPipelineStatisticsTargetsFollowTheTessellationShaderAdvertisement) {
const auto* extensionsString =
reinterpret_cast<const char*>(MG_Impl::GLImpl::GetString(GL_EXTENSIONS));
ASSERT_NE(extensionsString, nullptr);
const Bool advertised = String(extensionsString).find("GL_ARB_tessellation_shader") != String::npos;
const GLenum expectedError = advertised ? GL_NO_ERROR : GL_INVALID_ENUM;
static constexpr GLenum kTessTargets[] = {
GL_TESS_CONTROL_SHADER_PATCHES,
GL_TESS_EVALUATION_SHADER_INVOCATIONS,
};
for (const GLenum target: kTessTargets) {
GLuint id = 0;
MG_Impl::GLImpl::GenQueries(1, &id);
ASSERT_NE(id, 0u);
MG_Impl::GLImpl::BeginQuery(target, id);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), expectedError)
<< "glBeginQuery on tessellation pipeline-statistics target 0x" << std::hex << target
<< " must agree with the GL_ARB_tessellation_shader advertisement";
if (advertised) {
MG_Impl::GLImpl::EndQuery(target);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLint counterBits = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_QUERY_COUNTER_BITS, &counterBits);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(counterBits, 0);
} else {
// Refused at glEndQuery too, not just at glBeginQuery: a target the implementation
// does not have is not half-accepted.
MG_Impl::GLImpl::EndQuery(target);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_ENUM);
// GL_QUERY_COUNTER_BITS still answers the honest zero rather than an error - the
// getter has never validated its target, and zero is what "no such counter" reads as
// (GL 4.6 core 4.2.1), so the refusal costs no information.
GLint counterBits = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_QUERY_COUNTER_BITS, &counterBits);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(counterBits, 0);
// And GL_CURRENT_QUERY reads as "no query" rather than tracking a slot that was
// never opened.
GLint current = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_CURRENT_QUERY, &current);
EXPECT_EQ(current, 0);
}
MG_Impl::GLImpl::DeleteQueries(1, &id);
while (MG_Impl::GLImpl::GetError() != GL_NO_ERROR) {
}
}
}
// The negative half the conformance case actually asserts: an object already latched onto one
// pipeline-statistics target must refuse a different one with GL_INVALID_OPERATION. This is what
// per-target active slots buy - a single shared slot would have reported "a query is already
+17
View File
@@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
add_executable(
DriverBugProbesTest
DriverBugProbesTest.cpp
PersistentBufferOrderingProbeTest.cpp
)
target_include_directories(DriverBugProbesTest PRIVATE
@@ -30,6 +31,22 @@ target_link_libraries(DriverBugProbesTest PRIVATE
${LINK_LIBRARIES}
)
add_executable(
PrimitivesGeneratedNoXfbProbeTest
PrimitivesGeneratedNoXfbProbeTest.cpp
)
target_include_directories(PrimitivesGeneratedNoXfbProbeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(PrimitivesGeneratedNoXfbProbeTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(DriverPostIterationRPWitnessTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(DriverBugProbesTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(PrimitivesGeneratedNoXfbProbeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -26,6 +26,7 @@ using MobileGL::MG_Util::SelfTest::ProbeGeometryStageSsboWriteAfterEmitDropped;
using MobileGL::MG_Util::SelfTest::ProbeImageLocationPerNameBudget;
using MobileGL::MG_Util::SelfTest::ProbeImageWriteReadCoherencyResidual;
using MobileGL::MG_Util::SelfTest::ProbeBlitIgnoresDestinationArrayLayer;
using MobileGL::MG_Util::SelfTest::ProbeCopyImageMirrorsPacked16FieldOrder;
using MobileGL::MG_Util::SelfTest::ProbeExplicitVertexInputLocationCeiling;
using MobileGL::MG_Util::SelfTest::ProbeR32FMultisampleSwizzleCorruption;
@@ -107,6 +108,28 @@ namespace {
bool blitIgnoresDestinationLayer = false;
bool blitIgnoresSourceLayer = false;
// Probe 7: the driver stores a WHOLE 16-bit packed ALLOCATION with its fields packed
// from the other end of the word - on the measured device, every level of the probe's
// 30x30x12 three-level array, while same-shape plain-2D images stay in the canonical
// order. Modelled at the raw copy, which is the only path that can observe it (uploads
// and readbacks of the same image decode the driver's own layout consistently): a copy
// whose SOURCE is any level of the mirrored allocation delivers the mirrored
// re-encoding, which the plain-2D readback then decodes with the non-REV order -
// exactly the 0x0047 -> 0x8C20 arithmetic the affected Mali hands back. The mirror
// only engages for the allocation the failures were measured on - a THREE-level
// 30x30x12 array - so a probe that stopped building the triggering shape (fewer
// levels, other dimensions) stops detecting, which is exactly what these tests are
// for.
bool packed16ArrayAllocationMirrored = false;
// "Not this bug": the UPLOAD corrupts, so the array's own direct readback is already
// wrong. The probe's round-trip control must veto the verdict - the widening's
// raw-copy reasoning says nothing about an upload defect.
bool packed16UploadCorrupted = false;
// The inconclusive path: the copy silently lands nothing, so every destination keeps
// its 0xFFFF fill - a value that is neither the word nor its mirror - and the 2D-to-2D
// machinery control fails first.
bool packed16CopyDoesNothing = false;
// ---- object bookkeeping ---------------------------------------------
GLenum pendingError = GL_NO_ERROR;
GLuint nextShaderId = 1;
@@ -136,7 +159,34 @@ namespace {
std::map<GLuint, std::array<GLubyte, 2>> arrayLayerFill;
// framebuffer id -> the (2D array texture, layer) glFramebufferTextureLayer attached.
std::map<GLuint, std::pair<GLuint, GLint>> framebufferLayerAttachment;
// framebuffer id -> the LEVEL that same call named. Kept apart so the layered-blit
// bookkeeping above keeps its shape; the packed16 probe reads array LEVELS directly.
std::map<GLuint, GLint> framebufferLayerLevel;
// (texture, level) -> the PHYSICAL 16-bit word every texel of that 5551 image holds.
// One word per level is all the packed16 probe distinguishes: it uploads a uniform
// fill and reads one texel.
std::map<std::pair<GLuint, GLint>, GLushort> packedTexelWords;
// 2D-array texture id -> its allocation shape, as glTexImage3D built it. What the
// packed16 mirror is gated on: level-0 dimensions plus a mask of the levels actually
// allocated, so only the measured three-level 30x30x12 chain diverges.
struct FakeArrayAllocation {
GLsizei width = 0;
GLsizei height = 0;
GLsizei layers = 0;
unsigned levelMask = 0;
// The device rule the probe reproduces: the mirrored layout is only picked when
// the levels were uploaded onto a texture still at the driver defaults - any
// glTexParameteri BEFORE the first upload steers the driver to the plain layout.
// Modelling it makes a params-first probe (the round-one regression: it measured
// "clean" in the very context whose params-after textures mirrored) stop
// detecting, which turns that mistake into a red test instead of a silent miss.
bool paramsTouchedBeforeUpload = false;
};
std::map<GLuint, FakeArrayAllocation> packedArrayAllocations;
// framebuffer id -> the plain 2D texture glFramebufferTexture2D attached.
std::map<GLuint, GLuint> framebuffer2DAttachment;
GLuint boundArrayTexture = 0;
GLuint boundTexture2D = 0;
GLuint boundDrawFramebuffer = 0;
GLuint boundReadFramebuffer = 0;
@@ -164,6 +214,17 @@ namespace {
return haystack.find(needle) != std::string::npos;
}
// The 5_5_5_1 <-> 1_5_5_5_REV field-order mirror: the same fields, packed from the other
// end of the word. 0x0047 (R,G,B,A = 0,1,3,1) becomes 0x8C20 - the exact pair every
// failing KHR-GL4x.copy_image body printed on the affected Mali.
GLushort MirrorPacked5551(GLushort word) {
const GLushort r = (word >> 11) & 0x1F;
const GLushort g = (word >> 6) & 0x1F;
const GLushort b = (word >> 1) & 0x1F;
const GLushort a = word & 0x1;
return static_cast<GLushort>((a << 15) | (b << 10) | (g << 5) | r);
}
// Every `image2D <name>` the program declares, across all its stages.
std::vector<std::string> DeclaredImageNames(GLuint program) {
std::vector<std::string> names;
@@ -401,6 +462,7 @@ namespace {
funcs.glBindTexture = [](GLenum target, GLuint texture) {
if (target == GL_TEXTURE_2D_MULTISAMPLE) g_fake.boundMultisampleTexture = texture;
if (target == GL_TEXTURE_2D_ARRAY) g_fake.boundArrayTexture = texture;
if (target == GL_TEXTURE_2D) g_fake.boundTexture2D = texture;
};
funcs.glTexStorage3D = [](GLenum target, GLsizei, GLenum, GLsizei, GLsizei, GLsizei) {
if (target == GL_TEXTURE_2D_ARRAY) g_fake.arrayLayerFill[g_fake.boundArrayTexture] = {0, 0};
@@ -418,6 +480,14 @@ namespace {
funcs.glDeleteTextures = [](GLsizei n, const GLuint* textures) {
for (GLsizei i = 0; i < n; ++i) {
if (textures[i] != 0) --g_fake.aliveTextures;
for (auto it = g_fake.packedTexelWords.begin(); it != g_fake.packedTexelWords.end();) {
if (it->first.first == textures[i]) {
it = g_fake.packedTexelWords.erase(it);
} else {
++it;
}
}
g_fake.packedArrayAllocations.erase(textures[i]);
}
};
funcs.glTexParameteri = [](GLenum target, GLenum pname, GLint param) {
@@ -425,9 +495,63 @@ namespace {
g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture] =
static_cast<GLenum>(param);
}
// A parameter write on a 2D array that has no uploaded level yet steers the
// driver's layout choice to the plain order (see FakeArrayAllocation).
if (target == GL_TEXTURE_2D_ARRAY &&
g_fake.packedArrayAllocations.count(g_fake.boundArrayTexture) == 0) {
g_fake.packedArrayAllocations[g_fake.boundArrayTexture].paramsTouchedBeforeUpload = true;
}
};
// The packed16 probe's endpoints. A plain 2D image stores its 5551 words in the
// canonical (non-REV) order on every knob setting - the defect is confined to array
// mip levels, and keeping the 2D side clean is what lets the readback below decode
// with one order and still reproduce the mirror.
funcs.glTexImage2D = [](GLenum target, GLint level, GLint, GLsizei, GLsizei, GLint, GLenum,
GLenum type, const void* pixels) {
if (target != GL_TEXTURE_2D || type != GL_UNSIGNED_SHORT_5_5_5_1 || pixels == nullptr) return;
GLushort word = 0;
std::memcpy(&word, pixels, sizeof(word));
g_fake.packedTexelWords[{g_fake.boundTexture2D, level}] = word;
};
// Records the allocation shape the mirror below is gated on, and the uploaded word.
// Under the upload-corruption knob the STORED word is already wrong - the "not this
// bug" shape the probe's round-trip control must catch.
funcs.glTexImage3D = [](GLenum target, GLint level, GLint, GLsizei width, GLsizei height,
GLsizei depth, GLint, GLenum, GLenum type, const void* pixels) {
if (target != GL_TEXTURE_2D_ARRAY || type != GL_UNSIGNED_SHORT_5_5_5_1 || pixels == nullptr) return;
GLushort word = 0;
std::memcpy(&word, pixels, sizeof(word));
g_fake.packedTexelWords[{g_fake.boundArrayTexture, level}] =
g_fake.packed16UploadCorrupted ? MirrorPacked5551(word) : word;
auto& allocation = g_fake.packedArrayAllocations[g_fake.boundArrayTexture];
if (level == 0) {
allocation.width = width;
allocation.height = height;
allocation.layers = depth;
}
if (level >= 0 && level < 8) allocation.levelMask |= 1u << level;
};
// A raw texel-block move: the PHYSICAL word travels. The defect lives here - a source
// in the mirrored ALLOCATION delivers the re-encoded word from EVERY level - and it
// only exists for the allocation it was measured on: three levels of a 30x30x12 array.
funcs.glCopyImageSubData = [](GLuint srcName, GLenum, GLint srcLevel, GLint, GLint, GLint,
GLuint dstName, GLenum, GLint dstLevel, GLint, GLint, GLint,
GLsizei, GLsizei, GLsizei) {
if (g_fake.packed16CopyDoesNothing) return;
const auto source = g_fake.packedTexelWords.find({srcName, srcLevel});
if (source == g_fake.packedTexelWords.end()) return;
GLushort word = source->second;
const auto allocation = g_fake.packedArrayAllocations.find(srcName);
const bool measuredShape = allocation != g_fake.packedArrayAllocations.end() &&
allocation->second.width == 30 && allocation->second.height == 30 &&
allocation->second.layers == 12 &&
allocation->second.levelMask == 0b111u &&
!allocation->second.paramsTouchedBeforeUpload;
if (measuredShape && g_fake.packed16ArrayAllocationMirrored) {
word = MirrorPacked5551(word);
}
g_fake.packedTexelWords[{dstName, dstLevel}] = word;
};
funcs.glTexImage2D = [](GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum,
const void*) {};
funcs.glTexSubImage2D = [](GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum,
const void*) {};
funcs.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {};
@@ -446,11 +570,16 @@ namespace {
g_fake.boundReadFramebuffer = framebuffer;
}
};
funcs.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
funcs.glFramebufferTextureLayer = [](GLenum target, GLenum, GLuint texture, GLint, GLint layer) {
funcs.glFramebufferTexture2D = [](GLenum target, GLenum, GLenum, GLuint texture, GLint) {
const GLuint framebuffer = (target == GL_READ_FRAMEBUFFER) ? g_fake.boundReadFramebuffer
: g_fake.boundDrawFramebuffer;
g_fake.framebuffer2DAttachment[framebuffer] = texture;
};
funcs.glFramebufferTextureLayer = [](GLenum target, GLenum, GLuint texture, GLint level, GLint layer) {
const GLuint framebuffer = (target == GL_READ_FRAMEBUFFER) ? g_fake.boundReadFramebuffer
: g_fake.boundDrawFramebuffer;
g_fake.framebufferLayerAttachment[framebuffer] = {texture, layer};
g_fake.framebufferLayerLevel[framebuffer] = level;
};
funcs.glReadBuffer = [](GLenum) {};
// The defect itself: the source layer is read from where the READ framebuffer says (unless
@@ -482,6 +611,8 @@ namespace {
for (GLsizei i = 0; i < n; ++i) {
if (framebuffers[i] != 0) --g_fake.aliveFramebuffers;
g_fake.framebufferLayerAttachment.erase(framebuffers[i]);
g_fake.framebufferLayerLevel.erase(framebuffers[i]);
g_fake.framebuffer2DAttachment.erase(framebuffers[i]);
}
};
funcs.glGenVertexArrays = [](GLsizei n, GLuint* arrays) {
@@ -561,11 +692,54 @@ namespace {
funcs.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels) {
const std::size_t texels = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
// Answered before anything else: a read framebuffer that names an array LAYER is the
// layered-blit probe asking what that layer holds, and its bytes have nothing to do
// with the pass/fail texel encoding the image probes below share.
// A read framebuffer naming a plain 2D texture that holds a 5551 word is the
// packed16 probe reading its copy destination. The driver decodes its OWN storage
// with the canonical non-REV order and expands each field by bit replication -
// which is exactly how the mirrored word 0x8C20 becomes (140, 132, 132, 0).
if (const auto attached = g_fake.framebuffer2DAttachment.find(g_fake.boundReadFramebuffer);
attached != g_fake.framebuffer2DAttachment.end() &&
g_fake.packedTexelWords.count({attached->second, 0}) != 0) {
// Gated on the texture actually holding a 5551 word, so every OTHER probe that
// attaches a plain 2D texture keeps the pass/fail readback paths below.
const GLushort w = g_fake.packedTexelWords[{attached->second, 0}];
const auto expand5 = [](GLushort v) {
return static_cast<GLubyte>((v << 3) | (v >> 2));
};
GLubyte* out = static_cast<GLubyte*>(pixels);
for (std::size_t i = 0; i < texels; ++i) {
out[i * 4 + 0] = expand5((w >> 11) & 0x1F);
out[i * 4 + 1] = expand5((w >> 6) & 0x1F);
out[i * 4 + 2] = expand5((w >> 1) & 0x1F);
out[i * 4 + 3] = (w & 0x1) ? 255 : 0;
}
return;
}
// A read framebuffer naming an array LEVEL that holds a 5551 word is the packed16
// probe's round-trip control: the driver decodes its OWN storage, so whatever the
// physical word is - mirrored at upload under that knob included - its own decode
// is handed back with the canonical field meaning.
if (const auto layered = g_fake.framebufferLayerAttachment.find(g_fake.boundReadFramebuffer);
layered != g_fake.framebufferLayerAttachment.end()) {
const auto levelIt = g_fake.framebufferLayerLevel.find(g_fake.boundReadFramebuffer);
const GLint attachedLevel = levelIt == g_fake.framebufferLayerLevel.end() ? 0 : levelIt->second;
if (const auto word = g_fake.packedTexelWords.find({layered->second.first, attachedLevel});
word != g_fake.packedTexelWords.end()) {
const GLushort w = word->second;
const auto expand5 = [](GLushort v) {
return static_cast<GLubyte>((v << 3) | (v >> 2));
};
GLubyte* out = static_cast<GLubyte*>(pixels);
for (std::size_t i = 0; i < texels; ++i) {
out[i * 4 + 0] = expand5((w >> 11) & 0x1F);
out[i * 4 + 1] = expand5((w >> 6) & 0x1F);
out[i * 4 + 2] = expand5((w >> 1) & 0x1F);
out[i * 4 + 3] = (w & 0x1) ? 255 : 0;
}
return;
}
// Otherwise it is the layered-blit probe asking what a layer holds, and its
// bytes have nothing to do with the pass/fail texel encoding the image probes
// below share.
const auto& fill = g_fake.arrayLayerFill[layered->second.first];
const GLint layer = layered->second.second;
const GLubyte value =
@@ -626,6 +800,8 @@ TEST(DriverBugProbes, AProbeThatCannotRunReportsNoBug) {
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected);
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl))
<< "a probe with no entry points has measured nothing";
}
// The section lists only bugs the device HAS, so a driver nothing could be probed on renders
@@ -945,3 +1121,53 @@ TEST(DriverBugProbes, ImageCoherencyNeedsBothHalvesOfTheSplitPairInOneStage) {
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
TEST(DriverBugProbes, Packed16FieldOrderIsCleanOnAConformingDriver) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// The measured device shape: EVERY level of the mirrored allocation delivers the
// re-encoding, and the machinery/round-trip controls stay clean, so the probe must detect.
TEST(DriverBugProbes, Packed16FieldOrderIsDetectedWhenTheArrayAllocationIsMirrored) {
ResetFakeDriver();
g_fake.packed16ArrayAllocationMirrored = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_TRUE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// THE ROUND-TRIP CONTROL. A driver that corrupts the UPLOAD hands the mirror back from the
// array's own direct readback too - a different defect, and one the widening's raw-copy
// reasoning says nothing about - so the probe must reach no verdict rather than claim it.
TEST(DriverBugProbes, Packed16FieldOrderReportsNothingWhenTheUploadItselfCorrupts) {
ResetFakeDriver();
g_fake.packed16UploadCorrupted = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// And the shape that is not this bug: a copy that lands nothing leaves every destination's
// 0xFFFF fill, so the 2D-to-2D machinery control fails first - "reached no verdict".
TEST(DriverBugProbes, Packed16FieldOrderReportsNothingWhenTheCopyLandsNothing) {
ResetFakeDriver();
g_fake.packed16ArrayAllocationMirrored = true;
g_fake.packed16CopyDoesNothing = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// The byte arithmetic the fake's mirror encodes, pinned against the QPA evidence. The fake
// models the ARRAY-AS-SOURCE direction (decode 5_5_5_1, re-encode 1_5_5_5_REV): 0x0047 must
// deliver 0x8C20, the exact pair every failing array-as-source copy_image body printed. The
// QPA's array-as-destination bodies show the INVERSE transform (enc_5551 of dec_REV: 0x0007
// delivered as 0x3800), and enc_REV(dec_5551(x)) inverts enc_5551(dec_REV(x)), so feeding
// the delivered word back through the fake's mirror must reproduce the original.
TEST(DriverBugProbes, Packed16MirrorArithmeticMatchesTheDeviceEvidence) {
EXPECT_EQ(MirrorPacked5551(0x0047), 0x8C20);
EXPECT_EQ(MirrorPacked5551(0x3800), 0x0007);
}
@@ -0,0 +1,326 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#include <MG_Util/SelfTest/PersistentBufferOrderingProbe.h>
#include <map>
#include <set>
using namespace MobileGL;
using namespace MobileGL::MG_Util::SelfTest;
namespace {
// Deferred vertex fetch, not canned ReadPixels answers: a broken mapped destination
// reads its current bytes at Finish instead of the bytes at DrawArrays. ReadPixels also
// drains these jobs, so inserting an early readback into the probe hides the bug here too.
struct FakeDriver {
struct Buffer {
Bool mapped = false;
Bool arena = false;
Bool copied = false;
Int channel = 0;
Vector<Uint8> staging;
};
struct Draw { GLuint fbo, buffer; Int channel; Bool late; };
std::map<GLuint, Buffer> buffers;
std::map<GLuint, GLuint> vaoBuffers;
std::map<GLuint, Int> colors;
Vector<Draw> draws;
std::set<GLuint> live;
std::map<GLenum, GLint> state = {
{GL_CURRENT_PROGRAM, 1}, {GL_VERTEX_ARRAY_BINDING, 2}, {GL_ARRAY_BUFFER, 3},
{GL_COPY_READ_BUFFER, 4}, {GL_COPY_WRITE_BUFFER, 5},
{GL_DRAW_FRAMEBUFFER_BINDING, 6}, {GL_READ_FRAMEBUFFER_BINDING, 7},
{GL_TEXTURE_BINDING_2D, 8}, {GL_PIXEL_PACK_BUFFER, 9},
{GL_PACK_ALIGNMENT, 8}, {GL_PACK_ROW_LENGTH, 31},
{GL_PACK_SKIP_PIXELS, 4}, {GL_PACK_SKIP_ROWS, 5}};
std::map<GLenum, GLboolean> enabled = {{GL_BLEND, GL_TRUE}, {GL_SCISSOR_TEST, GL_TRUE},
{GL_SAMPLE_MASK, GL_TRUE}, {GL_RASTERIZER_DISCARD, GL_TRUE}};
std::array<GLint, 4> viewport = {3, 4, 5, 6};
std::array<GLfloat, 4> clear = {.25f, .5f, .75f, 0};
std::array<GLboolean, 4> mask = {GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE};
GLuint next = 100;
GLenum error = GL_NO_ERROR;
Bool extension = true, corruptSubData = false, corruptCopy = false;
Bool corruptUnmapped = false, corruptSerialized = false;
Bool failMap = false, failAllocation = false, failFramebuffer = false;
Int failReadbackAt = 0, readbacks = 0;
Uint arenaAllocations = 0;
Uint8 pointerSentinel = 0;
GLuint Create() { live.insert(next); return next++; }
void Generate(GLsizei count, GLuint* ids) { for (Int i = 0; i < count; ++i) ids[i] = Create(); }
void Delete(GLsizei count, const GLuint* ids) {
for (Int i = 0; i < count; ++i) {
live.erase(ids[i]);
buffers.erase(ids[i]);
}
}
GLint Get(GLenum name) const {
const auto found = state.find(name);
return found == state.end() ? 0 : found->second;
}
static Int Channel(const void* data) {
GLfloat color[3];
std::memcpy(color, static_cast<const Uint8*>(data) + 2 * sizeof(GLfloat), sizeof(color));
return color[0] > .5f ? 0 : color[1] > .5f ? 1 : 2;
}
void Finish() {
for (const auto& draw : draws) {
const auto& buffer = buffers.at(draw.buffer);
colors[draw.fbo] = draw.late ? buffer.channel : draw.channel;
if (buffer.mapped && corruptSerialized) colors[draw.fbo] = (draw.channel + 1) % 3;
}
draws.clear();
}
} driver;
MG_External::GLESFunctionsTable Table() {
MG_External::GLESFunctionsTable gl{};
gl.glGetIntegerv = [](GLenum name, GLint* out) {
if (name == GL_MAJOR_VERSION) *out = 3;
else if (name == GL_MINOR_VERSION) *out = 2;
else if (name == GL_NUM_EXTENSIONS) *out = driver.extension ? 1 : 0;
else if (name == GL_VIEWPORT) std::copy(driver.viewport.begin(), driver.viewport.end(), out);
else if (name == GL_ARRAY_BUFFER_BINDING) *out = driver.Get(GL_ARRAY_BUFFER);
else if (name == GL_PIXEL_PACK_BUFFER_BINDING) *out = driver.Get(GL_PIXEL_PACK_BUFFER);
else *out = driver.Get(name);
};
gl.glGetBooleanv = [](GLenum, GLboolean* out) { std::copy(driver.mask.begin(), driver.mask.end(), out); };
gl.glGetFloatv = [](GLenum, GLfloat* out) { std::copy(driver.clear.begin(), driver.clear.end(), out); };
gl.glGetStringi = [](GLenum, GLuint) { return reinterpret_cast<const GLubyte*>("GL_EXT_buffer_storage"); };
gl.glGetError = []() { return std::exchange(driver.error, GL_NO_ERROR); };
gl.glIsEnabled = [](GLenum name) -> GLboolean { return driver.enabled[name]; };
gl.glEnable = [](GLenum name) { driver.enabled[name] = GL_TRUE; };
gl.glDisable = [](GLenum name) { driver.enabled[name] = GL_FALSE; };
gl.glCreateShader = [](GLenum) { return driver.Create(); };
gl.glShaderSource = [](GLuint, GLsizei, const GLchar* const*, const GLint*) {};
gl.glCompileShader = [](GLuint) {};
gl.glGetShaderiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetShaderInfoLog = [](GLuint, GLsizei, GLsizei*, GLchar* out) { *out = 0; };
gl.glDeleteShader = [](GLuint id) { driver.Delete(1, &id); };
gl.glCreateProgram = []() { return driver.Create(); };
gl.glAttachShader = [](GLuint, GLuint) {};
gl.glLinkProgram = [](GLuint) {};
gl.glGetProgramiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetProgramInfoLog = gl.glGetShaderInfoLog;
gl.glDeleteProgram = gl.glDeleteShader;
gl.glUseProgram = [](GLuint id) { driver.state[GL_CURRENT_PROGRAM] = id; };
gl.glGenBuffers = [](GLsizei count, GLuint* ids) { driver.Generate(count, ids); };
gl.glBindBuffer = [](GLenum target, GLuint id) { driver.state[target] = id; };
gl.glBufferStorageEXT = [](GLenum target, GLsizeiptr size, const void*, GLbitfield) {
auto& buffer = driver.buffers[driver.Get(target)];
buffer.arena = size >= 16 * 1024 * 1024;
if (buffer.arena) ++driver.arenaAllocations;
if (driver.failAllocation) driver.error = GL_OUT_OF_MEMORY;
if (!buffer.arena) buffer.staging.resize(size);
};
gl.glBufferData = [](GLenum target, GLsizeiptr size, const void*, GLenum) {
driver.buffers[driver.Get(target)].staging.resize(size);
};
gl.glMapBufferRange = [](GLenum target, GLintptr offset, GLsizeiptr, GLbitfield) -> void* {
if (driver.failMap) return nullptr;
auto& buffer = driver.buffers[driver.Get(target)];
buffer.mapped = true;
return buffer.arena ? &driver.pointerSentinel : buffer.staging.data() + offset;
};
gl.glUnmapBuffer = [](GLenum) -> GLboolean { return GL_TRUE; }; // Preserve allocation history.
gl.glBufferSubData = [](GLenum target, GLintptr offset, GLsizeiptr size, const void* data) {
auto& buffer = driver.buffers[driver.Get(target)];
if (buffer.arena) {
buffer.channel = FakeDriver::Channel(data);
buffer.copied = false;
} else std::memcpy(buffer.staging.data() + offset, data, size);
};
gl.glCopyBufferSubData = [](GLenum read, GLenum write, GLintptr offset, GLintptr, GLsizeiptr) {
auto& source = driver.buffers[driver.Get(read)];
auto& dest = driver.buffers[driver.Get(write)];
dest.channel = FakeDriver::Channel(source.staging.data() + offset);
dest.copied = true;
};
gl.glDeleteBuffers = [](GLsizei count, const GLuint* ids) { driver.Delete(count, ids); };
gl.glGenVertexArrays = gl.glGenBuffers;
gl.glBindVertexArray = [](GLuint id) { driver.state[GL_VERTEX_ARRAY_BINDING] = id; };
gl.glVertexAttribPointer = [](GLuint, GLint, GLenum, GLboolean, GLsizei, const void*) {
driver.vaoBuffers[driver.Get(GL_VERTEX_ARRAY_BINDING)] = driver.Get(GL_ARRAY_BUFFER);
};
gl.glEnableVertexAttribArray = [](GLuint) {};
gl.glDeleteVertexArrays = gl.glDeleteBuffers;
gl.glGenTextures = gl.glGenBuffers;
gl.glBindTexture = [](GLenum, GLuint id) { driver.state[GL_TEXTURE_BINDING_2D] = id; };
gl.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {};
gl.glDeleteTextures = gl.glDeleteBuffers;
gl.glGenFramebuffers = gl.glGenBuffers;
gl.glBindFramebuffer = [](GLenum target, GLuint id) {
if (target != GL_READ_FRAMEBUFFER) driver.state[GL_DRAW_FRAMEBUFFER_BINDING] = id;
if (target != GL_DRAW_FRAMEBUFFER) driver.state[GL_READ_FRAMEBUFFER_BINDING] = id;
};
gl.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
gl.glCheckFramebufferStatus = [](GLenum) -> GLenum {
return driver.failFramebuffer ? GL_FRAMEBUFFER_UNSUPPORTED : GL_FRAMEBUFFER_COMPLETE;
};
gl.glDeleteFramebuffers = gl.glDeleteBuffers;
gl.glViewport = [](GLint x, GLint y, GLsizei w, GLsizei h) { driver.viewport = {x, y, w, h}; };
gl.glColorMask = [](GLboolean r, GLboolean g, GLboolean b, GLboolean a) { driver.mask = {r, g, b, a}; };
gl.glClearColor = [](GLfloat r, GLfloat g, GLfloat b, GLfloat a) { driver.clear = {r, g, b, a}; };
gl.glClear = [](GLbitfield) {};
gl.glDrawArrays = [](GLenum, GLint, GLsizei) {
const GLuint id = driver.vaoBuffers.at(driver.Get(GL_VERTEX_ARRAY_BINDING));
const auto& buffer = driver.buffers.at(id);
const Bool late = buffer.mapped ? (buffer.copied ? driver.corruptCopy : driver.corruptSubData)
: driver.corruptUnmapped;
driver.draws.push_back({GLuint(driver.Get(GL_DRAW_FRAMEBUFFER_BINDING)), id, buffer.channel, late});
};
gl.glFinish = []() { driver.Finish(); };
gl.glMemoryBarrier = [](GLbitfield) {};
gl.glPixelStorei = [](GLenum name, GLint value) { driver.state[name] = value; };
gl.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum, GLenum, void* data) {
driver.Finish(); // Models the implicit wait that must NOT occur between subject draws.
if (++driver.readbacks == driver.failReadbackAt) {
driver.error = GL_INVALID_OPERATION;
return;
}
EXPECT_EQ(driver.Get(GL_PIXEL_PACK_BUFFER), 0);
EXPECT_EQ(driver.Get(GL_PACK_ROW_LENGTH), 0);
const Int channel = driver.colors.at(driver.Get(GL_READ_FRAMEBUFFER_BINDING));
auto* pixels = static_cast<Uint8*>(data);
for (Int i = 0; i < width * height; ++i)
for (Int c = 0; c < 4; ++c) pixels[4 * i + c] = c == channel || c == 3 ? 255 : 0;
};
return gl;
}
class PersistentBufferOrderingProbeTest : public ::testing::Test {
protected:
void SetUp() override { driver = FakeDriver{}; }
void TearDown() override { EXPECT_TRUE(driver.live.empty()); EXPECT_TRUE(driver.draws.empty()); }
};
}
TEST_F(PersistentBufferOrderingProbeTest, RequiresExtensionAndCompleteDispatchBeforeAllocating) {
auto gl = Table();
driver.extension = false;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
driver.extension = true;
gl.glCopyBufferSubData = nullptr;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
EXPECT_EQ(driver.arenaAllocations, 0u);
}
TEST_F(PersistentBufferOrderingProbeTest, OrderedDriverPassesAllUploadsAndRestoresCallerState) {
const auto saved = driver;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
ASSERT_TRUE(measurement.supported);
for (const auto& row : measurement.uploads) {
EXPECT_TRUE(row.unmapped.Passed());
EXPECT_TRUE(row.mapped.Passed());
EXPECT_EQ(row.mapped.frames, 240u); // Three fresh attempts before a negative result.
EXPECT_EQ(row.finishBoth.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved.state);
EXPECT_EQ(driver.viewport, saved.viewport);
EXPECT_EQ(driver.clear, saved.clear);
EXPECT_EQ(driver.mask, saved.mask);
for (const auto& [cap, value] : driver.enabled) {
const auto found = saved.enabled.find(cap);
EXPECT_EQ(value, found == saved.enabled.end() ? GL_FALSE : found->second);
}
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredMappedSubDataFetchIsDetectedWithPassingControls) {
driver.corruptSubData = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_TRUE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[0].finishBefore.Passed());
EXPECT_GT(measurement.uploads[0].mapThenUnmap.badFrames, 0u);
EXPECT_GT(measurement.uploads[0].barrierBefore.badFrames, 0u);
EXPECT_FALSE(measurement.uploads[1].Detected());
EXPECT_FALSE(measurement.uploads[2].Detected());
const auto finding = DescribePersistentBufferOrderingBug(measurement);
ASSERT_TRUE(finding);
EXPECT_EQ(finding->verdict, DriverBugVerdict::Unfixable);
EXPECT_NE(finding->detail.find("SubData:"), String::npos);
EXPECT_NE(finding->detail.find("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1"), String::npos);
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredCopyFetchIsDetectedWithBothStagingSources) {
driver.corruptCopy = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[1].Detected());
EXPECT_TRUE(measurement.uploads[2].Detected());
}
TEST_F(PersistentBufferOrderingProbeTest, PostCollectorIncludesTheMeasuredFinding) {
driver.corruptSubData = true;
const auto findings = CollectGlesKnownDriverBugs(Table());
const auto found = std::find_if(findings.begin(), findings.end(), [](const auto& finding) {
return finding.name == "Persistent-mapped vertex buffers lose upload/draw ordering";
});
ASSERT_NE(found, findings.end());
EXPECT_NE(found->detail.find("never-mapped 0/80"), String::npos);
EXPECT_EQ(found->verdict, DriverBugVerdict::Unfixable);
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptNeverMappedControlCannotAccusePersistentMapping) {
driver.corruptSubData = driver.corruptCopy = driver.corruptUnmapped = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_GT(row.unmapped.badFrames, 0u);
EXPECT_EQ(row.mapped.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptSerializedControlCannotConfirmOrderingDefect) {
driver.corruptSubData = driver.corruptCopy = driver.corruptSerialized = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) EXPECT_GT(row.finishBoth.badFrames, 0u);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, FailedMappingIsInconclusiveAndReleasesResources) {
driver.failMap = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[1].unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, AllocationFailureIsInconclusiveAndRestoresBindings) {
driver.failAllocation = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_EQ(row.unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(row.unmapped.error, GLenum(GL_OUT_OF_MEMORY));
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved);
}
TEST_F(PersistentBufferOrderingProbeTest, ReadbackErrorAfterAMismatchDoesNotProduceAFinding) {
driver.corruptSubData = true;
driver.failReadbackAt = 82; // Eighty clean control readbacks, then one corrupt subject FBO.
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_GT(measurement.uploads[0].mapped.badFrames, 0u);
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[0].mapped.error, GLenum(GL_INVALID_OPERATION));
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, IncompleteFramebufferIsInconclusiveAndRestoresBindings) {
driver.failFramebuffer = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.arenaAllocations, 0u);
EXPECT_EQ(driver.state, saved);
}
@@ -0,0 +1,546 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PrimitivesGeneratedNoXfbProbeTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The primitives-generated-without-transform-feedback probe's VERDICT and ARMING
// logic, pinned over synthetic measurements. Recording the probe for real needs a
// GPU; the two pure functions are where the cheap mistakes live - a verdict that reads a
// half-broken driver as healthy, an override arm swapped so ForceOn disarms, a
// substitute ranked below a worse one - and every driver the campaign has
// characterised is written down here as a fake measurement so the mapping cannot
// drift without a red:
// - a conforming driver (stream counts everywhere),
// - Mesa lavapipe as measured 2026-08: stream silent everywhere, the dedicated
// VK_EXT_primitives_generated_query exact everywhere (discard included), and
// the statistics control exact on the plain shape but dead under rasterizer
// discard (llvmpipe's discard short-circuit),
// - the same driver without the dedicated query - the statistics tiers,
// - a device with the defect and no working substitute,
// - a substitute that would be WORSE than the stream query on some shape (the
// never-worse rule the plain-only arm has to prove before it may arm),
// - and the refuse-to-guess shapes (half counts, missing mandatory shapes).
//
// The last section pins the probe's TEARDOWN CONTRACT instead, driving the real
// RunPrimitivesGeneratedNoXfbProbe against a fake Vulkan driver whose fence wait
// can be made to expire: no GPU is needed for that, only the entry points the
// probe is handed, and what it does on that path is what keeps a hung driver from
// hanging the POST.
#include <gtest/gtest.h>
#include <cstdint>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
using MobileGL::Bool;
using MobileGL::Uint32;
using MobileGL::Uint64;
using MobileGL::MG_Config::QuirkOverride;
using MobileGL::MG_Util::SelfTest::EvaluatePrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::ChoosePrimitivesGeneratedReroute;
using MobileGL::MG_Util::SelfTest::PrimGenRerouteKind;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbProbeContext;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::RunPrimitivesGeneratedNoXfbProbe;
namespace {
struct ShapeAnswers {
Uint64 stream = 0;
// Negative-free encoding: measured flags separate from values.
Bool pgqMeasured = false;
Uint64 pgq = 0;
Bool statMeasured = false;
Uint64 stat = 0;
};
PrimitivesGeneratedNoXfbShapeMeasurement Shape(const ShapeAnswers& answers) {
PrimitivesGeneratedNoXfbShapeMeasurement shape;
shape.drawn = true;
shape.expectedPrimitives = 1;
shape.streamGenerated = answers.stream;
shape.primitivesGeneratedExtMeasured = answers.pgqMeasured;
shape.primitivesGeneratedExt = answers.pgq;
shape.statisticsMeasured = answers.statMeasured;
shape.statisticsClippingInput = answers.stat;
return shape;
}
PrimitivesGeneratedNoXfbMeasurement Measurement(PrimitivesGeneratedNoXfbShapeMeasurement plain,
PrimitivesGeneratedNoXfbShapeMeasurement discard,
PrimitivesGeneratedNoXfbShapeMeasurement patches) {
PrimitivesGeneratedNoXfbMeasurement measurement;
measurement.ran = true;
measurement.trianglesPlain = plain;
measurement.trianglesDiscard = discard;
measurement.patchesDiscard = patches;
return measurement;
}
PrimitivesGeneratedNoXfbShapeMeasurement NotDrawn() {
return PrimitivesGeneratedNoXfbShapeMeasurement{};
}
constexpr ShapeAnswers kHealthy{1, true, 1, true, 1};
// The lavapipe measurement: stream silent, dedicated query exact, statistics
// exact only where nothing is discarded.
constexpr ShapeAnswers kLavapipePlain{0, true, 1, true, 1};
constexpr ShapeAnswers kLavapipeDiscard{0, true, 1, true, 0};
} // namespace
// A conforming driver: the stream query counts every capture-less shape exactly.
// Controls agreeing changes nothing - health is decided by the subject.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AConformingDriverReadsStreamCounts) {
const auto measurement = Measurement(Shape(kHealthy), Shape(kHealthy), Shape(kHealthy));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// ...and stays healthy with no tessellation stage to draw the patches shape with,
// and with no control at all - a control is only required to QUALIFY a
// substitute, never to certify health.
TEST(PrimitivesGeneratedNoXfbVerdictTest, HealthNeedsNeitherTessellationNorAControl) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape(kHealthy), Shape(kHealthy), NotDrawn())),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({1}), Shape({1}), Shape({1}))),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// Mesa lavapipe as measured (2026-08): stream silent for every capture-less
// draw, the dedicated primitives-generated query exact on every shape (discard
// included), the statistics control dead under discard. The dedicated query must
// win - it is the only substitute that covers the CTS shape there.
TEST(PrimitivesGeneratedNoXfbVerdictTest, LavapipeShapedMeasurementTakesTheDedicatedQuery) {
const auto measurement =
Measurement(Shape(kLavapipePlain), Shape(kLavapipeDiscard), Shape(kLavapipeDiscard));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// The affected-device hypothesis with no dedicated query: statistics exact on
// every shape, the CTS's discarded shapes included.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsExactEverywhereIsTheFullStatisticsSubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// A dedicated query that is silent in the same way the stream query is must not
// be armed - the statistics tier decides instead.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASilentDedicatedQueryFallsThroughToStatistics) {
const auto measurement = Measurement(Shape({0, true, 0, true, 1}), Shape({0, true, 0, true, 1}),
Shape({0, true, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// The llvmpipe statistics hole without the dedicated query to rescue it: exact on
// the plain shape, dead under discard. Repairs undiscarded queries only, and the
// verdict must say so.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsDeadUnderDiscardIsThePlainOnlySubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// THE DOMINATION RULE. The plain-only substitute is armed for EVERY XFB-inactive
// draw, so it may only be armed where it is never worse than what it replaces:
// each shape it gets wrong must be one the stream query already answered 0 for.
// Here the discarded triangle is one the stream query answers EXACTLY (a driver
// whose silence is selective) and whose statistics read 0 - rerouting would turn
// that correct 1 into a 0, so the honest verdict is that nothing may be armed.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASubstituteWorseThanTheStreamOnAnyShapeIsRefused) {
const auto measurement = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// The same shape with the statistics slot MISSING on the stream-exact shape is
// the same trade: an unmeasured control cannot be assumed to answer.
const auto unmeasured = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, false, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(unmeasured),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// ...while the same selective silence WITH a substitute that covers the shapes
// it must still qualifies: every shape the statistics miss read 0 anyway.
const auto dominating = Measurement(Shape({1, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(dominating),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// The defect with no substitute: no control, controls silent, or a control that
// OVERCOUNTS the plain shape (as disqualifying as one that reads 0 - an exact
// match is what qualifies a substitute).
TEST(PrimitivesGeneratedNoXfbVerdictTest, StreamSilentWithoutAWorkingPlainControlIsUnfixable) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0}), Shape({0}), Shape({0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 0, true, 0}), Shape({0, true, 0, true, 0}),
Shape({0, true, 0, true, 0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 2, true, 2}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
}
// Refuse-to-guess shapes. A nonzero-but-wrong stream answer fits neither the
// defect (exact silence) nor health (the exact count), whichever shape carries
// it; and a probe that never ran, or lost its mandatory shapes, says nothing.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AnswersFittingNeitherHealthNorTheDefectAreInconclusive) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({2, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 1, true, 1}), Shape({3, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
PrimitivesGeneratedNoXfbMeasurement neverRan;
neverRan.ran = false;
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(neverRan),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
const auto missingMandatoryShape = Measurement(Shape({0, true, 1, true, 1}), NotDrawn(), NotDrawn());
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(missingMandatoryShape),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// A partial silence is still the defect: the plain shape counts but the discarded
// ones read 0 (a driver that gates the stream counter on rasterization rather
// than on the capture). With a whole control the substitute is whole.
TEST(PrimitivesGeneratedNoXfbVerdictTest, SilenceOnOnlyTheDiscardShapesIsStillTheDefect) {
const auto measurement = Measurement(Shape({1, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// ===================== THE OVERRIDE MAPPING =====================
//
// The one-line swap this exists to catch: ForceOn and ForceOff exchanging arms,
// Auto arming on a verdict that never qualified a substitute, or the pool ranking
// inverting. Every cell of the (override x verdict) table is written out.
namespace {
constexpr PrimitivesGeneratedNoXfbVerdict kAllVerdicts[] = {
PrimitivesGeneratedNoXfbVerdict::Inconclusive,
PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
PrimitivesGeneratedNoXfbVerdict::Unfixable,
};
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOffNeverReroutes) {
for (const auto verdict : kAllVerdicts) {
for (const Bool pgqUsable : {false, true}) {
for (const Bool statsUsable : {false, true}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOff, verdict, pgqUsable,
statsUsable),
PrimGenRerouteKind::None);
}
}
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOnBypassesTheVerdictButNeverTheStructuralChecks) {
for (const auto verdict : kAllVerdicts) {
// The dedicated query wins where the device can host it...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, false),
PrimGenRerouteKind::PrimitivesGeneratedExt);
// ...statistics stand in where only they exist...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, true),
PrimGenRerouteKind::ClippingStatistics);
// ...and no pool means no reroute, forced or not.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, false),
PrimGenRerouteKind::None);
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, AutoFollowsExactlyTheSubstituteVerdicts) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, true),
PrimGenRerouteKind::ClippingStatistics);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
false, true),
PrimGenRerouteKind::ClippingStatistics);
// The statistics verdicts never take the dedicated pool: that verdict only
// exists when the dedicated query did NOT qualify.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, true, true),
PrimGenRerouteKind::ClippingStatistics);
for (const auto verdict :
{PrimitivesGeneratedNoXfbVerdict::Inconclusive, PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::Unfixable}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::Auto, verdict, true, true),
PrimGenRerouteKind::None);
}
// The structural checks bind Auto too.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
false, true),
PrimGenRerouteKind::None);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, false),
PrimGenRerouteKind::None);
}
// ===================== THE FENCE-TIMEOUT CONTRACT =====================
//
// A driver whose queue never signals the probe's fence inside 5 s is the one case
// where the probe must NOT clean up: the submission may still be executing, so
// vkDeviceWaitIdle can block forever and destroying in-flight objects is
// undefined. It therefore leaks everything it made and says so in the measurement
// (`fenceWaitTimedOut`), which is what lets its callers make the same choice for
// the object THEY own - the driver POST leaks its throwaway VkDevice instead of
// destroying it under live children (vkDestroyDevice would be the very hang the
// bound exists to prevent), and the renderer, whose device is the real one, must
// not idle-wait it either. Neither guard is reachable from a unit test - the POST
// probe lives in an anonymous namespace and the renderer needs a GPU - so this
// pins the contract they both key on, at the boundary where it is produced.
//
// The fake driver below is the whole Vulkan surface the probe touches, with a
// dialable fence-wait result and per-entry-point call counters.
namespace {
struct FakeDriverState {
VkResult fenceWaitResult = VK_SUCCESS;
Uint32 objectsCreated = 0;
Uint32 destroyCalls = 0;
Uint32 deviceWaitIdleCalls = 0;
Uint32 queueSubmitCalls = 0;
Uint64 streamGenerated = 1;
};
FakeDriverState g_fake;
template <typename Handle>
Handle FakeHandle() {
++g_fake.objectsCreated;
// One cast form for both handle flavours: a pointer on 64-bit builds, a
// uint64_t on 32-bit ones. The probe only ever compares against
// VK_NULL_HANDLE, so any distinct nonzero value will do.
return (Handle)(std::uintptr_t)(0x1000u + g_fake.objectsCreated * 0x10u);
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateCommandPool(VkDevice, const VkCommandPoolCreateInfo*,
const VkAllocationCallbacks*, VkCommandPool* out) {
*out = FakeHandle<VkCommandPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyCommandPool(VkDevice, VkCommandPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeAllocateCommandBuffers(VkDevice, const VkCommandBufferAllocateInfo*,
VkCommandBuffer* out) {
*out = FakeHandle<VkCommandBuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeBeginCommandBuffer(VkCommandBuffer, const VkCommandBufferBeginInfo*) {
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeEndCommandBuffer(VkCommandBuffer) { return VK_SUCCESS; }
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateQueryPool(VkDevice, const VkQueryPoolCreateInfo*,
const VkAllocationCallbacks*, VkQueryPool* out) {
*out = FakeHandle<VkQueryPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyQueryPool(VkDevice, VkQueryPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdResetQueryPool(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQuery(VkCommandBuffer, VkQueryPool, uint32_t, VkQueryControlFlags) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQuery(VkCommandBuffer, VkQueryPool, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t,
VkQueryControlFlags, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateRenderPass(VkDevice, const VkRenderPassCreateInfo*,
const VkAllocationCallbacks*, VkRenderPass* out) {
*out = FakeHandle<VkRenderPass>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyRenderPass(VkDevice, VkRenderPass, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFramebuffer(VkDevice, const VkFramebufferCreateInfo*,
const VkAllocationCallbacks*, VkFramebuffer* out) {
*out = FakeHandle<VkFramebuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFramebuffer(VkDevice, VkFramebuffer, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginRenderPass(VkCommandBuffer, const VkRenderPassBeginInfo*,
VkSubpassContents) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndRenderPass(VkCommandBuffer) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateShaderModule(VkDevice, const VkShaderModuleCreateInfo*,
const VkAllocationCallbacks*, VkShaderModule* out) {
*out = FakeHandle<VkShaderModule>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyShaderModule(VkDevice, VkShaderModule, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreatePipelineLayout(VkDevice, const VkPipelineLayoutCreateInfo*,
const VkAllocationCallbacks*,
VkPipelineLayout* out) {
*out = FakeHandle<VkPipelineLayout>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipelineLayout(VkDevice, VkPipelineLayout,
const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateGraphicsPipelines(VkDevice, VkPipelineCache, uint32_t count,
const VkGraphicsPipelineCreateInfo*,
const VkAllocationCallbacks*, VkPipeline* out) {
for (uint32_t i = 0; i < count; ++i) {
out[i] = FakeHandle<VkPipeline>();
}
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipeline(VkDevice, VkPipeline, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBindPipeline(VkCommandBuffer, VkPipelineBindPoint, VkPipeline) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdDraw(VkCommandBuffer, uint32_t, uint32_t, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFence(VkDevice, const VkFenceCreateInfo*,
const VkAllocationCallbacks*, VkFence* out) {
*out = FakeHandle<VkFence>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFence(VkDevice, VkFence, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeQueueSubmit(VkQueue, uint32_t, const VkSubmitInfo*, VkFence) {
++g_fake.queueSubmitCalls;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeWaitForFences(VkDevice, uint32_t, const VkFence*, VkBool32, uint64_t) {
return g_fake.fenceWaitResult;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeGetQueryPoolResults(VkDevice, VkQueryPool, uint32_t, uint32_t,
size_t dataSize, void* data, VkDeviceSize,
VkQueryResultFlags) {
// The stream pool's {primitivesWritten, primitivesNeeded} pair; the probe
// reads primitivesNeeded, and this fake device counts capture-less draws.
if (data == nullptr || dataSize < 2 * sizeof(Uint64)) {
return VK_INCOMPLETE;
}
auto* pair = static_cast<Uint64*>(data);
pair[0] = 0;
pair[1] = g_fake.streamGenerated;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeDeviceWaitIdle(VkDevice) {
++g_fake.deviceWaitIdleCalls;
return VK_SUCCESS;
}
PrimitivesGeneratedNoXfbProbeContext FakeProbeContext() {
g_fake = FakeDriverState{};
PrimitivesGeneratedNoXfbProbeContext context;
context.device = (VkDevice)(std::uintptr_t)0xD0D0;
context.queue = (VkQueue)(std::uintptr_t)0xC0C0;
context.transformFeedbackQueriesUsable = true;
// No controls and no tessellation: this fixture is about the teardown
// contract, and the fewer optional slots the fewer moving parts.
auto& fns = context.fns;
fns.vkCreateCommandPool = FakeCreateCommandPool;
fns.vkDestroyCommandPool = FakeDestroyCommandPool;
fns.vkAllocateCommandBuffers = FakeAllocateCommandBuffers;
fns.vkBeginCommandBuffer = FakeBeginCommandBuffer;
fns.vkEndCommandBuffer = FakeEndCommandBuffer;
fns.vkCreateQueryPool = FakeCreateQueryPool;
fns.vkDestroyQueryPool = FakeDestroyQueryPool;
fns.vkCmdResetQueryPool = FakeCmdResetQueryPool;
fns.vkCmdBeginQuery = FakeCmdBeginQuery;
fns.vkCmdEndQuery = FakeCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = FakeCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = FakeCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = FakeCreateRenderPass;
fns.vkDestroyRenderPass = FakeDestroyRenderPass;
fns.vkCreateFramebuffer = FakeCreateFramebuffer;
fns.vkDestroyFramebuffer = FakeDestroyFramebuffer;
fns.vkCmdBeginRenderPass = FakeCmdBeginRenderPass;
fns.vkCmdEndRenderPass = FakeCmdEndRenderPass;
fns.vkCreateShaderModule = FakeCreateShaderModule;
fns.vkDestroyShaderModule = FakeDestroyShaderModule;
fns.vkCreatePipelineLayout = FakeCreatePipelineLayout;
fns.vkDestroyPipelineLayout = FakeDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = FakeCreateGraphicsPipelines;
fns.vkDestroyPipeline = FakeDestroyPipeline;
fns.vkCmdBindPipeline = FakeCmdBindPipeline;
fns.vkCmdDraw = FakeCmdDraw;
fns.vkCreateFence = FakeCreateFence;
fns.vkDestroyFence = FakeDestroyFence;
fns.vkQueueSubmit = FakeQueueSubmit;
fns.vkWaitForFences = FakeWaitForFences;
fns.vkGetQueryPoolResults = FakeGetQueryPoolResults;
fns.vkDeviceWaitIdle = FakeDeviceWaitIdle;
return context;
}
} // namespace
// The hung driver. Nothing the probe created may be destroyed, the device may not
// be idle-waited, and the measurement must SAY the wait timed out - a caller that
// owns the device reads that flag to leak it too, and `ran == false` alone cannot
// tell this apart from an ordinary setup failure (where teardown already ran and
// destroying the device is correct).
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, AFenceTimeoutLeaksEverythingAndReportsItself) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_TIMEOUT;
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_FALSE(measurement.ran);
EXPECT_TRUE(measurement.fenceWaitTimedOut)
<< "without this flag the POST destroys its throwaway VkDevice while the probe's children "
"are alive and its submission may still be executing";
EXPECT_GT(g_fake.queueSubmitCalls, 0u) << "the timeout must be the SUBMITTED probe's, not a setup failure";
EXPECT_EQ(g_fake.destroyCalls, 0u)
<< "a probe that timed out must destroy nothing: the submission may still be executing";
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 0u)
<< "vkDeviceWaitIdle on a queue that missed a 5 s deadline is the hang the bound exists to "
"prevent";
// The verdict must not read a timed-out probe as anything but "no verdict".
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// The control: a driver that signals normally gets the ordinary teardown - idle
// wait, every object destroyed, no timeout flag - so the case above is testing the
// timeout branch and not a probe that never cleans up at all.
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, ASignalledFenceTearsDownNormally) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_SUCCESS;
g_fake.streamGenerated = 1; // healthy: the capture-less draws are counted
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_TRUE(measurement.ran) << measurement.failureReason;
EXPECT_FALSE(measurement.fenceWaitTimedOut);
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 1u);
EXPECT_GT(g_fake.destroyCalls, 0u);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
@@ -11,12 +11,14 @@ add_executable(
FlattenFloat64StorageBlockTest.cpp
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
StripIoBlockLocationsTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
DemotePointSizeTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,720 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DemotePointSizeTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include "spirv-tools/libspirv.hpp"
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
// Compiles and LINKS a whole program, then returns one sanitized module per stage - the
// exact bytes ProgramSpirvTask hands the demotion in production, so every shape assertion
// below is made against what the backends would really receive.
Vector<Vector<Uint32>> CompileProgramToSpirv(const Vector<Pair<GLenum, const char*>>& stages) {
using namespace MG_Util::ShaderTranspiler;
Vector<SharedPtr<glslang::TShader>> shaders;
Vector<GLenum> types;
for (const auto& [stage, source] : stages) {
// sourceStr is a StringView; the literals handed in are static, so the view
// stays valid for the whole compile.
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
shaders.push_back(shaderResult.value());
types.push_back(stage);
}
ProgramAttrib programAttrib{.shaders = shaders};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = types, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult) return {};
Vector<Vector<Uint32>> modules = Move(binaryResult.value());
for (auto& module : modules) {
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
}
return modules;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
String Transpile(const Vector<Uint32>& spirv) {
SpvcSession session(spirv, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? essl.value() : String{};
}
Bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
return tools.Validate(spirv);
}
// The five-stage shape of the KHR-GL4x transform-feedback / tessellation capture bodies:
// the value is WRITTEN in the vertex stage, READ from gl_in and re-written in every stage
// after it, and the rasterized size never matters (the captures run under rasterizer
// discard). This is exactly the class the demotion exists to rescue.
const char* kVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
gl_PointSize = 2.0;
}
)";
const char* kTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_TessCoord.x * gl_in[0].gl_Position + gl_TessCoord.y * gl_in[1].gl_Position +
gl_TessCoord.z * gl_in[2].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* kGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
const char* kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
// A control chain that never touches point size: the demotion must prove it changed
// NOTHING here, byte for byte, because this is the overwhelming majority of programs on
// an affected device.
const char* kPlainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kPlainTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
}
)";
const char* kPlainVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
}
)";
// A control stage that also carries CLIP DISTANCE. SPIRV-Cross force-redeclares the whole
// gl_PerVertex output block for exactly this stage/builtin combination, and prints its
// members from the struct's DECORATIONS rather than from what the module accesses - so a
// demoted module's untouched PointSize member would still reach the driver's ESSL.
const char* kClipDistanceTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// The same clip-distance write and NO point-size access anywhere: the shape a
// successfully demoted module would have been left in. glslang emits the whole
// four-member gl_PerVertex block regardless, which is what makes it the exact
// "declared but unaccessed" state the pass header's premise is about.
const char* kClipDistanceUnusedPointSizeTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// A tessellation evaluation module reaching PointSize through a WHOLE-STRUCT load - the
// one shape the pass must refuse rather than half-rewrite. glslang never emits it, so it
// is assembled by hand.
const char* kWholeStructCopyTessEvalAsm = R"(
OpCapability Tessellation
OpCapability TessellationPointSize
OpMemoryModel Logical GLSL450
OpEntryPoint TessellationEvaluation %main "main" %gl_in %out_block
OpExecutionMode %main Triangles
OpExecutionMode %main SpacingEqual
OpExecutionMode %main VertexOrderCcw
OpMemberDecorate %gl_PerVertex 0 BuiltIn Position
OpMemberDecorate %gl_PerVertex 1 BuiltIn PointSize
OpDecorate %gl_PerVertex Block
%void = OpTypeVoid
%fn_ty = OpTypeFunction %void
%float = OpTypeFloat 32
%v4float = OpTypeVector %float 4
%gl_PerVertex = OpTypeStruct %v4float %float
%uint = OpTypeInt 32 0
%uint_32 = OpConstant %uint 32
%arr = OpTypeArray %gl_PerVertex %uint_32
%ptr_in_arr = OpTypePointer Input %arr
%gl_in = OpVariable %ptr_in_arr Input
%ptr_out_s = OpTypePointer Output %gl_PerVertex
%out_block = OpVariable %ptr_out_s Output
%ptr_in_s = OpTypePointer Input %gl_PerVertex
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%main = OpFunction %void None %fn_ty
%entry = OpLabel
%p = OpAccessChain %ptr_in_s %gl_in %int_0
%v = OpLoad %gl_PerVertex %p
OpStore %out_block %v
OpReturn
OpFunctionEnd
)";
} // namespace
class DemotePointSizeTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
m_validationFailuresBefore = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresBefore)
<< "a demoted module did not survive spirv-val";
}
private:
Uint64 m_validationFailuresBefore = 0;
};
TEST_F(DemotePointSizeTest, DemotesAFiveStageProgramWholesale) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_GEOMETRY_SHADER, kGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 5u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The defect, pinned first: every tessellation/geometry stage really does declare the
// capability the device lacks - the same probe production's declines use.
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// THE PRODUCTION GATE, as the arming guard: after demotion neither decline can arm.
// Magma's refusal and Espryt's missing-extension failure both key off exactly these.
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
for (const auto& module : modules) {
EXPECT_TRUE(Validates(module));
}
// The carrier chain, boundary by boundary. No user varyings, so the shared location is 0.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpName %mg_PointSizeIo0"), String::npos) << vs;
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos)
<< "the vertex stage must mirror its built-in into the carrier:\n"
<< vs;
EXPECT_NE(vs.find("BuiltIn PointSize"), String::npos)
<< "the vertex stage KEEPS its core built-in - only tess/geometry stages demote:\n"
<< vs;
const String tcs = Disassemble(modules[1]);
EXPECT_EQ(tcs.find("OpCapability TessellationPointSize"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo0"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo1"), String::npos) << tcs;
const String tes = Disassemble(modules[2]);
EXPECT_EQ(tes.find("OpCapability TessellationPointSize"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo1"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "with a geometry stage present the evaluation stage feeds the Io2 boundary, not the "
"capture carrier:\n"
<< tes;
const String gs = Disassemble(modules[3]);
EXPECT_EQ(gs.find("OpCapability GeometryPointSize"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeIo2"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 0"), String::npos) << gs;
EXPECT_NE(gs.find("OpStore %mg_PointSizeCapture"), String::npos) << gs;
// The struct keeps its member - declared, decorated, unaccessed - which is the shape a
// point-size-free glslang module already has on every extension-less driver.
EXPECT_NE(tes.find("BuiltIn PointSize"), String::npos) << tes;
// What SPIRV-Cross then prints: no gl_PointSize anywhere in a demoted stage's ESSL (the
// token DirectGLES's extension gate greps for), the carriers in its place. The CONTROL
// stage is transpiled too, and deliberately: it is the one stage SPIRV-Cross can be made
// to redeclare the whole output block for, which is why the clip-distance combination
// declines instead of demoting.
const String tcsEssl = Transpile(modules[1]);
EXPECT_EQ(tcsEssl.find("gl_PointSize"), String::npos) << tcsEssl;
EXPECT_NE(tcsEssl.find("mg_PointSizeIo1"), String::npos) << tcsEssl;
const String tesEssl = Transpile(modules[2]);
EXPECT_EQ(tesEssl.find("gl_PointSize"), String::npos) << tesEssl;
EXPECT_NE(tesEssl.find("mg_PointSizeIo1"), String::npos) << tesEssl;
const String gsEssl = Transpile(modules[3]);
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
// Demotion is idempotent by construction: with the capability gone, a second pass over
// the same modules finds nothing to arm on and must not touch a byte.
Vector<Vector<Uint32>> again = modules;
ShaderCompiler::PointSizeDemotionOutcome secondOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
again, types, true, true, true, secondOutcome, true, true));
EXPECT_FALSE(secondOutcome.demoted);
EXPECT_TRUE(secondOutcome.declineDetail.empty()) << secondOutcome.declineDetail;
EXPECT_EQ(again, modules);
}
TEST_F(DemotePointSizeTest, WithoutAGeometryStageTheEvaluationStageOwnsTheCaptureCarrier) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_NE(tes.find("OpStore %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_EQ(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "no geometry stage, no Io2 boundary:\n"
<< tes;
}
TEST_F(DemotePointSizeTest, AGeometryOnlyProgramReadsTheVertexBoundary) {
const char* geometryReadingVs = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, geometryReadingVs},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, /*demoteTessellation=*/false, /*demoteGeometry=*/true, false, outcome, true,
true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the geometry stage's input boundary is fed by the vertex stage:\n"
<< gs;
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos) << vs;
}
TEST_F(DemotePointSizeTest, TheCarrierLandsPastTheProgramsOwnVaryings) {
const char* vsWithVarying = R"(#version 460 core
out vec4 v_color;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_color = vec4(0.5);
}
)";
const char* gsWithVarying = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
in vec4 v_color[];
out vec4 g_color;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_color = v_color[0];
EmitVertex();
EndPrimitive();
}
)";
const char* fsWithVarying = R"(#version 460 core
in vec4 g_color;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = g_color; }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vsWithVarying},
{GL_GEOMETRY_SHADER, gsWithVarying},
{GL_FRAGMENT_SHADER, fsWithVarying}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_color / g_color occupy location 0, so every carrier must sit at 1 - in every stage,
// because producer and consumer match by location.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 1"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, APointSizeFreeProgramStaysByteIdentical) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kPlainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_TRUE(outcome.declineDetail.empty()) << outcome.declineDetail;
EXPECT_EQ(modules, before);
}
TEST_F(DemotePointSizeTest, AHostingDeviceStaysByteIdentical) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
// Both verdicts say the device hosts the built-in: the un-forced lane's contract.
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, false, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_EQ(modules, before);
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the un-demoted module must still arm the existing declines";
}
TEST_F(DemotePointSizeTest, ACaptureRequestForcesTheCarrierOnANonWritingCaptureStage) {
// The control stage writes point size (arming the demotion); the evaluation stage never
// does - but a by-name capture must still find the carrier declared there, holding
// whatever an unwritten varying holds, exactly as the unwritten built-in would have.
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_TRUE(Validates(modules[2]));
// And the driver-side half of the same contract: the ESSL DirectGLES hands its driver
// has to DECLARE the carrier, because DirectGLES respells the glTransformFeedbackVaryings
// request to that name. A carrier the transpile dropped would take the whole capture set
// down with an ES link error naming a variable the application never wrote.
const String tesEssl = Transpile(modules[2]);
EXPECT_NE(tesEssl.find("mg_PointSizeCapture"), String::npos) << tesEssl;
}
// THE PRODUCTION SHAPE THE FORCED CARRIER EXISTS FOR, and the one the flag's own unit test
// could not reach: the capture stage never WRITES gl_PointSize, it only reads the incoming
// one. The demotion still arms - glslang declares GeometryPointSize on a READ - so the
// built-in leaves the module, and only the capture request can put a carrier back. In
// production that request arrives as ProgramLinkTask::SpirvHandoff::captureRequestsPointSize;
// this is the same value one layer down.
TEST_F(DemotePointSizeTest, AReadOnlyCaptureStageStillDeclaresTheCaptureCarrier) {
const char* readOnlyGeometry = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main() {
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* echoFragment = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(g_echo); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The premise: a stage that only READS the built-in still declares the capability, so the
// device still refuses it and the demotion still arms.
ASSERT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "a geometry stage that only reads gl_in[].gl_PointSize must still declare "
"GeometryPointSize, or this whole class of program was never affected";
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the read still has to reach the vertex stage's mirrored value:\n"
<< gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos)
<< "the capture request must force the carrier even though this stage never writes "
"the built-in; without it DirectGLES respells the capture to a name no stage "
"declares and the whole capture set fails to link:\n"
<< gs;
const String gsEssl = Transpile(modules[1]);
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
// Without the request there is nothing to bind a by-name capture to - which is exactly
// what production did on every link while the request never reached this call.
Vector<Vector<Uint32>> unrequested = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(unrequested.size(), 3u);
ShaderCompiler::PointSizeDemotionOutcome unrequestedOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
unrequested, types, false, true, /*captureRequestsPointSize=*/false, unrequestedOutcome, true,
true));
EXPECT_TRUE(unrequestedOutcome.demoted) << unrequestedOutcome.declineDetail;
EXPECT_EQ(Disassemble(unrequested[1]).find("OpName %mg_PointSizeCapture"), String::npos)
<< "with no capture asking for it, the carrier must not be declared";
}
// THE PREMISE THE PASS HEADER USED TO STATE UNIVERSALLY: "declared but no longer accessed"
// is invisible to the ES hop. It is not, for one stage/builtin combination - and this case
// pins the mechanism with no demotion involved at all, so a future SPIRV-Cross that emitted
// by ACCESS would fail here first and the decline below could be relaxed.
TEST_F(DemotePointSizeTest, ARedeclaredControlBlockPrintsAnUnaccessedPointSizeMember) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceUnusedPointSizeTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
// Nothing in this control stage touches point size, so nothing declares the capability -
// it is byte-for-byte the state a demoted module would be left in.
ASSERT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
const String tcs = Disassemble(modules[1]);
EXPECT_NE(tcs.find("BuiltIn PointSize"), String::npos)
<< "the member has to still be declared for this case to say anything:\n"
<< tcs;
const String tcsEssl = Transpile(modules[1]);
EXPECT_NE(tcsEssl.find("gl_PointSize"), String::npos)
<< "SPIRV-Cross force-redeclares a control stage's gl_PerVertex output block when its "
"clip/cull distances are live, and prints the block's members from their "
"decorations rather than from what is accessed. DirectGLES's extension gate is a "
"text search for this token over exactly this string:\n"
<< tcsEssl;
}
// ... and therefore this program declines rather than demoting: a mutated module that the
// driver still rejects is strictly worse than the honest refusal, because it also flips the
// program-wide verdict and the L1 key.
TEST_F(DemotePointSizeTest, AControlStageCarryingClipDistanceDeclinesTheProgram) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("clip/cull"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the declined program must still arm the existing honest refusals";
}
// A legal desktop-GL shape the passthrough machinery explicitly serves: an evaluation stage
// sitting straight on the vertex stage. Both backends synthesize the missing control stage,
// and that synthesized stage forwards gl_Position and nothing else - so the input carrier the
// demotion would create has no producer, and each backend's "reads a located input" guard
// would decline the program against a varying name the application never wrote. Declining the
// demotion instead keeps the modules, and the diagnostics, honest.
TEST_F(DemotePointSizeTest, AnEvaluationStageWithNoControlStageDeclines) {
const char* readingTessEval = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
}
)";
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_EVALUATION_SHADER, readingTessEval},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_EVALUATION_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("control stage"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_FALSE(ShaderCompiler::ModuleReadsLocatedInput(modules[1]))
<< "the declined evaluation stage must not have acquired the located input carrier "
"that both backends' pass-through guard refuses";
}
// The carrier is placed one past the highest location any stage CONSUMES, and a 64-bit
// vector consumes two of them. GL 4.6 core 11.1.2.1 says so for doubles, and
// ARB_gpu_shader_int64 - which DirectVulkan advertises unconditionally - extends the rule
// verbatim to i64/u64. An i64vec4 counted as one location would put the carrier on the
// SECOND location that varying already owns: two Output variables at one location, an
// invalid Vulkan interface and an ES link error naming a variable the application never
// wrote. This is the one direction the placement is not allowed to be wrong in.
TEST_F(DemotePointSizeTest, TheCarrierClearsA64BitIntegerVectorVarying) {
const char* wideVertex = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat out i64vec4 v_wide;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_wide = i64vec4(1, 2, 3, 4);
}
)";
const char* wideGeometry = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(points) in;
layout(points, max_vertices = 1) out;
layout(location = 0) flat in i64vec4 v_wide[];
layout(location = 0) flat out i64vec4 g_wide;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_wide = v_wide[0];
EmitVertex();
EndPrimitive();
}
)";
const char* wideFragment = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat in i64vec4 g_wide;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(float(g_wide.x)); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, wideVertex},
{GL_GEOMETRY_SHADER, wideGeometry},
{GL_FRAGMENT_SHADER, wideFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_wide / g_wide sit at location 0 and occupy 0 AND 1, so every carrier must clear 2.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos)
<< "the carrier landed on a location the i64vec4 varying already owns:\n"
<< vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 2"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, AWholeStructCopyDeclinesTheProgramByteIdentically) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
ASSERT_TRUE(tools.Assemble(kWholeStructCopyTessEvalAsm, &module));
ASSERT_TRUE(tools.Validate(module));
Vector<Vector<Uint32>> modules{module};
const Vector<GLenum> types{GL_TESS_EVALUATION_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_FALSE(outcome.declineDetail.empty())
<< "a shape the pass cannot express must say so, not silently no-op";
EXPECT_EQ(modules[0], module) << "a decline must not leave a half-demoted module behind";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[0]))
<< "the declined module must still arm the existing honest refusals";
}
@@ -22,6 +22,8 @@
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
@@ -38,6 +40,7 @@ namespace {
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeRuntimeArray = 29;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
@@ -116,6 +119,18 @@ namespace {
return {elementTypeId, length};
}
// The element type id of OpTypeRuntimeArray <arrayId>, or 0 when it is not one - which is
// what a BOUNDED flattened member (an OpTypeArray) answers too, so the two shapes can be told
// apart by the pair of helpers.
Uint32 RuntimeArrayElementOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeRuntimeArray || wordCount < 3 || words[1] != arrayId) return;
elementTypeId = words[2];
});
return elementTypeId;
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
@@ -140,6 +155,61 @@ namespace {
return text;
}
// How many lines of a disassembly hold BOTH fragments - "OpIMul %uint" and "%uint_8", say -
// which is how the index arithmetic the pass emits is pinned without a host that could run it.
Uint32 CountLinesWith(const String& text, const String& first, const String& second) {
Uint32 count = 0;
SizeT lineStart = 0;
while (lineStart < text.size()) {
SizeT lineEnd = text.find('\n', lineStart);
if (lineEnd == String::npos) lineEnd = text.size();
const String line = text.substr(lineStart, lineEnd - lineStart);
if (line.find(first) != String::npos && line.find(second) != String::npos) ++count;
lineStart = lineEnd + 1;
}
return count;
}
// What every test of the open-ended shape asserts: the block collapsed to ONE member, which
// is a `uint[]` RUNTIME array of stride 4 rather than a bounded one, and nothing 64-bit is
// left for the demotion to find. Returns the disassembly for the arithmetic checks.
String ExpectOpenEndedWordArray(const Vector<Uint32>& output, const String& blockName) {
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, blockName);
EXPECT_NE(structId, 0u) << text;
if (structId == 0) return text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
EXPECT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n" << text;
if (members.size() != 1) return text;
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const Uint32 elementTypeId = RuntimeArrayElementOf(output, members[0]);
EXPECT_NE(elementTypeId, 0u) << "member 0 is not a runtime array\n" << text;
EXPECT_EQ(ArrayShapeOf(output, members[0]).first, 0u)
<< "an open-ended block must not be given a bounded length\n"
<< text;
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
return text;
}
// The compute shape every failing KHR-Single-GL45.subgroups fp64 case binds: one runtime
// array of doubles, indexed by an invocation id, read whole-element.
String OpenEndedComputeSource(const String& elementType) {
return String(R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { )") +
elementType + R"( data[]; };
void main() {
)" + elementType +
R"( value = data[gl_LocalInvocationID.x] * data[0];
result[gl_GlobalInvocationID.x] = uint(value)" +
(elementType == "double" ? String{} : String(".x")) + R"();
}
)";
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
@@ -345,3 +415,679 @@ TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityA
EXPECT_EQ(explicitlyDemoted, defaulted);
EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted);
}
// ---------------------------------------------------------------------------
// The open-ended shape: a block whose last member is a runtime array. Before this was accepted
// the pass declined it and the demotion re-derived ArrayStride 4 for the now-float element, so
// `double data[]` read the application's 8-byte-stride buffer as 32-bit words - every fp64
// KHR-Single-GL45.subgroups case failed on exactly that.
// ---------------------------------------------------------------------------
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockOfDoublesBecomesAWordRuntimeArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Element i of the original array starts at word 2i, so the dynamic index is scaled by 2 ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the constant `data[0]` is the pair of words at 0 and 1, reached through the one
// member the block has left.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, EachDoubleVectorWidthStepsByItsOwnStride) {
struct Shape {
const char* element;
// std430 strides: dvec2 16 bytes, dvec3 and dvec4 32 bytes - i.e. 4, 8 and 8 words.
const char* strideWords;
// The last component's word offset inside one element, and the first one past it.
const char* lastComponentWords;
const char* firstWordPastIt;
};
const Shape shapes[] = {{"dvec2", "%uint_4", "%uint_2", "%uint_4"},
{"dvec3", "%uint_8", "%uint_4", "%uint_6"},
{"dvec4", "%uint_8", "%uint_6", "%uint_8"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.element);
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource(shape.element));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", shape.strideWords), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", shape.lastComponentWords), 1u) << text;
// A dvec3 is six words in a stride of eight: nothing may be read from the padding.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", shape.firstWordPastIt), 0u) << text;
}
}
TEST_F(FlattenFloat64StorageBlockTest, AFixedPrefixBeforeTheRuntimeArrayIsAddedToEveryIndex) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = head.x + uint(data[gl_LocalInvocationID.x]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId), (Vector<Uint32>{0, 16})) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// The 16-byte prefix is 4 words: element i is at word 4 + 2i.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
// And the prefix member itself is still word 0.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
// OpArrayLength on the flattened member counts WORDS. GL's `.length()` is the number of whole
// elements the bound range holds past the array's offset, so the count has to be rebased and
// divided - in unsigned arithmetic, and clamped rather than wrapped when the range is shorter
// than the prefix.
namespace {
// A prefix, an open-ended array of doubles, and a `.length()` of it - the one shape whose
// rewrite is an instruction SPIRV-Cross has to spell rather than plain arithmetic.
constexpr const char* kOpenEndedLengthSource = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length()) + head.y;
}
)";
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfAnOpenEndedBlockIsRewrittenToAnElementCount) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
// glslang asks for member 1's length and signs the answer.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpArrayLength %uint", " 1"), 1u) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Re-aimed at the one member left, ...
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 1"), 0u) << text;
// ... rebased past the 4-word prefix, clamped at zero when the range does not reach it, ...
EXPECT_EQ(CountLinesWith(text, "OpISub %uint", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpULessThan %bool", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect %uint", "%uint_0"), 1u) << text;
// ... and divided by the 2-word stride, with glslang's own sign conversion still downstream.
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpBitcast %int", ""), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfABlockWithNoPrefixNeedsNoClamp) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dvec2 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length());
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
// Nothing to subtract, so nothing to clamp: the word count over the 4-word stride is it.
EXPECT_EQ(CountLinesWith(text, "OpISub", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_4"), 1u) << text;
}
// The graphics shape of the same CTS group: a fragment stage reading a `readonly` block. The
// NonWritable the qualifier became is a promise about the whole block, and has to be on the one
// member the flattened block keeps.
TEST_F(FlattenFloat64StorageBlockTest, AReadOnlyOpenEndedBlockKeepsNonWritable) {
const String source = R"(#version 450 core
layout(binding = 4, std430) readonly buffer Buffer4 { dvec3 data[]; };
layout(location = 0) out vec4 o_color;
void main() {
uint index = uint(gl_FragCoord.x);
o_color = vec4(float(data[index].z));
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_FRAGMENT_SHADER, source);
ASSERT_FALSE(input.empty());
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer4");
EXPECT_EQ(CountLinesWith(text, "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u) << text;
// dvec3: stride 8 words, .z at +4.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_8"), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
}
// Writing through an open-ended block, which no CTS case does but any shader may: the store
// is decomposed into the same words the load would have read, so the bytes the application
// gets back are the ones GL says it wrote.
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockIsWrittenThroughTheSameWords) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 1) buffer Data { double data[]; };
void main() {
data[gl_LocalInvocationID.x] = double(gl_LocalInvocationID.y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// One dynamic index, scaled to the 2-word element ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the double left as exactly two word stores, nothing wider.
EXPECT_EQ(CountLinesWith(text, "OpStore", ""), 2u) << text;
EXPECT_EQ(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", ""), 2u) << text;
}
// The exact compute shader KHR-Single-GL45.subgroups.arithmetic.compute.subgroupmul_double
// generates, so the CTS shape is pinned as it is and not as a paraphrase of it.
TEST_F(FlattenFloat64StorageBlockTest, TheSubgroupMulDoubleComputeShaderIsFlattened) {
const String source = R"(#version 450
#extension GL_KHR_shader_subgroup_arithmetic: enable
#extension GL_KHR_shader_subgroup_ballot: enable
layout (local_size_x = 16, local_size_y = 1, local_size_z = 1) in;
layout(binding = 0, std430) buffer Buffer0
{
uint result[];
};
layout(binding = 1, std430) buffer Buffer1
{
double data[];
};
void main (void)
{
uvec3 globalSize = gl_NumWorkGroups * gl_WorkGroupSize;
highp uint offset = globalSize.x * ((globalSize.y * gl_GlobalInvocationID.z) + gl_GlobalInvocationID.y) + gl_GlobalInvocationID.x;
uvec4 mask = subgroupBallot(true);
uint start = 0u, end = gl_SubgroupSize;
double ref = double(1);
uint tempResult = 0u;
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult = (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x1u : 0u;
if (1u == (gl_SubgroupInvocationID % 2u))
{
mask = subgroupBallot(true);
ref = double(1);
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult |= (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x2u : 0u;
}
else
{
tempResult |= 0x2u;
}
result[offset] = tempResult;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer1");
// Four reads of the array, each scaled to the 2-word element.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 4u) << text;
// The result block holds no double and is not the pass's business.
const Uint32 resultStructId = StructIdNamed(output, "Buffer0");
ASSERT_NE(resultStructId, 0u) << text;
const Vector<Uint32> resultMembers = MemberTypesOf(output, resultStructId);
ASSERT_EQ(resultMembers.size(), 1u);
EXPECT_TRUE(IsUint32Type(output, RuntimeArrayElementOf(output, resultMembers[0]))) << text;
}
// A runtime array whose element is a MATRIX. The member's own MatrixStride and RowMajor
// decorations describe those elements, so a row-major one has to be declined - its columns are
// not contiguous, and addressing it in column order against a row-major buffer would be silently
// wrong bytes rather than a refusal. The column-major twin must flatten, stepping by the
// element's stride and then by the column's.
TEST_F(FlattenFloat64StorageBlockTest, ARowMajorMatrixRuntimeArrayIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1, row_major) buffer Data { dmat4 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x][1][2]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
// The premise: glslang really did mark the member row-major.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Data 0 RowMajor", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// Still a runtime array of matrices - narrowed to fp32 by the demotion, not re-addressed.
EXPECT_NE(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u)
<< "a row-major matrix element must not have been flattened into words\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, AColumnMajorMatrixRuntimeArrayStepsByItsColumnStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dmat2x4 data[]; };
void main() {
dvec4 column = data[gl_LocalInvocationID.x][1];
result[gl_GlobalInvocationID.x] = uint(column.w);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// dmat2x4: two columns of dvec4, column stride 32 bytes, so one element is 64 bytes -
// 16 words - and column 1 starts 8 words into it.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_16"), 1u) << text;
// Exactly one +8: the column's own offset inside the element. A second would mean a word
// past the column was being addressed off that same base.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_8"), 1u) << text;
// All eight words of that column are read - the last of its four doubles ends at +7 ...
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_7"), 1u) << text;
// ... and the column that was not asked for is not touched: nothing is read at +9 or past.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_9"), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_10"), 0u) << text;
}
// A runtime array whose element is a STRUCT: the same walk, and the same decline test, as a
// bounded array of them - a shape no other open-ended case reaches.
TEST_F(FlattenFloat64StorageBlockTest, AStructRuntimeArrayStepsByItsElementStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Pair { double a; float b; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Pair data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].a) +
uint(data[gl_LocalInvocationID.x].b);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// std430 rounds `{ double a; float b; }` up to its 8-byte alignment: 16 bytes, 4 words,
// with `b` two words in.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_4"), 2u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_2"), 1u) << text;
}
// The leaf cap bounds ONE load or store, not a member's size: a block whose element is far too
// big to expand whole is still flattened while every access to it names a scalar. Declining it
// would leave the application's 8-byte-stride doubles to the demotion's re-derived stride 4 -
// the exact defect the open-ended shape exists to avoid.
TEST_F(FlattenFloat64StorageBlockTest, AHugeRuntimeArrayElementIsStillFlattenedWhenAccessesAreSmall) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Big { dvec4 v[300]; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Big data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].v[3].y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// 300 dvec4 of 32 bytes each: 9600 bytes, 2400 words per element - 1200 scalars, well past
// the per-access cap that a whole-element load would have to respect and this never does.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2400"), 1u) << text;
// v[3].y is 3 * 8 + 2 = 26 words into the element.
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_26"), 1u) << text;
}
// The flatten preserves a byte layout ACROSS a narrowing; where the backend consumes 64-bit
// floats itself there is nothing to preserve, and the open-ended block has to keep its runtime
// array of doubles exactly as the driver would lay it out.
TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesAnOpenEndedBlockAndItsDoublesAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
EXPECT_NE(RuntimeArrayElementOf(output, members[0]), 0u)
<< "the member should still be a runtime array\n"
<< text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 8u)
<< "the array must keep the 8-byte stride the application bound\n"
<< text;
EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u)
<< "nothing narrows here, so the doubles must survive\n"
<< text;
}
// The other backend prints the flattened module through SPIRV-Cross: an open-ended `uint[]`
// member has to come out as ESSL that names no 64-bit type. The `.length()` shape is here too,
// because the OpArrayLength the rewrite re-issues is the one instruction in it whose ESSL
// spelling is not plain arithmetic - if that backend ever refused it on the flattened member,
// a DirectGLES shader asking an fp64 buffer its length would fail at link and nowhere else.
namespace {
String TranspileToEssl(const Vector<Uint32>& spirv) {
using namespace MG_Util::ShaderTranspiler;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
EXPECT_EQ(session.CreateOptions(&options), SPVC_SUCCESS);
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
EXPECT_EQ(session.SetOptions(options), SPVC_SUCCESS);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? *essl : String{};
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("dvec4"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
EXPECT_NE(essl.find("uint"), String::npos) << essl;
}
TEST_F(FlattenFloat64StorageBlockTest, TheRewrittenLengthCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
// The length survived as a length - it was not folded away or dropped on the floor.
EXPECT_NE(essl.find(".length()"), String::npos) << essl;
}
// ---------------------------------------------------------------------------
// The gate from the other side: a runtime array anywhere but the block's own last member is a
// shape GLSL cannot spell and this pass does not describe. SPIR-V can spell it, so both are
// hand-written, and both are invalid Vulkan SPIR-V - the chain runs without its validator here,
// which is also why neither can be a validation-failure count.
// ---------------------------------------------------------------------------
namespace {
// `buffer Odd { double data[]; uint tail; }`, the runtime array FIRST.
const char* kRuntimeArrayNotLastAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Odd "Odd"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpDecorate %Odd Block
OpMemberDecorate %Odd 0 Offset 0
OpMemberDecorate %Odd 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Odd = OpTypeStruct %_runtimearr_double %uint
%_ptr_StorageBuffer_Odd = OpTypePointer StorageBuffer %Odd
%var = OpVariable %_ptr_StorageBuffer_Odd StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
// `struct Inner { double data[]; }; buffer Outer { uint head; Inner inner; }`: the runtime
// array IS last, but of a member rather than of the block.
const char* kRuntimeArrayNestedAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Outer "Outer"
OpName %Inner "Inner"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpMemberDecorate %Inner 0 Offset 0
OpDecorate %Outer Block
OpMemberDecorate %Outer 0 Offset 0
OpMemberDecorate %Outer 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Inner = OpTypeStruct %_runtimearr_double
%Outer = OpTypeStruct %uint %Inner
%_ptr_StorageBuffer_Outer = OpTypePointer StorageBuffer %Outer
%var = OpVariable %_ptr_StorageBuffer_Outer StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_1 %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
Vector<Uint32> AssembleUnchecked(const char* asmText) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
EXPECT_TRUE(tools.Assemble(asmText, &module));
return module;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, ARuntimeArrayThatIsNotTheBlocksLastMemberIsLeftToTheDemotion) {
struct Shape {
const char* asmText;
const char* blockName;
};
const Shape shapes[] = {{kRuntimeArrayNotLastAsm, "Odd"}, {kRuntimeArrayNestedAsm, "Outer"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.blockName);
const Vector<Uint32> input = AssembleUnchecked(shape.asmText);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, false, false));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
// Declined: both members are still there, and the demotion narrowed them the old way.
const Uint32 structId = StructIdNamed(output, shape.blockName);
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
}
// ---------------------------------------------------------------------------
// The front end declares types in first-use order, so a block that is the first thing the
// shader touches is declared before the module's `uint` - and the flattened member is an array
// OF `uint`. For an OPEN-ENDED block the pass moves that operand-less type up in front of the
// block rather than declining, so that where a buffer of doubles stands in the shader does not
// decide whether its bytes survive. A BOUNDED block in the same position keeps the decline it
// has always had: widening that is a change to a path this fix does not need, and the pair below
// pins both halves.
// ---------------------------------------------------------------------------
namespace {
// The position of <id>'s declaration in instruction order, or 0 when it has none.
Uint32 DeclarationIndexOf(const Vector<Uint32>& spirv, Uint32 id) {
Uint32 index = 0;
Uint32 found = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
++index;
if (found != 0 || wordCount < 2) return;
// Every OpType* has its result id in word 1; that is all this is asked about.
if (opcode >= kOpTypeInt && opcode <= kOpTypeStruct && words[1] == id) found = index;
});
return found;
}
Uint32 Uint32TypeIdOf(const Vector<Uint32>& spirv) {
Uint32 typeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeInt && wordCount >= 4 && words[2] == 32u && words[3] == 0u) typeId = words[1];
});
return typeId;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockDeclaredBeforeTheModulesUintIsStillFlattened) {
// The block is the first thing main touches, and nothing before it needs a uint - not even
// an array length, which is a uint constant and would declare one.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Data { double data[]; };
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
result[0] = float(data[0] + data[1]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
// The premise: the module's uint really is declared after the block (or not at all).
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// And the uint now stands in front of the block it is an element of.
EXPECT_LT(DeclarationIndexOf(output, RuntimeArrayElementOf(output, members[0])),
DeclarationIndexOf(output, structId))
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, ABoundedBlockDeclaredBeforeTheModulesUintIsLeftToTheDemotion) {
// The same position, a bounded block: this is the shape that has always been declined, and
// it stays declined - its members and the demotion's own repacking come through untouched.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Wide {
double data0;
dvec2 data1;
} g_wide;
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
double sum = g_wide.data0 + g_wide.data1.y;
result[0] = float(sum);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u)
<< "a bounded block in this position must keep the decline it shipped with\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
@@ -0,0 +1,289 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/StripIoBlockLocationsTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
String Transpile(const Vector<Uint32>& spirv) {
SpvcSession session(spirv, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? essl.value() : String{};
}
// How many times `needle` occurs in `haystack`.
SizeT CountOf(const String& haystack, const String& needle) {
SizeT count = 0;
for (SizeT at = haystack.find(needle); at != String::npos; at = haystack.find(needle, at + 1)) {
++count;
}
return count;
}
// The tessellation evaluation stage of
// KHR-GLxx.shading_language_420pack.length_of_vector_and_matrix_*, reduced to what this
// pass is about: one block consumed, one block produced, a plain varying in each
// direction, and NO location written anywhere in the source. Every location in the
// emitted ESSL is invented by glslang's cross-stage IO resolver.
const char* kTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in vec4 tcs_tes_result[];
out vec4 tes_gs_result;
in TCSOutputBlock {
vec4 tcs_tes_variable;
} input_block[];
out TESOutputBlock {
vec4 tes_gs_variable;
} output_block;
void main()
{
tes_gs_result = tcs_tes_result[0];
output_block.tes_gs_variable = input_block[0].tcs_tes_variable;
}
)";
// The OTHER place a block's location can live. When the application locates the MEMBERS
// rather than the block, glslang emits one OpMemberDecorate Location per member and
// NOTHING on the variable - and SPIRV-Cross then suppresses the block-level qualifier and
// prints the member ones instead. A strip that only looked at the variable would find
// nothing to remove here, report "unchanged", and leave the emitted ESSL carrying exactly
// the located block the driver drops the payload for.
const char* kMemberLocatedTessEvalSource = R"(#version 450 core
layout(isolines, point_mode) in;
in TCSOutputBlock {
layout(location = 4) vec4 tcs_tes_variable;
layout(location = 5) vec4 tcs_tes_second;
} input_block[];
out TESOutputBlock {
layout(location = 6) vec4 tes_gs_variable;
layout(location = 7) vec4 tes_gs_second;
} output_block;
void main()
{
output_block.tes_gs_variable = input_block[0].tcs_tes_variable;
output_block.tes_gs_second = input_block[0].tcs_tes_second;
}
)";
// A stage with no interface block at all: the pass must leave its located varyings alone
// and report that it changed nothing, so the caller declines the re-serialised module.
const char* kNoBlockTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in vec4 tcs_tes_result[];
out vec4 tes_gs_result;
void main()
{
tes_gs_result = tcs_tes_result[0];
}
)";
} // namespace
// NOTE ON spirv-val, because its absence here is deliberate and every sibling pass test
// asserts the opposite. Vulkan SPIR-V REQUIRES a Location decoration on every user-defined
// Input/Output variable ([VUID-StandaloneSpirv-Location-04915]), so a module whose interface
// blocks have had theirs removed is INVALID Vulkan SPIR-V by construction - that is what the
// pass was asked to produce. It never reaches a driver as SPIR-V: DirectGLES runs this last
// in its chain and hands the result straight to SPIRV-Cross, which needs no location to print
// a block. What the cases below assert instead is the thing that actually matters - that
// SPIRV-Cross still emits a complete, matchable interface from it.
class StripIoBlockLocationsTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
TEST_F(StripIoBlockLocationsTest, DropsTheQualifierFromBothBlocksAndLeavesVaryingsAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
// The defect this exists for, pinned before the repair: SPIRV-Cross really does print a
// location on the blocks, and on this driver that is what loses their payload.
const String before = Transpile(input);
EXPECT_NE(before.find(") in TCSOutputBlock"), String::npos) << before;
EXPECT_NE(before.find(") out TESOutputBlock"), String::npos) << before;
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
// The blocks come out bare...
EXPECT_NE(after.find("in TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find("out TESOutputBlock"), String::npos) << after;
EXPECT_EQ(after.find(") in TCSOutputBlock"), String::npos)
<< "the consumed block still carries a layout qualifier:\n"
<< after;
EXPECT_EQ(after.find(") out TESOutputBlock"), String::npos)
<< "the produced block still carries a layout qualifier:\n"
<< after;
// ...and everything ES matches them by is untouched, which is what makes the unlocated
// interface still find its other end.
EXPECT_NE(after.find("input_block"), String::npos) << after;
EXPECT_NE(after.find("output_block"), String::npos) << after;
EXPECT_NE(after.find("tcs_tes_variable"), String::npos) << after;
EXPECT_NE(after.find("tes_gs_variable"), String::npos) << after;
// The PLAIN varyings keep their locations. They work on the affected driver, and a
// fragment stage's inputs and a vertex stage's attributes are matched by them.
EXPECT_NE(after.find("in vec4 tcs_tes_result"), String::npos) << after;
EXPECT_NE(after.find("out vec4 tes_gs_result"), String::npos) << after;
EXPECT_EQ(CountOf(after, "layout(location"), 2u)
<< "exactly the two plain varyings should still be located:\n"
<< after;
}
TEST_F(StripIoBlockLocationsTest, StripsOnlyTheDirectionTheCallerArmed) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
// A separate-shader-objects program that ENDS at this stage: the block it produces is
// matched, in another program that never saw this decision, by the location alone. Only
// the consumed side may lose its qualifier.
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, false, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
EXPECT_EQ(after.find(") in TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find(") out TESOutputBlock"), String::npos)
<< "the produced block's location was dropped even though its consumer is elsewhere:\n"
<< after;
// And the mirror image, for a program that BEGINS at this stage.
bool strippedOutputOnly = false;
Vector<Uint32> outputOnly;
ASSERT_TRUE(
ShaderCompiler::StripIoBlockLocationsForEssl(input, false, true, strippedOutputOnly, outputOnly, true));
ASSERT_FALSE(outputOnly.empty());
EXPECT_TRUE(strippedOutputOnly);
const String afterOutputOnly = Transpile(outputOnly);
EXPECT_NE(afterOutputOnly.find(") in TCSOutputBlock"), String::npos) << afterOutputOnly;
EXPECT_EQ(afterOutputOnly.find(") out TESOutputBlock"), String::npos) << afterOutputOnly;
}
// The regression guard for the shape a variable-only strip walks straight past.
TEST_F(StripIoBlockLocationsTest, DropsLocationsTheApplicationPutOnTheBlockMembers) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kMemberLocatedTessEvalSource);
ASSERT_FALSE(input.empty());
// The defect, pinned first: SPIRV-Cross prints the member locations, and there is no
// block-level qualifier for a variable-level strip to find.
const String before = Transpile(input);
EXPECT_NE(before.find("layout(location = 4)"), String::npos) << before;
EXPECT_NE(before.find("layout(location = 6)"), String::npos) << before;
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny) << "the member-located block was passed by, and reporting no change "
"makes the caller decline the module and say nothing about it";
const String after = Transpile(output);
EXPECT_EQ(CountOf(after, "layout(location"), 0u)
<< "a member location survived, so the emitted block is still the shape the driver "
"drops the payload for:\n"
<< after;
// The interface still has to be matchable: same blocks, same members, same order.
EXPECT_NE(after.find("TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find("TESOutputBlock"), String::npos) << after;
EXPECT_LT(after.find("tcs_tes_variable"), after.find("tcs_tes_second")) << after;
EXPECT_LT(after.find("tes_gs_variable"), after.find("tes_gs_second")) << after;
}
// ...and the same shape with only ONE direction armed. The member decorations belong to the
// TYPE, so the unarmed block's must survive - it is matched, in another program, by exactly
// those numbers.
TEST_F(StripIoBlockLocationsTest, KeepsMemberLocationsOnTheDirectionTheCallerDidNotArm) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kMemberLocatedTessEvalSource);
ASSERT_FALSE(input.empty());
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, false, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
EXPECT_EQ(after.find("layout(location = 4)"), String::npos) << after;
EXPECT_EQ(after.find("layout(location = 5)"), String::npos) << after;
EXPECT_NE(after.find("layout(location = 6)"), String::npos)
<< "the produced block lost its member locations even though its consumer is elsewhere:\n"
<< after;
EXPECT_NE(after.find("layout(location = 7)"), String::npos) << after;
}
TEST_F(StripIoBlockLocationsTest, ReportsNoChangeForAStageWithoutInterfaceBlocks) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kNoBlockTessEvalSource);
ASSERT_FALSE(input.empty());
const String before = Transpile(input);
bool strippedAny = true; // deliberately wrong going in; the pass must clear it
Vector<Uint32> output;
ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true);
EXPECT_FALSE(strippedAny) << "a stage with no interface block must report nothing stripped, or "
"the caller adopts a re-serialised module for nothing";
// gl_PerVertex is an Input AND an Output block in this stage and must not be touched; the
// located plain varyings must not be either. Either way the emitted ESSL is unchanged.
if (!output.empty()) {
EXPECT_EQ(Transpile(output), before);
}
}
TEST_F(StripIoBlockLocationsTest, DeclinesWhenNeitherDirectionIsArmed) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
bool strippedAny = true;
Vector<Uint32> output;
EXPECT_FALSE(ShaderCompiler::StripIoBlockLocationsForEssl(input, false, false, strippedAny, output, true));
EXPECT_FALSE(strippedAny);
EXPECT_TRUE(output.empty()) << "an unarmed call must not even re-serialise the module";
}
@@ -499,6 +499,19 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
v.nativeFloat64 = true;
variants.emplace_back("nativeFloat64", BuildSpirvTranslationKey(v));
}
{ // CompileEnv::DemotesTessellationPointSize(): phase B rewrites the cached modules
// under it (the point-size demotion), so one key shape would describe two module
// sets - built-in kept vs carried as a varying with the capability stripped.
SpirvTranslationKeyInputs v = base;
v.demoteTessellationPointSize = true;
variants.emplace_back("demoteTessellationPointSize", BuildSpirvTranslationKey(v));
}
{ // ... and its geometry twin, keyed separately because the ES loader really does
// probe the two extension families independently.
SpirvTranslationKeyInputs v = base;
v.demoteGeometryPointSize = true;
variants.emplace_back("demoteGeometryPointSize", BuildSpirvTranslationKey(v));
}
// ---- inputs the WIDENED payload pulled into the key ----
// They cannot move a word of the generated SPIR-V, but they do shape the reflection the
// payload now carries, so they have to split the key. This is the group that would go
@@ -651,6 +664,48 @@ TEST_F(TranslationCacheTest, NativeFloat64IsOutOfTheFrontendFingerprintAndInside
<< "one L1 entry would then describe two different module sets";
}
// The second and third capability bits under the same placement rule as nativeFloat64:
// out of the front-end fingerprint (glslang produces the same thing either way), inside
// the L1 key (phase B's point-size demotion rewrites the cached modules under them). The
// accessor direction is pinned too, because it is INVERTED relative to the params field
// and a swap of the arms would disable the device repair with every rendering test green.
TEST_F(TranslationCacheTest, PointSizeDemotionBitsAreOutOfTheFrontendFingerprintAndInsideTheL1Key) {
CompileEnv none; // no backend at all: never demote, standalone compiles stay standard
CompileEnv hosting; // a backend that hosts the built-in
CompileEnv demoting; // a backend that cannot
hosting.backend = BackendType::DirectVulkan;
demoting.backend = BackendType::DirectVulkan;
demoting.params.SupportsTessellationPointSize = false;
demoting.params.SupportsGeometryPointSize = false;
EXPECT_FALSE(none.DemotesTessellationPointSize());
EXPECT_FALSE(none.DemotesGeometryPointSize());
EXPECT_FALSE(hosting.DemotesTessellationPointSize());
EXPECT_FALSE(hosting.DemotesGeometryPointSize());
EXPECT_TRUE(demoting.DemotesTessellationPointSize());
EXPECT_TRUE(demoting.DemotesGeometryPointSize());
EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(hosting), ComputeFrontendCompileEnvFingerprint(demoting))
<< "the point-size capability leaked into the front-end fingerprint";
EXPECT_NE(ComputeCompileEnvFingerprint(hosting), ComputeCompileEnvFingerprint(demoting))
<< "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters "
"field, hashed by object representation";
const Vector<SpirvTranslationKeyInputs::Stage> stages{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}};
SpirvTranslationKeyInputs demotedKey = BaselineSpirvInputs(stages);
demotedKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(demoting);
demotedKey.demoteTessellationPointSize = demoting.DemotesTessellationPointSize();
demotedKey.demoteGeometryPointSize = demoting.DemotesGeometryPointSize();
SpirvTranslationKeyInputs keptKey = BaselineSpirvInputs(stages);
keptKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(hosting);
keptKey.demoteTessellationPointSize = hosting.DemotesTessellationPointSize();
keptKey.demoteGeometryPointSize = hosting.DemotesGeometryPointSize();
EXPECT_FALSE(BuildSpirvTranslationKey(demotedKey) == BuildSpirvTranslationKey(keptKey))
<< "one L1 entry would then describe two different module sets";
}
// The other direction, one case per input that was KEPT. Each is a limit the front end
// really consumes - everything BuildTBuiltInResource copies into TBuiltInResource, plus the
// two inputs to the reflection vertex-attrib limit - so each must still split the key.
@@ -1001,6 +1056,20 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
v.outputBlockRenames = &otherIoBlockRenames;
variants.emplace_back("outputBlockRenames(other target)", BuildEsslTranslationKey(v));
}
{ // the two arguments to StripIoBlockLocationsForEssl, and separate cases for the same
// reason the rename maps are: a stage strips the blocks it CONSUMES only when the
// producer is in this program and the ones it PRODUCES only when the consumer is, so
// the two directions are independently armed and a key that folded them together
// would serve a fragment stage's ESSL to a vertex stage that needs the opposite.
EsslTranslationKeyInputs v = base;
v.stripInputBlockLocations = true;
variants.emplace_back("stripInputBlockLocations", BuildEsslTranslationKey(v));
}
{
EsslTranslationKeyInputs v = base;
v.stripOutputBlockLocations = true;
variants.emplace_back("stripOutputBlockLocations", BuildEsslTranslationKey(v));
}
{
EsslTranslationKeyInputs v = base;
v.enableSpirvValidation = true;
+115
View File
@@ -2052,6 +2052,81 @@ TEST_F(TextureTest, GetTextureSubImageRejectsPartialReadbackForNow) {
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
}
// A cube map keeps each face as its own stored image, so a level's texel size reads z = 1 whichever
// face is asked - but GL 4.6 core 8.11.4 addresses the six faces through zoffset, which is the
// by-name spelling of the face token glGetTexImage takes. Both halves of that were missing: the z
// range was measured against the level's 1, so every face but +X came back INVALID_OPERATION as a
// partial read, and the destination-size check summed all six faces, so even face +X could not be
// read into the one face's worth of buffer a single-face read has any reason to pass.
TEST_F(TextureTest, GetTextureSubImageSelectsTheCubeFaceZOffsetNames) {
GLuint texture = 0;
MG_Impl::GLImpl::GenTextures(1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_CUBE_MAP, texture);
// Every face carries its own index in the red channel, so a read that answers the wrong face
// says which one it answered with.
for (int face = 0; face < 6; ++face) {
const Uint8 pixel[] = {static_cast<Uint8>(10 + face), 20, 30, 40};
MG_Impl::GLImpl::TexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA8, 1, 1, 0, GL_RGBA,
GL_UNSIGNED_BYTE, pixel);
}
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "seeding the six faces failed";
for (int face = 0; face < 6; ++face) {
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(texture, 0, 0, 0, face, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
sizeof(output), output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "reading face " << face << " errored";
EXPECT_EQ(static_cast<int>(output[0]), 10 + face)
<< "zoffset " << face << " answered with face " << (static_cast<int>(output[0]) - 10);
}
// Past the last face. Still a partial read of a level with no sixth-and-beyond image.
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(texture, 0, 0, 0, 6, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, sizeof(output),
output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
}
// glGetTexImage of ONE cube face packs one face, so a PIXEL_PACK_BUFFER holding one face is
// exactly the right size for it. The validator used to measure the bound PBO against all SIX
// faces' worth and refuse - INVALID_OPERATION for a buffer the copy that follows would have filled
// precisely. glGetTexImage passes no bufSize, which skips the destination-size branch but NOT the
// PBO one, so this is the only spelling where the six-face sizing was reachable at all.
TEST_F(TextureTest, GetTexImageOfOneCubeFacePacksIntoAOneFacePixelPackBuffer) {
constexpr GLsizei kEdge = 2;
constexpr SizeT kFaceBytes = static_cast<SizeT>(kEdge) * kEdge * 4;
GLuint texture = 0;
MG_Impl::GLImpl::GenTextures(1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_CUBE_MAP, texture);
MG_Impl::GLImpl::TexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_RGBA8, kEdge, kEdge);
for (int face = 0; face < 6; ++face) {
Uint8 seed[kFaceBytes];
for (SizeT i = 0; i < kFaceBytes; ++i) seed[i] = static_cast<Uint8>(10 + face);
MG_Impl::GLImpl::TexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, 0, 0, kEdge, kEdge, GL_RGBA,
GL_UNSIGNED_BYTE, seed);
}
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "seeding the six faces failed";
GLuint buffer = 0;
MG_Impl::GLImpl::GenBuffers(1, &buffer);
MG_Impl::GLImpl::BindBuffer(GL_PIXEL_PACK_BUFFER, buffer);
MG_Impl::GLImpl::BufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(kFaceBytes), nullptr, GL_STREAM_READ);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "creating the one-face pixel pack buffer failed";
MG_Impl::GLImpl::GetTexImage(GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR)
<< "a pixel pack buffer sized for the one face this call packs was refused";
Uint8 packed[kFaceBytes] = {};
MG_Impl::GLImpl::GetBufferSubData(GL_PIXEL_PACK_BUFFER, 0, static_cast<GLsizeiptr>(kFaceBytes), packed);
EXPECT_EQ(static_cast<int>(packed[0]), 15) << "the PBO holds face " << (static_cast<int>(packed[0]) - 10)
<< ", not -Z";
MG_Impl::GLImpl::BindBuffer(GL_PIXEL_PACK_BUFFER, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(TextureTest, TextureParameteriAndBindTextureUnitAreDirectStateAccess) {
GLuint texture = 0;
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
@@ -3313,6 +3388,46 @@ TEST_F(TextureTest, NormalizePixelFormatKeepsPackedTransferTypesForPackedSizedFo
}
}
// The packed16 field-order quirk (PixelFormatNormalizeOptionBit::WidenPacked16Norm): where the
// driver stores some packed16 allocations with a mirrored field order (the Mali defect
// behind the KHR-GL4x.copy_image rgb5/rgb5_a1/rgba4 x *2d_array* failures), the
// three ES narrow formats move to 8-bit-per-channel storage. The transfer pair must NOT move
// with the bit - it is already the UNorm8 component layout the canonical shadow holds - and
// no other format may move with it either.
TEST_F(TextureTest, NormalizePixelFormatWidensThePacked16FormatsUnderTheQuirkBit) {
using MG_Util::TextureFormatProcessor::NormalizePixelFormat;
struct {
GLenum requested;
GLenum expectedNarrow;
GLenum expectedWidened;
GLenum expectedFormat;
} cases[] = {
{GL_RGB565, GL_RGB565, GL_RGB8, GL_RGB},
{GL_RGB5_A1, GL_RGB5_A1, GL_RGBA8, GL_RGBA},
{GL_RGBA4, GL_RGBA4, GL_RGBA8, GL_RGBA},
// Negative controls: a 32-bit packed format and an already-8-bit one stay put with
// the bit set - the quirk is about 16-bit packed normalized storage and nothing else.
{GL_RGB10_A2, GL_RGB10_A2, GL_RGB10_A2, GL_RGBA},
{GL_RGBA8, GL_RGBA8, GL_RGBA8, GL_RGBA},
};
for (const auto& c : cases) {
GLenum narrowInternal = 0, narrowFormat = 0, narrowType = 0;
NormalizePixelFormat(c.requested, PixelFormatNormalizeOptionBit::None, &narrowInternal, &narrowFormat,
&narrowType);
EXPECT_EQ(narrowInternal, c.expectedNarrow) << "internalformat 0x" << std::hex << c.requested;
GLenum widenedInternal = 0, widenedFormat = 0, widenedType = 0;
NormalizePixelFormat(c.requested, PixelFormatNormalizeOptionBit::WidenPacked16Norm, &widenedInternal,
&widenedFormat, &widenedType);
EXPECT_EQ(widenedInternal, c.expectedWidened) << "internalformat 0x" << std::hex << c.requested;
// The transfer pair is identical narrow and widened: the widening changes only the ES
// storage, never how client data is described to it.
EXPECT_EQ(widenedFormat, narrowFormat) << "internalformat 0x" << std::hex << c.requested;
EXPECT_EQ(widenedType, narrowType) << "internalformat 0x" << std::hex << c.requested;
EXPECT_EQ(widenedFormat, c.expectedFormat) << "internalformat 0x" << std::hex << c.requested;
}
}
// GL_RGB565 (ARB_ES2_compatibility / GL 4.1, used directly by the GL CTS) must round-trip
// through the internal-format enums; it had no GLToMG mapping at all, so glTexImage* with
// GL_RGB565 was rejected as an unknown internal format.
@@ -505,4 +505,47 @@ namespace {
ExpectSingleGlError(GL_INVALID_OPERATION);
EXPECT_FALSE(MG_State::pGLContext->ValidateTextureObject(view));
}
// ======================= which of the owner's layers a face names =======================
// A GL_TEXTURE_CUBE_MAP view over a LAYERED owner - a 2D array here, a cube-map ARRAY behaves
// identically - is the one shape where the face a target names cannot be carried by the choice
// of blob: the owner keeps every layer in ONE blob, so there is nothing for
// ToOwnerUploadTarget to choose between and the face has to land in the byte offset instead.
// It did not. The offset shifted by the view's layer origin alone, so all six face tokens read
// the view's FIRST layer-face - silently, with real texels from a real layer, on every path
// that answers out of the CPU shadow.
//
// The shadow is exactly what this exercises: the fixture's backend is not DirectVulkan, so the
// by-name readback takes the shadow arm rather than asking a backend. (DirectVulkan's own path
// resolves the face into a Vulkan baseArrayLayer and was always right, which is what made this
// a disagreement between the two backends rather than a uniform wrong answer.)
TEST_F(TextureViewTest, CubeMapViewOfAnArrayReadsTheFaceEachTokenNames) {
constexpr GLint kLayers = 8;
constexpr GLint kViewMinLayer = 2;
const GLuint storage = MakeImmutable2DArray(1, 1, kLayers);
// Every layer carries its own index, so a read that lands on the wrong one says which one
// answered instead of merely failing.
for (GLint layer = 0; layer < kLayers; ++layer) {
const Uint8 texel[] = {static_cast<Uint8>(10 + layer), 20, 30, 40};
MG_Impl::GLImpl::TexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
texel);
}
DrainPendingGlErrors();
const GLuint view = GenTexture();
MG_Impl::GLImpl::TextureView(view, GL_TEXTURE_CUBE_MAP, storage, GL_RGBA8, 0, 1, kViewMinLayer, 6);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "the cube-map view over the array was refused";
for (GLint face = 0; face < 6; ++face) {
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(view, 0, 0, 0, face, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
sizeof(output), output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "reading face " << face << " errored";
EXPECT_EQ(static_cast<GLint>(output[0]), 10 + kViewMinLayer + face)
<< "face " << face << " of a view based at layer " << kViewMinLayer << " answered with layer "
<< (static_cast<GLint>(output[0]) - 10);
}
}
} // namespace
+1 -1
View File
@@ -441,7 +441,7 @@ TEST(JobNodeException, AnExceptionEscapingABodyCancelsTheJobInsteadOfTerminating
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
EXPECT_NE(job->diagnostics.logLines[0].find("boom"), String::npos);
EXPECT_NE(job->diagnostics.logLines[0].text.find("boom"), String::npos);
}
TEST(JobNodeException, ANonStandardExceptionIsContainedToo) {
+24 -5
View File
@@ -95,11 +95,13 @@ namespace MobileGL::MG_Util::Async {
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, std::format("Job body threw: {}", e.what())});
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, String("Job body threw a non-std exception")});
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
@@ -162,10 +164,27 @@ namespace MobileGL::MG_Util::Async {
"being written");
if (!node.diagnostics.logLines.empty()) {
Vector<String> lines;
Vector<DeferredLogLine> lines;
lines.swap(node.diagnostics.logLines);
for (const String& line : lines) {
MGLOG_D("%s", line.c_str());
for (const DeferredLogLine& line : lines) {
// Per-line severity, because a shipped build compiles MGLOG_D away entirely
// and a verdict that only this channel records would vanish with it. The
// levels are the compile-time constants, so a suppressed one costs nothing
// beyond the string the worker already built.
switch (line.level) {
case MOBILEGL_LOG_LEVEL_INFO:
MGLOG_I("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_WARN:
MGLOG_W("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_ERROR:
MGLOG_E("%s", line.text.c_str());
break;
default:
MGLOG_D("%s", line.text.c_str());
break;
}
}
}
+19 -2
View File
@@ -9,6 +9,7 @@
#pragma once
#include <Includes.h>
#include <MG_Util/Types.h>
#include <MG_Util/Debug/Log.h>
#include <MG_State/GLState/ErrorState/ErrorCode.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
@@ -35,9 +36,25 @@ namespace MobileGL::MG_Util::Async {
UniquePtr<ErrorInfo> info;
};
// One line of worker-side MGLOG text, with the severity the join replays it at.
//
// DEBUG is the default and stays the default: nearly every deferred line is per-program
// trace that a shipped build compiles out, which is the whole reason this channel could
// be a plain string vector for as long as it was. A line a SHIPPED build has to show -
// the reason a repair refused, which no other surface records - has to name its level
// here, or it is formatted on the worker and then thrown away at replay under the INFO
// level every device and CI build pins. Callers that sit on a repeated path latch at
// the SOURCE (a per-call-site atomic, exactly what MGLOG_*_ONCE does): the replay below
// is one shared site for every job in the tree, so a latch there would silence
// unrelated lines.
struct DeferredLogLine {
Int level = MOBILEGL_LOG_LEVEL_DEBUG;
String text;
};
struct JobDiagnostics {
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<String> logLines; // worker-side MGLOG text, flushed in order by the join
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<DeferredLogLine> logLines; // worker-side MGLOG text, flushed in order by the join
};
// The scheduling primitive every asynchronous compile and link is built on. A node owns
@@ -22,7 +22,7 @@
namespace MobileGL::MG_Util::BackendLoader {
static Bool UseAngle() {
return MG_Config::Features.UseAngle;
return MG_Config::Features.EsprytUseAngle;
}
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS) && defined(__ANDROID__)
@@ -984,6 +984,20 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_OES_viewport_array") == 0) {
caps.SupportsViewportArray = true;
}
// EXT wins where both are advertised: it is the spelling the Android Extension
// Pack mandates, so it is the one a driver is most likely to have tested.
if (std::strcmp(extension, "GL_EXT_tessellation_point_size") == 0) {
caps.TessellationPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT;
} else if (std::strcmp(extension, "GL_OES_tessellation_point_size") == 0 &&
caps.TessellationPointSizeSupport == MG_External::GLESCapabilities::PointSizeTier::None) {
caps.TessellationPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionOES;
}
if (std::strcmp(extension, "GL_EXT_geometry_point_size") == 0) {
caps.GeometryPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT;
} else if (std::strcmp(extension, "GL_OES_geometry_point_size") == 0 &&
caps.GeometryPointSizeSupport == MG_External::GLESCapabilities::PointSizeTier::None) {
caps.GeometryPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionOES;
}
}
}
// The pointer check on top of the extension check makes each flag sufficient on its own
@@ -1055,6 +1069,19 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" clip distances (EXT_clip_cull_distance): %s", caps.SupportsClipDistance ? "yes" : "no");
MGLOG_I(" viewport array (OES_viewport_array; gl_ViewportIndex collapses to viewport 0 when absent): %s",
caps.SupportsViewportArray ? "yes" : "no");
{
const auto pointSizeTierName = [](MG_External::GLESCapabilities::PointSizeTier tier) {
switch (tier) {
case MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT: return "EXT";
case MG_External::GLESCapabilities::PointSizeTier::ExtensionOES: return "OES";
default: return "no";
}
};
MGLOG_I(" tessellation gl_PointSize (EXT/OES_tessellation_point_size): %s",
pointSizeTierName(caps.TessellationPointSizeSupport));
MGLOG_I(" geometry gl_PointSize (EXT/OES_geometry_point_size): %s",
pointSizeTierName(caps.GeometryPointSizeSupport));
}
// LOAD-BEARING STRING, not just a banner. android-plugin/trace-replay-ci.sh's
// is_angle_surface_lost() greps mobilegl.log for exactly "OpenGL ES capabilities:" to
@@ -1697,13 +1724,47 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" Indirect draw gl_InstanceID includes baseInstance: %s",
caps.IndirectDrawInstanceIdIncludesBaseInstance ? "true" : "false");
// ForceOn means "emit the blocks unlocated", i.e. treat the driver as NOT supporting
// located blocks - which is why the override reads inverted here. Auto is the probe's
// own answer and is what every real run uses; the two forced settings exist so the
// emulation can be exercised on a healthy driver (the integration lane) and turned
// off again as a negative control.
switch (MG_Config::Features.EsprytUnlocatedIoBlocks) {
case MG_Config::QuirkOverride::ForceOn:
caps.SupportsLocatedInterStageIoBlocks = false;
MGLOG_I(" Located inter-stage interface blocks: forced OFF by "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS; the driver was not probed");
break;
case MG_Config::QuirkOverride::ForceOff:
caps.SupportsLocatedInterStageIoBlocks = true;
MGLOG_I(" Located inter-stage interface blocks: forced ON by "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS; the driver was not probed");
break;
case MG_Config::QuirkOverride::Auto:
default:
// SelfTest::ProbeLocatedIoBlocksLosePayload - the Mali-G1-Ultra ES driver
// delivers nothing through an interface block that carries an explicit
// layout(location=) once a tessellation or geometry stage is in the pipeline.
// Probed with its own controls rather than matched on a renderer string; see
// DriverBugProbes.h for the shape and for why the two controls decide what the
// finding is allowed to claim.
caps.SupportsLocatedInterStageIoBlocks =
!SelfTest::LocatedIoBlocksLosePayload(glesFuncs).detected;
break;
}
MGLOG_I(" Located inter-stage interface blocks transport their payload: %s",
caps.SupportsLocatedInterStageIoBlocks
? "true"
: "false (DirectGLES will emit tessellation/geometry programs' interface "
"blocks without a location qualifier)");
caps.IsAngleRenderer = caps.GLESRendererString.find("ANGLE") != String::npos;
caps.IsAngleLlvmpipeRenderer =
caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos;
caps.AvoidSamplerMipmapMinFilter =
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidSamplerMipmapMinFilter;
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.EsprytAvoidSamplerMipmapMinFilter;
caps.AvoidExplicitLodBias =
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidExplicitLodBias;
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.EsprytAvoidExplicitLodBias;
MGLOG_I(" GL_EXT_disjoint_timer_query supported: %s",
caps.SupportsDisjointTimerQuery ? "true" : "false");
MGLOG_I(" GL_KHR_parallel_shader_compile supported: %s",
@@ -1118,6 +1118,23 @@ namespace MobileGL {
ExtensionOES, // GL_OES_texture_buffer; ESSL below 320 must say GL_OES_texture_buffer
};
TextureBufferTier TextureBufferSupport = TextureBufferTier::None;
// Which spelling of per-vertex point size a NON-VERTEX stage has, if any. In desktop
// GL gl_PointSize is an ordinary gl_PerVertex member that any vertex-processing stage
// may write and any program may capture by name; in ESSL it does not EXIST in a
// tessellation or geometry stage until GL_EXT/OES_tessellation_point_size (resp.
// ..._geometry_point_size) is requested - not even at 320, where the stages
// themselves are core. SPIRV-Cross prints the identifier bare and asks for nothing,
// exactly as it does for gl_ViewportIndex, so the directive has to be inserted into
// the emitted source (RequestPointSizeExtension) and a driver with neither spelling
// cannot compile such a stage at all. Extension string only: these add no entry
// points, so there is no pointer to require.
enum class PointSizeTier : Uint8 {
None = 0, // neither spelling; the stage cannot name gl_PointSize
ExtensionEXT, // GL_EXT_tessellation_point_size / GL_EXT_geometry_point_size
ExtensionOES, // GL_OES_tessellation_point_size / GL_OES_geometry_point_size
};
PointSizeTier TessellationPointSizeSupport = PointSizeTier::None;
PointSizeTier GeometryPointSizeSupport = PointSizeTier::None;
// GL_MAX_TEXTURE_BUFFER_SIZE actually came back from the driver. False means the value
// below is MobileGL's own floor, not a driver answer: the pname is only legal once
// buffer textures exist, and querying it on a driver without them raises
@@ -1218,10 +1235,10 @@ namespace MobileGL {
// GL_RENDERER contains both "ANGLE" and "llvmpipe".
Bool IsAngleLlvmpipeRenderer = false;
// IsAngleLlvmpipeRenderer combined with the
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle:
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle:
// sampler min filters should drop their mipmap component.
Bool AvoidSamplerMipmapMinFilter = false;
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS
// feature toggle: LOD-bias emulation should not touch explicit-LOD lookups.
Bool AvoidExplicitLodBias = false;
// True when indirect draws leak the command's baseInstance word ("reserved,
@@ -1230,6 +1247,17 @@ namespace MobileGL {
// straight to vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V
// InstanceIndex, which includes firstInstance.
Bool IndirectDrawInstanceIdIncludesBaseInstance = false;
// True when an inter-stage interface BLOCK carrying an explicit layout(location=)
// actually delivers its payload across a tessellation or geometry boundary. The
// Mali-G1-Ultra ES driver links such a program with an empty info log and then
// hands the consuming stage zeroes; DirectGLES answers by emitting those blocks
// with no location qualifier at all (StripIoBlockLocationsPass), which ES matches
// by block name and member sequence instead.
//
// Defaults TRUE and stays true when the probe cannot run, because that is the
// behaviour every driver had before the probe existed - a capability like this
// must never be assumed broken on a driver nobody measured.
Bool SupportsLocatedInterStageIoBlocks = true;
Int UniformBufferOffsetAlignment = 256;
// Its storage-buffer counterpart, queried separately because it is a separate limit:
// Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for
@@ -9,6 +9,7 @@
#include "Loader.h"
#include <Config.h>
#include <algorithm>
#include <cmath>
#include <limits>
@@ -91,7 +92,7 @@ namespace MobileGL::MG_Util::BackendLoader {
}
Bool IsShaderSubgroupForcedDisabled() {
return MG_Config::Features.DisableSubgroup;
return MG_Config::Features.MagmaDisableSubgroup;
}
} // namespace
@@ -177,7 +178,15 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(p.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(p.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = SaturateToInt(p.limits.maxSampleMaskWords);
// Clamped to one word, exactly as the GLES loader clamps the driver's value and for the
// same reason: MobileGL's sample-mask state IS a single 32-bit word
// (RenderState::SampleMaskValue) and SampleMaski_State() raises GL_INVALID_VALUE for any
// maskNumber other than 0. dEQP's per-case gluStateReset issues glSampleMaski up to
// GL_MAX_SAMPLE_MASK_WORDS, so advertising a device's real 2 would abort the whole glcts
// process after every single case - the failure da6f75dbd added the GLES clamp to stop,
// reproduced on this backend. One word is the spec minimum and therefore always legal.
// It is also what PipelineCreatePayload::sampleMask is sized for.
caps.MaxSampleMaskWords = std::min(SaturateToInt(p.limits.maxSampleMaskWords), 1);
caps.MaxTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
@@ -213,6 +222,12 @@ namespace MobileGL::MG_Util::BackendLoader {
vkGetPhysicalDeviceFeatures(physicalDevice, &supportedFeatures);
caps.SupportsWideLines = supportedFeatures.wideLines == VK_TRUE;
caps.SupportsShaderFloat64 = supportedFeatures.shaderFloat64 == VK_TRUE;
// One feature covers both stage families here, unlike the ES loader's two extension
// tiers; the renderer enables it on the device whenever advertised
// (VulkanRenderer::CreateLogicalDeviceAndQueues), so this probe and that enable can
// never disagree about the physical device.
caps.SupportsTessellationAndGeometryPointSize =
supportedFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
caps.SupportsImageCubeArray = supportedFeatures.imageCubeArray == VK_TRUE;
{
// Probe the formats a colour render target actually uses. A driver that refuses the flag
@@ -263,7 +278,7 @@ namespace MobileGL::MG_Util::BackendLoader {
supportsShaderSubgroup ? "true" : "false", caps.SupportsShaderSubgroup ? "true" : "false",
subgroupProps.subgroupSize, subgroupProps.supportedStages, subgroupProps.supportedOperations);
if (supportsShaderSubgroup && forceDisableShaderSubgroup) {
MGLOG_W("Vulkan shader subgroup support forced off by MOBILEGL_DISABLE_SUBGROUP");
MGLOG_W("Vulkan shader subgroup support forced off by MOBILEGL_MAGMA_DISABLE_SUBGROUP");
}
return true;
@@ -300,7 +315,15 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(properties.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(properties.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = SaturateToInt(properties.limits.maxSampleMaskWords);
// Clamped to one word, exactly as the GLES loader clamps the driver's value and for the
// same reason: MobileGL's sample-mask state IS a single 32-bit word
// (RenderState::SampleMaskValue) and SampleMaski_State() raises GL_INVALID_VALUE for any
// maskNumber other than 0. dEQP's per-case gluStateReset issues glSampleMaski up to
// GL_MAX_SAMPLE_MASK_WORDS, so advertising a device's real 2 would abort the whole glcts
// process after every single case - the failure da6f75dbd added the GLES clamp to stop,
// reproduced on this backend. One word is the spec minimum and therefore always legal.
// It is also what PipelineCreatePayload::sampleMask is sized for.
caps.MaxSampleMaskWords = std::min(SaturateToInt(properties.limits.maxSampleMaskWords), 1);
caps.MaxTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
@@ -333,6 +356,7 @@ namespace MobileGL::MG_Util::BackendLoader {
FillFragmentInterpolationLimits(caps, properties.limits);
caps.SupportsWideLines = false;
caps.SupportsShaderFloat64 = false;
caps.SupportsTessellationAndGeometryPointSize = false;
caps.SupportsImageCubeArray = false;
caps.Supports2DArrayCompatible3DImages = false;
// This helper only receives properties, not VkPhysicalDeviceFeatures. Leave optional
@@ -87,6 +87,13 @@ namespace MobileGL {
// needs it, which includes every 64-bit vertex attribute: the attribute itself arrives
// as 32-bit words, but the bitcast result and everything computed from it is Float64.
Bool SupportsShaderFloat64 = false;
// VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize. Any
// tessellation/geometry module declaring OpCapability TessellationPointSize /
// GeometryPointSize needs it (VUID-VkShaderModuleCreateInfo-pCode-08740's
// capability table); without it the shared phase-B chain demotes the built-in
// to an ordinary varying. One feature for both stage families, unlike the ES
// loader's two extension tiers.
Bool SupportsTessellationAndGeometryPointSize = false;
// VkPhysicalDeviceFeatures::imageCubeArray. Required before a
// VK_IMAGE_VIEW_TYPE_CUBE_ARRAY view may be created at all
// (VUID-VkImageViewCreateInfo-viewType-01004), which is every cube map array texture -
@@ -7,9 +7,12 @@
// End of Source File Header
#include "DriverBugProbes.h"
#include "PersistentBufferOrderingProbe.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
#include <algorithm>
#include <cstring>
#include <optional>
#include <string>
@@ -126,6 +129,21 @@ namespace MobileGL::MG_Util::SelfTest {
GLfloat clearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
GLint packAlignment = 4;
GLint packRowLength = 0;
// The rest of the pixel-transfer scope. The probes that upload or read back texels
// run under whatever scope their caller left - the lazy ones run from live paths,
// not just the POST screen - and a caller's skip/row-length/PBO would silently
// shear a probe's own data. Saved so a probe can zero them and the caller gets
// them back.
GLint packSkipPixels = 0;
GLint packSkipRows = 0;
GLint unpackAlignment = 4;
GLint unpackRowLength = 0;
GLint unpackImageHeight = 0;
GLint unpackSkipPixels = 0;
GLint unpackSkipRows = 0;
GLint unpackSkipImages = 0;
GLint pixelPackBuffer = 0;
GLint pixelUnpackBuffer = 0;
GLint imageName = 0;
GLint imageLevel = 0;
GLint imageLayered = 0;
@@ -165,6 +183,16 @@ namespace MobileGL::MG_Util::SelfTest {
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D_ARRAY, &state.texture2DArray);
gl.glGetIntegerv(GL_PACK_ALIGNMENT, &state.packAlignment);
gl.glGetIntegerv(GL_PACK_ROW_LENGTH, &state.packRowLength);
gl.glGetIntegerv(GL_PACK_SKIP_PIXELS, &state.packSkipPixels);
gl.glGetIntegerv(GL_PACK_SKIP_ROWS, &state.packSkipRows);
gl.glGetIntegerv(GL_UNPACK_ALIGNMENT, &state.unpackAlignment);
gl.glGetIntegerv(GL_UNPACK_ROW_LENGTH, &state.unpackRowLength);
gl.glGetIntegerv(GL_UNPACK_IMAGE_HEIGHT, &state.unpackImageHeight);
gl.glGetIntegerv(GL_UNPACK_SKIP_PIXELS, &state.unpackSkipPixels);
gl.glGetIntegerv(GL_UNPACK_SKIP_ROWS, &state.unpackSkipRows);
gl.glGetIntegerv(GL_UNPACK_SKIP_IMAGES, &state.unpackSkipImages);
gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &state.pixelPackBuffer);
gl.glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &state.pixelUnpackBuffer);
if (gl.glGetFloatv != nullptr) {
gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, state.clearColor);
}
@@ -220,6 +248,18 @@ namespace MobileGL::MG_Util::SelfTest {
if (gl.glPixelStorei != nullptr) {
gl.glPixelStorei(GL_PACK_ALIGNMENT, state.packAlignment);
gl.glPixelStorei(GL_PACK_ROW_LENGTH, state.packRowLength);
gl.glPixelStorei(GL_PACK_SKIP_PIXELS, state.packSkipPixels);
gl.glPixelStorei(GL_PACK_SKIP_ROWS, state.packSkipRows);
gl.glPixelStorei(GL_UNPACK_ALIGNMENT, state.unpackAlignment);
gl.glPixelStorei(GL_UNPACK_ROW_LENGTH, state.unpackRowLength);
gl.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, state.unpackImageHeight);
gl.glPixelStorei(GL_UNPACK_SKIP_PIXELS, state.unpackSkipPixels);
gl.glPixelStorei(GL_UNPACK_SKIP_ROWS, state.unpackSkipRows);
gl.glPixelStorei(GL_UNPACK_SKIP_IMAGES, state.unpackSkipImages);
}
if (gl.glBindBuffer != nullptr) {
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast<GLuint>(state.pixelPackBuffer));
gl.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, static_cast<GLuint>(state.pixelUnpackBuffer));
}
if (gl.glClearColor != nullptr) {
gl.glClearColor(state.clearColor[0], state.clearColor[1], state.clearColor[2],
@@ -1686,6 +1726,519 @@ namespace MobileGL::MG_Util::SelfTest {
return measurement;
}
namespace {
// ===================== LOCATED INTER-STAGE INTERFACE BLOCKS =====================
constexpr const char* kIoBlockProbeName = "located interface block";
// This probe's OWN requirements, not HasEveryEntryPoint's. That one is the geometry
// storage probe's list and asks for storage buffers and buffer mapping, which nothing
// here touches - borrowing it would let one unresolved SSBO pointer leave a driver that
// HAS this defect unrepaired, which is the opposite of what a gate is for. Covers what
// BuildProgram, Save/Restore, PrepareForProbeDraw and the draw below actually call.
Bool HasIoBlockProbeEntryPoints(const GLESFunctionsTable& gl) {
return gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv &&
gl.glGetShaderInfoLog && gl.glCreateProgram && gl.glAttachShader &&
gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog &&
gl.glDeleteShader && gl.glDeleteProgram && gl.glUseProgram && gl.glGenVertexArrays &&
gl.glBindVertexArray && gl.glDeleteVertexArrays && gl.glGenRenderbuffers &&
gl.glBindRenderbuffer && gl.glRenderbufferStorage && gl.glDeleteRenderbuffers &&
gl.glGenFramebuffers && gl.glBindFramebuffer && gl.glFramebufferRenderbuffer &&
gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers && gl.glViewport &&
gl.glClearColor && gl.glClear && gl.glDrawArrays && gl.glReadPixels &&
gl.glPixelStorei && gl.glGetIntegerv && gl.glGetIntegeri_v && gl.glGetError &&
gl.glEnable && gl.glDisable && gl.glIsEnabled;
}
// Two values that survive an 8-bit target exactly, so the read is a comparison and not
// a tolerance: 0.25 -> 64, 0.5 -> 128. A stage that received nothing reads 0/0, which is
// nowhere near either.
constexpr GLubyte kIoBlockExpectedR = 0x40;
constexpr GLubyte kIoBlockExpectedG = 0x80;
// `@BL@` becomes the layout qualifier under test, or nothing at all for the control.
// Position comes from gl_VertexID, so no probe here needs a vertex buffer.
String BuildIoBlockVertexSource(const char* blockQualifier) {
return format("#version 320 es\n"
"precision highp float;\n"
"{}out MgProbeBlock {{ vec2 mg_probeValue; }} mg_probeOut;\n"
"void main() {{\n"
" vec2 mg_p = vec2((gl_VertexID == 1) ? 3.0 : -1.0,\n"
" (gl_VertexID == 2) ? 3.0 : -1.0);\n"
" gl_Position = vec4(mg_p, 0.0, 1.0);\n"
" mg_probeOut.mg_probeValue = vec2(0.25, 0.5);\n"
"}}\n",
blockQualifier);
}
// The block name changes across the geometry stage, because the two boundaries are two
// separate interfaces; one name would also be the in-and-out-under-one-name shape
// UniquifyIoBlockNamesPass exists for, and confusing one defect with the other is
// exactly what this file's control rule is against.
String BuildIoBlockGeometrySource(const char* blockQualifier) {
return format("#version 320 es\n"
"precision highp float;\n"
"layout(triangles) in;\n"
"layout(triangle_strip, max_vertices = 3) out;\n"
"{0}in MgProbeBlock {{ vec2 mg_probeValue; }} mg_probeIn[];\n"
"{0}out MgProbeBlock2 {{ vec2 mg_probeValue; }} mg_probeOut;\n"
"void main() {{\n"
" for (int i = 0; i < 3; ++i) {{\n"
" gl_Position = gl_in[i].gl_Position;\n"
" mg_probeOut.mg_probeValue = mg_probeIn[i].mg_probeValue;\n"
" EmitVertex();\n"
" }}\n"
"}}\n",
blockQualifier);
}
String BuildIoBlockFragmentSource(const char* blockQualifier, const char* blockName) {
return format("#version 320 es\n"
"precision highp float;\n"
"{}in {} {{ vec2 mg_probeValue; }} mg_probeIn;\n"
"layout(location = 0) out vec4 mg_probeColor;\n"
"void main() {{ mg_probeColor = vec4(mg_probeIn.mg_probeValue, 0.0, 1.0); }}\n",
blockQualifier, blockName);
}
// Builds and draws one of the four programs this probe compares and reports whether the
// fragment stage received the payload. `outRan` distinguishes "the payload did not
// arrive" from "this program could not be built or drawn at all" - the second is
// inconclusive and must never become a finding.
Bool IoBlockPayloadArrives(const GLESFunctionsTable& gl, const char* blockQualifier,
Bool withGeometryStage, Bool& outRan) {
outRan = false;
Vector<StageSource> stages;
stages.push_back({GL_VERTEX_SHADER, BuildIoBlockVertexSource(blockQualifier), "vertex"});
if (withGeometryStage) {
stages.push_back(
{GL_GEOMETRY_SHADER, BuildIoBlockGeometrySource(blockQualifier), "geometry"});
}
stages.push_back({GL_FRAGMENT_SHADER,
BuildIoBlockFragmentSource(blockQualifier,
withGeometryStage ? "MgProbeBlock2"
: "MgProbeBlock"),
"fragment"});
const ProgramBuild build = BuildProgram(gl, stages, kIoBlockProbeName);
if (!build.linked) {
if (build.program != 0) gl.glDeleteProgram(build.program);
return false;
}
GLuint renderbuffer = 0;
GLuint framebuffer = 0;
Bool arrives = false;
gl.glGenRenderbuffers(1, &renderbuffer);
gl.glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
gl.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1);
gl.glGenFramebuffers(1, &framebuffer);
gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
gl.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
renderbuffer);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
gl.glUseProgram(build.program);
gl.glViewport(0, 0, 1, 1);
gl.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
gl.glClear(GL_COLOR_BUFFER_BIT);
Drain(gl);
gl.glDrawArrays(GL_TRIANGLES, 0, 3);
if (gl.glGetError() == GL_NO_ERROR) {
GLubyte pixel[4] = {0, 0, 0, 0};
gl.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
if (gl.glGetError() == GL_NO_ERROR) {
outRan = true;
// One bit of slack each way, for a driver that rounds the 8-bit
// conversion the other direction.
arrives = pixel[0] + 1 >= kIoBlockExpectedR && pixel[0] <= kIoBlockExpectedR + 1 &&
pixel[1] + 1 >= kIoBlockExpectedG && pixel[1] <= kIoBlockExpectedG + 1;
}
}
}
if (framebuffer != 0) gl.glDeleteFramebuffers(1, &framebuffer);
if (renderbuffer != 0) gl.glDeleteRenderbuffers(1, &renderbuffer);
gl.glDeleteProgram(build.program);
return arrives;
}
} // namespace
LocatedIoBlockMeasurement ProbeLocatedIoBlocksLosePayload(const GLESFunctionsTable& gl) {
LocatedIoBlockMeasurement measurement;
if (!HasIoBlockProbeEntryPoints(gl)) return measurement;
SavedState saved;
Save(gl, saved);
// The colour mask is not in SavedState - no other probe touches it - so this one saves
// and puts back its own. It has to be forced open: a masked channel would read back as
// zero and turn a healthy driver into a "payload lost" verdict.
GLboolean savedColorMask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE};
const Bool canMaskColor = gl.glColorMask != nullptr && gl.glGetBooleanv != nullptr;
if (canMaskColor) {
gl.glGetBooleanv(GL_COLOR_WRITEMASK, savedColorMask);
gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
}
GLuint vao = 0;
gl.glGenVertexArrays(1, &vao);
gl.glBindVertexArray(vao);
PrepareForProbeDraw(gl);
// THE CONTROL, and it runs first: the identical three-stage program with no location on
// the blocks. If THAT cannot carry the payload, this driver's problem is not the
// qualifier and the probe has no finding to make - reporting one would justify dropping
// a qualifier that was never the cause.
Bool controlRan = false;
const Bool controlArrives = IoBlockPayloadArrives(gl, "", true, controlRan);
if (controlRan && controlArrives) {
Bool subjectRan = false;
const Bool subjectArrives =
IoBlockPayloadArrives(gl, "layout(location = 0) ", true, subjectRan);
if (subjectRan && !subjectArrives) {
measurement.detected = true;
// The second control, and the one that scopes the repair: the same located
// block between a vertex and a fragment stage. It arrives on the driver this
// was characterised on, which is why DirectGLES only drops the qualifier for
// programs that have a tessellation or geometry stage. A driver where this one
// ALSO fails is losing payloads the repair does not reach, and the report says
// so rather than implying the fix is complete.
Bool vsFsRan = false;
const Bool vsFsArrives =
IoBlockPayloadArrives(gl, "layout(location = 0) ", false, vsFsRan);
measurement.alsoAffectsVertexToFragment = vsFsRan && !vsFsArrives;
}
}
if (vao != 0) {
gl.glBindVertexArray(0);
gl.glDeleteVertexArrays(1, &vao);
}
if (canMaskColor) {
gl.glColorMask(savedColorMask[0], savedColorMask[1], savedColorMask[2], savedColorMask[3]);
}
Restore(gl, saved);
Drain(gl);
return measurement;
}
const LocatedIoBlockMeasurement& LocatedIoBlocksLosePayload(const GLESFunctionsTable& gl) {
// One driver per process, and the answer is structural rather than sampled.
static const LocatedIoBlockMeasurement measurement = ProbeLocatedIoBlocksLosePayload(gl);
return measurement;
}
namespace {
// ===================== PACKED16 COPY-IMAGE FIELD ORDER =====================
constexpr const char* kPacked16CopyProbeName = "packed16 copy-image field order";
// The shape the KHR-GL4x.copy_image failures pin: a 30x30x12 GL_RGB5_A1 2D array with
// the CTS's three-level chain (FUNCTIONAL_TEST_N_LEVELS = 3, makeTextureComplete(0, 2):
// 30/15/7 x12; the plain endpoints are 7/3/1), against plain-2D endpoints.
//
// WHAT THE DEVICE MEASUREMENTS ACTUALLY SHOWED (round 2): the mirrored field order is
// a property of the WHOLE ALLOCATION, not of a mip level - a 30x30x12 packed16 array
// is born in the mirrored layout at every level, while the small arrays the CTS's
// passing iterations used (7- and 15-texel bases; its src/dst dim loop is {7, 15}, so
// a base-30 array only ever appears at level 1) are born plain, which is why the
// failures looked per-mip-level from the QPA alone. AND the layout is not fixed for
// the allocation's lifetime: FBO-ATTACHING the array transitions it to the plain
// (renderable) layout, content preserved. That transition is what produced every
// seemingly contradictory measurement of this campaign - a probe that direct-read its
// array before copying relayouted its own subject and reported the device clean in
// the very process whose CTS copies kept mirroring, and the raw matrix's one
// "clean" 30x30x12 array was exactly the one that had been direct-read first. It is
// also why the CTS's own "source image was not modified" checks always passed: they
// read through an FBO attach, after the copy already went wrong. So: subject copies
// FIRST, every control that attaches the array AFTER, and because the driver's
// allocation heuristic beyond the size threshold is not fully mapped, the probe tries
// several allocation recipes of the same client-visible texture and a mirror from ANY
// level of ANY recipe is the finding.
constexpr GLsizei kPacked16BaseSize = 30;
constexpr GLsizei kPacked16Layers = 12;
constexpr GLsizei kPacked16DstSize = 7;
constexpr GLint kPacked16Levels = 3;
// One GL_RGB5_A1 texel, as the client word the probe uploads everywhere:
// (R, G, B, A) = (0, 1, 3, 1) under GL_UNSIGNED_SHORT_5_5_5_1. Chosen because 5551 is
// the one 16-bit packed layout whose field widths are not a palindrome - its mirror
// fixes the DIRECTION of the swap - and because this word's mirror differs in every
// channel including alpha, so no expansion rounding can confuse the two predictions.
constexpr Uint16 kPacked16Word = 0x0047;
// What an FBO readback answers for the word, as UNorm8: (0, 1, 3) / 31 and alpha 1.
constexpr GLubyte kPacked16Expected[4] = {0, 8, 25, 255};
// The same readback when the stored bits are the mirrored re-encoding: 0x0047 decoded
// as 5_5_5_1 and re-encoded as 1_5_5_5_REV is 0x8C20, which the destination's non-REV
// layout then decodes as (17, 16, 16) / 31 with alpha 0. This is byte-for-byte the
// arithmetic behind every failing CTS body (src 0x0047 -> got 0x8C20).
constexpr GLubyte kPacked16Mirrored[4] = {140, 132, 132, 0};
// A 5-bit step is 255/31 ~ 8.2 UNorm8 codes; half a step accepts every 5-bit-to-8-bit
// expansion a driver uses (floor, round, bit replication) while still telling two
// adjacent 5-bit values apart.
constexpr Int kPacked16Tolerance = 4;
// The allocation recipes the probe tries. Same client-visible texture, same data -
// only the order and the filter state during the uploads move, because those are the
// knobs the driver's layout heuristic was measured keying on (differently in
// different contexts).
enum class Packed16Recipe : Uint8 {
// glTexImage3D per level on a fresh texture at driver defaults, parameters after:
// the order a freshly minted MobileGL backend texture performs (the storage sync
// runs before the parameter re-push, see SyncTextureObjectToBackend).
UploadsFirst,
// NEAREST and MAX_LEVEL set before the uploads: the shape an application that
// configures its sampler state ahead of its data gets.
ParamsFirst,
// MAX_LEVEL bounded but MIN_FILTER left at its mipmapped default: the CTS
// copy-test texture verbatim - copy tests never touch filters, and the chain is
// complete because all three levels exist under MAX_LEVEL = 2.
CtsShape,
};
constexpr Packed16Recipe kPacked16Recipes[] = {Packed16Recipe::UploadsFirst,
Packed16Recipe::ParamsFirst,
Packed16Recipe::CtsShape};
const char* Packed16RecipeName(Packed16Recipe recipe) {
switch (recipe) {
case Packed16Recipe::UploadsFirst: return "uploads-first";
case Packed16Recipe::ParamsFirst: return "params-first";
case Packed16Recipe::CtsShape: return "cts-shape";
}
return "?";
}
// A three-level GL_RGB5_A1 2D array (30/15/7, twelve layers each, every texel holding
// kPacked16Word) allocated per `recipe`. Every recipe ends mipmap-complete - some
// drivers refuse glCopyImageSubData on an incomplete texture.
GLuint MakePacked16ArrayTexture(const GLESFunctionsTable& gl, Packed16Recipe recipe) {
GLuint texture = 0;
gl.glGenTextures(1, &texture);
if (texture == 0) return 0;
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
if (recipe == Packed16Recipe::ParamsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}
if (recipe != Packed16Recipe::UploadsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
}
for (GLint level = 0; level < kPacked16Levels; ++level) {
const GLsizei size = kPacked16BaseSize >> level;
const Vector<Uint16> words(
static_cast<SizeT>(size) * static_cast<SizeT>(size) * kPacked16Layers, kPacked16Word);
gl.glTexImage3D(GL_TEXTURE_2D_ARRAY, level, GL_RGB5_A1, size, size, kPacked16Layers, 0,
GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, words.data());
}
if (recipe == Packed16Recipe::UploadsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
}
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
return texture;
}
// The plain-2D destination, three levels (7/3/1) like the CTS's, every level filled
// with 0xFFFF - the CTS's own (1,1,1,1) destination fill - so a copy that silently
// did nothing reads as "no verdict" rather than as either prediction.
// A plain-2D endpoint with the CTS's three-level 7/3/1 chain, every texel of every
// level holding `fill`: 0xFFFF (the CTS's own (1,1,1,1) destination fill, so a copy
// that silently did nothing reads as "no verdict" rather than as either prediction),
// or kPacked16Word for the machinery control's source. Uploads first, parameters
// after, for the same in-situ fidelity as the array above - this is the allocation
// discipline every MobileGL-minted texture gets, and the shape the failing bodies'
// clean plain endpoints had.
GLuint MakePacked16FlatTexture(const GLESFunctionsTable& gl, Uint16 fill) {
GLuint texture = 0;
gl.glGenTextures(1, &texture);
if (texture == 0) return 0;
gl.glBindTexture(GL_TEXTURE_2D, texture);
for (GLint level = 0; level < kPacked16Levels; ++level) {
const GLsizei size = std::max<GLsizei>(kPacked16DstSize >> level, 1);
const Vector<Uint16> texels(static_cast<SizeT>(size) * size, fill);
gl.glTexImage2D(GL_TEXTURE_2D, level, GL_RGB5_A1, size, size, 0, GL_RGBA,
GL_UNSIGNED_SHORT_5_5_5_1, texels.data());
}
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
gl.glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
// Texel (0, 0) of a 2D level 0, or of layer 0 of an array's `level`, through a
// framebuffer of its own. False when the attachment is incomplete or the read errors -
// both are declines, not verdicts.
Bool ReadPacked16Texel(const GLESFunctionsTable& gl, GLuint texture, Bool isArray, GLint level,
GLubyte out[4]) {
GLuint framebuffer = 0;
gl.glGenFramebuffers(1, &framebuffer);
if (framebuffer == 0) return false;
gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
if (isArray) {
gl.glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, level, 0);
} else {
gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, level);
}
Bool read = false;
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
gl.glReadBuffer(GL_COLOR_ATTACHMENT0);
Drain(gl);
gl.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
read = gl.glGetError() == GL_NO_ERROR;
}
gl.glBindFramebuffer(GL_FRAMEBUFFER, 0);
gl.glDeleteFramebuffers(1, &framebuffer);
Drain(gl);
return read;
}
// Copies a kPacked16DstSize-square region out of (source, sourceTarget, sourceLevel)
// layer 0 onto a freshly 0xFFFF-filled 2D destination and hands back the destination's
// texel (0, 0). False when the copy raised an error or the readback could not run.
Bool Packed16CopyLandsTexel(const GLESFunctionsTable& gl, GLuint source, GLenum sourceTarget,
GLint sourceLevel, GLubyte out[4]) {
const GLuint destination = MakePacked16FlatTexture(gl, Uint16{0xFFFF});
if (destination == 0) return false;
Drain(gl);
gl.glCopyImageSubData(source, sourceTarget, sourceLevel, 0, 0, 0, destination,
GL_TEXTURE_2D, 0, 0, 0, 0, kPacked16DstSize, kPacked16DstSize, 1);
const Bool copied = gl.glGetError() == GL_NO_ERROR;
const Bool read = copied && ReadPacked16Texel(gl, destination, false, 0, out);
gl.glDeleteTextures(1, &destination);
Drain(gl);
return read;
}
Bool Packed16TexelNear(const GLubyte got[4], const GLubyte want[4]) {
for (Int i = 0; i < 4; ++i) {
const Int delta = static_cast<Int>(got[i]) - static_cast<Int>(want[i]);
if (delta > kPacked16Tolerance || delta < -kPacked16Tolerance) return false;
}
return true;
}
// One recipe's whole measurement: allocate, both subject copies, THEN the round-trip
// control. The order is load-bearing: FBO-ATTACHING THE ARRAY TRANSITIONS IT to the
// plain (renderable) layout on the affected driver, so a round-trip read taken before
// the copies RELAYOUTS the subject and measures a texture the application's copy
// never sees - round two's first deployment did exactly that and reported the device
// clean while the CTS bodies kept failing in the same process. Copies first, the
// control after: the attach-driven transition preserves content, so the read still
// answers "the upload was intact" without disturbing what the copies measured. Only a
// mirror that matches the PREDICTION while that control holds counts; everything else
// is that recipe's no-verdict (logged as such).
Bool RunPacked16Recipe(const GLESFunctionsTable& gl, Packed16Recipe recipe) {
Bool mirrored = false;
const GLuint array = MakePacked16ArrayTexture(gl, recipe);
GLubyte direct[4] = {0, 0, 0, 0};
GLubyte level0[4] = {0, 0, 0, 0};
GLubyte level1[4] = {0, 0, 0, 0};
if (array == 0 || !Packed16CopyLandsTexel(gl, array, GL_TEXTURE_2D_ARRAY, 0, level0) ||
!Packed16CopyLandsTexel(gl, array, GL_TEXTURE_2D_ARRAY, 1, level1)) {
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (a subject copy could not run)",
kPacked16CopyProbeName, Packed16RecipeName(recipe));
} else if (!ReadPacked16Texel(gl, array, true, 1, direct) ||
!Packed16TexelNear(direct, kPacked16Expected)) {
// The recipe's own round trip: reading the level directly decodes the driver's
// own storage and must deliver the word whatever layout it picked. A wrong
// answer means the UPLOAD is what corrupts - a different defect, and one the
// widening's raw-copy reasoning says nothing about.
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (the array's own level-1 readback "
"answered (%d, %d, %d, %d) instead of the word - the upload, not the "
"copy, is what diverges)",
kPacked16CopyProbeName, Packed16RecipeName(recipe), direct[0], direct[1],
direct[2], direct[3]);
} else if (Packed16TexelNear(level0, kPacked16Mirrored) ||
Packed16TexelNear(level1, kPacked16Mirrored)) {
mirrored = true;
MGLOG_I("[driver-bug] %s probe [%s]: copies delivered level 0 (%d, %d, %d, %d) / "
"level 1 (%d, %d, %d, %d) - the 1_5_5_5_REV re-encoding of the word - "
"THIS ALLOCATION'S FIELD ORDER IS MIRRORED",
kPacked16CopyProbeName, Packed16RecipeName(recipe), level0[0], level0[1],
level0[2], level0[3], level1[0], level1[1], level1[2], level1[3]);
} else if (Packed16TexelNear(level0, kPacked16Expected) &&
Packed16TexelNear(level1, kPacked16Expected)) {
MGLOG_I("[driver-bug] %s probe [%s]: both levels copied the word intact",
kPacked16CopyProbeName, Packed16RecipeName(recipe));
} else {
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (copies read back level 0 "
"(%d, %d, %d, %d) / level 1 (%d, %d, %d, %d), neither the word nor its "
"mirror)",
kPacked16CopyProbeName, Packed16RecipeName(recipe), level0[0], level0[1],
level0[2], level0[3], level1[0], level1[1], level1[2], level1[3]);
}
if (array != 0) gl.glDeleteTextures(1, &array);
return mirrored;
}
} // namespace
Bool ProbeCopyImageMirrorsPacked16FieldOrder(const GLESFunctionsTable& gl) {
if (!gl.glGenTextures || !gl.glBindTexture || !gl.glTexParameteri || !gl.glTexImage2D ||
!gl.glTexImage3D || !gl.glDeleteTextures || !gl.glCopyImageSubData || !gl.glGenFramebuffers ||
!gl.glBindFramebuffer || !gl.glFramebufferTexture2D || !gl.glFramebufferTextureLayer ||
!gl.glCheckFramebufferStatus || !gl.glDeleteFramebuffers || !gl.glReadBuffer ||
!gl.glReadPixels || !gl.glPixelStorei || !gl.glGetError) {
return false;
}
SavedState saved;
Save(gl, saved);
// The uploads and readbacks below run under the probe's own tight pixel-transfer
// scope - a caller's skip/row-length/PBO would shear the probe's data into a false
// verdict either way. Restore puts the caller's scope back with the rest.
gl.glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
gl.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
gl.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_ROWS, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0);
gl.glPixelStorei(GL_PACK_ALIGNMENT, 1);
gl.glPixelStorei(GL_PACK_ROW_LENGTH, 0);
gl.glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
gl.glPixelStorei(GL_PACK_SKIP_ROWS, 0);
if (gl.glBindBuffer != nullptr) {
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
gl.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
}
Drain(gl);
Bool detected = false;
const GLuint flatSource = MakePacked16FlatTexture(gl, kPacked16Word);
GLubyte machinery[4] = {0, 0, 0, 0};
// THE MACHINERY CONTROL: a copy between two 2D images of the same three-level shape
// and allocation discipline. Two identical allocations share the driver's layout
// whatever it is, so this must deliver the word on ANY driver that can run copy_image
// on these formats at all - a driver that cannot reaches no verdict instead of being
// reported as this.
if (flatSource == 0 || !Packed16CopyLandsTexel(gl, flatSource, GL_TEXTURE_2D, 0, machinery)) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the 2D-to-2D machinery control "
"could not run)",
kPacked16CopyProbeName);
} else if (!Packed16TexelNear(machinery, kPacked16Expected)) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the 2D-to-2D machinery control "
"read back (%d, %d, %d, %d) instead of the word's (%d, %d, %d, %d))",
kPacked16CopyProbeName, machinery[0], machinery[1], machinery[2], machinery[3],
kPacked16Expected[0], kPacked16Expected[1], kPacked16Expected[2], kPacked16Expected[3]);
} else {
// THE SUBJECTS: every allocation recipe of the same array, each with its own
// round-trip control; a mirror from any level of any recipe is the finding. Every
// recipe logs its own verdict either way, so a device run always shows whether
// this probe executed and what each allocation delivered - a silent clean path
// would be indistinguishable from a probe that never ran.
for (const Packed16Recipe recipe : kPacked16Recipes) {
detected = RunPacked16Recipe(gl, recipe) || detected;
}
}
if (flatSource != 0) gl.glDeleteTextures(1, &flatSource);
Restore(gl, saved);
return detected;
}
Bool CopyImageMirrorsPacked16FieldOrder(const GLESFunctionsTable& gl) {
// One driver per process, and the answer is structural (the driver's storage layout
// for a shape), not sampled.
static const Bool mirrored = ProbeCopyImageMirrorsPacked16FieldOrder(gl);
return mirrored;
}
namespace {
Optional<DriverBugFinding> ProbeExplicitVertexInputLocationCeilingBug(const GLESFunctionsTable& gl) {
const VertexInputLocationCeilingMeasurement& measurement = ExplicitVertexInputLocationCeiling(gl);
@@ -1808,6 +2361,95 @@ namespace MobileGL::MG_Util::SelfTest {
percentOf(measurement.emittedShapeMismatchedTexels))};
}
Optional<DriverBugFinding> ProbeLocatedIoBlockPayloadBug(const GLESFunctionsTable& gl) {
const LocatedIoBlockMeasurement& measurement = LocatedIoBlocksLosePayload(gl);
if (!measurement.detected) return std::nullopt;
String detail =
"an inter-stage interface block that carries an explicit layout(location = N) "
"delivers NOTHING once a geometry (or tessellation) stage is in the pipeline: the "
"stages compile, the program links with an empty info log, the draw raises no "
"error, and the consuming stage reads zeroes. The byte-identical program with the "
"qualifier removed from the blocks carries its payload correctly, which is what "
"makes this a LOCATION defect rather than an interface-block one - blocks "
"themselves work here";
detail += measurement.alsoAffectsVertexToFragment
? ". A located block between a VERTEX and a FRAGMENT stage is lost on "
"this driver too, so the defect is wider than the repair below "
"reaches: MobileGL only drops the qualifier for programs that have a "
"tessellation or geometry stage, and a located block in a plain "
"vertex+fragment program is still emitted as the application wrote it"
: ". A located block between a VERTEX and a FRAGMENT stage is delivered "
"correctly on the same driver, which is what scopes the repair";
// The repair can be switched off from the environment, and a report that said
// "Fixed" while the strip was disabled would be describing a build nobody is
// running. The verdict follows what this process will actually do, not what the
// code is capable of.
const Bool repairDisabled =
MG_Config::Features.EsprytUnlocatedIoBlocks == MG_Config::QuirkOverride::ForceOff;
if (repairDisabled) {
detail +=
". THE REPAIR IS DISABLED in this process: MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS "
"is set to force located blocks ON, so DirectGLES emits the location "
"qualifier the driver cannot honour and the payload is lost. Unset the "
"variable to get the repair back";
} else {
detail +=
". MobileGL emits a tessellation/geometry program's interface blocks with no "
"location qualifier at all (StripIoBlockLocationsPass) and lets ES match them "
"by block name and member sequence, which it does; the locations were invented "
"by the cross-stage IO resolver rather than written by the application";
}
return DriverBugFinding{"Located inter-stage interface blocks carry no payload",
(repairDisabled || measurement.alsoAffectsVertexToFragment)
? DriverBugVerdict::Unfixable
: DriverBugVerdict::Fixed,
Move(detail)};
}
Optional<DriverBugFinding> ProbeCopyImagePacked16FieldOrderBug(const GLESFunctionsTable& gl) {
if (!CopyImageMirrorsPacked16FieldOrder(gl)) return std::nullopt;
// The mitigation is a knob (MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE), so the row consults
// it: under ForceOff - the documented negative control - the corruption is
// replayed verbatim, and a hardcoded "Fixed" would be exactly the kind of
// reassurance this file exists to refuse. Auto and ForceOn both widen once this
// probe has fired. Should POST ever run before env parsing, the field still holds
// its Auto default - which is also what the widening itself consults, so the row
// and the behaviour cannot disagree.
const Bool widened = MG_Config::Features.EsprytWidenPacked16Storage !=
MG_Config::QuirkOverride::ForceOff;
String detail =
"the driver stores SOME 16-bit packed images (RGB565 / RGB5_A1 / RGBA4) with the "
"R/G/B/A fields packed from the other end of the word - which allocations get the "
"*_REV layout depends on shape and context history (measured here on a 30x30x12 "
"three-level 2D array, every level of it) - so a glCopyImageSubData, a raw "
"texel-block move, between a mirrored allocation and a plain one lands the fields "
"reversed (a 5551 word 0x0047 arrives as 0x8C20). Uploads and readbacks of the "
"same image are clean - the driver decodes its own layout consistently, which is "
"this probe's second control - so only the raw-copy path ever crosses the two "
"layouts. ";
if (widened) {
detail +=
"MobileGL stores these three formats as 8-bit-per-channel ES storage on this "
"driver instead (GL_RGB8 / GL_RGBA8, the storage their canonical shadow "
"already holds and the client word round-trips through exactly), so no "
"16-bit packed image is left for a copy to disagree about, at twice the "
"memory for images of those formats; override with "
"MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE";
return DriverBugFinding{
"glCopyImageSubData mirrors 16-bit packed texels between differently-laid-out images",
DriverBugVerdict::Fixed, detail};
}
detail += "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=0 keeps the native narrow storage, so such "
"copies are left exactly as the driver delivers them, mirrored words included";
return DriverBugFinding{
"glCopyImageSubData mirrors 16-bit packed texels between differently-laid-out images",
DriverBugVerdict::Unfixable, detail};
}
Optional<DriverBugFinding> ProbePersistentBufferOrderingBug(const GLESFunctionsTable& gl) {
return DescribePersistentBufferOrderingBug(ProbePersistentBufferUpdateOrdering(gl));
}
// The table. One row per known driver bug; see the header for how to add a sibling.
using DriverBugProbeFn = Optional<DriverBugFinding> (*)(const GLESFunctionsTable&);
constexpr DriverBugProbeFn kGlesDriverBugProbes[] = {
@@ -1818,6 +2460,9 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeImageCoherencyResidualBug,
&ProbeExplicitVertexInputLocationCeilingBug,
&ProbeLayeredBlitDestinationBug,
&ProbeLocatedIoBlockPayloadBug,
&ProbeCopyImagePacked16FieldOrderBug,
&ProbePersistentBufferOrderingBug,
};
} // namespace
@@ -55,6 +55,44 @@ namespace MobileGL::MG_Util::SelfTest {
String detail;
};
// What the located-interface-block probe measured.
struct LocatedIoBlockMeasurement {
// The driver delivers nothing through an inter-stage interface block that carries an
// explicit layout(location=) once a geometry stage is in the pipeline. The only field
// any caller's behaviour depends on.
Bool detected = false;
// ...and it does the same WITHOUT a geometry stage, i.e. between a vertex and a
// fragment stage. False on the device this was characterised on, and reported because
// DirectGLES's repair is scoped to tessellation/geometry programs: a driver that
// answered true here would be losing block payloads the repair does not reach.
Bool alsoAffectsVertexToFragment = false;
};
// Draws one full-viewport triangle through VS+GS+FS whose two interface blocks carry an
// explicit layout(location = 0), and reports whether the payload the vertex stage wrote
// reached the fragment stage.
//
// The Mali-G1-Ultra ES driver (r54p1) delivers ZEROES: the stages compile, the program
// links with an empty info log, the draw runs without error, and the block is empty. It is
// the whole of the KHR-GLxx.shading_language_420pack interface-block group's failures on
// that device, and of a further 21 tessellation and geometry bodies beside it.
//
// TWO CONTROLS, and the first is why this is a LOCATION finding rather than a block one:
// (1) the identical three-stage program with the qualifier removed from both blocks must
// deliver its payload - without that, "this driver cannot carry an interface block through
// a geometry stage" would be the claim, which is false and would justify flattening every
// block on the device; and (2) a two-stage vertex-to-fragment program with a LOCATED block
// is measured separately, because that one works on the affected driver and is what scopes
// the repair to programs with a tessellation or geometry stage.
//
// Returns `detected` false when an entry point is missing, when the driver has no geometry
// stage, or when the unlocated control fails - an inconclusive probe must never be reported
// as a bug, and must never arm the repair. Restores every piece of GL state it touches.
LocatedIoBlockMeasurement ProbeLocatedIoBlocksLosePayload(const MG_External::GLESFunctionsTable& gl);
// ProbeLocatedIoBlocksLosePayload(), evaluated at most once per process.
const LocatedIoBlockMeasurement& LocatedIoBlocksLosePayload(const MG_External::GLESFunctionsTable& gl);
// Blits one layer of an RGBA8 2D array onto another array's layer 1 and reports whether the
// copy landed where it was asked to. Returns true only when the destination layer is ignored
// while the control lands correctly.
@@ -261,6 +299,46 @@ namespace MobileGL::MG_Util::SelfTest {
const ImageCoherencyResidualMeasurement& ImageWriteReadCoherencyResidual(
const MG_External::GLESFunctionsTable& gl);
// Copies one known GL_UNSIGNED_SHORT_5_5_5_1 word out of both mip levels of a GL_RGB5_A1
// 2D array into plain 2D images with glCopyImageSubData and reads the landed texels back
// - for SEVERAL ALLOCATION RECIPES of the same array. Returns true only when a copy from
// any level of any recipe delivers the word's 5_5_5_1 <-> 1_5_5_5_REV field-order mirror
// while the controls below hold.
//
// The affected Mali stores SOME 16-bit packed allocations (RGB565 / RGB5_A1 / RGBA4) with
// their fields packed from the other end of the word. The mirrored layout is an
// ALLOCATION property, not a mip-level one - the failing device's 30x30x12 array is born
// mirrored at level 0 and level 1 alike, which is what vetoed the first deployment's
// "level 0 is the clean control" design - and it is not fixed for the allocation's
// lifetime either: FBO-ATTACHING the array transitions it to the plain layout, content
// preserved, which is why a probe that direct-reads its array before copying relayouts
// its own subject and measures a texture the application's copies never see (the second
// deployment's miss), and why the CTS's "source not modified" checks always passed. The
// probe builds the CTS's failing shape (three-level chains both endpoints: 30/15/7 x12
// array, 7/3/1 plain, FUNCTIONAL_TEST_N_LEVELS = 3) with several allocation recipes,
// copies FIRST, in situ, and a mirror delivered from any level of any recipe is the
// finding. Uploads and readbacks decode each image's layout of the moment consistently,
// so nothing but a raw texel-block move can see the divergence - which is exactly what
// glCopyImageSubData is defined to be, and why the whole KHR-GL4x.copy_image
// rgb5/rgb5_a1/rgba4 x *2d_array* matrix fails there while every other suite touching
// these formats passes.
//
// CONTROLS. The machinery: an identical copy between two SAME-shape plain-2D images,
// which share a layout whatever it is, so it must deliver the word on any driver that can
// run copy_image on these formats - a driver that cannot reaches no verdict instead of
// being reported as this. And per recipe, AFTER its subject copies, the array's own round
// trip: a direct FBO readback of its level 1 must answer the word, or the UPLOAD is what
// corrupts - a different defect. The subjects must also match the mirror PREDICTION, not
// merely differ from the word - a copy that delivered anything else is a different defect
// and reaches no verdict either. Restores every piece of GL state it touches.
Bool ProbeCopyImageMirrorsPacked16FieldOrder(const MG_External::GLESFunctionsTable& gl);
// ProbeCopyImageMirrorsPacked16FieldOrder(), evaluated at most once per process. The
// DirectGLES format normalization consults this to decide whether the three 16-bit packed
// normalized formats must be stored as 8-bit-per-channel ES storage (see
// PixelFormatNormalizeOptionBit::WidenPacked16Norm).
Bool CopyImageMirrorsPacked16FieldOrder(const MG_External::GLESFunctionsTable& gl);
// Every known driver bug this GLES driver actually has. Bugs it does not have are absent,
// so an unaffected device renders an empty section rather than a wall of "not affected".
Vector<DriverBugFinding> CollectGlesKnownDriverBugs(const MG_External::GLESFunctionsTable& gl);
+377 -1
View File
@@ -9,6 +9,7 @@
#include "DriverPost.h"
#include "DriverPostIterationRPWitness.h"
#include "DriverPostIterationRPWitnessSpv.h"
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/BackendLoaders/OpenGL/Loader.h"
#include <Config.h>
#include <MGGitHash.h>
@@ -1555,9 +1556,380 @@ namespace MobileGL::MG_Util::SelfTest {
disabledNote);
}
// GL_PRIMITIVES_GENERATED for draws made with transform feedback INACTIVE. GL
// defines the query to count them; the DirectVulkan backend serves it from the
// stream query's primitivesNeeded, and an affected Mali driver answers 0 there
// unless a capture span is open - the exact shape the CTS uses to measure the
// tessellator (see PrimitivesGeneratedNoXfbProbe.h). One row:
// PASS - the stream query counts the capture-less draw exactly.
// WARN - it answers 0, and the CLIPPING_INPUT_PRIMITIVES statistics control
// on the same draw answers exactly right, so the renderer substitutes
// a pipeline-statistics pool for such draws (the same probe, run at
// renderer bring-up, is what arms it).
// FAIL (optional) - it answers 0 with no working substitute, or the probe
// could not reach a verdict; applications sizing capture buffers from
// the query get 0.
// Throwaway device on purpose, like every probe here: the row reports the
// driver, not the renderer's configuration - MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE
// steers the renderer, never this row.
void ProbeVulkanPrimitivesGeneratedNoXfb(ReportBuilder& builder,
PFN_vkGetInstanceProcAddr getInstanceProcAddr,
VkInstance instance, VkPhysicalDevice physicalDevice,
Uint32 graphicsQueueFamilyIndex,
const Vector<VkExtensionProperties>& deviceExtensions,
const VkPhysicalDeviceFeatures& features,
PFN_vkGetPhysicalDeviceFeatures2 getFeatures2,
PFN_vkGetPhysicalDeviceProperties2 getProperties2) {
constexpr const char* RowName = "Primitives-generated query without capture";
const auto fail = [&](String detail) { builder.FailOptional(RowName, Move(detail)); };
if (!HasVkExtension(deviceExtensions, VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME) ||
getFeatures2 == nullptr || getProperties2 == nullptr) {
fail("VK_EXT_transform_feedback is unavailable, so the backend has no GPU counter for "
"GL_PRIMITIVES_GENERATED at all - with or without a capture");
return;
}
VkPhysicalDeviceTransformFeedbackFeaturesEXT xfbFeatures{};
xfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
VkPhysicalDeviceFeatures2 features2{};
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
features2.pNext = &xfbFeatures;
getFeatures2(physicalDevice, &features2);
VkPhysicalDeviceTransformFeedbackPropertiesEXT xfbProperties{};
xfbProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_PROPERTIES_EXT;
VkPhysicalDeviceProperties2 properties2{};
properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
properties2.pNext = &xfbProperties;
getProperties2(physicalDevice, &properties2);
if (xfbFeatures.transformFeedback != VK_TRUE || xfbProperties.transformFeedbackQueries != VK_TRUE) {
fail("the device has VK_EXT_transform_feedback but no usable stream queries "
"(transformFeedbackQueries = false); GL_PRIMITIVES_GENERATED and "
"GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN queries answer 0");
return;
}
const auto vkCreateDeviceFn =
reinterpret_cast<PFN_vkCreateDevice>(getInstanceProcAddr(instance, "vkCreateDevice"));
const auto vkDestroyDeviceFn =
reinterpret_cast<PFN_vkDestroyDevice>(getInstanceProcAddr(instance, "vkDestroyDevice"));
const auto vkGetDeviceQueueFn =
reinterpret_cast<PFN_vkGetDeviceQueue>(getInstanceProcAddr(instance, "vkGetDeviceQueue"));
if (vkCreateDeviceFn == nullptr || vkDestroyDeviceFn == nullptr || vkGetDeviceQueueFn == nullptr) {
fail("vkGetInstanceProcAddr could not resolve the device-creation entry points");
return;
}
const Float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueInfo{};
queueInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueInfo.queueFamilyIndex = graphicsQueueFamilyIndex;
queueInfo.queueCount = 1;
queueInfo.pQueuePriorities = &queuePriority;
// Only what the probe itself needs: the transform feedback feature (a
// stream-query pool may not be created without it), the two candidate
// substitutes' features, and tessellationShader for the PATCHES shape -
// each only where the device has it. The dedicated
// primitives-generated query is taken with BOTH its bits or not at all,
// mirroring the renderer (without the discard bit two of the three
// shapes may not run inside it).
VkPhysicalDeviceFeatures enabledFeatures{};
enabledFeatures.pipelineStatisticsQuery = features.pipelineStatisticsQuery;
enabledFeatures.tessellationShader = features.tessellationShader;
VkPhysicalDeviceTransformFeedbackFeaturesEXT enabledXfbFeatures{};
enabledXfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
enabledXfbFeatures.transformFeedback = VK_TRUE;
const char* enabledExtensions[2] = {VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME, nullptr};
Uint32 enabledExtensionCount = 1;
Bool primitivesGeneratedQueryUsable = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT enabledPgqFeatures{};
enabledPgqFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (HasVkExtension(deviceExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT pgqQuery{};
pgqQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
VkPhysicalDeviceFeatures2 pgqFeatures2{};
pgqFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
pgqFeatures2.pNext = &pgqQuery;
getFeatures2(physicalDevice, &pgqFeatures2);
if (pgqQuery.primitivesGeneratedQuery == VK_TRUE &&
pgqQuery.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
primitivesGeneratedQueryUsable = true;
enabledPgqFeatures.primitivesGeneratedQuery = VK_TRUE;
enabledPgqFeatures.primitivesGeneratedQueryWithRasterizerDiscard = VK_TRUE;
enabledPgqFeatures.pNext = &enabledXfbFeatures;
enabledExtensions[enabledExtensionCount++] =
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME;
}
}
VkDeviceCreateInfo deviceInfo{};
deviceInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceInfo.pNext = primitivesGeneratedQueryUsable
? static_cast<const void*>(&enabledPgqFeatures)
: static_cast<const void*>(&enabledXfbFeatures);
deviceInfo.queueCreateInfoCount = 1;
deviceInfo.pQueueCreateInfos = &queueInfo;
deviceInfo.enabledExtensionCount = enabledExtensionCount;
deviceInfo.ppEnabledExtensionNames = enabledExtensions;
deviceInfo.pEnabledFeatures = &enabledFeatures;
VkDevice device = VK_NULL_HANDLE;
const VkResult createResult = vkCreateDeviceFn(physicalDevice, &deviceInfo, nullptr, &device);
if (createResult != VK_SUCCESS || device == VK_NULL_HANDLE) {
fail(format("vkCreateDevice failed (VkResult = {})", static_cast<Int>(createResult)));
return;
}
// The probe's own teardown destroys (and idle-waits) everything it created -
// EXCEPT when its bounded fence wait expires, where it deliberately leaks
// every child object rather than touch a possibly hung GPU. This device must
// then leak with them: vkDestroyDevice requires its children destroyed and its
// queues idle, and on the driver that just missed a 5 s deadline the realistic
// outcome is a block inside vkDestroyDevice - the POST hang the bound exists to
// prevent. Same shape as the timestamp probe's guard above and the iterationRP
// witness's below.
Bool probeFenceWaitTimedOut = false;
const ScopeGuard destroyDevice([&]() {
if (probeFenceWaitTimedOut) {
return;
}
vkDestroyDeviceFn(device, nullptr);
});
VkQueue queue = VK_NULL_HANDLE;
vkGetDeviceQueueFn(device, graphicsQueueFamilyIndex, 0, &queue);
if (queue == VK_NULL_HANDLE) {
fail("vkGetDeviceQueue returned a null graphics queue");
return;
}
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = device;
probeContext.queue = queue;
probeContext.queueFamilyIndex = graphicsQueueFamilyIndex;
probeContext.transformFeedbackQueriesUsable = true;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = enabledFeatures.pipelineStatisticsQuery == VK_TRUE;
probeContext.tessellationEnabled = enabledFeatures.tessellationShader == VK_TRUE;
auto& fns = probeContext.fns;
const auto resolve = [&](const char* name) { return getInstanceProcAddr(instance, name); };
fns.vkCreateCommandPool = reinterpret_cast<PFN_vkCreateCommandPool>(resolve("vkCreateCommandPool"));
fns.vkDestroyCommandPool =
reinterpret_cast<PFN_vkDestroyCommandPool>(resolve("vkDestroyCommandPool"));
fns.vkAllocateCommandBuffers =
reinterpret_cast<PFN_vkAllocateCommandBuffers>(resolve("vkAllocateCommandBuffers"));
fns.vkBeginCommandBuffer =
reinterpret_cast<PFN_vkBeginCommandBuffer>(resolve("vkBeginCommandBuffer"));
fns.vkEndCommandBuffer = reinterpret_cast<PFN_vkEndCommandBuffer>(resolve("vkEndCommandBuffer"));
fns.vkCreateQueryPool = reinterpret_cast<PFN_vkCreateQueryPool>(resolve("vkCreateQueryPool"));
fns.vkDestroyQueryPool = reinterpret_cast<PFN_vkDestroyQueryPool>(resolve("vkDestroyQueryPool"));
fns.vkCmdResetQueryPool = reinterpret_cast<PFN_vkCmdResetQueryPool>(resolve("vkCmdResetQueryPool"));
fns.vkCmdBeginQuery = reinterpret_cast<PFN_vkCmdBeginQuery>(resolve("vkCmdBeginQuery"));
fns.vkCmdEndQuery = reinterpret_cast<PFN_vkCmdEndQuery>(resolve("vkCmdEndQuery"));
fns.vkCmdBeginQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdBeginQueryIndexedEXT>(resolve("vkCmdBeginQueryIndexedEXT"));
fns.vkCmdEndQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdEndQueryIndexedEXT>(resolve("vkCmdEndQueryIndexedEXT"));
fns.vkCreateRenderPass = reinterpret_cast<PFN_vkCreateRenderPass>(resolve("vkCreateRenderPass"));
fns.vkDestroyRenderPass =
reinterpret_cast<PFN_vkDestroyRenderPass>(resolve("vkDestroyRenderPass"));
fns.vkCreateFramebuffer =
reinterpret_cast<PFN_vkCreateFramebuffer>(resolve("vkCreateFramebuffer"));
fns.vkDestroyFramebuffer =
reinterpret_cast<PFN_vkDestroyFramebuffer>(resolve("vkDestroyFramebuffer"));
fns.vkCmdBeginRenderPass =
reinterpret_cast<PFN_vkCmdBeginRenderPass>(resolve("vkCmdBeginRenderPass"));
fns.vkCmdEndRenderPass = reinterpret_cast<PFN_vkCmdEndRenderPass>(resolve("vkCmdEndRenderPass"));
fns.vkCreateShaderModule =
reinterpret_cast<PFN_vkCreateShaderModule>(resolve("vkCreateShaderModule"));
fns.vkDestroyShaderModule =
reinterpret_cast<PFN_vkDestroyShaderModule>(resolve("vkDestroyShaderModule"));
fns.vkCreatePipelineLayout =
reinterpret_cast<PFN_vkCreatePipelineLayout>(resolve("vkCreatePipelineLayout"));
fns.vkDestroyPipelineLayout =
reinterpret_cast<PFN_vkDestroyPipelineLayout>(resolve("vkDestroyPipelineLayout"));
fns.vkCreateGraphicsPipelines =
reinterpret_cast<PFN_vkCreateGraphicsPipelines>(resolve("vkCreateGraphicsPipelines"));
fns.vkDestroyPipeline = reinterpret_cast<PFN_vkDestroyPipeline>(resolve("vkDestroyPipeline"));
fns.vkCmdBindPipeline = reinterpret_cast<PFN_vkCmdBindPipeline>(resolve("vkCmdBindPipeline"));
fns.vkCmdDraw = reinterpret_cast<PFN_vkCmdDraw>(resolve("vkCmdDraw"));
fns.vkCreateFence = reinterpret_cast<PFN_vkCreateFence>(resolve("vkCreateFence"));
fns.vkDestroyFence = reinterpret_cast<PFN_vkDestroyFence>(resolve("vkDestroyFence"));
fns.vkQueueSubmit = reinterpret_cast<PFN_vkQueueSubmit>(resolve("vkQueueSubmit"));
fns.vkWaitForFences = reinterpret_cast<PFN_vkWaitForFences>(resolve("vkWaitForFences"));
fns.vkGetQueryPoolResults =
reinterpret_cast<PFN_vkGetQueryPoolResults>(resolve("vkGetQueryPoolResults"));
fns.vkDeviceWaitIdle = reinterpret_cast<PFN_vkDeviceWaitIdle>(resolve("vkDeviceWaitIdle"));
const PrimitivesGeneratedNoXfbMeasurement measurement =
RunPrimitivesGeneratedNoXfbProbe(probeContext);
// Before any return below: the guard above owns the device and must know.
probeFenceWaitTimedOut = measurement.fenceWaitTimedOut;
if (!measurement.ran) {
fail(format("the probe could not run ({}); the renderer's bring-up probe decides the "
"reroute independently",
measurement.failureReason));
return;
}
const auto shapeFacts = [](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn) {
return format("{} not drawn (no tessellationShader)", name);
}
String facts = format("{}: stream answered {} of {} expected", name, shape.streamGenerated,
shape.expectedPrimitives);
if (shape.primitivesGeneratedExtMeasured) {
facts += format(", dedicated query answered {}", shape.primitivesGeneratedExt);
}
if (shape.statisticsMeasured) {
facts += format(", statistics control answered {}", shape.statisticsClippingInput);
}
if (!shape.primitivesGeneratedExtMeasured && !shape.statisticsMeasured) {
facts += ", no control (neither VK_EXT_primitives_generated_query with its "
"discard feature nor pipelineStatisticsQuery is available)";
}
return facts;
};
const String facts = shapeFacts("triangles", measurement.trianglesPlain) + "; " +
shapeFacts("triangles under discard", measurement.trianglesDiscard) +
"; " + shapeFacts("patches under discard", measurement.patchesDiscard);
const auto statisticsExactOn = [](const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
return shape.statisticsMeasured && shape.statisticsClippingInput == shape.expectedPrimitives;
};
// What the PLAIN-ONLY verdict actually measured, named from the numbers rather
// than assumed: the shape the substitute misses may be the tessellated one
// alone, and a missed shape may read a wrong NONZERO count rather than 0. A
// row that always blamed rasterizer discard would put a false statement about
// the driver into the campaign's evidence artifact, contradicted by the facts
// string printed right after it.
const auto describeMissedStatisticsShapes = [&]() {
String missed;
const auto note = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape)) {
return;
}
if (!missed.empty()) {
missed += " and ";
}
missed += name;
missed += shape.statisticsMeasured
? format(" (read {} of {} expected)", shape.statisticsClippingInput,
shape.expectedPrimitives)
: String(" (its statistics slot did not read back)");
};
note("the plain draw", measurement.trianglesPlain);
note("triangles under rasterizer discard", measurement.trianglesDiscard);
note("patches under rasterizer discard", measurement.patchesDiscard);
return missed;
};
// The CTS's tessellator-measuring shape is a PATCHES draw under discard; say
// whether THIS driver's substitute covers it instead of assuming it does not.
const auto describeCtsShape = [&]() -> String {
if (!measurement.patchesDiscard.drawn) {
return "the CTS's tessellator-measuring shape (a PATCHES draw under discard) could "
"not be measured here - this device has no tessellationShader - so whether "
"the substitute covers it is unknown";
}
return statisticsExactOn(measurement.patchesDiscard)
? "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"NOT among them: the substitute answers it exactly, so those tests are "
"repaired"
: "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"among them, so those tests stay broken on this driver";
};
switch (EvaluatePrimitivesGeneratedNoXfbVerdict(measurement)) {
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
builder.Pass(RowName,
"the stream query counts a draw made with no capture span open, as "
"VK_EXT_transform_feedback defines (" +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT query around an identical replay answers "
"exactly right, rasterizer discard included, so the renderer "
"accumulates GL_PRIMITIVES_GENERATED for such draws through the "
"dedicated query instead (one extra query slot per XFB-inactive draw "
"inside a GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"clipping-invocations statistics query around an identical replay answers exactly "
"right, rasterizer discard included, so the renderer accumulates "
"GL_PRIMITIVES_GENERATED for such draws through a pipeline-statistics "
"pool instead (one extra query slot per XFB-inactive draw inside a "
"GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute counts the plain draw exactly but "
"misses " +
describeMissedStatisticsShapes() +
" - each of them a shape the stream query answered 0 for as well, so the renderer "
"reroutes XFB-inactive draws (repairing every shape the substitute answers, at no "
"cost to the rest, which is what the verdict requires); " +
describeCtsShape() + " (" + facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::Unfixable: {
// Two ways to land here, and the report must not conflate them: no
// substitute answers even the plain draw, or one does but it is WRONG on a
// shape the stream query answers EXACTLY - arming it would trade a correct
// answer for a wrong one, so MobileGL refuses (see the verdict's
// domination rule).
String downgradeShapes;
const auto noteDowngrade = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape) ||
shape.streamGenerated != shape.expectedPrimitives) {
return;
}
if (!downgradeShapes.empty()) {
downgradeShapes += " and ";
}
downgradeShapes += name;
};
noteDowngrade("the plain draw", measurement.trianglesPlain);
noteDowngrade("triangles under rasterizer discard", measurement.trianglesDiscard);
noteDowngrade("patches under rasterizer discard", measurement.patchesDiscard);
if (statisticsExactOn(measurement.trianglesPlain) && !downgradeShapes.empty()) {
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute repairs the plain draw but is wrong "
"on " +
downgradeShapes +
", which the stream query answers exactly - rerouting every XFB-inactive draw "
"would trade a correct count for a wrong one, so MobileGL arms nothing and the "
"capture-less query keeps the driver's answers (" +
facts + ")");
return;
}
fail("the stream query answers 0 for a draw made with no capture span open and the "
"device offers no working statistics substitute; an application sizing a capture "
"buffer from GL_PRIMITIVES_GENERATED gets 0 (" +
facts + ")");
return;
}
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
break;
}
fail("the probe reached no verdict - the answers fit neither the defect nor health, and "
"MobileGL declines to repair a driver it does not understand (" +
facts + ")");
}
// Native iterationRP compute witness. This deliberately uses a separate
// throwaway Vulkan device rather than the real renderer's queues, and it
// treats MOBILEGL_DISABLE_SUBGROUP as irrelevant: the row reports what the
// treats MOBILEGL_MAGMA_DISABLE_SUBGROUP as irrelevant: the row reports what the
// driver does, not what MobileGL elects to advertise to applications.
void ProbeVulkanIterationRPWitness(ReportBuilder& builder, PFN_vkGetInstanceProcAddr getInstanceProcAddr,
VkInstance instance, VkPhysicalDevice physicalDevice,
@@ -2652,6 +3024,10 @@ namespace MobileGL::MG_Util::SelfTest {
ProbeVulkanIterationRPWitness(builder, getInstanceProcAddr, instance, physicalDevice, computeQueueFamilyIndex,
properties, subgroupPropertiesAvailable, subgroupProperties);
ProbeVulkanPrimitivesGeneratedNoXfb(builder, getInstanceProcAddr, instance, physicalDevice,
graphicsQueueFamilyIndex, deviceExtensions, features,
vkGetPhysicalDeviceFeatures2Fn, vkGetPhysicalDeviceProperties2Fn);
if (HasVkExtension(deviceExtensions, VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME)) {
builder.Pass("VK_KHR_draw_indirect_count",
"supported (count-buffer indirect draws run as single native "
@@ -0,0 +1,413 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "PersistentBufferOrderingProbe.h"
#include <MG_Util/Debug/Log.h>
#include <array>
#include <cmath>
#include <cstring>
#include <new>
namespace MobileGL::MG_Util::SelfTest {
namespace {
using MG_External::GLESFunctionsTable;
constexpr GLbitfield kPersistent = 0x0040;
constexpr GLbitfield kCoherent = 0x0080;
constexpr GLbitfield kDynamicStorage = 0x0100;
constexpr GLbitfield kMapFlags = GL_MAP_WRITE_BIT | kPersistent | kCoherent;
constexpr GLsizeiptr kArenaSize = 128 * 1024 * 1024;
constexpr GLintptr kOffset = 96 * 1024 * 1024 + 28;
constexpr GLsizei kSide = 128;
constexpr GLsizei kSlots = 8;
constexpr Int kBatches = 10;
constexpr Int kDraws = 32;
constexpr GLsizei kQuads = 64 * 32;
constexpr Int kAttempts = 3;
constexpr std::array<const char*, 3> kUploadNames = {"SubData", "Copy/persistent staging",
"Copy/SubData staging"};
enum class Shape { Unmapped, Mapped, FinishBefore, FinishBoth, MapThenUnmap, BarrierBefore };
struct Vertex { GLfloat x, y, r, g, b; };
constexpr GLsizeiptr kPayloadSize = kQuads * 6 * sizeof(Vertex);
static_assert(kOffset + kPayloadSize <= kArenaSize);
void DrainErrors(const GLESFunctionsTable& gl) {
for (Int i = 0; i < 32 && gl.glGetError() != GL_NO_ERROR; ++i) {}
}
Bool CanProbe(const GLESFunctionsTable& gl) {
if (!(gl.glGetIntegerv && gl.glGetBooleanv && gl.glGetFloatv && gl.glGetError &&
gl.glGetStringi && gl.glIsEnabled && gl.glEnable && gl.glDisable &&
gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv &&
gl.glGetShaderInfoLog && gl.glDeleteShader && gl.glCreateProgram && gl.glAttachShader &&
gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteProgram &&
gl.glUseProgram && gl.glGenBuffers && gl.glBindBuffer && gl.glBufferStorageEXT &&
gl.glMapBufferRange && gl.glUnmapBuffer && gl.glBufferData && gl.glBufferSubData &&
gl.glCopyBufferSubData && gl.glDeleteBuffers && gl.glGenVertexArrays &&
gl.glBindVertexArray && gl.glVertexAttribPointer && gl.glEnableVertexAttribArray &&
gl.glDeleteVertexArrays && gl.glGenTextures && gl.glBindTexture && gl.glTexStorage2D &&
gl.glDeleteTextures && gl.glGenFramebuffers && gl.glBindFramebuffer &&
gl.glFramebufferTexture2D && gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers &&
gl.glViewport && gl.glColorMask && gl.glClearColor && gl.glClear && gl.glDrawArrays &&
gl.glFinish && gl.glMemoryBarrier && gl.glPixelStorei && gl.glReadPixels)) return false;
DrainErrors(gl);
GLint major = 0, minor = 0, count = 0;
gl.glGetIntegerv(GL_MAJOR_VERSION, &major);
gl.glGetIntegerv(GL_MINOR_VERSION, &minor);
gl.glGetIntegerv(GL_NUM_EXTENSIONS, &count);
if (gl.glGetError() != GL_NO_ERROR || major < 3 || (major == 3 && minor < 1)) return false;
for (GLint i = 0; i < count; ++i) {
const auto* extension = gl.glGetStringi(GL_EXTENSIONS, i);
if (extension && std::strcmp(reinterpret_cast<const char*>(extension),
"GL_EXT_buffer_storage") == 0) return true;
}
return false;
}
// This probe touches no images/SSBO bindings. Keep its scope independent from the
// other POST probes, including pack state and the caller's currently active texture unit.
struct StateScope {
const GLESFunctionsTable& gl;
GLint program = 0, vao = 0, array = 0, copyRead = 0, copyWrite = 0;
GLint drawFbo = 0, readFbo = 0, texture = 0, packBuffer = 0;
GLint viewport[4]{};
GLfloat clear[4]{};
GLboolean colorMask[4]{};
static constexpr std::array<GLenum, 10> enables = {
GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, GL_CULL_FACE, GL_SCISSOR_TEST,
GL_RASTERIZER_DISCARD, GL_DITHER, GL_SAMPLE_ALPHA_TO_COVERAGE,
GL_SAMPLE_COVERAGE, GL_SAMPLE_MASK};
static constexpr std::array<GLenum, 4> packNames = {
GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS};
std::array<GLboolean, enables.size()> enabled{};
std::array<GLint, packNames.size()> pack{};
explicit StateScope(const GLESFunctionsTable& api) : gl(api) {
gl.glGetIntegerv(GL_CURRENT_PROGRAM, &program);
gl.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
gl.glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &array);
gl.glGetIntegerv(GL_COPY_READ_BUFFER_BINDING, &copyRead);
gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, &copyWrite);
gl.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo);
gl.glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &readFbo);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture);
gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &packBuffer);
gl.glGetIntegerv(GL_VIEWPORT, viewport);
gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, clear);
gl.glGetBooleanv(GL_COLOR_WRITEMASK, colorMask);
for (SizeT i = 0; i < enables.size(); ++i) enabled[i] = gl.glIsEnabled(enables[i]);
for (SizeT i = 0; i < packNames.size(); ++i) gl.glGetIntegerv(packNames[i], &pack[i]);
}
void Prepare() {
for (auto cap : enables) gl.glDisable(cap);
gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
for (auto name : packNames) gl.glPixelStorei(name, name == GL_PACK_ALIGNMENT ? 1 : 0);
gl.glViewport(0, 0, kSide, kSide);
gl.glClearColor(0, 0, 0, 1);
}
~StateScope() {
gl.glUseProgram(program);
gl.glBindVertexArray(vao);
gl.glBindBuffer(GL_ARRAY_BUFFER, array);
gl.glBindBuffer(GL_COPY_READ_BUFFER, copyRead);
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, copyWrite);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, packBuffer);
gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, drawFbo);
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, readFbo);
gl.glBindTexture(GL_TEXTURE_2D, texture);
gl.glViewport(viewport[0], viewport[1], viewport[2], viewport[3]);
gl.glClearColor(clear[0], clear[1], clear[2], clear[3]);
gl.glColorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]);
for (SizeT i = 0; i < enables.size(); ++i) {
if (enabled[i]) gl.glEnable(enables[i]); else gl.glDisable(enables[i]);
}
for (SizeT i = 0; i < packNames.size(); ++i) gl.glPixelStorei(packNames[i], pack[i]);
}
};
struct Resources {
const GLESFunctionsTable& gl;
GLuint program = 0, vao = 0;
std::array<GLuint, kSlots> fbos{}, textures{};
explicit Resources(const GLESFunctionsTable& api) : gl(api) {}
~Resources() {
gl.glDeleteFramebuffers(kSlots, fbos.data());
gl.glDeleteTextures(kSlots, textures.data());
gl.glDeleteVertexArrays(1, &vao);
if (program) gl.glDeleteProgram(program);
}
GLuint Compile(GLenum type, const char* source) {
GLuint shader = gl.glCreateShader(type);
if (!shader) return 0;
gl.glShaderSource(shader, 1, &source, nullptr);
gl.glCompileShader(shader);
GLint compiled = 0;
gl.glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512]{};
gl.glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: shader failed: %s", log);
gl.glDeleteShader(shader);
return 0;
}
return shader;
}
Bool Setup() {
const GLuint vs = Compile(GL_VERTEX_SHADER,
"#version 310 es\nlayout(location=0) in vec2 pos; layout(location=1) in vec3 color;\n"
"out highp vec3 vColor; void main(){gl_Position=vec4(pos,0,1);vColor=color;}\n");
const GLuint fs = Compile(GL_FRAGMENT_SHADER,
"#version 310 es\nprecision highp float; in highp vec3 vColor;\n"
"layout(location=0) out vec4 outColor; void main(){outColor=vec4(vColor,1);}\n");
if (vs && fs) {
program = gl.glCreateProgram();
if (program) {
gl.glAttachShader(program, vs);
gl.glAttachShader(program, fs);
gl.glLinkProgram(program);
}
}
if (vs) gl.glDeleteShader(vs);
if (fs) gl.glDeleteShader(fs);
if (!program) return false;
GLint linked = 0;
gl.glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) {
char log[512]{};
gl.glGetProgramInfoLog(program, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: link failed: %s", log);
return false;
}
gl.glUseProgram(program);
gl.glGenVertexArrays(1, &vao);
gl.glBindVertexArray(vao);
gl.glGenFramebuffers(kSlots, fbos.data());
gl.glGenTextures(kSlots, textures.data());
for (Int i = 0; i < kSlots; ++i) {
if (!vao || !fbos[i] || !textures[i]) return false;
gl.glBindTexture(GL_TEXTURE_2D, textures[i]);
gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSide, kSide);
gl.glBindFramebuffer(GL_FRAMEBUFFER, fbos[i]);
gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
textures[i], 0);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false;
}
return gl.glGetError() == GL_NO_ERROR;
}
};
struct Buffers {
const GLESFunctionsTable& gl;
GLuint arena = 0, staging = 0;
explicit Buffers(const GLESFunctionsTable& api) : gl(api) {}
~Buffers() {
// All normal batches finish before cleanup; also retire a partially queued
// batch on an error path before destroying a mapped staging source.
gl.glFinish();
gl.glDeleteBuffers(1, &arena);
gl.glDeleteBuffers(1, &staging);
}
};
void FillVertices(Vector<Vertex>& vertices, Int channel) {
constexpr std::array<Vertex, 6> quad = {{{-1,-1,0,0,0}, {1,-1,0,0,0}, {1,1,0,0,0},
{-1,-1,0,0,0}, {1,1,0,0,0}, {-1,1,0,0,0}}};
for (SizeT k = 0; k < vertices.size(); ++k) {
auto& v = vertices[k];
v = quad[k % 6];
const SizeT q = k / 6;
v.x = v.x / 64.f - 1.f + (2 * (q % 64) + 1) / 64.f;
v.y = v.y / 32.f - 1.f + (2 * (q / 64) + 1) / 32.f;
v.r = channel == 0 ? 1.f : 0.f;
v.g = channel == 1 ? 1.f : 0.f;
v.b = channel == 2 ? 1.f : 0.f;
}
}
BufferOrderingSample Run(const GLESFunctionsTable& gl, const Resources& resources,
const Vector<Uint8>& seed, Int upload, Shape shape) {
BufferOrderingSample sample;
sample.status = BufferOrderingProbeStatus::Failed;
Buffers buffers(gl);
Vector<Vertex> payload(kQuads * 6);
Vector<Uint8> pixels(kSide * kSide * 4);
DrainErrors(gl);
do {
gl.glGenBuffers(1, &buffers.arena);
if (!buffers.arena) break;
gl.glBindBuffer(GL_ARRAY_BUFFER, buffers.arena);
gl.glBufferStorageEXT(GL_ARRAY_BUFFER, kArenaSize, seed.data(), kMapFlags | kDynamicStorage);
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
if (shape != Shape::Unmapped) {
// Deliberately never dereference the destination pointer. All destination
// writes below are ordered GL commands, with no client mapping accesses.
if (!gl.glMapBufferRange(GL_ARRAY_BUFFER, 0, kArenaSize, kMapFlags)) break;
if (shape == Shape::MapThenUnmap && !gl.glUnmapBuffer(GL_ARRAY_BUFFER)) break;
}
gl.glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset));
gl.glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset + 2 * sizeof(GLfloat)));
gl.glEnableVertexAttribArray(0);
gl.glEnableVertexAttribArray(1);
void* sourceMap = nullptr;
if (upload != 0) {
gl.glGenBuffers(1, &buffers.staging);
if (!buffers.staging) break;
gl.glBindBuffer(GL_COPY_READ_BUFFER, buffers.staging);
if (upload == 1) {
gl.glBufferStorageEXT(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, kMapFlags);
sourceMap = gl.glMapBufferRange(GL_COPY_READ_BUFFER, 0, kSlots * kPayloadSize, kMapFlags);
if (!sourceMap) break;
} else {
gl.glBufferData(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, GL_STREAM_DRAW);
}
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, buffers.arena);
}
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int batch = 0; batch < kBatches; ++batch) {
for (Int slot = 0; slot < kSlots; ++slot) {
FillVertices(payload, (batch * kSlots + slot) % 3);
if (shape == Shape::FinishBefore || shape == Shape::FinishBoth) gl.glFinish();
if (shape == Shape::BarrierBefore) gl.glMemoryBarrier(GL_ALL_BARRIER_BITS);
if (upload == 0) {
gl.glBufferSubData(GL_ARRAY_BUFFER, kOffset, kPayloadSize, payload.data());
} else {
// No slot is reused until the entire batch has finished on the GPU.
if (upload == 1) {
std::memcpy(static_cast<Uint8*>(sourceMap) + slot * kPayloadSize,
payload.data(), kPayloadSize);
} else {
gl.glBufferSubData(GL_COPY_READ_BUFFER, slot * kPayloadSize,
kPayloadSize, payload.data());
}
gl.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
slot * kPayloadSize, kOffset, kPayloadSize);
}
if (shape == Shape::FinishBoth) gl.glFinish();
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glClear(GL_COLOR_BUFFER_BIT);
for (Int draw = 0; draw < kDraws; ++draw) gl.glDrawArrays(GL_TRIANGLES, 0, kQuads * 6);
}
// No readback/Finish between subject update/draw pairs. Early readback
// would hide precisely the old-reader/new-writer overlap being tested.
gl.glFinish();
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int slot = 0; slot < kSlots; ++slot) {
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glReadPixels(0, 0, kSide, kSide, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
const Int channel = (batch * kSlots + slot) % 3;
Uint bad = 0;
for (Int pixel = 0; pixel < kSide * kSide; ++pixel) {
for (Int c = 0; c < 3; ++c) {
const Int expected = c == channel ? 255 : 0;
if (std::abs(Int(pixels[pixel * 4 + c]) - expected) > 8) ++bad;
}
}
++sample.frames;
if (bad != 0) ++sample.badFrames;
sample.badComponents += bad;
}
if (sample.error != GL_NO_ERROR) break;
}
if (sample.error == GL_NO_ERROR && sample.frames == kBatches * kSlots)
sample.status = BufferOrderingProbeStatus::Complete;
} while (false);
if (sample.error == GL_NO_ERROR) sample.error = gl.glGetError();
return sample;
}
String Describe(const BufferOrderingSample& sample) {
if (sample.status == BufferOrderingProbeStatus::NotRun) return "not run";
if (sample.status == BufferOrderingProbeStatus::Failed)
return format("inconclusive (GL error 0x{:x}, {} readbacks)", sample.error, sample.frames);
return format("{}/{} bad FBOs ({} components)", sample.badFrames, sample.frames, sample.badComponents);
}
String DescribeUpload(const BufferOrderingUploadMeasurement& row, Int upload) {
return format("{}: mapped {}, never-mapped {}, Finish-before {}, Finish-both {}, "
"map-then-unmap {}, barrier-before {}", kUploadNames[upload], Describe(row.mapped),
Describe(row.unmapped), Describe(row.finishBefore), Describe(row.finishBoth),
Describe(row.mapThenUnmap), Describe(row.barrierBefore));
}
} // namespace
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(const GLESFunctionsTable& gl) try {
PersistentBufferOrderingMeasurement measurement;
if (!CanProbe(gl)) return measurement;
measurement.supported = true;
StateScope state(gl);
state.Prepare();
Resources resources(gl);
if (gl.glGetError() != GL_NO_ERROR || !resources.Setup()) {
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
MGLOG_I("[driver-bug] persistent buffer ordering: setup failed; inconclusive");
return measurement;
}
Vector<Uint8> seed(kArenaSize, 0);
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
auto& row = measurement.uploads[upload];
row.unmapped = Run(gl, resources, seed, upload, Shape::Unmapped);
if (row.unmapped.Passed()) {
// A single allocation can miss on Mali. Stop once a mismatch is measured,
// otherwise retry with fresh storage rather than treating one pass as proof.
for (Int attempt = 0; attempt < kAttempts; ++attempt) {
const auto sample = Run(gl, resources, seed, upload, Shape::Mapped);
row.mapped.status = sample.status;
row.mapped.error = sample.error;
row.mapped.frames += sample.frames;
row.mapped.badFrames += sample.badFrames;
row.mapped.badComponents += sample.badComponents;
if (sample.status != BufferOrderingProbeStatus::Complete || sample.badFrames) break;
}
if (row.mapped.status == BufferOrderingProbeStatus::Complete && row.mapped.badFrames) {
row.finishBoth = Run(gl, resources, seed, upload, Shape::FinishBoth);
row.finishBefore = Run(gl, resources, seed, upload, Shape::FinishBefore);
row.mapThenUnmap = Run(gl, resources, seed, upload, Shape::MapThenUnmap);
row.barrierBefore = Run(gl, resources, seed, upload, Shape::BarrierBefore);
}
}
MGLOG_I("[driver-bug] persistent buffer ordering: %s; %s", DescribeUpload(row, upload).c_str(),
row.Detected() ? "detected" : "not detected or inconclusive");
}
return measurement;
} catch (const std::bad_alloc&) {
// The CPU initializer is arena-sized too. An allocation failure must not discard
// the rest of the POST report or turn a partially sampled case into a finding.
MGLOG_I("[driver-bug] persistent buffer ordering: host allocation failed; inconclusive");
PersistentBufferOrderingMeasurement measurement;
measurement.supported = true;
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
return measurement;
}
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement) {
String detail;
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
if (!measurement.uploads[upload].Detected()) continue;
if (!detail.empty()) detail += "; ";
detail += DescribeUpload(measurement.uploads[upload], upload);
}
if (detail.empty()) return std::nullopt;
detail += ". A 128 MiB immutable vertex destination was mapped WRITE|PERSISTENT|COHERENT, "
"but never accessed through its client pointer. Queued uploads/draws corrupt vertex data; "
"identical never-mapped and Finish-before-and-after controls pass. "
"This POST does not enable a workaround. MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1 "
"avoids automatic arena adoption; explicit application mappings remain separate. "
"FBO counts describe this bounded stress probe, not application flicker frequency.";
return DriverBugFinding{"Persistent-mapped vertex buffers lose upload/draw ordering",
DriverBugVerdict::Unfixable, Move(detail)};
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,66 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "DriverBugProbes.h"
#include <array>
namespace MobileGL::MG_Util::SelfTest {
enum class BufferOrderingProbeStatus : Uint8 { NotRun, Complete, Failed };
struct BufferOrderingSample {
BufferOrderingProbeStatus status = BufferOrderingProbeStatus::NotRun;
Uint frames = 0; // Independent FBO readbacks, not draw calls or application frames.
Uint badFrames = 0;
Uint badComponents = 0;
GLenum error = GL_NO_ERROR;
Bool Passed() const { return status == BufferOrderingProbeStatus::Complete && badFrames == 0; }
};
struct BufferOrderingUploadMeasurement {
BufferOrderingSample unmapped;
BufferOrderingSample mapped;
BufferOrderingSample finishBefore;
BufferOrderingSample finishBoth;
BufferOrderingSample mapThenUnmap;
BufferOrderingSample barrierBefore;
Bool Detected() const {
return unmapped.Passed() && finishBoth.Passed() &&
mapped.status == BufferOrderingProbeStatus::Complete && mapped.badFrames != 0;
}
};
struct PersistentBufferOrderingMeasurement {
Bool supported = false;
// SubData; CopyBufferSubData from coherent persistent staging; CopyBufferSubData
// from ordinary SubData staging. Each has its OWN otherwise-identical controls.
std::array<BufferOrderingUploadMeasurement, 3> uploads;
};
// POST-only: native GLES calls, no MobileGL buffers, renderer-name rules or config changes.
// The Mali r54p1 finding: updating an immutable vertex arena that has been persistently
// mapped can corrupt queued draws even when the application never accesses that mapping.
// Queue eight update/draw pairs into separate FBOs BEFORE any Finish/readback, then check
// every pixel of both old and new draws. Staging slots never overlap while in flight.
//
// Each upload runs a never-mapped control with identical storage flags. Try up to three
// fresh mapped allocations to catch intermittent failures. On corruption, measure explicit
// waits, map-then-unmap and a barrier as diagnostics. Only a passing never-mapped AND
// Finish-before-and-after control permits a finding. Setup/GL failures are inconclusive.
// Explicit allocations are one 128 MiB arena, its initializer, and small staging/FBOs;
// allocations, batches and draws are bounded. Every touched GL state is restored.
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(
const MG_External::GLESFunctionsTable& gl);
// Used by the POST collector. A report never labels an inconclusive sample as a bug.
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement);
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,556 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// See the header for what is being measured and why. The plumbing here is shaped
// like the POST timestamp probe (DriverPost.cpp, ProbeVulkanTimerQuery): one
// throwaway command buffer, a bounded fence wait that deliberately leaks the
// device objects rather than idle-wait a hung GPU, and teardown on every path.
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "PrimitivesGeneratedNoXfbProbeSpv.h"
namespace MobileGL::MG_Util::SelfTest {
namespace {
template <typename Callable>
struct ProbeScopeGuard {
explicit ProbeScopeGuard(Callable callable) : onExit(Move(callable)) {}
ProbeScopeGuard(const ProbeScopeGuard&) = delete;
ProbeScopeGuard& operator=(const ProbeScopeGuard&) = delete;
~ProbeScopeGuard() { onExit(); }
private:
Callable onExit;
};
Bool AllRequiredFnsPresent(const PrimitivesGeneratedNoXfbProbeFns& fns) {
return fns.vkCreateCommandPool != nullptr && fns.vkDestroyCommandPool != nullptr &&
fns.vkAllocateCommandBuffers != nullptr && fns.vkBeginCommandBuffer != nullptr &&
fns.vkEndCommandBuffer != nullptr && fns.vkCreateQueryPool != nullptr &&
fns.vkDestroyQueryPool != nullptr && fns.vkCmdResetQueryPool != nullptr &&
fns.vkCmdBeginQuery != nullptr && fns.vkCmdEndQuery != nullptr &&
fns.vkCmdBeginQueryIndexedEXT != nullptr && fns.vkCmdEndQueryIndexedEXT != nullptr &&
fns.vkCreateRenderPass != nullptr && fns.vkDestroyRenderPass != nullptr &&
fns.vkCreateFramebuffer != nullptr && fns.vkDestroyFramebuffer != nullptr &&
fns.vkCmdBeginRenderPass != nullptr && fns.vkCmdEndRenderPass != nullptr &&
fns.vkCreateShaderModule != nullptr && fns.vkDestroyShaderModule != nullptr &&
fns.vkCreatePipelineLayout != nullptr && fns.vkDestroyPipelineLayout != nullptr &&
fns.vkCreateGraphicsPipelines != nullptr && fns.vkDestroyPipeline != nullptr &&
fns.vkCmdBindPipeline != nullptr && fns.vkCmdDraw != nullptr &&
fns.vkCreateFence != nullptr && fns.vkDestroyFence != nullptr &&
fns.vkQueueSubmit != nullptr && fns.vkWaitForFences != nullptr &&
fns.vkGetQueryPoolResults != nullptr && fns.vkDeviceWaitIdle != nullptr;
}
} // namespace
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context) {
PrimitivesGeneratedNoXfbMeasurement measurement;
const auto fail = [&](String reason) {
measurement.ran = false;
measurement.failureReason = Move(reason);
return measurement;
};
if (!context.transformFeedbackQueriesUsable) {
return fail("transform feedback stream queries are not usable on this device, so the "
"probe has no subject");
}
if (context.device == VK_NULL_HANDLE || context.queue == VK_NULL_HANDLE) {
return fail("no device/queue was supplied");
}
const PrimitivesGeneratedNoXfbProbeFns& fns = context.fns;
if (!AllRequiredFnsPresent(fns)) {
return fail("a required Vulkan entry point was not resolved");
}
VkDevice device = context.device;
// Slot i of each pool belongs to shape i (0 = triangles plain, 1 = triangles
// under discard, 2 = patches under discard). Unused slots are reset either
// way; reset needs no feature and an unqueried reset slot is never read.
constexpr Uint32 kShapeSlots = 3;
const Bool drawPatches = context.tessellationEnabled;
const Bool measureStatistics = context.pipelineStatisticsEnabled;
// Only with BOTH feature bits: without ...WithRasterizerDiscard, a
// discarding draw inside the query is invalid usage
// (VUID-vkCmdDraw-primitivesGeneratedQueryWithRasterizerDiscard-06708),
// and two of the three shapes discard.
const Bool measurePrimitivesGeneratedExt = context.primitivesGeneratedQueryUsable;
VkCommandPool commandPool = VK_NULL_HANDLE;
VkQueryPool streamQueryPool = VK_NULL_HANDLE;
VkQueryPool primitivesGeneratedQueryPool = VK_NULL_HANDLE;
VkQueryPool statisticsQueryPool = VK_NULL_HANDLE;
VkRenderPass renderPass = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
VkShaderModule vertModule = VK_NULL_HANDLE;
VkShaderModule tescModule = VK_NULL_HANDLE;
VkShaderModule teseModule = VK_NULL_HANDLE;
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
VkPipeline trianglePlainPipeline = VK_NULL_HANDLE;
VkPipeline triangleDiscardPipeline = VK_NULL_HANDLE;
VkPipeline patchDiscardPipeline = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
// Teardown on every path. When the fence wait timed out the submission may
// still be executing on a hung GPU: vkDeviceWaitIdle could block forever
// and destroying in-flight objects is undefined, so everything is
// deliberately leaked - a hung GPU must not hang the caller. The same flag
// is returned in the measurement, because a caller that OWNS the device must
// make the same choice for it (see the header): destroying a device whose
// children are alive and whose queue may still be executing is the very hang
// this bound exists to prevent.
const ProbeScopeGuard teardown([&]() {
if (measurement.fenceWaitTimedOut) {
return;
}
fns.vkDeviceWaitIdle(device);
if (fence != VK_NULL_HANDLE) fns.vkDestroyFence(device, fence, nullptr);
if (trianglePlainPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, trianglePlainPipeline, nullptr);
if (triangleDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, triangleDiscardPipeline, nullptr);
if (patchDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, patchDiscardPipeline, nullptr);
if (pipelineLayout != VK_NULL_HANDLE) fns.vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
if (vertModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, vertModule, nullptr);
if (tescModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, tescModule, nullptr);
if (teseModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, teseModule, nullptr);
if (framebuffer != VK_NULL_HANDLE) fns.vkDestroyFramebuffer(device, framebuffer, nullptr);
if (renderPass != VK_NULL_HANDLE) fns.vkDestroyRenderPass(device, renderPass, nullptr);
if (statisticsQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, statisticsQueryPool, nullptr);
if (primitivesGeneratedQueryPool != VK_NULL_HANDLE)
fns.vkDestroyQueryPool(device, primitivesGeneratedQueryPool, nullptr);
if (streamQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, streamQueryPool, nullptr);
if (commandPool != VK_NULL_HANDLE) fns.vkDestroyCommandPool(device, commandPool, nullptr);
});
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = context.queueFamilyIndex;
if (fns.vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
return fail("vkCreateCommandPool failed");
}
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
if (fns.vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
return fail("vkAllocateCommandBuffers failed");
}
VkQueryPoolCreateInfo streamPoolInfo{};
streamPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
streamPoolInfo.queryType = VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT;
streamPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &streamPoolInfo, nullptr, &streamQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(TRANSFORM_FEEDBACK_STREAM) failed");
}
if (measurePrimitivesGeneratedExt) {
VkQueryPoolCreateInfo pgqPoolInfo{};
pgqPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
pgqPoolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
pgqPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &pgqPoolInfo, nullptr, &primitivesGeneratedQueryPool) !=
VK_SUCCESS) {
return fail("vkCreateQueryPool(PRIMITIVES_GENERATED_EXT) failed");
}
}
if (measureStatistics) {
VkQueryPoolCreateInfo statPoolInfo{};
statPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
statPoolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
statPoolInfo.queryCount = kShapeSlots;
// CLIPPING_INVOCATIONS counts the primitives PROCESSED BY (i.e. reaching)
// primitive clipping - GL's CLIPPING_INPUT_PRIMITIVES - which is the
// pre-clip, post-vertex-processing set PRIMITIVES_GENERATED is defined
// over. CLIPPING_PRIMITIVES (the stage's OUTPUT count) would be wrong:
// clipping may drop or split primitives.
statPoolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
if (fns.vkCreateQueryPool(device, &statPoolInfo, nullptr, &statisticsQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(PIPELINE_STATISTICS) failed");
}
}
// Zero-attachment render pass + 1x1 framebuffer: the draw is discarded
// before rasterization, nothing is ever written, but vkCmdDraw needs a
// render pass instance to live in.
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
VkRenderPassCreateInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
if (fns.vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {
return fail("vkCreateRenderPass failed");
}
VkFramebufferCreateInfo framebufferInfo{};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.width = 1;
framebufferInfo.height = 1;
framebufferInfo.layers = 1;
if (fns.vkCreateFramebuffer(device, &framebufferInfo, nullptr, &framebuffer) != VK_SUCCESS) {
return fail("vkCreateFramebuffer failed");
}
const auto makeModule = [&](const std::uint32_t* words, std::size_t wordCount, VkShaderModule& out) {
VkShaderModuleCreateInfo moduleInfo{};
moduleInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleInfo.codeSize = wordCount * sizeof(std::uint32_t);
moduleInfo.pCode = words;
return fns.vkCreateShaderModule(device, &moduleInfo, nullptr, &out) == VK_SUCCESS;
};
if (!makeModule(kPrimitivesGeneratedNoXfbProbeVertSpv, kPrimitivesGeneratedNoXfbProbeVertSpvWordCount,
vertModule)) {
return fail("vkCreateShaderModule(vert) failed");
}
if (drawPatches) {
if (!makeModule(kPrimitivesGeneratedNoXfbProbeTescSpv, kPrimitivesGeneratedNoXfbProbeTescSpvWordCount,
tescModule) ||
!makeModule(kPrimitivesGeneratedNoXfbProbeTeseSpv, kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount,
teseModule)) {
return fail("vkCreateShaderModule(tesc/tese) failed");
}
}
VkPipelineLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
if (fns.vkCreatePipelineLayout(device, &layoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {
return fail("vkCreatePipelineLayout failed");
}
// With rasterizerDiscardEnable the viewport and multisample state are
// ignored by the spec, but well-formed ones are supplied anyway: the probe
// must never be the thing that trips a picky driver. The discard-off
// variant rasterizes into the zero-attachment subpass, which writes
// nothing anywhere.
const auto makePipeline = [&](Bool tessellated, Bool rasterizerDiscard, VkPipeline& out) {
VkPipelineShaderStageCreateInfo stages[3] = {};
Uint32 stageCount = 0;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_VERTEX_BIT;
stages[stageCount].module = vertModule;
stages[stageCount].pName = "main";
++stageCount;
if (tessellated) {
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT;
stages[stageCount].module = tescModule;
stages[stageCount].pName = "main";
++stageCount;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT;
stages[stageCount].module = teseModule;
stages[stageCount].pName = "main";
++stageCount;
}
VkPipelineVertexInputStateCreateInfo vertexInput{};
vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology =
tessellated ? VK_PRIMITIVE_TOPOLOGY_PATCH_LIST : VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineTessellationStateCreateInfo tessellation{};
tessellation.sType = VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO;
tessellation.patchControlPoints = 1;
VkViewport viewport{};
viewport.width = 1.0f;
viewport.height = 1.0f;
viewport.maxDepth = 1.0f;
VkRect2D scissor{};
scissor.extent.width = 1;
scissor.extent.height = 1;
VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;
VkPipelineRasterizationStateCreateInfo rasterization{};
rasterization.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterization.rasterizerDiscardEnable = rasterizerDiscard ? VK_TRUE : VK_FALSE;
rasterization.polygonMode = VK_POLYGON_MODE_FILL;
rasterization.cullMode = VK_CULL_MODE_NONE;
rasterization.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterization.lineWidth = 1.0f;
VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkGraphicsPipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = stageCount;
pipelineInfo.pStages = stages;
pipelineInfo.pVertexInputState = &vertexInput;
pipelineInfo.pInputAssemblyState = &inputAssembly;
pipelineInfo.pTessellationState = tessellated ? &tessellation : nullptr;
pipelineInfo.pViewportState = &viewportState;
pipelineInfo.pRasterizationState = &rasterization;
pipelineInfo.pMultisampleState = &multisample;
pipelineInfo.layout = pipelineLayout;
pipelineInfo.renderPass = renderPass;
pipelineInfo.subpass = 0;
return fns.vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &out) ==
VK_SUCCESS;
};
if (!makePipeline(false, false, trianglePlainPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles) failed");
}
if (!makePipeline(false, true, triangleDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles, discard) failed");
}
if (drawPatches && !makePipeline(true, true, patchDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(patches, discard) failed");
}
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (fns.vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
return fail("vkBeginCommandBuffer failed");
}
fns.vkCmdResetQueryPool(commandBuffer, streamQueryPool, 0, kShapeSlots);
if (measurePrimitivesGeneratedExt) {
fns.vkCmdResetQueryPool(commandBuffer, primitivesGeneratedQueryPool, 0, kShapeSlots);
}
if (measureStatistics) {
fns.vkCmdResetQueryPool(commandBuffer, statisticsQueryPool, 0, kShapeSlots);
}
VkRenderPassBeginInfo renderPassBegin{};
renderPassBegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassBegin.renderPass = renderPass;
renderPassBegin.framebuffer = framebuffer;
renderPassBegin.renderArea.extent.width = 1;
renderPassBegin.renderArea.extent.height = 1;
fns.vkCmdBeginRenderPass(commandBuffer, &renderPassBegin, VK_SUBPASS_CONTENTS_INLINE);
// Each query kind wraps ITS OWN replay of the shape's draw, never a shared
// one. Not pedantry - a co-active control CONTAMINATES the subject:
// measured on lavapipe, a dedicated primitives-generated query active
// around the same draw switches llvmpipe's primitive collection on, and
// the stream query on that draw then answers the exact count it answers 0
// for when it is alone - which is how the renderer actually runs it. A
// probe that measured them together certified this driver healthy and
// repaired nothing. The replays are identical recordings of a
// deterministic draw, so the per-shape comparison loses nothing.
const auto recordShape = [&](Uint32 slot, VkPipeline pipeline, Uint32 vertexCount) {
fns.vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
// THE SUBJECT, alone: no vkCmdBeginTransformFeedbackEXT anywhere in
// this command buffer - the stream query wraps a draw with transform
// feedback inactive, exactly the CTS's tessellator-measuring shape.
fns.vkCmdBeginQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0);
if (measurePrimitivesGeneratedExt) {
// Plain vkCmdBeginQuery: a PRIMITIVES_GENERATED_EXT query begun
// this way counts vertex stream 0, which is where every non-GS
// (and default-stream GS) primitive goes.
fns.vkCmdBeginQuery(commandBuffer, primitivesGeneratedQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, primitivesGeneratedQueryPool, slot);
}
if (measureStatistics) {
fns.vkCmdBeginQuery(commandBuffer, statisticsQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, statisticsQueryPool, slot);
}
};
recordShape(0, trianglePlainPipeline, 3); // one rasterized triangle
recordShape(1, triangleDiscardPipeline, 3); // one discarded triangle
if (drawPatches) {
// one 1-vertex patch -> one tessellated, discarded triangle
recordShape(2, patchDiscardPipeline, 1);
}
fns.vkCmdEndRenderPass(commandBuffer);
if (fns.vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
return fail("vkEndCommandBuffer failed");
}
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
if (fns.vkCreateFence(device, &fenceInfo, nullptr, &fence) != VK_SUCCESS) {
return fail("vkCreateFence failed");
}
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
if (fns.vkQueueSubmit(context.queue, 1, &submitInfo, fence) != VK_SUCCESS) {
return fail("vkQueueSubmit failed");
}
constexpr Uint64 kFenceTimeoutNs = 5'000'000'000ull; // a probe must never hang its caller
if (fns.vkWaitForFences(device, 1, &fence, VK_TRUE, kFenceTimeoutNs) != VK_SUCCESS) {
// Set BEFORE failing: the scope guard reads it to skip every destroy, and
// the caller reads it out of the measurement to skip destroying the device.
measurement.fenceWaitTimedOut = true;
return fail("the probe submission did not complete within 5 s");
}
const auto readShape = [&](Uint32 slot, Uint64 expected, PrimitivesGeneratedNoXfbShapeMeasurement& out) {
Uint64 streamPair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
if (fns.vkGetQueryPoolResults(device, streamQueryPool, slot, 1, sizeof(streamPair), streamPair,
sizeof(streamPair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) != VK_SUCCESS) {
return false;
}
out.drawn = true;
out.expectedPrimitives = expected;
out.streamGenerated = streamPair[1];
if (measurePrimitivesGeneratedExt) {
Uint64 generated = 0;
if (fns.vkGetQueryPoolResults(device, primitivesGeneratedQueryPool, slot, 1, sizeof(generated),
&generated, sizeof(generated),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.primitivesGeneratedExtMeasured = true;
out.primitivesGeneratedExt = generated;
}
}
if (measureStatistics) {
Uint64 clippingInput = 0;
if (fns.vkGetQueryPoolResults(device, statisticsQueryPool, slot, 1, sizeof(clippingInput),
&clippingInput, sizeof(clippingInput),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.statisticsMeasured = true;
out.statisticsClippingInput = clippingInput;
}
}
return true;
};
if (!readShape(0, 1, measurement.trianglesPlain)) {
return fail("vkGetQueryPoolResults(triangles) failed");
}
if (!readShape(1, 1, measurement.trianglesDiscard)) {
return fail("vkGetQueryPoolResults(triangles, discard) failed");
}
if (drawPatches && !readShape(2, 1, measurement.patchesDiscard)) {
return fail("vkGetQueryPoolResults(patches, discard) failed");
}
measurement.ran = true;
return measurement;
}
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement) {
if (!measurement.ran || !measurement.trianglesPlain.drawn || !measurement.trianglesDiscard.drawn) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
const PrimitivesGeneratedNoXfbShapeMeasurement* shapes[3] = {&measurement.trianglesPlain,
&measurement.trianglesDiscard,
&measurement.patchesDiscard};
Bool anyStreamSilent = false;
Bool allStreamExact = true;
Bool allPrimitivesGeneratedExtExact = true;
Bool allStatisticsExact = true;
// Whether the statistics substitute DOMINATES the stream query shape by shape:
// every shape the statistics do not answer exactly must be one the stream query
// answered 0 for anyway. Without this, a plain-shape-only substitute could be
// armed on a device whose stream query was RIGHT on a shape the statistics get
// wrong - and the renderer reroutes every XFB-inactive draw, so that shape would
// be downgraded from correct to wrong. "Never worse per draw" is what makes
// arming on an uncharacterised driver defensible; it has to be measured, not
// assumed.
Bool statisticsDominateStream = true;
for (const auto* shape : shapes) {
if (!shape->drawn) {
continue;
}
if (shape->streamGenerated == 0) {
anyStreamSilent = true;
}
if (shape->streamGenerated != shape->expectedPrimitives) {
allStreamExact = false;
// A nonzero wrong answer is neither the defect nor health: refuse
// a verdict rather than repair a driver the probe does not
// understand.
if (shape->streamGenerated != 0) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
}
if (!shape->primitivesGeneratedExtMeasured ||
shape->primitivesGeneratedExt != shape->expectedPrimitives) {
allPrimitivesGeneratedExtExact = false;
}
if (!shape->statisticsMeasured ||
shape->statisticsClippingInput != shape->expectedPrimitives) {
allStatisticsExact = false;
// Only a shape the stream query was silent on may be left behind by
// the substitute; a shape it answered exactly must not be traded away.
if (shape->streamGenerated == shape->expectedPrimitives) {
statisticsDominateStream = false;
}
}
}
if (allStreamExact) {
return PrimitivesGeneratedNoXfbVerdict::StreamCounts;
}
// At this point at least one drawn shape answered exactly 0.
MOBILEGL_ASSERT(anyStreamSilent, "verdict fell through with no silent shape");
if (allPrimitivesGeneratedExtExact) {
return PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute;
}
if (allStatisticsExact) {
return PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute;
}
const auto& plain = measurement.trianglesPlain;
const Bool plainStatisticsExact =
plain.statisticsMeasured && plain.statisticsClippingInput == plain.expectedPrimitives;
// Both halves are required: the substitute must repair the plain shape, AND it
// must not cost any shape an answer the stream query already had right.
return (plainStatisticsExact && statisticsDominateStream)
? PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly
: PrimitivesGeneratedNoXfbVerdict::Unfixable;
}
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled) {
switch (overrideSetting) {
case MG_Config::QuirkOverride::ForceOff:
return PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::ForceOn:
// ForceOn bypasses the device verdict, never the structural checks:
// without a hostable pool there is nothing to route through. The
// dedicated query wins where both exist - its semantics are the GL
// target's by definition.
if (primitivesGeneratedQueryUsable) {
return PrimGenRerouteKind::PrimitivesGeneratedExt;
}
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::Auto:
break;
}
switch (verdict) {
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
return primitivesGeneratedQueryUsable ? PrimGenRerouteKind::PrimitivesGeneratedExt
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
case PrimitivesGeneratedNoXfbVerdict::Unfixable:
break;
}
return PrimGenRerouteKind::None;
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,258 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Config.h>
#include <Includes.h>
namespace MobileGL::MG_Util::SelfTest {
// ============ PRIMITIVES GENERATED WITHOUT TRANSFORM FEEDBACK ============
//
// GL_PRIMITIVES_GENERATED counts what the last vertex processing stage emits
// whether or not a transform feedback capture is active (GL 4.6 core 13.4), and
// the DirectVulkan backend serves it from the second result
// (primitivesNeeded) of a VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT pool
// slot wrapped around each draw. VK_EXT_transform_feedback defines that value
// as the primitives the vertex stream produced, capture or no capture - but a
// Mali driver (G1-Ultra, observed against the gl44/gl45/gl46 CTS) answers 0
// for every draw made while no vkCmdBeginTransformFeedbackEXT span is open,
// while answering exactly right as soon as one is. The tessellation suites
// measure the tessellator by exactly that shape (rasterizer discard on,
// transform feedback INACTIVE, a PATCHES draw inside a GENERATED query;
// esextcTessellationShaderUtils.cpp, captureTessellationData), size their
// capture buffers from the answer, and die on the zero-byte buffer the 0
// produces - about 29 tessellation tests per tree plus all 13
// tessellation_shader.vertex bodies.
//
// THE PROBE draws three shapes through pipelines with no Xfb execution mode
// and no transform feedback begun, each inside its own stream-query slot:
// - one triangle, plainly (no rasterizer discard);
// - one triangle with rasterizer discard baked into the pipeline;
// - one PATCHES draw with discard, through a passthrough tessellation
// pipeline whose all-1 levels emit exactly one triangle (when the device
// has tessellationShader) - the CTS shape verbatim.
// Alongside each stream slot it measures the TWO candidate substitutes, each
// around ITS OWN identical replay of the shape's draw - never co-active with
// the subject, because a co-active control contaminates it: on lavapipe a
// dedicated primitives-generated query active around the same draw switches
// the driver's primitive collection on and the stream query then counts a
// draw it answers 0 for when alone, which is how the renderer actually runs
// it. The substitutes:
// - a VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT slot, where the device has
// VK_EXT_primitives_generated_query with BOTH primitivesGeneratedQuery and
// primitivesGeneratedQueryWithRasterizerDiscard (without the discard
// feature the spec forbids the query around a discarding draw at all -
// VUID-vkCmdDraw-...-06708 - and GL applications toggle discard freely, so
// a base-feature-only device cannot use this tier). The extension exists
// precisely because GL needs PRIMITIVES_GENERATED without a capture, so
// its semantics are exact by definition - what remains to prove is that
// the DRIVER's implementation is not silent in the same way its stream
// query is;
// - a VK_QUERY_TYPE_PIPELINE_STATISTICS slot counting CLIPPING_INVOCATIONS
// (when the device has pipelineStatisticsQuery): one invocation of the
// primitive clipping stage per primitive reaching it - GL's
// CLIPPING_INPUT_PRIMITIVES - which sits AFTER every vertex processing
// stage (post-tess, post-GS) and, per spec, BEFORE rasterizer discard, so
// for an XFB-inactive draw it is definitionally the number
// PRIMITIVES_GENERATED must answer. (A geometry stage's non-zero vertex
// streams never reach clipping, but non-indexed GL_PRIMITIVES_GENERATED
// counts stream 0 alone, so the sets still agree. The stage's OUTPUT
// count - CLIPPING_PRIMITIVES - would not: clipping drops and splits.)
//
// THE CONTROL DISCIPLINE (DriverBugProbes.h): the substitute slots are the
// probe's controls, and the DISCARD dimension is measured separately because
// it is a real fault line, not paranoia: Mesa llvmpipe short-circuits its
// clipping statistics under rasterizer discard (reading 0 there while counting
// the identical undiscarded draw exactly) while its dedicated
// primitives-generated query counts both - measured 2026-08, and the reason
// the verdict ranks the dedicated query first. A substitute qualifies only by
// answering the exact expected count on every shape it is required to cover;
// a device where no substitute qualifies even for the plain shape gets none
// (the honest verdict is the current behaviour); anything that fits neither
// the defect nor health is INCONCLUSIVE and must never arm anything. The
// expected counts are exact (1 triangle per shape), not merely nonzero, so a
// driver that half-counts cannot arm a half-right repair.
// What one drawn shape of the probe measured.
struct PrimitivesGeneratedNoXfbShapeMeasurement {
// The shape's draw was recorded and its query slots were read back.
Bool drawn = false;
// Primitives the draw is defined to emit (1 for every shape).
Uint64 expectedPrimitives = 0;
// The stream-query slot's primitivesNeeded answer - what the renderer's
// GL_PRIMITIVES_GENERATED path would have returned.
Uint64 streamGenerated = 0;
// Whether the dedicated primitives-generated slot ran (it needs the
// extension with both feature bits, see above).
Bool primitivesGeneratedExtMeasured = false;
// Its answer for the same draw.
Uint64 primitivesGeneratedExt = 0;
// Whether the statistics slot ran (it needs pipelineStatisticsQuery).
Bool statisticsMeasured = false;
// The clipping-stage invocation count for the same draw.
Uint64 statisticsClippingInput = 0;
};
struct PrimitivesGeneratedNoXfbMeasurement {
// The probe submitted and read back at least the two triangle shapes.
// False when any setup step failed; failureReason then names the step.
Bool ran = false;
// The probe's bounded fence wait expired with the submission possibly
// still executing. The probe then deliberately LEAKED every child object
// it created (no vkDeviceWaitIdle, no destroys - a hung GPU must not hang
// the caller), so a caller that owns the device MUST NOT destroy or
// idle-wait it either: vkDestroyDevice with live children and in-flight
// work is the exact hang the bound exists to prevent. The POST leaks its
// throwaway device on this flag, mirroring its sibling probes.
Bool fenceWaitTimedOut = false;
String failureReason;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesPlain;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesDiscard;
// drawn = false when the device has no tessellationShader.
PrimitivesGeneratedNoXfbShapeMeasurement patchesDiscard;
};
// The device-level entry points the probe records with. Supplied by the caller
// because the two callers resolve them differently: the renderer passes its
// statically linked symbols (and its vkGetDeviceProcAddr-resolved EXT
// pointers), the driver POST passes vkGetInstanceProcAddr trampolines.
struct PrimitivesGeneratedNoXfbProbeFns {
PFN_vkCreateCommandPool vkCreateCommandPool = nullptr;
PFN_vkDestroyCommandPool vkDestroyCommandPool = nullptr;
PFN_vkAllocateCommandBuffers vkAllocateCommandBuffers = nullptr;
PFN_vkBeginCommandBuffer vkBeginCommandBuffer = nullptr;
PFN_vkEndCommandBuffer vkEndCommandBuffer = nullptr;
PFN_vkCreateQueryPool vkCreateQueryPool = nullptr;
PFN_vkDestroyQueryPool vkDestroyQueryPool = nullptr;
PFN_vkCmdResetQueryPool vkCmdResetQueryPool = nullptr;
PFN_vkCmdBeginQuery vkCmdBeginQuery = nullptr;
PFN_vkCmdEndQuery vkCmdEndQuery = nullptr;
PFN_vkCmdBeginQueryIndexedEXT vkCmdBeginQueryIndexedEXT = nullptr;
PFN_vkCmdEndQueryIndexedEXT vkCmdEndQueryIndexedEXT = nullptr;
PFN_vkCreateRenderPass vkCreateRenderPass = nullptr;
PFN_vkDestroyRenderPass vkDestroyRenderPass = nullptr;
PFN_vkCreateFramebuffer vkCreateFramebuffer = nullptr;
PFN_vkDestroyFramebuffer vkDestroyFramebuffer = nullptr;
PFN_vkCmdBeginRenderPass vkCmdBeginRenderPass = nullptr;
PFN_vkCmdEndRenderPass vkCmdEndRenderPass = nullptr;
PFN_vkCreateShaderModule vkCreateShaderModule = nullptr;
PFN_vkDestroyShaderModule vkDestroyShaderModule = nullptr;
PFN_vkCreatePipelineLayout vkCreatePipelineLayout = nullptr;
PFN_vkDestroyPipelineLayout vkDestroyPipelineLayout = nullptr;
PFN_vkCreateGraphicsPipelines vkCreateGraphicsPipelines = nullptr;
PFN_vkDestroyPipeline vkDestroyPipeline = nullptr;
PFN_vkCmdBindPipeline vkCmdBindPipeline = nullptr;
PFN_vkCmdDraw vkCmdDraw = nullptr;
PFN_vkCreateFence vkCreateFence = nullptr;
PFN_vkDestroyFence vkDestroyFence = nullptr;
PFN_vkQueueSubmit vkQueueSubmit = nullptr;
PFN_vkWaitForFences vkWaitForFences = nullptr;
PFN_vkGetQueryPoolResults vkGetQueryPoolResults = nullptr;
PFN_vkDeviceWaitIdle vkDeviceWaitIdle = nullptr;
};
struct PrimitivesGeneratedNoXfbProbeContext {
VkDevice device = VK_NULL_HANDLE;
VkQueue queue = VK_NULL_HANDLE;
Uint32 queueFamilyIndex = 0;
// The device was created with VK_EXT_transform_feedback, its
// transformFeedback feature, and advertises transformFeedbackQueries.
// Without this the probe has no subject and reports "did not run".
Bool transformFeedbackQueriesUsable = false;
// The device was created with VK_EXT_primitives_generated_query and BOTH
// its primitivesGeneratedQuery and ...WithRasterizerDiscard features;
// gates the dedicated-query control slots.
Bool primitivesGeneratedQueryUsable = false;
// The device was created with the pipelineStatisticsQuery feature; gates
// the statistics control slots. A probe with no control at all can still
// DETECT, but never qualifies a substitute.
Bool pipelineStatisticsEnabled = false;
// The device was created with the tessellationShader feature; gates the
// PATCHES shape.
Bool tessellationEnabled = false;
PrimitivesGeneratedNoXfbProbeFns fns;
};
// Records, submits and reads back the probe. Synchronous: waits on its own
// fence (bounded; on timeout it deliberately leaks its device objects rather
// than idle-wait a possibly hung GPU, mirroring the POST timestamp probe) and
// destroys everything it created. Never touches MG_State or renderer state -
// the caller only lends it a device and an otherwise idle queue.
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context);
// The verdict vocabulary. Pure function of the measurement, split from the
// Vulkan plumbing so a unit test can pin every mapping with synthetic numbers.
enum class PrimitivesGeneratedNoXfbVerdict : Uint8 {
// The probe did not run, or answered something that is neither healthy nor
// the defect (a half-count, a nonzero-but-wrong stream answer). Must never
// arm the reroute and must never be reported as the bug.
Inconclusive,
// Every drawn shape's stream query answered its exact expected count: the
// driver counts XFB-inactive draws and the existing path is correct.
StreamCounts,
// The defect is present (a drawn shape's stream query answered exactly 0)
// and the dedicated primitives-generated query answered every drawn shape
// exactly, the rasterizer-discard shapes included: the substitution is
// proven whole through the query Vulkan defines for exactly this GL
// target.
PrimitivesGeneratedExtSubstitute,
// The defect is present, the dedicated query did not qualify (absent, or
// silent like the stream query), and the statistics control answered EVERY
// drawn shape exactly - discard shapes included: the substitution is
// proven whole through clipping statistics.
StatisticsSubstitute,
// The defect is present and the statistics control is exact on the PLAIN
// shape but not on every drawn shape (llvmpipe's discard short-circuit
// does this to its statistics - its dedicated query is what rescues it to
// the verdict above). This verdict additionally GUARANTEES domination:
// every shape the statistics missed measured exactly 0 through the stream
// query too, so rerouting is never worse per draw - it repairs every
// shape the statistics answer exactly and leaves the rest at the 0 they
// already read. A measurement where the stream was EXACT on a shape the
// statistics missed does not qualify (rerouting would downgrade that
// shape) and falls to Unfixable instead. The shapes the substitute
// misses - the CTS's discarded shapes among them wherever they are the
// missed ones - stay broken, and the report must say which.
StatisticsSubstitutePlainOnly,
// The defect is present and no substitute qualifies: none is exact
// everywhere, and the plain-only fallback either misses the plain shape
// or fails the domination rule above. The honest verdict is the current
// behaviour.
Unfixable,
};
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement);
// Which query pool the renderer routes GL_PRIMITIVES_GENERATED accumulation
// for XFB-inactive draws through.
enum class PrimGenRerouteKind : Uint8 {
None,
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (needs the extension with both
// feature bits - see the context flag).
PrimitivesGeneratedExt,
// VK_QUERY_TYPE_PIPELINE_STATISTICS over clipping invocations (needs
// pipelineStatisticsQuery).
ClippingStatistics,
};
// The arming decision. Pure, so the override mapping is unit-pinnable:
// - ForceOff never reroutes;
// - ForceOn bypasses the verdict but never the structural checks: it takes
// the dedicated query where the device can host it, the statistics pool
// where only that exists, and nothing where neither does;
// - Auto follows the verdict: the dedicated query on
// PrimitivesGeneratedExtSubstitute, the statistics pool on
// StatisticsSubstitute and StatisticsSubstitutePlainOnly (each already
// implies its feature-backed control), and nothing otherwise.
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled);
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,24 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tesc
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Tessellation control stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Every level is 1, so with
// the evaluation stage's triangles domain the tessellator emits exactly one
// triangle per patch - the expected count the probe checks the queries against.
#version 450
layout(vertices = 1) out;
void main() {
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
@@ -0,0 +1,19 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tese
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Tessellation evaluation stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Triangles domain: with the
// control stage's all-1 levels the tessellator emits exactly one triangle per
// patch. Like the vertex stage, it deliberately carries no Xfb execution mode.
#version 450
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
@@ -0,0 +1,22 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.vert
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Vertex stage of the primitives-generated-without-transform-feedback probe
// (PrimitivesGeneratedNoXfbProbe.cpp), used by both triangle shapes (with and
// without rasterizer discard) and as the tessellation shapes' vertex stage.
// Deliberately carries NO Xfb execution mode: the probe's whole subject is what
// the transform-feedback stream query answers for a pipeline that captures
// nothing. Positions are distinct (a full-viewport triangle per three vertices)
// so no driver can excuse the primitive as degenerate before it reaches
// primitive assembly.
#version 450
void main() {
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
gl_Position = vec4(corners[gl_VertexIndex % 3], 0.0, 1.0);
}
@@ -0,0 +1,153 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbeSpv.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Generated from PrimitivesGeneratedNoXfbProbe.{vert,tesc,tese} with:
// glslangValidator --target-env vulkan1.1 -V PrimitivesGeneratedNoXfbProbe.<stage>
// (SPIR-V words dumped little-endian, six per line.)
//
// Regenerate whenever a probe shader changes; nothing else in the probe depends on
// the exact binary. None of the modules carries an Xfb execution mode - that is the
// probe's subject, see PrimitivesGeneratedNoXfbProbe.cpp.
#pragma once
#include <cstddef>
#include <cstdint>
namespace MobileGL::MG_Util::SelfTest {
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeVertSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000002a, 0x00000000, 0x00020011,
0x00000001, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000000,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x0000001a, 0x00030003,
0x00000002, 0x000001c2, 0x00040005, 0x00000004, 0x6e69616d, 0x00000000,
0x00060005, 0x0000000b, 0x505f6c67, 0x65567265, 0x78657472, 0x00000000,
0x00060006, 0x0000000b, 0x00000000, 0x505f6c67, 0x7469736f, 0x006e6f69,
0x00070006, 0x0000000b, 0x00000001, 0x505f6c67, 0x746e696f, 0x657a6953,
0x00000000, 0x00070006, 0x0000000b, 0x00000002, 0x435f6c67, 0x4470696c,
0x61747369, 0x0065636e, 0x00070006, 0x0000000b, 0x00000003, 0x435f6c67,
0x446c6c75, 0x61747369, 0x0065636e, 0x00030005, 0x0000000d, 0x00000000,
0x00060005, 0x0000001a, 0x565f6c67, 0x65747265, 0x646e4978, 0x00007865,
0x00050005, 0x0000001f, 0x65646e69, 0x6c626178, 0x00000065, 0x00030047,
0x0000000b, 0x00000002, 0x00050048, 0x0000000b, 0x00000000, 0x0000000b,
0x00000000, 0x00050048, 0x0000000b, 0x00000001, 0x0000000b, 0x00000001,
0x00050048, 0x0000000b, 0x00000002, 0x0000000b, 0x00000003, 0x00050048,
0x0000000b, 0x00000003, 0x0000000b, 0x00000004, 0x00040047, 0x0000001a,
0x0000000b, 0x0000002a, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040017, 0x00000007,
0x00000006, 0x00000004, 0x00040015, 0x00000008, 0x00000020, 0x00000000,
0x0004002b, 0x00000008, 0x00000009, 0x00000001, 0x0004001c, 0x0000000a,
0x00000006, 0x00000009, 0x0006001e, 0x0000000b, 0x00000007, 0x00000006,
0x0000000a, 0x0000000a, 0x00040020, 0x0000000c, 0x00000003, 0x0000000b,
0x0004003b, 0x0000000c, 0x0000000d, 0x00000003, 0x00040015, 0x0000000e,
0x00000020, 0x00000001, 0x0004002b, 0x0000000e, 0x0000000f, 0x00000000,
0x00040017, 0x00000010, 0x00000006, 0x00000002, 0x0004002b, 0x00000008,
0x00000011, 0x00000003, 0x0004001c, 0x00000012, 0x00000010, 0x00000011,
0x0004002b, 0x00000006, 0x00000013, 0xbf800000, 0x0005002c, 0x00000010,
0x00000014, 0x00000013, 0x00000013, 0x0004002b, 0x00000006, 0x00000015,
0x40400000, 0x0005002c, 0x00000010, 0x00000016, 0x00000015, 0x00000013,
0x0005002c, 0x00000010, 0x00000017, 0x00000013, 0x00000015, 0x0006002c,
0x00000012, 0x00000018, 0x00000014, 0x00000016, 0x00000017, 0x00040020,
0x00000019, 0x00000001, 0x0000000e, 0x0004003b, 0x00000019, 0x0000001a,
0x00000001, 0x0004002b, 0x0000000e, 0x0000001c, 0x00000003, 0x00040020,
0x0000001e, 0x00000007, 0x00000012, 0x00040020, 0x00000020, 0x00000007,
0x00000010, 0x0004002b, 0x00000006, 0x00000023, 0x00000000, 0x0004002b,
0x00000006, 0x00000024, 0x3f800000, 0x00040020, 0x00000028, 0x00000003,
0x00000007, 0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003,
0x000200f8, 0x00000005, 0x0004003b, 0x0000001e, 0x0000001f, 0x00000007,
0x0004003d, 0x0000000e, 0x0000001b, 0x0000001a, 0x0005008b, 0x0000000e,
0x0000001d, 0x0000001b, 0x0000001c, 0x0003003e, 0x0000001f, 0x00000018,
0x00050041, 0x00000020, 0x00000021, 0x0000001f, 0x0000001d, 0x0004003d,
0x00000010, 0x00000022, 0x00000021, 0x00050051, 0x00000006, 0x00000025,
0x00000022, 0x00000000, 0x00050051, 0x00000006, 0x00000026, 0x00000022,
0x00000001, 0x00070050, 0x00000007, 0x00000027, 0x00000025, 0x00000026,
0x00000023, 0x00000024, 0x00050041, 0x00000028, 0x00000029, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000029, 0x00000027, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeVertSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTescSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000001d, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000001,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000b, 0x0000001a, 0x00040010,
0x00000004, 0x0000001a, 0x00000001, 0x00030003, 0x00000002, 0x000001c2,
0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00070005, 0x0000000b,
0x545f6c67, 0x4c737365, 0x6c657665, 0x6574754f, 0x00000072, 0x00070005,
0x0000001a, 0x545f6c67, 0x4c737365, 0x6c657665, 0x656e6e49, 0x00000072,
0x00040047, 0x0000000b, 0x0000000b, 0x0000000b, 0x00030047, 0x0000000b,
0x0000000f, 0x00040047, 0x0000001a, 0x0000000b, 0x0000000c, 0x00030047,
0x0000001a, 0x0000000f, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040015, 0x00000007,
0x00000020, 0x00000000, 0x0004002b, 0x00000007, 0x00000008, 0x00000004,
0x0004001c, 0x00000009, 0x00000006, 0x00000008, 0x00040020, 0x0000000a,
0x00000003, 0x00000009, 0x0004003b, 0x0000000a, 0x0000000b, 0x00000003,
0x00040015, 0x0000000c, 0x00000020, 0x00000001, 0x0004002b, 0x0000000c,
0x0000000d, 0x00000000, 0x0004002b, 0x00000006, 0x0000000e, 0x3f800000,
0x00040020, 0x0000000f, 0x00000003, 0x00000006, 0x0004002b, 0x0000000c,
0x00000011, 0x00000001, 0x0004002b, 0x0000000c, 0x00000013, 0x00000002,
0x0004002b, 0x0000000c, 0x00000015, 0x00000003, 0x0004002b, 0x00000007,
0x00000017, 0x00000002, 0x0004001c, 0x00000018, 0x00000006, 0x00000017,
0x00040020, 0x00000019, 0x00000003, 0x00000018, 0x0004003b, 0x00000019,
0x0000001a, 0x00000003, 0x00050036, 0x00000002, 0x00000004, 0x00000000,
0x00000003, 0x000200f8, 0x00000005, 0x00050041, 0x0000000f, 0x00000010,
0x0000000b, 0x0000000d, 0x0003003e, 0x00000010, 0x0000000e, 0x00050041,
0x0000000f, 0x00000012, 0x0000000b, 0x00000011, 0x0003003e, 0x00000012,
0x0000000e, 0x00050041, 0x0000000f, 0x00000014, 0x0000000b, 0x00000013,
0x0003003e, 0x00000014, 0x0000000e, 0x00050041, 0x0000000f, 0x00000016,
0x0000000b, 0x00000015, 0x0003003e, 0x00000016, 0x0000000e, 0x00050041,
0x0000000f, 0x0000001b, 0x0000001a, 0x0000000d, 0x0003003e, 0x0000001b,
0x0000000e, 0x00050041, 0x0000000f, 0x0000001c, 0x0000001a, 0x00000011,
0x0003003e, 0x0000001c, 0x0000000e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTescSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTeseSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x00000021, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000002,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x00000012, 0x00030010,
0x00000004, 0x00000016, 0x00030010, 0x00000004, 0x00000001, 0x00030010,
0x00000004, 0x00000004, 0x00030003, 0x00000002, 0x000001c2, 0x00040005,
0x00000004, 0x6e69616d, 0x00000000, 0x00060005, 0x0000000b, 0x505f6c67,
0x65567265, 0x78657472, 0x00000000, 0x00060006, 0x0000000b, 0x00000000,
0x505f6c67, 0x7469736f, 0x006e6f69, 0x00070006, 0x0000000b, 0x00000001,
0x505f6c67, 0x746e696f, 0x657a6953, 0x00000000, 0x00070006, 0x0000000b,
0x00000002, 0x435f6c67, 0x4470696c, 0x61747369, 0x0065636e, 0x00070006,
0x0000000b, 0x00000003, 0x435f6c67, 0x446c6c75, 0x61747369, 0x0065636e,
0x00030005, 0x0000000d, 0x00000000, 0x00060005, 0x00000012, 0x545f6c67,
0x43737365, 0x64726f6f, 0x00000000, 0x00030047, 0x0000000b, 0x00000002,
0x00050048, 0x0000000b, 0x00000000, 0x0000000b, 0x00000000, 0x00050048,
0x0000000b, 0x00000001, 0x0000000b, 0x00000001, 0x00050048, 0x0000000b,
0x00000002, 0x0000000b, 0x00000003, 0x00050048, 0x0000000b, 0x00000003,
0x0000000b, 0x00000004, 0x00040047, 0x00000012, 0x0000000b, 0x0000000d,
0x00020013, 0x00000002, 0x00030021, 0x00000003, 0x00000002, 0x00030016,
0x00000006, 0x00000020, 0x00040017, 0x00000007, 0x00000006, 0x00000004,
0x00040015, 0x00000008, 0x00000020, 0x00000000, 0x0004002b, 0x00000008,
0x00000009, 0x00000001, 0x0004001c, 0x0000000a, 0x00000006, 0x00000009,
0x0006001e, 0x0000000b, 0x00000007, 0x00000006, 0x0000000a, 0x0000000a,
0x00040020, 0x0000000c, 0x00000003, 0x0000000b, 0x0004003b, 0x0000000c,
0x0000000d, 0x00000003, 0x00040015, 0x0000000e, 0x00000020, 0x00000001,
0x0004002b, 0x0000000e, 0x0000000f, 0x00000000, 0x00040017, 0x00000010,
0x00000006, 0x00000003, 0x00040020, 0x00000011, 0x00000001, 0x00000010,
0x0004003b, 0x00000011, 0x00000012, 0x00000001, 0x00040017, 0x00000013,
0x00000006, 0x00000002, 0x0004002b, 0x00000006, 0x00000016, 0x40000000,
0x0004002b, 0x00000006, 0x00000018, 0x3f800000, 0x0004002b, 0x00000006,
0x0000001b, 0x00000000, 0x00040020, 0x0000001f, 0x00000003, 0x00000007,
0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8,
0x00000005, 0x0004003d, 0x00000010, 0x00000014, 0x00000012, 0x0007004f,
0x00000013, 0x00000015, 0x00000014, 0x00000014, 0x00000000, 0x00000001,
0x0005008e, 0x00000013, 0x00000017, 0x00000015, 0x00000016, 0x00050050,
0x00000013, 0x00000019, 0x00000018, 0x00000018, 0x00050083, 0x00000013,
0x0000001a, 0x00000017, 0x00000019, 0x00050051, 0x00000006, 0x0000001c,
0x0000001a, 0x00000000, 0x00050051, 0x00000006, 0x0000001d, 0x0000001a,
0x00000001, 0x00070050, 0x00000007, 0x0000001e, 0x0000001c, 0x0000001d,
0x0000001b, 0x00000018, 0x00050041, 0x0000001f, 0x00000020, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000020, 0x0000001e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv[0]);
} // namespace MobileGL::MG_Util::SelfTest
@@ -154,6 +154,20 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// demoted module is the one that works everywhere, so it is what a standalone compile
// (an internal shader object, a unit test) gets.
Bool ConsumesFloat64Natively() const { return HasBackend() && params.SupportsShaderFloat64; }
// Whether the tessellation / geometry gl_PointSize demotion is ARMED for this env -
// i.e. the backend declared it cannot host the capability. Deliberately requiring a
// backend, opposite in shape to ConsumesFloat64Natively's fallback but for the same
// conservatism: the fp64 demotion is the module that works everywhere, while this
// one rewrites interfaces and capture names, so the no-backend answer (standalone
// compiles, unit tests) is the untouched module. Like nativeFloat64, each bit is L1
// key material of its own (SpirvTranslationKeyInputs), never part of the frontend
// fingerprint: glslang produces the same thing either way.
Bool DemotesTessellationPointSize() const {
return HasBackend() && !params.SupportsTessellationPointSize;
}
Bool DemotesGeometryPointSize() const {
return HasBackend() && !params.SupportsGeometryPointSize;
}
// Matches the historical rule exactly: with no active backend every extension counts
// as advertised, because the frontend then has nothing to gate against.
Bool IsExtensionAdvertised(GLExtension extension) const {
@@ -45,12 +45,14 @@
#include "SpirvPasses/ClampMultisampleFetchPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripIoBlockLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeResourceArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "SpirvPasses/DemotePointSizePass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -758,6 +760,383 @@ namespace MobileGL {
return false;
}
Bool ShaderCompiler::ModuleDeclaresTransformFeedback(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresTransformFeedback"),
spirv.data(), spirv.size());
if (!context) {
// Unparseable is not a capture verdict; say no, which makes the caller decline
// the span rather than issue transform-feedback commands against it.
return false;
}
// The exact question VUID-vkCmdBeginTransformFeedbackEXT-None-04128 asks of the
// bound pipeline's last pre-rasterization stage: was it declared with the Xfb
// execution mode. Reading the execution modes rather than the TransformFeedback
// capability because the capability can legally be declared by a module that has
// no Xfb entry point, and the VUID is about the mode.
for (const spvtools::opt::Instruction& mode : context->module()->execution_modes()) {
if (mode.NumInOperands() >= 2 &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::Xfb) {
return true;
}
}
return false;
}
Bool ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1,
MakeSpirvMessageConsumer("ModuleDeclaresTessellationOrGeometryPointSize"), spirv.data(),
spirv.size());
if (!context) {
// Unparseable is not a verdict about point size. Say no, so the caller keeps
// building the program: the module is already broken for other reasons and
// the diagnostics that own that failure are better placed than this one.
return false;
}
// The CAPABILITY, not the BuiltIn decoration, because the capability is exactly
// what the feature gates: a module may declare gl_PerVertex with a PointSize
// member and never access it, and glslang then emits no capability
// (GlslangToSpv defers it to actual use) - such a module is legal without the
// feature and must not be declined.
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared = static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize ||
declared == spv::Capability::GeometryPointSize) {
return true;
}
}
return false;
}
namespace {
namespace opt_analysis = spvtools::opt::analysis;
// Locations one value of `type` consumes (GL 4.6 core 11.1.2.1). Unknown
// shapes OVERESTIMATE (4) rather than fail: this feeds the free-location
// choice for the demoted point-size carrier, where an overestimate wastes a
// couple of slots and an underestimate aliases a live varying.
Uint32 ConservativeLocationSpan(const opt_analysis::Type* type) {
constexpr Uint32 kUnknownSpan = 4;
if (type == nullptr) return kUnknownSpan;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
type->AsBool() != nullptr) {
return 1u;
}
if (const auto* vector = type->AsVector()) {
// 64-bit INTEGER elements count exactly like 64-bit floats:
// ARB_gpu_shader_int64 extends 11.1.2.1's double-precision rule
// verbatim to i64/u64, and DirectVulkan advertises that extension
// unconditionally - so answering "one location" for an i64vec4 would
// place the carrier on the SECOND location that varying already owns,
// which is the underestimate this function's header forbids.
const auto* element = vector->element_type();
const auto* elementFloat = element->AsFloat();
const auto* elementInteger = element->AsInteger();
const Bool is64Bit = (elementFloat != nullptr && elementFloat->width() == 64) ||
(elementInteger != nullptr && elementInteger->width() == 64);
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
return ConservativeLocationSpan(matrix->element_type()) * matrix->element_count();
}
if (const auto* array = type->AsArray()) {
const auto& lengthWords = array->length_info().words;
if (lengthWords.size() != 2 ||
lengthWords[0] !=
static_cast<Uint32>(opt_analysis::Array::LengthInfo::kConstant)) {
return kUnknownSpan;
}
return ConservativeLocationSpan(array->element_type()) * std::max(lengthWords[1], 1u);
}
if (const auto* strct = type->AsStruct()) {
Uint32 sum = 0;
for (const auto* member : strct->element_types()) {
sum += ConservativeLocationSpan(member);
}
return std::max(sum, 1u);
}
return kUnknownSpan;
}
// One BuildModule per module answers all three questions the program-scoped
// demotion driver asks: which point-size capability the module declares, and
// one past the highest Input/Output location slot it consumes (so the carrier
// can be placed beyond every varying of every stage).
struct PointSizeModuleProbe {
Bool parsed = false;
Bool declaresTessellationPointSize = false;
Bool declaresGeometryPointSize = false;
Uint32 locationSlotEnd = 0;
};
PointSizeModuleProbe ProbePointSizeModule(const Vector<Uint32>& spirv) {
PointSizeModuleProbe probe;
if (spirv.empty()) {
probe.parsed = true; // an absent stage constrains nothing
return probe;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ProbePointSizeModule"), spirv.data(),
spirv.size());
if (!context) return probe;
probe.parsed = true;
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize) {
probe.declaresTessellationPointSize = true;
} else if (declared == spv::Capability::GeometryPointSize) {
probe.declaresGeometryPointSize = true;
}
}
spv::ExecutionModel model = spv::ExecutionModel::Max;
for (spvtools::opt::Instruction& entryPoint : context->module()->entry_points()) {
model = static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0));
break;
}
// Per-vertex interfaces are arrayed one level deeper than the locations
// they consume; peel that level, but never off a per-patch output.
const Bool peelInputs = model == spv::ExecutionModel::TessellationControl ||
model == spv::ExecutionModel::TessellationEvaluation ||
model == spv::ExecutionModel::Geometry;
const Bool peelOutputs = model == spv::ExecutionModel::TessellationControl;
std::unordered_set<Uint32> patchDecorated;
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Patch) {
patchDecorated.insert(annotation.GetSingleWordInOperand(0));
}
}
auto* defUse = context->get_def_use_mgr();
auto* typeMgr = context->get_type_mgr();
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Location) {
const Uint32 location = annotation.GetSingleWordInOperand(2);
Uint32 span = 1;
spvtools::opt::Instruction* var =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
const auto storage =
static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
// Two location namespaces are NOT varying slots and must not
// shrink the carrier budget: vertex-stage inputs (attribute
// locations) and fragment-stage outputs (draw buffers).
if ((model == spv::ExecutionModel::Vertex &&
storage == spv::StorageClass::Input) ||
(model == spv::ExecutionModel::Fragment &&
storage == spv::StorageClass::Output)) {
continue;
}
spvtools::opt::Instruction* pointerType = defUse->GetDef(var->type_id());
if (pointerType != nullptr &&
pointerType->opcode() == spv::Op::OpTypePointer) {
const opt_analysis::Type* pointee =
typeMgr->GetType(pointerType->GetSingleWordInOperand(1));
const Bool peel =
((storage == spv::StorageClass::Input && peelInputs) ||
(storage == spv::StorageClass::Output && peelOutputs)) &&
patchDecorated.count(var->result_id()) == 0;
if (peel && pointee != nullptr && pointee->AsArray() != nullptr) {
pointee = pointee->AsArray()->element_type();
}
span = ConservativeLocationSpan(pointee);
}
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
} else if (annotation.opcode() == spv::Op::OpMemberDecorate &&
annotation.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::Location) {
const Uint32 member = annotation.GetSingleWordInOperand(1);
const Uint32 location = annotation.GetSingleWordInOperand(3);
Uint32 span = 1;
spvtools::opt::Instruction* structType =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (structType != nullptr && structType->opcode() == spv::Op::OpTypeStruct &&
member < structType->NumInOperands()) {
span = ConservativeLocationSpan(
typeMgr->GetType(structType->GetSingleWordInOperand(member)));
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
}
}
return probe;
}
// Interior boundary carrier names, spelled by the PRODUCING stage so both
// sides of one boundary agree textually as well as by location. The capture
// stage's output uses POINT_SIZE_CAPTURE_CARRIER_NAME instead. None of these
// may embed the token "gl_PointSize" - see the constant's comment.
const char* PointSizeBoundaryCarrierName(const GLenum producerStage) {
switch (producerStage) {
case GL_VERTEX_SHADER:
return "mg_PointSizeIo0";
case GL_TESS_CONTROL_SHADER:
return "mg_PointSizeIo1";
case GL_TESS_EVALUATION_SHADER:
return "mg_PointSizeIo2";
default:
return "mg_PointSizeIo0";
}
}
// Past this the carrier would sit above what a minimum-spec varying budget can
// address; such a program keeps its honest decline instead.
constexpr Uint32 kMaxDemotedPointSizeCarrierLocation = 30;
} // namespace
Bool ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
const Bool demoteTessellation, const Bool demoteGeometry,
const Bool captureRequestsPointSize, PointSizeDemotionOutcome& outcome,
const bool validateOutput, const bool enableSpirvValidation) {
outcome = {};
if (!demoteTessellation && !demoteGeometry) return true;
// The pre-rasterization chain, in pipeline order, as indices into `modules`.
Int stageIndex[4] = {-1, -1, -1, -1}; // VS, TCS, TES, GS
for (SizeT i = 0; i < shaderTypes.size() && i < modules.size(); ++i) {
switch (shaderTypes[i]) {
case GL_VERTEX_SHADER: stageIndex[0] = static_cast<Int>(i); break;
case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast<Int>(i); break;
case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast<Int>(i); break;
case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast<Int>(i); break;
default: break;
}
}
if (stageIndex[1] < 0 && stageIndex[2] < 0 && stageIndex[3] < 0) return true;
// One probe per module: the capability facts arm the verdict, the location
// scan places the carrier past every varying of every stage (the location is
// shared program-wide, so it has to clear all of them at once).
Bool anyTessellationUse = false;
Bool anyGeometryUse = false;
Uint32 carrierLocation = 0;
for (const auto& module : modules) {
const PointSizeModuleProbe probe = ProbePointSizeModule(module);
if (!probe.parsed) {
// Unparseable is not a verdict; the module is already broken for
// other reasons and owns its own failure.
return true;
}
anyTessellationUse |= probe.declaresTessellationPointSize;
anyGeometryUse |= probe.declaresGeometryPointSize;
carrierLocation = std::max(carrierLocation, probe.locationSlotEnd);
}
if (!((anyTessellationUse && demoteTessellation) ||
(anyGeometryUse && demoteGeometry))) {
return true;
}
if (carrierLocation > kMaxDemotedPointSizeCarrierLocation) {
outcome.declineDetail = std::format(
"the program's varyings already reach location {}, past the carrier budget",
carrierLocation);
return true;
}
// GL 4.6 core 13.3: capture reads the last capture-capable stage - geometry,
// else evaluation, else the vertex stage (whose built-in needs no demotion).
const Int captureStage = stageIndex[3] >= 0 ? 3 : (stageIndex[2] >= 0 ? 2 : -1);
constexpr GLenum kStageEnum[4] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER};
// Back to front, so each stage's "I now read the carrier" report can force the
// producing stage's output carrier into existence - Vulkan requires every
// consumed input to be produced (VUID-RuntimeSpirv-OpEntryPoint-08743), and an
// ES link may reject a statically read input with no producing output.
Vector<Vector<Uint32>> rewritten(modules.size());
Bool rewrote[4] = {false, false, false, false};
Bool forceOutput[4] = {false, false, false, false};
if (captureStage >= 0 && captureRequestsPointSize) {
forceOutput[captureStage] = true;
}
for (Int stage = 3; stage >= 0; --stage) {
const Int moduleIndex = stageIndex[stage];
if (moduleIndex < 0) continue;
Int producer = stage - 1;
while (producer >= 0 && stageIndex[producer] < 0) --producer;
DemotePointSizeOptions options;
options.location = carrierLocation;
options.inputCarrierName = PointSizeBoundaryCarrierName(
producer >= 0 ? kStageEnum[producer]
// A separable program whose first present stage already
// consumes the carrier: the producer lives in another
// program. Name by the conventional producer of this
// stage's boundary; matching across programs is by
// location and is documented residue either way.
: kStageEnum[stage > 0 ? stage - 1 : 0]);
options.outputCarrierName = stage == captureStage
? String(POINT_SIZE_CAPTURE_CARRIER_NAME)
: String(PointSizeBoundaryCarrierName(kStageEnum[stage]));
options.forceOutputCarrier = forceOutput[stage];
// A vertex stage with nothing downstream consuming the carrier needs no
// mirror and stays byte-identical without an optimizer round trip.
if (stage == 0 && !options.forceOutputCarrier) continue;
DemotePointSizeReport report;
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
DemotePointSizePass::CreateDemotePointSizePass(options, &report));
if (!RunOptimizerChecked("DemoteTessellationGeometryPointSizeForProgram", optimizer,
modules[moduleIndex], rewritten[moduleIndex],
validateOutput, enableSpirvValidation)) {
return false; // modules untouched: nothing was committed
}
if (report.declined) {
outcome.declineDetail = Move(report.declineReason);
return true; // byte-identical decline; the existing refusals stay armed
}
// AN EVALUATION STAGE WITH NO CONTROL STAGE THAT NOW READS A LOCATED
// INPUT. GL lets the evaluation stage sit straight on the vertex stage,
// and both backends stand a SYNTHESIZED pass-through control stage in
// between - one that forwards gl_Position and nothing else. Their guard
// for that is literally "does this module read a located input"
// (ModuleReadsLocatedInput / ReflectPassthroughTessControlNeed), so the
// carrier this pass just created would turn the very program the demotion
// exists to rescue into a declined one, reported against a varying name
// the application never wrote. Declining here keeps the modules
// byte-identical and leaves the honest built-in refusal in charge; only
// teaching the synthesized stage to forward the carrier could do better.
if (stage == 2 && stageIndex[1] < 0 && report.createdInputCarrier) {
outcome.declineDetail =
"an evaluation stage reads gl_in point size with no control stage to "
"carry it; the synthesized pass-through cannot forward the carrier";
return true;
}
rewrote[stage] = true;
if (report.createdInputCarrier && producer >= 0) {
forceOutput[producer] = true;
}
}
// Atomic commit: every stage rewritten together or none at all.
for (Int stage = 0; stage < 4; ++stage) {
if (!rewrote[stage]) continue;
modules[stageIndex[stage]] = Move(rewritten[stageIndex[stage]]);
}
outcome.demoted = true;
return true;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -1170,6 +1549,31 @@ namespace MobileGL {
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
const bool stripInputBlocks,
const bool stripOutputBlocks,
bool& strippedAny,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
strippedAny = false;
if (!stripInputBlocks && !stripOutputBlocks) return false;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripIoBlockLocationsPass::CreateStripIoBlockLocationsPass(
stripInputBlocks, stripOutputBlocks, &strippedAny));
// NOT VALIDATED, and that is the point of the pass rather than an oversight.
// Vulkan SPIR-V requires a Location on every user-defined Input/Output variable
// ([VUID-StandaloneSpirv-Location-04915]), so a module whose interface blocks
// have deliberately lost theirs fails spirv-val by construction. It never
// reaches a driver as SPIR-V: the caller runs this last in the DirectGLES chain
// and hands the result straight to SPIRV-Cross, which needs no location to
// print a block. Validating here would latch a failure on every affected
// program and teach the counter to cry wolf.
return RunOptimizerChecked("StripIoBlockLocationsForEssl", optimizer, inputBinary,
outputBinary, false, enableSpirvValidation);
}
bool ShaderCompiler::PackDoubleVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -154,6 +154,20 @@ namespace MobileGL {
std::set<String>& renamedBlockNames,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops the Location (and Component) decoration from inter-stage interface
// BLOCK variables, so SPIRV-Cross emits them unqualified and ES matches them
// by block name plus member sequence. `stripInputBlocks` covers the blocks
// this stage consumes and `stripOutputBlocks` the ones it produces - armed
// separately because an interface whose other end is in a DIFFERENT program
// must keep the location that matches it there. `strippedAny` reports whether
// this stage actually had one. The Mali ES driver loses the payload of a
// located block across any tessellation or geometry boundary; only for the
// DirectGLES transpile path, and only when the driver POST says so. See
// StripIoBlockLocationsPass.
static bool StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
bool stripInputBlocks, bool stripOutputBlocks,
bool& strippedAny, Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops RelaxedPrecision member decorations from uniform-block structs so
// SPIRV-Cross prints the same (highp) member precision in every stage; ES
// drivers reject cross-stage uniform blocks whose member precisions differ.
@@ -532,6 +546,76 @@ namespace MobileGL {
// check exists so that failure can be reported as the missing capability it is,
// naming the shader, rather than as a driver info log nobody sees.
static Bool ModuleDeclaresBufferTextureSampler(const Vector<Uint32>& spirv);
// Does this module carry the Xfb execution mode - i.e. would a
// vkCmdBeginTransformFeedbackEXT against a pipeline whose last pre-rasterization
// stage is this module satisfy VUID-vkCmdBeginTransformFeedbackEXT-None-04128?
// Asked of the FINAL bytes, so it answers for whatever the backend transform
// chain actually produced rather than for what it was asked to produce.
static Bool ModuleDeclaresTransformFeedback(const Vector<Uint32>& spirv);
// Does this module declare TessellationPointSize or GeometryPointSize - i.e. does
// it need VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize before
// a pipeline built from it is legal usage (VUID-RuntimeSpirv-PointSize-06439)?
// glslang emits either capability from any access to the PointSize built-in in a
// tessellation or geometry stage, which desktop GL treats as an ordinary
// per-vertex output, so a program that is perfectly legal in GL can need a Vulkan
// feature the device does not have. Callers only ask when the feature is OFF, so
// the module parse costs nothing on a device that has it.
static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv);
// ---- gl_PointSize demotion for devices without the capability above ----
// The name of the demoted program's LAST capture-capable stage's point-size
// carrier. It is the contract three parties meet at: the demotion pass names
// the variable, DirectVulkan's XfbCaptureDecoratePass binds a "gl_PointSize"
// capture to it instead of mirroring the (no longer accessed) built-in, and
// DirectGLES respells the driver-side glTransformFeedbackVaryings request
// with it. Deliberately NOT containing the substring "gl_PointSize":
// DirectGLES's extension-request gate is a text search for that token over
// the emitted ESSL, and a carrier name embedding it would re-arm the decline
// this demotion exists to retire.
static constexpr const char* POINT_SIZE_CAPTURE_CARRIER_NAME = "mg_PointSizeCapture";
// What the program-scoped demotion left behind. `demoted` false with an empty
// detail means the program never needed it (no tessellation/geometry stage
// accesses the built-in, or the device hosts it); false WITH a detail means a
// module shape the pass cannot express - the modules are byte-identical and
// the existing decline paths (Espryt's missing-extension compile failure,
// Magma's pointSizeCapabilityUnsupported refusal) stay in charge of it.
struct PointSizeDemotionOutcome {
Bool demoted = false;
String declineDetail;
};
// Demotes gl_PointSize across a WHOLE program's pre-rasterization chain into
// ordinary float varyings at one shared free location, so a device that
// advertises neither ES tessellation/geometry_point_size extension nor
// Vulkan's shaderTessellationAndGeometryPointSize can still run programs
// whose tessellation/geometry stages merely CARRY the value (transform
// feedback and gl_in[].gl_PointSize reads). Runs after
// SanitizeAndOptimizeBinary, on the final shared modules both backends
// consume, and is atomic per program: every stage is rewritten or none is,
// because a consumer whose producer kept the built-in would read garbage.
// `demoteTessellation` / `demoteGeometry` are the env verdicts (the device
// LACKS that capability); the per-program half of the decision - whether any
// module actually declares TessellationPointSize / GeometryPointSize - is
// probed here. `captureRequestsPointSize` forces the capture-capable last
// stage to declare its carrier even when it never writes the built-in, so a
// by-name capture always has something to bind to. Returns false only when
// the optimizer itself failed (modules untouched); a shape decline is
// reported through `outcome` and also leaves the modules untouched. See
// DemotePointSizePass for the per-module rewrite and its honest residue.
//
// Two declines are PROGRAM-shaped and therefore live here rather than in the
// pass: a carrier that would land past the minimum-spec varying budget, and
// an evaluation stage reading gl_in point size with NO control stage - the
// synthesized pass-through control stage both backends stand in that gap
// forwards gl_Position alone, so the input carrier would strand the value and
// trip the backends' own "reads a located input" refusal against a name the
// application never wrote.
static Bool DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
Bool demoteTessellation, Bool demoteGeometry, Bool captureRequestsPointSize,
PointSizeDemotionOutcome& outcome, bool validateOutput = true,
bool enableSpirvValidation = false);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the

Some files were not shown because too many files have changed in this diff Show More