From dcc31e95ca9b0cfa93d686347c300cdf91507f02 Mon Sep 17 00:00:00 2001 From: rereview Date: Tue, 8 Sep 2026 20:15:55 -0400 Subject: [PATCH] [Fix, Test] (MG_State, Integration): move the framebuffer aggregate from a texture's or renderbuffer's storage definition - set_framebuffer_state inlines an attachment's format and a respecify while attached left Espryt's handle arm answering its four cross-object masks from the stale copy (P4a seam F-3) --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 2 + .../Harness/P4aSeamPeek.cpp | 99 ++++ .../MG_IntegrationTest/Harness/P4aSeamPeek.h | 72 +++ .../Scenarios/P4aSeamAuditScenario.cpp | 475 ++++++++++++++++++ MobileGL/MG_Pipe/PipeMutation.h | 6 +- .../RenderbufferState/RenderbufferObject.cpp | 24 +- .../GLState/TextureState/TextureObject.cpp | 14 + MobileGL/MG_Test/Pipe/TrackerTest.cpp | 35 ++ 8 files changed, 719 insertions(+), 8 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp create mode 100644 MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 916d7564..6578c1cb 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -54,6 +54,7 @@ add_executable(MobileGLIntegrationTest Harness/BackendCapsPeek.cpp Harness/PipeSlotPeek.cpp Harness/PipeApplyPeek.cpp + Harness/P4aSeamPeek.cpp Scenarios/OrientationScenario.cpp Scenarios/CrossFrameBufferScenario.cpp Scenarios/ResidentIndexScenario.cpp @@ -137,6 +138,7 @@ add_executable(MobileGLIntegrationTest Scenarios/TextureParamsWithoutASamplerViewScenario.cpp Scenarios/TextureUploadShapeScenario.cpp Scenarios/ObjectSubsystemControlScenario.cpp + Scenarios/P4aSeamAuditScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp new file mode 100644 index 00000000..355ec646 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp @@ -0,0 +1,99 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.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 "P4aSeamPeek.h" + +#if !defined(__ANDROID__) +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#define MGITEST_P4A_SEAM_PEEK_LIVE 1 +#endif +#endif + +namespace MGITest { + +#if defined(MGITEST_P4A_SEAM_PEEK_LIVE) + namespace { + namespace MGP = MobileGL::MG_Pipe; + namespace MGB = MobileGL::MG_Backend::DirectGLES; + + // "Is Espryt the backend running" - the same test PipeApplyPeek.cpp makes through a twin: + // on Magma no ES entry point was ever resolved and every member of g_GLESFuncs is null. + // It is asked BEFORE SamplerSubsystemEnabled(), which is Espryt's own latch and must not + // be resolved on a process whose backend is not Espryt. + bool EsprytIsRunning() { return MGB::g_GLESFuncs.glBindSampler != nullptr; } + } // namespace + + bool PeekEsprytSamplerHandleArmIsLive(bool* outLive) { + if (outLive == nullptr) return false; + if (!EsprytIsRunning()) return false; + *outLive = MGB::SamplerSubsystemEnabled(); + return true; + } + + bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek* out) { + if (out == nullptr) return false; + const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier(); + out->Start = static_cast(applier.ShaderImageStart); + out->Count = static_cast(applier.ShaderImageCount); + out->Serial = static_cast(applier.ShaderImagesSerial); + return true; + } + + bool PeekEsprytUnitSampler(unsigned unit, unsigned glSamplerName, EsprytUnitSamplerPeek* out) { + if (out == nullptr) return false; + if (!EsprytIsRunning()) return false; + if (!MobileGL::MG_State::pGLContext) return false; + const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier(); + if (unit >= applier.BoundSamplerStates.size() || unit >= MGB::SamplerImpl::g_boundSamplersCache.size()) { + return false; + } + *out = EsprytUnitSamplerPeek{}; + + // Espryt's own binding shadow: every glBindSampler this backend issues routes through it + // (BackendSamplerObject::Bind / UnbindSampler), so it IS what the driver holds. + if (MGB::SamplerImpl::BackendSamplerObject* const bound = MGB::SamplerImpl::g_boundSamplersCache[unit]) { + out->BoundSamplerId = static_cast(bound->GetBackendSamplerId()); + } + + const MGP::MGPipeHandle cso = applier.BoundSamplerStates[unit]; + out->CsoHandleSlot = static_cast(cso.Slot); + out->CsoHandleGen = static_cast(cso.Gen); + out->UnitInsideWindow = unit >= applier.SamplerStateStart && + unit - applier.SamplerStateStart < applier.SamplerStateCount; + // The twin AT THE CSO HANDLE, asked of the same table Espryt asks (FindByHandle): a null + // here with a live handle is the F-4 shape - a content-addressed handle looked up in a + // table that only ever held identity-minted slots. + if (!MGP::MGPipeHandleIsNull(cso)) { + if (auto* const slot = MGB::SamplerImpl::g_backendSamplerObjects.FindByHandle(cso); slot && *slot) { + out->CsoTwinSamplerId = static_cast((*slot)->GetBackendSamplerId()); + } + } + + // And the twin keyed on the frontend OBJECT, which is what the pre-handle program pass + // used to mint and bind, so a scenario can say which of the two the driver holds. + const auto& object = MobileGL::MG_State::pGLContext->GetSamplerObject( + static_cast(glSamplerName)); + if (object) { + if (auto* const slot = MGB::SamplerImpl::g_backendSamplerObjects.Find(object.get()); slot && *slot) { + out->IdentityTwinSamplerId = static_cast((*slot)->GetBackendSamplerId()); + } + } + return true; + } +#else + bool PeekEsprytSamplerHandleArmIsLive(bool*) { return false; } + bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek*) { return false; } + bool PeekEsprytUnitSampler(unsigned, unsigned, EsprytUnitSamplerPeek*) { return false; } +#endif + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h new file mode 100644 index 00000000..8895f1db --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h @@ -0,0 +1,72 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.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 +// +// The three readings P4aSeamAuditScenario.cpp takes from the inside, for the two seams the fable +// seam audit proved that PUBLIC GL CANNOT SEE: F-4 (the record arm's sampler bind is a permanent +// no-op, hidden by the pre-handle program pass binding the same values) and F-2 / SD-4 (the +// shader-image window does not follow a program switch, hidden by the server's window/high-water +// union taking the pre-handle bind for the units outside it). Both are correct pictures over a +// permanent silent fallback, which is precisely the class ROADMAP.md:20 says a gate has to be +// able to make red - and the only place the difference exists is inside. +// +// A SEPARATE TRANSLATION UNIT for PipeApplyPeek.h's reason, verbatim: this file includes +// Espryt's own Managers.h, which may not meet a scenario's GL headers in one file. It is NOT +// PipeApplyPeek.cpp because that file is package F's (gates v3) and this round may not edit it. +// +// EVERY ENTRY POINT RETURNS false, TOUCHING NOTHING, WHERE IT CANNOT LOOK - a pull build, Android, +// a backend that is not Espryt - and a caller that gets false has learned NOTHING: "could not +// look" is not "was bound". The scenario declines the reading BY NAME and keeps its public-GL +// half, which is the shape TextureParamsWithoutASamplerViewScenario.cpp argues for. + +#pragma once + +namespace MGITest { + + // ---- is Espryt's sampler family on its HANDLE arm in this process? ------------------- + // + // The gate for every other reading here. True only on DirectGLES, in a push build, with + // Espryt's own resolver answering "handle" for kMGPipeSubsystemSamplers (bit 11 set and its + // dependency satisfied) - i.e. exactly when bind_sampler_states / set_shader_images are + // consumed, so a white-box assertion about them can be red for its own reason and for no + // other. Written only on true. + bool PeekEsprytSamplerHandleArmIsLive(bool* outLive); + + // ---- the applier's shader-image window, as last received ------------------------------ + // + // MGPipeApplierState::ShaderImageStart / ShaderImageCount / ShaderImagesSerial. Count is + // "how many units set_shader_images last described" - 0 means the set has NEVER arrived + // (MGPipeApplierReset advances the serial whether or not anything was emitted, so the serial + // is not that test). Push build only. + struct PipeShaderImageWindowPeek { + unsigned Start; + unsigned Count; + unsigned long long Serial; + }; + + bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek* out); + + // ---- which driver sampler a texture unit is bound to, and whose twin it is ------------- + // + // For F-4. `BoundSamplerId` is the ES sampler name Espryt's own binding shadow says unit + // `unit` carries (0 = none). `CsoHandleSlot/Gen` is bind_sampler_states' handle for the unit, + // `CsoTwinSamplerId` the ES name of the twin Espryt holds AT THAT HANDLE (0 = no twin at the + // content-addressed slot - the F-4 shape), and `IdentityTwinSamplerId` the ES name of a twin + // keyed on the frontend SamplerObject named `glSamplerName` (0 = none). On a correct handle + // arm the unit's driver sampler IS the CSO twin. Push build, DirectGLES only. + struct EsprytUnitSamplerPeek { + unsigned BoundSamplerId; + unsigned CsoHandleSlot; + unsigned CsoHandleGen; + bool UnitInsideWindow; + unsigned CsoTwinSamplerId; + unsigned IdentityTwinSamplerId; + }; + + bool PeekEsprytUnitSampler(unsigned unit, unsigned glSamplerName, EsprytUnitSamplerPeek* out); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.cpp new file mode 100644 index 00000000..04df4db1 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.cpp @@ -0,0 +1,475 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.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 - THE FOUR SEAMS THE P4a FABLE SEAM AUDIT PROVED, each pinned by the public-GL sequence +// (or the white-box reading) that was red on the tree the audit read and is green with its fix. +// +// The audit's rule, which every case here is an instance of: EVERY FIELD OF EVERY EMITTED RECORD +// NAMES THE FRONTEND SETTER THAT CHANGES IT, AND THAT SETTER MOVES A COUNTER THE EMITTING BIT'S +// SHUTTER READS. A record whose field has a setter no shutter sees is a stale record with nothing +// to refuse - no census line, no Fatal, a wrong picture or a permanent silent fallback - which is +// why none of the 80 scenarios before this file caught any of the four (Tracker.h carries the +// record-field -> setter -> shutter table this file is the gate for). +// +// F-3 set_framebuffer_state INLINES an attachment's format (D-C1) and a storage redefinition of +// an ATTACHED texture or renderbuffer moved nothing bit 11 read: Espryt's handle arm then +// answered its alpha-widening / snorm-clamp / integer masks from the stale copy while the +// legacy arm re-read the frontend. Three cases, both directions, texture and renderbuffer. +// DirectGLES only: the masks are Espryt's substitution machinery. +// F-1 set_sampler_views is resolved for the PROGRAM IN USE and bit 12's shutter read no program +// input, so a glUseProgram alone never re-emitted it; E's record epoch (the two set serials) +// then kept the program-independent texture sync list from ever rebuilding, and a texture +// bound to an EMPTY slot under one program was never synced for the next. One case, both +// backends, red as a black quad. +// F-2 bit 14's plain-program arm mixed a per-program COUNTER two programs routinely share, so a +// program switch never re-emitted set_shader_images and the window stayed the previous +// program's - and E's SD-4 (a buffer image never reaching the record at all) is the same +// bit through the null -> program transition. One case, white-box, both backends run it. +// F-4 BindCurrentUnitSamplers' record arm looked a CONTENT-addressed CSO handle up in the +// IDENTITY-keyed twin registry: a miss on every draw, hidden because the pre-handle program +// pass bound the same values. One case, white-box: the unit's driver sampler must be the +// CSO's own twin. +// +// A WHITE-BOX READING THAT CANNOT BE TAKEN IS DECLINED BY NAME AND THE CASE CONTINUES with its +// public-GL half (the shape TextureParamsWithoutASamplerViewScenario.cpp argues for): a pull +// build, Magma, or a lane whose mask leaves Espryt's sampler family on its legacy arm has no +// record arm to assert about, and skipping the whole case there would delete the public-GL +// verdict those lanes carry. Every decline is printed and RecordProperty'd. + +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/P4aSeamPeek.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kSize = 16; + constexpr int kInset = 2; + + // No attributes: the quad's corners come from gl_VertexID, so a bare VAO is all a draw + // needs and no vertex-input state can enter any of the sequences below. + constexpr const char* kQuadVS = R"(#version 330 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* kColorFS = R"(#version 330 core +uniform vec4 uColor; +out vec4 oColor; +void main() { oColor = uColor; } +)"; + + // texelFetch, so WHICH image the unit holds is the whole answer and no filter, wrap or + // completeness rule can explain a colour away. + constexpr const char* kFetchFS = R"(#version 330 core +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texelFetch(uTex, ivec2(0, 0), 0); } +)"; + + // texture() at (1.5, 1.5): outside the image on both axes, so the WRAP mode of whichever + // sampler applies - the unit's sampler object or the texture's built-in one - decides + // whether the texel or the border colour comes back. + constexpr const char* kOutsideSampleFS = R"(#version 330 core +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vec2(1.5, 1.5)); } +)"; + + // F-2 / SD-4: two compute programs over BUFFER images (the SD-4 shape - the kind E's I2 + // flip found never reached the record at all), the second naming one unit more than the + // first, and both image-unit counters equal (layout(binding) assigns the unit at link, so + // neither program ever moves it through glUniform1i). + constexpr const char* kOneBufferImageCS = R"(#version 430 core +layout(local_size_x = 1) in; +layout(binding = 0, r32ui) writeonly uniform uimageBuffer i0; +void main() { imageStore(i0, 0, uvec4(7u, 0u, 0u, 0u)); } +)"; + + constexpr const char* kTwoBufferImagesCS = R"(#version 430 core +layout(local_size_x = 1) in; +layout(binding = 0, r32ui) readonly uniform uimageBuffer i0; +layout(binding = 1, r32ui) writeonly uniform uimageBuffer i1; +void main() { imageStore(i1, 0, imageLoad(i0, 0) + uvec4(2u, 0u, 0u, 0u)); } +)"; + + class P4aSeamAuditScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glDisable(GL_BLEND); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + while (glGetError() != GL_NO_ERROR) { + } + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + glBindVertexArray(0); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + while (glGetError() != GL_NO_ERROR) { + } + } + + // The F-3 cases are about Espryt's four cross-object masks, which are its own + // substitution machinery (three-channel widening, SNORM/UNORM clamp, integer outputs); + // Magma answers the same GL questions on its own terms, so a verdict there would pin + // a coincidence - the same reason SnormAttachment and ThreeChannelAttachment skip. + // Marks the case skipped; the caller tests IsSkipped() and returns (GTEST_SKIP is a + // void statement, so it cannot return the verdict itself). + void SkipUnlessEspryt(const char* what) { + if (Gl().BackendName() == "DirectGLES") return; + GTEST_SKIP() << what << " is a DirectGLES handle-arm seam; backend is " << Gl().BackendName(); + } + + static void DrawQuad() { glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); } + + // One pixel's RGBA as floats, from the currently bound READ framebuffer. + static void ReadPixelFloat(int x, int y, float out[4]) { + out[0] = out[1] = out[2] = out[3] = -1.0f; + glReadPixels(x, y, 1, 1, GL_RGBA, GL_FLOAT, out); + } + + // The white-box gate shared by F-2 and F-4: true when Espryt's sampler family is on + // its handle arm in this process, so the applier's unit sets are consumed and an + // assertion about them can only be red for its own reason. Prints the decline. + bool SamplerHandleArmIsLive(const char* what) { + bool live = false; + std::string why; + if (!PeekEsprytSamplerHandleArmIsLive(&live)) { + why = "the reading cannot be taken here (a pull build, Android, or a backend that " + "is not DirectGLES)"; + } else if (!live) { + why = "Espryt's sampler family runs its legacy arm in this process " + "(MOBILEGL_PIPE_PUSH leaves bit 11 clear or refuses it)"; + } + if (why.empty()) return true; + std::cout << "[ P4aSeamAudit ] white-box reading DECLINED for " << what << ": " << why + << "; the public-GL half of the case still runs" << std::endl; + RecordProperty("p4a_seam_white_box", "declined"); + RecordProperty("p4a_seam_white_box_reason", why); + return false; + } + + // A 2x2 RGBA8 texture filled with one colour, NEAREST, single level - complete under + // every rule, so nothing about completeness can enter the F-1 and F-4 sequences. + static GLuint MakeSolidTexture2D(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + std::uint8_t texels[2 * 2 * 4]; + for (int i = 0; i < 4; ++i) { + texels[i * 4 + 0] = r; + texels[i * 4 + 1] = g; + texels[i * 4 + 2] = b; + texels[i * 4 + 3] = 255; + } + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + return texture; + } + + bool ComputeImagesAreUsable() const { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + GLint maxComputeImageUniforms = 0; + glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms); + GLint maxBufferSize = 0; + glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &maxBufferSize); + while (glGetError() != GL_NO_ERROR) { + } + return maxImageUnits >= 2 && maxComputeImageUniforms >= 2 && maxBufferSize >= 4; + } + + static GLuint MakeComputeProgram(const char* source, std::string* outError) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + *outError = std::string("compute shader did not compile: ") + log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + *outError = std::string("compute program did not link: ") + log; + glDeleteProgram(program); + return 0; + } + return program; + } + + // An R32UI buffer texture over a fresh 4-texel buffer, every texel `fill`. + static GLuint MakeBufferTexture(GLuint* outBuffer, GLuint fill) { + const GLuint texels[4] = {fill, fill, fill, fill}; + glGenBuffers(1, outBuffer); + glBindBuffer(GL_TEXTURE_BUFFER, *outBuffer); + glBufferData(GL_TEXTURE_BUFFER, sizeof(texels), texels, GL_DYNAMIC_COPY); + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_BUFFER, texture); + glTexBuffer(GL_TEXTURE_BUFFER, GL_R32UI, *outBuffer); + return texture; + } + + static GLuint ReadBufferTexel0(GLuint buffer) { + GLuint value = 0xFFFFFFFFu; + glBindBuffer(GL_TEXTURE_BUFFER, buffer); + glGetBufferSubData(GL_TEXTURE_BUFFER, 0, sizeof(value), &value); + return value; + } + + GLuint m_vao = 0; + }; + + // ----------------------------------------------------------------------------------- + // F-3: a storage redefinition WHILE ATTACHED reaches the framebuffer record + // ----------------------------------------------------------------------------------- + // + // GL_SRGB8 is a format Espryt can only render into through its three-channel widening + // (llvmpipe reports INCOMPLETE_ATTACHMENT for it natively - ThreeChannelAttachmentScenario + // measured the table), so its draw buffer carries the alpha-widened mask: every draw has its + // alpha masked off so the stored alpha stays at the 1.0 a three-channel format implies. + // Redefine the same attached texture as GL_SRGB8_ALPHA8 and the application owns alpha + // again - the mask must clear. On the tree the audit read the record still said SRGB8, the + // handle arm kept masking, and the 0.25 this case draws never reached the storage. + + TEST_F(P4aSeamAuditScenario, ATextureRespecifiedWhileAttachedReachesTheFramebufferRecord) { + if (!Ready()) return; + SkipUnlessEspryt("F-3"); + if (IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kQuadVS, kColorFS, &error); + ASSERT_NE(program, 0u) << error; + const GLint colorLocation = glGetUniformLocation(program, "uColor"); + ASSERT_GE(colorLocation, 0); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8, kSize, kSize, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8 texture was refused"; + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "an SRGB8 colour attachment must be complete (natively or through the widening)"; + glViewport(0, 0, kSize, kSize); + glUseProgram(program); + glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f); + + // Phase 1: the three-channel format. Whatever the draw writes, GL reports alpha 1.0. + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + DrawQuad(); + float pixel[4]; + glReadBuffer(GL_COLOR_ATTACHMENT0); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[3], 1.0f, 0.02f) << "a three-channel attachment reports alpha 1.0"; + + // Phase 2: THE RESPECIFY, while attached, with no re-attach and no rebind of the FBO. + // The only thing that moves between the two draws is the texture's storage. + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kSize, kSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + ASSERT_EQ(FirstGLError(), 0u) << "the respecify to SRGB8_ALPHA8 was refused"; + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + DrawQuad(); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[1], 1.0f, 0.05f) << "the draw did not land at all"; + EXPECT_NEAR(pixel[3], 0.25f, 0.02f) + << "the draw's alpha never reached a four-channel attachment: the framebuffer record " + "still describes the three-channel storage the texture was attached with, so the " + "handle arm kept masking alpha off (F-3)"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The renderbuffer twin. A renderbuffer's three storage setters bump no version at all: + // D-D2 closed the RESOURCE record by emitting from the entry point and left the framebuffer + // record - and with it the masks - describing the storage it was attached with. + TEST_F(P4aSeamAuditScenario, ARenderbufferRestoragedWhileAttachedReachesTheFramebufferRecord) { + if (!Ready()) return; + SkipUnlessEspryt("F-3"); + if (IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kQuadVS, kColorFS, &error); + ASSERT_NE(program, 0u) << error; + const GLint colorLocation = glGetUniformLocation(program, "uColor"); + ASSERT_GE(colorLocation, 0); + + GLuint renderbuffer = 0; + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_SRGB8, kSize, kSize); + ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8 renderbuffer was refused"; + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "an SRGB8 renderbuffer attachment must be complete (natively or through the widening)"; + glViewport(0, 0, kSize, kSize); + glUseProgram(program); + glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f); + + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + DrawQuad(); + float pixel[4]; + glReadBuffer(GL_COLOR_ATTACHMENT0); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[3], 1.0f, 0.02f) << "a three-channel attachment reports alpha 1.0"; + + // THE RE-STORAGE, while attached. + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_SRGB8_ALPHA8, kSize, kSize); + ASSERT_EQ(FirstGLError(), 0u) << "the re-storage to SRGB8_ALPHA8 was refused"; + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + DrawQuad(); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[1], 1.0f, 0.05f) << "the draw did not land at all"; + EXPECT_NEAR(pixel[3], 0.25f, 0.02f) + << "the draw's alpha never reached the four-channel renderbuffer: the framebuffer " + "record still describes the storage it was attached with (F-3)"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &renderbuffer); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The mirror direction, four channels -> three, and it needs the driver to READ the stored + // alpha because the readback fixup (which consults the frontend) would hide it: after the + // respecify to SRGB8 the widening discipline has to hold - the clear puts 1.0 into the + // carrier's alpha and the draw is masked away from it - so a GL_DST_ALPHA blend of white sees + // 1.0. On a stale record the draw wrote its 0.25 into the carrier and the blend saw that. + TEST_F(P4aSeamAuditScenario, ATextureRespecifiedToThreeChannelsWhileAttachedReachesTheFramebufferRecord) { + if (!Ready()) return; + SkipUnlessEspryt("F-3"); + if (IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kQuadVS, kColorFS, &error); + ASSERT_NE(program, 0u) << error; + const GLint colorLocation = glGetUniformLocation(program, "uColor"); + ASSERT_GE(colorLocation, 0); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kSize, kSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8_ALPHA8 texture was refused"; + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, kSize, kSize); + glUseProgram(program); + + // Phase 1: four channels, the application owns alpha. + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f); + DrawQuad(); + float pixel[4]; + glReadBuffer(GL_COLOR_ATTACHMENT0); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[3], 0.25f, 0.02f) << "a four-channel attachment stores the draw's alpha"; + + // Phase 2: THE RESPECIFY to three channels, while attached. + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8, kSize, kSize, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr); + ASSERT_EQ(FirstGLError(), 0u) << "the respecify to SRGB8 was refused"; + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + glDisable(GL_BLEND); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f); + DrawQuad(); + // dst = stored alpha; src factor GL_DST_ALPHA, dst factor GL_ZERO, source white => + // the colour becomes (storedAlpha, storedAlpha, storedAlpha) - ThreeChannelAttachment's + // own probe, which nothing on the readback path can doctor. + glEnable(GL_BLEND); + glBlendFunc(GL_DST_ALPHA, GL_ZERO); + glUniform4f(colorLocation, 1.0f, 1.0f, 1.0f, 1.0f); + DrawQuad(); + glDisable(GL_BLEND); + ReadPixelFloat(kSize / 2, kSize / 2, pixel); + EXPECT_NEAR(pixel[0], 1.0f, 0.05f) + << "GL_DST_ALPHA read the stored alpha of a three-channel attachment and it was not " + "1.0: the framebuffer record still describes the four-channel storage the texture " + "was attached with, so the handle arm let the draw write alpha (F-3, mirror)"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h index 271a0c61..04c80925 100644 --- a/MobileGL/MG_Pipe/PipeMutation.h +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -56,7 +56,11 @@ namespace MobileGL::MG_Pipe { enum class MGPipeAggregate : Uint32 { // VertexArrayState: any VAO attribute format / buffer / enable moved. VaoAttribute = 0, - // FramebufferState: any FBO attachment or default-geometry write, or a bind. + // FramebufferState: any FBO attachment or default-geometry write, or a bind - and, since + // P4a (fable seam F-3), any STORAGE DEFINITION of a texture or a renderbuffer, because + // set_framebuffer_state inlines an attachment's format, extent and samples and those + // setters are the only writers of what it inlines (TextureObject.cpp / + // RenderbufferObject.cpp, PipePublishDescriptor). FramebufferAttachment, // TextureState: any texture object CONTENT moved (an upload, a dirty region). TextureContent, diff --git a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp index 21689f25..81ea3ae5 100644 --- a/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp +++ b/MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.cpp @@ -137,18 +137,28 @@ namespace MobileGL { #if MOBILEGL_PIPE_PUSH // D-D2: THE RENDERBUFFER PUBLICATION HOLE, CLOSED BY EMISSION AND NOT BY A NEW - // VERSION. These three setters bump no version and raise no notice, and the - // framebuffer dirty bit's shutter does not move when an ALREADY-ATTACHED renderbuffer - // is re-storaged - so `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an - // attached renderbuffer was invisible to everything downstream. Emitting from the - // storage entry point closes it; a version counter here would resize the pull build's - // object and break G1, and widening the shutter would fire the framebuffer emission on - // an unrelated renderbuffer write. + // VERSION. These three setters bump no version and raise no notice, so + // `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an attached renderbuffer + // was invisible to everything downstream. Emitting from the storage entry point + // closes the RESOURCE half; a version counter here would resize the pull build's + // object and break G1. + // + // THE FRAMEBUFFER HALF IS THE AGGREGATE BUMP BELOW (P4a fable seam F-3), and the + // sentence that used to end the paragraph above - "widening the shutter would fire + // the framebuffer emission on an unrelated renderbuffer write" - was the seam: + // set_framebuffer_state inlines an attachment's InternalFormat, extent and Samples at + // emission (D-C1), so re-storaging an ATTACHED renderbuffer left the framebuffer + // record - and the handle arm's four cross-object masks - describing the previous + // storage while the resource record described the new one. The bump costs one + // framebuffer re-emission per storage definition, whether or not the object is + // attached, which the emitter's content hash suppresses when nothing it inlines + // moved; it is not a counter on this object. // // The emitter dedupes on the built descriptor, so glRenderbufferStorage's three-setter // sequence publishes once rather than three times. void RenderbufferObject::PipePublishDescriptor() { MG_Pipe::MGPipeEmitRenderbufferResourceRespecify(*this); + MGP_NOTE_AGGREGATE(FramebufferAttachment); } #endif } // namespace GLState diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp index 5626a273..cbf49536 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.cpp @@ -67,6 +67,20 @@ namespace MobileGL { void TextureObjectBase::PipePublishDescriptor() { MG_Pipe::MGPipeEmitTextureResourceRespecify(*this); + // AND THE FRAMEBUFFER AGGREGATE MOVES (P4a fable seam F-3). The resource record + // above is only half of what a storage definition changes: set_framebuffer_state + // INLINES an attachment's InternalFormat, TextureTarget, extent, Samples and + // Complete at emission (D-C1), so redefining the storage of a texture that is + // ATTACHED changed those fields with nothing bit 11 reads moving - the format + // and shape setters bump the two TEXTURE aggregates and never the attachment + // one, and no path from a texture reaches its framebuffers. The handle arm then + // answered its four cross-object masks from the stale copy while the legacy arm + // re-read the frontend at the same re-sync. This is the one funnel every + // storage-defining entry point takes (see AllocateStorage), so the bump lives + // here and not per setter, it is push-only like the rest of this block, and it + // over-fires the framebuffer bit once per storage definition of an unattached + // texture - at load time, where a 304-byte hash is nothing. + MGP_NOTE_AGGREGATE(FramebufferAttachment); } void TextureObjectBase::PipePublishParams() { diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 7fd283b7..dbd3a5a3 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -80,6 +80,8 @@ namespace { X(TrackerWalk, ARestagedProgramPipelineFiresTheProgramBits) \ X(TrackerWalk, ARelinkOfAStageProgramFiresTheProgramBits) \ X(TrackerWalk, UseProgramZeroLeavesTheBoundPipelineDrivingTheProgramBits) \ + X(TrackerAggregates, ATextureStorageDefinitionMovesTheFramebufferAggregateToo) \ + X(TrackerAggregates, ARenderbufferStorageDefinitionMovesTheFramebufferAggregate) \ X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \ X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \ X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \ @@ -232,6 +234,39 @@ namespace { SUCCEED(); } + // P4a FABLE SEAM F-3. set_framebuffer_state INLINES an attachment's format, extent and + // samples (D-C1), so the setters that define a texture's storage are setters of a + // framebuffer-record field - and the record-field -> setter -> shutter rule (Tracker.h's + // table) says they must move the aggregate bit 11 reads. They still move the params + // aggregate they always moved; what this case pins is the SECOND bump, which the + // "moves only" cases above cannot see and which is the whole of F-3's fix. + TEST_F(TrackerAggregates, ATextureStorageDefinitionMovesTheFramebufferAggregateToo) { + const auto& tex = Ctx().CreateTextureObject(3, TextureTarget::Texture2D); + ASSERT_TRUE(tex != nullptr); + const Snapshot before = Snap(); + tex->SetInternalFormat(MobileGL::TextureInternalFormat::RGBA8); + const Snapshot after = Snap(); + EXPECT_GT(after[MGPipeAggregate::FramebufferAttachment], before[MGPipeAggregate::FramebufferAttachment]) + << "a texture whose storage is redefined WHILE ATTACHED left set_framebuffer_state " + "describing the previous format (F-3)"; + EXPECT_GT(after[MGPipeAggregate::TextureParams], before[MGPipeAggregate::TextureParams]); + EXPECT_EQ(after[MGPipeAggregate::TextureContent], before[MGPipeAggregate::TextureContent]); + EXPECT_EQ(after[MGPipeAggregate::VaoAttribute], before[MGPipeAggregate::VaoAttribute]); + EXPECT_EQ(after[MGPipeAggregate::BufferChange], before[MGPipeAggregate::BufferChange]); + EXPECT_EQ(after[MGPipeAggregate::VertexAttribDefault], before[MGPipeAggregate::VertexAttribDefault]); + } + + // The renderbuffer twin, and the one that had NO aggregate at all before: its three storage + // setters bumped no version and raised no notice (D-D2 closed the resource record by emitting + // from the entry point and left the framebuffer record stale). + TEST_F(TrackerAggregates, ARenderbufferStorageDefinitionMovesTheFramebufferAggregate) { + const auto& rbo = Ctx().CreateRenderbufferObject(1); + ASSERT_TRUE(rbo != nullptr); + const Snapshot before = Snap(); + rbo->AllocateStorage(IntVec2{8, 8}); + ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap()); + } + // =================================================================================== // The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7) // ===================================================================================