From 4b54a6f902179bc5ae09f53e24c4a822e2365bde Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 17 Sep 2026 03:55:13 -0400 Subject: [PATCH] [MG_Remote, MG_Backend] (Disaggregated): P5c tx - the server's texture staged shadow StagedTextureStore (MG_Remote/Server/StagedTextureStore.h), the texture twin of R-11's StagedShadowStore: keyed by the wire handle (a twin-address key would force glGenTextures at adopt time), coverage = the whole staged run per (uploadTarget, level), defined-ness tracked from the respecify hook so sparse chains keep their holes. ApplyTextureUpload adopts the staged bytes at the last instant they are alive (rule C) through three new disaggregated-only MGPipeResourceOps members; SyncMipmapsToBackend's four arms read the store and the descriptor instead of the client's MipmapStorage (texels, extent, target, defined-ness, dirty); Magma's GenerateMipmap marks the server shadow Defined+GpuDirty instead of writing the client's level storage (T5). Monolith arms reproduce the pre-tx expressions character for character behind the same macro discipline as P4a. Evidence: unit 2147/2147 incl. two named-Fatal death tests and the copies/no-copies difference suite; DirectGLES.Split 104/104 on WSL GPU; red-once - reverting the adoption to pointer-dropping turns exactly StagedTextureProductionTest red; the two monolith lane failures reproduce on the base commit (DirectVulkan.IterationRPProgram203Scenario, DirectVulkan.F1WireScenario.GenerateMipmapPackedFloatPixels). CONTRACT-P5C section 2. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 63 +++ MobileGL/MG_Backend/DirectGLES/Managers.cpp | 314 +++++++++++++-- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 60 +++ MobileGL/MG_Pipe/PipeApply.cpp | 47 ++- MobileGL/MG_Pipe/PipeApply.h | 23 ++ .../MG_Remote/Server/StagedTextureStore.h | 356 +++++++++++++++++ MobileGL/MG_Test/Wire/CMakeLists.txt | 19 +- .../MG_Test/Wire/StagedTextureStoreTest.cpp | 376 ++++++++++++++++++ 8 files changed, 1219 insertions(+), 39 deletions(-) create mode 100644 MobileGL/MG_Remote/Server/StagedTextureStore.h create mode 100644 MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index d589a76f..f7872243 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -21,6 +21,10 @@ // P3a: the applier's vertex-input records the re-keyed draw-buffer memo is validated against. #include #endif +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): §1's server-side per-level extent derivation, for GenerateMipmap's shape reads. +#include +#endif #include #include #include @@ -8494,6 +8498,35 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* mipmapTexture = dynamic_cast(texture.get()); MOBILEGL_ASSERT(mipmapTexture != nullptr, "Depth mipmap generation requires mipmap storage."); + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): the per-level extents are §1's derivation from the descriptor under an + // active transport - GetMipmapTexelSize is the client's per-level shape and the apply + // thread may not name it (rule E). Texture2D only, so x and y shrink and z stays 1. + // The record resolution is the same registry lookup texture sync and + // EnsureGenerateMipmapStorageAllocated's disaggregated arm already make. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto pushedHandle = TextureImpl::g_backendTextureObjects.HandleOf(texture.get()); + const auto* pushedRecord = PipeTextureRecordForHandle(pushedHandle); + if (pushedRecord == nullptr || pushedRecord->Desc.Width == 0 || pushedRecord->Desc.Levels == 0) { + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-shape"); + } + const auto& desc = pushedRecord->Desc; + const GLuint textureId = backendTexture->GetBackendTextureId(); + for (Uint32 level = 1; level < desc.Levels; ++level) { + const IntVec3 srcSize = MG_Remote::Server::StagedTextureMipExtent( + desc.Target, desc.Width, desc.Height, desc.Depth, level - 1); + const IntVec3 dstSize = MG_Remote::Server::StagedTextureMipExtent( + desc.Target, desc.Width, desc.Height, desc.Depth, level); + BlitDepthTexture2D(textureId, static_cast(level - 1), 0, 0, + static_cast(srcSize.x()), static_cast(srcSize.y()), + textureId, static_cast(level), 0, 0, + static_cast(dstSize.x()), static_cast(dstSize.y())); + } + return; + } +#endif + const Uint mipLevelCount = mipmapTexture->GetMipmapLevelCount(); MOBILEGL_ASSERT(mipLevelCount > 0, "Depth mipmap generation requires allocated storage."); @@ -8519,6 +8552,36 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* mipmapTexture = dynamic_cast(texture.get()); MOBILEGL_ASSERT(mipmapTexture != nullptr, "Color mipmap generation requires mipmap storage."); + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): GenerateDepthTexture2DMipmap's descriptor arm, for the color filter path - + // same §1 extent derivation, same registry resolution, same refusal when the handle + // arm has no record to read. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto pushedHandle = TextureImpl::g_backendTextureObjects.HandleOf(texture.get()); + const auto* pushedRecord = PipeTextureRecordForHandle(pushedHandle); + if (pushedRecord == nullptr || pushedRecord->Desc.Width == 0 || pushedRecord->Desc.Levels == 0) { + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-shape"); + } + const auto& desc = pushedRecord->Desc; + const GLenum filter = + IsIntegerColorFormat(static_cast(desc.InternalFormat)) ? GL_NEAREST + : GL_LINEAR; + const GLuint textureId = backendTexture->GetBackendTextureId(); + for (Uint32 level = 1; level < desc.Levels; ++level) { + const IntVec3 srcSize = MG_Remote::Server::StagedTextureMipExtent( + desc.Target, desc.Width, desc.Height, desc.Depth, level - 1); + const IntVec3 dstSize = MG_Remote::Server::StagedTextureMipExtent( + desc.Target, desc.Width, desc.Height, desc.Depth, level); + BlitColorTexture2D(textureId, static_cast(level - 1), 0, 0, + static_cast(srcSize.x()), static_cast(srcSize.y()), + textureId, static_cast(level), 0, 0, + static_cast(dstSize.x()), static_cast(dstSize.y()), filter); + } + return; + } +#endif + const Uint mipLevelCount = mipmapTexture->GetMipmapLevelCount(); MOBILEGL_ASSERT(mipLevelCount > 0, "Color mipmap generation requires allocated storage."); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 4dcc85e9..42178fb0 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -20,6 +20,7 @@ // R-11's server-owned staging copy. Header-only and package v1's; see its own header block for // why GLESBufferResource does not simply gain a member. #include +#include #include #endif @@ -2508,6 +2509,139 @@ namespace MobileGL::MG_Backend::DirectGLES { return result; } +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5c (tx): the TEXTURE half of the resource family, staged server-side -------- + // + // The buffer half's R-11 pattern (ServerStaged() above), for texture levels: the + // staged bytes of resource_subdata's texture half are adopted into the server's + // StagedTextureStore AT APPLY TIME, because `bytes` names SEG_STAGE and is dead the + // moment the record retires (rule C). The store, its ownership and its coverage + // rules are documented in MG_Remote/Server/StagedTextureStore.h; these are only the + // three hook bodies. All three are no-ops in monolith (CopiesIntoServerStorage() + // false), which is what keeps the monolith expression character for character. + // + // NOTHING here mints or reads a texture twin: the store is keyed by the wire handle + // the record carried (StagedTextureStore.h explains why not the twin address), so + // adoption needs no GL call and no frontend object. + + // GetUploadTargets() answered from the descriptor's Target: one static list per + // target, matching the frontend classes' own lists member for member + // (TextureObject*.h / TextureObjectStubs.h: a cube is six faces, a cube array is + // the single CubeMapArray target, everything else its own one). + const Vector& StagedUploadTargetsForPipeTarget(Uint8 pipeResourceTarget) { + static const Vector kUnknown{}; + static const Vector kTex1D{TextureUploadTarget::Texture1D}; + static const Vector kTex2D{TextureUploadTarget::Texture2D}; + static const Vector kTex3D{TextureUploadTarget::Texture3D}; + static const Vector kTex1DArray{TextureUploadTarget::Texture1DArray}; + static const Vector kTex2DArray{TextureUploadTarget::Texture2DArray}; + static const Vector kTexCube{ + TextureUploadTarget::CubeMapPositiveX, TextureUploadTarget::CubeMapNegativeX, + TextureUploadTarget::CubeMapPositiveY, TextureUploadTarget::CubeMapNegativeY, + TextureUploadTarget::CubeMapPositiveZ, TextureUploadTarget::CubeMapNegativeZ}; + static const Vector kTexCubeArray{TextureUploadTarget::CubeMapArray}; + static const Vector kTex2DMS{TextureUploadTarget::Texture2DMultisample}; + static const Vector kTex2DMSArray{TextureUploadTarget::Texture2DMultisampleArray}; + static const Vector kTexRect{TextureUploadTarget::TextureRectangle}; + static const Vector kTexBuffer{TextureUploadTarget::TextureBuffer}; + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1D: return kTex1D; + case MG_Pipe::MGPipeResourceTarget::Tex2D: return kTex2D; + case MG_Pipe::MGPipeResourceTarget::Tex3D: return kTex3D; + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: return kTex1DArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: return kTex2DArray; + case MG_Pipe::MGPipeResourceTarget::TexCube: return kTexCube; + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: return kTexCubeArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DMS: return kTex2DMS; + case MG_Pipe::MGPipeResourceTarget::Tex2DMSArray: return kTex2DMSArray; + case MG_Pipe::MGPipeResourceTarget::TexRect: return kTexRect; + case MG_Pipe::MGPipeResourceTarget::TexBuffer: return kTexBuffer; + default: return kUnknown; + } + } + + // The inverse of MG_Pipe::MGPipeResourceTargetForTextureTarget, for the sync's + // target reads (ConvertTextureTargetToBackendGLEnum and MapToBackendTextureTarget + // both want the frontend enum). + TextureTarget StagedTextureTargetForPipeTarget(Uint8 pipeResourceTarget) { + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1D: return TextureTarget::Texture1D; + case MG_Pipe::MGPipeResourceTarget::Tex2D: return TextureTarget::Texture2D; + case MG_Pipe::MGPipeResourceTarget::Tex3D: return TextureTarget::Texture3D; + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: return TextureTarget::Texture1DArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: return TextureTarget::Texture2DArray; + case MG_Pipe::MGPipeResourceTarget::TexCube: return TextureTarget::TextureCubeMap; + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: return TextureTarget::TextureCubeMapArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DMS: return TextureTarget::Texture2DMultisample; + case MG_Pipe::MGPipeResourceTarget::Tex2DMSArray: return TextureTarget::Texture2DMultisampleArray; + case MG_Pipe::MGPipeResourceTarget::TexRect: return TextureTarget::TextureRectangle; + case MG_Pipe::MGPipeResourceTarget::TexBuffer: return TextureTarget::TextureBuffer; + default: return TextureTarget::Unknown; + } + } + + void Ops_H_TextureSubData(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, + const void* bytes, const MG_Pipe::MGPSubRegion* regions) { + // The region set is the upload planner's shape and stays in the applier's + // pending set; the store's coverage is the staged run itself + // (StagedTextureStore.h's coverage ruling). + (void)regions; + auto& store = MG_Remote::Server::ServerStagedTexture(); + if (!store.CopiesIntoServerStorage()) return; + // The applier's gate has already faulted every shape that reaches here without + // bytes, and under split the codec declared the run's length (Blob.Size) - + // TextureEmit.h:1285's "the bytes this record declares ARE the level shadow". + if (bytes == nullptr || record.Blob.Size == 0) return; + const auto* stored = PipeTextureRecordForHandle(res); + if (stored == nullptr) return; + const IntVec3 extent = MG_Remote::Server::StagedTextureMipExtent( + stored->Desc.Target, stored->Desc.Width, stored->Desc.Height, stored->Desc.Depth, + static_cast(record.Level)); + store.Adopt(MG_Remote::Server::StagedTextureStore::KeyForHandle(res), + MG_Pipe::MGPipeSubDataUploadTargetOf(record.Target), record.Level, extent, + bytes, static_cast(record.Blob.Size)); + } + + void Ops_H_TextureRespecify(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc, + const MG_Pipe::MGPRespecifiedLevel* level) { + auto& store = MG_Remote::Server::ServerStagedTexture(); + if (!store.CopiesIntoServerStorage()) return; + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res); + if (level != nullptr) { + // ONE glTexImage*D redefined one level: it exists from here on, at the + // derived extent (§1). NoteLevelDefined keeps a same-extent level's bytes. + store.NoteLevelDefined( + key, MG_Pipe::MGPipeSubDataUploadTargetOf(level->UploadTarget), level->Level, + MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level->Level)); + return; + } + // A whole-resource redefinition: every level's old coordinate system is gone. + // glTexStorage* then defines the WHOLE chain at once (GL 4.6 core 8.19 - all six + // cube faces included), so an immutable descriptor re-marks every level of every + // upload target; a mutable whole-resource respecify (a texture view) defines + // nothing here and the store simply forgets the old levels. + store.ResetLevels(key); + if (desc.Immutable == 0 || desc.Levels == 0) return; + for (const auto& uploadTarget : StagedUploadTargetsForPipeTarget(desc.Target)) { + for (Uint32 levelIndex = 0; levelIndex < desc.Levels; ++levelIndex) { + store.NoteLevelDefined( + key, static_cast(uploadTarget), static_cast(levelIndex), + MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, levelIndex)); + } + } + } + + void Ops_H_TextureDestroy(MG_Pipe::MGPipeHandle res) { + // Deliberately NOT gated on CopiesIntoServerStorage(): Drop's own m_any gate + // makes the monolith call one acquire load, and an unconditional drop cannot + // strand a key the latch state was misread for. + MG_Remote::Server::ServerStagedTexture().Drop( + MG_Remote::Server::StagedTextureStore::KeyForHandle(res)); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + const MG_Pipe::MGPipeResourceOps g_glesResourceOps = { .Create = Ops_H_Create, .Respecify = Ops_H_RespecifyTracked, @@ -2518,6 +2652,11 @@ namespace MobileGL::MG_Backend::DirectGLES { .Destroy = Ops_H_DestroyTracked, .MapPersistent = Ops_H_MapPersistentTracked, .UnmapPersistent = Ops_H_UnmapPersistent, +#if MOBILEGL_BUILD_DISAGGREGATED + .TextureSubData = Ops_H_TextureSubData, + .TextureRespecify = Ops_H_TextureRespecify, + .TextureDestroy = Ops_H_TextureDestroy, +#endif }; #endif // MOBILEGL_PIPE_PUSH @@ -2812,6 +2951,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // generation, and a shadow that outlived its twin would be looked up by a RECYCLED // address on the next allocation - which is the quietest possible wrong answer. MGL_SERVER_STAGED_DROP_ALL(); +#if MOBILEGL_BUILD_DISAGGREGATED + // tx's texture shadows die for the same reason, keyed by handle rather than address + // but with the same recycled-identity failure mode: a new context's allocator may + // hand out a {slot, gen} the old one's store still answers for. + MG_Remote::Server::ServerStagedTexture().DropAll(); +#endif #endif } @@ -6586,7 +6731,91 @@ namespace MobileGL::MG_Backend::DirectGLES { // pre-P4a expression exactly when MOBILEGL_PIPE_PUSH is off, so the pull build's // preprocessed text, and therefore its object code, is unchanged. Both are #undef'd // immediately after the function. -#if MOBILEGL_PIPE_PUSH +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): THE READS THE FOUR UPLOAD ARMS MAKE, re-sourced. With an active transport the +// apply thread may not name the client's TextureObjectMipmap at all (rule E), so on the +// handle arm: +// +// * the level TEXELS come from the server's staged-texture store, adopted at apply time +// (MGB_LEVEL_TEXELS - Fatal{StageSnapshotTooNarrow} when no record covered the level, +// which is the data-correctness refusal of the texture half); +// * the per-level EXTENT and DEFINED-NESS come from the same store (fed by the sub-data +// adoption and the respecify hook; {0,0,0} for a level nothing defined, which is exactly +// GetMipmapTexelSize's answer for one); +// * the level BYTE SIZE is the adopted run's length; +// * the texture TARGET and the UPLOAD-TARGET list come from the descriptor. +// +// Every macro keeps the P4a discipline: the non-disaggregated expansion is the original +// frontend read, character for character modulo one pair of parentheses, so the pull and +// push builds compile exactly what they compiled before tx, and every disaggregated arm +// falls back to the frontend read when there is no active transport (the legacy arm, and +// monolith). MGB_STAGED_TEXTURE_LIVE is the runtime discriminator; pushedStorage/pushedRes +// are the function's own locals. All are #undef'd with the rest after the function. +#define MGB_STAGED_TEXTURE_LIVE \ + (pushedStorage != nullptr && MG_Remote::Server::ServerStagedTexture().CopiesIntoServerStorage()) +#define MGB_TEXTURE_TARGET(obj) \ + (MGB_STAGED_TEXTURE_LIVE ? BufferImpl::StagedTextureTargetForPipeTarget(pushedStorage->Desc.Target) \ + : (obj)->GetTarget()) +#define MGB_UPLOAD_TARGETS(obj) \ + (MGB_STAGED_TEXTURE_LIVE ? BufferImpl::StagedUploadTargetsForPipeTarget(pushedStorage->Desc.Target) \ + : (obj)->GetUploadTargets()) +#define MGB_LEVEL_TEXEL_SIZE(obj, tgt, lvl) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().LevelExtentOrUndefined( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl)) \ + : (obj)->GetMipmapTexelSize(tgt, lvl)) +#define MGB_LEVEL_BYTE_SIZE(obj, tgt, lvl) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().LevelByteSize( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl)) \ + : (obj)->GetMipmapByteSize(tgt, lvl)) +#define MGB_LEVEL_TEXELS(obj, tgt, lvl, site) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().RequireLevelBytes( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl), site) \ + : (obj)->MapMipmapData(tgt, lvl)) +#else +#define MGB_TEXTURE_TARGET(obj) ((obj)->GetTarget()) +#define MGB_UPLOAD_TARGETS(obj) ((obj)->GetUploadTargets()) +#define MGB_LEVEL_TEXEL_SIZE(obj, tgt, lvl) ((obj)->GetMipmapTexelSize(tgt, lvl)) +#define MGB_LEVEL_BYTE_SIZE(obj, tgt, lvl) ((obj)->GetMipmapByteSize(tgt, lvl)) +#define MGB_LEVEL_TEXELS(obj, tgt, lvl, site) ((obj)->MapMipmapData(tgt, lvl)) +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): the disaggregated pair adds ONE term and ONE clear to the P4a shapes - the +// staged-texture store's GPU-dirty mark (T5: a level the GPU generated dirties the SERVER's +// shadow, and the pending set cannot see it). The mark is never set on Espryt today - its +// GenerateMipmap fills the levels on the driver - so the term is inert here and is the +// contract-shaped answer (§2.2's last row) rather than a hot-path cost: one m_any acquire +// load when the store is empty. +#define MGB_LEVEL_NEEDS_UPLOAD(obj, tgt, lvl) \ + (pushedStorage != nullptr \ + ? (FindPipeTextureUpload(*pushedStorage, static_cast(tgt), static_cast(lvl)) != nullptr || \ + MG_Remote::Server::ServerStagedTexture().IsLevelGpuDirty( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl))) \ + : (obj)->IsStorageDirty(tgt, lvl)) +// Re-resolves the record itself, so it is safe after any amount of driver work - and it +// invalidates any PendingUpload* taken earlier for THIS texture, which is why every such pointer +// is used and dropped inside one level's iteration. +#define MGB_LEVEL_UPLOAD_DONE(obj, tgt, lvl) \ + do { \ + if (pushedStorage != nullptr) { \ + ConsumePipeTextureUpload(pushedRes, static_cast(tgt), static_cast(lvl)); \ + if (MGB_STAGED_TEXTURE_LIVE) { \ + MG_Remote::Server::ServerStagedTexture().MarkLevelGpuDirty( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl), false); \ + } \ + } else { \ + (obj)->MarkStorageDirty(tgt, lvl, false); \ + } \ + } while (0) +#elif MOBILEGL_PIPE_PUSH #define MGB_LEVEL_NEEDS_UPLOAD(obj, tgt, lvl) \ (pushedStorage != nullptr \ ? FindPipeTextureUpload(*pushedStorage, static_cast(tgt), static_cast(lvl)) != nullptr \ @@ -6783,8 +7012,8 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("Syncing texture mipmaps with backend ID %u to backend for state ID %u", m_backendTextureId, stateTextureObject->GetExternalIndex()); - GLenum target = ConvertTextureTargetToBackendGLEnum(stateTextureObject->GetTarget()); - auto targetInternal = stateTextureObject->GetTarget(); + GLenum target = ConvertTextureTargetToBackendGLEnum(MGB_TEXTURE_TARGET(stateTextureObject)); + auto targetInternal = MGB_TEXTURE_TARGET(stateTextureObject); MGLOG_D(" Texture target for syncing is %s", MG_Util::ConvertTextureTargetToString(targetInternal).c_str()); if (!IsSupportedTextureTarget(targetInternal)) { @@ -6921,11 +7150,11 @@ namespace MobileGL::MG_Backend::DirectGLES { TextureImpl::GenerateTextureFormatInfo(MGB_STORAGE_FORMAT(textureMipmapObject), &glInternalFormat, &glFormat, &glType, targetInternal); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = m_prevTextureInfo.mipmapLevels; level < mipmapCount; ++level) { - auto levelTexelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + auto levelTexelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); // A level the application never defined reads back as {0, 0, 0}; now that a // sparse chain is synced rather than skipped whole, leave those undefined on // the driver instead of giving the name a 0x0 image at that index. @@ -6933,11 +7162,12 @@ namespace MobileGL::MG_Backend::DirectGLES { MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); continue; } - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto* pData = (levelDirty && levelByteSize != 0) - ? textureMipmapObject->MapMipmapData(uploadTarget, level) + ? MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "append-mips") : nullptr; Vector convertedUploadData; Vector widenedUploadData; @@ -6951,8 +7181,8 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned const IntVec3 uploadSize = - GetBackendUploadSize(stateTextureObject->GetTarget(), levelTexelSize); - switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { + GetBackendUploadSize(MGB_TEXTURE_TARGET(stateTextureObject), levelTexelSize); + switch (MapToBackendTextureTarget(MGB_TEXTURE_TARGET(stateTextureObject))) { case TextureTarget::Texture2D: case TextureTarget::TextureCubeMap: g_GLESFuncs.glTexImage2D( @@ -7022,7 +7252,7 @@ namespace MobileGL::MG_Backend::DirectGLES { &glFormat, &glType, targetInternal); ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned @@ -7120,13 +7350,14 @@ namespace MobileGL::MG_Backend::DirectGLES { ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); const bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); if (levelDirty && levelByteSize != 0) { auto levelTexelSize = - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); - auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level); + auto* pData = MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "immutable-regen"); Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( @@ -7183,7 +7414,7 @@ namespace MobileGL::MG_Backend::DirectGLES { ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - auto levelTexelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + auto levelTexelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); // See the append-mips loop: an undefined level stays undefined on the // driver rather than becoming a 0x0 image. if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0 || @@ -7191,11 +7422,12 @@ namespace MobileGL::MG_Backend::DirectGLES { MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); continue; } - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto* pData = (levelDirty && levelByteSize != 0) - ? textureMipmapObject->MapMipmapData(uploadTarget, level) + ? MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "mutable-regen") : nullptr; Vector convertedUploadData; Vector widenedUploadData; @@ -7214,7 +7446,7 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned - auto textureTarget = stateTextureObject->GetTarget(); + auto textureTarget = MGB_TEXTURE_TARGET(stateTextureObject); const IntVec3 uploadSize = GetBackendUploadSize(textureTarget, levelTexelSize); switch (MapToBackendTextureTarget(textureTarget)) { case TextureTarget::Texture2D: @@ -7263,7 +7495,7 @@ namespace MobileGL::MG_Backend::DirectGLES { { // Update all dirty mipmap levels if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) { - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); for (const auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { if (MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level)) { @@ -7283,7 +7515,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // requires glTexSubImage's `format` to match the storage's base internal // format, so a GL_RG upload into a GL_RGBA32F image is GL_INVALID_OPERATION. ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { @@ -7291,7 +7523,7 @@ namespace MobileGL::MG_Backend::DirectGLES { continue; } - auto byteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); + auto byteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); if (byteSize == 0) { MGLOG_D("Mipmap level %d has no data, skipping update.", level); continue; @@ -7301,8 +7533,8 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s: Updating dirty mip %d for texture ID %u, size: %dx%d, " "byteSize: %d", __func__, level, m_backendTextureId, - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level).x(), - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level).y(), byteSize); + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level).x(), + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level).y(), byteSize); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned @@ -7311,8 +7543,9 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s(%s:%d) ES error: %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); - auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); - const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level); + auto texelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); + const void* mipData = MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "dirty-level"); Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( @@ -7329,7 +7562,7 @@ namespace MobileGL::MG_Backend::DirectGLES { uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize, imageWidenedUploadData); const IntVec3 uploadSize = - GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize); + GetBackendUploadSize(MGB_TEXTURE_TARGET(stateTextureObject), texelSize); // Sub-rect upload: when only a region of the level changed (a // 16x16 sprite in a 1024x512 atlas, the per-frame lightmap) and // the shadow bytes go to the driver unconverted, upload just that @@ -7357,6 +7590,20 @@ namespace MobileGL::MG_Backend::DirectGLES { : nullptr; const auto dirtyRegion = [&]() -> MG_State::GLState::MipmapDirtyRegion { if (pendingUpload == nullptr) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): a level this arm owes with NO pending upload behind it + // was dirtied by the GPU (T5), and the dirty answer is the + // server's own mark on the staged shadow - the whole level, + // because a generation touches all of it. The client is never + // asked (§2.2's last row). + if (MGB_STAGED_TEXTURE_LIVE) { + const IntVec3 gpuExtent = + MG_Remote::Server::ServerStagedTexture().LevelExtentOrUndefined( + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), + static_cast(uploadTarget), static_cast(level)); + return MG_State::GLState::MipmapDirtyRegion{IntVec3{0, 0, 0}, gpuExtent}; + } +#endif return textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level); } // MGPBox is {origin, extent}; MipmapDirtyRegion is {lo, hi}. The @@ -7507,6 +7754,13 @@ namespace MobileGL::MG_Backend::DirectGLES { region.Z + static_cast(region.D)}}; } } else +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // tx: with an active transport the rect list is the record's (the + // pendingUpload arm above) or nothing - the server's GPU-dirty mark + // is whole-level and has no scatter refinement to hand out, and the + // frontend's rect model is not this side's to read. + if (!MGB_STAGED_TEXTURE_LIVE) #endif dirtyRectCount = textureMipmapObject->GetStorageDirtyRects( uploadTarget, level, dirtyRects, @@ -7661,7 +7915,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, rectShape ? static_cast(dirtyRectCount) : 1u); } - switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { + switch (MapToBackendTextureTarget(MGB_TEXTURE_TARGET(stateTextureObject))) { case TextureTarget::Texture2D: case TextureTarget::TextureCubeMap: if (subRectEligible && dirtyRectCount >= 2) { @@ -8009,6 +8263,14 @@ namespace MobileGL::MG_Backend::DirectGLES { #undef MGB_STORAGE_FIXED_SAMPLE_LOCATIONS #undef MGB_STORAGE_IMMUTABLE #undef MGB_STORAGE_KIND +#undef MGB_TEXTURE_TARGET +#undef MGB_UPLOAD_TARGETS +#undef MGB_LEVEL_TEXEL_SIZE +#undef MGB_LEVEL_BYTE_SIZE +#undef MGB_LEVEL_TEXELS +#if MOBILEGL_BUILD_DISAGGREGATED +#undef MGB_STAGED_TEXTURE_LIVE +#endif #if MOBILEGL_PIPE_PUSH const SamplerParameters* BackendTextureObject::ResolvePushedBuiltinSampler( diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 7cc2f3e0..9f7a34c5 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -33,6 +33,10 @@ #include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h" #include "MG_Util/Texture/PixelStoreProcessor.h" #include +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (T5 / tx): the server's staged-texture shadow GenerateMipmap defines its chain on. +#include +#endif #include #include #include @@ -1608,6 +1612,52 @@ void main() { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (T5 / tx): the split arm of EnsureGenerateMipmapStorageAllocated. Under an active + // transport the apply thread may not WRITE the client's level storage - AllocateStorage + // and MarkStorageDirty on a frontend TextureObjectMipmap are §6 layer-1 surfaces + // (CONTRACT-P5C §2.3) - so the generated chain is defined on the SERVER's staged-texture + // shadow instead, keyed by this renderer's own texture twin (the TextureResource, + // node-stable in VkTextureManager's map). Same levels, same extents - derived from the + // Vulkan-space base extent, whose depth is already 1 for every array target (layers + // live in arrayLayers there, so the fixed-component split the GL-space derivation + // needs is unnecessary here; these shadow extents answer in Vulkan space, which every + // consumer of a Magma-keyed entry shares) - and every generated level is marked + // dirty-in-shadow, because its texels are generated on the GPU and no byte answer + // exists on this side. The client's chain is left stale, which §2.3 rules CORRECT: the + // two readers that could observe the staleness are both named refusals under split. + // The upload-target list still comes from the frontend object - a shape READ, the P7 + // registry's residual, not one of the writes this arm exists to remove. + static Bool EnsureGenerateMipmapShadowAllocated(const VkTextureManager::TextureResource& resource, + Uint32 baseMipLevel, + const Vector& uploadTargets) { + if (resource.mipLevels <= baseMipLevel || uploadTargets.empty()) { + return false; + } + const IntVec3 storageBaseTexelSize = {static_cast(resource.extent.width), + static_cast(resource.extent.height), + static_cast(resource.depth)}; + const IntVec3 baseTexelSize = ComputeMipTexelSize(storageBaseTexelSize, baseMipLevel); + if (baseTexelSize.x() <= 0 || baseTexelSize.y() <= 0 || baseTexelSize.z() <= 0) { + return false; + } + const Uint32 requiredMipLevelCount = baseMipLevel + ComputeFullMipLevelCount(baseTexelSize); + auto& store = MG_Remote::Server::ServerStagedTexture(); + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForTwinAddress(&resource); + for (const auto uploadTarget : uploadTargets) { + for (Uint32 level = baseMipLevel + 1; level < requiredMipLevelCount; ++level) { + // A level the shadow already tracks (an adopted base chain) keeps its bytes; + // the generation made the GPU newer than either, which the mark says. + store.NoteLevelDefined(key, static_cast(uploadTarget), static_cast(level), + ComputeMipTexelSize(storageBaseTexelSize, level)); + store.MarkLevelGpuDirty(key, static_cast(uploadTarget), static_cast(level), + true); + } + } + return true; + } +#endif + static VkImageLayout ResolveGenerateMipmapFinalLayout(VkImageAspectFlags aspectMask) { return (aspectMask & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT)) != 0 ? VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL @@ -11314,7 +11364,17 @@ void main() { "GenerateMipmap: depth-stencil mipmap generation is not supported yet."); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (T5 / tx): under an active transport the generated chain is defined on the + // server's staged shadow (keyed by the synced TextureResource above) and the client's + // level storage is never written; in monolith the client-object path runs unchanged. + const Bool allocatedMipmapStorage = + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? EnsureGenerateMipmapShadowAllocated(*resource, baseMipLevel, texture->GetUploadTargets()) + : EnsureGenerateMipmapStorageAllocated(*mipmapTexture, baseMipLevel); +#else const Bool allocatedMipmapStorage = EnsureGenerateMipmapStorageAllocated(*mipmapTexture, baseMipLevel); +#endif MOBILEGL_ASSERT(allocatedMipmapStorage, "GenerateMipmap could not allocate a full mip chain for this texture."); resource = m_textureManager->SyncTextureAndGetDescriptor(*texture); diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 1cf852d9..cd68a2bb 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -986,11 +986,13 @@ namespace MobileGL::MG_Pipe { return true; } - // The texture half of resource_subdata, and it DISPATCHES TO NOBODY. Nothing in this - // family reaches the backend at GL-call time today: a texture write marks a level dirty - // and Espryt uploads it at its own sync point, out of the accumulated set below. So the - // whole of this function is the gate, the accumulation and the serial - which is also - // why MGPipeResourceOps did not have to grow a member for it. + // The texture half of resource_subdata, and it DISPATCHES TO NOBODY at GL-call time: + // a texture write marks a level dirty and Espryt uploads it at its own sync point, out + // of the accumulated set below. So the whole of this function is the gate, the + // accumulation, the serial and - P5c (tx), disaggregated builds only - the adoption + // hook that moves the staged bytes into the server's staged-texture store while they + // are still alive. MGPipeResourceOps grew its three texture members for exactly that + // hook; the monolith shape of everything above them is unchanged. Bool ApplyTextureUpload(const MGPSubData& record, const void* bytes, const MGPSubRegion* regions) { MGPipeResourceRecord* stored = ResolveResourceIn(g_applier.TextureResources, "resource_subdata", record.Res); @@ -1032,6 +1034,16 @@ namespace MobileGL::MG_Pipe { // record was accumulated, so the texels are the server's now, and that is the true // this returns. ++stored->Serial; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): THE STAGED BYTES ARE ADOPTED HERE, at the last instant `bytes` is known + // alive (rule C: SEG_STAGE retires when this record does, and PendingUpload + // deliberately holds no byte pointer). The hook copies the run into the server's + // staged-texture store keyed by this record's own handle; it is a no-op in + // monolith, so the monolith shape keeps P5's pointer-dropping expression exactly. + if (g_resourceOps != nullptr && g_resourceOps->TextureSubData != nullptr) { + g_resourceOps->TextureSubData(record.Res, record, bytes, regions); + } +#endif return true; } @@ -1782,6 +1794,22 @@ namespace MobileGL::MG_Pipe { record->PendingUploads.clear(); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): the staged-texture store's defined-ness and drop bookkeeping rides THE SAME + // scope rules as the pending-set drops above - a named level redefines that one + // (uploadTarget, level), a whole-resource respecify drops every level, and a metadata + // update replaces no storage and is not delivered. Without this the store could not say + // whether a level exists at all (a null-data glTexImage*D carries no sub-data), and a + // whole-resource respecify would leave levels keyed against a replaced coordinate + // system. Textures only: a buffer's storage is the ops table's own Respecify hook, and + // a renderbuffer has no levels. + if (desc.Target != kMGPipeResourceTargetBuffer && + desc.Target != static_cast(MGPipeResourceTarget::Renderbuffer) && !metadataOnly && + g_resourceOps != nullptr && g_resourceOps->TextureRespecify != nullptr) { + g_resourceOps->TextureRespecify(desc.Resource, desc, level); + } +#endif + // resource_respecify is the catalogue's only kNeedsAck call, and the per-record half // of that flag is MGPipeResourceRespecifyNeedsAck(desc): glBufferStorage is a real // synchronous allocation and the only entry point allowed a synchronous ack, while @@ -1978,6 +2006,15 @@ namespace MobileGL::MG_Pipe { // AND ONLY A BUFFER IS HANDED ON, for resource_create's reason: the op table is the // buffer family's, its Destroy takes a handle whose kind that backend registered for, // and a texture's death is read out of the record at the sync that would have used it. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): with ONE exception - the staged-texture store is keyed by the handle, so + // the death must reach it or a recycled slot's stale levels would answer for the + // successor. This does not hand the texture to the buffer family's Destroy. + if (static_cast(handle.Kind) == MGPipeKind::Texture && + g_resourceOps != nullptr && g_resourceOps->TextureDestroy != nullptr) { + g_resourceOps->TextureDestroy(handle.Handle); + } +#endif if (static_cast(handle.Kind) != MGPipeKind::Buffer) return; if (g_resourceOps != nullptr && g_resourceOps->Destroy != nullptr) { g_resourceOps->Destroy(handle.Handle); diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index bfd409bf..6a7f3caf 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -41,6 +41,9 @@ namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_Pipe { struct PipeInputs; + // P5c (tx): MGPipeResourceOps::TextureRespecify names it; the definition is beside the + // applier entry point that produces one (below, with MGPipeApplyResourceRespecify). + struct MGPRespecifiedLevel; // --------------------------------------------------------------------------------- // The CSO store @@ -94,6 +97,26 @@ namespace MobileGL::MG_Pipe { void (*Destroy)(MGPipeHandle res); void* (*MapPersistent)(MGPipeHandle res, Uint64 size, const void* seedBytes); void (*UnmapPersistent)(MGPipeHandle res); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): the TEXTURE half of the resource family, appended so every positional + // initialiser of the nine P3a members keeps its meaning. TextureSubData is called + // from ApplyTextureUpload AFTER the gate, the accumulation and the serial, while + // `bytes` still names the staged run (SEG_STAGE retires when the record does, so an + // adoption anywhere later would read dead bytes - rule C); the backend copies the + // run into the server's staged-texture store (MG_Remote/Server/StagedTextureStore.h) + // and does nothing in monolith. TextureRespecify is the defined-ness/drop channel: + // it rides MGPipeApplyResourceRespecify's own scope rules (a named level redefines + // that level, a whole-resource respecify drops them all, a metadata update is not + // delivered). TextureDestroy is resource_destroy's texture arm - the applier hands + // only a buffer to Destroy, and a store keyed by the handle needs the death to drop + // its key. All three may be null together: a backend that has not adopted the staged + // shadow leaves them null and keeps the pre-tx shape. + void (*TextureSubData)(MGPipeHandle res, const MGPSubData& record, const void* bytes, + const MGPSubRegion* regions); + void (*TextureRespecify)(MGPipeHandle res, const MGPResourceDesc& desc, + const MGPRespecifiedLevel* level); + void (*TextureDestroy)(MGPipeHandle res); +#endif }; // Install / read the table. A null argument uninstalls, which is what a backend does at diff --git a/MobileGL/MG_Remote/Server/StagedTextureStore.h b/MobileGL/MG_Remote/Server/StagedTextureStore.h new file mode 100644 index 00000000..7c8372d1 --- /dev/null +++ b/MobileGL/MG_Remote/Server/StagedTextureStore.h @@ -0,0 +1,356 @@ +// MobileGL - MobileGL/MG_Remote/Server/StagedTextureStore.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P5c tx - THE SERVER'S OWN COPY OF THE STAGED TEXTURE LEVELS. The texture twin of +// StagedShadow.h (R-11), held to its four rulings (CONTRACT-P5C.md §2.1). +// +// THE DEFECT THIS ENDS (T1/T5). resource_subdata's texture half stages the level bytes into +// SEG_STAGE and ApplyTextureUpload (PipeApply.cpp:989-1036) drops the pointer after the gate, +// the accumulation and the serial. Espryt then re-reads the client's MipmapStorage at sync +// time (Managers.cpp:6940, :7129, :7198, :7315), reads per-level shape off the client object +// (:6928, :6936, :7314, :7332, DirectGLES.cpp:8502-8507, :8528-8529), and Magma WRITES the +// client's level storage outright (VulkanRenderer.cpp:1562-1609: AllocateStorage + +// MarkStorageDirty). In monolith every one of those is correct - the bytes belong to a +// frontend object that outlives the call. Under split the pointer names SEG_STAGE, valid +// only until retiredSeq passes the record, and w1's MOBILEGL_IPC_AUDIT=1 fills retired +// staging with 0xDD precisely so an implementation that kept the pointer is DISTINGUISHABLE +// from one that copied. So this copies, at apply time, into server-owned storage, and the +// sync reads nothing but this store and the descriptor. +// +// WHAT A KEY IS, AND WHY IT IS NOT THE TWIN ADDRESS THE CONTRACT'S FIRST DRAFT SAID. +// CONTRACT-P5C §2.1 rules "keyed by the texture resource twin's address". The buffer half +// keys by twin address because the twin is minted at sub-data time under split +// (Managers.cpp:2139-2142) and GLESBufferResource's constructor is GL-free. The texture +// twin's constructor is NOT: BackendTextureObject() calls glGenTextures (Managers.cpp:5496), +// so minting it at sub-data time would allocate a driver name for every texture that is +// written and never drawn, and would put a GL call into a unit test that has no context +// (R-16's unit case, StagedTextureStoreTest, exercises the REAL ops table headless). The +// wire HANDLE the record carried serves the same purpose the address served - stable for +// the object's life, liveness-exact through the generation, already in every caller's hand +// (rule E: the apply thread resolves every object from a handle the record carried) - so +// KeyForHandle is the primary key. KeyForTwinAddress exists for the one caller that has a +// server-side twin and no handle: Magma's T5 shadow writes key by the TextureResource. The +// two namespaces cannot collide (handle keys carry the top bit; user-space heap addresses +// never do). Every event that ends a key's life has a call site: a named-level respecify +// re-defines the level (NoteLevelDefined), a whole-resource respecify drops every level +// (ResetLevels), resource_destroy drops the key (Ops_H_TextureDestroy), context death drops +// all (OnBackendContextDestroyed, beside MGL_SERVER_STAGED_DROP_ALL). +// +// COVERAGE IS EXACTLY THE STAGED RUN, NEVER WIDENED AND NEVER NARROWER. The texture half of +// resource_subdata ALWAYS stages the whole level shadow (TextureEmit.h:1285: "the bytes this +// record declares ARE the level shadow", Blob.Size = the level's byte count), so one Adopt +// covers its level whole. The record's region set is deliberately NOT the coverage unit: it +// declares which texels CHANGED (the upload planner's shape, which the applier's pending set +// already carries), while the full-level upload paths - every conversion fallback, and the +// immutable/mutable regeneration arms - read the whole level including texels no region +// named. Those texels crossed in the staged run; treating them as uncovered would Fatal a +// legal glTexStorage-then-small-glTexSubImage sequence whose first (and only) record names a +// small box, and moving them into the store is not the buffer half's silent-zero case +// because they are the client's own shadow bytes, delivered and declared. A sync that asks +// for a level this store has no covered run for is Fatal{StageSnapshotTooNarrow} - the same +// words as the buffer half, for the same reason: the bytes have never existed on this side, +// and inventing them is silent data loss, not a missing optimisation. +// +// DEFINED-NESS IS TRACKED, NOT DERIVED ALONE. §1's per-level extent derivation (max(1, +// base >> level), layer axes fixed) computes the extent of a level that EXISTS; it cannot +// say whether the level was ever defined, and the mutable regen arms skip undefined levels +// (a sparse chain stays sparse on the driver). The client does not emit per-level extents, +// but every storage-defining call DOES cross as a respecify, so defined-ness reaches this +// store through the ops table's TextureRespecify hook: a named level marks that (uploadTarget, +// level), an immutable whole-resource respecify (glTexStorage*) marks every level of every +// upload target. Levels the store has never heard of answer extent {0,0,0}, which is the +// exact answer the frontend's GetMipmapTexelSize gives for them - FBO completeness over a +// null-data level and the sparse-chain skip both reproduce the monolith arm. +// +// GPU-GENERATED LEVELS (T5) ARE DEFINED HERE WITH NO BYTES AND A DIRTY MARK. A GPU-side +// generation (Magma's GenerateMipmap) dirties the SERVER's shadow, not the client's: the +// level is NoteLevelDefined + MarkLevelGpuDirty(true), it holds no bytes (they were made on +// the GPU and never crossed), and the mark is the "dirty region, level has NO pending +// upload" answer of §2.2's table. A texel read of such a level is the +// Fatal{StageSnapshotTooNarrow} case above, which is correct: the two readers that could +// ask are both named refusals under split (§2.3). +// +// WHY IT IS A HEADER AND NOT A BLOCK INSIDE Managers.cpp - StagedShadow.h's reason, and it +// is the one that matters here too: a block inside Managers.cpp could only ever be exercised +// by a test that also has a GL context, a resource twin and a live session, which is exactly +// how a rule ends up with no check that can fail for its own reason (R-16). Here `copies` is +// a constructor parameter rather than a read of MG_Config::Transport, so a unit case builds +// one store of each kind and asserts the DIFFERENCE between them; the production wiring is +// asserted separately, through the real ops table, in StagedTextureStoreTest. + +#pragma once +#include + +#include +#include +#include + +#include +#include +#include +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#endif + +namespace MobileGL::MG_Remote::Server { + + // §1's server-side per-level extent (CONTRACT-P5C table 0): max(1, base_extent >> level) + // per SHRINKING axis, with an array texture's layer count fixed - it is not a dimension of + // the image (GL 4.6 core 8.14.3), and GetMipmapTexelSize parks it in the slot after the + // image's own dimensions. Texture1DArray shrinks x only; Texture2DArray and + // TextureCubeMapArray shrink x and y; every other target shrinks all three. This is the + // mip chain's definition (MG_State's IsMipmapCompleteForFilter applies the same split, + // TextureObject.cpp:705-734), so two honest ends compute the same number. + inline Int StagedTextureShrinkingAxisCount(Uint8 pipeResourceTarget) { + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: + return 1; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: + return 2; + default: + return 3; + } + } + + inline IntVec3 StagedTextureMipExtent(Uint8 pipeResourceTarget, Uint32 baseWidth, Uint32 baseHeight, + Uint32 baseDepth, Uint32 level) { + const Int shrinking = StagedTextureShrinkingAxisCount(pipeResourceTarget); + const Int shift = static_cast(level); + IntVec3 extent{static_cast(baseWidth), static_cast(baseHeight), static_cast(baseDepth)}; + for (Int axis = 0; axis < shrinking && axis < 3; ++axis) { + extent[axis] = std::max(extent[axis] >> shift, 1); + } + return extent; + } + + class StagedTextureStore { + public: + // `copies` is "this process is really split". False reproduces the monolith expression + // character for character: the client shadow answers every question (the sync's macros + // never consult this store when CopiesIntoServerStorage() is false), nothing is + // allocated, and every push and verify lane stays byte-identical to what it was + // before tx. + explicit StagedTextureStore(Bool copies) : m_copies(copies) {} + + Bool CopiesIntoServerStorage() const { return m_copies; } + + // The wire handle the record carried, tagged so it can never alias a twin address. + // Slot and Gen are the client allocator's identity for one live object, so a recycled + // slot's new owner keys a different entry than its predecessor's stale one. + static Uint64 KeyForHandle(MG_Pipe::MGPipeHandle handle) { + return (Uint64{1} << 63) | (static_cast(handle.Slot) << 32) | + static_cast(handle.Gen); + } + // For the caller whose server-side twin has no wire handle (Magma's TextureResource, + // T5). Node-stable by that table's own ruling (std::unordered_map nodes). + static Uint64 KeyForTwinAddress(const void* twin) { + return static_cast(reinterpret_cast(twin)); + } + + // THE ADOPTION. Copies the record's staged run - the whole level shadow, byteSize = + // MGPSubData::Blob.Size under split - into server-owned storage and marks the level + // covered whole (see the header comment for why the run, not the region set, is the + // coverage). REPLACES the level's entry: the run is the level's complete current + // content, so nothing of a previous run survives, and a fresh adoption is by + // definition not GPU-dirty. Returns the server-owned base; under monolith it does + // nothing and returns nullptr, and the caller (Ops_H_TextureSubData) has already + // returned before reaching here. + const Uint8* Adopt(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent, + const void* bytes, SizeT byteSize) { + if (!m_copies) return nullptr; + const std::lock_guard lock(m_mutex); + LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)]; + shadow.Extent = extent; + shadow.Bytes.clear(); + if (bytes != nullptr && byteSize != 0) { + const auto* raw = static_cast(bytes); + shadow.Bytes.assign(raw, raw + byteSize); + } + shadow.Defined = true; + shadow.GpuDirty = false; + m_any.store(true, std::memory_order_release); + return shadow.Bytes.empty() ? nullptr : shadow.Bytes.data(); + } + + // A storage-defining respecify named this level: it EXISTS from here on, at this + // extent (null-data glTexImage*D, a generated level). An extent move redefines the + // level's coordinate system, so the bytes and the dirty mark of the old one go with + // it; an extent-restating one keeps them, which is the same answer the driver gives + // (a same-shape redefinition that carries no new upload leaves the old texels in + // place - undefined content is allowed to be the old content). + void NoteLevelDefined(Uint64 key, Uint16 uploadTarget, Uint16 level, const IntVec3& extent) { + if (!m_copies) return; + const std::lock_guard lock(m_mutex); + LevelShadow& shadow = m_shadows[key].Levels[PackLevel(uploadTarget, level)]; + if (!shadow.Defined || shadow.Extent != extent) { + shadow.Bytes.clear(); + shadow.GpuDirty = false; + } + shadow.Extent = extent; + shadow.Defined = true; + m_any.store(true, std::memory_order_release); + } + + // A whole-resource redefinition (glTexStorage*, a texture view): every level's old + // coordinate system is gone, so every level entry goes. The caller marks the new + // chain defined level by level afterwards where the call defines one. + void ResetLevels(Uint64 key) { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.erase(key); + } + + // T5's dirty mark: a GPU-side generation made this level's texels newer than any + // shadow. The level typically holds NO bytes - they were generated on the GPU and + // never crossed - and the mark is what answers "dirty region, level has no pending + // upload" without asking the client (§2.2's last row). + void MarkLevelGpuDirty(Uint64 key, Uint16 uploadTarget, Uint16 level, Bool dirty) { + if (!m_copies) return; + const std::lock_guard lock(m_mutex); + m_shadows[key].Levels[PackLevel(uploadTarget, level)].GpuDirty = dirty; + m_any.store(true, std::memory_order_release); + } + + void Drop(Uint64 key) { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.erase(key); + } + + void DropAll() { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.clear(); + } + + // Fatal when a sync wants texels this store has no covered run for. This is THE data + // -correctness refusal of the texture half: a pending upload with no adoption behind + // it, a GPU-generated level (no bytes by construction), a level the client never + // defined, and a monolith-arm misuse all land here, because in every one of them the + // bytes have never existed on this side and re-reading the client's shadow for them + // is the cross-role access tx exists to end. + const Uint8* RequireLevelBytes(Uint64 key, Uint16 uploadTarget, Uint16 level, const char* site) const { + if (!m_copies) return nullptr; + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + if (shadow != nullptr && shadow->Defined && !shadow->Bytes.empty()) { + return shadow->Bytes.data(); + } + MGLOG_F("MGPipe: Fatal{StageSnapshotTooNarrow, \"%s\"} - the texture sync wants the " + "bytes of (uploadTarget=%u, level=%u) and the server's staged shadow has no " + "covered run for it. Under split the authoritative shadow is SERVER-OWNED " + "(rule C) and resource_subdata is the only way bytes reach it, so these " + "texels have never existed on this side: the record that should have " + "carried them is missing, or the level's texels were generated on the GPU " + "and no byte answer exists at all. Re-reading the client's shadow would be " + "the cross-role access this store exists to end", + site, static_cast(uploadTarget), static_cast(level)); + std::abort(); + } + + // Diagnostics the sync path and the unit cases read, so that a check can assert WHAT + // HAPPENED rather than that nothing blew up. + Bool IsCovered(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + return shadow != nullptr && shadow->Defined && !shadow->Bytes.empty(); + } + Bool IsLevelDefined(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + return shadow != nullptr && shadow->Defined; + } + Bool IsLevelGpuDirty(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->GpuDirty; + } + // {0,0,0} for a level this store has never heard of - the exact answer the frontend's + // GetMipmapTexelSize gives for an undefined level, which is what keeps the regen + // arms' sparse-chain skip intact. + IntVec3 LevelExtentOrUndefined(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + if (!m_any.load(std::memory_order_acquire)) return IntVec3{0, 0, 0}; + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + if (shadow == nullptr || !shadow->Defined) return IntVec3{0, 0, 0}; + return shadow->Extent; + } + SizeT LevelByteSize(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + if (!m_any.load(std::memory_order_acquire)) return 0; + const std::lock_guard lock(m_mutex); + const LevelShadow* shadow = FindLevel(key, uploadTarget, level); + if (shadow == nullptr || !shadow->Defined) return 0; + return shadow->Bytes.size(); + } + Bool HasShadow(Uint64 key) const { + if (!m_any.load(std::memory_order_acquire)) return false; + const std::lock_guard lock(m_mutex); + return m_shadows.find(key) != m_shadows.end(); + } + SizeT TrackedResources() const { + const std::lock_guard lock(m_mutex); + return m_shadows.size(); + } + SizeT TrackedLevelCount(Uint64 key) const { + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + return it == m_shadows.end() ? 0 : it->second.Levels.size(); + } + + private: + static Uint32 PackLevel(Uint16 uploadTarget, Uint16 level) { + return (static_cast(uploadTarget) << 16) | static_cast(level); + } + + struct LevelShadow { + IntVec3 Extent{0, 0, 0}; + // The level's whole staged run. EMPTY for a defined-but-byteless level (null-data + // definition, GPU-generated) - emptiness is the coverage answer, not an error. + Vector Bytes; + Bool Defined = false; + Bool GpuDirty = false; + }; + struct TextureShadow { + ska::flat_hash_map Levels; + }; + + const LevelShadow* FindLevel(Uint64 key, Uint16 uploadTarget, Uint16 level) const { + 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; + } + + const Bool m_copies; + mutable std::mutex m_mutex; + ska::flat_hash_map m_shadows; + // 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}; + }; + +#if MOBILEGL_BUILD_DISAGGREGATED + // ONE PER PROCESS, and its copying arm is decided ONCE at first use - StagedShadow's + // ServerStaged() ruling verbatim: the two arms hold the authoritative bytes in DIFFERENT + // places, so an answer that changed mid-run would strand every level already staged. + // Leaked at exit like every other MG_Remote singleton (ID-8). In a header rather than in + // Managers.cpp because TWO backends consume it: Espryt's adoption and sync + // (Managers.cpp) and Magma's T5 shadow writes (VulkanRenderer.cpp) must name the same + // store, and an inline function's one static gives them exactly that. + inline StagedTextureStore& ServerStagedTexture() { + static StagedTextureStore& store = + *new StagedTextureStore(MG_Config::Transport != MG_Config::TransportMode::Monolith); + return store; + } +#endif + +} // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 240e228a..b7257e1d 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -73,18 +73,21 @@ gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS un # main() of its own. All three also reach MG_Pipe, MG_Backend and MG_State, so all three carry # those include paths. # -# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates, R-17's -# routing and ID-47/ID-49's readback rules (c1) -# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and -# R-11's server-owned staging copy (v1) -# SessionHandshakeTest the two null-union guards driven THROUGH ServerSession::Accept and -# ClientSession::StartOverTransportPair, and the ABI fingerprint's -# sensitivity case driven from CapsAbiFingerprint() (s1, ID-46 6 and 7) +# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates, R-17's +# routing and ID-47/ID-49's readback rules (c1) +# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and +# R-11's server-owned staging copy (v1) +# SessionHandshakeTest the two null-union guards driven THROUGH ServerSession::Accept and +# ClientSession::StartOverTransportPair, and the ABI fingerprint's +# sensitivity case driven from CapsAbiFingerprint() (s1, ID-46 6 and 7) +# StagedTextureStoreTest the texture half of R-11: tx's staged-texture store, its +# Fatal{StageSnapshotTooNarrow}, and the E-P5c #2 production wiring +# through the real resource op table (P5c tx) # # THIS FILE IS THE PHASE'S ONE RECURRING MERGE CONFLICT, and it is the same-point-append shape # BRIEF §5's ownership table exists to prevent: three packages, three targets, one end-of-file. # The loop is what stops there being a fourth: a package adding a suite adds a NAME. -foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest) +foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest StagedTextureStoreTest) add_executable(${wiretest} ${wiretest}.cpp) target_include_directories(${wiretest} PRIVATE diff --git a/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp new file mode 100644 index 00000000..2a7be263 --- /dev/null +++ b/MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp @@ -0,0 +1,376 @@ +// MobileGL - MobileGL/MG_Test/Wire/StagedTextureStoreTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Package tx's suite (P5c): the server's staged-texture shadow. +// +// THE SHAPE IS StagedShadowTest's (ServerLoopTest.cpp:606-760), for the R-16 reason stated +// there: every case must be able to go red for the reason it exists, and no other. The store +// unit cases build one store of each kind and assert the DIFFERENCE (copies is a constructor +// parameter, not a read of MG_Config::Transport); the production case drives the REAL +// resource op table - the same g_glesResourceOps RegisterBufferBackendOps installs - +// because a suite that only exercised StagedTextureStore in isolation would stay green with +// the ops-table registration deleted. +// +// E-P5c GATE #2 LIVES IN THE PRODUCTION CASE: reverting the adoption to P5's pointer-dropping +// (delete the copy inside Ops_H_TextureSubData, or the ops-table registration, or the +// ApplyTextureUpload hook call) turns it red - that is what makes "the server consumes the +// staged bytes" a checked fact rather than a design intention. + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +using namespace MobileGL; + +namespace Server = MobileGL::MG_Remote::Server; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + if (!in) return {}; + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + + unsigned ProcessId() { +#if defined(_WIN32) + return static_cast(_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + constexpr Uint16 kTex2DTarget = static_cast(TextureUploadTarget::Texture2D); + + MG_Pipe::MGPipeHandle TestHandle(Uint32 slot, Uint32 gen) { + MG_Pipe::MGPipeHandle handle{}; + handle.Slot = slot; + handle.Gen = gen; + return handle; + } + +} // namespace + +// ===================================================================================== +// The store, in isolation +// ===================================================================================== + +// THE PROPERTY MOBILEGL_IPC_AUDIT=1's 0xDD FILL EXISTS TO TEST, at unit scope: after the +// staged source bytes are overwritten - which is what the decoder does to a retired SEG_STAGE +// run - the server's copy still reads the original. The monolith store is the control: the +// SAME calls are no-ops on it, because the monolith sync answers from the client shadow. +TEST(StagedTextureStoreTest, TheSplitArmCopiesAndSurvivesTheSourceBeingPoisoned) { + Server::StagedTextureStore splitStore(/*copies=*/true); + Server::StagedTextureStore monolithStore(/*copies=*/false); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(3, 1)); + + Vector staged(64, 0xAB); + const IntVec3 extent{4, 4, 1}; + const Uint8* splitBase = splitStore.Adopt(key, kTex2DTarget, 0, extent, staged.data(), staged.size()); + const Uint8* monolithBase = + monolithStore.Adopt(key, kTex2DTarget, 0, extent, staged.data(), staged.size()); + + ASSERT_NE(splitBase, nullptr); + EXPECT_EQ(monolithBase, nullptr) + << "the monolith arm allocates nothing and answers nothing - the client shadow answers"; + EXPECT_EQ(monolithStore.TrackedResources(), 0u); + EXPECT_FALSE(monolithStore.IsCovered(key, kTex2DTarget, 0)); + EXPECT_NE(splitBase, staged.data()) << "the adoption returned the CLIENT's pointer - the " + "exact rule-C violation this store exists to fix"; + + // w1's retired-stage poison, by hand and at the right moment: the record has retired, so + // the staging run is dead. + std::fill(staged.begin(), staged.end(), Uint8{0xDD}); + for (SizeT i = 0; i < 64; ++i) { + EXPECT_EQ(splitBase[i], 0xAB) << "byte " << i << " of the server's copy is the poison, " + << "so the copy never happened"; + } +} + +// Defined-ness is tracked separately from bytes: a null-data definition (NoteLevelDefined +// with no Adopt) makes the level EXIST at the derived extent without covering any bytes, and +// an extent move redefines the coordinate system, so the old run goes with it while the +// level stays defined. A same-extent re-note keeps the run - the driver keeps the old texels +// for a same-shape redefinition too. +TEST(StagedTextureStoreTest, DefinednessIsTrackedAndAnExtentMoveDropsTheBytes) { + Server::StagedTextureStore store(/*copies=*/true); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(4, 1)); + + EXPECT_EQ(store.LevelExtentOrUndefined(key, kTex2DTarget, 0), IntVec3(0, 0, 0)) + << "a level nothing defined must answer {0,0,0} - the sparse-chain skip reads exactly " + "this, and the frontend's GetMipmapTexelSize answers the same"; + EXPECT_FALSE(store.IsLevelDefined(key, kTex2DTarget, 0)); + + store.NoteLevelDefined(key, kTex2DTarget, 0, IntVec3{4, 4, 1}); + EXPECT_TRUE(store.IsLevelDefined(key, kTex2DTarget, 0)); + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)) << "defined-without-bytes covers nothing"; + EXPECT_EQ(store.LevelExtentOrUndefined(key, kTex2DTarget, 0), IntVec3(4, 4, 1)); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 0u); + + Vector bytes(64, 0x11); + store.Adopt(key, kTex2DTarget, 0, IntVec3{4, 4, 1}, bytes.data(), bytes.size()); + EXPECT_TRUE(store.IsCovered(key, kTex2DTarget, 0)); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), 64u); + + store.NoteLevelDefined(key, kTex2DTarget, 0, IntVec3{4, 4, 1}); + EXPECT_TRUE(store.IsCovered(key, kTex2DTarget, 0)) << "a same-extent re-definition keeps the run"; + + store.NoteLevelDefined(key, kTex2DTarget, 0, IntVec3{8, 8, 1}); + EXPECT_TRUE(store.IsLevelDefined(key, kTex2DTarget, 0)); + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 0)) + << "an extent move replaced the coordinate system; the old run must not answer for it"; + EXPECT_EQ(store.LevelExtentOrUndefined(key, kTex2DTarget, 0), IntVec3(8, 8, 1)); +} + +// Keys are independent, ResetLevels drops one resource's whole chain, Drop one key and +// DropAll every one - the three events the contract names (respecify, destroy, context death) +// each have their call site, and these are the answers those call sites rely on. +TEST(StagedTextureStoreTest, ResetDropAndDropAllForgetExactlyWhatTheyName) { + Server::StagedTextureStore store(/*copies=*/true); + const Uint64 a = Server::StagedTextureStore::KeyForHandle(TestHandle(5, 1)); + const Uint64 b = Server::StagedTextureStore::KeyForHandle(TestHandle(6, 1)); + Vector bytes(16, 0x22); + + store.Adopt(a, kTex2DTarget, 0, IntVec3{4, 4, 1}, bytes.data(), bytes.size()); + store.Adopt(a, kTex2DTarget, 1, IntVec3{2, 2, 1}, bytes.data(), bytes.size()); + store.Adopt(b, kTex2DTarget, 0, IntVec3{4, 4, 1}, bytes.data(), bytes.size()); + ASSERT_EQ(store.TrackedResources(), 2u); + ASSERT_EQ(store.TrackedLevelCount(a), 2u); + + store.ResetLevels(a); + EXPECT_FALSE(store.HasShadow(a)) << "a whole-resource respecify forgets every level"; + EXPECT_TRUE(store.IsCovered(b, kTex2DTarget, 0)); + + store.Adopt(a, kTex2DTarget, 0, IntVec3{4, 4, 1}, bytes.data(), bytes.size()); + store.Drop(a); + EXPECT_EQ(store.TrackedResources(), 1u); + EXPECT_TRUE(store.IsCovered(b, kTex2DTarget, 0)); + + store.DropAll(); + EXPECT_EQ(store.TrackedResources(), 0u); + EXPECT_FALSE(store.HasShadow(b)); +} + +// T5's dirty mark: a GPU-side generation dirties the SERVER's shadow, and the mark - not the +// client - answers "dirty region, level has no pending upload". It is settable and clearable +// per (uploadTarget, level), independent of bytes, and inert on a monolith store. +TEST(StagedTextureStoreTest, TheGpuDirtyMarkIsTheServersOwnDirtyAnswer) { + Server::StagedTextureStore store(/*copies=*/true); + Server::StagedTextureStore monolithStore(/*copies=*/false); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(7, 1)); + + EXPECT_FALSE(store.IsLevelGpuDirty(key, kTex2DTarget, 2)); + store.NoteLevelDefined(key, kTex2DTarget, 2, IntVec3{2, 2, 1}); + store.MarkLevelGpuDirty(key, kTex2DTarget, 2, true); + EXPECT_TRUE(store.IsLevelGpuDirty(key, kTex2DTarget, 2)); + EXPECT_FALSE(store.IsLevelGpuDirty(key, kTex2DTarget, 3)) << "the mark is per level"; + EXPECT_FALSE(store.IsCovered(key, kTex2DTarget, 2)) + << "a generated level holds no bytes; a texel read of it is the Fatal case"; + store.MarkLevelGpuDirty(key, kTex2DTarget, 2, false); + EXPECT_FALSE(store.IsLevelGpuDirty(key, kTex2DTarget, 2)); + + monolithStore.MarkLevelGpuDirty(key, kTex2DTarget, 2, true); + EXPECT_FALSE(monolithStore.IsLevelGpuDirty(key, kTex2DTarget, 2)); + EXPECT_EQ(monolithStore.TrackedResources(), 0u); +} + +// 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) { + const MG_Pipe::MGPipeHandle handle = TestHandle(7, 1); + const Uint64 handleKey = Server::StagedTextureStore::KeyForHandle(handle); + int twin = 0; + const Uint64 twinKey = Server::StagedTextureStore::KeyForTwinAddress(&twin); + EXPECT_NE(handleKey, twinKey); + EXPECT_NE(handleKey, Server::StagedTextureStore::KeyForHandle(TestHandle(7, 2))) + << "a recycled slot's new generation must key a different entry"; +} + +// §1's derivation: max(1, base >> level) per SHRINKING axis, with an array texture's layer +// count fixed. This is the mip chain's definition, so the server and the client compute the +// same number - and the layer axes are exactly where a naive shift would diverge. +TEST(StagedTextureStoreTest, TheMipExtentDerivationKeepsArrayLayersFixed) { + EXPECT_EQ(Server::StagedTextureMipExtent(static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), 8, 4, 1, 2), + IntVec3(2, 1, 1)); + EXPECT_EQ(Server::StagedTextureMipExtent(static_cast(MG_Pipe::MGPipeResourceTarget::Tex3D), 8, 8, 8, 3), + IntVec3(1, 1, 1)); + EXPECT_EQ( + Server::StagedTextureMipExtent(static_cast(MG_Pipe::MGPipeResourceTarget::Tex2DArray), 8, 8, 6, 2), + IntVec3(2, 2, 6)) << "the layer count is not a dimension of the image"; + EXPECT_EQ( + Server::StagedTextureMipExtent(static_cast(MG_Pipe::MGPipeResourceTarget::Tex1DArray), 16, 4, 1, 3), + IntVec3(2, 4, 1)) << "a 1D array's HEIGHT is the layer count"; + EXPECT_EQ(Server::StagedTextureMipExtent(static_cast(MG_Pipe::MGPipeResourceTarget::TexCube), 7, 7, 1, 3), + IntVec3(1, 1, 1)) << "shrinking clamps at 1, never 0"; +} + +// The Fatal, and it asserts ITS OWN failure string rather than "the process died" - +// ServerLoopTest.cpp:702-704's reason: a death test that only checks for a crash goes green +// on any other abort in the same body. ONE death per case: the forked children of two +// EXPECT_EXITs would share this process's log file, and the second child's truncated open +// would erase the first's line. +#if !defined(_WIN32) +TEST(StagedTextureStoreTest, ATexelReadOutsideTheStagedCoverageIsFatalByName) { + Server::StagedTextureStore store(/*copies=*/true); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(8, 1)); + Vector bytes(16, 0x33); + store.Adopt(key, kTex2DTarget, 0, IntVec3{4, 4, 1}, bytes.data(), bytes.size()); + + // In coverage: no Fatal, asserted first so the death below cannot be a function that + // aborts on everything. + ASSERT_NE(store.RequireLevelBytes(key, kTex2DTarget, 0, "unit"), nullptr); + + // The death MODE and the diagnostic, both pinned: KilledBySignal(SIGABRT) refuses a + // SIGSEGV, and the log grep names the exact wording. The log flush is pinned the way + // ServerLoopTest's is: Log.cpp's WriteToFile fflushes after every write and MGLOG_F logs + // before abort(), so the line is on disk in the forked child before it dies. + EXPECT_EXIT(store.RequireLevelBytes(key, kTex2DTarget, 5, "unit_undefined_level"), + ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"unit_undefined_level\"}"), std::string::npos) + << "the abort happened but not for this rule's reason; the log says: " << log; +} + +TEST(StagedTextureStoreTest, AGpuGeneratedLevelHasNoBytesAndItsTexelReadIsFatalByName) { + Server::StagedTextureStore store(/*copies=*/true); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(TestHandle(9, 1)); + // Defined-without-bytes (T5's GPU-generated level): the level EXISTS, and a texel read of + // it is the same named refusal, because no byte answer exists on this side. + store.NoteLevelDefined(key, kTex2DTarget, 1, IntVec3{2, 2, 1}); + ASSERT_TRUE(store.IsLevelDefined(key, kTex2DTarget, 1)); + ASSERT_FALSE(store.IsCovered(key, kTex2DTarget, 1)); + + EXPECT_EXIT(store.RequireLevelBytes(key, kTex2DTarget, 1, "unit_gpu_level"), + ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"unit_gpu_level\"}"), std::string::npos) + << "the abort happened but not for this rule's reason; the log says: " << log; +} +#endif + +// ===================================================================================== +// The production wiring - E-P5c gate #2 at unit scope +// ===================================================================================== + +// THE GATE. The REAL resource op table (RegisterBufferBackendOps installs g_glesResourceOps, +// the exact table the apply path dispatches through), a REAL applier record, and the REAL +// TextureSubData hook ApplyTextureUpload calls - then w1's poison. Reverting ANY link of the +// adoption - the ops-table registration, the hook call in PipeApply.cpp, or the copy inside +// Ops_H_TextureSubData (i.e. going back to P5's pointer-dropping) - turns this red, which is +// the R-16 red-once for "the server consumes the staged bytes" (E-P5c #2). +TEST(StagedTextureProductionTest, TextureSubDataThroughTheRealOpsTableCopiesAndSurvivesTheSourcePoison) { + // MG_Config::Transport is InProcess (main), so ServerStagedTexture() latches its copying + // arm on - the same latch ServerLoopTest's R-11 production case relies on. + MG_Backend::DirectGLES::BufferImpl::RegisterBufferBackendOps(); + const MG_Pipe::MGPipeResourceOps* ops = MG_Pipe::MGPipeGetResourceOps(); + ASSERT_NE(ops, nullptr) << "RegisterBufferBackendOps did not install the resource op table"; + ASSERT_NE(ops->TextureSubData, nullptr) + << "the texture half of resource_subdata has no adoption hook - the staged bytes are " + "dropped at apply time and the sync re-reads the client"; + + // A real applier record: the hook derives the level's extent from the record's + // descriptor, so the record must exist the way resource_create makes it. + const MG_Pipe::MGPipeHandle res = TestHandle(41, 1); + MG_Pipe::MGPResourceDesc desc{}; + desc.Resource = res; + desc.Target = static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D); + desc.Width = 4; + desc.Height = 4; + desc.Depth = 1; + desc.Levels = 1; + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc)); + + Vector src(64, 0xAB); // 4x4 texels, 4 bytes each + MG_Pipe::MGPSubData rec{}; + rec.Res = res; + rec.Target = MG_Pipe::MGPipePackSubDataTarget( + static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), + static_cast(TextureUploadTarget::Texture2D)); + rec.Level = 0; + rec.UnionBox = {0, 0, 0, 4, 4, 1}; + rec.RegionCount = 0; + // Under split the codec declares the run's length: "the bytes this record declares ARE + // the level shadow" (TextureEmit.h:1285). The adoption reads exactly this field. + rec.Blob.Size = src.size(); + + // THE PRODUCTION CALL. Not StagedTextureStore::Adopt directly - the whole point of the + // gate is that the OPS TABLE carries the bytes into the store. + ops->TextureSubData(res, rec, src.data(), nullptr); + + auto& store = Server::ServerStagedTexture(); + const Uint64 key = Server::StagedTextureStore::KeyForHandle(res); + ASSERT_TRUE(store.IsCovered(key, kTex2DTarget, 0)) + << "the hook ran but nothing was adopted - the sync will Fatal or re-read the client"; + EXPECT_EQ(store.LevelExtentOrUndefined(key, kTex2DTarget, 0), IntVec3(4, 4, 1)); + EXPECT_EQ(store.LevelByteSize(key, kTex2DTarget, 0), src.size()); + const Uint8* base = store.RequireLevelBytes(key, kTex2DTarget, 0, "unit_production"); + ASSERT_NE(base, nullptr); + EXPECT_NE(base, static_cast(src.data())) + << "the sync's texel base points into the CLIENT's staging run - the pointer-dropping " + "shape P5 had; restoring it turns this red, and that is the gate"; + + // w1's retired-stage poison, by hand and at the right moment: the record has retired, so + // the staging run is dead. A server that copied still reads the original bytes. + std::fill(src.begin(), src.end(), Uint8{0xDD}); + for (SizeT i = 0; i < 64; ++i) { + ASSERT_EQ(base[i], 0xAB) << "byte " << i << " of the sync's texel source is the poison, " + << "so the adoption never happened"; + } + + // And the death drops the key, through the same ops table the applier dispatches. + MG_Pipe::MGPHandleOnly death{}; + death.Handle = res; + death.Kind = static_cast(MG_Pipe::MGPipeKind::Texture); + MG_Pipe::MGPipeApplyResourceDestroy(death); + EXPECT_FALSE(store.HasShadow(key)) + << "a destroyed texture's staged levels must not answer for the slot's next owner"; +} + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. The name carries this process's pid, because + // gtest_discover_tests runs every case as its own process, in parallel under ctest -j. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-stagedtexture-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + // THIS PROCESS IS A SPLIT ONE - ServerLoopTest's main() ruling, and it matters twice here: + // ServerStagedTexture() latches its copying arm off MG_Config::Transport at first use, + // and a suite that left it at Monolith would be testing the monolith answers. + MG_Config::Transport = MG_Config::TransportMode::InProcess; + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +}