diff --git a/CMakeLists.txt b/CMakeLists.txt index c354766f..260b76b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -571,6 +571,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Client/ClientSession.cpp MobileGL/MG_Remote/Client/EmitTables.cpp MobileGL/MG_Remote/Client/CapsMirror.cpp + # P5 c1: the client role's BackendObject. pActiveBackendObject holds one of these + # under split (table 3); the hook that installs it is v1's, in MG_Backend/Init.cpp. + MobileGL/MG_Remote/Client/BackendObject_Remote.cpp # P5 b1's two: the conservative GPU-write set the client must build because all six # MarkGpuWritten producers are on the server's side of the line, and the # block-granularity persistent-map push that tier T2 makes mandatory. diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index 7a96b6a0..dec921e9 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -31,6 +31,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { // Declared rather than #included from GL_RenderState.h on purpose: that header also declares @@ -1354,7 +1359,16 @@ namespace MobileGL::MG_Impl::GLImpl { // full 64-bit GPU timestamp survives; LWJGL reads it this way. Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } @@ -2265,7 +2279,16 @@ namespace MobileGL::MG_Impl::GLImpl { case GL_TIMESTAMP: { Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } diff --git a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp index dd21c0e3..64c990fc 100644 --- a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp +++ b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp @@ -13,6 +13,10 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read, whatever class the slot itself is in. +// The two macros carry that rule; in a pull build each expands to exactly the check it replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -478,7 +482,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -528,10 +532,12 @@ namespace MobileGL::MG_Impl::GLImpl { } else if (isTransformFeedbackQuery) { // Prefer real GPU transform-feedback queries (exact with geometry shaders); // the CPU accounting delta stays as the fallback when the backend lacks them. + const Bool xfbQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginXfbPrimitivesQuery, MG_Pipe::kCapXfbPrimitivesQuery); const auto beginXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.BeginXfbPrimitivesQuery; MGP_FILL(BeginXfbPrimitivesQuery); queryObject->backendHandle = - beginXfbPrimitivesQuery ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; + xfbQuerySupported ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; queryObject->counterSnapshot = TransformFeedbackCounterForTarget(target); queryObject->accountedCaptureDrawSnapshot = MG_State::pGLContext->GetTransformFeedbackAccountedCaptureDraws(); @@ -541,10 +547,12 @@ namespace MobileGL::MG_Impl::GLImpl { MGP_FILL(BeginOcclusionQuery); queryObject->backendHandle = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery(); } else { + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginTimeElapsedQuery, MG_Pipe::kCapTimerQuery); const auto beginTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.BeginTimeElapsedQuery; MGP_FILL(BeginTimeElapsedQuery); queryObject->backendHandle = - (!TimerQueryDisabled() && beginTimeElapsedQuery) ? beginTimeElapsedQuery() : nullptr; + (!TimerQueryDisabled() && timerQuerySupported) ? beginTimeElapsedQuery() : nullptr; } activeQueryId = id; } @@ -555,7 +563,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -657,7 +665,10 @@ namespace MobileGL::MG_Impl::GLImpl { ResetQueryObjectLocked(queryObject); // discard any previous result queryObject->target = target; - const auto queryCounterTimestamp = MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp; + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(QueryCounterTimestamp, MG_Pipe::kCapTimerQuery); + const auto queryCounterTimestamp = + timerQuerySupported ? MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp : nullptr; MGP_FILL(QueryCounterTimestamp); queryObject->backendHandle = (!TimerQueryDisabled() && queryCounterTimestamp) ? queryCounterTimestamp() : nullptr; @@ -782,7 +793,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) { - const Bool occlusionSupported = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + const Bool occlusionSupported = MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); *params = occlusionSupported ? (target == GL_SAMPLES_PASSED ? 32 : 1) : 0; return; } diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index 60813976..9e8e02e9 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -10,6 +10,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -56,7 +61,11 @@ namespace MobileGL::MG_Impl::GLImpl { auto* syncObject = new SyncObject; syncObject->condition = condition; syncObject->flags = flags; - if (const auto backendFenceSync = MG_Backend::gBackendFunctionsTable.GL.FenceSync) { + // The family's ONE gate. FenceSync is class C under split, and "absent" is the + // answer the whole fallback chain below is written against: every later site already + // checks syncObject->backendHandle, which stays null from here. The POINTER-valued + // macro keeps the init-statement byte-identical in a pull build (G1). + if (const auto backendFenceSync = MGL_BACKEND_SLOT_PTR_LOCAL(FenceSync)) { MGP_FILL(FenceSync); syncObject->backendHandle = backendFenceSync(); } diff --git a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp index 2792e400..43b10a02 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp @@ -31,6 +31,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include // P4a, ID-18 M2. The ONE door MG_State and MG_Impl have into the client's emitters; the three // call sites below are declarations only, exactly as the frontend's mutators are. #include @@ -6534,7 +6539,7 @@ namespace MobileGL::MG_Impl::GLImpl { GLenum type, GLsizei bufSize, void* pixels, const char* caller) { if (MG_Backend::pActiveBackendObject != nullptr && MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan && - MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) { + MGL_BACKEND_SLOT_LOCAL(GetTextureImage)) { MGP_FILL(GetTextureImage); MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type, bufSize, pixels); @@ -6796,7 +6801,7 @@ namespace MobileGL::MG_Impl::GLImpl { void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { if (!GetTexImage_State(target, level, format, type, pixels)) return; - if (MG_Backend::gBackendFunctionsTable.GL.GetTexImage != nullptr) { + if (MGL_BACKEND_SLOT_LOCAL(GetTexImage)) { GetTexImage_Backend(target, level, format, type, pixels); return; } diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index ca41fc34..83f43a17 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -39,6 +39,15 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +// R-8 (c1): the client's liveness gates read the caps mirror, never MGPipeGetResourceOps(). +// Behind the build option for G1's reason - nothing under MG_Remote may be reachable from a +// pull build - and every use below is additionally gated on the resolved TRANSPORT, because +// build-split runs MOBILEGL_TRANSPORT=monolith in every unit and integration-gpu lane and those +// lanes must keep answering exactly what they answered before. +#include +#endif + #include #include #include @@ -609,12 +618,38 @@ namespace MobileGL::MG_Pipe { } } // namespace + // R-8 (c1). THE SECOND CONJUNCT MOVES UNDER SPLIT, AND ONLY UNDER SPLIT. + // + // `MGPipeGetResourceOps() != nullptr` asks "has a backend registered the consumer". That + // table is the SERVER's registration and it is a PROCESS-WIDE global (PipeApply.cpp:402): + // under inproc a client reading it answers correctly BY ACCIDENT, and under spawn the + // client process has no backend at all, so the read answers null and five record families + // stop emitting - silently, while the emitters go on clearing their per-level dirty flags + // on the acceptance they never asked for. That is ID-39's 66 lost DirectVulkan uploads with + // a wire in between. The client asks the caps mirror instead, which carries the answer the + // SERVER gave at the handshake (CallMask bits 32..47). + // + // s1 made the server end Fatal when nobody sets the mask; this is the client end. Bool MGPipeResourceSubsystemEnabled() { - return (MG_Config::Features.PipePush & kMGPipeSubsystemResources) != 0 && - MGPipeGetResourceOps() != nullptr; + if ((MG_Config::Features.PipePush & kMGPipeSubsystemResources) == 0) return false; +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources); + } +#endif + return MGPipeGetResourceOps() != nullptr; } Bool MGPipeResourceOpsHaveSubDataResident() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // CONTRACT-P5.md §7's THIRD NAMED CAPABILITY PROBE. `ops->SubDataResident != nullptr` + // is not a safety check - it decides whether the resident-upload path EXISTS - and + // under split there is no op table here to probe. kCapResidentSubData is the bit the + // server publishes for exactly this question. + return MG_Remote::Client::CapsMirrorInstance().HasCap(kCapResidentSubData); + } +#endif const MGPipeResourceOps* ops = MGPipeGetResourceOps(); return ops != nullptr && ops->SubDataResident != nullptr; } @@ -936,8 +971,21 @@ namespace MobileGL::MG_Pipe { // MGPipeEmitResourceRespecify above uses for buffers) publishes it. Nothing here needs // to remember the window. Bool P4aFamilyHasItsConsumer(Uint64 subsystem) { - return (subsystem & kMGPipeP4aFamilySubsystems) == 0 || - MGPipeGetResourceOps() != nullptr; + if ((subsystem & kMGPipeP4aFamilySubsystems) == 0) return true; +#if MOBILEGL_BUILD_DISAGGREGATED + // R-8 (c1), the same move as MGPipeResourceSubsystemEnabled's and for the same + // reason. ALL FOUR FAMILIES RIDE THE ONE SIGNAL, exactly as they do in monolith: + // the paragraph above explains why the resource consumer IS the texture family's + // consumer, and the split spelling of "a backend registered the resource op table" + // is "the server published the resource subsystem's consumer bit". Asking per + // family here would be a NEW rule, and a client that withheld more than the server + // refuses leaves the server's handle arm live with no records to read. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes( + kMGPipeSubsystemResources); + } +#endif + return MGPipeGetResourceOps() != nullptr; } // ================================================================================ diff --git a/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp b/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp new file mode 100644 index 00000000..a3a2dfa1 --- /dev/null +++ b/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp @@ -0,0 +1,332 @@ +// MobileGL - MobileGL/MG_Remote/Client/BackendObject_Remote.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 + +// P5 package c1. See BackendObject_Remote.h for the three traps and why each is paid here. + +#include "BackendObject_Remote.h" + +#include "CapsMirror.h" +#include "ClientSession.h" +#include "EmitTables.h" + +#include "../Server/ServerLoop.h" + +#include + +// Declared rather than included: MG_Backend/BackendObjects.h drags in both concrete backend +// objects, and this translation unit must not depend on either - the client role links the same +// library but never constructs one. +namespace MobileGL::MG_Backend { + extern UniquePtr& pActiveBackendObject; +} + +namespace MobileGL::MG_Remote::Client { + + namespace { + + // ---- the EGL bridge --------------------------------------------------------------- + // + // Every one of the nine crosses as a BLOCKING control request on the apply thread, + // because every one of them has a return value the caller acts on immediately. v1's + // ServerLoop::RunOnApplyThread takes a raw function pointer plus a user pointer rather + // than a std::function, deliberately: this path runs at teardown too, and the teardown + // path may not allocate (ID-8). + // + // A NULL SERVER BACKEND IS "false", NOT A CRASH AND NOT A LOCAL SUCCESS. Under inproc + // the apply thread creates it during ClientSession::Start, so a null one here means the + // bring-up did not complete - and answering `true` would let the frontend believe it + // has a context. + MG_Backend::BackendObject* ServerBackend() { return Server::ServerLoopInstance().Backend(); } + + struct DisplayArgs { + EGLDisplay Dpy; + EGLint* Major; + EGLint* Minor; + Bool Ok; + }; + MobileGLResult RunInitDisplay(void* user) { + auto* args = static_cast(user); + args->Ok = ServerBackend()->InitializeEGLDisplay(args->Dpy, args->Major, args->Minor); + return MOBILEGL_OK; + } + + struct WindowSurfaceArgs { + EGLSurface Surface; + const MG_Backend::WindowHandle* Handle; + Bool Ok; + }; + MobileGLResult RunCreateWindowSurface(void* user) { + auto* args = static_cast(user); + args->Ok = ServerBackend()->CreateEGLWindowSurface(args->Surface, *args->Handle); + return MOBILEGL_OK; + } + + struct ResizeArgs { + EGLSurface Surface; + Uint32 Width; + Uint32 Height; + Bool Ok; + }; + MobileGLResult RunResize(void* user) { + auto* args = static_cast(user); + args->Ok = ServerBackend()->ResizeEGLWindowSurface(args->Surface, args->Width, args->Height); + return MOBILEGL_OK; + } + + struct PbufferArgs { + EGLSurface Surface; + EGLint Width; + EGLint Height; + Bool Ok; + }; + MobileGLResult RunCreatePbuffer(void* user) { + auto* args = static_cast(user); + args->Ok = ServerBackend()->CreateEGLPbufferSurface(args->Surface, args->Width, args->Height); + return MOBILEGL_OK; + } + + struct MakeCurrentArgs { + EGLDisplay Dpy; + EGLSurface Draw; + EGLSurface Read; + EGLContext Ctx; + Bool Ok; + }; + MobileGLResult RunMakeCurrent(void* user) { + auto* args = static_cast(user); + args->Ok = ServerBackend()->MakeEGLCurrent(args->Dpy, args->Draw, args->Read, args->Ctx); + return MOBILEGL_OK; + } + + struct SwapIntervalArgs { + Int Interval; + }; + MobileGLResult RunSwapInterval(void* user) { + ServerBackend()->SetEGLSwapInterval(static_cast(user)->Interval); + return MOBILEGL_OK; + } + + struct SurfaceArgs { + EGLSurface Surface; + }; + MobileGLResult RunReleaseSurface(void* user) { + ServerBackend()->ReleaseEGLSurface(static_cast(user)->Surface); + return MOBILEGL_OK; + } + + MobileGLResult RunReleaseResources(void*) { + ServerBackend()->ReleaseEGLResources(); + return MOBILEGL_OK; + } + + // Runs `work` on the apply thread if there is a server backend to run it against, and + // says so by name when there is not. + Bool ForwardToApplyThread(const char* what, Server::ServerLoop::ControlWork work, void* user) { + if (ServerBackend() == nullptr) { + MGLOG_E("MG_Remote client: %s has no server backend to forward to - the apply " + "thread's bring-up did not complete, and answering success here would " + "tell the frontend it has a context it does not have", + what); + return false; + } + return Server::ServerLoopInstance().RunOnApplyThread(work, user) == MOBILEGL_OK; + } + + // CapsMirror's adoption hook. A free function because the hook is a raw function + // pointer (ID-8: this can fire on a path that must not allocate), and it reaches the + // live object through pActiveBackendObject rather than through a second global. + void OnCapsAdopted() { + auto* self = dynamic_cast(MG_Backend::pActiveBackendObject.get()); + if (self != nullptr) self->RefreshFormatCapabilities(); + } + + } // namespace + + BackendObject_Remote::BackendObject_Remote() { + // Installed in the constructor rather than at the first snapshot, because the first + // snapshot has usually already arrived by then: ClientSession::Start pumps the control + // plane during the handshake, and MG_Backend::Init() constructs this object after it. + // RefreshFormatCapabilities below picks up that already-adopted generation. + SetCapsAdoptedHook(&OnCapsAdopted); + RefreshFormatCapabilities(); + } + + BackendObject_Remote::~BackendObject_Remote() { + // The hook holds a raw function pointer, not a pointer to this - but the function it + // names reaches pActiveBackendObject, which is being destroyed right now. Uninstall. + SetCapsAdoptedHook(nullptr); + } + + void BackendObject_Remote::RefreshFormatCapabilities() { + CapsMirror& mirror = CapsMirrorInstance(); + if (!mirror.Valid() || mirror.Generation() == m_formatsGeneration) return; + // TRAP 2. GetFormatCapabilities() is non-virtual and hands back this member, so the + // only way a remote object can answer it is to fill it. + MutableFormatCapabilities() = mirror.Formats(); + m_formatsGeneration = mirror.Generation(); + MGLOG_I("MG_Remote client: format capabilities filled from caps mirror generation %llu", + static_cast(m_formatsGeneration)); + } + + // ---- the eight pure virtuals ---------------------------------------------------------- + + void BackendObject_Remote::Initialize() { + // NOT FORWARDED. The server's own BackendObject_DirectGLES is created and initialised + // by v1's ServerLoop, on the apply thread, before this object exists; forwarding here + // would be a second Initialize() on an already-initialised backend. What this call + // does is drain whatever the handshake left and take the caps that came with it. + if (ClientSession* session = ClientSession::Active()) { + session->PumpControlPlane(); + } + RefreshFormatCapabilities(); + } + + Bool BackendObject_Remote::InitCapabilities() { + // Reached from the base class's MakeEGLCurrent, lazily, once per surface lifetime + // (BackendObject.cpp:341-347). By this point MakeEGLCurrent below has already run the + // SERVER's MakeEGLCurrent on the apply thread, whose own base class ran the server + // backend's InitCapabilities and whose ServerSession re-published the snapshot - so + // the client's job here is to pick that snapshot up. R-12: re-arrival IS the + // invalidation, and this is the second place it is drained (the other is Present). + ClientSession* session = ClientSession::Active(); + if (session == nullptr) { + MGLOG_E("MG_Remote client: InitCapabilities with no session"); + return false; + } + session->PumpControlPlane(); + RefreshFormatCapabilities(); + // A PLACEHOLDER MIRROR IS A FAILURE HERE, unlike at startup. LogBackendInfo reading a + // placeholder costs one wrong log line; a context going current on one costs every + // limit, every advertised extension and the compile-env fingerprint. + if (!CapsMirrorInstance().Valid()) { + MGLOG_E("MG_Remote client: InitCapabilities found no CapsSnapshot - the server has " + "not published one. Going current on a placeholder caps mirror would put " + "default limits into every glGetIntegerv answer and into the compile env"); + return false; + } + return true; + } + + Bool BackendObject_Remote::InitWindowSurface() { + // The real surface work happened on the apply thread inside the server backend's own + // ActivateEGLSurface; this is the client's half of the base state machine and has + // nothing of its own to do. + return true; + } + + Bool BackendObject_Remote::InitPbufferSurface(EGLint, EGLint) { return true; } + + const RendererInfo& BackendObject_Remote::GetRendererInfo() const { + // TRAP 1: a reference, so the storage is the mirror's and not a temporary's. + return CapsMirrorInstance().Renderer(); + } + + String BackendObject_Remote::GetBackendAPIVersionString() const { + return CapsMirrorInstance().ApiVersion(); + } + + const MG_Backend::GlobalBackendFunctionsTable& BackendObject_Remote::GetBackendFunctions() const { + return RemoteEmitTable(); + } + + const MG_Backend::DynamicBackendParameters& BackendObject_Remote::GetDynamicParameters() const { + return CapsMirrorInstance().Dynamic(); + } + + BackendType BackendObject_Remote::GetBackendType() const { + // TRAP 3: the SERVER's backend, never a new enumerator. + return CapsMirrorInstance().Backend(); + } + + // ---- the nine EGL lifecycle virtuals --------------------------------------------------- + // + // FORWARD FIRST, THEN RUN THE BASE. The server has to own the context before the client's + // base class latches "the surface is initialised" and calls InitCapabilities, because + // InitCapabilities' answer comes from a snapshot the server can only publish once its own + // InitCapabilities has run. + + Bool BackendObject_Remote::InitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) { + DisplayArgs args{dpy, major, minor, false}; + if (!ForwardToApplyThread("InitializeEGLDisplay", &RunInitDisplay, &args) || !args.Ok) { + return false; + } + return MG_Backend::BackendObject::InitializeEGLDisplay(dpy, major, minor); + } + + Bool BackendObject_Remote::CreateEGLWindowSurface(EGLSurface surface, + const MG_Backend::WindowHandle& handle) { + WindowSurfaceArgs args{surface, &handle, false}; + if (!ForwardToApplyThread("CreateEGLWindowSurface", &RunCreateWindowSurface, &args) || + !args.Ok) { + return false; + } + return MG_Backend::BackendObject::CreateEGLWindowSurface(surface, handle); + } + + Bool BackendObject_Remote::ResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) { + ResizeArgs args{surface, width, height, false}; + if (!ForwardToApplyThread("ResizeEGLWindowSurface", &RunResize, &args) || !args.Ok) { + return false; + } + return MG_Backend::BackendObject::ResizeEGLWindowSurface(surface, width, height); + } + + Bool BackendObject_Remote::CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) { + PbufferArgs args{surface, width, height, false}; + if (!ForwardToApplyThread("CreateEGLPbufferSurface", &RunCreatePbuffer, &args) || !args.Ok) { + return false; + } + return MG_Backend::BackendObject::CreateEGLPbufferSurface(surface, width, height); + } + + Bool BackendObject_Remote::MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, + EGLContext ctx) { + MakeCurrentArgs args{dpy, draw, read, ctx, false}; + if (!ForwardToApplyThread("MakeEGLCurrent", &RunMakeCurrent, &args) || !args.Ok) { + return false; + } + // AND ONLY NOW the client's own bookkeeping, which is what calls InitCapabilities. + return MG_Backend::BackendObject::MakeEGLCurrent(dpy, draw, read, ctx); + } + + Bool BackendObject_Remote::SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) { + // NOT FORWARDED, and this is the one that must not be. The base implementation's last + // act is GetBackendFunctions().Present() (BackendObject.cpp:396) - which is this + // client's class-B Present EMITTER, the only route by which Present is reached at all + // (it has zero MG_Impl call sites). Forwarding would present on the server directly and + // put no record on the wire, which is the shape every gate in this phase exists to + // catch. + return MG_Backend::BackendObject::SwapEGLBuffers(dpy, draw); + } + + void BackendObject_Remote::SetEGLSwapInterval(Int interval) { + // OVERRIDDEN BECAUSE THE BASE WOULD FATAL. BackendObject.cpp:402 null-checks + // GetBackendFunctions().SetSwapInterval and calls it when non-null - one of the 41 + // null checks R-4 turns into "always supported" - and SetSwapInterval is class C, so + // the base implementation would abort on every eglSwapInterval. The answer is the + // caps-mirror-read rule's general shape: the question "can the presentation path take + // an interval" belongs to the server, so it is asked of the server. + SwapIntervalArgs args{interval}; + ForwardToApplyThread("SetEGLSwapInterval", &RunSwapInterval, &args); + } + + void BackendObject_Remote::ReleaseEGLSurface(EGLSurface surface) { + SurfaceArgs args{surface}; + ForwardToApplyThread("ReleaseEGLSurface", &RunReleaseSurface, &args); + MG_Backend::BackendObject::ReleaseEGLSurface(surface); + } + + void BackendObject_Remote::ReleaseEGLResources() { + // BLOCKING BY CONTRACT (ServerLoop.h's header note): MobileGL::Destroy() + // (MobileGL/Init.cpp:68) walks on the moment this returns, and the server still holds + // the context until the apply thread has run it. + ForwardToApplyThread("ReleaseEGLResources", &RunReleaseResources, nullptr); + MG_Backend::BackendObject::ReleaseEGLResources(); + } + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/BackendObject_Remote.h b/MobileGL/MG_Remote/Client/BackendObject_Remote.h new file mode 100644 index 00000000..e88c3f89 --- /dev/null +++ b/MobileGL/MG_Remote/Client/BackendObject_Remote.h @@ -0,0 +1,96 @@ +// MobileGL - MobileGL/MG_Remote/Client/BackendObject_Remote.h +// 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 CLIENT ROLE'S BackendObject. Owner: package c1. +// +// MG_Backend::Init() installs one of these into pActiveBackendObject when the transport +// resolved (the hook is v1's; the object is c1's, table 3). It is NOT a backend: it owns no +// context, no driver and no GL state. It is the frontend's single answer to four questions - +// "what can the device do", "what backend is it", "what table do I call", and the nine EGL +// lifecycle calls - and each of the four is answered somewhere that is not here. +// +// THE THREE TRAPS THE SCOUT FOUND, AND WHERE EACH IS PAID: +// +// 1. GetRendererInfo() RETURNS A REFERENCE (BackendObject.h:590), so the object cannot +// synthesise one per call. CapsMirror owns the storage; this class forwards. And +// LogBackendInfo() reads it at MG_Backend/Init.cpp:21, DURING MG_Backend::Init(), before +// any surface exists - so the mirror answers a placeholder and P5 accepts one imprecise +// startup log line. MG_Backend::Init() is NOT restructured (scout-caps-reply §1.2 (a)). +// +// 2. GetFormatCapabilities() IS NOT VIRTUAL (BackendObject.h:594). It returns the base class's +// own m_formatCapabilities member, so there is no accessor to override: the cache has to be +// PUSHED into that member, and the only moment this object can know to is when a snapshot +// lands. CapsMirror's adoption hook is that moment. +// +// 3. GetBackendType() MUST ANSWER THE SERVER'S BACKEND. There is no "Remote" enumerator and +// there must not be one: GL_Framebuffer.cpp:47, GL_Texture.cpp:6536 and CompileEnv.cpp:122 +// switch on this value, and a value they do not know takes a WRONG ARM rather than failing. +// +// THE EGL VIRTUALS ARE BOTH FORWARDED AND KEPT. The base class runs a real state machine - +// surface registration, per-thread current-context bookkeeping, the lazy InitCapabilities latch, +// and SwapEGLBuffers' route into GetBackendFunctions().Present() - and the client needs all of +// it, because Present is a class-B emitter reached through exactly that route (the verb census's +// trap 3: Present has zero MG_Impl call sites). So each override does BOTH: it runs the real +// EGL work on the apply thread, through v1's ServerLoop::RunOnApplyThread, and then lets the +// base class keep the client-side books. +// +// SetEGLSwapInterval IS THE ONE THAT MUST NOT REACH THE TABLE. The base implementation +// null-checks GetBackendFunctions().SetSwapInterval (BackendObject.cpp:402) - one of the 41 +// null checks R-4 turns into "always supported" - and SetSwapInterval is class C, so the base +// implementation would Fatal on every eglSwapInterval. It is overridden to forward instead, +// which is the caps-mirror-read rule's shape for a slot whose answer is "ask the server". + +#pragma once +#include + +#include + +namespace MobileGL::MG_Remote::Client { + + class BackendObject_Remote final : public MG_Backend::BackendObject { + public: + BackendObject_Remote(); + ~BackendObject_Remote() override; + + // ---- the eight pure virtuals ------------------------------------------------------ + void Initialize() override; + Bool InitCapabilities() override; + Bool InitWindowSurface() override; + const RendererInfo& GetRendererInfo() const override; + String GetBackendAPIVersionString() const override; + const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override; + const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override; + BackendType GetBackendType() const override; + + // ---- the nine EGL lifecycle virtuals --------------------------------------------- + Bool InitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) override; + Bool CreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle) override; + Bool ResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) override; + Bool CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) override; + Bool MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) override; + Bool SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) override; + void SetEGLSwapInterval(Int interval) override; + void ReleaseEGLSurface(EGLSurface surface) override; + void ReleaseEGLResources() override; + + // Copies the caps mirror's FormatCapabilityCache into the base class's + // m_formatCapabilities. Public because CapsMirror's adoption hook is a free function + // and this is what it calls; it is the whole of trap 2's answer. + void RefreshFormatCapabilities(); + + protected: + Bool InitPbufferSurface(EGLint width, EGLint height) override; + + private: + // The generation of the snapshot m_formatCapabilities was filled from. Exposed only + // through the log line on a refresh: a cache that silently stopped tracking the mirror + // is exactly the shape trap 2 exists to prevent. + Uint64 m_formatsGeneration = 0; + }; + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/CapsMirror.cpp b/MobileGL/MG_Remote/Client/CapsMirror.cpp index 136ce840..a5c5ecad 100644 --- a/MobileGL/MG_Remote/Client/CapsMirror.cpp +++ b/MobileGL/MG_Remote/Client/CapsMirror.cpp @@ -6,49 +6,161 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package c1. Every body is MGLOG_F + std::abort and never a silent no-op: a -// caps accessor that answers a default is how a split lane runs on the wrong device's limits. +// P5 package c1. +// +// EVERY ACCESSOR ANSWERS BEFORE THE FIRST SNAPSHOT, AND THAT IS THE RULING, NOT A WEAKENING. +// c0's stubs aborted on all but Valid()/Generation() so that nothing could answer from a zeroed +// mirror by accident. But scout-caps-reply §1.2's option (a) - the one the brief adopts - says +// LogBackendInfo() reads GetRendererInfo() at MG_Backend/Init.cpp:21, during MG_Backend::Init(), +// BEFORE any surface exists, and that P5 accepts one imprecise startup log line rather than +// restructure MG_Backend::Init(). An abort there is not a stricter mirror; it is a process that +// cannot start. So the mirror answers a placeholder and SAYS SO, once per accessor: a wrong +// number that announced itself is auditable, and the announcement is what a case asserts on. +// +// THE PLACEHOLDER IS A REAL OBJECT, NOT A TEMPORARY. GetRendererInfo() and +// GetDynamicParameters() return const references, so the storage has to outlive every caller; +// the members below are it, default-constructed, and Adopt() replaces them in place. #include "CapsMirror.h" -#include +#include "../CapsCodec.h" -#include +#include +#include namespace MobileGL::MG_Remote::Client { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedCapsMirror, \"%s\"} - P5 package c1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + namespace { + CapsAdoptedHook g_capsAdoptedHook = nullptr; - void CapsMirror::Adopt(const MG_Pipe::MGPCaps&, const MG_Backend::FormatCapabilityCache&, - const RendererInfo&, const String&, BackendType) { - MGP5_C0_STUB("CapsMirror::Adopt"); + // R-8's negative control, counted at the funnel. Not atomics: every reader of this + // mirror is the GL thread, by the same argument that makes one gPipeInputs legal. + Uint64 g_consumerRefusals = 0; + Uint64 g_lastRefusedSubsystem = 0; + Uint64 g_refusedSubsystemsLogged = 0; + + // One line per accessor, once, and only while the mirror is a placeholder. MGLOG_W_ONCE + // keys on the call site, so each accessor gets its own line and a hot getter cannot + // flood the log. + void WarnPlaceholder(const char* what, Uint64 generation) { + if (generation != 0) return; + MGLOG_W("MG_Remote client: CapsMirror::%s read before the first CapsSnapshot - " + "answering a PLACEHOLDER. Expected exactly once at startup, from " + "LogBackendInfo (MG_Backend/Init.cpp:21); anything later means a caps read " + "beat the handshake", + what); + } + } // namespace + + void CapsMirror::Adopt(const MG_Pipe::MGPCaps& caps, const MG_Backend::FormatCapabilityCache& formats, + const RendererInfo& renderer, const String& apiVersion, + BackendType backend) { + m_caps = caps; + // The two blobrefs inside MGPCaps name SEG_STAGE runs that belong to the SERVER's + // encoder and are meaningless on this side; the decoded objects beside them are the + // answer. Clearing them is not tidiness - a later reader that resolved one would read + // whatever the stage allocator has since put there. + m_caps.FormatCapabilities = MG_Pipe::MGPBlobRef{}; + m_caps.RendererInfo = MG_Pipe::MGPBlobRef{}; + m_formats = formats; + m_renderer = renderer; + m_apiVersion = apiVersion; + m_backend = backend; + ++m_generation; + + // R-12: RE-ARRIVAL IS THE INVALIDATION, and this is the one place that acts on it. + // GLContext::GetCompileEnv() memoises on the raw pActiveBackendObject pointer + // (Core.cpp:34); under split that pointer is the single long-lived BackendObject_Remote + // and NEVER changes, so without this line the compile env, its preprocess memos and the + // advertised-extension list stay stale for ever after a server-side InitCapabilities + // re-run. DirectVulkan already spells the monolith half of this exactly this way + // (BackendObject_DirectVulkan.cpp:390). + if (m_generation > 1 && MG_State::pGLContext != nullptr) { + MG_State::pGLContext->InvalidateCompileEnv(); + } + MGLOG_I("MG_Remote client: CapsMirror generation %llu adopted (backend=%d, callMask=0x%llx)", + static_cast(m_generation), static_cast(m_backend), + static_cast(m_caps.CallMask)); + + // LAST, and after the generation has moved: the hook reads this mirror. + if (g_capsAdoptedHook != nullptr) g_capsAdoptedHook(); } - // Not stubs: the two the placeholder contract above promises are readable before the first - // snapshot. Everything else aborts, so nothing can accidentally answer from a zeroed mirror. + void SetCapsAdoptedHook(CapsAdoptedHook hook) { g_capsAdoptedHook = hook; } + Bool CapsMirror::Valid() const { return m_generation != 0; } Uint64 CapsMirror::Generation() const { return m_generation; } - const RendererInfo& CapsMirror::Renderer() const { MGP5_C0_STUB("CapsMirror::Renderer"); } + const RendererInfo& CapsMirror::Renderer() const { + WarnPlaceholder("Renderer", m_generation); + return m_renderer; + } + const MG_Backend::DynamicBackendParameters& CapsMirror::Dynamic() const { - MGP5_C0_STUB("CapsMirror::Dynamic"); + WarnPlaceholder("Dynamic", m_generation); + return m_caps.Dynamic; } + const MG_Backend::FormatCapabilityCache& CapsMirror::Formats() const { - MGP5_C0_STUB("CapsMirror::Formats"); + WarnPlaceholder("Formats", m_generation); + return m_formats; } - const String& CapsMirror::ApiVersion() const { MGP5_C0_STUB("CapsMirror::ApiVersion"); } - BackendType CapsMirror::Backend() const { MGP5_C0_STUB("CapsMirror::Backend"); } - Uint64 CapsMirror::CallMask() const { MGP5_C0_STUB("CapsMirror::CallMask"); } - Bool CapsMirror::HasCap(MG_Pipe::MGPCapBit) const { MGP5_C0_STUB("CapsMirror::HasCap"); } - Bool CapsMirror::ServerConsumes(Uint64) const { MGP5_C0_STUB("CapsMirror::ServerConsumes"); } + + const String& CapsMirror::ApiVersion() const { + WarnPlaceholder("ApiVersion", m_generation); + return m_apiVersion; + } + + BackendType CapsMirror::Backend() const { + WarnPlaceholder("Backend", m_generation); + return m_backend; + } + + Uint64 CapsMirror::CallMask() const { return m_caps.CallMask; } + + Bool CapsMirror::HasCap(MG_Pipe::MGPCapBit bit) const { + return (m_caps.CallMask & static_cast(bit)) != 0; + } + + Bool CapsMirror::ServerConsumes(Uint64 subsystemBit) const { + // THE ONLY LEGAL CLIENT-SIDE SPELLING (R-8). Never MGPipeGetResourceOps(): that is the + // SERVER's registration, a process-wide global, right by accident under inproc and null + // under spawn - and a null read there silently stops five record families while the + // client goes on clearing its dirty flags, which is ID-39's 66 lost uploads with a wire + // in between. Folded through CapsCodec.h's helper rather than shifted here, because two + // spellings of one bit layout is how the two sides come to disagree about it. + // + // A PLACEHOLDER MIRROR CONSUMES NOTHING, and that is the safe direction: with no + // snapshot the mask is 0, every family answers "no consumer", the client emits nothing + // and the LEGACY PULL PATH runs untouched. The unsafe direction - emitting to a server + // that has no consumer - is the one that loses uploads. + const Bool consumes = MGCapsServerConsumes(m_caps.CallMask, subsystemBit); + if (!consumes) { + ++g_consumerRefusals; + g_lastRefusedSubsystem = subsystemBit; + if ((g_refusedSubsystemsLogged & subsystemBit) == 0) { + g_refusedSubsystemsLogged |= subsystemBit; + MGLOG_W("MG_Remote client: the server does not consume MGPipe subsystem 0x%llx - " + "this family emits NOTHING and the legacy pull path runs for it " + "(R-8). callMask=0x%llx, caps generation %llu", + static_cast(subsystemBit), + static_cast(m_caps.CallMask), + static_cast(m_generation)); + } + } + return consumes; + } + + Uint64 ConsumerRefusals() { return g_consumerRefusals; } + Uint64 LastRefusedSubsystem() { return g_lastRefusedSubsystem; } + void ResetConsumerRefusalsForTest() { + g_consumerRefusals = 0; + g_lastRefusedSubsystem = 0; + g_refusedSubsystemsLogged = 0; + } + Bool CapsMirror::PrefersCpuXfbPrimitiveAccounting() const { - MGP5_C0_STUB("CapsMirror::PrefersCpuXfbPrimitiveAccounting"); + return HasCap(MG_Pipe::kCapCpuXfbPrimitiveAccounting); } CapsMirror& CapsMirrorInstance() { @@ -58,6 +170,4 @@ namespace MobileGL::MG_Remote::Client { return instance; } -#undef MGP5_C0_STUB - } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/CapsMirror.h b/MobileGL/MG_Remote/Client/CapsMirror.h index 600bdc1c..f961cabe 100644 --- a/MobileGL/MG_Remote/Client/CapsMirror.h +++ b/MobileGL/MG_Remote/Client/CapsMirror.h @@ -97,4 +97,28 @@ namespace MobileGL::MG_Remote::Client { // reach pipe or backend state from an exit handler. CapsMirror& CapsMirrorInstance(); + // ---- c1's addition to c0's signature block ----------------------------------------- + // + // WHY A HOOK AND NOT A READ. BackendObject::GetFormatCapabilities() is NON-VIRTUAL + // (BackendObject.h:594) and returns the base class's own m_formatCapabilities member, so a + // remote backend object cannot answer it lazily from the mirror - it has to PUSH the cache + // into that member, and the only moment it can know to is when a snapshot lands. A raw + // function pointer rather than std::function, for ID-8's reason: this can fire on paths + // that must not allocate. One hook, installed by BackendObject_Remote's constructor. + using CapsAdoptedHook = void (*)(); + void SetCapsAdoptedHook(CapsAdoptedHook hook); + + // R-8's NEGATIVE CONTROL NEEDS TO SEE THE WITHHOLDING, NOT INFER IT FROM AN ABSENCE. + // "the client emits nothing for a family the server does not consume" is, on its own, + // indistinguishable from "the client emits nothing because nothing called it" - and the + // second is how a gate goes green for the wrong reason. So the one funnel that answers the + // question counts its own refusals and names the family, once per family, in the log. + // + // Counted inside ServerConsumes(), which is R-8's only legal spelling, so a refusal that + // happened cannot fail to be counted and a count that moved cannot have come from anywhere + // else. + Uint64 ConsumerRefusals(); + Uint64 LastRefusedSubsystem(); + void ResetConsumerRefusalsForTest(); + } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index 4fb94dd1..d02072d6 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -18,9 +18,12 @@ #include "../Transport/InProcessTransport.h" #include +#include #include +#include #include +#include #include namespace MobileGL::MG_Remote::Client { @@ -100,6 +103,29 @@ namespace MobileGL::MG_Remote::Client { #endif } + // ---- c1: the barrier's two flags ------------------------------------------------ + // + // thread_local for the client's own "am I waiting", a shared atomic for "is the apply + // thread inside the applier" - see ClientSession::InBarrierWait's note. + thread_local Bool g_inBarrierWait = false; + std::atomic g_applyThreadInsideApplier{false}; + + struct BarrierWaitScope { + BarrierWaitScope() { g_inBarrierWait = true; } + ~BarrierWaitScope() { g_inBarrierWait = false; } + BarrierWaitScope(const BarrierWaitScope&) = delete; + BarrierWaitScope& operator=(const BarrierWaitScope&) = delete; + }; + + // BOUNDED, AND THE BOUND IS GENEROUS RATHER THAN TIGHT. The barrier is a correctness + // device, not a watchdog: a slow readback on a software rasterizer is a legitimate + // second-scale wait, while a lost record never completes at all. 30 s separates the two + // without turning a loaded CI machine into a red lane, and the Fatal names the seq. + constexpr Uint32 kBarrierTimeoutMs = 30000; + // How many queued control frames one pump will drain. A backlog deeper than this is a + // finding, not a steady state. + constexpr Uint32 kMaxControlFramesPerPump = 16; + const char* TransportModeName(MG_Config::TransportMode mode) { switch (mode) { case MG_Config::TransportMode::Monolith: return "monolith"; @@ -111,6 +137,170 @@ namespace MobileGL::MG_Remote::Client { return "?"; } + // ---- c1: the reverse channel's reading end --------------------------------------- + // + // R-12's three: OnBufferWriteback (#3), OnGpuWritten (#2), OnSurfaceChanged (#7). + // Drained BY THE GL THREAD BETWEEN VERBS, which under the barrier means immediately + // after appliedSeq reaches this record - the one moment at which the apply thread is + // known not to be inside the applier. + // + // THE BYTES LIVE IN THE RING ITSELF, so Drained() is called only after every payload + // pointer popped here has been consumed: retiring earlier is R-11's violation one level + // down (EventRing.h:168-171 says so in as many words). + Uint32 DrainEventRing(Transport::EventRingConsumer& events) { + if (!events.Valid()) return 0; + Uint32 delivered = 0; + Transport::RingRecordView view{}; + Bool corrupt = false; + while (events.Pop(view, &corrupt)) { + if (corrupt) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"event-ring\"} - the reverse " + "channel's record stream is corrupt"); + std::abort(); + } + switch (view.kind) { + case Transport::kEventBufferWriteback: { + if (view.payloadSize < sizeof(Transport::EventBufferWritebackHead)) break; + const auto* head = + static_cast(view.payload); + const void* bytes = static_cast(view.payload) + sizeof(*head); + if (view.payloadSize - sizeof(*head) < head->Size) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"buffer-writeback\"} - the " + "head declares %llu inline bytes and the record carries %llu", + static_cast(head->Size), + static_cast(view.payloadSize - sizeof(*head))); + std::abort(); + } + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { + // The blobref names SEG_EVENT and the IN-SEGMENT offset of those inline + // bytes - never a host address (R-2's rule B), which is the whole reason + // EventRingConsumer exposes OffsetInSegment at all. + MG_Pipe::MGPBlobRef blob{}; + blob.Seg = Wire::kSegEvent; + blob.Offset = events.OffsetInSegment(bytes); + blob.Size = head->Size; + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + head->Offset, blob); + ++delivered; + } + break; + } + case Transport::kEventGpuWritten: { + if (view.payloadSize < sizeof(Transport::EventGpuWrittenHead)) break; + const auto* head = + static_cast(view.payload); + const Uint64 tail = view.payloadSize - sizeof(*head); + if (tail / sizeof(Transport::EventRange) < head->RangeCount) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"gpu-written\"} - RangeCount " + "%u does not fit the record's %llu tail bytes", + static_cast(head->RangeCount), + static_cast(tail)); + std::abort(); + } + if (MG_Pipe::gMGPipeCallbacks.OnGpuWritten != nullptr) { + // EventRange and MGPRange are the same two Uint64s (EventRing.h:61-66 + // asserts it), so the tail is handed over as-is rather than copied into + // a second array a later reader could get out of step with. + const auto* ranges = reinterpret_cast( + static_cast(view.payload) + sizeof(*head)); + MG_Pipe::gMGPipeCallbacks.OnGpuWritten( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + static_cast(head->RangeCount), ranges); + ++delivered; + } + break; + } + case Transport::kEventSurfaceChanged: { + if (view.payloadSize < sizeof(Transport::EventSurfaceChangedHead)) break; + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + const auto* head = + static_cast(view.payload); + MG_Pipe::MGPSurfaceInfo info{}; + info.Width = head->Width; + info.Height = head->Height; + info.InternalFormat = head->InternalFormat; + info.Samples = head->Samples; + info.Layers = head->Layers; + info.IsDefault = head->IsDefault; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + ++delivered; + } + break; + } + default: + MGLOG_W("MG_Remote client: reverse-channel record kind %u is not consumed in " + "P5 (R-12 takes three and a half of the ten callbacks)", + static_cast(view.kind)); + break; + } + } + // AND ONLY NOW. Every payload pointer above has been consumed. + events.Drained(); + return delivered; + } + + // ---- c1: the CapsSnapshot -> CapsMirror adoption, in ONE place ------------------- + // + // Every field of the snapshot has exactly one reader, and a field that fails to decode + // is a REFUSAL rather than a partial adopt: CompileEnv.cpp:123 copies the whole + // DynamicBackendParameters struct into the compile env, so a mirror that adopted three + // of four members would put the fourth's default into a shader fingerprint. + Bool AdoptCapsSnapshot(const ::MobileGL::Wire::CapsSnapshot* snapshot) { + if (snapshot == nullptr) return false; + + MG_Pipe::MGPCaps caps{}; + const auto* dynamicBytes = snapshot->dynamicParameters(); + if (dynamicBytes == nullptr || dynamicBytes->size() != sizeof(caps.Dynamic)) { + // The Hello/Welcome fingerprint already asserted both peers agree on + // sizeof(DynamicBackendParameters), so a disagreement HERE is a corrupt frame + // rather than an ABI skew - which is why it is a refusal and not FatalAbiMismatch. + MGLOG_E("MG_Remote client: CapsSnapshot carries %llu dynamic bytes, this build's " + "struct is %llu - the snapshot is refused whole", + static_cast(dynamicBytes == nullptr ? 0 + : dynamicBytes->size()), + static_cast(sizeof(caps.Dynamic))); + return false; + } + std::memcpy(&caps.Dynamic, dynamicBytes->data(), sizeof(caps.Dynamic)); + caps.CallMask = snapshot->callMask(); + + RendererInfo renderer{}; + const auto* rendererBytes = snapshot->rendererInfo(); + if (rendererBytes == nullptr || + !DecodeRendererInfo(rendererBytes->data(), rendererBytes->size(), renderer)) { + MGLOG_E("MG_Remote client: CapsSnapshot's rendererInfo blob did not decode"); + return false; + } + + MG_Backend::FormatCapabilityCache formats{}; + const auto* formatBytes = snapshot->formatCaps(); + if (formatBytes == nullptr || + !DecodeFormatCapabilities(formatBytes->data(), formatBytes->size(), formats)) { + MGLOG_E("MG_Remote client: CapsSnapshot's formatCaps blob did not decode"); + return false; + } + + const String apiVersion = + snapshot->apiVersion() == nullptr ? String{} : String{snapshot->apiVersion()->c_str()}; + + // THE SERVER'S BACKEND TYPE, NEVER A NEW "Remote" ENUMERATOR and never guessed from + // the renderer string: GL_Framebuffer.cpp:47, GL_Texture.cpp:6536 and + // CompileEnv.cpp:122 SWITCH on it, and a value they do not know takes a wrong arm + // rather than failing. + const Uint32 rawBackend = snapshot->backendType(); + if (rawBackend >= static_cast(BackendType::BackendTypeCount)) { + MGLOG_E("MG_Remote client: CapsSnapshot names backend type %u, which this build " + "has no enumerator for - the snapshot is refused rather than folded onto " + "Unknown, which three frontend switches would silently mis-branch on", + static_cast(rawBackend)); + return false; + } + CapsMirrorInstance().Adopt(caps, formats, renderer, apiVersion, + static_cast(rawBackend)); + return true; + } + } // namespace // Null, not a Fatal: MG_Backend::Init() asks whether a session exists before it decides to @@ -308,24 +498,17 @@ namespace MobileGL::MG_Remote::Client { m_encoder = Wire::PipeWireEncoder(control, &m_cmd, nullptr, &m_segments); // ---- 7. the first CapsSnapshot, if the server had a backend to publish one from. - if (m_transport->PeekFrameSize() != 0) { - std::vector frame; - if (ReceiveEnvelope(*m_transport, frame, 0) == MOBILEGL_OK) { - const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); - if (envelope != nullptr && - envelope->msg_type() == ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { - // The mirror's Adopt and the two blob DECODERS are c1's and w1's. s1 stops - // at "the snapshot arrived and is verifiable": adopting it here would put - // the caps mirror's invalidation rule (R-12: a second arrival IS the - // invalidation) in two places. - MGLOG_I("MG_Remote client: first CapsSnapshot received (%llu bytes); adopting " - "it is package c1's CapsMirror::Adopt over package w1's decoders", - static_cast(frame.size())); - } - } + // ONE DRAIN, ONE ADOPTER (c1): PumpControlPlane below is the only thing in the client + // that turns a CapsSnapshot into a CapsMirror generation, so R-12's "a second arrival + // IS the invalidation" lives in exactly one place. s1's step here used to stop at + // "the snapshot arrived and is verifiable"; it now goes all the way, through the same + // function every later arrival goes through. + if (PumpControlPlane() == 0) { + MGLOG_W("MG_Remote client: the handshake carried no CapsSnapshot - the caps mirror is " + "a PLACEHOLDER until one arrives. Every caps read until then answers a " + "default and says so"); } - m_started = true; g_active = this; LogMemory("handshake"); @@ -425,18 +608,179 @@ namespace MobileGL::MG_Remote::Client { // cost zero extra round trips - and the client may not re-derive any of those four answers // locally. s1 supplies the four primitives it composes from: Encoder(), Producer(), // WaitForApplied() and ReadReply(). - Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp, const void*, Uint64, const void*, Uint64, - void*, Uint64, Int32*) { - MGP5_C0_STUB("ClientSession::EmitAndWait"); + // + // THE ORDER IS ENCODE -> PUBLISH+NOTIFY -> WAIT -> READ REPLY, and it is not negotiable. + // Splitting the wait from the read is how a package ends up answering an acceptance + // question locally, which is the c0f/c0g defect P4a paid two contract corrections for; and + // "always accept" is ID-39's 66 lost DirectVulkan uploads with a wire in between. + Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + const void* varTail, Uint64 varTailBytes, void* replyOut, + Uint64 replyBytes, Int32* statusOut) { + if (statusOut != nullptr) *statusOut = Wire::ReplySink::kStatusError; + if (!m_started) { + MGLOG_F("MGPipe: Fatal{NoClientSession, \"%s\"} - EmitAndWait on a session that has " + "not started. There is no fall-through: a record that could not be emitted " + "is a verb that did not happen", + Wire::WireOpName(op)); + std::abort(); + } + + // R-1's INVARIANT, AS A RUNTIME CHECK RATHER THAN A SENTENCE. While the barrier holds, + // at most one of {GL thread, apply thread} is runnable - and that is the whole reason a + // single process-wide gPipeInputs is legal (table 3). The client is about to publish a + // record whose fields the applier will read out of gPipeInputs, so the apply thread + // being inside the applier right now means the invariant has already been broken and + // the next record would be read against a half-written residual fill. + if (ApplyThreadIsInsideApplier()) { + MGLOG_F("MGPipe: Fatal{BarrierViolation, \"%s\"} - the apply thread is inside the " + "applier while the GL thread is emitting. R-1 makes at most one of them " + "runnable, which is what keeps one process-wide gPipeInputs legal", + Wire::WireOpName(op)); + std::abort(); + } + + const Uint64 seq = + m_encoder.EncodeRecord(op, payload, payloadBytes, varTail, varTailBytes); + if (seq == Wire::kInvalidSeq) { + // The ring refused it. NOT a silent drop and not a retry loop: R-10 says P5 does no + // chunking and must prove it needs none, so a refusal is the proof failing. + MGLOG_F("MGPipe: Fatal{RingOverrun, \"%s\"} - the command ring refused a %llu-byte " + "record. P5 does not chunk (R-10); this is the proof obligation failing, not " + "a back-pressure case", + Wire::WireOpName(op), + static_cast(payloadBytes + varTailBytes)); + std::abort(); + } + + // Publish the head, record submittedSeq, THEN ring - in that order, which is + // SessionProducer's one job and RingTest.cpp:446's pin. Notify-then-publish loses the + // wakeup. + m_producer.PublishAndNotify(seq); + + // Does this row own a reply slot? kMGPipeCallFlags IS THE SINGLE SOURCE OF TRUTH + // (R-16 / ID-31) - fourteen rows now, because the four Bool acceptance entry points + // gained the flag. Asking the catalogue rather than the caller is what stops a caller + // that forgot to pass a buffer from silently turning an answer into a guess. + const Bool ownsReplySlot = + (MG_Pipe::MGPipeCallFlagsFor(op) & static_cast(MG_Pipe::kReplySlot)) != 0; + + if (!m_barrierArmed && !ownsReplySlot) { + // R-1's NEGATIVE CONTROL ARM, and the only thing it turns off is the barrier. A + // reply-slot row still waits: the answer is not derivable here and R-5 forbids + // inventing one, so MOBILEGL_IPC_VERB_BARRIER=0 makes the queue free-running, not + // the client clairvoyant. + return seq; + } + + const BarrierWaitScope waiting; + const Transport::SessionWait wait = m_producer.WaitForApplied(seq, kBarrierTimeoutMs); + if (wait == Transport::SessionWait::ShutDown) { + // The doorbell died: the server went away. The only thing that returns from a + // kWaitForever park, and therefore the only way a client blocked in the barrier + // survives a server that is gone. Not a Fatal - teardown legitimately reaches here. + MGLOG_E("MG_Remote client: the barrier for %s (seq %llu) woke on a dead doorbell; the " + "server is gone and this verb did not happen", + Wire::WireOpName(op), static_cast(seq)); + return seq; + } + if (wait != Transport::SessionWait::Reached) { + MGLOG_F("MGPipe: Fatal{BarrierTimeout, \"%s\"} - appliedSeq did not reach %llu within " + "%u ms. A bounded wait is deliberate: a wedged CI job and a lost record look " + "identical from outside, and only one of them is a bug worth finding", + Wire::WireOpName(op), static_cast(seq), kBarrierTimeoutMs); + std::abort(); + } + + // THE REVERSE CHANNEL IS DRAINED HERE, and here is the only place it can be: under the + // barrier this is the one instant at which the apply thread is known not to be inside + // the applier, and MGPipeClientOnGpuWritten / OnBufferWriteback write frontend objects. + // It is what gives AwaitBufferWriteback something to have waited FOR: b1's third state + // clears when the writeback lands, and the writeback lands on this ring. + DrainEventRing(m_events); + + if (!ownsReplySlot) return seq; + + // THE SAME WAIT, NOT A SECOND ONE. appliedSeq >= seq already means the server wrote + // this record's answer, because it writes the slot before it advances the watermark. + Uint64 replySize = 0; + Int32 status = Wire::ReplySink::kStatusError; + if (!ReadReply(seq, replyOut, replyBytes, &status, &replySize)) { + MGLOG_F("MGPipe: Fatal{ReplyMissing, \"%s\"} - seq %llu carries kReplySlot and the " + "server applied it, but its slot does not stamp that seq. The stamp is what " + "makes a wrong-slot read detectable rather than plausible (R-3)", + Wire::WireOpName(op), static_cast(seq)); + std::abort(); + } + if (statusOut != nullptr) *statusOut = status; + if (status == Wire::ReplySink::kStatusError) { + MGLOG_E("MG_Remote client: %s (seq %llu) answered ERROR", Wire::WireOpName(op), + static_cast(seq)); + } + // DECLINED is NOT an error and is deliberately not logged as one: it is how + // MapPersistent says nullptr (R-6) and how the four Bool acceptance rows say false + // (R-5). A client that treated it as a failure would re-create ID-39 from the other + // side. + if (replyOut != nullptr && replySize > replyBytes) { + MGLOG_F("MGPipe: Fatal{ReplyTooLarge, \"%s\"} - the answer is %llu bytes and the " + "caller offered %llu. P5 does not chunk a reply", + Wire::WireOpName(op), static_cast(replySize), + static_cast(replyBytes)); + std::abort(); + } + return seq; } Bool ClientSession::BarrierArmed() const { return m_barrierArmed; } - // False, not a Fatal, for both: these are the R-1 mutual-exclusion assertion's two probes, - // and an assertion helper that aborts when asked is worse than useless. Package c1 gives - // them real answers when it lands the barrier. - Bool ClientSession::InBarrierWait() { return false; } - Bool ClientSession::ApplyThreadIsInsideApplier() { return false; } + // R-1's mutual-exclusion invariant, as two probes that answer honestly. + // + // THE CLIENT'S FLAG IS THREAD-LOCAL AND THE SERVER'S IS NOT, and the asymmetry is the + // point: "am I inside a barrier wait" is a question about the calling thread, while "is the + // apply thread inside the applier" is a question the GL thread asks about a DIFFERENT + // thread - so the second has to be a shared atomic and the first must not be, or a second + // GL thread would see the first one's wait as its own. + Bool ClientSession::InBarrierWait() { return g_inBarrierWait; } + Bool ClientSession::ApplyThreadIsInsideApplier() { + return g_applyThreadInsideApplier.load(std::memory_order_acquire); + } + void ClientSession::NoteApplyThreadEnteredApplier() { + g_applyThreadInsideApplier.store(true, std::memory_order_release); + } + void ClientSession::NoteApplyThreadLeftApplier() { + g_applyThreadInsideApplier.store(false, std::memory_order_release); + } + + Uint32 ClientSession::PumpControlPlane() { + // NOT gated on m_started. The first snapshot arrives DURING Start(), before this session + // is started or active - and s1's half-built teardown path depends on m_started staying + // false until step 8 has succeeded, so the flag cannot be moved earlier to suit this. + if (m_transport == nullptr) return 0; + Uint32 adopted = 0; + // Bounded rather than `while (true)`: a server that queued frames faster than this + // drains them would otherwise hold the GL thread here for ever, and a frame backlog + // deeper than this is a finding rather than a steady state. + for (Uint32 guard = 0; guard < kMaxControlFramesPerPump; ++guard) { + if (m_transport->PeekFrameSize() == 0) break; + std::vector frame; + if (ReceiveEnvelope(*m_transport, frame, 0) != MOBILEGL_OK) break; + const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); + if (envelope == nullptr) { + MGLOG_E("MG_Remote client: an unverifiable control frame (%llu bytes) was dropped", + static_cast(frame.size())); + continue; + } + if (envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { + // SurfaceOp / SurfaceReply / ResyncRequest / AuxRequest / LogLine are P6's and + // P7's. Named rather than ignored, so a phase that starts sending one does not + // discover this loop swallowing it. + MGLOG_W("MG_Remote client: control message %d is not consumed in P5", + static_cast(envelope->msg_type())); + continue; + } + if (AdoptCapsSnapshot(envelope->msg_as_CapsSnapshot())) ++adopted; + } + return adopted; + } Transport::SessionProducer& ClientSession::Producer() { return m_producer; } diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index ad1cdae6..a80542d2 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -100,6 +100,36 @@ namespace MobileGL::MG_Remote::Client { static Bool InBarrierWait(); static Bool ApplyThreadIsInsideApplier(); + // ---- c1's additions --------------------------------------------------------------- + + // The apply thread's half of R-1's invariant. v1's apply loop brackets its + // DecodeAndApply with these; the client checks the flag before it publishes, so + // "at most one of {GL thread, apply thread} is runnable" is a runtime assertion rather + // than a sentence in a brief. A raw pair rather than an RAII type in this header + // because the server side owns its own scoping and must not have to include a client + // header to get it - ScopedApplierEntry below is the convenience, not the contract. + static void NoteApplyThreadEnteredApplier(); + static void NoteApplyThreadLeftApplier(); + struct ScopedApplierEntry { + ScopedApplierEntry() { NoteApplyThreadEnteredApplier(); } + ~ScopedApplierEntry() { NoteApplyThreadLeftApplier(); } + ScopedApplierEntry(const ScopedApplierEntry&) = delete; + ScopedApplierEntry& operator=(const ScopedApplierEntry&) = delete; + }; + + // R-12's INVALIDATION EDGE. Drains whatever the server has queued on the control plane + // and adopts every CapsSnapshot in it - and a SECOND snapshot IS the invalidation, + // which is why there is no Invalidate(). Non-blocking: it peeks and returns. + // + // Called at the handshake, from BackendObject_Remote's Initialize/InitCapabilities, and + // once per Present. Present is the boundary every one of P5's three targets crosses, + // and a caps re-run can only follow a surface event, so once a frame is both sufficient + // and the cheapest place that is. + // + // Returns how many snapshots it adopted, so a case can assert the edge fired rather + // than assert that a number downstream of it happened to change. + Uint32 PumpControlPlane(); + // ---- s1's additions: the four primitives c1's EmitAndWait composes --------------- // // s1 owns construction and lifetime; c1 owns the barrier POLICY. So the plumbing is diff --git a/MobileGL/MG_Remote/Client/EmitTables.cpp b/MobileGL/MG_Remote/Client/EmitTables.cpp index 3b8cdb03..af0c6e94 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.cpp +++ b/MobileGL/MG_Remote/Client/EmitTables.cpp @@ -6,13 +6,36 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package c1. +// P5 package c1: the 71-slot emit table. +// +// THE PARTITION IS CONTRACT-P5.md §7's AND IS NOT RE-DERIVED HERE (R-15, ID-12): +// class A 2 slots answered locally from the caps mirror, never emitted, never Fatal +// class B 5 slots emitted +// class C 64 slots Fatal{UnmigratedVerb, ""} +// The three counts are static_asserted to sum to kRemoteEmitSlotCount below, so a slot that +// changes class without changing the arithmetic is a build break rather than a behaviour +// change nobody reviewed. +// +// THE PRE-VERB HOOKS RUN BEFORE THE RECORD, NEVER AFTER (b1, ID-18). PushPersistentMapsBeforeVerb +// publishes the bytes an application wrote through a coherent map with no API call at all, and +// MarkGpuWritesForDraw builds the conservative GPU-write set the client now owns. Both describe +// the work the record is ABOUT TO START, so a hook deferred past its own record is the C-1 +// regression re-committed at the transport layer. #include "EmitTables.h" +#include "ClientSession.h" +#include "GpuWritePending.h" +#include "PersistentMapTracker.h" + +#include #include +#include + +#include #include +#include namespace MobileGL::MG_Remote::Client { @@ -36,12 +59,478 @@ namespace MobileGL::MG_Remote::Client { std::abort(); } + namespace { + + Bool g_dropClearEmission = false; + Uint64 g_droppedClearEmissions = 0; + + // E2's control has to be armable from OUTSIDE the process that runs the replay, because + // the statement it makes is about a trace lane and not about a unit case: "drop one + // Clear emission and OpenRA's SSIM falls below 0.99". A recompile would make the control + // arm against source text, which is ID-22(a)'s defect. + // + // READ WITH getenv RATHER THAN THROUGH MG_Config, DELIBERATELY AND TEMPORARILY. Config.h + // is c0's and a new MOBILEGL_IPC_* knob goes through the integrator; this is a + // NEGATIVE-CONTROL switch no operator may ever set, and it announces itself at warning + // level every time it arms so it cannot be on by accident. Flagged for adoption into + // IpcTable if the integrator wants it there. + Bool ReadDropClearFromEnvironment() { + const char* value = std::getenv("MOBILEGL_IPC_E2_DROP_CLEAR"); + const Bool armed = value != nullptr && value[0] == '1' && value[1] == '\0'; + if (armed) { + MGLOG_W("MG_Remote client: MOBILEGL_IPC_E2_DROP_CLEAR=1 - E2's NEGATIVE CONTROL is " + "armed and every glClear will be DROPPED on the wire. This arm is expected " + "to fail its SSIM threshold; a lane that stays green with it set is not " + "going through the wire at all"); + } + return armed; + } + + // ---- the session, demanded rather than assumed -------------------------------- + // + // Every class-B slot needs one. A null session here is NOT the monolith answer - the + // monolith answer is that this table was never installed at all, because + // MG_Backend::Init() only reaches BackendObject_Remote when the transport resolved. So + // a null one is a Fatal by name and not a fall-through to the driver: a pass-through + // slot is the "split lane ran monolith and went green" shape that every gate in this + // phase exists to prevent (R-4). + ClientSession& RequireSession(const char* slot) { + ClientSession* session = ClientSession::Active(); + if (session == nullptr) { + MGLOG_F("MGPipe: Fatal{NoClientSession, \"%s\"} - the remote emit table is " + "installed but no ClientSession is active. A slot may not fall through " + "to a driver this role does not have", + slot); + std::abort(); + } + return *session; + } + + // The two hooks b1 wrote and deliberately left with no caller, because the call site is + // this file's. ORDER: the push first (it produces resource_subdata records that must + // precede the verb on SEG_CMD), then the mark walk, then the verb record. + void BeforeDrawVerb() { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDraw(); + } + + // A verb that reads buffers but starts no shader: clear, blit, readback, present. The + // push still has to run - a coherent map is read by the GPU on any of them - but there + // is no shader that could write one, so no mark walk. + void BeforeReadOnlyVerb() { PushPersistentMapsBeforeVerb(); } + + // ============================================================================= + // CLASS B - the five slots the verb census measured (CONTRACT-P5.md §7) + // ============================================================================= + + void EmitClear(GLbitfield mask) { + ClientSession& session = RequireSession("Clear"); + BeforeReadOnlyVerb(); + + if (g_dropClearEmission) { + // E2's negative control. Everything above still ran, so the only difference + // between this arm and the live one is the record - which is exactly the + // statement "the picture comes from the wire" that E2 exists to prove. + ++g_droppedClearEmissions; + return; + } + + MG_Pipe::MGPClear record{}; + // The DRAW framebuffer is whatever the server's own SyncRenderState resolves from + // gPipeInputs, which the client's MGP_FILL(Clear) at GL_Drawing.cpp:534 has just + // written and the verb barrier keeps still (R-1). Naming a handle here would be a + // SECOND statement of the binding, and the second one is the one that goes stale. + record.Fbo = MG_Pipe::kMGPipeNullHandle; + record.Kind = kRemoteClearWhole; + record.DrawBufferIndex = -1; + record.BufferMask = static_cast(mask); + record.ValueClass = 0; + session.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + } + + void EmitDrawArrays(GLenum mode, GLint first, GLsizei count) { + ClientSession& session = RequireSession("DrawArrays"); + BeforeDrawVerb(); + + MG_Pipe::MGPDrawInfo info{}; + info.Mode = static_cast(mode); + info.IndexSize = 0; // arrays + info.Flags = 0; // NO kDrawHasUserIndices: the reduced path draws from a VBO + info.InstanceCount = 1; + info.StartInstance = 0; + info.RestartIndex = 0; + info.DrawIdOffset = 0; + info.IndexResource = MG_Pipe::kMGPipeNullHandle; + info.MinIndex = ~0u; // "unknown", MGPipeTypes.h:1330 + info.MaxIndex = ~0u; + info.XfbCpuCapturedVertices = 0; + info.NumDraws = 1; + + const MG_Pipe::MGPDrawRange range{static_cast(first), static_cast(count), 0}; + // One tail of exactly NumDraws entries. w1's encoder recomputes that from the + // payload and Fatals on a disagreement, on THIS side - so a NumDraws that drifted + // from the tail is a producer-side abort rather than a corrupt stream a peer has to + // diagnose. + session.EmitAndWait(MG_Pipe::MGPWireOp::DrawVbo, &info, sizeof(info), &range, + sizeof(range), nullptr, 0, nullptr); + } + + void EmitBlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, + GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, + GLenum filter) { + ClientSession& session = RequireSession("BlitFramebuffer"); + BeforeReadOnlyVerb(); + + MG_Pipe::MGPBlit record{}; + // Same reasoning as Clear's Fbo: the read and draw bindings are gPipeInputs', set + // by MGP_FILL(BlitFramebuffer) at GL_Framebuffer.cpp:660 and held still by the + // barrier. glBlitNamedFramebuffer, which DOES name two framebuffers, is class C. + record.ReadFbo = MG_Pipe::kMGPipeNullHandle; + record.DrawFbo = MG_Pipe::kMGPipeNullHandle; + record.SrcX0 = srcX0; + record.SrcY0 = srcY0; + record.SrcX1 = srcX1; + record.SrcY1 = srcY1; + record.DstX0 = dstX0; + record.DstY0 = dstY0; + record.DstX1 = dstX1; + record.DstY1 = dstY1; + record.Mask = static_cast(mask); + record.Filter = static_cast(filter); + session.EmitAndWait(MG_Pipe::MGPWireOp::Blit, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + } + + // How many bytes glReadPixels will pack for this rectangle, from the PACK half of the + // pixel-store state. The client has to declare it - MGPReadbackInfo::DstSize is what + // sizes the reply and what the client checks against MaxReplyBytes() BEFORE it emits, + // because a reply bigger than a slot is Fatal rather than chunked (s1's ReplySlot.h). + // + // GL 4.6 8.4.4's arithmetic, and nothing cleverer: a row is rounded up to Alignment, + // the LAST row is not padded, and SkipRows/SkipPixels/SkipImages shift the destination + // rather than growing it (the application owns those bytes and we never write them). + Uint64 PackedReadbackBytes(GLsizei width, GLsizei height, GLenum format, GLenum type) { + if (width <= 0 || height <= 0) return 0; + const TextureInputFormat inputFormat = + MG_Util::ConvertGLEnumToTextureInputFormat(format); + const TexturePixelDataType dataType = MG_Util::ConvertGLEnumToTexturePixelDataType(type); + const SizeT bytesPerPixel = MG_Util::GetInputBytesPerPixel(inputFormat, dataType); + if (bytesPerPixel == 0) { + // NOT a guess and not a zero-length reply. A format this build cannot size is a + // readback whose answer would be silently truncated, which is the one failure a + // picture comparison cannot see. + MGLOG_F("MGPipe: Fatal{UnsizedReadback, \"read_pixels\"} format=0x%04x type=0x%04x " + "- the client must declare MGPReadbackInfo::DstSize and cannot size this " + "pair; P5's reduced path reads RGBA/UNSIGNED_BYTE", + static_cast(format), static_cast(type)); + std::abort(); + } + + PixelStoreParameters pack{}; + if (MG_State::pGLContext != nullptr) { + pack = MG_State::pGLContext->GetPixelStoreParameters(/*isUnpack=*/false); + } + const Uint64 rowPixels = + pack.RowLength > 0 ? static_cast(pack.RowLength) : static_cast(width); + const Uint64 alignment = pack.Alignment > 0 ? static_cast(pack.Alignment) : 1ull; + const Uint64 rowBytes = rowPixels * static_cast(bytesPerPixel); + const Uint64 paddedRow = ((rowBytes + alignment - 1) / alignment) * alignment; + const Uint64 lastRow = static_cast(width) * static_cast(bytesPerPixel); + return paddedRow * (static_cast(height) - 1) + lastRow; + } + + void EmitReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, + GLenum type, void* pixels) { + ClientSession& session = RequireSession("ReadPixels"); + BeforeReadOnlyVerb(); + + // THE PBO HALF IS b1's DESIGN AND b1 ALREADY WIRED ITS MARK, at + // GL_Framebuffer.cpp:3109 - immediately after this table call returns, inside + // ReadPixels_Backend itself. So this emitter deliberately does NOT call + // MarkReadPixelsPackBuffer(): a second call there would be the "wire it twice" + // shape, and the per-row counter b1's unit cases assert on would then count one + // read as two. + const Uint64 bytes = PackedReadbackBytes(width, height, format, type); + + MG_Pipe::MGPReadbackInfo info{}; + info.Res = MG_Pipe::kMGPipeNullHandle; // "the bound read surface answers" + info.Box = MG_Pipe::MGPBox{x, y, 0, static_cast(width), + static_cast(height), 1}; + info.Format = static_cast(format); + info.Type = static_cast(type); + info.Target = 0; + info.Level = 0; + info.DstOffset = 0; + info.DstSize = bytes; + + // CHECKED BEFORE THE EMISSION, not after the answer. A reply bigger than a slot is + // Fatal on the server, and a Fatal there is a dead apply thread with a client + // parked in the barrier for ever; here it is one line naming the number. + const Uint64 capacity = session.MaxReplyBytes(); + if (bytes > capacity) { + MGLOG_F("MGPipe: Fatal{ReplyTooLarge, \"read_pixels\"} %llu bytes into a %llu-byte " + "reply slot - P5 does not chunk a readback (R-10); raise the reply pool's " + "slot size or shrink the read", + static_cast(bytes), + static_cast(capacity)); + std::abort(); + } + + Int32 status = 0; + // The pixels land straight in the application's buffer: the barrier's wait IS the + // reply's wait (R-3), so this costs no round trip beyond the one the barrier was + // already paying. + session.EmitAndWait(MG_Pipe::MGPWireOp::ReadPixels, &info, sizeof(info), nullptr, 0, + pixels, bytes, &status); + } + + void EmitPresent() { + ClientSession& session = RequireSession("Present"); + BeforeReadOnlyVerb(); + + MG_Pipe::MGPPresent record{}; + // FrameSerial 0 = "the server stamps its own". P5 has no client-side present credit + // (MOBILEGL_IPC_PRESENT_CREDIT is P6's), so a client-minted serial would be a second + // id space with no consumer. + record.FrameSerial = 0; + session.EmitAndWait(MG_Pipe::MGPWireOp::Present, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + + // R-12's invalidation edge, drained at the one boundary every target crosses. A + // second CapsSnapshot IS the invalidation; nothing else on the client can see that + // the server re-ran InitCapabilities, because GLContext::GetCompileEnv()'s memo is + // keyed on pActiveBackendObject.get() (Core.cpp:34) and that pointer never changes + // under split. + session.PumpControlPlane(); + } + + // ============================================================================= + // CLASS A - answered locally from the caps mirror (R-15). NO RECORD, EVER. + // ============================================================================= + + void AnswerGetIntegeri_v(GLenum target, GLuint index, GLint* data) { + if (data == nullptr) return; + const MG_Backend::DynamicBackendParameters& dynamic = CapsMirrorInstance().Dynamic(); + // The ONLY two indexed pnames the device owns; every other indexed pname names + // frontend state and is answered in GL_Getter::GetIntegeri_v before any table is + // consulted (BackendObject.h:196-205). The existing gate is + // AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer, which pins + // that this answer and the caps copy are ONE number. + switch (target) { + case GL_MAX_COMPUTE_WORK_GROUP_COUNT: + if (index < 3) *data = static_cast(dynamic.MaxComputeWorkGroupCount[index]); + return; + case GL_MAX_COMPUTE_WORK_GROUP_SIZE: + if (index < 3) *data = static_cast(dynamic.MaxComputeWorkGroupSize[index]); + return; + default: + // Not a Fatal: the slot's own contract is "whatever pname the frontend has no + // case for at all", and the monolith backends answer such a pname by leaving + // the driver's own default in place. Answering a wrong number would be worse + // than answering none. + MGLOG_W_ONCE("MG_Remote client: GetIntegeri_v(0x%04x, %u) is not one of the two " + "device-owned indexed pnames and has no caps-mirror answer", + static_cast(target), static_cast(index)); + return; + } + } + + Bool AnswerIsTimerQuerySupported() { + // A capability predicate, not a call. Today a null slot means COUNTER_BITS == 0 + // (GL_Query.cpp:792) - which is precisely the null check R-4 forbids, so it moves + // here, to the bit the server published. + return CapsMirrorInstance().HasCap(MG_Pipe::kCapTimerQuery); + } + + // ============================================================================= + // CLASS C - Fatal{UnmigratedVerb}. 64 slots: 63 in GLFunctionsTable + SetSwapInterval. + // ============================================================================= + // + // The list is an X-macro so the DEFINITION and the ASSIGNMENT cannot drift apart, and + // so the count is arithmetic rather than a comment. Two of them carry a pre-verb hook + // before the Fatal - see the note on DispatchCompute. + +#define MGR_UNMIGRATED_GL_SLOTS(X) \ + X(DrawElements, void, (GLenum, GLsizei, GLenum, const void*)) \ + X(DrawElementsBaseVertex, void, (GLenum, GLsizei, GLenum, const void*, GLint)) \ + X(MultiDrawArrays, void, (GLenum, const GLint*, const GLsizei*, GLsizei)) \ + X(MultiDrawElements, void, (GLenum, const GLsizei*, GLenum, const GLvoid* const*, GLsizei)) \ + X(MultiDrawElementsBaseVertex, void, \ + (GLenum, const GLsizei*, GLenum, const GLvoid* const*, GLsizei, const GLint*)) \ + X(MultiDrawElementsIndirect, void, (GLenum, GLenum, const void*, GLsizei, GLsizei)) \ + X(MultiDrawArraysIndirect, void, (GLenum, const void*, GLsizei, GLsizei)) \ + X(MultiDrawElementsIndirectCount, void, (GLenum, GLenum, const void*, GLintptr, GLsizei, GLsizei)) \ + X(MultiDrawArraysIndirectCount, void, (GLenum, const void*, GLintptr, GLsizei, GLsizei)) \ + X(DrawRangeElementsBaseVertex, void, \ + (GLenum, GLuint, GLuint, GLsizei, GLenum, const void*, GLint)) \ + X(DrawRangeElements, void, (GLenum, GLuint, GLuint, GLsizei, GLenum, const void*)) \ + X(DrawElementsInstancedBaseVertexBaseInstance, void, \ + (GLenum, GLsizei, GLenum, const void*, GLsizei, GLint, GLuint)) \ + X(DrawElementsInstancedBaseVertex, void, (GLenum, GLsizei, GLenum, const void*, GLsizei, GLint)) \ + X(DrawElementsInstancedBaseInstance, void, \ + (GLenum, GLsizei, GLenum, const void*, GLsizei, GLuint)) \ + X(DrawElementsInstanced, void, (GLenum, GLsizei, GLenum, const void*, GLsizei)) \ + X(DrawArraysInstancedBaseInstance, void, (GLenum, GLint, GLsizei, GLsizei, GLuint)) \ + X(DrawArraysInstanced, void, (GLenum, GLint, GLsizei, GLsizei)) \ + X(DrawElementsIndirect, void, (GLenum, GLenum, const void*)) \ + X(DrawArraysIndirect, void, (GLenum, const void*)) \ + X(ClearBufferfi, void, (GLenum, GLint, GLfloat, GLint)) \ + X(ClearBufferfv, void, (GLenum, GLint, const GLfloat*)) \ + X(ClearBufferuiv, void, (GLenum, GLint, const GLuint*)) \ + X(ClearBufferiv, void, (GLenum, GLint, const GLint*)) \ + X(ClearNamedFramebufferfv, void, \ + (const SharedPtr&, GLenum, GLint, const GLfloat*)) \ + X(ClearNamedFramebufferfi, void, \ + (const SharedPtr&, GLenum, GLint, GLfloat, GLint)) \ + X(ClearNamedFramebufferiv, void, \ + (const SharedPtr&, GLenum, GLint, const GLint*)) \ + X(ClearNamedFramebufferuiv, void, \ + (const SharedPtr&, GLenum, GLint, const GLuint*)) \ + X(BlitNamedFramebuffer, void, \ + (const SharedPtr&, \ + const SharedPtr&, GLint, GLint, GLint, GLint, GLint, \ + GLint, GLint, GLint, GLbitfield, GLenum)) \ + X(CopyTexImage2D, void, (GLenum, GLint, GLenum, GLint, GLint, GLsizei, GLsizei, GLint)) \ + X(CopyTexSubImage2D, void, (GLenum, GLint, GLint, GLint, GLint, GLint, GLsizei, GLsizei)) \ + X(CopyImageSubData, void, \ + (const MG_Backend::CopyImageEndpoint&, GLenum, GLint, GLint, GLint, GLint, \ + const MG_Backend::CopyImageEndpoint&, GLenum, GLint, GLint, GLint, GLint, GLsizei, GLsizei, \ + GLsizei)) \ + X(GenerateMipmap, void, (GLenum)) \ + X(GetTexImage, void, (GLenum, GLint, GLenum, GLenum, GLvoid*)) \ + X(GetTextureImage, void, \ + (const SharedPtr&, TextureUploadTarget, GLint, GLenum, \ + GLenum, GLsizei, GLvoid*)) \ + X(MemoryBarrier, void, (GLbitfield)) \ + X(MemoryBarrierByRegion, void, (GLbitfield)) \ + X(BindImageTexture, void, (GLuint, GLuint, GLint, GLboolean, GLint, GLenum, GLenum)) \ + X(ShaderStorageBlockBinding, void, (GLuint, const GLchar*, GLuint)) \ + X(WaitSync, void, (MG_Backend::BackendSyncHandle, GLbitfield, GLuint64)) \ + X(DeleteSync, void, (MG_Backend::BackendSyncHandle)) \ + X(EndTimeElapsedQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(DeleteBackendQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(EndOcclusionQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(EndXfbPrimitivesQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(PatchParameteri, void, (GLenum, GLint)) \ + X(BeginTransformFeedback, void, (GLenum)) \ + X(EndTransformFeedback, void, ()) \ + X(PauseTransformFeedback, void, ()) \ + X(ResumeTransformFeedback, void, ()) \ + X(BindTransformFeedback, void, (GLuint)) \ + X(DeleteTransformFeedback, void, (GLuint)) + + // The non-void ones, kept apart only because the macro body differs: a [[noreturn]] + // call is a complete body for a void slot and for a value-returning one alike, but a + // compiler that does not see UnmigratedVerbFatal's attribute through the macro would + // warn on the second. It does see it; they are split for readability. +#define MGR_UNMIGRATED_GL_VALUE_SLOTS(X) \ + X(FenceSync, MG_Backend::BackendSyncHandle, ()) \ + X(ClientWaitSync, GLenum, (MG_Backend::BackendSyncHandle, GLbitfield, GLuint64)) \ + X(GetSyncStatus, Bool, (MG_Backend::BackendSyncHandle)) \ + X(BeginTimeElapsedQuery, MG_Backend::BackendQueryHandle, ()) \ + X(QueryCounterTimestamp, MG_Backend::BackendQueryHandle, ()) \ + X(IsQueryResultAvailable, Bool, (MG_Backend::BackendQueryHandle)) \ + X(GetQueryResult64, Bool, (MG_Backend::BackendQueryHandle, Bool, Uint64*)) \ + X(BeginOcclusionQuery, MG_Backend::BackendQueryHandle, ()) \ + X(BeginXfbPrimitivesQuery, MG_Backend::BackendQueryHandle, (Bool)) \ + X(GetGpuTimestampNs, Int64, ()) + +#define MGR_DEFINE_UNMIGRATED(Name, Ret, Sig) \ + Ret Name##_Unmigrated Sig { UnmigratedVerbFatal(#Name); } + + MGR_UNMIGRATED_GL_SLOTS(MGR_DEFINE_UNMIGRATED) + MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_DEFINE_UNMIGRATED) +#undef MGR_DEFINE_UNMIGRATED + + // THE TWO COMPUTE SLOTS CARRY b1's DISPATCH HOOK BEFORE THE FATAL, and this is stated + // rather than hidden. MarkGpuWritesForDispatch() belongs immediately before the + // dispatch record, and the dispatch record is class C in P5 - so the call site is here, + // in the right place, and is UNREACHABLE-IN-EFFECT: the abort follows it. There is no + // gate on it and this file says so; the phase that moves DispatchCompute into class B + // replaces the Fatal and inherits a call site that is already correct rather than + // discovering that the mark walk was never wired. + void DispatchCompute_Unmigrated(GLuint, GLuint, GLuint) { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDispatch(); + UnmigratedVerbFatal("DispatchCompute"); + } + void DispatchComputeIndirect_Unmigrated(GLintptr) { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDispatch(); + UnmigratedVerbFatal("DispatchComputeIndirect"); + } + + void SetSwapInterval_Unmigrated(Int) { UnmigratedVerbFatal("SetSwapInterval"); } + + // The counts, as arithmetic. MGR_COUNT_ONE expands to `+ 1` per row. +#define MGR_COUNT_ONE(Name, Ret, Sig) +1 + constexpr Uint32 kUnmigratedListedSlots = + 0 MGR_UNMIGRATED_GL_SLOTS(MGR_COUNT_ONE) MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_COUNT_ONE); +#undef MGR_COUNT_ONE + // + DispatchCompute, DispatchComputeIndirect, SetSwapInterval, written out by hand + // because they carry a body the macro cannot. + constexpr Uint32 kUnmigratedSlots = kUnmigratedListedSlots + 3; + constexpr Uint32 kLocallyAnsweredSlots = 2; // GetIntegeri_v, IsTimerQuerySupported + constexpr Uint32 kEmittedSlots = 5; // Clear, DrawArrays, ReadPixels, Blit, Present + + static_assert(kUnmigratedSlots == 64, "CONTRACT-P5.md §7 class C is 64 slots"); + static_assert(kLocallyAnsweredSlots + kEmittedSlots + kUnmigratedSlots == kRemoteEmitSlotCount, + "the three classes no longer partition the 71 slots"); + + MG_Backend::GlobalBackendFunctionsTable BuildRemoteEmitTable() { + g_dropClearEmission = ReadDropClearFromEnvironment(); + MG_Backend::GlobalBackendFunctionsTable table{}; + + // ---- class C first, so that a slot forgotten below stays Fatal rather than null. + // Order matters for exactly this reason: if class B's assignment were first, a + // typo in class C would leave a NULL slot, and a null slot is 91 potential null + // calls with no diagnostic. This way the worst a mistake can do is name a verb + // that was supposed to be emitted, loudly. +#define MGR_ASSIGN_UNMIGRATED(Name, Ret, Sig) table.GL.Name = &Name##_Unmigrated; + MGR_UNMIGRATED_GL_SLOTS(MGR_ASSIGN_UNMIGRATED) + MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_ASSIGN_UNMIGRATED) +#undef MGR_ASSIGN_UNMIGRATED + table.GL.DispatchCompute = &DispatchCompute_Unmigrated; + table.GL.DispatchComputeIndirect = &DispatchComputeIndirect_Unmigrated; + table.SetSwapInterval = &SetSwapInterval_Unmigrated; + + // ---- class A + table.GL.GetIntegeri_v = &AnswerGetIntegeri_v; + table.GL.IsTimerQuerySupported = &AnswerIsTimerQuerySupported; + // NOT A SLOT and not a verb: a Bool member of the table, whose one non-test client + // reader is GL_Query.cpp:221. It does NOT ride inside MGPCaps::Dynamic - it is a + // member of GLFunctionsTable, which is exactly the thing a split client never + // receives - so it is answered from kCapCpuXfbPrimitiveAccounting. + table.GL.PrefersCpuXfbPrimitiveAccounting = + CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting(); + + // ---- class B + table.GL.Clear = &EmitClear; + table.GL.DrawArrays = &EmitDrawArrays; + table.GL.ReadPixels = &EmitReadPixels; + table.GL.BlitFramebuffer = &EmitBlitFramebuffer; + table.Present = &EmitPresent; + + return table; + } + + } // namespace + const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable() { - MGLOG_F("MGPipe: Fatal{UnimplementedEmitTable, \"RemoteEmitTable\"} - P5 package c1 has " - "not landed this yet; c0 shipped the signature only"); - std::abort(); + // Leaked at exit like every other MG_Remote singleton (ID-8): MG_Backend::Init() + // copies it into gBackendFunctionsTable and MobileGL::Destroy() clears that copy from + // an exit handler, by which point a static destructor here would already have run. + static const MG_Backend::GlobalBackendFunctionsTable& table = + *new MG_Backend::GlobalBackendFunctionsTable{BuildRemoteEmitTable()}; + return table; } - Uint32 ImplementedVerbCount() { return 0; } + Uint32 ImplementedVerbCount() { return kEmittedSlots; } + Uint32 LocallyAnsweredSlotCount() { return kLocallyAnsweredSlots; } + Uint32 UnmigratedSlotCount() { return kUnmigratedSlots; } + + void SetDropClearEmissionForNegativeControl(Bool drop) { g_dropClearEmission = drop; } + Uint64 DroppedClearEmissions() { return g_droppedClearEmissions; } } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/EmitTables.h b/MobileGL/MG_Remote/Client/EmitTables.h index 791297f3..0c8c51b1 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.h +++ b/MobileGL/MG_Remote/Client/EmitTables.h @@ -60,6 +60,22 @@ namespace MobileGL::MG_Remote::Client { + // MGPClear::Kind. MGPipeTypes.h:1273 states the list as a COMMENT - "Whole | Color | Depth + // | Stencil | DepthStencil" - and mints no enumerator, because until P5 the record had no + // producer. These are the values, in that comment's own order, and they are here rather + // than in MGPipeTypes.h because that file is c0's and this phase produces exactly ONE of + // them: glClear is the only entry point that reaches the Clear slot (the four + // glClearBuffer* and the four glClearNamedFramebuffer* are class C). v1's + // WireVerbSink::OnClear must therefore Fatal on anything but Whole rather than guess, and + // the phase that migrates the other eight moves these into the contract. + enum MGRemoteClearKind : Uint32 { + kRemoteClearWhole = 0, + kRemoteClearColor = 1, + kRemoteClearDepth = 2, + kRemoteClearStencil = 3, + kRemoteClearDepthStencil = 4, + }; + // The table MG_Backend::Init() installs into gBackendFunctionsTable for the remote role. // A reference to a never-destroyed block, like every other MG_Remote singleton (ID-8). const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable(); @@ -77,4 +93,25 @@ namespace MobileGL::MG_Remote::Client { // a slot added to GLFunctionsTable without a decision here is a build break. inline constexpr Uint32 kRemoteEmitSlotCount = 71; + // The other two thirds of the census, so a case can assert the WHOLE partition rather than + // only the half that emits. CONTRACT-P5.md §7's three classes are 2 + 5 + 64, and + // EmitTables.cpp static_asserts that they sum to kRemoteEmitSlotCount: a slot that quietly + // changes class shows up as a build break in the sum, not as a silent behaviour change. + Uint32 LocallyAnsweredSlotCount(); // class A - answered from the caps mirror, R-15 + Uint32 UnmigratedSlotCount(); // class C - Fatal{UnmigratedVerb} + + // THE E2 NEGATIVE CONTROL (t1's debt against c1, BRIEF §7). When set, the Clear emitter + // SKIPS its record - it still runs the pre-verb hooks and still returns - so a replay that + // is really going through the wire loses one clear per frame and its SSIM falls below the + // 0.99 threshold, while a replay that fell through to the driver is unaffected. It is a + // function rather than a knob in Config.h for two reasons: the control has to be settable + // from a test process that has already started, and a knob would be a + // MOBILEGL_IPC_-shaped name for something no operator may ever set. + // + // Emissions actually skipped, so the control can assert that it DID something rather than + // that a picture changed - a control that silently never fired is the third shape of R-16's + // "a gate that cannot go red for its own reason". + void SetDropClearEmissionForNegativeControl(Bool drop); + Uint64 DroppedClearEmissions(); + } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/SlotCaps.h b/MobileGL/MG_Remote/Client/SlotCaps.h new file mode 100644 index 00000000..63bcf4b0 --- /dev/null +++ b/MobileGL/MG_Remote/Client/SlotCaps.h @@ -0,0 +1,135 @@ +// MobileGL - MobileGL/MG_Remote/Client/SlotCaps.h +// 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 41 NULL CHECKS. Owner: package c1 (CONTRACT-P5.md §7, ID-14). +// +// FORTY-ONE OF THE SIXTY-NINE GLFunctionsTable SLOTS ARE NULL-CHECKED AT THEIR CALL SITE, AND +// SEVERAL OF THOSE CHECKS ARE CAPABILITY PROBES RATHER THAN SAFETY CHECKS. R-4 forbids a null +// slot in the client's emit table - so in that table every one of those probes would answer +// "supported", and whatever fallback sits behind it would silently disappear. That is not a +// theoretical risk: it is how a split lane produces a plausible picture for the wrong reason. +// ARCHITECTURE.md:114 already said what replaces the probe - "CallMask replaces 'is this table +// slot null' as the implicit capability probe" - and this header is the concrete list. +// +// TWO SPELLINGS, AND WHICH ONE A SITE TAKES IS DECIDED BY THE SLOT'S CLASS, NOT BY TASTE: +// +// MGL_BACKEND_SLOT_CAP(Slot, CapBit) +// The capability has a published bit. Under split the answer is the SERVER's bit; +// under monolith it is exactly today's null check, character for character. +// The three cases CONTRACT-P5.md §7 names by hand are all of this shape: +// GL_Query.cpp:481 / :558 / :785 BeginOcclusionQuery -> kCapOcclusionQuery +// GL_Query.cpp:534 BeginXfbPrimitivesQuery -> kCapXfbPrimitivesQuery +// PipeFill.cpp SubDataResident -> kCapResidentSubData +// (the third is not a GLFunctionsTable slot but an op-table one, so it reads the bit +// directly in PipeFill.cpp rather than through this header). +// +// MGL_BACKEND_SLOT_LOCAL(Slot) +// The capability has NO published bit and the slot is CONTRACT-P5.md §7 class C - +// Fatal{UnmigratedVerb} in the client's table. Under split the honest answer is +// "absent", which is precisely what the monolith nullptr meant, so every fallback the +// site already has survives instead of being replaced by an abort. Under monolith it is +// again today's null check. +// +// WHY "ABSENT" AND NOT "LET IT FATAL". A Fatal is loud, and for the 28 UNGUARDED slots it is +// strictly better than today (a null there is already an immediate crash with no diagnostic). +// But a guarded site is guarded because the frontend has a real answer for the absent case - +// always-signaled syncs, a CPU readback, CPU primitive accounting - and turning that answer +// into an abort is a behaviour change nobody asked for, in the direction that stops a lane +// dead. The class-C slot IS absent from this client; saying so is the accurate answer, not a +// weakening of R-4. +// +// THE TEST THAT DECIDES WHETHER A GUARDED SITE IS CONVERTED AT ALL, and it is the half of the +// walk the contract leaves to whoever does it: A PROBE MOVES ONLY WHERE "ABSENT" IS A CORRECT +// AND SUFFICIENT ANSWER. Where the fallback behind the probe produces a RIGHT result, "absent" +// is the accurate description of a client that does not have the slot, and converting keeps +// the lane running. Where the fallback produces a SILENTLY WRONG result, converting would +// manufacture exactly the defect R-4 exists to prevent, and the probe is left alone so the +// class-C slot Fatals by name instead. +// +// Converted, because the fallback is right: +// GL_Sync.cpp:59 FenceSync -> always-signaled syncs, which the table's own +// header documents as the fallback and which GL +// permits; every other sync site is already +// guarded on syncObject->backendHandle, so this +// one gate carries the whole family. +// GL_Texture.cpp:6537 GetTextureImage -> the frontend's own CPU readback, which is exact +// GL_Texture.cpp:6799 GetTexImage -> the same +// GL_Getter.cpp x2 GetGpuTimestampNs -> 0, which BackendObject.h:192 already names as +// the unsupported answer +// GL_Query.cpp the four query probes -> CPU primitive accounting / target rejection / +// COUNTER_BITS 0, all of them spec answers +// +// NOT converted, deliberately, because "absent" would be wrong rather than quiet: +// GL_Drawing.cpp:1274/:1371/:1420/:1435/:1641/:1673 the transform-feedback span family. The +// frontend reads a null EndTransformFeedback as "this backend does NOT own the capture, +// so reorder the captured records for it" (FixupGsStripCaptureOrder, :1290). Under split +// the server's backend DOES own it, so answering "absent" would reorder a capture that +// was already in GL order - a silently corrupt buffer. Transform feedback is off P5's +// reduced path (BRIEF §4's exclusion list) and its slots are class C, so the first XFB +// call aborts by name, which is the outcome R-4 asks for. +// GL_Drawing.cpp:844 PatchParameteri. "Absent" means the patch size is never set and every +// tessellation draw silently uses the previous one. Class C; it aborts by name. +// +// WHAT THIS HEADER DELIBERATELY DOES NOT DO. It does not touch the 28 unguarded slots: those +// have no probe to convert, and calling one reaches Fatal{UnmigratedVerb, ""} by name, +// which is R-4's intent. And it does not invent a cap bit - a new MGPCapBit is an +// MGPipeTypes.h edit and that file is c0's, so a family that needs one goes through the +// integrator (the XFB span family is the first that will). +// +// G1: in a build without MOBILEGL_BUILD_DISAGGREGATED both macros expand to the null check the +// site already had, so the pull build's code generation is unchanged. + +#pragma once +#include + +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include + +#define MGL_BACKEND_SLOT_CAP(Slot, CapBit) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? ::MobileGL::MG_Remote::Client::CapsMirrorInstance().HasCap(CapBit) \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) + +#define MGL_BACKEND_SLOT_LOCAL(Slot) \ + (::MobileGL::MG_Config::Transport == ::MobileGL::MG_Config::TransportMode::Monolith && \ + ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) + +// The POINTER-valued forms, for the several sites shaped `if (const auto f = TABLE.GL.Slot)`. +// They exist for G1 and for nothing else: a site rewritten from that shape into +// `if (MGL_BACKEND_SLOT_CAP(...)) { const auto f = TABLE.GL.Slot; ... }` is semantically the +// same and generated DIFFERENT CODE - the first measurement of this change moved +// GL_Getter.cpp's GetInteger64v by -150 bytes and GetIntegerv by +2, which is two "resized" +// symbols and a red G1. In a pull build these two expand to the slot expression ITSELF, so the +// init-statement survives verbatim and the pull build's code generation cannot move. +#define MGL_BACKEND_SLOT_PTR_CAP(Slot, CapBit) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? (::MobileGL::MG_Remote::Client::CapsMirrorInstance().HasCap(CapBit) \ + ? ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot \ + : nullptr) \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#define MGL_BACKEND_SLOT_PTR_LOCAL(Slot) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? nullptr \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#else + +#define MGL_BACKEND_SLOT_CAP(Slot, CapBit) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) +#define MGL_BACKEND_SLOT_LOCAL(Slot) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) +#define MGL_BACKEND_SLOT_PTR_CAP(Slot, CapBit) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) +#define MGL_BACKEND_SLOT_PTR_LOCAL(Slot) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#endif diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 45393eff..89dc505f 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -41,6 +41,13 @@ #include #include #include + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5 c1 / R-8: the client's liveness gates read the caps mirror's consumer mask under split, so +// a split-armed case has to arm that half too - registering an op table is the SERVER's arming. +#include +#include +#endif #include #include #include @@ -4695,6 +4702,21 @@ TEST(DirectGLESBufferDrawProbe, UnderSplitTheRecordAloneAnswersTheLiveHostMapQue MG_Pipe::MGPipeSetResourceOps(&ops); MG_Config::Transport = MG_Config::TransportMode::InProcess; MG_Config::Ipc.PersistentBlockKb = 64; + // P5 c1 / R-8: UNDER SPLIT THE OP TABLE IS NO LONGER THE ARMING CONDITION, and this case is + // the first place that shows. `MGPipeSetResourceOps(&ops)` is the SERVER's registration; the + // client's liveness gate now reads the caps mirror's consumer mask instead, because under a + // spawn the client process has no op table at all and reading one would silently stop five + // record families. So the probe has to arm BOTH halves - and the fact that it did not is the + // defect R-8 exists to catch, reproduced here by a change rather than argued about. + const Uint64 previousCapsGeneration = MG_Remote::Client::CapsMirrorInstance().Generation(); + { + MG_Pipe::MGPCaps caps{}; + caps.CallMask = MG_Remote::MGCapsConsumerBits(MG_Pipe::kMGPipeSubsystemResources); + MG_Remote::Client::CapsMirrorInstance().Adopt(caps, MG_Backend::FormatCapabilityCache{}, + RendererInfo{}, String{}, + BackendType::DirectGLES); + } + (void)previousCapsGeneration; { // The constructor mints the handle and emits resource_create; Respecify emits the diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 280acb07..ec0f9050 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -65,3 +65,30 @@ if (MSVC) endif () gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +# P5 c1's suite: the 71-slot emit table, the caps mirror and R-8's liveness gates. Registered +# on its own for PipeWireCodecTest's reason - MGLOG_F writes to stdout and to a named file and +# NEVER to stderr, so a Fatal arm can only be asserted by forking and reading that file back, +# which needs a main() of its own. +add_executable(RemoteClientTest RemoteClientTest.cpp) + +target_include_directories(RemoteClientTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/flatbuffers/include + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries(RemoteClientTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(RemoteClientTest PRIVATE /Zc:preprocessor) +endif () + +gtest_discover_tests(RemoteClientTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp new file mode 100644 index 00000000..1cf103fa --- /dev/null +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -0,0 +1,419 @@ +// MobileGL - MobileGL/MG_Test/Wire/RemoteClientTest.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 + +// P5 package c1's suite: the 71-slot emit table, the caps mirror and R-8's liveness gates. No +// session, no transport and no thread - s1's SessionTest owns those and w1's PipeWireCodecTest +// owns the bytes. +// +// IT LINKS gtest RATHER THAN gtest_main AND CARRIES ITS OWN main(), for PipeWireCodecTest's and +// PipeInputsTest's reason: the Fatal arms report through MGLOG_F + std::abort, and MGLOG_F +// writes to STDOUT and to a named file, NEVER to stderr - so EXPECT_DEATH's stderr regex could +// only ever match the empty string. A case that drives one FORKS and reads the Fatal line back +// out of a log file this process names before anything logs. That is what makes each control +// assert ITS OWN failure string (R-16) instead of asserting that something, somewhere, died. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Includes.h" + +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; +using namespace MobileGL::MG_Remote; +using namespace MobileGL::MG_Remote::Client; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + if (!in) return {}; + std::ostringstream out; + out << in.rdbuf(); + return out.str(); + } + + int ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + // A caps snapshot the client could plausibly have received, with every field distinct from + // its default so a mirror that answered from a zeroed struct cannot look like one that + // adopted. THIS IS NOT THE STATE UNDER TEST - it is the INPUT to Adopt(), which is the + // producer; the assertions below read what the mirror hands the frontend's own accessors + // back, never what this function wrote (R-16). + struct Snapshot { + MGPCaps Caps{}; + MG_Backend::FormatCapabilityCache Formats{}; + RendererInfo Renderer{}; + String ApiVersion; + BackendType Backend = BackendType::DirectGLES; + }; + + Snapshot MakeSnapshot(Uint64 consumedSubsystems, Uint64 capBits) { + Snapshot s; + s.Caps.CallMask = capBits | MGCapsConsumerBits(consumedSubsystems); + s.Caps.Dynamic.MaxComputeWorkGroupCount[0] = 65531; + s.Caps.Dynamic.MaxComputeWorkGroupCount[1] = 65532; + s.Caps.Dynamic.MaxComputeWorkGroupCount[2] = 65533; + s.Caps.Dynamic.MaxComputeWorkGroupSize[0] = 1021; + s.Caps.Dynamic.MaxComputeWorkGroupSize[1] = 1022; + s.Caps.Dynamic.MaxComputeWorkGroupSize[2] = 1023; + s.Caps.Dynamic.UniformBufferOffsetAlignment = 64; + s.Renderer.RendererName = "MobileGL Remote Test Renderer"; + s.Renderer.BackendName = "Espryt"; + s.Renderer.ExtraVendor = String{"c1"}; + s.Renderer.RendererGLInfo.TargetGLVersion = Version{4, 6, 0, {}, {}}; + s.Renderer.RendererGLInfo.TargetGLSLVersion = Version{4, 6, 0, {}, {}}; + s.ApiVersion = "4.6"; + return s; + } + + void AdoptSnapshot(const Snapshot& s) { + CapsMirrorInstance().Adopt(s.Caps, s.Formats, s.Renderer, s.ApiVersion, s.Backend); + } + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; + std::string Log; + }; + + template + ChildResult RunInChild(Body body) { + ChildResult result; + // TRUNCATE, RATHER THAN REMEMBER AN OFFSET, and the difference is not cosmetic: this + // parent never logs, so MG_Util::Debug's FILE* is opened FOR THE FIRST TIME by each + // child - with "w", which truncates. An offset taken before the fork therefore points + // past the end of the child's own log, and `substr` hands back the tail of a DIFFERENT + // child's output. That is how the second death case in this file came to see the first + // one's slot name and assert on it: a control reading another control's message, which + // is one of the three shapes R-16 was written after. + { std::ofstream truncate(g_logPath, std::ios::trunc | std::ios::binary); } + std::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid < 0) return result; + if (pid == 0) { + body(); + ::_exit(0); + } + int status = 0; + if (::waitpid(pid, &status, 0) != pid) return result; + result.Status = status; + result.Log = ReadLog(); + return result; + } + + bool DiedOfAbort(const ChildResult& r) { + return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; + } + std::string DescribeStatus(const ChildResult& r) { + if (r.Status < 0) return "fork/waitpid failed"; + if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status)); + if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status)); + return "status " + std::to_string(r.Status); + } +#endif + +} // namespace + +// ===================================================================================== +// The emit table: the partition, and that no slot is null +// ===================================================================================== + +TEST(RemoteEmitTable, TheThreeClassesPartitionAllSeventyOneSlots) { + // CONTRACT-P5.md §7: 2 answered locally + 5 emitted + 64 Fatal. Read from the functions the + // table itself reports with - which is also what t1's arming condition reads - rather than + // recomputed here, so a table that lost an emitter cannot look like one that never had it. + EXPECT_EQ(LocallyAnsweredSlotCount(), 2u); + EXPECT_EQ(ImplementedVerbCount(), 5u); + EXPECT_EQ(UnmigratedSlotCount(), 64u); + EXPECT_EQ(LocallyAnsweredSlotCount() + ImplementedVerbCount() + UnmigratedSlotCount(), + kRemoteEmitSlotCount); +} + +TEST(RemoteEmitTable, NoSlotIsNull) { + // R-4's whole rule, asserted over the STRUCT rather than over the list that built it. 91 + // MG_Impl sites call through this table directly; a null slot is 91 potential null calls, + // and the one thing a list-driven check could not catch is a slot the list forgot to name. + // + // Walked as a block of function pointers because that is exactly what the struct is - the + // static_asserts in EmitTables.cpp pin that shape - so a slot ADDED to GLFunctionsTable is + // covered here on the day it appears, without this file being edited. + const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable(); + const void* const* cells = reinterpret_cast(&table); + const SizeT cellCount = sizeof(table) / sizeof(void*); + // The struct is 71 function pointers plus ONE cell holding the packed Bool + // PrefersCpuXfbPrimitiveAccounting and its padding (EmitTables.cpp static_asserts exactly + // that shape). That Bool is legitimately zero when the server did not publish + // kCapCpuXfbPrimitiveAccounting, so at most one cell may read null - and this is stated as + // a bound rather than an index, because an index would drift the day a slot is inserted. + SizeT nullCells = 0; + for (SizeT i = 0; i < cellCount; ++i) { + if (cells[i] == nullptr) ++nullCells; + } + EXPECT_LE(nullCells, 1u) + << "a slot in the remote emit table is null (" << nullCells << " null cells out of " + << cellCount + << "). R-4 forbids it: 91 MG_Impl sites call through this table with no null check at all"; +} + +TEST(RemoteEmitTable, TheFiveEmittersAreTheOnesTheCensusMeasured) { + const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable(); + // Named, so that a table which emitted a DIFFERENT five would be red rather than merely + // counted. The census's answer is Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present. + EXPECT_NE(table.GL.Clear, nullptr); + EXPECT_NE(table.GL.DrawArrays, nullptr); + EXPECT_NE(table.GL.ReadPixels, nullptr); + EXPECT_NE(table.GL.BlitFramebuffer, nullptr); + EXPECT_NE(table.Present, nullptr); + // And the two R-15 answers them locally, so they are not the same pointer as any Fatal one. + EXPECT_NE(table.GL.GetIntegeri_v, nullptr); + EXPECT_NE(table.GL.IsTimerQuerySupported, nullptr); + EXPECT_NE(reinterpret_cast(table.GL.DrawArrays), + reinterpret_cast(table.GL.DrawElements)) + << "DrawArrays is class B and DrawElements is class C; they cannot share a thunk"; +} + +#if MGTEST_HAVE_FORK +TEST(RemoteEmitTable, AnUnmigratedSlotAbortsAndNamesItself) { + // THE DEATH TEST ON THE UnmigratedVerbFatal ARM. It asserts the exact wording, not merely + // that the child died: a control that trips on any abort is satisfied by the wrong abort, + // which is one of the three shapes R-16 was written after. + const ChildResult r = RunInChild([] { + RemoteEmitTable().GL.DrawElements(0x0004 /*GL_TRIANGLES*/, 3, 0x1405 /*GL_UNSIGNED_INT*/, + nullptr); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"DrawElements\"}"), std::string::npos) << r.Log; +} + +TEST(RemoteEmitTable, EachUnmigratedSlotNamesItsOwnSlot) { + // The half the case above cannot state on its own: that the name in the message is the + // slot's and not a constant. Two different slots, two different names. + const ChildResult r = RunInChild([] { RemoteEmitTable().GL.GenerateMipmap(0x0DE1); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"GenerateMipmap\"}"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("DrawElements"), std::string::npos) + << "the Fatal message names a slot other than the one that was called:\n" + << r.Log; +} + +TEST(RemoteEmitTable, SetSwapIntervalIsClassCAndSaysSo) { + // The slot the verb census found by NOT mirroring GLImpl: SetSwapInterval has zero MG_Impl + // call sites and is reached only through the EGL path, so a table built from the 89 GLImpl + // sites would have left it null. + const ChildResult r = RunInChild([] { RemoteEmitTable().SetSwapInterval(1); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"SetSwapInterval\"}"), std::string::npos) << r.Log; +} + +TEST(RemoteEmitTable, AClassBSlotWithNoSessionAbortsRatherThanFallingThrough) { + // The other half of "no slot may fall through to the driver". With no ClientSession the + // emitter has nowhere to put the record, and the one thing it may not do is return quietly: + // that is the split lane running monolith and going green. + const ChildResult r = RunInChild([] { RemoteEmitTable().GL.Clear(0x4000 /*COLOR_BUFFER_BIT*/); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{NoClientSession, \"Clear\"}"), std::string::npos) << r.Log; +} +#endif // MGTEST_HAVE_FORK + +// ===================================================================================== +// The caps mirror: the three read paths the acceptance names +// ===================================================================================== + +TEST(CapsMirrorTest, GlGetStringReadsTheRendererStringsBackOutOfTheMirror) { + // glGetString's path is GL_Getter.cpp:596 -> pActiveBackendObject->GetRendererInfo(), which + // BackendObject_Remote answers from this mirror BY REFERENCE - so the test reads the + // reference, holds it across a second adoption, and requires it to follow. A mirror that + // handed back a temporary would pass an equality check and dangle here. + const Snapshot first = MakeSnapshot(kMGPipeSubsystemResources, 0); + AdoptSnapshot(first); + const RendererInfo& bound = CapsMirrorInstance().Renderer(); + EXPECT_EQ(bound.RendererName, "MobileGL Remote Test Renderer"); + EXPECT_EQ(bound.BackendName, "Espryt"); + ASSERT_TRUE(bound.ExtraVendor.has_value()); + EXPECT_EQ(*bound.ExtraVendor, "c1"); + + Snapshot second = MakeSnapshot(kMGPipeSubsystemResources, 0); + second.Renderer.RendererName = "A Different Device"; + AdoptSnapshot(second); + EXPECT_EQ(bound.RendererName, "A Different Device") + << "GetRendererInfo() returns a reference, so a re-arrival must be visible through a " + "reference a caller already holds"; +} + +TEST(CapsMirrorTest, GlGetIntegervReadsTheDynamicParametersBackOutOfTheMirror) { + // glGetIntegerv's limit family is GL_Getter.cpp:2400 -> GetDynamicParameters(), which binds + // a reference and then reads many members - which is why a partial snapshot is not an + // option and why the whole struct crosses. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + const MG_Backend::DynamicBackendParameters& dynamic = CapsMirrorInstance().Dynamic(); + EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[0], 65531); + EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[2], 65533); + EXPECT_EQ(dynamic.MaxComputeWorkGroupSize[1], 1022); + EXPECT_EQ(dynamic.UniformBufferOffsetAlignment, 64u); +} + +TEST(CapsMirrorTest, GlGetStringiReadsTheAdvertisedExtensionListBackOutOfTheMirror) { + // glGetStringi(GL_EXTENSIONS) is GL_Getter.cpp:654 -> GetRendererInfo().RendererGLInfo, the + // same list CompileEnv.cpp:124 copies into the compile env. + Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0); + s.Renderer.RendererGLInfo.Extensions.push_back(E_GL_ARB_timer_query); + AdoptSnapshot(s); + const auto& extensions = CapsMirrorInstance().Renderer().RendererGLInfo.Extensions; + ASSERT_EQ(extensions.size(), 1u); + EXPECT_EQ(extensions[0], E_GL_ARB_timer_query); + EXPECT_EQ(CapsMirrorInstance().Renderer().RendererGLInfo.TargetGLVersion.Major, 4); +} + +TEST(CapsMirrorTest, ReArrivalIsTheInvalidationAndMovesTheGeneration) { + // R-12 has no Invalidate(), so Generation() is the ONLY thing on the client that can see a + // server context death - and a client memo has to key on it. + const Uint64 before = CapsMirrorInstance().Generation(); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + const Uint64 after = CapsMirrorInstance().Generation(); + EXPECT_EQ(after, before + 1); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + EXPECT_EQ(CapsMirrorInstance().Generation(), after + 1); + EXPECT_TRUE(CapsMirrorInstance().Valid()); +} + +TEST(CapsMirrorTest, TheBackendTypeIsTheServersAndNeverANewEnumerator) { + Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0); + s.Backend = BackendType::DirectVulkan; + AdoptSnapshot(s); + EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectVulkan); + s.Backend = BackendType::DirectGLES; + AdoptSnapshot(s); + EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectGLES); +} + +TEST(CapsMirrorTest, ThePrefersCpuXfbAnswerComesFromTheCapBitAndNotFromTheTable) { + // GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting is a member of the FUNCTION TABLE, + // which is exactly the thing a split client never receives - so it cannot ride in + // MGPCaps::Dynamic and must come from kCapCpuXfbPrimitiveAccounting. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + EXPECT_FALSE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting()); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, kCapCpuXfbPrimitiveAccounting)); + EXPECT_TRUE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting()); +} + +// ===================================================================================== +// R-8: the liveness gates read the caps mirror, and a family with no consumer says so +// ===================================================================================== + +TEST(CapsMirrorTest, AMaskWithoutAFamilyRefusesItAndNamesIt) { + // R-8's NEGATIVE CONTROL. "The client emits nothing for a family the server does not + // consume" is, on its own, indistinguishable from "nothing called it" - so the refusal is + // COUNTED at the one funnel that answers the question, and the count is what this asserts. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemPrograms, 0)); + ResetConsumerRefusalsForTest(); + + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_EQ(ConsumerRefusals(), 0u) << "a family the server DOES consume must not be counted " + "as refused"; + + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_EQ(ConsumerRefusals(), 1u); + EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemResources) + << "the refusal must name the family; a counter that only says 'something was withheld' " + "cannot tell five silent families apart"; + + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemTextureResources)); + EXPECT_EQ(ConsumerRefusals(), 2u); + EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemTextureResources); +} + +TEST(CapsMirrorTest, APlaceholderMirrorConsumesNothing) { + // The safe direction, stated as a case. With no snapshot the mask is zero, every family + // answers "no consumer", the client emits nothing and the legacy pull path runs. The unsafe + // direction - emitting to a server that has no consumer - is ID-39's 66 lost uploads. + MGPCaps empty{}; + CapsMirror mirror; + EXPECT_FALSE(mirror.Valid()); + EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_FALSE(mirror.HasCap(kCapResidentSubData)); + (void)empty; +} + +TEST(CapsMirrorTest, TheConsumerBlockDoesNotCollideWithTheFeatureBits) { + // The two halves of CallMask, asserted against each other rather than against a constant: + // bits 0..8 are MGPCapBit and bits 32..47 are the consumer mask, and the whole reason R-8 + // became implementable is that they do not overlap. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources | kMGPipeSubsystemPrograms, + kCapTimerQuery | kCapOcclusionQuery)); + EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapTimerQuery)); + EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapOcclusionQuery)); + EXPECT_FALSE(CapsMirrorInstance().HasCap(kCapXfbPrimitivesQuery)); + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemSamplers)); +} + +// ===================================================================================== +// E2's emitter-drop control: the switch itself, driven through the real emitter's own counter +// ===================================================================================== + +TEST(RemoteEmitTable, TheE2DropSwitchStartsDisarmed) { + // The half a unit case can state. E2's statement - "drop one Clear emission and OpenRA's + // SSIM falls below 0.99" - is a TRACE LANE's, because the picture is the thing it is about; + // what belongs here is that the control is off unless someone armed it, so a lane that + // forgot to disarm cannot look like a lane that was never armed. + EXPECT_EQ(DroppedClearEmissions(), 0u); + SetDropClearEmissionForNegativeControl(true); + SetDropClearEmissionForNegativeControl(false); + EXPECT_EQ(DroppedClearEmissions(), 0u) + << "arming and disarming the control must not, by itself, drop anything"; +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-remoteclient-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +}