[MG_Backend, MG_IntegrationTest] (P7): bind the storage placeholder for an image unit naming a level or layer its texture lacks

This commit is contained in:
2026-09-23 14:37:13 -04:00
parent 0f64af8f55
commit 3d5160d397
7 changed files with 594 additions and 15 deletions
@@ -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<MG_Pipe::MGPipeResourceTarget>(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<TextureSwizzleParam>(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<MG_Pipe::MGPipeResourceTarget>(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;
}
}
@@ -211,9 +211,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 layers = 1;
};
mutable UnorderedMap<Uint64, WirePlaceholderImage> 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
@@ -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<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex3D)
? std::max(record.Desc.Depth >> mappedLevel, 1u)
: std::max<Uint32>(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<TextureInternalFormat>(view.InternalFormat)).format;
if (outerFormat == VK_FORMAT_UNDEFINED) return MG_Pipe::kMGPipeNullHandle;
@@ -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
@@ -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;
+45 -1
View File
@@ -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.<arm>.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()
@@ -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 <cstdint>
#include <cstdlib>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitRuntimePeek.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 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<GLubyte> Pattern(int width, int height, int depth, GLubyte magic) {
std::vector<GLubyte> texels(static_cast<std::size_t>(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<std::size_t>(((z * height + y) * width + x) * 4);
texels[at + 0] = magic;
texels[at + 1] = static_cast<GLubyte>(x + 1);
texels[at + 2] = static_cast<GLubyte>(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<GLsizei>(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<int> 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<GLubyte> ReadLevel(GLenum target, GLuint texture, int level, int layers) {
const int edge = kEdge >> level;
std::vector<GLubyte> texels(static_cast<std::size_t>(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<std::size_t>((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<std::size_t>((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<GLuint> 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