[MG_Backend, MG_IntegrationTest, docs] (P7): an orphaned XFB capture target keeps its varyings

P3b/P4b espryt D1 slice 1, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "XFB scatter", §3 wave 2-D).

ScatterCapturedRecords 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 - and under a transport those bytes are the server's staged shadow rather than a
frontend MappedData(). The split arm read a null shadow as "this target has no
pre-capture bytes, discard it". A null shadow is not an error: MGPResourceDesc's
HasDefinedContent == 0 is the store the application ORPHANED with glBufferData(size,
NULL), and Managers.cpp's own M-3 comment says every byte it has not staged since is
undefined by its own declaration. So the continue threw a whole capture away 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 partly uploaded
shape did not even reach the continue: it has a shadow, so it asked RequireStagedCoverage
for the whole bound range and died on Fatal{StageSnapshotTooNarrow,
"xfb_scatter_pre_capture"}.

Monolith has always been right here and is right by construction: `Vector<Uint8>
staged(rangeBytes)` is value-initialised, so scattering over an unreadable source scatters
over zeroes and uploads them, which is byte for byte what a fresh MappedData() hands back.
The split arm now skips only the Memcpy, never the target. Discard-with-log is kept for
splitResource == nullptr alone, which really is impossible and has no backendId to upload
into either. RequireStagedCoverage is guarded by ResourceContentIsDeclared, 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, an orphaned one does not.

ResourceContentIsDeclared moves 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 live inside
#if MOBILEGL_BUILD_DISAGGREGATED, so the pull build compiles neither.

Red-once (R-16): XfbAfterClipDistanceScenario.OrphanedCaptureTarget{WithAPartialUpload,
WithNoUploadAtAll}KeepsItsVaryings, registered on the three split arms under the Xfb.
prefix. With the fix reverted the six split entries go red - the partial-upload three on
Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"} ([0, 696) of a buffer whose
staged coverage does not include it), the whole-orphan three on a captured varying that is
still zero - while the two ambient monolith entries pass, which is what makes it a
transport fact. Details and the lane numbers in docs/Disaggregated/notes/p34b/espryt-d1.md.

The narrow filter is deliberate: the rest of XfbAfterClipDistanceScenario is a DirectVulkan
allocation-pattern regression that has never run under a transport, and it joins the arms
in slice 2 once the writeback event it posts is sliced.

Gates: integration-split 188/188 (186 + 2), integration-spawn 104/104, integration-tcp
107/107, ctest -L unit 2408/2408, the monolith DirectGLES XFB + readback families 66/66,
spawn_lane_parity / fatal_census / link_ratchet (186, unchanged) all green, G1 .text
0xa52203 with both nm lists byte-identical to ~/w7/p7-before.
This commit is contained in:
2026-09-22 16:41:54 -04:00
parent 09ba88e70d
commit faf0a1f902
6 changed files with 332 additions and 26 deletions
+33 -7
View File
@@ -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);
+25 -17
View File
@@ -2133,23 +2133,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto* record = ResourceRecordOf(res);
return record != nullptr ? static_cast<SizeT>(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) {
+10
View File
@@ -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
@@ -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) ----------------------
#
@@ -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<float>& outCaptured, std::string* buildLog) {
void RunSkipComponentsCapture(std::vector<float>& 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<float>(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<GLsizeiptr>(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<float>& captured,
unsigned stagedFloats) {
const std::vector<float> 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<unsigned>(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<float>(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<Uint8>, 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<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log, CaptureStoreShape::OrphanedPartial);
EXPECT_TRUE(CheckSkipComponentsOrphaned(captured, kOrphanStagedFloats));
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(XfbAfterClipDistanceScenario, OrphanedCaptureTargetWithNoUploadAtAllKeepsItsVaryings) {
if (!Ready()) return;
std::vector<float> 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<GLenum>(GL_NO_ERROR));
}
// Control: the capture on its own must work.
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) {
if (!Ready()) return;
+121
View File
@@ -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<Uint8> 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)`