From 87e915382c6b48f35d05a7dc9592bbb632186e78 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 6 Oct 2026 22:17:16 -0400 Subject: [PATCH] [Test] (P13 W8): recorder goldens pin the pushed record stream of two fixed GL call sequences, with two negative controls. --- MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp | 6 + MobileGL/MG_Impl/Pipe/Verb/VerbPort.h | 4 + MobileGL/MG_Test/Pipe/CMakeLists.txt | 9 +- MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp | 678 ++++++++++++++++++ .../recorder/BufferVertexArrayDraw.txt | 47 ++ .../recorder/TextureFramebufferClearBlit.txt | 40 ++ 6 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp create mode 100644 MobileGL/MG_Test/Pipe/goldens/recorder/BufferVertexArrayDraw.txt create mode 100644 MobileGL/MG_Test/Pipe/goldens/recorder/TextureFramebufferClearBlit.txt diff --git a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp index 5ccd0506..dd0cf7b5 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp +++ b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp @@ -1794,6 +1794,12 @@ namespace MobileGL::MG_Record { Bool MonolithVerbPortInstalled() { return g_monolithVerbPort; } + Bool SetMonolithVerbPortRoutingForTesting(Bool viaPort) { + const Bool previous = g_monolithVerbPort; + g_monolithVerbPort = viaPort; + return previous; + } + void SetDropClearEmissionForNegativeControl(Bool drop) { g_dropClearEmission = drop; } Uint64 DroppedClearEmissions() { return g_droppedClearEmissions; } diff --git a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h index a46f05a0..0a2b54b4 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h +++ b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h @@ -76,6 +76,10 @@ namespace MobileGL::MG_Record { // by the library's bring-up when MG_Config::RecordArmAliasesFrontend() holds. void InstallMonolithVerbPort(MG_Backend::GlobalBackendFunctionsTable& table); Bool MonolithVerbPortInstalled(); + // Tests only (MG_Test/Pipe/RecorderGoldenTest.cpp): send the ported slots to the verb session + // resolver instead of the in-process sink, so a recorder sees the records the port would apply. + // Returns the previous routing. + Bool SetMonolithVerbPortRoutingForTesting(Bool viaPort); // THE E2 NEGATIVE CONTROLS (t1's debt against c1, BRIEF ยง7). When set, the named emitter // SKIPS its record - it still runs the pre-verb hooks and still returns - so a replay that diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index e2a43163..72258bd5 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -169,7 +169,7 @@ endif() foreach(pipeTest ResourceEmitTest VertexInputEmitTest FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest ProgramEmitTest CompositeResolverTest - ShaderBufferEmitTest) + ShaderBufferEmitTest RecorderGoldenTest) add_executable(${pipeTest} ${pipeTest}.cpp) target_include_directories(${pipeTest} PRIVATE @@ -191,6 +191,11 @@ foreach(pipeTest ResourceEmitTest VertexInputEmitTest endif() endforeach() +# P13 W8: the recorder goldens live beside the source, so a regeneration +# (MOBILEGL_RECORDER_GOLDEN_WRITE=1) writes the checked-in files. +target_compile_definitions(RecorderGoldenTest PRIVATE + MGL_RECORDER_GOLDEN_DIR="${CMAKE_CURRENT_SOURCE_DIR}/goldens/recorder") + # P5 package p1: table 2 (PipeInputs field ownership) at runtime. Registered in EVERY # configuration rather than behind `if (MOBILEGL_BUILD_DISAGGREGATED)` like the Wire suite, # because half of it is arithmetic over the generated table that a push or verify build can @@ -243,7 +248,7 @@ gtest_discover_tests(FieldOwnershipTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS u foreach(pipeTest ResourceEmitTest VertexInputEmitTest FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest ProgramEmitTest CompositeResolverTest - ShaderBufferEmitTest) + ShaderBufferEmitTest RecorderGoldenTest) gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach() gtest_discover_tests(MagmaPipeIdentityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp b/MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp new file mode 100644 index 00000000..c676c757 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp @@ -0,0 +1,678 @@ +// MobileGL - MobileGL/MG_Test/Pipe/RecorderGoldenTest.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P13 W8: RECORDER GOLDENS - "the pushed content does not change by accident". +// +// G1 (the pull build's symbol set) is retired; what it guarded - that refactoring the client does +// not quietly change what the backend is told - is guarded here instead, at the seam that is left: +// the record stream. A fixed sequence of frontend GL calls is run with a recorder in front of every +// door a record leaves the client through: +// +// * the two MGPipe interface tables (gMGPipeScreen / gMGPipeContext) and the route escapes, i.e. +// every state / object record (PipeRoute.h routes nothing around them); +// * the verb session (MG_Record::SetVerbSessionResolver), i.e. every verb record the emitters +// in MG_Impl/Pipe/Verb plan - the same bytes a transport would carry. +// +// Each record is NORMALISED (the one thing in a payload that is not a function of the GL calls is +// an address: an MGPBlobRef / MGHostSpan naming caller memory - zeroed, the size kept; the bytes +// they name are hashed instead) and hashed with its companion bytes and tails. The golden is one +// line per record - index, call name, size, hash - in goldens/recorder/.txt, small enough +// to review in a diff. The state records still reach the real applier after they are recorded, so +// the client's emitters see the same acceptance answers they always do; the verbs are recorded and +// answered "ok" (there is no backend in a unit process to apply them). +// +// REGENERATING IS DELIBERATE: MOBILEGL_RECORDER_GOLDEN_WRITE=1 rewrites the files and the case then +// fails, so a regeneration can never pass silently in CI. A changed golden is a statement that the +// pushed content changed on purpose; the commit that changes one says why. +// +// NEGATIVE CONTROLS: one field of one recorded record is perturbed (exactly what an emitter that +// wrote a field differently produces) and the comparison must go red naming that record; and the +// same fixture with one GL input changed must differ from the golden. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MGL_RECORDER_GOLDEN_DIR +#error "MGL_RECORDER_GOLDEN_DIR must name the golden directory (MG_Test/Pipe/CMakeLists.txt)" +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; +namespace GL = MobileGL::MG_Impl::GLImpl; + +namespace { + // ---- the hash and the recorded stream ------------------------------------------------------- + + struct Fnv { + Uint64 Value = 1469598103934665603ull; + void Add(const void* bytes, SizeT size) { + const auto* p = static_cast(bytes); + for (SizeT i = 0; i < size; ++i) { + Value ^= p[i]; + Value *= 1099511628211ull; + } + } + void AddU64(Uint64 v) { Add(&v, sizeof(v)); } + }; + + struct Record { + String Op; + Uint64 Bytes = 0; + Uint64 Hash = 0; + }; + + std::vector* g_stream = nullptr; + // Negative control: when set, the payload of the Nth record is perturbed before it is hashed. + Int64 g_perturbIndex = -1; + + void Note(const char* op, Fnv& hash, Uint64 bytes) { + if (g_stream == nullptr) return; + if (static_cast(g_stream->size()) == g_perturbIndex) hash.AddU64(0x5eedull); + g_stream->push_back(Record{op, bytes, hash.Value}); + } + + // ---- normalisation: addresses out, sizes kept -------------------------------------------------- + + void Scrub(MGPBlobRef& blob) { + if (blob.Seg == kMGHostSpanSegNone) blob.Offset = 0; + } + void Scrub(MGHostSpan& span) { + span.Ptr = nullptr; + if (span.Seg == kMGHostSpanSegNone) span.Offset = 0; + } + + template + void Normalize(T&) {} + template <> + void Normalize(MGPCaps& p) { + Scrub(p.FormatCapabilities); + Scrub(p.RendererInfo); + } + template <> + void Normalize(MGPRenderStateDesc& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPDynamicState& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPVertexElements& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPSamplerDesc& p) { Scrub(p.Parameters); } + template <> + void Normalize(MGPGlobalConstants& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPResidualValueState& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPSubData& p) { Scrub(p.Blob); } + template <> + void Normalize(MGPStorageBlockBinding& p) { Scrub(p.Name); } + template <> + void Normalize(MGPProgramDesc& p) { + for (auto& stage : p.Spirv) Scrub(stage); + Scrub(p.Reflection); + } + + template + void HashPayload(Fnv& hash, const P* payload) { + if (payload == nullptr) { + hash.AddU64(0); + return; + } + P copy; + std::memcpy(©, payload, sizeof(P)); + Normalize(copy); + hash.Add(©, sizeof(P)); + } + + // The element size of each table call's variable tail (the monolith adapters' own types, + // PipeRoute.cpp's MGP_MONO_TAIL rows, plus the sub-data region list). A tail call missing here is + // a red case, not a silently shorter hash. + SizeT TailElementBytes(const char* op) { + const String name = op; + if (name == "SetVertexBuffers") return sizeof(MGPVertexBuffer); + if (name == "SetSamplerViews") return sizeof(MGPBoundView); + if (name == "BindSamplerStates") return sizeof(MGPipeHandle); + if (name == "SetShaderImages") return sizeof(MGPImageView); + if (name == "SetShaderBuffers") return sizeof(MGPBufferRange); + if (name == "SetVertexAttribDefaults") return sizeof(MGPAttribValue); + if (name == "ResourceSubData") return sizeof(MGPSubRegion); + ADD_FAILURE() << "the recorder has no tail element size for " << op; + return 0; + } + + // The six argument shapes PipeTables.inc generates after the payload. + Uint64 HashRest(Fnv&, const char*) { return 0; } + Uint64 HashRest(Fnv&, const char*, MGPReplySlot*) { return 0; } + Uint64 HashRest(Fnv& hash, const char*, const void* blob, Uint64 blobBytes) { + hash.AddU64(blobBytes); + if (blob != nullptr && blobBytes != 0) hash.Add(blob, static_cast(blobBytes)); + return blobBytes; + } + Uint64 HashRest(Fnv& hash, const char* op, const void* blob, Uint64 blobBytes, MGPReplySlot*) { + return HashRest(hash, op, blob, blobBytes); + } + Uint64 HashRest(Fnv& hash, const char* op, const void* tail, Uint32 count) { + hash.AddU64(count); + const SizeT bytes = TailElementBytes(op) * count; + if (tail != nullptr && bytes != 0) hash.Add(tail, bytes); + return bytes; + } + Uint64 HashRest(Fnv& hash, const char* op, const void* blob, Uint64 blobBytes, const void* tail, Uint32 count, + MGPReplySlot*) { + return HashRest(hash, op, blob, blobBytes) + HashRest(hash, op, tail, count); + } + + // ---- the table wrappers, one per catalogue row ------------------------------------------------- + + template + struct Wrap; + template + struct Wrap { + using Fn = void (*)(const P*, Rest...); + static inline Fn Saved = nullptr; + static void Call(const P* payload, Rest... rest) { + Fnv hash; + HashPayload(hash, payload); + const Uint64 extra = HashRest(hash, Name, rest...); + Note(Name, hash, sizeof(P) + extra); + Saved(payload, rest...); + } + static void Install(Fn& slot) { + if (slot == nullptr || slot == &Call) return; + Saved = slot; + slot = &Call; + } + static void Uninstall(Fn& slot) { + if (slot == &Call) slot = Saved; + } + }; + +#define MGL_REC_TABLE_kScreen gMGPipeScreen +#define MGL_REC_TABLE_kCtxCso gMGPipeContext +#define MGL_REC_TABLE_kCtxState gMGPipeContext +#define MGL_REC_TABLE_kCtxObject gMGPipeContext +#define MGL_REC_TABLE_kCtxVerb gMGPipeContext +#define MGL_REC_TABLE_kCtxQuery gMGPipeContext + +#define MGL_REC_NAME(Name, Payload, Class, Flags, Wait) constexpr char kRecName_##Name[] = #Name; + MGP_CALL_LIST(MGL_REC_NAME) +#undef MGL_REC_NAME + + void InstallTableRecorders() { +#define MGL_REC_INSTALL(Name, Payload, Class, Flags, Wait) \ + Wrap::Install(MGL_REC_TABLE_##Class.Name); + MGP_CALL_LIST(MGL_REC_INSTALL) +#undef MGL_REC_INSTALL + } + void UninstallTableRecorders() { +#define MGL_REC_UNINSTALL(Name, Payload, Class, Flags, Wait) \ + Wrap::Uninstall(MGL_REC_TABLE_##Class.Name); + MGP_CALL_LIST(MGL_REC_UNINSTALL) +#undef MGL_REC_UNINSTALL + } + + // ---- the escapes --------------------------------------------------------------------------- + + MGPipeRouteEscapes g_savedEscapes{}; + + Bool RecResourceRespecify(const MGPResourceDesc* desc, const void* initialBytes, const MGPRespecifiedLevel* level) { + Fnv hash; + HashPayload(hash, desc); + HashPayload(hash, level); + hash.AddU64(initialBytes != nullptr ? 1u : 0u); + Note("ResourceRespecify", hash, sizeof(*desc)); + return g_savedEscapes.ResourceRespecify(desc, initialBytes, level); + } + void RecResourceFlushRange(const MGPFlushRange* record, const void* bytes) { + Fnv hash; + HashPayload(hash, record); + if (bytes != nullptr && record->Size != 0) hash.Add(bytes, static_cast(record->Size)); + Note("ResourceFlushRange", hash, sizeof(*record) + record->Size); + g_savedEscapes.ResourceFlushRange(record, bytes); + } + void* RecMapPersistent(const MGPHandleOnly* handle, Uint64 size, const void* seedBytes) { + Fnv hash; + HashPayload(hash, handle); + hash.AddU64(size); + if (seedBytes != nullptr && size != 0) hash.Add(seedBytes, static_cast(size)); + Note("MapPersistent", hash, sizeof(*handle) + size); + return g_savedEscapes.MapPersistent(handle, size, seedBytes); + } + void RecCreateShaderState(const MGPProgramDesc* desc, const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv, const Uint32* linkedStages, + Uint32 linkedStageCount) { + Fnv hash; + HashPayload(hash, desc); + hash.AddU64(linkedStageCount); + if (linkedStages != nullptr) hash.Add(linkedStages, sizeof(Uint32) * linkedStageCount); + Note("CreateShaderState", hash, sizeof(*desc)); + g_savedEscapes.CreateShaderState(desc, link, spirv, linkedStages, linkedStageCount); + } + void RecSetProgramBindings(const MGPProgramBindings* hdr, const Int32* blockBindings, + const MGPProgramSamplerUnit* samplerUnits, + const MGPProgramStorageOverride* storageOverrides, + const char* const* storageOverrideNames) { + Fnv hash; + HashPayload(hash, hdr); + if (blockBindings != nullptr) hash.Add(blockBindings, sizeof(Int32) * hdr->BlockBindingCount); + if (samplerUnits != nullptr) hash.Add(samplerUnits, sizeof(MGPProgramSamplerUnit) * hdr->SamplerUnitCount); + for (Uint32 i = 0; storageOverrides != nullptr && i < hdr->StorageOverrideCount; ++i) { + MGPProgramStorageOverride entry = storageOverrides[i]; + Scrub(entry.Name); + hash.Add(&entry, sizeof(entry)); + if (storageOverrideNames != nullptr && storageOverrideNames[i] != nullptr) { + hash.Add(storageOverrideNames[i], std::strlen(storageOverrideNames[i])); + } + } + Note("SetProgramBindings", hash, sizeof(*hdr)); + g_savedEscapes.SetProgramBindings(hdr, blockBindings, samplerUnits, storageOverrides, storageOverrideNames); + } + + // ---- the verb session ------------------------------------------------------------------------ + + const char* WireOpName(MGPWireOp op) { + switch (op) { +#define MGL_REC_OP_NAME(Name, Payload, Class, Flags, Wait) \ + case MGPWireOp::Name: \ + return #Name; + MGP_CALL_LIST(MGL_REC_OP_NAME) +#undef MGL_REC_OP_NAME + default: + return ""; + } + } + + class RecordingVerbSession final : public MG_Record::VerbSession { + public: + Uint64 EmitAndWait(MGPWireOp op, const void* payload, Uint64 payloadBytes, const void* varTail, + Uint64 varTailBytes, void* replyOut, Uint64 replyBytes, Int32* statusOut, + Uint64* replySizeOut) override { + const MGPipeVerbTail tail{varTail, varTailBytes}; + return EmitAndWaitTails(op, payload, payloadBytes, &tail, varTail != nullptr ? 1u : 0u, replyOut, + replyBytes, statusOut, replySizeOut); + } + Uint64 EmitAndWaitTails(MGPWireOp op, const void* payload, Uint64 payloadBytes, const MGPipeVerbTail* tails, + Uint32 tailCount, void* replyOut, Uint64 replyBytes, Int32* statusOut, + Uint64* replySizeOut) override { + Fnv hash; + HashVerbPayload(hash, op, payload, payloadBytes); + Uint64 bytes = payloadBytes; + for (Uint32 i = 0; i < tailCount; ++i) { + hash.AddU64(tails[i].Size); + if (tails[i].Bytes != nullptr && tails[i].Size != 0) hash.Add(tails[i].Bytes, static_cast(tails[i].Size)); + bytes += tails[i].Size; + } + Note(WireOpName(op), hash, bytes); + // No backend lives here: every verb is answered ok, with zeroed reply bytes. + if (replyOut != nullptr && replyBytes != 0) std::memset(replyOut, 0, static_cast(replyBytes)); + if (statusOut != nullptr) *statusOut = MGPipeReplySink::kStatusOk; + if (replySizeOut != nullptr) *replySizeOut = replyBytes; + return ++m_seq; + } + Uint32 MaxReplyBytes() const override { return 1u << 24; } + void RequireReadPixelsReplyFits(Uint32, Uint32, Uint32, Uint32, Uint64) const override {} + MGPBlobRef StageBytes(const void* bytes, Uint64 size) override { + // A deterministic segment offset rather than an address, and the bytes go into the hash + // of the record that names them through the next Note. + Fnv hash; + if (bytes != nullptr && size != 0) hash.Add(bytes, static_cast(size)); + Note("StageBytes", hash, size); + const MGPBlobRef ref{m_staged, size, 1u, 0u}; + m_staged += (size + 7u) & ~Uint64{7}; + return ref; + } + + private: + static void HashVerbPayload(Fnv& hash, MGPWireOp op, const void* payload, Uint64 payloadBytes) { + if (payload == nullptr || payloadBytes == 0) return; + if (op == MGPWireOp::SetStorageBlockBinding && payloadBytes == sizeof(MGPStorageBlockBinding)) { + HashPayload(hash, static_cast(payload)); + return; + } + hash.Add(payload, static_cast(payloadBytes)); + } + Uint64 m_seq = 0; + Uint64 m_staged = 0; + }; + + RecordingVerbSession* g_session = nullptr; + MG_Record::VerbSession& ResolveRecordingSession(const char*) { return *g_session; } + + // ---- the scope a fixture runs in ------------------------------------------------------------- + + class RecorderScope { + public: + explicit RecorderScope(std::vector& stream) { + m_savedPush = MG_Config::Features.PipePush; + MG_Config::Features.PipePush |= kMGPipeSubsystemsMigratedAtP5e; + m_savedResourceOps = MGPipeGetResourceOps(); + static const MGPipeResourceOps kEmpty{}; + MGPipeSetResourceOps(&kEmpty); + m_savedContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + + m_savedTable = MG_Backend::gBackendFunctionsTable; + MG_Record::AssignVerbEmitters(MG_Backend::gBackendFunctionsTable); + g_session = &m_session; + MG_Record::SetVerbSessionResolver(&ResolveRecordingSession); + m_savedPortRouting = MG_Record::SetMonolithVerbPortRoutingForTesting(false); + + g_savedEscapes = gMGPipeRouteEscapes; + gMGPipeRouteEscapes.ResourceRespecify = &RecResourceRespecify; + gMGPipeRouteEscapes.ResourceFlushRange = &RecResourceFlushRange; + gMGPipeRouteEscapes.MapPersistent = &RecMapPersistent; + gMGPipeRouteEscapes.CreateShaderState = &RecCreateShaderState; + gMGPipeRouteEscapes.SetProgramBindings = &RecSetProgramBindings; + InstallTableRecorders(); + g_stream = &stream; + } + ~RecorderScope() { + g_stream = nullptr; + UninstallTableRecorders(); + gMGPipeRouteEscapes = g_savedEscapes; + MG_Backend::gBackendFunctionsTable = m_savedTable; + // The context goes while the verb session is still the recorder's: its objects' deaths + // emit, and nothing after this scope may land in another fixture's stream. + MG_State::pGLContext.reset(); + MG_State::pGLContext = Move(m_savedContext); + MGPipeSetResourceOps(m_savedResourceOps); + MG_Config::Features.PipePush = m_savedPush; + MG_Record::SetMonolithVerbPortRoutingForTesting(m_savedPortRouting); + g_session = nullptr; + } + RecorderScope(const RecorderScope&) = delete; + RecorderScope& operator=(const RecorderScope&) = delete; + + private: + Uint64 m_savedPush = 0; + Bool m_savedPortRouting = false; + const MGPipeResourceOps* m_savedResourceOps = nullptr; + SharedPtr m_savedContext; + MG_Backend::GlobalBackendFunctionsTable m_savedTable{}; + RecordingVerbSession m_session; + }; + + // ---- the fixtures: fixed frontend call sequences --------------------------------------------- + + const char* kVs = R"(#version 430 core +layout(location = 0) in vec4 a_position; +uniform vec4 u_offset; +void main() { gl_Position = a_position + u_offset; } +)"; + const char* kFs = R"(#version 430 core +uniform sampler2D u_texture; +out vec4 o_color; +void main() { o_color = texture(u_texture, vec2(0.5)); } +)"; + + GLuint MakeShader(GLenum stage, const char* source) { + const GLuint shader = GL::CreateShader(stage); + GL::ShaderSource(shader, 1, &source, nullptr); + GL::CompileShader(shader); + return shader; + } + + // Buffers, a vertex array, a sampled texture, a program with a uniform, and the draw verbs. + // `vertexBytesDelta` changes one input for the negative control. + void RunBufferVertexArrayDraw(GLsizeiptr vertexBytesDelta = 0) { + const GLuint program = GL::CreateProgram(); + GL::AttachShader(program, MakeShader(GL_VERTEX_SHADER, kVs)); + GL::AttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFs)); + GL::LinkProgram(program); + GLint linked = GL_FALSE; + GL::GetProgramiv(program, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE) << "the fixture's program did not link"; + GL::UseProgram(program); + GL::Uniform4f(GL::GetUniformLocation(program, "u_offset"), 0.25f, -0.5f, 0.0f, 0.0f); + GL::Uniform1i(GL::GetUniformLocation(program, "u_texture"), 0); + + GLuint texture = 0; + GL::GenTextures(1, &texture); + GL::BindTexture(GL_TEXTURE_2D, texture); + GL::TexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 2, 2); + const Uint8 texels[16] = {255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255}; + GL::TexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, texels); + GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + GLuint vao = 0; + GL::GenVertexArrays(1, &vao); + GL::BindVertexArray(vao); + const GLfloat vertices[12] = {-1, -1, 0, 1, 1, -1, 0, 1, 0, 1, 0, 1}; + GLuint vbo = 0; + GL::GenBuffers(1, &vbo); + GL::BindBuffer(GL_ARRAY_BUFFER, vbo); + GL::BufferData(GL_ARRAY_BUFFER, static_cast(sizeof(vertices)) + vertexBytesDelta, nullptr, + GL_STATIC_DRAW); + GL::BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices); + GL::VertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), nullptr); + GL::EnableVertexAttribArray(0); + const GLushort indices[3] = {0, 1, 2}; + GLuint ebo = 0; + GL::GenBuffers(1, &ebo); + GL::BindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); + GL::BufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); + + GL::Enable(GL_BLEND); + GL::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + GL::Viewport(0, 0, 64, 32); + GL::DrawArrays(GL_TRIANGLES, 0, 3); + GL::DrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, nullptr); + GL::DrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); + GL::Disable(GL_BLEND); + GL::DrawElementsInstancedBaseVertex(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, nullptr, 2, 0); + } + + // A framebuffer over two textures: clears of both kinds, a blit, a mipmap generation. + void RunTextureFramebufferClearBlit() { + GLuint textures[2] = {0, 0}; + GL::GenTextures(2, textures); + for (const GLuint texture : textures) { + GL::BindTexture(GL_TEXTURE_2D, texture); + GL::TexStorage2D(GL_TEXTURE_2D, 3, GL_RGBA8, 8, 8); + } + GLuint framebuffers[2] = {0, 0}; + GL::GenFramebuffers(2, framebuffers); + for (Int i = 0; i < 2; ++i) { + GL::BindFramebuffer(GL_FRAMEBUFFER, framebuffers[i]); + GL::FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textures[i], 0); + } + GL::BindFramebuffer(GL_FRAMEBUFFER, framebuffers[0]); + GL::ClearColor(0.25f, 0.5f, 0.75f, 1.0f); + GL::Clear(GL_COLOR_BUFFER_BIT); + const GLfloat color[4] = {1.0f, 0.0f, 0.5f, 1.0f}; + GL::ClearBufferfv(GL_COLOR, 0, color); + GL::BindFramebuffer(GL_READ_FRAMEBUFFER, framebuffers[0]); + GL::BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffers[1]); + GL::BlitFramebuffer(0, 0, 8, 8, 0, 0, 4, 4, GL_COLOR_BUFFER_BIT, GL_NEAREST); + GL::BindTexture(GL_TEXTURE_2D, textures[1]); + GL::GenerateMipmap(GL_TEXTURE_2D); + } + + // ---- the comparison ------------------------------------------------------------------------ + + std::filesystem::path GoldenPath(const char* fixture) { + return std::filesystem::path(MGL_RECORDER_GOLDEN_DIR) / (String(fixture) + ".txt"); + } + + String Render(const std::vector& stream) { + std::ostringstream out; + out << "# MobileGL recorder golden (MG_Test/Pipe/RecorderGoldenTest.cpp). One record per line:\n" + "# index call bytes hash. Regenerate deliberately: MOBILEGL_RECORDER_GOLDEN_WRITE=1.\n"; + for (SizeT i = 0; i < stream.size(); ++i) { + char hash[17]; + std::snprintf(hash, sizeof(hash), "%016llx", static_cast(stream[i].Hash)); + out << i << ' ' << stream[i].Op << ' ' << stream[i].Bytes << ' ' << hash << '\n'; + } + return out.str(); + } + + std::vector Lines(const String& text) { + std::vector lines; + std::istringstream in(text); + for (String line; std::getline(in, line);) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty() || line[0] == '#') continue; + lines.push_back(line); + } + return lines; + } + + // Empty when the stream matches the golden; otherwise the first difference, named. + String Compare(const char* fixture, const std::vector& stream) { + std::ifstream file(GoldenPath(fixture)); + if (!file.good()) return "no golden at " + GoldenPath(fixture).string(); + const String golden((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + const std::vector want = Lines(golden); + const std::vector got = Lines(Render(stream)); + for (SizeT i = 0; i < std::max(want.size(), got.size()); ++i) { + const String a = i < want.size() ? want[i] : ""; + const String b = i < got.size() ? got[i] : ""; + if (a != b) return "record " + std::to_string(i) + ": golden '" + a + "', recorded '" + b + "'"; + } + return {}; + } + + Bool WriteRequested() { + const char* knob = std::getenv("MOBILEGL_RECORDER_GOLDEN_WRITE"); + return knob != nullptr && std::strcmp(knob, "1") == 0; + } + + void CheckAgainstGolden(const char* fixture, const std::vector& stream) { + ASSERT_FALSE(stream.empty()) << fixture << ": the recorder saw no record at all"; + if (WriteRequested()) { + std::error_code ec; + std::filesystem::create_directories(GoldenPath(fixture).parent_path(), ec); + std::ofstream out(GoldenPath(fixture), std::ios::binary); + out << Render(stream); + ASSERT_TRUE(out.good()) << fixture << ": could not write " << GoldenPath(fixture).string(); + FAIL() << fixture << ": golden REWRITTEN at " << GoldenPath(fixture).string() + << " (MOBILEGL_RECORDER_GOLDEN_WRITE=1); review the diff and rerun without the knob"; + } + const String difference = Compare(fixture, stream); + EXPECT_TRUE(difference.empty()) + << fixture << ": the pushed record stream changed - " << difference + << ". If the change is deliberate, regenerate with MOBILEGL_RECORDER_GOLDEN_WRITE=1 and say why " + "in the commit"; + } + + // HANDLES AND CSO NUMBERS ARE A FUNCTION OF WHAT THE PROCESS DID BEFORE, so a golden is only + // comparable for the FIRST capture in a process. ctest runs every case in a process of its own + // (gtest_discover_tests), which is the shape the goldens are recorded and checked in; a case + // that compares a later capture against a golden says so instead of failing for that reason. + Uint32 g_captures = 0; + + std::vector Capture(void (*fixture)()) { + ++g_captures; + std::vector stream; + { + RecorderScope scope(stream); + fixture(); + } + return stream; + } + + Bool FirstCaptureInProcess() { return g_captures == 0; } + + // Index of the first record whose op is `op` in a golden, or -1. + Int64 FirstGoldenRecordOf(const char* fixture, const char* op) { + std::ifstream file(GoldenPath(fixture)); + const String golden((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + const std::vector lines = Lines(golden); + for (SizeT i = 0; i < lines.size(); ++i) { + std::istringstream fields(lines[i]); + String index, name; + fields >> index >> name; + if (name == op) return static_cast(i); + } + return -1; + } +} // namespace + +TEST(RecorderGolden, BufferVertexArrayDraw) { + if (!FirstCaptureInProcess()) GTEST_SKIP() << "compare the first capture of a process only (ctest runs each case alone)"; + const std::vector stream = Capture([] { RunBufferVertexArrayDraw(); }); + CheckAgainstGolden("BufferVertexArrayDraw", stream); +} + +TEST(RecorderGolden, TextureFramebufferClearBlit) { + if (!FirstCaptureInProcess()) GTEST_SKIP() << "compare the first capture of a process only (ctest runs each case alone)"; + const std::vector stream = Capture(&RunTextureFramebufferClearBlit); + CheckAgainstGolden("TextureFramebufferClearBlit", stream); +} + +// The SHAPE of the stream (which records, in which order) is a function of the calls alone; only +// handle values and content-addressed CSO numbers depend on what the process did before, which is +// why the goldens compare a process's first capture. Two captures in one process: the same calls. +TEST(RecorderGolden, TheSameCallsRecordTheSameStreamTwice) { + const std::vector first = Capture(&RunTextureFramebufferClearBlit); + const std::vector second = Capture(&RunTextureFramebufferClearBlit); + ASSERT_EQ(first.size(), second.size()); + for (SizeT i = 0; i < first.size(); ++i) { + EXPECT_EQ(first[i].Op, second[i].Op) << "record " << i; + } +} + +// NEGATIVE CONTROL 1: one field of one record perturbed - what an emitter that wrote one field +// differently produces - must turn the comparison red AT that record, and nowhere before it. +TEST(RecorderGolden, APerturbedRecordFieldIsCaughtAtThatRecord) { + if (WriteRequested()) GTEST_SKIP() << "goldens are being rewritten"; + if (!FirstCaptureInProcess()) GTEST_SKIP() << "compare the first capture of a process only (ctest runs each case alone)"; + // The first draw: a verb record, i.e. the session half of the recorder rather than the tables. + const Int64 victim = FirstGoldenRecordOf("BufferVertexArrayDraw", "DrawVbo"); + ASSERT_GE(victim, 0) << "the golden has no DrawVbo record to perturb"; + g_perturbIndex = victim; + const std::vector perturbed = Capture([] { RunBufferVertexArrayDraw(); }); + g_perturbIndex = -1; + const String difference = Compare("BufferVertexArrayDraw", perturbed); + EXPECT_EQ(difference.rfind("record " + std::to_string(victim) + ":", 0), 0u) + << "a one-field change in record " << victim << " was not reported there: '" << difference << "'"; +} + +// NEGATIVE CONTROL 2: the same fixture with ONE GL input changed (the vertex buffer is defined four +// bytes longer) must differ from the golden - first at a resource record, which is where that input +// enters the stream. +TEST(RecorderGolden, AChangedGlInputChangesTheStream) { + if (WriteRequested()) GTEST_SKIP() << "goldens are being rewritten"; + if (!FirstCaptureInProcess()) GTEST_SKIP() << "compare the first capture of a process only (ctest runs each case alone)"; + const std::vector stream = Capture([] { RunBufferVertexArrayDraw(4); }); + const String difference = Compare("BufferVertexArrayDraw", stream); + ASSERT_FALSE(difference.empty()) + << "a buffer defined with a different size recorded the golden stream - the recorder is not " + "seeing the resource family"; + EXPECT_NE(difference.find(" Resource"), String::npos) + << "the first difference is not a resource record: " << difference; +} + +int main(int argc, char** argv) { + MobileGL::Initialize(); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/MobileGL/MG_Test/Pipe/goldens/recorder/BufferVertexArrayDraw.txt b/MobileGL/MG_Test/Pipe/goldens/recorder/BufferVertexArrayDraw.txt new file mode 100644 index 00000000..4e710ca3 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/goldens/recorder/BufferVertexArrayDraw.txt @@ -0,0 +1,47 @@ +# MobileGL recorder golden (MG_Test/Pipe/RecorderGoldenTest.cpp). One record per line: +# index call bytes hash. Regenerate deliberately: MOBILEGL_RECORDER_GOLDEN_WRITE=1. +0 ResourceCreate 88 71558ae14496671c +1 ResourceRespecify 88 88017539bd1a0af0 +2 SetTextureParams 40 3d2a5c32ef71aff7 +3 ResourceRespecify 88 aef432663d91fb51 +4 ResourceRespecify 88 c598f22b9a64c4f6 +5 SetTextureParams 40 d8c2913291025e86 +6 CreateSamplerState 132 d745c6a533207b8c +7 SetTextureParams 40 893f0d59d249d3c9 +8 CreateSamplerState 132 c96bbb5d56ab4a6c +9 SetTextureParams 40 730fe69e44ae8894 +10 ResourceCreate 88 d5372ec2536746dc +11 ResourceRespecify 88 66936501de8dd97f +12 ResourceSubData 136 04cd08ec9b6759f3 +13 ResourceCreate 88 68bf4e03f1c5b264 +14 ResourceRespecify 88 d6d1bac1d1f5f777 +15 SetFramebufferState 304 bfa746aeca933905 +16 CreateShaderState 200 14fd5a1b4872f269 +17 SetProgramBindings 32 55a48f4f0bc79772 +18 BindShaderState 16 0e00bb8e4477d0c8 +19 SetDrawProgram 16 0e00bb8e4477d0c8 +20 SetDispatchProgram 16 0e00bb8e4477d0c8 +21 ResourceSubData 104 592591476e1de3eb +22 ResourceRespecify 88 d2ea8273d6969816 +23 CreateSamplerView 36 92735167665158b2 +24 SetSamplerViews 40 ff12d52ac483fd59 +25 BindSamplerStates 24 8ec570815d8132c4 +26 SetGlobalConstants 56 3a08c059ed3fb509 +27 CreateRenderState 444 cbaa7e1b0c8f56b0 +28 BindRenderState 12 2df0851678bfe6a3 +29 SetDynamicState 804 feeb3db4dd9979e1 +30 SetPixelPackState 28 78c6269e722f3497 +31 SetPatchState 40 0307120c1ada1890 +32 SetVertexAttribDefaults 1800 007a2317a881dd1f +33 SetContextValues 96 8d24acac68829781 +34 CreateVertexElements 1320 1e6a49d992827735 +35 BindVertexElements 16 b110667323a9f265 +36 SetVertexBuffers 56 98392787becb775d +37 SetIndexBuffer 24 8b038a41009b3de1 +38 DrawVbo 68 f68b4c81a25b7c80 +39 DrawVbo 68 bfcdfb6905f6a2e0 +40 DrawVbo 68 1dc574eef36ac0b5 +41 CreateRenderState 444 640a0ca7f8c372cf +42 BindRenderState 12 588ef79fccfb799e +43 SetDynamicState 32 8d8e0f37b78b17a7 +44 DrawVbo 68 f1244468aa18a333 diff --git a/MobileGL/MG_Test/Pipe/goldens/recorder/TextureFramebufferClearBlit.txt b/MobileGL/MG_Test/Pipe/goldens/recorder/TextureFramebufferClearBlit.txt new file mode 100644 index 00000000..05497380 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/goldens/recorder/TextureFramebufferClearBlit.txt @@ -0,0 +1,40 @@ +# MobileGL recorder golden (MG_Test/Pipe/RecorderGoldenTest.cpp). One record per line: +# index call bytes hash. Regenerate deliberately: MOBILEGL_RECORDER_GOLDEN_WRITE=1. +0 ResourceCreate 88 71558ae14496671c +1 ResourceRespecify 88 88017539bd1a0af0 +2 SetTextureParams 40 3d2a5c32ef71aff7 +3 ResourceRespecify 88 230a5834ddd9fb11 +4 ResourceRespecify 88 a89aff026f4da9b3 +5 ResourceRespecify 88 e6c007d9f022120d +6 ResourceRespecify 88 050cbea1c62f0be8 +7 SetTextureParams 40 2f10ffaec453f3a4 +8 ResourceCreate 88 c217fc7ff1452b9e +9 ResourceRespecify 88 d321fba14a2c36c2 +10 SetTextureParams 40 bdba21408c8e83c2 +11 ResourceRespecify 88 8c62341c0210880f +12 ResourceRespecify 88 f650fc199867fa85 +13 ResourceRespecify 88 b18e4add172826b3 +14 ResourceRespecify 88 fd774dc11eebc9ee +15 SetTextureParams 40 4556a7173eb3d5b9 +16 ResourceRespecify 88 3a52ffc2b6f65868 +17 SetFramebufferState 304 aa19202ca33b5839 +18 SetSamplerViews 40 55c63a25a13ef0e5 +19 BindSamplerStates 24 8ec570815d8132c4 +20 CreateRenderState 444 f6f688292ec4adf0 +21 BindRenderState 12 6ddf112725259973 +22 SetDynamicState 804 03f9f241df0e53e8 +23 SetPixelPackState 28 78c6269e722f3497 +24 SetPatchState 40 0307120c1ada1890 +25 SetVertexAttribDefaults 1800 007a2317a881dd1f +26 SetContextValues 96 8d24acac68829781 +27 CreateVertexElements 1320 ce2fdd5c06385602 +28 BindVertexElements 16 b110667323a9f265 +29 SetVertexBuffers 24 2aa235b376840448 +30 SetIndexBuffer 24 d852dcb87b6f12a3 +31 Clear 48 83eb2e137a7bf1df +32 Clear 48 cc7ba82ee577512b +33 ResourceRespecify 88 c8310ca02e247d6e +34 SetFramebufferState 304 f9ff0332b1933fe3 +35 SetFramebufferState 304 38ba5a914cd6e74e +36 Blit 56 972a72218ae59845 +37 GenerateMipmap 16 8faf9bba18b55233