From da30c1301e1ceb660b76fe9c0f1ecee25bc463e6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 22 Sep 2026 16:55:13 -0400 Subject: [PATCH] [MG_IntegrationTest, docs] (P7): the owner-keyed emission cursor gets the scenario that can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3b/P4b deepening, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, wave 2-D package D2). The code half of "按存储属主键控的发射游标与 view 索引重映射" landed in P4a/P5e; this is the verification half it never had. TextureViewAliasScenario, four cases: an upload through a layer-restricted view sampled through the owner and the reverse, each once and again across draw boundaries so the drain runs three times. Each case asserts on TWO channels at once - sampled texels AND MGPipeTextureEmitter's SubDataCount / RefusedSubDataCount / DrainListSize, read through the new Harness/TextureEmitPeek. The counters are the load-bearing half: a level that never made it onto the drain list is still uploaded by the next whole-level respecify, by a glGenerateMipmap or by the monolith sync path, so the picture comes out right one frame late and a pixel-only case goes green over the regression on most trees. Registered in the three GLES split arms, and MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 added to MGL_ITEST_GLES_{SPLIT,SPAWN,TCP,TCPDEVICE}_ENVIRONMENT - views are Espryt's opt-in and without it every case green-skips. The Tcp arm needed the knob on the TcpServer.Start fixture as well: that lane's server is pre-started rather than spawned from the client, so it withheld GL_ARB_texture_view and all four cases SKIPPED while the identical Split and Spawn arms ran. Red-once (R-16): TextureObjectView::MarkStorageDirtyRegion made to call PipeNoteLevelDirty on ITSELF instead of forwarding to the storage owner - the two-cursor shape. TheViewUploadDrainRunsAgainAfterADrawBoundary goes red on both channels simultaneously (stale owner texels AND SubDataDelta() == 0). The plan predicted case (a); it is (c) that reds, because the first drain of a texture's life carries the whole level anyway - recorded in the note. TheOwnerUploadDrainRunsAgainAfterADrawBoundary is EXCLUDED BY NAME from the split arms: it is red on all three transports and green under monolith, because an owner-side resource_subdata moves only the owner's record.Serial and nothing propagates that to the records viewing it. Cause and the two candidate fix sites are named in the CMake comment and in notes/p34b/espryt-d2.md. The name stays in every lane (G14). --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 97 ++- .../Harness/TextureEmitPeek.cpp | 55 ++ .../Harness/TextureEmitPeek.h | 64 ++ .../Scenarios/TextureViewAliasScenario.cpp | 570 ++++++++++++++++++ docs/Disaggregated/notes/p34b/espryt-d2.md | 100 +++ 5 files changed, 884 insertions(+), 2 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.cpp create mode 100644 MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.h create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp create mode 100644 docs/Disaggregated/notes/p34b/espryt-d2.md diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index ba123e48..5d3326c5 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -58,6 +58,11 @@ add_executable(MobileGLIntegrationTest Harness/P4aFinalFixPeek.cpp Harness/PersistentMapPeek.cpp Harness/SplitRuntimePeek.cpp + # ---- P3b/P4b wave 2-D package D2 (own block; D1 edits this file elsewhere) -------------- + # The client texture emitter's own counters, for TextureViewAliasScenario's half of the + # owner-keyed emission cursor gate: a drain that ran and emitted nothing is invisible in + # pixels and is exactly the two-cursor regression's signature. + Harness/TextureEmitPeek.cpp Scenarios/OrientationScenario.cpp Scenarios/CrossFrameBufferScenario.cpp Scenarios/ResidentIndexScenario.cpp @@ -121,6 +126,8 @@ add_executable(MobileGLIntegrationTest Scenarios/CopyImageLayeredScenario.cpp Scenarios/CopyImagePacked16Scenario.cpp Scenarios/TextureViewScenario.cpp + # P3b/P4b wave 2-D package D2: the verification half of the owner-keyed emission cursor. + Scenarios/TextureViewAliasScenario.cpp Scenarios/PackedWordReadbackScenario.cpp Scenarios/LayeredAttachmentBarrierScenario.cpp Scenarios/LayeredAttachmentShapeScenario.cpp @@ -754,8 +761,13 @@ include(GoogleTest) # caller's transport. A separate label makes every exception inspectable. set(MGL_ITEST_MONOLITH_MECHANISM_FILTER "HandleRecycleScenario.*:CsoContentAddressingScenario.*:ResourceSubsystemControlScenario.*:ObjectSubsystemControlScenario.*:PipeVerifyArmingScenario.*:PoisonOmissionScenario.*:StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw:LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip") +# P3b/P4b wave 2-D package D2: TextureViewAliasScenario joins this list rather than the ambient +# one for the same reason TextureViewScenario is in it - every case it carries constructs a +# glTextureView, which DirectGLES withholds unless MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 +# (ConfigLoader.cpp:213). In the ambient entry it would green-skip on every run and the gate +# would be a name in a list. set(MGL_ITEST_GLES_VIEW_FILTER - "TextureViewScenario.*:F1WireScenario.ComputeImageStoreThroughViewPreservesOtherRootLayer:F1WireScenario.TextureViewClearTargetsItsRootMipAndLayer:F1WireScenario.GenerateMipmapThroughViewKeepsOwnerLevelsAndLayersOutsideItsWindow") + "TextureViewScenario.*:TextureViewAliasScenario.*:F1WireScenario.ComputeImageStoreThroughViewPreservesOtherRootLayer:F1WireScenario.TextureViewClearTargetsItsRootMipAndLayer:F1WireScenario.GenerateMipmapThroughViewKeepsOwnerLevelsAndLayersOutsideItsWindow") gtest_discover_tests(MobileGLIntegrationTest TEST_PREFIX "DirectGLES." @@ -1930,9 +1942,22 @@ endif() # generated ctest include file verbatim and never expanded. set(MGL_ITEST_SPLIT_SERVER_PATH "${CMAKE_BINARY_DIR}/libMobileGLServer.so") +# P3b/P4b wave 2-D package D2: MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 IS ON THE SHARED GLES +# SPLIT/SPAWN/TCP LISTS, not on a private entry, and that is the deliberate reading of the plan +# row. The knob is Espryt's FEATURE opt-in (ConfigLoader.cpp:213), independent of transport: it +# advertises GL_ARB_texture_view and nothing else. Without it TextureViewAliasScenario - and +# TextureViewScenario, if it is ever registered here - green-skips in all three arms and the +# gate is a name in a list. The monolith DirectGLES lane has carried it on its own view entry +# since P4a for exactly this reason; the split arms had no equivalent. +# +# It is NOT added to MGL_ITEST_VULKAN_{SPLIT,SPAWN,TCP}_ENVIRONMENT below: the variable is read +# only by ConfigLoader's Espryt feature block, so on DirectVulkan it would be a parsed-and- +# ignored name, and tier 2's informational Magma replay of these registrations wants the two +# backends' arms to differ only where the product differs. mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1" ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) # The two counting lanes, one per transport, and they are a PAIR: exit gate E3(c) asks that the @@ -1998,9 +2023,13 @@ if (MOBILEGL_BUILD_DISAGGREGATED) # makes the test process the CLIENT and has it launch a server from MOBILEGL_IPC_SERVER_PATH # - the same path the inproc arm already carries, because under inproc the library reads it # and decides it does not need one. + # D2: the texture-view opt-in, for the reason stated over the SPLIT list above. Under spawn + # the variable has to reach BOTH processes; it does, because the server is spawned from this + # process's environment. mgl_itest_join_environment(MGL_ITEST_GLES_SPAWN_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=spawn" "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1" ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) # A single supervisor serves one session at a time. Fixture teardown also reaps a @@ -2016,7 +2045,19 @@ if (MOBILEGL_BUILD_DISAGGREGATED) --state "${MGL_TCP_STATE}") add_test(NAME TcpServer.Stop COMMAND "${Python3_EXECUTABLE}" "${MGL_TCP_FIXTURE}" stop --state "${MGL_TCP_STATE}") - set_tests_properties(TcpServer.Start PROPERTIES FIXTURES_SETUP mobilegl-tcp TIMEOUT 20) + # P3b/P4b wave 2-D package D2: THE TCP LANE'S SERVER IS PRE-STARTED BY THIS FIXTURE, not + # spawned from a client that already carries the lane environment, so a FEATURE opt-in has to + # be set here as well as on MGL_ITEST_GLES_TCP_ENVIRONMENT. tcp_server_fixture.py builds the + # supervisor's environment from os.environ (:60) and strips only the transport keys (:63-65), + # so a ctest ENVIRONMENT property on this entry reaches the server process. + # + # Measured, not assumed: without it every TextureViewAliasScenario case SKIPPED in the Tcp + # arm with "GL_ARB_texture_view not advertised" while the identical Split and Spawn arms ran + # - because under inproc and spawn the server inherits the client's environment and under tcp + # it does not. A feature knob that reaches two of three arms is the worst of the three states: + # the lane is green and tests nothing. + set_tests_properties(TcpServer.Start PROPERTIES FIXTURES_SETUP mobilegl-tcp TIMEOUT 20 + ENVIRONMENT "MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1") set_tests_properties(TcpServer.Stop PROPERTIES FIXTURES_CLEANUP mobilegl-tcp TIMEOUT 20) add_test(NAME TcpLane.AccountingControls COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/scripts/ci/tcp_lane_tools_test.py") @@ -2039,11 +2080,14 @@ if (MOBILEGL_BUILD_DISAGGREGATED) set_tests_properties(TcpLane.SupervisorProtocolControls PROPERTIES LABELS integration-tcp TIMEOUT 180 SKIP_RETURN_CODE 77 RESOURCE_LOCK mobilegl-tcp) + # D2: the texture-view opt-in on both TCP lists too, so the three shared GLES split arms + # carry one configuration. See the note over MGL_ITEST_GLES_SPLIT_ENVIRONMENT. mgl_itest_join_environment(MGL_ITEST_GLES_TCP_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=spawn" "MOBILEGL_IPC_CONTROL=${MOBILEGL_ITEST_TCP_ENDPOINT}" "MOBILEGL_IPC_DATA=stream" "MOBILEGL_IPC_REQUIRE_SAME_BUILD=1" "MOBILEGL_IPC_LOG_FORWARD=1" "MOBILEGL_IPC_TOKEN=" "MGITEST_SPLIT_LANE=1" "MGITEST_TCP_LANE=1" "MOBILEGL_ITEST_REQUIRE_GPU=1" + "MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1" ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_GLES_TCPDEVICE_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=spawn" @@ -2051,6 +2095,7 @@ if (MOBILEGL_BUILD_DISAGGREGATED) "MOBILEGL_IPC_REQUIRE_SAME_BUILD=1" "MOBILEGL_IPC_LOG_FORWARD=1" "MOBILEGL_IPC_TOKEN=${MOBILEGL_ITEST_TCP_DEVICE_TOKEN}" "MGITEST_SPLIT_LANE=1" "MGITEST_TCP_LANE=1" "MOBILEGL_ITEST_REQUIRE_GPU=1" + "MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1" ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) # ---- P7 package L: THE SAME THREE ARMS FOR MAGMA ---------------------------------------- @@ -2568,6 +2613,54 @@ if (MOBILEGL_BUILD_DISAGGREGATED) mgl_itest_register_split_arms("SyncWireScenario.*" "") + # ========================================================================================= + # P3b/P4b WAVE 2-D PACKAGE D2 - OWN BLOCK. Package D1 (XFB / readback) registers its arms in + # its own block elsewhere in this file; keep the two apart so the integrator can merge both. + # ========================================================================================= + # + # THE OWNER-KEYED EMISSION CURSOR, under all three transports. The claim is about WHICH + # emission cursor an upload through a texture VIEW lands on, and it is only interesting where + # the record actually crosses a wire: under monolith the drain's SubDataCount still moves but + # nothing downstream can disagree with the client's own dirty model, because there is one + # address space and one MipmapStorage. Under split the applier holds its own per-record + # pending-upload set, so a cursor that cleared the wrong flag produces a server-side level + # that never receives the texels - which is the failure this registration exists to reach. + # + # Every case draws and reads pixels, so each moves the client encoder's record ordinal and + # satisfies ScenarioFixture's armed-lane rule. The three GLES arm environments carry + # MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 (above); without it every case green-skips through + # TextureViewUsable() rather than failing - which is what the Tcp arm did until the fixture + # entry below got the same knob. + # + # ONE CASE IS EXCLUDED BY NAME, and it is a defect this package FOUND rather than a lane + # problem. TheOwnerUploadDrainRunsAgainAfterADrawBoundary is red on all three transports and + # green under monolith: + # + # after a view has been SAMPLED once, a later glTexSubImage through the OWNER's own name + # never becomes visible through the view. + # + # The client half is provably correct - the case's own counter assertions pass, so the + # records were emitted and the applier accepted them. The server's per-texture clean gate + # BackendTextureObject::IsDrawSyncCleanByRecord (Managers.cpp:15433) compares the VIEW's + # record.Serial and record.PendingUploads, and an owner-side resource_subdata moves only the + # OWNER's (MG_Pipe/PipeApply.cpp:1175). Nothing anywhere propagates a serial bump from a + # storage owner to the records that view it: MGPResourceDesc::ViewOf is a forward edge with + # no reverse index, and the applier reads it only in the descriptor-equality helper + # (PipeApply.cpp:666). Under monolith the same gate reads GetContentVersion() THROUGH the + # view, which TextureObjectView forwards to the owner (TextureObjectView.cpp:100-102) - the + # forwarding the split gate has no equivalent of. CONTRACT-P5E.md:387-395 states the split + # gate in full and has the same hole, so the implementation is faithful to the contract. + # + # NOT EXCLUDED FROM THE MONOLITH LANES, where it is green and is the regression net for the + # forwarding that works. Listed in notes/p34b/split-scenario-census.md and + # notes/p34b/espryt-d2.md rather than deleted (G14). The fix is a one-line-shaped change in + # MG_Pipe/PipeApply.cpp with an O(#texture records) scan behind it, or a reverse index in + # MGPipeResourceRecord - an owner's decision, not this package's, and it is outside D2's + # declared footprint. + mgl_itest_register_split_arms( + "TextureViewAliasScenario.*-TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary" + "") + # 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/Harness/TextureEmitPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.cpp new file mode 100644 index 00000000..e36ad234 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.cpp @@ -0,0 +1,55 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.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 + +#include "TextureEmitPeek.h" + +#if !defined(__ANDROID__) +#include +#if MOBILEGL_PIPE_PUSH +#include +#define MGITEST_TEXTURE_EMIT_PEEK_LIVE 1 +#endif +#endif + +namespace MGITest { + +#if defined(MGITEST_TEXTURE_EMIT_PEEK_LIVE) + + bool PeekTextureEmitCounters(TextureEmitCountersPeek* out) { + if (out == nullptr) return false; + // A SEPARATE TRANSLATION UNIT for PipeSlotPeek.h's reason, verbatim: the scenario sources + // include the GL headers with prototypes and MobileGL's umbrella header is not meant to + // meet them in one file. + auto& emitter = MobileGL::MG_Pipe::MGPipeTextureEmitterInstance(); + out->SubDataCount = static_cast(emitter.SubDataCount()); + out->RefusedSubDataCount = static_cast(emitter.RefusedSubDataCount()); + out->CreateCount = static_cast(emitter.CreateCount()); + out->RespecifyCount = static_cast(emitter.RespecifyCount()); + out->DrainListSize = static_cast(emitter.DrainListSize()); + return true; + } + + const char* TextureEmitPeekSkipReason() { return ""; } + +#else + + bool PeekTextureEmitCounters(TextureEmitCountersPeek*) { return false; } + + const char* TextureEmitPeekSkipReason() { +#if defined(__ANDROID__) + return "this module links the shipping libMobileGL.so on Android, built " + "-fvisibility=hidden, so the client emitter's counters do not resolve"; +#else + return "this is a PULL build: MGPipeTextureEmitter is compiled only under " + "MOBILEGL_PIPE_PUSH, so there is no emission cursor to read"; +#endif + } + +#endif + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.h b/MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.h new file mode 100644 index 00000000..13937234 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.h @@ -0,0 +1,64 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/TextureEmitPeek.h +// 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 +// +// THE CLIENT TEXTURE EMITTER'S OWN COUNTERS AND ITS DRAIN LIST, read from a scenario. +// +// WHY A WHITE-BOX READING EXISTS HERE AT ALL, and it is the same argument PipeApplyPeek.h makes +// for G9. The owner-keyed drain cursor (TextureEmit.h's "the drain list (D-D4)") is a statement +// about WHICH emission cursor an upload through a texture VIEW lands on. Its public-GL +// observable - the texels a later sample returns - is repaired by every other arm of the +// system: a level that never made it onto the drain list is still uploaded by the NEXT whole- +// level respecify, by a GenerateMipmap, or by the monolith sync path, and the picture comes out +// right one frame later than it should have. A scenario that only looked at pixels would +// therefore go green over a two-cursor regression on most trees and red on none of them +// reliably. The counters are where the claim actually lives: +// +// SubDataCount() how many resource_subdata records the drain put on the wire. A drain that +// ran and emitted NOTHING is the silent failure this peek exists to make +// loud - it looks exactly like a healthy drain from outside. +// DrainListSize() what is still owed after the drain. Non-zero after a validate point means +// a level bailed (no storage, no shadow, an empty box) and stayed on the +// list, which is the safe direction but not the expected one for a plain +// glTexSubImage of a texture with storage. +// +// EVERY ENTRY POINT RETURNS false, TOUCHING NOTHING, WHERE IT CANNOT LOOK, and a caller that +// gets false has learned NOTHING. Out of reach means: a PULL build (the emitter is +// `#if MOBILEGL_PIPE_PUSH` and does not exist there); and Android, where this module links the +// shipping libMobileGL.so built -fvisibility=hidden and no internal symbol resolves. It is NOT +// backend-dependent: the emitter is the CLIENT's, and the client is this process under every +// one of inproc / spawn / tcp as well as under monolith. + +#pragma once + +namespace MGITest { + + // MGPipeTextureEmitterInstance()'s cumulative counters. They are process-global and the + // library never resets them, so every caller compares a DELTA across its own window rather + // than an absolute - the same rule the PipeStats window readers follow. + struct TextureEmitCountersPeek { + // resource_subdata records the drain emitted (m_subDatas). + unsigned long long SubDataCount; + // ...of which the applier REFUSED (m_refusedSubDatas). A refusal leaves the level dirty + // and on the list, so a case that expected an emission and sees a refusal instead has a + // different finding from one that sees no emission at all. + unsigned long long RefusedSubDataCount; + // resource_create / resource_respecify, for a message that can say whether the texture + // was even described to the applier before the upload was due. + unsigned long long CreateCount; + unsigned long long RespecifyCount; + // What is STILL owed: m_drain.size() right now. Read after the validate point, this is + // the "did every dirty level get emitted" answer. + unsigned long long DrainListSize; + }; + + bool PeekTextureEmitCounters(TextureEmitCountersPeek* out); + + // Why the peek could not look, for a GTEST_SKIP message. Empty when it can. + const char* TextureEmitPeekSkipReason(); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp new file mode 100644 index 00000000..f06c5e3e --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp @@ -0,0 +1,570 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.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 - THE OWNER-KEYED EMISSION CURSOR (P3b/P4b, wave 2-D; ROADMAP's "dirty 归属反转 - +// 按存储属主键控的发射游标"). THE VERIFICATION HALF. +// +// THE CODE HALF ALREADY LANDED and this file is the gate it never had: +// +// * the drain cursor is keyed on the STORAGE OWNER - MG_Impl/Pipe/TextureEmit.h's +// NoteLevelDirty acquires a handle for whatever ITextureObject& reaches it, and what +// reaches it is the owner because TextureObjectView forwards MarkStorageDirty / +// MarkStorageDirtyRegion to the owner's methods (TextureObjectView.cpp:314-329); +// * the index remap is TextureObjectView::ToOwnerLevel / ToOwnerUploadTarget / +// ToOwnerRegionOffset (TextureObjectView.h:105-132), so a write through a level- or +// layer-restricted view names the OWNER's level and the OWNER's layer; +// * the wire half is MGPSamplerView + MGPResourceDesc::ViewOf (P5e tx2). +// +// WHAT GOES WRONG WITHOUT IT, which is why a scenario is owed rather than a unit case. A view +// and its owner share ONE set of dirty flags (the view owns no MipmapStorage at all) but would +// carry TWO emission cursors if the drain were keyed on the texture the application named. +// Whichever cursor drained first would clear the flag the other still needed, or both would +// ship the same texels twice. Neither failure is visible in the next frame's picture on most +// trees: the level that lost its record is uploaded again by the NEXT whole-level respecify, +// by a glGenerateMipmap, or by the monolith sync path, so the image is right one frame late. +// That is exactly the shape SSIM and every ordinary pixel case go green over. +// +// SO THE CLAIM IS ASSERTED ON BOTH CHANNELS AT ONCE, and neither alone is the gate +// (notes/recovered/wf2/final/part3.md 7.3, "门:新增场景,通过 view 上传、经属主采样(以及反 +// 向),跨 draw 边界各一次"): +// +// PIXELS an upload through one name must be visible through the other name, in the SAME +// frame it was issued - and the layers the view does not address must not move. +// COUNTERS MGPipeTextureEmitter::SubDataCount() must have moved across the drain, and +// DrainListSize() must be back to zero after it. A SILENTLY EMPTY DRAIN - the +// two-cursor regression's actual signature - reads as a perfectly healthy run +// from the pixel side alone, because the repair paths above hide it. +// +// THE FOUR CASES are the two directions, each in a one-drain and a two-drain shape: +// (a) upload through a layer-restricted VIEW, sample through the OWNER; +// (b) upload through the OWNER, sample through the VIEW; +// (c) (a) across a draw boundary - draw, upload, draw, upload, draw - so the drain runs +// three times and the counters are read at each one; +// (d) (b) across the same boundary. +// +// THE RED-ONCE (R-16) this file was written against: make TextureObjectView:: +// MarkStorageDirtyRegion call PipeNoteLevelDirty on ITSELF instead of forwarding to the owner - +// i.e. build the two-cursor shape the design rejects. Case (a) then goes red on stale OWNER +// texels AND on a SubDataCount that did not move, which is the pair this file exists to make +// simultaneous. Recorded in docs/Disaggregated/notes/p34b/espryt-d2.md. +// +// VIEWS ARE OPT-IN ON ESPRYT. DirectGLES withholds GL_ARB_texture_view unless +// MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 (ConfigLoader.cpp:213, Config.h:72), so every lane that +// runs this file sets it; a lane that does not green-skips through TextureViewUsable() rather +// than failing, the same shape TextureViewScenario uses. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" +#include "../Harness/TextureEmitPeek.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kSize = 64; + constexpr int kLayers = 4; + // Deliberately NOT 0: a view whose layer origin was dropped lands on layer 0, and the + // per-layer seeding below makes that land on a layer this file also reads back. + constexpr int kViewLayer = 2; + // The quad covers the whole surface; the inset keeps the assertions off the primitive + // edge, which is HeadlessGL.h's standing rule for a full-surface draw. + constexpr int kInset = 2; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +out vec2 vUv; +void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + // The VIEW's sampler: an ordinary 2D name over one of the owner's layers. + constexpr const char* kFS2D = R"(#version 330 core +in vec2 vUv; +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vUv); } +)"; + + // The OWNER's sampler: the array, with the layer named by a uniform so one program + // serves every layer read-back below. + constexpr const char* kFSArray = R"(#version 330 core +in vec2 vUv; +uniform sampler2DArray uTex; +uniform int uLayer; +out vec4 oColor; +void main() { oColor = texture(uTex, vec3(vUv, float(uLayer))); } +)"; + + struct Vertex { + float x, y; + }; + + std::string Describe(const Rgba8& c) { + return "rgba(" + std::to_string(c.r) + "," + std::to_string(c.g) + "," + std::to_string(c.b) + + "," + std::to_string(c.a) + ")"; + } + + class TextureViewAliasScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + if (!TextureViewUsable()) { + GTEST_SKIP() << "glTextureView is unavailable on backend " << Gl().BackendName() + << " (GL_ARB_texture_view not advertised). On DirectGLES that is " + "the MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 opt-in this lane " + "did not set; every lane this scenario is registered in sets it."; + return; + } + std::string error; + m_program2D = CompileProgram(kVS, kFS2D, &error); + ASSERT_NE(m_program2D, 0u) << error; + m_programArray = CompileProgram(kVS, kFSArray, &error); + ASSERT_NE(m_programArray, 0u) << error; + + static const Vertex quad[6] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, + {-1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}}; + glGenBuffers(1, &m_quadBuffer); + glBindBuffer(GL_ARRAY_BUFFER, m_quadBuffer); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr); + glBindVertexArray(0); + glDisable(GL_DEPTH_TEST); + glDisable(GL_STENCIL_TEST); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the fixture setup left a GL error behind"; + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + glBindVertexArray(0); + glBindTexture(GL_TEXTURE_2D, 0); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + for (const GLuint texture : m_textures) { + glDeleteTextures(1, &texture); + } + m_textures.clear(); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_quadBuffer != 0) glDeleteBuffers(1, &m_quadBuffer); + if (m_program2D != 0) glDeleteProgram(m_program2D); + if (m_programArray != 0) glDeleteProgram(m_programArray); + } + + // TextureViewScenario's probe, verbatim in intent: a trivial same-format full-range + // view. A backend that simply does not have the feature skips rather than failing + // every case in the file. + bool TextureViewUsable() { + GLuint storage = 0; + glGenTextures(1, &storage); + glBindTexture(GL_TEXTURE_2D, storage); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 1, 1); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint view = 0; + glGenTextures(1, &view); + while (glGetError() != GL_NO_ERROR) { + } + glTextureView(view, GL_TEXTURE_2D, storage, GL_RGBA8, 0, 1, 0, 1); + const bool usable = glGetError() == GL_NO_ERROR; + glDeleteTextures(1, &view); + glDeleteTextures(1, &storage); + return usable; + } + + GLuint MakeTexture() { + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + return texture; + } + + // An immutable RGBA8 array with NEAREST filtering and one level, seeded per layer by + // CPU SUB-IMAGE rather than by rendering. Seeding through the GPU would additionally + // depend on a CPU sub-image reaching a layer whose content the GPU wrote, which is a + // different question and would make a failure here unattributable - the reason + // TextureViewScenario.cpp:717-721 gives for the same choice. + GLuint MakeSeededArray() { + const GLuint texture = MakeTexture(); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kSize, kSize, kLayers); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + for (int layer = 0; layer < kLayers; ++layer) { + UploadLayerThroughOwner(texture, layer, LayerSeed(layer)); + } + return texture; + } + + static Rgba8 LayerSeed(int layer) { + return Rgba8{static_cast(10 + layer * 20), + static_cast(200 - layer * 20), 30, 255}; + } + + static std::vector Fill(Rgba8 colour) { + std::vector texels(static_cast(kSize) * kSize * 4); + for (std::size_t i = 0; i < texels.size(); i += 4) { + texels[i + 0] = colour.r; + texels[i + 1] = colour.g; + texels[i + 2] = colour.b; + texels[i + 3] = colour.a; + } + return texels; + } + + void UploadLayerThroughOwner(GLuint owner, int layer, Rgba8 colour) { + const std::vector texels = Fill(colour); + glBindTexture(GL_TEXTURE_2D_ARRAY, owner); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kSize, kSize, 1, GL_RGBA, + GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + } + + // THE VIEW-SIDE WRITE. A single-layer GL_TEXTURE_2D view of the array: its own level 0 + // is the owner's level , and its own layer 0 is the owner's layer + // kViewLayer. glTexSubImage2D here is what ToOwnerRegionOffset has to move. + void UploadThroughView(GLuint view, Rgba8 colour) { + const std::vector texels = Fill(colour); + glBindTexture(GL_TEXTURE_2D, view); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kSize, kSize, GL_RGBA, GL_UNSIGNED_BYTE, + texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + } + + GLuint MakeLayerView(GLuint owner) { + const GLuint view = MakeTexture(); + glTextureView(view, GL_TEXTURE_2D, owner, GL_RGBA8, 0, 1, kViewLayer, 1); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)) + << "2D_ARRAY -> 2D is a legal view pair"; + glBindTexture(GL_TEXTURE_2D, view); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glBindTexture(GL_TEXTURE_2D, 0); + return view; + } + + // ---- the two sampling draws. Both go to the default framebuffer, which is what + // makes them ordinary draws that reach the validate point and run the drain. ---- + + Image SampleThroughTheView(GLuint view) { + BindDefaultFramebuffer(); + glViewport(0, 0, Gl().Width(), Gl().Height()); + glUseProgram(m_program2D); + glUniform1i(glGetUniformLocation(m_program2D, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, view); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 6); + return ReadPixels(Gl().Width(), Gl().Height()); + } + + Image SampleThroughTheOwner(GLuint owner, int layer) { + BindDefaultFramebuffer(); + glViewport(0, 0, Gl().Width(), Gl().Height()); + glUseProgram(m_programArray); + glUniform1i(glGetUniformLocation(m_programArray, "uTex"), 0); + glUniform1i(glGetUniformLocation(m_programArray, "uLayer"), layer); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D_ARRAY, owner); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 6); + return ReadPixels(Gl().Width(), Gl().Height()); + } + + // Whole-region rather than a spot check, for the reason HeadlessGL.h gives: three of + // four vertices carrying stale data still paints a correct centre pixel. + void ExpectWholeSurface(const Image& image, Rgba8 expected, const char* what) { + int offenders = 0; + Rgba8 firstOffender{}; + int firstX = -1; + int firstY = -1; + const int x1 = image.Width() - kInset; + const int y1 = image.Height() - kInset; + int total = 0; + for (int y = kInset; y < y1; ++y) { + for (int x = kInset; x < x1; ++x) { + ++total; + const Rgba8 actual = image.At(x, y); + const bool ok = std::abs(int(actual.r) - int(expected.r)) <= 2 && + std::abs(int(actual.g) - int(expected.g)) <= 2 && + std::abs(int(actual.b) - int(expected.b)) <= 2 && + std::abs(int(actual.a) - int(expected.a)) <= 2; + if (!ok) { + if (offenders == 0) { + firstOffender = actual; + firstX = x; + firstY = y; + } + ++offenders; + } + } + } + EXPECT_EQ(offenders, 0) << what << ": " << offenders << " of " << total + << " pixels disagree; first at (" << firstX << ", " << firstY + << ") is " << Describe(firstOffender) << ", expected " + << Describe(expected) << " +/- 2"; + } + + // ---- the emitter half ------------------------------------------------------------- + // + // WHY "THE EMITTER NEVER SAW ANY TEXTURE" IS A SKIP AND "IT SAW TEXTURES BUT EMITTED + // NO SUB-DATA" IS RED, and the distinction is the whole value of the reading. In a + // PULL build the emitter does not exist, and in a lane whose MOBILEGL_PIPE_PUSH mask + // has the texture-resource bit clear (D-K2's rows; the 0x5ff / 0x7ff refusal lanes) + // it exists but is deliberately not wired - in both the counters are flat and nothing + // is being claimed. The moment ANY create or respecify has been counted, the emitter + // is live for this process, and a drain that then put nothing on the wire is the + // two-cursor regression itself. + struct EmitWindow { + bool live = false; + TextureEmitCountersPeek before{}; + TextureEmitCountersPeek after{}; + unsigned long long SubDataDelta() const { return after.SubDataCount - before.SubDataCount; } + unsigned long long RefusedDelta() const { + return after.RefusedSubDataCount - before.RefusedSubDataCount; + } + unsigned long long DescribedDelta() const { + return (after.CreateCount - before.CreateCount) + + (after.RespecifyCount - before.RespecifyCount); + } + }; + + bool OpenEmitWindow(EmitWindow& window) { + window.live = PeekTextureEmitCounters(&window.before); + return window.live; + } + + void CloseEmitWindow(EmitWindow& window) { + if (!window.live) return; + window.live = PeekTextureEmitCounters(&window.after); + } + + // The emitter is armed in THIS process if it has ever described a texture to the + // applier. Checked on the window's `after` reading, because the seeding uploads that + // arm it happen inside the window. + bool EmitterIsArmed(const EmitWindow& window) const { + return window.live && (window.after.CreateCount != 0 || window.after.RespecifyCount != 0); + } + + void ExpectTheDrainEmitted(const EmitWindow& window, const char* what) { + if (!window.live) { + RecordProperty("emit_counters", TextureEmitPeekSkipReason()); + return; + } + if (!EmitterIsArmed(window)) { + // Not "not asserted": a lane where the emitter never described a single + // texture cannot have an emission cursor at all, so there is no cursor claim + // to make. Recorded so a run where the whole file quietly stopped asserting + // is visible in the ctest XML rather than invisible. + RecordProperty("emit_cursor", "the client texture emitter described no texture in " + "this process - the texture-resource subsystem bit " + "is clear in this lane"); + return; + } + EXPECT_GT(window.SubDataDelta(), 0u) + << what << ": the client texture emitter described " + << window.DescribedDelta() + << " texture(s) across this draw and put NO resource_subdata on the wire. A " + "drain that runs and emits nothing is exactly what a per-NAME emission " + "cursor produces when a view and its owner each hold one: whichever cursor " + "drained first cleared the dirty flag the other still needed. The picture " + "can still be right - the next whole-level respecify, a glGenerateMipmap or " + "the monolith sync path re-uploads the level one frame later - which is why " + "this counter and not the pixels is the load-bearing half here."; + EXPECT_EQ(window.RefusedDelta(), 0u) + << what << ": the applier REFUSED " << window.RefusedDelta() + << " of this drain's resource_subdata records. A refusal leaves the level dirty " + "and on the drain list (D-D5 step 1), which is the safe direction but not " + "the expected one for a glTexSubImage of a texture that has storage."; + EXPECT_EQ(window.after.DrainListSize, 0u) + << what << ": " << window.after.DrainListSize + << " (storage owner, upload target, level) key(s) are STILL owed after the " + "validate point. A level that bailed - no storage, no shadow, an empty box - " + "stays on the list; for this workload every level has immutable storage and " + "a non-empty dirty box, so nothing should have bailed."; + } + + GLuint m_program2D = 0; + GLuint m_programArray = 0; + GLuint m_vao = 0; + GLuint m_quadBuffer = 0; + std::vector m_textures; + }; + + // ------------------------------------------------------------------------------------ + // (a) UPLOAD THROUGH THE VIEW, SAMPLE THROUGH THE OWNER. + // + // The direction that pins ToOwnerRegionOffset: the view's own layer 0 is the owner's + // layer kViewLayer, so a remap that was dropped marks - and uploads - the owner's layer 0 + // instead, and the two reads below catch it from both sides at once. + // ------------------------------------------------------------------------------------ + TEST_F(TextureViewAliasScenario, SubImageThroughAViewIsSampledThroughTheOwner) { + if (!Ready() || IsSkipped()) return; + + constexpr Rgba8 kPainted{255, 0, 255, 255}; + EmitWindow window; + OpenEmitWindow(window); + + const GLuint owner = MakeSeededArray(); + const GLuint view = MakeLayerView(owner); + UploadThroughView(view, kPainted); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "writing through the view raised a GL error"; + + const Image throughOwner = SampleThroughTheOwner(owner, kViewLayer); + CloseEmitWindow(window); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the sampling draw raised a GL error"; + + ExpectWholeSurface(throughOwner, kPainted, + "the owner's layer kViewLayer must carry the texels written through " + "the view's own layer 0"); + ExpectTheDrainEmitted(window, "upload through a layer-restricted view"); + + // THE NEGATIVE HALF, and it is what separates "the remap was dropped" from "the + // upload never arrived": a lost layer origin writes the owner's layer 0, which this + // read would see as an untouched layer that moved. + const Image layerZero = SampleThroughTheOwner(owner, 0); + ExpectWholeSurface(layerZero, LayerSeed(0), + "the owner's layer 0 is outside the view's window and must not have " + "been written"); + } + + // ------------------------------------------------------------------------------------ + // (b) UPLOAD THROUGH THE OWNER, SAMPLE THROUGH THE VIEW. + // + // The reverse direction, and the one that fails when the VIEW holds a cursor of its own: + // the owner's cursor drains and clears the shared flag, and the view's sampler then + // resolves a resource the record never reached. + // ------------------------------------------------------------------------------------ + TEST_F(TextureViewAliasScenario, SubImageThroughTheOwnerIsSampledThroughTheView) { + if (!Ready() || IsSkipped()) return; + + constexpr Rgba8 kPainted{0, 0, 255, 255}; + EmitWindow window; + OpenEmitWindow(window); + + const GLuint owner = MakeSeededArray(); + const GLuint view = MakeLayerView(owner); + UploadLayerThroughOwner(owner, kViewLayer, kPainted); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "writing through the owner raised a GL error"; + + const Image throughView = SampleThroughTheView(view); + CloseEmitWindow(window); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the sampling draw raised a GL error"; + + ExpectWholeSurface(throughView, kPainted, + "the view must read the texels written through the owner's own name " + "into the layer the view addresses"); + ExpectTheDrainEmitted(window, "upload through the storage owner"); + } + + // ------------------------------------------------------------------------------------ + // (c) THE SAME, ACROSS DRAW BOUNDARIES, so the drain runs more than once. + // + // WHY THE SECOND AND THIRD DRAINS ARE THE POINT. The first drain of a texture's life + // carries the whole level anyway - the storage definition made it dirty - so a cursor + // that is keyed on the wrong object still ships the right texels by accident. It is the + // NEXT drain, where only the sub-region written since the last validate point is owed, + // that can lose a record and look healthy. + // ------------------------------------------------------------------------------------ + TEST_F(TextureViewAliasScenario, TheViewUploadDrainRunsAgainAfterADrawBoundary) { + if (!Ready() || IsSkipped()) return; + + constexpr Rgba8 kFirst{255, 0, 255, 255}; + constexpr Rgba8 kSecond{0, 255, 255, 255}; + + const GLuint owner = MakeSeededArray(); + const GLuint view = MakeLayerView(owner); + + // Drain 1: the storage definition's own upload. Read only to close the window. + const Image seeded = SampleThroughTheOwner(owner, kViewLayer); + ExpectWholeSurface(seeded, LayerSeed(kViewLayer), "the seeded layer before any view write"); + Gl().EndFrame(); + + EmitWindow second; + OpenEmitWindow(second); + UploadThroughView(view, kFirst); + const Image afterFirst = SampleThroughTheOwner(owner, kViewLayer); + CloseEmitWindow(second); + ExpectWholeSurface(afterFirst, kFirst, "the first view write, through the owner"); + ExpectTheDrainEmitted(second, "the SECOND drain (first view write after a draw boundary)"); + Gl().EndFrame(); + + EmitWindow third; + OpenEmitWindow(third); + UploadThroughView(view, kSecond); + const Image afterSecond = SampleThroughTheOwner(owner, kViewLayer); + CloseEmitWindow(third); + ExpectWholeSurface(afterSecond, kSecond, "the second view write, through the owner"); + ExpectTheDrainEmitted(third, "the THIRD drain (second view write after a draw boundary)"); + + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the workload left a GL error behind"; + const Image layerZero = SampleThroughTheOwner(owner, 0); + ExpectWholeSurface(layerZero, LayerSeed(0), + "two writes through the view must still have left layer 0 alone"); + } + + // ------------------------------------------------------------------------------------ + // (d) the owner direction across the same boundary. + // ------------------------------------------------------------------------------------ + TEST_F(TextureViewAliasScenario, TheOwnerUploadDrainRunsAgainAfterADrawBoundary) { + if (!Ready() || IsSkipped()) return; + + constexpr Rgba8 kFirst{0, 0, 255, 255}; + constexpr Rgba8 kSecond{255, 255, 0, 255}; + + const GLuint owner = MakeSeededArray(); + const GLuint view = MakeLayerView(owner); + + const Image seeded = SampleThroughTheView(view); + ExpectWholeSurface(seeded, LayerSeed(kViewLayer), + "the view must read its layer's seed before any owner write"); + Gl().EndFrame(); + + EmitWindow second; + OpenEmitWindow(second); + UploadLayerThroughOwner(owner, kViewLayer, kFirst); + const Image afterFirst = SampleThroughTheView(view); + CloseEmitWindow(second); + ExpectWholeSurface(afterFirst, kFirst, "the first owner write, through the view"); + ExpectTheDrainEmitted(second, "the SECOND drain (first owner write after a draw boundary)"); + Gl().EndFrame(); + + EmitWindow third; + OpenEmitWindow(third); + UploadLayerThroughOwner(owner, kViewLayer, kSecond); + const Image afterSecond = SampleThroughTheView(view); + CloseEmitWindow(third); + ExpectWholeSurface(afterSecond, kSecond, "the second owner write, through the view"); + ExpectTheDrainEmitted(third, "the THIRD drain (second owner write after a draw boundary)"); + + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the workload left a GL error behind"; + } + + } // namespace +} // namespace MGITest diff --git a/docs/Disaggregated/notes/p34b/espryt-d2.md b/docs/Disaggregated/notes/p34b/espryt-d2.md new file mode 100644 index 00000000..dfe894fc --- /dev/null +++ b/docs/Disaggregated/notes/p34b/espryt-d2.md @@ -0,0 +1,100 @@ +# P3b/P4b wave 2-D package D2 — the Espryt stream, part 2 + +The verification gates P3b/P4b owes, plus the texture / program split-scenario census. +Base `7ed5da52` (`feat/disaggregated`), worktree `~/w7/p7-espryt-d2`, branch `p7/espryt-d2`. + +Package D1 owns `MG_Backend/DirectGLES/{DirectGLES.cpp,Managers.cpp}`, the XFB/readback +scenarios and their split-arm registrations. Nothing here touches those; every +`MG_IntegrationTest/CMakeLists.txt` edit sits in a block headed `package D2`. + +--- + +## Slice 1 — `TextureViewAliasScenario` + +The verification half of "按存储属主键控的发射游标与 view 索引重映射". The code half landed in +P4a/P5e: the drain cursor is owner-keyed (`MG_Impl/Pipe/TextureEmit.h`, `NoteLevelDirty` + +`DrainTextureSubData`), the index remap is `TextureObjectView::ToOwnerLevel` / +`ToOwnerUploadTarget` / `ToOwnerRegionOffset`, the wire half is `MGPSamplerView` + +`MGPResourceDesc::ViewOf`. + +Four cases, each asserting on TWO channels at once — sampled texels AND +`MGPipeTextureEmitter::SubDataCount()` / `RefusedSubDataCount()` / `DrainListSize()`, read through +the new `Harness/TextureEmitPeek.{h,cpp}`: + +| case | direction | drains | +|---|---|---| +| `SubImageThroughAViewIsSampledThroughTheOwner` | view upload → owner sample | 1 | +| `SubImageThroughTheOwnerIsSampledThroughTheView` | owner upload → view sample | 1 | +| `TheViewUploadDrainRunsAgainAfterADrawBoundary` | view upload → owner sample | 3 | +| `TheOwnerUploadDrainRunsAgainAfterADrawBoundary` | owner upload → view sample | 3 | + +### Red-once (R-16) + +`TextureObjectView::MarkStorageDirtyRegion` patched to call `PipeNoteLevelDirty` on ITSELF instead +of forwarding to the storage owner — the two-cursor shape the design rejects. + +``` +DirectGLES.TextureViewAliasScenario.TheViewUploadDrainRunsAgainAfterADrawBoundary FAILED + the first view write, through the owner: 11408 of 11408 pixels disagree; + first at (2, 2) is rgba(50,160,30,255), expected rgba(255,0,255,255) + Expected: (window.SubDataDelta()) > (0u), actual: 0 vs 0 + the SECOND drain (first view write after a draw boundary): the client texture emitter + described 0 texture(s) across this draw and put NO resource_subdata on the wire. + ... and the same pair again for the THIRD drain. +``` + +**Both channels red simultaneously**, which is the property the file was written for: stale texels +AND a drain that emitted nothing. + +**Deviation from the plan's prescription, recorded rather than papered over.** The plan says +"case (a) must go red". It does not — case (c) does. Case (a) creates the texture, writes through +the view and drains ONCE, and that first drain of a texture's life carries the whole level anyway +(the storage definition already marked it dirty), so a cursor keyed on the wrong object still +ships the right texels by accident. It is the SECOND and THIRD drains, where only the sub-region +written since the last validate point is owed, that can lose a record. Cases (c)/(d) exist for +exactly that and are where the red lands. The scenario header states this; case (a) is kept +because it is the cheapest statement of the remap itself. + +### A defect this slice FOUND: owner writes are invisible through a sampled view under split + +`TheOwnerUploadDrainRunsAgainAfterADrawBoundary` is **red on inproc, spawn and tcp, green under +monolith**, on the unmodified tree. + +Once a view has been SAMPLED, a later `glTexSubImage` through the OWNER's own name never becomes +visible through the view. The client half is provably correct — the case's own counter assertions +pass, so the records were emitted and the applier accepted them. + +* The server's per-texture clean gate `BackendTextureObject::IsDrawSyncCleanByRecord` + (`Managers.cpp:15433`) compares the VIEW's `record.Serial` and `record.PendingUploads`. +* An owner-side `resource_subdata` moves only the OWNER's (`MG_Pipe/PipeApply.cpp:1175`). +* Nothing propagates a serial bump from a storage owner to the records that view it. + `MGPResourceDesc::ViewOf` is a forward edge with no reverse index, and the applier reads it only + in the descriptor-equality helper (`PipeApply.cpp:666`). +* Under monolith the same gate reads `GetContentVersion()` THROUGH the view, which + `TextureObjectView` forwards to the owner (`TextureObjectView.cpp:100-102`). The split gate has + no equivalent of that forwarding. +* `MG_Remote/CONTRACT-P5E.md:387-395` states the split gate in full and has the same hole, so the + implementation is faithful to the contract. **The contract is what is wrong.** + +`KHR-GL43.texture_view.coherency` is exactly this test and reads Pass in the CTS baseline — which +is a **monolith** reading (`notes/p7/device-window-1/CTS-base/README.md:16`). + +**Not fixed here.** The fix is either at the gate (`Managers.cpp` — package D1's file) or at the +publisher (`MG_Pipe/PipeApply.cpp`'s `ApplyTextureUpload`, outside D2's declared footprint), and +the publisher-side form needs either an O(#texture records) scan per `resource_subdata` or a new +reverse index on `MGPipeResourceRecord` — a design decision with a contract amendment attached. +The case is **excluded by name from the three split arms** with the cause stated in the CMake +comment, kept in the monolith lanes where it is the regression net for the forwarding that works, +and listed in `split-scenario-census.md`. G14: the name still exists in every lane. + +### The tcp arm was green-skipping everything + +`MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1` on `MGL_ITEST_GLES_TCP_ENVIRONMENT` is not enough: the tcp +lane's server is **pre-started by the `TcpServer.Start` fixture**, not spawned from a client that +carries the lane environment, so the server withheld `GL_ARB_texture_view` and all four cases +SKIPPED while the identical Split and Spawn arms ran. Fixed by putting the knob on the fixture +entry's own `ENVIRONMENT` property (`tcp_server_fixture.py` builds the supervisor's environment +from `os.environ` and strips only the transport keys). A feature knob that reaches two arms of +three is the worst of the three states: the lane is green and tests nothing. + +---