Files
MobileGL/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp
T

1192 lines
67 KiB
C++

// MobileGL - MobileGL/MG_Test/Pipe/FramebufferEmitTest.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
// P4a's framebuffer family: set_framebuffer_state, emitted per bound target, on both sides of
// the call.
//
// THIS SUITE IS A NAMED GATE. The phase's descriptor-consistency gate is "for every framebuffer
// configuration the emitted MGPFramebufferState reproduces exactly the values the backend's
// SyncToBackend family reads from the frontend today, field by field", and it is spelled
// `ctest -R 'FramebufferEmit\.'`; its negative control is a script that stops the conversion
// copying ONE field (MGPSurface::Layered) and expects this suite to go red NAMING that field.
// So a case here must fail by field name, never by a bare count, or the control cannot answer.
//
// THE SUITE IS `FramebufferEmit`, not `FramebufferEmitTest`: the file is XTest.cpp and the
// suite is X, this directory's convention, and it is what the gates grep for.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. The
// applier-side cases (a record's lifecycle, the per-target storage, what a make-current does
// and does not clear) are the wire commits'; the emitter-side cases (the resolved read
// surface, the draw-buffer array in the content hash, a recycled handle never suppressed
// against its predecessor, every attachment field surviving the surface conversion, an
// attachment point above the wire width refused rather than truncated, a re-storaged attached
// renderbuffer publishing its new extent) are the client package's - and neither of them has
// to come back to MG_Test/Pipe/CMakeLists.txt to add one.
//
// IT HAS ITS OWN main() for the same reason ResourceEmitTest and VertexInputEmitTest do: the
// applier's bounds and protocol trip wires report through a log line in a shipped push build
// and std::abort() in a poison or verify one, so a case that drives one reads the line back
// out of a file this process names before anything logs.
//
// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N`
// stays name-for-name identical between the pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/FramebufferEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_State/GLState/RenderbufferState/RenderbufferObject.h>
#include <MG_State/GLState/TextureState/TextureObject2D.h>
#include <MG_State/GLState/TextureState/TextureObject2DCube.h>
#include <algorithm>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
// A record with real values in every field a case might read back, so a body that stored
// the wrong one - or stored nothing - is visible BY FIELD.
MGPFramebufferState FramebufferRecord(MGPipeHandle fbo, MGPipeFramebufferTarget target, Uint16 width) {
MGPFramebufferState state{};
state.Fbo = fbo;
state.Target = static_cast<Uint8>(target);
state.Width = width;
state.Height = 64;
state.Layers = 1;
state.Samples = 1;
state.Complete = 1;
state.ContentHash = 0x1234u + width;
for (Uint32 i = 0; i < kMGPipeMaxColorAttachments; ++i) {
state.DrawBuffers[i] = static_cast<Int8>(i == 0 ? 0 : -1);
}
state.Color[0].Res = MGPipeHandle{9, 1};
state.Color[0].InternalFormat = 0x8058u; // GL_RGBA8
state.Color[0].Kind = 1;
return state;
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// The one case the contract commit lands, and it is not a placeholder: it pins the SHAPE every
// later case depends on. The emitter is a process singleton that is heap-constructed and
// intentionally leaked, because a static holding client state whose destructor an exit handler
// can run is the exit-order use-after-free this design closed once already - `exit` runs the
// frontend's own teardown into a pipe whose allocator has already been destroyed. One
// allocation for the life of the process, no destructor to lose.
TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(&MGPipeFramebufferEmitterInstance(), &MGPipeFramebufferEmitterInstance());
// And the family's wired-subsystem constant is either 0 or its own bit and nothing else.
// It is 0 until this family's emitter has a body; the OR in PipeFill.cpp is what turns it
// into the switch, so a header that set the wrong bit would switch the wrong family on.
EXPECT_TRUE(kMGPipeWiredFramebufferSubsystem == 0 ||
kMGPipeWiredFramebufferSubsystem == kMGPipeSubsystemFramebuffer);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build";
#endif
}
// ============================================================================
// P4a package B's EMITTER-SIDE cases. The contract commit landed the file, its ctest
// registration and one shape pin; the wire package's applier-side cases and these are disjoint
// TEST bodies in one file, and a collision between them is resolved by UNION, never by
// choosing a side.
//
// EVERY CASE FAILS BY FIELD NAME, never by a bare count: G7's scripted control stops the
// conversion copying exactly one member (MGPSurface::Layered) and expects this suite to go red
// NAMING that field, and a case that reported only "the records differ" could not answer it.
// ============================================================================
#if !MOBILEGL_PIPE_PUSH
#define MGL_FRAMEBUFFER_EMIT_CLIENT_TEST_LIST(X) \
X(FramebufferEmit, EveryRecordsReadSurfaceComesFromItsOwnFramebuffersReadBuffer) \
X(FramebufferEmit, OneObjectBoundToBothTargetsEmitsOneRecordWithTargetBoth) \
X(FramebufferEmit, ADrawBufferChangeAloneStillMovesTheContentHash) \
X(FramebufferEmit, ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor) \
X(FramebufferEmit, EveryAttachmentFieldSurvivesTheSurfaceConversion) \
X(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) \
X(FramebufferEmit, ARestoragedAttachedRenderbufferPublishesItsNewExtent) \
X(FramebufferEmit, AnUnchangedBindingPairEmitsNothing) \
X(FramebufferEmit, AFramebufferHandedOverByNameGetsANamedRecordWithoutMovingABinding) \
X(FramebufferEmit, ANamedRecordIsSuppressedPerObjectAndNeverAgainstABoundRecord) \
X(FramebufferEmit, ADrawBufferTokenAboveTheWireWidthIsRefusedNotTruncated) \
X(FramebufferEmit, ALayeredCubeAttachmentDoesNotAssertAFaceItCannotKnow) \
X(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) \
X(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_FRAMEBUFFER_EMIT_CLIENT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::FramebufferObject;
using MG_State::GLState::MipmapInput;
using MG_State::GLState::RenderbufferObject;
using MG_State::GLState::TextureObject2D;
using MG_State::GLState::TextureObject2DCube;
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason, and it arms
// BOTH bits this family needs: the framebuffer bit for the emission itself, and the
// texture-resource bit because MGPSurface::Res names a texture or renderbuffer handle and
// bit 9 requires bit 10 for exactly that reason.
struct FramebufferScope {
FramebufferScope() {
m_previousPush = MG_Config::Features.PipePush;
// D-K2, ALL THREE ROWS THAT REACH THIS SUITE, because the client enforces them since
// S-3 / ID-41 and not only Espryt's Resolve*SubsystemArm: bit 9 requires bit 10
// (MGPSurface::Res names a texture or renderbuffer handle), bit 10 requires bit 11
// (MGPTextureParams::BuiltinSampler is a SamplerCso out of the sampler family's
// content-addressed cache) and bit 10 requires bit 7 (a buffer texture's
// BufferForTexBuffer names a Buffer handle, D-D1). Bit 7 changes nothing else here:
// this file constructs no BufferObject.
MG_Config::Features.PipePush |= kMGPipeSubsystemResources |
kMGPipeSubsystemFramebuffer |
kMGPipeSubsystemTextureResources |
kMGPipeSubsystemSamplers;
m_previousContext = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
MGPipeFramebufferEmitterInstance().ResetForTest();
MGPipeTextureEmitterInstance().ResetForTest();
MGPipeSetHashSuppressorInstance().InvalidateAll();
// The applier is a process singleton and its object records outlive a case; with both
// families' wired constants set the emissions actually land, so a case must start
// from an empty table (v1 armed the texture emitter here instead - ArmForTest is
// gone with the flip).
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~FramebufferScope() {
MGPipeFramebufferEmitterInstance().ResetForTest();
MGPipeTextureEmitterInstance().ResetForTest();
MGPipeSetHashSuppressorInstance().InvalidateAll();
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
MG_State::pGLContext.reset();
MG_State::pGLContext = Move(m_previousContext);
MG_Config::Features.PipePush = m_previousPush;
}
FramebufferScope(const FramebufferScope&) = delete;
FramebufferScope& operator=(const FramebufferScope&) = delete;
UniquePtr<GLContext> m_previousContext;
Uint64 m_previousPush = 0;
};
MGPipeFramebufferEmitter& Framebuffers() { return MGPipeFramebufferEmitterInstance(); }
GLContext& Ctx() { return *MG_State::pGLContext; }
SharedPtr<TextureObject2D> MakeColorTexture(Uint name, Int size, Uint levels = 1) {
auto texture = MakeShared<TextureObject2D>(name);
texture->SetInternalFormat(TextureInternalFormat::RGBA8);
for (Uint level = 0; level < levels; ++level) {
const Int extent = std::max<Int>(size >> level, 1);
texture->AllocateStorage(TextureUploadTarget::Texture2D, level,
MipmapInput{IntVec3{extent, extent, 1},
static_cast<SizeT>(extent) * static_cast<SizeT>(extent) * 4});
}
return texture;
}
void BindDrawAndRead(const SharedPtr<FramebufferObject>& draw, const SharedPtr<FramebufferObject>& read) {
Ctx().GetFramebufferBindingSlot(FramebufferTarget::Draw).Bind(draw);
Ctx().GetFramebufferBindingSlot(FramebufferTarget::Read).Bind(read);
}
} // namespace
// ============================ D-C1 / D-C2 ============================
//
// THE RESOLVED READ SURFACE IS WHAT STRUCTURALLY CLOSES THE read-buffer-shared-FBO DEFECT
// CLASS. The record carries the surface itself rather than an index, and EVERY record resolves
// it from the framebuffer ITS OWN Fbo names - Named included (c0e / MGPipeTypes.h). v1 resolved
// a DRAW record's ReadSurface from the READ-bound object, which was D-C2's letter and muddled
// in substance: no field of this record may refer to "whatever is bound", and a glReadBuffer on
// the read FBO moved the draw record's ContentHash and forced a redundant draw emission.
TEST(FramebufferEmit, EveryRecordsReadSurfaceComesFromItsOwnFramebuffersReadBuffer) {
FramebufferScope scope;
const auto drawColor = MakeColorTexture(1, 32);
const auto readColor0 = MakeColorTexture(2, 32);
const auto readColor1 = MakeColorTexture(3, 32);
const auto drawFbo = MakeShared<FramebufferObject>(1);
drawFbo->AttachTexture(FramebufferAttachmentType::Color0, drawColor, TextureUploadTarget::Texture2D);
const auto readFbo = MakeShared<FramebufferObject>(2);
readFbo->AttachTexture(FramebufferAttachmentType::Color0, readColor0, TextureUploadTarget::Texture2D);
readFbo->AttachTexture(FramebufferAttachmentType::Color1, readColor1, TextureUploadTarget::Texture2D);
readFbo->SetReadBuffer(FramebufferAttachmentType::Color1);
BindDrawAndRead(drawFbo, readFbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 2u) << "two distinct bindings are two records";
const MGPipeHandle readColor1Handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, readColor1->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(readColor1Handle));
const MGPipeHandle drawColorHandle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, drawColor->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(drawColorHandle));
EXPECT_EQ(Framebuffers().LastDraw().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Draw));
EXPECT_TRUE(Framebuffers().LastDraw().ReadSurface.Res == drawColorHandle)
<< "the DRAW record's ReadSurface names a surface that is not part of the framebuffer its "
"own Fbo names";
EXPECT_EQ(Framebuffers().LastRead().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Read));
EXPECT_TRUE(Framebuffers().LastRead().ReadSurface.Res == readColor1Handle)
<< "MGPFramebufferState::ReadSurface on the READ record did not come from that "
"framebuffer's own read buffer";
// AND THE DRAW RECORD DOES NOT MOVE WHEN THE READ FRAMEBUFFER'S READ BUFFER DOES.
const Uint64 drawHashBefore = Framebuffers().LastDraw().ContentHash;
const Uint64 emissionsBefore = Framebuffers().EmissionCount();
readFbo->SetReadBuffer(FramebufferAttachmentType::Color0);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), emissionsBefore + 1)
<< "only the READ record moved, so exactly one record goes out";
EXPECT_EQ(Framebuffers().LastDraw().ContentHash, drawHashBefore)
<< "a glReadBuffer on the read framebuffer moved the DRAW record's content hash";
// And the draw-buffer array belongs to the DRAW object, whichever record carries it.
EXPECT_EQ(Framebuffers().LastDraw().DrawBuffers[0], 0);
}
TEST(FramebufferEmit, OneObjectBoundToBothTargetsEmitsOneRecordWithTargetBoth) {
FramebufferScope scope;
const auto color0 = MakeColorTexture(4, 32);
const auto color1 = MakeColorTexture(5, 32);
const auto fbo = MakeShared<FramebufferObject>(3);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color0, TextureUploadTarget::Texture2D);
fbo->AttachTexture(FramebufferAttachmentType::Color1, color1, TextureUploadTarget::Texture2D);
fbo->SetReadBuffer(FramebufferAttachmentType::Color1);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u) << "one object on both targets is ONE record";
EXPECT_EQ(Framebuffers().LastDraw().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Both));
const MGPipeHandle color1Handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, color1->GetLifetimeId());
EXPECT_TRUE(Framebuffers().LastDraw().ReadSurface.Res == color1Handle)
<< "the shared-FBO record's ReadSurface is not that object's own read buffer";
}
// ============================ D-C4 ============================
//
// THE fragColor BROADCAST TRAP. The backend derives the broadcast count from the draw-buffer
// array, at the verb, from the framebuffer state it then holds - precisely so a program can
// relink inside the same draw. A hash that did not cover the array would let a suppressed
// set_framebuffer_state mean "the draw buffers did not move" when they had, and the shader
// would be specialised for the previous output shape.
TEST(FramebufferEmit, ADrawBufferChangeAloneStillMovesTheContentHash) {
FramebufferScope scope;
const auto color0 = MakeColorTexture(6, 32);
const auto color1 = MakeColorTexture(7, 32);
const auto fbo = MakeShared<FramebufferObject>(4);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color0, TextureUploadTarget::Texture2D);
fbo->AttachTexture(FramebufferAttachmentType::Color1, color1, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
const Uint64 before = Framebuffers().LastDraw().ContentHash;
const Int8 slot1Before = Framebuffers().LastDraw().DrawBuffers[1];
// NOTHING BUT THE DRAW-BUFFER ARRAY MOVES: the same attachments, the same extent, the same
// completeness answer, the same handle.
fbo->SetDrawBuffer(1, FramebufferAttachmentType::Color1);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 2u)
<< "a draw-buffer change alone was suppressed, which would freeze the fragColor "
"broadcast count at the previous output shape";
EXPECT_NE(Framebuffers().LastDraw().ContentHash, before)
<< "MGPFramebufferState::ContentHash does not cover DrawBuffers[]";
EXPECT_NE(Framebuffers().LastDraw().DrawBuffers[1], slot1Before);
EXPECT_EQ(Framebuffers().LastDraw().DrawBuffers[1], 1);
}
TEST(FramebufferEmit, ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor) {
FramebufferScope scope;
// The SAME attachment set on both objects, so every other field of the record is identical
// and Fbo is the only thing that can move the hash.
const auto color = MakeColorTexture(8, 32);
Uint64 firstHash = 0;
MGPipeHandle firstHandle{};
{
const auto first = MakeShared<FramebufferObject>(5);
first->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(first, first);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
firstHash = Framebuffers().LastDraw().ContentHash;
firstHandle = Framebuffers().LastDraw().Fbo;
BindDrawAndRead(nullptr, nullptr);
}
const auto second = MakeShared<FramebufferObject>(5);
second->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(second, second);
Framebuffers().EmitFramebufferState(Ctx());
const MGPFramebufferState& record = Framebuffers().LastDraw();
EXPECT_EQ(record.Fbo.Slot, firstHandle.Slot) << "the slot was not recycled; the case proves nothing";
EXPECT_NE(record.Fbo.Gen, firstHandle.Gen) << "a recycled slot must carry a new generation";
EXPECT_NE(record.ContentHash, firstHash)
<< "MGPFramebufferState::ContentHash does not cover Fbo, so a recycled framebuffer handle "
"would be suppressed against its predecessor's record";
}
// ============================ G6 ============================
//
// "For every framebuffer configuration the emitted MGPFramebufferState reproduces exactly the
// values the backend's SyncToBackend family reads from the frontend today, field by field."
// The oracle is the frontend attachment itself, read back through the same getters the twin
// uses, so this cannot drift into asserting what the emitter happens to do.
TEST(FramebufferEmit, EveryAttachmentFieldSurvivesTheSurfaceConversion) {
FramebufferScope scope;
const auto fbo = MakeShared<FramebufferObject>(6);
Vector<SharedPtr<TextureObject2D>> colors;
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
colors.push_back(MakeColorTexture(static_cast<Uint>(20 + i), 32, 2));
// Distinct level, layer and layered flag per point, so a conversion that dropped one
// field could not be masked by another point's value.
fbo->AttachTexture(static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) + static_cast<Int>(i)),
colors.back(), TextureUploadTarget::Texture2D,
static_cast<int>(i % 2), static_cast<int>(i), (i % 2) == 0);
}
const auto depth = MakeShared<RenderbufferObject>(2);
depth->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
depth->AllocateStorage(IntVec2{32, 32});
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, depth);
const auto stencil = MakeColorTexture(40, 32);
fbo->AttachTexture(FramebufferAttachmentType::Stencil, stencil, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
const MGPFramebufferState& record = Framebuffers().LastDraw();
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
const auto type = static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) + static_cast<Int>(i));
const auto& attachment = fbo->GetAttachment(type);
const MGPSurface& surface = record.Color[i];
const MGPipeHandle expected =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, attachment.GetTexture()->GetLifetimeId());
EXPECT_TRUE(surface.Res == expected) << "MGPSurface::Res at colour point " << i;
EXPECT_EQ(surface.Kind, kMGPipeSurfaceKindTexture) << "MGPSurface::Kind at colour point " << i;
EXPECT_EQ(surface.InternalFormat, static_cast<Uint32>(attachment.GetTexture()->GetFormat()))
<< "MGPSurface::InternalFormat at colour point " << i;
EXPECT_EQ(surface.Level, static_cast<Uint16>(attachment.GetTextureLevel()))
<< "MGPSurface::Level at colour point " << i;
EXPECT_EQ(surface.Layer, static_cast<Uint32>(attachment.GetTextureLayer()))
<< "MGPSurface::Layer at colour point " << i;
EXPECT_EQ(surface.Layered, attachment.IsLayered() ? 1 : 0)
<< "MGPSurface::Layered at colour point " << i;
EXPECT_EQ(surface.UploadTarget, static_cast<Uint16>(TextureUploadTarget::Texture2D))
<< "MGPSurface::UploadTarget at colour point " << i;
// ID-12 DV-5: three of the four cross-object masks reduce to (format, TEXTURE TARGET)
// and no TextureUploadTarget -> TextureTarget inverse exists anywhere in the tree.
EXPECT_EQ(surface.TextureTarget,
static_cast<Uint16>(attachment.GetTexture()->GetTarget()))
<< "MGPSurface::TextureTarget at colour point " << i;
}
EXPECT_EQ(record.Depth.Kind, kMGPipeSurfaceKindRenderbuffer) << "MGPSurface::Kind on the depth point";
EXPECT_EQ(record.Depth.InternalFormat, static_cast<Uint32>(depth->GetInternalFormat()))
<< "MGPSurface::InternalFormat on the depth point";
EXPECT_TRUE(record.Depth.Res ==
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, depth->GetLifetimeId()))
<< "MGPSurface::Res on the depth point";
EXPECT_EQ(record.Depth.Layered, 0) << "MGPSurface::Layered on the depth point";
EXPECT_EQ(record.Depth.TextureTarget, kMGPipeSurfaceNoTextureTarget)
<< "MGPSurface::TextureTarget on the RENDERBUFFER point must be the Unknown sentinel";
EXPECT_EQ(record.Stencil.Kind, kMGPipeSurfaceKindTexture) << "MGPSurface::Kind on the stencil point";
EXPECT_TRUE(record.Stencil.Res ==
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, stencil->GetLifetimeId()))
<< "MGPSurface::Res on the stencil point";
EXPECT_EQ(record.Width, 32u) << "MGPFramebufferState::Width";
EXPECT_EQ(record.Height, 32u) << "MGPFramebufferState::Height";
EXPECT_EQ(record.IsDefault, 0) << "MGPFramebufferState::IsDefault";
// Complete is FramebufferObject::CheckCompleteness(), the FRONTEND-only answer, and never
// glCheckFramebufferStatus's - that entry point additionally consults the backend's probed
// format-capability cache, which a client emitting it would be reading from the wrong side.
EXPECT_EQ(record.Complete, fbo->CheckCompleteness() ? 1 : 0) << "MGPFramebufferState::Complete";
// And an empty point really is {null, None} and every other field zero.
const auto emptyFbo = MakeShared<FramebufferObject>(7);
emptyFbo->AttachTexture(FramebufferAttachmentType::Color0, colors[0], TextureUploadTarget::Texture2D);
BindDrawAndRead(emptyFbo, emptyFbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_TRUE(MGPipeHandleIsNull(Framebuffers().LastDraw().Color[1].Res));
EXPECT_EQ(Framebuffers().LastDraw().Color[1].Kind, kMGPipeSurfaceKindNone);
EXPECT_EQ(Framebuffers().LastDraw().Color[1].InternalFormat, 0u);
// BOTH TARGET FIELDS CARRY THE SENTINEL, not 0 (m6 / ID-12 DV-5): a Uint16 zero is
// TextureUploadTarget::Texture1D and TextureTarget::Texture1D, so a reader that forgot to
// gate on Kind would read a plausible wrong answer instead of a nonsense one.
EXPECT_EQ(Framebuffers().LastDraw().Color[1].UploadTarget,
static_cast<Uint16>(TextureUploadTarget::Unknown));
EXPECT_EQ(Framebuffers().LastDraw().Color[1].TextureTarget, kMGPipeSurfaceNoTextureTarget);
}
// ============================ D-C3 ============================
TEST(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) {
FramebufferScope scope;
const auto color = MakeColorTexture(50, 32);
const auto beyond = MakeColorTexture(51, 32);
const auto fbo = MakeShared<FramebufferObject>(8);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
ASSERT_EQ(Framebuffers().RefusedCount(), 0u);
// MGPFramebufferState::Color[] is eight wide and MaxColorAttachments is the driver's raw ES
// cap, not clamped to eight on the GLES path. Truncating silently is the bug class this
// phase is closing, so the record is REFUSED and the legacy arm runs.
fbo->AttachTexture(static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(kMGPipeMaxColorAttachments)),
beyond, TextureUploadTarget::Texture2D);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u)
<< "an attachment above the wire width was truncated into a record instead of refused";
EXPECT_GE(Framebuffers().RefusedCount(), 1u) << "the refusal was not counted";
}
// ============================ D-D2 ============================
//
// RED BEFORE THIS PACKAGE LANDED. RenderbufferObject's three storage setters bump no version
// and raise no notice, and the framebuffer bit's shutter does not move when an ALREADY-ATTACHED
// renderbuffer is re-storaged - so `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an
// attached renderbuffer published nothing at all.
TEST(FramebufferEmit, ARestoragedAttachedRenderbufferPublishesItsNewExtent) {
FramebufferScope scope;
const auto renderbuffer = MakeShared<RenderbufferObject>(3);
renderbuffer->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
renderbuffer->AllocateStorage(IntVec2{64, 64});
const auto fbo = MakeShared<FramebufferObject>(9);
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, renderbuffer);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, renderbuffer->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(handle));
const Uint64 respecifiesBefore = MGPipeTextureEmitterInstance().RespecifyCount();
// NO BIND, NO ATTACHMENT CHANGE, NO DIRTY BIT - only the storage entry point.
renderbuffer->AllocateStorage(IntVec2{128, 96});
EXPECT_GT(MGPipeTextureEmitterInstance().RespecifyCount(), respecifiesBefore)
<< "a re-storaged attached renderbuffer published nothing";
const MGPResourceDesc desc = MGPipeTextureEmitterInstance().LastDesc();
EXPECT_TRUE(desc.Resource == handle);
EXPECT_EQ(desc.Width, 128u);
EXPECT_EQ(desc.Height, 96u);
}
TEST(FramebufferEmit, AnUnchangedBindingPairEmitsNothing) {
FramebufferScope scope;
const auto color = MakeColorTexture(60, 32);
const auto fbo = MakeShared<FramebufferObject>(10);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
const Uint64 bytes = Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(bytes, sizeof(MGPFramebufferState));
EXPECT_EQ(Framebuffers().EmissionCount(), 1u);
// The version-first skip: nothing moved, so nothing goes out and nothing is hashed twice.
EXPECT_EQ(Framebuffers().EmitFramebufferState(Ctx()), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 1u);
}
// ============================ ID-19(c) ============================
//
// THE HOLE esprytobj's C-1 FOUND. The applier keeps framebuffer records PER OBJECT, but v1 only
// ever built the two BOUND-target records at the validate point - so glClearNamedFramebufferfv
// on an fbo bound to NEITHER binding reached a backend that minted a driver framebuffer with no
// attachments, found no record for it, declined, and cleared against it anyway.
TEST(FramebufferEmit, AFramebufferHandedOverByNameGetsANamedRecordWithoutMovingABinding) {
FramebufferScope scope;
const auto boundColor = MakeColorTexture(70, 32);
const auto namedColor = MakeColorTexture(71, 16);
const auto bound = MakeShared<FramebufferObject>(20);
bound->AttachTexture(FramebufferAttachmentType::Color0, boundColor, TextureUploadTarget::Texture2D);
BindDrawAndRead(bound, bound);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
const MGPipeHandle boundHandle = MGPipeApplier().DrawFramebuffer()->Fbo;
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
// The object the DSA entry point is about to hand over, bound to neither binding.
const auto named = MakeShared<FramebufferObject>(21);
named->AttachTexture(FramebufferAttachmentType::Color0, namedColor, TextureUploadTarget::Texture2D);
const Uint64 bytes = Framebuffers().EmitFramebufferByName(*named);
EXPECT_EQ(bytes, sizeof(MGPFramebufferState));
EXPECT_EQ(Framebuffers().LastNamed().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Named));
EXPECT_GT(MGPipeApplier().FramebufferSerial, serialBefore) << "a Named record must publish";
const MGPFramebufferState* stored =
MGPipeApplier().FramebufferRecordFor(Framebuffers().LastNamed().Fbo);
ASSERT_NE(stored, nullptr) << "the framebuffer the server is about to receive by name has no record";
EXPECT_EQ(stored->Width, 16u) << "the Named record does not describe the object it names";
// AND IT MOVED NEITHER BINDING.
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_TRUE(MGPipeApplier().DrawFramebuffer()->Fbo == boundHandle)
<< "a Named record took the draw binding";
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_TRUE(MGPipeApplier().ReadFramebuffer()->Fbo == boundHandle)
<< "a Named record took the read binding";
}
TEST(FramebufferEmit, ANamedRecordIsSuppressedPerObjectAndNeverAgainstABoundRecord) {
FramebufferScope scope;
const auto colorA = MakeColorTexture(72, 32);
const auto colorB = MakeColorTexture(73, 32);
const auto first = MakeShared<FramebufferObject>(22);
first->AttachTexture(FramebufferAttachmentType::Color0, colorA, TextureUploadTarget::Texture2D);
const auto second = MakeShared<FramebufferObject>(23);
second->AttachTexture(FramebufferAttachmentType::Color0, colorB, TextureUploadTarget::Texture2D);
// TWO DIFFERENT OBJECTS' Named RECORDS IN A ROW MUST BOTH GO OUT: the suppressor is keyed by
// the framebuffer the record names and never by one global slot.
EXPECT_GT(Framebuffers().EmitFramebufferByName(*first), 0u);
EXPECT_GT(Framebuffers().EmitFramebufferByName(*second), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 2u);
// A repeat of the same object with nothing moved costs nothing.
EXPECT_EQ(Framebuffers().EmitFramebufferByName(*second), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 2u);
// ...and the first object's record is still its own, not the second's.
EXPECT_EQ(Framebuffers().EmitFramebufferByName(*first), 0u);
// A BOUND OBJECT IS NEVER HANDED A Named RECORD, because the record would then say "no
// binding" while BoundFramebuffer still resolves through it.
BindDrawAndRead(first, nullptr);
Framebuffers().EmitFramebufferByName(*first);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Target, static_cast<Uint8>(MGPipeFramebufferTarget::Draw))
<< "a by-name publish of a DRAW-bound framebuffer overwrote its record with Target = Named";
}
// ============================ m2 ============================
TEST(FramebufferEmit, ADrawBufferTokenAboveTheWireWidthIsRefusedNotTruncated) {
FramebufferScope scope;
const auto color = MakeColorTexture(74, 32);
const auto fbo = MakeShared<FramebufferObject>(24);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
ASSERT_EQ(Framebuffers().RefusedCount(), 0u);
// A LEGAL STATE THE ATTACHMENT SCAN CANNOT SEE: nothing is attached at the point the token
// names, so D-C3's attachment loop passes, and MGPipeDrawBufferIndex would write an index of
// 8 or more into an 8-wide array.
fbo->SetDrawBuffer(0, static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(kMGPipeMaxColorAttachments) + 2));
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u)
<< "a draw-buffer token naming a colour point at or above the wire width was truncated "
"into a record instead of refused";
EXPECT_GE(Framebuffers().RefusedCount(), 1u) << "the refusal was not counted";
}
// ============================ m1 ============================
TEST(FramebufferEmit, ALayeredCubeAttachmentDoesNotAssertAFaceItCannotKnow) {
FramebufferScope scope;
const auto cube = MakeShared<TextureObject2DCube>(75);
cube->SetInternalFormat(TextureInternalFormat::RGBA8);
for (const TextureUploadTarget face : cube->GetUploadTargets()) {
cube->AllocateStorage(face, 0, MipmapInput{IntVec3{16, 16, 1}, 16 * 16 * 4});
}
ASSERT_GT(cube->GetUploadTargets().size(), 1u);
const auto fbo = MakeShared<FramebufferObject>(25);
// The LAYERED entry point carries no face token, so the attachment stores Unknown. v1 fell
// back to GetUploadTargets()[0] - which is CubeMapPositiveX - and the record then ASSERTED a
// face that is not the truth for an attachment naming all six. The precedent it copied
// (FramebufferAttachmentObject::GetSize) only needs an EXTENT, which is identical across the
// six faces; face identity is not.
fbo->AttachTexture(FramebufferAttachmentType::Color0, cube, TextureUploadTarget::Unknown, 0, 0, true);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
EXPECT_EQ(Framebuffers().LastDraw().Color[0].Layered, 1);
EXPECT_EQ(Framebuffers().LastDraw().Color[0].UploadTarget,
static_cast<Uint16>(TextureUploadTarget::Unknown))
<< "a layered cube attachment resolved to one face, so the record asserts a face the "
"attachment does not name";
EXPECT_EQ(Framebuffers().LastDraw().Color[0].TextureTarget,
static_cast<Uint16>(TextureTarget::TextureCubeMap))
<< "MGPSurface::TextureTarget is what a cross-object mask reads instead";
}
TEST(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) {
FramebufferScope scope;
const auto color = MakeColorTexture(76, 32);
const auto depth = MakeShared<RenderbufferObject>(4);
depth->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
depth->AllocateStorage(IntVec2{32, 32});
const auto fbo = MakeShared<FramebufferObject>(26);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, depth);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
const MGPFramebufferState& record = Framebuffers().LastDraw();
// The RENDERBUFFER point, the EMPTY colour points and the STENCIL point that names nothing.
for (const MGPSurface* surface : {&record.Depth, &record.Stencil, &record.Color[1]}) {
EXPECT_NE(surface->Kind, kMGPipeSurfaceKindTexture);
EXPECT_EQ(surface->UploadTarget, static_cast<Uint16>(TextureUploadTarget::Unknown))
<< "a non-texture point says TextureUploadTarget::Texture1D, which is what 0 means";
EXPECT_EQ(surface->TextureTarget, kMGPipeSurfaceNoTextureTarget)
<< "a non-texture point says TextureTarget::Texture1D, which is what 0 means";
}
// And the texture point is unaffected.
EXPECT_EQ(record.Color[0].TextureTarget, static_cast<Uint16>(TextureTarget::Texture2D));
// THE FIELD IS IN THE HASH, so a texture-target change alone cannot be suppressed.
MGPSurface probe = record.Color[0];
MGPFramebufferState probeState = record;
probeState.Color[0].TextureTarget = static_cast<Uint16>(TextureTarget::TextureRectangle);
EXPECT_NE(MGPipeFramebufferStateContentHash(probeState), MGPipeFramebufferStateContentHash(record))
<< "MGPipeCopySurfaceForHash does not copy MGPSurface::TextureTarget, so a record whose "
"only moved field is the attachment's texture target would be suppressed";
(void)probe;
}
// ============================ final review C-2 ============================
//
// A framebuffer has no wire lifetime (D-I2), so the only client state under its handle is this
// emitter's per-object Named latch - and the death helper retires it before the slot is freed,
// the shape every P4a kind takes (ID-8). A recycled handle's Gen already refused the stale
// latch, so this pins the hygiene rather than a picture.
TEST(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired) {
FramebufferScope scope;
MGPipeHandle handle{};
{
const auto fbo = MakeShared<FramebufferObject>(31);
const auto color = MakeColorTexture(32, 8);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
handle = MGPipeFramebufferEmitter::HandleFor(*fbo);
ASSERT_GT(Framebuffers().EmitFramebufferByName(*fbo), 0u) << "the Named record did not go out";
ASSERT_TRUE(Framebuffers().NamedRecordIsLatched(handle));
}
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Framebuffer, handle));
EXPECT_FALSE(Framebuffers().NamedRecordIsLatched(handle))
<< "the dead framebuffer's Named latch survived its death";
}
#endif // MOBILEGL_PIPE_PUSH
// =========================================================================================
// The APPLIER's half of set_framebuffer_state (the wire commits'). The emitter's half - the
// resolved read surface, the draw-buffer array in the content hash, a recycled handle never
// suppressed against its predecessor, an attachment point above the wire width refused rather
// than truncated - is the client package's and lands beside these.
// =========================================================================================
// THE WHOLE POINT OF THE Target BYTE. GL has two independent framebuffer bindings and this
// record carries one Fbo and one ReadSurface, so a record says which binding it describes;
// Both is one object bound to both and writes both. Deleting either store, or the serial bump,
// leaves this red.
TEST(FramebufferEmit, ADrawRecordAndAReadRecordAreKeptApartAndBothWritesBoth) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const Uint64 serialAtStart = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Draw, 100));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Color[0].InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->DrawBuffers[0], 0);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr)
<< "a Draw record landed in the read binding as well";
const Uint64 afterDraw = MGPipeApplier().FramebufferSerial;
EXPECT_GT(afterDraw, serialAtStart) << "an applied record must move the serial the twin memoises";
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{5, 2}, MGPipeFramebufferTarget::Read, 200));
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Width, 200u);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr) << "a Read record overwrote the draw binding";
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterDraw);
// Both: one record, one serial bump, two destinations.
const Uint64 beforeBoth = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Both, 300));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 300u);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Width, 300u);
EXPECT_EQ(MGPipeApplier().FramebufferSerial, beforeBoth + 1)
<< "a Both record is ONE record and moves the serial once";
// The two earlier framebuffers keep their own records - the table is keyed by the handle,
// so binding a third displaced neither (ID-19(b)).
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1})->Width, 100u);
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{5, 2}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{5, 2})->Width, 200u);
// A framebuffer has a handle but NO wire lifetime, so there is no record to refuse against
// and this entry point never counts an object refusal.
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
#endif
}
// A target outside the FOUR is not a target this server has, and guessing one would put a
// draw's attachments into the read binding or the other way round. Named (3) is legal since
// ID-19(b) and has its own case below; the first refused value is the one above it.
TEST(FramebufferEmit, ATargetOutsideTheThreeBindingsIsRefusedNamingTheRecord) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPFramebufferState bad = FramebufferRecord(MGPipeHandle{7, 4}, MGPipeFramebufferTarget::Draw, 100);
bad.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Count);
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ExpectRefusedNaming("set_framebuffer_state {slot=7, gen=4, target=4}: the record names no framebuffer "
"binding target",
[&bad]() { MGPipeApplySetFramebufferState(bad); });
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{7, 4}), nullptr)
<< "a refused record was written into the per-object table anyway";
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore)
<< "a refused record must not move the serial";
#endif
}
// The draw-buffer array is an INDEX into this record's own Color[], and -1 is NONE. An entry
// outside that range would have the server read a colour attachment the record does not carry,
// which is the truncation the wire width's cap refusal exists to prevent upstream.
TEST(FramebufferEmit, ADrawBufferEntryOutsideTheRecordsOwnArrayIsRefusedRatherThanRead) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The positive control first: -1 everywhere and the last legal index are both fine, so
// what follows is refusing the value and not the loop around it.
MGPFramebufferState legal = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 100);
legal.DrawBuffers[7] = static_cast<Int8>(kMGPipeMaxColorAttachments - 1);
MGPipeApplySetFramebufferState(legal);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{8, 1}));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState past = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 111);
past.DrawBuffers[3] = static_cast<Int8>(kMGPipeMaxColorAttachments);
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&past]() { MGPipeApplySetFramebufferState(past); });
MGPFramebufferState negative = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 222);
negative.DrawBuffers[0] = -2;
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&negative]() { MGPipeApplySetFramebufferState(negative); });
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u) << "a refused record was stored anyway";
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore);
#endif
}
// D-J4, as ID-19(b) leaves it. The two framebuffer BINDINGS are per-context working state and a
// make-current takes them - so both accessors answer null afterwards, exactly as the zeroed
// records used to answer a null Fbo - while the per-object RECORD survives, because a
// framebuffer that is only ever addressed BY NAME has no re-emission trigger at all. The serial
// ADVANCES rather than restarting, because a counter that walks back through values it has
// already stamped into a twin that outlived the switch is not a generation at all. Restoring
// `= 0` anywhere in the reset, or clearing the table there, leaves this red.
TEST(FramebufferEmit, AMakeCurrentClearsBothRecordsAndAdvancesTheSerialRatherThanZeroingIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Both, 100));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
MGPipeApplierReset(); // the make-current
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[0], kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[1], kMGPipeNullHandle);
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr)
<< "the per-object record is not working state and a make-current may not take it";
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1})->Width, 100u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, serialBefore)
<< "the serial was carried over or restarted; the cleared window is itself a change the "
"twin has to hear about, and no stamped value may ever recur";
// And the teardown scope advances it again, for the same reason.
const Uint64 afterReset = MGPipeApplier().FramebufferSerial;
MGPipeApplierReleaseObjectRecords();
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterReset);
#endif
}
// THE TEARDOWN SCOPE DROPS THE OBJECT RECORDS, SO IT MUST DROP EVERY WORKING HANDLE THAT NAMES
// ONE. The two framebuffer records hold eleven MGPSurface::Res naming texture and renderbuffer
// records, and the three unit windows hold entries naming sampler-view, sampler-CSO and texture
// records; a window left standing after the tables are emptied is a set of handles into empty
// tables, which the next resolve either refuses and counts or - on a slot the next context
// re-mints - resolves onto somebody else's record. Deleting any one of the eleven clears in
// MGPipeApplierReleaseObjectRecords leaves this red.
TEST(FramebufferEmit, AReleaseOfTheObjectRecordsAlsoClearsTheWorkingHandlesThatCouldNameThem) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Both, 100));
// The three kVarTail sets, each with one entry naming a record the release is about to
// drop, and each at a non-zero Start so the window itself is visible in the assertions.
MGPBoundView view{};
view.View = MGPipeHandle{3, 1};
view.Texture = MGPipeHandle{9, 1};
view.Unit = 2;
MGPipeApplySetSamplerViews(MGPSamplerViews{2, 1, 0xAAAAu}, &view);
const MGPipeHandle samplerState{5, 1};
MGPipeApplyBindSamplerStates(MGPSamplerStates{2, 1, 0xBBBBu}, &samplerState);
MGPImageView image{};
image.Res = MGPipeHandle{9, 1};
image.Unit = 2;
image.InternalFormat = 0x8058u; // GL_RGBA8
MGPipeApplySetShaderImages(MGPShaderImages{2, 1, 0xCCCCu}, &image);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Color[0].Res, (MGPipeHandle{9, 1}));
ASSERT_EQ(MGPipeApplier().SamplerViewCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundSamplerViews[2].View, (MGPipeHandle{3, 1}));
ASSERT_EQ(MGPipeApplier().SamplerStateCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundSamplerStates[2], samplerState);
ASSERT_EQ(MGPipeApplier().ShaderImageCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundShaderImages[2].Res, (MGPipeHandle{9, 1}));
MGPipeApplierReleaseObjectRecords();
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr)
<< "a framebuffer record holding eleven MGPSurface::Res into the emptied texture and "
"renderbuffer tables survived the teardown";
EXPECT_TRUE(MGPipeApplier().FramebufferRecords.empty());
EXPECT_EQ(MGPipeApplier().SamplerViewStart, 0u);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[2].View, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().SamplerStateStart, 0u);
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundSamplerStates[2], kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Res, kMGPipeNullHandle);
#endif
}
// ID-19's CORRECTION, AND THE CASE THAT SAYS WHAT THE FOURTH TARGET IS FOR. Every DSA entry
// point - BlitNamedFramebuffer and the four ClearNamedFramebuffer* - hands Espryt a framebuffer
// BY NAME, and that framebuffer is very often bound to neither binding. With only the two bound
// records the server had no description of it at all, bound its driver FBO with no attachments
// and cleared or blitted into nothing (esprytobj C-1). A Named record fixes that WITHOUT lying
// about the bindings: the record is written and addressable by handle, and BoundFramebuffer
// does not move. Making the Named arm touch either binding leaves this red.
TEST(FramebufferEmit, ANamedRecordDescribesTheFramebufferItNamesWithoutMovingEitherBinding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// TWO DIFFERENT FRAMEBUFFERS ON THE TWO BINDINGS FIRST, so "the bindings did not move" is an
// assertion about values rather than about null.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Draw, 100));
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{5, 2}, MGPipeFramebufferTarget::Read, 200));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState named = FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Draw, 300);
named.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Named);
named.Color[0].Res = MGPipeHandle{21, 1};
MGPipeApplySetFramebufferState(named);
// (a) THE DSA LOOKUP FINDS IT, BY HANDLE, WITH ITS ATTACHMENTS. This is the call package D
// makes at every named blit and clear.
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3}), nullptr)
<< "a framebuffer handed to the server by name has no record, which is the state that "
"clears into a driver framebuffer with no attachments";
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Width, 300u);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Color[0].Res, (MGPipeHandle{21, 1}));
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Target, static_cast<Uint8>(MGPipeFramebufferTarget::Named));
// (b) AND NEITHER BINDING MOVED.
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}))
<< "a Named record claimed the draw binding";
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}))
<< "a Named record claimed the read binding";
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[0], (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[1], (MGPipeHandle{5, 2}));
// (c) The serial moves for a Named record too: a twin memoising a framebuffer's attachments
// has to hear that they moved, and whether it is bound is a different question.
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore + 1);
// (d) And the same framebuffer can then be BOUND, which moves the binding and restates the
// record - the two targets are not two tables.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Draw, 400));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 400u);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
#endif
}
// STALE-GENERATION REFUSAL, ON THE ONE TABLE WHOSE OBJECT HAS NO WIRE LIFETIME. A framebuffer is
// never destroyed on the wire, so its slot is simply overwritten by its successor - and until
// that successor describes itself, a handle naming the DEAD one must be refused rather than
// answered with the predecessor's attachments. That answer would be a blit or a clear into
// somebody else's colour buffer. It is LOUD (counted, and logged once) because the only way to
// reach it is an emitter defect, and it is counted APART from RefusedObjectCalls because this is
// a read by the server's own sync path and not a call the applier refused.
TEST(FramebufferEmit, AFramebufferHandleWhoseGenerationHasMovedOnIsRefusedRatherThanAnswered) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{12, 1}, MGPipeFramebufferTarget::Draw, 100));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 1}), nullptr);
const Uint64 staleBefore = MGPipeApplier().StaleFramebufferRecordLookups;
// The slot has been recycled and the successor has not described itself yet.
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2}), nullptr)
<< "a handle at a recycled slot was answered with its predecessor's record";
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 1);
// Now it does, and the predecessor's handle becomes the stale one - in the other direction.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{12, 2}, MGPipeFramebufferTarget::Draw, 200));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2})->Width, 200u);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 2);
// THE TWO SILENT NULLS, and they are silent on purpose. "Nothing is bound to this binding"
// is what a make-current leaves behind and arrives on every draw of a context that has not
// described its framebuffers; "no record at this slot" is what every framebuffer looks like
// before its first set_framebuffer_state. Counting either would bury the one that matters.
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(kMGPipeNullHandle), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{99, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 2)
<< "an unbound binding or an undescribed slot was counted as a stale generation";
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u)
<< "the framebuffer family may never move the object-refusal counter";
#endif
}
// THE TWO REFUSALS THE PER-OBJECT TABLE ADDED. The null handle is what "nothing is bound" reads
// as, so a record installed at {0,0} would be answered to every caller asking about an EMPTY
// binding; and Slot is a client-supplied Uint32 that now reaches an allocator, so it takes the
// same bound the five object tables take. Every emitter has a handle for every framebuffer it
// describes - kMGPipeDefaultFramebuffer {0,1} for the default one - so neither value is
// producible by a correct client, which is why both are Fatal rather than counted refusals.
TEST(FramebufferEmit, AFramebufferRecordThatNamesNoUsableHandleIsRefusedRatherThanStored) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The positive control first: the DEFAULT framebuffer is slot 0 at generation 1 and is
// perfectly legal, so what follows refuses the null handle and not slot 0.
MGPipeApplySetFramebufferState(
FramebufferRecord(kMGPipeDefaultFramebuffer, MGPipeFramebufferTarget::Both, 128));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(kMGPipeDefaultFramebuffer), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(kMGPipeDefaultFramebuffer)->Width, 128u);
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState nullHandle = FramebufferRecord(kMGPipeNullHandle, MGPipeFramebufferTarget::Draw, 300);
ExpectRefusedNaming("set_framebuffer_state {slot=0, gen=0, target=0}: the record names the null "
"framebuffer handle",
[&nullHandle]() { MGPipeApplySetFramebufferState(nullHandle); });
MGPFramebufferState pastTheBound = FramebufferRecord(
MGPipeHandle{kMGPipeMaxFramebufferSlots, 1}, MGPipeFramebufferTarget::Draw, 400);
ExpectRefusedNaming("set_framebuffer_state {slot=65536, gen=1, target=0}: the framebuffer slot is "
"outside the record table's bound",
[&pastTheBound]() { MGPipeApplySetFramebufferState(pastTheBound); });
static_assert(kMGPipeMaxFramebufferSlots == 65536u,
"the refusal line above names the bound; move both together");
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore) << "a refused record moved the serial";
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, kMGPipeDefaultFramebuffer)
<< "a refused record took the draw binding";
EXPECT_LT(MGPipeApplier().FramebufferRecords.size(),
static_cast<SizeT>(kMGPipeMaxFramebufferSlots))
<< "an out-of-range slot resized the table instead of being refused";
#endif
}
int main(int argc, char** argv) {
// Before anything logs: the logger reads this variable once, on its first write, and
// caches the handle. The name carries this process's pid, and the file is removed on the
// way out.
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-framebufferemit-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
#if MOBILEGL_PIPE_PUSH
// ID-39: A BACKEND IS PRESENT, for the whole binary. Since ID-39 every P4a-family entry
// point in MG_Pipe/PipeApply.cpp declines a record - and the client's gate in
// MG_Impl/Pipe/PipeFill.cpp emits none at all - when no backend has registered
// MGPipeResourceOps, because acceptance is a contract with the emitter and an accepted
// record nothing reads makes the client clear work the legacy pull path still owed. Every
// case in this suite is about the arm where a backend DOES consume the records, which is
// the shipped DirectGLES configuration, so it installs the same signal that backend
// installs. The table is empty because none of its hooks is on a framebuffer path at all:
// set_framebuffer_state stores a record and dispatches nothing. The two arms of the rule
// itself are pinned in ResourceEmitTest and TextureEmitTest.
static const MGPipeResourceOps kConsumerPresent{};
MGPipeSetResourceOps(&kConsumerPresent);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}