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

1156 lines
58 KiB
C++

// MobileGL - MobileGL/MG_Test/Pipe/SamplerEmitTest.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 sampler family: the content-addressed sampler CSO, the identity-addressed sampler view
// per texture object, and the set_sampler_views / bind_sampler_states unit sets.
//
// THIS SUITE IS A NAMED GATE (`ctest -R 'SamplerEmit\.'`), and its negative control is a
// script that stops the conversion copying SamplerParameters::borderColorForm and expects this
// suite to go red NAMING that field - which it must, because all three border-colour
// representations are always numerically populated and the value alone cannot say which driver
// entry point to use.
//
// THE ONE CASE THAT LOOKS LIKE PARANOIA AND IS NOT: SamplerParameters is 100 bytes with THREE
// BYTES OF TRAILING PADDING, so a cache that hashes or memcmps the object's own bytes reads
// uninitialised memory and mints a fresh CSO per call - a 256-entry cache with a hit rate of
// zero, and nobody notices, because the pixels are right. The case that writes garbage into
// the padding through a byte pointer is what turns that into a red gate.
//
// THE SUITE IS `SamplerEmit`, not `SamplerEmitTest`: 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 are the wire commits' and the emitter-side cases are the client
// package's, and neither has to come back to MG_Test/Pipe/CMakeLists.txt to add one.
//
// IT HAS ITS OWN main() for ResourceEmitTest's reason. 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 <cstring>
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_Impl/Pipe/TextureEmit.h>
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#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
// `from` is a byte offset, and it exists because of the fork below: the library's log file
// is already open by the time a case runs, so the child's lines are APPENDED to it rather
// than written to a fresh file, and only what the child appended is this drive's evidence.
std::string ReadLog(std::streamoff from = 0) {
std::ifstream in(g_logPath, std::ios::binary);
if (from > 0) in.seekg(from, std::ios::beg);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// Where the library's log file currently ends. Reading from here after the child has
// aborted gives exactly the lines that drive produced.
std::streamoff LogEnd() {
std::ifstream in(g_logPath, std::ios::binary | std::ios::ate);
return in ? static_cast<std::streamoff>(in.tellg()) : std::streamoff{0};
}
// 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;
// THE LOG PATH IS NOT UNLINKED HERE, and that is what this helper had to learn when the
// client's cases landed in the same file as the applier's: main() calls
// MobileGL::Initialize(), so the library's log FILE* is already open on this path and
// fork() duplicates it. Removing the path would leave the child writing into a deleted
// inode and the parent reading an empty file - the child would still abort, and the
// assertion on WHAT it named could never see the line. So the log's end is remembered
// and only what the child appended is read back.
const std::streamoff before = LogEnd();
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(before);
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
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
TEST(SamplerEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(&MGPipeSamplerEmitterInstance(), &MGPipeSamplerEmitterInstance());
// One bit for the whole sampler family - the CSO, the view and all three unit sets,
// including set_shader_images, whose emitter lives in ImageEmit.h. An operator switching
// samplers off has to get the whole family's legacy arm, not two thirds of it.
EXPECT_TRUE(kMGPipeWiredSamplerSubsystem == 0 ||
kMGPipeWiredSamplerSubsystem == kMGPipeSubsystemSamplers);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build";
#endif
}
// =========================================================================================
// The APPLIER's half of the sampler family (the wire commits'): the CSO record, the
// identity-addressed view record and its back-pointer, and the two unit sets. The emitter's
// half - the content-addressed 256-entry cache, the canonical zero-initialised copy the hash
// and the memcmp run over, the padding that cannot change the hash, borderColorForm crossing -
// is the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, INCLUDING borderColorForm and all three border
// representations: they are always numerically populated, so the value alone cannot say
// which driver entry point to use and the form is what does.
SamplerParameters SamplerValues(Float lodBias, BorderColorForm form) {
SamplerParameters params{};
params.wrapS = SamplerWrapMode::ClampToEdge;
params.wrapT = SamplerWrapMode::MirroredRepeat;
params.minFilter = SamplerFilterMode::Linear;
params.magFilter = SamplerFilterMode::Nearest;
params.mipmapMode = SamplerMipmapMode::Nearest;
params.lodBias = lodBias;
params.maxAnisotropy = 4.0f;
params.compareMode = SamplerCompareMode::CompareToTexture;
params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f};
params.borderColorI = {-1, 2, -3, 4};
params.borderColorUI = {5u, 6u, 7u, 8u};
params.borderColorForm = form;
return params;
}
MGPSamplerDesc SamplerDesc(MGPipeHandle cso, Uint64 declaredBlobSize) {
MGPSamplerDesc desc{};
desc.Cso = cso;
desc.Parameters.Size = declaredBlobSize;
return desc;
}
MGPSamplerView ViewOf(MGPipeHandle cso, MGPipeHandle texture, Uint16 minLevel) {
MGPSamplerView view{};
view.Cso = cso;
view.Texture = texture;
view.InternalFormat = 0x8058u; // GL_RGBA8
view.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
view.MinLevel = minLevel;
view.NumLevels = 4;
view.MinLayer = 0;
view.NumLayers = 1;
view.Samples = 1;
return view;
}
MGPHandleOnly SamplerHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerCso), 0};
}
MGPHandleOnly ViewHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerViewCso), 0};
}
MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
desc.GlNameForDiag = glName;
return desc;
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0 - so a fresh backend twin that starts
// its own synced serial at 0 agrees without either side publishing anything - while a re-issue
// on a LIVE identity counts up, which is how a value change travels on a handle whose
// generation moves only on slot reuse.
TEST(SamplerEmit, ACreateStoresTheParametersByValueAndAReissueOnALiveIdentityCountsUp) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{6, 2};
const SamplerParameters first = SamplerValues(0.5f, BorderColorForm::Int);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &first);
ASSERT_GT(MGPipeApplier().SamplerCsos.size(), 6u);
const MGPipeSamplerCsoRecord& record = MGPipeApplier().SamplerCsos[6];
EXPECT_TRUE(record.Live);
EXPECT_EQ(record.Gen, 2u);
EXPECT_EQ(record.Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(record.Params.wrapT, SamplerWrapMode::MirroredRepeat);
EXPECT_EQ(record.Params.magFilter, SamplerFilterMode::Nearest);
EXPECT_EQ(record.Params.compareMode, SamplerCompareMode::CompareToTexture);
EXPECT_FLOAT_EQ(record.Params.lodBias, 0.5f);
EXPECT_FLOAT_EQ(record.Params.borderColor.z(), 0.75f);
EXPECT_EQ(record.Params.borderColorI.x(), -1);
EXPECT_EQ(record.Params.borderColorUI.w(), 8u);
EXPECT_EQ(record.Params.borderColorForm, BorderColorForm::Int)
<< "borderColorForm crosses; without it the backend cannot choose an entry point";
const SamplerParameters second = SamplerValues(1.5f, BorderColorForm::Uint);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, sizeof(SamplerParameters)), &second);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 1u);
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 1.5f);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Params.borderColorForm, BorderColorForm::Uint);
// A RECYCLED SLOT STARTS OVER. Inheriting one field of the previous occupant - a serial, a
// filter - is precisely how a sampler at a recycled slot inherits its predecessor's state.
const SamplerParameters third = SamplerValues(2.5f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{6, 3}, 0), &third);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Gen, 3u);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 2.5f);
#endif
}
// The one Blob rule, on this family's own blob: a non-zero declared length must be exactly one
// SamplerParameters, a zero means "this record does not declare its blob" - which is what a
// monolith emission is - and either way the bytes read are bounded by the TYPE.
TEST(SamplerEmit, ARecordThatDoesNotDescribeItsOwnParametersIsRefusedNamingTheLength) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
const MGPSamplerDesc lying = SamplerDesc(MGPipeHandle{4, 1}, sizeof(SamplerParameters) + 1);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the declared blob length is not one "
"SamplerParameters",
[&lying, &values]() { MGPipeApplyCreateSamplerState(lying, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
const MGPSamplerDesc undeclared = SamplerDesc(MGPipeHandle{4, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the record declares no parameters and "
"carries none",
[&undeclared]() { MGPipeApplyCreateSamplerState(undeclared, nullptr); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
// And the slot bound, which is the one number in the family that reaches an allocator.
const MGPSamplerDesc pastTheBound = SamplerDesc(MGPipeHandle{kMGPipeMaxSamplerCsoSlots, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=65536, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &values]() { MGPipeApplyCreateSamplerState(pastTheBound, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty()) << "the table was grown by a corrupt slot";
#endif
}
// A death notice on a record the applier does not have is the ONE refusal a legal sequence
// produces - the teardown order - so it stays a defined no-op, and it is COUNTED because
// MOBILEGL_ASSERT compiles out at INFO and every build that matters is one.
TEST(SamplerEmit, ADeleteDropsTheRecordAndAStaleNoticeIsCountedRatherThanSilentlyDropped) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{3, 7};
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &values);
ASSERT_TRUE(MGPipeApplier().SamplerCsos[3].Live);
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_FALSE(MGPipeApplier().SamplerCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Gen, 7u) << "a destroy keeps the generation";
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Params.wrapT, SamplerWrapMode::Repeat)
<< "a stale read of a deleted slot must find nothing, not the state that used to be there";
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// The second notice - the one a teardown produces - is refused and counted.
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
MGPipeApplyDeleteSamplerState(SamplerHandle(MGPipeHandle{3, 8}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 2u);
#endif
}
// A sampler view is IDENTITY-addressed one per texture object, minted off that object's
// lifetime id, so a restriction change is a re-issue on the same handle rather than a new one.
// The texture's own back-pointer is written here and cleared by the delete, and both are silent
// lookups: the texture bit and the sampler bit are independent, so a view arriving without its
// texture is an ordering fact and not a refusal.
TEST(SamplerEmit, AViewIsReissuedOnTheSameHandleAndKeepsItsTexturesBackPointerInStep) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{5, 1};
const MGPipeHandle view{9, 2};
MGPipeApplyResourceCreate(TextureDesc(texture, 42));
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 0));
ASSERT_GT(MGPipeApplier().SamplerViewCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 0u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.NumLevels, 4u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, view)
<< "the texture's back-pointer to its one view was not written";
// A restriction change: same handle, serial up, nothing started over.
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 2));
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 1u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.MinLevel, 2u);
// A view whose texture this applier has not been told about is stored anyway - refusing it
// would make one legal A/B arm drop every view - and it counts no refusal.
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{10, 1}, MGPipeHandle{77, 1}, 0));
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[10].Live);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_FALSE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Gen, 2u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, kMGPipeNullHandle)
<< "the texture kept a back-pointer to a view that is gone";
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// THE WINDOW IS THE BOUND AND ENTRIES OUTSIDE IT ARE NOT CLEARED: a set that names four units
// has said nothing about the other 188, and clearing them would unbind textures the client
// never mentioned. Deleting the entry loop, the window gate or either serial bump leaves this
// red.
TEST(SamplerEmit, TheTwoUnitSetsLandInTheirWindowAndLeaveEverythingOutsideItAlone) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView views[2] = {};
views[0].View = MGPipeHandle{1, 1};
views[0].Texture = MGPipeHandle{2, 1};
views[0].Unit = 4;
views[1].View = kMGPipeNullHandle; // a unit the program does not resolve is legal
views[1].Texture = MGPipeHandle{3, 1};
views[1].Unit = 5;
MGPSamplerViews viewHeader{};
viewHeader.Start = 4;
viewHeader.Count = 2;
viewHeader.ContentHash = 0xABCDu;
const Uint64 viewSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetSamplerViews(viewHeader, views);
EXPECT_EQ(MGPipeApplier().SamplerViewStart, 4u);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[4].View, (MGPipeHandle{1, 1}));
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[5].View));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[3].View)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[6].View)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewSerial);
MGPipeHandle states[2] = {MGPipeHandle{8, 1}, kMGPipeNullHandle};
MGPSamplerStates stateHeader{};
stateHeader.Start = 4;
stateHeader.Count = 2;
const Uint64 stateSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplyBindSamplerStates(stateHeader, states);
EXPECT_EQ(MGPipeApplier().BoundSamplerStates[4], (MGPipeHandle{8, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerStates[5]))
<< "a unit with no sampler object carries a null CSO and the texture's built-in one applies";
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 2u);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, stateSerial);
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, viewSerial + 1)
<< "one set moved the other set's serial; the three are independent";
// A NARROWER SET DOES NOT CLEAR WHAT IT DOES NOT NAME - "the last set as received".
MGPSamplerViews narrow{};
narrow.Start = 4;
narrow.Count = 1;
MGPipeApplySetSamplerViews(narrow, views);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}))
<< "the entry outside the new window was cleared";
#endif
}
// The window gate itself, at the bound and one past it, plus the null-tail arm. A header that
// describes more than its destination can hold is the same class of fault as a blob outside
// its segment, and the destination here is the merged 192-unit space.
TEST(SamplerEmit, AUnitWindowPastTheMergedUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView entry{};
entry.Texture = MGPipeHandle{2, 1};
// The positive control: a window ending EXACTLY at the bound is fine.
MGPSamplerViews exact{};
exact.Start = kMGPipeMaxTextureUnits - 1;
exact.Count = 1;
MGPipeApplySetSamplerViews(exact, &entry);
ASSERT_EQ(MGPipeApplier().SamplerViewCount, 1u);
const Uint64 serialBefore = MGPipeApplier().SamplerViewsSerial;
MGPSamplerViews past{};
past.Start = kMGPipeMaxTextureUnits - 1;
past.Count = 2;
past.ContentHash = 7;
ExpectRefusedNaming("set_sampler_views {start=191, count=2, hash=7}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetSamplerViews(past, &entry); });
MGPSamplerStates statesPast{};
statesPast.Start = 0;
statesPast.Count = kMGPipeMaxTextureUnits + 1;
ExpectRefusedNaming("bind_sampler_states {start=0, count=193, hash=0}: the window runs past the merged "
"texture-unit space",
[&statesPast]() {
MGPipeHandle one = kMGPipeNullHandle;
MGPipeApplyBindSamplerStates(statesPast, &one);
});
MGPSamplerViews noTail{};
noTail.Start = 0;
noTail.Count = 3;
ExpectRefusedNaming("set_sampler_views {start=0, count=3, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetSamplerViews(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().SamplerViewStart, kMGPipeMaxTextureUnits - 1);
#endif
}
// D-J4 for this family: the CSO and the view are OBJECT records and survive a make-current;
// the two unit sets are WORKING state and do not, and their serials advance rather than
// restarting.
TEST(SamplerEmit, AMakeCurrentTakesTheUnitSetsAndLeavesTheCsoAndViewRecordsStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(3.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{2, 1}, 0), &values);
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{3, 1}, MGPipeHandle{4, 1}, 1));
MGPBoundView entry{};
entry.Texture = MGPipeHandle{4, 1};
MGPSamplerViews header{};
header.Count = 1;
MGPipeApplySetSamplerViews(header, &entry);
const Uint64 viewsSerial = MGPipeApplier().SamplerViewsSerial;
const Uint64 statesSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplierReset();
EXPECT_TRUE(MGPipeApplier().SamplerCsos[2].Live) << "a make-current dropped a share-group CSO record";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[2].Params.lodBias, 3.0f);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u) << "the unit set is per context and must be cleared";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[0].Texture));
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewsSerial);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, statesSerial);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name, so every
// push-only case is present here and SKIPS rather than being absent.
#define MGL_SAMPLER_EMIT_TEST_LIST(X) \
X(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) \
X(SamplerEmit, PaddingCannotChangeTheHash) \
X(SamplerEmit, TwoIdenticalSamplersShareOneCso) \
X(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) \
X(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) \
X(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) \
X(SamplerEmit, AnUnchangedTextureReIssuesNothing) \
X(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) \
X(SamplerEmit, AnUnchangedSetEmitsNothing) \
X(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) \
X(SamplerEmit, ABoundSamplerStateHoldsItsCsoUntilTheUnitMoves) \
X(SamplerEmit, AReferencedCsoIsNeverTheLruVictim) \
X(SamplerEmit, AFullyPinnedCacheMintsBeyondItsCapacityAndCountsIt) \
X(SamplerEmit, AReleaseThisCacheNeverHandedOutIsCountedRatherThanAbsorbed) \
X(SamplerEmit, AResolvedSamplerViewMarksItsTextureAsSamplerBound)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_SAMPLER_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::ITextureObject;
using MG_State::GLState::SamplerObject;
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason: both gates
// grep `ctest -R 'SamplerEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and
// gtest refuses to mix TEST and TEST_F under one suite name.
//
// UNLIKE VertexInputEmitTest's, this scope does NOT replace pGLContext: half these cases
// need a really linked program, so the process is initialised once in main() and the cases
// share that context, each using GL names of its own. What the scope does is put the
// emitter, its counters and the suppressor back to a known state.
struct EmitterScope {
EmitterScope() { Clear(); }
~EmitterScope() { Clear(); }
EmitterScope(const EmitterScope&) = delete;
EmitterScope& operator=(const EmitterScope&) = delete;
static void Clear() {
MGPipeSamplerEmitterInstance().Reset();
MGPipeSamplerEmitterInstance().ResetCounters();
MGPipeSamplerCsoCacheInstance().ResetForTest();
MGPipeSamplerCsoCacheInstance().ResetCounters();
MGPipeSetHashSuppressorInstance().InvalidateAll();
}
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeSamplerEmitter& Emitter() { return MGPipeSamplerEmitterInstance(); }
MGPipeSamplerCsoCache& Cache() { return MGPipeSamplerCsoCacheInstance(); }
// Every field of SamplerParameters set to a value that is not its default, so a conversion
// that dropped one is caught by the field's own EXPECT rather than by a count.
SamplerParameters DistinctParameters() {
SamplerParameters params{};
params.wrapS = SamplerWrapMode::ClampToBorder;
params.wrapT = SamplerWrapMode::MirroredRepeat;
params.wrapR = SamplerWrapMode::MirrorClampToEdge;
params.minFilter = SamplerFilterMode::Linear;
params.magFilter = SamplerFilterMode::Nearest;
params.mipmapMode = SamplerMipmapMode::Nearest;
params.minLod = -3.5f;
params.maxLod = 7.25f;
params.lodBias = 1.5f;
params.maxAnisotropy = 2.0f;
params.compareFunc = SamplerCompareFunc::Greater;
params.compareMode = SamplerCompareMode::CompareToTexture;
params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f};
params.borderColorI = {-1, 2, -3, 4};
params.borderColorUI = {5u, 6u, 7u, 8u};
params.borderColorForm = BorderColorForm::Int;
return params;
}
// A 2D texture with a single level and a mipmap mode of None, so it is mipmap-complete for
// its filter and therefore actually reaches the emitted set. A texture that samples as
// incomplete is dropped to a null view on purpose, which is the resolution DirectGLES
// performs by leaving the native target unbound.
const SharedPtr<ITextureObject>& MakeCompleteTexture(GLuint& name, Int width) {
namespace GL = MobileGL::MG_Impl::GLImpl;
GL::GenTextures(1, &name);
GL::BindTexture(GL_TEXTURE_2D, name);
GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, width, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
return Ctx().GetTextureObject(name);
}
void BindTextureToUnit(Uint32 unit, const SharedPtr<ITextureObject>& texture) {
Ctx().GetTextureUnitObject(static_cast<Int>(unit))
.GetBindingSlot(TextureTarget::Texture2D)
.Bind(texture);
Ctx().NoteTextureUnitTouched(static_cast<Int>(unit));
}
// ============================ D-F1: the CSO cache ============================
// G6 for this family: every one of SamplerParameters' sixteen members survives the
// canonical copy the cache hashes and confirms over. Each member is its own EXPECT naming
// that member, which is what G7's scripted control needs - it stops the conversion copying
// borderColorForm and expects this case to go red NAMING it.
TEST(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) {
EmitterScope scope;
const SamplerParameters source = DistinctParameters();
SamplerParameters canon;
MGPipeCanonicaliseSamplerParameters(source, canon);
EXPECT_EQ(canon.wrapS, source.wrapS);
EXPECT_EQ(canon.wrapT, source.wrapT);
EXPECT_EQ(canon.wrapR, source.wrapR);
EXPECT_EQ(canon.minFilter, source.minFilter);
EXPECT_EQ(canon.magFilter, source.magFilter);
EXPECT_EQ(canon.mipmapMode, source.mipmapMode);
EXPECT_EQ(canon.minLod, source.minLod);
EXPECT_EQ(canon.maxLod, source.maxLod);
EXPECT_EQ(canon.lodBias, source.lodBias);
EXPECT_EQ(canon.maxAnisotropy, source.maxAnisotropy);
EXPECT_EQ(canon.compareFunc, source.compareFunc);
EXPECT_EQ(canon.compareMode, source.compareMode);
EXPECT_EQ(canon.borderColor, source.borderColor);
EXPECT_EQ(canon.borderColorI, source.borderColorI);
EXPECT_EQ(canon.borderColorUI, source.borderColorUI);
EXPECT_EQ(canon.borderColorForm, source.borderColorForm)
<< "borderColorForm decides which of glSamplerParameterIiv / fv applies, and the "
"three representations are always numerically populated, so the value alone "
"cannot say";
}
// THE PADDING TRAP. SamplerParameters is 100 bytes and its members occupy 97 of them, so
// bytes 97..99 are padding no writer ever touches. A cache that hashed the object's own
// bytes would read them, miss on every probe and mint a fresh CSO per call - a 256-entry
// cache with a hit rate of zero that nobody notices, because the pixels are right.
TEST(SamplerEmit, PaddingCannotChangeTheHash) {
EmitterScope scope;
SamplerParameters clean = DistinctParameters();
SamplerParameters dirty = clean;
auto* bytes = reinterpret_cast<Uint8*>(&dirty);
// Written THROUGH A BYTE POINTER, past the last member and inside the object, which is
// the only way to make the difference this case is about.
for (SizeT i = 97; i < sizeof(SamplerParameters); ++i) bytes[i] = static_cast<Uint8>(0xA5 + i);
SamplerParameters canonClean;
SamplerParameters canonDirty;
MGPipeCanonicaliseSamplerParameters(clean, canonClean);
MGPipeCanonicaliseSamplerParameters(dirty, canonDirty);
EXPECT_EQ(MGPipeHashSamplerParameters(canonClean), MGPipeHashSamplerParameters(canonDirty));
// And the CONFIRM, not only the hash: the cache reuses a handle on a memcmp over these
// same canonical bytes, so the padding has to be deterministically zero in both.
EXPECT_EQ(std::memcmp(&canonClean, &canonDirty, sizeof(SamplerParameters)), 0);
Uint64 payload = 0;
const MGPipeHandle first = Cache().Acquire(clean, payload);
const MGPipeHandle second = Cache().Acquire(dirty, payload);
EXPECT_EQ(first, second) << "uninitialised padding must not mint a second CSO";
EXPECT_EQ(Cache().GetCounters().Mints, 1u);
EXPECT_EQ(Cache().GetCounters().Hits, 1u);
}
TEST(SamplerEmit, TwoIdenticalSamplersShareOneCso) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters params = DistinctParameters();
const MGPipeHandle a = Cache().Acquire(params, payload);
const MGPipeHandle b = Cache().Acquire(params, payload);
EXPECT_FALSE(MGPipeHandleIsNull(a));
EXPECT_EQ(a, b);
EXPECT_EQ(Cache().Size(), 1u);
EXPECT_EQ(Cache().GetCounters().Mints, 1u);
// A SamplerObject is a pure value with no driver-side per-object binding state, so two
// identical samplers really can share one CSO and one server-side twin. That is what
// makes content addressing right for this kind and wrong for vertex elements.
EXPECT_GT(payload, 0u);
}
TEST(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters params = DistinctParameters();
const MGPipeHandle asInt = Cache().Acquire(params, payload);
params.borderColorForm = BorderColorForm::Uint;
const MGPipeHandle asUint = Cache().Acquire(params, payload);
EXPECT_NE(asInt, asUint) << "the form is the only thing that says which driver entry "
"point applies; the three colour values did not move";
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
}
// The memcmp confirm exists because a bare 64-bit equality would alias two DIFFERENT
// sampler states onto one CSO - silent wrong filtering with no gate that can see it.
//
// A GENUINE XXH64 COLLISION CANNOT BE MANUFACTURED, so this case drives the branch through
// the cache's forced-hash test seam instead. Without it the case could not go red for its
// own name: two states that differ by one float ULP have different hashes, the confirm is
// never consulted, and the memcmp branch, the Collisions counter and the probe's
// continuation past a rejected entry were all completely uncovered.
TEST(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters first = DistinctParameters();
SamplerParameters second = DistinctParameters();
second.maxLod = second.maxLod + 0.0009765625f; // one representable step, nothing else moves
ASSERT_NE(std::memcmp(&first, &second, sizeof(SamplerParameters)), 0);
// ONE hash for two different values, which is exactly what a collision is.
constexpr Uint64 kForcedHash = 0x0123456789ABCDEFull;
const MGPipeHandle a = Cache().AcquireWithForcedHashForTest(first, kForcedHash, payload);
const MGPipeHandle b = Cache().AcquireWithForcedHashForTest(second, kForcedHash, payload);
EXPECT_NE(a, b) << "reusing the handle would filter one state with the other's parameters";
EXPECT_EQ(Cache().GetCounters().Collisions, 1u) << "and the rejection is counted, not silent";
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
EXPECT_EQ(Cache().Size(), 2u);
// AND THE PROBE KEEPS GOING PAST THE REJECTED ENTRY rather than stopping at the first
// hash match: the first value is still resident behind the colliding one, so asking for
// it again is a HIT and not a third mint. Stopping at the first match would mint a
// duplicate CSO for a value the cache already holds.
const MGPipeHandle again = Cache().AcquireWithForcedHashForTest(first, kForcedHash, payload);
EXPECT_EQ(again, a);
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
EXPECT_EQ(Cache().GetCounters().Hits, 1u);
// The ordinary path, for completeness: two states that differ in ONE field never share
// a handle and never reach the confirm at all.
Cache().ResetForTest();
Cache().ResetCounters();
const MGPipeHandle plainFirst = Cache().Acquire(first, payload);
const MGPipeHandle plainSecond = Cache().Acquire(second, payload);
EXPECT_NE(plainFirst, plainSecond);
EXPECT_EQ(Cache().GetCounters().Collisions, 0u);
}
// ============================ D-F2: the sampler view ============================
TEST(SamplerEmit, AnUnchangedTextureReIssuesNothing) {
EmitterScope scope;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
ASSERT_TRUE(texture);
Uint64 payload = 0;
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId());
const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload);
ASSERT_FALSE(MGPipeHandleIsNull(view));
EXPECT_EQ(Emitter().ViewCreateCount(), 1u);
// THE VERSION-FIRST SKIP: nothing moved, so nothing is hashed, copied or emitted.
EXPECT_EQ(Emitter().AcquireSamplerView(*texture, handle, payload), view);
EXPECT_EQ(Emitter().ViewCreateCount(), 1u);
}
TEST(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) {
EmitterScope scope;
namespace GL = MobileGL::MG_Impl::GLImpl;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
ASSERT_TRUE(texture);
Uint64 payload = 0;
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId());
const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload);
ASSERT_FALSE(MGPipeHandleIsNull(view));
ASSERT_EQ(Emitter().ViewCreateCount(), 1u);
const Uint64 shapeBefore = texture->GetShapeVersion();
GL::BindTexture(GL_TEXTURE_2D, name);
GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_NE(texture->GetShapeVersion(), shapeBefore) << "the storage really has to move";
// THE SAME HANDLE, a second record. Gen increments only on slot reuse and never on a
// respecify, so re-issuing on the same handle is legal and is what keeps a server's
// twin table from minting a second identity for one texture.
const MGPipeHandle reissued = Emitter().AcquireSamplerView(*texture, handle, payload);
EXPECT_EQ(reissued, view);
EXPECT_EQ(Emitter().ViewCreateCount(), 2u);
EXPECT_EQ(Emitter().LastCreatedView().Cso, view);
EXPECT_EQ(Emitter().LastCreatedView().Texture, handle);
EXPECT_EQ(Emitter().LastCreatedView().Target, static_cast<Uint8>(TextureTarget::Texture2D));
EXPECT_EQ(Emitter().LastCreatedView().InternalFormat, static_cast<Uint32>(texture->GetFormat()));
// An ordinary texture carries no view restriction, and zero is that statement rather
// than a second spelling of it: glTextureView always writes NumLevels >= 1.
EXPECT_EQ(Emitter().LastCreatedView().NumLevels, 0u);
EXPECT_EQ(Emitter().LastCreatedView().NumLayers, 0u);
}
// ============================ D-G: the two unit sets ============================
Uint MakeSamplerProgram() {
namespace GL = MobileGL::MG_Impl::GLImpl;
static const char* kVs = "#version 330 core\nvoid main(){ gl_Position = vec4(0.0); }\n";
static const char* kFs =
"#version 330 core\n"
"uniform sampler2D sampled;\n"
"out vec4 color;\n"
"void main(){ color = texture(sampled, vec2(0.0)); }\n";
const Uint program = GL::CreateProgram();
const Uint vs = GL::CreateShader(GL_VERTEX_SHADER);
GL::ShaderSource(vs, 1, &kVs, nullptr);
GL::CompileShader(vs);
GL::AttachShader(program, vs);
const Uint fs = GL::CreateShader(GL_FRAGMENT_SHADER);
GL::ShaderSource(fs, 1, &kFs, nullptr);
GL::CompileShader(fs);
GL::AttachShader(program, fs);
GL::LinkProgram(program);
return program;
}
// ARCHITECTURE's third merge rule: the client emits the PROGRAM-RESOLVED set. A unit the
// shader does not sample carries no view, whatever is bound to it - which is exactly the
// gallium one-view-per-slot resolved form, and exactly what DirectGLES arrives at by
// asking the program which of a unit's aliased bindings is the sampled one.
TEST(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) {
EmitterScope scope;
namespace GL = MobileGL::MG_Impl::GLImpl;
const Uint program = MakeSamplerProgram();
GLint linked = 0;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
GL::UseProgram(program);
const GLint location = GL::GetUniformLocation(program, "sampled");
ASSERT_GE(location, 0);
GL::Uniform1i(location, 3);
GLuint sampledName = 0;
GLuint unsampledName = 0;
const SharedPtr<ITextureObject> sampled = MakeCompleteTexture(sampledName, 4);
const SharedPtr<ITextureObject> unsampled = MakeCompleteTexture(unsampledName, 4);
BindTextureToUnit(3, sampled);
BindTextureToUnit(5, unsampled);
ASSERT_GT(Emitter().EmitSamplerViews(Ctx()), 0u);
ASSERT_EQ(Emitter().ViewSetCount(), 1u);
ASSERT_GE(Emitter().LastSamplerViews().Count, 6u);
EXPECT_EQ(Emitter().LastSamplerViews().Start, 0u);
const MGPBoundView& resolved = Emitter().LastBoundViews()[3];
EXPECT_EQ(resolved.Unit, 3u);
EXPECT_FALSE(MGPipeHandleIsNull(resolved.Texture)) << "unit 3 is what the shader samples";
EXPECT_FALSE(MGPipeHandleIsNull(resolved.View));
const MGPBoundView& ignored = Emitter().LastBoundViews()[5];
EXPECT_EQ(ignored.Unit, 5u);
EXPECT_TRUE(MGPipeHandleIsNull(ignored.Texture))
<< "unit 5 has a texture bound but no sampler uniform resolves to it";
EXPECT_TRUE(MGPipeHandleIsNull(ignored.View));
GL::UseProgram(0);
}
TEST(SamplerEmit, AnUnchangedSetEmitsNothing) {
EmitterScope scope;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
BindTextureToUnit(1, texture);
Emitter().EmitSamplerViews(Ctx());
const Uint64 after = Emitter().ViewSetCount();
// The suppressor's whole job: an unchanged resolved set is not sent again. Without it
// this would be a several-hundred-byte variable-length record per batch, because a
// redundant re-bind moves the bind generation and the dirty bit with it.
Emitter().EmitSamplerViews(Ctx());
EXPECT_EQ(Emitter().ViewSetCount(), after);
}
TEST(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) {
// THE SCOPE IS LOAD-BEARING and not decoration: without it this case's counter
// assertions would be reading whatever the previous case's destructor happened to
// leave, which is a suite-order dependence of exactly the class the padding bug was.
EmitterScope scope;
const SharedPtr<MG_State::GLState::SamplerObject>& sampler = Ctx().CreateSamplerObject(4131);
ASSERT_TRUE(sampler);
Ctx().GetTextureUnitObject(2).SetSamplerObject(sampler);
Ctx().NoteTextureUnitTouched(2);
ASSERT_GT(Emitter().EmitSamplerStates(Ctx()), 0u);
ASSERT_EQ(Emitter().StateSetCount(), 1u);
ASSERT_GE(Emitter().LastSamplerStates().Count, 3u);
EXPECT_FALSE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[2]));
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[0]))
<< "a unit with no sampler object carries the null handle, and the texture's "
"built-in sampler then applies exactly as today";
// 26.2's idiom: the same sampler object re-bound at every texture-unit switch. The bind
// generation moves, so the dirty bit fires and this emitter runs - and emits nothing.
Ctx().BumpTextureBindGeneration();
Emitter().EmitSamplerStates(Ctx());
EXPECT_EQ(Emitter().StateSetCount(), 1u);
EXPECT_EQ(Cache().GetCounters().Mints, 1u) << "and it mints no second CSO either";
Ctx().GetTextureUnitObject(2).SetSamplerObject(SharedPtr<SamplerObject>());
}
// ==================== ID-17: the reference count on a cache entry ====================
// A BOUND SAMPLER STATE HOLDS A REFERENCE WHILE IT IS BOUND. The set the applier holds is
// "the last set as received" and outlives the pass that sent it, so a handle in it must not
// become the LRU's victim - nothing re-emits bind_sampler_states when a CSO is evicted,
// because an eviction is not a state change and no dirty bit fires for it.
//
// AND THE REFERENCE DOES NOT ACCUMULATE. Every Acquire takes one, and this emitter runs
// once per firing of the sampler bit - which 26.2 makes happen at every texture-unit
// switch - so an emitter that took a reference per pass and never gave one back would pin
// every CSO it ever bound for the life of the process.
TEST(SamplerEmit, ABoundSamplerStateHoldsItsCsoUntilTheUnitMoves) {
EmitterScope scope;
const SharedPtr<SamplerObject>& sampler = Ctx().CreateSamplerObject(4137);
ASSERT_TRUE(sampler);
Ctx().GetTextureUnitObject(2).SetSamplerObject(sampler);
Ctx().NoteTextureUnitTouched(2);
ASSERT_GT(Emitter().EmitSamplerStates(Ctx()), 0u);
const MGPipeHandle bound = Emitter().LastSamplerStateHandles()[2];
ASSERT_FALSE(MGPipeHandleIsNull(bound));
EXPECT_EQ(Cache().RefCountOf(bound), 1u) << "the applier's standing set names this handle";
Ctx().BumpTextureBindGeneration();
Emitter().EmitSamplerStates(Ctx());
EXPECT_EQ(Cache().RefCountOf(bound), 1u) << "one binding is one reference, not one per pass";
// The unit stops naming it, and the reference goes with the binding rather than with
// the entry: the value is still worth caching, it is merely evictable again.
Ctx().GetTextureUnitObject(2).SetSamplerObject(SharedPtr<SamplerObject>());
Emitter().EmitSamplerStates(Ctx());
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[2]));
EXPECT_EQ(Cache().RefCountOf(bound), 0u);
EXPECT_TRUE(Cache().RecordIsPublished(bound)) << "released is not evicted";
}
// THE FINDING ID-17 RULES ON. MGPTextureParams::BuiltinSampler names a sampler CSO out of
// this cache, the applier deliberately does NOT resolve that handle (an unresolvable one is
// an ordering fact, not a corrupt one), and set_texture_params is re-emitted only when a
// texture's parameters move - so an LRU eviction of a built-in sampler's entry would leave
// a published record naming a handle whose slot has been re-handed out to a different
// value, with nothing that refuses, counts, logs or re-emits. The capacity argument closes
// only the intra-pass case; the reference count is what closes this one.
TEST(SamplerEmit, AReferencedCsoIsNeverTheLruVictim) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters pinnedParams = DistinctParameters();
const MGPipeHandle pinned = Cache().Acquire(pinnedParams, payload);
ASSERT_FALSE(MGPipeHandleIsNull(pinned));
ASSERT_EQ(Cache().RefCountOf(pinned), 1u);
// A CTS sampler sweep, in miniature: twice the cache's capacity in distinct values,
// none of which anything keeps naming. The LRU has to run, hard.
SamplerParameters filler = DistinctParameters();
for (SizeT i = 0; i < kMGPipeSamplerCsoCacheCapacity * 2; ++i) {
filler.minLod = static_cast<float>(i) + 0.5f;
Cache().Release(Cache().Acquire(filler, payload));
}
ASSERT_GT(Cache().GetCounters().Evictions, 0u) << "the LRU really has to have run";
EXPECT_EQ(Cache().GetCounters().OverCapacityMints, 0u)
<< "there were unreferenced entries to take, so nothing had to grow";
EXPECT_TRUE(Cache().RecordIsPublished(pinned))
<< "a handle a standing record names is not the LRU's to take";
Uint64 second = 0;
EXPECT_EQ(Cache().Acquire(pinnedParams, second), pinned);
EXPECT_EQ(second, 0u) << "and it was a hit, not a re-mint under the same value";
Cache().Release(pinned);
Cache().Release(pinned);
}
// AND WHEN EVERY ENTRY IS PINNED THE CACHE GROWS AND SAYS SO. Growing is the safe
// direction - a slot too many costs memory, a handle pulled out from under a live record
// costs correctness - and OverCapacityMints is a RECORDED NUMBER rather than a gate, so a
// workload that pins more than the capacity is visible instead of being wrong.
TEST(SamplerEmit, AFullyPinnedCacheMintsBeyondItsCapacityAndCountsIt) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters params = DistinctParameters();
Vector<MGPipeHandle> held;
for (SizeT i = 0; i < kMGPipeSamplerCsoCacheCapacity; ++i) {
params.minLod = static_cast<float>(i) + 0.5f;
held.push_back(Cache().Acquire(params, payload));
}
ASSERT_EQ(Cache().Size(), kMGPipeSamplerCsoCacheCapacity);
ASSERT_EQ(Cache().GetCounters().Evictions, 0u);
ASSERT_EQ(Cache().GetCounters().OverCapacityMints, 0u);
params.minLod = -12.5f; // one more distinct value, with nothing evictable
const MGPipeHandle extra = Cache().Acquire(params, payload);
EXPECT_FALSE(MGPipeHandleIsNull(extra));
EXPECT_EQ(Cache().Size(), kMGPipeSamplerCsoCacheCapacity + 1);
EXPECT_EQ(Cache().GetCounters().Evictions, 0u) << "no pinned entry may be taken";
EXPECT_EQ(Cache().GetCounters().OverCapacityMints, 1u) << "and the growth is counted";
for (const MGPipeHandle& handle : held) {
EXPECT_TRUE(Cache().RecordIsPublished(handle));
Cache().Release(handle);
}
Cache().Release(extra);
}
// THE COUNT IS PER HANDLE, NOT PER HOLDER, and this is what makes that visible to whoever
// holds one. Entries are content-addressed and shared by design - package B's texture-params
// record and this file's own bind_sampler_states name the SAME handle when the values match,
// each owing exactly one Release - so a holder that releases twice takes the OTHER holder's
// pin and the LRU may then evict a handle a published record still names, with nothing to
// refuse and nothing to re-emit. The cache cannot repair that, so it counts it; an assert
// could not, because MOBILEGL_ASSERT compiles out at INFO, i.e. in every build that runs.
TEST(SamplerEmit, AReleaseThisCacheNeverHandedOutIsCountedRatherThanAbsorbed) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters params = DistinctParameters();
const MGPipeHandle cso = Cache().Acquire(params, payload);
ASSERT_FALSE(MGPipeHandleIsNull(cso));
ASSERT_EQ(Cache().RefCountOf(cso), 1u);
ASSERT_EQ(Cache().GetCounters().UnknownReleases, 0u);
ASSERT_EQ(Cache().GetCounters().UnderflowedReleases, 0u);
// A HANDLE THIS CACHE NEVER MINTED: a stale one from before a reset, or one of another
// kind passed by mistake. It falls off the end of the probe and used to leave no trace
// at all - Releases counts only releases that found an entry.
Cache().Release(MGPipeHandle{cso.Slot + 4096u, cso.Gen});
EXPECT_EQ(Cache().GetCounters().UnknownReleases, 1u);
EXPECT_EQ(Cache().RefCountOf(cso), 1u) << "and it took nobody else's pin on the way";
// A SECOND RELEASE OF A REFERENCE ONLY ONE HOLDER OWED. The count does not underflow -
// that half was already right - but the attempt is now named.
Cache().Release(cso);
EXPECT_EQ(Cache().RefCountOf(cso), 0u);
EXPECT_EQ(Cache().GetCounters().UnderflowedReleases, 0u);
Cache().Release(cso);
EXPECT_EQ(Cache().RefCountOf(cso), 0u) << "no underflow";
EXPECT_EQ(Cache().GetCounters().UnderflowedReleases, 1u);
// A NULL HANDLE IS NEITHER: an out-of-window unit holds one and releasing it is the
// ordinary no-op the reconciliation depends on.
const auto before = Cache().GetCounters();
Cache().Release(kMGPipeNullHandle);
EXPECT_EQ(Cache().GetCounters().UnknownReleases, before.UnknownReleases);
EXPECT_EQ(Cache().GetCounters().Releases, before.Releases);
// NOTHING WAS EVICTED WHILE REFERENCED, which is the ID-17 invariant the same round
// turned from a compiled-out assert into a number.
EXPECT_EQ(Cache().GetCounters().ReferencedEvictions, 0u);
}
// FINAL REVIEW M-A: THE SAMPLER-VIEW RESOLUTION IS D-A4's PRODUCER OF kMGPipeBindSampler.
// "Any texture the sampler-view resolution names in an emitted MGPBoundView" carries the
// sticky bit from then on; a texture bound to a unit no sampler uniform resolves does not.
// Nothing produced the bit before the fix round.
TEST(SamplerEmit, AResolvedSamplerViewMarksItsTextureAsSamplerBound) {
EmitterScope scope;
MGPipeTextureEmitterInstance().ResetForTest();
namespace GL = MobileGL::MG_Impl::GLImpl;
const Uint program = MakeSamplerProgram();
GLint linked = 0;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
GL::UseProgram(program);
const GLint location = GL::GetUniformLocation(program, "sampled");
ASSERT_GE(location, 0);
GL::Uniform1i(location, 3);
GLuint sampledName = 0;
GLuint unsampledName = 0;
const SharedPtr<ITextureObject> sampled = MakeCompleteTexture(sampledName, 4);
const SharedPtr<ITextureObject> unsampled = MakeCompleteTexture(unsampledName, 4);
BindTextureToUnit(3, sampled);
BindTextureToUnit(5, unsampled);
ASSERT_GT(Emitter().EmitSamplerViews(Ctx()), 0u);
const MGPBoundView& resolved = Emitter().LastBoundViews()[3];
ASSERT_FALSE(MGPipeHandleIsNull(resolved.Texture));
EXPECT_NE(MGPipeTextureEmitterInstance().TextureBindMask(resolved.Texture) & kMGPipeBindSampler, 0)
<< "the texture a sampler view was resolved for does not carry kMGPipeBindSampler";
const MGPipeHandle unsampledHandle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, unsampled->GetLifetimeId());
if (!MGPipeHandleIsNull(unsampledHandle)) {
EXPECT_EQ(MGPipeTextureEmitterInstance().TextureBindMask(unsampledHandle) & kMGPipeBindSampler, 0)
<< "a texture no sampler uniform resolves to was marked sampler-bound";
}
GL::UseProgram(0);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-sampleremit-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
// ONE process-wide context for the whole suite, because half these cases need a really
// linked program and glslang lives behind this call. Each case uses GL names of its own.
MobileGL::Initialize();
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}