From aa263d216912a09e556a59164392eea95d150828 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 13:58:06 -0400 Subject: [PATCH 1/9] [Feat] (MG_Remote): the G3 record codec - encoder, decoder, SEG_STAGE allocator and R-2s honesty arms, with the per-opcode tail cross-check MGP_WIRE_CHECK_BOUNDS cannot see --- MobileGL/MG_Remote/Wire/PipeWireCodec.cpp | 1456 ++++++++++++++++++++- MobileGL/MG_Remote/Wire/PipeWireCodec.h | 192 +++ 2 files changed, 1613 insertions(+), 35 deletions(-) diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index 4a81aee3..b01d2c65 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -6,19 +6,42 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0's stubs for package w1. Every body is MGLOG_F + std::abort and NOT a silent no-op: -// an unimplemented codec that returns quietly is exactly how a split lane runs monolith and -// goes green, which is the failure the whole phase is built to make impossible. +// P5 package w1: the G3 record codec. +// +// ONE CALL IN, BYTES OUT; BYTES IN, ONE MGPipeApply* CALL OUT. This file owns NO semantics - +// MG_Pipe/PipeApply.cpp is not edited by this package and every decoder arm ends in an +// existing free function or in the verb sink v1 installs. An arm that "handles" a record +// itself rather than delegating is a review failure (R-4's rule, one level down). +// +// THE THING THAT MOST NEARLY WENT WRONG, WRITTEN AT THE TOP BECAUSE IT IS INVISIBLE: +// MGPWireRecHeader::Flags and Transport::RingRecordHeader::flags ARE THE SAME 16-BIT FIELD, +// and the two flag spaces COLLIDE. MGPipeCallFlags::kVarTail is 1<<2 and +// RingRecordFlags::kRecPad is 1<<2, so an encoder that stamped MGPipeCallFlagsFor(op) into +// the header - which the generated comment ("MGPipeCallFlags of the call") invites - would +// make RingConsumer::Pop skip every one of the nine kVarTail records as a WRAP FILLER. +// Silently, with no checksum anywhere on this ring. MGPipeCallFlags::kHostSpan (1<<3) lands +// on kRecBorrowSlot the same way and would retire those slots on the GPU timeline instead of +// on apply. So: THE HEADER CARRIES RING FRAMING FLAGS, and the call's own flags are read from +// the opcode through MGPipeCallFlagsFor (R-13.4), which is why that table was generated in the +// first place. The static_asserts below pin the collision so it cannot be re-introduced by +// someone who reads the comment and not this file. #include "PipeWireCodec.h" +#include +#include +#include #include +#include #include #include +#include namespace MobileGL::MG_Remote::Wire { + using namespace MobileGL::MG_Pipe; + // Table 0's first row, mechanised: this enum and the schema's SegmentKind are ONE id // space, and the only place they are compared is here. A schema edit that renumbers a // segment is a build break rather than a wrong pointer on a ring. @@ -38,30 +61,236 @@ namespace MobileGL::MG_Remote::Wire { static_assert(static_cast(MG_Pipe::kMGHostSpanSegNone) == kSegNone, "kMGHostSpanSegNone and SegmentId::kSegNone must be the same value"); -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedWireCodec, \"%s\"} - P5 package w1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + // The collision named in the file header, asserted rather than described. If a later edit + // moves either bit these fire and the mapping below is revisited; if someone deletes the + // mapping and stamps MGPipeCallFlags straight into the header, the RingTest wrap cases + // stay green and nine opcodes vanish, which is why this is a static_assert and not a + // comment. + static_assert(static_cast(MG_Pipe::kVarTail) == + static_cast(Transport::kRecPad), + "MGPipeCallFlags::kVarTail and RingRecordFlags::kRecPad share a bit AND a " + "field; the encoder must translate, never stamp. If this ever stops being " + "true, keep translating anyway - two flag spaces in one field is the hazard, " + "not this particular overlap"); + static_assert(static_cast(MG_Pipe::kHostSpan) == + static_cast(Transport::kRecBorrowSlot), + "MGPipeCallFlags::kHostSpan and RingRecordFlags::kRecBorrowSlot share a bit"); + static_assert(static_cast(MG_Pipe::kNeedsAck) == + static_cast(Transport::kRecNeedsAck), + "the two kNeedsAck bits agree; the mapping below relies on it only for " + "readability, not for correctness"); + static_assert(static_cast(MG_Pipe::kHasBlob) == + static_cast(Transport::kRecHasBlob), + "the two blob bits agree"); - void SegmentTable::Install(SegmentId, SegmentView) { MGP5_C0_STUB("SegmentTable::Install"); } + // --------------------------------------------------------------------------------- + // The catalogue, once. Name and payload type per opcode. + // --------------------------------------------------------------------------------- + // + // ONE LIST, THREE READERS: the name table (Fatal lines), the payload-size table (the tail + // arithmetic's base) and the decoder's own completeness assertion. Hand-maintained lists + // that say the same thing three times are how a catalogue edit lands in two of them. + // gen_pipe.py owns PipeCalls.def and PipeWire.inc; this list is held to them by the + // kOpCount assertion under it, which is the strongest statement a non-generated file can + // make about a generated enum. +#define MGPW_FOR_EACH_CALL(X) \ + X(GetCaps, MGPCaps) \ + X(ResourceCreate, MGPResourceDesc) \ + X(ResourceRespecify, MGPResourceDesc) \ + X(ResourceDestroy, MGPHandleOnly) \ + X(MapPersistent, MGPHandleOnly) \ + X(UnmapPersistent, MGPHandleOnly) \ + X(FenceCreate, MGPHandleOnly) \ + X(FenceStatus, MGPHandleOnly) \ + X(FenceWait, MGPFenceWait) \ + X(FenceDestroy, MGPHandleOnly) \ + X(QueryCreate, MGPQueryDesc) \ + X(QueryBegin, MGPQueryDesc) \ + X(QueryEnd, MGPQueryDesc) \ + X(QueryAvailable, MGPHandleOnly) \ + X(QueryResult, MGPQueryResultRequest) \ + X(QueryDestroy, MGPHandleOnly) \ + X(CreateRenderState, MGPRenderStateDesc) \ + X(BindRenderState, MGPBindRenderState) \ + X(DeleteRenderState, MGPHandleOnly) \ + X(CreateVertexElements, MGPVertexElements) \ + X(BindVertexElements, MGPHandleOnly) \ + X(DeleteVertexElements, MGPHandleOnly) \ + X(CreateSamplerState, MGPSamplerDesc) \ + X(DeleteSamplerState, MGPHandleOnly) \ + X(CreateSamplerView, MGPSamplerView) \ + X(DeleteSamplerView, MGPHandleOnly) \ + X(CreateShaderState, MGPProgramDesc) \ + X(BindShaderState, MGPHandleOnly) \ + X(DeleteShaderState, MGPHandleOnly) \ + X(SetDynamicState, MGPDynamicState) \ + X(SetFramebufferState, MGPFramebufferState) \ + X(SetVertexBuffers, MGPVertexBuffers) \ + X(SetIndexBuffer, MGPIndexBuffer) \ + X(SetIndirectBuffers, MGPIndirectBuffers) \ + X(SetSamplerViews, MGPSamplerViews) \ + X(BindSamplerStates, MGPSamplerStates) \ + X(SetShaderImages, MGPShaderImages) \ + X(SetShaderBuffers, MGPShaderBuffers) \ + X(SetStreamOutputTargets, MGPStreamOutputTargets) \ + X(SetGlobalConstants, MGPGlobalConstants) \ + X(SetVertexAttribDefaults, MGPVertexAttribDefaults) \ + X(SetPixelPackState, MGPPixelPackState) \ + X(SetPatchState, MGPPatchState) \ + X(SetDrawProgram, MGPHandleOnly) \ + X(SetDispatchProgram, MGPHandleOnly) \ + X(SetResidualValueState, MGPResidualValueState) \ + X(SetTextureParams, MGPTextureParams) \ + X(ResourceSubData, MGPSubData) \ + X(BufferSubDataResident, MGPSubData) \ + X(ResourceSubDataComplete, MGPSubDataComplete) \ + X(ResourceFlushRange, MGPFlushRange) \ + X(ResourceReadback, MGPReadback) \ + X(ResourceCopyRegion, MGPCopyRegion) \ + X(GenerateMipmap, MGPMipPlan) \ + X(GetTextureImage, MGPReadbackInfo) \ + X(Blit, MGPBlit) \ + X(Clear, MGPClear) \ + X(ReadPixels, MGPReadbackInfo) \ + X(DrawVbo, MGPDrawInfo) \ + X(LaunchGrid, MGPGridInfo) \ + X(MemoryBarrier, MGPMemoryBarrier) \ + X(BeginStreamOutput, MGPStreamOutputBegin) \ + X(EndStreamOutput, MGPXfbAccounting) \ + X(PauseStreamOutput, MGPStreamOutputControl) \ + X(ResumeStreamOutput, MGPStreamOutputControl) \ + X(Flush, MGPFlush) \ + X(Present, MGPPresent) \ + X(SetSwapInterval, MGPSwapInterval) \ + X(QueryTimestamp, MGPTimestampRequest) \ + X(QueryCounter, MGPQueryDesc) \ + X(FenceWaitServer, MGPFenceWait) - SegmentView SegmentTable::Get(SegmentId) const { MGP5_C0_STUB("SegmentTable::Get"); } + namespace { - const void* SegmentTable::Resolve(Uint32, Uint64, Uint64) const { - MGP5_C0_STUB("SegmentTable::Resolve"); + constexpr Uint64 Align8(Uint64 value) { return (value + 7u) & ~Uint64(7u); } + +#define MGPW_NAME_ROW(Name, Payload) #Name, + constexpr const char* kWireOpNames[] = { + "", + MGPW_FOR_EACH_CALL(MGPW_NAME_ROW) + }; +#undef MGPW_NAME_ROW + + // The catalogue grew or shrank. Add the new opcode to MGPW_FOR_EACH_CALL, give it an + // arm in ApplyChecked, and decide in CONTRACT-P5.md table 1 whether it carries bytes. + static_assert(sizeof(kWireOpNames) / sizeof(kWireOpNames[0]) == + static_cast(MGPWireOp::kOpCount), + "PipeCalls.def and this file's call list disagree"); + + // The payload struct's own sizeof, which is what the tail arithmetic starts from. + // NOT sizeof(MGPWireRec_X) - 8: three payloads are not multiples of 8 and the record + // is rounded up around them. +#define MGPW_SIZE_ROW(Name, Payload) sizeof(MG_Pipe::Payload), + constexpr Uint64 kWirePayloadBytes[] = { + 0, + MGPW_FOR_EACH_CALL(MGPW_SIZE_ROW) + }; +#undef MGPW_SIZE_ROW + static_assert(sizeof(kWirePayloadBytes) / sizeof(kWirePayloadBytes[0]) == + static_cast(MGPWireOp::kOpCount)); + + // The header's Size field is 32 bits by wire contract (Ring.h's kMaxRingCapacity + // comment). Every count-derived total is checked against this BEFORE it is used, so a + // corrupt Count can never wrap the arithmetic that is supposed to catch it. + constexpr Uint64 kMaxRecordBytesOnTheWire = 0xFFFFFFFFull; + + // MGPResidualValueState's blob is the block itself, which only ever ratchets DOWN. + static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE); + + // CreateShaderState's seven blob members are one contiguous run, which is what lets + // the honesty pass walk them as an array. + static_assert(offsetof(MGPProgramDesc, Reflection) == + offsetof(MGPProgramDesc, Spirv) + 6 * sizeof(MGPBlobRef), + "MGPProgramDesc's seven MGPBlobRefs must stay contiguous"); + static_assert(offsetof(MGPCaps, RendererInfo) == + offsetof(MGPCaps, FormatCapabilities) + sizeof(MGPBlobRef), + "MGPCaps's two MGPBlobRefs must stay contiguous"); + + // Where an op's MGPBlobRef members live inside its payload. Ten rows: the eight + // kHasBlob calls plus the two R-13.1 corrected ones. A row's Count is how many + // CONSECUTIVE MGPBlobRefs start at Offset. + struct BlobSlots { + Uint32 Offset = 0; + Uint32 Count = 0; + }; + + BlobSlots BlobSlotsFor(MGPWireOp op) { + switch (op) { + case MGPWireOp::GetCaps: + return {static_cast(offsetof(MGPCaps, FormatCapabilities)), 2}; + case MGPWireOp::CreateRenderState: + return {static_cast(offsetof(MGPRenderStateDesc, Blob)), 1}; + case MGPWireOp::CreateVertexElements: + return {static_cast(offsetof(MGPVertexElements, Blob)), 1}; + case MGPWireOp::CreateSamplerState: + return {static_cast(offsetof(MGPSamplerDesc, Parameters)), 1}; + case MGPWireOp::CreateShaderState: + return {static_cast(offsetof(MGPProgramDesc, Spirv)), 7}; + case MGPWireOp::SetDynamicState: + return {static_cast(offsetof(MGPDynamicState, Blob)), 1}; + case MGPWireOp::SetGlobalConstants: + return {static_cast(offsetof(MGPGlobalConstants, Blob)), 1}; + case MGPWireOp::SetResidualValueState: + return {static_cast(offsetof(MGPResidualValueState, Blob)), 1}; + case MGPWireOp::ResourceSubData: + case MGPWireOp::BufferSubDataResident: + return {static_cast(offsetof(MGPSubData, Blob)), 1}; + default: + return {}; + } + } + + Uint32 PopCount32(Uint32 value) { + Uint32 count = 0; + while (value != 0) { + value &= value - 1u; + ++count; + } + return count; + } + + // The one place a count-times-element-size becomes a byte count. Uint32 * a small + // constant cannot overflow 64 bits, and the total is bounded above before anyone + // indexes with it. + Uint64 TailBytesFor(Uint64 count, Uint64 elementBytes) { return count * elementBytes; } + + // The process resolver is a plain function pointer with no user datum + // (MGPipeHostSpan.h:46), so the table it resolves through has to be found here. + SegmentTable* g_processResolverTable = nullptr; + + const void* ProcessResolverThunk(Uint32 seg, Uint64 offset, Uint64 size) { + if (g_processResolverTable == nullptr) { + return nullptr; + } + return g_processResolverTable->Resolve(seg, offset, size); + } + + // The decoder currently inside MGPipeApplyWireRecord. Thread-local rather than a file + // static: there is one apply thread by construction, but a unit suite drives several + // decoders from several test threads and a shared slot would make those cases depend + // on each other. + thread_local PipeWireDecoder* t_activeDecoder = nullptr; + + } // namespace + + const char* WireOpName(MGPWireOp op) { + const SizeT index = static_cast(op); + if (index >= static_cast(MGPWireOp::kOpCount)) { + return ""; + } + return kWireOpNames[index]; } - void SegmentTable::InstallProcessResolver() { MGP5_C0_STUB("SegmentTable::InstallProcessResolver"); } + // --------------------------------------------------------------------------------- + // The Fatal arms, worded once + // --------------------------------------------------------------------------------- - void SegmentTable::UninstallProcessResolver() { - MGP5_C0_STUB("SegmentTable::UninstallProcessResolver"); - } - - // NOT a stub: the two Fatal helpers are the one thing every package needs on day one, and - // a Fatal that is itself unimplemented would report the wrong failure. void WireProtocolFatal(const char* what, const char* detail) { MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} %s", what, detail != nullptr ? detail : ""); std::abort(); @@ -73,11 +302,277 @@ namespace MobileGL::MG_Remote::Wire { std::abort(); } - void CheckBlobIsHonest(MG_Pipe::MGPWireOp, const MG_Pipe::MGPBlobRef&, const SegmentTable&) { - MGP5_C0_STUB("CheckBlobIsHonest"); + // --------------------------------------------------------------------------------- + // SegmentTable + // --------------------------------------------------------------------------------- + + void SegmentTable::Install(SegmentId seg, SegmentView view) { + if (seg == kSegNone || static_cast(seg) > static_cast(kSegAdopt)) { + WireProtocolFatalAt("SegmentTable::Install", static_cast(seg), + static_cast(kSegAdopt)); + } + m_views[static_cast(seg)] = view; } - void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan&) { MGP5_C0_STUB("CheckHostSpanIsHonest"); } + SegmentView SegmentTable::Get(SegmentId seg) const { + if (seg == kSegNone || static_cast(seg) > static_cast(kSegAdopt)) { + return SegmentView{}; + } + return m_views[static_cast(seg)]; + } + + const void* SegmentTable::Resolve(Uint32 seg, Uint64 offset, Uint64 size) const { + // 0 is "no segment", ALWAYS (table 0), and 0xFFFFFFFF is P8's index-mirror sentinel + // which this phase does not resolve. Both land here as "unknown", and the CALLER + // escalates to Fatal - a unit case has to be able to exercise this arithmetic without + // dying. + if (seg == kSegNone || seg > static_cast(kSegAdopt)) { + return nullptr; + } + if (size == 0) { + return nullptr; + } + const SegmentView& view = m_views[static_cast(seg)]; + if (view.Base == nullptr) { + return nullptr; + } + // Written as a subtraction so offset + size cannot wrap: both are attacker-controlled + // in the only sense that matters here - they came off a shared page. + if (offset > view.Size || size > view.Size - offset) { + return nullptr; + } + return static_cast(view.Base) + offset; + } + + void SegmentTable::InstallProcessResolver() { + if (MG_Pipe::gMGPipeSegmentResolver != nullptr && g_processResolverTable != this) { + // Table 3: there is exactly ONE gMGPipeSegmentResolver per process, the SERVER + // role installs it before the apply thread starts, and the client never resolves a + // span at all. Two roles racing on one inline variable is loud rather than silent + // because of this line. + WireProtocolFatal("SegmentTable::InstallProcessResolver", + "a process segment resolver is already installed; the server role " + "installs it once, before the apply thread starts"); + } + g_processResolverTable = this; + MG_Pipe::gMGPipeSegmentResolver = &ProcessResolverThunk; + } + + void SegmentTable::UninstallProcessResolver() { + // Teardown order (table 3): uninstall AFTER the join, never before - a record still in + // flight can still resolve. + MG_Pipe::gMGPipeSegmentResolver = nullptr; + g_processResolverTable = nullptr; + } + + // --------------------------------------------------------------------------------- + // R-2's honesty arms + // --------------------------------------------------------------------------------- + + void CheckBlobIsHonest(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { + if (blob.Size == 0) { + if (blob.Seg != kSegNone || blob.Offset != 0) { + // The monolith shape: Seg = None, Offset = a host address, Size = 0. Legal + // there, and precisely what must not cross (rule A). Reading it as "absent" + // would silently drop the bytes of every record an unconverted emitter sent. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} a blob with Size 0 " + "declares Seg=%u Offset=%llu; under split a blobref is either fully " + "declared or all three fields zero", + WireOpName(op), static_cast(blob.Seg), + static_cast(blob.Offset)); + std::abort(); + } + return; + } + if (blob.Seg == kSegNone) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} Size=%llu with no segment " + "(R-2.3): a non-zero Size must name a real SEG_*", + WireOpName(op), static_cast(blob.Size)); + std::abort(); + } + if (segments.Resolve(blob.Seg, blob.Offset, blob.Size) == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu size=%llu " + "does not lie inside that segment (R-2.3)", + WireOpName(op), static_cast(blob.Seg), + static_cast(blob.Offset), + static_cast(blob.Size)); + std::abort(); + } + } + + void RequireDeclaredBlob(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { + if (blob.Size == 0) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} the record's own fields say " + "it carries content and its blob declares none (R-2.2 / rule A)", + WireOpName(op)); + std::abort(); + } + CheckBlobIsHonest(op, blob, segments); + } + + void CheckHostSpanIsHonest(const MGHostSpan& span) { + if (span.Ptr != nullptr) { + // Rule B. In one address space this pointer WORKS, which is the whole reason the + // rule has to be mechanical: an inproc implementation that kept using it is + // indistinguishable from a correct one until the day it is a second process. + WireProtocolFatal("host-span", + "MGHostSpan::Ptr is non-null under split; the encoder writes " + "nullptr and names SEG_STAGE (R-2.1)"); + } + if (span.Size != 0 && span.Seg == kMGHostSpanSegNone) { + WireProtocolFatalAt("host-span.seg", span.Size, 0); + } + if (span.Size == 0 && span.Seg != kMGHostSpanSegNone) { + WireProtocolFatalAt("host-span.size", span.Seg, 0); + } + } + + // --------------------------------------------------------------------------------- + // The record layout: the tail arithmetic both sides run + // --------------------------------------------------------------------------------- + + Bool MGPipeWireRecordLayout(MGPWireOp op, const void* payload, WireRecordLayout& out) { + out = WireRecordLayout{}; + const SizeT index = static_cast(op); + if (index == 0 || index >= static_cast(MGPWireOp::kOpCount)) { + return false; + } + out.PayloadBytes = kWirePayloadBytes[index]; + const Uint64 afterPayload = Align8(sizeof(MGPWireRecHeader) + out.PayloadBytes); + out.TotalBytes = afterPayload; + if (payload == nullptr) { + return true; + } + + // Tail sizes. The COUNTS come off the wire, so every product is bounded before it is + // believed, and the bound is the header's own 32-bit Size field. + Uint64 tail0 = 0; + Uint64 tail1 = 0; + Uint32 tails = 0; + switch (op) { + case MGPWireOp::SetVertexBuffers: { + const auto& p = *static_cast(payload); + if (p.Count > kMGPipeMaxVertexAttribs || + static_cast(p.Start) + p.Count > kMGPipeMaxVertexAttribs) { + WireProtocolFatalAt("SetVertexBuffers.Count", + static_cast(p.Start) + p.Count, kMGPipeMaxVertexAttribs); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPVertexBuffer)); + tails = 1; + break; + } + case MGPWireOp::SetSamplerViews: { + const auto& p = *static_cast(payload); + if (static_cast(p.Start) + p.Count > kMGPipeMaxTextureUnits) { + WireProtocolFatalAt("SetSamplerViews.Count", static_cast(p.Start) + p.Count, + kMGPipeMaxTextureUnits); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPBoundView)); + tails = 1; + break; + } + case MGPWireOp::BindSamplerStates: { + const auto& p = *static_cast(payload); + if (static_cast(p.Start) + p.Count > kMGPipeMaxTextureUnits) { + WireProtocolFatalAt("BindSamplerStates.Count", + static_cast(p.Start) + p.Count, kMGPipeMaxTextureUnits); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPipeHandle)); + tails = 1; + break; + } + case MGPWireOp::SetShaderImages: { + const auto& p = *static_cast(payload); + if (static_cast(p.Start) + p.Count > kMGPipeMaxImageUnits) { + WireProtocolFatalAt("SetShaderImages.Count", static_cast(p.Start) + p.Count, + kMGPipeMaxImageUnits); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPImageView)); + tails = 1; + break; + } + case MGPWireOp::SetShaderBuffers: { + // TWO TAILS. HostSpanCount is 0 or Count and NEVER anything else + // (MGPipeTypes.h:820-823), because the two arrays stay index-aligned; a third + // value would let a record describe spans for ranges it does not have. + const auto& p = *static_cast(payload); + if (p.HostSpanCount != 0 && p.HostSpanCount != p.Count) { + WireProtocolFatalAt("SetShaderBuffers.HostSpanCount", p.HostSpanCount, p.Count); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPBufferRange)); + tail1 = TailBytesFor(p.HostSpanCount, sizeof(MGHostSpan)); + tails = p.HostSpanCount != 0 ? 2 : 1; + break; + } + case MGPWireOp::SetStreamOutputTargets: { + // TWO TAILS, one Count. MGPBufferRange[Count] then Uint32[Count]. + const auto& p = *static_cast(payload); + tail0 = TailBytesFor(p.Count, sizeof(MGPBufferRange)); + tail1 = TailBytesFor(p.Count, sizeof(Uint32)); + tails = 2; + break; + } + case MGPWireOp::SetVertexAttribDefaults: { + // TWO DECLARANTS THAT MUST AGREE (contract table 1 row 15): Count and + // popcount(Mask). Nothing checks this today; a disagreement is a wire fault that + // scatters attribute values onto the wrong locations. + const auto& p = *static_cast(payload); + if (p.Count != PopCount32(p.Mask)) { + WireProtocolFatalAt("SetVertexAttribDefaults.Count", p.Count, PopCount32(p.Mask)); + } + if (p.Count > kMGPipeMaxVertexAttribs) { + WireProtocolFatalAt("SetVertexAttribDefaults.Count", p.Count, kMGPipeMaxVertexAttribs); + } + tail0 = TailBytesFor(p.Count, sizeof(MGPAttribValue)); + tails = 1; + break; + } + case MGPWireOp::ResourceSubData: + case MGPWireOp::BufferSubDataResident: { + const auto& p = *static_cast(payload); + tail0 = TailBytesFor(p.RegionCount, sizeof(MGPSubRegion)); + tails = 1; + break; + } + case MGPWireOp::DrawVbo: { + // MGPDrawRange[NumDraws], then a CONDITIONAL MGHostSpan. The span's start is + // realigned to 8 because MGPDrawRange is twelve bytes: see WireRecordLayout's + // header comment. + const auto& p = *static_cast(payload); + tail0 = TailBytesFor(p.NumDraws, sizeof(MGPDrawRange)); + if ((p.Flags & kDrawHasUserIndices) != 0) { + tail1 = sizeof(MGHostSpan); + tails = 2; + } else { + tails = 1; + } + break; + } + default: + break; + } + + if (tails >= 1) { + out.TailOffset[0] = afterPayload; + out.TailBytes[0] = tail0; + out.TotalBytes = afterPayload + tail0; + } + if (tails >= 2) { + out.TailOffset[1] = Align8(out.TailOffset[0] + tail0); + out.TailBytes[1] = tail1; + out.TotalBytes = out.TailOffset[1] + tail1; + } + out.TailCount = tails; + out.TotalBytes = Align8(out.TotalBytes); + if (out.TotalBytes > kMaxRecordBytesOnTheWire) { + WireProtocolFatalAt("record.Size", out.TotalBytes, kMaxRecordBytesOnTheWire); + } + return true; + } + + // --------------------------------------------------------------------------------- + // Encoder + // --------------------------------------------------------------------------------- PipeWireEncoder::PipeWireEncoder(Transport::RingControl* control, Transport::RingProducer* cmd, Transport::RingProducer* stage, SegmentTable* segments) @@ -85,32 +580,923 @@ namespace MobileGL::MG_Remote::Wire { Bool PipeWireEncoder::Valid() const { return m_control != nullptr && m_cmd != nullptr; } - MG_Pipe::MGPBlobRef PipeWireEncoder::StageBytes(const void*, Uint64) { - MGP5_C0_STUB("PipeWireEncoder::StageBytes"); + MGPBlobRef PipeWireEncoder::StageBytes(const void* bytes, Uint64 size) { + if (!Valid() || m_stage == nullptr || m_segments == nullptr) { + WireProtocolFatal("PipeWireEncoder::StageBytes", + "no SEG_STAGE producer or segment table installed"); + } + if (size == 0) { + // "The record declared no blob" and "the record declared an empty blob" must not + // be spelled the same way on a wire (R-2.2), so this is a programming error rather + // than an empty ref. + WireProtocolFatal("PipeWireEncoder::StageBytes", + "a content blob may not declare zero bytes (R-2.2)"); + } + if (bytes == nullptr) { + WireProtocolFatal("PipeWireEncoder::StageBytes", "non-zero size with a null source"); + } + + void* slot = m_stage->Reserve(static_cast(MGPWireOp::kInvalid), + Transport::kRecHasBlob, size); + if (slot == nullptr) { + // One try at reclaiming what the server has already retired, then give up: a + // second failure means the run genuinely does not fit SEG_STAGE, which R-10 says + // P5 does not chunk and must instead prove it never needs to. + ReclaimStagedBytes(); + slot = m_stage->Reserve(static_cast(MGPWireOp::kInvalid), + Transport::kRecHasBlob, size); + } + if (slot == nullptr) { + MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob does not fit a " + "%llu byte staging ring with %llu bytes free; P5 does not chunk (R-10) - " + "raise MOBILEGL_IPC_STAGE_MB or report the record to the integrator", + static_cast(size), + static_cast(m_stage->Capacity()), + static_cast(m_stage->FreeBytes())); + std::abort(); + } + std::memcpy(slot, bytes, static_cast(size)); + + const SegmentView stageView = m_segments->Get(kSegStage); + if (stageView.Base == nullptr) { + WireProtocolFatal("PipeWireEncoder::StageBytes", "SEG_STAGE has no segment view"); + } + const Uint64 offset = + static_cast(static_cast(slot) - static_cast(stageView.Base)); + + MGPBlobRef ref{}; + ref.Seg = kSegStage; + ref.Offset = offset; + ref.Size = size; + ref.Pad0 = 0; + // The self-check that keeps the two halves of "SEG_STAGE" one thing: the segment view + // the decoder resolves through must cover the ring this producer just wrote into. A + // view installed over the CONTROL page, or over the ring plus its header, resolves to + // a plausible pointer that is not these bytes. + if (m_segments->Resolve(ref.Seg, ref.Offset, ref.Size) != slot) { + WireProtocolFatal("PipeWireEncoder::StageBytes", + "the SEG_STAGE segment view does not cover the staging ring's " + "byte area; the two would resolve to different addresses"); + } + // The stage ring's own Publish: the decoder reads these bytes by OFFSET, never by + // popping the stage ring, so the head has to be visible before the command record + // that names them is. + m_stage->Publish(); + return ref; } - Uint64 PipeWireEncoder::EncodeRecord(MG_Pipe::MGPWireOp, const void*, Uint64, const void*, Uint64) { - MGP5_C0_STUB("PipeWireEncoder::EncodeRecord"); + Uint64 PipeWireEncoder::EncodeRecord(MGPWireOp op, const void* payload, Uint64 payloadBytes, + const void* varTail, Uint64 varTailBytes) { + WireTail tail{varTail, varTailBytes}; + return EncodeRecord(op, payload, payloadBytes, &tail, varTail != nullptr ? 1u : 0u); } - void PipeWireEncoder::Publish() { MGP5_C0_STUB("PipeWireEncoder::Publish"); } + Uint64 PipeWireEncoder::EncodeRecord(MGPWireOp op, const void* payload, Uint64 payloadBytes, + const WireTail* tails, Uint32 tailCount) { + if (!Valid()) { + WireProtocolFatal("PipeWireEncoder::EncodeRecord", "no SEG_CMD producer installed"); + } + if (payload == nullptr) { + WireProtocolFatal("PipeWireEncoder::EncodeRecord", "null payload"); + } + + WireRecordLayout layout{}; + if (!MGPipeWireRecordLayout(op, payload, layout)) { + WireProtocolFatalAt("PipeWireEncoder::EncodeRecord", + static_cast(op), + static_cast(MGPWireOp::kOpCount)); + } + if (payloadBytes != layout.PayloadBytes) { + WireProtocolFatalAt("EncodeRecord.payloadBytes", payloadBytes, layout.PayloadBytes); + } + // The caller's tails are held to the layout the PAYLOAD declares, which is the same + // arithmetic the decoder will run. A Count that says 4000 while the tail holds 8 bytes + // dies here, on the producing side, rather than on a peer that can only say "corrupt". + if (tailCount != layout.TailCount) { + WireProtocolFatalAt("EncodeRecord.tailCount", tailCount, layout.TailCount); + } + for (Uint32 i = 0; i < tailCount; ++i) { + if (tails[i].Size != layout.TailBytes[i]) { + WireProtocolFatalAt("EncodeRecord.tailBytes", tails[i].Size, layout.TailBytes[i]); + } + if (tails[i].Size != 0 && tails[i].Bytes == nullptr) { + WireProtocolFatal("EncodeRecord.tail", "non-zero tail length with a null pointer"); + } + } + + const Uint64 total = layout.TotalBytes; + if (total > m_cmd->MaxRecordBytes()) { + // R-10: P5 does no chunking and must instead PROVE it never needs any. This is + // where the proof fails loudly if it was wrong. + MGLOG_F("MGPipe: Fatal{RingOverrun, \"%s\"} a %llu byte record exceeds " + "RingProducer::MaxRecordBytes() == %llu (half of a %llu byte SEG_CMD); P5 " + "does not chunk (R-10) - report it to the integrator, who decides between " + "early chunking and a bigger default ring", + WireOpName(op), static_cast(total), + static_cast(m_cmd->MaxRecordBytes()), + static_cast(m_cmd->Capacity())); + std::abort(); + } + + // MGPipeCallFlags -> RingRecordFlags. See the file header: these are two flag spaces + // in one 16-bit field and stamping the wrong one loses whole opcodes silently. + const Uint32 callFlags = MGPipeCallFlagsFor(op); + Uint16 ringFlags = Transport::kRecNone; + if ((callFlags & static_cast(kNeedsAck)) != 0) ringFlags |= Transport::kRecNeedsAck; + if ((callFlags & static_cast(kHasBlob)) != 0) ringFlags |= Transport::kRecHasBlob; + if ((callFlags & static_cast(kVarTail)) != 0) ringFlags |= Transport::kRecVarTail; + + void* slot = m_cmd->Reserve(static_cast(op), ringFlags, + total - sizeof(MGPWireRecHeader)); + if (slot == nullptr) { + // The ring is full, not the record too big - Reserve refuses an oversized record + // above, and we already proved this one is not. The caller publishes, waits for + // the apply side and retries. + return kInvalidSeq; + } + + auto* bytes = static_cast(slot); + std::memcpy(bytes, payload, static_cast(payloadBytes)); + // Reserve does not zero the alignment padding it hands back, and these bytes leave the + // process under spawn: a record must be a function of what it declares, not of + // whatever the client's ring last held. + const Uint64 payloadSlack = layout.TailCount > 0 + ? layout.TailOffset[0] - sizeof(MGPWireRecHeader) - payloadBytes + : total - sizeof(MGPWireRecHeader) - payloadBytes; + if (payloadSlack != 0) { + std::memset(bytes + payloadBytes, 0, static_cast(payloadSlack)); + } + Uint64 written = layout.TailCount > 0 ? layout.TailOffset[0] - sizeof(MGPWireRecHeader) + : total - sizeof(MGPWireRecHeader); + for (Uint32 i = 0; i < tailCount; ++i) { + const Uint64 at = layout.TailOffset[i] - sizeof(MGPWireRecHeader); + if (at > written) { + std::memset(bytes + written, 0, static_cast(at - written)); + } + if (tails[i].Size != 0) { + std::memcpy(bytes + at, tails[i].Bytes, static_cast(tails[i].Size)); + } + written = at + tails[i].Size; + } + const Uint64 recordBody = total - sizeof(MGPWireRecHeader); + if (written < recordBody) { + std::memset(bytes + written, 0, static_cast(recordBody - written)); + } + + // R-2 arms 1 and 3, on the PRODUCING side, over the bytes actually written. Doing it + // here rather than only in the decoder is what makes `inproc` worth running: the + // encoder is the half that can still be wrong in a way the decoder would never see, + // because under inproc a host pointer resolves. + if (m_segments != nullptr) { + const BlobSlots slots = BlobSlotsFor(op); + for (Uint32 i = 0; i < slots.Count; ++i) { + MGPBlobRef blob{}; + std::memcpy(&blob, bytes + slots.Offset + i * sizeof(MGPBlobRef), sizeof(MGPBlobRef)); + CheckBlobIsHonest(op, blob, *m_segments); + } + } + if ((callFlags & static_cast(kHostSpan)) != 0 && layout.TailCount == 2 && + layout.TailBytes[1] != 0) { + const Uint64 at = layout.TailOffset[1] - sizeof(MGPWireRecHeader); + const Uint64 spans = layout.TailBytes[1] / sizeof(MGHostSpan); + for (Uint64 i = 0; i < spans; ++i) { + MGHostSpan span{}; + std::memcpy(&span, bytes + at + i * sizeof(MGHostSpan), sizeof(MGHostSpan)); + CheckHostSpanIsHonest(span); + } + } + + if (total > m_maxRecordBytes) { + m_maxRecordBytes = total; + } + ++m_emitSeq; + // The stage mark: where SEG_STAGE stood once everything this record names had been + // staged. ReclaimStagedBytes releases up to the newest mark the server has retired. + if (m_stage != nullptr) { + m_stageMarks.push_back(StageMark{m_emitSeq, m_stage->LocalHead()}); + } + return m_emitSeq; + } + + void PipeWireEncoder::ReclaimStagedBytes() { + if (m_stage == nullptr || m_control == nullptr) { + return; + } + const Uint64 retired = m_control->retiredSeq.load(std::memory_order_acquire); + Uint64 upTo = 0; + Bool found = false; + while (m_stageMarkFront < m_stageMarks.size() && + m_stageMarks[m_stageMarkFront].Seq <= retired) { + upTo = m_stageMarks[m_stageMarkFront].StageCursor; + found = true; + ++m_stageMarkFront; + } + // Compact rather than erase-from-front on every call: the list is at most as long as + // the number of records in flight, which the verb barrier keeps at one or two. + if (m_stageMarkFront != 0 && m_stageMarkFront == m_stageMarks.size()) { + m_stageMarks.clear(); + m_stageMarkFront = 0; + } + if (!found || upTo <= m_stageReclaimed) { + return; + } + m_stageReclaimed = upTo; + // SEG_STAGE is CLIENT-OWNED memory (contract table 1: "client stages, server copies"), + // and the server only ever reads it, so the client is both the producer and the thing + // that frees. What it may not do is free ahead of retiredSeq, which is the whole + // content of R-11 on this side. + m_control->stageAppliedTail.store(upTo, std::memory_order_release); + m_control->stageRetiredTail.store(upTo, std::memory_order_release); + } + + Uint64 PipeWireEncoder::StagedBytesInFlight() const { + if (m_stage == nullptr) { + return 0; + } + return m_stage->LocalHead() - m_stageReclaimed; + } + + void PipeWireEncoder::Publish() { + if (m_cmd == nullptr) { + return; + } + // Publish then notify. The order is pinned by RingTest and must not be swapped: + // notify-then-publish loses the wakeup. The doorbell itself belongs to the SESSION + // (s1) - the codec does not own a Doorbell and must not, or a unit case could not + // drive encoder -> ring -> decoder without one. + if (m_stage != nullptr) { + m_stage->Publish(); + } + m_cmd->Publish(); + if (m_control != nullptr) { + m_control->submittedSeq.store(m_emitSeq, std::memory_order_release); + } + } Uint64 PipeWireEncoder::EmitSeq() const { return m_emitSeq; } Uint64 PipeWireEncoder::MaxRecordBytesSeen() const { return m_maxRecordBytes; } + // --------------------------------------------------------------------------------- + // Decoder + // --------------------------------------------------------------------------------- + PipeWireDecoder::PipeWireDecoder(Transport::RingControl* control, SegmentTable* segments, ReplySink* replies) - : m_control(control), m_segments(segments), m_replies(replies) {} + : m_control(control), m_segments(segments), m_replies(replies) { + m_auditPoison = MG_Config::Ipc.Audit; + } Bool PipeWireDecoder::Valid() const { return m_control != nullptr && m_segments != nullptr; } - Bool PipeWireDecoder::DecodeAndApply(const Transport::RingRecordView&) { - MGP5_C0_STUB("PipeWireDecoder::DecodeAndApply"); - } - Uint64 PipeWireDecoder::AppliedSeq() const { return m_applySeq; } -#undef MGP5_C0_STUB + void PipeWireDecoder::SetVerbSink(WireVerbSink* sink) { m_verbs = sink; } + + WireVerbSink* PipeWireDecoder::VerbSink() const { return m_verbs; } + + void PipeWireDecoder::SetAuditPoison(Bool enabled) { m_auditPoison = enabled; } + + Bool PipeWireDecoder::AuditPoison() const { return m_auditPoison; } + + Uint64 PipeWireDecoder::PoisonedStageBytes() const { return m_poisonedBytes; } + + Bool MGPipeWireRecordApplyThunk(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { + (void)remaining; + if (t_activeDecoder == nullptr) { + return false; + } + return t_activeDecoder->ApplyChecked(op, record, size); + } + + void PipeWireDecoder::NoteResolvedRun(const MGPBlobRef& blob) { + if (blob.Size == 0 || blob.Seg != kSegStage) { + return; + } + if (m_resolvedCount < sizeof(m_resolved) / sizeof(m_resolved[0])) { + m_resolved[m_resolvedCount++] = blob; + } + } + + const void* PipeWireDecoder::ResolveOrFatal(MGPWireOp op, const MGPBlobRef& blob) { + RequireDeclaredBlob(op, blob, *m_segments); + const void* bytes = m_segments->Resolve(blob.Seg, blob.Offset, blob.Size); + if (bytes == nullptr) { + // RequireDeclaredBlob already ran the same arithmetic, so reaching here means the + // table moved under us rather than that the record is wrong. Same Fatal either + // way: there is no recovery from a segment that stopped covering its own runs. + WireProtocolFatalAt("segment-resolve", blob.Offset, blob.Size); + } + NoteResolvedRun(blob); + return bytes; + } + + void PipeWireDecoder::PoisonResolvedRuns() { + if (!m_auditPoison) { + m_resolvedCount = 0; + return; + } + for (Uint32 i = 0; i < m_resolvedCount; ++i) { + const MGPBlobRef& blob = m_resolved[i]; + const SegmentView view = m_segments->Get(kSegStage); + if (view.Base == nullptr || blob.Offset + blob.Size > view.Size) { + continue; + } + // R-2.5. The record has been applied and its bytes are retired, so an applier that + // kept the pointer reads 0xDD next frame instead of bytes that merely happen to + // still be there. Exactly the runs this record resolved, not a conservative window. + std::memset(static_cast(view.Base) + blob.Offset, 0xDD, + static_cast(blob.Size)); + m_poisonedBytes += blob.Size; + } + m_resolvedCount = 0; + } + + Bool PipeWireDecoder::DecodeAndApply(const Transport::RingRecordView& record) { + if (!Valid()) { + WireProtocolFatal("PipeWireDecoder::DecodeAndApply", "no control page or segment table"); + } + if ((record.flags & Transport::kRecPad) != 0 || + record.kind == Transport::kRingPadRecordKind) { + // R-9: a pad does not advance seq, and both sides skip it BEFORE counting. + // RingConsumer::Pop already does; one that reached here has been counted, and a + // seq that drifted by one silently reads another call's reply slot. + WireProtocolFatal("PipeWireDecoder::DecodeAndApply", + "a kRecPad wrap filler reached the decoder; the caller skips pads " + "before counting (R-9)"); + } + + const auto* base = static_cast(record.payload) - sizeof(MGPWireRecHeader); + const Uint64 size = record.payloadSize + sizeof(MGPWireRecHeader); + const MGPWireOp op = static_cast(record.kind); + + m_resolvedCount = 0; + PipeWireDecoder* previous = t_activeDecoder; + t_activeDecoder = this; + // The generated gate first, ALWAYS: MGPipeApplyWireRecord owns the per-opcode + // `size >= sizeof(MGPWireRec_X)` check, because that is the half that follows from the + // opcode alone and therefore belongs to the generator. It then calls back into + // ApplyChecked through the hook, which owns the half that needs the payload. + MG_Pipe::gMGPipeWireRecordApply = &MGPipeWireRecordApplyThunk; + const Bool applied = MG_Pipe::MGPipeApplyWireRecord(op, base, size, size); + t_activeDecoder = previous; + + PoisonResolvedRuns(); + + ++m_applySeq; + // R-9: EVERY record, never batched. The client's verb barrier and every reply wait + // read appliedSeq, and a batched watermark makes a waiter resume on work the server + // has not done. retiredSeq goes with it because nothing in P5 borrows a ring slot into + // the GPU timeline - the day something does, this is the line that splits. + if (m_control != nullptr) { + m_control->appliedSeq.store(m_applySeq, std::memory_order_release); + m_control->retiredSeq.store(m_applySeq, std::memory_order_release); + } + return applied; + } + + // --------------------------------------------------------------------------------- + // The 71 arms + // --------------------------------------------------------------------------------- + // + // EVERY ARM ENDS IN AN EXISTING MGPipeApply* FREE FUNCTION, in the verb sink, or in + // `false`. None of them interprets a field: the packed MGPSubData::Target, the + // MGPImageView::Access encoding and MGPFramebufferState::DrawBuffers' -1 token are all + // read by the applier that already owns them, which is why table 0 could say "a decoder + // that open-codes it is the class-1 defect" and this file could obey. + // + // `false` means "this build deliberately does not implement it" and is the answer for the + // 30-odd rows off P5's reduced path (BRIEF §4's exclusion list: the fence family, the + // query family, compute, XFB, indirect, copy-region, GetTextureImage, GenerateMipmap, + // SetShaderBuffers, SetStreamOutputTargets, ResourceSubDataComplete). They are NOT + // unchecked: every one of them runs the same bounds gate, the same tail cross-check and + // the same blob honesty pass before it declines, so the phase that implements one inherits + // a validated record rather than a validation problem. + Bool PipeWireDecoder::ApplyChecked(MGPWireOp op, const void* record, Uint64 size) { + const auto* base = static_cast(record); + const void* payload = base + sizeof(MGPWireRecHeader); + + WireRecordLayout layout{}; + if (!MGPipeWireRecordLayout(op, payload, layout)) { + WireProtocolFatalAt("opcode", static_cast(op), + static_cast(MGPWireOp::kOpCount)); + } + // THE TAIL CROSS-CHECK (BRIEF §5 w1). The generated gate proved `size >= sizeof(the + // record type)`; it CANNOT SEE THE TAIL, so this is the first thing that holds a + // record declaring Count = 4000 while carrying 8 bytes to its own arithmetic. + if (size != layout.TotalBytes) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} the record declares Size=%llu and " + "its own count fields describe %llu bytes (fixed payload %llu + tails %llu/%llu)", + WireOpName(op), static_cast(size), + static_cast(layout.TotalBytes), + static_cast(layout.PayloadBytes), + static_cast(layout.TailBytes[0]), + static_cast(layout.TailBytes[1])); + std::abort(); + } + + // The record's own ordinal, which is its reply-slot id (R-3). 1-based, and m_applySeq + // only moves after the applier returns. + const Uint64 seq = m_applySeq + 1; + + const auto tailAt = [&](Uint32 i) -> const Uint8* { + return layout.TailBytes[i] != 0 ? base + layout.TailOffset[i] : nullptr; + }; + // The four Bool-returning appliers answer ACCEPTANCE, not "applied" (R-5: the client + // may not re-derive it, because an if-constexpr discard, a stale handle and a refused + // record are all invisible from the call site). The answer rides the reply slot the + // record's seq already names, so no payload of theirs needs an MGPReplySlot member. + const auto postAcceptance = [&](Bool accepted) { + if (m_replies != nullptr) { + m_replies->PostReply(seq, accepted ? ReplySink::kStatusOk : ReplySink::kStatusDeclined, + nullptr, 0); + } + }; + + switch (op) { + + // ---- screen --------------------------------------------------------------------- + case MGPWireOp::GetCaps: + // The SESSION answers this one: the snapshot is built from the server's live + // backend and posted through ServerSession::PublishCapsSnapshot (s1/v1), and the + // two blob serializers it needs are MG_Remote/CapsCodec's. The codec validates the + // record and declines, rather than inventing a caps source of its own. + for (Uint32 i = 0; i < 2; ++i) { + MGPBlobRef blob{}; + std::memcpy(&blob, + base + sizeof(MGPWireRecHeader) + offsetof(MGPCaps, FormatCapabilities) + + i * sizeof(MGPBlobRef), + sizeof(MGPBlobRef)); + CheckBlobIsHonest(op, blob, *m_segments); + } + return false; + + // ---- resources ------------------------------------------------------------------- + case MGPWireOp::ResourceCreate: + postAcceptance(MGPipeApplyResourceCreate(*static_cast(payload))); + return true; + + case MGPWireOp::ResourceRespecify: { + const auto& desc = *static_cast(payload); + // R-13.3: `initialBytes` IS ALWAYS NULL UNDER SPLIT. Initial content arrives as + // resource_subdata records immediately after this one, which is what the texture + // path already does (TextureEmit.h:1137). + // + // The SCOPE is contract table 1 row 19b's carrier, read only through the helpers: + // the presence byte and the pair are one value in three pieces, and an open-coded + // reader that forgets the byte reads level 0 of upload target 0 as a real scope. + MGPRespecifiedLevel level{}; + const MGPRespecifiedLevel* scope = nullptr; + if (!MGPipeRespecifyIsWholeResource(desc)) { + level.UploadTarget = MGPipeRespecifiedUploadTargetOf(desc); + level.Level = MGPipeRespecifiedLevelOf(desc); + scope = &level; + } + postAcceptance(MGPipeApplyResourceRespecify(desc, nullptr, scope)); + return true; + } + + case MGPWireOp::ResourceDestroy: + MGPipeApplyResourceDestroy(*static_cast(payload)); + return true; + + case MGPWireOp::MapPersistent: + // R-6 / R-2.4: A CONSTANT DECLINE IN P5, and the applier is not called at all. + // Two reasons, and the second is the one worth writing down: the answer is a HOST + // POINTER, which cannot cross; and MGPWireRec_MapPersistent's payload is a bare + // MGPHandleOnly, so the record carries NEITHER the `size` NOR the `seedBytes` the + // entry point takes. The phase that lands a real map_persistent needs a payload + // change (MGPipeTypes.h, c0's file) before it can even call the applier. + // + // DECLINED is a real answer, not a failure: the three frontend sites already + // tolerate it (BufferObject.cpp:238, :603-606, :657-660). + if (m_replies != nullptr) { + m_replies->PostReply(seq, ReplySink::kStatusDeclined, nullptr, 0); + } + return true; + + case MGPWireOp::UnmapPersistent: + MGPipeApplyUnmapPersistent(*static_cast(payload)); + return true; + + // ---- fences and queries: off the reduced path (BRIEF §4) ------------------------- + case MGPWireOp::FenceCreate: + case MGPWireOp::FenceStatus: + case MGPWireOp::FenceWait: + case MGPWireOp::FenceDestroy: + case MGPWireOp::FenceWaitServer: + case MGPWireOp::QueryCreate: + case MGPWireOp::QueryBegin: + case MGPWireOp::QueryEnd: + case MGPWireOp::QueryAvailable: + case MGPWireOp::QueryResult: + case MGPWireOp::QueryDestroy: + case MGPWireOp::QueryTimestamp: + case MGPWireOp::QueryCounter: + return false; + + // ---- CSOs ------------------------------------------------------------------------ + case MGPWireOp::CreateRenderState: { + const auto& desc = *static_cast(payload); + const void* chunks = nullptr; + if (desc.ChunkMask != 0) { + // Contract table 1 row 1: nothing reads Blob.Size today, so the decoder is + // where the record is held to it. The expected length is not a guess - the + // chunk table computes it from the mask the record itself carries. + const Uint64 expected = + static_cast(MGPipePipelineChunkBlobBytes(desc.ChunkMask)); + RequireDeclaredBlob(op, desc.Blob, *m_segments); + if (desc.Blob.Size != expected) { + WireProtocolFatalAt("CreateRenderState.Blob", desc.Blob.Size, expected); + } + chunks = ResolveOrFatal(op, desc.Blob); + } else { + CheckBlobIsHonest(op, desc.Blob, *m_segments); + } + MGPipeApplyCreateRenderState(desc, chunks); + return true; + } + + case MGPWireOp::BindRenderState: + MGPipeApplyBindRenderState(*static_cast(payload)); + return true; + + case MGPWireOp::DeleteRenderState: + MGPipeApplyDeleteRenderState(*static_cast(payload)); + return true; + + case MGPWireOp::CreateVertexElements: { + const auto& desc = *static_cast(payload); + // The blob's length cross-check against the two counts is the applier's already + // (PipeApply.cpp:1990-1999) and is the model every other row copies; the decoder + // adds only what the applier cannot see, which is that the bytes exist at all. + const void* blob = ResolveOrFatal(op, desc.Blob); + MGPipeApplyCreateVertexElements(desc, blob); + return true; + } + + case MGPWireOp::BindVertexElements: + MGPipeApplyBindVertexElements(*static_cast(payload)); + return true; + + case MGPWireOp::DeleteVertexElements: + MGPipeApplyDeleteVertexElements(*static_cast(payload)); + return true; + + case MGPWireOp::CreateSamplerState: { + const auto& desc = *static_cast(payload); + // R-13.1 gave this call its kHasBlob. The serialization IS the memcpy - a POD - + // and borderColorForm must survive BYTE FOR BYTE, because all three colour + // representations are always numerically populated and it is the only thing that + // says which one the backend must use (MGPipeTypes.h:425-428). + RequireDeclaredBlob(op, desc.Parameters, *m_segments); + if (desc.Parameters.Size != sizeof(MobileGL::SamplerParameters)) { + WireProtocolFatalAt("CreateSamplerState.Parameters", desc.Parameters.Size, + sizeof(MobileGL::SamplerParameters)); + } + const void* bytes = ResolveOrFatal(op, desc.Parameters); + // Copied into a local rather than reinterpret_cast in place: the staged run is + // 8-aligned by the ring, but SamplerParameters is a frontend type and this file + // may not assume its alignment requirement is one the ring happens to satisfy. + MobileGL::SamplerParameters parameters{}; + std::memcpy(¶meters, bytes, sizeof(parameters)); + MGPipeApplyCreateSamplerState(desc, ¶meters); + return true; + } + + case MGPWireOp::DeleteSamplerState: + MGPipeApplyDeleteSamplerState(*static_cast(payload)); + return true; + + case MGPWireOp::CreateSamplerView: + MGPipeApplyCreateSamplerView(*static_cast(payload)); + return true; + + case MGPWireOp::DeleteSamplerView: + MGPipeApplyDeleteSamplerView(*static_cast(payload)); + return true; + + case MGPWireOp::CreateShaderState: { + const auto& desc = *static_cast(payload); + // ONE ARCHIVE, NOT SEVEN RUNS - w1's ruling, and the cheapest one available. + // EncodeProgramArtifacts already serialises SpirvArtifacts::generatedSpirv, i.e. + // every stage module, so Reflection names the WHOLE archive and Spirv[0..5] stay + // undeclared. Shipping the modules a second time would double the largest record + // in the catalogue for no reader at all: MGPipeApplyCreateShaderState takes + // (desc, link*, spirv*) and reads the modules out of spirv->generatedSpirv - + // PipeApply.cpp touches neither desc.Spirv[] nor desc.Reflection. + // + // A DECLARED Spirv[i] IS FATAL rather than ignored, for resource_flush_range's + // reason (contract §6.3): a second, forgeable way to say the same thing is worse + // than no way at all. Overturn it by measuring that a per-stage run beats one + // archive - and then the archive has to stop carrying generatedSpirv, in the same + // change, or the two disagree. + for (Uint32 i = 0; i < 6; ++i) { + CheckBlobIsHonest(op, desc.Spirv[i], *m_segments); + if (desc.Spirv[i].Size != 0) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"CreateShaderState.Spirv[%u]\"} " + "declares %llu bytes; under split the modules travel inside the " + "Reflection archive and the six per-stage runs stay undeclared", + static_cast(i), + static_cast(desc.Spirv[i].Size)); + std::abort(); + } + } + const void* archive = ResolveOrFatal(op, desc.Reflection); + MG_State::GLState::LinkArtifacts link; + MG_State::GLState::SpirvArtifacts spirv; + if (!MG_State::GLState::DecodeProgramArtifacts(static_cast(archive), + static_cast(desc.Reflection.Size), + link, spirv)) { + WireProtocolFatal("CreateShaderState.Reflection", + "DecodeProgramArtifacts refused the archive - a truncated " + "stream, a codec version mismatch or a struct-size mismatch"); + } + MGPipeApplyCreateShaderState(desc, &link, &spirv); + return true; + } + + case MGPWireOp::BindShaderState: + MGPipeApplyBindShaderState(*static_cast(payload)); + return true; + + case MGPWireOp::DeleteShaderState: + MGPipeApplyDeleteShaderState(*static_cast(payload)); + return true; + + // ---- working state --------------------------------------------------------------- + case MGPWireOp::SetDynamicState: { + const auto& dyn = *static_cast(payload); + const void* chunks = nullptr; + if (dyn.ChunkMask != 0) { + const Uint64 expected = + static_cast(MGPipeDynamicChunkBlobBytes(dyn.ChunkMask)); + RequireDeclaredBlob(op, dyn.Blob, *m_segments); + if (dyn.Blob.Size != expected) { + WireProtocolFatalAt("SetDynamicState.Blob", dyn.Blob.Size, expected); + } + chunks = ResolveOrFatal(op, dyn.Blob); + } else { + CheckBlobIsHonest(op, dyn.Blob, *m_segments); + } + MGPipeApplySetDynamicState(dyn, chunks); + return true; + } + + case MGPWireOp::SetFramebufferState: + MGPipeApplySetFramebufferState(*static_cast(payload)); + return true; + + case MGPWireOp::SetVertexBuffers: + MGPipeApplySetVertexBuffers(*static_cast(payload), + reinterpret_cast(tailAt(0))); + return true; + + case MGPWireOp::SetIndexBuffer: + MGPipeApplySetIndexBuffer(*static_cast(payload)); + return true; + + case MGPWireOp::SetIndirectBuffers: + // Indirect is off the reduced path (BRIEF §4) and has no applier entry point. + return false; + + case MGPWireOp::SetSamplerViews: + MGPipeApplySetSamplerViews(*static_cast(payload), + reinterpret_cast(tailAt(0))); + return true; + + case MGPWireOp::BindSamplerStates: + MGPipeApplyBindSamplerStates(*static_cast(payload), + reinterpret_cast(tailAt(0))); + return true; + + case MGPWireOp::SetShaderImages: + MGPipeApplySetShaderImages(*static_cast(payload), + reinterpret_cast(tailAt(0))); + return true; + + case MGPWireOp::SetShaderBuffers: { + // NO APPLIER ENTRY POINT EXISTS, and P5 does not invent one: the call is on + // BRIEF §4's exclusion list, so a consumer written here would be a semantics + // nobody can test this phase. What the record needs and cannot get later is its + // TAIL ARITHMETIC, which MGPipeWireRecordLayout above has already run - including + // the HostSpanCount-is-0-or-Count rule, the thing that keeps the two arrays + // index-aligned. + // + // kCapNeedsHostUboBytes is 0 for the whole of P5 (table 0), so the second tail is + // always absent here; the honesty pass below is what says so out loud if it ever + // is not. + if (layout.TailCount == 2 && layout.TailBytes[1] != 0) { + const auto* spans = reinterpret_cast(tailAt(1)); + const Uint64 count = layout.TailBytes[1] / sizeof(MGHostSpan); + for (Uint64 i = 0; i < count; ++i) { + MGHostSpan span{}; + std::memcpy(&span, reinterpret_cast(spans) + i * sizeof(MGHostSpan), + sizeof(MGHostSpan)); + CheckHostSpanIsHonest(span); + } + } + return false; + } + + case MGPWireOp::SetStreamOutputTargets: + // Same as above: no applier, off the reduced path, both tails validated. + return false; + + case MGPWireOp::SetGlobalConstants: { + const auto& rec = *static_cast(payload); + // The length cross-check is the applier's (PipeApply.cpp:2784, against the + // program's GlobalUboSize, which this record does not carry). Under rule A it + // stops being inert for the first time, which is the whole point of arming Size. + const void* bytes = ResolveOrFatal(op, rec.Blob); + MGPipeApplySetGlobalConstants(rec, bytes); + return true; + } + + case MGPWireOp::SetVertexAttribDefaults: + // Count == popcount(Mask) was enforced by the layout above: two declarants that + // must agree, and nothing checked it before (contract table 1 row 15). + MGPipeApplySetVertexAttribDefaults(*static_cast(payload), + reinterpret_cast(tailAt(0))); + return true; + + case MGPWireOp::SetPixelPackState: + MGPipeApplySetPixelPackState(*static_cast(payload)); + return true; + + case MGPWireOp::SetPatchState: + MGPipeApplySetPatchState(*static_cast(payload)); + return true; + + case MGPWireOp::SetDrawProgram: + MGPipeApplySetDrawProgram(*static_cast(payload)); + return true; + + case MGPWireOp::SetDispatchProgram: + MGPipeApplySetDispatchProgram(*static_cast(payload)); + return true; + + case MGPWireOp::SetResidualValueState: { + // THE HARDEST ROW IN TABLE 1, and it is hard for a reason that does not show in + // the payload: MGPipeApplySetResidualValueState takes `const ResidualValueBlock&` + // - not a payload, not a const void* - and MGPResidualValueState is NEVER + // INSTANTIATED on the live path (PipeFill.cpp:2184 passes the block straight to + // the applier). So the encoder had to invent both the record fill and the blob + // fill, and this is the first code in the tree that reads either. + // + // The block is the blob, whole, and its size only ever ratchets DOWN + // (MGL_RESIDUAL_BLOCK_SIZE, 1248 -> 8 at P2, 0 at P13). Requiring exact equality + // rather than ">=" is what makes a client built against an older block a loud + // mismatch instead of a silently short read of CapabilityBits. + const auto& rec = *static_cast(payload); + RequireDeclaredBlob(op, rec.Blob, *m_segments); + if (rec.Blob.Size != sizeof(ResidualValueBlock)) { + WireProtocolFatalAt("SetResidualValueState.Blob", rec.Blob.Size, + sizeof(ResidualValueBlock)); + } + const void* bytes = ResolveOrFatal(op, rec.Blob); + ResidualValueBlock block{}; + std::memcpy(&block, bytes, sizeof(block)); + MGPipeApplySetResidualValueState(block); + return true; + } + + case MGPWireOp::SetTextureParams: + postAcceptance(MGPipeApplySetTextureParams(*static_cast(payload))); + return true; + + // ---- transfer --------------------------------------------------------------------- + case MGPWireOp::ResourceSubData: { + const auto& rec = *static_cast(payload); + // Rule A arms both halves. The buffer half already declared a real size in + // monolith and is cross-checked at PipeApply.cpp:702; THE TEXTURE HALF DECLARED 0 + // (TextureEmit.h:1265-1267, on the grounds that the byte count was "the server's + // to compute") and under split it must declare too - a length the reader computes + // from the record it is checking is not a bounds check. + const Bool namesABuffer = rec.Target == kMGPipeResourceTargetBuffer; + const Bool carriesContent = + namesABuffer ? MGPipeSubDataBufferSize(rec) != 0 + : (rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0); + const void* bytes = nullptr; + if (carriesContent) { + bytes = ResolveOrFatal(op, rec.Blob); + } else { + CheckBlobIsHonest(op, rec.Blob, *m_segments); + } + postAcceptance(MGPipeApplyResourceSubData( + rec, bytes, reinterpret_cast(tailAt(0)))); + return true; + } + + case MGPWireOp::BufferSubDataResident: { + const auto& rec = *static_cast(payload); + // kOptional is a CAPABILITY question under split, not a null-pointer one: the + // client gates on kCapResidentSubData through the caps mirror before it emits + // (R-8). By the time a record is here, the answer was already yes. + const void* bytes = MGPipeSubDataBufferSize(rec) != 0 ? ResolveOrFatal(op, rec.Blob) + : nullptr; + if (bytes == nullptr) { + CheckBlobIsHonest(op, rec.Blob, *m_segments); + } + MGPipeApplyBufferSubDataResident(rec, bytes); + return true; + } + + case MGPWireOp::ResourceSubDataComplete: + // The forward terminator of a SERVER-initiated texture pull (section 7.1). There + // is no client producer and no applier; the reverse channel is P7/P9. + return false; + + case MGPWireOp::ResourceFlushRange: + // R-13.2 / contract §6.3: IT CARRIES NO BYTES AT ALL under split. The ladder it + // drives rewrites its range from the AUTHORITATIVE SHADOW, which rule C makes + // server-owned, so resource_subdata is already the only way bytes reach it and a + // second way would be a forgeable one. AccessFlags cross verbatim, never + // normalised (PipeApply.h:902-903). + MGPipeApplyResourceFlushRange(*static_cast(payload), nullptr); + return true; + + case MGPWireOp::ResourceReadback: + // The BYTES go back in SEG_EVENT through OnBufferWriteback (contract table 1 row + // 22) - the destination is the client's shadow and the size is the resource's, not + // a fixed slot's - so the reply slot carries COMPLETION only. + MGPipeApplyResourceReadback(*static_cast(payload)); + if (m_replies != nullptr) { + m_replies->PostReply(seq, ReplySink::kStatusOk, nullptr, 0); + } + return true; + + case MGPWireOp::ResourceCopyRegion: + case MGPWireOp::GenerateMipmap: + case MGPWireOp::GetTextureImage: + // Off the reduced path (BRIEF §4), and GetTextureImage's emit-table slot is + // Fatal{UnmigratedVerb} on the client anyway (contract §7 class C). + return false; + + // ---- the five class-B verbs: no MGPipeApply* exists, so v1's sink or nothing ------- + case MGPWireOp::Blit: + return m_verbs != nullptr && m_verbs->OnBlit(*static_cast(payload)); + + case MGPWireOp::Clear: + return m_verbs != nullptr && m_verbs->OnClear(*static_cast(payload)); + + case MGPWireOp::ReadPixels: + // P5 BLOCKS on read_pixels (ROADMAP.md:21) and the pixels come back in the reply + // slot, which is why ReplyPool::SlotBytes() is sized from the scenario's largest + // read rather than guessed. MGPReadbackInfo has DstOffset/DstSize and no Seg on + // purpose: in P5 the destination is ALWAYS SEG_REPLY (contract table 1 row 23). + return m_verbs != nullptr && + m_verbs->OnReadPixels(*static_cast(payload), seq, m_replies); + + case MGPWireOp::DrawVbo: { + const auto& info = *static_cast(payload); + // The conditional second tail. P5 must produce NONE of these - the reduced path + // draws from a VBO precisely so kDrawHasUserIndices never fires (table 0's cap-bit + // row) - so a span arriving here is a FINDING, not merely a corruption check, and + // CheckHostSpanIsHonest is what makes it one. + const MGHostSpan* userIndices = nullptr; + MGHostSpan span{}; + if ((info.Flags & kDrawHasUserIndices) != 0) { + if (layout.TailCount != 2 || layout.TailBytes[1] != sizeof(MGHostSpan)) { + WireProtocolFatalAt("DrawVbo.userIndices", layout.TailBytes[1], + sizeof(MGHostSpan)); + } + std::memcpy(&span, tailAt(1), sizeof(span)); + CheckHostSpanIsHonest(span); + userIndices = &span; + } + return m_verbs != nullptr && + m_verbs->OnDrawVbo(info, reinterpret_cast(tailAt(0)), + userIndices); + } + + case MGPWireOp::Present: + return m_verbs != nullptr && m_verbs->OnPresent(*static_cast(payload)); + + // ---- compute, XFB, barriers, flush, swap interval: off the reduced path ------------ + case MGPWireOp::LaunchGrid: + case MGPWireOp::MemoryBarrier: + case MGPWireOp::BeginStreamOutput: + case MGPWireOp::EndStreamOutput: + case MGPWireOp::PauseStreamOutput: + case MGPWireOp::ResumeStreamOutput: + case MGPWireOp::SetSwapInterval: + return false; + + case MGPWireOp::Flush: + // NOT A VERB AND NOT A NO-OP DECISION: there is no Flush slot in + // GLFunctionsTable at all, and glFlush/glFinish are empty function bodies + // (Definitions.cpp:111-112), so R-4's predicted minimum set naming Flush was + // wrong (C-3). A TriangleScenario that orders its readback with glFlush orders + // nothing; the verb barrier is what orders it. + return false; + + case MGPWireOp::kInvalid: + case MGPWireOp::kOpCount: + default: + WireProtocolFatalAt("opcode", static_cast(op), + static_cast(MGPWireOp::kOpCount)); + } + } } // namespace MobileGL::MG_Remote::Wire diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index 720ba6b6..7477e925 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -111,13 +111,73 @@ namespace MobileGL::MG_Remote::Wire { // R-2.3 arms 1-4 over one record's blobref. Split only; a monolith emission is exempt by // construction because it never reaches this layer. + // + // ARMS 3 AND 4 ONLY, PLUS ONE THIS FUNCTION HAD TO INVENT. A blobref is honest when it is + // EITHER fully declared - a real Seg, a non-zero Size and an Offset+Size inside that + // segment - OR fully absent, which is all three fields zero. The third shape, a Seg or an + // Offset with Size == 0, is neither, and it is the shape a monolith emitter produces + // today (Seg = None, Offset = a host address, Size = 0), so under split it has to be + // Fatal rather than "absent": a decoder that read it as absent would silently drop the + // bytes of every record an unconverted emitter sent. + // + // ARM 2 - "Blob.Size == 0 on a CONTENT record" - is NOT here and cannot be: whether a + // record carries content is a property of the record's OTHER fields (ChunkMask, the + // destination range, GlobalUboSize, the stage mask), which this signature does not see. + // RequireDeclaredBlob below is arm 2, and the decoder's per-op arm calls it exactly where + // the payload says content is implied. void CheckBlobIsHonest(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, const SegmentTable& segments); + // R-2.3 arm 2: the record's other fields say it carries content, so the blob must be + // declared. Fatal on an absent blob, then CheckBlobIsHonest on a present one. + void RequireDeclaredBlob(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, + const SegmentTable& segments); // R-2.3 arm for MGHostSpan. P5's reduced path should produce ZERO host spans // (kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes are both 0 in P5, table 0), so this // firing at all is a finding, not just a corruption check. void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span); + // ---- one record's shape, computed ONCE and read by both sides ---------------------- + // + // THE TAIL CROSS-CHECK LIVES HERE AND NOWHERE ELSE (BRIEF §5 w1, contract table 1 group + // B). MGP_WIRE_CHECK_BOUNDS only proves `size >= sizeof(MGPWireRec_X)` - IT CANNOT SEE + // THE TAIL - so a record declaring Count = 4000 while carrying 8 bytes passes it. The + // encoder computes this layout from the payload it is about to write and REFUSES a caller + // whose tails disagree; the decoder computes the same layout from the payload it just + // received and REFUSES a record whose MGPWireRecHeader::Size disagrees. Two readers, one + // arithmetic, so the two sides cannot drift. + // + // EVERY TAIL STARTS 8-BYTE ALIGNED WITHIN THE RECORD, and the encoder zero-fills the gap. + // Eight of the nine kVarTail rows are already aligned by construction (their payload and + // element sizes are multiples of 8); DrawVbo is not - MGPDrawRange is TWELVE bytes, so an + // odd NumDraws leaves the conditional MGHostSpan on a 4-byte boundary, and MGHostSpan + // holds a pointer and two Uint64s. P5 emits no host span at all, so this rule costs + // nothing now and is stated now because the phase that arms kDrawHasUserIndices would + // otherwise have to discover it as a misaligned load on a device. + struct WireRecordLayout { + Uint64 PayloadBytes = 0; // sizeof the op's payload struct + Uint64 TailOffset[2] = {0, 0}; // from the START of the record, header included + Uint64 TailBytes[2] = {0, 0}; + Uint32 TailCount = 0; + Uint64 TotalBytes = 0; // header + payload + gaps + tails, rounded up to 8 + }; + + // `payload` must already be known to hold at least the op's payload struct - that is what + // MGP_WIRE_CHECK_BOUNDS proves, and this function is only ever called after it. Returns + // false for an opcode outside the catalogue; a count past its own GL bound is Fatal, + // because a decoder holding such a record has nothing safe left to do with it. + Bool MGPipeWireRecordLayout(MG_Pipe::MGPWireOp op, const void* payload, WireRecordLayout& out); + + // The catalogue's own spelling of an opcode, for a Fatal line. Out of range is "". + const char* WireOpName(MG_Pipe::MGPWireOp op); + + // One tail array. Two of the 71 rows carry two (SetShaderBuffers, SetStreamOutputTargets) + // and DrawVbo carries a conditional second one, which is why EncodeRecord's one-tail form + // could not stay the only one. + struct WireTail { + const void* Bytes = nullptr; + Uint64 Size = 0; + }; + // ---- encoder ----------------------------------------------------------------------- // // Not thread safe: one encoder per client context, driven by the GL thread, by @@ -150,6 +210,31 @@ namespace MobileGL::MG_Remote::Wire { Uint64 EncodeRecord(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, const void* varTail = nullptr, Uint64 varTailBytes = 0); + // The same call for the three rows that carry TWO tails. The one-tail form above is + // this one with tailCount <= 1; nothing is duplicated between them. + // + // The tails a caller hands over are CROSS-CHECKED against the layout the payload + // itself declares (MGPipeWireRecordLayout): a caller whose Count says 4000 while its + // tail holds 8 bytes is Fatal HERE, on the producing side, rather than on a peer that + // can only report a corrupt stream. That is the same arithmetic the decoder runs, so + // the check is real rather than a restatement of the caller's own belief. + Uint64 EncodeRecord(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + const WireTail* tails, Uint32 tailCount); + + // Releases every SEG_STAGE run named by a record the apply side has RETIRED + // (RingControl::retiredSeq, R-9). Called by the client at its verb barrier and + // whenever StageBytes runs short; the allocator reclaims behind retiredSeq and + // nothing else may (table 1's "retires" column, R-11). + // + // THE MARK IS HELD ON THIS SIDE, NOT ON THE WIRE. A record does not carry where its + // staged bytes end, so the encoder remembers {seq, stage cursor} per record and + // reclaims to the newest mark whose seq the server has retired. That is exact, needs + // no wire field, and does not depend on the verb barrier - so it keeps working when + // R-1's barrier retires family by family. + void ReclaimStagedBytes(); + // Staged bytes not yet reclaimed. The number MOBILEGL_IPC_STAGE_MB has to cover. + Uint64 StagedBytesInFlight() const; + // Release-stores the head cursor, then rings the consumer doorbell IF PARKED. The // order is pinned by RingTest.cpp:446 and must not be swapped: notify-then-publish // loses the wakeup. @@ -163,12 +248,21 @@ namespace MobileGL::MG_Remote::Wire { Uint64 MaxRecordBytesSeen() const; private: + // {the record's seq, the SEG_STAGE cursor just past everything that record named}. + struct StageMark { + Uint64 Seq = 0; + Uint64 StageCursor = 0; + }; + Transport::RingControl* m_control = nullptr; Transport::RingProducer* m_cmd = nullptr; Transport::RingProducer* m_stage = nullptr; SegmentTable* m_segments = nullptr; Uint64 m_emitSeq = kInvalidSeq; Uint64 m_maxRecordBytes = 0; + Vector m_stageMarks; + SizeT m_stageMarkFront = 0; + Uint64 m_stageReclaimed = 0; }; // ---- decoder ----------------------------------------------------------------------- @@ -189,6 +283,61 @@ namespace MobileGL::MG_Remote::Wire { virtual void PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) = 0; }; + // ---- the verb sink: the five rows with no MGPipeApply* to delegate to --------------- + // + // The decoder owns NO semantics, so every arm ends in an existing MGPipeApply* free + // function - except five, and they are exactly contract §7's class B: Clear (57), Blit + // (56), ReadPixels (58), DrawVbo (59) and Present (67). Those are GLFunctionsTable VERBS. + // MG_Pipe has no applier for any of them (the 37 MGPipeApply* entry points are the object + // and state families), so for these five P5 writes the first consumer as well as the first + // producer - and the consumer is the SERVER'S backend call, which is v1's, not the codec's. + // + // So the codec does what it can prove and stops there: it bounds-checks, cross-checks the + // tail, resolves the segments and hands over a DECODED, VALIDATED argument list. With no + // sink installed those five arms return false ("this build does not implement it"), which + // is the same answer the other unimplemented rows give. + // + // SetShaderBuffers (38) and SetStreamOutputTargets (39) also have no applier entry point, + // and they deliberately get NO sink method: both are off P5's reduced path (BRIEF §4's + // exclusion list), so inventing a consumer for them would be building a semantics nobody + // can test this phase. Their arms validate both tails - which is the part a later phase + // must not have to re-derive - and return false. + class WireVerbSink { + public: + virtual ~WireVerbSink() = default; + virtual Bool OnClear(const MG_Pipe::MGPClear& clear) { + (void)clear; + return false; + } + virtual Bool OnBlit(const MG_Pipe::MGPBlit& blit) { + (void)blit; + return false; + } + virtual Bool OnPresent(const MG_Pipe::MGPPresent& present) { + (void)present; + return false; + } + // The pixels go back in the reply slot (contract table 1 row 23: the destination is + // ALWAYS SEG_REPLY in P5, which is why MGPReadbackInfo gains no Seg field), so the + // sink is handed the seq and the sink it must answer into. + virtual Bool OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq, ReplySink* replies) { + (void)info; + (void)seq; + (void)replies; + return false; + } + // `ranges` is info.NumDraws entries. `userIndices` is null unless the record set + // kDrawHasUserIndices - which P5 never does, because the reduced path draws from a + // VBO precisely so no MGHostSpan is produced (table 0's cap-bit row). + virtual Bool OnDrawVbo(const MG_Pipe::MGPDrawInfo& info, const MG_Pipe::MGPDrawRange* ranges, + const MG_Pipe::MGHostSpan* userIndices) { + (void)info; + (void)ranges; + (void)userIndices; + return false; + } + }; + // Not thread safe: one decoder on the apply thread, by construction. class PipeWireDecoder { public: @@ -218,13 +367,56 @@ namespace MobileGL::MG_Remote::Wire { // Advanced by exactly one per applied non-pad record. P5 FORBIDS BATCHING IT (R-9): // the verb barrier's waiter reads it, and a batched watermark makes the client wait // for records the server has not run. + // + // DecodeAndApply PUBLISHES RingControl::appliedSeq AND retiredSeq to this value after + // every record, because the decoder is the thing that knows when a record's SEG_STAGE + // runs stopped being read (R-11, table 1's "retires: apply"). v1's PipeApplier must + // therefore NOT advance either watermark a second time - a double advance makes the + // client's barrier resume on a record the server has not run, which is precisely the + // failure R-9's "never publish a watermark early" exists to forbid. Uint64 AppliedSeq() const; + // v1 installs the backend bridge for contract §7's five class-B verbs. Null - the + // default - makes those five arms return false rather than invent a semantics. + void SetVerbSink(WireVerbSink* sink); + WireVerbSink* VerbSink() const; + + // R-2.5 / rule C's mechanical control: with MOBILEGL_IPC_AUDIT=1 every SEG_STAGE byte + // this decoder resolved for a record is overwritten with 0xDD once the applier has + // RETURNED, so an applier that kept the pointer reads 0xDD on the next frame instead + // of bytes that happen to still be there. Off by default; the run is exact - the + // decoder poisons what it resolved, not a conservative window. + void SetAuditPoison(Bool enabled); + Bool AuditPoison() const; + // How many staged bytes this decoder has poisoned. Zero with the audit off, and the + // number a t1 lane asserts is non-zero with it on: an instrumentation that cannot be + // observed to have run is decoration. + Uint64 PoisonedStageBytes() const; + private: + Bool ApplyChecked(MG_Pipe::MGPWireOp op, const void* record, Uint64 size); + const void* ResolveOrFatal(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); + void NoteResolvedRun(const MG_Pipe::MGPBlobRef& blob); + void PoisonResolvedRuns(); + + friend Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp, const void*, Uint64, Uint64); + Transport::RingControl* m_control = nullptr; SegmentTable* m_segments = nullptr; ReplySink* m_replies = nullptr; + WireVerbSink* m_verbs = nullptr; Uint64 m_applySeq = kInvalidSeq; + Bool m_auditPoison = false; + Uint64 m_poisonedBytes = 0; + // The SEG_STAGE runs the record being applied resolved, for the 0xDD fill. At most + // seven (CreateShaderState's blob members) plus one tail. + MG_Pipe::MGPBlobRef m_resolved[8]; + Uint32 m_resolvedCount = 0; }; + // The hook MGPipeApplyWireRecord dispatches to once its generated per-opcode bounds gate + // has passed. Installed by DecodeAndApply on the thread that decodes. + Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp op, const void* record, Uint64 size, + Uint64 remaining); + } // namespace MobileGL::MG_Remote::Wire From 713d1b51ac21bf67618e00fc642dbeaa39bd9c69 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 13:58:06 -0400 Subject: [PATCH 2/9] [Feat] (tools/gen_pipe, MG_Pipe): hand MGPipeApplyWireRecord to the installed decoder once its generated per-opcode bounds gate has passed, behind MOBILEGL_BUILD_DISAGGREGATED so the pull build gains no symbol --- MobileGL/MG_Pipe/generated/PipeWire.inc | 187 ++++++++++++++---------- scripts/gen_pipe.py | 47 +++++- 2 files changed, 150 insertions(+), 84 deletions(-) diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index d909caf9..024426ed 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -820,233 +820,266 @@ static_assert(sizeof(MGPWireRec_FenceWaitServer) == } \ } while (0) -// Returns whether the record was applied. P0 is a SKELETON: every case validates its -// bounds and then reports "not applied", because no applier exists until P5 wires -// MG_Remote/Server/PipeApplier.cpp to the real backend tables. The switch and the opcode -// enum come from the same list, so a call added to the catalogue cannot be forgotten here; -// the default arm is for the opcode that never came from this catalogue at all - a byte -// off a corrupt stream - and it is fatal for the same reason the bounds check is. +#if MOBILEGL_BUILD_DISAGGREGATED +// THE DECODER HOOK (P5 w1). MG_Pipe is BELOW MG_Remote and may not include it, so the real +// 71-arm decoder - MG_Remote/Wire/PipeWireCodec.cpp, which resolves the segments, cross-checks +// the variable tails and calls today's MGPipeApply* free functions - installs itself here. +// The indirection is the layering, not a policy: gMGPipeSegmentResolver +// (MGPipeHostSpan.h:47) is the same shape for the same reason. +// +// It lives behind the build option because G1 admits no symbol movement in a PULL build, and +// an inline variable that MGPipeApplyWireRecord odr-uses would be one. +using MGPipeWireRecordApplyFn = Bool (*)(MGPWireOp op, const void* record, Uint64 size, + Uint64 remaining); +inline MGPipeWireRecordApplyFn gMGPipeWireRecordApply = nullptr; +#endif + +// Returns whether the record was applied. +// +// THE SWITCH IS THE PER-OPCODE BOUNDS GATE AND NOTHING ELSE, AND IT CANNOT SEE THE TAIL. +// MGP_WIRE_CHECK_BOUNDS proves `size >= sizeof(MGPWireRec_X)`, `size <= remaining` and +// 8-alignment - which is exactly the part a generator can state, because it is the part that +// follows from the opcode alone. A kVarTail record declaring Count = 4000 while carrying 8 +// bytes passes every one of those, so the SECOND check - recompute the total from the +// record's own count fields and require it to EQUAL MGPWireRecHeader::Size - belongs to the +// decoder, which has the payload (MG_Remote/Wire's MGPipeWireRecordLayout, P5 w1). +// +// The switch and the opcode enum come from the same list, so a call added to the catalogue +// cannot be forgotten here; the default arm is for the opcode that never came from this +// catalogue at all - a byte off a corrupt stream - and it is fatal for the same reason the +// bounds check is. +// +// With no decoder installed - every monolith build, and a split build before +// ClientSession::Start - this returns false, "this build does not implement it". That is the +// P0 skeleton's answer, kept deliberately: a codec that has not been installed must not look +// like one that applied the record. inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { (void)record; switch (op) { case MGPWireOp::GetCaps: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetCaps, "GetCaps"); - return false; + break; case MGPWireOp::ResourceCreate: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCreate, "ResourceCreate"); - return false; + break; case MGPWireOp::ResourceRespecify: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceRespecify, "ResourceRespecify"); - return false; + break; case MGPWireOp::ResourceDestroy: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceDestroy, "ResourceDestroy"); - return false; + break; case MGPWireOp::MapPersistent: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MapPersistent, "MapPersistent"); - return false; + break; case MGPWireOp::UnmapPersistent: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_UnmapPersistent, "UnmapPersistent"); - return false; + break; case MGPWireOp::FenceCreate: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceCreate, "FenceCreate"); - return false; + break; case MGPWireOp::FenceStatus: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceStatus, "FenceStatus"); - return false; + break; case MGPWireOp::FenceWait: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWait, "FenceWait"); - return false; + break; case MGPWireOp::FenceDestroy: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceDestroy, "FenceDestroy"); - return false; + break; case MGPWireOp::QueryCreate: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCreate, "QueryCreate"); - return false; + break; case MGPWireOp::QueryBegin: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryBegin, "QueryBegin"); - return false; + break; case MGPWireOp::QueryEnd: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryEnd, "QueryEnd"); - return false; + break; case MGPWireOp::QueryAvailable: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryAvailable, "QueryAvailable"); - return false; + break; case MGPWireOp::QueryResult: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryResult, "QueryResult"); - return false; + break; case MGPWireOp::QueryDestroy: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryDestroy, "QueryDestroy"); - return false; + break; case MGPWireOp::CreateRenderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateRenderState, "CreateRenderState"); - return false; + break; case MGPWireOp::BindRenderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindRenderState, "BindRenderState"); - return false; + break; case MGPWireOp::DeleteRenderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteRenderState, "DeleteRenderState"); - return false; + break; case MGPWireOp::CreateVertexElements: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateVertexElements, "CreateVertexElements"); - return false; + break; case MGPWireOp::BindVertexElements: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindVertexElements, "BindVertexElements"); - return false; + break; case MGPWireOp::DeleteVertexElements: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteVertexElements, "DeleteVertexElements"); - return false; + break; case MGPWireOp::CreateSamplerState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerState, "CreateSamplerState"); - return false; + break; case MGPWireOp::DeleteSamplerState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerState, "DeleteSamplerState"); - return false; + break; case MGPWireOp::CreateSamplerView: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerView, "CreateSamplerView"); - return false; + break; case MGPWireOp::DeleteSamplerView: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerView, "DeleteSamplerView"); - return false; + break; case MGPWireOp::CreateShaderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateShaderState, "CreateShaderState"); - return false; + break; case MGPWireOp::BindShaderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindShaderState, "BindShaderState"); - return false; + break; case MGPWireOp::DeleteShaderState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteShaderState, "DeleteShaderState"); - return false; + break; case MGPWireOp::SetDynamicState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDynamicState, "SetDynamicState"); - return false; + break; case MGPWireOp::SetFramebufferState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetFramebufferState, "SetFramebufferState"); - return false; + break; case MGPWireOp::SetVertexBuffers: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexBuffers, "SetVertexBuffers"); - return false; + break; case MGPWireOp::SetIndexBuffer: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndexBuffer, "SetIndexBuffer"); - return false; + break; case MGPWireOp::SetIndirectBuffers: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndirectBuffers, "SetIndirectBuffers"); - return false; + break; case MGPWireOp::SetSamplerViews: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSamplerViews, "SetSamplerViews"); - return false; + break; case MGPWireOp::BindSamplerStates: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindSamplerStates, "BindSamplerStates"); - return false; + break; case MGPWireOp::SetShaderImages: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderImages, "SetShaderImages"); - return false; + break; case MGPWireOp::SetShaderBuffers: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderBuffers, "SetShaderBuffers"); - return false; + break; case MGPWireOp::SetStreamOutputTargets: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetStreamOutputTargets, "SetStreamOutputTargets"); - return false; + break; case MGPWireOp::SetGlobalConstants: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetGlobalConstants, "SetGlobalConstants"); - return false; + break; case MGPWireOp::SetVertexAttribDefaults: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexAttribDefaults, "SetVertexAttribDefaults"); - return false; + break; case MGPWireOp::SetPixelPackState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPixelPackState, "SetPixelPackState"); - return false; + break; case MGPWireOp::SetPatchState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPatchState, "SetPatchState"); - return false; + break; case MGPWireOp::SetDrawProgram: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDrawProgram, "SetDrawProgram"); - return false; + break; case MGPWireOp::SetDispatchProgram: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDispatchProgram, "SetDispatchProgram"); - return false; + break; case MGPWireOp::SetResidualValueState: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetResidualValueState, "SetResidualValueState"); - return false; + break; case MGPWireOp::SetTextureParams: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetTextureParams, "SetTextureParams"); - return false; + break; case MGPWireOp::ResourceSubData: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubData, "ResourceSubData"); - return false; + break; case MGPWireOp::BufferSubDataResident: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BufferSubDataResident, "BufferSubDataResident"); - return false; + break; case MGPWireOp::ResourceSubDataComplete: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubDataComplete, "ResourceSubDataComplete"); - return false; + break; case MGPWireOp::ResourceFlushRange: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceFlushRange, "ResourceFlushRange"); - return false; + break; case MGPWireOp::ResourceReadback: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceReadback, "ResourceReadback"); - return false; + break; case MGPWireOp::ResourceCopyRegion: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCopyRegion, "ResourceCopyRegion"); - return false; + break; case MGPWireOp::GenerateMipmap: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GenerateMipmap, "GenerateMipmap"); - return false; + break; case MGPWireOp::GetTextureImage: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetTextureImage, "GetTextureImage"); - return false; + break; case MGPWireOp::Blit: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Blit, "Blit"); - return false; + break; case MGPWireOp::Clear: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Clear, "Clear"); - return false; + break; case MGPWireOp::ReadPixels: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ReadPixels, "ReadPixels"); - return false; + break; case MGPWireOp::DrawVbo: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DrawVbo, "DrawVbo"); - return false; + break; case MGPWireOp::LaunchGrid: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_LaunchGrid, "LaunchGrid"); - return false; + break; case MGPWireOp::MemoryBarrier: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MemoryBarrier, "MemoryBarrier"); - return false; + break; case MGPWireOp::BeginStreamOutput: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BeginStreamOutput, "BeginStreamOutput"); - return false; + break; case MGPWireOp::EndStreamOutput: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_EndStreamOutput, "EndStreamOutput"); - return false; + break; case MGPWireOp::PauseStreamOutput: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_PauseStreamOutput, "PauseStreamOutput"); - return false; + break; case MGPWireOp::ResumeStreamOutput: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResumeStreamOutput, "ResumeStreamOutput"); - return false; + break; case MGPWireOp::Flush: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Flush, "Flush"); - return false; + break; case MGPWireOp::Present: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Present, "Present"); - return false; + break; case MGPWireOp::SetSwapInterval: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSwapInterval, "SetSwapInterval"); - return false; + break; case MGPWireOp::QueryTimestamp: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryTimestamp, "QueryTimestamp"); - return false; + break; case MGPWireOp::QueryCounter: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCounter, "QueryCounter"); - return false; + break; case MGPWireOp::FenceWaitServer: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWaitServer, "FenceWaitServer"); - return false; + break; case MGPWireOp::kInvalid: case MGPWireOp::kOpCount: default: MGPipeWireProtocolFatal("", size, remaining); } +#if MOBILEGL_BUILD_DISAGGREGATED + if (gMGPipeWireRecordApply != nullptr) { + return gMGPipeWireRecordApply(op, record, size, remaining); + } +#endif + return false; } #undef MGP_WIRE_CHECK_BOUNDS diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 7f2b3f11..b42584af 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -740,24 +740,57 @@ static_assert((MGPipeCallFlagsFor(MGPWireOp::DrawVbo) & } \\ } while (0) -// Returns whether the record was applied. P0 is a SKELETON: every case validates its -// bounds and then reports "not applied", because no applier exists until P5 wires -// MG_Remote/Server/PipeApplier.cpp to the real backend tables. The switch and the opcode -// enum come from the same list, so a call added to the catalogue cannot be forgotten here; -// the default arm is for the opcode that never came from this catalogue at all - a byte -// off a corrupt stream - and it is fatal for the same reason the bounds check is. +#if MOBILEGL_BUILD_DISAGGREGATED +// THE DECODER HOOK (P5 w1). MG_Pipe is BELOW MG_Remote and may not include it, so the real +// 71-arm decoder - MG_Remote/Wire/PipeWireCodec.cpp, which resolves the segments, cross-checks +// the variable tails and calls today's MGPipeApply* free functions - installs itself here. +// The indirection is the layering, not a policy: gMGPipeSegmentResolver +// (MGPipeHostSpan.h:47) is the same shape for the same reason. +// +// It lives behind the build option because G1 admits no symbol movement in a PULL build, and +// an inline variable that MGPipeApplyWireRecord odr-uses would be one. +using MGPipeWireRecordApplyFn = Bool (*)(MGPWireOp op, const void* record, Uint64 size, + Uint64 remaining); +inline MGPipeWireRecordApplyFn gMGPipeWireRecordApply = nullptr; +#endif + +// Returns whether the record was applied. +// +// THE SWITCH IS THE PER-OPCODE BOUNDS GATE AND NOTHING ELSE, AND IT CANNOT SEE THE TAIL. +// MGP_WIRE_CHECK_BOUNDS proves `size >= sizeof(MGPWireRec_X)`, `size <= remaining` and +// 8-alignment - which is exactly the part a generator can state, because it is the part that +// follows from the opcode alone. A kVarTail record declaring Count = 4000 while carrying 8 +// bytes passes every one of those, so the SECOND check - recompute the total from the +// record's own count fields and require it to EQUAL MGPWireRecHeader::Size - belongs to the +// decoder, which has the payload (MG_Remote/Wire's MGPipeWireRecordLayout, P5 w1). +// +// The switch and the opcode enum come from the same list, so a call added to the catalogue +// cannot be forgotten here; the default arm is for the opcode that never came from this +// catalogue at all - a byte off a corrupt stream - and it is fatal for the same reason the +// bounds check is. +// +// With no decoder installed - every monolith build, and a split build before +// ClientSession::Start - this returns false, "this build does not implement it". That is the +// P0 skeleton's answer, kept deliberately: a codec that has not been installed must not look +// like one that applied the record. inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { (void)record; switch (op) {""") for call in calls: out.append(" case MGPWireOp::%s:" % call.Name) out.append(" MGP_WIRE_CHECK_BOUNDS(MGPWireRec_%s, \"%s\");" % (call.Name, call.Name)) - out.append(" return false;") + out.append(" break;") out.append(""" case MGPWireOp::kInvalid: case MGPWireOp::kOpCount: default: MGPipeWireProtocolFatal("", size, remaining); } +#if MOBILEGL_BUILD_DISAGGREGATED + if (gMGPipeWireRecordApply != nullptr) { + return gMGPipeWireRecordApply(op, record, size, remaining); + } +#endif + return false; } #undef MGP_WIRE_CHECK_BOUNDS""") From d17c6c225c34e7f3de99717b02ba0754edbd0279 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 13:58:06 -0400 Subject: [PATCH 3/9] [Feat] (MG_Remote): MGPCapss two blob serializers and the ABI fingerprint - sparse format-capability tables, length-prefixed renderer strings, every decoder refusing truncation and trailing bytes --- MobileGL/MG_Remote/CapsCodec.cpp | 466 ++++++++++++++++++++++++++++++- 1 file changed, 451 insertions(+), 15 deletions(-) diff --git a/MobileGL/MG_Remote/CapsCodec.cpp b/MobileGL/MG_Remote/CapsCodec.cpp index eed88646..8151600a 100644 --- a/MobileGL/MG_Remote/CapsCodec.cpp +++ b/MobileGL/MG_Remote/CapsCodec.cpp @@ -6,11 +6,38 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header +// P5 package w1: MGPCaps's two blob serializers, the pair MGPipeTypes.h:134-136 defers to +// this phase by name ("Their serializers land with the transport (P5)"). +// +// THE FORMAT, and every part of it is a refusal rather than a guess. It is +// ProgramArtifactsCodec's shape on purpose - that codec is the tree's one worked example of a +// wire format that has survived a real transport, so copying it is cheaper than being +// original and much cheaper than being wrong: +// +// * a VERSION word first, and the two TABLE DIMENSIONS second, so a peer whose +// TextureInternalFormat enum grew is a mismatch AT READ TIME rather than a silent shear +// that reads one format's capabilities as another's; +// * length-prefixed everything, with the count checked against the bytes that REMAIN before +// a single element is reserved, so a corrupt count cannot become a four-billion-element +// resize; +// * SPARSE for all three capability tables. The dense form is +// 2 * targets * formats * 8 bytes plus the sample-count lists - half a megabyte of mostly +// zeroes, per context, per caps invalidation (R-12 makes a re-arriving snapshot the +// invalidation, so this is not a once-per-process cost). The tables are overwhelmingly +// empty, so what crosses is (index, value) pairs and the decoder Clear()s first; +// * little-endian by memcpy of fixed-width scalars, which is what every other MobileGL wire +// struct already assumes and what the ABI fingerprint below makes checkable; +// * every decoder returns FALSE on truncation, a bad version, a dimension mismatch, an +// out-of-range index or TRAILING BYTES THE FORMAT DOES NOT ACCOUNT FOR. These bytes +// arrive over a wire and the outputs are left in a defined, default state on a refusal. + #include "CapsCodec.h" +#include #include #include +#include namespace MobileGL::MG_Remote { @@ -29,28 +56,437 @@ namespace MobileGL::MG_Remote { static_assert(MG_Pipe::kMGPipeSubsystemsMigratedAtP4a <= 0xFFFFull, "the subsystem mask no longer fits CallMask's sixteen consumer bits"); -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedCapsCodec, \"%s\"} - P5 package w1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + namespace { - Bool EncodeFormatCapabilities(const MG_Backend::FormatCapabilityCache&, Vector&) { - MGP5_C0_STUB("EncodeFormatCapabilities"); + // Bumped whenever the bytes change in a way a previous reader would misread. A reader + // that sees a different word REFUSES; it never tries to guess a layout. + constexpr Uint32 kFormatCapabilitiesCodecVersion = 1; + constexpr Uint32 kRendererInfoCodecVersion = 1; + + // A count is never believed before it is weighed against the bytes that are left. The + // largest legal element count in either blob is bounded by the tables' own dimensions, + // but a String's length is not, so the reader checks bytes rather than a constant. + class Writer { + public: + explicit Writer(Vector& out) : m_out(out) {} + + void Raw(const void* bytes, SizeT size) { + if (size == 0) { + return; + } + const auto* p = static_cast(bytes); + m_out.insert(m_out.end(), p, p + size); + } + void U8(Uint8 v) { Raw(&v, sizeof(v)); } + void U32(Uint32 v) { Raw(&v, sizeof(v)); } + void U64(Uint64 v) { Raw(&v, sizeof(v)); } + void I32(Int32 v) { Raw(&v, sizeof(v)); } + void Str(const String& s) { + U32(static_cast(s.size())); + Raw(s.data(), s.size()); + } + void OptStr(const Optional& s) { + U8(s.has_value() ? 1u : 0u); + if (s.has_value()) { + Str(*s); + } + } + + private: + Vector& m_out; + }; + + class Reader { + public: + Reader(const void* bytes, Uint64 size) + : m_p(static_cast(bytes)), m_left(bytes != nullptr ? size : 0) {} + + Bool Raw(void* out, Uint64 size) { + if (!m_ok || size > m_left) { + m_ok = false; + return false; + } + std::memcpy(out, m_p, static_cast(size)); + m_p += size; + m_left -= size; + return true; + } + Bool U8(Uint8& v) { return Raw(&v, sizeof(v)); } + Bool U32(Uint32& v) { return Raw(&v, sizeof(v)); } + Bool U64(Uint64& v) { return Raw(&v, sizeof(v)); } + Bool I32(Int32& v) { return Raw(&v, sizeof(v)); } + Bool Str(String& s) { + Uint32 length = 0; + if (!U32(length)) { + return false; + } + // THE CHECK THAT MATTERS: the count is weighed against the bytes that remain + // BEFORE the string is sized, so a corrupt length is a refusal rather than a + // four-gigabyte allocation. + if (length > m_left) { + m_ok = false; + return false; + } + s.assign(reinterpret_cast(m_p), static_cast(length)); + m_p += length; + m_left -= length; + return true; + } + Bool OptStr(Optional& s) { + Uint8 present = 0; + if (!U8(present)) { + return false; + } + if (present == 0) { + s.reset(); + return true; + } + String value; + if (!Str(value)) { + return false; + } + s = Move(value); + return true; + } + // How many elements of `elementBytes` could still possibly be there. The gate a + // length-prefixed array is held to before it reserves anything. + Uint64 RoomFor(Uint64 elementBytes) const { + return elementBytes == 0 ? 0 : m_left / elementBytes; + } + Bool Ok() const { return m_ok; } + Uint64 Left() const { return m_left; } + // Trailing bytes the format does not account for are a REFUSAL: they mean the + // writer and the reader disagree about the shape, and the half that was read is + // not trustworthy just because it parsed. + Bool Finished() const { return m_ok && m_left == 0; } + + private: + const Uint8* m_p = nullptr; + Uint64 m_left = 0; + Bool m_ok = true; + }; + + using MG_Backend::FormatCapabilityCache; + using MG_Backend::FormatCapabilityFlags; + + constexpr Uint64 kFormatCells = static_cast(MG_Backend::kFormatCapabilityTargetCount) * + static_cast(MG_Backend::kFormatCapabilityFormatCount); + + // FNV-1a, the same mixer the tree already uses for build-stamp style fingerprints. + constexpr Uint64 kFnvOffset = 1469598103934665603ull; + constexpr Uint64 kFnvPrime = 1099511628211ull; + + Uint64 FnvBytes(Uint64 hash, const void* bytes, SizeT size) { + const auto* p = static_cast(bytes); + for (SizeT i = 0; i < size; ++i) { + hash ^= static_cast(p[i]); + hash *= kFnvPrime; + } + return hash; + } + + Uint64 FnvU64(Uint64 hash, Uint64 value) { return FnvBytes(hash, &value, sizeof(value)); } + + } // namespace + + // --------------------------------------------------------------------------------- + // FormatCapabilityCache + // --------------------------------------------------------------------------------- + + Bool EncodeFormatCapabilities(const FormatCapabilityCache& cache, Vector& out) { + Writer w(out); + w.U32(kFormatCapabilitiesCodecVersion); + w.U32(static_cast(MG_Backend::kFormatCapabilityTargetCount)); + w.U32(static_cast(MG_Backend::kFormatCapabilityFormatCount)); + w.U32(0); // reserved, keeps the header 16 bytes and 8-aligned for the pairs below + + // The two flag tables, sparse. A cell is written only when it is non-zero, so what + // crosses is proportional to what the driver actually supports rather than to the + // square of two enum spaces. + const auto writeTable = [&](const MG_Backend::FormatCapabilityTable& table) { + Uint32 populated = 0; + for (SizeT t = 0; t < MG_Backend::kFormatCapabilityTargetCount; ++t) { + for (SizeT f = 0; f < MG_Backend::kFormatCapabilityFormatCount; ++f) { + if (table[t][f].GetRaw() != 0) { + ++populated; + } + } + } + w.U32(populated); + for (SizeT t = 0; t < MG_Backend::kFormatCapabilityTargetCount; ++t) { + for (SizeT f = 0; f < MG_Backend::kFormatCapabilityFormatCount; ++f) { + const Uint64 raw = static_cast(table[t][f].GetRaw()); + if (raw == 0) { + continue; + } + w.U32(static_cast(t * MG_Backend::kFormatCapabilityFormatCount + f)); + w.U64(raw); + } + } + }; + writeTable(cache.FullCaps); + writeTable(cache.CaveatCaps); + + // The sample-count lists: the Vector that is the reason this cannot be a memcpy. + Uint32 populated = 0; + for (SizeT t = 0; t < MG_Backend::kFormatCapabilityTargetCount; ++t) { + for (SizeT f = 0; f < MG_Backend::kFormatCapabilityFormatCount; ++f) { + if (!cache.SampleCounts[t][f].empty()) { + ++populated; + } + } + } + w.U32(populated); + for (SizeT t = 0; t < MG_Backend::kFormatCapabilityTargetCount; ++t) { + for (SizeT f = 0; f < MG_Backend::kFormatCapabilityFormatCount; ++f) { + const Vector& counts = cache.SampleCounts[t][f]; + if (counts.empty()) { + continue; + } + w.U32(static_cast(t * MG_Backend::kFormatCapabilityFormatCount + f)); + w.U32(static_cast(counts.size())); + for (const Int value : counts) { + w.I32(static_cast(value)); + } + } + } + return true; } - Bool DecodeFormatCapabilities(const void*, Uint64, MG_Backend::FormatCapabilityCache&) { - MGP5_C0_STUB("DecodeFormatCapabilities"); + Bool DecodeFormatCapabilities(const void* bytes, Uint64 size, FormatCapabilityCache& out) { + out.Clear(); + Reader r(bytes, size); + + Uint32 version = 0; + Uint32 targets = 0; + Uint32 formats = 0; + Uint32 reserved = 0; + if (!r.U32(version) || !r.U32(targets) || !r.U32(formats) || !r.U32(reserved)) { + return false; + } + if (version != kFormatCapabilitiesCodecVersion) { + MGLOG_E("MG_Remote caps: format-capability blob is version %u, this build reads %u", + version, kFormatCapabilitiesCodecVersion); + return false; + } + if (targets != MG_Backend::kFormatCapabilityTargetCount || + formats != MG_Backend::kFormatCapabilityFormatCount) { + // Not a corrupt stream: a peer whose TextureTarget or TextureInternalFormat enum + // is a different size. Reading it anyway shears every cell onto a neighbouring + // format, which is exactly the failure the ABI fingerprint exists to make loud. + MGLOG_E("MG_Remote caps: format-capability blob is %ux%u, this build is %llux%llu", + targets, formats, + static_cast(MG_Backend::kFormatCapabilityTargetCount), + static_cast(MG_Backend::kFormatCapabilityFormatCount)); + return false; + } + + const auto readTable = [&](MG_Backend::FormatCapabilityTable& table) -> Bool { + Uint32 populated = 0; + if (!r.U32(populated)) { + return false; + } + if (populated > r.RoomFor(sizeof(Uint32) + sizeof(Uint64))) { + return false; + } + for (Uint32 i = 0; i < populated; ++i) { + Uint32 index = 0; + Uint64 raw = 0; + if (!r.U32(index) || !r.U64(raw)) { + return false; + } + if (static_cast(index) >= kFormatCells) { + return false; + } + table[index / MG_Backend::kFormatCapabilityFormatCount] + [index % MG_Backend::kFormatCapabilityFormatCount] = + FormatCapabilityFlags(raw); + } + return true; + }; + if (!readTable(out.FullCaps) || !readTable(out.CaveatCaps)) { + out.Clear(); + return false; + } + + Uint32 populated = 0; + if (!r.U32(populated) || populated > r.RoomFor(2 * sizeof(Uint32))) { + out.Clear(); + return false; + } + for (Uint32 i = 0; i < populated; ++i) { + Uint32 index = 0; + Uint32 count = 0; + if (!r.U32(index) || !r.U32(count)) { + out.Clear(); + return false; + } + if (static_cast(index) >= kFormatCells || count > r.RoomFor(sizeof(Int32))) { + out.Clear(); + return false; + } + Vector& counts = out.SampleCounts[index / MG_Backend::kFormatCapabilityFormatCount] + [index % MG_Backend::kFormatCapabilityFormatCount]; + counts.resize(static_cast(count)); + for (Uint32 j = 0; j < count; ++j) { + Int32 value = 0; + if (!r.I32(value)) { + out.Clear(); + return false; + } + counts[j] = static_cast(value); + } + } + + if (!r.Finished()) { + MGLOG_E("MG_Remote caps: format-capability blob has %llu trailing bytes the format " + "does not account for", + static_cast(r.Left())); + out.Clear(); + return false; + } + return true; } - Bool EncodeRendererInfo(const RendererInfo&, Vector&) { MGP5_C0_STUB("EncodeRendererInfo"); } + // --------------------------------------------------------------------------------- + // RendererInfo + // --------------------------------------------------------------------------------- - Bool DecodeRendererInfo(const void*, Uint64, RendererInfo&) { MGP5_C0_STUB("DecodeRendererInfo"); } + namespace { - Uint64 CapsAbiFingerprint() { MGP5_C0_STUB("CapsAbiFingerprint"); } + void WriteVersion(Writer& w, const Version& v) { + w.I32(static_cast(v.Major)); + w.I32(static_cast(v.Minor)); + w.I32(static_cast(v.Patch)); + w.OptStr(v.Suffix); + w.U8(v.Type.has_value() ? 1u : 0u); + w.U8(v.Type.has_value() ? static_cast(*v.Type) : 0u); + } -#undef MGP5_C0_STUB + Bool ReadVersion(Reader& r, Version& v) { + Int32 major = 0; + Int32 minor = 0; + Int32 patch = 0; + if (!r.I32(major) || !r.I32(minor) || !r.I32(patch)) { + return false; + } + v.Major = static_cast(major); + v.Minor = static_cast(minor); + v.Patch = static_cast(patch); + if (!r.OptStr(v.Suffix)) { + return false; + } + Uint8 hasType = 0; + Uint8 type = 0; + if (!r.U8(hasType) || !r.U8(type)) { + return false; + } + if (hasType != 0) { + v.Type = static_cast(type); + } else { + v.Type.reset(); + } + return true; + } + + } // namespace + + Bool EncodeRendererInfo(const RendererInfo& info, Vector& out) { + Writer w(out); + w.U32(kRendererInfoCodecVersion); + w.Str(info.RendererName); + w.Str(info.BackendName); + w.OptStr(info.ExtraVendor); + WriteVersion(w, info.RendererGLInfo.TargetGLVersion); + WriteVersion(w, info.RendererGLInfo.TargetGLSLVersion); + w.U32(static_cast(info.RendererGLInfo.Extensions.size())); + for (const GLExtension extension : info.RendererGLInfo.Extensions) { + w.U32(static_cast(extension)); + } + w.U8(info.RendererGLInfo.IsCompatibilityProfile ? 1u : 0u); + w.U8(info.StaticBackendCapability.AllowVSOnlyPrograms ? 1u : 0u); + return true; + } + + Bool DecodeRendererInfo(const void* bytes, Uint64 size, RendererInfo& out) { + out = RendererInfo{}; + Reader r(bytes, size); + + Uint32 version = 0; + if (!r.U32(version)) { + return false; + } + if (version != kRendererInfoCodecVersion) { + MGLOG_E("MG_Remote caps: renderer-info blob is version %u, this build reads %u", version, + kRendererInfoCodecVersion); + return false; + } + if (!r.Str(out.RendererName) || !r.Str(out.BackendName) || !r.OptStr(out.ExtraVendor) || + !ReadVersion(r, out.RendererGLInfo.TargetGLVersion) || + !ReadVersion(r, out.RendererGLInfo.TargetGLSLVersion)) { + out = RendererInfo{}; + return false; + } + + Uint32 extensionCount = 0; + if (!r.U32(extensionCount) || extensionCount > r.RoomFor(sizeof(Uint32))) { + out = RendererInfo{}; + return false; + } + out.RendererGLInfo.Extensions.resize(static_cast(extensionCount)); + for (Uint32 i = 0; i < extensionCount; ++i) { + Uint32 value = 0; + if (!r.U32(value)) { + out = RendererInfo{}; + return false; + } + out.RendererGLInfo.Extensions[i] = static_cast(value); + } + + Uint8 compatibility = 0; + Uint8 vsOnly = 0; + if (!r.U8(compatibility) || !r.U8(vsOnly)) { + out = RendererInfo{}; + return false; + } + out.RendererGLInfo.IsCompatibilityProfile = compatibility != 0; + out.StaticBackendCapability.AllowVSOnlyPrograms = vsOnly != 0; + + if (!r.Finished()) { + MGLOG_E("MG_Remote caps: renderer-info blob has %llu trailing bytes the format does " + "not account for", + static_cast(r.Left())); + out = RendererInfo{}; + return false; + } + return true; + } + + // --------------------------------------------------------------------------------- + // The ABI assertion the handshake carries + // --------------------------------------------------------------------------------- + + Uint64 CapsAbiFingerprint() { + // MGPCaps has only a COMPOSITIONAL size assertion (MGPipeTypes.h:145-146) because + // DynamicBackendParameters still carries SizeT and GLenum members - P0.5's fixed-width + // rewrite did not happen and P5 does not do it either (table 0's ABI row; the rewrite + // is P7's account). So the caps block's literal size IS ABI-dependent, and this + // fingerprint is what turns that from a latent hazard into a named refusal. + // + // The git stamp is in it because two builds of the same sizes can still disagree about + // a FIELD ORDER, which no sizeof can see; P6's spawn is same-machine and same-binary, + // so it inherits this unchanged rather than needing a looser rule. + Uint64 hash = kFnvOffset; + hash = FnvU64(hash, sizeof(MG_Backend::DynamicBackendParameters)); + hash = FnvU64(hash, sizeof(MG_Pipe::MGPCaps)); + hash = FnvU64(hash, sizeof(MG_Backend::GLFunctionsTable)); + hash = FnvU64(hash, static_cast(MG_Backend::kFormatCapabilityTargetCount)); + hash = FnvU64(hash, static_cast(MG_Backend::kFormatCapabilityFormatCount)); + hash = FnvU64(hash, kFormatCapabilitiesCodecVersion); + hash = FnvU64(hash, kRendererInfoCodecVersion); + hash = FnvU64(hash, static_cast(MG_Pipe::MGPWireOp::kOpCount)); + hash = FnvBytes(hash, GIT_COMMIT_HASH_SHORT, std::strlen(GIT_COMMIT_HASH_SHORT)); + return hash; + } } // namespace MobileGL::MG_Remote From 1af5e31f3b1e334a55331f0fc25805d211cf4415 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 13:58:06 -0400 Subject: [PATCH 4/9] [Test] (MG_Test, Wire): drive encoder to ring to decoder to applier for every flag class, both double-tailed rows, DrawVbos conditional tail and each R-2 Fatal arm --- MobileGL/MG_Test/Wire/CMakeLists.txt | 28 + MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 1355 +++++++++++++++++++ 2 files changed, 1383 insertions(+) create mode 100644 MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 0af34f0b..df11438f 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -34,3 +34,31 @@ foreach (test IN LISTS MOBILEGL_WIRE_TESTS) gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach () + +# P5 w1's G3 codec suite. It is registered on its own rather than in the list above because it +# links gtest, NOT gtest_main: the R-2 Fatal arms report through MGLOG_F + std::abort, so the +# cases that drive one fork and read the Fatal line back out of a log file the process names +# before anything logs - which needs a main() of its own (PipeInputsTest's shape). It also +# reaches MG_Pipe and MG_State, so it carries their include paths. +add_executable(PipeWireCodecTest PipeWireCodecTest.cpp) + +target_include_directories(PipeWireCodecTest 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(PipeWireCodecTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(PipeWireCodecTest PRIVATE /Zc:preprocessor) +endif () + +gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp new file mode 100644 index 00000000..13a72a68 --- /dev/null +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -0,0 +1,1355 @@ +// MobileGL - MobileGL/MG_Test/Wire/PipeWireCodecTest.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 w1's suite: encoder -> SEG_CMD -> RingConsumer -> decoder -> the real +// MGPipeApply* free functions, with no session, no transport and no thread. s1 owns the +// session; this file owns the bytes. +// +// It links gtest rather than gtest_main and carries its own main(), for PipeInputsTest's +// reason: the R-2 arms report through MGLOG_F + std::abort, so a case that drives one FORKS +// and reads the Fatal line back out of a log file this process names before anything logs. +// NEVER EXPECT_DEATH - it re-runs the whole binary and would re-enter the applier's globals. +// +// WHAT A "ROUND TRIP" MEANS HERE. The decoder implements no semantics, so a case cannot +// assert on rendering; what it asserts is that the record crossed intact and that the arm +// reached the right consumer with the right arguments. For the five class-B verbs that is a +// recording WireVerbSink; for everything else it is the real applier, whose acceptance return +// comes back through the recording ReplySink on the record's own seq (R-3/R-5). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Includes.h" + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#include +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; +using namespace MobileGL::MG_Remote; +using namespace MobileGL::MG_Remote::Wire; +namespace Transport = MobileGL::MG_Remote::Transport; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + + long ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + // ---- the fixture ---------------------------------------------------------------- + // + // Two rings over two byte arrays plus a SegmentTable that covers exactly those arrays. + // "Exactly" is load-bearing: StageBytes asserts that the SEG_STAGE view resolves a staged + // run to the same address the producer wrote it at, so a view installed over the wrong + // base is a Fatal rather than a plausible pointer. + class Wire2 { + public: + static constexpr std::uint64_t kCmdBytes = 64 * 1024; + static constexpr std::uint64_t kStageBytes = 256 * 1024; + + Wire2() : m_cmdBytes(kCmdBytes), m_stageBytes(kStageBytes) { + Transport::InitRingControl(m_control); + m_cmd = Transport::RingProducer(&m_control, m_cmdBytes.data(), kCmdBytes, + Transport::RingCursorSet::Cmd); + m_stage = Transport::RingProducer(&m_control, m_stageBytes.data(), kStageBytes, + Transport::RingCursorSet::Stage); + m_consumer = Transport::RingConsumer(&m_control, m_cmdBytes.data(), kCmdBytes, + Transport::RingCursorSet::Cmd); + m_segments.Install(kSegCmd, SegmentView{m_cmdBytes.data(), kCmdBytes}); + m_segments.Install(kSegStage, SegmentView{m_stageBytes.data(), kStageBytes}); + m_encoder = PipeWireEncoder(&m_control, &m_cmd, &m_stage, &m_segments); + m_decoder = PipeWireDecoder(&m_control, &m_segments, &m_replies); + m_decoder.SetVerbSink(&m_verbs); + } + + PipeWireEncoder& Encoder() { return m_encoder; } + PipeWireDecoder& Decoder() { return m_decoder; } + SegmentTable& Segments() { return m_segments; } + Transport::RingControl& Control() { return m_control; } + Transport::RingProducer& Cmd() { return m_cmd; } + Transport::RingConsumer& Consumer() { return m_consumer; } + std::uint8_t* StageBase() { return m_stageBytes.data(); } + + // Pops one record and decodes it. Returns whether the decoder reported "applied"; + // `popped` says whether there was a record at all, so a case cannot pass because + // nothing was there. + bool PumpOne(bool* applied) { + m_encoder.Publish(); + Transport::RingRecordView view{}; + bool corrupt = false; + if (!m_consumer.Pop(view, &corrupt)) { + return false; + } + if (corrupt) { + return false; + } + const bool result = m_decoder.DecodeAndApply(view); + m_consumer.PublishRetired(); + if (applied != nullptr) { + *applied = result; + } + return true; + } + + // ---- recorded answers ---- + struct Reply { + std::uint64_t Seq = 0; + std::int32_t Status = 0; + std::vector Bytes; + }; + + class Replies : public ReplySink { + public: + void PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) override { + Reply r; + r.Seq = seq; + r.Status = status; + if (bytes != nullptr && size != 0) { + const auto* p = static_cast(bytes); + r.Bytes.assign(p, p + size); + } + All.push_back(std::move(r)); + } + std::vector All; + }; + + class Verbs : public WireVerbSink { + public: + Bool OnClear(const MGPClear& clear) override { + Clears.push_back(clear); + return true; + } + Bool OnBlit(const MGPBlit& blit) override { + Blits.push_back(blit); + return true; + } + Bool OnPresent(const MGPPresent& present) override { + Presents.push_back(present); + return true; + } + Bool OnReadPixels(const MGPReadbackInfo& info, Uint64 seq, ReplySink* replies) override { + Readbacks.push_back(info); + ReadbackSeqs.push_back(seq); + if (replies != nullptr) { + const std::uint8_t pixels[4] = {1, 2, 3, 4}; + replies->PostReply(seq, ReplySink::kStatusOk, pixels, sizeof(pixels)); + } + return true; + } + Bool OnDrawVbo(const MGPDrawInfo& info, const MGPDrawRange* ranges, + const MGHostSpan* userIndices) override { + Draws.push_back(info); + DrawRanges.clear(); + for (Uint32 i = 0; i < info.NumDraws; ++i) { + DrawRanges.push_back(ranges[i]); + } + SawUserIndices = userIndices != nullptr; + if (userIndices != nullptr) { + LastSpan = *userIndices; + } + return true; + } + std::vector Clears; + std::vector Blits; + std::vector Presents; + std::vector Readbacks; + std::vector ReadbackSeqs; + std::vector Draws; + std::vector DrawRanges; + bool SawUserIndices = false; + MGHostSpan LastSpan{}; + }; + + Replies& Answers() { return m_replies; } + Verbs& Sink() { return m_verbs; } + + private: + Transport::RingControl m_control{}; + std::vector m_cmdBytes; + std::vector m_stageBytes; + Transport::RingProducer m_cmd; + Transport::RingProducer m_stage; + Transport::RingConsumer m_consumer; + SegmentTable m_segments; + PipeWireEncoder m_encoder; + PipeWireDecoder m_decoder; + Replies m_replies; + Verbs m_verbs; + }; + + MGPipeHandle MakeHandle(Uint32 slot, Uint32 gen = 1) { + MGPipeHandle handle{}; + handle.Slot = slot; + handle.Gen = gen; + return handle; + } + + MGPHandleOnly HandleOnly(Uint32 slot, MGPipeKind kind) { + MGPHandleOnly record{}; + record.Handle = MakeHandle(slot); + record.Kind = static_cast(kind); + return record; + } + + class PipeWireCodecTest : public ::testing::Test { + protected: + void SetUp() override { MGPipeApplierReleaseObjectRecords(); } + void TearDown() override { MGPipeApplierReleaseObjectRecords(); } + }; + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; + std::string Log; + }; + + // Runs `body` in a forked child. The child must not use gtest assertions; it _exit(0)s + // when `body` returns, so a body expected to die must be ASSERTED dead by the parent + // (WIFSIGNALED), never assumed. + template + ChildResult RunInChild(Body body) { + ChildResult result; + const std::string before = ReadLog(); + 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().substr(before.size()); + 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); + } + + // Forges ONE record straight into SEG_CMD, bypassing the encoder. Every R-2 arm needs + // this: the encoder REFUSES to build a dishonest record, which is the point of it, so a + // case that drove the encoder could only ever test the encoder's own check. + void ForgeAndDecode(Wire2& wire, MGPWireOp op, const void* payload, std::uint64_t payloadBytes, + const void* tail, std::uint64_t tailBytes) { + const std::uint64_t body = payloadBytes + tailBytes; + void* slot = wire.Cmd().Reserve(static_cast(op), Transport::kRecNone, body); + if (slot == nullptr) { + std::_Exit(9); + } + std::memcpy(slot, payload, static_cast(payloadBytes)); + if (tailBytes != 0) { + std::memcpy(static_cast(slot) + payloadBytes, tail, + static_cast(tailBytes)); + } + wire.Cmd().Publish(); + Transport::RingRecordView view{}; + bool corrupt = false; + if (!wire.Consumer().Pop(view, &corrupt) || corrupt) { + std::_Exit(10); + } + (void)wire.Decoder().DecodeAndApply(view); + } +#endif // MGTEST_HAVE_FORK + +} // namespace + +// ===================================================================================== +// Segment table and staging +// ===================================================================================== + +TEST_F(PipeWireCodecTest, SegmentZeroIsNeverARealSegment) { + SegmentTable table; + std::vector bytes(256); + table.Install(kSegStage, SegmentView{bytes.data(), bytes.size()}); + EXPECT_EQ(table.Resolve(kSegNone, 0, 8), nullptr); + EXPECT_EQ(table.Get(kSegNone).Base, nullptr); + // P8's index-mirror sentinel is reserved, not resolvable in this phase. + EXPECT_EQ(table.Resolve(kMGHostSpanSegFromServerIndexMirror, 0, 8), nullptr); +} + +TEST_F(PipeWireCodecTest, ResolveRefusesEveryRunThatLeavesItsSegment) { + SegmentTable table; + std::vector bytes(256); + table.Install(kSegStage, SegmentView{bytes.data(), bytes.size()}); + EXPECT_EQ(table.Resolve(kSegStage, 0, 256), bytes.data()); + EXPECT_EQ(table.Resolve(kSegStage, 248, 8), bytes.data() + 248); + EXPECT_EQ(table.Resolve(kSegStage, 249, 8), nullptr); + EXPECT_EQ(table.Resolve(kSegStage, 256, 1), nullptr); + EXPECT_EQ(table.Resolve(kSegStage, 0, 0), nullptr); + // The arithmetic is a subtraction, so an offset that would wrap offset+size cannot come + // back as "inside". + EXPECT_EQ(table.Resolve(kSegStage, 0xFFFFFFFFFFFFFFF0ull, 32), nullptr); +} + +TEST_F(PipeWireCodecTest, StagedBytesResolveBackToTheSameAddress) { + Wire2 wire; + const std::uint8_t pattern[37] = {0}; + std::uint8_t source[37]; + for (std::size_t i = 0; i < sizeof(source); ++i) { + source[i] = static_cast(0xA0 + i); + } + (void)pattern; + const MGPBlobRef ref = wire.Encoder().StageBytes(source, sizeof(source)); + EXPECT_EQ(ref.Seg, static_cast(kSegStage)); + EXPECT_EQ(ref.Size, sizeof(source)); + const void* back = wire.Segments().Resolve(ref.Seg, ref.Offset, ref.Size); + ASSERT_NE(back, nullptr); + EXPECT_EQ(std::memcmp(back, source, sizeof(source)), 0); +} + +// ===================================================================================== +// THE TRAP: two flag spaces in one 16-bit field +// ===================================================================================== + +TEST_F(PipeWireCodecTest, AVarTailRecordIsNotSkippedAsAWrapFiller) { + // MGPipeCallFlags::kVarTail is 1<<2 and RingRecordFlags::kRecPad is 1<<2, and + // MGPWireRecHeader::Flags IS RingRecordHeader::flags. An encoder that stamped the call's + // own flags - which the generated comment invites - would make RingConsumer::Pop skip + // every one of the nine kVarTail records as a wrap filler, silently, with no checksum + // anywhere on this ring. This case is the trip wire for that. + static_assert(static_cast(kVarTail) == static_cast(Transport::kRecPad), + "the collision this case exists for is gone; keep the case anyway"); + + Wire2 wire; + MGPVertexBuffers header{}; + header.Start = 0; + header.Count = 2; + header.ContentHash = 0x1234; + MGPVertexBuffer tail[2]{}; + tail[0].Res = MakeHandle(11); + tail[0].Stride = 16; + tail[1].Res = MakeHandle(12); + tail[1].Stride = 32; + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetVertexBuffers, &header, sizeof(header), tail, + sizeof(tail)), + kInvalidSeq); + wire.Encoder().Publish(); + + Transport::RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(wire.Consumer().Pop(view, &corrupt)) << "the record was skipped as a pad"; + EXPECT_FALSE(corrupt); + EXPECT_EQ(view.kind, static_cast(MGPWireOp::SetVertexBuffers)); + EXPECT_EQ(view.flags & Transport::kRecPad, 0u); + EXPECT_NE(view.flags & Transport::kRecVarTail, 0u); +} + +// ===================================================================================== +// One round trip per flag class +// ===================================================================================== + +TEST_F(PipeWireCodecTest, KNoneRoundTripsAndReachesItsApplier) { + Wire2 wire; + MGPBindRenderState bind{}; + bind.Cso = MakeHandle(4); + bind.Version = 7; + bind.PipelineVersion = 3; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_EQ(wire.Decoder().AppliedSeq(), 1u); + EXPECT_EQ(wire.Control().appliedSeq.load(), 1u); + EXPECT_EQ(wire.Control().retiredSeq.load(), 1u); +} + +TEST_F(PipeWireCodecTest, KHasBlobRoundTripsWithARealChunkBlob) { + Wire2 wire; + // A brand-new CSO must name EVERY pipeline chunk - the applier refuses an incremental + // create with no BaseCso (Fatal{PipeIncompleteCso}) - so this is the whole half. + const Uint32 mask = MGPipeRenderStateChunkDetail::kAllPipelineHalfBits; + const SizeT blobBytes = MGPipePipelineChunkBlobBytes(mask); + ASSERT_GT(blobBytes, 0u); + std::vector chunks(blobBytes); + for (std::size_t i = 0; i < chunks.size(); ++i) { + chunks[i] = static_cast(i * 7 + 1); + } + + MGPRenderStateDesc desc{}; + desc.Cso = MakeHandle(9); + desc.ChunkMask = mask; + desc.Blob = wire.Encoder().StageBytes(chunks.data(), chunks.size()); + // R-2.2 in one line: what a monolith emission writes is Size 0, and what crosses must not + // be. + EXPECT_NE(desc.Blob.Size, 0u); + EXPECT_EQ(desc.Blob.Seg, static_cast(kSegStage)); + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::CreateRenderState, &desc, sizeof(desc)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + +TEST_F(PipeWireCodecTest, KVarTailRoundTripsWithItsTailIntact) { + Wire2 wire; + MGPSamplerViews header{}; + header.Start = 3; + header.Count = 4; + header.ContentHash = 99; + MGPBoundView tail[4]{}; + for (Uint32 i = 0; i < 4; ++i) { + tail[i].View = MakeHandle(100 + i); + tail[i].Texture = MakeHandle(200 + i); + tail[i].Unit = 3 + i; + } + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetSamplerViews, &header, sizeof(header), tail, + sizeof(tail)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + +TEST_F(PipeWireCodecTest, KReplySlotMapPersistentIsAConstantDecline) { + // R-6 / R-2.4. DECLINED is a real answer, not a failure, and the applier is not called at + // all: the record's payload is a bare MGPHandleOnly and carries NEITHER the size NOR the + // seedBytes MGPipeApplyMapPersistent takes. + Wire2 wire; + const MGPHandleOnly handle = HandleOnly(5, MGPipeKind::Buffer); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::MapPersistent, &handle, sizeof(handle)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + ASSERT_EQ(wire.Answers().All.size(), 1u); + EXPECT_EQ(wire.Answers().All[0].Seq, 1u); + EXPECT_EQ(wire.Answers().All[0].Status, ReplySink::kStatusDeclined); + EXPECT_TRUE(wire.Answers().All[0].Bytes.empty()); +} + +TEST_F(PipeWireCodecTest, KNeedsAckRespecifyCarriesItsRedefinitionScope) { + // Contract table 1 row 19b. Without the carrier every per-level glTexImage*D would take + // the whole-resource arm on the far side and eat the other levels' pending uploads, so + // this case is about the SCOPE surviving, not about the descriptor. + Wire2 wire; + MGPResourceDesc create{}; + create.Resource = MakeHandle(21); + create.Target = static_cast(MGPipeResourceTarget::Tex2D); + create.InternalFormat = 1; + create.Width = 8; + create.Height = 8; + create.Depth = 1; + create.ArrayLayers = 1; + create.Levels = 2; + create.Samples = 1; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceCreate, &create, sizeof(create)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + + MGPResourceDesc respecify = create; + MGPipeSetRespecifiedLevel(respecify, 0x0102u, 1u); + EXPECT_FALSE(MGPipeRespecifyIsWholeResource(respecify)); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceRespecify, &respecify, sizeof(respecify)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + // Two records, two acceptance answers, on their own seqs (R-3: the seq IS the id). + ASSERT_EQ(wire.Answers().All.size(), 2u); + EXPECT_EQ(wire.Answers().All[0].Seq, 1u); + EXPECT_EQ(wire.Answers().All[1].Seq, 2u); +} + +TEST_F(PipeWireCodecTest, KOptionalUnmapPersistentRoundTrips) { + Wire2 wire; + const MGPHandleOnly handle = HandleOnly(6, MGPipeKind::Buffer); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::UnmapPersistent, &handle, sizeof(handle)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + +TEST_F(PipeWireCodecTest, KHostSpanClassIsValidatedEvenThoughP5ProducesNone) { + // kCapNeedsHostUboBytes is 0 for the whole of P5 (table 0), so the second tail is always + // absent here - and the record still has to be REFUSED if it ever is not honest. + Wire2 wire; + MGPShaderBuffers header{}; + header.Class = 0; + header.Start = 0; + header.Count = 2; + header.HostSpanCount = 0; + MGPBufferRange ranges[2]{}; + ranges[0].Res = MakeHandle(31); + ranges[0].Size = 64; + ranges[1].Res = MakeHandle(32); + ranges[1].Size = 128; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetShaderBuffers, &header, sizeof(header), + ranges, sizeof(ranges)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + // No applier entry point exists and the call is off the reduced path, so the honest answer + // is "this build does not implement it" - after the tails have been checked. + EXPECT_FALSE(applied); +} + +// ===================================================================================== +// The two double-tailed rows, and DrawVbo's conditional one +// ===================================================================================== + +TEST_F(PipeWireCodecTest, SetShaderBuffersCarriesBothTailsWhenTheSpanTailIsPresent) { + Wire2 wire; + MGPShaderBuffers header{}; + header.Class = 0; + header.Count = 2; + header.HostSpanCount = 2; // 0 or Count, never anything else + MGPBufferRange ranges[2]{}; + ranges[0].Res = MakeHandle(41); + ranges[1].Res = MakeHandle(42); + MGHostSpan spans[2]{}; + spans[0].Ptr = nullptr; + spans[0].Seg = kSegStage; + spans[0].Size = 8; + spans[0].Offset = 0; + spans[1] = spans[0]; + + const WireTail tails[2] = {{ranges, sizeof(ranges)}, {spans, sizeof(spans)}}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetShaderBuffers, &header, sizeof(header), + tails, 2), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_FALSE(applied); +} + +TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) { + // The only row in the catalogue that is BOTH kHasBlob and kVarTail, and the one rule A + // changes most: the texture half declared Size 0 in monolith "because the byte count is + // the server's to compute", which cannot be a bounds check. + Wire2 wire; + MGPResourceDesc create{}; + create.Resource = MakeHandle(61); + create.Target = static_cast(MGPipeResourceTarget::Tex2D); + create.InternalFormat = 1; + create.Width = 4; + create.Height = 4; + create.Depth = 1; + create.ArrayLayers = 1; + create.Levels = 1; + create.Samples = 1; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceCreate, &create, sizeof(create)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + + std::vector texels(4 * 4 * 4, 0x5A); + MGPSubData upload{}; + upload.Res = create.Resource; + upload.Target = MGPipePackSubDataTarget(static_cast(MGPipeResourceTarget::Tex2D), 0u); + upload.Level = 0; + upload.UnionBox = MGPBox{0, 0, 0, 4, 4, 1}; + upload.RegionCount = 1; + upload.Blob = wire.Encoder().StageBytes(texels.data(), texels.size()); + MGPSubRegion region{}; + region.W = 4; + region.H = 4; + region.D = 1; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceSubData, &upload, sizeof(upload), + ®ion, sizeof(region)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + ASSERT_EQ(wire.Answers().All.size(), 2u); + // ACCEPTANCE IS NOT "APPLIED", and this case is where the difference shows. The record + // crossed and reached MGPipeApplyResourceSubData, which is the codec's whole job; the + // applier then DECLINED it, because no backend registered a P4a texture consumer in this + // unit process (PipeApply.cpp's NoP4aConsumer belt - the one that turned "no consumer" + // into lost texels on Magma). That is exactly the answer R-5 says must travel rather than + // be re-derived on the client: a client that cleared its dirty flags on the strength of + // having EMITTED would drop these texels for good. + EXPECT_EQ(wire.Answers().All[1].Seq, 2u); + EXPECT_EQ(wire.Answers().All[1].Status, ReplySink::kStatusDeclined); + // And the texels themselves crossed intact: the decline is the applier's, not the wire's. + const void* staged = + wire.Segments().Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size); + ASSERT_NE(staged, nullptr); + EXPECT_EQ(std::memcmp(staged, texels.data(), texels.size()), 0); +} + +TEST_F(PipeWireCodecTest, SetGlobalConstantsCarriesTheDefaultUniformBlock) { + Wire2 wire; + std::vector block(256, 0x11); + MGPGlobalConstants record{}; + record.ShaderCso = MakeHandle(71); + record.Version = 2; + record.Blob = wire.Encoder().StageBytes(block.data(), block.size()); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetGlobalConstants, &record, sizeof(record)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + +TEST_F(PipeWireCodecTest, SetStreamOutputTargetsCarriesItsRangesAndItsOffsets) { + Wire2 wire; + MGPStreamOutputTargets header{}; + header.Count = 3; + header.Generation = 5; + MGPBufferRange ranges[3]{}; + Uint32 offsets[3] = {16, 32, 48}; + for (Uint32 i = 0; i < 3; ++i) { + ranges[i].Res = MakeHandle(51 + i); + ranges[i].Size = 256; + } + const WireTail tails[2] = {{ranges, sizeof(ranges)}, {offsets, sizeof(offsets)}}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetStreamOutputTargets, &header, sizeof(header), + tails, 2), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_FALSE(applied); // no applier entry point; off the reduced path +} + +TEST_F(PipeWireCodecTest, DrawVboWithoutUserIndicesHasExactlyOneTail) { + Wire2 wire; + MGPDrawInfo info{}; + info.Mode = 4; + info.InstanceCount = 1; + info.NumDraws = 3; // odd * 12 bytes: the case that makes the alignment rule matter + MGPDrawRange ranges[3] = {{0, 3, 0}, {3, 6, 0}, {9, 3, 1}}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::DrawVbo, &info, sizeof(info), ranges, + sizeof(ranges)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + ASSERT_EQ(wire.Sink().Draws.size(), 1u); + EXPECT_EQ(wire.Sink().Draws[0].NumDraws, 3u); + ASSERT_EQ(wire.Sink().DrawRanges.size(), 3u); + EXPECT_EQ(wire.Sink().DrawRanges[2].Start, 9u); + EXPECT_EQ(wire.Sink().DrawRanges[2].IndexBias, 1); + EXPECT_FALSE(wire.Sink().SawUserIndices); +} + +TEST_F(PipeWireCodecTest, DrawVboConditionalSpanTailIsEightAlignedAndSurvives) { + Wire2 wire; + MGPDrawInfo info{}; + info.Mode = 4; + info.IndexSize = 2; + info.Flags = kDrawHasUserIndices; + info.InstanceCount = 1; + info.NumDraws = 1; // 12 bytes: the span behind it would land on a 4-byte boundary + + const std::uint16_t indices[4] = {0, 1, 2, 3}; + const MGPBlobRef staged = wire.Encoder().StageBytes(indices, sizeof(indices)); + MGHostSpan span{}; + span.Ptr = nullptr; // rule B + span.Seg = staged.Seg; + span.Offset = staged.Offset; + span.Size = staged.Size; + + MGPDrawRange ranges[1] = {{0, 4, 0}}; + const WireTail tails[2] = {{ranges, sizeof(ranges)}, {&span, sizeof(span)}}; + + WireRecordLayout layout{}; + ASSERT_TRUE(MGPipeWireRecordLayout(MGPWireOp::DrawVbo, &info, layout)); + EXPECT_EQ(layout.TailCount, 2u); + EXPECT_EQ(layout.TailOffset[1] % 8, 0u) << "MGPDrawRange is twelve bytes; the span behind an " + "odd NumDraws must still be 8-aligned"; + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::DrawVbo, &info, sizeof(info), tails, 2), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_TRUE(wire.Sink().SawUserIndices); + EXPECT_EQ(wire.Sink().LastSpan.Ptr, nullptr); + EXPECT_EQ(wire.Sink().LastSpan.Size, sizeof(indices)); +} + +// ===================================================================================== +// CreateShaderState's seven blobs +// ===================================================================================== + +TEST_F(PipeWireCodecTest, CreateShaderStateCrossesAsOneArchiveAndSixUndeclaredRuns) { + Wire2 wire; + MG_State::GLState::LinkArtifacts link; + MG_State::GLState::SpirvArtifacts spirv; + spirv.spirvStatus = true; + spirv.nativeFloat64 = false; + spirv.generatedSpirv.resize(6); + for (std::size_t stage = 0; stage < 6; ++stage) { + spirv.generatedSpirv[stage].assign(4 + stage, static_cast(0x07230203 + stage)); + } + spirv.globalUboScratch.assign(32, 0xAB); + + Vector archive; + MG_State::GLState::EncodeProgramArtifacts(link, spirv, archive); + ASSERT_FALSE(archive.empty()); + + MGPProgramDesc desc{}; + desc.Cso = MakeHandle(77); + desc.StageMask = 0x3f; + desc.SpirvStatus = 1; + // The ruling: Reflection names the WHOLE archive - which already carries every stage's + // modules - and Spirv[0..5] stay all-zero. Shipping the modules twice would double the + // biggest record in the catalogue for a reader that does not exist. + desc.Reflection = wire.Encoder().StageBytes(archive.data(), archive.size()); + for (Uint32 i = 0; i < 6; ++i) { + EXPECT_EQ(desc.Spirv[i].Size, 0u); + EXPECT_EQ(desc.Spirv[i].Seg, 0u); + EXPECT_EQ(desc.Spirv[i].Offset, 0u); + } + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::CreateShaderState, &desc, sizeof(desc)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + + // And the archive really did carry the six modules: decode it the way the arm does. + MG_State::GLState::LinkArtifacts back; + MG_State::GLState::SpirvArtifacts backSpirv; + ASSERT_TRUE(MG_State::GLState::DecodeProgramArtifacts(archive.data(), archive.size(), back, + backSpirv)); + ASSERT_EQ(backSpirv.generatedSpirv.size(), 6u); + for (std::size_t stage = 0; stage < 6; ++stage) { + EXPECT_EQ(backSpirv.generatedSpirv[stage].size(), 4 + stage); + EXPECT_EQ(backSpirv.generatedSpirv[stage][0], 0x07230203u + stage); + } + EXPECT_TRUE(backSpirv.spirvStatus); +} + +// ===================================================================================== +// SetResidualValueState - table 1's hardest row +// ===================================================================================== + +TEST_F(PipeWireCodecTest, ResidualValueBlockCrossesAsItsOwnBlob) { + // The applier takes `const ResidualValueBlock&` and MGPResidualValueState is never + // instantiated on the live path, so this is the first code in the tree that fills either. + Wire2 wire; + ResidualValueBlock block{}; + block.CapabilityBits = 0x0123456789ABCDEFull; + + MGPResidualValueState record{}; + record.Version = 3; + record.Blob = wire.Encoder().StageBytes(&block, sizeof(block)); + EXPECT_EQ(record.Blob.Size, static_cast(MGL_RESIDUAL_BLOCK_SIZE)); + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetResidualValueState, &record, sizeof(record)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + +// ===================================================================================== +// CreateSamplerState - a POD memcpy in which borderColorForm must survive +// ===================================================================================== + +TEST_F(PipeWireCodecTest, SamplerParametersCrossByteForByteIncludingBorderColorForm) { + Wire2 wire; + SamplerParameters params{}; + params.borderColorForm = BorderColorForm::Int; + params.minLod = -3.5f; + params.maxLod = 11.25f; + + MGPSamplerDesc desc{}; + desc.Cso = MakeHandle(88); + desc.Parameters = wire.Encoder().StageBytes(¶ms, sizeof(params)); + EXPECT_EQ(desc.Parameters.Size, sizeof(SamplerParameters)); + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::CreateSamplerState, &desc, sizeof(desc)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + + const void* staged = wire.Segments().Resolve(desc.Parameters.Seg, desc.Parameters.Offset, + desc.Parameters.Size); + ASSERT_NE(staged, nullptr); + SamplerParameters back{}; + std::memcpy(&back, staged, sizeof(back)); + EXPECT_EQ(static_cast(back.borderColorForm), static_cast(BorderColorForm::Int)); + EXPECT_FLOAT_EQ(back.minLod, -3.5f); + EXPECT_FLOAT_EQ(back.maxLod, 11.25f); +} + +// ===================================================================================== +// The class-B verbs +// ===================================================================================== + +TEST_F(PipeWireCodecTest, TheFiveClassBVerbsReachTheSinkAndNothingElse) { + Wire2 wire; + MGPClear clear{}; + clear.Kind = 0; + clear.BufferMask = 0x4000; + clear.ColorValue[0] = 0x3f800000u; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::Clear, &clear, sizeof(clear)), kInvalidSeq); + + MGPBlit blit{}; + blit.SrcX1 = 64; + blit.DstX1 = 64; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::Blit, &blit, sizeof(blit)), kInvalidSeq); + + MGPReadbackInfo readback{}; + readback.Box = MGPBox{0, 0, 0, 2, 2, 1}; + readback.DstSize = 16; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ReadPixels, &readback, sizeof(readback)), + kInvalidSeq); + + MGPPresent present{}; + present.FrameSerial = 12; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::Present, &present, sizeof(present)), kInvalidSeq); + + bool applied = false; + for (int i = 0; i < 4; ++i) { + ASSERT_TRUE(wire.PumpOne(&applied)) << "record " << i; + EXPECT_TRUE(applied) << "record " << i; + } + EXPECT_EQ(wire.Sink().Clears.size(), 1u); + EXPECT_EQ(wire.Sink().Blits.size(), 1u); + EXPECT_EQ(wire.Sink().Presents.size(), 1u); + ASSERT_EQ(wire.Sink().Readbacks.size(), 1u); + EXPECT_EQ(wire.Sink().Presents[0].FrameSerial, 12u); + // read_pixels BLOCKS in P5 and its pixels come back in the reply slot the record's own + // seq names (contract table 1 row 23). + ASSERT_EQ(wire.Answers().All.size(), 1u); + EXPECT_EQ(wire.Answers().All[0].Seq, wire.Sink().ReadbackSeqs[0]); + EXPECT_EQ(wire.Answers().All[0].Bytes.size(), 4u); + EXPECT_EQ(wire.Decoder().AppliedSeq(), 4u); +} + +TEST_F(PipeWireCodecTest, AClassBVerbWithNoSinkDeclinesRatherThanInventsASemantics) { + Wire2 wire; + wire.Decoder().SetVerbSink(nullptr); + MGPClear clear{}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::Clear, &clear, sizeof(clear)), kInvalidSeq); + bool applied = true; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_FALSE(applied); +} + +// ===================================================================================== +// R-9, R-10, R-11 +// ===================================================================================== + +TEST_F(PipeWireCodecTest, AppliedSeqAdvancesByExactlyOnePerRecordAndIsNeverBatched) { + Wire2 wire; + MGPPresent present{}; + for (Uint64 i = 1; i <= 5; ++i) { + present.FrameSerial = i; + ASSERT_EQ(wire.Encoder().EncodeRecord(MGPWireOp::Present, &present, sizeof(present)), i); + } + wire.Encoder().Publish(); + for (Uint64 i = 1; i <= 5; ++i) { + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_EQ(wire.Decoder().AppliedSeq(), i); + EXPECT_EQ(wire.Control().appliedSeq.load(), i); + } + EXPECT_EQ(wire.Encoder().EmitSeq(), 5u); +} + +TEST_F(PipeWireCodecTest, MaxRecordBytesSeenStaysFarBelowHalfTheRing) { + // R-10's proof obligation. P5 does no chunking and must instead show it never needed any. + Wire2 wire; + MGPDrawInfo info{}; + info.NumDraws = 64; + std::vector ranges(64); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::DrawVbo, &info, sizeof(info), ranges.data(), + ranges.size() * sizeof(MGPDrawRange)), + kInvalidSeq); + const Uint64 largest = wire.Encoder().MaxRecordBytesSeen(); + EXPECT_GT(largest, 0u); + EXPECT_EQ(largest, 8u + sizeof(MGPDrawInfo) + 64u * sizeof(MGPDrawRange)); + EXPECT_LT(largest, wire.Cmd().MaxRecordBytes()); + // The biggest fixed payload in the whole catalogue is MGPProgramDesc at 192 bytes plus + // MGPFramebufferState at 304, so a record only ever grows through its TAIL - which is why + // the counter is on the encoder and not a constant. + EXPECT_LT(8u + sizeof(MGPFramebufferState), wire.Cmd().MaxRecordBytes()); +} + +TEST_F(PipeWireCodecTest, StagedBytesAreReclaimedOnlyBehindRetiredSeq) { + Wire2 wire; + const std::uint8_t payload[64] = {}; + const MGPBlobRef first = wire.Encoder().StageBytes(payload, sizeof(payload)); + (void)first; + const Uint64 inFlight = wire.Encoder().StagedBytesInFlight(); + EXPECT_GE(inFlight, sizeof(payload)); + + MGPBindRenderState bind{}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)), + kInvalidSeq); + // Nothing is retired yet, so nothing may be released: R-11's whole content on this side. + wire.Encoder().ReclaimStagedBytes(); + EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), inFlight); + + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + wire.Encoder().ReclaimStagedBytes(); + EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u); +} + +TEST_F(PipeWireCodecTest, TheAuditFillOverwritesExactlyTheRunsTheRecordResolved) { + // R-2.5, the only mechanical control on rule C ("no applier entry point retains a pointer + // past its return"). An instrumentation that cannot be observed to have run is decoration, + // so this case asserts the bytes, not the flag. + Wire2 wire; + wire.Decoder().SetAuditPoison(true); + ResidualValueBlock block{}; + block.CapabilityBits = 0x5555555555555555ull; + MGPResidualValueState record{}; + record.Blob = wire.Encoder().StageBytes(&block, sizeof(block)); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetResidualValueState, &record, sizeof(record)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_EQ(wire.Decoder().PoisonedStageBytes(), sizeof(ResidualValueBlock)); + const auto* staged = wire.StageBase() + record.Blob.Offset; + for (std::size_t i = 0; i < sizeof(ResidualValueBlock); ++i) { + EXPECT_EQ(staged[i], 0xDD) << "byte " << i; + } +} + +TEST_F(PipeWireCodecTest, TheAuditFillIsOffByDefaultSoTheHotPathPaysNothing) { + Wire2 wire; + ResidualValueBlock block{}; + block.CapabilityBits = 0x77ull; + MGPResidualValueState record{}; + record.Blob = wire.Encoder().StageBytes(&block, sizeof(block)); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetResidualValueState, &record, sizeof(record)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_EQ(wire.Decoder().PoisonedStageBytes(), 0u); + const auto* staged = wire.StageBase() + record.Blob.Offset; + EXPECT_NE(staged[0], 0xDD); +} + +// ===================================================================================== +// MGPCaps's two blob codecs +// ===================================================================================== + +TEST_F(PipeWireCodecTest, FormatCapabilitiesRoundTrip) { + MG_Backend::FormatCapabilityCache cache; + cache.FullCaps[0][1] = MG_Backend::FormatCapabilityFlags( + static_cast(MG_Backend::FormatCapability::Creatable) | + static_cast(MG_Backend::FormatCapability::Sampled)); + cache.CaveatCaps[2][3] = + MG_Backend::FormatCapabilityFlags(static_cast(MG_Backend::FormatCapability::LinearFilter)); + cache.SampleCounts[1][4] = Vector{1, 2, 4, 8}; + + Vector bytes; + ASSERT_TRUE(EncodeFormatCapabilities(cache, bytes)); + ASSERT_FALSE(bytes.empty()); + + MG_Backend::FormatCapabilityCache back; + ASSERT_TRUE(DecodeFormatCapabilities(bytes.data(), bytes.size(), back)); + EXPECT_EQ(back.FullCaps[0][1].GetRaw(), cache.FullCaps[0][1].GetRaw()); + EXPECT_EQ(back.CaveatCaps[2][3].GetRaw(), cache.CaveatCaps[2][3].GetRaw()); + EXPECT_EQ(back.SampleCounts[1][4], cache.SampleCounts[1][4]); + EXPECT_EQ(back.FullCaps[5][5].GetRaw(), 0u); + EXPECT_TRUE(back.SampleCounts[0][0].empty()); + + // Sparse: an almost-empty cache must not cost the square of two enum spaces. + EXPECT_LT(bytes.size(), 4096u); +} + +TEST_F(PipeWireCodecTest, FormatCapabilitiesDecoderRefusesTruncationAndTrailingBytes) { + MG_Backend::FormatCapabilityCache cache; + cache.FullCaps[0][0] = + MG_Backend::FormatCapabilityFlags(static_cast(MG_Backend::FormatCapability::Creatable)); + Vector bytes; + ASSERT_TRUE(EncodeFormatCapabilities(cache, bytes)); + + MG_Backend::FormatCapabilityCache back; + for (SizeT cut = 1; cut < bytes.size(); ++cut) { + EXPECT_FALSE(DecodeFormatCapabilities(bytes.data(), cut, back)) << "truncated to " << cut; + } + Vector longer = bytes; + longer.push_back(0); + EXPECT_FALSE(DecodeFormatCapabilities(longer.data(), longer.size(), back)); + // A version word this build does not read is a refusal, never a guess. + Vector wrongVersion = bytes; + wrongVersion[0] = static_cast(wrongVersion[0] + 1); + EXPECT_FALSE(DecodeFormatCapabilities(wrongVersion.data(), wrongVersion.size(), back)); +} + +TEST_F(PipeWireCodecTest, RendererInfoRoundTrips) { + RendererInfo info; + info.RendererName = "Espryt"; + info.BackendName = "DirectGLES"; + info.ExtraVendor = String("Qualcomm"); + info.RendererGLInfo.TargetGLVersion = Version{4, 6, 0, Optional(), Optional()}; + info.RendererGLInfo.TargetGLSLVersion = + Version{4, 60, 0, Optional(String("-dev")), Optional(VersionType::Development)}; + info.RendererGLInfo.Extensions = Vector{static_cast(1), + static_cast(7)}; + info.RendererGLInfo.IsCompatibilityProfile = true; + info.StaticBackendCapability.AllowVSOnlyPrograms = true; + + Vector bytes; + ASSERT_TRUE(EncodeRendererInfo(info, bytes)); + + RendererInfo back; + ASSERT_TRUE(DecodeRendererInfo(bytes.data(), bytes.size(), back)); + EXPECT_EQ(back.RendererName, info.RendererName); + EXPECT_EQ(back.BackendName, info.BackendName); + ASSERT_TRUE(back.ExtraVendor.has_value()); + EXPECT_EQ(*back.ExtraVendor, "Qualcomm"); + EXPECT_EQ(back.RendererGLInfo.TargetGLVersion.Major, 4); + EXPECT_EQ(back.RendererGLInfo.TargetGLSLVersion.Minor, 60); + ASSERT_TRUE(back.RendererGLInfo.TargetGLSLVersion.Suffix.has_value()); + EXPECT_EQ(*back.RendererGLInfo.TargetGLSLVersion.Suffix, "-dev"); + ASSERT_TRUE(back.RendererGLInfo.TargetGLSLVersion.Type.has_value()); + EXPECT_EQ(static_cast(*back.RendererGLInfo.TargetGLSLVersion.Type), + static_cast(VersionType::Development)); + ASSERT_EQ(back.RendererGLInfo.Extensions.size(), 2u); + EXPECT_EQ(static_cast(back.RendererGLInfo.Extensions[1]), 7); + EXPECT_TRUE(back.RendererGLInfo.IsCompatibilityProfile); + EXPECT_TRUE(back.StaticBackendCapability.AllowVSOnlyPrograms); +} + +TEST_F(PipeWireCodecTest, RendererInfoDecoderRefusesEveryTruncation) { + RendererInfo info; + info.RendererName = "Magma"; + info.BackendName = "DirectVulkan"; + Vector bytes; + ASSERT_TRUE(EncodeRendererInfo(info, bytes)); + RendererInfo back; + for (SizeT cut = 1; cut < bytes.size(); ++cut) { + EXPECT_FALSE(DecodeRendererInfo(bytes.data(), cut, back)) << "truncated to " << cut; + } + EXPECT_FALSE(DecodeRendererInfo(nullptr, 0, back)); +} + +TEST_F(PipeWireCodecTest, TheAbiFingerprintIsStableWithinABuildAndNotZero) { + const Uint64 first = CapsAbiFingerprint(); + EXPECT_NE(first, 0u); + EXPECT_EQ(first, CapsAbiFingerprint()); +} + +TEST_F(PipeWireCodecTest, TheConsumerMaskAnswersPerFamilyAndNotPerOpTable) { + // R-8's only legal client-side spelling. Under inproc a client that read + // MGPipeGetResourceOps() would be right BY ACCIDENT; under spawn that table is null and + // five whole record families emit nothing at all, silently. + const Uint64 mask = MGCapsConsumerBits(kMGPipeSubsystemResources | kMGPipeSubsystemPrograms); + EXPECT_TRUE(MGCapsServerConsumes(mask, kMGPipeSubsystemResources)); + EXPECT_TRUE(MGCapsServerConsumes(mask, kMGPipeSubsystemPrograms)); + EXPECT_FALSE(MGCapsServerConsumes(mask, kMGPipeSubsystemTextureResources)); + // The feature bits below it are untouched by the consumer block. + EXPECT_EQ(mask & 0xFFFFFFFFull, 0u); +} + +// ===================================================================================== +// The Fatal arms. Forked, never EXPECT_DEATH. +// ===================================================================================== + +#if MGTEST_HAVE_FORK + +TEST_F(PipeWireCodecTest, ANonNullHostSpanPointerIsFatal) { + // R-2 arm 1 / rule B. In ONE address space this pointer works, which is exactly why the + // rule has to be mechanical. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPShaderBuffers header{}; + header.Count = 1; + header.HostSpanCount = 1; + MGPBufferRange range{}; + MGHostSpan span{}; + std::uint64_t here = 0; + span.Ptr = &here; // the inproc cheat + span.Seg = kSegStage; + span.Size = 8; + std::vector tail(sizeof(range) + sizeof(span)); + std::memcpy(tail.data(), &range, sizeof(range)); + std::memcpy(tail.data() + sizeof(range), &span, sizeof(span)); + ForgeAndDecode(wire, MGPWireOp::SetShaderBuffers, &header, sizeof(header), tail.data(), + tail.size()); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption, \"host-span\"}"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, AContentRecordThatDeclaresNoBlobIsFatal) { + // R-2 arm 2. This is the arm that inverts today's legal state: Blob.Size == 0 means "this + // record does not declare its blob", which is right for monolith and a lie under split. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPVertexElements desc{}; + desc.Cso = MakeHandle(3); + desc.AttributeCount = 1; + desc.BindingPointCount = 1; + desc.Blob = MGPBlobRef{}; // all three fields zero: "absent" + ForgeAndDecode(wire, MGPWireOp::CreateVertexElements, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("carries content and its blob declares none"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ANonZeroSizeWithNoSegmentIsFatal) { + // R-2 arm 3. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPVertexElements desc{}; + desc.Cso = MakeHandle(3); + desc.AttributeCount = 1; + desc.Blob.Seg = kSegNone; + desc.Blob.Offset = 0; + desc.Blob.Size = 64; + ForgeAndDecode(wire, MGPWireOp::CreateVertexElements, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("with no segment"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ARunThatLeavesItsSegmentIsFatal) { + // R-2 arm 4. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPVertexElements desc{}; + desc.Cso = MakeHandle(3); + desc.AttributeCount = 1; + desc.Blob.Seg = kSegStage; + desc.Blob.Offset = Wire2::kStageBytes - 8; + desc.Blob.Size = 4096; + ForgeAndDecode(wire, MGPWireOp::CreateVertexElements, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("does not lie inside that segment"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, AHalfDeclaredBlobIsFatalRatherThanReadAsAbsent) { + // The shape a MONOLITH emitter produces - Seg None, Offset a host address, Size 0. Reading + // it as "absent" would silently drop the bytes of every record an unconverted emitter sent. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPRenderStateDesc desc{}; + desc.Cso = MakeHandle(3); + desc.ChunkMask = 0; + desc.Blob.Seg = kMGHostSpanSegNone; + desc.Blob.Offset = 0xDEADBEEFull; // a host address, the way ProgramEmit.h writes one + desc.Blob.Size = 0; + ForgeAndDecode(wire, MGPWireOp::CreateRenderState, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("a blob with Size 0 declares"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ATailThatDoesNotMatchItsOwnCountIsFatal) { + // THE CROSS-CHECK THIS PACKAGE EXISTS FOR. MGP_WIRE_CHECK_BOUNDS proves + // `size >= sizeof(MGPWireRec_X)` and CANNOT SEE THE TAIL, so this record - Count = 4000 + // with eight bytes behind it - passes the generated gate today. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPSamplerViews header{}; + header.Start = 0; + header.Count = 4000; + const std::uint64_t eightBytes = 0; + ForgeAndDecode(wire, MGPWireOp::SetSamplerViews, &header, sizeof(header), &eightBytes, + sizeof(eightBytes)); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + // The count is past the unit bound, so the layout refuses it before the length even + // matters - which is the stronger of the two answers. + EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ATailWhoseLengthDisagreesWithItsCountIsFatal) { + // The same defect inside the legal count range, so the SIZE arithmetic is what catches it + // rather than the bound. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPSamplerViews header{}; + header.Start = 0; + header.Count = 4; // 4 * sizeof(MGPBoundView) == 96 bytes of tail + const std::uint64_t eightBytes = 0; + ForgeAndDecode(wire, MGPWireOp::SetSamplerViews, &header, sizeof(header), &eightBytes, + sizeof(eightBytes)); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("its own count fields describe"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, VertexAttribDefaultsMustAgreeWithItsOwnMask) { + // Two declarants, Count and popcount(Mask), and nothing checked them before. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPVertexAttribDefaults header{}; + header.Mask = 0x7; // three bits + header.Count = 2; // two entries + MGPAttribValue tail[2]{}; + ForgeAndDecode(wire, MGPWireOp::SetVertexAttribDefaults, &header, sizeof(header), tail, + sizeof(tail)); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("SetVertexAttribDefaults.Count"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, APadRecordReachingTheDecoderIsFatal) { + // R-9: a pad does not advance seq and both sides skip it BEFORE counting. One that reached + // here has already been counted, and because the seq IS the reply-slot id, a drift of one + // silently reads another call's answer rather than failing. + const ChildResult r = RunInChild([] { + Wire2 wire; + Transport::RingRecordView view{}; + std::uint8_t bytes[16] = {}; + view.kind = Transport::kRingPadRecordKind; + view.flags = Transport::kRecPad; + view.payload = bytes + 8; + view.payloadSize = 8; + (void)wire.Decoder().DecodeAndApply(view); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("wrap filler reached the decoder"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ADeclaredPerStageSpirvRunIsFatal) { + // w1's ruling: the archive already carries every stage's modules, so a per-stage run would + // be a second, forgeable way to say the same thing. + const ChildResult r = RunInChild([] { + Wire2 wire; + MG_State::GLState::LinkArtifacts link; + MG_State::GLState::SpirvArtifacts spirv; + Vector archive; + MG_State::GLState::EncodeProgramArtifacts(link, spirv, archive); + MGPProgramDesc desc{}; + desc.Cso = MakeHandle(3); + desc.Reflection = wire.Encoder().StageBytes(archive.data(), archive.size()); + const std::uint32_t words[4] = {1, 2, 3, 4}; + desc.Spirv[0] = wire.Encoder().StageBytes(words, sizeof(words)); + ForgeAndDecode(wire, MGPWireOp::CreateShaderState, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("CreateShaderState.Spirv[0]"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, AHostSpanCountThatIsNeitherZeroNorCountIsFatal) { + // MGPipeTypes.h:820-823: HostSpanCount is 0 OR Count, never anything else, so the two + // arrays stay index-aligned. A third value lets a record describe spans for ranges it does + // not have - and the arrays would then be read off by one for the rest of the tail. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPShaderBuffers header{}; + header.Count = 4; + header.HostSpanCount = 3; + MGPBufferRange ranges[4]{}; + MGHostSpan spans[3]{}; + std::vector tail(sizeof(ranges) + sizeof(spans)); + std::memcpy(tail.data(), ranges, sizeof(ranges)); + std::memcpy(tail.data() + sizeof(ranges), spans, sizeof(spans)); + ForgeAndDecode(wire, MGPWireOp::SetShaderBuffers, &header, sizeof(header), tail.data(), + tail.size()); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("SetShaderBuffers.HostSpanCount"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, ASecondProcessResolverIsFatalRatherThanASilentRace) { + // Table 3: one gMGPipeSegmentResolver per process, installed by the SERVER role only. + const ChildResult r = RunInChild([] { + SegmentTable a; + SegmentTable b; + std::vector bytes(64); + a.Install(kSegStage, SegmentView{bytes.data(), bytes.size()}); + b.Install(kSegStage, SegmentView{bytes.data(), bytes.size()}); + a.InstallProcessResolver(); + b.InstallProcessResolver(); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("already installed"), std::string::npos) << r.Log; +} + +#else + +TEST_F(PipeWireCodecTest, TheFatalArmsNeedFork) { + GTEST_SKIP() << "the R-2 Fatal arms are asserted by forking; POSIX only"; +} + +#endif // MGTEST_HAVE_FORK + +TEST_F(PipeWireCodecTest, TheProcessResolverRoundTripsThroughMGPipeHostBytes) { + SegmentTable table; + std::vector bytes(128); + for (std::size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i); + } + table.Install(kSegStage, SegmentView{bytes.data(), bytes.size()}); + table.InstallProcessResolver(); + + MGHostSpan span{}; + span.Ptr = nullptr; + span.Seg = kSegStage; + span.Offset = 16; + span.Size = 32; + EXPECT_EQ(MGPipeHostBytes(span), bytes.data() + 16); + + SegmentTable::UninstallProcessResolver(); + EXPECT_EQ(MGPipeHostBytes(span), nullptr); +} + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. The name carries this process's pid, because + // gtest_discover_tests runs every case as its own process, in parallel under ctest -j. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-pipewirecodec-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; +} From 91d0c6b06f20a1ab927457c89b4b6bed364f1e73 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 13:59:51 -0400 Subject: [PATCH 5/9] [Fix] (MG_Remote, Wire): let an encoder caller omit a tail whose own count is zero - Count == 0 is a legal record for every kVarTail row and a required placeholder entry would be a trap --- MobileGL/MG_Remote/Wire/PipeWireCodec.cpp | 16 +++++++++++----- MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index b01d2c65..1d32f097 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -672,14 +672,20 @@ namespace MobileGL::MG_Remote::Wire { // The caller's tails are held to the layout the PAYLOAD declares, which is the same // arithmetic the decoder will run. A Count that says 4000 while the tail holds 8 bytes // dies here, on the producing side, rather than on a peer that can only say "corrupt". - if (tailCount != layout.TailCount) { + // + // A caller may SUPPLY FEWER TAILS THAN THE LAYOUT HAS, but only while the ones it left + // out are empty - Count == 0 is a legal record for every kVarTail row, and requiring a + // {nullptr, 0} entry for it would be a trap rather than a check. Anything else is a + // disagreement between the counts the payload declares and the bytes the caller holds. + if (tailCount > layout.TailCount) { WireProtocolFatalAt("EncodeRecord.tailCount", tailCount, layout.TailCount); } - for (Uint32 i = 0; i < tailCount; ++i) { - if (tails[i].Size != layout.TailBytes[i]) { - WireProtocolFatalAt("EncodeRecord.tailBytes", tails[i].Size, layout.TailBytes[i]); + for (Uint32 i = 0; i < layout.TailCount; ++i) { + const Uint64 supplied = i < tailCount ? tails[i].Size : 0; + if (supplied != layout.TailBytes[i]) { + WireProtocolFatalAt("EncodeRecord.tailBytes", supplied, layout.TailBytes[i]); } - if (tails[i].Size != 0 && tails[i].Bytes == nullptr) { + if (supplied != 0 && tails[i].Bytes == nullptr) { WireProtocolFatal("EncodeRecord.tail", "non-zero tail length with a null pointer"); } } diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index 13a72a68..d53ac624 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -452,6 +452,27 @@ TEST_F(PipeWireCodecTest, KVarTailRoundTripsWithItsTailIntact) { EXPECT_TRUE(applied); } +TEST_F(PipeWireCodecTest, AnEmptyVarTailIsALegalRecordAndNeedsNoPlaceholderEntry) { + // Count == 0 is legal for every kVarTail row - "bind nothing at this range" - and a caller + // that had to pass a {nullptr, 0} entry for each absent tail would be walking into a trap + // rather than through a check. SetStreamOutputTargets is the sharpest case: its layout has + // TWO tails and both are empty at Count 0. + Wire2 wire; + MGPStreamOutputTargets header{}; + header.Count = 0; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetStreamOutputTargets, &header, sizeof(header)), + kInvalidSeq); + MGPVertexBuffers buffers{}; + buffers.Count = 0; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetVertexBuffers, &buffers, sizeof(buffers)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_FALSE(applied); // no applier for stream output; off the reduced path + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); +} + TEST_F(PipeWireCodecTest, KReplySlotMapPersistentIsAConstantDecline) { // R-6 / R-2.4. DECLINED is a real answer, not a failure, and the applier is not called at // all: the record's payload is a bare MGPHandleOnly and carries NEITHER the size NOR the From 75c0bcae804f422e51172c2f0b80c82cbe2e47b4 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:28:34 -0400 Subject: [PATCH 6/9] [Docs] (MG_Remote): correct the sparse-caps comment - 13 targets by 77 internal formats is ~16 KiB of dense table, not half a megabyte --- MobileGL/MG_Remote/CapsCodec.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/MobileGL/MG_Remote/CapsCodec.cpp b/MobileGL/MG_Remote/CapsCodec.cpp index 8151600a..13a9574e 100644 --- a/MobileGL/MG_Remote/CapsCodec.cpp +++ b/MobileGL/MG_Remote/CapsCodec.cpp @@ -21,10 +21,11 @@ // a single element is reserved, so a corrupt count cannot become a four-billion-element // resize; // * SPARSE for all three capability tables. The dense form is -// 2 * targets * formats * 8 bytes plus the sample-count lists - half a megabyte of mostly -// zeroes, per context, per caps invalidation (R-12 makes a re-arriving snapshot the -// invalidation, so this is not a once-per-process cost). The tables are overwhelmingly -// empty, so what crosses is (index, value) pairs and the decoder Clear()s first; +// 2 * targets * formats * 8 bytes plus the sample-count lists - with 13 targets and 77 +// internal formats that is ~16 KiB of mostly zeroes, PER CONTEXT AND PER CAPS +// INVALIDATION, because R-12 makes a re-arriving snapshot the invalidation rather than a +// once-per-process cost. The tables are overwhelmingly empty, so what crosses is +// (index, value) pairs and the decoder Clear()s first; // * little-endian by memcpy of fixed-width scalars, which is what every other MobileGL wire // struct already assumes and what the ABI fingerprint below makes checkable; // * every decoder returns FALSE on truncation, a bad version, a dimension mismatch, an From 76140c33ebfb764a195ecbb75935b1f524810022 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:28:34 -0400 Subject: [PATCH 7/9] [Fix] (MG_Remote, Wire): stop the decoder writing RingControl - appliedSeq has exactly one writer and it is the session, so the decoder keeps its own tally and the two are compared instead --- MobileGL/MG_Remote/Wire/PipeWireCodec.cpp | 14 ++-- MobileGL/MG_Remote/Wire/PipeWireCodec.h | 21 +++--- MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 80 ++++++++++++++++++++- 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index 1d32f097..a1ee9f27 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -949,15 +949,13 @@ namespace MobileGL::MG_Remote::Wire { PoisonResolvedRuns(); + // THE DECODER'S OWN TALLY, AND NOT THE SHARED WATERMARK. RingControl::appliedSeq has + // exactly one writer - s1's SessionConsumer::ApplyOne, +1 per record, pads never + // counted - and this class does not write RingControl at all. Keeping a private count + // beside it is what makes R-9's batching ban CHECKABLE rather than merely stated: the + // session's watermark and this number must agree after every record, and a test that + // compares them catches a batched publish that a single counter could not. ++m_applySeq; - // R-9: EVERY record, never batched. The client's verb barrier and every reply wait - // read appliedSeq, and a batched watermark makes a waiter resume on work the server - // has not done. retiredSeq goes with it because nothing in P5 borrows a ring slot into - // the GPU timeline - the day something does, this is the line that splits. - if (m_control != nullptr) { - m_control->appliedSeq.store(m_applySeq, std::memory_order_release); - m_control->retiredSeq.store(m_applySeq, std::memory_order_release); - } return applied; } diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index 7477e925..80124f0d 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -364,16 +364,19 @@ namespace MobileGL::MG_Remote::Wire { // here Fatals, because a pad that reached the decoder has already been counted. Bool DecodeAndApply(const Transport::RingRecordView& record); - // Advanced by exactly one per applied non-pad record. P5 FORBIDS BATCHING IT (R-9): - // the verb barrier's waiter reads it, and a batched watermark makes the client wait - // for records the server has not run. + // THE DECODER'S OWN TALLY, NOT THE SHARED WATERMARK. Advanced by exactly one per + // applied non-pad record. // - // DecodeAndApply PUBLISHES RingControl::appliedSeq AND retiredSeq to this value after - // every record, because the decoder is the thing that knows when a record's SEG_STAGE - // runs stopped being read (R-11, table 1's "retires: apply"). v1's PipeApplier must - // therefore NOT advance either watermark a second time - a double advance makes the - // client's barrier resume on a record the server has not run, which is precisely the - // failure R-9's "never publish a watermark early" exists to forbid. + // RingControl::appliedSeq has exactly ONE writer - s1's SessionConsumer::ApplyOne, +1 + // per record, pads never counted - and this class writes NO RingControl field at all. + // That is deliberate rather than a division of labour: two writers of a watermark is + // how a waiter resumes on a record the server has not run, which is what R-9's "never + // publish a watermark early" forbids, and there is no checksum on this ring that would + // catch it. + // + // Keeping a private count beside the session's is what makes the batching ban + // CHECKABLE instead of merely stated: after every record the two numbers must agree, + // and a single counter could not tell a batched publish from an honest one. Uint64 AppliedSeq() const; // v1 installs the backend bridge for contract §7's five class-B verbs. Null - the diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index d53ac624..02ab7035 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -111,9 +111,14 @@ namespace { Transport::RingConsumer& Consumer() { return m_consumer; } std::uint8_t* StageBase() { return m_stageBytes.data(); } - // Pops one record and decodes it. Returns whether the decoder reported "applied"; - // `popped` says whether there was a record at all, so a case cannot pass because - // nothing was there. + // Pops one record, decodes it, and then does what s1's SessionConsumer::ApplyOne does: + // advance RingControl's watermarks by ONE. THE DECODER DOES NOT WRITE RingControl - + // appliedSeq has exactly one writer and it is the session - so this fixture has to + // play that role, which is also what lets a case compare the session's watermark + // against the decoder's own tally and catch a batched publish. + // + // Returns whether there was a record at all, so a case cannot pass because nothing was + // there; `applied` is what the decoder reported. bool PumpOne(bool* applied) { m_encoder.Publish(); Transport::RingRecordView view{}; @@ -125,6 +130,11 @@ namespace { return false; } const bool result = m_decoder.DecodeAndApply(view); + ++m_sessionApplied; + m_control.appliedSeq.store(m_sessionApplied, std::memory_order_release); + // Nothing in P5 borrows a ring slot into the GPU timeline, so a record's SEG_STAGE + // runs retire as soon as it is applied (table 1's "retires: apply"). + m_control.retiredSeq.store(m_sessionApplied, std::memory_order_release); m_consumer.PublishRetired(); if (applied != nullptr) { *applied = result; @@ -132,6 +142,8 @@ namespace { return true; } + std::uint64_t SessionAppliedSeq() const { return m_sessionApplied; } + // ---- recorded answers ---- struct Reply { std::uint64_t Seq = 0; @@ -216,6 +228,7 @@ namespace { PipeWireDecoder m_decoder; Replies m_replies; Verbs m_verbs; + std::uint64_t m_sessionApplied = 0; }; MGPipeHandle MakeHandle(Uint32 slot, Uint32 gen = 1) { @@ -899,6 +912,10 @@ TEST_F(PipeWireCodecTest, AClassBVerbWithNoSinkDeclinesRatherThanInventsASemanti // ===================================================================================== TEST_F(PipeWireCodecTest, AppliedSeqAdvancesByExactlyOnePerRecordAndIsNeverBatched) { + // TWO COUNTS, ON PURPOSE. RingControl::appliedSeq has exactly one writer - the session - + // and the decoder keeps its own tally. They must agree after every record, and a single + // counter could not tell a batched publish from an honest one (R-9: a batched watermark + // makes the client's barrier resume on work the server has not run). Wire2 wire; MGPPresent present{}; for (Uint64 i = 1; i <= 5; ++i) { @@ -911,12 +928,44 @@ TEST_F(PipeWireCodecTest, AppliedSeqAdvancesByExactlyOnePerRecordAndIsNeverBatch ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_EQ(wire.Decoder().AppliedSeq(), i); EXPECT_EQ(wire.Control().appliedSeq.load(), i); + EXPECT_EQ(wire.SessionAppliedSeq(), wire.Decoder().AppliedSeq()); } EXPECT_EQ(wire.Encoder().EmitSeq(), 5u); } +TEST_F(PipeWireCodecTest, TheDecoderWritesNoRingControlFieldOfItsOwn) { + // s1 owns RingControl::appliedSeq; the codec's job ends at "this record was applied". Two + // writers of a watermark is how a waiter resumes on a record the server has not run, and + // there is no checksum on this ring that would catch it - so this is asserted rather than + // documented. + Wire2 wire; + MGPPresent present{}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::Present, &present, sizeof(present)), + kInvalidSeq); + wire.Encoder().Publish(); + + Transport::RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(wire.Consumer().Pop(view, &corrupt)); + ASSERT_FALSE(corrupt); + const std::uint64_t appliedBefore = wire.Control().appliedSeq.load(); + const std::uint64_t retiredBefore = wire.Control().retiredSeq.load(); + + (void)wire.Decoder().DecodeAndApply(view); + + EXPECT_EQ(wire.Control().appliedSeq.load(), appliedBefore); + EXPECT_EQ(wire.Control().retiredSeq.load(), retiredBefore); + EXPECT_EQ(wire.Decoder().AppliedSeq(), 1u); // the decoder's own tally did move +} + TEST_F(PipeWireCodecTest, MaxRecordBytesSeenStaysFarBelowHalfTheRing) { // R-10's proof obligation. P5 does no chunking and must instead show it never needed any. + // + // THE CAP IS ASKED FOR AT RUNTIME AND NEVER DERIVED FROM MOBILEGL_IPC_RING_MB. s1 found + // that SEG_CMD's 8 MiB holds a 4096-byte control page plus a POWER-OF-TWO ring, so the + // ring is 4 MiB and MaxRecordBytes() is 2 MiB - half of what both CONTRACT-P5.md §5 and + // Config.h's comment say. Every comparison in the codec goes through + // RingProducer::MaxRecordBytes() for exactly this reason. Wire2 wire; MGPDrawInfo info{}; info.NumDraws = 64; @@ -934,6 +983,31 @@ TEST_F(PipeWireCodecTest, MaxRecordBytesSeenStaysFarBelowHalfTheRing) { EXPECT_LT(8u + sizeof(MGPFramebufferState), wire.Cmd().MaxRecordBytes()); } +TEST_F(PipeWireCodecTest, ABigProgramArchiveDoesNotGrowItsRecordAtAll) { + // THE POINT OF R-10's "every blob goes through SEG_STAGE": create_shader_state's RECORD is + // 8 + sizeof(MGPProgramDesc) == 200 bytes whether the archive is one kilobyte or one + // megabyte, because the record carries {Seg, Offset, Size} and nothing else. So + // create_shader_state is one of the SMALLEST records in the catalogue, not the one most + // likely to approach MaxRecordBytes(); what approaches that cap is a var-tail, and the + // archive's own bound is MOBILEGL_IPC_STAGE_MB with a Fatal of its own. + Wire2 wire; + MG_State::GLState::LinkArtifacts link; + MG_State::GLState::SpirvArtifacts spirv; + spirv.generatedSpirv.resize(1); + spirv.generatedSpirv[0].assign(8 * 1024, 0x07230203u); // 32 KiB of module words + Vector archive; + MG_State::GLState::EncodeProgramArtifacts(link, spirv, archive); + ASSERT_GT(archive.size(), 32u * 1024u); + + MGPProgramDesc desc{}; + desc.Cso = MakeHandle(91); + desc.Reflection = wire.Encoder().StageBytes(archive.data(), archive.size()); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::CreateShaderState, &desc, sizeof(desc)), + kInvalidSeq); + EXPECT_EQ(wire.Encoder().MaxRecordBytesSeen(), 8u + sizeof(MGPProgramDesc)); + EXPECT_GE(wire.Encoder().StagedBytesInFlight(), archive.size()); +} + TEST_F(PipeWireCodecTest, StagedBytesAreReclaimedOnlyBehindRetiredSeq) { Wire2 wire; const std::uint8_t payload[64] = {}; From 31de23142f7713f5802c15e5eeef83f3aa1eee70 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:37:43 -0400 Subject: [PATCH 8/9] [Test] (MG_Test, Wire): assert a payload crosses byte for byte including the bytes spelled Pad - a codec that normalised one would silently delete the fields P5 and b1 have just put there --- MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index 02ab7035..00d036f5 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -570,6 +570,96 @@ TEST_F(PipeWireCodecTest, KHostSpanClassIsValidatedEvenThoughP5ProducesNone) { EXPECT_FALSE(applied); } +// ===================================================================================== +// Declared padding is payload, not slack +// ===================================================================================== + +TEST_F(PipeWireCodecTest, EveryPayloadByteCrossesIncludingTheOnesSpelledPad) { + // A PAD IS A FIELD SOMEBODY HAS NOT CLAIMED YET, and this phase is the proof: P5 put the + // respecify scope into MGPResourceDesc's two pads (contract table 1 row 19b) and b1 put + // MGPSubData::Pad0's low byte to work as HasLiveHostWrites. A codec that zeroed a pad "for + // determinism", or built a payload field by field, would DELETE those bits - and a dropped + // HasLiveHostWrites is not a visible failure, it is IsBufferDrawClean answering "clean" + // for a buffer with a live host writer, i.e. the frame drawing the last uploaded bytes + // with no diagnostic at all. + // + // So this case asserts the whole payload byte for byte rather than the named fields: a + // test that compared only the members would go green through exactly that bug. + // IT NAMES NO PAD MEMBER, deliberately. The whole struct is stamped with a recognisable + // byte first and only the fields the decoder validates are then written, so whatever is + // left - Pad0, Pad1, or the names a later phase gives them - still carries the stamp and a + // memcmp over the whole payload is the assertion. A case that named `Pad0` would stop + // COMPILING the day someone claims it, which is precisely the day it is most needed. + Wire2 wire; + std::vector texels(64, 0x31); + + MGPSubData upload{}; + std::memset(&upload, 0xA5, sizeof(upload)); + upload.Res = MakeHandle(101); + upload.Target = MGPipePackSubDataTarget(static_cast(MGPipeResourceTarget::Tex2D), 0u); + upload.Level = 2; + upload.SourceIsVerbatimLevelShadow = 1; + upload.UnionBox = MGPBox{1, 2, 0, 4, 4, 1}; + upload.RegionCount = 0; + upload.Blob = wire.Encoder().StageBytes(texels.data(), texels.size()); + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceSubData, &upload, sizeof(upload)), + kInvalidSeq); + wire.Encoder().Publish(); + + Transport::RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(wire.Consumer().Pop(view, &corrupt)); + ASSERT_FALSE(corrupt); + ASSERT_GE(view.payloadSize, sizeof(upload)); + EXPECT_EQ(std::memcmp(view.payload, &upload, sizeof(upload)), 0) + << "the payload did not cross byte for byte"; + + // And the stamp really did survive somewhere the named fields do not cover, so the case + // cannot pass by comparing a struct that has no unclaimed bytes left. + const auto* crossed = static_cast(view.payload); + std::size_t stamped = 0; + for (std::size_t i = 0; i < sizeof(upload); ++i) { + if (crossed[i] == 0xA5) { + ++stamped; + } + } + EXPECT_GT(stamped, 0u) << "no byte of the payload was left unclaimed; the case still checks " + "the memcmp above, but it no longer proves anything about pads"; +} + +TEST_F(PipeWireCodecTest, ResourceDescPadsCrossToo) { + // The same property over the struct P5 itself put two fields into. The helpers are the + // only legal reader (three fields are one value), but the BYTES are what the codec owes. + Wire2 wire; + MGPResourceDesc desc{}; + desc.Resource = MakeHandle(111); + desc.Target = static_cast(MGPipeResourceTarget::TexCube); + desc.InternalFormat = 7; + desc.Width = 16; + desc.Height = 16; + desc.Depth = 1; + desc.ArrayLayers = 6; + desc.Levels = 3; + desc.Samples = 1; + MGPipeSetRespecifiedLevel(desc, 0x0304u, 2u); + + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceRespecify, &desc, sizeof(desc)), + kInvalidSeq); + wire.Encoder().Publish(); + + Transport::RingRecordView view{}; + bool corrupt = false; + ASSERT_TRUE(wire.Consumer().Pop(view, &corrupt)); + ASSERT_FALSE(corrupt); + EXPECT_EQ(std::memcmp(view.payload, &desc, sizeof(desc)), 0); + const auto* crossed = static_cast(view.payload); + EXPECT_FALSE(MGPipeRespecifyIsWholeResource(*crossed)); + EXPECT_EQ(MGPipeRespecifiedUploadTargetOf(*crossed), 0x0304u); + EXPECT_EQ(MGPipeRespecifiedLevelOf(*crossed), 2u); + +} + // ===================================================================================== // The two double-tailed rows, and DrawVbo's conditional one // ===================================================================================== From 79b7866511c851f00074ab2772e06c729ec3d41d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 15:20:15 -0400 Subject: [PATCH 9/9] [Fix] (MG_Remote, Wire): close R-2 arm 4 over MGHostSpan, stop the producer writing SEG_STAGEs consumer cursors, bound the stage-mark queue, and answer a reply slot only for rows the catalogue flags kReplySlot --- MobileGL/MG_Remote/Wire/PipeWireCodec.cpp | 473 +++++++++++++++----- MobileGL/MG_Remote/Wire/PipeWireCodec.h | 101 ++++- MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 252 ++++++++++- 3 files changed, 692 insertions(+), 134 deletions(-) diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index a1ee9f27..0b13148c 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -61,27 +61,94 @@ namespace MobileGL::MG_Remote::Wire { static_assert(static_cast(MG_Pipe::kMGHostSpanSegNone) == kSegNone, "kMGHostSpanSegNone and SegmentId::kSegNone must be the same value"); - // The collision named in the file header, asserted rather than described. If a later edit - // moves either bit these fire and the mapping below is revisited; if someone deletes the - // mapping and stamps MGPipeCallFlags straight into the header, the RingTest wrap cases - // stay green and nine opcodes vanish, which is why this is a static_assert and not a - // comment. - static_assert(static_cast(MG_Pipe::kVarTail) == - static_cast(Transport::kRecPad), - "MGPipeCallFlags::kVarTail and RingRecordFlags::kRecPad share a bit AND a " - "field; the encoder must translate, never stamp. If this ever stops being " - "true, keep translating anyway - two flag spaces in one field is the hazard, " - "not this particular overlap"); - static_assert(static_cast(MG_Pipe::kHostSpan) == - static_cast(Transport::kRecBorrowSlot), - "MGPipeCallFlags::kHostSpan and RingRecordFlags::kRecBorrowSlot share a bit"); + // ---- the collision, asserted EXHAUSTIVELY rather than described ------------------- + // + // Naming the two overlaps somebody happened to notice is not enough: the review found a + // THIRD (kReplySlot == kRecVarTail) that four hand-written asserts had missed, and the + // fourth would have been found by a user. So the assertions below cover the whole of both + // enums - every bit, the exact overlap mask, and the completeness of the translation - and + // a new enumerator on either side breaks the build rather than being dropped in silence. + namespace FlagSpace { + constexpr Uint16 kCallAll = static_cast(MG_Pipe::kNeedsAck) | + static_cast(MG_Pipe::kHasBlob) | + static_cast(MG_Pipe::kVarTail) | + static_cast(MG_Pipe::kHostSpan) | + static_cast(MG_Pipe::kReplySlot) | + static_cast(MG_Pipe::kOptional); + constexpr Uint16 kRingAll = static_cast(Transport::kRecNeedsAck) | + static_cast(Transport::kRecHasBlob) | + static_cast(Transport::kRecPad) | + static_cast(Transport::kRecBorrowSlot) | + static_cast(Transport::kRecVarTail); + // The three call flags the encoder TRANSLATES, and the three it deliberately drops + // because MGPipeCallFlagsFor(op) recovers them from the opcode. + constexpr Uint16 kTranslated = static_cast(MG_Pipe::kNeedsAck) | + static_cast(MG_Pipe::kHasBlob) | + static_cast(MG_Pipe::kVarTail); + constexpr Uint16 kDropped = static_cast(MG_Pipe::kHostSpan) | + static_cast(MG_Pipe::kReplySlot) | + static_cast(MG_Pipe::kOptional); + } // namespace FlagSpace + + static_assert(FlagSpace::kCallAll == 0x3Fu, + "MGPipeCallFlags gained or lost an enumerator; re-derive the translation in " + "EncodeRecord and extend the overlap assertions below before assuming the " + "new bit is safe to drop"); + static_assert(FlagSpace::kRingAll == 0x1Fu, + "RingRecordFlags gained or lost an enumerator; the two spaces share one " + "16-bit field, so a new ring bit may now alias a call flag"); + static_assert((FlagSpace::kTranslated | FlagSpace::kDropped) == FlagSpace::kCallAll && + (FlagSpace::kTranslated & FlagSpace::kDropped) == 0, + "every MGPipeCallFlags bit must be either translated or deliberately " + "dropped; a seventh enumerator that needs a ring bit would otherwise be " + "dropped in silence"); + // FIVE OF THE SIX CALL-FLAG BITS ALIAS A RING BIT. Only kOptional (1<<5) is free, and it + // is free by luck rather than by design - RingRecordFlags simply has not reached 1<<5 yet. + static_assert((FlagSpace::kCallAll & FlagSpace::kRingAll) == FlagSpace::kRingAll, + "the overlap between the two flag spaces moved"); + // Named individually so a failure says WHICH pair, and so the three that actually bite are + // impossible to overlook while reading. static_assert(static_cast(MG_Pipe::kNeedsAck) == static_cast(Transport::kRecNeedsAck), - "the two kNeedsAck bits agree; the mapping below relies on it only for " - "readability, not for correctness"); + "kNeedsAck == kRecNeedsAck (harmless: the meanings agree)"); static_assert(static_cast(MG_Pipe::kHasBlob) == static_cast(Transport::kRecHasBlob), - "the two blob bits agree"); + "kHasBlob == kRecHasBlob (harmless: the meanings agree)"); + static_assert(static_cast(MG_Pipe::kVarTail) == + static_cast(Transport::kRecPad), + "kVarTail == kRecPad - THE DANGEROUS ONE. Stamping MGPipeCallFlags into the " + "header would make RingConsumer::Pop discard all nine kVarTail opcodes as " + "wrap fillers. The encoder must translate, never stamp; if this ever stops " + "being true, keep translating anyway - two flag spaces in one field is the " + "hazard, not this particular overlap"); + static_assert(static_cast(MG_Pipe::kHostSpan) == + static_cast(Transport::kRecBorrowSlot), + "kHostSpan == kRecBorrowSlot - a stamped host-span record would look like a " + "slot borrowed into the GPU timeline and retire on completedFrameSerial"); + static_assert(static_cast(MG_Pipe::kReplySlot) == + static_cast(Transport::kRecVarTail), + "kReplySlot == kRecVarTail - live in the REVERSE direction: the encoder " + "stamps kRecVarTail on nine opcodes, and a reader who believes " + "PipeWire.inc's 'MGPipeCallFlags of the call' comment reads those nine as " + "kReplySlot"); + static_assert((static_cast(MG_Pipe::kOptional) & FlagSpace::kRingAll) == 0, + "kOptional is the one call flag with no ring alias, and only because " + "RingRecordFlags has not reached 1<<5"); + + // ---- and the two headers really are one layout ----------------------------------- + // + // The decoder walks backwards from RingRecordView::payload to the MGPWireRecHeader in + // front of it, so the two structs being separately asserted to be eight bytes is not + // enough: if RingRecordHeader ever reorders its fields, every flag assert above still + // passes and every payload read shifts by the difference. + static_assert(sizeof(MGPWireRecHeader) == sizeof(Transport::RingRecordHeader)); + static_assert(offsetof(MGPWireRecHeader, Op) == offsetof(Transport::RingRecordHeader, kind), + "MGPWireRecHeader::Op and RingRecordHeader::kind are the same two bytes"); + static_assert(offsetof(MGPWireRecHeader, Flags) == + offsetof(Transport::RingRecordHeader, flags), + "MGPWireRecHeader::Flags and RingRecordHeader::flags are the same two bytes"); + static_assert(offsetof(MGPWireRecHeader, Size) == offsetof(Transport::RingRecordHeader, size), + "MGPWireRecHeader::Size and RingRecordHeader::size are the same four bytes"); // --------------------------------------------------------------------------------- // The catalogue, once. Name and payload type per opcode. @@ -427,6 +494,29 @@ namespace MobileGL::MG_Remote::Wire { } } + void CheckHostSpanIsHonest(const MGHostSpan& span, const SegmentTable& segments) { + CheckHostSpanIsHonest(span); + if (span.Size == 0) { + // Fully absent, and the arms above already proved Seg agrees with that. + return; + } + // ARM 4, WHICH THE OTHER OVERLOAD CANNOT DO. A span naming a real segment and a run + // past the end of it used to pass every check and reach WireVerbSink::OnDrawVbo, which + // this file's header promises is "a DECODED, VALIDATED argument list". It resolves to + // nullptr through MGPipeHostBytes - a draw from a null index pointer - or, for any + // consumer that adds Offset to its own SEG_STAGE base instead of going through the + // resolver, reads outside the segment. P5 emits no spans, so this was latent; P8 arms + // it, which is exactly when nobody will be reading this code. + if (segments.Resolve(span.Seg, span.Offset, span.Size) == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"host-span\"} seg=%u offset=%llu " + "size=%llu does not lie inside that segment (R-2.3 arm 4)", + static_cast(span.Seg), + static_cast(span.Offset), + static_cast(span.Size)); + std::abort(); + } + } + // --------------------------------------------------------------------------------- // The record layout: the tail arithmetic both sides run // --------------------------------------------------------------------------------- @@ -527,13 +617,27 @@ namespace MobileGL::MG_Remote::Wire { tails = 1; break; } - case MGPWireOp::ResourceSubData: - case MGPWireOp::BufferSubDataResident: { + case MGPWireOp::ResourceSubData: { const auto& p = *static_cast(payload); tail0 = TailBytesFor(p.RegionCount, sizeof(MGPSubRegion)); tails = 1; break; } + case MGPWireOp::BufferSubDataResident: { + // NO TAIL, because its catalogue row has no kVarTail (PipeCalls.def gives it + // kHasBlob|kOptional) and MGPipeApplyBufferSubDataResident takes no regions. The + // layout used to share ResourceSubData's arm, which meant a record with + // RegionCount = 2 was REQUIRED to carry 80 bytes the arm then dropped on the + // floor - and MGPipeBuildSubDataRecord is the shared builder that fills + // RegionCount for both halves, so that was one routing change away from being + // live. The resident path is the BUFFER half only and a buffer record declares no + // regions, so a non-zero count is a fault rather than a tail. + const auto& p = *static_cast(payload); + if (p.RegionCount != 0) { + WireProtocolFatalAt("BufferSubDataResident.RegionCount", p.RegionCount, 0); + } + break; + } case MGPWireOp::DrawVbo: { // MGPDrawRange[NumDraws], then a CONDITIONAL MGHostSpan. The span's start is // realigned to 8 because MGPDrawRange is twelve bytes: see WireRecordLayout's @@ -580,10 +684,72 @@ namespace MobileGL::MG_Remote::Wire { Bool PipeWireEncoder::Valid() const { return m_control != nullptr && m_cmd != nullptr; } + Uint8* PipeWireEncoder::StageAllocate(Uint64 size) { + if (m_stageBase == nullptr) { + const SegmentView view = m_segments != nullptr ? m_segments->Get(kSegStage) + : SegmentView{}; + if (view.Base == nullptr || view.Size == 0) { + WireProtocolFatal("PipeWireEncoder::StageBytes", "SEG_STAGE has no segment view"); + } + // The RingProducer c0's constructor takes is the authority on how many of the + // segment's bytes are really the staging area - the rest is whatever the session + // put in front of it. It is read, never written: SEG_STAGE's cursor triple belongs + // to nobody in P5 (see ReclaimStagedBytes' header). + Uint64 capacity = view.Size; + if (m_stage != nullptr && m_stage->Valid()) { + if (m_stage->Capacity() > view.Size) { + WireProtocolFatalAt("SEG_STAGE.capacity", m_stage->Capacity(), view.Size); + } + capacity = m_stage->Capacity(); + } + m_stageBase = static_cast(view.Base); + m_stageCapacity = capacity; + } + + const Uint64 need = Align8(size); + if (need > m_stageCapacity) { + MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob cannot fit a " + "%llu byte staging segment at any occupancy; P5 does not chunk (R-10) - " + "raise MOBILEGL_IPC_STAGE_MB or report the record to the integrator", + static_cast(size), + static_cast(m_stageCapacity)); + std::abort(); + } + + for (int attempt = 0; attempt < 2; ++attempt) { + const Uint64 offset = m_stageHead % m_stageCapacity; + // A run is always contiguous: one that would straddle the end skips the remainder, + // exactly as the ring's wrap pad does, and the skipped bytes are reclaimed with + // everything else behind them. + const Uint64 skip = offset + need > m_stageCapacity ? m_stageCapacity - offset : 0; + if ((m_stageHead + skip + need) - m_stageTail <= m_stageCapacity) { + const Uint64 at = (m_stageHead + skip) % m_stageCapacity; + m_stageHead += skip + need; + return m_stageBase + at; + } + if (attempt == 0) { + // One try at reclaiming what the server has already retired. A second failure + // means the bytes genuinely do not fit, which R-10 says P5 does not chunk and + // must instead prove it never needs to. + ReclaimStagedBytes(); + } + } + MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob does not fit a %llu " + "byte staging segment with %llu bytes still in flight (retiredSeq=%llu); P5 " + "does not chunk (R-10) - raise MOBILEGL_IPC_STAGE_MB or report the record to " + "the integrator", + static_cast(size), + static_cast(m_stageCapacity), + static_cast(m_stageHead - m_stageTail), + static_cast( + m_control != nullptr ? m_control->retiredSeq.load(std::memory_order_acquire) : 0)); + std::abort(); + } + MGPBlobRef PipeWireEncoder::StageBytes(const void* bytes, Uint64 size) { - if (!Valid() || m_stage == nullptr || m_segments == nullptr) { + if (!Valid() || m_segments == nullptr) { WireProtocolFatal("PipeWireEncoder::StageBytes", - "no SEG_STAGE producer or segment table installed"); + "no command producer or segment table installed"); } if (size == 0) { // "The record declared no blob" and "the record declared an empty blob" must not @@ -596,33 +762,9 @@ namespace MobileGL::MG_Remote::Wire { WireProtocolFatal("PipeWireEncoder::StageBytes", "non-zero size with a null source"); } - void* slot = m_stage->Reserve(static_cast(MGPWireOp::kInvalid), - Transport::kRecHasBlob, size); - if (slot == nullptr) { - // One try at reclaiming what the server has already retired, then give up: a - // second failure means the run genuinely does not fit SEG_STAGE, which R-10 says - // P5 does not chunk and must instead prove it never needs to. - ReclaimStagedBytes(); - slot = m_stage->Reserve(static_cast(MGPWireOp::kInvalid), - Transport::kRecHasBlob, size); - } - if (slot == nullptr) { - MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob does not fit a " - "%llu byte staging ring with %llu bytes free; P5 does not chunk (R-10) - " - "raise MOBILEGL_IPC_STAGE_MB or report the record to the integrator", - static_cast(size), - static_cast(m_stage->Capacity()), - static_cast(m_stage->FreeBytes())); - std::abort(); - } + Uint8* slot = StageAllocate(size); std::memcpy(slot, bytes, static_cast(size)); - - const SegmentView stageView = m_segments->Get(kSegStage); - if (stageView.Base == nullptr) { - WireProtocolFatal("PipeWireEncoder::StageBytes", "SEG_STAGE has no segment view"); - } - const Uint64 offset = - static_cast(static_cast(slot) - static_cast(stageView.Base)); + const Uint64 offset = static_cast(slot - m_stageBase); MGPBlobRef ref{}; ref.Seg = kSegStage; @@ -630,18 +772,14 @@ namespace MobileGL::MG_Remote::Wire { ref.Size = size; ref.Pad0 = 0; // The self-check that keeps the two halves of "SEG_STAGE" one thing: the segment view - // the decoder resolves through must cover the ring this producer just wrote into. A - // view installed over the CONTROL page, or over the ring plus its header, resolves to + // the decoder resolves through must cover the bytes this allocator just wrote into. A + // view installed over the CONTROL page, or over the segment plus a header, resolves to // a plausible pointer that is not these bytes. if (m_segments->Resolve(ref.Seg, ref.Offset, ref.Size) != slot) { WireProtocolFatal("PipeWireEncoder::StageBytes", - "the SEG_STAGE segment view does not cover the staging ring's " - "byte area; the two would resolve to different addresses"); + "the SEG_STAGE segment view does not cover the staging area; the " + "two would resolve to different addresses"); } - // The stage ring's own Publish: the decoder reads these bytes by OFFSET, never by - // popping the stage ring, so the head has to be visible before the command record - // that names them is. - m_stage->Publish(); return ref; } @@ -759,16 +897,32 @@ namespace MobileGL::MG_Remote::Wire { MGPBlobRef blob{}; std::memcpy(&blob, bytes + slots.Offset + i * sizeof(MGPBlobRef), sizeof(MGPBlobRef)); CheckBlobIsHonest(op, blob, *m_segments); + // THE ENCODER MUST NOT ACCEPT A RECORD THE DECODER FATALS ON. w1's ruling that + // CreateShaderState's modules travel inside the Reflection archive lived only + // in the decoder, so an emitter that declared a per-stage run got a valid seq + // here and a Fatal on a peer - exactly the asymmetry EncodeRecord's own + // comment says it exists to prevent. + if (op == MGPWireOp::CreateShaderState && i < 6 && blob.Size != 0) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"CreateShaderState.Spirv[%u]\"} " + "declares %llu bytes at the ENCODER; under split the modules travel " + "inside the Reflection archive and the six per-stage runs stay " + "undeclared", + static_cast(i), + static_cast(blob.Size)); + std::abort(); + } } } if ((callFlags & static_cast(kHostSpan)) != 0 && layout.TailCount == 2 && - layout.TailBytes[1] != 0) { + layout.TailBytes[1] != 0 && m_segments != nullptr) { const Uint64 at = layout.TailOffset[1] - sizeof(MGPWireRecHeader); const Uint64 spans = layout.TailBytes[1] / sizeof(MGHostSpan); for (Uint64 i = 0; i < spans; ++i) { MGHostSpan span{}; std::memcpy(&span, bytes + at + i * sizeof(MGHostSpan), sizeof(MGHostSpan)); - CheckHostSpanIsHonest(span); + // All four arms, including the one that needs the table: a producer that + // computed an offset wrongly is caught here rather than on a peer. + CheckHostSpanIsHonest(span, *m_segments); } } @@ -778,49 +932,56 @@ namespace MobileGL::MG_Remote::Wire { ++m_emitSeq; // The stage mark: where SEG_STAGE stood once everything this record names had been // staged. ReclaimStagedBytes releases up to the newest mark the server has retired. - if (m_stage != nullptr) { - m_stageMarks.push_back(StageMark{m_emitSeq, m_stage->LocalHead()}); - } + m_stageMarks.push_back(StageMark{m_emitSeq, m_stageHead}); return m_emitSeq; } void PipeWireEncoder::ReclaimStagedBytes() { - if (m_stage == nullptr || m_control == nullptr) { + if (m_control == nullptr) { return; } const Uint64 retired = m_control->retiredSeq.load(std::memory_order_acquire); - Uint64 upTo = 0; - Bool found = false; + Uint64 upTo = m_stageTail; while (m_stageMarkFront < m_stageMarks.size() && m_stageMarks[m_stageMarkFront].Seq <= retired) { upTo = m_stageMarks[m_stageMarkFront].StageCursor; - found = true; ++m_stageMarkFront; } - // Compact rather than erase-from-front on every call: the list is at most as long as - // the number of records in flight, which the verb barrier keeps at one or two. - if (m_stageMarkFront != 0 && m_stageMarkFront == m_stageMarks.size()) { + + // COMPACT ON A THRESHOLD, NOT ONLY ON A FULL DRAIN. The queue used to be cleared only + // when front reached size(), so any pipelining deeper than "fully drained at every + // reclaim" - which is precisely the regime this design exists to survive when R-1's + // barrier retires family by family - left front < size() for ever and grew the vector + // 16 bytes per encoded record for the life of the context. Erasing the consumed prefix + // once it is half the queue is amortised O(1) and bounds the storage at twice the + // records actually in flight. + if (m_stageMarkFront == m_stageMarks.size()) { m_stageMarks.clear(); m_stageMarkFront = 0; + } else if (m_stageMarkFront != 0 && m_stageMarkFront * 2 >= m_stageMarks.size()) { + m_stageMarks.erase(m_stageMarks.begin(), + m_stageMarks.begin() + static_cast(m_stageMarkFront)); + m_stageMarkFront = 0; } - if (!found || upTo <= m_stageReclaimed) { - return; + + // SEG_STAGE is CLIENT-OWNED memory (contract table 1: "client stages, server copies") + // and the server only reads it, so the client is both the allocator and the thing that + // frees. What it may not do is free ahead of retiredSeq - the whole content of R-11 on + // this side - and what it may ALSO not do is write RingControl's stage tails, which + // Ring.h makes consumer-owned. So the reclaim watermark is this local counter and the + // shared triple is untouched. + if (upTo > m_stageTail) { + m_stageTail = upTo; } - m_stageReclaimed = upTo; - // SEG_STAGE is CLIENT-OWNED memory (contract table 1: "client stages, server copies"), - // and the server only ever reads it, so the client is both the producer and the thing - // that frees. What it may not do is free ahead of retiredSeq, which is the whole - // content of R-11 on this side. - m_control->stageAppliedTail.store(upTo, std::memory_order_release); - m_control->stageRetiredTail.store(upTo, std::memory_order_release); } - Uint64 PipeWireEncoder::StagedBytesInFlight() const { - if (m_stage == nullptr) { - return 0; - } - return m_stage->LocalHead() - m_stageReclaimed; - } + Uint64 PipeWireEncoder::StagedBytesInFlight() const { return m_stageHead - m_stageTail; } + + // THE STORAGE, NOT THE LIVE COUNT. `size() - front` is the number of marks still in + // flight, and it stays at one or two even while the vector behind it grows for ever - so a + // control written against it would have gone green through exactly the leak it was meant + // to catch. What leaks is the container, so that is what this reports. + SizeT PipeWireEncoder::StageMarksHeld() const { return m_stageMarks.size(); } void PipeWireEncoder::Publish() { if (m_cmd == nullptr) { @@ -830,13 +991,21 @@ namespace MobileGL::MG_Remote::Wire { // notify-then-publish loses the wakeup. The doorbell itself belongs to the SESSION // (s1) - the codec does not own a Doorbell and must not, or a unit case could not // drive encoder -> ring -> decoder without one. - if (m_stage != nullptr) { - m_stage->Publish(); - } + // + // SEG_STAGE needs no publish: the decoder reads those bytes by OFFSET, never by + // popping a ring, and the release store on SEG_CMD's head below is what orders the + // staged writes before the record that names them. m_cmd->Publish(); if (m_control != nullptr) { + // submittedSeq is the one watermark the PRODUCER owns (Ring.h's five-watermark + // block). Nobody waits on it; it answers "how far ahead of the server is the + // client right now". m_control->submittedSeq.store(m_emitSeq, std::memory_order_release); } + // The reclaim has a trigger in this package, rather than depending on a c1 barrier + // that does not exist yet: every publish is a chance to notice what the server has + // already retired, and it costs one acquire load. + ReclaimStagedBytes(); } Uint64 PipeWireEncoder::EmitSeq() const { return m_emitSeq; } @@ -851,8 +1020,18 @@ namespace MobileGL::MG_Remote::Wire { ReplySink* replies) : m_control(control), m_segments(segments), m_replies(replies) { m_auditPoison = MG_Config::Ipc.Audit; + // Once, at construction, beside the resolver it is modelled on - not per record from + // the apply thread. The thunk is inert without a decoder on the calling thread, so an + // early install changes nothing for a monolith caller of MGPipeApplyWireRecord. + InstallApplyHook(); } + void PipeWireDecoder::InstallApplyHook() { + MG_Pipe::gMGPipeWireRecordApply = &MGPipeWireRecordApplyThunk; + } + + void PipeWireDecoder::UninstallApplyHook() { MG_Pipe::gMGPipeWireRecordApply = nullptr; } + Bool PipeWireDecoder::Valid() const { return m_control != nullptr && m_segments != nullptr; } Uint64 PipeWireDecoder::AppliedSeq() const { return m_applySeq; } @@ -867,6 +1046,34 @@ namespace MobileGL::MG_Remote::Wire { Uint64 PipeWireDecoder::PoisonedStageBytes() const { return m_poisonedBytes; } + Bool PipeWireDecoder::LastAcceptanceKnown() const { return m_lastAcceptanceKnown; } + + Bool PipeWireDecoder::LastAcceptance() const { return m_lastAcceptance; } + + Uint64 PipeWireDecoder::AcceptedRecords() const { return m_accepted; } + + Uint64 PipeWireDecoder::DeclinedRecords() const { return m_declined; } + + void PipeWireDecoder::PostReply(MGPWireOp op, Uint64 seq, Int32 status, const void* bytes, + Uint64 size) { + // THE ONE GATE ON SEG_REPLY. s1 sizes ReplyPool from MGPipeCallFlagsFor - table 0 says + // that table is what "every package" reads - so writing SEG_REPLY[seq % slots] for a + // record the pool reserved no slot for silently overwrites a waiter's answer. And + // because the slot header stamps the WRITER's seq for self-check, the waiter's check + // then fails for ever: the barrier HANGS rather than returning something wrong, which + // is the harder failure to diagnose of the two. + if ((MGPipeCallFlagsFor(op) & static_cast(kReplySlot)) == 0) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} the decoder tried to answer into " + "a reply slot for a call the catalogue gives no kReplySlot; s1 sizes " + "ReplyPool from MGPipeCallFlagsFor and reserved none", + WireOpName(op)); + std::abort(); + } + if (m_replies != nullptr) { + m_replies->PostReply(seq, status, bytes, size); + } + } + Bool MGPipeWireRecordApplyThunk(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) { (void)remaining; if (t_activeDecoder == nullptr) { @@ -875,13 +1082,21 @@ namespace MobileGL::MG_Remote::Wire { return t_activeDecoder->ApplyChecked(op, record, size); } - void PipeWireDecoder::NoteResolvedRun(const MGPBlobRef& blob) { + void PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) { if (blob.Size == 0 || blob.Seg != kSegStage) { return; } - if (m_resolvedCount < sizeof(m_resolved) / sizeof(m_resolved[0])) { - m_resolved[m_resolvedCount++] = blob; + if (m_resolvedCount >= sizeof(m_resolved) / sizeof(m_resolved[0])) { + // LOUD, NOT A SILENT DROP. This array is what the 0xDD fill covers, and a poison + // that quietly stopped covering a run is the same failure as no poison at all - + // rule C's only mechanical control going dark without a line in the log. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} more than %llu SEG_STAGE runs in " + "one record; the audit fill would stop covering them", + WireOpName(op), + static_cast(sizeof(m_resolved) / sizeof(m_resolved[0]))); + std::abort(); } + m_resolved[m_resolvedCount++] = blob; } const void* PipeWireDecoder::ResolveOrFatal(MGPWireOp op, const MGPBlobRef& blob) { @@ -893,7 +1108,7 @@ namespace MobileGL::MG_Remote::Wire { // way: there is no recovery from a segment that stopped covering its own runs. WireProtocolFatalAt("segment-resolve", blob.Offset, blob.Size); } - NoteResolvedRun(blob); + NoteResolvedRun(op, blob); return bytes; } @@ -905,7 +1120,12 @@ namespace MobileGL::MG_Remote::Wire { for (Uint32 i = 0; i < m_resolvedCount; ++i) { const MGPBlobRef& blob = m_resolved[i]; const SegmentView view = m_segments->Get(kSegStage); - if (view.Base == nullptr || blob.Offset + blob.Size > view.Size) { + // The SUBTRACTION form, the same one Resolve uses, so the addition cannot wrap. + // Every run here has already been through Resolve, so this is unreachable today - + // but it is the one place in the file where a wrap would be an arbitrary 0xDD + // memset, and "unreachable" is not a reason to write the weaker test. + if (view.Base == nullptr || blob.Offset > view.Size || + blob.Size > view.Size - blob.Offset) { continue; } // R-2.5. The record has been applied and its bytes are retired, so an applier that @@ -937,13 +1157,14 @@ namespace MobileGL::MG_Remote::Wire { const MGPWireOp op = static_cast(record.kind); m_resolvedCount = 0; + m_lastAcceptanceKnown = false; PipeWireDecoder* previous = t_activeDecoder; t_activeDecoder = this; // The generated gate first, ALWAYS: MGPipeApplyWireRecord owns the per-opcode // `size >= sizeof(MGPWireRec_X)` check, because that is the half that follows from the // opcode alone and therefore belongs to the generator. It then calls back into - // ApplyChecked through the hook, which owns the half that needs the payload. - MG_Pipe::gMGPipeWireRecordApply = &MGPipeWireRecordApplyThunk; + // ApplyChecked through the hook, which owns the half that needs the payload. The hook + // was installed once, at construction. const Bool applied = MG_Pipe::MGPipeApplyWireRecord(op, base, size, size); t_activeDecoder = previous; @@ -1008,12 +1229,20 @@ namespace MobileGL::MG_Remote::Wire { }; // The four Bool-returning appliers answer ACCEPTANCE, not "applied" (R-5: the client // may not re-derive it, because an if-constexpr discard, a stale handle and a refused - // record are all invisible from the call site). The answer rides the reply slot the - // record's seq already names, so no payload of theirs needs an MGPReplySlot member. - const auto postAcceptance = [&](Bool accepted) { - if (m_replies != nullptr) { - m_replies->PostReply(seq, accepted ? ReplySink::kStatusOk : ReplySink::kStatusDeclined, - nullptr, 0); + // record are all invisible from the call site). + // + // IT DOES NOT GO IN A REPLY SLOT. None of the four carries kReplySlot, and s1 sizes + // ReplyPool from that table - see PostReply. The answer is recorded here and read + // through LastAcceptance() / Accepted+DeclinedRecords() until the integrator rules on + // which half of the contract moves (table 0 says these four use DECLINED; the + // catalogue gives them no slot). + const auto noteAcceptance = [&](Bool accepted) { + m_lastAcceptanceKnown = true; + m_lastAcceptance = accepted; + if (accepted) { + ++m_accepted; + } else { + ++m_declined; } }; @@ -1037,7 +1266,7 @@ namespace MobileGL::MG_Remote::Wire { // ---- resources ------------------------------------------------------------------- case MGPWireOp::ResourceCreate: - postAcceptance(MGPipeApplyResourceCreate(*static_cast(payload))); + noteAcceptance(MGPipeApplyResourceCreate(*static_cast(payload))); return true; case MGPWireOp::ResourceRespecify: { @@ -1056,7 +1285,7 @@ namespace MobileGL::MG_Remote::Wire { level.Level = MGPipeRespecifiedLevelOf(desc); scope = &level; } - postAcceptance(MGPipeApplyResourceRespecify(desc, nullptr, scope)); + noteAcceptance(MGPipeApplyResourceRespecify(desc, nullptr, scope)); return true; } @@ -1074,9 +1303,7 @@ namespace MobileGL::MG_Remote::Wire { // // DECLINED is a real answer, not a failure: the three frontend sites already // tolerate it (BufferObject.cpp:238, :603-606, :657-660). - if (m_replies != nullptr) { - m_replies->PostReply(seq, ReplySink::kStatusDeclined, nullptr, 0); - } + PostReply(op, seq, ReplySink::kStatusDeclined, nullptr, 0); return true; case MGPWireOp::UnmapPersistent: @@ -1297,7 +1524,10 @@ namespace MobileGL::MG_Remote::Wire { MGHostSpan span{}; std::memcpy(&span, reinterpret_cast(spans) + i * sizeof(MGHostSpan), sizeof(MGHostSpan)); - CheckHostSpanIsHonest(span); + // ALL FOUR ARMS, the segment-range one included: this row and DrawVbo are + // the only two host-span carriers in the catalogue, and an out-of-segment + // span used to pass every check here. + CheckHostSpanIsHonest(span, *m_segments); } } return false; @@ -1366,7 +1596,7 @@ namespace MobileGL::MG_Remote::Wire { } case MGPWireOp::SetTextureParams: - postAcceptance(MGPipeApplySetTextureParams(*static_cast(payload))); + noteAcceptance(MGPipeApplySetTextureParams(*static_cast(payload))); return true; // ---- transfer --------------------------------------------------------------------- @@ -1378,16 +1608,31 @@ namespace MobileGL::MG_Remote::Wire { // to compute") and under split it must declare too - a length the reader computes // from the record it is checking is not a bounds check. const Bool namesABuffer = rec.Target == kMGPipeResourceTargetBuffer; - const Bool carriesContent = - namesABuffer ? MGPipeSubDataBufferSize(rec) != 0 - : (rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0); + // THE TEXTURE PREDICATE, TIGHTENED. It used to be "all three extents non-zero", + // which read a 4x4x0 box as carrying nothing and let rule A's arm 2 sit out - so a + // record could describe a real destination and declare no bytes. A box is either + // EMPTY (every extent zero, which is how a pull that needs nothing is spelled) or + // WHOLE; a partially-zero extent is neither, and no emitter produces one. + const Bool boxIsEmpty = + rec.UnionBox.W == 0 && rec.UnionBox.H == 0 && rec.UnionBox.D == 0; + const Bool boxIsWhole = + rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0; + if (!namesABuffer && !boxIsEmpty && !boxIsWhole) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ResourceSubData\"} the union box " + "%ux%ux%u has a zero extent on some axes and not others; a box is " + "either empty or whole", + rec.UnionBox.W, rec.UnionBox.H, rec.UnionBox.D); + std::abort(); + } + const Bool carriesContent = namesABuffer ? MGPipeSubDataBufferSize(rec) != 0 + : (boxIsWhole || rec.RegionCount != 0); const void* bytes = nullptr; if (carriesContent) { bytes = ResolveOrFatal(op, rec.Blob); } else { CheckBlobIsHonest(op, rec.Blob, *m_segments); } - postAcceptance(MGPipeApplyResourceSubData( + noteAcceptance(MGPipeApplyResourceSubData( rec, bytes, reinterpret_cast(tailAt(0)))); return true; } @@ -1425,9 +1670,7 @@ namespace MobileGL::MG_Remote::Wire { // 22) - the destination is the client's shadow and the size is the resource's, not // a fixed slot's - so the reply slot carries COMPLETION only. MGPipeApplyResourceReadback(*static_cast(payload)); - if (m_replies != nullptr) { - m_replies->PostReply(seq, ReplySink::kStatusOk, nullptr, 0); - } + PostReply(op, seq, ReplySink::kStatusOk, nullptr, 0); return true; case MGPWireOp::ResourceCopyRegion: @@ -1466,7 +1709,11 @@ namespace MobileGL::MG_Remote::Wire { sizeof(MGHostSpan)); } std::memcpy(&span, tailAt(1), sizeof(span)); - CheckHostSpanIsHonest(span); + // ALL FOUR ARMS. WireVerbSink's header promises OnDrawVbo "a DECODED, + // VALIDATED argument list"; without the segment-range arm a span whose run + // left SEG_STAGE reached the sink and that promise was false. P8 is what arms + // this path, which is exactly when nobody will be reading this code. + CheckHostSpanIsHonest(span, *m_segments); userIndices = &span; } return m_verbs != nullptr && diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index 80124f0d..1f87f2df 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -131,10 +131,21 @@ namespace MobileGL::MG_Remote::Wire { // declared. Fatal on an absent blob, then CheckBlobIsHonest on a present one. void RequireDeclaredBlob(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, const SegmentTable& segments); - // R-2.3 arm for MGHostSpan. P5's reduced path should produce ZERO host spans + // R-2.3 arms 1 and 3 for MGHostSpan. P5's reduced path should produce ZERO host spans // (kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes are both 0 in P5, table 0), so this // firing at all is a finding, not just a corruption check. + // + // IT CANNOT DO ARM 4 - it has no segment table - so a span that names a real segment and a + // run PAST THE END OF IT passes this function. Use the overload below on any path that has + // a table; this one exists because c0 shipped the signature and other packages compile + // against it. void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span); + // All four arms. Arm 4 is the one the signature above cannot express: a span is only + // honest if its Offset+Size actually lies inside the segment it names, and the promise + // WireVerbSink's header makes - that OnDrawVbo is handed a VALIDATED argument list - is + // false without it. Latent in P5 (nothing emits a span) and armed at P8, which is exactly + // when nobody will be reading this file. + void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span, const SegmentTable& segments); // ---- one record's shape, computed ONCE and read by both sides ---------------------- // @@ -222,18 +233,37 @@ namespace MobileGL::MG_Remote::Wire { const WireTail* tails, Uint32 tailCount); // Releases every SEG_STAGE run named by a record the apply side has RETIRED - // (RingControl::retiredSeq, R-9). Called by the client at its verb barrier and - // whenever StageBytes runs short; the allocator reclaims behind retiredSeq and - // nothing else may (table 1's "retires" column, R-11). + // (RingControl::retiredSeq, R-9, which this class only ever READS). Called from + // Publish() and whenever StageBytes runs short, so nothing outside this package has to + // remember to; the allocator reclaims behind retiredSeq and nothing else may + // (table 1's "retires" column, R-11). // // THE MARK IS HELD ON THIS SIDE, NOT ON THE WIRE. A record does not carry where its // staged bytes end, so the encoder remembers {seq, stage cursor} per record and // reclaims to the newest mark whose seq the server has retired. That is exact, needs // no wire field, and does not depend on the verb barrier - so it keeps working when // R-1's barrier retires family by family. + // + // SEG_STAGE'S CURSOR TRIPLE IN RingControl IS NOT USED BY EITHER SIDE IN P5, and this + // is the reason. Ring.h makes stageAppliedTail / stageRetiredTail CONSUMER-owned, the + // server never Pops the stage ring (the decoder resolves by offset), and a producer + // that wrote those cursors would be the very shape R-9 forbids one segment over. So + // the allocator below is entirely encoder-local and the triple is left at its + // InitRingControl values. **s1 must not attach a RingConsumer to + // RingCursorSet::Stage**: its m_localTail would never move, and PublishApplied / + // PublishRetired would then walk both cursors BACKWARDS under this allocator. void ReclaimStagedBytes(); // Staged bytes not yet reclaimed. The number MOBILEGL_IPC_STAGE_MB has to cover. Uint64 StagedBytesInFlight() const; + // The {seq, cursor} marks the queue is STILL STORING, consumed ones included - not the + // number in flight. Bounded by twice the records in flight, and exposed so a case can + // assert that bound rather than trust the comment, because an unbounded queue here is + // a steady-state leak no SSIM comparison and no two-scenario lane would ever see. + // + // It reports the storage deliberately: the in-flight count stays at one or two while + // the container behind it grows for ever, so a control written against that number + // goes green through exactly the leak it exists to catch. + SizeT StageMarksHeld() const; // Release-stores the head cursor, then rings the consumer doorbell IF PARKED. The // order is pinned by RingTest.cpp:446 and must not be swapped: notify-then-publish @@ -254,6 +284,12 @@ namespace MobileGL::MG_Remote::Wire { Uint64 StageCursor = 0; }; + // The SEG_STAGE linear allocator (BRIEF §5 w1's own wording). Monotonic byte counters + // over the segment; the in-segment offset is `cursor % capacity` and a run that would + // straddle the end skips to the boundary, exactly as a ring does, but WITHOUT touching + // RingControl - see ReclaimStagedBytes above. + Uint8* StageAllocate(Uint64 size); + Transport::RingControl* m_control = nullptr; Transport::RingProducer* m_cmd = nullptr; Transport::RingProducer* m_stage = nullptr; @@ -262,7 +298,10 @@ namespace MobileGL::MG_Remote::Wire { Uint64 m_maxRecordBytes = 0; Vector m_stageMarks; SizeT m_stageMarkFront = 0; - Uint64 m_stageReclaimed = 0; + Uint8* m_stageBase = nullptr; + Uint64 m_stageCapacity = 0; + Uint64 m_stageHead = 0; // monotonic bytes allocated + Uint64 m_stageTail = 0; // monotonic bytes reclaimed }; // ---- decoder ----------------------------------------------------------------------- @@ -394,13 +433,52 @@ namespace MobileGL::MG_Remote::Wire { // How many staged bytes this decoder has poisoned. Zero with the audit off, and the // number a t1 lane asserts is non-zero with it on: an instrumentation that cannot be // observed to have run is decoration. + // + // WHAT IT ACTUALLY COVERS is "the blob runs the arm resolved", which today is AT MOST + // ONE per record: tails live in SEG_CMD and are never noted, and CreateShaderState's + // six per-stage runs are Fatal rather than resolved, so the one archive is the only + // multi-kilobyte run in the catalogue that reaches it. The array is eight deep so a + // later phase that declares more can fill it without a code change - and overflowing + // it is Fatal rather than a silent drop, because a poison that quietly stopped + // covering a run is the same failure as no poison at all. Uint64 PoisonedStageBytes() const; + // R-5's acceptance answer for the four Bool-returning appliers - ResourceCreate, + // ResourceRespecify, ResourceSubData, SetTextureParams. + // + // IT DOES NOT RIDE A REPLY SLOT, and that is a contract conflict this package could + // not settle on its own. CONTRACT-P5.md table 0's reply-slot-header row says DECLINED + // "is how the four Bool acceptance entry points say false (R-5)", but PipeCalls.def + // gives none of those four `kReplySlot` - and table 0 ALSO says kMGPipeCallFlags is + // what "every package" reads, so s1 will size ReplyPool from it. Writing + // SEG_REPLY[seq % slots] for a record the pool never reserved a slot for overwrites a + // waiter's answer, and because the slot header stamps the writer's seq for self-check, + // the waiter's check then fails FOR EVER and the barrier hangs rather than returning + // something wrong. So the decoder posts a reply only for rows whose flags say + // kReplySlot (PostReply itself Fatals otherwise) and exposes the acceptance here + // instead. The integrator rules on which half of the contract moves. + Bool LastAcceptanceKnown() const; + Bool LastAcceptance() const; + Uint64 AcceptedRecords() const; + Uint64 DeclinedRecords() const; + + // Points MG_Pipe::gMGPipeWireRecordApply at this layer's thunk. Called once from the + // constructor and modelled on SegmentTable::InstallProcessResolver, which is the same + // shape for the same reason; Uninstall belongs beside that one at teardown. The thunk + // is inert without a decoder on the calling thread, so installing it early changes + // nothing for a monolith caller of MGPipeApplyWireRecord. + static void InstallApplyHook(); + static void UninstallApplyHook(); + private: Bool ApplyChecked(MG_Pipe::MGPWireOp op, const void* record, Uint64 size); const void* ResolveOrFatal(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); - void NoteResolvedRun(const MG_Pipe::MGPBlobRef& blob); + void NoteResolvedRun(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); void PoisonResolvedRuns(); + // The ONLY way this class answers a record. Fatals if `op` carries no kReplySlot - + // see LastAcceptanceKnown() for why that is a Fatal and not a log line. + void PostReply(MG_Pipe::MGPWireOp op, Uint64 seq, Int32 status, const void* bytes, + Uint64 size); friend Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp, const void*, Uint64, Uint64); @@ -411,14 +489,19 @@ namespace MobileGL::MG_Remote::Wire { Uint64 m_applySeq = kInvalidSeq; Bool m_auditPoison = false; Uint64 m_poisonedBytes = 0; - // The SEG_STAGE runs the record being applied resolved, for the 0xDD fill. At most - // seven (CreateShaderState's blob members) plus one tail. + Bool m_lastAcceptanceKnown = false; + Bool m_lastAcceptance = false; + Uint64 m_accepted = 0; + Uint64 m_declined = 0; + // The SEG_STAGE runs the record being applied resolved, for the 0xDD fill. Eight deep + // so CreateShaderState's seven blob members plus a tail would fit if a later phase + // declares them; today at most one run is ever noted (see PoisonedStageBytes). MG_Pipe::MGPBlobRef m_resolved[8]; Uint32 m_resolvedCount = 0; }; // The hook MGPipeApplyWireRecord dispatches to once its generated per-opcode bounds gate - // has passed. Installed by DecodeAndApply on the thread that decodes. + // has passed. Inert unless a decoder is active on the calling thread. Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp op, const void* record, Uint64 size, Uint64 remaining); diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index 00d036f5..e29613c1 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -371,6 +371,19 @@ TEST_F(PipeWireCodecTest, AVarTailRecordIsNotSkippedAsAWrapFiller) { // anywhere on this ring. This case is the trip wire for that. static_assert(static_cast(kVarTail) == static_cast(Transport::kRecPad), "the collision this case exists for is gone; keep the case anyway"); + // And the third one, which the first round missed and a reviewer found: it bites in the + // REVERSE direction, because the encoder stamps kRecVarTail on nine opcodes and anyone + // trusting PipeWire.inc's "MGPipeCallFlags of the call" reads those nine as kReplySlot. + static_assert(static_cast(kReplySlot) == static_cast(Transport::kRecVarTail), + "kReplySlot and kRecVarTail alias"); + // Five of the six call-flag bits alias a ring bit; only kOptional is free, and only + // because RingRecordFlags has not reached 1<<5. + static_assert((static_cast(kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | + kOptional) & + static_cast(Transport::kRecNeedsAck | Transport::kRecHasBlob | + Transport::kRecPad | Transport::kRecBorrowSlot | + Transport::kRecVarTail)) == 0x1Fu, + "the overlap between the two flag spaces moved"); Wire2 wire; MGPVertexBuffers header{}; @@ -530,10 +543,12 @@ TEST_F(PipeWireCodecTest, KNeedsAckRespecifyCarriesItsRedefinitionScope) { kInvalidSeq); ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_TRUE(applied); - // Two records, two acceptance answers, on their own seqs (R-3: the seq IS the id). - ASSERT_EQ(wire.Answers().All.size(), 2u); - EXPECT_EQ(wire.Answers().All[0].Seq, 1u); - EXPECT_EQ(wire.Answers().All[1].Seq, 2u); + // Two records, two ACCEPTANCE answers - and NOT in a reply slot. Neither ResourceCreate + // nor ResourceRespecify carries kReplySlot, and s1 sizes ReplyPool from that table, so a + // reply written here would overwrite some waiter's slot. + EXPECT_TRUE(wire.Answers().All.empty()); + EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 2u); + EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown()); } TEST_F(PipeWireCodecTest, KOptionalUnmapPersistentRoundTrips) { @@ -726,7 +741,7 @@ TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) { kInvalidSeq); ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_TRUE(applied); - ASSERT_EQ(wire.Answers().All.size(), 2u); + EXPECT_TRUE(wire.Answers().All.empty()); // ACCEPTANCE IS NOT "APPLIED", and this case is where the difference shows. The record // crossed and reached MGPipeApplyResourceSubData, which is the codec's whole job; the // applier then DECLINED it, because no backend registered a P4a texture consumer in this @@ -734,8 +749,10 @@ TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) { // into lost texels on Magma). That is exactly the answer R-5 says must travel rather than // be re-derived on the client: a client that cleared its dirty flags on the strength of // having EMITTED would drop these texels for good. - EXPECT_EQ(wire.Answers().All[1].Seq, 2u); - EXPECT_EQ(wire.Answers().All[1].Status, ReplySink::kStatusDeclined); + EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown()); + EXPECT_FALSE(wire.Decoder().LastAcceptance()); + // Both records answered - the texture create is declined by the same NoP4aConsumer belt. + EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 2u); // And the texels themselves crossed intact: the decline is the applier's, not the wire's. const void* staged = wire.Segments().Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size); @@ -1051,11 +1068,12 @@ TEST_F(PipeWireCodecTest, TheDecoderWritesNoRingControlFieldOfItsOwn) { TEST_F(PipeWireCodecTest, MaxRecordBytesSeenStaysFarBelowHalfTheRing) { // R-10's proof obligation. P5 does no chunking and must instead show it never needed any. // - // THE CAP IS ASKED FOR AT RUNTIME AND NEVER DERIVED FROM MOBILEGL_IPC_RING_MB. s1 found - // that SEG_CMD's 8 MiB holds a 4096-byte control page plus a POWER-OF-TWO ring, so the - // ring is 4 MiB and MaxRecordBytes() is 2 MiB - half of what both CONTRACT-P5.md §5 and - // Config.h's comment say. Every comparison in the codec goes through - // RingProducer::MaxRecordBytes() for exactly this reason. + // THE CAP IS ASKED FOR AT RUNTIME AND NEVER DERIVED FROM MOBILEGL_IPC_RING_MB, and this + // phase is why: the number moved twice in one afternoon. s1 first found that the control + // page came out of the segment (making the cap 2 MiB at the default), then fixed it the + // other way round - MOBILEGL_IPC_RING_MB now names the RING and the segment adds a page on + // top - so the cap is 4 MiB again and the contract is true as written. Nothing in the + // codec changed either time, because every comparison goes through MaxRecordBytes(). Wire2 wire; MGPDrawInfo info{}; info.NumDraws = 64; @@ -1119,6 +1137,102 @@ TEST_F(PipeWireCodecTest, StagedBytesAreReclaimedOnlyBehindRetiredSeq) { EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u); } +// ---- M2 / M3: SEG_STAGE's cursors and the mark queue -------------------------------------- + +TEST_F(PipeWireCodecTest, TheEncoderNeverWritesSegStagesConsumerCursors) { + // Ring.h makes stageAppliedTail / stageRetiredTail CONSUMER-owned and says the staging + // allocator reclaims behind retiredSeq "and nothing else may". A producer writing them is + // the same shape w1's own §8.1 argues against for appliedSeq, one segment over: if s1 ever + // attaches a RingConsumer to RingCursorSet::Stage - the obvious thing to do for a segment + // with a cursor triple - its m_localTail never moves (nothing Pops the stage ring) and the + // two cursors get walked forward by the client and back by the server. + Wire2 wire; + const std::uint8_t payload[128] = {}; + (void)wire.Encoder().StageBytes(payload, sizeof(payload)); + MGPBindRenderState bind{}; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + wire.Encoder().ReclaimStagedBytes(); + EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u) << "the reclaim did not run at all"; + + EXPECT_EQ(wire.Control().stageHead.load(), 0u); + EXPECT_EQ(wire.Control().stageAppliedTail.load(), 0u); + EXPECT_EQ(wire.Control().stageRetiredTail.load(), 0u); +} + +TEST_F(PipeWireCodecTest, TheStageMarkQueueStaysBoundedWithOneRecordAlwaysInFlight) { + // The steady-state leak: the queue used to be cleared ONLY when it drained completely, so + // one unretired record at every reclaim meant front < size() for ever and 16 bytes per + // encoded record for the life of the context. That is exactly the regime W-3 says the + // design exists to survive once R-1's barrier retires family by family - and no SSIM + // comparison and no two-scenario lane would ever see it. + Wire2 wire; + MGPBindRenderState bind{}; + const std::uint8_t blob[32] = {}; + + // PRIMING IS THE WHOLE POINT. Encoding and applying one record per iteration drains the + // queue at every reclaim, which is the one regime the old "clear only when front reaches + // size()" code handled - a case written that way stays green against the bug. One record + // encoded ahead of the one being applied is what makes `front < size()` permanent. + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)), + kInvalidSeq); + for (int i = 0; i < 4000; ++i) { + (void)wire.Encoder().StageBytes(blob, sizeof(blob)); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)) << "record " << i; + } + EXPECT_EQ(wire.Encoder().EmitSeq(), 4001u); + EXPECT_EQ(wire.Decoder().AppliedSeq(), 4000u) << "one record must still be in flight"; + // Bounded by twice the records actually in flight, not by the records ever encoded. + EXPECT_LE(wire.Encoder().StageMarksHeld(), 4u) + << "the mark queue is tracking history rather than flight"; +} + +// ---- M4: the reply slot is for kReplySlot rows only ---------------------------------------- + +TEST_F(PipeWireCodecTest, NoReplyIsWrittenForARowTheCatalogueGivesNoReplySlot) { + // s1 sizes ReplyPool from MGPipeCallFlagsFor, so a reply written for a record the pool + // reserved no slot for overwrites a waiter's answer - and because the slot header stamps + // the WRITER's seq for self-check, the waiter's check then fails for ever and the barrier + // HANGS rather than returning something wrong. + Wire2 wire; + ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::ResourceCreate) & static_cast(kReplySlot), 0u); + ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::SetTextureParams) & static_cast(kReplySlot), 0u); + + MGPResourceDesc create{}; + create.Resource = MakeHandle(131); + create.Target = static_cast(MGPipeResourceTarget::Buffer); + create.Width = 64; + create.Height = 1; + create.Depth = 1; + create.ArrayLayers = 1; + create.Levels = 1; + create.Samples = 1; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceCreate, &create, sizeof(create)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + + EXPECT_TRUE(wire.Answers().All.empty()) << "a reply slot was written for a kNone row"; + // The acceptance answer R-5 requires is still produced - it just does not ride SEG_REPLY. + EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown()); + EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 1u); +} + +TEST_F(PipeWireCodecTest, EveryRowThatDoesWriteAReplyCarriesKReplySlot) { + // The other half: the three rows in P5 that answer into a slot all carry the flag, so + // PostReply's gate cannot be firing on any of them. + for (const MGPWireOp op : + {MGPWireOp::MapPersistent, MGPWireOp::ResourceReadback, MGPWireOp::ReadPixels}) { + EXPECT_NE(MGPipeCallFlagsFor(op) & static_cast(kReplySlot), 0u) << WireOpName(op); + } +} + TEST_F(PipeWireCodecTest, TheAuditFillOverwritesExactlyTheRunsTheRecordResolved) { // R-2.5, the only mechanical control on rule C ("no applier entry point retains a pointer // past its return"). An instrumentation that cannot be observed to have run is decoration, @@ -1475,6 +1589,120 @@ TEST_F(PipeWireCodecTest, AHostSpanCountThatIsNeitherZeroNorCountIsFatal) { EXPECT_NE(r.Log.find("SetShaderBuffers.HostSpanCount"), std::string::npos) << r.Log; } +// ---- M1: R-2 arm 4 over MGHostSpan, on both host-span rows ------------------------------ + +TEST_F(PipeWireCodecTest, AHostSpanRunPastItsSegmentIsFatalOnSetShaderBuffers) { + // Before the fix this child exited 0: CheckHostSpanIsHonest took no SegmentTable and no + // caller resolved, so arm 4 was implemented for blobrefs only. The span names a REAL + // segment and a run that leaves it. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPShaderBuffers header{}; + header.Count = 1; + header.HostSpanCount = 1; + MGPBufferRange range{}; + MGHostSpan span{}; + span.Ptr = nullptr; // rule B satisfied, so only arm 4 can catch this + span.Seg = kSegStage; + span.Offset = Wire2::kStageBytes - 8; + span.Size = 1024; + std::vector tail(sizeof(range) + sizeof(span)); + std::memcpy(tail.data(), &range, sizeof(range)); + std::memcpy(tail.data() + sizeof(range), &span, sizeof(span)); + ForgeAndDecode(wire, MGPWireOp::SetShaderBuffers, &header, sizeof(header), tail.data(), + tail.size()); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption, \"host-span\"}"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("does not lie inside that segment (R-2.3 arm 4)"), std::string::npos) + << r.Log; +} + +TEST_F(PipeWireCodecTest, AHostSpanRunPastItsSegmentIsFatalOnDrawVbo) { + // The same span on the other host-span row - the one whose sink WireVerbSink's header + // promises is handed "a DECODED, VALIDATED argument list". Before the fix it reached + // OnDrawVbo. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPDrawInfo info{}; + info.Mode = 4; + info.IndexSize = 2; + info.Flags = kDrawHasUserIndices; + info.NumDraws = 1; + MGPDrawRange ranges[1] = {{0, 4, 0}}; + MGHostSpan span{}; + span.Ptr = nullptr; + span.Seg = kSegStage; + span.Offset = 0xFFFFFFFFull; + span.Size = 0xFFFFu; + // The layout realigns the span to 8 after a 12-byte MGPDrawRange, so the forged tail + // has to carry the same four pad bytes the encoder would. + std::vector tail(16 + sizeof(span), 0); + std::memcpy(tail.data(), ranges, sizeof(ranges)); + std::memcpy(tail.data() + 16, &span, sizeof(span)); + ForgeAndDecode(wire, MGPWireOp::DrawVbo, &info, sizeof(info), tail.data(), tail.size()); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("does not lie inside that segment (R-2.3 arm 4)"), std::string::npos) + << r.Log; +} + +// ---- M5 / m8 / q2 ------------------------------------------------------------------------ + +TEST_F(PipeWireCodecTest, BufferSubDataResidentMayNotDeclareRegions) { + // Its catalogue row has no kVarTail and its applier takes no regions, so a non-zero + // RegionCount is a fault rather than a tail. The layout used to share ResourceSubData's + // arm, which REQUIRED 80 bytes of tail the arm then dropped. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPSubData rec{}; + rec.Res = MakeHandle(7); + rec.RegionCount = 2; + MGPipeSetSubDataBufferRange(rec, 0, 64); + rec.RegionCount = 2; // after the helper, which zeroes it + ForgeAndDecode(wire, MGPWireOp::BufferSubDataResident, &rec, sizeof(rec), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("BufferSubDataResident.RegionCount"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, TheEncoderRefusesThePerStageSpirvRunTheDecoderCallsFatal) { + // The rule lived only in the decoder, so an emitter that declared a per-stage run got a + // valid seq here and a Fatal on a peer that could only report "corrupt stream". + const ChildResult r = RunInChild([] { + Wire2 wire; + MG_State::GLState::LinkArtifacts link; + MG_State::GLState::SpirvArtifacts spirv; + Vector archive; + MG_State::GLState::EncodeProgramArtifacts(link, spirv, archive); + MGPProgramDesc desc{}; + desc.Cso = MakeHandle(3); + desc.Reflection = wire.Encoder().StageBytes(archive.data(), archive.size()); + const std::uint32_t words[4] = {1, 2, 3, 4}; + desc.Spirv[0] = wire.Encoder().StageBytes(words, sizeof(words)); + (void)wire.Encoder().EncodeRecord(MGPWireOp::CreateShaderState, &desc, sizeof(desc)); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("declares 16 bytes at the ENCODER"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, APartiallyZeroUploadBoxIsFatalRatherThanReadAsEmpty) { + // The content predicate used to be "all three extents non-zero", so a 4x4x0 box was read + // as carrying nothing and rule A's arm 2 sat out for a record that named a real + // destination. A box is either empty or whole. + const ChildResult r = RunInChild([] { + Wire2 wire; + MGPSubData rec{}; + rec.Res = MakeHandle(9); + rec.Target = MGPipePackSubDataTarget(static_cast(MGPipeResourceTarget::Tex2D), 0u); + rec.UnionBox = MGPBox{0, 0, 0, 4, 4, 0}; + ForgeAndDecode(wire, MGPWireOp::ResourceSubData, &rec, sizeof(rec), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("has a zero extent on some axes and not others"), std::string::npos) + << r.Log; +} + TEST_F(PipeWireCodecTest, ASecondProcessResolverIsFatalRatherThanASilentRace) { // Table 3: one gMGPipeSegmentResolver per process, installed by the SERVER role only. const ChildResult r = RunInChild([] {