diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index ae05af20..9b1e9d82 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1937,17 +1937,43 @@ namespace MobileGL::MG_Backend::DirectGLES { if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { splitRes = target.handle; splitResource = BufferImpl::FindBufferResourceForHandle(splitRes); - const Uint8* hostBytes = splitResource != nullptr ? splitResource->hostBytes : nullptr; - if (splitResource == nullptr || hostBytes == nullptr) { + if (splitResource == nullptr) { + // THE ONLY DISCARD LEFT, and it is the impossible one: this target + // was recorded by BindBufferRange/Base against a handle the applier + // had already minted a twin for, so no twin here means the slot + // table and the capture list disagree about one resource. There is + // no backendId to upload into either, so there is nothing to do but + // name it. MGLOG_E_ONCE("EndTransformFeedback: a scattered capture target (handle {%u,%u}) has " - "no server shadow to read the pre-capture bytes from; its capture is " - "discarded", + "no server twin at all; its capture is discarded", splitRes.Slot, splitRes.Gen); continue; } - BufferImpl::RequireStagedCoverage(*splitResource, hostBytes, target.start, target.end, - "xfb_scatter_pre_capture"); - Memcpy(staged.data(), hostBytes + target.start, rangeBytes); + const Uint8* hostBytes = splitResource->hostBytes; + // A NULL SHADOW IS NOT A LOST CAPTURE. It is the ORPHANED store + // (glBufferData(size, NULL) with no resource_subdata since), which + // M-3 declares legal: every byte the application has not staged is + // UNDEFINED by its own declaration, and `staged` is already + // value-initialised to zero - which is byte for byte what the monolith + // arm scatters over when it reads a fresh MappedData(). Skipping the + // Memcpy is therefore the WHOLE of the difference; skipping the TARGET + // threw the varyings away and left the application's buffer holding its + // pre-draw bytes under GL_NO_ERROR, the silent-loss class this file's + // preamble exists to close. The streaming idiom that hits it - orphan, + // partial glBufferSubData, capture into the rest - is ordinary. + if (hostBytes != nullptr) { + // Managers.cpp's pool-reuse ladder guards its whole-store + // MGL_SERVER_STAGED_REQUIRE with exactly this predicate and for + // exactly this reason: for a store whose content the application + // SUPPLIED a coverage gap is a missing record and zero-filling past + // it is silent data loss (Fatal by name); for an orphaned one the + // gap is its own undefined content and the read is legal. + if (BufferImpl::ResourceContentIsDeclared(splitRes)) { + BufferImpl::RequireStagedCoverage(*splitResource, hostBytes, target.start, + target.end, "xfb_scatter_pre_capture"); + } + Memcpy(staged.data(), hostBytes + target.start, rangeBytes); + } } else #endif Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 0d8d315f..7112972f 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -2133,23 +2133,10 @@ namespace MobileGL::MG_Backend::DirectGLES { const auto* record = ResourceRecordOf(res); return record != nullptr ? static_cast(record->Desc.Width) : 0; } -#if MOBILEGL_BUILD_DISAGGREGATED - // M-3's rule for the two WHOLE-STORE readers (v1 round 3). The descriptor is the - // application's own statement about the store: HasDefinedContent set means it SUPPLIED - // the content (glBufferData(size, data)), which under split arrives as resource_subdata - // records behind the respecify (table 1 row 19) - so a coverage gap at the draw is a - // MISSING RECORD and the zero-fill past the coverage is not the application's bytes. - // Clear means it ORPHANED the store (glBufferData(size, NULL), glBufferStorage(NULL)): - // every byte it has not staged since is UNDEFINED by its own declaration, the streaming - // idiom (orphan, partial glBufferSubData, draw) is the ordinary case, and uploading the - // shadow's zero-fill for the rest is exactly what the monolith arm uploads from - // MappedData(). Round 2 refused both shapes and aborted six LargeArenaAdoption / - // ResourceSubsystemControl entries on the joint by name (v1-v3.md 6). - Bool ResourceContentIsDeclared(MG_Pipe::MGPipeHandle res) { - const auto* record = ResourceRecordOf(res); - return record != nullptr && record->Desc.HasDefinedContent != 0; - } -#endif + // ResourceContentIsDeclared MOVED OUT OF THIS ANONYMOUS NAMESPACE (P3b/P4b espryt + // D1 slice 1) to sit beside RequireStagedCoverage at namespace scope: the XFB + // scatter in DirectGLES.cpp has to ask the same M-3 question this file's pool-reuse + // ladder asks, and the two must not be able to answer it differently. void Ops_H_Create(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc) { (void)res; @@ -3052,6 +3039,27 @@ namespace MobileGL::MG_Backend::DirectGLES { SizeT end, const char* site) { ServerStaged().RequireCoverage(&resource, hostBase, start, end, site); } + + // M-3's rule for the WHOLE-STORE readers (v1 round 3). The descriptor is the + // application's own statement about the store: HasDefinedContent set means it SUPPLIED + // the content (glBufferData(size, data)), which under split arrives as resource_subdata + // records behind the respecify (table 1 row 19) - so a coverage gap at the draw is a + // MISSING RECORD and the zero-fill past the coverage is not the application's bytes. + // Clear means it ORPHANED the store (glBufferData(size, NULL), glBufferStorage(NULL)): + // every byte it has not staged since is UNDEFINED by its own declaration, the streaming + // idiom (orphan, partial glBufferSubData, draw) is the ordinary case, and uploading the + // shadow's zero-fill for the rest is exactly what the monolith arm uploads from + // MappedData(). Round 2 refused both shapes and aborted six LargeArenaAdoption / + // ResourceSubsystemControl entries on the joint by name (v1-v3.md 6). + // + // AT NAMESPACE SCOPE, not in the anonymous namespace it was written in (P3b/P4b espryt + // D1 slice 1): the XFB scatter guards ITS whole-store read with the same question, and + // a second spelling of "did the application declare these bytes" is how the pool-reuse + // ladder and the scatter end up disagreeing about one buffer. + Bool ResourceContentIsDeclared(MG_Pipe::MGPipeHandle res) { + const auto* record = ResourceRecordOf(res); + return record != nullptr && record->Desc.HasDefinedContent != 0; + } #endif MG_Pipe::MGPipeHandle HandleOfBuffer(const MG_State::GLState::BufferObject* bufferObject) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index e3a3404e..812d9c0d 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -1029,6 +1029,16 @@ namespace MobileGL::MG_Backend::DirectGLES { // resolver. A no-op for a base that is not this resource's server shadow. void RequireStagedCoverage(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, SizeT end, const char* site); + + // M-3's WHOLE-STORE predicate, and the GUARD every caller of RequireStagedCoverage over + // a whole-store read has to carry: true when the application DECLARED the store's + // content (glBufferData(size, data)), so a coverage gap is a missing record; false when + // it ORPHANED the store (glBufferData(size, NULL)), where every byte it has not staged + // since is undefined by its own declaration and the shadow's zero-fill is exactly what + // the monolith arm would have uploaded from MappedData(). Managers.cpp's pool-reuse + // ladder and DirectGLES.cpp's XFB scatter are its two callers and they must not be able + // to answer it differently. + Bool ResourceContentIsDeclared(MG_Pipe::MGPipeHandle res); #endif // MONOLITH GLUE, and named as such: the handle of a resource this backend is looking diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 72636fce..d92f44c7 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -2274,6 +2274,22 @@ if (MOBILEGL_BUILD_DISAGGREGATED) mgl_itest_register_split_arms("TriangleScenario.*" "") mgl_itest_register_split_arms("PersistentCoherentMapScenario.*" "") + # ---- P3b/P4b espryt D1 slice 1: THE ORPHANED XFB CAPTURE TARGET ------------------------- + # + # The two cases whose whole subject is the split arm. A scattered capture is a + # read-modify-write of the destination's pre-capture bytes, and under a transport those bytes + # are the SERVER's staged shadow - so a buffer the application orphaned + # (glBufferData(size, NULL)) and only partly uploaded is the one shape where the shadow + # legitimately cannot answer for the whole range. The scatter used to read that as "discard + # this target": silently for a wholly orphaned store, and as + # Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"} for a partly uploaded one. + # + # NARROW FILTER ON PURPOSE. The rest of XfbAfterClipDistanceScenario is a DirectVulkan + # allocation-pattern regression (its own header says so) and has never been run under a + # transport; slice 2 registers the whole scenario once the writeback event it posts is + # sliced. Registering it wholesale here would mix that slice's reds into this one's. + mgl_itest_register_split_arms("XfbAfterClipDistanceScenario.OrphanedCaptureTarget*" "Xfb.") + # ---- P5e fix1: THE PUSH-MONOLITH ARM, INSIDE THE GATED SET (ID-107) ---------------------- # diff --git a/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp index 6a75a178..c94080fe 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp @@ -331,9 +331,43 @@ void main (void) constexpr unsigned kSkipComponentCount = 4 * 4 + (1 + 2 + 3 + 4 + 1 + 2); // 16 values + 13 skipped constexpr unsigned kSkipVertexCount = 6; + // HOW THE CAPTURE BUFFER'S STORE IS DECLARED, which is the whole subject of the two + // orphan cases at the end of this file (P3b/P4b espryt D1 slice 1). + // + // glBufferData(size, data) DECLARES the content: the application supplied every byte, + // and under a transport those bytes reach the server as resource_subdata records behind + // the respecify, so a gap in the staged coverage is a MISSING RECORD. glBufferData(size, + // NULL) ORPHANS the store instead: the application has said, in the API, that every byte + // it does not upload afterwards is UNDEFINED. Both are ordinary - "orphan, upload part of + // it, capture into it" is the streaming idiom - and the scatter is a read-modify-write, so + // the two shapes are the difference between "read the old bytes" and "there are no old + // bytes to read, and the API says nobody may look". + enum class CaptureStoreShape { + Declared, // glBufferData(size, prefill): every byte declared and staged. + OrphanedPartial, // glBufferData(size, NULL) + glBufferSubData over the FIRST HALF. + OrphanedWhole, // glBufferData(size, NULL) and nothing staged at all. + }; + + // The floats the OrphanedPartial shape uploads: the first half of the records, so the + // buffer has a staged region and an unstaged one and the assertions can tell them apart. + constexpr unsigned kOrphanStagedFloats = (kSkipVertexCount / 2) * kSkipComponentCount; + + // Is this component's offset WITHIN one record a captured varying rather than one of the + // gl_SkipComponents holes? The holes keep whatever the destination held, which past the + // staged region is undefined by the application's own declaration and must not be + // asserted; the varyings are the capture and must be there whatever the store's shape. + bool SkipComponentsIndexIsCaptured(unsigned indexInRecord) { + static const unsigned kValueOffsets[4] = {1, 8, 17, 25}; + for (const unsigned offset : kValueOffsets) { + if (indexInRecord >= offset && indexInRecord < offset + 4) return true; + } + return false; + } + // Runs skip_components and reports what came back. `outCaptured` is the raw // readback so a failure can say whether anything was written at all. - void RunSkipComponentsCapture(std::vector& outCaptured, std::string* buildLog) { + void RunSkipComponentsCapture(std::vector& outCaptured, std::string* buildLog, + CaptureStoreShape shape = CaptureStoreShape::Declared) { outCaptured.clear(); const GLuint program = BuildProgram(kXfbVertexSource, kXfbFragmentSource, SkipComponentsVaryings(), @@ -376,7 +410,21 @@ void main (void) prefill[i] = -1.0f - static_cast(i); } glBindBuffer(GL_ARRAY_BUFFER, captureBuffer); - glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW); + switch (shape) { + case CaptureStoreShape::Declared: + glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW); + break; + case CaptureStoreShape::OrphanedPartial: + // The streaming idiom: orphan the store, then upload only the part the + // application cares about keeping. + glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_DRAW); + glBufferSubData(GL_ARRAY_BUFFER, 0, + static_cast(sizeof(float) * kOrphanStagedFloats), prefill.data()); + break; + case CaptureStoreShape::OrphanedWhole: + glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_DRAW); + break; + } glBindBuffer(GL_ARRAY_BUFFER, 0); glEnable(GL_RASTERIZER_DISCARD); @@ -468,6 +516,43 @@ void main (void) return ::testing::AssertionSuccess(); } + // The orphaned store's weaker contract, checked exactly as far as the API allows. + // + // Below `stagedFloats` the destination's bytes are the application's - it uploaded them - + // so the FULL assertion holds: the varyings are the capture and the gl_SkipComponents + // holes still carry the pre-fill, which is the read-modify-write actually having read + // something. Above it the application declared the bytes undefined, so only the captured + // varyings are asserted; the holes are whatever the server had and are nobody's to check. + // + // WHAT THIS CATCHES, and it is the whole point of the two cases: the failure mode is not a + // wrong number, it is the pre-fill surviving where a varying belongs - the capture target + // silently dropped and the buffer left holding its pre-draw bytes under GL_NO_ERROR. + ::testing::AssertionResult CheckSkipComponentsOrphaned(const std::vector& captured, + unsigned stagedFloats) { + const std::vector expected = SkipComponentsExpected(); + if (captured.size() != expected.size()) { + return ::testing::AssertionFailure() + << "readback size " << captured.size() << " != " << expected.size(); + } + for (std::size_t i = 0; i < expected.size(); ++i) { + const bool isCaptured = + SkipComponentsIndexIsCaptured(static_cast(i) % kSkipComponentCount); + if (i >= stagedFloats && !isCaptured) continue; + if (std::fabs(captured[i] - expected[i]) > 0.0125f) { + ::testing::AssertionResult failure = ::testing::AssertionFailure(); + failure << (isCaptured ? "captured varying" : "preserved hole") << " at index " << i + << (i < stagedFloats ? " (inside the uploaded region)" : " (past the uploaded region)") + << ": got " << captured[i] << ", expected " << expected[i]; + if (isCaptured && std::fabs(captured[i] - (-1.0f - static_cast(i))) <= 0.0125f) { + failure << " - this component is still the PRE-FILL, so the capture never reached this " + "target at all"; + } + return failure; + } + } + return ::testing::AssertionSuccess(); + } + // The harness turns "no context came up" into a clean skip, and a skip is // indistinguishable from a pass in a ctest summary. For this scenario that // is a hole rather than a courtesy: the defect it pins is DirectVulkan's @@ -502,6 +587,46 @@ void main (void) } }; + // ------------------------------------------- P3b/P4b espryt D1 slice 1: orphaned targets + // + // AN ORPHANED CAPTURE TARGET IS A LEGAL TARGET, and under a transport it was being thrown + // away. The scattered capture is a read-modify-write of the destination's PRE-CAPTURE + // bytes (the gl_SkipComponents holes have to keep them), and under split those bytes are + // the SERVER's staged shadow rather than a frontend MappedData(). A buffer the + // application orphaned and then only partly uploaded has no staged bytes for the rest - + // legitimately, because it declared them undefined - and the scatter read that as "this + // target has no pre-capture bytes, discard it", which is a total loss of the varyings + // under GL_NO_ERROR. The whole-store case (no upload at all) discarded silently; the + // partial one asked the staged-coverage assertion for a range wider than anything staged + // and died on Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"}. + // + // The monolith arm has always been right here: `staged` is a value-initialised + // Vector, so an unreadable source means the scatter runs over zeroes and uploads + // them - which is byte for byte what it gets from a fresh MappedData(). Skipping the COPY + // is the whole of the difference between the two arms; skipping the TARGET was the bug. + // + // Both cases run on the split arms, where they are the red-once, and on the ambient + // monolith arms, where they are the control that says the expectation itself is right. + TEST_F(XfbAfterClipDistanceScenario, OrphanedCaptureTargetWithAPartialUploadKeepsItsVaryings) { + if (!Ready()) return; + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log, CaptureStoreShape::OrphanedPartial); + EXPECT_TRUE(CheckSkipComponentsOrphaned(captured, kOrphanStagedFloats)); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + + TEST_F(XfbAfterClipDistanceScenario, OrphanedCaptureTargetWithNoUploadAtAllKeepsItsVaryings) { + if (!Ready()) return; + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log, CaptureStoreShape::OrphanedWhole); + // Nothing was ever staged, so every hole is undefined and only the varyings are + // checked - which is still the entire capture. + EXPECT_TRUE(CheckSkipComponentsOrphaned(captured, 0)); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + // Control: the capture on its own must work. TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) { if (!Ready()) return; diff --git a/docs/Disaggregated/notes/p34b/espryt-d1.md b/docs/Disaggregated/notes/p34b/espryt-d1.md new file mode 100644 index 00000000..31f60a5f --- /dev/null +++ b/docs/Disaggregated/notes/p34b/espryt-d1.md @@ -0,0 +1,121 @@ +# espryt D1 — the Espryt (DirectGLES) stream, part 1 + +P3b/P4b items landing under the P7 program (`notes/p7/PLAN-PH-P34B-P7.md` §1.2 rows XFB / +回读 / `g_fboTextureSyncList`, §3 wave 2-D). Base `feat/disaggregated@665cfefc`, branch +`p7/espryt-d1`. + +Per-slice red-once notes (R-16). A slice is not done until the case it adds has been seen RED +against the tree without that slice's fix and GREEN with it, on the arm the fix is for. + +--- + +## slice 1 — the orphaned XFB capture target + +**The hole.** `ScatterCapturedRecords` (`MG_Backend/DirectGLES/DirectGLES.cpp`) is a +read-modify-write of the capture destination's PRE-CAPTURE bytes: a `gl_SkipComponents` hole +has to come back holding whatever the application left there, so the scatter stages the old +range, patches the captured varyings into it, and uploads the whole range once. Under a +transport the old bytes are the SERVER's staged shadow (`GLESBufferResource::hostBytes`) +rather than a frontend `MappedData()`, and the split arm read a null shadow as *"this target +has no pre-capture bytes, discard it"*: + +``` +if (splitResource == nullptr || hostBytes == nullptr) { MGLOG_E_ONCE(...); continue; } +``` + +A null shadow is not an error. `MGPResourceDesc.HasDefinedContent == 0` — the store the +application ORPHANED with `glBufferData(size, NULL)` — declares every byte it has not staged +since to be UNDEFINED, and `Managers.cpp`'s own M-3 comment says so in as many words. So the +`continue` threw away a whole capture for the ordinary streaming idiom (orphan, upload part of +it, capture into it), leaving the application's buffer holding its pre-draw bytes under +`GL_NO_ERROR` — the exact silent-loss class the preamble above `ReadbackCapturedRanges` exists +to close. + +The partly-uploaded shape did not even reach the `continue`: it has a shadow, so it went on to +`RequireStagedCoverage` over the WHOLE bound range and died on +`Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"}`. + +**The fix.** Monolith has always been right here, and it is right by construction: +`Vector staged(rangeBytes)` is value-initialised, so scattering over an unreadable +source means scattering over zeroes and uploading them — byte for byte what a fresh +`MappedData()` hands back. So the split arm skips only the `Memcpy`, never the target: + +* `splitResource == nullptr` keeps discard-with-log. That one really is impossible (the target + was recorded against a handle the applier had already minted a twin for) and there is no + `backendId` to upload into either. +* a null `hostBytes` falls through with `staged` left zeroed. +* `RequireStagedCoverage` is guarded by `BufferImpl::ResourceContentIsDeclared(res)`, exactly + as `Managers.cpp`'s pool-reuse ladder guards its own whole-store `MGL_SERVER_STAGED_REQUIRE`. + A declared store still Fatals on a coverage gap (a missing record is still a missing record); + an orphaned one does not. + +`ResourceContentIsDeclared` moved out of `Managers.cpp`'s anonymous namespace to namespace +scope beside `RequireStagedCoverage` and is declared in `Managers.h`: the two callers must not +be able to answer "did the application declare these bytes" differently. Both are inside +`#if MOBILEGL_BUILD_DISAGGREGATED`, so the pull build compiles neither (G1). + +**Red-once (R-16).** `XfbAfterClipDistanceScenario.OrphanedCaptureTarget*`, two cases, on the +split arms: + +| case | store | without the fix | +|---|---|---| +| `…WithAPartialUploadKeepsItsVaryings` | `glBufferData(size, NULL)` + `glBufferSubData` over the first half | `Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"}` | +| `…WithNoUploadAtAllKeepsItsVaryings` | `glBufferData(size, NULL)`, nothing staged | silent discard: every captured component is still the pre-fill | + +Both assert the captured varyings everywhere and the preserved `gl_SkipComponents` holes only +inside the uploaded region — past it the application declared the bytes undefined and nobody +may check them. The ambient monolith registrations of the same two cases are the control that +says the expectation is right rather than the arm's. + +**RESULT — red once, then green.** With the `continue` and the unguarded `RequireStagedCoverage` +restored (fix reverted, everything else identical), `ctest -R 'DirectGLES.*OrphanedCaptureTarget'` +gave 4/10 passed, 6 failed — and the six are exactly the six split-arm entries: + +``` +3736 DirectGLES.Split.Xfb …WithAPartialUploadKeepsItsVaryings (Subprocess aborted) +3737 DirectGLES.Split.Xfb …WithNoUploadAtAllKeepsItsVaryings (Failed) +3738 DirectGLES.Spawn.Xfb …WithAPartialUploadKeepsItsVaryings (Subprocess aborted) +3739 DirectGLES.Spawn.Xfb …WithNoUploadAtAllKeepsItsVaryings (Failed) +3740 DirectGLES.Tcp.Xfb …WithAPartialUploadKeepsItsVaryings (Subprocess aborted) +3741 DirectGLES.Tcp.Xfb …WithNoUploadAtAllKeepsItsVaryings (Failed) +``` + +The abort is the one the row predicted, verbatim from the lane log: + +``` +MGPipe: Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"} - the server's ladder wants +[0, 696) of a buffer whose staged coverage does not include it. +``` + +and the failure is the silent one: +`captured varying at index 1 (past the uploaded region): got 0, expected 1`. The two ambient +monolith entries (2471 / 2472) PASSED in the same red run, which is what makes the six a +transport fact rather than a wrong expectation. + +With the fix: 10/10. + +**Gates.** `integration-split` 188/188 (base 186 + these 2), `integration-spawn` 104/104 +(base 102), `integration-tcp` 105 labelled + 2 fixtures = 107/107 (base 103 + 2), +`ctest -L unit` 2408/2408, the monolith `DirectGLES.` XFB + readback families 66/66, +`spawn_lane_parity.py build-split` RC 0 (split 105 / 102 comparable, spawn 102), +`fatal_census.py` RC 0 (79 abort sites, 0 unmarked), `link_ratchet.py --assert-monotone` +unchanged at 186 symbols. G1: `.text` `0xa52203`, `nm --defined-only` and `nm --defined-only -D` +both byte-identical to `~/w7/p7-before/`. + +--- + +## slice 2 — the unbounded writeback event + +`RESULT: (pending)` + +## slice 3 — the dead `OnXfbScatterReady` declaration + +`RESULT: (pending)` + +## slice 4 — readback close-out + +`RESULT: (pending)` + +## slice 5 — four transport-aware resolvers + +`RESULT: (pending)`