Files
MobileGL/MobileGL/MG_Remote/Server/PipeApplier.cpp
T

1660 lines
99 KiB
C++

// MobileGL - MobileGL/MG_Remote/Server/PipeApplier.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 v1: the applier bridge, and the consumer for contract 7's five class-B verbs.
#include "PipeApplier.h"
#include <MG_Remote/FatalFunnel.h>
#include "ServerSession.h"
#include "../Transport/ReplySlot.h"
#include "StagedTextureStore.h"
#include <Config.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
// P5c ct: object_death's per-kind release names the Espryt twin tables (CONTRACT-P5C.md
// §5.2). The same dependency ServerLoop.cpp already takes for CreateBackend; a server built
// on Magma simply holds no twins in these tables and every release resolves to nothing.
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectGLES/DirectGLES.h>
#include <MG_Backend/DirectVulkan/DirectVulkan.h>
#include <MG_Remote/Client/ClientSession.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Debug/Log.h>
#include <MG_Util/Metrics/TextureMetrics.h>
#include <cstdlib>
#include <cstring>
#include <limits>
namespace MobileGL::MG_Remote::Server {
// P5e (ra, CONTRACT-P5E §1 / §6): DOES THIS SERVER PUBLISH kCapRunAheadApply? The
// question is asked of the server's OWN CallMask and not of a build constant, because
// "the client may run ahead" is exactly what that bit says and Magma never sets it.
// A session with no CallMask yet (the bring-up window, a fixture that never called
// SetCapabilityBits) answers false: no client can have latched run-ahead against a
// snapshot that was never published.
//
// P5e (gl, ID-111): PROMOTED OUT OF THE ANONYMOUS NAMESPACE, unchanged in body. It is now
// the second conjunct of the barriered stamp as well as Present's frame-serial gate, and
// the red-once has to be able to assert what it answers for the session it built.
Bool MGPipeServerPublishesRunAhead() {
const ServerSession* session = ServerSession::Active();
if (session == nullptr || !session->CallMaskIsSet()) return false;
return (session->CallMask() & static_cast<Uint64>(MG_Pipe::kCapRunAheadApply)) != 0;
}
ReplyPool::ReplyPool(void* base, Uint64 sizeBytes, Uint32 slotCount, Uint32 slotBytes)
: m_base(static_cast<Uint8*>(base)), m_size(sizeBytes), m_slots(slotCount), m_slotBytes(slotBytes) {}
// PACKAGE s1's, not v1's, even though the class is declared in v1's header: the SEG_REPLY
// slot pool is s1's deliverable (BRIEF 5) and its addressing lives in one place,
// Transport/ReplySlot.h, which the CLIENT reads the same slots back through. Duplicating
// `seq % slots` on this side is how the two halves come to disagree about which slot an
// answer is in - and because seq IS the reply-slot id (R-3), a disagreement reads another
// call's answer instead of failing.
//
// The view is rebuilt per call rather than stored, so that this body does not change
// ReplyPool's four members and therefore does not touch v1's header at all.
void ReplyPool::PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) {
if (m_link) {
const auto result = m_link->PostReply(seq, status, bytes, size);
if (result == MOBILEGL_OK) {
++m_posted;
return;
}
++m_failed;
if (result == MOBILEGL_ERR_BUFFER_TOO_SMALL)
SessionFail(MGFatalFamily::ReplyTooLarge, "MGPipe: Fatal{ReplyTooLarge, stream reply}");
// EVERY OTHER FAILURE IS NAMED AND COUNTED RATHER THAN SWALLOWED (P12). This used to
// `return` on anything that was not BUFFER_TOO_SMALL, so a TRANSPORT_CLOSED reply
// vanished with no line, no counter and no Fatal - and the client's only symptom is a
// deferred answer that never arrives, which is indistinguishable from a buffer that
// overwrote it. Once per call site rather than once per reply: a broken link fails
// every time, and a log per answer would drown the run it is there to explain.
MGLOG_E_ONCE("MGPipe: reply seq %llu was NOT delivered (%d); the client will not read"
" this answer. FailedReplies() counts them",
static_cast<unsigned long long>(seq), static_cast<int>(result));
return;
}
Transport::ReplySlotPool pool(m_base, m_size, m_slots);
// Fatal inside Post when the answer does not fit a slot: P5 does not chunk replies,
// and the client knows an answer's size before it emits the record.
pool.Post(seq, status, bytes, size);
++m_posted;
}
Uint32 ReplyPool::SlotBytes() const { return m_slotBytes; }
// -----------------------------------------------------------------------------------
// ServerVerbSink - the five class-B verbs
// -----------------------------------------------------------------------------------
void ServerVerbSink::SetBackend(MG_Backend::BackendObject* backend) {
if (m_backend != backend) {
ReleaseQueries();
ReleaseFences();
}
m_backend = backend;
if (backend != nullptr) {
const auto& limits = backend->GetDynamicParameters();
ServerStagedTexture().SetDeviceLimits(
limits.MaxTextureSize, limits.Max3DTextureSize, limits.MaxCubeMapTextureSize,
limits.MaxArrayTextureLayers, limits.MaxTextureBufferSize);
}
}
const MG_Backend::GlobalBackendFunctionsTable* ServerVerbSink::Table(const char* verb) const {
if (m_backend == nullptr) {
// DECLINE BY NAME, DO NOT DEREFERENCE. A verb that arrives before
// ServerLoop::CreateBackend has run means the hook order changed under us, and the
// honest answer is "this build did not apply it" - which DecodeAndApply reports as
// false and the lane sees as a record that did not render, rather than as a crash
// with no line saying which verb was first.
MGLOG_E_ONCE("MG_Remote server: %s arrived with no backend object; the verb is "
"DECLINED. ServerLoop::CreateBackend runs from MG_Backend::Init()'s "
"hook, before ClientSession::Start",
verb);
return nullptr;
}
return &m_backend->GetBackendFunctions();
}
ServerVerbSink::FenceEntry& ServerVerbSink::FindFence(MG_Pipe::MGPipeHandle handle) {
const auto it = m_fences.find(handle.Slot);
if (handle.Slot == 0 || it == m_fences.end() ||
!it->second.Live || it->second.Gen != handle.Gen) {
Wire::WireProtocolFatal("Fence.handle", "missing, destroyed or stale fence handle");
}
return it->second;
}
Bool ServerVerbSink::OnFenceCreate(const MG_Pipe::MGPHandleOnly& desc) {
if (desc.Kind != static_cast<Uint32>(MG_Pipe::MGPipeKind::Fence))
Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace");
const auto* table = Table("FenceCreate");
if (table == nullptr) return false;
const auto handle = desc.Handle;
if (handle.Slot == 0) {
Wire::WireProtocolFatal("FenceCreate.handle", "reserved fence handle");
}
const Bool seen = m_fences.find(handle.Slot) != m_fences.end();
auto& entry = m_fences[handle.Slot];
if (entry.Live || (seen && handle.Gen <= entry.Gen)) {
Wire::WireProtocolFatal("FenceCreate.handle", "duplicate or stale fence generation");
}
entry.Gen = handle.Gen;
entry.Live = true;
// GL_Sync.cpp treats an absent slot or a null creation result as always signaled.
entry.Native = table->GL.FenceSync == nullptr ? nullptr : table->GL.FenceSync();
// P10: in creation order, for ReportSignaledFences - the order the GPU finishes them in.
m_unreportedFences.push_back(handle);
return true;
}
Bool ServerVerbSink::OnFenceDestroy(const MG_Pipe::MGPHandleOnly& desc) {
if (desc.Kind != static_cast<Uint32>(MG_Pipe::MGPipeKind::Fence))
Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace");
auto& entry = FindFence(desc.Handle);
const auto* table = Table("FenceDestroy");
if (table == nullptr) return false;
if (entry.Native != nullptr && table->GL.DeleteSync != nullptr) table->GL.DeleteSync(entry.Native);
entry.Native = nullptr;
entry.Live = false;
return true;
}
Bool ServerVerbSink::OnFenceStatus(const MG_Pipe::MGPHandleOnly& desc, Uint32& result) {
if (desc.Kind != static_cast<Uint32>(MG_Pipe::MGPipeKind::Fence))
Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace");
auto& entry = FindFence(desc.Handle);
const auto* table = Table("FenceStatus");
if (table == nullptr) return false;
result = entry.Native == nullptr || table->GL.GetSyncStatus == nullptr ||
table->GL.GetSyncStatus(entry.Native);
return true;
}
Bool ServerVerbSink::OnFenceWait(const MG_Pipe::MGPFenceWait& request, Uint32& result) {
if ((request.Flags & ~static_cast<Uint32>(GL_SYNC_FLUSH_COMMANDS_BIT)) != 0) {
Wire::WireProtocolFatal("FenceWait.Flags", "unknown client-wait flag");
}
auto& entry = FindFence(request.Fence);
const auto* table = Table("FenceWait");
if (table == nullptr) return false;
result = entry.Native == nullptr || table->GL.ClientWaitSync == nullptr
? GL_ALREADY_SIGNALED
: table->GL.ClientWaitSync(entry.Native, request.Flags, request.TimeoutNs);
return true;
}
Bool ServerVerbSink::OnFenceWaitServer(const MG_Pipe::MGPFenceWait& request) {
if (request.Flags != 0 || request.TimeoutNs != GL_TIMEOUT_IGNORED) {
Wire::WireProtocolFatal("FenceWaitServer.arguments", "invalid server wait arguments");
}
auto& entry = FindFence(request.Fence);
const auto* table = Table("FenceWaitServer");
if (table == nullptr) return false;
if (entry.Native != nullptr && table->GL.WaitSync != nullptr)
table->GL.WaitSync(entry.Native, request.Flags, request.TimeoutNs);
return true;
}
void ServerVerbSink::ReleaseFences() {
// Detach runs on the apply thread before its private backend/context is destroyed.
if (m_backend != nullptr) {
const auto destroy = m_backend->GetBackendFunctions().GL.DeleteSync;
if (destroy != nullptr) {
for (auto& [slot, entry] : m_fences)
if (entry.Live && entry.Native != nullptr) destroy(entry.Native);
}
}
m_fences.clear();
m_unreportedFences.clear();
}
// P10 (CONTRACT-P10.md §1): WHICH OF THE CLIENT'S FENCES HAS THE GPU FINISHED.
//
// The client answers a poll of a fence it knows is signaled without asking - that is the
// whole of package A - and it learns what is signaled only from here. Walked in CREATION
// order and stopped at the first one still pending: one context's GPU work completes in
// the order it was submitted, so a later fence cannot be done while an earlier one is not,
// and the steady-state cost is one status query per drain batch. (Were a backend ever to
// finish them out of order, the later fence would only be reported late - an answer the
// client then gets by its own round trip - never early.)
//
// `flush`: the apply thread is about to go idle. A fence whose commands the driver still
// holds never signals on its own, and an idle server submits nothing more, so the first
// pending fence is asked through ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0) - the one
// backend-neutral way both backends take to submit what they hold - instead of the plain
// status query. Only when idle: mid-stream the next records flush soon enough, and a
// submit per batch would cost Magma its command-buffer batching.
Uint32 ServerVerbSink::ReportSignaledFences(Bool flush) {
if (m_unreportedFences.empty()) return 0;
ServerSession* session = ServerSession::Active();
if (session == nullptr || m_backend == nullptr) return 0;
const MG_Backend::GLFunctionsTable& gl = m_backend->GetBackendFunctions().GL;
Uint32 reported = 0;
while (!m_unreportedFences.empty()) {
const MG_Pipe::MGPipeHandle handle = m_unreportedFences.front();
const auto it = m_fences.find(handle.Slot);
if (it == m_fences.end() || !it->second.Live || it->second.Gen != handle.Gen) {
// Deleted before it was seen signaled: nobody can poll it any more.
m_unreportedFences.pop_front();
continue;
}
const FenceEntry& entry = it->second;
Bool signaled = entry.Native == nullptr;
if (!signaled) {
if (flush && gl.ClientWaitSync != nullptr) {
const GLenum status = gl.ClientWaitSync(entry.Native, GL_SYNC_FLUSH_COMMANDS_BIT, 0);
signaled = status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED;
flush = false; // one submit is all an idle server owes
++m_fenceIdleFlushes;
} else {
signaled = gl.GetSyncStatus == nullptr || gl.GetSyncStatus(entry.Native);
}
}
if (!signaled) break;
m_unreportedFences.pop_front();
session->PostFenceSignaled(handle);
++reported;
}
m_fencesReported += reported;
return reported;
}
#include "QueryServer.inc"
Bool ServerVerbSink::OnClear(const MG_Pipe::MGPClear& clear) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("clear");
if (table == nullptr) return false;
const MG_Backend::GLFunctionsTable& gl = table->GL;
// The named framebuffer record already crossed before this verb. Scope
// only the server's resolved draw target; the next verb still observes
// the application's unchanged binding. Both backends sync from this
// handle and never need the frontend FramebufferObject.
auto& applier = MG_Pipe::MGPipeApplier();
struct ScopedClearTarget {
MG_Pipe::MGPipeApplierState& State;
MG_Pipe::MGPipeHandle Saved;
~ScopedClearTarget() { State.BoundFramebuffer[0] = Saved; }
} clearTarget{applier, applier.BoundFramebuffer[0]};
if (!MG_Pipe::MGPipeHandleIsNull(clear.Fbo)) {
if (!applier.FramebufferRecordFor(clear.Fbo))
Wire::WireProtocolFatal("Clear.Fbo", "missing named framebuffer record");
applier.BoundFramebuffer[0] = clear.Fbo;
}
switch (clear.Kind) {
case kMGPClearKindWhole:
if (gl.Clear == nullptr) return false;
gl.Clear(static_cast<GLbitfield>(clear.BufferMask));
break;
case kMGPClearKindColor:
switch (clear.ValueClass) {
case kMGPClearValueClassFloat:
if (gl.ClearBufferfv == nullptr) return false;
gl.ClearBufferfv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLfloat*>(clear.ColorValue));
break;
case kMGPClearValueClassInt:
if (gl.ClearBufferiv == nullptr) return false;
gl.ClearBufferiv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLint*>(clear.ColorValue));
break;
case kMGPClearValueClassUint:
if (gl.ClearBufferuiv == nullptr) return false;
gl.ClearBufferuiv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLuint*>(clear.ColorValue));
break;
default:
// A value class outside the three is a wire fault, not a fallback: all three
// representations of a clear colour are numerically populated by the frontend
// and only this field says which one the backend must use, so guessing renders
// a plausible wrong colour.
Wire::WireProtocolFatalAt("MGPClear::ValueClass", clear.ValueClass, 3);
}
break;
case kMGPClearKindDepth:
if (gl.ClearBufferfv == nullptr) return false;
gl.ClearBufferfv(GL_DEPTH, 0, &clear.DepthValue);
break;
case kMGPClearKindStencil:
if (gl.ClearBufferiv == nullptr) return false;
gl.ClearBufferiv(GL_STENCIL, 0, &clear.StencilValue);
break;
case kMGPClearKindDepthStencil:
if (gl.ClearBufferfi == nullptr) return false;
gl.ClearBufferfi(GL_DEPTH_STENCIL, 0, clear.DepthValue, clear.StencilValue);
break;
default:
Wire::WireProtocolFatalAt("MGPClear::Kind", clear.Kind, kMGPClearKindDepthStencil + 1);
}
++m_clears;
return true;
}
Bool ServerVerbSink::OnBlit(const MG_Pipe::MGPBlit& blit) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("blit");
if (table == nullptr) return false;
if (table->GL.BlitFramebuffer == nullptr) return false;
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.3): the record's handles cross to the backend as the verb's
// own state. The bound form carries two nulls and nothing changes; the named form's
// pair is what the backend's named-blit arm resolves - the sink no longer relies on
// "the read and draw framebuffers are already bound" (the client's ScopedBlitBindings
// staging is deleted with this), and a backend that does not consume the pair has no
// named arm, which is a loud decline rather than a blit of whatever is bound.
auto& applierState = MG_Pipe::MGPipeApplier();
applierState.ClearVerbHandles();
applierState.VerbBlitReadFbo = blit.ReadFbo;
applierState.VerbBlitDrawFbo = blit.DrawFbo;
const Bool named = !MG_Pipe::MGPipeHandleIsNull(blit.ReadFbo) ||
!MG_Pipe::MGPipeHandleIsNull(blit.DrawFbo);
#endif
table->GL.BlitFramebuffer(blit.SrcX0, blit.SrcY0, blit.SrcX1, blit.SrcY1, blit.DstX0,
blit.DstY0, blit.DstX1, blit.DstY1,
static_cast<GLbitfield>(blit.Mask),
static_cast<GLenum>(blit.Filter));
#if MOBILEGL_BUILD_DISAGGREGATED
if (named && !applierState.VerbBlitNamedConsumed) {
MGLOG_E_ONCE("MGPipe: a named blit (read {%u, %u}, draw {%u, %u}) reached a backend "
"with no named-blit arm; the verb is DECLINED rather than applied to "
"the bound framebuffers",
blit.ReadFbo.Slot, blit.ReadFbo.Gen, blit.DrawFbo.Slot, blit.DrawFbo.Gen);
applierState.VerbBlitReadFbo = MG_Pipe::kMGPipeNullHandle;
applierState.VerbBlitDrawFbo = MG_Pipe::kMGPipeNullHandle;
return false;
}
#endif
++m_blits;
return true;
}
Bool ServerVerbSink::OnPresent(const MG_Pipe::MGPPresent& present) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("present");
if (table == nullptr) return false;
if (table->Present == nullptr) return false;
// Present is the ONLY frame-boundary drain the backend has (DirectGLES.cpp:12424-12470:
// the fence poll, the four ring OnPresent hooks, TrimBufferPool, PipeStats::OnPresent),
// which is why ARCHITECTURE.md:531 wants present <-> eglSwapBuffers to stay 1:1.
//
// m-1: THAT 1:1 IS A CONVENTION c1 UPHOLDS, NOT A STRUCTURAL GUARANTEE, and the earlier
// claim that it was structural is wrong. This Present() is reached ONLY from a present
// RECORD (ServerVerbSink::OnPresent). ServerSwapEGLBuffers does NOT reach it - it calls
// backend->SwapEGLBuffers -> BackendObject::SwapEGLBuffers -> eglSwapBuffers, and never
// Present() - so the two paths do NOT both end here. The frame count staying in step with
// the swap count rests entirely on c1 emitting exactly one present record per swap;
// nothing here compares Presents() to a swap count. If that drifts, the frame fence and
// TrimBufferPool's recycle watermark stop tracking frames - which is the reason the 1:1
// was wanted, recorded here so a future swap-without-present is looked for rather than
// assumed impossible. Presents() is exposed for a lane that wants to make the comparison.
table->Present();
++m_presents;
// FrameSerial 0 means "the server stamps its own" (c1-v1 8.3): P5 has no client-side
// present credit, so the client sends 0 and the frame count on this side IS the serial.
//
// P5e (ra, CONTRACT-P5E §1, §2.4): IT IS THE CLIENT'S NOW, 1-BASED AND MINTED BY THE
// PAYER. A credit can only be paced in an id space the waiter advances, and the waiter
// is the client (`WaitForPresentAck(m_presentsSent + 1 - credit)`), so a server-stamped
// serial would be the server acknowledging its own count. The 0 arm survives for a
// peer that has not been re-built - and on a server that PUBLISHES the run-ahead cap it
// is a protocol fault, because such a client is pacing on this answer and a 0 would
// acknowledge a frame nobody asked about.
if (present.FrameSerial == 0 && MGPipeServerPublishesRunAhead()) {
// PH-1 (3): latches in an armed session child - no credit is returned for it, the
// session closes and the peer's credit wait ends on the hang-up.
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"Present.FrameSerial\"} - a run-ahead "
"server was handed present serial 0. The client mints this 1-based and "
"waits on it for its credit (CONTRACT-P5E §2.4); returning a credit for "
"serial 0 would release a wait that is asking about frame N");
}
m_lastPresentSerial = present.FrameSerial != 0 ? present.FrameSerial : m_presents;
// §2.4's other half, and the reason ServerSession::ReturnPresentCredit has had no
// production caller since v1 wrote it: ONE CREDIT PER SWAP, returned after Present()
// has returned rather than before it, because what the client is waiting for is the
// swap and not the record's apply. It advances presentAckSerial AND rings the client's
// bell - a client parked in WaitForPresentAck(kWaitForever) needs the pair.
if (ServerSession* session = ServerSession::Active()) {
// P10 (CONTRACT-P10.md §1): the fences finished by now are reported BEFORE the credit
// goes back - a client released by the credit polls or waits on the fence of a frame
// or two ago at once, and a report posted only after this batch would race it. A
// status query, no submit: the swap has just submitted.
if (session->EventRingHasRoom()) ReportSignaledFences(/*flush=*/false);
session->ReturnPresentCredit(m_lastPresentSerial);
}
return true;
}
Bool ServerVerbSink::OnGetTextureImage(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq,
Wire::ReplySink* replies) {
if (!replies) Wire::WireProtocolFatal("GetTextureImage.reply", "missing reply sink");
const SizeT bpp = MG_Util::GetInputBytesPerPixel(
MG_Util::ConvertGLEnumToTextureInputFormat(info.Format),
MG_Util::ConvertGLEnumToTexturePixelDataType(info.Type));
if (!bpp || !info.Box.W || !info.Box.H || !info.Box.D || info.Box.X || info.Box.Y || info.Box.Z) {
Wire::WireProtocolFatal("GetTextureImage.extent", "invalid tight image extent");
}
const Uint64 tight = static_cast<Uint64>(info.Box.W) * info.Box.H * info.Box.D * bpp;
if (tight / bpp / info.Box.W / info.Box.H != info.Box.D ||
info.DstOffset > tight || !info.DstSize || info.DstSize > tight - info.DstOffset)
Wire::WireProtocolFatal("GetTextureImage.range", "invalid reply window");
auto image = info;
image.DstOffset = 0;
image.DstSize = tight;
Vector<Uint8> bytes;
const Bool ok = ReadTextureImageTight(image, bytes);
if (!ok || bytes.size() != tight) {
replies->PostReply(seq, Wire::ReplySink::kStatusError, nullptr, 0);
return false;
}
replies->PostReply(seq, Wire::ReplySink::kStatusOk, bytes.data() + info.DstOffset, info.DstSize);
m_readbackBytes += info.DstSize;
++m_readbacks;
return true;
}
Bool ServerVerbSink::OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq,
Wire::ReplySink* replies) {
if (replies == nullptr) {
// The decoder always passes its ReplySink; a null one means the applier was built
// without a reply pool, and answering nothing would leave the client's barrier
// waiting for a slot that never gets stamped - a hang, not a wrong picture.
SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"read_pixels without a reply sink\"} - "
"the pixels' only destination in P5 is SEG_REPLY (contract table 1 row 23) "
"and a client blocked on seq %llu would never be answered",
static_cast<unsigned long long>(seq));
}
const SizeT bytesPerPixel = MG_Util::GetInputBytesPerPixel(
MG_Util::ConvertGLEnumToTextureInputFormat(static_cast<GLenum>(info.Format)),
MG_Util::ConvertGLEnumToTexturePixelDataType(static_cast<GLenum>(info.Type)));
// The server's LinkTerms.maxReplyBytes is copied from its attached ILink capabilities.
// Reject malformed shapes and oversized replies before growing m_readbackScratch. The
// client-side guard is not a trust boundary: a peer can write this record directly.
const auto rejectReadback = [&] {
replies->PostReply(seq, Wire::ReplySink::kStatusError, nullptr, 0);
return false;
};
if (bytesPerPixel == 0 || info.Box.W == 0 || info.Box.H == 0 ||
info.Box.W > static_cast<Uint32>(std::numeric_limits<GLsizei>::max()) ||
info.Box.H > static_cast<Uint32>(std::numeric_limits<GLsizei>::max()) ||
info.DstSize == 0 || m_maxReplyBytes == 0) {
return rejectReadback();
}
const Uint64 width = info.Box.W;
const Uint64 height = info.Box.H;
const Uint64 bpp = static_cast<Uint64>(bytesPerPixel);
constexpr Uint64 kUint64Max = std::numeric_limits<Uint64>::max();
if (width > kUint64Max / height) return rejectReadback();
const Uint64 pixels = width * height;
if (pixels > kUint64Max / bpp) return rejectReadback();
const Uint64 tight = pixels * bpp;
// PH-3's bound is the SERVER's answer size: w*h*bpp against its own maxReplyBytes,
// checked before the scratch grows. It is not a rule about DstSize.
if (tight > m_maxReplyBytes ||
tight > static_cast<Uint64>(std::numeric_limits<SizeT>::max())) {
return rejectReadback();
}
// ID-49: THE REPLY CROSSES TIGHT, WHATEVER DstSize THE CLIENT SENT. The server reads with
// neutral pack state into a w*h*bpp extent that IS the reply payload and the client
// scatters it per its own GL_PACK_* state, so a DstSize that disagrees with the tight
// extent (a client that sized its destination with pack padding) is logged and the tight
// extent this side owns is read and posted - never the client's number, so a wrong
// DstSize cannot make this a short read into uninitialised scratch. F2's first PH-3 draft
// refused the mismatch instead, which broke the ID-49 control
// (ServerLoopEglTest.AReadPixelsReplyIsTheTightExtentWhateverDstSizeTheClientSent); the
// bound above is what PH-3 needs and it does not depend on DstSize.
if (tight != info.DstSize) {
MGLOG_E_ONCE("MG_Remote server: read_pixels DstSize %llu != tight w*h*bpp %llu "
"(%ux%u, bpp %llu); reading the tight extent (ID-49)",
static_cast<unsigned long long>(info.DstSize),
static_cast<unsigned long long>(tight), info.Box.W, info.Box.H,
static_cast<unsigned long long>(bpp));
}
if (!ReadBoundFramebufferTight(info, tight)) {
// A well-formed call can still be unsupported by this server; return the
// established decline status after the peer-controlled size has been checked.
replies->PostReply(seq, Wire::ReplySink::kStatusDeclined, nullptr, 0);
return false;
}
// m-7: the answer is written into the slot HERE, mid-apply, while the verb stamp is still
// up - and that is safe for exactly one reason, which is the contract's and is stated so it
// is not mistaken for luck: the client reaches a reply slot ONLY through appliedSeq
// (ReplySlot.h's ORDERING clause), never by polling the slot's own stamp, and s1's
// SessionConsumer::ApplyOne publishes appliedSeq only AFTER PipeApplier::ApplyOne has run
// LeaveApplier() and (on the joint tree) dropped the ScopedApplierEntry. So by the time the
// client is allowed to look at this slot, the apply-side gPipeInputs flag is already down.
replies->PostReply(seq, Wire::ReplySink::kStatusOk, m_readbackScratch.data(), tight);
m_readbackBytes += tight;
++m_readbacks;
return true;
}
Bool ServerVerbSink::ReadBoundFramebufferTight(const MG_Pipe::MGPReadbackInfo& info, Uint64 tight) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("read_pixels");
if (table == nullptr || table->GL.ReadPixels == nullptr) return false;
if (tight > m_readbackScratch.size()) {
m_readbackScratch.resize(static_cast<SizeT>(tight));
}
// Save the server-visible pack state, force neutral for the read, restore. Both go through
// the applier's own set_pixel_pack_state entry point (MGPipeApplySetPixelPackState writes
// gPipeInputs.m_pixelStore[0], which the backend's ReadPixels reads via
// MGB_CTX->GetPixelStoreParameters); the read is synchronous on this thread, so the window
// in which the pack state is neutral does not outlive the call.
const MG_Pipe::PixelStoreParameters savedPack =
MG_Pipe::gPipeInputs.GetPixelStoreParameters(/*isUnpack=*/false);
// MG_Pipe owns the constant (MGPipeTypes.h): in a verify build the compare-at-read hook's
// oracle for the pack half inside this window is the same value, and a second hand-typed
// copy would drift without a build break.
const MG_Pipe::MGPPixelPackState neutralPack = MG_Pipe::MGPipeNeutralReadPixelsPack();
MG_Pipe::MGPipeApplySetPixelPackState(neutralPack);
table->GL.ReadPixels(info.Box.X, info.Box.Y, static_cast<GLsizei>(info.Box.W),
static_cast<GLsizei>(info.Box.H), static_cast<GLenum>(info.Format),
static_cast<GLenum>(info.Type), m_readbackScratch.data());
MG_Pipe::MGPPixelPackState restorePack{};
restorePack.Pack = savedPack;
MG_Pipe::MGPipeApplySetPixelPackState(restorePack);
return true;
}
Bool ServerVerbSink::ReadTextureImageTight(const MG_Pipe::MGPReadbackInfo& image, Vector<Uint8>& bytes) {
#if MOBILEGL_BUILD_DISAGGREGATED
if (m_backend && m_backend->GetBackendType() == BackendType::DirectGLES)
return MG_Backend::DirectGLES::ReadTextureImageWire(image, bytes);
if (m_backend && m_backend->GetBackendType() == BackendType::DirectVulkan &&
MG_Backend::DirectVulkan::pVulkanRenderer)
return MG_Backend::DirectVulkan::pVulkanRenderer->ReadTextureImageWire(image, bytes);
#endif
(void)image;
(void)bytes;
return false;
}
// ---- P9 (W1, MG_Remote/CONTRACT-P9.md §1): the pack-buffer readbacks ------------------------
//
// WHY THE ROWS GO THROUGH THE APPLIER'S BUFFER-WRITE GATE AND NOT THROUGH THE DRIVER'S OWN
// PBO READ. Monolith binds the driver buffer as GL_PIXEL_PACK_BUFFER and lets glReadPixels
// write it, then maps the whole store back into the frontend shadow (DirectGLES.cpp's
// "PBO used, mapping buffer to client memory"). A server has two stores per buffer - the
// backend's (GL buffer / VkBuffer) and R-11's staged shadow that every later drain uploads
// from - and a driver-side write reaches only the first: the next queued range drained from
// the shadow would then land OLD bytes over the pixels on Espryt, and Magma has no pack path
// in its ReadPixels at all. MGPipeApplyResourceSubData is the one write both backends already
// take from the server (Ops_H_SubData adopts into the staged shadow and queues the range;
// WriteWireBuffer orders the copy against in-flight reads), so the rows are exactly what a
// client glBufferSubData of the same bytes would have been - same serial bump, same epoch
// bump, same ordering against the records that follow.
//
// THE READ IS THE REPLY FORM'S, byte for byte (ReadBoundFramebufferTight /
// ReadTextureImageTight), so the two forms cannot disagree about what a pixel is; only the
// destination moved. The PACK layout is the client's arithmetic (ReadbackLayout) carried in
// the record, and the byte swap is applied to the tight bytes first, exactly where the client
// applied it to its reply.
//
// A FAILED READ IS LOUD. The reply form's failures reach the client as ERROR / DECLINED and
// end in a named Fatal there (RequireReadbackReplyComplete: "a blocking readback has no pixels
// to return"); this form has no reply to carry them, and a pack buffer left holding its old
// bytes is the same silently wrong answer. So the refusal latches here, under the same family
// words.
// Every number here is the peer's, and they are checked BEFORE the read, so a corrupt record
// costs no GPU work - and completely enough that LandReadbackInBuffer cannot meet a range it
// would have to refuse: every run it writes starts at or before the LAST row's offset and is
// no wider than the tight extent, and both are bounded here by what one MGPSubData can name
// (MGPipeSetSubDataBufferRange: an offset up to 2^31-1, a width up to 2^32-1). Whether the
// range fits the buffer is the applier's own gate, the one every buffer write meets.
static const char* ReadbackToBufferLayoutFault(const MG_Pipe::MGPReadbackToBuffer& record,
Uint64 bytesPerPixel) {
const MG_Pipe::MGPBox& box = record.Src.Box;
const Uint64 rowBytes = static_cast<Uint64>(box.W) * bytesPerPixel;
const Uint64 tightBytes = rowBytes * box.H * box.D; // the caller proved this fits
if (record.RowStride < rowBytes) return "the row stride is narrower than a tight row";
if (box.D > 1 && record.ImageStride < record.RowStride) return "the image stride is narrower than a row";
if (record.SwapGroup != 0 && record.SwapGroup != 2 && record.SwapGroup != 4 && record.SwapGroup != 8)
return "the swap group is not a byte group GL_PACK_SWAP_BYTES reverses (2, 4 or 8)";
constexpr Uint64 kMaxOffset = 0x7FFFFFFFull;
const Uint64 lastImage = box.D - 1;
const Uint64 lastRow = box.H - 1;
if (record.DstBase > kMaxOffset) return "the first row starts past 2^31-1";
if (lastImage != 0 && record.ImageStride > (kMaxOffset - record.DstBase) / lastImage)
return "the last image starts past 2^31-1";
const Uint64 imageBase = record.DstBase + lastImage * record.ImageStride;
if (lastRow != 0 && record.RowStride > (kMaxOffset - imageBase) / lastRow)
return "the last row starts past 2^31-1";
if (tightBytes > 0xFFFFFFFFull) return "the read is wider than one buffer write can name";
return nullptr;
}
Bool ServerVerbSink::LandReadbackInBuffer(const MG_Pipe::MGPReadbackToBuffer& record, Uint8* tight,
Uint64 bytesPerPixel) {
const MG_Pipe::MGPBox& box = record.Src.Box;
const Uint64 rowBytes = static_cast<Uint64>(box.W) * bytesPerPixel;
const Uint64 rows = box.H;
const Uint64 images = box.D;
const Uint64 tightBytes = rowBytes * rows * images;
if (record.SwapGroup != 0) {
const Uint64 group = record.SwapGroup;
for (Uint64 at = 0; at + group <= tightBytes; at += group) {
for (Uint64 i = 0; i < group / 2; ++i) std::swap(tight[at + i], tight[at + group - 1 - i]);
}
}
// ONE WRITE PER CONTIGUOUS RUN: the whole read when the layout is tight (the common
// case - a neutral pack state, or an image-packed read of whole images), one per image
// when only the rows are tight, one per row otherwise. The bytes between rows are the
// application's (GL_PACK_ROW_LENGTH / ALIGNMENT padding) and are never written.
const Bool rowsTight = record.RowStride == rowBytes;
const Bool imagesTight = images == 1 || record.ImageStride == rowBytes * rows;
const Uint64 runBytes = rowsTight ? (imagesTight ? tightBytes : rowBytes * rows) : rowBytes;
const Uint64 runsPerImage = rowsTight ? 1 : rows;
const Uint64 imageRuns = rowsTight && imagesTight ? 1 : images;
for (Uint64 z = 0; z < imageRuns; ++z) {
for (Uint64 r = 0; r < runsPerImage; ++r) {
// No overflow and no refusal below: ReadbackToBufferLayoutFault bounded the last
// offset and the widest run before the read.
const Uint64 offset = record.DstBase + z * record.ImageStride + r * record.RowStride;
MG_Pipe::MGPSubData write{};
write.Res = record.Dst;
write.Target = MG_Pipe::kMGPipeResourceTargetBuffer;
// HasLiveHostWrites stays 0 and that is the client's fact, not a default: the
// client takes this form only for a pack buffer it does not map
// (TextureReadbackEmit.inc, PackBufferReadbackTarget), so "no live host writer" is
// what a glBufferSubData of the same bytes would have said.
if (!MG_Pipe::MGPipeSetSubDataBufferRange(write, offset, runBytes)) return false;
const Uint8* bytes = tight + (z * runsPerImage + r) * runBytes;
++m_bufferReadbackWrites;
// The acceptance is the applier's (a dead handle is a counted no-op, a range
// past the store a trip wire), so a refused write ends the landing here.
if (!MG_Pipe::MGPipeApplyResourceSubData(write, bytes, nullptr)) return false;
}
}
++m_bufferReadbacks;
return true;
}
Bool ServerVerbSink::OnReadPixelsToBuffer(const MG_Pipe::MGPReadbackToBuffer& record) {
const MG_Pipe::MGPReadbackInfo& info = record.Src;
const Uint64 bpp = static_cast<Uint64>(MG_Util::GetInputBytesPerPixel(
MG_Util::ConvertGLEnumToTextureInputFormat(static_cast<GLenum>(info.Format)),
MG_Util::ConvertGLEnumToTexturePixelDataType(static_cast<GLenum>(info.Type))));
// The reply form's shape checks and its PH-3 bound: the scratch is sized from the record,
// so the SERVER's own maxReplyBytes caps it exactly as it caps an answer - the client bands
// this form by the same number (PlanReadbackBands), so no correct client is refused by it.
// ONE latch site for every shape fault (PeerLatchTest's row names it once).
constexpr Uint64 kUint64Max = std::numeric_limits<Uint64>::max();
const char* fault = nullptr;
Uint64 tight = 0;
if (bpp == 0 || info.Box.W == 0 || info.Box.H == 0 || info.Box.D != 1 ||
info.Box.W > static_cast<Uint32>(std::numeric_limits<GLsizei>::max()) ||
info.Box.H > static_cast<Uint32>(std::numeric_limits<GLsizei>::max())) {
fault = "the read box or its format is invalid";
} else if (static_cast<Uint64>(info.Box.W) > kUint64Max / info.Box.H ||
static_cast<Uint64>(info.Box.W) * info.Box.H > kUint64Max / bpp) {
fault = "the tight extent overflows";
} else {
tight = static_cast<Uint64>(info.Box.W) * info.Box.H * bpp;
if (m_maxReplyBytes == 0 || tight > m_maxReplyBytes ||
tight > static_cast<Uint64>(std::numeric_limits<SizeT>::max())) {
fault = "the tight extent is past this server's maxReplyBytes (PH-3)";
} else if (info.DstOffset != 0 || info.DstSize != tight) {
fault = "Src.DstOffset / Src.DstSize are not 0 / the tight extent";
} else {
fault = ReadbackToBufferLayoutFault(record, bpp);
}
}
if (fault != nullptr) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ReadPixelsToBuffer.shape\"} %s", fault);
}
if (!ReadBoundFramebufferTight(info, tight)) {
return SessionLatch(MGFatalFamily::ReadbackDeclined, "MGPipe: Fatal{ReadbackDeclined, \"ReadPixelsToBuffer\"} - the server "
"has no GL.ReadPixels; the pack buffer would keep bytes the application is "
"about to read as pixels, and there is no reply to carry the decline");
}
return LandReadbackInBuffer(record, m_readbackScratch.data(), bpp);
}
Bool ServerVerbSink::OnGetTextureImageToBuffer(const MG_Pipe::MGPReadbackToBuffer& record) {
const MG_Pipe::MGPReadbackInfo& info = record.Src;
const Uint64 bpp = static_cast<Uint64>(MG_Util::GetInputBytesPerPixel(
MG_Util::ConvertGLEnumToTextureInputFormat(info.Format),
MG_Util::ConvertGLEnumToTexturePixelDataType(info.Type)));
const char* fault = nullptr;
Uint64 tight = 0;
if (!bpp || !info.Box.W || !info.Box.H || !info.Box.D || info.Box.X || info.Box.Y || info.Box.Z) {
fault = "the image extent is not a whole level's";
} else {
tight = static_cast<Uint64>(info.Box.W) * info.Box.H * info.Box.D * bpp;
if (tight / bpp / info.Box.W / info.Box.H != info.Box.D) {
fault = "the tight extent overflows";
} else if (info.DstOffset != 0 || info.DstSize != tight) {
fault = "Src.DstOffset / Src.DstSize are not 0 / the tight extent";
} else {
fault = ReadbackToBufferLayoutFault(record, bpp);
}
}
if (fault != nullptr) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"GetTextureImageToBuffer.shape\"} %s", fault);
}
Vector<Uint8> bytes;
if (!ReadTextureImageTight(info, bytes) || bytes.size() != tight) {
return SessionLatch(MGFatalFamily::ReplyError, "MGPipe: Fatal{ReplyError, \"GetTextureImageToBuffer\"} - the level "
"could not be read (%llu of %llu bytes); the reply form answers ERROR here, and "
"this form has no reply to carry it",
static_cast<unsigned long long>(bytes.size()),
static_cast<unsigned long long>(tight));
}
return LandReadbackInBuffer(record, bytes.data(), bpp);
}
// P5b's server-side stub shape (CONTRACT-P5B.md): the same line the client's class-C table
// raises (EmitTables.cpp UnmigratedVerbFatal) and the same family the census greps, so a
// slot flipped on the client ahead of its server half aborts BY NAME on the apply thread
// rather than rendering nothing. Named "(server sink)" in the message so a log reader can
// tell which half is missing.
//
// PH-1 (3): the shape is the PEER's (a multi-draw record it chose to send), so in an armed
// session child the refusal latches and the verb returns false; unarmed it still dies.
static Bool ServerUnmigratedVerbLatch(const char* slot) {
return SessionLatch(MGFatalFamily::UnmigratedVerb, "MGPipe: Fatal{UnmigratedVerb, \"%s\"} (server sink: the record crossed and "
"ServerVerbSink has no body for it yet - CONTRACT-P5B.md names the package)",
slot);
}
// P5b d1 (MG_Remote/CONTRACT-P5B.md §2 d1): draw_vbo's whole cross product. The record
// carries the GL call verbatim (rule D) and this body reproduces the backend call the
// monolith makes for that shape, reading only the record and the backend's own
// barrier-pulled state; the twenty GL entry points collapse onto the arms below:
//
// kDrawIsIndirect arrays: DrawArraysIndirect (DrawCount 1, Stride 0) /
// MultiDrawArraysIndirect / MultiDrawArraysIndirectCount (a parameter
// buffer named); indexed: the three Elements twins
// NumDraws != 1 MultiDrawArrays / MultiDrawElementsBaseVertex, the two arrays
// rebuilt from the ranges into bounded locals (rule C)
// arrays, one range DrawArrays / DrawArraysInstanced / DrawArraysInstancedBaseInstance
// indexed, one range DrawElementsBaseVertex (the P5 arm, unchanged, bias 0 for a plain
// DrawElements) / DrawRangeElements[BaseVertex] under
// kDrawHasIndexRange / the four DrawElementsInstanced* by whether a
// base vertex and a base instance are non-zero
// kDrawHasUserIndices the `indices` argument is the resolved SEG_STAGE run instead of an
// element-buffer offset (the client staged a client index array)
//
// "Instanced" is InstanceCount != 1 || StartInstance != 0: an instanced call with a count
// of 1 and no base instance is the plain draw it is equivalent to, and a count of 0 must
// NOT collapse onto the plain draw (it draws nothing, the plain draw would draw once).
//
// What is still refused by name (the census's own grep family): a multi-draw that arrived
// with a span (the client refuses "MultiDrawElements+CLIENT_INDICES" first; P8's
// HostResolve.cpp flattens it) and a multi-draw that claims instancing (no GL entry point
// produces one; the client never sends it).
#if MOBILEGL_BUILD_DISAGGREGATED
namespace {
// P5e (tx2), CONTRACT-P5E §5.3 / ruling 19 (ID-95, A8 closed). THE TWO UNIT WINDOWS MUST
// COVER [0, MaxTouchedTextureUnit], AND THIS IS WHERE THAT PROMISE IS CHECKED.
//
// Everything the texture and sampler families do per draw now reads
// [SamplerViewStart, +SamplerViewCount) and [SamplerStateStart, +SamplerStateCount).
// Under run-ahead a window NARROWER than the frontend's high-water mark silently drops a
// sync of a texture the draw is about to sample, or leaves an earlier draw's sampler
// object on a unit - and a decline is exactly what run-ahead cannot take, because the
// client has already moved on and there is no wait in which to notice.
//
// A SERVER-SIDE RE-DERIVATION IS REFUSED, and that is the ruling's point: the client owns
// the high-water mark (it is the `count` argument SamplerEmit.h passes, Start=0 /
// Count=maxTouched+1) and the server's job is to check the promise, not to invent a
// second authority for it. So this is a CHECK and its failure is corruption.
//
// WHERE THE MARK COMES FROM: MGPContextValues::MaxTouchedTextureUnit, applied by
// set_context_values, which precedes the verb on the ring - the same ordering §2.1's
// XFB clause leans on. It is RECORD_SUPPLIED, so reading it is reading what a record
// put there and not client memory (rule F).
//
// WIDER IS FINE. A window larger than the mark costs a walk over provably-empty units;
// only SMALLER is unrepresentable. An empty window on a draw that touches no unit at all
// (mark 0 with nothing ever touched) is the correct and only possible emission, so a
// count of 0 is admitted exactly while the applied counters say no unit was touched.
//
// THE ONE ADMITTED SILENCE, and it is the A/B rather than a hole: a client whose sampler
// subsystem bit is CLEAR emits neither record, the backend then runs its pre-handle
// frontend walk, and there is no window to check because there is no window. That is
// "both counts are still 0", and it is distinguishable from the narrowing this refuses
// (a narrowed window carries a non-zero count that is merely too small). The moment
// either set has been received, the rule binds.
//
// PH-1 (3): both windows are the PEER's records, so the refusals latch in an armed
// session child (false = latched, the verb declines); unarmed they still die.
Bool CheckUnitWindows(const MG_Pipe::MGPipeApplierState& st, const char* verb) {
if (st.SamplerViewCount == 0 && st.SamplerStateCount == 0) return true;
const Int maxTouched = MG_Pipe::gPipeInputs.GetMaxTouchedTextureUnit();
if (maxTouched < 0) return true;
const Uint32 required = static_cast<Uint32>(maxTouched) + 1u;
if (st.SamplerViewStart != 0 || st.SamplerViewCount < required) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SetSamplerViews.Count\"} - %s applies with "
"units 0..%d touched, so set_sampler_views must carry Start=0 and Count >= %u "
"(CONTRACT-P5E.md §5.3); the applied window is Start=%u Count=%u, which drops "
"the sync of at least one texture this draw samples",
verb, static_cast<int>(maxTouched), required, st.SamplerViewStart,
st.SamplerViewCount);
}
if (st.SamplerStateStart != 0 || st.SamplerStateCount < required) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"BindSamplerStates.Count\"} - %s applies with "
"units 0..%d touched, so bind_sampler_states must carry Start=0 and Count >= %u "
"(CONTRACT-P5E.md §5.3); the applied window is Start=%u Count=%u, which leaves "
"an earlier draw's sampler object on at least one unit",
verb, static_cast<int>(maxTouched), required, st.SamplerStateStart,
st.SamplerStateCount);
}
return true;
}
} // namespace
#endif
Bool ServerVerbSink::OnDrawVbo(const MG_Pipe::MGPDrawInfo& info,
const MG_Pipe::MGPDrawRange* ranges,
const MG_Pipe::MGHostSpan* userIndices,
const MG_Pipe::MGPDrawIndirect* indirect) {
if (userIndices != nullptr && !Wire::CheckDrawUserIndices(info, ranges, *userIndices)) {
return false; // PH-1 (3): latched (armed session child); unarmed it died inside
}
// The witness first, before the backend is consulted, so a unit process with no
// backend object still sees the wire's fields (PipeApplier.h LastDraw).
m_lastDraw = LastDrawRecord{};
m_lastDraw.Info = info;
if (ranges != nullptr && info.NumDraws != 0) m_lastDraw.FirstRange = ranges[0];
if (userIndices != nullptr) {
m_lastDraw.HadUserIndices = true;
m_lastDraw.UserIndexBytes = userIndices->Size;
}
if (indirect != nullptr) {
m_lastDraw.HadIndirect = true;
m_lastDraw.Indirect = *indirect;
}
++m_drawRecords;
#if MOBILEGL_BUILD_DISAGGREGATED
// Ruling 19 / ID-95: the window promise, checked at every draw, before the backend is
// asked to resolve anything out of the windows.
if (!CheckUnitWindows(MG_Pipe::MGPipeApplier(), "draw_vbo")) return false;
#endif
const MG_Backend::GlobalBackendFunctionsTable* table = Table("draw_vbo");
if (table == nullptr) return false;
const MG_Backend::GLFunctionsTable& gl = table->GL;
const auto mode = static_cast<GLenum>(info.Mode);
GLenum indexType = 0;
switch (info.IndexSize) {
case 0: break; // arrays
case 1: indexType = GL_UNSIGNED_BYTE; break;
case 2: indexType = GL_UNSIGNED_SHORT; break;
case 4: indexType = GL_UNSIGNED_INT; break;
default:
// IndexSize is "0 = arrays, else 1 / 2 / 4" (MGPipeTypes.h) and nothing else is a
// legal width; defaulting to 4 would read past the element buffer.
Wire::WireProtocolFatalAt("MGPDrawInfo::IndexSize", info.IndexSize, 4);
}
// ---- the indirect family: the block is the whole description --------------------
if (indirect != nullptr) {
// The layout already refused a record that sets both flags or declares ranges
// beside the block, so NumDraws is 0 and there is no span here.
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.5): the command/parameter buffer handles cross as the
// verb's own state; the backend's indirect arm resolves the buffer twins from
// them instead of reading the client's GL_DRAW_INDIRECT_BUFFER binding slot.
auto& applierState = MG_Pipe::MGPipeApplier();
applierState.ClearVerbHandles();
applierState.VerbIndirectBuffer = indirect->Buffer;
applierState.VerbIndirectParameterBuffer = indirect->ParameterBuffer;
#endif
const auto offset = reinterpret_cast<const void*>(static_cast<std::uintptr_t>(indirect->Offset));
const auto drawCount = static_cast<GLsizei>(indirect->DrawCount);
const auto stride = static_cast<GLsizei>(indirect->Stride);
const Bool counted = !MG_Pipe::MGPipeHandleIsNull(indirect->ParameterBuffer);
const auto parameterOffset = static_cast<GLintptr>(indirect->ParameterOffset);
// glMultiDraw*Indirect with drawcount 1 and stride 0 IS glDraw*Indirect by GL's own
// definition, so the single-draw entry point is the one the monolith reaches for
// the single-draw call and nothing is lost for the multi-draw spelling of it.
const Bool single = !counted && drawCount == 1 && stride == 0;
if (info.IndexSize == 0) {
if (counted) {
if (gl.MultiDrawArraysIndirectCount == nullptr) return false;
gl.MultiDrawArraysIndirectCount(mode, offset, parameterOffset, drawCount, stride);
} else if (single) {
if (gl.DrawArraysIndirect == nullptr) return false;
gl.DrawArraysIndirect(mode, offset);
} else {
if (gl.MultiDrawArraysIndirect == nullptr) return false;
gl.MultiDrawArraysIndirect(mode, offset, drawCount, stride);
}
} else {
if (counted) {
if (gl.MultiDrawElementsIndirectCount == nullptr) return false;
gl.MultiDrawElementsIndirectCount(mode, indexType, offset, parameterOffset,
drawCount, stride);
} else if (single) {
if (gl.DrawElementsIndirect == nullptr) return false;
gl.DrawElementsIndirect(mode, indexType, offset);
} else {
if (gl.MultiDrawElementsIndirect == nullptr) return false;
gl.MultiDrawElementsIndirect(mode, indexType, offset, drawCount, stride);
}
}
++m_draws;
return true;
}
if (ranges == nullptr || info.NumDraws == 0) return false;
const Bool instanced = info.InstanceCount != 1 || info.StartInstance != 0;
const auto instanceCount = static_cast<GLsizei>(info.InstanceCount);
const GLuint baseInstance = info.StartInstance;
// ---- the multi-draws: the two arrays rebuilt from the ranges (rule C) --------------
if (info.NumDraws != 1) {
if (userIndices != nullptr) {
return ServerUnmigratedVerbLatch(info.IndexSize == 0 ? "MultiDrawArrays+CLIENT_INDICES"
: "MultiDrawElements+CLIENT_INDICES");
}
if (instanced) {
return ServerUnmigratedVerbLatch(info.IndexSize == 0 ? "MultiDrawArrays+INSTANCED"
: "MultiDrawElements+INSTANCED");
}
const auto n = static_cast<SizeT>(info.NumDraws);
m_multiCounts.resize(n);
if (info.IndexSize == 0) {
if (gl.MultiDrawArrays == nullptr) return false;
m_multiFirsts.resize(n);
for (SizeT i = 0; i < n; ++i) {
m_multiFirsts[i] = static_cast<GLint>(ranges[i].Start);
m_multiCounts[i] = static_cast<GLsizei>(ranges[i].Count);
}
gl.MultiDrawArrays(mode, m_multiFirsts.data(), m_multiCounts.data(),
static_cast<GLsizei>(n));
} else {
if (gl.MultiDrawElementsBaseVertex == nullptr) return false;
m_multiOffsets.resize(n);
m_multiBaseVertices.resize(n);
for (SizeT i = 0; i < n; ++i) {
m_multiOffsets[i] = reinterpret_cast<const void*>(
static_cast<std::uintptr_t>(ranges[i].Start) * info.IndexSize);
m_multiCounts[i] = static_cast<GLsizei>(ranges[i].Count);
m_multiBaseVertices[i] = ranges[i].IndexBias;
}
// MultiDrawElements is the same call with every base vertex 0, which is what
// its ranges carry (CONTRACT-P5B.md d1's MultiDrawElements row).
gl.MultiDrawElementsBaseVertex(mode, m_multiCounts.data(), indexType,
m_multiOffsets.data(), static_cast<GLsizei>(n),
m_multiBaseVertices.data());
}
++m_draws;
return true;
}
// ---- one range ----------------------------------------------------------------------
const MG_Pipe::MGPDrawRange& range = ranges[0];
const auto count = static_cast<GLsizei>(range.Count);
if (info.IndexSize == 0) {
if (!instanced) {
if (gl.DrawArrays == nullptr) return false;
gl.DrawArrays(mode, static_cast<GLint>(range.Start), count);
} else if (baseInstance != 0) {
if (gl.DrawArraysInstancedBaseInstance == nullptr) return false;
gl.DrawArraysInstancedBaseInstance(mode, static_cast<GLint>(range.Start), count,
instanceCount, baseInstance);
} else {
if (gl.DrawArraysInstanced == nullptr) return false;
gl.DrawArraysInstanced(mode, static_cast<GLint>(range.Start), count, instanceCount);
}
++m_draws;
return true;
}
// Indexed. `indices` is the element-buffer byte offset Start * IndexSize - the same
// arithmetic PipeFill's emitter inverted - or, for a client index array, the staged run
// resolved through the process resolver the server installed (rule C: the pointer is
// used for this call only). The codec proved the span lies inside SEG_STAGE (all four
// honesty arms), so a null here is the resolver missing, which is a wiring fault.
const void* indices = nullptr;
if (userIndices != nullptr) {
indices = MG_Pipe::MGPipeHostBytes(*userIndices);
if (indices == nullptr) {
Wire::WireProtocolFatal("DrawVbo.userIndices",
"the user-index span passed the codec's four honesty arms "
"but MGPipeHostBytes resolved it to null; the server's "
"segment resolver is not installed");
}
} else {
indices = reinterpret_cast<const void*>(static_cast<std::uintptr_t>(range.Start) *
info.IndexSize);
}
const GLint baseVertex = range.IndexBias;
const Bool ranged = (info.Flags & MG_Pipe::kDrawHasIndexRange) != 0;
// A BASE VERTEX OF 0 IS THE PLAIN ENTRY POINT, NOT THE BaseVertex ONE WITH A 0 - d1's one
// ruling against CONTRACT-P5B.md's "DrawElementsBaseVertex(..., 0), the P5 arm,
// unchanged". Espryt's DrawElementsBaseVertex slot calls glDrawElementsBaseVertex, which
// is ES 3.2 / OES_draw_elements_base_vertex and is NOT loaded on an ES 3.1 provider
// (ANGLE on D3D11: "Failed to load GLES function: glDrawElementsBaseVertex" at bring-up),
// so the P5 arm dereferenced a null function pointer on every plain glDrawElements - the
// one call every Minecraft frame is made of - while the monolith, which calls
// GL.DrawElements for glDrawElements, was fine. Rule D says the sink reproduces the call
// the monolith makes; this is that, for all three indexed families.
if (!instanced) {
if (ranged) {
if (baseVertex != 0) {
if (gl.DrawRangeElementsBaseVertex == nullptr) return false;
gl.DrawRangeElementsBaseVertex(mode, info.MinIndex, info.MaxIndex, count, indexType,
indices, baseVertex);
} else {
if (gl.DrawRangeElements == nullptr) return false;
gl.DrawRangeElements(mode, info.MinIndex, info.MaxIndex, count, indexType, indices);
}
} else if (baseVertex != 0) {
if (gl.DrawElementsBaseVertex == nullptr) return false;
gl.DrawElementsBaseVertex(mode, count, indexType, indices, baseVertex);
} else {
if (gl.DrawElements == nullptr) return false;
gl.DrawElements(mode, count, indexType, indices);
}
} else if (baseInstance != 0) {
if (baseVertex != 0) {
if (gl.DrawElementsInstancedBaseVertexBaseInstance == nullptr) return false;
gl.DrawElementsInstancedBaseVertexBaseInstance(mode, count, indexType, indices,
instanceCount, baseVertex, baseInstance);
} else {
if (gl.DrawElementsInstancedBaseInstance == nullptr) return false;
gl.DrawElementsInstancedBaseInstance(mode, count, indexType, indices, instanceCount,
baseInstance);
}
} else if (baseVertex != 0) {
if (gl.DrawElementsInstancedBaseVertex == nullptr) return false;
gl.DrawElementsInstancedBaseVertex(mode, count, indexType, indices, instanceCount, baseVertex);
} else {
if (gl.DrawElementsInstanced == nullptr) return false;
gl.DrawElementsInstanced(mode, count, indexType, indices, instanceCount);
}
++m_draws;
return true;
}
// -----------------------------------------------------------------------------------
// P5b: the stubs the four migration packages replace (MG_Remote/CONTRACT-P5B.md).
//
// Each dies by the GL slot's own name. The record has crossed and been validated by the
// codec by the time one of these runs, so the only thing missing is the backend call, and
// the package that owns the row writes it here: `Table("<row>")`, the null-slot check
// (a backend that leaves the slot null DECLINES, which is the monolith's null-slot answer
// in the same words), the call, and a tally the lane can assert moved.
// -----------------------------------------------------------------------------------
// ---- i1 ---- (MG_Remote/CONTRACT-P5B.md §2 i1; landed by package p5b/i1)
//
// RULE D IN FIVE BODIES. Each reproduces the backend call the monolith makes, from the
// record and from server state, and NOTHING ELSE: the backend goes on reading the frontend
// fields it reads today through the BARRIER-PULLED entries of its verb class, which the
// verb stamp PipeApplier::ApplyOne put up before this sink ran is exactly what makes legal.
// That is why none of these touches a backend file and why the monolith path is byte
// identical - and it is also the honest statement of the debt, which `rsp` counts.
Bool ServerVerbSink::OnLaunchGrid(const MG_Pipe::MGPGridInfo& grid) {
#if MOBILEGL_BUILD_DISAGGREGATED
// Ruling 19 / ID-95: a dispatch samples through the same unit windows a draw does.
if (!CheckUnitWindows(MG_Pipe::MGPipeApplier(), "launch_grid")) return false;
#endif
const MG_Backend::GlobalBackendFunctionsTable* table = Table("launch_grid");
if (table == nullptr) return false;
const MG_Backend::GLFunctionsTable& gl = table->GL;
// The compute program is NOT named by THIS record and must not be - but it IS named, by
// the set_dispatch_program record that preceded it, and the backend's PrepareForCompute
// reads MGPipeApplier().DispatchProgram from there.
//
// P5e (pa): that is what this comment used to get wrong. Until P5e the backend PULLED
// GetProgramForDispatch - GetProgramForDraw's twin - inside PrepareForCompute, and the
// field's FATAL -> BARRIER_PULLED move in FieldOwnership.def (contract §6.9, i1's one
// granted row) is what made the pull legal. pg gave the sync and the link/SPIR-V gate a
// handle arm and pa retired the pull itself, so on the handle arm the row is not read at
// all; the BARRIER_PULLED class stays because the monolith arm still reads it (ID-81).
// Block* are 0 on the wire for the unchanged reason: the local size is a link artifact
// the backend reads from the program it resolved, which on that arm is the record's
// archive.
if (grid.IsIndirect != 0) {
if (gl.DispatchComputeIndirect == nullptr) return false;
// IndirectBuffer travels for P7's sake; the BINDING is server state, put there by
// the set_buffer_bindings record that preceded this one, exactly as OnClear's Fbo
// is not re-resolved here. glDispatchComputeIndirect takes only the offset.
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.5): the buffer handle itself is now also the verb's own
// state, so the backend's dispatch-indirect arm resolves the twin from the record
// rather than from the client's GL_DISPATCH_INDIRECT_BUFFER binding slot.
auto& applierState = MG_Pipe::MGPipeApplier();
applierState.ClearVerbHandles();
applierState.VerbDispatchIndirectBuffer = grid.IndirectBuffer;
#endif
gl.DispatchComputeIndirect(static_cast<GLintptr>(grid.IndirectOffset));
} else {
if (gl.DispatchCompute == nullptr) return false;
gl.DispatchCompute(static_cast<GLuint>(grid.GridX), static_cast<GLuint>(grid.GridY),
static_cast<GLuint>(grid.GridZ));
}
++m_dispatches;
return true;
}
Bool ServerVerbSink::OnMemoryBarrier(const MG_Pipe::MGPMemoryBarrier& barrier) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("memory_barrier");
if (table == nullptr) return false;
const MG_Backend::GLFunctionsTable& gl = table->GL;
// THE BITS GO OVER VERBATIM AND ARE LOWERED HERE BY NOBODY. Espryt's atomic-counter
// lowering - the counter bit implying the storage bit, because glslang lowers every
// atomic_uint onto a storage block - lives inside its own MemoryBarrier
// (DirectGLES.cpp:8837) and is a statement about the DRIVER. Repeating it on this side
// would make the split arm and the monolith arm two different calls.
if (barrier.ByRegion != 0) {
if (gl.MemoryBarrierByRegion == nullptr) return false;
gl.MemoryBarrierByRegion(static_cast<GLbitfield>(barrier.Bits));
} else {
if (gl.MemoryBarrier == nullptr) return false;
gl.MemoryBarrier(static_cast<GLbitfield>(barrier.Bits));
}
++m_memoryBarriers;
return true;
}
Bool ServerVerbSink::OnResourceCopyRegion(const MG_Pipe::MGPCopyRegion& copy) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("resource_copy_region");
if (table == nullptr) return false;
if (table->GL.CopyImageSubData == nullptr) return false;
// Typed handles resolve both texture and renderbuffer storage on the server.
MG_Backend::CopyImageEndpoint src{};
MG_Backend::CopyImageEndpoint dst{};
if (copy.SrcTarget == GL_RENDERBUFFER) src.RenderbufferHandle = copy.Src;
else src.TextureHandle = copy.Src;
if (copy.DstTarget == GL_RENDERBUFFER) dst.RenderbufferHandle = copy.Dst;
else dst.TextureHandle = copy.Dst;
if (MG_Pipe::MGPipeHandleIsNull(copy.Src) || MG_Pipe::MGPipeHandleIsNull(copy.Dst)) {
// The monolith's own answer to this, in its own words (DirectGLES.cpp:9067
// "source or destination image failed to sync; declining the copy"): the frontend
// validator is what keeps it unreachable and what reports the INVALID_VALUE the
// application is owed. A decline here is a real answer, not a silent success.
MGLOG_E_ONCE("MG_Remote server: resource_copy_region named texture(s) %u -> %u that "
"the frontend no longer holds; declining the copy",
static_cast<unsigned>(copy.SrcGlName),
static_cast<unsigned>(copy.DstGlName));
return false;
}
// SrcBox is {origin, extent} and the extent is the copy's, spelled once by GL for both
// endpoints; the destination contributes only its origin.
table->GL.CopyImageSubData(src, static_cast<GLenum>(copy.SrcTarget),
static_cast<GLint>(copy.SrcLevel), copy.SrcBox.X, copy.SrcBox.Y,
copy.SrcBox.Z, dst, static_cast<GLenum>(copy.DstTarget),
static_cast<GLint>(copy.DstLevel), copy.DstX, copy.DstY,
copy.DstZ, static_cast<GLsizei>(copy.SrcBox.W),
static_cast<GLsizei>(copy.SrcBox.H),
static_cast<GLsizei>(copy.SrcBox.D));
++m_imageCopies;
return true;
}
Bool ServerVerbSink::OnBindShaderImage(const MG_Pipe::MGPImageBind& bind) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("bind_shader_image");
if (table == nullptr) return false;
if (table->GL.BindImageTexture == nullptr) return false;
// THE SAME CALL IS RIGHT FOR BOTH BACKENDS, which is why the record carries the whole
// argument list although neither reads all of it today: Espryt ignores everything but
// Unit and syncs that unit from the barrier-pulled GetImageTextureBinding
// (DirectGLES.cpp:9154, :2471), and Magma's slot is a no-op (DirectVulkan.cpp:665). The
// arguments travel because rule D says a verb crosses as the CALL, and because P7 is
// what makes the backend read them instead of pulling.
table->GL.BindImageTexture(static_cast<GLuint>(bind.Unit), static_cast<GLuint>(bind.GlName),
static_cast<GLint>(bind.Level),
bind.Layered != 0 ? GL_TRUE : GL_FALSE,
static_cast<GLint>(bind.Layer),
static_cast<GLenum>(bind.Access),
static_cast<GLenum>(bind.Format));
++m_imageBinds;
// P5c (rv): an image bind moves the frontend's texture bind generation, and this verb
// carries no set_shader_images alongside it - so the server-side shutter serial the
// accessors now answer with (CONTRACT-P5C.md §5.3) moves here, at the one place the
// event reaches the applier's side.
MG_Pipe::MGPipeApplierNoteTextureStateMoved();
return true;
}
Bool ServerVerbSink::OnSetStorageBlockBinding(const MG_Pipe::MGPStorageBlockBinding& binding,
const char* name) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("set_storage_block_binding");
if (table == nullptr) return false;
if (table->GL.ShaderStorageBlockBinding == nullptr) return false;
if (name == nullptr) return false;
// The NAME is the one coordinate the application, the frontend and both backends agree
// on (BackendObject.h:216-221), which is why the row carries a blob rather than the
// application's block INDEX. `name` points into the decoder's bounded local and is
// valid for this call only (rule C); the backend slot copies what it needs.
//
// P5f fe: publish the record's program handle for the backend consumer. Espryt
// resolves its server twin directly; Magma's consumer is the fm package's seam.
MG_Pipe::MGPipeApplier().ClearVerbHandles();
MG_Pipe::MGPipeApplier().VerbStorageBlockProgram = binding.ShaderCso;
table->GL.ShaderStorageBlockBinding(static_cast<GLuint>(binding.GlName), name,
static_cast<GLuint>(binding.Binding));
++m_storageBlockBindings;
return true;
}
// ---- t2 ---- (MG_Remote/CONTRACT-P5B.md §2 t2)
//
// SIX BODIES, SIX BACKEND CALLS, NO STATE OF THEIR OWN. Rule D: the record IS the call, and
// everything the backend reads around it - the capture program, the capture-buffer
// bindings, the bound XFB object, the patch state - it reads from gPipeInputs through its
// verb class's BARRIER-PULLED fields, which the client filled at the call site and the
// verb barrier holds still (R-1). That is why none of these touches m_backend beyond
// Table() and why not one of them caches anything across records.
//
// A NULL SLOT DECLINES, AND THE DECLINE IS THE MONOLITH'S ANSWER IN THE SAME WORDS. Magma
// (DirectVulkan) registers NO XFB slot and no PatchParameteri at all
// (BackendObject_DirectVulkan.cpp), and under monolith the frontend's own
// `if (const auto f = table.GL.X)` guard simply skips the call; `return false` here is that
// same skip, reported to DecodeAndApply as "this build did not apply it" rather than as a
// crash or as a silent success. Contract §2 t2 says so for PatchParameteri by name.
Bool ServerVerbSink::OnBeginStreamOutput(const MG_Pipe::MGPStreamOutputBegin& begin) {
auto& state = MG_Pipe::MGPipeApplier();
state.BoundStreamOutputLifetimeId = begin.LifetimeId;
state.StreamOutputSpans[begin.LifetimeId] = begin;
const MG_Backend::GlobalBackendFunctionsTable* table = Table("begin_stream_output");
if (table == nullptr) return false;
if (table->GL.BeginTransformFeedback == nullptr) return false;
// Espryt's Begin only ARMS the span (DirectGLES.cpp:1212-1220: primitiveMode, pending,
// targets cleared); the driver glBeginTransformFeedback happens in the tail of the next
// PrepareForDraw (StartPendingTransformFeedback, :1224), where the capture program and
// the buffer bindings are read through the pulls. So this record's effect is not
// visible until a DRAW crosses - which is why an XFB scenario whose draw is still class
// C moves its first blocker to that draw rather than rendering.
table->GL.BeginTransformFeedback(static_cast<GLenum>(begin.PrimitiveMode));
++m_streamOutputSpans;
return true;
}
Bool ServerVerbSink::OnEndStreamOutput(const MG_Pipe::MGPXfbAccounting& accounting) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("end_stream_output");
if (table == nullptr) return false;
if (table->GL.EndTransformFeedback == nullptr) {
auto& state = MG_Pipe::MGPipeApplier();
state.StreamOutputSpans.erase(state.BoundStreamOutputLifetimeId);
return false;
}
// THE THREE ACCOUNTING FIELDS ARE NOT READ, AND THAT IS THE RULING RATHER THAN AN
// OMISSION. glEndTransformFeedback takes no arguments; the numbers are the CLIENT's own
// per-span accounting (contract §2 t2's companions row) and the client is where they are
// consumed - by the primitive queries and by the capture-capacity clamp. A server that
// second-guessed them from its own driver would be publishing a second answer to a
// question the frontend already answers, and the second answer is the one that goes
// stale. They cross because the row has carried them since P4a and because P9's
// server-side scatter is what will need them.
(void)accounting;
table->GL.EndTransformFeedback();
auto& state = MG_Pipe::MGPipeApplier();
state.StreamOutputSpans.erase(state.BoundStreamOutputLifetimeId);
++m_streamOutputSpans;
return true;
}
Bool ServerVerbSink::OnPauseStreamOutput(const MG_Pipe::MGPStreamOutputControl& control) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("pause_stream_output");
if (table == nullptr) return false;
if (table->GL.PauseTransformFeedback == nullptr) return false;
(void)control; // Reserved, and the contract says it is 0.
table->GL.PauseTransformFeedback();
++m_streamOutputControls;
return true;
}
Bool ServerVerbSink::OnResumeStreamOutput(const MG_Pipe::MGPStreamOutputControl& control) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("resume_stream_output");
if (table == nullptr) return false;
if (table->GL.ResumeTransformFeedback == nullptr) return false;
(void)control;
table->GL.ResumeTransformFeedback();
++m_streamOutputControls;
return true;
}
Bool ServerVerbSink::OnBindStreamOutput(const MG_Pipe::MGPStreamOutputBind& bind) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("bind_stream_output");
if (table == nullptr) return false;
// THE GL NAME IS THE ARGUMENT, NOT THE LifetimeId BESIDE IT. Espryt keys its driver
// objects by the GL name (XfbImpl::g_xfbObjects[name], DirectGLES.cpp:1401) and creates
// the ES object on first bind; passing the lifetime id would index a map that has never
// heard of it and silently create a second driver object per bind. The lifetime id
// identifies the P5f capture snapshot on this side. Keeping it separate preserves
// the backend GL-name argument while Begin/End find the right server-owned span.
MG_Pipe::MGPipeApplier().BoundStreamOutputLifetimeId = bind.LifetimeId;
if (table->GL.BindTransformFeedback) table->GL.BindTransformFeedback(static_cast<GLuint>(bind.GlName));
++m_streamOutputBinds;
return true;
}
Bool ServerVerbSink::OnDeleteStreamOutput(const MG_Pipe::MGPStreamOutputBind& object) {
const auto* table = Table("DeleteTransformFeedback");
if (!table) return false;
if (!object.GlName || object.Pad0)
Wire::WireProtocolFatal("DeleteStreamOutput.name", "default object or reserved field");
auto& state = MG_Pipe::MGPipeApplier();
if (object.LifetimeId) {
state.StreamOutputSpans.erase(object.LifetimeId);
if (state.BoundStreamOutputLifetimeId == object.LifetimeId) state.BoundStreamOutputLifetimeId = 0;
}
state.VerbDeleteStreamOutputLifetimeId = object.LifetimeId;
if (table->GL.DeleteTransformFeedback) table->GL.DeleteTransformFeedback(object.GlName);
state.VerbDeleteStreamOutputLifetimeId = 0;
return true;
}
Bool ServerVerbSink::OnPatchParameter(const MG_Pipe::MGPPatchParameter& patch) {
const MG_Backend::GlobalBackendFunctionsTable* table = Table("patch_parameter");
if (table == nullptr) return false;
// Magma registers no PatchParameteri: it compiles the patch size into its synthesized
// control stage from set_patch_state instead, so the DECLINE below is the whole of the
// right answer for that backend and not a gap (contract §2 t2).
if (table->GL.PatchParameteri == nullptr) return false;
// Pname is GL_PATCH_VERTICES and the frontend has already rejected every other spelling
// with INVALID_ENUM before the record was built, so this is a forward and not a switch.
table->GL.PatchParameteri(static_cast<GLenum>(patch.Pname), static_cast<GLint>(patch.Value));
++m_patchParameters;
return true;
}
// ---- f1 ----
Bool ServerVerbSink::OnGenerateMipmap(const MG_Pipe::MGPMipPlan& plan) {
const auto* table = Table("GenerateMipmap");
if (table == nullptr || table->GL.GenerateMipmap == nullptr) return false;
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.2): the texture the client resolved at Target on the active
// unit crosses as the verb's own state; the backend's mip-descriptor check resolves
// the record from it instead of probing the client allocator for the bound object's
// lifetime id (T2's mip half).
auto& applierState = MG_Pipe::MGPipeApplier();
applierState.ClearVerbHandles();
applierState.VerbMipRes = plan.Res;
applierState.VerbMipBaseLevel = plan.BaseLevel;
applierState.VerbMipLevelCount = plan.LevelCount;
#endif
table->GL.GenerateMipmap(plan.Target);
return true;
}
Bool ServerVerbSink::OnCopyFramebufferToTexture(const MG_Pipe::MGPCopyFromFramebuffer& copy) {
const auto* table = Table(copy.SubImage ? "CopyTexSubImage2D" : "CopyTexImage2D");
if (table == nullptr) return false;
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.4): the destination texture the client resolved at the
// active unit crosses as the verb's own state; the backend resolves its twin from the
// handle instead of reading the client's texture-unit binding slot (T4).
auto& applierState = MG_Pipe::MGPipeApplier();
applierState.ClearVerbHandles();
applierState.VerbCopyTexDst = copy.Dst;
#endif
if (copy.SubImage) {
if (table->GL.CopyTexSubImage2D == nullptr) return false;
table->GL.CopyTexSubImage2D(copy.Target, copy.Level, copy.XOffset, copy.YOffset,
copy.X, copy.Y, copy.Width, copy.Height);
} else {
if (table->GL.CopyTexImage2D == nullptr) return false;
table->GL.CopyTexImage2D(copy.Target, copy.Level, copy.InternalFormat,
copy.X, copy.Y, copy.Width, copy.Height, 0);
}
return true;
}
// ---- P5c ct (MG_Remote/CONTRACT-P5C.md §5) ------------------------------------------
//
// TWO CONTROL RECORDS, NO BACKEND TABLE AND NO DECLINE ARM. Neither body consults
// Table(): the reset belongs to the applier this process owns, and the death release
// belongs to the twin tables - a backend that registered no slots (Magma's XFB shape)
// still has an applier to reset and still answers a death with the same generation
// check. A record that cannot be proved is Fatal, not declined: both refusals are
// ProtocolCorruption because by the time the sink runs the codec has already proved the
// record's SHAPE, and what is left to check are the contract facts about the peer (§1).
Bool ServerVerbSink::OnApplierReset(const MG_Pipe::MGPApplierReset& reset) {
// §1: the serial is ASSERTED, never dispatched on. P5c has exactly one context per
// session, so the only legal sequence is 0, 1, 2, ... and the session's own count of
// accepted resets IS the expected value; anything else means the two ends disagree
// about how many make-current edges have crossed, which no backend answer can fix.
// PH-1 (3): latches in an armed session child (the peer wrote the serial); dies unarmed.
if (reset.ContextSerial != m_applierResetSerial) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"} - the "
"record carries %llu and this session has accepted %llu reset(s); the "
"serial is asserted against the session's own count, not dispatched on "
"(one context per session in P5c)",
static_cast<unsigned long long>(reset.ContextSerial),
static_cast<unsigned long long>(m_applierResetSerial));
}
++m_applierResetSerial;
// THE WHOLE POINT OF THE RECORD: the reset runs HERE, on the apply thread, against
// the g_applier this role owns (PipeApply.cpp:409). The layer-2 guard inside
// MGPipeApplierReset passes because this IS the apply thread; the GL-thread direct
// call it replaced is the Fatal arm.
MG_Pipe::MGPipeApplierReset();
++m_applierResets;
return true;
}
Bool ServerVerbSink::OnObjectDeath(const MG_Pipe::MGPHandleOnly& death) {
// §1's zero ruling: a null handle means "the object never crossed", and the client
// emits NOTHING in that case (§5.2) - so a null handle arriving here is corruption,
// not a no-op.
// PH-1 (3): both refusals latch in an armed session child; unarmed they die as before.
if (MG_Pipe::MGPipeHandleIsNull(death.Handle)) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"} - a null "
"handle never crosses: the client emits nothing for an object its own "
"allocator cannot resolve (CONTRACT-P5C.md §5.2)");
}
if (death.Kind >= static_cast<Uint32>(MG_Pipe::MGPipeKind::KindCount)) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Kind\"} - %u is not an "
"MGPipeKind",
static_cast<unsigned>(death.Kind));
}
// The per-kind release, keyed by the handle the record carried. A false answer is
// NOT a decline: the kind's own delete opcode may already have released the twin
// (the idempotent second path every notice arm documents), and a kind this backend
// does not twin (Buffer, whose death crosses as resource_destroy) legally resolves
// to nothing.
MG_Backend::DirectGLES::ReleaseTwinsForWireObjectDeath(
death.Handle, static_cast<MG_Pipe::MGPipeKind>(death.Kind));
++m_objectDeaths;
return true;
}
// -----------------------------------------------------------------------------------
// PipeApplier
// -----------------------------------------------------------------------------------
PipeApplier::PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies)
: m_segments(segments), m_replies(replies) {}
void PipeApplier::Attach(Transport::ILink* link, MG_Backend::BackendObject* backend) {
if (!link || !link->Attached() || !m_segments)
SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, PipeApplier::Attach missing link}");
m_verbs.SetBackend(backend);
m_verbs.SetMaxReplyBytes(link->Capabilities().MaxReplyBytes);
m_decoder = Wire::PipeWireDecoder(link, m_segments, m_replies);
m_decoder.SetVerbSink(&m_verbs);
MG_Pipe::MGPipeServerBlockNoteIdentity(); m_attached = true;
}
void PipeApplier::Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend) {
if (control == nullptr || m_segments == nullptr) {
SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::Attach\"} - no control "
"page or no segment table; ServerSession::Accept builds both before the "
"apply thread starts");
}
m_verbs.SetBackend(backend);
m_decoder = Wire::PipeWireDecoder(control, m_segments, m_replies);
m_decoder.SetVerbSink(&m_verbs);
// P5f (f1), CONTRACT-P5E §3.2: the server block's identity, once per session (the
// per-verb stamp refreshes it against the served-context serial). A no-op unless the
// dual-block rehearsal is armed; without it the server block's ContextIdentity() stays
// nullptr and the backend's identity-keyed memo caches read that as a HIT on their
// zero-initialised slot - an unnamed null dereference instead of a named marker.
MG_Pipe::MGPipeServerBlockNoteIdentity();
m_attached = true;
}
void PipeApplier::Detach() {
m_decoder = Wire::PipeWireDecoder();
m_verbs.SetBackend(nullptr);
m_attached = false;
}
Bool PipeApplier::Attached() const { return m_attached; }
Bool PipeApplier::ApplyOne(const Transport::RingRecordView& record) {
if (!m_attached) {
SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::ApplyOne before Attach\"} "
"- a record reached the applier with no decoder; the apply thread calls "
"Attach once before its first pop");
}
// R-1's INVARIANT, THE SERVER'S HALF (table 3's gPipeInputs row, c1-v1 8.1). The flag
// is raised for the WHOLE of this function and not only around DecodeAndApply: the
// stamp below and LeaveApplier at the end are both writes to gPipeInputs, and the client
// asserts the flag is down before it publishes (ClientSession::EmitAndWait), so a
// bracket that excluded either would leave a real write outside the check. It is
// dropped before this function returns, and s1's SessionConsumer::ApplyOne publishes
// appliedSeq only after that - so by the time the client is runnable the flag is down.
const Client::ClientSession::ScopedApplierEntry insideApplier;
// P5e (id), CONTRACT-P5E §2.1 / §4.4: STAMP WHETHER THE CLIENT IS PARKED BEHIND THIS
// RECORD, before anything can ask. It is the input to the allocator guard's exemption
// (SlotAllocator.cpp) and to every frontend-keyed twin member that survives as monolith
// glue (SlotTables.h), and it has to be up before DecodeAndApply because the sinks those
// reach are exactly the askers. MGPipeBarriered answers true for every record until ra
// lands the wait rule, so this line changes nothing this phase and is the line ra
// rebases onto rather than adds.
//
// AND THE STAMP IS `true` UNTIL ra LANDS THE CLIENT'S HALF (ID-103). The predicate
// describes what the client WILL do once EmitAndWaitTails follows the wait classes;
// today it still blocks after every record, so a `false` stamp here would withdraw the
// §4.4 exemptions from a probe the client's own wait still makes safe - a refusal with
// no defect behind it. The predicate is computed on every record all the same, so it is
// exercised for the whole phase rather than first run on the day it starts deciding.
//
// AND THE SERVER'S OWN CAPABILITY IS THE SECOND CONJUNCT (P5e gl, ID-111). The constant
// above says what the CLIENT will do once ra's wait rule is live; it says nothing about
// which server this is. kCapRunAheadApply is never published on DirectVulkan (ID-90,
// MGPipeRunAheadCapBitsFor), yet draw_vbo / blit / clear / launch_grid are all
// kWaitNone - so a stamp that read the build constant alone would, on the day it flips,
// have a MAGMA server mark every draw record UNBARRIERED while its client is still
// lockstep. CountBarrierPull (PipeInputs.cpp) is an unconditional Fatal on an
// unbarriered pull, no knob involved, and Magma's residual fill is its ONLY source for
// the seven pointer-backed fields: Magma would die on its first draw and take
// MagmaP7AllocatorDebtScope's exemption with it.
//
// BOTH PREDICATES ARE COMPUTED UNCONDITIONALLY, as arguments rather than as the arms of
// a short-circuit, which is ID-103's reason extended to the capability probe: they are
// exercised on every record for the whole phase rather than first running on the day
// they start deciding.
//
// PH-1 (3): BUT NOT BEFORE THE RECORD HAS BEEN ADMITTED. MGPipeBarriered reads payload
// fields (a DrawVbo's MGPDrawInfo::Flags) and the stamps index per-opcode tables, so a
// record whose ring header is shorter than its own type - or names no opcode - would be
// read past its end here, before DecodeAndApply's pre-gate could refuse it. In an armed
// session child the pre-gate therefore runs FIRST: a refused record latches by name and
// is declined with nothing stamped (ServerLoopTest's short-record case reads the stamp).
// Unarmed it admits everything and the generated gate keeps its death, as before.
if (!m_decoder.AdmitOrDecline(record)) return false;
const Bool wireSaysBarriered = MG_Pipe::MGPipeBarriered(
static_cast<MG_Pipe::MGPWireOp>(record.kind), record.payload, MG_Pipe::MGPipeApplier());
MG_Pipe::MGPipeApplierSetCurrentRecordBarriered(
MGPipeApplierStampsBarriered(MG_Pipe::kMGPipeP5eClientWaitRuleLanded,
MGPipeServerPublishesRunAhead(), wireSaysBarriered));
// P5e (gl), ID-128: AND WHETHER IT WAS BARRIERED BY ESCALATION RATHER THAN BY ITS CLASS.
// The predicate above is the static wait class plus two payload-derived escalations
// (ID-83: an open transform-feedback span, a draw carrying client vertex arrays), so the
// difference between it and the table IS the escalation - both halves are already
// computed here, and the second flag costs one compare. The strict knob is the only
// reader: a pull on an escalated record is a debt the phase that owns XFB (§5.7) or
// client arrays (ID-82) owes, neither of which is this one.
MG_Pipe::MGPipeApplierSetCurrentRecordBarrieredByEscalation(
wireSaysBarriered &&
MG_Pipe::MGPipeWaitClassFor(static_cast<MG_Pipe::MGPWireOp>(record.kind)) ==
MG_Pipe::kWaitNone);
// ORDER IS THE CONTRACT'S: stamp, then apply. The stamp is what makes any server-side
// read of gPipeInputs legal at all (PipeApplier.h's block 1), so a record applied
// before it aborts on the FIRST field inside SyncRenderState.
StampVerbBoundary(static_cast<MG_Pipe::MGPWireOp>(record.kind));
const Bool applied = m_decoder.DecodeAndApply(record);
// AND THE CLEAR IS INSIDE ApplyOne, NOT AFTER THE DRAIN BATCH. That is not tidiness,
// it is the barrier invariant. s1's SessionConsumer::ApplyOne publishes appliedSeq the
// instant this returns, and publishing appliedSeq is what makes the CLIENT runnable
// again (R-1: the barrier waits on exactly that watermark). A clear that ran after the
// batch would therefore be a second writer of gPipeInputs while the client is already
// touching it - the one thing table 3 says may not be introduced before the barrier
// retires - and the first version of this file had it there. It was caught by
// AClearRecordCrossesAndIsStampedAsAVerbBoundary failing INTERMITTENTLY, which is what
// a race looks like from the outside.
//
// THE COST, STATED: a record that is NOT a verb boundary now applies with the flag
// disarmed, so a sticky forward pulled from inside such a record's applier is not
// counted in `rsp`. Closing that needs an "enter the applier" entry point beside
// MGPipeServerStampVerbBoundary that arms the flag WITHOUT re-stamping - re-stamping on
// a non-verb op is what p1 forbids outright - and PipeInputs.cpp is p1's file. Left for
// the integrator to sequence; it makes `rsp` larger, never smaller, so the number this
// phase reports is a floor.
LeaveApplier();
return applied;
}
// p1's rule verbatim (p1-v1 2). MGPipeVerbForWireOp is generated from MGP_VERB_OP_LIST in
// FieldOwnership.def and answers kVerbCount for every op that is NOT a verb boundary, so
// calling it unconditionally on every record is both correct and cheap. Four ops stamp:
// Clear -> Clear, DrawVbo -> DrawArrays, ReadPixels -> ReadPixels, Blit -> BlitFramebuffer.
//
// PRESENT IS DELIBERATELY NOT ONE, although contract 7 puts it in class B: FillPoints.def:21
// says Present and SetSwapInterval "go through BackendObject virtuals and read no frontend
// state, so they are not verbs here". There is no MGPipeVerb::Present, and stamping there
// would retire the previous verb's answers with nothing to put in their place.
void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp op) {
const MG_Pipe::MGPipeVerb verb = MG_Pipe::MGPipeVerbForWireOp(op);
if (verb == MG_Pipe::MGPipeVerb::kVerbCount) return; // not a verb boundary: stamp nothing
MG_Pipe::MGPipeServerStampVerbBoundary(verb);
}
void PipeApplier::LeaveApplier() { MG_Pipe::MGPipeServerClearVerbBoundary(); }
Uint64 PipeApplier::ResidualPullCount() const { return MG_Pipe::MGPipeResidualPullCount(); }
// The decoder poisons EXACTLY the runs it resolved, from inside DecodeAndApply, once the
// applier has returned - so this entry point is the manual one, for a caller that knows a
// range is dead and is not the decoder. It is kept because c0's signature block declares
// it and because the R-11 copy in Managers.cpp is verified by poisoning a range by hand in
// a unit case; nothing on the live path calls it.
void PipeApplier::PoisonRetiredStageBytes(Uint64 offset, Uint64 size) {
if (size == 0 || m_segments == nullptr) return;
#if MOBILEGL_BUILD_DISAGGREGATED
if (!MG_Config::Ipc.Audit) return;
#endif
const void* run = m_segments->Resolve(Wire::kSegStage, offset, size);
if (run == nullptr) return;
std::memset(const_cast<void*>(run), 0xDD, static_cast<SizeT>(size));
}
Uint64 PipeApplier::PoisonedStageBytes() const { return m_decoder.PoisonedStageBytes(); }
Uint64 PipeApplier::DecoderAppliedSeq() const { return m_decoder.AppliedSeq(); }
} // namespace MobileGL::MG_Remote::Server