[MG_Pipe, MG_Remote, MG_Test, MG_IntegrationTest] (P7): accept empty texture levels in the respecify extent carrier

This commit is contained in:
2026-09-23 13:44:37 -04:00
parent 38ac5dbd2d
commit 10c5168be2
6 changed files with 252 additions and 8 deletions
@@ -104,6 +104,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/ImageFormatQualifierScenario.cpp
Scenarios/NonCoreImageFormatScenario.cpp
Scenarios/ImageSizeAfterRespecScenario.cpp
Scenarios/EmptyTextureLevelScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
@@ -2862,6 +2863,13 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
mgl_itest_register_split_arms("${mglItestD2Scenario}.*" "")
endforeach()
# P7 gate 5 (g5-msrbo): AN EMPTY TEXTURE LEVEL. glTexImage*D with a zero extent is legal GL
# and dEQP's resetStateGLCore issues it for every target after every case; PH-4's extent
# carrier refused it as ProtocolCorruption on the apply thread, so no KHR-GL case could
# finish on a split arm. Backend-agnostic (the refusal was in MGPipeApplyResourceRespecify),
# so the DirectGLES gated arms carry it here and the Magma gated lanes below carry it too.
mgl_itest_register_split_arms("EmptyTextureLevelScenario.*" "")
# ProgramPipelineScenario BEYOND *StorageBlock*: the i1 block above already registers the two
# storage-block cases. The other nine are pure name/state cases that draw nothing, and
# ScenarioFixture's armed-lane rule (the client encoder's record ordinal must move) makes a
@@ -3071,6 +3079,19 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
${mglItestArmProperties}
ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5f-fm${mglItestArmLogTag}-${fmCase}.log")
endforeach()
# P7 gate 5 (g5-msrbo): the empty-level pair, on the gated Magma lanes as well - the CTS
# arm gate 5 measures is inproc x DirectVulkan, and every one of its cases ends in the
# empty-level reset this scenario reproduces.
foreach(emptyLevelCase TheCtsStateResetDefinesEveryDefaultTargetEmpty
ALevelDefinedEmptyThenRedefinedCarriesTheNewTexels)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.${mglItestArm}.Fm."
TEST_FILTER "EmptyTextureLevelScenario.${emptyLevelCase}"
DISCOVERY_TIMEOUT 30
PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT}
${mglItestArmProperties}
ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-emptylevel${mglItestArmLogTag}-${emptyLevelCase}.log")
endforeach()
# ---- P7 wave 2-B: THE TWO SHADER MIP ARMS, FORCED ------------------------------------
#
# WHY A KNOB AND NOT A FORMAT. The refusal these two entries cover
@@ -0,0 +1,149 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/EmptyTextureLevelScenario.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 TEXTURE LEVEL DEFINED EMPTY IS LEGAL GL, AND IT MUST NOT END THE SESSION.
//
// glTexImage1D(width 0), glTexImage2D(0 x 0), glTexImage3D(0 x 0 x 0) define a level with no
// texels. dEQP's glu::resetStateGLCore issues exactly that for EVERY texture target on EVERY unit
// after EVERY case (gluStateReset.cpp), so a split arm that cannot carry an empty level cannot run
// a single KHR-GL case to completion.
//
// P7 PH-4 (f90afcd1) put each per-level respecify's exact extent on the wire and made the server
// refuse any zero component as a noncanonical carrier - Fatal{ProtocolCorruption,
// "ResourceRespecify.ExtentCarrier"} on the apply thread, from the reset that follows the case
// rather than the case itself. The two cases below are the reset's own sequence, then the
// round trip that proves a level can go empty and come back with texels the server really staged.
#include <array>
#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 {
class EmptyTextureLevelScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
m_fbo = m_texture = 0;
while (glGetError() != GL_NO_ERROR) {
}
}
// Every target resetStateGLCore empties, in its order, on the default textures.
static void EmptyEveryDefaultTarget() {
glBindTexture(GL_TEXTURE_1D, 0);
glTexImage1D(GL_TEXTURE_1D, 0, GL_RGBA, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z; ++face)
glTexImage2D(face, 0, GL_RGBA, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_1D_ARRAY, 0);
glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_RGBA, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, 0, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_3D, 0);
glTexImage3D(GL_TEXTURE_3D, 0, GL_RGBA, 0, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_RECTANGLE, 0);
glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGBA, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
}
GLuint m_texture = 0;
GLuint m_fbo = 0;
};
} // namespace
// dEQP's reset, verbatim in shape: every default texture target defined EMPTY at level 0,
// twice (the reset runs after every case), then an ordinary clear + readback. Before the fix
// the first glTexImage1D ended the session on every split arm; the readback is what proves
// the session is still the one the case started with.
TEST_F(EmptyTextureLevelScenario, TheCtsStateResetDefinesEveryDefaultTargetEmpty) {
if (!Ready()) return;
for (int pass = 0; pass < 2; ++pass) {
glActiveTexture(GL_TEXTURE0);
EmptyEveryDefaultTarget();
glActiveTexture(GL_TEXTURE1);
EmptyEveryDefaultTarget();
glActiveTexture(GL_TEXTURE0);
ASSERT_EQ(FirstGLError(), 0u) << "an empty level definition is legal GL (pass " << pass << ")";
}
HeadlessGL& gl = Gl();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, gl.Width(), gl.Height());
glDisable(GL_SCISSOR_TEST);
glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
std::array<GLubyte, 4> pixel{};
glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data());
EXPECT_EQ(FirstGLError(), 0u);
// The default framebuffer's readback is not what this case is about (it reads zeros on some
// DirectVulkan hosts, tools/cts/README.md) - what matters is that the call RETURNED, i.e.
// the session that took the empty levels is still answering.
gl.EndFrame();
}
// A NAMED texture's level goes 4x4 (with texels) -> EMPTY -> 2x2 (with other texels), and the
// 2x2 content must be what a framebuffer readback of that level returns. This is the staged
// store's half: the empty definition must drop the 4x4 bytes and coverage (the coordinate
// system was replaced), and must not stand in the way of the next non-empty definition
// adopting its own bytes.
TEST_F(EmptyTextureLevelScenario, ALevelDefinedEmptyThenRedefinedCarriesTheNewTexels) {
if (!Ready()) return;
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
std::vector<GLubyte> redTexels(4 * 4 * 4, 0);
for (std::size_t i = 0; i < redTexels.size(); i += 4) {
redTexels[i] = 255;
redTexels[i + 3] = 255;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, redTexels.data());
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); // empty
std::vector<GLubyte> greenTexels(2 * 2 * 4, 0);
for (std::size_t i = 0; i < greenTexels.size(); i += 4) {
greenTexels[i + 1] = 255;
greenTexels[i + 3] = 255;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, greenTexels.data());
ASSERT_EQ(FirstGLError(), 0u) << "4x4 -> empty -> 2x2 on one level is legal GL";
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
std::vector<GLubyte> readBack(2 * 2 * 4, 7);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, readBack.data());
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(readBack, greenTexels)
<< "the level that went empty and came back must hold the 2x2 texels it was redefined with";
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
} // namespace MGITest
+8 -3
View File
@@ -428,10 +428,15 @@ namespace MobileGL::MG_Pipe {
inline constexpr Uint32 MGPipeRespecifiedDepthOf(const MGPResourceDesc& desc) {
return MGPipeRespecifyIsWholeResource(desc) ? 0u : static_cast<Uint32>(desc.BufSize);
}
// A ZERO COMPONENT IS CANONICAL. glTexImage1D(width 0), glTexImage2D(0 x 0) and friends are
// legal GL that define an EMPTY level - dEQP's resetStateGLCore issues exactly that for every
// texture target after every case - and the frontend reports the level's exact extent as
// e.g. {0, 1, 1}. Requiring nonzero components made that legal call a session Fatal on every
// split arm. The carrier's only noncanonical shape is a depth word past 32 bits; whether an
// extent is USABLE is the staged store's question (an empty level has no byte bound, so any
// resource_subdata against it is refused there).
inline constexpr Bool MGPipeRespecifiedExtentCarrierIsCanonical(const MGPResourceDesc& desc) {
return MGPipeRespecifyIsWholeResource(desc) ||
((desc.BufSize >> 32) == 0 && MGPipeRespecifiedWidthOf(desc) != 0 &&
MGPipeRespecifiedHeightOf(desc) != 0 && MGPipeRespecifiedDepthOf(desc) != 0);
return MGPipeRespecifyIsWholeResource(desc) || (desc.BufSize >> 32) == 0;
}
inline constexpr void MGPipeSetRespecifiedLevel(MGPResourceDesc& desc, Uint16 uploadTarget,
+1 -1
View File
@@ -2081,7 +2081,7 @@ namespace MobileGL::MG_Pipe {
if (!MGPipeRespecifyIsWholeResource(desc) &&
!MGPipeRespecifiedExtentCarrierIsCanonical(desc)) {
MGP_TRIP_WIRE_REPORT("MGPipe: Fatal{ProtocolCorruption, \"ResourceRespecify.ExtentCarrier\"} - "
"a named image level must carry nonzero width/height/depth with no high BufSize bits");
"a named image level's depth word must fit in 32 bits (no high BufSize bits)");
}
if (level == nullptr && desc.Target != static_cast<Uint8>(MGPipeResourceTarget::TexBuffer) &&
(desc.BufOffset != 0 || desc.BufSize != 0)) {
+14 -4
View File
@@ -376,6 +376,15 @@ namespace MobileGL::MG_Remote::Server {
// extent carried by that accepted operation (including noncanonical mutable mips and
// null-data glTexImage*D). Its byte bound is established here, before any upload arrives.
// An extent move replaces the coordinate system, so old bytes and coverage go with it.
//
// AN EMPTY LEVEL IS A DEFINITION TOO. A zero width, height or depth is legal GL
// (glTexImage1D(width 0) is how dEQP's resetStateGLCore resets every texture target after
// every case) and it replaces the level's coordinate system like any other: the old bytes
// go, the level is defined at its exact empty extent - which is what the frontend's
// GetMipmapTexelSize answers for it, so both roles agree - and it gets NO byte bound, so
// a resource_subdata against it is RequireDeclaredLevel's refusal rather than a write
// into storage that has no texels. Only a NEGATIVE component (a 32-bit carrier word past
// INT_MAX) is malformed.
void NoteLevelDefined(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent,
Uint8 resourceTarget = static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2D),
Uint32 internalFormat = static_cast<Uint32>(TextureInternalFormat::RGBA8)) {
@@ -383,10 +392,11 @@ namespace MobileGL::MG_Remote::Server {
const std::lock_guard<std::mutex> lock(m_mutex);
Uint64 byteBound = 0;
const Bool supportsSubData = StagedTextureTargetSupportsSubData(resourceTarget);
const Bool validExtent = extent.x() > 0 && extent.y() > 0 && extent.z() > 0;
const Bool validExtent = extent.x() >= 0 && extent.y() >= 0 && extent.z() >= 0;
const Bool emptyExtent = validExtent && (extent.x() == 0 || extent.y() == 0 || extent.z() == 0);
if (MG_Util::GetSizedInternalFormatSizeInBytes(
static_cast<TextureInternalFormat>(internalFormat)) == 0 || !validExtent ||
(supportsSubData && !StagedTextureDeclaredLevelByteBound(
(supportsSubData && !emptyExtent && !StagedTextureDeclaredLevelByteBound(
resourceTarget, internalFormat, extent, m_deviceLimits, &byteBound))) {
SessionFail(MGFatalFamily::ProtocolCorruption,
"MGPipe: Fatal{ProtocolCorruption, \"StagedTextureStore.NoteLevelDefined\"} - "
@@ -407,7 +417,7 @@ namespace MobileGL::MG_Remote::Server {
shadow.Extent = extent;
shadow.InternalFormat = internalFormat;
shadow.ResourceTarget = resourceTarget;
shadow.DeclaredByteBound = byteBound; // zero for targets without glTexSubImage
shadow.DeclaredByteBound = byteBound; // zero for targets without glTexSubImage and for an empty level
shadow.Defined = true;
m_any.store(true, std::memory_order_release);
}
@@ -591,7 +601,7 @@ namespace MobileGL::MG_Remote::Server {
SessionFail(MGFatalFamily::ProtocolCorruption,
"MGPipe: Fatal{ProtocolCorruption, \"StagedTextureStore.Target\"} - "
"resource_subdata is not legal for the server-declared texture target "
"(key=%llu, uploadTarget=%u, level=%u, target=%u)",
"or empty level (key=%llu, uploadTarget=%u, level=%u, target=%u)",
static_cast<unsigned long long>(key), uploadTarget, level,
static_cast<Uint32>(shadow.ResourceTarget));
}
@@ -164,6 +164,65 @@ TEST(StagedTextureStoreTest, AMutableNoncanonicalLevelKeepsItsExactDeclaredExten
EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 2));
}
// AN EMPTY LEVEL IS A DEFINITION (P7 gate 5). glTexImage1D(width 0) / glTexImage2D(0 x 0) are
// legal GL and dEQP's state reset issues them after every case; the frontend reports the level's
// exact extent as {0,1,1} / {0,0,1}. The store must take it as a definition that REPLACES the
// level (the old run and its coverage go), answer the exact empty extent, and give it no byte
// bound - never a session Fatal. A negative component (a carrier word past INT_MAX) stays one.
TEST(StagedTextureStoreTest, AnEmptyLevelIsADefinitionWithNoByteBound) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(42, 1));
constexpr Uint16 kTex1DTarget = static_cast<Uint16>(TextureUploadTarget::Texture1D);
const auto tex1D = static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex1D);
const auto rgba8 = static_cast<Uint32>(TextureInternalFormat::RGBA8);
store.NoteLevelDefined(key, kTex1DTarget, 0, IntVec3{4, 1, 1}, tex1D, rgba8);
Vector<Uint8> bytes(16, 0x5A);
store.Adopt(key, kTex1DTarget, 0, IntVec3{4, 1, 1}, bytes.data(), bytes.size());
ASSERT_TRUE(store.IsCovered(key, kTex1DTarget, 0));
store.NoteLevelDefined(key, kTex1DTarget, 0, IntVec3{0, 1, 1}, tex1D, rgba8);
EXPECT_TRUE(store.IsLevelDefined(key, kTex1DTarget, 0)) << "an empty level still exists";
EXPECT_EQ(store.LevelExtentOrUndefined(key, kTex1DTarget, 0), IntVec3(0, 1, 1))
<< "the store must answer the frontend's own exact extent for an empty level";
EXPECT_EQ(store.LevelDeclaredByteBound(key, kTex1DTarget, 0), 0u) << "an empty level has no texels";
EXPECT_EQ(store.LevelByteSize(key, kTex1DTarget, 0), 0u) << "the 4-texel run belongs to the old level";
EXPECT_FALSE(store.IsCovered(key, kTex1DTarget, 0));
// And back: the next non-empty definition takes its own bytes as usual.
store.NoteLevelDefined(key, kTex1DTarget, 0, IntVec3{2, 1, 1}, tex1D, rgba8);
Vector<Uint8> smaller(8, 0x3C);
store.Adopt(key, kTex1DTarget, 0, IntVec3{2, 1, 1}, smaller.data(), smaller.size());
EXPECT_TRUE(store.IsCovered(key, kTex1DTarget, 0));
EXPECT_EQ(store.LevelDeclaredByteBound(key, kTex1DTarget, 0), 8u);
// The 2D and 3D spellings of the same reset.
const Uint64 key2D = Server::StagedTextureStore::KeyForHandle(TestHandle(43, 1));
store.NoteLevelDefined(key2D, kTex2DTarget, 0, IntVec3{0, 0, 1},
static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2D), rgba8);
EXPECT_EQ(store.LevelExtentOrUndefined(key2D, kTex2DTarget, 0), IntVec3(0, 0, 1));
const Uint64 key3D = Server::StagedTextureStore::KeyForHandle(TestHandle(44, 1));
constexpr Uint16 kTex3DTarget = static_cast<Uint16>(TextureUploadTarget::Texture3D);
store.NoteLevelDefined(key3D, kTex3DTarget, 0, IntVec3{0, 0, 0},
static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex3D), rgba8);
EXPECT_TRUE(store.IsLevelDefined(key3D, kTex3DTarget, 0));
EXPECT_EQ(store.LevelDeclaredByteBound(key3D, kTex3DTarget, 0), 0u);
}
// The wire half of the same rule: the carrier's only noncanonical shape is a depth word past
// 32 bits. A zero component is an empty level, which the store above takes.
TEST(StagedTextureStoreTest, AZeroExtentCarrierIsCanonicalAndAHighDepthWordIsNot) {
MG_Pipe::MGPResourceDesc desc{};
desc.Target = static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex1D);
MG_Pipe::MGPipeSetRespecifiedLevel(desc, kTex2DTarget, 0, 0, 1, 1);
EXPECT_TRUE(MG_Pipe::MGPipeRespecifiedExtentCarrierIsCanonical(desc))
<< "glTexImage1D(width 0) is legal GL; its carrier must not be refused";
MG_Pipe::MGPipeSetRespecifiedLevel(desc, kTex2DTarget, 0, 0, 0, 0);
EXPECT_TRUE(MG_Pipe::MGPipeRespecifiedExtentCarrierIsCanonical(desc));
desc.BufSize |= (Uint64{1} << 32);
EXPECT_FALSE(MG_Pipe::MGPipeRespecifiedExtentCarrierIsCanonical(desc));
}
TEST(StagedTextureStoreTest, MultisampleAndTextureBufferTargetsCannotAcceptStagedTexelBytes) {
EXPECT_FALSE(Server::StagedTextureTargetSupportsSubData(
static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2DMS)));