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); +}