From f823422bfa052735c9ae9579376123a479341862 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 22:43:02 -0400 Subject: [PATCH 1/3] [Fix] (Espryt, P8-E): a texture readback the driver refuses is answered from the server's staged store, which now follows glCopyImageSubData (FollowCopy, client-box merge, sticky driver-written mark, named decline); CopyImageStoreReadbackScenario on both backends x four arms plus the extent-scoped store-read knob; the dead CopyImageSubData+RENDERBUFFER latch goes (census 79 -> 78) --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 76 ++-- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 76 +++- MobileGL/MG_Backend/DirectGLES/Managers.h | 19 +- .../DirectGLES/WireTextureReadback.inc | 119 +++++- MobileGL/MG_IntegrationTest/CMakeLists.txt | 67 ++++ .../CopyImageStoreReadbackScenario.cpp | 373 ++++++++++++++++++ .../MG_Remote/Server/StagedTextureStore.h | 194 +++++++++ .../MG_Test/Wire/StagedTextureStoreTest.cpp | 191 +++++++++ scripts/ci/fatal_census_baseline.json | 4 +- 9 files changed, 1080 insertions(+), 39 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index b776965a..9962ac7f 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -13109,25 +13109,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 SyncRenderbufferObjectToBackend( const SharedPtr& 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 backendRenderbufferObject; if (auto* slot = RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get())) { backendRenderbufferObject = *slot; @@ -13248,19 +13237,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 @@ -13347,9 +13331,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) || @@ -13357,6 +13343,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(srcLevel), IntVec3{srcX, srcY, srcZ}, + dstEndpoint.TextureHandle, static_cast(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. @@ -13430,6 +13436,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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 0ff841b9..80ebdfcf 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -3185,6 +3185,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(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 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. @@ -10873,7 +10898,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; @@ -10883,6 +10908,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(MG_Pipe::MGPipeResourceTarget::Tex1DArray)) { + return false; + } + const auto& targets = BufferImpl::StagedUploadTargetsForPipeTarget(record->Desc.Target); + if (targets.size() != 1) return false; + target = static_cast(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(sourceLevel), sourceOrigin, + MG_Remote::Server::StagedTextureStore::KeyForHandle(destination), + destinationTarget, static_cast(destinationLevel), destinationOrigin, + size); } #endif } // namespace TextureImpl diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index bda68a71..f58db92a 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -2321,7 +2321,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 diff --git a/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc b/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc index 607fe5cf..7564cf69 100644 --- a/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc +++ b/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc @@ -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=x, 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(width), static_cast(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& bytes) { + auto& store = MG_Remote::Server::ServerStagedTexture(); + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res); + const auto target = static_cast(uploadTarget); + const auto mip = static_cast(level); + ReadbackChannelMapping mapping{}; + const SizeT pixelBytes = GetReadbackChannelMapping(format, mapping) ? GetReadbackDstPixelSize(mapping, type) : 0; + const SizeT texels = static_cast(std::max(extent.x(), 0)) * static_cast(std::max(extent.y(), 0)) * + static_cast(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 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 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(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(logicalFormat)); + bytes = Move(out); + return true; + } + Bool ReadTextureImageWire(const MG_Pipe::MGPReadbackInfo& info, Vector& 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(info.Box.W), static_cast(info.Box.H), wholeCube ? 1 : static_cast(info.Box.D)}; + const auto logicalFormat = static_cast(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 result; for (Int face = 0; face < (wholeCube ? 6 : 1); ++face) { const auto uploadTarget = wholeCube ? static_cast(static_cast(TextureUploadTarget::CubeMapPositiveX) + face) : MG_Util::ConvertGLEnumToTextureUploadTarget(info.Target); Vector slice; - if (!ReadTextureLevelTight(twin->GetBackendTextureId(), target, uploadTarget, - static_cast(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); diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 9929f305..2117a984 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -137,6 +137,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 @@ -3225,6 +3226,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..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 diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp new file mode 100644 index 00000000..620dd546 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CopyImageStoreReadbackScenario.cpp @@ -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=x 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..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 +#include +#include +#include +#include +#include + +#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 +#include +#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 MakeTexels(const CopyFormat& format, int width, int height, std::uint32_t seed) { + const std::size_t texels = static_cast(width) * static_cast(height); + std::vector bytes(texels * static_cast(TexelBytes(format))); + if (format.components == 0) { + for (std::size_t i = 0; i < texels; ++i) { + std::uint32_t word = (static_cast(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(format.components); + for (std::size_t i = 0; i < words; ++i) { + std::uint32_t mix = (static_cast(i) + 1u) * 2654435761u ^ seed * 40503u; + mix ^= mix >> 15; + std::uint16_t value = static_cast(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 WithBlock(std::vector image, int width, const std::vector& 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(dy + row) * width + dx) * texelBytes, + source.data() + (static_cast(sy + row) * sourceWidth + sx) * texelBytes, + static_cast(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 x"; + 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(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& 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(format.internalFormat), m_width, m_height, 0, + format.format, format.type, texels.data()); + } + glBindTexture(GL_TEXTURE_2D, 0); + return texture; + } + + std::vector ReadTexImage(GLuint texture, const CopyFormat& format) { + std::vector bytes( + static_cast(m_width) * m_height * static_cast(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& got, const std::vector& 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(texelBytes); + const int x = static_cast(texel % static_cast(m_width)); + const int y = static_cast(texel / static_cast(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 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(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 cleared(static_cast(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 diff --git a/MobileGL/MG_Remote/Server/StagedTextureStore.h b/MobileGL/MG_Remote/Server/StagedTextureStore.h index 8b136596..0a7142d9 100644 --- a/MobileGL/MG_Remote/Server/StagedTextureStore.h +++ b/MobileGL/MG_Remote/Server/StagedTextureStore.h @@ -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 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 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 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 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 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 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(size.x()) * texel; + Vector rows(rowBytes * static_cast(size.y()) * static_cast(size.z())); + for (Int z = 0; z < size.z(); ++z) { + for (Int y = 0; y < size.y(); ++y) { + std::memcpy(rows.data() + (static_cast(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(dst->DeclaredByteBound), Uint8{0}); + dst->Covered.clear(); + StagedShadowStore::CoverageAdd(dst->Covered, 0, static_cast(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(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 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(row), box.Z + static_cast(slice), + texel); + const Uint64 begin = static_cast(offset); + const Uint64 end = begin + static_cast(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(box.W), static_cast(box.H), static_cast(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(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(imageOffset)), + static_cast(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 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(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(shadow.Extent.x()) * static_cast(shadow.Extent.y()) * + static_cast(shadow.Extent.z()); + if (shadow.DeclaredByteBound == 0 || shadow.DeclaredByteBound % texels != 0) return 0; + return static_cast(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(origin[axis]) + size[axis] > static_cast(extent[axis])) { + return false; + } + } + return true; + } + static SizeT TexelOffset(const IntVec3& extent, Int x, Int y, Int z, SizeT texel) { + return ((static_cast(z) * static_cast(extent.y()) + static_cast(y)) * + static_cast(extent.x()) + + static_cast(x)) * + texel; + } const Bool m_copies; mutable std::mutex m_mutex; StagedTextureDeviceLimits m_deviceLimits{}; ska::flat_hash_map 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 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 m_any{false}; diff --git a/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp index 9206363d..6c9ae509 100644 --- a/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp +++ b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp @@ -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 FollowPattern(Uint8 base) { + Vector bytes(64); + for (SizeT i = 0; i < bytes.size(); ++i) bytes[i] = static_cast(base + i); + return bytes; + } + + void DefineAndAdopt(Server::StagedTextureStore& store, Uint64 key, const Vector& 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((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(MG_Pipe::MGPipeResourceTarget::Tex2D), + static_cast(TextureInternalFormat::R8)); // 64 bytes, one a texel + Vector 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 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 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) { diff --git a/scripts/ci/fatal_census_baseline.json b/scripts/ci/fatal_census_baseline.json index 7da07dee..0f9888e1 100644 --- a/scripts/ci/fatal_census_baseline.json +++ b/scripts/ci/fatal_census_baseline.json @@ -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, From 00d6c2480edcc86af5639ec31dbf1bfa3fa44fa6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 22:43:02 -0400 Subject: [PATCH 2/3] [Docs] (P8-E): the package note - the split read never touched a shadow, the store route and FollowCopy, the red-once table, the monolith defect for dev --- docs/Disaggregated/notes/p8/E.md | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/Disaggregated/notes/p8/E.md diff --git a/docs/Disaggregated/notes/p8/E.md b/docs/Disaggregated/notes/p8/E.md new file mode 100644 index 00000000..31f7fb6b --- /dev/null +++ b/docs/Disaggregated/notes/p8/E.md @@ -0,0 +1,102 @@ +# 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=x`,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 包用手机)。 + +## 8. 门 + +(见 §8 补记) From c00f5eabae8ee6a21850bd898d0dbf8c82f7957a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 23:51:49 -0400 Subject: [PATCH 3/3] [Docs] (P8-E): the note's gate and retrace readings and two residuals (the extent-scoped tcp knob, the ERROR line before a store answer) --- docs/Disaggregated/notes/p8/E.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/Disaggregated/notes/p8/E.md b/docs/Disaggregated/notes/p8/E.md index 31f7fb6b..0ee103de 100644 --- a/docs/Disaggregated/notes/p8/E.md +++ b/docs/Disaggregated/notes/p8/E.md @@ -97,6 +97,23 @@ - 深度格式经 `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. 门 -(见 §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`) |