[Merge] (P8): integrate package E - a texture readback the Espryt driver refuses is answered from the split server's staged store, which now follows glCopyImageSubData (FollowCopy, client-box merge, sticky driver-written mark, named decline texture-readback-store-declined); CopyImageStoreReadbackScenario on both backends x every arm plus the extent-scoped StoreRead knob; the dead CopyImageSubData+RENDERBUFFER latch goes (census 79 -> 78)

This commit is contained in:
2026-09-30 02:17:51 -04:00
10 changed files with 1199 additions and 39 deletions
+44 -32
View File
@@ -13402,25 +13402,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// The renderbuffer twin of TextureImpl::SyncTextureObjectToBackend: the same
// find-or-create-then-sync the framebuffer attachment walk does (see SyncAttachmentObject),
// reachable from a path that has a renderbuffer but no framebuffer.
// reachable from a path that has a renderbuffer but no framebuffer. The MONOLITH arm's only:
// under a transport MakeGLESCopyImageEndpoint resolves a renderbuffer endpoint by handle and
// never calls this. (P8-E removed the `CopyImageSubData+RENDERBUFFER` latch that stood here -
// its transport condition could not hold at the only call site - and
// CopyImageStoreReadbackScenario's renderbuffer case runs the handle arm on every transport.)
static SharedPtr<RenderbufferImpl::BackendRenderbufferObject> SyncRenderbufferObjectToBackend(
const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbufferObject) {
if (!renderbufferObject) return nullptr;
#if MOBILEGL_BUILD_DISAGGREGATED
// P5e (fb, CONTRACT-P5E.md §4.4): the scope at this site is DELETED, and with it the
// arm that needed it. The only caller is the glCopyImageSubData endpoint builder, and a
// RENDERBUFFER endpoint is refused on the wire before it is emitted and again at the
// sink (PipeApplier.cpp's ServerUnmigratedVerbFatal("CopyImageSubData+RENDERBUFFER")),
// so under a transport nothing can reach here with one. Saying so by name is better
// than a find-or-MINT against the frontend address that the refusal already proved
// unreachable - if it ever becomes reachable, the name is where to start.
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
MGLOG_F("MGPipe: Fatal{UnmigratedVerb, \"CopyImageSubData+RENDERBUFFER\"} a renderbuffer "
"endpoint reached the backend under an active transport, where both the client "
"emitter and the sink refuse one");
std::abort();
}
#endif
SharedPtr<RenderbufferImpl::BackendRenderbufferObject> backendRenderbufferObject;
if (auto* slot = RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get())) {
backendRenderbufferObject = *slot;
@@ -13541,19 +13530,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto* dstMipmap = MG_State::GLState::AsMipmapTexture(dstEndpoint.Texture.get());
if (!srcMipmap || !dstMipmap) return;
#if MOBILEGL_BUILD_DISAGGREGATED
// P5b package i1's ONE backend edit, and the contract names this site
// (MG_Remote/CONTRACT-P5B.md §2 i1 "the copy-image-shadow-mirror emulation", ruling
// §6.7). Migrating glCopyImageSubData moves the first blocker off the client's
// Fatal{UnmigratedVerb} and onto the Fatal below, on every Espryt copy between two
// textures with CPU shadows - so the ruling is: UNDER A REAL TRANSPORT THE SERVER SKIPS
// THE MIRROR, and the client-side mirror ROADMAP P8 names ("CopyImage 镜像搬到 client")
// stays P8's.
//
// WHAT THE SKIP LOSES IS BOUNDED BY TWO FATALS, which is the whole reason it is allowed
// to be a skip rather than a port: a later glGetTexImage of the destination served from
// the shadow is class C wave 3 (Fatal{UnmigratedVerb, "GetTexImage"}, P9), and a texture
// re-mint under a transport reads the GPU level itself (RequireImageBindableStorageByHandle,
// P9 W2) rather than any shadow. Neither can silently read the un-mirrored shadow.
// UNDER A REAL TRANSPORT THE SERVER HAS NO FRONTEND SHADOW TO MIRROR INTO (P5b i1, ruling
// §6.7), and P8-E settled where the move lives instead - not on the client (ROADMAP P8's
// original row) but in the server's staged store: CopyImageSubData's handle arm makes the
// same texel-block move there (TextureImpl::FollowCopyImageInStagedStore) for EVERY
// format, not only the redundant-encoding ones this mirror serves, and marks the store
// driver-written where it cannot. The store is what answers a split glGetTexImage the
// driver refuses to read back (WireTextureReadback.inc's store route); every other split
// read of the destination reads the GPU level itself (notes/p8/E.md).
//
// BEHIND #if MOBILEGL_BUILD_DISAGGREGATED so the pull build's code does not move (G1),
// and the arm is the TRANSPORT and not the build - build-split runs its unit and
@@ -13640,9 +13624,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
#if MOBILEGL_BUILD_DISAGGREGATED
// The handle arm's endpoint names its texture by handle, and the server has no frontend
// object to note.
if (!dstEndpoint.IsRenderbuffer() && !MG_Pipe::MGPipeHandleIsNull(dstEndpoint.TextureHandle)) {
TextureImpl::NoteDriverSideTextureWriteByHandle(dstEndpoint.TextureHandle);
// object to note. The staged store's half of the note waits for the copy (P8-E, below).
const Bool handleArmDestination =
!dstEndpoint.IsRenderbuffer() && !MG_Pipe::MGPipeHandleIsNull(dstEndpoint.TextureHandle);
if (handleArmDestination) {
TextureImpl::NoteDriverSideTextureWriteByHandle(dstEndpoint.TextureHandle, /*storeFollowsTheWrite=*/true);
}
#endif
if (!MakeGLESCopyImageEndpoint(srcEndpoint, srcTarget, srcX, srcY, srcZ, src) ||
@@ -13650,6 +13636,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_E_ONCE("%s: source or destination image failed to sync; declining the copy", __func__);
return;
}
#if MOBILEGL_BUILD_DISAGGREGATED
// P8-E (notes/p8/E.md): THE SERVER'S STAGED STORE FOLLOWS THE COPY. Both endpoints are synced,
// so every byte the store holds for them is on the driver, and the copy below is a raw
// texel-block move - the store makes the same move, which keeps the destination level
// answerable from here when the driver will not read it back (ReadTextureImageWire's store
// route) and keeps a whole-level replay of it (a re-mint's refused readback, a full-level
// re-upload) from putting the pre-copy texels back. Where the store cannot follow (a
// renderbuffer, a cube or 1D-array endpoint, a source the driver has written, a level whose
// bytes did not all cross) it is marked driver-written instead, so it declines rather than
// answering with stale bytes. Nothing moves to the client (ROADMAP P8's original row).
if (MG_Config::Transport != MG_Config::TransportMode::Monolith && handleArmDestination) {
const Bool followed =
!srcEndpoint.IsRenderbuffer() && !MG_Pipe::MGPipeHandleIsNull(srcEndpoint.TextureHandle) &&
TextureImpl::FollowCopyImageInStagedStore(
srcEndpoint.TextureHandle, static_cast<Uint32>(srcLevel), IntVec3{srcX, srcY, srcZ},
dstEndpoint.TextureHandle, static_cast<Uint32>(dstLevel), IntVec3{dstX, dstY, dstZ},
IntVec3{srcWidth, srcHeight, srcDepth});
if (!followed) TextureImpl::NoteDriverSideTextureWriteByHandle(dstEndpoint.TextureHandle);
}
#endif
// Verbatim: GL already spells a 1D array's extent the way the ES 2D array it maps onto
// wants it (height 1, layers on depth) - see MakeGLESCopyImageEndpoint.
@@ -13723,6 +13729,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ConvertGLEnumToString(dst.target).c_str(),
MG_Util::ConvertGLEnumToString(dstTarget).c_str());
MOBILEGL_ASSERT(false, "glCopyImageSubData failed after frontend validation accepted the request.");
#if MOBILEGL_BUILD_DISAGGREGATED
// P8-E: the store already made the move the driver just refused.
if (MG_Config::Transport != MG_Config::TransportMode::Monolith && handleArmDestination) {
TextureImpl::NoteDriverSideTextureWriteByHandle(dstEndpoint.TextureHandle);
}
#endif
return;
}
// The copy landed on the GPU. For a destination whose readback cannot be bit-exact the
+75 -1
View File
@@ -3189,6 +3189,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Uint16 uploadTarget = MG_Pipe::MGPipeSubDataUploadTargetOf(record.Target);
const IntVec3 extent = MG_Remote::Server::StagedTextureUploadExtent(stored->Desc, record);
const SizeT runBytes = static_cast<SizeT>(record.Blob.Size);
// P8-E (notes/p8/E.md): THE LEVEL HOLDS A COPY THE CLIENT NEVER SAW
// (FollowCopyImageInStagedStore). The run is the client's shadow, stale everywhere it
// did not write, so only the texels it names land - its regions, or its union box
// when it carries none (D-D3's "the box is the whole story"). A run the store cannot
// place that way is adopted as ever, and the store stops standing for the texture.
if (store.LevelHoldsFollowedCopy(key, uploadTarget, record.Level) &&
(record.RegionCount == 0 || regions != nullptr)) {
Vector<MG_Remote::Server::StagedTextureStore::ClientTexelBox> boxes;
if (record.RegionCount == 0) {
const auto& box = record.UnionBox;
boxes.push_back({box.X, box.Y, box.Z, box.W, box.H, box.D});
} else {
for (Uint32 i = 0; i < record.RegionCount; ++i) {
const auto& region = regions[i];
boxes.push_back({region.X, region.Y, region.Z, region.W, region.H, region.D});
}
}
const Uint64 imageOffset = record.RegionCount == 0
? 0 : MG_Remote::Server::StagedTextureRunImageOffset(record, regions);
if (store.AdoptClientBoxes(key, uploadTarget, record.Level, extent, imageOffset, bytes, runBytes,
boxes.data(), boxes.size())) {
return;
}
store.MarkDriverWritten(key);
}
// 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.
@@ -10963,7 +10988,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
return slot->get();
}
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle res) {
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle res, Bool storeFollowsTheWrite) {
// The storage owner's handle, one hop per view like the frontend walk above (the
// client composes a view of a view onto its root, so one hop is the real shape).
MG_Pipe::MGPipeHandle storage = res;
@@ -10973,6 +10998,55 @@ namespace MobileGL::MG_Backend::DirectGLES {
storage = record->Desc.ViewOf;
}
if (auto* twin = ResolveTextureTwin(storage)) twin->NoteDriverSideWrite();
#if MOBILEGL_BUILD_DISAGGREGATED
// P8-E: the staged store stops standing for this texture's content (a no-op on a
// monolith arm, where the store does not copy).
if (!storeFollowsTheWrite && !MG_Pipe::MGPipeHandleIsNull(storage)) {
MG_Remote::Server::ServerStagedTexture().MarkDriverWritten(
MG_Remote::Server::StagedTextureStore::KeyForHandle(storage));
}
#else
(void)storeFollowsTheWrite;
#endif
}
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
Bool FollowCopyImageInStagedStore(MG_Pipe::MGPipeHandle source, Uint32 sourceLevel, const IntVec3& sourceOrigin,
MG_Pipe::MGPipeHandle destination, Uint32 destinationLevel,
const IntVec3& destinationOrigin, const IntVec3& size) {
auto& store = MG_Remote::Server::ServerStagedTexture();
if (!store.CopiesIntoServerStorage()) return false;
// The store's level key for an endpoint copy-image addresses as ONE image with its
// layers on z: exactly one upload target (not a cube map, whose faces are six store
// levels) and not a 1D array (the store keeps its layers as rows). A view's own levels
// are byteless, so FollowCopy answers false for it by itself.
const auto storeTarget = [](MG_Pipe::MGPipeHandle handle, Uint16& target) {
const auto* record = PipeTextureRecordForHandle(handle);
if (record == nullptr ||
record->Desc.Target == static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex1DArray)) {
return false;
}
const auto& targets = BufferImpl::StagedUploadTargetsForPipeTarget(record->Desc.Target);
if (targets.size() != 1) return false;
target = static_cast<Uint16>(targets[0]);
return true;
};
Uint16 sourceTarget = 0, destinationTarget = 0;
if (!storeTarget(source, sourceTarget) || !storeTarget(destination, destinationTarget)) return false;
// ONE FORMAT BOTH ENDS. Copy-image moves the GL texel bits; the store holds each
// format's canonical shadow, which for most formats IS those bits and for some is not
// (the 16-bit packed formats are kept as UNorm8, RGB10 / RGB12 as UNorm16). Between two
// levels of one format the canonical move and the GL move agree whatever the format;
// across two compatible formats they need not, so that copy is not followed.
if (PipeTextureRecordForHandle(source)->Desc.InternalFormat !=
PipeTextureRecordForHandle(destination)->Desc.InternalFormat) {
return false;
}
return store.FollowCopy(MG_Remote::Server::StagedTextureStore::KeyForHandle(source), sourceTarget,
static_cast<Uint16>(sourceLevel), sourceOrigin,
MG_Remote::Server::StagedTextureStore::KeyForHandle(destination),
destinationTarget, static_cast<Uint16>(destinationLevel), destinationOrigin,
size);
}
#endif
} // namespace TextureImpl
+18 -1
View File
@@ -2345,7 +2345,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
// VerbCopyTexDst, a copy-image endpoint): the twin the handle's record names is marked, or
// its storage owner's (Desc.ViewOf) for a view. Nothing on the apply thread reads a
// frontend object for it.
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle texture);
//
// P8-E: under a transport the same storage owner's staged store is marked driver-written
// too (StagedTextureStore::MarkDriverWritten), so a refused readback does not answer from
// bytes the driver has since overwritten - unless `storeFollowsTheWrite`, which only
// glCopyImageSubData passes: it tries FollowCopyImageInStagedStore first and marks the store
// itself when that fails.
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle texture, Bool storeFollowsTheWrite = false);
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
// P8-E (notes/p8/E.md): make glCopyImageSubData's texel-block move in the server's staged
// store too (StagedTextureStore::FollowCopy), so the destination level's bytes stay its
// content. Box in each image's own texel grid. False when the store cannot follow: a cube
// map or 1D array endpoint (copy-image puts faces / layers on z, the store does not), a view,
// two different internal formats, a level whose bytes did not all cross, a source the
// driver has written, or a monolith arm.
Bool FollowCopyImageInStagedStore(MG_Pipe::MGPipeHandle source, Uint32 sourceLevel, const IntVec3& sourceOrigin,
MG_Pipe::MGPipeHandle destination, Uint32 destinationLevel,
const IntVec3& destinationOrigin, const IntVec3& size);
#endif
// Brings every texture the next draw reads - the touched units' bindings and the draw
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
@@ -320,6 +320,105 @@
return width == extent.x() && height == extent.y() && (!layered || depth == extent.z());
}
// P8-E (notes/p8/E.md). MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT=<w>x<h>, READ BY THE SERVER:
// a colour level of exactly that extent is treated as one the driver refused to attach - what an
// Adreno does for RGB16 and the 16-bit SNORM formats, and what no host driver can be made to do.
// Scoped by the extent so the tcp lane's shared supervisor can carry it for the whole lane: only
// the scenario that builds that extent (CopyImageStoreReadbackScenario) ever meets it.
static Bool WireTextureReadbackRefusedForTesting(const IntVec3& extent) {
static const IntVec3 s_refused = [] {
const char* value = std::getenv("MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT");
if (value == nullptr) return IntVec3{0, 0, 0};
char* end = nullptr;
const long width = std::strtol(value, &end, 10);
if (end == value || *end != 'x') return IntVec3{0, 0, 0};
const long height = std::strtol(end + 1, nullptr, 10);
if (width <= 0 || height <= 0) return IntVec3{0, 0, 0};
return IntVec3{static_cast<Int>(width), static_cast<Int>(height), 1};
}();
return s_refused.x() > 0 && extent == s_refused;
}
// P8-E: THE STORE ROUTE, for a level the driver refused to read back. The server's staged store
// is the only CPU copy of the level on this side, and it answers only while its bytes ARE the
// level: every byte crossed (IsCovered), at the extent asked for, and the texture was never
// written by the driver except by copies the store followed (!IsDriverWritten - see
// StagedTextureStore's P8-E block and FollowCopyImageInStagedStore). The conversion is
// GetTexImageViaShadowConversion's, into tight rows: a packed format read with its own type
// takes the stored words verbatim, everything else decodes to wide RGBA and converts row by row.
// Anything else DECLINES BY NAME: the reply is an error, and the client's
// Fatal{ReplyError, "ReadPixels"} ends the session instead of handing back bytes the driver has
// since overwritten.
static Bool ReadWireLevelFromStagedStore(MG_Pipe::MGPipeHandle res, TextureInternalFormat logicalFormat,
TextureUploadTarget uploadTarget, GLint level, const IntVec3& extent,
GLenum format, GLenum type, Vector<Uint8>& bytes) {
auto& store = MG_Remote::Server::ServerStagedTexture();
const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res);
const auto target = static_cast<Uint16>(uploadTarget);
const auto mip = static_cast<Uint16>(level);
ReadbackChannelMapping mapping{};
const SizeT pixelBytes = GetReadbackChannelMapping(format, mapping) ? GetReadbackDstPixelSize(mapping, type) : 0;
const SizeT texels = static_cast<SizeT>(std::max(extent.x(), 0)) * static_cast<SizeT>(std::max(extent.y(), 0)) *
static_cast<SizeT>(std::max(extent.z(), 0));
const char* declined = nullptr;
if (!store.CopiesIntoServerStorage()) {
declined = "this arm keeps no staged store";
} else if (store.IsDriverWritten(key)) {
declined = "the driver has written the texture since its bytes crossed";
} else if (!store.IsCovered(key, target, mip)) {
declined = "not every byte of the level crossed";
} else if (store.LevelExtentOrUndefined(key, target, mip) != extent) {
declined = "the staged level is not the extent asked for";
} else if (pixelBytes == 0 || texels == 0) {
declined = "the format/type pair has no readback mapping";
}
Vector<Uint8> out;
if (declined == nullptr) {
const Uint8* staged = store.RequireLevelBytes(key, target, mip, "texture-readback-from-store");
if (MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer(
logicalFormat, MG_Util::ConvertGLEnumToTextureInputFormat(format),
MG_Util::ConvertGLEnumToTexturePixelDataType(type))) {
if (store.LevelByteSize(key, target, mip) != texels * pixelBytes) {
declined = "the staged words are not the client's word size";
} else {
out.assign(staged, staged + texels * pixelBytes);
}
} else {
Vector<Uint8> wide;
Bool isInteger = false;
Bool isSigned = false;
if (!MG_Util::PixelStoreProcessor::DecodeShadowDataToWideRGBA(logicalFormat, staged, texels, wide,
isInteger, isSigned)) {
declined = "the staged format has no wide decoding";
} else if (mapping.isInteger != isInteger) {
declined = "an integer read of a normalized level, or the reverse";
} else {
const GLenum wideType = isInteger ? (isSigned ? GL_INT : GL_UNSIGNED_INT) : GL_FLOAT;
const SizeT rowTexels = static_cast<SizeT>(extent.x());
const SizeT rows = texels / rowTexels;
const SizeT wideRowBytes = rowTexels * 4 * GetReadbackComponentSize(wideType);
out.resize(texels * pixelBytes);
for (SizeT row = 0; row < rows; ++row) {
ReadbackImpl::ConvertWideReadbackRow(wide.data() + row * wideRowBytes,
out.data() + row * rowTexels * pixelBytes, rowTexels,
wideType, mapping, type);
}
}
}
}
if (declined != nullptr) {
MGLOG_E_ONCE("MGPipe: texture {%u,%u} level %d: the driver refused the readback and the staged store "
"cannot answer it (%s) - DECLINED (texture-readback-store-declined)",
res.Slot, res.Gen, level, declined);
return false;
}
MGLOG_W_ONCE("MGPipe: texture {%u,%u} level %d: the driver refused the readback (internal format %u); "
"answered from the server's staged store (texture-readback-from-store)",
res.Slot, res.Gen, level, static_cast<unsigned>(logicalFormat));
bytes = Move(out);
return true;
}
Bool ReadTextureImageWire(const MG_Pipe::MGPReadbackInfo& info, Vector<Uint8>& bytes) {
const auto* record = PipeTextureRecordForHandle(info.Res);
if (!record) return false;
@@ -333,15 +432,29 @@
const Bool wholeCube = info.Target == GL_TEXTURE_CUBE_MAP;
const IntVec3 extent{static_cast<Int>(info.Box.W), static_cast<Int>(info.Box.H),
wholeCube ? 1 : static_cast<Int>(info.Box.D)};
const auto logicalFormat = static_cast<TextureInternalFormat>(record->Desc.InternalFormat);
// The R32UI word leg never goes through a framebuffer, so the knob leaves it alone (it is
// ReadTextureLevelTight's first choice for exactly these reads).
const Bool packedWordRead =
!twin->RequiresImageBindableStorage() &&
MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer(
logicalFormat, MG_Util::ConvertGLEnumToTextureInputFormat(info.Format),
MG_Util::ConvertGLEnumToTexturePixelDataType(info.Type)) &&
MG_Util::GetInputBytesPerPixel(MG_Util::ConvertGLEnumToTextureInputFormat(info.Format),
MG_Util::ConvertGLEnumToTexturePixelDataType(info.Type)) == sizeof(Uint32);
const Bool refusedForTesting = !packedWordRead && WireTextureReadbackRefusedForTesting(extent);
Vector<Uint8> result;
for (Int face = 0; face < (wholeCube ? 6 : 1); ++face) {
const auto uploadTarget = wholeCube
? static_cast<TextureUploadTarget>(static_cast<Int>(TextureUploadTarget::CubeMapPositiveX) + face)
: MG_Util::ConvertGLEnumToTextureUploadTarget(info.Target);
Vector<Uint8> slice;
if (!ReadTextureLevelTight(twin->GetBackendTextureId(), target, uploadTarget,
static_cast<TextureInternalFormat>(record->Desc.InternalFormat), info.Level, extent,
twin->RequiresImageBindableStorage(), info.Format, info.Type, slice)) return false;
if (refusedForTesting ||
!ReadTextureLevelTight(twin->GetBackendTextureId(), target, uploadTarget, logicalFormat, info.Level,
extent, twin->RequiresImageBindableStorage(), info.Format, info.Type, slice)) {
if (!ReadWireLevelFromStagedStore(info.Res, logicalFormat, uploadTarget, info.Level, extent,
info.Format, info.Type, slice)) return false;
}
result.insert(result.end(), slice.begin(), slice.end());
}
bytes = Move(result);
@@ -140,6 +140,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
Scenarios/CopyImagePacked16Scenario.cpp
Scenarios/CopyImageStoreReadbackScenario.cpp
Scenarios/TextureViewScenario.cpp
# P3b/P4b wave 2-D package D2: the verification half of the owner-keyed emission cursor.
Scenarios/TextureViewAliasScenario.cpp
@@ -3248,6 +3249,72 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
# case that produced no record RED for having nothing to say - which is the rule working, not
# a defect. They stay unregistered for that reason and are listed in the census note.
# P8-E (docs/Disaggregated/notes/p8/E.md): glCopyImageSubData into a level the driver will not
# read back, then glGetTexImage. The knob-free cases on Espryt's three arms; Magma's gated arms
# are the `DirectVulkan.<arm>.CopyStore.` entries just below; both monolith entries come from
# the whole-binary discovery.
mgl_itest_register_split_arms("CopyImageStoreReadbackScenario.*" "")
# The same cases with MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT pinned: the Espryt server
# treats a colour level of exactly that extent as one the driver refused to attach (what an
# Adreno does for RGB16 and 16-bit SNORM), so its staged store has to answer. READ BY THE
# SERVER, and it reaches all three arms: inproc sees this entry's environment, spawn copies it
# into the child, and the tcp supervisor carries it in its own fixture ENVIRONMENT (appended to
# TcpServer.Start here). Lane-wide there is harmless because the knob names an EXTENT and the
# scenario builds that extent only when it finds the knob in its own environment - every other
# tcp entry, this scenario's knob-free ones included, reads levels of other sizes. The
# renderbuffer case is not here: a renderbuffer's texels were never uploaded, so no store can
# answer for them, and a declined readback ends the session by design.
set(MGL_P8E_REFUSE_EXTENT "13x7")
set_property(TEST TcpServer.Start APPEND PROPERTY ENVIRONMENT
"MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT=${MGL_P8E_REFUSE_EXTENT}")
foreach(mglItestArm IN ITEMS Split Spawn Tcp)
string(TOUPPER "${mglItestArm}" mglItestArmUpper)
string(TOLOWER "${mglItestArm}" mglItestArmLower)
set(mglItestStoreReadProperties)
if(mglItestArm STREQUAL "Tcp")
set(mglItestStoreReadProperties FIXTURES_REQUIRED mobilegl-tcp RESOURCE_LOCK mobilegl-tcp)
endif()
foreach(storeReadCase AnUploadedLevelReadsBackExactly
ACopiedWindowReadsBackOverTheUpload
ACopiedWindowReadsBackFromAnImmutableLevel
APartialUploadAfterTheCopyKeepsTheCopiedWindow)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.${mglItestArm}.StoreRead."
TEST_LIST "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}"
TEST_FILTER "CopyImageStoreReadbackScenario.${storeReadCase}"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS "integration-gpu\\;integration-${mglItestArmLower}"
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_${mglItestArmUpper}_ENVIRONMENT}\;MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT=${MGL_P8E_REFUSE_EXTENT}"
${mglItestStoreReadProperties}
)
# Into the accumulated list, so SplitLogPaths gives each entry a private log: the
# store-route assertion reads the server's half of it.
list(APPEND MGL_SPLIT_ARM_TEST_LISTS "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}")
math(EXPR MGL_SPLIT_ARM_INDEX "${MGL_SPLIT_ARM_INDEX} + 1")
endforeach()
# Magma, the control backend, GATED on its three arms: it reads the VkImage back whatever
# the format, so it has no store route and no knob - only the copy and the read.
set(mglItestMagmaCopyStoreProperties)
if(mglItestArm STREQUAL "Tcp")
set(mglItestMagmaCopyStoreProperties FIXTURES_REQUIRED mobilegl-tcp RESOURCE_LOCK mobilegl-tcp)
endif()
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.${mglItestArm}.CopyStore."
TEST_LIST "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}"
TEST_FILTER "CopyImageStoreReadbackScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS "integration-magma-${mglItestArmLower}"
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_${mglItestArmUpper}_ENVIRONMENT}"
${mglItestMagmaCopyStoreProperties}
)
list(APPEND MGL_SPLIT_ARM_TEST_LISTS "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}")
math(EXPR MGL_SPLIT_ARM_INDEX "${MGL_SPLIT_ARM_INDEX} + 1")
endforeach()
# The split half of the counting pair. MGITEST_PERSISTENT_MAP_ARM=emulated is R-6: under split
# the adopt tier is pinned at T2, the resource owner declines every acquisition and the client
# pushes the mapping's dirty blocks - so pmap must be non-zero and mpr must be the monolith
@@ -0,0 +1,373 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - glCopyImageSubData INTO A LEVEL THE DRIVER WILL NOT READ BACK (P8-E,
// docs/Disaggregated/notes/p8/E.md).
//
// The formats are the ones an ES driver keeps as texture-only storage: GL_RGB16 (EXT_texture_norm16
// makes the three-channel spelling sampleable, never colour-renderable) and the 16-bit SNORM family
// (renderable only with EXT_render_snorm, which Adreno does not have). Their glGetTexImage cannot go
// through a framebuffer on such a driver, so something other than the GPU image has to answer it:
// the CPU shadow on the monolith arm, the server's staged store on a split one. A copy writes the
// GPU image and nothing else, which is exactly the write such a byte store never sees.
//
// GL_RGB9_E5 is the positive control: a 32-bit packed format, read through the scratch GL_R32UI
// image whoever wrote the level, so it must stay green under every configuration below.
//
// ARMS. Both backends on the monolith arm and on Split / Spawn / Tcp, every case, with no knob:
// on llvmpipe every one of these formats is attachable, so these runs pin the GPU route and the
// copy itself. Magma is the control backend - it reads the VkImage back whatever the format, so it
// has no store route to exercise.
//
// THE KNOB. MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT=<w>x<h> is read BY THE ESPRYT SERVER
// (WireTextureReadback.inc, ReadTextureImageWire): a colour level of exactly that extent is
// treated as a level the driver refused to attach, which is what an Adreno does for every one of
// these formats and what llvmpipe can be made to do no other way. The `DirectGLES.<arm>.StoreRead.`
// entries set it, and this scenario then builds its textures at THAT extent instead of the default
// one - so the knob is scoped by the extent, and the tcp lane's shared supervisor can carry it
// (the TcpServer.Start fixture's ENVIRONMENT) without touching any other entry, this scenario's
// own knob-free tcp entries included. Under the knob every read must still be right, and the
// server log must name the store route - otherwise the entry would be pinning the GPU route a
// second time under a name that claims otherwise.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeStatsWindow.h"
#include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitRuntimePeek.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kRefuseExtentKnob = "MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT";
// What the Espryt server logs (once per process) when it answers a read from its store.
constexpr const char* kStoreReadMarker = "texture-readback-from-store";
// The knob-free extent. Deliberately not the extent the knob entries use, so the tcp
// supervisor - which carries the knob for the whole lane - leaves these textures alone.
constexpr int kDefaultWidth = 12;
constexpr int kDefaultHeight = 6;
struct CopyFormat {
GLenum internalFormat;
GLenum format;
GLenum type;
int components; // 16-bit components per texel; 0 for the one 32-bit packed format
bool isSigned;
const char* name;
};
const CopyFormat kFormats[] = {
{GL_RGB16, GL_RGB, GL_UNSIGNED_SHORT, 3, false, "rgb16"},
{GL_RGBA16_SNORM, GL_RGBA, GL_SHORT, 4, true, "rgba16_snorm"},
{GL_RGB16_SNORM, GL_RGB, GL_SHORT, 3, true, "rgb16_snorm"},
{GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, 4, false, "rgba16"},
{GL_RGB9_E5, GL_RGB, GL_UNSIGNED_INT_5_9_9_9_REV, 0, false, "rgb9_e5"},
};
int TexelBytes(const CopyFormat& format) { return format.components == 0 ? 4 : format.components * 2; }
// Every word differs from its neighbours and from the other patterns, so a mis-addressed
// row, a swapped endpoint or a copy that never happened cannot cancel out. SNORM never
// gets -32768: it and -32767 are the same value (-1.0), and a read that goes through a
// normalized float is entitled to answer either.
std::vector<std::uint8_t> MakeTexels(const CopyFormat& format, int width, int height, std::uint32_t seed) {
const std::size_t texels = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
std::vector<std::uint8_t> bytes(texels * static_cast<std::size_t>(TexelBytes(format)));
if (format.components == 0) {
for (std::size_t i = 0; i < texels; ++i) {
std::uint32_t word = (static_cast<std::uint32_t>(i) + 1u) * 2654435761u ^ seed * 40503u;
word ^= word >> 13;
std::memcpy(bytes.data() + i * 4, &word, 4);
}
return bytes;
}
const std::size_t words = texels * static_cast<std::size_t>(format.components);
for (std::size_t i = 0; i < words; ++i) {
std::uint32_t mix = (static_cast<std::uint32_t>(i) + 1u) * 2654435761u ^ seed * 40503u;
mix ^= mix >> 15;
std::uint16_t value = static_cast<std::uint16_t>(mix >> 8);
if (format.isSigned && value == 0x8000u) value = 0x8001u;
std::memcpy(bytes.data() + i * 2, &value, 2);
}
return bytes;
}
// `image` with the w x h block at (dx, dy) replaced by the block of `source` at (sx, sy).
std::vector<std::uint8_t> WithBlock(std::vector<std::uint8_t> image, int width, const std::vector<std::uint8_t>& source,
int sourceWidth, int sx, int sy, int dx, int dy, int w, int h,
int texelBytes) {
for (int row = 0; row < h; ++row) {
std::memcpy(image.data() + (static_cast<std::size_t>(dy + row) * width + dx) * texelBytes,
source.data() + (static_cast<std::size_t>(sy + row) * sourceWidth + sx) * texelBytes,
static_cast<std::size_t>(w) * texelBytes);
}
return image;
}
class CopyImageStoreReadbackScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_wire = SplitRuntimeSkipReason().empty();
m_espryt = Gl().BackendName() == "DirectGLES";
if (const char* knob = std::getenv(kRefuseExtentKnob)) {
int width = 0, height = 0;
ASSERT_EQ(std::sscanf(knob, "%dx%d", &width, &height), 2)
<< kRefuseExtentKnob << "=" << knob << " is not <w>x<h>";
ASSERT_GE(width, 12) << "the copy windows below need at least a 12x6 level";
ASSERT_GE(height, 6) << "the copy windows below need at least a 12x6 level";
m_width = width;
m_height = height;
m_forced = true;
}
// 16-bit RGB rows are 6 bytes a texel: the default 4-byte alignment would pad every
// row of an odd-width level and shear both the upload and the read.
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
ASSERT_EQ(FirstGLError(), 0u);
}
void TearDown() override {
if (!Ready()) return;
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glBindTexture(GL_TEXTURE_2D, 0);
if (!m_textures.empty()) {
glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
}
if (m_renderbuffer != 0) glDeleteRenderbuffers(1, &m_renderbuffer);
if (m_fbo != 0) {
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &m_fbo);
}
FirstGLError();
}
GLuint MakeTexture(const CopyFormat& format, const std::vector<std::uint8_t>& texels, bool immutable) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
if (immutable) {
glTexStorage2D(GL_TEXTURE_2D, 1, format.internalFormat, m_width, m_height);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, m_width, m_height, format.format, format.type,
texels.data());
} else {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(format.internalFormat), m_width, m_height, 0,
format.format, format.type, texels.data());
}
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
std::vector<std::uint8_t> ReadTexImage(GLuint texture, const CopyFormat& format) {
std::vector<std::uint8_t> bytes(
static_cast<std::size_t>(m_width) * m_height * static_cast<std::size_t>(TexelBytes(format)), 0xA5);
glBindTexture(GL_TEXTURE_2D, texture);
glGetTexImage(GL_TEXTURE_2D, 0, format.format, format.type, bytes.data());
glBindTexture(GL_TEXTURE_2D, 0);
return bytes;
}
// The first texel that differs names the defect (which half of the copy, which word);
// the rest would only bury it.
void ExpectImage(const std::vector<std::uint8_t>& got, const std::vector<std::uint8_t>& want,
const CopyFormat& format, const std::string& what) {
ASSERT_EQ(got.size(), want.size());
const int texelBytes = TexelBytes(format);
for (std::size_t at = 0; at < got.size(); ++at) {
if (got[at] == want[at]) continue;
const std::size_t texel = at / static_cast<std::size_t>(texelBytes);
const int x = static_cast<int>(texel % static_cast<std::size_t>(m_width));
const int y = static_cast<int>(texel / static_cast<std::size_t>(m_width));
std::string gotHex, wantHex;
char buffer[4];
for (int b = 0; b < texelBytes; ++b) {
std::snprintf(buffer, sizeof(buffer), "%02x", got[texel * texelBytes + b]);
gotHex += buffer;
std::snprintf(buffer, sizeof(buffer), "%02x", want[texel * texelBytes + b]);
wantHex += buffer;
}
ADD_FAILURE() << what << " (" << format.name << ", " << Gl().BackendName() << ", "
<< (m_wire ? "split" : "monolith") << (m_forced ? ", store read forced" : "")
<< "): texel (" << x << ", " << y << ") reads " << gotHex << ", want " << wantHex;
return;
}
}
// Under the knob the Espryt server must have answered from its store at least once;
// otherwise the entry proves nothing about that route.
void ExpectStoreReadNamedSince(const PipeStatsWindow::LogMark& mark) {
if (!m_forced || !m_espryt || !m_wire) return;
const std::string log = PipeStatsWindow::ReadServerLogSince(mark);
EXPECT_NE(log.find(kStoreReadMarker), std::string::npos)
<< kRefuseExtentKnob << " is set but the server log carries no '" << kStoreReadMarker
<< "' line: the knob did not reach the server, or no read took the store route";
}
// The copy window: 5x3 texels from (1, 1) of the source to (6, 2) of the destination,
// inside a 12x6 level and away from the partial upload's (0, 0) 3x2 corner.
static constexpr int kSrcX = 1, kSrcY = 1, kDstX = 6, kDstY = 2, kCopyW = 5, kCopyH = 3;
void CopyWindow(GLuint source, GLuint destination) {
glCopyImageSubData(source, GL_TEXTURE_2D, 0, kSrcX, kSrcY, 0, destination, GL_TEXTURE_2D, 0, kDstX,
kDstY, 0, kCopyW, kCopyH, 1);
}
bool m_wire = false;
bool m_espryt = false;
bool m_forced = false;
int m_width = kDefaultWidth;
int m_height = kDefaultHeight;
std::vector<GLuint> m_textures;
GLuint m_renderbuffer = 0;
GLuint m_fbo = 0;
};
// The control: an uploaded level reads back as uploaded.
TEST_F(CopyImageStoreReadbackScenario, AnUploadedLevelReadsBackExactly) {
if (!Ready()) return;
const auto mark = PipeStatsWindow::MarkLaneLog();
for (const CopyFormat& format : kFormats) {
const auto texels = MakeTexels(format, m_width, m_height, 11);
const GLuint texture = MakeTexture(format, texels, /*immutable=*/false);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": setup raised a GL error";
ExpectImage(ReadTexImage(texture, format), texels, format, "the uploaded level");
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glGetTexImage raised a GL error";
}
ExpectStoreReadNamedSince(mark);
}
// THE CASE THE PACKAGE IS ABOUT: the copy lands in the destination's GPU image only, and the
// read has to see it - whatever answers the read.
TEST_F(CopyImageStoreReadbackScenario, ACopiedWindowReadsBackOverTheUpload) {
if (!Ready()) return;
const auto mark = PipeStatsWindow::MarkLaneLog();
for (const CopyFormat& format : kFormats) {
const auto sourceTexels = MakeTexels(format, m_width, m_height, 23);
const auto destinationTexels = MakeTexels(format, m_width, m_height, 37);
const GLuint source = MakeTexture(format, sourceTexels, /*immutable=*/false);
const GLuint destination = MakeTexture(format, destinationTexels, /*immutable=*/false);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": setup raised a GL error";
CopyWindow(source, destination);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glCopyImageSubData raised a GL error";
const auto want = WithBlock(destinationTexels, m_width, sourceTexels, m_width, kSrcX, kSrcY, kDstX,
kDstY, kCopyW, kCopyH, TexelBytes(format));
ExpectImage(ReadTexImage(destination, format), want, format, "the copy destination");
ExpectImage(ReadTexImage(source, format), sourceTexels, format, "the copy source");
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glGetTexImage raised a GL error";
}
ExpectStoreReadNamedSince(mark);
}
// The same into immutable storage (glTexStorage2D, then the whole level uploaded).
TEST_F(CopyImageStoreReadbackScenario, ACopiedWindowReadsBackFromAnImmutableLevel) {
if (!Ready()) return;
const auto mark = PipeStatsWindow::MarkLaneLog();
for (const CopyFormat& format : kFormats) {
const auto sourceTexels = MakeTexels(format, m_width, m_height, 41);
const auto destinationTexels = MakeTexels(format, m_width, m_height, 43);
const GLuint source = MakeTexture(format, sourceTexels, /*immutable=*/true);
const GLuint destination = MakeTexture(format, destinationTexels, /*immutable=*/true);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": setup raised a GL error";
CopyWindow(source, destination);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glCopyImageSubData raised a GL error";
const auto want = WithBlock(destinationTexels, m_width, sourceTexels, m_width, kSrcX, kSrcY, kDstX,
kDstY, kCopyW, kCopyH, TexelBytes(format));
ExpectImage(ReadTexImage(destination, format), want, format, "the immutable copy destination");
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glGetTexImage raised a GL error";
}
ExpectStoreReadNamedSince(mark);
}
// A copy, then an ordinary partial upload into the same level elsewhere. The upload is the
// client's; the copied window is not, and a byte store that took the upload as "the level"
// would lose it again.
TEST_F(CopyImageStoreReadbackScenario, APartialUploadAfterTheCopyKeepsTheCopiedWindow) {
if (!Ready()) return;
const auto mark = PipeStatsWindow::MarkLaneLog();
for (const CopyFormat& format : kFormats) {
const auto sourceTexels = MakeTexels(format, m_width, m_height, 53);
const auto destinationTexels = MakeTexels(format, m_width, m_height, 59);
const auto patch = MakeTexels(format, 3, 2, 61);
const GLuint source = MakeTexture(format, sourceTexels, /*immutable=*/false);
const GLuint destination = MakeTexture(format, destinationTexels, /*immutable=*/false);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": setup raised a GL error";
CopyWindow(source, destination);
glBindTexture(GL_TEXTURE_2D, destination);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 3, 2, format.format, format.type, patch.data());
glBindTexture(GL_TEXTURE_2D, 0);
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": the copy or the upload raised a GL error";
auto want = WithBlock(destinationTexels, m_width, sourceTexels, m_width, kSrcX, kSrcY, kDstX, kDstY,
kCopyW, kCopyH, TexelBytes(format));
want = WithBlock(want, m_width, patch, 3, 0, 0, 0, 0, 3, 2, TexelBytes(format));
ExpectImage(ReadTexImage(destination, format), want, format, "the copy destination after the upload");
ASSERT_EQ(FirstGLError(), 0u) << format.name << ": glGetTexImage raised a GL error";
}
ExpectStoreReadNamedSince(mark);
}
// A RENDERBUFFER endpoint. The split arm resolves it by handle (DirectGLES.cpp,
// MakeGLESCopyImageEndpoint), which is what retired the "CopyImageSubData+RENDERBUFFER"
// latch; this is the case that says so on every arm. It has no store to answer from - a
// renderbuffer's texels were never uploaded - so the knob entries do not run it.
TEST_F(CopyImageStoreReadbackScenario, ARenderbufferSourceLandsInTheTexture) {
if (!Ready()) return;
const CopyFormat format{GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, 4, false, "rgba16"};
const auto destinationTexels = MakeTexels(format, m_width, m_height, 71);
const GLuint destination = MakeTexture(format, destinationTexels, /*immutable=*/false);
glGenRenderbuffers(1, &m_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA16, m_width, m_height);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "the RGBA16 renderbuffer is not attachable";
// k / 65535 for four 16-bit words: exact through a float clear and a UNORM16 store.
const std::uint16_t words[4] = {0x1234, 0x5678, 0x9abc, 0xffff};
const GLfloat color[4] = {words[0] / 65535.0f, words[1] / 65535.0f, words[2] / 65535.0f,
words[3] / 65535.0f};
glDisable(GL_SCISSOR_TEST);
glClearBufferfv(GL_COLOR, 0, color);
BindDefaultFramebuffer();
ASSERT_EQ(FirstGLError(), 0u) << "setup raised a GL error";
glCopyImageSubData(m_renderbuffer, GL_RENDERBUFFER, 0, kSrcX, kSrcY, 0, destination, GL_TEXTURE_2D, 0,
kDstX, kDstY, 0, kCopyW, kCopyH, 1);
ASSERT_EQ(FirstGLError(), 0u) << "glCopyImageSubData from the renderbuffer raised a GL error";
std::vector<std::uint8_t> cleared(static_cast<std::size_t>(m_width) * m_height * 8);
for (std::size_t texel = 0; texel < cleared.size() / 8; ++texel) {
std::memcpy(cleared.data() + texel * 8, words, 8);
}
const auto want = WithBlock(destinationTexels, m_width, cleared, m_width, kSrcX, kSrcY, kDstX, kDstY,
kCopyW, kCopyH, 8);
ExpectImage(ReadTexImage(destination, format), want, format, "the texture a renderbuffer was copied into");
ASSERT_EQ(FirstGLError(), 0u) << "glGetTexImage raised a GL error";
}
} // namespace
} // namespace MGITest
@@ -350,6 +350,12 @@ namespace MobileGL::MG_Remote::Server {
shadow.Covered.clear();
}
#endif
if (shadow.HoldsFollowedCopy) {
// P8-E: a run landed whole over a followed copy (AdoptClientBoxes is the spelling
// that keeps it), so the bytes no longer hold what the driver does.
shadow.HoldsFollowedCopy = false;
m_driverWritten[key] = true;
}
shadow.GpuDirty = false;
return CopyRunInto(shadow, key, uploadTarget, level, imageOffset, bytes, byteSize);
}
@@ -367,6 +373,8 @@ namespace MobileGL::MG_Remote::Server {
LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)];
RequireDeclaredLevel(shadow, key, uploadTarget, level, extent);
shadow.GpuDirty = false;
// P8-E: the caller states these bytes ARE the level, which a followed copy is part of.
shadow.HoldsFollowedCopy = false;
shadow.Bytes.clear();
shadow.Covered.clear();
return CopyRunInto(shadow, key, uploadTarget, level, 0, bytes, byteSize);
@@ -424,6 +432,7 @@ namespace MobileGL::MG_Remote::Server {
// the bytes it described.
shadow.Covered.clear();
shadow.GpuDirty = false;
shadow.HoldsFollowedCopy = false;
}
shadow.Extent = extent;
shadow.InternalFormat = internalFormat;
@@ -457,12 +466,155 @@ namespace MobileGL::MG_Remote::Server {
if (!m_any.load(std::memory_order_acquire)) return;
const std::lock_guard<std::mutex> lock(m_mutex);
m_shadows.erase(key);
m_driverWritten.erase(key);
}
void DropAll() {
if (!m_any.load(std::memory_order_acquire)) return;
const std::lock_guard<std::mutex> lock(m_mutex);
m_shadows.clear();
m_driverWritten.clear();
}
// ---- P8-E (docs/Disaggregated/notes/p8/E.md): WHEN THESE BYTES ARE THE LEVEL'S CONTENT ----
//
// The store holds what the client uploaded. It is also the only CPU copy of a level the
// server has, so a readback the driver refuses (a format the ES driver will not attach -
// RGB16 and 16-bit SNORM on Adreno) can be answered from it - but only while the driver
// holds nothing the store has not seen. Two facts decide that:
//
// * THE DRIVER-WRITTEN MARK, per texture and sticky: a framebuffer attachment, a writable
// image unit, a glCopyTexSubImage, a generated mip chain, a copy this store could not
// follow. The writers know the texture, not the level, and an attachment keeps writing
// for as long as it stays attached, so only the key's death (Drop / DropAll) clears it -
// a whole-resource respecify (ResetLevels) does not.
// * A COPY THE STORE FOLLOWED (FollowCopy): glCopyImageSubData between two levels whose
// bytes this store holds is a raw texel-block move, so the store makes the same move and
// the destination stays readable from here. Its level is flagged, because the client's
// next run for it is the CLIENT's shadow, which never saw the copy: AdoptClientBoxes
// lands only the texels that run says the client wrote.
void MarkDriverWritten(Uint64 key) {
if (!m_copies) return;
const std::lock_guard<std::mutex> lock(m_mutex);
m_driverWritten[key] = true;
m_any.store(true, std::memory_order_release);
}
Bool IsDriverWritten(Uint64 key) const {
if (!m_any.load(std::memory_order_acquire)) return false;
const std::lock_guard<std::mutex> lock(m_mutex);
return m_driverWritten.find(key) != m_driverWritten.end();
}
Bool LevelHoldsFollowedCopy(Uint64 key, Uint16 uploadTarget, Uint16 level) const {
if (!m_any.load(std::memory_order_acquire)) return false;
const std::lock_guard<std::mutex> lock(m_mutex);
const LevelShadow* shadow = FindLevel(key, uploadTarget, level);
return shadow != nullptr && shadow->HoldsFollowedCopy;
}
// The copy, in the level images' own texel grid (x, y, z = texel, row, slice/layer). True
// when the destination's bytes now hold it. False - and neither level touched - unless the
// source level is complete and its texture was never written by the driver, the destination
// level is complete (or the copy covers all of it), both levels' stored texels are one size,
// and the box lies inside both. The caller marks the destination driver-written on false.
Bool FollowCopy(Uint64 srcKey, Uint16 srcTarget, Uint16 srcLevel, const IntVec3& srcOrigin,
Uint64 dstKey, Uint16 dstTarget, Uint16 dstLevel, const IntVec3& dstOrigin,
const IntVec3& size) {
if (!m_copies) return false;
const std::lock_guard<std::mutex> lock(m_mutex);
if (m_driverWritten.find(srcKey) != m_driverWritten.end()) return false;
const LevelShadow* src = FindLevel(srcKey, srcTarget, srcLevel);
LevelShadow* dst = FindLevelMutable(dstKey, dstTarget, dstLevel);
if (src == nullptr || dst == nullptr || !LevelComplete(*src)) return false;
const SizeT texel = LevelTexelBytes(*src);
if (texel == 0 || !dst->Defined || dst->DeclaredByteBound == 0 || LevelTexelBytes(*dst) != texel) return false;
if (!BoxInside(srcOrigin, size, src->Extent) || !BoxInside(dstOrigin, size, dst->Extent)) return false;
const Bool wholeDestination = dstOrigin == IntVec3{0, 0, 0} && size == dst->Extent;
if (!LevelComplete(*dst) && !wholeDestination) return false;
// Gathered first, so a copy within one level cannot read rows it has already written.
const SizeT rowBytes = static_cast<SizeT>(size.x()) * texel;
Vector<Uint8> rows(rowBytes * static_cast<SizeT>(size.y()) * static_cast<SizeT>(size.z()));
for (Int z = 0; z < size.z(); ++z) {
for (Int y = 0; y < size.y(); ++y) {
std::memcpy(rows.data() + (static_cast<SizeT>(z) * size.y() + y) * rowBytes,
src->Bytes.data() + TexelOffset(src->Extent, srcOrigin.x(), srcOrigin.y() + y,
srcOrigin.z() + z, texel),
rowBytes);
}
}
if (!LevelComplete(*dst)) {
dst->Bytes.assign(static_cast<SizeT>(dst->DeclaredByteBound), Uint8{0});
dst->Covered.clear();
StagedShadowStore::CoverageAdd(dst->Covered, 0, static_cast<SizeT>(dst->DeclaredByteBound));
}
for (Int z = 0; z < size.z(); ++z) {
for (Int y = 0; y < size.y(); ++y) {
std::memcpy(dst->Bytes.data() + TexelOffset(dst->Extent, dstOrigin.x(), dstOrigin.y() + y,
dstOrigin.z() + z, texel),
rows.data() + (static_cast<SizeT>(z) * size.y() + y) * rowBytes, rowBytes);
}
}
dst->HoldsFollowedCopy = true;
m_any.store(true, std::memory_order_release);
return true;
}
// One box of texels, in the level image's grid, that a client record says it wrote.
struct ClientTexelBox {
Int32 X, Y, Z;
Uint32 W, H, D;
};
// A client run for a level that holds a followed copy. The run is the client's level shadow
// (or one slab of it) and is stale everywhere the client did not write, so only `boxes` land,
// each texel read from where it sits in the run (the run begins at `imageOffset` of the
// level image) - the rest of the level keeps the copy. False, touching nothing, when a box
// is outside the level or a row of it is outside the run: the caller then adopts the run as
// usual and marks the texture driver-written.
Bool AdoptClientBoxes(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent,
Uint64 imageOffset, const void* bytes, SizeT byteSize,
const ClientTexelBox* boxes, SizeT boxCount) {
if (!m_copies || bytes == nullptr || (boxCount != 0 && boxes == nullptr)) return false;
const std::lock_guard<std::mutex> lock(m_mutex);
LevelShadow* shadow = FindLevelMutable(key, uploadTarget, level);
if (shadow == nullptr || !shadow->HoldsFollowedCopy || !LevelComplete(*shadow) || shadow->Extent != extent) {
return false;
}
const SizeT texel = LevelTexelBytes(*shadow);
if (texel == 0) return false;
const auto rowInRun = [&](const ClientTexelBox& box, Uint32 row, Uint32 slice, SizeT& offset) {
offset = TexelOffset(shadow->Extent, box.X, box.Y + static_cast<Int>(row), box.Z + static_cast<Int>(slice),
texel);
const Uint64 begin = static_cast<Uint64>(offset);
const Uint64 end = begin + static_cast<Uint64>(box.W) * texel;
return begin >= imageOffset && end <= imageOffset + byteSize;
};
for (SizeT i = 0; i < boxCount; ++i) {
const ClientTexelBox& box = boxes[i];
if (!BoxInside(IntVec3{box.X, box.Y, box.Z},
IntVec3{static_cast<Int>(box.W), static_cast<Int>(box.H), static_cast<Int>(box.D)},
shadow->Extent)) {
return false;
}
for (Uint32 slice = 0; slice < box.D; ++slice) {
for (Uint32 row = 0; row < box.H; ++row) {
SizeT offset = 0;
if (!rowInRun(box, row, slice, offset)) return false;
}
}
}
const auto* run = static_cast<const Uint8*>(bytes);
for (SizeT i = 0; i < boxCount; ++i) {
const ClientTexelBox& box = boxes[i];
for (Uint32 slice = 0; slice < box.D; ++slice) {
for (Uint32 row = 0; row < box.H; ++row) {
SizeT offset = 0;
rowInRun(box, row, slice, offset);
std::memcpy(shadow->Bytes.data() + offset, run + (offset - static_cast<SizeT>(imageOffset)),
static_cast<SizeT>(box.W) * texel);
}
}
}
return true;
}
// Fatal when a sync wants texels this store has no covered run for. This is THE data
@@ -589,6 +741,8 @@ namespace MobileGL::MG_Remote::Server {
Uint64 DeclaredByteBound = 0;
Bool Defined = false;
Bool GpuDirty = false;
// P8-E: FollowCopy wrote server-side texels into Bytes that the client's shadow lacks.
Bool HoldsFollowedCopy = false;
};
struct TextureShadow {
ska::flat_hash_map<Uint32, LevelShadow> Levels;
@@ -684,11 +838,51 @@ namespace MobileGL::MG_Remote::Server {
const auto levelIt = textureIt->second.Levels.find(PackLevel(uploadTarget, level));
return levelIt == textureIt->second.Levels.end() ? nullptr : &levelIt->second;
}
LevelShadow* FindLevelMutable(Uint64 key, Uint16 uploadTarget, Uint16 level) {
const auto textureIt = m_shadows.find(key);
if (textureIt == m_shadows.end()) return nullptr;
const auto levelIt = textureIt->second.Levels.find(PackLevel(uploadTarget, level));
return levelIt == textureIt->second.Levels.end() ? nullptr : &levelIt->second;
}
// P8-E helpers, with the lock held. A level is COMPLETE when every declared byte crossed
// (IsCovered's test); its texel size is the declared bound over the texel count, and a bound
// that does not divide evenly is a level this cannot address texel by texel.
static Bool LevelComplete(const LevelShadow& shadow) {
return shadow.Defined && shadow.DeclaredByteBound != 0 &&
shadow.Bytes.size() == shadow.DeclaredByteBound &&
StagedShadowStore::CoverageHas(shadow.Covered, 0, static_cast<SizeT>(shadow.DeclaredByteBound));
}
static SizeT LevelTexelBytes(const LevelShadow& shadow) {
if (shadow.Extent.x() <= 0 || shadow.Extent.y() <= 0 || shadow.Extent.z() <= 0) return 0;
const Uint64 texels = static_cast<Uint64>(shadow.Extent.x()) * static_cast<Uint64>(shadow.Extent.y()) *
static_cast<Uint64>(shadow.Extent.z());
if (shadow.DeclaredByteBound == 0 || shadow.DeclaredByteBound % texels != 0) return 0;
return static_cast<SizeT>(shadow.DeclaredByteBound / texels);
}
static Bool BoxInside(const IntVec3& origin, const IntVec3& size, const IntVec3& extent) {
for (Int axis = 0; axis < 3; ++axis) {
if (origin[axis] < 0 || size[axis] <= 0 ||
static_cast<Int64>(origin[axis]) + size[axis] > static_cast<Int64>(extent[axis])) {
return false;
}
}
return true;
}
static SizeT TexelOffset(const IntVec3& extent, Int x, Int y, Int z, SizeT texel) {
return ((static_cast<SizeT>(z) * static_cast<SizeT>(extent.y()) + static_cast<SizeT>(y)) *
static_cast<SizeT>(extent.x()) +
static_cast<SizeT>(x)) *
texel;
}
const Bool m_copies;
mutable std::mutex m_mutex;
StagedTextureDeviceLimits m_deviceLimits{};
ska::flat_hash_map<Uint64, TextureShadow> m_shadows;
// P8-E: MarkDriverWritten's keys (the value is always true). Apart from m_shadows on purpose,
// because ResetLevels erases a key's levels and must not forget this.
ska::flat_hash_map<Uint64, Bool> m_driverWritten;
// Read on every IsLevelGpuDirty / LevelExtentOrUndefined, so the monolith cost is one
// acquire load of a never-written flag rather than a mutex and two hash lookups.
std::atomic<Bool> m_any{false};
@@ -302,6 +302,197 @@ TEST(StagedTextureStoreTest, TheGpuDirtyMarkIsTheServersOwnDirtyAnswer) {
EXPECT_EQ(monolithStore.TrackedResources(), 0u);
}
// ---- P8-E (docs/Disaggregated/notes/p8/E.md): the store follows glCopyImageSubData -----------
//
// Two 4x4 RGBA8 levels (four bytes a texel), each adopted whole with a distinct byte pattern so a
// mis-addressed row or a swapped endpoint shows as a wrong byte, not a coincidentally right one.
namespace {
const IntVec3 kFollowExtent{4, 4, 1};
Vector<Uint8> FollowPattern(Uint8 base) {
Vector<Uint8> bytes(64);
for (SizeT i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<Uint8>(base + i);
return bytes;
}
void DefineAndAdopt(Server::StagedTextureStore& store, Uint64 key, const Vector<Uint8>& bytes) {
store.NoteLevelDefined(key, kTex2DTarget, 0, kFollowExtent);
store.Adopt(key, kTex2DTarget, 0, kFollowExtent, bytes.data(), bytes.size());
}
// The byte (x, y, channel) of a 4x4 RGBA8 level image.
SizeT At(Int x, Int y, Int c) { return static_cast<SizeT>((y * 4 + x) * 4 + c); }
} // namespace
// THE MOVE: the 2x3 window at (1, 1) of the source lands at (2, 0) of the destination, every other
// destination byte and every source byte stays, and only the destination is flagged as holding a
// copy the client never saw.
TEST(StagedTextureStoreTest, AFollowedCopyMovesTheWindowAndLeavesTheSourceAlone) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 src = Server::StagedTextureStore::KeyForHandle(TestHandle(60, 1));
const Uint64 dst = Server::StagedTextureStore::KeyForHandle(TestHandle(61, 1));
const auto srcBytes = FollowPattern(0x00);
const auto dstBytes = FollowPattern(0x80);
DefineAndAdopt(store, src, srcBytes);
DefineAndAdopt(store, dst, dstBytes);
ASSERT_TRUE(store.FollowCopy(src, kTex2DTarget, 0, IntVec3{1, 1, 0}, dst, kTex2DTarget, 0, IntVec3{2, 0, 0},
IntVec3{2, 3, 1}));
EXPECT_TRUE(store.LevelHoldsFollowedCopy(dst, kTex2DTarget, 0));
EXPECT_FALSE(store.LevelHoldsFollowedCopy(src, kTex2DTarget, 0));
const Uint8* got = store.RequireLevelBytes(dst, kTex2DTarget, 0, "unit");
const Uint8* source = store.RequireLevelBytes(src, kTex2DTarget, 0, "unit");
for (Int y = 0; y < 4; ++y) {
for (Int x = 0; x < 4; ++x) {
const Bool copied = x >= 2 && y < 3;
for (Int c = 0; c < 4; ++c) {
const Uint8 want = copied ? srcBytes[At(x - 2 + 1, y + 1, c)] : dstBytes[At(x, y, c)];
ASSERT_EQ(got[At(x, y, c)], want) << "destination texel (" << x << ", " << y << ")";
ASSERT_EQ(source[At(x, y, c)], srcBytes[At(x, y, c)]) << "the copy moved a SOURCE byte";
}
}
}
}
// Every shape the store cannot address, and a source it can no longer vouch for, is REFUSED with
// the destination untouched - the caller marks it driver-written instead. The monolith store
// follows nothing at all.
TEST(StagedTextureStoreTest, ACopyTheStoreCannotVouchForIsNotFollowed) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 src = Server::StagedTextureStore::KeyForHandle(TestHandle(62, 1));
const Uint64 dst = Server::StagedTextureStore::KeyForHandle(TestHandle(63, 1));
const Uint64 narrow = Server::StagedTextureStore::KeyForHandle(TestHandle(64, 1));
const Uint64 bare = Server::StagedTextureStore::KeyForHandle(TestHandle(65, 1));
const auto dstBytes = FollowPattern(0x80);
DefineAndAdopt(store, src, FollowPattern(0x00));
DefineAndAdopt(store, dst, dstBytes);
store.NoteLevelDefined(narrow, kTex2DTarget, 0, IntVec3{8, 8, 1},
static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2D),
static_cast<Uint32>(TextureInternalFormat::R8)); // 64 bytes, one a texel
Vector<Uint8> narrowBytes(64, 0x11);
store.Adopt(narrow, kTex2DTarget, 0, IntVec3{8, 8, 1}, narrowBytes.data(), narrowBytes.size());
store.NoteLevelDefined(bare, kTex2DTarget, 0, kFollowExtent); // defined, no bytes crossed
const IntVec3 origin{0, 0, 0};
EXPECT_FALSE(store.FollowCopy(src, kTex2DTarget, 0, IntVec3{3, 0, 0}, dst, kTex2DTarget, 0, origin,
IntVec3{2, 1, 1}))
<< "the box runs off the source's right edge";
EXPECT_FALSE(store.FollowCopy(narrow, kTex2DTarget, 0, origin, dst, kTex2DTarget, 0, origin, IntVec3{1, 1, 1}))
<< "a one-byte texel moved into a four-byte one";
EXPECT_FALSE(store.FollowCopy(src, kTex2DTarget, 0, origin, bare, kTex2DTarget, 0, origin, IntVec3{2, 2, 1}))
<< "a partial copy into a level none of whose bytes crossed";
EXPECT_FALSE(store.FollowCopy(bare, kTex2DTarget, 0, origin, dst, kTex2DTarget, 0, origin, IntVec3{1, 1, 1}))
<< "a source none of whose bytes crossed";
store.MarkDriverWritten(src);
EXPECT_FALSE(store.FollowCopy(src, kTex2DTarget, 0, origin, dst, kTex2DTarget, 0, origin, IntVec3{1, 1, 1}))
<< "the driver wrote the source behind the store's back, so its bytes are not the texels copied";
EXPECT_FALSE(store.LevelHoldsFollowedCopy(dst, kTex2DTarget, 0));
const Uint8* got = store.RequireLevelBytes(dst, kTex2DTarget, 0, "unit");
for (SizeT i = 0; i < dstBytes.size(); ++i) ASSERT_EQ(got[i], dstBytes[i]) << "a refused copy moved byte " << i;
Server::StagedTextureStore monolithStore(/*copies=*/false);
EXPECT_FALSE(monolithStore.FollowCopy(src, kTex2DTarget, 0, origin, dst, kTex2DTarget, 0, origin,
IntVec3{1, 1, 1}));
}
// A copy that covers ALL of a destination level whose bytes never crossed (glTexStorage, no upload)
// makes that level's content known: the store completes it.
TEST(StagedTextureStoreTest, AWholeLevelCopyCompletesADestinationThatHadNoBytes) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 src = Server::StagedTextureStore::KeyForHandle(TestHandle(66, 1));
const Uint64 dst = Server::StagedTextureStore::KeyForHandle(TestHandle(67, 1));
const auto srcBytes = FollowPattern(0x40);
DefineAndAdopt(store, src, srcBytes);
store.NoteLevelDefined(dst, kTex2DTarget, 0, kFollowExtent);
ASSERT_FALSE(store.IsCovered(dst, kTex2DTarget, 0));
ASSERT_TRUE(store.FollowCopy(src, kTex2DTarget, 0, IntVec3{0, 0, 0}, dst, kTex2DTarget, 0, IntVec3{0, 0, 0},
kFollowExtent));
ASSERT_TRUE(store.IsCovered(dst, kTex2DTarget, 0));
const Uint8* got = store.RequireLevelBytes(dst, kTex2DTarget, 0, "unit");
for (SizeT i = 0; i < srcBytes.size(); ++i) ASSERT_EQ(got[i], srcBytes[i]) << "byte " << i;
}
// The client's next run for a level holding a followed copy is the CLIENT's shadow, which never saw
// the copy: only the boxes it says it wrote land, the copied window survives, and a box whose rows
// are not in the run is refused with nothing written.
TEST(StagedTextureStoreTest, AClientRunOverAFollowedCopyLandsOnlyItsBoxes) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 src = Server::StagedTextureStore::KeyForHandle(TestHandle(68, 1));
const Uint64 dst = Server::StagedTextureStore::KeyForHandle(TestHandle(69, 1));
const auto srcBytes = FollowPattern(0x00);
const auto dstBytes = FollowPattern(0x80);
DefineAndAdopt(store, src, srcBytes);
DefineAndAdopt(store, dst, dstBytes);
ASSERT_TRUE(store.FollowCopy(src, kTex2DTarget, 0, IntVec3{0, 0, 0}, dst, kTex2DTarget, 0, IntVec3{0, 0, 0},
IntVec3{2, 2, 1}));
// The client's whole-level shadow: its own old bytes everywhere, 0xEE in the one row it wrote.
Vector<Uint8> run = dstBytes;
for (Int x = 0; x < 4; ++x) {
for (Int c = 0; c < 4; ++c) run[At(x, 3, c)] = 0xEE;
}
const Server::StagedTextureStore::ClientTexelBox lastRow{0, 3, 0, 4, 1, 1};
ASSERT_TRUE(store.AdoptClientBoxes(dst, kTex2DTarget, 0, kFollowExtent, 0, run.data(), run.size(), &lastRow, 1));
const Uint8* got = store.RequireLevelBytes(dst, kTex2DTarget, 0, "unit");
for (Int y = 0; y < 4; ++y) {
for (Int x = 0; x < 4; ++x) {
for (Int c = 0; c < 4; ++c) {
const Uint8 want = y == 3 ? Uint8{0xEE} : (x < 2 && y < 2) ? srcBytes[At(x, y, c)] : dstBytes[At(x, y, c)];
ASSERT_EQ(got[At(x, y, c)], want) << "texel (" << x << ", " << y << ")";
}
}
}
EXPECT_TRUE(store.LevelHoldsFollowedCopy(dst, kTex2DTarget, 0));
EXPECT_FALSE(store.IsDriverWritten(dst));
// A slab run that holds rows 2..3 only (imageOffset 32): row 0 is not in it.
const Server::StagedTextureStore::ClientTexelBox firstRow{0, 0, 0, 4, 1, 1};
Vector<Uint8> slab(32, 0x33);
EXPECT_FALSE(store.AdoptClientBoxes(dst, kTex2DTarget, 0, kFollowExtent, 32, slab.data(), slab.size(), &firstRow, 1));
EXPECT_EQ(got[At(0, 0, 0)], srcBytes[At(0, 0, 0)]) << "a refused run wrote into the level";
}
// A run adopted WHOLE over a followed copy (any caller that does not take AdoptClientBoxes' route)
// puts the client's stale texels back over it - so the store stops standing for the texture.
TEST(StagedTextureStoreTest, AWholeRunOverAFollowedCopyMarksTheTextureDriverWritten) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 src = Server::StagedTextureStore::KeyForHandle(TestHandle(70, 1));
const Uint64 dst = Server::StagedTextureStore::KeyForHandle(TestHandle(71, 1));
const auto dstBytes = FollowPattern(0x80);
DefineAndAdopt(store, src, FollowPattern(0x00));
DefineAndAdopt(store, dst, dstBytes);
ASSERT_TRUE(store.FollowCopy(src, kTex2DTarget, 0, IntVec3{0, 0, 0}, dst, kTex2DTarget, 0, IntVec3{0, 0, 0},
IntVec3{1, 1, 1}));
store.AdoptRun(dst, kTex2DTarget, 0, kFollowExtent, 0, dstBytes.data(), dstBytes.size());
EXPECT_TRUE(store.IsDriverWritten(dst));
EXPECT_FALSE(store.LevelHoldsFollowedCopy(dst, kTex2DTarget, 0));
EXPECT_FALSE(store.IsDriverWritten(src));
}
// The driver-written mark is per texture and sticky: a whole-resource respecify does not clear it
// (an attachment made before it keeps writing after it), only the key's death does. Inert on a
// monolith store.
TEST(StagedTextureStoreTest, TheDriverWrittenMarkOutlivesAResetAndDiesWithTheKey) {
Server::StagedTextureStore store(/*copies=*/true);
const Uint64 a = Server::StagedTextureStore::KeyForHandle(TestHandle(72, 1));
const Uint64 b = Server::StagedTextureStore::KeyForHandle(TestHandle(73, 1));
EXPECT_FALSE(store.IsDriverWritten(a));
store.MarkDriverWritten(a);
store.MarkDriverWritten(b);
EXPECT_TRUE(store.IsDriverWritten(a));
store.ResetLevels(a);
EXPECT_TRUE(store.IsDriverWritten(a)) << "a respecify forgot that the driver writes this texture";
store.Drop(a);
EXPECT_FALSE(store.IsDriverWritten(a)) << "a destroyed texture's mark must not answer for the slot's next owner";
EXPECT_TRUE(store.IsDriverWritten(b));
store.DropAll();
EXPECT_FALSE(store.IsDriverWritten(b));
Server::StagedTextureStore monolithStore(/*copies=*/false);
monolithStore.MarkDriverWritten(a);
EXPECT_FALSE(monolithStore.IsDriverWritten(a));
}
// The two key namespaces share one map, so their disjointness is a property to pin, not to
// assume: handle keys carry the top bit, twin addresses (user-space, aligned) never do.
TEST(StagedTextureStoreTest, HandleKeysAndTwinAddressKeysCannotCollide) {
+119
View File
@@ -0,0 +1,119 @@
# P8-E:CopyImageSubData 由 server 的 staged store 跟随;死闩清理
> 2026-09-29,分支 `p8/e`(基线 `8d3e2317`)。简报 `~/w7/notes/p8/BRIEF-E.md`;证据 `~/w7/notes/p8/e-evidence/`。只在 Espryt;Magma 无代码改动,作对照。
## 1. 结论
- 审计的推断「split 下 copy 后 `glGetTexImage` 读到陈旧影子」不成立:split 的 `glGetTexImage` 不进 `DirectGLES::GetTexImage`,也不读任何影子。
- split 路径:client `EmitGetTexImage`(`MG_Remote/Client/TextureReadbackEmit.inc:158`)→ server `OnGetTextureImage`(`MG_Remote/Server/PipeApplier.cpp:443`)→ `ReadTextureImageWire`(`DirectGLES/WireTextureReadback.inc:422`)→ `ReadTextureLevelTight`(`DirectGLES/WireTextureReadback.inc:217`),只读 GPU(R32UI 字腿或 FBO)。
- 真实缺口:驱动不能 attach 的格式(Adreno:RGB16、16 位 SNORM)FBO 不完整(`DirectGLES/WireTextureReadback.inc:269`)→ `kStatusError`(`PipeApplier.cpp:462`)→ client `Fatal{ReplyError, "ReadPixels"}`(`EmitTables.cpp:903`)。与有没有 copy 无关,只上传过的层也结束会话。
- 主机 llvmpipe 能 attach 这些格式:不加旋钮时 split / spawn / tcp 与 monolith 全绿,所以要旋钮才能在主机上走到这条路(§4 R1)。
- 修法全在 server,不搬到 client:驱动拒绝时由 staged store 回答(store 路由),store 跟随 copy 做同一次 texel 块搬移(`FollowCopy`)。选「正确字节」,理由见 §2。
- monolith 同一缺陷(影子不随 copy 更新)记给 dev(§6)。
## 2. 设计
| 部件 | 位置 | 内容 |
|---|---|---|
| store 路由 | `DirectGLES/WireTextureReadback.inc:352` | 驱动拒绝回读时,store 仅在「整层覆盖、尺寸一致、纹理从未被驱动写过」时回答;转换同 `GetTexImageViaShadowConversion`(打包格式按字原样,其余解成宽 RGBA 逐行转) |
| 具名拒绝 / 武装标记 | `DirectGLES/WireTextureReadback.inc:411`,`:416` | 拒绝:`MGLOG_E_ONCE` `texture-readback-store-declined`,回复 error,client Fatal。回答:`MGLOG_W_ONCE` `texture-readback-from-store` |
| 驱动写标记 | `MG_Remote/Server/StagedTextureStore.h:479-514` | 按纹理、粘性;只有 `Drop` / `DropAll` 清,`ResetLevels` 不清(附件在重定义后照样写) |
| 标记来源 | `DirectGLES/Managers.cpp:10901` | `NoteDriverSideTextureWriteByHandle` 同时标 store:附件、可写 image、`CopyTexSubImage`、生成 mip 都经过它 |
| store 跟随 copy | `StagedTextureStore.h:519`,`DirectGLES/Managers.cpp:10924`,调用点 `DirectGLES/DirectGLES.cpp:13347-13365` | 两端同一内部格式(`DirectGLES/Managers.cpp:10946`)、单 upload target(非 cube / 1D array)、源整层覆盖且未被驱动写、目标整层覆盖或 copy 覆盖整层 → store 做同一搬移;否则给目标标驱动写 |
| copy 失败 | `DirectGLES/DirectGLES.cpp:13440` | 驱动拒绝 copy 时 store 已搬,补标驱动写 |
| 跟随后的 client 上传 | `DirectGLES/Managers.cpp:3193`,`StagedTextureStore.h:573` | client run 是 client 影子(没见过 copy),只落 record 声明写过的盒(regions,无 region 时 union box);放不下则照常收养并标驱动写;`AdoptRun` 兜底同样标(`StagedTextureStore.h:353`) |
| 测试旋钮 | `DirectGLES/WireTextureReadback.inc:328` | `MGITEST_ESPRYT_REFUSE_TEXTURE_READBACK_EXTENT=<w>x<h>`,server 读;该尺寸颜色层的 FBO 腿视为驱动拒绝,R32UI 字腿不动 |
不选「标 GPU 脏」:
- Espryt 的 `GpuDirty` 语义是「需要从 store 重传」(`DirectGLES/Managers.cpp:8460`),标了会把 store 旧字节传回去盖掉 GPU 上的 copy。
- 跟随后其他整层读 store 的路径也拿到对的字节:重铸回读被拒时的 W2-b 重放(`DirectGLES/Managers.cpp:6888`)、整层重传。
- 跟随不了的形状(跨格式、renderbuffer 源、被驱动写过的源、cube / 1D array、视图)降为具名拒绝,不给旧字节。
旋钮按尺寸定界:
- tcp 的 server 是整条 lane 共用的 `TcpServer.Start` fixture,旋钮只能放进它的 ENVIRONMENT(`MG_IntegrationTest/CMakeLists.txt:3245`)。
- 场景只有自身环境里有旋钮时才建 13x7 的纹理,否则 12x6(`CopyImageStoreReadbackScenario.cpp:66`),所以 lane 级旋钮只碰 StoreRead 条目。
- 实测:tcp 上无旋钮条目的 server 日志没有 `texture-readback-from-store`,StoreRead 条目有。
## 3. 场景与登记
`CopyImageStoreReadbackScenario`(`MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp:251-337`);格式 RGB16、RGBA16_SNORM、RGB16_SNORM、RGBA16,RGB9_E5 为正对照(R32UI 字腿)。
| 例 | 内容 |
|---|---|
| `AnUploadedLevelReadsBackExactly` | 上传后读回(对照) |
| `ACopiedWindowReadsBackOverTheUpload` | 可变纹理,5x3 窗口 copy 后读目标与源 |
| `ACopiedWindowReadsBackFromAnImmutableLevel` | 同上,`glTexStorage2D` |
| `APartialUploadAfterTheCopyKeepsTheCopiedWindow` | copy 后在同层别处 `glTexSubImage2D` |
| `ARenderbufferSourceLandsInTheTexture` | RGBA16 renderbuffer → 纹理(handle 臂,证明 `+RENDERBUFFER` 闩已死) |
| 条目 | 臂 | 登记 |
|---|---|---|
| `DirectGLES.*` / `DirectVulkan.*` | monolith | 整体发现 |
| `DirectGLES.{Split,Spawn,Tcp}.*` | 三臂,无旋钮 | `mgl_itest_register_split_arms`(`MG_IntegrationTest/CMakeLists.txt:3229`) |
| `DirectGLES.{Split,Spawn,Tcp}.StoreRead.*` | 三臂,旋钮 13x7,前 4 例 | `MG_IntegrationTest/CMakeLists.txt:3247-3273`,带 `integration-gpu` |
| `DirectVulkan.{Split,Spawn,Tcp}.CopyStore.*` | Magma 门控三臂 | `MG_IntegrationTest/CMakeLists.txt:3281`;另有 tier 2 信息层 |
- 三臂对称,`spawn_lane_parity.py` 不需例外行(`StoreRead.` 尾不被 Espryt 的 `CASE` 解析,`scripts/ci/spawn_lane_parity.py:39`)。
- store 单元用例 6 个:`MG_Test/Wire/StagedTextureStoreTest.cpp:330-475`。
## 4. red-once
| # | 变异 | 载体 | 红 |
|---|---|---|---|
| R1 | 基线代码 + 旋钮(无 store 路由) | StoreRead 12 条 | 12/12 `Fatal{ReplyError, "ReadPixels"}`,只上传的对照也死 |
| M0 | server 跳过 copy | 无旋钮 Espryt 三臂 15 条 | 12 红(4 例 × 3 臂),读到 copy 前字节;上传对照绿 |
| M1 | store 既不跟随也不标记 | StoreRead | 9/12 红,读到 copy 前字节(简报的「去掉即陈旧字节」) |
| M2 | 不跟随但标记 | StoreRead | 9/12 红,`Fatal{ReplyError}`(具名拒绝) |
| M3 | 关掉盒合并与 `AdoptRun` 兜底 | StoreRead 部分上传例 + 单元 | 3 条读到旧 copy 窗口 + `AWholeRunOverAFollowedCopyMarksTheTextureDriverWritten` 红 |
| M4 | store 路由忽略驱动写标记 | renderbuffer 例 + 旋钮 12x6(手动) | 旧字节;未变异时是 `texture-readback-store-declined` |
| U1 | `FollowCopy` 信任被驱动写过的源 | `ACopyTheStoreCannotVouchForIsNotFollowed` | 红 |
| U2 | `ResetLevels` 清驱动写标记 | `TheDriverWrittenMarkOutlivesAResetAndDiesWithTheKey` | 红 |
| U3 | `AdoptClientBoxes` 落整条 run | `AClientRunOverAFollowedCopyLandsOnlyItsBoxes` | 红 |
| MV | Magma 跳过 copy | Magma 35 条 | 28 红(4 例 × 7 条目),上传对照绿 |
| MONO | monolith `GetTexImage` 强制走影子(临时探针,不提交) | `DirectGLES.CopyImageStoreReadbackScenario.*` | 4 个 copy 例 × 4 个 16 位格式读到 copy 前字节;RGB9_E5 与上传对照绿 |
每次变异后按字节复制还原、`touch`、`cmp` 一致;日志在证据目录(`redonce-*.log`、`red1-head-knob-all.log`、`monolith-probe-shadow.log`)。
## 5. 死闩清理(无行为变化)
- `CopyImageSubData+RENDERBUFFER`:原在 `SyncRenderbufferObjectToBackend` 内,条件是 transport≠monolith;唯一调用点只在 monolith 臂(split 臂按句柄处理,`DirectGLES/DirectGLES.cpp:13134-13155`),删去并改写注释(`DirectGLES/DirectGLES.cpp:13110-13117`)。
- 镜像函数里过时的「client 侧镜像留给 P8」「由两个 Fatal 兜底」注释改写(`DirectGLES/DirectGLES.cpp:13239-13252`)。
- Fatal 普查:abort 点 79 → 78(`DirectGLES.cpp` 15 → 14),族 45 不变;基线用 `fatal_census.py --write-baseline` 重写。
## 6. monolith 缺陷(ID-P8-3,记给 dev,不在此修)
- `MirrorCopyImageIntoDestinationShadow` 只对 `HasRedundantPackedEncoding`(仅 RGB9_E5)调用(`DirectGLES/DirectGLES.cpp:13450`);其他格式 copy 后 CPU 影子不变。
- 驱动 attach 不了目标格式时 `GetTexImage` 退到影子(`DirectGLES/DirectGLES.cpp:15748`)→ 返回 copy 前字节。设备(Adreno:RGB16 / 16 位 SNORM / 无 norm16 时的 RGBA16)可达。
- red-once 见 §4 MONO;修法建议:镜像对所有非压缩同格式 copy 都做,或像 split 一样按纹理记「驱动写过」后拒绝。
## 7. 未做 / 发现未修
- `copy-image-shadow-mirror` 的 `MGPipeUnmigratedEmulation` 调用(`DirectGLES/DirectGLES.cpp:13261`)在 transport 下不可达(`:13253` 先返回),未删:名单在 `PipeCatalogueTest.cpp:1439`,归 B / F 统一改。
- `MGPipeTypes.h:1535` 的 P5b 历史注释仍提 `+RENDERBUFFER` 拒绝;是 wire 头注释,未动。
- 跨格式 copy 不跟随(store 存规范影子,16 位打包、RGB10 / RGB12 的规范表示不是 GL 位),降为具名拒绝。
- 具名拒绝仍是会话 Fatal(client 的 `RequireReadbackReplyComplete`);改成 `GL_INVALID_OPERATION` 需要协议变更,不在本包。
- 深度格式经 `BlitDepthTexture2D` 的 copy 由 store 按原样搬移;若该仿真非逐位,store 与 GPU 可能有精度差,只影响驱动拒绝深度回读的情形。
- 未上设备验证(简报:只有 S 包用手机)。
- tcp 的 lane 级旋钮今后会碰到任何读回 13x7x1 颜色层的 tcp 条目;现有场景无 13x7 纹理(grep),现有 357 个 tcp 条目在它下面全过。
- 设备上走 store 路由时,`ReadTextureLevelTight` 先打一行 `MGLOG_E_ONCE` `framebuffer status=`(`DirectGLES/WireTextureReadback.inc:269`),随后才是成功的 `texture-readback-from-store`;日志里会有一行 ERROR 在正常路径上。
## 8. 门
`gate.sh ~/w7/p8-e p8e`,头 `00d6c248`,dirty 0(`~/w7/notes/p8/e-evidence/gate-p8e.log`)。
| 项 | 读数 |
|---|---|
| G1 | `added=0 removed=0`;`.text` a5ba13 与基线相同 |
| Fatal 普查 | 78 abort 点 / 20 文件 / 45 族 / 0 未标记(79 → 78,§5) |
| link ratchet | 172 不变 |
| parity errors | 0 |
| protocol pin / wire-declines | revision 5 / 53 行 53 处 0 未记 |
| hygiene | 12/12 OK |
| unit | 2720 / 2720 |
| integration-split / spawn / tcp | 436 / 355 / 357,全过 |
| magma split / spawn / tcp / full-split | 256 / 233 / 202 / 641,全过 |
| integration-gpu[monolith] / [inproc] | 2725 / 2725 |
| retrace(`-L retrace`,236) | 230 过;6 红 = 已知 `iterationrp-in-world` llvmpipe JIT 崩溃(monolith 两后端 + split / spawn 两后端);split 76、spawn 76 条全过(`retrace-p8e.log`) |
+2 -2
View File
@@ -1,7 +1,7 @@
{
"abort_sites": 79,
"abort_sites": 78,
"abort_sites_by_file": {
"MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp": 15,
"MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp": 14,
"MobileGL/MG_Backend/DirectGLES/Managers.cpp": 12,
"MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp": 2,
"MobileGL/MG_Backend/DirectGLES/Utils.cpp": 1,