diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index e0e0b26e..4a231834 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -99,6 +99,47 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + // GL ignores Layer/Layered for a target with no layers. In particular image1D stays a 1D + // view, and an ignored nonzero Layer must not address another slice. + static Bool WireTargetHasLayers(MG_Pipe::MGPipeResourceTarget target) { + using T = MG_Pipe::MGPipeResourceTarget; + return target == T::Tex1DArray || target == T::Tex2DArray || target == T::Tex2DMSArray || + target == T::Tex3D || target == T::TexCube || target == T::TexCubeArray; + } + + // GL 4.6 core 8.26: an access through an image unit whose texture does not HAVE the + // (level, layer) the unit names is INVALID. A load returns zero, a store does nothing, an + // atomic updates nothing - and none of it is an error, neither at bind time (the bind only + // checks the level's and the layer's signs) nor at the draw. It is the observable an empty + // unit has, so the answer is the empty unit's: the storage placeholder, cleared before each + // use, of the dimensionality the SHADER declared (ResolveWirePlaceholderImage). + // + // KHR-GL46.shader_image_load_store.incomplete_textures is the shape that found it: level 2 + // of a texture that defines only level 0 (MAX_LEVEL 7, a mipmapping filter). The same + // question comes up empty for a level past an immutable texture's storage, a layer past an + // array's last slice, a texture that has no storage at all, and a level past a texture + // view's own window even where its owner has one. The record carries exactly what + // glBindImageTexture was given, so it is decided here, by the walk every wire consumer uses + // to find a texel's storage, and from that walk's "not there" answer ONLY: a null for any + // other reason (a dead record, a stale view CSO) stays the descriptor resolve's refusal. + // + // NOT DECIDED HERE, deliberately: a level that IS in storage but lies outside + // [BASE_LEVEL, MAX_LEVEL], or of a texture that is not mipmap-complete. The monolith arm + // binds those as it finds them too, and the P7 gate 5 comparison is against that arm. + static Bool WireShaderImageNamesNoTexel(VkTextureManager& textures, const MG_Pipe::MGPImageView& image) { + const auto& state = MG_Pipe::MGPipeApplier(); + if (MG_Pipe::MGPipeHandleIsNull(image.Res) || image.Res.Slot >= state.TextureResources.size()) return false; + const auto& record = state.TextureResources[image.Res.Slot]; + if (!record.Live || record.Gen != image.Res.Gen) return false; + const Bool selectsLayer = + WireTargetHasLayers(static_cast(record.Desc.Target)) && !image.Layered; + Uint32 level = image.Level; + Uint32 layer = selectsLayer ? image.Layer : 0; + Bool outsideWindow = false; + (void)textures.ResolveWireTextureStorage(image.Res, level, layer, nullptr, nullptr, &outsideWindow); + return outsideWindow; + } + static VkComponentSwizzle WireSwizzle(Uint8 value) { switch (static_cast(value)) { case TextureSwizzleParam::Red: return VK_COMPONENT_SWIZZLE_R; @@ -129,18 +170,21 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (handle.Slot >= state.TextureResources.size()) WireDescriptorFatal("image-record"); const auto& record = state.TextureResources[handle.Slot]; if (!record.Live || record.Gen != handle.Gen) WireDescriptorFatal("image-record-generation"); + // Before the sync: a texture with no storage at all is one of the shapes, and syncing + // it would refuse rather than answer. + if (storage && WireShaderImageNamesNoTexel(*m_textureManager, state.BoundShaderImages[unit])) { + const VkFormat bound = MG_Util::ConvertTextureInternalFormatToVkEnum( + MG_Util::ConvertGLEnumToTextureInternalFormat(state.BoundShaderImages[unit].InternalFormat)); + return ResolveWirePlaceholderImage(commandBuffer, program, programObj, binding, storage, out, bound); + } auto* resource = m_textureManager->SyncTextureResourceByHandle(handle, false, storage); if (!resource) WireDescriptorFatal("image-resource"); m_textureManager->FlushPendingUploads(); const auto target = static_cast(record.Desc.Target); - using Target = MG_Pipe::MGPipeResourceTarget; - const Bool layerable = target == Target::Tex1DArray || target == Target::Tex2DArray || - target == Target::Tex2DMSArray || target == Target::Tex3D || - target == Target::TexCube || target == Target::TexCubeArray; + const Bool layerable = WireTargetHasLayers(target); Uint32 level = storage ? state.BoundShaderImages[unit].Level : record.Params.BaseLevel; - // GL ignores Layer/Layered for non-layerable targets. In particular image1D - // stays a 1D view, and an ignored nonzero Layer must not address another slice. + // GL ignores Layer/Layered for non-layerable targets (WireTargetHasLayers). Uint32 layer = storage && layerable && !state.BoundShaderImages[unit].Layered ? state.BoundShaderImages[unit].Layer : 0; const Uint32 localLevel = level; @@ -3014,6 +3058,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Null is the wire's unbound/incomplete binding. Its native // placeholder needs no resource upload or frontend allocation. if (MG_Pipe::MGPipeHandleIsNull(handle)) continue; + // Nor does an image unit naming a (level, layer) its texture lacks: the + // descriptor resolve binds that same placeholder (GL 4.6 core 8.26), and + // preparing the texture would decline a storage-less one and lose the pass. + if (storage && WireShaderImageNamesNoTexel(*m_textureManager, state.BoundShaderImages[unit])) + continue; if (!m_textureManager->SyncTextureResourceByHandle(handle, false, storage)) return false; } } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index 05beb507..275a3664 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -211,9 +211,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 layers = 1; }; mutable UnorderedMap m_wirePlaceholderImages; + // boundStorageFormat: for a storage binding with no reflected format, the format the + // unit's glBindImageTexture named, when the unit holds a texture it cannot address + // (GL 4.6 core 8.26); UNDEFINED keeps the numeric-domain R32 default. Bool ResolveWirePlaceholderImage(VkCommandBuffer commandBuffer, const MagmaProgramSource& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Bool storage, - VkDescriptorImageInfo& out) const; + VkDescriptorImageInfo& out, VkFormat boundStorageFormat = VK_FORMAT_UNDEFINED) const; void DestroyWirePlaceholderImage(WirePlaceholderImage& image) const; #endif diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index b0c44aa1..415c918a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -2677,9 +2677,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_Pipe::MGPipeHandle VkTextureManager::ResolveWireTextureStorage( MG_Pipe::MGPipeHandle handle, Uint32& level, Uint32& layer, - VkFormat* viewFormat, Uint32* layerCount) { + VkFormat* viewFormat, Uint32* layerCount, Bool* outsideWindow) { const auto& state = MG_Pipe::MGPipeApplier(); if (viewFormat) *viewFormat = VK_FORMAT_UNDEFINED; + if (outsideWindow) *outsideWindow = false; + const auto outside = [outsideWindow] { + if (outsideWindow) *outsideWindow = true; + return MG_Pipe::kMGPipeNullHandle; + }; Uint32 mappedLevel = level, mappedLayer = layer; Uint32 remainingLayers = ~Uint32{0}; VkFormat outerFormat = VK_FORMAT_UNDEFINED; @@ -2691,12 +2696,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const auto& record = state.TextureResources[handle.Slot]; if (!record.Live || record.Gen != handle.Gen) return MG_Pipe::kMGPipeNullHandle; if (MG_Pipe::MGPipeHandleIsNull(record.Desc.ViewOf)) { - if (mappedLevel >= record.Desc.Levels) return MG_Pipe::kMGPipeNullHandle; + // Levels == 0 is a texture with no storage at all: every level is outside it. + if (mappedLevel >= record.Desc.Levels) return outside(); const Uint32 layers = record.Desc.Target == static_cast(MG_Pipe::MGPipeResourceTarget::Tex3D) ? std::max(record.Desc.Depth >> mappedLevel, 1u) : std::max(record.Desc.ArrayLayers, 1u); - if (mappedLayer >= layers) return MG_Pipe::kMGPipeNullHandle; + if (mappedLayer >= layers) return outside(); level = mappedLevel; layer = mappedLayer; if (viewFormat) *viewFormat = outerFormat; @@ -2708,9 +2714,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { return MG_Pipe::kMGPipeNullHandle; const auto& viewRecord = state.SamplerViewCsos[viewHandle.Slot]; const auto& view = viewRecord.View; - if (!viewRecord.Live || viewRecord.Gen != viewHandle.Gen || view.Texture != handle || - mappedLevel >= view.NumLevels || mappedLayer >= view.NumLayers) + if (!viewRecord.Live || viewRecord.Gen != viewHandle.Gen || view.Texture != handle) return MG_Pipe::kMGPipeNullHandle; + if (mappedLevel >= view.NumLevels || mappedLayer >= view.NumLayers) return outside(); if (outerFormat == VK_FORMAT_UNDEFINED) { outerFormat = ResolveTextureFormatInfo(static_cast(view.InternalFormat)).format; if (outerFormat == VK_FORMAT_UNDEFINED) return MG_Pipe::kMGPipeNullHandle; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h index 064d547d..0bb3c147 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h @@ -566,8 +566,13 @@ public: // Map a texture/view-relative subresource to its live storage owner. layerCount, when // supplied, receives the number of accessible layers starting at the input layer. // viewFormat is UNDEFINED for an ordinary texture, or the outer view's format. + // outsideWindow, when supplied, says WHY a null came back: true only when every record on + // the way was live and consistent and the (level, layer) simply is not there - past the + // owner's levels or layers, or past a view's own window. A dead record, a missing or stale + // view CSO and an unresolvable view format leave it false. MG_Pipe::MGPipeHandle ResolveWireTextureStorage(MG_Pipe::MGPipeHandle handle, Uint32& level, - Uint32& layer, VkFormat* viewFormat = nullptr, Uint32* layerCount = nullptr); + Uint32& layer, VkFormat* viewFormat = nullptr, Uint32* layerCount = nullptr, + Bool* outsideWindow = nullptr); // Grows the live image of a handle-keyed resource to `requiredMipLevels`, carrying the // existing levels' content across with an in-command-buffer copy. The caller // (GenerateMipmap's record arm) has flushed every pending submission first, so the old diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WirePlaceholderImages.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WirePlaceholderImages.inc index 65122edf..397d9b58 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WirePlaceholderImages.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WirePlaceholderImages.inc @@ -15,7 +15,7 @@ Bool UniformManager::ResolveWirePlaceholderImage(VkCommandBuffer commands, const MagmaProgramSource& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Bool storage, - VkDescriptorImageInfo& out) const { + VkDescriptorImageInfo& out, VkFormat boundStorageFormat) const { if (binding >= programObj.samplerTextureTargetByBinding.size() || binding >= programObj.samplerNumericDomainByBinding.size()) return false; const auto target = programObj.samplerTextureTargetByBinding[binding]; @@ -36,6 +36,20 @@ if (storage) { if (binding >= programObj.storageImageFormatByBinding.size()) return false; format = programObj.storageImageFormatByBinding[binding]; + // A formatless declaration of an INVALID binding (a unit whose texture lacks the + // named level or layer) takes the format the application bound, where the device can + // store to it and it is in the shader's numeric class. The R32 default expands a load + // to (0, 0, 0, 1); a four-channel carrier cleared to zero reads (0, 0, 0, 0), which + // is what KHR-GL46.shader_image_load_store.incomplete_textures compares against. + if (format == VK_FORMAT_UNDEFINED && boundStorageFormat != VK_FORMAT_UNDEFINED) { + const Bool classMatches = domain == SamplerNumericDomain::Float + ? !vkuFormatIsUINT(boundStorageFormat) && !vkuFormatIsSINT(boundStorageFormat) + : domain == SamplerNumericDomain::SignedInteger ? vkuFormatIsSINT(boundStorageFormat) + : domain == SamplerNumericDomain::UnsignedInteger && vkuFormatIsUINT(boundStorageFormat); + VkFormatProperties properties{}; + if (classMatches) vkGetPhysicalDeviceFormatProperties(m_physicalDevice, boundStorageFormat, &properties); + if (properties.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT) format = boundStorageFormat; + } if (format == VK_FORMAT_UNDEFINED) format = PlaceholderFormatForNumericDomain(domain); } else if (shadow) format = VK_FORMAT_D16_UNORM; else if (domain == SamplerNumericDomain::Float) format = VK_FORMAT_R8G8B8A8_UNORM; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index bdf1c54c..ca619946 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -144,6 +144,8 @@ add_executable(MobileGLIntegrationTest Scenarios/RelinkStageSetScenario.cpp Scenarios/GuiBatchScenario.cpp Scenarios/UnboundImageDescriptorScenario.cpp + # P7 gate 5: an image unit naming a (level, layer) its texture lacks (GL 4.6 core 8.26). + Scenarios/InvalidImageUnitScenario.cpp Scenarios/IntegerBorderColorScenario.cpp Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp @@ -778,7 +780,7 @@ set(MGL_ITEST_MONOLITH_MECHANISM_FILTER # (ConfigLoader.cpp:213). In the ambient entry it would green-skip on every run and the gate # would be a name in a list. set(MGL_ITEST_GLES_VIEW_FILTER - "TextureViewScenario.*:TextureViewAliasScenario.*:F1WireScenario.ComputeImageStoreThroughViewPreservesOtherRootLayer:F1WireScenario.TextureViewClearTargetsItsRootMipAndLayer:F1WireScenario.GenerateMipmapThroughViewKeepsOwnerLevelsAndLayersOutsideItsWindow") + "TextureViewScenario.*:TextureViewAliasScenario.*:F1WireScenario.ComputeImageStoreThroughViewPreservesOtherRootLayer:F1WireScenario.TextureViewClearTargetsItsRootMipAndLayer:F1WireScenario.GenerateMipmapThroughViewKeepsOwnerLevelsAndLayersOutsideItsWindow:InvalidImageUnitScenario.ALevelPastATextureViewsWindowReadsZero") gtest_discover_tests(MobileGLIntegrationTest TEST_PREFIX "DirectGLES." @@ -2660,6 +2662,24 @@ if (MOBILEGL_BUILD_DISAGGREGATED) mgl_itest_register_split_arms("${MGL_SPLIT_I1_FILTER_${mglItestI1Scenario}}" "") endforeach() + # ---- P7 gate 5 (g5-imgwin): AN IMAGE UNIT NAMING A (LEVEL, LAYER) ITS TEXTURE LACKS ------ + # + # GL 4.6 core 8.26 makes every access through such a unit invalid - zero loads, dropped + # stores - and never an error. Espryt forwards the unit to the driver, which applies the rule + # itself; these are its three arms. The Magma arms of the same scenario are laid by hand in + # the Fm block (`DirectVulkan..ImageUnitWindow.`), because that is where the wire arm's + # gated flavour lives. + # + # THE TEXTURE-VIEW CASE IS EXCLUDED FROM EVERY GATED ARM, and not for anything it asserts: at + # this package's base (p7/f2 f90afcd1) every glTextureView under a transport ends the session + # at Fatal{ProtocolCorruption, "StagedTextureStore.NoteLevelDefined"} (the view's descriptor + # carries internalFormat 0xFFFFFFFF) - the same death TextureViewScenario, TextureViewAliasScenario + # and F1WireScenario's two view cases already show on these arms. It runs in the Espryt + # view-enabled monolith entry (MGL_ITEST_GLES_VIEW_FILTER) and in the informational + # integration-magma-full-* tier, and joins the gated arms when that death is retired. + mgl_itest_register_split_arms( + "InvalidImageUnitScenario.*-InvalidImageUnitScenario.ALevelPastATextureViewsWindowReadsZero" "") + # ---- P5b t2: the transform-feedback spans, the XFB object bind, the patch parameter ------- # # MG_Remote/CONTRACT-P5B.md ยง2 t2. The three P5 lanes above are the REDUCED PATH's targets @@ -3381,6 +3401,30 @@ if (MOBILEGL_BUILD_DISAGGREGATED) ${mglItestArmProperties} ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-copyinplace${mglItestArmLogTag}-${inPlaceCase}.log") endforeach() + # ---- P7 gate 5 (g5-imgwin): the image-unit window ------------------------------------ + # + # KHR-GL46.shader_image_load_store.incomplete_textures ended the inproc Redmi run at + # Fatal{UnmigratedVerb, "Magma:image-view-window"}: the unit named level 2 of a texture + # with only level 0, a monolith Fail turned into a SIGABRT. GL 4.6 core 8.26 makes that + # access invalid - zero loads, dropped stores - and the wire descriptor resolve now binds + # the storage placeholder for it. One entry per case for the private log path; NO KNOB, + # so all three arms. The monolith `DirectVulkan.` entry of the same scenario skips by + # name (it drops the draw: the gate 5 BASE, and a pull-build .text change to move). + # ALevelPastATextureViewsWindowReadsZero is not here: see the split-arm registration of + # this scenario for the base's glTextureView death it waits on. + foreach(imageWindowCase AMissingMipLevelOfAnIncompleteTextureReadsZeroInADraw + AMissingMipLevelOfAnIncompleteTextureReadsZeroInADispatch + ALevelPastAnImmutableTexturesStorageReadsZero + ALayerPastAnArraysLastSliceReadsZero + ATextureWithNoStorageReadsZero) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.ImageUnitWindow." + TEST_FILTER "InvalidImageUnitScenario.${imageWindowCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-imagewindow${mglItestArmLogTag}-${imageWindowCase}.log") + endforeach() endforeach() endif() diff --git a/MobileGL/MG_IntegrationTest/Scenarios/InvalidImageUnitScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/InvalidImageUnitScenario.cpp new file mode 100644 index 00000000..1723090b --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/InvalidImageUnitScenario.cpp @@ -0,0 +1,458 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/InvalidImageUnitScenario.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 - AN IMAGE UNIT NAMES A TEXTURE, BUT NOT A TEXEL THE TEXTURE HAS. +// +// UnboundImageDescriptorScenario's neighbour, one step further in: there the unit holds nothing, +// here it holds a real texture at a (level, layer) that texture does not have. GL 4.6 core 8.26 +// makes every such access INVALID, and an invalid access is not an error at draw time: a load +// returns zero in R, G and B, a store has no effect. It is the same observable an empty unit has. +// +// KHR-GL46.shader_image_load_store.incomplete_textures is the conformance shape (case 1 below, +// once as a dispatch and once as the CTS's own fragment-shader draw): a texture with only level 0, +// GL_TEXTURE_MAX_LEVEL 7 and a mipmapping minification filter, bound at level 2. Magma's wire +// descriptor resolve looked the (level, layer) up in the texture's storage, found nothing, and +// died: Fatal{UnmigratedVerb, "Magma:image-view-window"} - a monolith Fail turned into a SIGABRT +// on the Redmi's inproc run (P7 gate 5 dry run). The other cases are the other ways the same +// lookup comes up empty: an immutable texture one level short, an array one slice short, a +// texture that has no storage at all, and a texture view whose own window is shorter than its +// owner's storage. +// +// Every case asserts all three halves of "invalid": the load through the invalid unit reads zero +// (it lands in a COMPLETE destination that started non-zero, so a lost dispatch is red as well), +// the store through the invalid unit changed no texel the invalid texture does have, and no GL +// error was raised. +// +// Arms. Split/spawn/tcp on both backends. Espryt forwards the unit to the driver, which applies +// 8.26 itself. The MONOLITH DirectVulkan arm is skipped by name: it resolves the same binding to +// "no descriptor" and drops the whole draw (VkTextureManager::GetOrCreateStorageImageView's +// `mipLevel >= resource->mipLevels`), which is why the conformance case is a Fail there - that +// Fail is the P7 gate 5 BASE, and moving it would move the pull build's .text (G1). + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kEdge = 8; + constexpr GLubyte kCompleteDestinationMagic = 0x11; + constexpr GLubyte kCompleteSourceMagic = 0x22; + constexpr GLubyte kInvalidDestinationMagic = 0x33; + constexpr GLubyte kInvalidSourceMagic = 0x44; + + // Units 0..3 and their roles are the conformance case's own. + constexpr const char* kComputeSource = R"(#version 430 core +layout(local_size_x = 8, local_size_y = 8) in; +layout(rgba8, binding = 0) writeonly uniform image2D u_completeDestination; +layout(rgba8, binding = 1) readonly uniform image2D u_completeSource; +layout(rgba8, binding = 2) writeonly uniform image2D u_invalidDestination; +layout(rgba8, binding = 3) readonly uniform image2D u_invalidSource; +void main() { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + imageStore(u_completeDestination, p, imageLoad(u_invalidSource, p)); + imageStore(u_invalidDestination, p, imageLoad(u_completeSource, p)); +} +)"; + + constexpr const char* kVertexSource = R"(#version 430 core +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); +} +)"; + + // The conformance case's fragment shader, including its trailing discard. + constexpr const char* kFragmentSource = R"(#version 430 core +layout(rgba8, binding = 0) writeonly uniform image2D u_completeDestination; +layout(rgba8, binding = 1) readonly uniform image2D u_completeSource; +layout(rgba8, binding = 2) writeonly uniform image2D u_invalidDestination; +layout(rgba8, binding = 3) readonly uniform image2D u_invalidSource; +out vec4 o_color; +void main() { + ivec2 p = ivec2(gl_FragCoord.xy); + vec4 complete = imageLoad(u_completeSource, p); + vec4 invalid = imageLoad(u_invalidSource, p); + imageStore(u_completeDestination, p, invalid); + imageStore(u_invalidDestination, p, complete); + o_color = vec4(1.0); + discard; +} +)"; + + // Every byte non-zero, and distinct per texture, so "unchanged" and "zero" can never be + // the same answer. + std::vector Pattern(int width, int height, int depth, GLubyte magic) { + std::vector texels(static_cast(width * height * depth) * 4u); + for (int z = 0; z < depth; ++z) { + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const std::size_t at = static_cast(((z * height + y) * width + x) * 4); + texels[at + 0] = magic; + texels[at + 1] = static_cast(x + 1); + texels[at + 2] = static_cast(y + 1 + 16 * z); + texels[at + 3] = 0xFF; + } + } + } + return texels; + } + + enum class Invalidity { + MissingMipLevel, // mutable, level 0 only, MAX_LEVEL 7, bound at level 2 (the CTS shape) + PastImmutableLevels, // glTexStorage2D with 2 levels, bound at level 2 + PastArrayLayers, // 2D array of 2 slices, bound non-layered at layer 2 + NoStorage, // a named texture that was never given an image + PastViewWindow, // a 1-level view of a 3-level owner, bound at view level 1 + }; + + class InvalidImageUnitScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_wire = SplitRuntimeSkipReason().empty(); + if (Gl().BackendName() == "DirectVulkan" && !m_wire) { + GTEST_SKIP() << "the monolith DirectVulkan arm resolves an image unit whose (level, layer) the " + "texture lacks to NO descriptor and drops the whole draw " + "(GetOrCreateStorageImageView: mipLevel >= resource->mipLevels), so the " + "valid half of the work never lands either. That is the P7 gate 5 BASE " + "(KHR-GL46.shader_image_load_store.incomplete_textures Fails there), and " + "changing it moves the pull build's .text (G1). Magma's wire arms and both " + "Espryt arms are the subject."; + } + FirstGLError(); + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + for (GLuint unit = 0; unit < 4; ++unit) { + glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + } + glBindTexture(GL_TEXTURE_2D, 0); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + if (m_program != 0) glDeleteProgram(m_program); + if (!m_textures.empty()) { + glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + } + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_target.fbo != 0) { + BindDefaultFramebuffer(); + DestroyColorFbo(m_target); + glViewport(0, 0, Gl().Width(), Gl().Height()); + } + FirstGLError(); + } + + static bool LimitIsAtLeast(GLenum limit, GLint wanted) { + GLint value = 0; + glGetIntegerv(limit, &value); + while (glGetError() != GL_NO_ERROR) { + } + return value >= wanted; + } + + GLuint NewTexture() { + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + return texture; + } + + GLuint MakeComplete2D(GLubyte magic) { + const GLuint texture = NewTexture(); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge); + const auto texels = Pattern(kEdge, kEdge, 1, magic); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + return texture; + } + + // The invalid texture for one reason, plus the (level, layer) to bind it at. The + // levels/layers it DOES have are recorded so the store half can be checked on all of + // them. + struct InvalidTexture { + GLuint texture = 0; + GLint bindLevel = 0; + GLint bindLayer = 0; + GLenum readTarget = GL_TEXTURE_2D; + GLuint readTexture = 0; // the texture whose texels are checked (a view's owner) + std::vector definedLevels; // levels of readTexture that hold `magic` + int layers = 1; + GLubyte magic = 0; + }; + + InvalidTexture MakeInvalid(Invalidity why, GLubyte magic) { + InvalidTexture made{}; + made.magic = magic; + switch (why) { + case Invalidity::MissingMipLevel: { + made.texture = NewTexture(); + glBindTexture(GL_TEXTURE_2D, made.texture); + const auto texels = Pattern(kEdge, kEdge, 1, magic); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, + texels.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 7); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_LINEAR); + made.bindLevel = 2; + made.definedLevels = {0}; + break; + } + case Invalidity::PastImmutableLevels: { + made.texture = NewTexture(); + glBindTexture(GL_TEXTURE_2D, made.texture); + glTexStorage2D(GL_TEXTURE_2D, 2, GL_RGBA8, kEdge, kEdge); + for (int level = 0; level < 2; ++level) { + const int edge = kEdge >> level; + const auto texels = Pattern(edge, edge, 1, magic); + glTexSubImage2D(GL_TEXTURE_2D, level, 0, 0, edge, edge, GL_RGBA, GL_UNSIGNED_BYTE, + texels.data()); + } + made.bindLevel = 2; + made.definedLevels = {0, 1}; + break; + } + case Invalidity::PastArrayLayers: { + made.texture = NewTexture(); + glBindTexture(GL_TEXTURE_2D_ARRAY, made.texture); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kEdge, kEdge, 2); + const auto texels = Pattern(kEdge, kEdge, 2, magic); + glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, kEdge, kEdge, 2, GL_RGBA, GL_UNSIGNED_BYTE, + texels.data()); + made.bindLayer = 2; + made.readTarget = GL_TEXTURE_2D_ARRAY; + made.layers = 2; + made.definedLevels = {0}; + break; + } + case Invalidity::NoStorage: { + made.texture = NewTexture(); + // Bound once, so the name is an existing texture object glBindImageTexture accepts. + glBindTexture(GL_TEXTURE_2D, made.texture); + glBindTexture(GL_TEXTURE_2D, 0); + break; + } + case Invalidity::PastViewWindow: { + const GLuint owner = NewTexture(); + glBindTexture(GL_TEXTURE_2D, owner); + glTexStorage2D(GL_TEXTURE_2D, 3, GL_RGBA8, kEdge, kEdge); + for (int level = 0; level < 3; ++level) { + const int edge = kEdge >> level; + const auto texels = Pattern(edge, edge, 1, magic); + glTexSubImage2D(GL_TEXTURE_2D, level, 0, 0, edge, edge, GL_RGBA, GL_UNSIGNED_BYTE, + texels.data()); + } + made.texture = NewTexture(); + // The view's one level is the owner's level 1. View level 1 would be the + // owner's level 2, which EXISTS - the window, not the storage, is what the + // binding falls outside of. + glTextureView(made.texture, GL_TEXTURE_2D, owner, GL_RGBA8, 1, 1, 0, 1); + made.bindLevel = 1; + made.readTexture = owner; + made.definedLevels = {0, 1, 2}; + break; + } + } + if (made.readTexture == 0) made.readTexture = made.texture; + glBindTexture(GL_TEXTURE_2D, 0); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + return made; + } + + std::vector ReadLevel(GLenum target, GLuint texture, int level, int layers) { + const int edge = kEdge >> level; + std::vector texels(static_cast(edge * edge * layers) * 4u, 0xA5); + glBindTexture(target, texture); + glGetTexImage(target, level, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(target, 0); + return texels; + } + + GLuint BuildCompute() { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &kComputeSource, nullptr); + glCompileShader(shader); + GLint ok = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (ok == GL_FALSE) { + char log[4096] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute shader did not compile: " << log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + glGetProgramiv(program, GL_LINK_STATUS, &ok); + if (ok == GL_FALSE) { + char log[4096] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute program did not link: " << log; + glDeleteProgram(program); + return 0; + } + return program; + } + + // The whole case: complete destination/source on units 0/1, the invalid texture on + // units 2/3 (two textures of the same shape, as in the conformance case), one pass, + // then the three halves of "invalid". + void ExpectInvalidAccess(Invalidity why, bool asDraw, const char* what) { + if (asDraw) { + if (!LimitIsAtLeast(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, 4)) { + GTEST_SKIP() << "the fragment stage has fewer than four image uniforms"; + } + } else if (!LimitIsAtLeast(GL_MAX_COMPUTE_IMAGE_UNIFORMS, 4)) { + GTEST_SKIP() << "the compute stage has fewer than four image uniforms"; + } + const GLuint completeDestination = MakeComplete2D(kCompleteDestinationMagic); + const GLuint completeSource = MakeComplete2D(kCompleteSourceMagic); + const InvalidTexture invalidDestination = MakeInvalid(why, kInvalidDestinationMagic); + const InvalidTexture invalidSource = MakeInvalid(why, kInvalidSourceMagic); + ASSERT_EQ(FirstGLError(), 0u) << "building the textures raised a GL error (" << what << ")"; + + glBindImageTexture(0, completeDestination, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8); + glBindImageTexture(1, completeSource, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8); + glBindImageTexture(2, invalidDestination.texture, invalidDestination.bindLevel, GL_FALSE, + invalidDestination.bindLayer, GL_READ_WRITE, GL_RGBA8); + glBindImageTexture(3, invalidSource.texture, invalidSource.bindLevel, GL_FALSE, + invalidSource.bindLayer, GL_READ_WRITE, GL_RGBA8); + // A binding that is invalid at ACCESS time is still a legal bind (8.26 validates + // only the unit, the level's sign, the layer's sign, the access and the format). + ASSERT_EQ(FirstGLError(), 0u) << "glBindImageTexture raised a GL error (" << what << ")"; + + if (asDraw) { + std::string error; + m_program = CompileProgram(kVertexSource, kFragmentSource, &error); + ASSERT_NE(m_program, 0u) << error; + m_target = MakeColorFbo(kEdge, kEdge); + ASSERT_NE(m_target.fbo, 0u) << "could not create the render target"; + BindFbo(m_target); + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glUseProgram(m_program); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + } else { + m_program = BuildCompute(); + ASSERT_NE(m_program, 0u); + glUseProgram(m_program); + glDispatchCompute(1, 1, 1); + } + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << "the pass raised a GL error (" << what << ")"; + + // (1) The load through the invalid unit read zero. The complete destination + // started at 0x11 everywhere, so a pass that never ran is red here too. + // ALL FOUR CHANNELS, which is stricter than 8.26 (it leaves the A of an invalid + // load undefined) and exactly what the conformance case compares: Espryt's driver + // answers (0, 0, 0, 0), and Magma's R32 placeholder used to answer (0, 0, 0, 1) - + // a green on R, G and B and a Fail on the CTS. + const auto loaded = ReadLevel(GL_TEXTURE_2D, completeDestination, 0, 1); + int nonZero = 0; + int firstX = -1, firstY = -1; + for (int y = 0; y < kEdge; ++y) { + for (int x = 0; x < kEdge; ++x) { + const std::size_t at = static_cast((y * kEdge + x) * 4); + if (loaded[at] != 0 || loaded[at + 1] != 0 || loaded[at + 2] != 0 || loaded[at + 3] != 0) { + if (nonZero++ == 0) { + firstX = x; + firstY = y; + } + } + } + } + if (nonZero != 0) { + const std::size_t at = static_cast((firstY * kEdge + firstX) * 4); + ADD_FAILURE() << nonZero << " of " << kEdge * kEdge << " texels loaded through the invalid " + << what << " unit were not zero; first at (" << firstX << ", " << firstY << ") = (" + << int(loaded[at]) << ", " << int(loaded[at + 1]) << ", " << int(loaded[at + 2]) + << ", " << int(loaded[at + 3]) << ") (R = 17 means the pass never ran)"; + } + + // (2) The store through the invalid unit changed nothing the texture has. + for (const int level : invalidDestination.definedLevels) { + const int edge = kEdge >> level; + const auto expected = Pattern(edge, edge, invalidDestination.layers, invalidDestination.magic); + const auto actual = ReadLevel(invalidDestination.readTarget, invalidDestination.readTexture, level, + invalidDestination.layers); + EXPECT_EQ(actual, expected) << "a store through the invalid " << what + << " unit modified level " << level << " of the texture it names"; + } + + // (3) The valid half of the same pass is intact: the complete source was only read. + EXPECT_EQ(ReadLevel(GL_TEXTURE_2D, completeSource, 0, 1), Pattern(kEdge, kEdge, 1, kCompleteSourceMagic)) + << "the complete source changed (" << what << ")"; + EXPECT_EQ(FirstGLError(), 0u) << "the readbacks raised a GL error (" << what << ")"; + } + + bool m_wire = false; + std::vector m_textures; + unsigned int m_program = 0; + GLuint m_vao = 0; + ColorFbo m_target{}; + }; + + } // namespace + + // The conformance shape, as the conformance case runs it: a fragment-shader draw. + TEST_F(InvalidImageUnitScenario, AMissingMipLevelOfAnIncompleteTextureReadsZeroInADraw) { + if (!Ready() || IsSkipped()) return; + ExpectInvalidAccess(Invalidity::MissingMipLevel, true, "missing-mip-level"); + } + + TEST_F(InvalidImageUnitScenario, AMissingMipLevelOfAnIncompleteTextureReadsZeroInADispatch) { + if (!Ready() || IsSkipped()) return; + ExpectInvalidAccess(Invalidity::MissingMipLevel, false, "missing-mip-level"); + } + + TEST_F(InvalidImageUnitScenario, ALevelPastAnImmutableTexturesStorageReadsZero) { + if (!Ready() || IsSkipped()) return; + ExpectInvalidAccess(Invalidity::PastImmutableLevels, false, "past-immutable-levels"); + } + + TEST_F(InvalidImageUnitScenario, ALayerPastAnArraysLastSliceReadsZero) { + if (!Ready() || IsSkipped()) return; + ExpectInvalidAccess(Invalidity::PastArrayLayers, false, "past-array-layers"); + } + + TEST_F(InvalidImageUnitScenario, ATextureWithNoStorageReadsZero) { + if (!Ready() || IsSkipped()) return; + ExpectInvalidAccess(Invalidity::NoStorage, false, "no-storage"); + } + + TEST_F(InvalidImageUnitScenario, ALevelPastATextureViewsWindowReadsZero) { + if (!Ready() || IsSkipped()) return; + if (Gl().BackendName() == "DirectGLES") { + const char* enabled = std::getenv("MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW"); + if (enabled == nullptr || std::string(enabled) != "1") { + GTEST_SKIP() << "Espryt withholds glTextureView unless MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1"; + } + } + ExpectInvalidAccess(Invalidity::PastViewWindow, false, "past-view-window"); + } + +} // namespace MGITest