From 9469d48edb5a9169e837f145b83fd07d108b5ee2 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 20 Sep 2026 19:19:47 -0400 Subject: [PATCH] [MGPipe, MG_Remote] cut a texture level's staged run at the stage chunk ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, a linear arena, so a texture level larger than the segment's chunk budget was Fatal{RingOverrun, "SEG_STAGE"} at the encoder rather than a split - and the CI traces hit it: the texture half of resource_subdata stages the whole level shadow regardless of how little of it changed, and a 512x128x33 GL_RGBA32F level is 34,603,008 bytes while a 192-cube GL_RGBA16 level is 56,623,104, both against the default 32 MiB segment. Three retrace cases aborted there: iterationt-in-world and iterationt-nodsa-in-world in both backends, and iterationrp-in-world in DirectVulkan. THE CLIENT now cuts such a level into WHOLE-WIDTH SLABS, one resource_subdata each (TextureEmit.h's MGPipeForEachTextureSlab, driven at MGPipeStageChunkBytes - the same cap PipeFill.cpp's buffer walks are cut at). A piece is a set of consecutive rows of the level shadow, so its box names both its texels and its byte run; whole slices are the coarsest cut, a slice bigger than the cap falls back to whole rows and a row bigger than the cap to whole texels inside one row. Every piece is emitted, dirty or not: the server assembles one level image out of the runs, so a piece left out would leave a hole no texel read can answer. Each piece carries at least one region (its clipped dirty rects, or its own box when none reaches it) because RegionCount == 0 remains the whole-level spelling, and each region's SrcOffset is rebased onto its own piece's run - MGPipeTypes.h has always said SrcOffset is "into the blob" - while the strides stay the level's. A level that FITS keeps the single whole-level record, field for field what it has always been, which is what every existing gate is written against. THE SERVER now assembles levels from those runs. StagedTextureStore::AdoptRun places one run at an image offset, its coverage set is exactly the runs it has adopted (StagedShadowStore's CoverageAdd, the buffer half's helpers), and only a FULLY covered level satisfies IsCovered / RequireLevelBytes - a level with one piece missing is as unreadable as one that never arrived, and it is the same Fatal{StageSnapshotTooNarrow}. The whole-level spelling survives as Adopt, its offset-0 replacement, and the two are told apart by the run's own LENGTH: the whole-level run is the level's complete shadow whatever its box says, a piece's run is exactly its box's byte extent. Both adoption call sites - Ops_H_TextureSubData and PipeApply.cpp's hookless fallback, which is what Magma takes - read the offset that way. THE PROTOCOL IS UNCHANGED: no field moved, no caps bit was added. The one compatibility statement is that a split level now crosses as several records of one apply batch, so an OLD server (which replaces the level on every record) would keep only the last piece - inproc runs both roles out of one library, so the constraint is noted here rather than armed with a gate. Tests. TextureEmit.ALevelTooLargeForTheStageChunkIsCutIntoSlabs drives the split at MGPipeStageChunkBytesFor's clamp with both trace shapes (5 and 7 slabs), the row-granularity and texel-granularity fallbacks, the three shapes that refuse to cut, the region clipping/rebasing, and the piece no rect reaches. StagedTextureStoreTest gains the multi-run assembly, the gap that must not read as coverage, the partial-level Fatal by name, the run-offset discriminator, and two production cases - the applier's hookless arm and DirectGLES's real TextureSubData hook - that would put both halves at the front of the image if the offset were dropped. --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 37 +- MobileGL/MG_Impl/Pipe/TextureEmit.h | 347 +++++++++++++++--- MobileGL/MG_Pipe/PipeApply.cpp | 26 +- .../MG_Remote/Server/StagedTextureStore.h | 196 +++++++--- MobileGL/MG_Test/Pipe/TextureEmitTest.cpp | 225 ++++++++++++ .../MG_Test/Wire/StagedTextureStoreTest.cpp | 289 +++++++++++++++ 6 files changed, 1012 insertions(+), 108 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index bd5ec318..89aa1ab8 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -2713,20 +2713,31 @@ namespace MobileGL::MG_Backend::DirectGLES { const void* bytes, const MG_Pipe::MGPSubRegion* regions) { // The region set is the upload planner's shape and stays in the applier's // pending set; the store's coverage is the staged run itself - // (StagedTextureStore.h's coverage ruling). - (void)regions; + // (StagedTextureStore.h's coverage ruling). It is ALSO what places that run in + // the level image when the record carries a piece of the level rather than the + // whole of it (fix A2), which is why it is read here now. auto& store = MG_Remote::Server::ServerStagedTexture(); if (!store.CopiesIntoServerStorage()) return; // The applier's gate has already faulted every shape that reaches here without // bytes, and under split the codec declared the run's length (Blob.Size) - - // TextureEmit.h:1285's "the bytes this record declares ARE the level shadow". + // TextureEmit.h's "the bytes this record declares ARE the level shadow". if (bytes == nullptr || record.Blob.Size == 0) return; const auto* stored = PipeTextureRecordForHandle(res); if (stored == nullptr) return; + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res); + const Uint16 uploadTarget = MG_Pipe::MGPipeSubDataUploadTargetOf(record.Target); const IntVec3 extent = MG_Remote::Server::StagedTextureUploadExtent(stored->Desc, record); - store.Adopt(MG_Remote::Server::StagedTextureStore::KeyForHandle(res), - MG_Pipe::MGPipeSubDataUploadTargetOf(record.Target), record.Level, extent, - bytes, static_cast(record.Blob.Size)); + const SizeT runBytes = static_cast(record.Blob.Size); + // RegionCount == 0 IS the whole-level spelling - "the run is the level shadow", + // beginning at the level's first byte - so it is adopted with the spelling that + // replaces the level; every other record names a RUN of the level by its own box. + if (record.RegionCount == 0) { + store.Adopt(key, uploadTarget, record.Level, extent, bytes, runBytes); + return; + } + store.AdoptRun(key, uploadTarget, record.Level, extent, + MG_Remote::Server::StagedTextureRunImageOffset(record, regions), bytes, + runBytes); } void Ops_H_TextureRespecify(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc, @@ -8419,6 +8430,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // derived pointer, which is what the legacy arm sends - and one named // line says so. Gated on subRectEligible because bpp is 0 otherwise // and the regions are not read at all. + // + // FIX A2 IS THE SECOND LEGITIMATE DISAGREEMENT, and the fallback is + // exactly right for it: a level too large to stage whole crosses as + // several slab records (TextureEmit.h's MGPipeForEachTextureSlab) whose + // regions carry offsets into THEIR OWN run rather than into the level + // shadow. Every piece of the level is merged into this one entry, the + // entry's rects are then read out of the SERVER'S WHOLE LEVEL IMAGE - + // which is what the derived pointer names - and those piece-relative + // offsets are precisely the ones that must not be used here. Bool pendingOffsetsAgree = true; if (pendingUpload != nullptr && subRectEligible) { for (const auto& region : pendingUpload->Regions) { @@ -8430,7 +8450,10 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E_ONCE("MGPipe: texture %u level %u carries a region whose SrcOffset " "%llu is not its own origin (%d,%d,%d) at the level's pitch " "(%llu expected) - the carried offsets are dropped and every " - "rect is read from the shadow instead", + "rect is read from the shadow instead. Expected once for a " + "level cut into stage-chunk pieces, whose regions index their " + "own run; otherwise this names a record whose regions and " + "origins disagree", MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject), static_cast(level), static_cast(region.SrcOffset), region.X, region.Y, region.Z, diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h index 04798d4a..cbac2736 100644 --- a/MobileGL/MG_Impl/Pipe/TextureEmit.h +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -67,6 +67,13 @@ #include +#if MOBILEGL_BUILD_DISAGGREGATED +// MGPipeStageChunkBytes: the cap one texture level's staged run is cut at (see +// MGPipeTextureStageChunkBytes below). Behind the build option for ResourceTracker.h's reason - +// nothing under MG_Remote may be reachable from a pull build. +#include +#endif + #include #include #include @@ -400,6 +407,170 @@ namespace MobileGL::MG_Pipe { return region; } + // --------------------------------------------------------------------------------- + // THE STAGE-CHUNK SLAB SPLIT (fix A2) + // --------------------------------------------------------------------------------- + // + // ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, a linear arena, so a level shadow larger + // than that arena is Fatal{RingOverrun, "SEG_STAGE"} at the encoder rather than a split + // (PipeWireCodec.cpp:856-864) - the same wall PipeFill.cpp's content walks are cut at. It is + // not hypothetical: measured on the CI traces, a 512x128x33 GL_RGBA32F level is 34,603,008 + // bytes and a 192-cube GL_RGBA16 level is 56,623,104, both against the default 32 MiB + // segment, so those glTexImage3D calls aborted their replays at the emitter. + // + // THE SPLIT IS INTO WHOLE-WIDTH SLABS. A piece is a set of CONSECUTIVE ROWS OF THE LEVEL + // SHADOW - {x0, y0, z0, w, dy, dz} in level coordinates - because that is the one shape whose + // box names both its texels and its byte run: the run begins at + // z0 * sliceStride + y0 * rowStride + x0 * bpp and is exactly the box's own byte extent. So + // the run's placement is derivable from the strides and box the record carries, and the far + // side never has to know the level's format or pixel size. Whole slices are the coarsest cut + // and the two CI blobs take it; a slice bigger than the cap falls back to whole rows, and a + // ROW bigger than the cap to whole texels inside one row - still one box exactly matching its + // own run, which is the property everything downstream depends on. + // + // THE PIECES ARE CONTIGUOUS AND ASCENDING, and their union is the level exactly once: the + // server assembles one level image out of them (StagedTextureStore::AdoptRun), so a gap would + // lose texels and an overlap would place them twice. + struct MGPipeTextureSlab { + MGPBox Box{}; // level coordinates + Uint64 RunOffset = 0; // the run's first byte, as an offset into the level shadow + Uint64 RunBytes = 0; // the run's length, which is exactly the box's own byte extent + }; + + // THE WALK. `chunkBytes` is how many bytes one record may stage - MGPipeTextureStageChunkBytes + // below, 0 meaning "do not cut". False is the answer when the level cannot be tiled at this + // cap (a pitch that does not describe the level's bytes exactly, or a cap too small for one + // texel), and NOTHING has been handed out when it returns false, so the caller keeps the + // whole-level record it would have emitted anyway. + template + inline Bool MGPipeForEachTextureSlab(const IntVec3& levelSize, const MGPipeLevelPitch& pitch, + Uint64 levelBytes, SizeT chunkBytes, Fn&& body) { + const Uint64 cap = static_cast(chunkBytes); + const Uint64 width = static_cast(std::max(levelSize.x(), 0)); + const Uint64 height = static_cast(std::max(levelSize.y(), 0)); + const Uint64 depth = static_cast(std::max(levelSize.z(), 1)); + const Uint64 texelBytes = pitch.BytesPerTexel; + const Uint64 rowStride = pitch.RowStride; + const Uint64 sliceStride = pitch.SliceStride; + if (cap == 0 || width == 0 || height == 0 || texelBytes == 0) return false; + // The pitch has to TILE THE LEVEL EXACTLY, because every piece's box is its run's extent: + // a level whose byte size is not sliceStride * depth is the one shape that would leave a + // tail no box describes, and it stays on the whole-level record. + if (sliceStride * depth != levelBytes) return false; + + const auto emit = [&](Uint64 x0, Uint64 y0, Uint64 z0, Uint64 w, Uint64 h, Uint64 d, + Uint64 runOffset, Uint64 runBytes) { + MGPipeTextureSlab slab{}; + slab.Box = MGPBox{static_cast(x0), static_cast(y0), static_cast(z0), + static_cast(w), static_cast(h), + static_cast(d)}; + slab.RunOffset = runOffset; + slab.RunBytes = runBytes; + body(slab); + }; + + if (sliceStride <= cap) { + const Uint64 slabDepth = std::max(cap / sliceStride, 1); + for (Uint64 z = 0; z < depth; z += slabDepth) { + const Uint64 d = std::min(slabDepth, depth - z); + emit(0, 0, z, width, height, d, z * sliceStride, d * sliceStride); + } + return true; + } + if (rowStride <= cap) { + // One slice is bigger than the cap, so the pieces are runs of whole rows and none + // spans two slices - a piece that did would have a box this arithmetic cannot name. + const Uint64 slabRows = std::max(cap / rowStride, 1); + for (Uint64 z = 0; z < depth; ++z) { + for (Uint64 y = 0; y < height; y += slabRows) { + const Uint64 h = std::min(slabRows, height - y); + emit(0, y, z, width, h, 1, (z * height + y) * rowStride, h * rowStride); + } + } + return true; + } + const Uint64 slabTexels = cap / texelBytes; + if (slabTexels == 0) return false; + for (Uint64 z = 0; z < depth; ++z) { + for (Uint64 y = 0; y < height; ++y) { + for (Uint64 x = 0; x < width; x += slabTexels) { + const Uint64 w = std::min(slabTexels, width - x); + emit(x, y, z, w, 1, 1, (z * height + y) * rowStride + x * texelBytes, + w * texelBytes); + } + } + } + return true; + } + + // ONE CLIPPED REGION OF A PIECE, with its SrcOffset rebased onto the piece's own run. + // MGPipeTypes.h says SrcOffset is "into the blob", and under this split the blob IS the + // piece's run rather than the level shadow; the two coincide only for the whole-level record, + // where box and piece are the same box and this leaves the field exactly as + // MGPipeBuildSubRegion computes it. THE STRIDES STAY THE LEVEL'S: a clipped rect's rows are no + // more contiguous inside the piece than inside the level, and a 0 would be read as "tightly + // packed" - the whole-level spelling - which would repack the wrong bytes. + inline MGPSubRegion MGPipeBuildPieceRegion(const MGPBox& box, const MGPBox& piece, + const MGPipeLevelPitch& pitch) { + MGPSubRegion region{}; + region.X = box.X; + region.Y = box.Y; + region.Z = box.Z; + region.W = box.W; + region.H = box.H; + region.D = box.D; + region.SrcOffset = static_cast(box.Z - piece.Z) * pitch.SliceStride + + static_cast(box.Y - piece.Y) * pitch.RowStride + + static_cast(box.X - piece.X) * pitch.BytesPerTexel; + region.SrcRowStride = pitch.RowStride; + region.SrcSliceStride = pitch.SliceStride; + return region; + } + + inline MGPBox MGPipeIntersectBoxes(const MGPBox& a, const MGPBox& b) { + const Int64 x0 = std::max(a.X, b.X); + const Int64 y0 = std::max(a.Y, b.Y); + const Int64 z0 = std::max(a.Z, b.Z); + const Int64 x1 = std::min(static_cast(a.X) + a.W, static_cast(b.X) + b.W); + const Int64 y1 = std::min(static_cast(a.Y) + a.H, static_cast(b.Y) + b.H); + const Int64 z1 = std::min(static_cast(a.Z) + a.D, static_cast(b.Z) + b.D); + if (x1 <= x0 || y1 <= y0 || z1 <= z0) return MGPBox{}; + return MGPBox{static_cast(x0), static_cast(y0), static_cast(z0), + static_cast(x1 - x0), static_cast(y1 - y0), + static_cast(z1 - z0)}; + } + + // THE REGIONS ONE PIECE CARRIES: every dirty rect clipped to the piece's box, each with its + // own run-relative offset. A piece the dirty set does not reach still carries ONE region - its + // own box - and that is not padding: RegionCount == 0 is the whole-level spelling (a bare box + // the far side reads as tightly packed), and the piece's bytes have to cross whether or not + // any of them changed, because the server's level image is assembled from these runs and a + // hole in it is a texel read nothing on that side can answer. + inline SizeT MGPipeBuildSlabRegions(const MGPBox* rects, SizeT rectCount, + const MGPipeTextureSlab& slab, const MGPipeLevelPitch& pitch, + MGPSubRegion* out, SizeT maxOut) { + if (out == nullptr || maxOut == 0) return 0; + SizeT count = 0; + for (SizeT i = 0; i < rectCount && count < maxOut; ++i) { + const MGPBox clipped = MGPipeIntersectBoxes(rects[i], slab.Box); + if (clipped.W == 0 || clipped.H == 0 || clipped.D == 0) continue; + out[count++] = MGPipeBuildPieceRegion(clipped, slab.Box, pitch); + } + if (count == 0) out[count++] = MGPipeBuildPieceRegion(slab.Box, slab.Box, pitch); + return count; + } + + // The cap one level's staged run is cut at, or 0 for "keep the whole level in one record": + // monolith, the server role's own uploads and a process with no session - every unit gate - + // all answer 0, which is what keeps those lanes byte for byte what they were. + inline SizeT MGPipeTextureStageChunkBytes() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Remote::Client::MGPipeStageChunkBytes(); +#else + return 0; +#endif + } + // --------------------------------------------------------------------------------- // The emitter: handles, the inverse, the sticky mask, the drain list // --------------------------------------------------------------------------------- @@ -984,6 +1155,10 @@ namespace MobileGL::MG_Pipe { const MGPTextureParams& LastParams() const { return m_lastParams; } const MGPSubData& LastSubData() const { return m_lastSubData; } const Vector& LastRegions() const { return m_regions; } + // HOW MANY resource_subdata records the last level's drain put on the wire: 1 for the + // whole-level shape, N for a stage-chunk split (fix A2). LastSubData() and LastRegions() + // are the LAST piece's, and that is the whole-level record in the 1 case. + Uint64 SubDataPieceCount() const { return m_subDataPieces; } Uint64 CreateCount() const { return m_creates; } Uint64 RespecifyCount() const { return m_respecifies; } Uint64 ParamCount() const { return m_paramSets; } @@ -1205,7 +1380,15 @@ namespace MobileGL::MG_Pipe { } } - // True when the level's record went out and its flags may be cleared. + // True when the level's record(s) went out and its flags may be cleared. + // + // ONE LEVEL MAY TAKE MORE THAN ONE RECORD (fix A2). A level shadow larger than the + // staging segment's chunk budget is cut into whole-width slabs, each staged as its own + // resource_subdata (MGPipeForEachTextureSlab above; the buffer half's walks are cut the + // same way). Every piece is emitted even when nothing in it changed, because the + // server's level image is assembled out of these runs; and the level's dirty flag is + // cleared only when EVERY piece was accepted - a refusal anywhere leaves the whole level + // dirty and on the drain list, which is the safe direction and self-heals. Bool EmitOneLevel(const DrainEntry& pending, Uint64& bytes) { ITextureObject* texture = ResolveTexture(pending.Handle); if (texture == nullptr) return true; // the object is gone; nothing is owed @@ -1231,69 +1414,122 @@ namespace MobileGL::MG_Pipe { // beats many small ones. The invariant that makes the server's choice safe is that // the two describe the SAME texels: every rect lies inside the box, and their union // is the box. + // + // READ AS BOXES, ONCE, because both shapes below want them in that form: the + // whole-level record converts each one straight back through MGPipeBuildSubRegion, + // and a piece clips each one to its own slab. MG_State::GLState::MipmapDirtyRegion rects[MG_State::GLState::MipmapStorage::kMaxDirtyRects]; const SizeT rectCount = mipmap->GetStorageDirtyRects( uploadTarget, level, rects, MG_State::GLState::MipmapStorage::kMaxDirtyRects); - m_regions.clear(); - m_regions.reserve(rectCount); - for (SizeT i = 0; i < rectCount; ++i) { - m_regions.push_back( - MGPipeBuildSubRegion(MGPipeBoxOfDirtyRegion(rects[i]), levelSize, pitch)); - } + MGPBox rectBoxes[MG_State::GLState::MipmapStorage::kMaxDirtyRects]; + for (SizeT i = 0; i < rectCount; ++i) rectBoxes[i] = MGPipeBoxOfDirtyRegion(rects[i]); - m_lastSubData = MGPSubData{}; - m_lastSubData.Res = pending.Handle; - // The contract's packer takes two Uint32s (c0c keeps MGPipeTypes.h backend-neutral), - // so the frontend enumeration is widened here rather than there. - m_lastSubData.Target = MGPipePackSubDataTarget( - static_cast(MGPipeResourceTargetForTextureTarget(texture->GetTarget())), - static_cast(uploadTarget)); - m_lastSubData.Level = static_cast(level); - m_lastSubData.LevelWidth = static_cast(levelSize.x()); - m_lastSubData.LevelHeight = static_cast(levelSize.y()); - m_lastSubData.LevelDepth = static_cast(levelSize.z()); - // ALWAYS 1 ON THE CLIENT SIDE. The conversion fallbacks (the packed-norm, widened - // and fallback upload preparers) are the server's and run there, so the bytes this - // record declares ARE the level shadow. The server clears the flag internally when - // it converts, which is the `uploadData == mipData` pointer comparison turned into - // a carried fact. - m_lastSubData.SourceIsVerbatimLevelShadow = 1; - m_lastSubData.UnionBox = unionBox; - m_lastSubData.RegionCount = static_cast(m_regions.size()); - // "THIS RECORD DOES NOT DECLARE ITS BLOB", which is what a monolith emission is: - // Seg is kMGHostSpanSegNone, Offset IS the address of the level shadow base, and a - // zero Size means the destination box is what bounds the write. A non-zero Size - // that did not match the record's own byte count would be Fatal{ProtocolCorruption} - // on the far side, and a texture record's byte count is the server's to compute - // once it has picked box-or-rects. - m_lastSubData.Blob.Seg = kMGHostSpanSegNone; - m_lastSubData.Blob.Offset = static_cast(reinterpret_cast(shadow)); - m_lastSubData.Blob.Size = 0; + Bool dispatched = false; + Bool accepted = true; + m_subDataPieces = 0; + + const auto fillRecord = [&](const MGPBox& box, Uint64 runAddress) { + m_lastSubData = MGPSubData{}; + m_lastSubData.Res = pending.Handle; + // The contract's packer takes two Uint32s (c0c keeps MGPipeTypes.h backend-neutral), + // so the frontend enumeration is widened here rather than there. + m_lastSubData.Target = MGPipePackSubDataTarget( + static_cast(MGPipeResourceTargetForTextureTarget(texture->GetTarget())), + static_cast(uploadTarget)); + m_lastSubData.Level = static_cast(level); + m_lastSubData.LevelWidth = static_cast(levelSize.x()); + m_lastSubData.LevelHeight = static_cast(levelSize.y()); + m_lastSubData.LevelDepth = static_cast(levelSize.z()); + // ALWAYS 1 ON THE CLIENT SIDE. The conversion fallbacks (the packed-norm, widened + // and fallback upload preparers) are the server's and run there, so the bytes this + // record declares ARE the level shadow. The server clears the flag internally when + // it converts, which is the `uploadData == mipData` pointer comparison turned into + // a carried fact. + m_lastSubData.SourceIsVerbatimLevelShadow = 1; + m_lastSubData.UnionBox = box; + m_lastSubData.RegionCount = static_cast(m_regions.size()); + // "THIS RECORD DOES NOT DECLARE ITS BLOB", which is what a monolith emission is: + // Seg is kMGHostSpanSegNone, Offset IS the address of this record's run in the + // level shadow, and a zero Size means the destination box is what bounds the + // write. A non-zero Size that did not match the record's own byte count would be + // Fatal{ProtocolCorruption} on the far side, and a texture record's byte count is + // the server's to compute once it has picked box-or-rects. Under split the encoder + // fills Seg/Size from the staged run it just cut. + m_lastSubData.Blob.Seg = kMGHostSpanSegNone; + m_lastSubData.Blob.Offset = runAddress; + m_lastSubData.Blob.Size = 0; + }; // THE REGION LIST IS THE CALL'S VARIABLE TAIL AND IT IS HANDED OVER (M1). v1 built // m_regions, wrote its size into RegionCount and passed nothing, so on this base - // where the applier's tail exists - every scattered upload would have declared N // regions and supplied none (the applier faults on exactly that). A null tail is // correct ONLY for the whole-level shape, where RegionCount is 0. - Bool accepted = false; - Bool dispatched = false; - if constexpr (MGPipeTextureRecordsReachTheApplier()) { - dispatched = true; - // `levelBytes` closes CONTRACT-P5 table 1 row 7's open half. The record still - // declares Blob.Size 0 on the monolith arm - where the applier reads the - // companion pointer and the destination box bounds the write - but under split - // the staged run needs a length, and the comment above already says what it - // is: "the bytes this record declares ARE the level shadow". The regions' own - // SrcOffsets index into exactly that run. - accepted = MGPipeRouteResourceSubData(m_lastSubData, shadow, - static_cast(levelBytes), - m_regions.empty() ? nullptr : m_regions.data()); + const auto emitRecord = [&](const void* run, Uint64 runBytes) { + Bool pieceAccepted = true; + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + dispatched = true; + // `runBytes` closes CONTRACT-P5 table 1 row 7's open half. The record still + // declares Blob.Size 0 on the monolith arm - where the applier reads the + // companion pointer and the destination box bounds the write - but under split + // the staged run needs a length, and the comment above already says what it + // is: "the bytes this record declares ARE the level shadow". For a piece that + // is the piece's own run of it, and the regions' SrcOffsets index into exactly + // that run. + pieceAccepted = MGPipeRouteResourceSubData( + m_lastSubData, run, runBytes, + m_regions.empty() ? nullptr : m_regions.data()); + } else { + (void)run; + (void)runBytes; + } + ++m_subDatas; + ++m_subDataPieces; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ClientTextureUploadEmissions, 1); + } + bytes += sizeof(MGPSubData) + m_regions.size() * sizeof(MGPSubRegion); + return pieceAccepted; + }; + + // WHICH SHAPE THIS LEVEL TAKES. The split is taken only when the level's whole run + // does not fit one record's chunk budget; a level that fits keeps the single + // whole-level record, field for field what this emitter has always sent and what + // every existing expectation is written against. + const SizeT chunkBytes = MGPipeTextureStageChunkBytes(); + Bool split = false; + if (chunkBytes != 0 && static_cast(levelBytes) > static_cast(chunkBytes)) { + split = MGPipeForEachTextureSlab( + levelSize, pitch, levelBytes, chunkBytes, [&](const MGPipeTextureSlab& slab) { + // One slot per rect plus one for a piece no rect reaches, which is the + // most MGPipeBuildSlabRegions can write. + m_regions.assign(std::max(rectCount, 1), MGPSubRegion{}); + const SizeT pieceRegions = MGPipeBuildSlabRegions( + rectBoxes, rectCount, slab, pitch, m_regions.data(), m_regions.size()); + m_regions.resize(pieceRegions); + fillRecord(slab.Box, + reinterpret_cast(reinterpret_cast(shadow)) + + slab.RunOffset); + const Bool pieceAccepted = emitRecord( + static_cast(shadow) + slab.RunOffset, slab.RunBytes); + accepted = accepted && pieceAccepted; + }); } - ++m_subDatas; - if (MG_Util::PipeStats::Enabled()) { - MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ClientTextureUploadEmissions, 1); + if (!split) { + // THE WHOLE-LEVEL RECORD: one run, the level shadow itself, beginning at the + // level's first byte - which is why Blob.Offset is the shadow base and why the + // box may be the dirty union box without describing the run. The far side learned + // to tell the two shapes apart from the run's own length + // (StagedTextureStore::AdoptRunImageOffset). + m_regions.clear(); + m_regions.reserve(rectCount); + for (SizeT i = 0; i < rectCount; ++i) { + m_regions.push_back(MGPipeBuildSubRegion(rectBoxes[i], levelSize, pitch)); + } + fillRecord(unionBox, static_cast(reinterpret_cast(shadow))); + accepted = emitRecord(shadow, static_cast(levelBytes)); } - bytes += sizeof(MGPSubData) + m_regions.size() * sizeof(MGPSubRegion); + // THE CLIENT CLEARS ITS OWN FLAG ONLY FOR A LEVEL THE APPLIER ACCEPTED (D-D5 step 1 // read literally; ID-18 M3). v1 cleared on DISPATCH - and, with the wired constant // still 0, even on a call the `if constexpr` had discarded - so any refusal left the @@ -1307,7 +1543,10 @@ namespace MobileGL::MG_Pipe { // direction and self-heals: the ordinary cause is a record the applier does not // hold, and the next respecify's self-healing create gives it one. It is LOUD // because a permanently refused level would otherwise re-emit once per verb for - // ever with nothing to show for it. + // ever with nothing to show for it. WITH PIECES IN FLIGHT it is the same rule one + // level up: every piece is still emitted, so a refusal leaves the server with a + // partial image and the WHOLE level dirty, and the next drain re-sends every piece - + // an adoption of the same bytes twice is idempotent. if (dispatched && !accepted) { ++m_refusedSubDatas; MGLOG_E_ONCE("MGPipe: resource_subdata for texture {slot=%u, gen=%u} level %u was refused; " @@ -1333,6 +1572,8 @@ namespace MobileGL::MG_Pipe { Uint64 m_respecifies = 0; Uint64 m_paramSets = 0; Uint64 m_subDatas = 0; + // What the LAST EmitOneLevel put on the wire: 1 unless a level's run was cut into slabs. + Uint64 m_subDataPieces = 0; Uint64 m_refusedSubDatas = 0; Uint64 m_refusedParamSets = 0; Uint64 m_samplerCsoPayloadBytes = 0; diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 7a3addb6..d5585608 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -1018,14 +1018,25 @@ namespace MobileGL::MG_Pipe { // Their staged bytes still belong to the server before SEG_STAGE retires. Keep the // existing backend hooks authoritative when present, and use this fallback otherwise. void AdoptTextureWithoutBackendHook(const MGPipeResourceRecord& stored, const MGPSubData& upload, - const void* bytes) { + const void* bytes, const MGPSubRegion* regions) { if (MG_Config::Transport == MG_Config::TransportMode::Monolith || !bytes || upload.Blob.Size == 0) return; auto& store = MG_Remote::Server::ServerStagedTexture(); const auto& desc = stored.Desc; - store.Adopt(MG_Remote::Server::StagedTextureStore::KeyForHandle(upload.Res), - MGPipeSubDataUploadTargetOf(upload.Target), upload.Level, - MG_Remote::Server::StagedTextureUploadExtent(desc, upload), - bytes, static_cast(upload.Blob.Size)); + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(upload.Res); + const Uint16 target = MGPipeSubDataUploadTargetOf(upload.Target); + const IntVec3 extent = MG_Remote::Server::StagedTextureUploadExtent(desc, upload); + // RegionCount == 0 IS the whole-level spelling - "the run is the level shadow" - so it + // is adopted with the spelling that replaces the level; every other record names a RUN + // of the level by its own box (fix A2: a level too large to stage whole crosses as + // slabs, one record each, and the first of them is at the level's first byte). + if (upload.RegionCount == 0) { + store.Adopt(key, target, upload.Level, extent, bytes, + static_cast(upload.Blob.Size)); + return; + } + store.AdoptRun(key, target, upload.Level, extent, + MG_Remote::Server::StagedTextureRunImageOffset(upload, regions), bytes, + static_cast(upload.Blob.Size)); } void DefineTextureWithoutBackendHook(const MGPResourceDesc& desc, const MGPRespecifiedLevel* level) { @@ -1120,10 +1131,13 @@ namespace MobileGL::MG_Pipe { // deliberately holds no byte pointer). The hook copies the run into the server's // staged-texture store keyed by this record's own handle; it is a no-op in // monolith, so the monolith shape keeps P5's pointer-dropping expression exactly. + // The REGIONS travel with it because a record may carry a RUN of its level rather + // than the whole of it (fix A2): they are what places that run in the level image, + // and the applier's pending set - which already has them - is not the store's. if (g_resourceOps != nullptr && g_resourceOps->TextureSubData != nullptr) { g_resourceOps->TextureSubData(record.Res, record, bytes, regions); } else { - AdoptTextureWithoutBackendHook(*stored, record, bytes); + AdoptTextureWithoutBackendHook(*stored, record, bytes, regions); } #endif return true; diff --git a/MobileGL/MG_Remote/Server/StagedTextureStore.h b/MobileGL/MG_Remote/Server/StagedTextureStore.h index 5ef2a549..7abbbce3 100644 --- a/MobileGL/MG_Remote/Server/StagedTextureStore.h +++ b/MobileGL/MG_Remote/Server/StagedTextureStore.h @@ -41,20 +41,27 @@ // (ResetLevels), resource_destroy drops the key (Ops_H_TextureDestroy), context death drops // all (OnBackendContextDestroyed, beside MGL_SERVER_STAGED_DROP_ALL). // -// COVERAGE IS EXACTLY THE STAGED RUN, NEVER WIDENED AND NEVER NARROWER. The texture half of -// resource_subdata ALWAYS stages the whole level shadow (TextureEmit.h:1285: "the bytes this -// record declares ARE the level shadow", Blob.Size = the level's byte count), so one Adopt -// covers its level whole. The record's region set is deliberately NOT the coverage unit: it -// declares which texels CHANGED (the upload planner's shape, which the applier's pending set -// already carries), while the full-level upload paths - every conversion fallback, and the -// immutable/mutable regeneration arms - read the whole level including texels no region -// named. Those texels crossed in the staged run; treating them as uncovered would Fatal a -// legal glTexStorage-then-small-glTexSubImage sequence whose first (and only) record names a -// small box, and moving them into the store is not the buffer half's silent-zero case -// because they are the client's own shadow bytes, delivered and declared. A sync that asks -// for a level this store has no covered run for is Fatal{StageSnapshotTooNarrow} - the same -// words as the buffer half, for the same reason: the bytes have never existed on this side, -// and inventing them is silent data loss, not a missing optimisation. +// COVERAGE IS EXACTLY THE STAGED RUNS, NEVER WIDENED AND NEVER NARROWER. One resource_subdata +// stages one RUN of the level shadow - the whole level for a level that fits one record's chunk +// budget (TextureEmit.h: "the bytes this record declares ARE the level shadow", Blob.Size = the +// level's byte count), a whole-width slab of it for one that does not +// (TextureEmit.h's MGPipeForEachTextureSlab) - and what this store covers is the union of the +// runs it has adopted, in the level's own byte coordinates. The record's region set is +// deliberately NOT the coverage unit: it declares which texels CHANGED (the upload planner's +// shape, which the applier's pending set already carries), while the full-level upload paths - +// every conversion fallback, and the immutable/mutable regeneration arms - read the whole level +// including texels no region named. Those texels crossed in the staged run; treating them as +// uncovered would Fatal a legal glTexStorage-then-small-glTexSubImage sequence whose first (and +// only) record names a small box, and moving them into the store is not the buffer half's +// silent-zero case because they are the client's own shadow bytes, delivered and declared. +// +// SO A FULL-LEVEL READ ASKS FOR THE WHOLE LEVEL TO BE COVERED, which is where the texture half +// parts company with the buffer half's one-range shape: the runs of a split level arrive as +// several records of ONE apply batch - the same verb, so all of them land before the barrier +// that lets the sync read - the set is complete exactly when every piece has landed, and a level +// with a hole in it is Fatal{StageSnapshotTooNarrow}: the same words as the buffer half, for the +// same reason, because those bytes have never existed on this side and inventing them is silent +// data loss rather than a missing optimisation. // // DEFINED-NESS IS TRACKED, NOT DERIVED ALONE. ยง1's per-level extent derivation (max(1, // base >> level), layer axes fixed) computes the extent of a level that EXISTS; it cannot @@ -87,6 +94,9 @@ #include #include +// StagedShadowStore::CoverageAdd / CoverageHas: ONE spelling of the covered set, so the texture +// half's runs and the buffer half's ranges cannot drift apart on what "covered" means. +#include #include #include @@ -139,6 +149,45 @@ namespace MobileGL::MG_Remote::Server { return StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, desc.Depth, upload.Level); } + // WHERE A RECORD'S STAGED RUN BEGINS IN THE SERVER-OWNED LEVEL IMAGE. The record itself says + // so, and the two shapes that reach AdoptRun are told apart by the run's own LENGTH - the one + // field of MGPSubData that differs between them: + // + // * THE WHOLE-LEVEL RUN, which is what resource_subdata's texture half has always staged: + // TextureEmit.h's "the bytes this record declares ARE the level shadow", so Blob.Size is + // the level's byte count and the run begins at the level's FIRST byte whatever the record's + // box says - the box describes the dirty texels, not the run. Answer: 0. That is also the + // answer for the whole-level spelling that carries no regions at all (RegionCount == 0, + // strides 0 = tightly packed). + // + // * A SLAB of a level too large to stage whole (TextureEmit.h's MGPipeForEachTextureSlab), + // whose run is exactly the byte extent of its own box and therefore begins at that box's + // first byte: z0 * sliceStride + y0 * rowStride + x0 * bpp, every one of them carried in + // the record's own regions. So the box names the placement, and the far side still needs no + // knowledge of the level's format: the texel's byte size falls out of the carried row + // stride and the carried level width - the two numbers the region and the record both + // state. + // + // A run SHORTER than its box's byte extent is neither shape and answers 0 - not silently + // placed: no emitter produces one, and the whole-level reading is the only one that cannot + // lose texels the record did carry. + inline Uint64 StagedTextureRunImageOffset(const MG_Pipe::MGPSubData& upload, + const MG_Pipe::MGPSubRegion* regions) { + const MG_Pipe::MGPBox& box = upload.UnionBox; + if (regions == nullptr || upload.RegionCount == 0) return 0; + const Uint64 rowStride = regions[0].SrcRowStride; + const Uint64 sliceStride = regions[0].SrcSliceStride; + if (rowStride == 0 || sliceStride == 0 || upload.LevelWidth == 0) return 0; + const Uint64 texelBytes = rowStride / upload.LevelWidth; + if (texelBytes == 0 || box.W == 0 || box.H == 0 || box.D == 0) return 0; + const Uint64 boxBytes = static_cast(box.D - 1) * sliceStride + + static_cast(box.H - 1) * rowStride + + static_cast(box.W) * texelBytes; + if (upload.Blob.Size != boxBytes) return 0; + return static_cast(box.Z) * sliceStride + static_cast(box.Y) * rowStride + + static_cast(box.X) * texelBytes; + } + class StagedTextureStore { public: // `copies` is "this process is really split". False reproduces the monolith expression @@ -163,29 +212,46 @@ namespace MobileGL::MG_Remote::Server { return static_cast(reinterpret_cast(twin)); } - // THE ADOPTION. Copies the record's staged run - the whole level shadow, byteSize = - // MGPSubData::Blob.Size under split - into server-owned storage and marks the level - // covered whole (see the header comment for why the run, not the region set, is the - // coverage). REPLACES the level's entry: the run is the level's complete current - // content, so nothing of a previous run survives, and a fresh adoption is by - // definition not GPU-dirty. Returns the server-owned base; under monolith it does - // nothing and returns nullptr, and the caller (Ops_H_TextureSubData) has already - // returned before reaching here. + // THE ADOPTION OF ONE RUN OF A LEVEL. `imageOffset` is where the run's first byte belongs + // in the server-owned level image and `byteSize` (MGPSubData::Blob.Size under split) is + // its length; the covered set grows by exactly [imageOffset, imageOffset + byteSize), so + // coverage stays EXACTLY the staged runs and a hole between them is what RequireLevelBytes + // refuses on. The caller gets the offset from the record itself + // (StagedTextureRunImageOffset below). Runs may arrive in any order: coverage is a set, + // and the level's extent comes from the record either way. + // + // THE RETURNED BASE IS ONLY VALID UNTIL THE NEXT RUN OF THE SAME LEVEL: growing the image + // reallocates it, which is StagedShadowStore::Adopt's note one level down. Nothing needs + // the base while a level is still being assembled - the readers run at the barrier, after + // the last piece - so callers here ignore it. + const Uint8* AdoptRun(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent, + Uint64 imageOffset, const void* bytes, SizeT byteSize) { + if (!m_copies) return nullptr; + const std::lock_guard lock(m_mutex); + LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)]; + shadow.Extent = extent; + shadow.Defined = true; + shadow.GpuDirty = false; + return CopyRunInto(shadow, imageOffset, bytes, byteSize); + } + + // THE WHOLE-LEVEL SPELLING: the run is the level's COMPLETE current content, beginning at + // the level's first byte, so nothing of a previous run survives it - which is what makes a + // re-adoption the right answer for the image-promotion readback and for every + // monolith-shaped caller. AdoptRun(offset = 0) with the entry replaced rather than grown + // into; under monolith both do nothing and return nullptr, and the caller + // (Ops_H_TextureSubData) has already returned before reaching either. const Uint8* Adopt(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent, const void* bytes, SizeT byteSize) { if (!m_copies) return nullptr; const std::lock_guard lock(m_mutex); LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)]; shadow.Extent = extent; - shadow.Bytes.clear(); - if (bytes != nullptr && byteSize != 0) { - const auto* raw = static_cast(bytes); - shadow.Bytes.assign(raw, raw + byteSize); - } shadow.Defined = true; shadow.GpuDirty = false; - m_any.store(true, std::memory_order_release); - return shadow.Bytes.empty() ? nullptr : shadow.Bytes.data(); + shadow.Bytes.clear(); + shadow.Covered.clear(); + return CopyRunInto(shadow, 0, bytes, byteSize); } // A storage-defining respecify named this level: it EXISTS from here on, at this @@ -200,6 +266,9 @@ namespace MobileGL::MG_Remote::Server { LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)]; if (!shadow.Defined || shadow.Extent != extent) { shadow.Bytes.clear(); + // The covered set is a statement about THIS coordinate system, so it goes with + // the bytes it described. + shadow.Covered.clear(); shadow.GpuDirty = false; } shadow.Extent = extent; @@ -242,24 +311,31 @@ namespace MobileGL::MG_Remote::Server { // Fatal when a sync wants texels this store has no covered run for. This is THE data // -correctness refusal of the texture half: a pending upload with no adoption behind // it, a GPU-generated level (no bytes by construction), a level the client never - // defined, and a monolith-arm misuse all land here, because in every one of them the - // bytes have never existed on this side and re-reading the client's shadow for them - // is the cross-role access tx exists to end. + // defined, a level whose pieces did not all arrive, and a monolith-arm misuse all land + // here, because in every one of them the bytes have never existed on this side and + // re-reading the client's shadow for them is the cross-role access tx exists to end. + // + // THE QUESTION IS "IS THE WHOLE LEVEL COVERED", not "are there any bytes": a split + // level's pieces arrive as separate records of the same apply batch, and this caller + // reads the level image WHOLE (LevelByteSize / the sync's texel base), so a level with + // one piece missing is as unreadable as one with none. const Uint8* RequireLevelBytes(Uint64 key, Uint16 uploadTarget, Uint16 level, const char* site) const { if (!m_copies) return nullptr; const std::lock_guard lock(m_mutex); const LevelShadow* shadow = FindLevel(key, uploadTarget, level); - if (shadow != nullptr && shadow->Defined && !shadow->Bytes.empty()) { + if (shadow != nullptr && shadow->Defined && !shadow->Bytes.empty() && + StagedShadowStore::CoverageHas(shadow->Covered, 0, shadow->Bytes.size())) { return shadow->Bytes.data(); } MGLOG_F("MGPipe: Fatal{StageSnapshotTooNarrow, \"%s\"} - the texture sync wants the " "bytes of (uploadTarget=%u, level=%u) and the server's staged shadow has no " - "covered run for it. Under split the authoritative shadow is SERVER-OWNED " - "(rule C) and resource_subdata is the only way bytes reach it, so these " - "texels have never existed on this side: the record that should have " - "carried them is missing, or the level's texels were generated on the GPU " - "and no byte answer exists at all. Re-reading the client's shadow would be " - "the cross-role access this store exists to end", + "COMPLETE covered run for it. Under split the authoritative shadow is " + "SERVER-OWNED (rule C) and resource_subdata is the only way bytes reach it, " + "so these texels have never existed on this side: the record that should " + "have carried them is missing, a piece of a level cut into stage-chunk runs " + "has not arrived, or the level's texels were generated on the GPU and no " + "byte answer exists at all. Re-reading the client's shadow would be the " + "cross-role access this store exists to end", site, static_cast(uploadTarget), static_cast(level)); std::abort(); } @@ -269,7 +345,20 @@ namespace MobileGL::MG_Remote::Server { Bool IsCovered(Uint64 key, Uint16 uploadTarget, Uint16 level) const { const std::lock_guard lock(m_mutex); const LevelShadow* shadow = FindLevel(key, uploadTarget, level); - return shadow != nullptr && shadow->Defined && !shadow->Bytes.empty(); + if (shadow == nullptr || !shadow->Defined || shadow->Bytes.empty()) return false; + // THE WHOLE LEVEL, not merely "some bytes": a level assembled out of stage-chunk + // pieces is covered only once every piece has landed, and both readers of this + // answer then read the image whole - the image-promotion merge and Magma's + // unbacked-level readback. + return StagedShadowStore::CoverageHas(shadow->Covered, 0, shadow->Bytes.size()); + } + // How many runs this level's coverage is made of: 1 for a whole-level adoption, one per + // piece for a level cut into stage-chunk slabs. Diagnostics for the unit cases, so a + // check can assert WHAT happened rather than that nothing blew up. + SizeT LevelCoveredRunCount(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + return shadow == nullptr ? 0 : shadow->Covered.size(); } Bool IsLevelDefined(Uint64 key, Uint16 uploadTarget, Uint16 level) const { const std::lock_guard lock(m_mutex); @@ -314,7 +403,6 @@ namespace MobileGL::MG_Remote::Server { return it == m_shadows.end() ? 0 : it->second.Levels.size(); } - private: static Uint32 PackLevel(Uint16 uploadTarget, Uint16 level) { return (static_cast(uploadTarget) << 16) | static_cast(level); } @@ -322,8 +410,14 @@ namespace MobileGL::MG_Remote::Server { struct LevelShadow { IntVec3 Extent{0, 0, 0}; // The level's whole staged run. EMPTY for a defined-but-byteless level (null-data - // definition, GPU-generated) - emptiness is the coverage answer, not an error. + // definition, GPU-generated) - emptiness is the coverage answer, not an error. For a + // level adopted in pieces it is the assembled image, grown to the high-water mark of + // the runs. Vector Bytes; + // Sorted, disjoint, in the level image's byte coordinates, and EXACTLY the runs + // adopted for this level: a gap in it is a piece that never arrived, which is the + // Fatal below (StagedShadowStore's coverage rules, same helpers). + Vector Covered; Bool Defined = false; Bool GpuDirty = false; }; @@ -331,6 +425,24 @@ namespace MobileGL::MG_Remote::Server { ska::flat_hash_map Levels; }; + // The copy both adoption spellings share, with the lock already held: grow the image to + // hold the run, place it, and widen the covered set by exactly its range. An empty run + // (a level defined with no bytes, or a null pointer) covers nothing - emptiness is the + // coverage answer for such a level, not an error. + const Uint8* CopyRunInto(LevelShadow& shadow, Uint64 imageOffset, const void* bytes, + SizeT byteSize) { + if (bytes != nullptr && byteSize != 0) { + const auto* raw = static_cast(bytes); + const SizeT needed = static_cast(imageOffset) + byteSize; + if (shadow.Bytes.size() < needed) shadow.Bytes.resize(needed, 0); + std::memcpy(shadow.Bytes.data() + imageOffset, raw, byteSize); + StagedShadowStore::CoverageAdd(shadow.Covered, static_cast(imageOffset), + needed); + } + m_any.store(true, std::memory_order_release); + return shadow.Bytes.empty() ? nullptr : shadow.Bytes.data(); + } + const LevelShadow* FindLevel(Uint64 key, Uint16 uploadTarget, Uint16 level) const { const auto textureIt = m_shadows.find(key); if (textureIt == m_shadows.end()) return nullptr; diff --git a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp index 8b5533cd..3c7cedb4 100644 --- a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp @@ -74,6 +74,13 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +// MGPipeStageChunkBytesFor: the cap the slab split is driven at. A unit process has no +// session, so the live answer (MGPipeTextureStageChunkBytes) is 0 - "keep the whole level in +// one record" - and the clamp is what a case can drive the cut at, exactly as +// ResourceEmitTest does for the buffer half's walk. +#include +#endif #endif using namespace MobileGL; @@ -206,6 +213,7 @@ TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { X(TextureEmit, AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero) \ X(TextureEmit, AWholeLevelUploadCarriesZeroStrides) \ X(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) \ + X(TextureEmit, ALevelTooLargeForTheStageChunkIsCutIntoSlabs) \ X(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) \ X(TextureEmit, EveryTexturesParamsNameItsBuiltinSamplerCso) \ X(TextureEmit, TwoTexturesWithIdenticalSamplingShareOneBuiltinCso) \ @@ -549,6 +557,9 @@ TEST(TextureEmit, AWholeLevelUploadCarriesZeroStrides) { EXPECT_EQ(record.Blob.Seg, kMGHostSpanSegNone); EXPECT_EQ(record.Blob.Size, 0u) << "a monolith record does not declare its blob"; EXPECT_NE(record.Blob.Offset, 0u) << "Blob.Offset is the level shadow's address in monolith"; + EXPECT_EQ(Textures().SubDataPieceCount(), 1u) + << "a level that fits one record is exactly one piece (fix A2's counter), so the " + "whole-level shape stays what it always was"; // The upload target rides in the record's Target byte beside the resource target, which is // the only place a cube face could ever be carried. EXPECT_EQ(MGPipeSubDataResourceTargetOf(record.Target), @@ -586,6 +597,220 @@ TEST(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) { EXPECT_EQ(Textures().LastRegions().size(), oracleCount); } +// ==================== fix A2: the stage-chunk slab split ==================== +// +// ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, a linear arena, so a level shadow larger than +// the segment's chunk budget is Fatal{RingOverrun, "SEG_STAGE"} at the encoder rather than a +// split - and it is not hypothetical: measured on the CI traces, a 512x128x33 GL_RGBA32F level +// is 34,603,008 bytes and a 192-cube GL_RGBA16 level 56,623,104, both against the default 32 MiB +// segment. EmitOneLevel cuts such a level into slabs; THIS case drives the arithmetic of the cut +// at the cap MGPipeStageChunkBytesFor really returns, because a unit process has no ClientSession +// and MGPipeTextureStageChunkBytes() therefore answers 0. +// +// THREE PROPERTIES, AND EACH IS LOAD-BEARING: +// * the pieces are CONTIGUOUS AND ASCENDING and their union is the level EXACTLY ONCE - the +// server assembles one level image out of the runs (StagedTextureStore::AdoptRun), so a gap +// loses texels and an overlap places them twice; +// * EACH PIECE'S RUN IS EXACTLY ITS OWN BOX'S BYTE EXTENT - that is what lets the far side place +// a run from the box and the carried strides alone, with no knowledge of the format; +// * EACH PIECE CARRIES AT LEAST ONE REGION, with the LEVEL's pitches - RegionCount == 0 is the +// whole-level spelling, so a piece with an empty tail would be read as one. +TEST(TextureEmit, ALevelTooLargeForTheStageChunkIsCutIntoSlabs) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no emitter in this build"; +#elif !MOBILEGL_BUILD_DISAGGREGATED + GTEST_SKIP() << "there is no stage segment to size a content chunk against without the " + "transport built in"; +#else + EXPECT_EQ(MGPipeTextureStageChunkBytes(), 0u) + << "a unit process has no ClientSession, so the drain must keep the whole-level record"; + // SessionRings.h:89's default segment, and the cap is a quarter of it - the number the + // emitter really passes to the walk. + const SizeT cap = MG_Remote::Client::MGPipeStageChunkBytesFor(32ull * 1024ull * 1024ull); + ASSERT_EQ(cap, static_cast(8ull * 1024ull * 1024ull)); + + // THE CUT ITSELF, with the two properties that hold for every arm: the pieces tile the level + // exactly once, in order, each within the cap and each exactly its own box's byte extent. + const auto cut = [&](const IntVec3& levelSize, Uint64 levelBytes, SizeT chunk) { + std::vector slabs; + const MGPipeLevelPitch pitch = + MGPipeLevelPitchOf(levelSize, static_cast(levelBytes)); + EXPECT_TRUE(MGPipeForEachTextureSlab(levelSize, pitch, levelBytes, chunk, + [&](const MGPipeTextureSlab& slab) { + slabs.push_back(slab); + })); + EXPECT_GE(slabs.size(), 2u) << "a level of " << levelBytes << " bytes was not cut at a " + << chunk << " byte cap"; + Uint64 expectedOffset = 0; + Uint64 covered = 0; + for (const MGPipeTextureSlab& slab : slabs) { + EXPECT_GT(slab.RunBytes, 0u); + EXPECT_LE(slab.RunBytes, static_cast(chunk)) << "a piece is bigger than the cap"; + EXPECT_EQ(slab.RunOffset, expectedOffset) + << "the pieces are not contiguous and ascending"; + expectedOffset += slab.RunBytes; + covered += slab.RunBytes; + const Uint64 boxBytes = static_cast(slab.Box.D - 1) * pitch.SliceStride + + static_cast(slab.Box.H - 1) * pitch.RowStride + + static_cast(slab.Box.W) * pitch.BytesPerTexel; + EXPECT_EQ(boxBytes, slab.RunBytes) + << "the box does not describe the run, so the far side cannot place it"; + } + EXPECT_EQ(covered, levelBytes) << "the pieces do not cover the level exactly once"; + return slabs; + }; + + // (1) THE TRACE'S FIRST SHAPE: 512x128x33 RGBA32F, 34,603,008 bytes. Its slice pitch is + // 1 MiB, so the coarsest arm applies and the cut is by whole slices. + const IntVec3 volume{512, 128, 33}; + const Uint64 volumeBytes = 512ull * 128ull * 33ull * 16ull; + const std::vector volumeSlabs = cut(volume, volumeBytes, cap); + ASSERT_EQ(volumeSlabs.size(), 5u); + for (const MGPipeTextureSlab& slab : volumeSlabs) { + EXPECT_EQ(slab.Box.X, 0); + EXPECT_EQ(slab.Box.Y, 0); + EXPECT_EQ(slab.Box.W, 512u) << "a slab is not the level's full width"; + EXPECT_EQ(slab.Box.H, 128u); + EXPECT_EQ(slab.Box.D, 8u); + } + EXPECT_EQ(volumeSlabs.back().Box.D, 1u) << "the remainder slice is its own piece"; + + // (2) THE TRACE'S SECOND SHAPE: 192-cubed RGBA16, 56,623,104 bytes, cut the same way. + const IntVec3 cube{192, 192, 192}; + const Uint64 cubeBytes = 192ull * 192ull * 192ull * 8ull; + const std::vector cubeSlabs = cut(cube, cubeBytes, cap); + ASSERT_EQ(cubeSlabs.size(), 7u); + EXPECT_EQ(cubeSlabs.back().Box.D, 24u); + + // (3) A SLICE BIGGER THAN THE CAP: a 2D level degenerates to whole ROWS, and every piece is + // still the level's FULL WIDTH - the property that makes its box name its run. + const IntVec3 sheet{256, 256, 1}; + const Uint64 sheetBytes = 256ull * 256ull * 4ull; + const SizeT sheetCap = MG_Remote::Client::MGPipeStageChunkBytesFor(256ull * 1024ull); + ASSERT_EQ(sheetCap, static_cast(64ull * 1024ull)); + const std::vector sheetSlabs = cut(sheet, sheetBytes, sheetCap); + ASSERT_EQ(sheetSlabs.size(), 4u) << "64 of 256 rows per piece"; + for (const MGPipeTextureSlab& slab : sheetSlabs) { + EXPECT_EQ(slab.Box.X, 0); + EXPECT_EQ(slab.Box.W, 256u); + EXPECT_EQ(slab.Box.H, 64u); + EXPECT_EQ(slab.Box.D, 1u); + EXPECT_EQ(slab.RunBytes, 64ull * 1024ull); + } + EXPECT_EQ(sheetSlabs[1].Box.Y, 64); + EXPECT_EQ(sheetSlabs[1].RunOffset, 64ull * 1024ull); + + // (4) THE LAST RESORT: a ROW wider than the cap leaves runs of whole texels inside one row. + // The full-width property is the one that goes, and nothing downstream depends on it - the + // box still names the run exactly. + const IntVec3 line{64, 4, 1}; + const Uint64 lineBytes = 64ull * 4ull * 4ull; + const std::vector lineSlabs = cut(line, lineBytes, static_cast(64)); + ASSERT_EQ(lineSlabs.size(), 16u) << "16 texels per piece over 4 rows of 64"; + for (const MGPipeTextureSlab& slab : lineSlabs) { + EXPECT_EQ(slab.Box.W, 16u); + EXPECT_EQ(slab.Box.H, 1u); + EXPECT_EQ(slab.Box.D, 1u); + EXPECT_EQ(slab.RunBytes, 64ull); + } + + // (5) AND THE SHAPES THAT REFUSE TO CUT, which keep the whole-level record they had before the + // split existed: no cap at all, a pitch that does not tile the level's bytes exactly, and a cap + // below one texel. + std::vector refusals; + const auto refuses = [&](const IntVec3& levelSize, const MGPipeLevelPitch& pitch, + Uint64 levelBytes, SizeT chunk) { + return !MGPipeForEachTextureSlab(levelSize, pitch, levelBytes, chunk, + [&](const MGPipeTextureSlab& slab) { + refusals.push_back(slab); + }); + }; + const MGPipeLevelPitch volumePitch = + MGPipeLevelPitchOf(volume, static_cast(volumeBytes)); + const MGPipeLevelPitch linePitch = MGPipeLevelPitchOf(line, static_cast(lineBytes)); + EXPECT_TRUE(refuses(volume, volumePitch, volumeBytes, 0)); + EXPECT_TRUE(refuses(volume, volumePitch, volumeBytes + 1, cap)) + << "a level whose bytes the pitch does not tile has a tail no box describes"; + EXPECT_TRUE(refuses(line, linePitch, lineBytes, 1)) << "a cap below one texel"; + EXPECT_TRUE(refusals.empty()) << "a refused walk handed out pieces"; + + // (6) THE REGIONS OF A PIECE: every dirty rect CLIPPED to the slab, its SrcOffset REBASED onto + // the piece's own run (MGPipeTypes.h: SrcOffset is "into the blob") and the LEVEL's pitches + // kept. A slab the dirty set does not reach still carries its own box as ONE region. + const MGPBox dirty[5] = { + {0, 0, 0, 512, 128, 1}, // the whole first slice, inside slab 0 + {0, 0, 4, 64, 64, 1}, // a small rect in the middle of slab 0 + {10, 20, 8, 64, 64, 2}, // a rect spanning the first two slices of slab 1 + {460, 100, 7, 64, 64, 3}, // clipped on X, Y and the slab-0/slab-1 boundary + {0, 0, 32, 512, 128, 1}, // the last slice, inside slab 4 + }; + SizeT regionCounts[5] = {0, 0, 0, 0, 0}; + for (SizeT i = 0; i < volumeSlabs.size(); ++i) { + const MGPipeTextureSlab& slab = volumeSlabs[i]; + MGPSubRegion regions[5]{}; + const SizeT count = MGPipeBuildSlabRegions(dirty, 5, slab, volumePitch, regions, 5); + regionCounts[i] = count; + ASSERT_GE(count, 1u) << "a piece with no dirty rect must still carry one region"; + for (SizeT r = 0; r < count; ++r) { + const MGPSubRegion& region = regions[r]; + EXPECT_EQ(region.SrcRowStride, volumePitch.RowStride); + EXPECT_EQ(region.SrcSliceStride, volumePitch.SliceStride); + EXPECT_EQ(region.SrcOffset, + static_cast(region.Z - slab.Box.Z) * volumePitch.SliceStride + + static_cast(region.Y - slab.Box.Y) * volumePitch.RowStride + + static_cast(region.X - slab.Box.X) * volumePitch.BytesPerTexel) + << "SrcOffset is not relative to the piece's own run"; + EXPECT_GE(region.X, slab.Box.X); + EXPECT_GE(region.Y, slab.Box.Y); + EXPECT_GE(region.Z, slab.Box.Z); + EXPECT_LE(region.X + static_cast(region.W), + slab.Box.X + static_cast(slab.Box.W)) + << "a clipped rect reaches outside the piece's box"; + EXPECT_LE(region.Y + static_cast(region.H), + slab.Box.Y + static_cast(slab.Box.H)); + EXPECT_LE(region.Z + static_cast(region.D), + slab.Box.Z + static_cast(slab.Box.D)); + } + } + // Slab 0 sees three rects (one of them clipped at its own z end), slab 1 two, slabs 2 and 3 + // none at all - so their single region IS their box - and slab 4 the last slice. + EXPECT_EQ(regionCounts[0], 3u); + EXPECT_EQ(regionCounts[1], 2u); + EXPECT_EQ(regionCounts[2], 1u); + EXPECT_EQ(regionCounts[3], 1u); + EXPECT_EQ(regionCounts[4], 1u); + const MGPipeTextureSlab& unreached = volumeSlabs[2]; + MGPSubRegion invented[1]{}; + ASSERT_EQ(MGPipeBuildSlabRegions(dirty, 5, unreached, volumePitch, invented, 1), 1u); + EXPECT_EQ(invented[0].Z, unreached.Box.Z); + EXPECT_EQ(invented[0].W, unreached.Box.W); + EXPECT_EQ(invented[0].H, unreached.Box.H); + EXPECT_EQ(invented[0].D, unreached.Box.D); + EXPECT_EQ(invented[0].SrcOffset, 0u) << "the piece's own box starts at its own first byte"; + + // The fourth rect reaches past the level's width (460 + 64 > 512), and its Y and Z runs + // cross the slab's own end: the clip is what the emitted region shows, and its SrcOffset is + // the CLIPPED origin's offset into the piece. + bool sawClippedRect = false; + { + MGPSubRegion regions[5]{}; + ASSERT_EQ(MGPipeBuildSlabRegions(dirty, 5, volumeSlabs[0], volumePitch, regions, 5), 3u); + for (SizeT i = 0; i < 3; ++i) { + if (regions[i].X != 460 || regions[i].W != 52u) continue; + sawClippedRect = true; + EXPECT_EQ(regions[i].Y, 100); + EXPECT_EQ(regions[i].H, 28u); + EXPECT_EQ(regions[i].Z, 7); + EXPECT_EQ(regions[i].D, 1u); + EXPECT_EQ(regions[i].SrcOffset, + static_cast(7) * volumePitch.SliceStride + + static_cast(100) * volumePitch.RowStride + + static_cast(460) * volumePitch.BytesPerTexel); + } + } + EXPECT_TRUE(sawClippedRect) << "a rect wider than the level was not clipped to the piece"; +#endif +} // ============================ D-D4 ============================ TEST(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) { TextureScope scope; diff --git a/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp index 27616eea..72e3ee85 100644 --- a/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp +++ b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp @@ -228,6 +228,125 @@ TEST(StagedTextureStoreTest, TheMipExtentDerivationKeepsArrayLayersFixed) { IntVec3(1, 1, 1)) << "shrinking clamps at 1, never 0"; } +// ==================== fix A2: one level, several staged runs ==================== +// +// ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, so a level shadow larger than the segment's +// chunk budget is cut by the CLIENT into whole-width slabs, one resource_subdata each +// (TextureEmit.h's MGPipeForEachTextureSlab). The server's half of that is here: place each run +// where its record says and answer "covered" only once the level is whole. All the runs of one +// level arrive in ONE apply batch (one verb), so nothing reads between them in production; what a +// case can assert is what the readers would see at each instant, and that is what these do. +TEST(StagedTextureStoreTest, StageChunkRunsAssembleTheLevelImageAndAGapIsNotCovered) { + Server::StagedTextureStore store(/*copies=*/true); + Server::StagedTextureStore monolithStore(/*copies=*/false); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(11, 1)); + // A 4x4 level of four bytes per texel - 64 bytes - cut into two 32-byte runs. + const IntVec3 extent{4, 4, 1}; + Vector lower(32, 0x33); + Vector upper(32, 0x77); + + // THE SECOND RUN FIRST, deliberately: coverage is a SET and the placement comes from the + // record, so the order the pieces land in cannot matter. + EXPECT_EQ(monolithStore.AdoptRun(key, kTex2DTarget, 0, extent, 32, upper.data(), upper.size()), + nullptr); + EXPECT_EQ(monolithStore.TrackedResources(), 0u); + store.AdoptRun(key, kTex2DTarget, 0, extent, 32, upper.data(), upper.size()); + EXPECT_TRUE(store.IsLevelDefined(key, kTex2DTarget, 0)); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 64u) << "the image grew to the run's end"; + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)) + << "half a level read as covered, so a sync would take zeroes for the missing half"; + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u); + + store.AdoptRun(key, kTex2DTarget, 0, extent, 0, lower.data(), lower.size()); + EXPECT_TRUE(store.IsCovered(key, kTex2DTarget, 0)); + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u) + << "two adjacent runs must merge into one covered range"; + const Uint8* base = store.RequireLevelBytes(key, kTex2DTarget, 0, "unit_stage_chunk_runs"); + ASSERT_NE(base, nullptr); + for (SizeT i = 0; i < 32; ++i) EXPECT_EQ(base[i], 0x33) << "byte " << i << " of the lower run"; + for (SizeT i = 32; i < 64; ++i) EXPECT_EQ(base[i], 0x77) << "byte " << i << " of the upper run"; + + // w1's retired-stage poison, at the store's own scope: the COPY is what answers the readers. + std::fill(lower.begin(), lower.end(), Uint8{0xDD}); + std::fill(upper.begin(), upper.end(), Uint8{0xDD}); + EXPECT_EQ(base[0], 0x33); + EXPECT_EQ(base[63], 0x77); + + // AND A GAP IS NOT COVERAGE: it is the missing-piece case, which is the one thing the covered + // set exists to make visible instead of papered over. + const Uint64 holed = Server::StagedTextureStore::KeyForHandle(TestHandle(12, 1)); + Vector part(16, 0x11); + store.AdoptRun(holed, kTex2DTarget, 0, extent, 0, part.data(), part.size()); + store.AdoptRun(holed, kTex2DTarget, 0, extent, 32, part.data(), part.size()); + EXPECT_EQ(store.LevelCoveredRunCount(holed, kTex2DTarget, 0), 2u) + << "two runs with a 16-byte gap merged into one"; + EXPECT_FALSE(store.IsCovered(holed, kTex2DTarget, 0)) << "a span across the gap reads covered"; + // Filling the gap merges all three into one run: adjacency, not proximity, is the rule. + store.AdoptRun(holed, kTex2DTarget, 0, extent, 16, part.data(), part.size()); + EXPECT_EQ(store.LevelCoveredRunCount(holed, kTex2DTarget, 0), 1u); + EXPECT_TRUE(store.IsCovered(holed, kTex2DTarget, 0)); + + // An extent move redefines the coordinate system, so the assembled runs go with it - the same + // rule the whole-level adoption has always been held to. + store.NoteLevelDefined(key, kTex2DTarget, 0, IntVec3{8, 8, 1}); + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)); + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 0u); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 0u); +} + +// WHICH OF THE TWO STAGED-RUN SHAPES A RECORD CARRIES, read off the run's own LENGTH because the +// wire layout is frozen and carries no field for it. The whole-level run (the shape this half has +// always staged) is the level's complete shadow and begins at the level's first byte; a slab's run +// is exactly the byte extent of its own box, so the box names where it goes. Getting this wrong +// places a whole level's bytes at a dirty box's offset, or a slab's bytes at the level's base. +TEST(StagedTextureStoreTest, TheRunImageOffsetIsReadOffTheRecordsOwnRunAndBox) { + // THE TRACE'S SLAB: 512x128 RGBA32F, slices 8..16 of a 33-slice level. + constexpr Uint64 kSliceStride = 512ull * 128ull * 16ull; + constexpr Uint64 kLevelBytes = kSliceStride * 33ull; + MG_Pipe::MGPSubData slab{}; + slab.LevelWidth = 512; + slab.LevelHeight = 128; + slab.LevelDepth = 33; + slab.UnionBox = MG_Pipe::MGPBox{0, 0, 8, 512, 128, 8}; + slab.RegionCount = 1; + slab.Blob.Size = 8ull * kSliceStride; + MG_Pipe::MGPSubRegion region{}; + region.X = 0; + region.Y = 0; + region.Z = 8; + region.W = 512; + region.H = 128; + region.D = 8; + region.SrcRowStride = 512u * 16u; + region.SrcSliceStride = static_cast(kSliceStride); + EXPECT_EQ(Server::StagedTextureRunImageOffset(slab, ®ion), 8ull * kSliceStride); + + // THE SAME FIELDS WITH A WHOLE-LEVEL RUN - only the length differs - is the whole-level shape, + // whose run begins at the level's first byte however the box describes the dirty texels. + MG_Pipe::MGPSubData whole = slab; + whole.UnionBox = MG_Pipe::MGPBox{16, 32, 0, 64, 32, 1}; + whole.Blob.Size = kLevelBytes; + EXPECT_EQ(Server::StagedTextureRunImageOffset(whole, ®ion), 0u); + + // A whole-level BOX over a whole-level RUN is the same answer by the other route: the box + // starts at the level's first byte, so "derived from the box" and "offset 0" agree. + whole.UnionBox = MG_Pipe::MGPBox{0, 0, 0, 512, 128, 33}; + EXPECT_EQ(Server::StagedTextureRunImageOffset(whole, ®ion), 0u); + + // THE NO-REGION SPELLING is the whole-level one by construction: RegionCount == 0 means the + // box is the whole story and the strides are 0 = tightly packed. + EXPECT_EQ(Server::StagedTextureRunImageOffset(slab, nullptr), 0u); + slab.RegionCount = 0; + EXPECT_EQ(Server::StagedTextureRunImageOffset(slab, ®ion), 0u); + slab.RegionCount = 1; + + // A RUN SHORTER THAN ITS BOX'S EXTENT is neither shape. No emitter produces one, and the + // whole-level reading is the only one that cannot lose texels the record did carry. + MG_Pipe::MGPSubData shortRun = slab; + shortRun.Blob.Size = 8ull * kSliceStride - 16ull; + EXPECT_EQ(Server::StagedTextureRunImageOffset(shortRun, ®ion), 0u); +} + // The Fatal, and it asserts ITS OWN failure string rather than "the process died" - // ServerLoopTest.cpp:702-704's reason: a death test that only checks for a crash goes green // on any other abort in the same body. ONE death per case: the forked children of two @@ -272,6 +391,32 @@ TEST(StagedTextureStoreTest, AGpuGeneratedLevelHasNoBytesAndItsTexelReadIsFatalB } #endif +// The same refusal for a level that arrived IN PIECES (fix A2): the sync reads the level image +// whole, so a level with one piece missing is as unreadable as one that never arrived - and it is +// the same named Fatal. ONE DEATH PER CASE, ServerLoopTest's ruling above. +#if !defined(_WIN32) +TEST(StagedTextureStoreTest, ALevelWithAPieceMissingIsRefusedAsAWholeLevelReadByName) { + Server::StagedTextureStore store(/*copies=*/true); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(13, 1)); + Vector half(32, 0x55); + Vector whole(64, 0x22); + store.AdoptRun(key, kTex2DTarget, 0, IntVec3{4, 4, 1}, 0, half.data(), half.size()); + ASSERT_FALSE(store.IsCovered(key, kTex2DTarget, 0)); + ASSERT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u); + + // A DIFFERENT level of the same store, adopted whole, reads fine - asserted first so that the + // death below cannot be a function that aborts on everything. + store.AdoptRun(key, kTex2DTarget, 1, IntVec3{4, 4, 1}, 0, whole.data(), whole.size()); + ASSERT_NE(store.RequireLevelBytes(key, kTex2DTarget, 1, "unit"), nullptr); + + EXPECT_EXIT(store.RequireLevelBytes(key, kTex2DTarget, 0, "unit_missing_piece"), + ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"unit_missing_piece\"}"), std::string::npos) + << "the abort happened but not for this rule's reason; the log says: " << log; +} +#endif + // ===================================================================================== // The production wiring - E-P5c gate #2 at unit scope // ===================================================================================== @@ -418,6 +563,150 @@ TEST(StagedTextureProductionTest, HooklessTextureConsumerOwnsBytesAndScopedStora EXPECT_FALSE(store.HasShadow(nextKey)); } +// FIX A2's PRODUCTION WIRING, hookless arm: the two runs of ONE level cross the applier as two +// records and reach the store through PipeApply.cpp's AdoptTextureWithoutBackendHook, which has no +// backend hook to lean on. Each must land where its OWN box and carried strides say - the record is +// the only place the placement exists. The second run is sent first so that a store that ignored +// the box and grew the image from the front cannot pass. +TEST(StagedTextureProductionTest, StageChunkRunsThroughTheHooklessApplierLandWhereTheirBoxesSay) { + // A record-only texture consumer has no texture callbacks in the buffer ops table, which is + // exactly the arm that takes the fallback rather than Ops_H_TextureSubData. + const MG_Pipe::MGPipeResourceOps emptyOps{}; + struct RestoreOps { + const MG_Pipe::MGPipeResourceOps* Previous = MG_Pipe::MGPipeGetResourceOps(); + ~RestoreOps() { MG_Pipe::MGPipeSetResourceOps(Previous); } + } restore; + MG_Pipe::MGPipeSetResourceOps(&emptyOps); + + auto& store = Server::ServerStagedTexture(); + const auto res = TestHandle(45, 1); + const auto key = Server::StagedTextureStore::KeyForHandle(res); + MG_Pipe::MGPResourceDesc desc{}; + desc.Resource = res; + desc.Target = static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D); + desc.Width = 4; + desc.Height = 4; + desc.Depth = 1; + desc.Levels = 1; + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc)); + + // A 4x4 RGBA8 level: 64 bytes, four bytes per texel, a 16-byte row pitch. The two runs are its + // upper and lower halves, 32 bytes each. + Vector upper(32, 0x77); + Vector lower(32, 0x33); + MG_Pipe::MGPSubData upperRecord{}; + upperRecord.Res = res; + upperRecord.Target = MG_Pipe::MGPipePackSubDataTarget( + static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), + static_cast(TextureUploadTarget::Texture2D)); + upperRecord.LevelWidth = 4; + upperRecord.LevelHeight = 4; + upperRecord.LevelDepth = 1; + upperRecord.UnionBox = MG_Pipe::MGPBox{0, 2, 0, 4, 2, 1}; + upperRecord.RegionCount = 1; + upperRecord.Blob.Size = upper.size(); + MG_Pipe::MGPSubRegion upperRegion{}; + upperRegion.Y = 2; + upperRegion.W = 4; + upperRegion.H = 2; + upperRegion.D = 1; + upperRegion.SrcOffset = 0; + upperRegion.SrcRowStride = 16; + upperRegion.SrcSliceStride = 64; + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceSubData(upperRecord, upper.data(), &upperRegion)); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 64u) + << "the image did not grow to the second half's end"; + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u); + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)) << "only half the level has arrived"; + + MG_Pipe::MGPSubData lowerRecord = upperRecord; + lowerRecord.UnionBox = MG_Pipe::MGPBox{0, 0, 0, 4, 2, 1}; + MG_Pipe::MGPSubRegion lowerRegion = upperRegion; + lowerRegion.Y = 0; + lowerRegion.SrcOffset = 0; + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceSubData(lowerRecord, lower.data(), &lowerRegion)); + EXPECT_TRUE(store.IsCovered(key, kTex2DTarget, 0)); + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u) + << "the two halves did not merge into one covered range"; + const Uint8* base = store.RequireLevelBytes(key, kTex2DTarget, 0, "unit_production_runs"); + ASSERT_NE(base, nullptr); + for (SizeT i = 0; i < 32; ++i) EXPECT_EQ(base[i], 0x33) << "byte " << i << " of the lower run"; + for (SizeT i = 32; i < 64; ++i) EXPECT_EQ(base[i], 0x77) << "byte " << i << " of the upper run"; + + MG_Pipe::MGPHandleOnly death{}; + death.Handle = res; + death.Kind = static_cast(MG_Pipe::MGPipeKind::Texture); + MG_Pipe::MGPipeApplyResourceDestroy(death); + EXPECT_FALSE(store.HasShadow(key)); +} + +// THE SAME, through the REAL texture hook DirectGLES registers (Ops_H_TextureSubData), which is the +// arm the shipped backend takes. Reverting the offset - passing 0, or adopting every run as if it +// were the whole level - puts both halves at the front of the image and turns the byte checks below +// red. +TEST(StagedTextureProductionTest, StageChunkRunsThroughTheRealTextureHookLandWhereTheirBoxesSay) { + MG_Backend::DirectGLES::BufferImpl::RegisterBufferBackendOps(); + const MG_Pipe::MGPipeResourceOps* ops = MG_Pipe::MGPipeGetResourceOps(); + ASSERT_NE(ops, nullptr); + ASSERT_NE(ops->TextureSubData, nullptr); + + auto& store = Server::ServerStagedTexture(); + const auto res = TestHandle(46, 1); + const auto key = Server::StagedTextureStore::KeyForHandle(res); + MG_Pipe::MGPResourceDesc desc{}; + desc.Resource = res; + desc.Target = static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D); + desc.Width = 4; + desc.Height = 4; + desc.Depth = 1; + desc.Levels = 1; + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc)); + + Vector upper(32, 0x77); + Vector lower(32, 0x33); + MG_Pipe::MGPSubData upperRecord{}; + upperRecord.Res = res; + upperRecord.Target = MG_Pipe::MGPipePackSubDataTarget( + static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), + static_cast(TextureUploadTarget::Texture2D)); + upperRecord.LevelWidth = 4; + upperRecord.LevelHeight = 4; + upperRecord.LevelDepth = 1; + upperRecord.UnionBox = MG_Pipe::MGPBox{0, 2, 0, 4, 2, 1}; + upperRecord.RegionCount = 1; + upperRecord.Blob.Size = upper.size(); + MG_Pipe::MGPSubRegion upperRegion{}; + upperRegion.Y = 2; + upperRegion.W = 4; + upperRegion.H = 2; + upperRegion.D = 1; + upperRegion.SrcRowStride = 16; + upperRegion.SrcSliceStride = 64; + // THE HOOK, not MGPipeApplyResourceSubData: the applier's own dispatch is what routes a + // texture record into Ops_H_TextureSubData, and that is the call site under test. + ops->TextureSubData(res, upperRecord, upper.data(), &upperRegion); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 64u); + EXPECT_EQ(store.LevelCoveredRunCount(key, kTex2DTarget, 0), 1u); + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)); + + MG_Pipe::MGPSubData lowerRecord = upperRecord; + lowerRecord.UnionBox = MG_Pipe::MGPBox{0, 0, 0, 4, 2, 1}; + MG_Pipe::MGPSubRegion lowerRegion = upperRegion; + lowerRegion.Y = 0; + ops->TextureSubData(res, lowerRecord, lower.data(), &lowerRegion); + EXPECT_TRUE(store.IsCovered(key, kTex2DTarget, 0)); + const Uint8* base = store.RequireLevelBytes(key, kTex2DTarget, 0, "unit_hook_runs"); + ASSERT_NE(base, nullptr); + for (SizeT i = 0; i < 32; ++i) EXPECT_EQ(base[i], 0x33) << "byte " << i << " of the lower run"; + for (SizeT i = 32; i < 64; ++i) EXPECT_EQ(base[i], 0x77) << "byte " << i << " of the upper run"; + + MG_Pipe::MGPHandleOnly death{}; + death.Handle = res; + death.Kind = static_cast(MG_Pipe::MGPipeKind::Texture); + MG_Pipe::MGPipeApplyResourceDestroy(death); + EXPECT_FALSE(store.HasShadow(key)); +} + 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