From 33632589085d585a30a1217e91e786891d649a64 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:07:33 -0400 Subject: [PATCH] [Test] (MGPipe): pin the catalogue arithmetic, the wire opcodes and the comparator - Thirteen cases that need no GL context and no driver, so the fact that PipeCalls.def and the seven generated files agree is checked on every unit run rather than at review time. - The catalogue count is one fact stated three times - the macro expansion, MGP_CALL_LIST_DOCUMENTED_COUNT and the two generated tables - and the per-class counts documented in the .def header are asserted individually, which is what caught kCtxState being 17 rather than 18 (set_texture_params carries kCtxObject, per the plan's own example in section 4.1). - An uninstalled pipe must be all-null: that is what "this subsystem has not been migrated, keep pulling" means (section 4.1), so it is asserted rather than assumed. - The comparator test pins the two properties the verify harness depends on: the FIRST differing field is named, and padding is not a field - two payloads that differ only in padding compare equal, while a nested array element does not. - The poison test pins the per-verb behaviour a bitmap cannot express: a field filled for verb N is stale at verb N+1. --- MobileGL/MG_Test/CMakeLists.txt | 3 + MobileGL/MG_Test/Pipe/CMakeLists.txt | 28 +++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 220 ++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 MobileGL/MG_Test/Pipe/CMakeLists.txt create mode 100644 MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index a981087d..14615b54 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -76,6 +76,9 @@ add_subdirectory(VertexArray) add_subdirectory(Program) add_subdirectory(Query) add_subdirectory(Pipeline) +# The MGPipe catalogue arithmetic: no GL context and no driver, just the .def, the seven +# generated files and the payload layouts. +add_subdirectory(Pipe) add_subdirectory(ShaderTranspiler) add_subdirectory(Util) add_subdirectory(SelfTest) diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt new file mode 100644 index 00000000..19dc5e4b --- /dev/null +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + PipeCatalogueTest + PipeCatalogueTest.cpp +) + +target_include_directories(PipeCatalogueTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + PipeCatalogueTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor) +endif() + +include(GoogleTest) +gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp new file mode 100644 index 00000000..3d63eb71 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -0,0 +1,220 @@ +// MobileGL - MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The arithmetic of the MGPipe catalogue (plan B section 4.4, appendix A). Everything here +// is cheap on purpose: it is the test that fails when PipeCalls.def and the seven generated +// files stop agreeing, and it must not need a GL context to say so. + +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // Counting expansions of the catalogue. The Class parameter is a real enumerator, so a + // per-class count is a constant expression too. +#define MGP_COUNT_ONE(Name, Payload, Class, Flags) +1 +#define MGP_COUNT_CLASS(Name, Payload, Class, Flags) +((Class) == countedClass ? 1 : 0) + + constexpr SizeT kExpandedCallCount = 0 MGP_CALL_LIST(MGP_COUNT_ONE); + + template + constexpr SizeT ClassCount() { + return 0 MGP_CALL_LIST(MGP_COUNT_CLASS); + } + + // Every payload named in the catalogue must be a memcpy-able POD, and so must every + // payload the verify comparator knows about. +#define MGP_ASSERT_CALL_PAYLOAD_POD(Name, Payload, Class, Flags) \ + static_assert(std::is_trivially_copyable_v, #Name "'s payload " #Payload " is not trivially copyable"); + MGP_CALL_LIST(MGP_ASSERT_CALL_PAYLOAD_POD) + +#define MGP_ASSERT_VERIFY_PAYLOAD_POD(Payload) \ + static_assert(std::is_trivially_copyable_v, #Payload " is not trivially copyable"); + MGP_VERIFY_PAYLOAD_LIST(MGP_ASSERT_VERIFY_PAYLOAD_POD) +} // namespace + +// The handle is the whole object model. Eight bytes, a register pair, no padding. +TEST(PipeCatalogue, HandleIsEightBytes) { + static_assert(sizeof(MGPipeHandle) == 8); + static_assert(alignof(MGPipeHandle) == 4); + static_assert(std::is_trivially_copyable_v); + EXPECT_EQ(sizeof(MGPipeHandle), 8u); + + // The two reserved handles, and the composite band that the program-pipeline resolver + // allocates out of. + EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle)); + EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer)); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeFirstAllocatableSlot)); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoCompositeSlotBase)); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoSlotLimit)); +} + +// The catalogue, the number documented in its header, and the two generated tables are one +// fact stated three times. This is the test that notices when they stop being. +TEST(PipeCatalogue, EntryCountMatchesTheDocumentedCount) { + static_assert(kExpandedCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT); + static_assert(kExpandedCallCount == kMGPipeCallCount); + EXPECT_EQ(kExpandedCallCount, static_cast(MGP_CALL_LIST_DOCUMENTED_COUNT)); + EXPECT_EQ(kMGPipeCallCount, kExpandedCallCount); +} + +TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { + static_assert(ClassCount() == kMGPipeScreenCallCount); + static_assert(ClassCount() + ClassCount() + ClassCount() + + ClassCount() + ClassCount() + ClassCount() == + kMGPipeCallCount); + // The tables ARE their function pointers: a struct that is bigger than its call count + // has grown a member no generator knows about. + static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)())); + static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)())); + + EXPECT_EQ(kMGPipeScreenCallCount, ClassCount()); + EXPECT_EQ(kMGPipeContextCallCount, kMGPipeCallCount - ClassCount()); + + // The per-class counts PipeCalls.def documents in its header. + EXPECT_EQ(ClassCount(), 10u); + EXPECT_EQ(ClassCount(), 6u); + EXPECT_EQ(ClassCount(), 13u); + EXPECT_EQ(ClassCount(), 17u); + EXPECT_EQ(ClassCount(), 9u); + EXPECT_EQ(ClassCount(), 13u); +} + +// An uninstalled pipe is every entry null - which is exactly what "this subsystem has not +// been migrated, keep pulling" means (plan B section 4.1). +TEST(PipeCatalogue, UninstalledTablesAreAllNull) { + const void* const* screen = reinterpret_cast(&gMGPipeScreen); + for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) { + EXPECT_EQ(screen[i], nullptr) << "screen entry " << i; + } + const void* const* context = reinterpret_cast(&gMGPipeContext); + for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) { + EXPECT_EQ(context[i], nullptr) << "context entry " << i; + } +} + +// The retirement ratchet of the migration carrier (section 6.3): the constant and the +// struct must agree, and the constant only ever goes down. +TEST(PipeCatalogue, ResidualBlockSizeIsPinned) { + static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE); + EXPECT_EQ(sizeof(ResidualValueBlock), static_cast(MGL_RESIDUAL_BLOCK_SIZE)); + // It carries the whole of both value structs today; that is what the later stages eat. + EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters)); +} + +// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. +TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) { + EXPECT_EQ(static_cast(MGPWireOp::GetCaps), 1); + EXPECT_EQ(static_cast(MGPWireOp::kOpCount), kMGPipeCallCount + 1); + EXPECT_EQ(sizeof(MGPWireRecHeader), 8u); + // Every record is a multiple of the stream's 8-byte granularity, which is half of the + // applier's precondition. + EXPECT_EQ(sizeof(MGPWireRec_DrawVbo) % 8, 0u); + EXPECT_EQ(sizeof(MGPWireRec_BindRenderState) % 8, 0u); + EXPECT_EQ(sizeof(MGPWireRec_SetResidualValueState) % 8, 0u); +} + +// A well-formed record passes the applier's bounds gate. P0 has no applier, so "accepted" +// is reported as "not applied" rather than "fatal". +TEST(PipeCatalogue, ApplierAcceptsAWellFormedRecord) { + MGPWireRec_Present record{}; + record.Header.Op = static_cast(MGPWireOp::Present); + record.Header.Size = sizeof(record); + record.Payload.FrameSerial = 42; + EXPECT_FALSE(MGPipeApplyWireRecord(MGPWireOp::Present, &record, sizeof(record), sizeof(record))); +} + +// G4 reports the FIRST differing field by name, and compares field by field so that +// padding cannot produce a difference that does not exist. +TEST(PipeCatalogue, VerifyComparatorNamesTheDifferingField) { + MGPDrawInfo a{}; + MGPDrawInfo b{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(a, b, &field)); + + b.InstanceCount = 7; + EXPECT_FALSE(MGPipeVerify(a, b, &field)); + EXPECT_STREQ(field, "InstanceCount"); + + // Padding bytes are not fields: writing to them cannot make two payloads differ. + MGPBindRenderState c{}; + MGPBindRenderState d{}; + c.Cso = MGPipeHandle{3, 1}; + d.Cso = MGPipeHandle{3, 1}; + field = nullptr; + EXPECT_TRUE(MGPipeVerify(c, d, &field)); + + // Nested payloads recurse, and arrays compare element-wise. + MGPFramebufferState left{}; + MGPFramebufferState right{}; + right.Color[3].Level = 2; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "Color"); +} + +// G6's join over the backend read inventory. P0 allows unmapped rows; from P5 the gate is +// zero, so the numbers are asserted here to make a regression visible the day it happens. +TEST(PipeCatalogue, CoverageAccountsForEveryInventoryRow) { + EXPECT_EQ(kMGPipeInventoryReadPoints, 477u); + EXPECT_EQ(kMGPipeInventoryUnmapped, 0u); + EXPECT_EQ(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved + + kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle + + kMGPipeInventoryUnmapped, + kMGPipeInventoryReadPoints); + EXPECT_GT(kMGPipeCoverageEntryCount, 0u); +} + +// G5's field ids come from the same accessor list as the coverage table, and every field +// starts un-filled: reading one before its verb fills it is the poison's whole job. +TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { + EXPECT_EQ(kMGPipeInputFieldCount, 61u); + MGPipeFilledState state{}; + state.CurrentVerbSerial = 1; + EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); + state.FilledGen[static_cast(MGPipeInputField::GetRenderStateParameters)] = 1; + EXPECT_TRUE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); + // The next verb makes the same value stale, which a written-once bitmap could not see. + state.CurrentVerbSerial = 2; + EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); +} + +// G7 pins the member list the pipeline/dynamic split is derived from. +TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { + EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled"); + EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks"); +} + +// The reverse channel is exactly ten callbacks (section 7.1). +TEST(PipeCatalogue, ReverseChannelHasTenCallbacks) { + EXPECT_EQ(kMGPipeCallbackCount, 10u); + EXPECT_EQ(sizeof(MGPipeCallbacks), kMGPipeCallbackCount * sizeof(void (*)())); +} + +// The one shape that changes with the transport. In a monolith it resolves to the pointer +// it was given; with no transport installed a segment-backed span resolves to nothing +// rather than to garbage. +TEST(PipeCatalogue, HostSpanResolvesTheMonolithPointer) { + static_assert(sizeof(MGHostSpan) == 32); + const Uint8 bytes[8] = {0, 1, 2, 3, 4, 5, 6, 7}; + MGHostSpan span{}; + span.Ptr = bytes; + span.Size = sizeof(bytes); + span.Offset = 2; + EXPECT_EQ(MGPipeHostBytes(span), bytes + 2); + + MGHostSpan staged{}; + staged.Seg = 4; + staged.Size = 16; + EXPECT_EQ(gMGPipeSegmentResolver, nullptr); + EXPECT_EQ(MGPipeHostBytes(staged), nullptr); +}