[Feat] (MG_Remote, P5): land the client role - BackendObject_Remote, the 71-slot emit table, the caps mirror and the verb barrier

This commit is contained in:
2026-09-11 16:46:24 -04:00
parent bfaf5f9d0e
commit d628d906d1
18 changed files with 2235 additions and 71 deletions
+3
View File
@@ -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.
+25 -2
View File
@@ -31,6 +31,11 @@
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Backend/BackendObjects.h>
#include <MG_Impl/Pipe/PipeFill.h>
// 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 <MG_Remote/Client/SlotCaps.h>
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();
}
+17 -6
View File
@@ -13,6 +13,10 @@
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
#include <MG_Impl/Pipe/PipeFill.h>
// 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 <MG_Remote/Client/SlotCaps.h>
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;
}
+10 -1
View File
@@ -10,6 +10,11 @@
#include <MG_Backend/BackendObjects.h>
#include <MG_State/GLState/Core.h>
#include <MG_Impl/Pipe/PipeFill.h>
// 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 <MG_Remote/Client/SlotCaps.h>
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();
}
@@ -31,6 +31,11 @@
#include <MG_Util/Math/FixedPointConversion.h>
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
#include <MG_Impl/Pipe/PipeFill.h>
// 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 <MG_Remote/Client/SlotCaps.h>
// 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 <MG_Pipe/PipeMutation.h>
@@ -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;
}
+52 -4
View File
@@ -39,6 +39,15 @@
#include <MG_Pipe/PipeMutation.h>
#include <Config.h>
#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 <MG_Remote/Client/CapsMirror.h>
#endif
#include <atomic>
#include <cstdlib>
#include <cstring>
@@ -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;
}
// ================================================================================
@@ -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 <MG_Util/Debug/Log.h>
// 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<BackendObject>& 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<DisplayArgs*>(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<WindowSurfaceArgs*>(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<ResizeArgs*>(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<PbufferArgs*>(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<MakeCurrentArgs*>(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<SwapIntervalArgs*>(user)->Interval);
return MOBILEGL_OK;
}
struct SurfaceArgs {
EGLSurface Surface;
};
MobileGLResult RunReleaseSurface(void* user) {
ServerBackend()->ReleaseEGLSurface(static_cast<SurfaceArgs*>(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<BackendObject_Remote*>(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<unsigned long long>(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
@@ -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 <Includes.h>
#include <MG_Backend/BackendObject.h>
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
+137 -27
View File
@@ -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 <MG_Util/Debug/Log.h>
#include "../CapsCodec.h"
#include <cstdlib>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Debug/Log.h>
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<unsigned long long>(m_generation), static_cast<int>(m_backend),
static_cast<unsigned long long>(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<Uint64>(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<unsigned long long>(subsystemBit),
static_cast<unsigned long long>(m_caps.CallMask),
static_cast<unsigned long long>(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
+24
View File
@@ -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
+368 -24
View File
@@ -18,9 +18,12 @@
#include "../Transport/InProcessTransport.h"
#include <MGGitHash.h>
#include <MG_Pipe/MGPipeCallbacks.h>
#include <MG_Util/Debug/Log.h>
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <vector>
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<Bool> 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<const Transport::EventBufferWritebackHead*>(view.payload);
const void* bytes = static_cast<const Uint8*>(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<unsigned long long>(head->Size),
static_cast<unsigned long long>(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<const Transport::EventGpuWrittenHead*>(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<unsigned>(head->RangeCount),
static_cast<unsigned long long>(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<const MG_Pipe::MGPRange*>(
static_cast<const Uint8*>(view.payload) + sizeof(*head));
MG_Pipe::gMGPipeCallbacks.OnGpuWritten(
MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen},
static_cast<Uint>(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<const Transport::EventSurfaceChangedHead*>(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<unsigned>(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<unsigned long long>(dynamicBytes == nullptr ? 0
: dynamicBytes->size()),
static_cast<unsigned long long>(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<Uint32>(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<unsigned>(rawBackend));
return false;
}
CapsMirrorInstance().Adopt(caps, formats, renderer, apiVersion,
static_cast<BackendType>(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<Uint8> 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<unsigned long long>(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<unsigned long long>(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<Uint32>(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<unsigned long long>(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<unsigned long long>(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<unsigned long long>(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<unsigned long long>(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<unsigned long long>(replySize),
static_cast<unsigned long long>(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<Uint8> 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<unsigned long long>(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<int>(envelope->msg_type()));
continue;
}
if (AdoptCapsSnapshot(envelope->msg_as_CapsSnapshot())) ++adopted;
}
return adopted;
}
Transport::SessionProducer& ClientSession::Producer() { return m_producer; }
+30
View File
@@ -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
+494 -5
View File
@@ -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, "<slot>"}
// 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 <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Debug/Log.h>
#include <MG_Util/Metrics/TextureMetrics.h>
#include <MG_State/GLState/Core.h>
#include <cstdlib>
#include <cstring>
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<Uint32>(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<Uint32>(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<Uint32>(first), static_cast<Uint32>(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<Uint32>(mask);
record.Filter = static_cast<Uint32>(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<unsigned>(format), static_cast<unsigned>(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<Uint64>(pack.RowLength) : static_cast<Uint64>(width);
const Uint64 alignment = pack.Alignment > 0 ? static_cast<Uint64>(pack.Alignment) : 1ull;
const Uint64 rowBytes = rowPixels * static_cast<Uint64>(bytesPerPixel);
const Uint64 paddedRow = ((rowBytes + alignment - 1) / alignment) * alignment;
const Uint64 lastRow = static_cast<Uint64>(width) * static_cast<Uint64>(bytesPerPixel);
return paddedRow * (static_cast<Uint64>(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<Uint32>(width),
static_cast<Uint32>(height), 1};
info.Format = static_cast<Uint32>(format);
info.Type = static_cast<Uint32>(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<unsigned long long>(bytes),
static_cast<unsigned long long>(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<GLint>(dynamic.MaxComputeWorkGroupCount[index]);
return;
case GL_MAX_COMPUTE_WORK_GROUP_SIZE:
if (index < 3) *data = static_cast<GLint>(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<unsigned>(target), static_cast<unsigned>(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<MG_State::GLState::FramebufferObject>&, GLenum, GLint, const GLfloat*)) \
X(ClearNamedFramebufferfi, void, \
(const SharedPtr<MG_State::GLState::FramebufferObject>&, GLenum, GLint, GLfloat, GLint)) \
X(ClearNamedFramebufferiv, void, \
(const SharedPtr<MG_State::GLState::FramebufferObject>&, GLenum, GLint, const GLint*)) \
X(ClearNamedFramebufferuiv, void, \
(const SharedPtr<MG_State::GLState::FramebufferObject>&, GLenum, GLint, const GLuint*)) \
X(BlitNamedFramebuffer, void, \
(const SharedPtr<MG_State::GLState::FramebufferObject>&, \
const SharedPtr<MG_State::GLState::FramebufferObject>&, 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<MG_State::GLState::ITextureObject>&, 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
+37
View File
@@ -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
+135
View File
@@ -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, "<slot>"} 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 <Includes.h>
#include <Config.h>
#if MOBILEGL_BUILD_DISAGGREGATED
#include <MG_Pipe/MGPipeTypes.h>
#include <MG_Remote/Client/CapsMirror.h>
#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
+22
View File
@@ -41,6 +41,13 @@
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/Debug/Log.h>
#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 <MG_Remote/CapsCodec.h>
#include <MG_Remote/Client/CapsMirror.h>
#endif
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/Types.h>
#include <Config.h>
@@ -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
+27
View File
@@ -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)
+419
View File
@@ -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 <gtest/gtest.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "Includes.h"
#include <Config.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Client/CapsMirror.h>
#include <MG_Remote/Client/EmitTables.h>
#if !defined(_WIN32)
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#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<int>(::_getpid());
#else
return static_cast<int>(::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 <class Body>
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<const void* const*>(&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<const void*>(table.GL.DrawArrays),
reinterpret_cast<const void*>(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;
}