diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index a3d2c354..e6752f88 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -66,8 +66,20 @@ namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_BUILD_DISAGGREGATED - // read_pixels writes server reply scratch. The client refuses PACK_BUFFER before - // emission, so no frontend pack binding can affect this destination. + // A readback on the server writes REPLY SCRATCH, so it has no pack buffer - and this + // function is how every pack-PBO lookup in this file says so under a transport. + // + // THE REASON IS NOT "THE CLIENT REFUSES PACK_BUFFER BEFORE EMISSION", which is what this + // comment used to claim and is not true of the tree (P3b/P4b espryt D1 slice 4). The client + // SERVICES the pack PBO itself, AFTER the reply lands: EmitTables.cpp uploads the reply's + // tight rows into the bound buffer with pbo->UploadSubData, one row at a time at the stride + // the application's GL_PACK_* state implies, so padding and untouched bytes survive. A + // refusal would have been visible as a GL error; what actually happens is an ordinary + // buffer upload the client performs on its own side. + // + // The server's half is ID-49's: read with NEUTRAL pack state into a tight w*h*bpp reply. + // Which is exactly why the server must see NO pack buffer here - not because the client + // forbade one, but because the client kept it. static const SharedPtr& SplitReadbackPackBuffer() { static const SharedPtr none; return none; @@ -14917,11 +14929,43 @@ namespace MobileGL::MG_Backend::DirectGLES { if (width <= 0 || sliceHeight <= 0 || sliceCount <= 0) { return true; } + const void* shadow = textureMipmapObject->MapMipmapData(uploadTarget, level); #if MOBILEGL_PIPE_PUSH - // P4a (D-M). glGetTexImage is answered out of the frontend's own level shadow, - // converted to the requested format and type. Monolith is unchanged; a split server - // holds no shadow to convert, and the readback family as a whole is P3b/P4b's and - // P8's rather than this phase's. +#if MOBILEGL_BUILD_DISAGGREGATED + // P4a (D-M) SAID "a split server holds no shadow to convert" AND ABORTED. That is the + // premise this row of P3b/P4b re-examined, and it is wrong in the only case that can + // get here: the level storage this converts IS the server's own, fed by the staged + // texture store, so when MapMipmapData answers the conversion is reading bytes that + // exist on this side and the answer is right. There is nothing to migrate - the + // emulation was never reaching across the split, it only looked as though it must. + // + // WHEN IT DOES NOT ANSWER, THE VERB DECLINES BY NAME (rule I) RATHER THAN ABORTING. + // std::abort inside a server that is answering a client's readback takes the whole + // session down for a texture level it happens not to hold; GL's own answer for "this + // read cannot be served" is an error code and untouched destination bytes, and a + // client that gets GL_INVALID_OPERATION can carry on. The abort also bypassed + // Session::Fail entirely, which is the funnel the census gate watches. + // + // THE FUNNEL KEEPS ITS TEETH FOR THE OTHER SITES: MGPipeUnmigratedEmulation still + // aborts for generate-mipmap-storage, generate-mipmap-cpu-fallback, + // generate-mipmap-cpu-filter and texture-remint-pull, which really do reach into a + // client address space. Only this call site is retired, and only because its own + // premise did not hold. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (shadow == nullptr) { + MGLOG_E_ONCE("GetTexImage: no level shadow on this side for target=0x%x level=%d, and under an " + "active transport there is no client shadow to fall back to - the read is " + "DECLINED and the destination keeps its bytes", + static_cast(uploadTarget), level); + MGB_CTX->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "DirectGLES", "GetTexImage", + "the level has no shadow on the server and there is no client shadow to convert")); + return false; + } + } else +#endif MG_Pipe::MGPipeUnmigratedEmulation("get-tex-image-shadow"); #endif const auto& pixelPackBufferObject = @@ -14934,7 +14978,6 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } - const void* shadow = textureMipmapObject->MapMipmapData(uploadTarget, level); if (!shadow) { return false; } diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 3a6c4ceb..ba123e48 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -2297,6 +2297,59 @@ if (MOBILEGL_BUILD_DISAGGREGATED) mgl_itest_register_split_arms("XfbAfterClipDistanceScenario.*" "Xfb.") mgl_itest_register_split_arms("XfbRepeatedCaptureScenario.*" "Xfb.") + # ---- P3b/P4b espryt D1 slice 4: THE READBACK FAMILY ON THE SPLIT ARMS ------------------- + # + # The 回读 half of the plan's "回读 / pack state" row. The PACK half is done - server reads + # with neutral pack state, the client scatters (ID-49, EmitTables.cpp) - and the READBACK half + # had no split coverage at all, so every claim about it was a reading of the code rather than + # a run of it. Arming the lanes is the first half of the work and the census is the second: + # what reds here IS the remaining worklist, and each red is either D1's to fix or excluded BY + # NAME with the Fatal quoted, the way the d1 draw block excludes + # MultiDrawScenario.ClientSideIndicesBatchMatchesUnrolledDraws. + mgl_itest_register_split_arms("PixelStoreSweepScenario.*" "") + mgl_itest_register_split_arms("PackedWordReadbackScenario.*" "") + mgl_itest_register_split_arms("LayeredTextureReadbackScenario.*" "") + # FramebufferChurnScenario shares PixelStoreSweepScenario.cpp and is the other half of its + # story - ~3300 texture+framebuffer pairs per case, the shape that walked an Adreno driver to + # 1.2 GB of leaked objects and then answered every readback with someone else's pixels. Under + # a transport every one of those pairs is also a create/destroy record round trip, so this is + # the only entry in the readback block that says anything about the OBJECT lifetime half. + mgl_itest_register_split_arms("FramebufferChurnScenario.*" "") + + # DepthStencilReadbackMatrixScenario GETS ITS OWN BLOCK, because it needs a variable the + # registration macro cannot carry: MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1. + # + # Without it this scenario is UNFALSIFIABLE on the machines this suite runs on, and the + # ambient DirectGLES.ForcedDepthStencilEmulation. lane says why: OpenGL ES has no depth or + # stencil readback in core, but Mesa accepts the reads anyway, so on llvmpipe every case goes + # green through a native path the Adreno device does not have - deleting the whole emulation + # left all of them passing. A split arm that inherited the ambient default would be measuring + # the same non-path three more times. So the pin is MIRRORED here, which is what the plan's + # row asks for. + foreach(mglItestDsArm IN LISTS MGL_ITEST_MAGMA_ARMS) + mgl_itest_magma_arm_shape(${mglItestDsArm}) + string(TOLOWER "${mglItestDsArm}" mglItestDsArmLower) + mgl_itest_join_environment(mglItestDsEnvironment + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_SPLIT_LANE=1" + "MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1" + ${mglItestArmTransportEnv} ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.${mglItestDsArm}.ForcedDs." + TEST_LIST "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}" + TEST_FILTER "DepthStencilReadbackMatrixScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-${mglItestDsArmLower}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${mglItestDsEnvironment}" + ${mglItestArmProperties} + ) + # Into the accumulated list like every other arm entry, so SplitLogPaths gives each of + # these a private MOBILEGL_LOG_FILE_PATH too - two processes append on the spawn arm. + list(APPEND MGL_SPLIT_ARM_TEST_LISTS "MGL_SPLIT_ARM_TESTS_${MGL_SPLIT_ARM_INDEX}") + math(EXPR MGL_SPLIT_ARM_INDEX "${MGL_SPLIT_ARM_INDEX} + 1") + endforeach() + # ---- P5e fix1: THE PUSH-MONOLITH ARM, INSIDE THE GATED SET (ID-107) ---------------------- # @@ -2652,6 +2705,17 @@ if (MOBILEGL_BUILD_DISAGGREGATED) foreach(f1Slot ClearBufferfi ClearBufferfv ClearBufferiv ClearBufferuiv ClearNamedFramebufferfi ClearNamedFramebufferfv ClearNamedFramebufferiv ClearNamedFramebufferuiv CopyTexImage2D CopyTexSubImage2D GenerateMipmap GenerateMipmapPackedFloat GenerateMipmapDepth) mgl_itest_register_split_arms("F1WireScenario.${f1Slot}Pixels" "F1.") endforeach() + # THE TWO PACK-STATE CASES OF F1, which are not ${slot}Pixels-shaped (P3b/P4b espryt D1 + # slice 4). ID-49 splits a readback in two: the server reads with NEUTRAL pack state into a + # tight w*h*bpp reply, and the CLIENT scatters those rows into the application's destination + # per its own GL_PACK_*. Only the client half has ever been exercised by a unit case over + # ScatterTightReadbackIntoPackState; these two run the whole seam, one for the padding a + # non-default pack state must leave untouched and one for the overflow that has to be refused + # BEFORE anything is written. + mgl_itest_register_split_arms( + "F1WireScenario.TextureAndFramebufferReadsPreservePackBufferPadding" "F1.") + mgl_itest_register_split_arms( + "F1WireScenario.TextureReadbackRejectsPackedDestinationOverflowBeforeWriting" "F1.") # P5f fm: the framebuffer/copy/mipmap verbs now consume server records on Magma too. # # P7 package L: THE SAME CASES ON ALL THREE TRANSPORTS. The flavour is everything this lane diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp index 120a4fe0..30670c59 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp @@ -31,6 +31,7 @@ // and DirectVulkan is the built-in control. #include +#include #include #include @@ -257,6 +258,117 @@ namespace MGITest { glDeleteTextures(1, &texture); } + // THE ONE READBACK DESTINATION NOTHING COVERED (P3b/P4b espryt D1 slice 4): a bound + // GL_PIXEL_PACK_BUFFER, with a non-default GL_PACK_* state, read back afterwards through + // glMapBufferRange. + // + // The case above reads into a CLIENT POINTER, which under a transport is served by + // ScatterTightReadbackIntoPackState - a pure function with unit cover of its own. A PBO + // destination is a different half of the same seam and had none: the server reads with + // NEUTRAL pack state into a tight w*h*bpp reply (ID-49), and the CLIENT then services the + // PBO itself after the reply lands, uploading the tight rows one at a time at the stride the + // application's pack state implies (EmitTables.cpp's `if (pbo)` branch). Nothing in the tree + // ran that branch, and it is arithmetic - a stride, an alignment round-up, two skips and a + // buffer offset - which is the kind of code that is either exactly right or quietly wrong. + // + // WHAT MAKES IT A TEST RATHER THAN A SMOKE CHECK is the poison. Every byte of the buffer + // starts 0xCD; the rows land where the pack state says and NOTHING ELSE MOVES. A branch that + // uploaded the reply whole, or ignored the buffer offset, or rounded the stride the other + // way, writes over poison that this asserts is still there. + // + // The numbers are chosen so each term does something: ROW_LENGTH 5 at 4 bytes per pixel is + // 20, which ALIGNMENT 8 rounds up to a 24-byte stride (a stride equal to the row length would + // not tell a missing round-up from a present one); SKIP_ROWS 1 and SKIP_PIXELS 2 move the + // first pixel to byte 24 + 8 = 32 of the image; and the read is issued at buffer offset 16, + // so the first pixel lands at 48 and a branch that dropped the offset would land at 32. + TEST_F(PixelStoreSweepScenario, ReadPixelsIntoAPackBufferHonoursPackStateAndLeavesTheGapsAlone) { + if (!Ready()) return; + ResetAllPixelStoreModes(); + + constexpr int kW = 2; + constexpr int kH = 2; + constexpr GLint kAlignment = 8; + constexpr GLint kRowLength = 5; + constexpr GLint kSkipRows = 1; + constexpr GLint kSkipPixels = 2; + constexpr std::size_t kBpp = 4; + constexpr std::size_t kOffset = 16; + constexpr std::size_t kPboBytes = 256; + constexpr std::uint8_t kPoison = 0xCD; + // The same arithmetic GL defines and EmitTables.cpp implements, written out here rather + // than borrowed, so the test cannot agree with the code by construction. + constexpr std::size_t kStride = ((kRowLength * kBpp) + kAlignment - 1) / kAlignment * kAlignment; + constexpr std::size_t kFirst = kOffset + kSkipRows * kStride + kSkipPixels * kBpp; + static_assert(kStride == 24, "the alignment round-up must actually round"); + static_assert(kFirst == 48, "the skips and the buffer offset must all move the first pixel"); + + // Four distinct opaque colours, so a row or a pixel landing in the wrong place is a + // wrong VALUE and not just a wrong offset. + const std::uint8_t texels[kW * kH * kBpp] = { + 0x11, 0x22, 0x33, 0xFF, 0x44, 0x55, 0x66, 0xFF, + 0x77, 0x88, 0x99, 0xFF, 0xAA, 0xBB, 0xCC, 0xFF, + }; + + GLuint texture = 0, framebuffer = 0, pbo = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + ASSERT_EQ(FirstGLError(), 0u); + + const std::vector poison(kPboBytes, kPoison); + glGenBuffers(1, &pbo); + glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo); + glBufferData(GL_PIXEL_PACK_BUFFER, static_cast(kPboBytes), poison.data(), GL_STREAM_READ); + ASSERT_EQ(FirstGLError(), 0u); + + glPixelStorei(GL_PACK_ALIGNMENT, kAlignment); + glPixelStorei(GL_PACK_ROW_LENGTH, kRowLength); + glPixelStorei(GL_PACK_SKIP_ROWS, kSkipRows); + glPixelStorei(GL_PACK_SKIP_PIXELS, kSkipPixels); + glReadPixels(0, 0, kW, kH, GL_RGBA, GL_UNSIGNED_BYTE, + reinterpret_cast(static_cast(kOffset))); + EXPECT_EQ(FirstGLError(), 0u); + ResetAllPixelStoreModes(); + + std::vector readback(kPboBytes, 0); + const void* mapped = + glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, static_cast(kPboBytes), GL_MAP_READ_BIT); + ASSERT_NE(mapped, nullptr) << "the pack buffer could not be mapped for readback"; + std::memcpy(readback.data(), mapped, kPboBytes); + glUnmapBuffer(GL_PIXEL_PACK_BUFFER); + + // Every byte the pack state names carries its texel; every other byte is still poison. + std::vector written(kPboBytes, false); + for (int y = 0; y < kH; ++y) { + for (int x = 0; x < kW; ++x) { + for (std::size_t c = 0; c < kBpp; ++c) { + const std::size_t at = kFirst + static_cast(y) * kStride + + static_cast(x) * kBpp + c; + ASSERT_LT(at, kPboBytes); + written[at] = true; + EXPECT_EQ(readback[at], texels[(y * kW + x) * kBpp + c]) + << "pixel (" << x << ", " << y << ") component " << c << " at buffer byte " << at + << (readback[at] == kPoison ? " is still the poison: nothing was written here" : ""); + } + } + } + for (std::size_t i = 0; i < kPboBytes; ++i) { + if (written[i]) continue; + EXPECT_EQ(readback[i], kPoison) + << "buffer byte " << i << " is outside every row the pack state names and must not have moved"; + } + + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteBuffers(1, &pbo); + glDeleteFramebuffers(1, &framebuffer); + glDeleteTextures(1, &texture); + } + // The leak regression. Each iteration is one complete CTS inner step, and every readback has // to be exactly the gradient THIS iteration uploaded - never the previous one's. Before the // missing destructors were added, the driver-side framebuffer count grew without bound here. diff --git a/docs/Disaggregated/notes/p34b/espryt-d1.md b/docs/Disaggregated/notes/p34b/espryt-d1.md index 20ccb00c..c3e5da95 100644 --- a/docs/Disaggregated/notes/p34b/espryt-d1.md +++ b/docs/Disaggregated/notes/p34b/espryt-d1.md @@ -220,7 +220,115 @@ was re-run clean at 121/121.)* ## slice 4 — readback close-out -`RESULT: (pending)` +The plan's 回读 row had four parts. The headline is part (a): **the census came back clean**, which +is not what the row expected ("Expect reds — that is the point"). + +### (a) the readback lane census + +Registered on the three split arms (`integration-split` / `-spawn` / `-tcp`), 22 cases per arm: + +| family | cases/arm | result | +|---|---|---| +| `F1WireScenario.TextureAndFramebufferReadsPreservePackBufferPadding` | 1 | green | +| `F1WireScenario.TextureReadbackRejectsPackedDestinationOverflowBeforeWriting` | 1 | green | +| `PixelStoreSweepScenario.*` | 2 (+1 new, below) | green | +| `FramebufferChurnScenario.*` | 1 | green | +| `PackedWordReadbackScenario.*` | 3 | green | +| `LayeredTextureReadbackScenario.*` | 2 | green | +| `DepthStencilReadbackMatrixScenario.*` | 12 | green | + +**No case needed a fix and no case needed a named deferral.** The first run was 65/65 (63 cases + +the two TCP fixture entries), before any of this slice's code changes. That is a real finding and +it is worth stating plainly: the 回读 half was assumed open because nothing had ever run it under a +transport, and the assumption, not the code, was what was wrong. `ScatterTightReadbackIntoPackState` +and the server's neutral-pack read were already carrying it. + +Two honest caveats, because a skip is not coverage: + +* `DepthStencilReadbackMatrixScenario.SeparateDepthAndStencilAttachmentsAreBothReadable` **SKIPS on + all three arms** — and on monolith too. It is a driver fact, named by the case itself: *"this + driver cannot host separate DEPTH_COMPONENT24 and STENCIL_INDEX8 attachments"*. Not a transport + fact, nothing deferred. +* `DepthStencilReadbackMatrixScenario` needs `MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1` + mirrored into the split arms, which the registration macro cannot carry, so it gets its own + per-arm block (`DirectGLES.{Split,Spawn,Tcp}.ForcedDs.`). Without the pin the scenario is + unfalsifiable here: ES has no core depth/stencil readback, Mesa accepts the reads anyway, and on + llvmpipe every case goes green through a path the Adreno device does not have — deleting the + whole emulation left all of them passing. A split arm inheriting the ambient default would have + measured the same non-path three more times. + +### (b) the two `std::abort` emulations + +The row said both *"still `std::abort` under any non-monolith transport"*. Checked rather than +assumed, and the tree disagrees on one of them: + +* **`copy-image-shadow-mirror` already cannot abort.** `MirrorCopyImageIntoDestinationShadow` + returns under `if (MG_Config::Transport != Monolith)` **before** reaching + `MGPipeUnmigratedEmulation`, and has since P5b package i1 (its own comment names the ruling and + the two backstop Fatals that bound what the skip loses). Nothing to do; the row is stale. +* **`get-tex-image-shadow` is retired here**, and the interesting part is *why* it was there. P4a's + comment reads "a split server holds no shadow to convert" — but the level storage this function + converts **is the server's own**, fed by the staged texture store. When `MapMipmapData` answers, + the conversion reads bytes that exist on this side and the answer is right; the emulation was + never reaching across the split, it only looked as though it must. So: convert when the shadow + is there, and decline by name (rule I: `MGLOG_E_ONCE` + `GL_INVALID_OPERATION`, destination + untouched) when it is not. `std::abort` inside a server answering a client's readback takes the + whole session down for a texture level it happens not to hold, and it bypasses `Session::Fail`, + which is the funnel the census gate watches. + **The funnel keeps its teeth for the other four sites** — `generate-mipmap-storage`, + `generate-mipmap-cpu-fallback`, `generate-mipmap-cpu-filter`, `texture-remint-pull` — which + really do reach into a client address space. Only this call site is retired, and only because its + own premise did not hold. `fatal_census.py` is unchanged at 79 abort sites, because the abort + lives in `MGPipeUnmigratedEmulation` and that function stays. + + **Reachability, said out loud**: no case in the armed census reaches this site under a transport, + and the analysis says why — the client emits `MGPWireOp::GetTextureImage` and the server answers + it with `ReadTextureImageWire` (a scratch-FBO read), never through the backend's `GetTexImage` + slot. So this change is defence in depth and **has no integration-level red-once of its own**; + saying otherwise would be inventing one. + +### (c) the case nothing covered + +`PixelStoreSweepScenario.ReadPixelsIntoAPackBufferHonoursPackStateAndLeavesTheGapsAlone`: +`glReadPixels` into a bound `GL_PIXEL_PACK_BUFFER` at a non-zero buffer offset, with +`PACK_ALIGNMENT`/`ROW_LENGTH`/`SKIP_ROWS`/`SKIP_PIXELS` all non-default, read back with +`glMapBufferRange`. This is the only thing that drives `EmitTables.cpp`'s `if (pbo)` branch — the +client servicing the pack PBO itself after the reply lands, uploading tight rows one at a time at +the stride the pack state implies. The existing sweep case reads into a client POINTER, which is +`ScatterTightReadbackIntoPackState`'s half and has unit cover; the PBO half had none, and it is +pure arithmetic (stride, alignment round-up, two skips, a buffer offset). + +Every number is chosen to make a term matter: `ROW_LENGTH` 5 × 4 bpp = 20, which `ALIGNMENT` 8 +rounds up to a **24**-byte stride; `SKIP_ROWS` 1 + `SKIP_PIXELS` 2 put the first pixel at image +byte 32; the read is issued at buffer offset 16, so it lands at **48**. The whole buffer starts +`0xCD` and the case asserts both halves: the rows land where the pack state says, and **every other +byte is still poison**. + +**RESULT — red once, then green.** Dropping just the buffer-offset term from +`EmitTables.cpp:1001` (`start = pixels + …` → `start = …`) and rebuilding: + +``` +3794 DirectGLES.Split.PixelStoreSweepScenario.ReadPixelsIntoAPackBuffer… (Failed) +3797 DirectGLES.Spawn.… (Failed) +3800 DirectGLES.Tcp.… (Failed) +``` +`PixelStoreSweepScenario.cpp:353: readback[at] Which is: '\xCD' (205)` — the destination is still +poison because the rows landed 16 bytes early. The ambient monolith entries passed in the same run +(the driver services its own PBO there), so the three reds are a transport fact. `EmitTables.cpp` +was restored immediately and is **not** part of this commit. + +### (d) the stale comment + +`SplitReadbackPackBuffer`'s comment claimed *"The client refuses PACK_BUFFER before emission, so no +frontend pack binding can affect this destination."* The client does no such thing — it **keeps** +the PBO and services it after the reply. The conclusion (the server must see no pack buffer) was +right; the reason was backwards, and a refusal would have been observable as a GL error. Rewritten +to the actual reason, with ID-49's split of the work named. + +**Gates.** `integration-split` 225/225 (base 186), `integration-spawn` 141/141 (base 102), +`integration-tcp` 144/144 (142 labelled + 2 fixtures; base 103 + 2), `ctest -L unit` 2408/2408, +`integration-magma-split` 73/73, parity / census (79 sites, 0 unmarked) / ratchet (186) green. +G1 `.text` `0xa52203`, both nm lists byte-identical. ## slice 5 — four transport-aware resolvers