From 4db17d58c6e1ab3a98d1339ea7930c4b068a3ec3 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 22 Sep 2026 20:33:09 -0400 Subject: [PATCH] [MG_Backend, MG_IntegrationTest, scripts] (P7): a declined multisample depth/stencil shape is decided once before any aspect runs, so an erroring blit writes nothing, and the shader resolve arm gets a flipped knob twin - BlitWireFramebuffers runs colour first. A scaled COLOR|DEPTH blit out of a multisample framebuffer scaled and wrote its colour and then recorded INVALID_OPERATION on its depth, and an X-mirrored one mirrored its colour and left its depth alone - partial effects GL 18.3.1 does not allow. The shape decline (scale, X mirror, cross-format Y mirror) moves into DeclineWireDepthStencilResolveShape, which BlitWireFramebuffers asks for every depth/stencil aspect that would take the multisample resolve arm before any aspect runs; ResolveWireDepthStencil asks it too for any other caller. Default read/draw framebuffers are left to the lambda (a default read is never multisampled; a default draw behind one is the region Fatal, and resolving it early would move its write index). - The X decline and the cross-format Y decline get their own MGLOG_E_ONCE sites; one site used to cover both, so after the first X decline a cross-format Y decline logged nothing. - An empty rectangle on one side only (64x48 onto 0x48) is a size mismatch and now takes the scale decline (INVALID_OPERATION) instead of returning silently; empty on both sides stays a silent no-op. - DirectVulkan.{Split,Spawn}.MsFlip1. forces MGITEST_MAGMA_FORCE_SHADER_DEPTH_RESOLVE=1, so the shader arm min-corner rectangle under a flip runs on this host; split+spawn only and named in MAGMA_SERVER_ENV_KNOB_NO_TCP. - spawn_lane_parity.py: every server-env-knob tail must match exactly one split key, so a dead or over-broad tail fails the gate instead of riding silently. - The flipped-resolve case gains a COLOR|DEPTH scale leg (error, colour untouched, depth untouched) and an empty-destination leg (error, depth untouched). - Red-once, each on split + spawn (+ tcp MsFlip.) and restored: pre-pass disabled -> all five MsFlip/MsFlip1 entries fail "the declined COLOR|DEPTH scale must not have written its colour either" (read {255,0,0,255}, want {0,0,255,255}); round-1 silent empty-side return -> all five fail the empty-destination leg (error 0, want 1282); shader arm handed sx0/sy0 instead of the min corner -> MsFlip1 fails on both arms ("768 of 768 depth values differ from 0.75", "928 of 3072 stencil values are wrong; first at (20, 4): got 255, want 99") while MsFlip. stays green; a dead ".MsFlip2." tail -> parity rc 1 "matches 0 split entrie(s)". - The split once-sites have no lane red-once: the cross-format Y decline needs a device whose renderbuffer and texture D24S8 map to different VkFormats, which lavapipe is not. --- .../DirectVulkan/Renderer/VulkanRenderer.h | 7 + .../DirectVulkan/Renderer/WireFramebuffer.inc | 139 +++++++++++++----- MobileGL/MG_IntegrationTest/CMakeLists.txt | 14 ++ .../DepthStencilReadbackMatrixScenario.cpp | 39 +++++ scripts/ci/spawn_lane_parity.py | 18 ++- 5 files changed, 179 insertions(+), 38 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 583ab3d5..6a27cbfe 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -513,6 +513,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void ResolveWireDepthStencil(WireImage source, WireImage destination, GLint sx0, GLint sy0, GLint sx1, GLint sy1, GLint dx0, GLint dy0, GLint dx1, GLint dy1); + // True when the rectangles give a multisample depth/stencil resolve a shape it declines + // (a scale, an X mirror, a Y mirror across two formats); the decline has then been + // logged and its INVALID_OPERATION recorded. BlitWireFramebuffers asks it BEFORE any + // aspect runs, so a declined call leaves every attachment alone. + Bool DeclineWireDepthStencilResolveShape(const WireImage& source, const WireImage& destination, + GLint sx0, GLint sy0, GLint sx1, GLint sy1, + GLint dx0, GLint dy0, GLint dx1, GLint dy1); PFN_vkCreateRenderPass2 m_wireCreateRenderPass2 = nullptr; VkResolveModeFlags m_wireDepthResolveModes = 0; VkResolveModeFlags m_wireStencilResolveModes = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc index 906d59cd..4d27fbbf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc @@ -432,6 +432,68 @@ MagmaWireFatal("read-conversion"); } + Bool VulkanRenderer::DeclineWireDepthStencilResolveShape(const WireImage& source, + const WireImage& destination, GLint sx0, GLint sy0, GLint sx1, GLint sy1, + GLint dx0, GLint dy0, GLint dx1, GLint dy1) { + const Int64 spanX = static_cast(sx1) - sx0; + const Int64 spanY = static_cast(sy1) - sy0; + const Int64 dstSpanX = static_cast(dx1) - dx0; + const Int64 dstSpanY = static_cast(dy1) - dy0; + const Int64 width = spanX < 0 ? -spanX : spanX; + const Int64 height = spanY < 0 ? -spanY : spanY; + const Int64 dstWidth = dstSpanX < 0 ? -dstSpanX : dstSpanX; + const Int64 dstHeight = dstSpanY < 0 ? -dstSpanY : dstSpanY; + // A SCALE has no arm here and is not supposed to: 18.3.1 makes a multisample blit whose + // rectangles differ in size INVALID_OPERATION. Rule I (a), the same three lines the + // colour arm's multisample-destination decline uses. A ZERO span on one side only is a + // size mismatch too (review round 2): it used to return silently with the other side + // non-empty, where GL raises the error. Only when BOTH sides are empty on an axis is + // there nothing to do and nothing to say - that is the caller's early return. + if (width != dstWidth || height != dstHeight) { + MGLOG_E_ONCE("DirectVulkan::ResolveWireDepthStencil: a %lldx%lld source rectangle " + "cannot reach a %lldx%lld destination rectangle - a multisample resolve " + "carries no scale; declining the blit", + static_cast(width), static_cast(height), + static_cast(dstWidth), static_cast(dstHeight)); + RecordTextureCopyError("ResolveWireDepthStencil", ErrorCode::InvalidOperation, + "glBlitFramebuffer from a multisampled read framebuffer requires " + "identical source and destination rectangle dimensions."); + return true; + } + if (!width || !height) return false; + // WHICH MIRRORS HAVE AN ARM. The copy-out in ResolveWireDepthStencil moves the resolved + // aspect through a buffer, and a buffer copy addresses ROWS: one VkBufferImageCopy per + // row, walked backwards, is a vertical mirror for free. A HORIZONTAL one would need a + // region per TEXEL, which is not a copy any more - and re-encoding across formats + // (BlitDepthAcrossFormats, shared with the monolith arm) has no flip either. Both + // decline by name, each under its OWN once-site (review round 2: one site covered both, + // so after the first X decline a cross-format Y decline logged nothing); §6 in + // notes/p7/magma-b2.md carries them. These are declines of a LEGAL blit - GL would + // raise no error here - so the INVALID_OPERATION is rule I (a)'s named refusal, not + // GL's answer. + const Bool mirrorX = (spanX < 0) != (dstSpanX < 0); + const Bool mirrorY = (spanY < 0) != (dstSpanY < 0); + if (mirrorX) { + MGLOG_E_ONCE("DirectVulkan::ResolveWireDepthStencil: a multisample resolve mirrored on " + "X (source format %d, destination format %d) has no arm - the copy-out " + "reverses whole rows and nothing smaller; declining the blit", + static_cast(source.format), static_cast(destination.format)); + RecordTextureCopyError("ResolveWireDepthStencil", ErrorCode::InvalidOperation, + "This device cannot mirror that multisample depth/stencil blit."); + return true; + } + if (mirrorY && source.format != destination.format) { + MGLOG_E_ONCE("DirectVulkan::ResolveWireDepthStencil: a multisample resolve mirrored on " + "Y across two formats (source format %d, destination format %d) has no " + "arm - the cross-format re-encode has no flip; declining the blit", + static_cast(source.format), static_cast(destination.format)); + RecordTextureCopyError("ResolveWireDepthStencil", ErrorCode::InvalidOperation, + "This device cannot mirror that multisample depth/stencil blit."); + return true; + } + return false; + } + void VulkanRenderer::ResolveWireDepthStencil(WireImage source, WireImage destination, GLint sx0, GLint sy0, GLint sx1, GLint sy1, GLint dx0, GLint dy0, GLint dx1, GLint dy1) { // P7 wave 2-B2 (review round): A REVERSED RECTANGLE IS NOT A MALFORMED ONE. This guard @@ -443,31 +505,23 @@ // spans - so a flip is a legal blit that has to produce a mirrored picture. // // Spans are therefore signed and everything downstream works off the min corner. + // + // THE SHAPE DECLINES ARE DECIDED ONCE PER CALL (review round 2). BlitWireFramebuffers + // asks DeclineWireDepthStencilResolveShape before ANY aspect runs, because the colour + // lambda runs first: asked only here, a scaled COLOR|DEPTH blit scaled and wrote its + // colour and THEN recorded INVALID_OPERATION on its depth - a partial effect GL 18.3.1 + // forbids (the whole call errors) - and an X-mirrored one mirrored its colour and left + // its depth alone. The call below is the same question for a caller that did not ask; + // on the BlitWireFramebuffers path it has already answered false. + if (DeclineWireDepthStencilResolveShape(source, destination, sx0, sy0, sx1, sy1, dx0, dy0, dx1, dy1)) + return; const Int64 spanX = static_cast(sx1) - sx0; const Int64 spanY = static_cast(sy1) - sy0; - const Int64 dstSpanX = static_cast(dx1) - dx0; - const Int64 dstSpanY = static_cast(dy1) - dy0; - if (!spanX || !spanY || !dstSpanX || !dstSpanY) return; + if (!spanX || !spanY) return; // both sides empty on an axis (the helper errored otherwise) const Int64 width = spanX < 0 ? -spanX : spanX; const Int64 height = spanY < 0 ? -spanY : spanY; - // A SCALE has no arm here and is not supposed to: 18.3.1 makes a multisample blit whose - // rectangles differ in size INVALID_OPERATION. Rule I (a), the same three lines the - // colour arm's multisample-destination decline uses. - if (width != (dstSpanX < 0 ? -dstSpanX : dstSpanX) || - height != (dstSpanY < 0 ? -dstSpanY : dstSpanY)) { - MGLOG_E_ONCE("DirectVulkan::ResolveWireDepthStencil: a %lldx%lld source rectangle " - "cannot reach a %lldx%lld destination rectangle - a multisample resolve " - "carries no scale; declining the blit", - static_cast(width), static_cast(height), - static_cast(dstSpanX < 0 ? -dstSpanX : dstSpanX), - static_cast(dstSpanY < 0 ? -dstSpanY : dstSpanY)); - RecordTextureCopyError("ResolveWireDepthStencil", ErrorCode::InvalidOperation, - "glBlitFramebuffer from a multisampled read framebuffer requires " - "identical source and destination rectangle dimensions."); - return; - } - const Bool mirrorX = (spanX < 0) != (dstSpanX < 0); - const Bool mirrorY = (spanY < 0) != (dstSpanY < 0); + // Past the helper the only mirror left is a same-format vertical one. + const Bool mirrorY = (spanY < 0) != (dy1 < dy0); const GLint sxMin = std::min(sx0, sx1), syMin = std::min(sy0, sy1); const GLint dxMin = std::min(dx0, dx1), dyMin = std::min(dy0, dy1); if (source.isDefault || destination.isDefault || source.is3D || destination.is3D || @@ -477,22 +531,6 @@ static_cast(dxMin + width) > destination.extent.width || static_cast(dyMin + height) > destination.extent.height) MagmaWireFatal("multisample-depth-resolve-region"); - // WHICH MIRRORS HAVE AN ARM. The copy-out below moves the resolved aspect through a - // buffer, and a buffer copy addresses ROWS: one VkBufferImageCopy per row, walked - // backwards, is a vertical mirror for free. A HORIZONTAL one would need a region per - // TEXEL, which is not a copy any more - and re-encoding across formats - // (BlitDepthAcrossFormats, shared with the monolith arm) has no flip either. Both - // decline by name; §12 in notes/p7/magma-b2.md carries them. - if (mirrorX || (mirrorY && source.format != destination.format)) { - MGLOG_E_ONCE("DirectVulkan::ResolveWireDepthStencil: a multisample resolve mirrored on " - "%s (source format %d, destination format %d) has no arm - the copy-out " - "reverses whole rows and nothing smaller; declining the blit", - mirrorX ? "X" : "Y across two formats", - static_cast(source.format), static_cast(destination.format)); - RecordTextureCopyError("ResolveWireDepthStencil", ErrorCode::InvalidOperation, - "This device cannot mirror that multisample depth/stencil blit."); - return; - } VkImageAspectFlags formatAspects = VK_IMAGE_ASPECT_DEPTH_BIT; if (source.format == VK_FORMAT_S8_UINT) formatAspects = VK_IMAGE_ASPECT_STENCIL_BIT; else if (source.format == VK_FORMAT_D16_UNORM_S8_UINT || source.format == VK_FORMAT_D24_UNORM_S8_UINT || @@ -772,6 +810,33 @@ const auto* draw = named ? state.FramebufferRecordFor(state.VerbBlitDrawFbo) : state.DrawFramebuffer(); if (!read || !draw) MagmaWireFatal("blit-framebuffer-record"); state.VerbBlitNamedConsumed = named; + // P7 wave 2-B2 (review round 2): ONE ANSWER FOR THE WHOLE CALL. The aspects below run + // colour first, so a multisample depth/stencil shape that declines (a scale, an X + // mirror, a Y mirror across two formats) used to be discovered only after the colour + // attachment had already been scaled or mirrored and written - the call then recorded + // INVALID_OPERATION with half its effect in place, which GL 18.3.1 does not allow (an + // erroring blit writes nothing). The shape is a fact about the rectangles and the two + // images, so it is asked here, before any aspect runs, for every depth/stencil aspect + // the mask names that would take the multisample resolve arm. + // + // A default READ framebuffer is never multisampled here, and a default DRAW one behind a + // multisample read is ResolveWireDepthStencil's region Fatal whatever the shape - and + // resolving the default draw image early would move its write index - so both are left + // to the lambda. + if ((mask & (GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT)) && !read->IsDefault && !draw->IsDefault) { + const auto declines = [&](const MG_Pipe::MGPSurface& src, const MG_Pipe::MGPSurface& dst, + VkImageAspectFlags aspect) -> Bool { + const WireImage source = ResolveWireImage(*read, src, aspect, /*isWriteTarget=*/false); + if (!source.image || source.samples == VK_SAMPLE_COUNT_1_BIT) return false; + const WireImage destination = ResolveWireImage(*draw, dst, aspect, /*isWriteTarget=*/true); + if (!destination.image || destination.samples != VK_SAMPLE_COUNT_1_BIT) return false; + return DeclineWireDepthStencilResolveShape(source, destination, sx0, sy0, sx1, sy1, + dx0, dy0, dx1, dy1); + }; + if (((mask & GL_DEPTH_BUFFER_BIT) && declines(read->Depth, draw->Depth, VK_IMAGE_ASPECT_DEPTH_BIT)) || + ((mask & GL_STENCIL_BUFFER_BIT) && declines(read->Stencil, draw->Stencil, VK_IMAGE_ASPECT_STENCIL_BIT))) + return; + } const auto blit = [&](const MG_Pipe::MGPSurface& src, const MG_Pipe::MGPSurface& dst, VkImageAspectFlags aspect) { WireImage source = ResolveWireImage(*read,src,aspect, /*isWriteTarget=*/false); diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 6263bd2b..32b5ee8b 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -3228,6 +3228,20 @@ if (MOBILEGL_BUILD_DISAGGREGATED) PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT} ${mglItestArmProperties} ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-msflip${mglItestArmLogTag}.log") + # ITS KNOB TWIN (review round 2). Knob-free, lavapipe answers every resolve through the + # VK_KHR_depth_stencil_resolve arm, so the shader arm's min-corner rectangle under a + # flip - the device WITHOUT the extension - never ran. MsFlip1 forces it. Split+spawn + # only, the server-side-knob reason above, named in spawn_lane_parity.py's + # MAGMA_SERVER_ENV_KNOB_NO_TCP; the knob-free MsFlip. keeps all three arms. + if(mglItestServerEnvKnobArm) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.MsFlip1." + TEST_FILTER "DepthStencilReadbackMatrixScenario.AFlippedMultisampleResolveMirrorsTheBandsAndAScaleDeclines" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MGITEST_MAGMA_FORCE_SHADER_DEPTH_RESOLVE=1\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-msflip${mglItestArmLogTag}-shader1.log") + endif() foreach(inPlaceCase CopyImageInPlaceAcrossLevelsAndLayers CopyImageInPlaceOverlapDeclinesAndLeavesTheLevelAlone) gtest_discover_tests(MobileGLIntegrationTest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackMatrixScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackMatrixScenario.cpp index f20db14e..2f82f159 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackMatrixScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackMatrixScenario.cpp @@ -566,6 +566,45 @@ namespace MGITest { "the declined scale must have left the destination alone"); EXPECT_EQ(FirstGLError(), 0u) << "the session survived the decline"; + // THE SAME SCALE WITH COLOUR IN THE MASK. An erroring blit writes nothing (18.3.1), so + // the colour attachment must come through untouched too. The colour arm CAN scale a + // multisample resolve on its own, and it runs before the depth arm - so a decline + // decided per aspect scaled and wrote the colour and then raised the error on the + // depth. The shape is decided once, before any aspect, and this is what pins it. + glBindFramebuffer(GL_FRAMEBUFFER, resolved.fbo); + ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.5f, kPrimeStencil); + glClearColor(0, 0, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + ASSERT_EQ(FirstGLError(), 0u); + glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampled.fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolved.fbo); + glBlitFramebuffer(0, 0, kWidth, kHeight, 0, 0, kWidth / 2, kHeight / 2, + GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, GL_NEAREST); + glFinish(); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_OPERATION)) + << "a scaled multisample COLOR|DEPTH blit is INVALID_OPERATION as a whole"; + glBindFramebuffer(GL_FRAMEBUFFER, resolved.fbo); + std::array untouched{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, untouched.data()); + EXPECT_EQ(untouched, (std::array{0, 0, 255, 255})) + << "the declined COLOR|DEPTH scale must not have written its colour either"; + ExpectAllDepth(ReadDepthFloat(0, 0, kWidth, kHeight), 0.5f, + "the declined COLOR|DEPTH scale must have left the depth alone"); + EXPECT_EQ(FirstGLError(), 0u) << "the session survived the combined decline"; + + // AN EMPTY DESTINATION BEHIND A NON-EMPTY SOURCE is a size mismatch as well, not a + // no-op: 64x48 cannot reach 0x48. + glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampled.fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolved.fbo); + glBlitFramebuffer(0, 0, kWidth, kHeight, 0, 0, 0, kHeight, GL_DEPTH_BUFFER_BIT, GL_NEAREST); + glFinish(); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_OPERATION)) + << "a multisample resolve onto an empty destination rectangle is a size mismatch"; + glBindFramebuffer(GL_FRAMEBUFFER, resolved.fbo); + ExpectAllDepth(ReadDepthFloat(0, 0, kWidth, kHeight), 0.5f, + "the empty-destination decline must have left the depth alone"); + EXPECT_EQ(FirstGLError(), 0u) << "the session survived the empty-destination decline"; + DestroySource(resolved); DestroySource(multisampled); glBindFramebuffer(GL_FRAMEBUFFER, 0); diff --git a/scripts/ci/spawn_lane_parity.py b/scripts/ci/spawn_lane_parity.py index 99146a57..690fd994 100644 --- a/scripts/ci/spawn_lane_parity.py +++ b/scripts/ci/spawn_lane_parity.py @@ -76,8 +76,10 @@ MAGMA_INPROC_ONLY = ("MagmaRunAheadScenario.", "MagmaWireCacheScenario.") # P7 wave 2-B3 (ID-P7-34) adds `.StaleSerial.`: MGITEST_MAGMA_FORCE_STALE_BUFFER_SERIAL is read by # the server too (VkBufferManager.cpp), so the streamed subdata-then-draw red-once has the same # two arms and the same tcp absence. One list, one mechanism, for every server-side knob. +# `.MsFlip1.` (B2 review round 2) is the shader-resolve knob twin of the knob-free `.MsFlip.`, +# which keeps its tcp entry. MAGMA_SERVER_ENV_KNOB_NO_TCP = (".ShaderMip1.", ".ShaderMip2.", ".DepthMip.", - ".DefaultBlitShape1.", ".MsResolve1.", ".StaleSerial.") + ".DefaultBlitShape1.", ".MsResolve1.", ".StaleSerial.", ".MsFlip1.") def lane_names(build_dir, label): @@ -130,6 +132,20 @@ def compare_arms(build_dir, tier, labels, inproc_only=(), no_tcp=()): f"from the comparison by name ({', '.join(inproc_only)})") if no_tcp: dropped = {k for k in comparable if any(only in k for only in no_tcp)} + # EVERY TAIL MUST NAME SOMETHING (review round 2). A tail that matches no split key is + # silently inert - a renamed prefix would leave the exception in the list and the + # entry it was about riding under some other name - so an unmatched tail is an error, + # and so is a tail that suddenly matches a different number of entries than the one + # knob case it was written for. + for tail in no_tcp: + hits = sorted(k for k in comparable if tail in k) + if len(hits) != 1: + print(f"::error::{tier}: the server-env-knob tail {tail!r} matches {len(hits)} " + f"split entrie(s) {hits}, not exactly one. Each tail names ONE knob case; a " + f"tail that names none is an exception about nothing, and one that names " + f"more has let a second case ride off the tcp arm unexamined.", + file=sys.stderr) + return True if dropped: print(f"{tier}: {len(dropped)} server-env-knob entrie(s) excluded from the TCP " f"comparison only ({', '.join(no_tcp)}) - the knob is read on the server and "