From 2d2090cf1c081dca990d8a6fa426cb6775ba58ee Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 13:04:49 -0400 Subject: [PATCH] [Feat, Test] (Pipe): wire the framebuffer subsystem bit and pin the resolved read surface, the sticky bind mask, the level-shadow strides and the union-box/region-list invariant --- MobileGL/MG_Impl/Pipe/FramebufferEmit.h | 19 +- MobileGL/MG_Impl/Pipe/TextureEmit.h | 110 +++- MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp | 368 ++++++++++++- MobileGL/MG_Test/Pipe/TextureEmitTest.cpp | 488 +++++++++++++++++- .../MG_Test/State/ObjectLifetimeIdTest.cpp | 70 +++ 5 files changed, 1021 insertions(+), 34 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h index 0c7ab4de..45a4f325 100644 --- a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -41,12 +41,19 @@ namespace MobileGL::MG_Pipe { - // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR, and it is 0 until the emitter below - // has a body. PipeFill.cpp ORs the four per-family constants into kMGPipeWiredSubsystems, - // so the bit is added by the commit that gives the emitters their bodies, with no file - // touched twice - and a Coverage.def row can never silently drop a field on the floor - // before the call that carries it exists. - inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = 0; + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family + // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the + // emitters their bodies, with no file touched twice - and a Coverage.def row can never + // silently drop a field on the floor before the call that carries it exists. + // + // TURNING IT ON RETIRES NO PULL. GetFramebufferBindingSlot is the family's one + // Coverage.def emitted row and PipeFill.cpp's EmittedCallSuppliesTheWholeField answers + // FALSE for it, with the reason: the field's storage is a BindingSlot - + // a frontend heap reference - and the call that supplies it carries eight-byte {slot, gen} + // handles and a fully resolved descriptor. So this bit switches the EMISSION on and the + // residual fill keeps writing the mirror, which is what keeps the verify lane at zero + // divergence. + inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = kMGPipeSubsystemFramebuffer; inline Bool MGPipeFramebufferSubsystemEnabled() { return (kMGPipeWiredFramebufferSubsystem & kMGPipeSubsystemFramebuffer) != 0 && diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h index 14bbf1d8..8a45fd21 100644 --- a/MobileGL/MG_Impl/Pipe/TextureEmit.h +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -67,22 +67,61 @@ namespace MobileGL::MG_Pipe { - // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR, and it is 0 until the emitter below - // has a body. PipeFill.cpp ORs the four per-family constants into kMGPipeWiredSubsystems, - // so the bit is added by the commit that gives the emitters their bodies, with no file - // touched twice - and a Coverage.def row can never silently drop a field on the floor - // before the call that carries it exists. + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family + // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the + // emitters their bodies, with no file touched twice - and a Coverage.def row can never + // silently drop a field on the floor before the call that carries it exists. + // + // IT IS STILL 0, AND THAT IS A BLOCKED FLIP RATHER THAN AN UNFINISHED ONE. Unlike the other + // three P4a families, this one does not get four fresh apply entry points: the catalogue is + // closed and a texture rides P3a's OWN resource_create / resource_respecify / + // resource_subdata / resource_destroy rows. On a base without the wire package's `w1` those + // four have P3a's BUFFER bodies, and two of their properties make a texture record actively + // harmful rather than merely ignored: + // + // * MGPipeApplierState::Resources is ONE vector indexed by SLOT (D-B2 makes it three, one + // per resource kind). Buffer, Texture and Renderbuffer slot spaces are independent, so + // a texture create at slot 12 OVERWRITES the buffer record at slot 12, and the next + // write to that buffer is refused against the texture's extent - a dropped content + // write with no diagnostic beyond the refusal counter; + // * SubDataBoxFault validates every record as the buffer half of MGPSubData, so a texture + // sub-data record is Fatal{ProtocolCorruption} on `record.Level != 0` alone (D-D4's + // drain cases and TextureTest.GetTexImageReadsALevelWhoseLowerLevelsWereNeverDefined + // abort in a verify build, which is how this was found rather than argued). + // + // So the flip is `w1`'s to unblock and the integrator's to make, in the rebase of this + // branch onto the wire branch: change the 0 below to kMGPipeSubsystemTextureResources, and + // nothing else. The whole conversion is already gated by TextureEmit's cases, which arm the + // emitter directly (ArmForTest) and assert on the EMITTED records rather than on applier + // state - so the flip cannot land untested, and until it lands nothing this file builds + // reaches an applier that cannot hold it. inline constexpr Uint64 kMGPipeWiredTextureSubsystem = 0; // BOTH HALVES MATTER, exactly as MGPipeResourceSubsystemEnabled()'s two do. The bit is the - // operator's per-subsystem A/B; the constant above is "has the client's texture family been - // given a body in this build at all". There is no third half - no MGPipeResourceOps member - // and no backend op table (D-B1: every P4a call is an object record or working state the - // applier stores, and none of them dispatches to a backend function pointer) - which is - // what makes the A/B a pure configuration question rather than a bring-up-order one. - inline Bool MGPipeTextureSubsystemEnabled() { - return (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0 && - (MG_Config::Features.PipePush & kMGPipeSubsystemTextureResources) != 0; + // operator's per-subsystem A/B; the emitter's arm is "has this build's texture family been + // switched on at all", and it is initialised from the constant above. There is no third + // half - no MGPipeResourceOps member and no backend op table (D-B1: every P4a call is an + // object record or working state the applier stores, and none of them dispatches to a + // backend function pointer) - which is what makes the A/B a pure configuration question + // rather than a bring-up-order one. + inline Bool MGPipeTextureSubsystemEnabled(); + + // DO THE RECORDS THIS EMITTER BUILDS REACH THE APPLIER ON THIS BASE? It is the wired + // constant asked as a predicate, and it is a SECOND gate rather than the same one because + // the two questions really are different while the flip is blocked: + // + // * the emitter's ARM decides whether the family runs at all - the handles, the sticky + // bind mask, the descriptor dedupe, the drain list and the record construction; + // * this decides whether the four resource_* records are handed to P3a's apply bodies, + // which cannot hold them until the wire package's w1 gives them their Desc.Target + // branch and their three per-kind vectors. + // + // set_texture_params is deliberately NOT behind it: MGPipeApplySetTextureParams is one of + // P4a's OWN fifteen entry points and is a stub at the contract tag, so a record handed to it + // is stored by nobody and refused by nobody. Sending it is what keeps that seam exercised + // rather than merely declared. + inline constexpr Bool MGPipeTextureRecordsReachTheApplier() { + return (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0; } // --------------------------------------------------------------------------------- @@ -455,8 +494,8 @@ namespace MobileGL::MG_Pipe { } void NoteRenderbufferBoundAs(MGPipeHandle handle, Uint16 bit) { if (MGPipeHandleIsNull(handle)) return; - EntryFor(m_renderbuffers, handle).BindMask = static_cast( - EntryFor(m_renderbuffers, handle).BindMask | bit); + Entry& entry = EntryFor(m_renderbuffers, handle); + entry.BindMask = static_cast(entry.BindMask | bit); } Uint16 TextureBindMask(MGPipeHandle handle) const { return MaskOf(m_textures, handle); } Uint16 RenderbufferBindMask(MGPipeHandle handle) const { return MaskOf(m_renderbuffers, handle); } @@ -489,7 +528,7 @@ namespace MobileGL::MG_Pipe { entry.LastDesc = desc; entry.HasLastDesc = true; NoteDesc(desc, /*isCreate=*/true); - MGPipeApplyResourceCreate(desc); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceCreate(desc); } // resource_create for a texture whose DERIVED object does not exist yet - the base @@ -510,7 +549,7 @@ namespace MobileGL::MG_Pipe { entry.LastDesc = desc; entry.HasLastDesc = true; NoteDesc(desc, /*isCreate=*/true); - MGPipeApplyResourceCreate(desc); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceCreate(desc); } // resource_respecify, from every storage-defining entry point. DEDUPED ON THE @@ -565,7 +604,7 @@ namespace MobileGL::MG_Pipe { bufOffset, bufSize); entry.Published = true; NoteDesc(createDesc, /*isCreate=*/true); - MGPipeApplyResourceCreate(createDesc); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceCreate(createDesc); } entry.LastDesc = desc; entry.HasLastDesc = true; @@ -573,7 +612,7 @@ namespace MobileGL::MG_Pipe { // NO initial bytes: a texture's texels travel as resource_subdata out of the drain // list, never inside its storage definition. This is what keeps glTexImage2D's // "define the level and upload it" one allocation and one upload rather than two. - MGPipeApplyResourceRespecify(desc, nullptr); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceRespecify(desc, nullptr); } void EmitTextureParams(ITextureObject& texture) { @@ -614,7 +653,7 @@ namespace MobileGL::MG_Pipe { entry.LastDesc = desc; entry.HasLastDesc = true; NoteDesc(desc, /*isCreate=*/true); - MGPipeApplyResourceCreate(desc); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceCreate(desc); } // D-D2: THE RENDERBUFFER PUBLICATION HOLE, CLOSED BY EMISSION. @@ -639,12 +678,12 @@ namespace MobileGL::MG_Pipe { renderbuffer, handle, entry.BindMask, /*storageDefined=*/false); entry.Published = true; NoteDesc(createDesc, /*isCreate=*/true); - MGPipeApplyResourceCreate(createDesc); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceCreate(createDesc); } entry.LastDesc = desc; entry.HasLastDesc = true; NoteDesc(desc, /*isCreate=*/false); - MGPipeApplyResourceRespecify(desc, nullptr); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceRespecify(desc, nullptr); } // ---- the drain list (D-D4) ---- @@ -755,6 +794,18 @@ namespace MobileGL::MG_Pipe { void ResetCounters() { m_creates = m_respecifies = m_paramSets = m_subDatas = 0; } + // ---- the arm (see kMGPipeWiredTextureSubsystem) ---- + // + // "Does this build's texture family emit at all", initialised from the wired constant. + // It is a RUNTIME latch and not a constant only because the flip is blocked on the wire + // package's `w1` while the conversion below is finished: a unit case arms it, drives a + // frontend mutation and asserts on the record the emitter built, so the conversion is + // gated by a test on a base whose applier could not yet hold that record. Once the + // constant is flipped this stays true for the life of the process and ArmForTest is + // redundant rather than wrong. + Bool Armed() const { return m_armed; } + void ArmForTest(Bool armed) { m_armed = armed; } + // A unit fixture's per-case reset; the library never calls it. See // MGPipeResourceTracker::ResetForTest for the rule this restates: a texture handle and // the applier record it names are SHARE-GROUP OBJECT STATE, so nothing here is @@ -768,6 +819,7 @@ namespace MobileGL::MG_Pipe { m_lastDesc = MGPResourceDesc{}; m_lastParams = MGPTextureParams{}; m_lastSubData = MGPSubData{}; + m_armed = (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0; ResetCounters(); } @@ -884,7 +936,9 @@ namespace MobileGL::MG_Pipe { m_lastSubData.Blob.Offset = static_cast(reinterpret_cast(shadow)); m_lastSubData.Blob.Size = 0; - MGPipeApplyResourceSubData(m_lastSubData, shadow); + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + MGPipeApplyResourceSubData(m_lastSubData, shadow); + } ++m_subDatas; if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ClientTextureUploadEmissions, 1); @@ -911,6 +965,7 @@ namespace MobileGL::MG_Pipe { Uint64 m_respecifies = 0; Uint64 m_paramSets = 0; Uint64 m_subDatas = 0; + Bool m_armed = (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0; }; inline MGPipeTextureEmitter& MGPipeTextureEmitterInstance() { @@ -922,6 +977,11 @@ namespace MobileGL::MG_Pipe { return *emitter; } + inline Bool MGPipeTextureSubsystemEnabled() { + return MGPipeTextureEmitterInstance().Armed() && + (MG_Config::Features.PipePush & kMGPipeSubsystemTextureResources) != 0; + } + // --------------------------------------------------------------------------------- // The six entry points MG_State calls. See this file's header comment for why they are // here rather than in MG_Pipe/PipeMutation.h. @@ -1004,7 +1064,7 @@ namespace MobileGL::MG_Pipe { MGPHandleOnly only{}; only.Handle = handle; only.Kind = static_cast(MGPipeKind::Texture); - MGPipeApplyResourceDestroy(only); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceDestroy(only); emitter.NoteTextureRecordDestroyed(handle); return true; } @@ -1016,7 +1076,7 @@ namespace MobileGL::MG_Pipe { MGPHandleOnly only{}; only.Handle = handle; only.Kind = static_cast(MGPipeKind::Renderbuffer); - MGPipeApplyResourceDestroy(only); + if constexpr (MGPipeTextureRecordsReachTheApplier()) MGPipeApplyResourceDestroy(only); emitter.NoteRenderbufferRecordDestroyed(handle); return true; } diff --git a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp index 0e3e76a8..62da38a3 100644 --- a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp @@ -58,7 +58,14 @@ #include #if MOBILEGL_PIPE_PUSH #include +#include #include +#include +#include +#include +#include + +#include #endif using namespace MobileGL; @@ -202,7 +209,6 @@ TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { #endif } -// ========================================================================================= // 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 @@ -560,7 +566,367 @@ TEST(FramebufferEmit, AFramebufferRecordThatNamesNoUsableHandleIsRefusedRatherTh << "an out-of-range slot resized the table instead of being refused"; #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, TheResolvedReadSurfaceComesFromTheReadFramebuffersOwnReadBuffer) \ + X(FramebufferEmit, OneObjectBoundToBothTargetsEmitsOneRecordWithTargetBoth) \ + X(FramebufferEmit, ADrawBufferChangeAloneStillMovesTheContentHash) \ + X(FramebufferEmit, ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor) \ + X(FramebufferEmit, EveryAttachmentFieldSurvivesTheSurfaceConversion) \ + X(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) \ + X(FramebufferEmit, ARestoragedAttachedRenderbufferPublishesItsNewExtent) \ + X(FramebufferEmit, AnUnchangedBindingPairEmitsNothing) +#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; + + // 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; + MG_Config::Features.PipePush |= + kMGPipeSubsystemFramebuffer | kMGPipeSubsystemTextureResources; + m_previousContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + MGPipeFramebufferEmitterInstance().ResetForTest(); + MGPipeTextureEmitterInstance().ResetForTest(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + // See TextureEmitTest's twin: the texture-resource family's wired constant is still + // blocked on the wire package, and this suite drives a renderbuffer respecify through + // it, so the emitter is armed explicitly here. + MGPipeTextureEmitterInstance().ArmForTest(true); + } + ~FramebufferScope() { + MGPipeFramebufferEmitterInstance().ResetForTest(); + MGPipeTextureEmitterInstance().ResetForTest(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + 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 m_previousContext; + Uint64 m_previousPush = 0; + }; + + MGPipeFramebufferEmitter& Framebuffers() { return MGPipeFramebufferEmitterInstance(); } + GLContext& Ctx() { return *MG_State::pGLContext; } + + SharedPtr MakeColorTexture(Uint name, Int size, Uint levels = 1) { + auto texture = MakeShared(name); + texture->SetInternalFormat(TextureInternalFormat::RGBA8); + for (Uint level = 0; level < levels; ++level) { + const Int extent = std::max(size >> level, 1); + texture->AllocateStorage(TextureUploadTarget::Texture2D, level, + MipmapInput{IntVec3{extent, extent, 1}, + static_cast(extent) * static_cast(extent) * 4}); + } + return texture; + } + + void BindDrawAndRead(const SharedPtr& draw, const SharedPtr& 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 it is resolved from the +// READ framebuffer's OWN read buffer - so the "same FBO as draw" skip that used to lose it +// cannot be expressed at all. +TEST(FramebufferEmit, TheResolvedReadSurfaceComesFromTheReadFramebuffersOwnReadBuffer) { + FramebufferScope scope; + const auto drawColor = MakeColorTexture(1, 32); + const auto readColor0 = MakeColorTexture(2, 32); + const auto readColor1 = MakeColorTexture(3, 32); + + const auto drawFbo = MakeShared(1); + drawFbo->AttachTexture(FramebufferAttachmentType::Color0, drawColor, TextureUploadTarget::Texture2D); + const auto readFbo = MakeShared(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)); + EXPECT_EQ(Framebuffers().LastDraw().Target, static_cast(MGPipeFramebufferTarget::Draw)); + EXPECT_TRUE(Framebuffers().LastDraw().ReadSurface.Res == readColor1Handle) + << "MGPFramebufferState::ReadSurface on the DRAW record did not come from the READ " + "framebuffer's own read buffer"; + EXPECT_EQ(Framebuffers().LastRead().Target, static_cast(MGPipeFramebufferTarget::Read)); + EXPECT_TRUE(Framebuffers().LastRead().ReadSurface.Res == readColor1Handle); + // 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(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(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(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(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(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(6); + Vector> colors; + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + colors.push_back(MakeColorTexture(static_cast(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( + static_cast(FramebufferAttachmentType::Color0) + static_cast(i)), + colors.back(), TextureUploadTarget::Texture2D, + static_cast(i % 2), static_cast(i), (i % 2) == 0); + } + const auto depth = MakeShared(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( + static_cast(FramebufferAttachmentType::Color0) + static_cast(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(attachment.GetTexture()->GetFormat())) + << "MGPSurface::InternalFormat at colour point " << i; + EXPECT_EQ(surface.Level, static_cast(attachment.GetTextureLevel())) + << "MGPSurface::Level at colour point " << i; + EXPECT_EQ(surface.Layer, static_cast(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(TextureUploadTarget::Texture2D)) + << "MGPSurface::UploadTarget at colour point " << i; + } + + EXPECT_EQ(record.Depth.Kind, kMGPipeSurfaceKindRenderbuffer) << "MGPSurface::Kind on the depth point"; + EXPECT_EQ(record.Depth.InternalFormat, static_cast(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.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(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); + EXPECT_EQ(Framebuffers().LastDraw().Color[1].UploadTarget, 0u); +} + +// ============================ D-C3 ============================ +TEST(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) { + FramebufferScope scope; + const auto color = MakeColorTexture(50, 32); + const auto beyond = MakeColorTexture(51, 32); + const auto fbo = MakeShared(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( + static_cast(FramebufferAttachmentType::Color0) + + static_cast(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(3); + renderbuffer->SetInternalFormat(TextureInternalFormat::Depth24Stencil8); + renderbuffer->AllocateStorage(IntVec2{64, 64}); + const auto fbo = MakeShared(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(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); +} +#endif // MOBILEGL_PIPE_PUSH + +// ================================================================================== 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 diff --git a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp index 9bdaff8a..092164c2 100644 --- a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp @@ -57,8 +57,15 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include #include #include +#include +#include +#include +#include + +#include #endif using namespace MobileGL; @@ -175,7 +182,6 @@ TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { #endif } -// ========================================================================================= // The APPLIER's half of the texture family (the wire commits'): set_texture_params on the // texture's own record, and the sub-data validator plus the pending-upload set that replaces // the frontend dirty flags the client clears at emission. The emitter's half - the descriptor @@ -795,7 +801,485 @@ TEST(TextureEmit, TheCreateAndRespecifyCallsAnswerWhetherTheRecordWasAccepted) { #endif } -int main(int argc, char** argv) { +======= +// ============================================================================ +// P4a package B's EMITTER-SIDE cases. The contract commit landed the file and 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. +// ============================================================================ +#if !MOBILEGL_PIPE_PUSH +#define MGL_TEXTURE_EMIT_CLIENT_TEST_LIST(X) \ + X(TextureEmit, EveryTextureTargetMapsToItsOwnResourceTarget) \ + X(TextureEmit, EveryBindKindSetsItsBindMaskBit) \ + X(TextureEmit, ABindMaskBitIsStickyAcrossARespecify) \ + X(TextureEmit, AnImageBoundTextureCarriesTheImageBindableHintForever) \ + X(TextureEmit, TheUnionBoxAndTheRegionListDescribeTheSameTexels) \ + X(TextureEmit, AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero) \ + X(TextureEmit, AWholeLevelUploadCarriesZeroStrides) \ + X(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) \ + X(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) \ + X(TextureEmit, EveryTexturesParamsNameItsBuiltinSamplerCso) \ + X(TextureEmit, TwoTexturesWithIdenticalSamplingShareOneBuiltinCso) \ + X(TextureEmit, ADestroyedTextureReleasesItsResourceViewAndBuiltinSamplerSlots) \ + X(TextureEmit, ARenderbufferRespecifyPublishesItsExtentWithoutAVersionCounter) \ + X(TextureEmit, ABailedLevelStaysDirtyAndStaysOnTheDrainList) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } +MGL_TEXTURE_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::MipmapDirtyRegion; + using MG_State::GLState::MipmapInput; + using MG_State::GLState::MipmapStorage; + using MG_State::GLState::RenderbufferObject; + using MG_State::GLState::TextureObject2D; + using MG_State::GLState::TextureObjectView; + + // AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason: both gates + // grep `ctest -R 'TextureEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and + // gtest refuses to mix TEST and TEST_F under one suite name - so a fixture would rename + // every case out of the gate's reach. + // + // It ARMS THE SUBSYSTEM BIT, which a unit binary otherwise has cleared: + // MG_Config::Features.PipePush defaults to 0 and only ConfigLoader ever sets the phase mask, + // so without this every emission below would be correctly skipped and every assertion would + // be green for the wrong reason. + struct TextureScope { + TextureScope() { + m_previousPush = MG_Config::Features.PipePush; + MG_Config::Features.PipePush |= kMGPipeSubsystemTextureResources; + m_previousContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + MGPipeTextureEmitterInstance().ResetForTest(); + // ARMED EXPLICITLY. kMGPipeWiredTextureSubsystem is still 0 on this base - P3a's four + // resource_* apply bodies index ONE slot space and validate every record as a buffer + // write, so a texture record there is a dropped buffer write or a + // Fatal{ProtocolCorruption}, and the flip is the wire package's w1 to unblock. The + // conversion itself is finished, so it is gated HERE: the emitter is armed, a frontend + // mutation is driven, and the assertions read the record the emitter BUILT rather than + // any applier state. + MGPipeTextureEmitterInstance().ArmForTest(true); + } + ~TextureScope() { + MGPipeTextureEmitterInstance().ResetForTest(); + MG_State::pGLContext.reset(); + MG_State::pGLContext = Move(m_previousContext); + MG_Config::Features.PipePush = m_previousPush; + } + TextureScope(const TextureScope&) = delete; + TextureScope& operator=(const TextureScope&) = delete; + + UniquePtr m_previousContext; + Uint64 m_previousPush = 0; + }; + + MGPipeTextureEmitter& Textures() { return MGPipeTextureEmitterInstance(); } + GLContext& Ctx() { return *MG_State::pGLContext; } + + // A 2D texture with `levels` levels, each RGBA8 and each half the previous one, so the + // bytes-per-texel the emitter derives from (byteSize / texelCount) is exactly 4 and every + // stride assertion below is an exact number rather than a range. + SharedPtr MakeTexture2D(Uint name, Int size, Uint levels = 1) { + auto texture = MakeShared(name); + texture->SetInternalFormat(TextureInternalFormat::RGBA8); + for (Uint level = 0; level < levels; ++level) { + const Int extent = std::max(size >> level, 1); + texture->AllocateStorage(TextureUploadTarget::Texture2D, level, + MipmapInput{IntVec3{extent, extent, 1}, + static_cast(extent) * static_cast(extent) * 4}); + } + return texture; + } +} // namespace + +// ============================ D-A3 ============================ +// +// The exhaustiveness the contract's static_assert already pins, walked at RUNTIME over every +// enumerator - because the assert answers "is every target mapped" and this answers the +// stronger "does every target map to its OWN row". Folding rectangle onto 2D is the one +// collapse anybody would be tempted by, and it is a distinction the frontend keeps and both +// backends switch on. +TEST(TextureEmit, EveryTextureTargetMapsToItsOwnResourceTarget) { + Vector seen; + for (Int i = 0; i < static_cast(TextureTarget::TextureTargetCount); ++i) { + const auto target = static_cast(i); + const Uint32 resourceTarget = MGPipeResourceTargetForTextureTarget(target); + EXPECT_NE(resourceTarget, kMGPipeResourceTargetUnmapped) + << "TextureTarget " << i << " has no MGPResourceDesc::Target row"; + EXPECT_NE(resourceTarget, static_cast(MGPipeResourceTarget::Buffer)) + << "TextureTarget " << i << " maps onto the BUFFER row, which the ack predicate reads"; + EXPECT_NE(resourceTarget, static_cast(MGPipeResourceTarget::Renderbuffer)) + << "TextureTarget " << i << " maps onto the RENDERBUFFER row"; + for (const Uint32 previous : seen) { + EXPECT_NE(previous, resourceTarget) + << "TextureTarget " << i << " shares its resource target with an earlier one"; + } + seen.push_back(resourceTarget); + } + EXPECT_EQ(seen.size(), static_cast(TextureTarget::TextureTargetCount)); +} + +// ============================ D-A4 ============================ +TEST(TextureEmit, EveryBindKindSetsItsBindMaskBit) { + TextureScope scope; + const auto texture = MakeTexture2D(1, 8); + const MGPipeHandle handle = Textures().FindTexture(*texture); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + + const Uint16 bits[] = {kMGPipeBindSampler, kMGPipeBindShaderImage, kMGPipeBindRenderTarget, + kMGPipeBindDepthStencil}; + Uint16 expected = 0; + for (const Uint16 bit : bits) { + Textures().NoteTextureBoundAs(handle, bit); + expected = static_cast(expected | bit); + EXPECT_EQ(Textures().TextureBindMask(handle), expected) << "bind bit " << bit; + } + // ORed, never cleared: re-noting one bit cannot drop the others. + Textures().NoteTextureBoundAs(handle, kMGPipeBindSampler); + EXPECT_EQ(Textures().TextureBindMask(handle), expected); +} + +TEST(TextureEmit, ABindMaskBitIsStickyAcrossARespecify) { + TextureScope scope; + const auto texture = MakeTexture2D(2, 8); + const MGPipeHandle handle = Textures().FindTexture(*texture); + Textures().NoteTextureBoundAs(handle, kMGPipeBindRenderTarget); + + // A storage definition REPUBLISHES the mask; it does not rebuild it. + texture->AllocateStorage(TextureUploadTarget::Texture2D, 0, + MipmapInput{IntVec3{16, 16, 1}, 16 * 16 * 4}); + EXPECT_TRUE(Textures().LastDesc().Resource == handle); + EXPECT_NE(Textures().LastDesc().BindMask & kMGPipeBindRenderTarget, 0) + << "MGPResourceDesc::BindMask lost the RENDER_TARGET bit across a respecify"; + EXPECT_EQ(Textures().LastDesc().Width, 16u); +} + +TEST(TextureEmit, AnImageBoundTextureCarriesTheImageBindableHintForever) { + TextureScope scope; + const auto texture = MakeTexture2D(3, 8); + const MGPipeHandle handle = Textures().FindTexture(*texture); + EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 0); + + Textures().NoteTextureBoundAs(handle, kMGPipeBindShaderImage); + // The next respecify carries the hint - and it is the PREVENTION half of the texture-remint + // stall class, so it must never go back to 0 afterwards. + texture->AllocateStorage(TextureUploadTarget::Texture2D, 1, MipmapInput{IntVec3{4, 4, 1}, 4 * 4 * 4}); + EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 1); + texture->AllocateStorage(TextureUploadTarget::Texture2D, 2, MipmapInput{IntVec3{2, 2, 1}, 2 * 2 * 4}); + EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 1); + + // And the transition armed the ONE resync the client is allowed to ask for (D-E2): the + // widened-channel carrier needs a swizzle override the frontend params version never moves + // for. It is one-shot - the server clears its own copy, the client never clears a server + // flag, and the client must not keep asking. + texture->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Blue); + EXPECT_EQ(Textures().LastParams().ForceResync, 1); + texture->SetSwizzleParam(TextureSwizzleParam::Green, TextureSwizzleParam::Blue); + EXPECT_EQ(Textures().LastParams().ForceResync, 0); +} + +// ============================ D-D3 / D-D6 ============================ +// +// THE INVARIANT THAT MAKES THE SERVER'S CHOICE SAFE, and nothing else in the tree can see it: +// SSIM is completely blind to whether the server uploaded one union box or N rects, and the +// Mali cliff behind that choice is ~+6 ms/frame for a hundred one-rect jobs against one box. So +// the two representations have to describe the SAME texels - every rect inside the box, and +// their union exactly the box. +TEST(TextureEmit, TheUnionBoxAndTheRegionListDescribeTheSameTexels) { + TextureScope scope; + const auto texture = MakeTexture2D(4, 256); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{1, 1, 1}); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{255, 255, 0}, + IntVec3{1, 1, 1}); + ASSERT_EQ(Textures().DrainListSize(), 1u); + + Textures().DrainTextureSubData(Ctx()); + const MGPSubData record = Textures().LastSubData(); + const Vector regions = Textures().LastRegions(); + ASSERT_EQ(record.RegionCount, regions.size()); + ASSERT_GE(record.RegionCount, 2u) << "two far-apart one-texel writes must survive as two rects"; + + Int32 loX = record.UnionBox.X + static_cast(record.UnionBox.W); + Int32 loY = record.UnionBox.Y + static_cast(record.UnionBox.H); + Int32 hiX = record.UnionBox.X; + Int32 hiY = record.UnionBox.Y; + for (const MGPSubRegion& region : regions) { + EXPECT_GE(region.X, record.UnionBox.X) << "a rect starts left of the union box"; + EXPECT_GE(region.Y, record.UnionBox.Y) << "a rect starts above the union box"; + EXPECT_LE(region.X + static_cast(region.W), + record.UnionBox.X + static_cast(record.UnionBox.W)) + << "a rect ends right of the union box"; + EXPECT_LE(region.Y + static_cast(region.H), + record.UnionBox.Y + static_cast(record.UnionBox.H)) + << "a rect ends below the union box"; + loX = std::min(loX, region.X); + loY = std::min(loY, region.Y); + hiX = std::max(hiX, region.X + static_cast(region.W)); + hiY = std::max(hiY, region.Y + static_cast(region.H)); + } + EXPECT_EQ(loX, record.UnionBox.X) << "the rects' union does not reach the box's left edge"; + EXPECT_EQ(loY, record.UnionBox.Y) << "the rects' union does not reach the box's top edge"; + EXPECT_EQ(hiX, record.UnionBox.X + static_cast(record.UnionBox.W)); + EXPECT_EQ(hiY, record.UnionBox.Y + static_cast(record.UnionBox.H)); +} + +TEST(TextureEmit, AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero) { + TextureScope scope; + const auto texture = MakeTexture2D(5, 256); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{4, 8, 0}, IntVec3{2, 2, 1}); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{200, 220, 0}, + IntVec3{2, 2, 1}); + Textures().DrainTextureSubData(Ctx()); + const Vector regions = Textures().LastRegions(); + ASSERT_GE(regions.size(), 2u); + // A SUB-RECT'S ROWS ARE NOT CONTIGUOUS IN THE SHADOW, so it must carry the LEVEL's pitches - + // not its own width - or the staging planner on the far side repacks the wrong bytes. + for (const MGPSubRegion& region : regions) { + EXPECT_EQ(region.SrcRowStride, 256u * 4u) << "SrcRowStride is not the LEVEL's row pitch"; + EXPECT_EQ(region.SrcSliceStride, 256u * 4u * 256u) + << "SrcSliceStride is not the LEVEL's slice pitch"; + const Uint64 expectedOffset = + static_cast(region.Y) * 256u * 4u + static_cast(region.X) * 4u; + EXPECT_EQ(region.SrcOffset, expectedOffset) << "SrcOffset is not the first texel's byte offset"; + } +} + +TEST(TextureEmit, AWholeLevelUploadCarriesZeroStrides) { + TextureScope scope; + const auto texture = MakeTexture2D(6, 64); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, + IntVec3{64, 64, 1}); + Textures().DrainTextureSubData(Ctx()); + const MGPSubData record = Textures().LastSubData(); + EXPECT_EQ(record.RegionCount, 0u) << "a single whole-level rect IS the union box, not a list"; + EXPECT_EQ(record.UnionBox.X, 0); + EXPECT_EQ(record.UnionBox.Y, 0); + EXPECT_EQ(record.UnionBox.W, 64u); + EXPECT_EQ(record.UnionBox.H, 64u); + EXPECT_EQ(record.SourceIsVerbatimLevelShadow, 1) + << "the client always declares the level shadow; the server clears it when it converts"; + EXPECT_EQ(record.Blob.Seg, kMGHostSpanSegNone); + EXPECT_EQ(record.Blob.Size, 0u) << "a monolith record does not declare its blob"; + EXPECT_NE(record.Blob.Offset, 0u) << "Blob.Offset is the level shadow's address in monolith"; + // The upload target rides in the record's Target byte beside the resource target, which is + // the only place a cube face could ever be carried. + EXPECT_EQ(MGPipeSubDataResourceTargetOf(record.Target), + static_cast(MGPipeResourceTarget::Tex2D)); + EXPECT_EQ(MGPipeSubDataUploadTargetOf(record.Target), + static_cast(TextureUploadTarget::Texture2D)); + // And the whole-level builder really does say TIGHTLY PACKED. + const MGPipeLevelPitch pitch = MGPipeLevelPitchOf(IntVec3{64, 64, 1}, 64 * 64 * 4); + const MGPSubRegion whole = MGPipeBuildSubRegion(record.UnionBox, IntVec3{64, 64, 1}, pitch); + EXPECT_EQ(whole.SrcRowStride, 0u); + EXPECT_EQ(whole.SrcSliceStride, 0u); + EXPECT_EQ(whole.SrcOffset, 0u); +} + +TEST(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) { + TextureScope scope; + const auto texture = MakeTexture2D(7, 128); + // Far more writes than the rect cap. The storage MERGES rather than truncates - a dropped + // rect is a dropped write - and degrades toward the union box; what the emitter must never + // do is invent or truncate. So the storage's own answer is the oracle, read BEFORE the drain + // clears the level, and 0 means "the union box is the whole story". + for (Int i = 0; i < 4 * static_cast(MipmapStorage::kMaxDirtyRects); ++i) { + const Int x = (i * 7) % 120; + const Int y = (i * 11) % 120; + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{x, y, 0}, + IntVec3{2, 2, 1}); + } + MipmapDirtyRegion oracle[MipmapStorage::kMaxDirtyRects]; + const SizeT oracleCount = texture->GetStorageDirtyRects(TextureUploadTarget::Texture2D, 0, oracle, + MipmapStorage::kMaxDirtyRects); + EXPECT_LE(oracleCount, MipmapStorage::kMaxDirtyRects); + Textures().DrainTextureSubData(Ctx()); + EXPECT_EQ(Textures().LastSubData().RegionCount, static_cast(oracleCount)) + << "the emitted region count is not the storage's own answer"; + EXPECT_EQ(Textures().LastRegions().size(), oracleCount); +} + +// ============================ D-D4 ============================ +TEST(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) { + TextureScope scope; + const auto owner = MakeTexture2D(8, 64, 3); + owner->SetImmutableLevels(3); + const MGPipeHandle ownerHandle = Textures().FindTexture(*owner); + const auto view = MakeShared(9, TextureTarget::Texture2D, owner, 1, 2, 0, 1); + const MGPipeHandle viewHandle = Textures().FindTexture(*view); + ASSERT_FALSE(MGPipeHandleIsNull(ownerHandle)); + ASSERT_FALSE(MGPipeHandleIsNull(viewHandle)); + ASSERT_FALSE(ownerHandle == viewHandle); + // ViewOf names the storage owner, and ONE HOP always reaches storage. + EXPECT_TRUE(Textures().LastDesc().Resource == viewHandle); + EXPECT_TRUE(Textures().LastDesc().ViewOf == ownerHandle) + << "the view's descriptor does not name its storage owner"; + + // An upload through the VIEW: TextureObjectView forwards the mark to the OWNER's method + // after remapping the level, so the drain list holds exactly one entry and it is the + // owner's - which is what makes an upload through a view and an upload through the owner one + // key rather than two. + view->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1}); + ASSERT_EQ(Textures().DrainListSize(), 1u); + Textures().DrainTextureSubData(Ctx()); + EXPECT_TRUE(Textures().LastSubData().Res == ownerHandle) + << "an upload through a view was keyed on the view instead of on its storage owner"; + // The view's level 0 IS the owner's level 1. + EXPECT_EQ(Textures().LastSubData().Level, 1u); +} + +// ============================ D-E1 ============================ +TEST(TextureEmit, EveryTexturesParamsNameItsBuiltinSamplerCso) { + TextureScope scope; + const auto texture = MakeTexture2D(10, 8); + const MGPipeHandle handle = Textures().FindTexture(*texture); + texture->SetSwizzleParamRGBA(Vec4{TextureSwizzleParam::Alpha, + TextureSwizzleParam::Blue, + TextureSwizzleParam::Green, + TextureSwizzleParam::Red}); + const MGPTextureParams params = Textures().LastParams(); + EXPECT_TRUE(params.Res == handle); + // kMGPipeNullHandle is ILLEGAL here: every texture object owns a sampler object, so a null + // is Fatal{ProtocolCorruption} on the far side rather than "no sampler". + EXPECT_FALSE(MGPipeHandleIsNull(params.BuiltinSampler)) + << "MGPTextureParams::BuiltinSampler must never be the null handle"; + EXPECT_TRUE(params.BuiltinSampler == + MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, + texture->GetSamplerObject()->GetLifetimeId())) + << "the built-in sampler CSO is not keyed on the SamplerObject's own lifetime id, which is " + "the key ~SamplerObject's death helper resolves through"; + EXPECT_EQ(params.Swizzle[0], static_cast(TextureSwizzleParam::Alpha)); + EXPECT_EQ(params.Swizzle[1], static_cast(TextureSwizzleParam::Blue)); + EXPECT_EQ(params.Swizzle[2], static_cast(TextureSwizzleParam::Green)); + EXPECT_EQ(params.Swizzle[3], static_cast(TextureSwizzleParam::Red)); + EXPECT_EQ(params.DepthStencilMode, kMGPipeDepthStencilModeDepth); + // D-E3's deliverable at the one site that proves it: a texture nothing has bound - no + // sampler view, no image unit, only ever an attachment - still publishes the mode, because + // set_texture_params is addressed by RESOURCE and is independent of every binding. + texture->SetDepthStencilTextureMode(GL_STENCIL_INDEX); + EXPECT_EQ(Textures().LastParams().DepthStencilMode, kMGPipeDepthStencilModeStencil); +} + +TEST(TextureEmit, TwoTexturesWithIdenticalSamplingShareOneBuiltinCso) { + TextureScope scope; + if (kMGPipeWiredSamplerSubsystem == 0) { + GTEST_SKIP() << "the sampler family's content-addressed CSO cache is not wired on this tree. " + "This package resolves MGPTextureParams::BuiltinSampler IDENTITY-addressed, " + "off the SamplerObject's own lifetime id - which is the key " + "~SamplerObject's death helper already resolves through - so two textures " + "get two CSOs here. Sharing is the sampler package's cache (capacity 256, " + "hashed and memcmp-confirmed field-wise over a zero-initialised canonical " + "copy), and this case is its gate on the integrated tree."; + } + const auto first = MakeTexture2D(11, 8); + const auto second = MakeTexture2D(12, 8); + first->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Green); + const MGPipeHandle firstCso = Textures().LastParams().BuiltinSampler; + second->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Green); + EXPECT_TRUE(Textures().LastParams().BuiltinSampler == firstCso); +} + +// ============================ D-I1 ============================ +TEST(TextureEmit, ADestroyedTextureReleasesItsResourceViewAndBuiltinSamplerSlots) { + TextureScope scope; + const Uint32 texturesBefore = MGPipeSlots().LiveCount(MGPipeKind::Texture); + const Uint32 viewsBefore = MGPipeSlots().LiveCount(MGPipeKind::SamplerViewCso); + MGPipeHandle handle{}; + { + const auto texture = MakeTexture2D(13, 8); + handle = Textures().FindTexture(*texture); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::Texture, handle)); + // The sampler VIEW is minted off the TEXTURE's own lifetime id - one per ITextureObject + // (D-F2) - so the texture's death is the only thing that can release it. + (void)MGPipeSlots().Acquire(MGPipeKind::SamplerViewCso, texture->GetLifetimeId()); + } + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Texture, handle)) + << "~TextureObjectBase did not return the texture's slot"; + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::Texture), texturesBefore) + << "a destroyed texture leaked its resource slot"; + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::SamplerViewCso), viewsBefore) + << "a destroyed texture leaked the sampler view minted off its lifetime id"; + // THE BUILT-IN SAMPLER'S CSO SLOT IS NOT CHECKED HERE, and the absence is a statement rather + // than an omission: it belongs to a real SamplerObject with its OWN lifetime id and its own + // #if MOBILEGL_PIPE_PUSH destructor, so releasing it from the texture's id would resolve the + // wrong slot - possibly a live one belonging to another object. ~SamplerObject runs + // immediately after ~TextureObjectBase's body (a member's destructor follows its owner's) and + // is where the sampler package wires MGPipeEmitSamplerCsoDestroyAndFree; until that lands, + // this tree's SamplerCso slots are released by nobody, which is exactly the ordered + // dependency the integration order (clientfb, then clientsp) exists for. + // + // A second release on the same handle is a proven no-op: Free bumps no generation of its + // own, so a double free cannot skip one. + MGPipeSlots().Free(MGPipeKind::Texture, handle); + EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::Texture), texturesBefore); +} + +// ============================ D-D2 ============================ +TEST(TextureEmit, ARenderbufferRespecifyPublishesItsExtentWithoutAVersionCounter) { + TextureScope scope; + const auto renderbuffer = MakeShared(1); + const MGPipeHandle handle = Textures().FindRenderbuffer(*renderbuffer); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + EXPECT_TRUE(Textures().LastDesc().Resource == handle); + EXPECT_EQ(Textures().LastDesc().Target, static_cast(MGPipeResourceTarget::Renderbuffer)); + EXPECT_EQ(Textures().LastDesc().HasDefinedContent, 0) << "a create carries no storage"; + + // The three setters bump no version and raise no notice, and the framebuffer bit's shutter + // does not move for an ALREADY-ATTACHED renderbuffer - which is exactly why the hole is + // closed by EMISSION from the storage entry point rather than by a new counter (a member + // would resize the pull build's object) or a wider shutter. + renderbuffer->SetInternalFormat(TextureInternalFormat::Depth24Stencil8); + renderbuffer->AllocateStorage(IntVec2{320, 240}); + renderbuffer->SetSamples(4); + const MGPResourceDesc desc = Textures().LastDesc(); + EXPECT_TRUE(desc.Resource == handle); + EXPECT_EQ(desc.Width, 320u); + EXPECT_EQ(desc.Height, 240u); + EXPECT_EQ(desc.Samples, 4u); + EXPECT_EQ(desc.HasDefinedContent, 1); + EXPECT_EQ(desc.InternalFormat, static_cast(TextureInternalFormat::Depth24Stencil8)); + + // THE DEDUPE, which is what keeps one glRenderbufferStorage one record rather than three. + const Uint64 respecifiesBefore = Textures().RespecifyCount(); + renderbuffer->AllocateStorage(IntVec2{320, 240}); + EXPECT_EQ(Textures().RespecifyCount(), respecifiesBefore); +} + +// ============================ D-D5 ============================ +TEST(TextureEmit, ABailedLevelStaysDirtyAndStaysOnTheDrainList) { + TextureScope scope; + // A level with no storage at all: there is nothing to upload, the record cannot be built, + // and the texels are still owed. Naively clearing the flag here is exactly how a bail loses + // texels, which is the failure D-D5's three steps exist to make impossible. + // A level with a real EXTENT and no BYTES: the region is non-empty so the level is genuinely + // dirty, and the emitter cannot derive a bytes-per-texel or find a shadow to point at. + const auto texture = MakeShared(14); + texture->SetInternalFormat(TextureInternalFormat::RGBA8); + texture->AllocateStorage(TextureUploadTarget::Texture2D, 0, MipmapInput{IntVec3{8, 8, 1}, 0}); + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1}); + ASSERT_TRUE(texture->IsStorageDirty(TextureUploadTarget::Texture2D, 0)); + ASSERT_EQ(Textures().DrainListSize(), 1u); + Textures().DrainTextureSubData(Ctx()); + EXPECT_EQ(Textures().DrainListSize(), 1u) + << "a level the emitter could not describe was dropped from the drain list"; + EXPECT_EQ(Textures().SubDataCount(), 0u); + + // And a level that WAS emitted leaves both the flag and the list entry behind it. + const auto good = MakeTexture2D(15, 16); + good->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1}); + Textures().DrainTextureSubData(Ctx()); + EXPECT_EQ(Textures().SubDataCount(), 1u); + EXPECT_FALSE(good->IsStorageDirty(TextureUploadTarget::Texture2D, 0)) + << "the client must clear its own flag for a level whose record the applier accepted"; +} +#endif // MOBILEGL_PIPE_PUSH + + +// ==================================================================================int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = fs::temp_directory_path() / ("mobilegl-textureemit-test-" + std::to_string(ProcessId()) + ".log"); diff --git a/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp index 0f4a3f02..2cef04a9 100644 --- a/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp +++ b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp @@ -32,7 +32,11 @@ #include "Includes.h" #include +#include +#include #include +#include +#include #include using namespace MobileGL; @@ -156,3 +160,69 @@ TEST(ObjectLifetimeIdTest, RenderbufferObjectAtARecycledAddressCarriesAFreshLife TEST(ObjectLifetimeIdTest, LiveRenderbufferObjectsHaveDistinctLifetimeIds) { ExpectDistinctIdsWhileBothAlive("RenderbufferObject"); } + +// P4a mints a client handle off the lifetime id of FOUR more object classes - a texture, a +// framebuffer, a sampler and a program - and resolves every one of their death paths through +// the same lifetimeId -> slot map. So the property these cases prove stops being a statement +// about two backend memos and becomes the thing the whole handle space rests on: a recycled +// heap address must not reproduce a handle, because the applier's record and the backend's twin +// are both keyed on one. +// +// The texture case is additionally a CHURN check that costs nothing extra: under a push build +// each round mints a Texture slot in the constructor and returns it in the destructor, so 64 +// rounds that leaked would be visible to the leak cases the gates package owns. +TEST(ObjectLifetimeIdTest, TextureObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::TextureObject2D; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("TextureObject2D"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveTextureObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("TextureObject2D"); +} + +TEST(ObjectLifetimeIdTest, FramebufferObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::FramebufferObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("FramebufferObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveFramebufferObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("FramebufferObject"); +} + +TEST(ObjectLifetimeIdTest, SamplerObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::SamplerObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("SamplerObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveSamplerObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("SamplerObject"); +} + +TEST(ObjectLifetimeIdTest, ProgramObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::ProgramObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("ProgramObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveProgramObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("ProgramObject"); +}