[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.
This commit is contained in:
2026-09-20 19:19:47 -04:00
parent ed919f0d64
commit 9469d48edb
6 changed files with 1012 additions and 108 deletions
+30 -7
View File
@@ -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<SizeT>(record.Blob.Size));
const SizeT runBytes = static_cast<SizeT>(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<Uint>(level),
static_cast<unsigned long long>(region.SrcOffset), region.X,
region.Y, region.Z,
+294 -53
View File
@@ -67,6 +67,13 @@
#include <Config.h>
#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 <MG_Remote/Client/GpuWritePending.h>
#endif
#include <algorithm>
#include <cstdint>
#include <cstring>
@@ -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 <class Fn>
inline Bool MGPipeForEachTextureSlab(const IntVec3& levelSize, const MGPipeLevelPitch& pitch,
Uint64 levelBytes, SizeT chunkBytes, Fn&& body) {
const Uint64 cap = static_cast<Uint64>(chunkBytes);
const Uint64 width = static_cast<Uint64>(std::max<Int>(levelSize.x(), 0));
const Uint64 height = static_cast<Uint64>(std::max<Int>(levelSize.y(), 0));
const Uint64 depth = static_cast<Uint64>(std::max<Int>(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<Int32>(x0), static_cast<Int32>(y0), static_cast<Int32>(z0),
static_cast<Uint32>(w), static_cast<Uint32>(h),
static_cast<Uint32>(d)};
slab.RunOffset = runOffset;
slab.RunBytes = runBytes;
body(slab);
};
if (sliceStride <= cap) {
const Uint64 slabDepth = std::max<Uint64>(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<Uint64>(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<Uint64>(box.Z - piece.Z) * pitch.SliceStride +
static_cast<Uint64>(box.Y - piece.Y) * pitch.RowStride +
static_cast<Uint64>(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<Int64>(a.X, b.X);
const Int64 y0 = std::max<Int64>(a.Y, b.Y);
const Int64 z0 = std::max<Int64>(a.Z, b.Z);
const Int64 x1 = std::min<Int64>(static_cast<Int64>(a.X) + a.W, static_cast<Int64>(b.X) + b.W);
const Int64 y1 = std::min<Int64>(static_cast<Int64>(a.Y) + a.H, static_cast<Int64>(b.Y) + b.H);
const Int64 z1 = std::min<Int64>(static_cast<Int64>(a.Z) + a.D, static_cast<Int64>(b.Z) + b.D);
if (x1 <= x0 || y1 <= y0 || z1 <= z0) return MGPBox{};
return MGPBox{static_cast<Int32>(x0), static_cast<Int32>(y0), static_cast<Int32>(z0),
static_cast<Uint32>(x1 - x0), static_cast<Uint32>(y1 - y0),
static_cast<Uint32>(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<MGPSubRegion>& 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<Uint32>(MGPipeResourceTargetForTextureTarget(texture->GetTarget())),
static_cast<Uint32>(uploadTarget));
m_lastSubData.Level = static_cast<Uint16>(level);
m_lastSubData.LevelWidth = static_cast<Uint32>(levelSize.x());
m_lastSubData.LevelHeight = static_cast<Uint32>(levelSize.y());
m_lastSubData.LevelDepth = static_cast<Uint32>(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<Uint32>(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<Uint64>(reinterpret_cast<std::uintptr_t>(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<Uint32>(MGPipeResourceTargetForTextureTarget(texture->GetTarget())),
static_cast<Uint32>(uploadTarget));
m_lastSubData.Level = static_cast<Uint16>(level);
m_lastSubData.LevelWidth = static_cast<Uint32>(levelSize.x());
m_lastSubData.LevelHeight = static_cast<Uint32>(levelSize.y());
m_lastSubData.LevelDepth = static_cast<Uint32>(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<Uint32>(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<Uint64>(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<Uint64>(levelBytes) > static_cast<Uint64>(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<SizeT>(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<Uint64>(reinterpret_cast<std::uintptr_t>(shadow)) +
slab.RunOffset);
const Bool pieceAccepted = emitRecord(
static_cast<const Uint8*>(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<Uint64>(reinterpret_cast<std::uintptr_t>(shadow)));
accepted = emitRecord(shadow, static_cast<Uint64>(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;
+20 -6
View File
@@ -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<SizeT>(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<SizeT>(upload.Blob.Size));
return;
}
store.AdoptRun(key, target, upload.Level, extent,
MG_Remote::Server::StagedTextureRunImageOffset(upload, regions), bytes,
static_cast<SizeT>(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;
+154 -42
View File
@@ -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 <Includes.h>
#include <MG_Pipe/MGPipeTypes.h>
// 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 <MG_Remote/Server/StagedShadow.h>
#include <MG_Util/Debug/Log.h>
#include <MG_Util/Math/VectorTypes.h>
@@ -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<Uint64>(box.D - 1) * sliceStride +
static_cast<Uint64>(box.H - 1) * rowStride +
static_cast<Uint64>(box.W) * texelBytes;
if (upload.Blob.Size != boxBytes) return 0;
return static_cast<Uint64>(box.Z) * sliceStride + static_cast<Uint64>(box.Y) * rowStride +
static_cast<Uint64>(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<Uint64>(reinterpret_cast<std::uintptr_t>(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<std::mutex> 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<std::mutex> 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<const Uint8*>(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<std::mutex> 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<Uint32>(uploadTarget), static_cast<Uint32>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<Uint32>(uploadTarget) << 16) | static_cast<Uint32>(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<Uint8> 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<Range1D> Covered;
Bool Defined = false;
Bool GpuDirty = false;
};
@@ -331,6 +425,24 @@ namespace MobileGL::MG_Remote::Server {
ska::flat_hash_map<Uint32, LevelShadow> 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<const Uint8*>(bytes);
const SizeT needed = static_cast<SizeT>(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<SizeT>(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;
+225
View File
@@ -74,6 +74,13 @@
#include <MG_State/GLState/TextureState/TextureObjectView.h>
#include <algorithm>
#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 <MG_Remote/Client/GpuWritePending.h>
#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<SizeT>(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<MGPipeTextureSlab> slabs;
const MGPipeLevelPitch pitch =
MGPipeLevelPitchOf(levelSize, static_cast<SizeT>(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<Uint64>(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<Uint64>(slab.Box.D - 1) * pitch.SliceStride +
static_cast<Uint64>(slab.Box.H - 1) * pitch.RowStride +
static_cast<Uint64>(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<MGPipeTextureSlab> 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<MGPipeTextureSlab> 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<SizeT>(64ull * 1024ull));
const std::vector<MGPipeTextureSlab> 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<MGPipeTextureSlab> lineSlabs = cut(line, lineBytes, static_cast<SizeT>(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<MGPipeTextureSlab> 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<SizeT>(volumeBytes));
const MGPipeLevelPitch linePitch = MGPipeLevelPitchOf(line, static_cast<SizeT>(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<Uint64>(region.Z - slab.Box.Z) * volumePitch.SliceStride +
static_cast<Uint64>(region.Y - slab.Box.Y) * volumePitch.RowStride +
static_cast<Uint64>(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<Int32>(region.W),
slab.Box.X + static_cast<Int32>(slab.Box.W))
<< "a clipped rect reaches outside the piece's box";
EXPECT_LE(region.Y + static_cast<Int32>(region.H),
slab.Box.Y + static_cast<Int32>(slab.Box.H));
EXPECT_LE(region.Z + static_cast<Int32>(region.D),
slab.Box.Z + static_cast<Int32>(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<Uint64>(7) * volumePitch.SliceStride +
static_cast<Uint64>(100) * volumePitch.RowStride +
static_cast<Uint64>(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;
@@ -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<Uint8> lower(32, 0x33);
Vector<Uint8> 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<Uint8> 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<Uint32>(kSliceStride);
EXPECT_EQ(Server::StagedTextureRunImageOffset(slab, &region), 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, &region), 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, &region), 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, &region), 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, &region), 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<Uint8> half(32, 0x55);
Vector<Uint8> 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<Uint8>(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<Uint8> upper(32, 0x77);
Vector<Uint8> lower(32, 0x33);
MG_Pipe::MGPSubData upperRecord{};
upperRecord.Res = res;
upperRecord.Target = MG_Pipe::MGPipePackSubDataTarget(
static_cast<Uint32>(MG_Pipe::MGPipeResourceTarget::Tex2D),
static_cast<Uint32>(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<Uint32>(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<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2D);
desc.Width = 4;
desc.Height = 4;
desc.Depth = 1;
desc.Levels = 1;
ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc));
Vector<Uint8> upper(32, 0x77);
Vector<Uint8> lower(32, 0x33);
MG_Pipe::MGPSubData upperRecord{};
upperRecord.Res = res;
upperRecord.Target = MG_Pipe::MGPipePackSubDataTarget(
static_cast<Uint32>(MG_Pipe::MGPipeResourceTarget::Tex2D),
static_cast<Uint32>(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<Uint32>(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