From 452d33b578b02e51e9a3e8c46dbdb5ab1a6045be Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 22 Sep 2026 17:03:47 -0400 Subject: [PATCH] [MG_Backend, MG_IntegrationTest] (P7): a scaled multisample colour blit resolves into a scratch and blits that, and a multisample destination declines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT-P7 §3.2's `multisample-blit-shape@P7`. The name covered two halves that are not the same question. The half with an arm: a multisample colour blit whose destination rectangle is a different size from its source. vkCmdResolveImage carries one extent and one offset per side, so it can express neither the scale nor the flip - which is why this arm already resolved into a single-sample scratch at raw offsets and blitted THAT for the flip. The two `width != |dx1-dx0|` clauses were the only thing keeping the scale out of the same route, and the monolith arm reaches it the same way (VulkanRenderer.cpp's `regionWasTransformed` branch). They are gone; the scratch is still sized by the SOURCE rect, so only the second leg sees the scale. The filter only started mattering with that scale - at 1:1 LINEAR and NEAREST read the same texel - so the second leg now asks whether the format can be filtered before it passes VK_FILTER_LINEAR, which is undefined behaviour on a format whose optimalTilingFeatures lack SAMPLED_IMAGE_FILTER_LINEAR. GL ignores filter for a multisample resolve anyway, so the fallback is NEAREST and not a refusal. The half with no arm: a MULTISAMPLE DESTINATION. vkCmdCopyImage is the only command that writes one, it takes a single extent, and GL 4.6 core 18.3.1 makes a blit whose rectangles differ in size INVALID_OPERATION when either framebuffer is multisampled. There is no picture to get right, so rule I (a): MGLOG_E_ONCE with both rectangles and both sample counts, the error GL names, the destination left alone, the session kept. Two entries on each of the three Magma arms (`MsBlit.`), split-only because the monolith arm drops the scale (its vkCmdResolveImage lands 1:1 at the destination offset), so an equality between the arms would assert that the wire arm is as wrong. The scale and the flip share one case over one source: dropping the scale lands an 8x4 band in a 4x2 attachment, dropping the flip returns the right two colours in the wrong order, and the second blit's expectation is the first's with the rows swapped. Red once (R-16, executed and reverted), twice: * restore the two size-equality clauses -> DirectVulkan.Split.MsBlit.…ScalesAndFlipsThroughTheResolveScratch dies as Fatal{UnmigratedVerb, "Magma:multisample-resolve-region"}; * restore MagmaWireFatal("multisample-blit-shape@P7") -> DirectVulkan.Split.MsBlit.…DeclinesAndKeepsTheSession dies by that name. Reverted, 6/6 green on the three arms. Measured on the way in: the decline's GL error reaches the client with a LATER reply, not with the blit call, so the case's glFinish is load-bearing - without it the INVALID_OPERATION surfaced attached to the 1:1 resolve twenty lines down. Gates: integration-magma-split 90 (+2), -spawn 69 (+2), -tcp 71 (+2), integration-magma-full-split 523 (+2), ctest -L unit 2420, all green; fatal_census 79 abort sites / 44 family words / 0 unmarked; link_ratchet --assert-monotone unchanged at 186; G1 pull .text 0xa52203 with an nm --defined-only list identical to p7-before/pull-syms.txt (30570 lines, 0 diffs) - every line of this commit is inside WireFramebuffer.inc's `#if MOBILEGL_BUILD_DISAGGREGATED` or in the test tree. --- .../DirectVulkan/Renderer/WireFramebuffer.inc | 72 +++++- MobileGL/MG_IntegrationTest/CMakeLists.txt | 20 ++ .../Scenarios/F1WireScenario.cpp | 207 ++++++++++++++++++ 3 files changed, 287 insertions(+), 12 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc index 8f7d1fe5..1ef02132 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc @@ -646,11 +646,23 @@ // A resolve cannot flip axes or convert RGBA to a BGRA/default // attachment. Resolve into the source format first, then use // the existing single-sample blit for those GL semantics. + // + // P7 wave 2-B2, CONTRACT-P7 §3.2 (`multisample-blit-shape`): AND IT CANNOT SCALE. + // vkCmdResolveImage carries ONE extent and one offset per side, so this arm used + // to refuse every rectangle whose destination was a different size from its + // source - a shape glBlitFramebuffer names, and one the monolith arm reaches by + // this very route (VulkanRenderer.cpp's `regionWasTransformed` branch resolves + // into a scratch and blits THAT). The two legs split the work the same way here: + // the resolve reads the source band AT RAW OFFSETS into a scratch sized by the + // SOURCE rect, and only the second leg - an ordinary vkCmdBlitImage on a + // single-sampled image, which is the one thing it is for - sees the scale and the + // flip. So the size equality drops out of this guard; what stays is the part + // neither leg can express (a rotated default source, a 3D source) or that names + // texels the source does not have. const Int64 width = std::abs(static_cast(sx1) - sx0); const Int64 height = std::abs(static_cast(sy1) - sy0); - if (!width || !height) return; - if (source.isDefault || source.is3D || width != std::abs(static_cast(dx1) - dx0) || - height != std::abs(static_cast(dy1) - dy0) || + if (!width || !height || dx0 == dx1 || dy0 == dy1) return; + if (source.isDefault || source.is3D || std::min(sx0, sx1) < 0 || std::min(sy0, sy1) < 0 || static_cast(std::max(sx0, sx1)) > source.extent.width || static_cast(std::max(sy0, sy1)) > source.extent.height) @@ -708,9 +720,21 @@ destination.is3D ? 0u : destination.layer, 1}; const Int32 z = destination.is3D ? static_cast(destination.layer) : 0; copy.dstOffsets[0] = {dx0, dy0, z}; copy.dstOffsets[1] = {dx1, dy1, z + 1}; + // The filter only started MATTERING with the scale above: at 1:1 LINEAR and + // NEAREST read the same texel, so nothing here ever asked whether the format + // could be filtered. VK_FILTER_LINEAR on a source whose optimalTilingFeatures + // lack SAMPLED_IMAGE_FILTER_LINEAR is undefined behaviour, and GL says filter + // is ignored for a multisample resolve in the first place - so a format that + // cannot be filtered takes NEAREST rather than the command being illegal. + VkFormatProperties scratchProperties{}; + vkGetPhysicalDeviceFormatProperties(m_physicalDevice.handle, source.format, &scratchProperties); + const VkFilter blitFilter = + filter == GL_LINEAR && (scratchProperties.optimalTilingFeatures & + VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) + ? VK_FILTER_LINEAR : VK_FILTER_NEAREST; vkCmdBlitImage(m_frameContext.GetCurrent().commandBuffer, resolved.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, - 1, ©, filter == GL_LINEAR ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); + 1, ©, blitFilter); if (dst.Kind == MG_Pipe::kMGPipeSurfaceKindTexture) m_textureManager->MarkWireTextureGpuWritten(destination.storage, destination.level, destination.layer, 1); } @@ -821,17 +845,41 @@ } auto command = m_frameContext.GetCurrent().commandBuffer; if (source.samples != VK_SAMPLE_COUNT_1_BIT || destination.samples != VK_SAMPLE_COUNT_1_BIT) { + // P7 wave 2-B2, CONTRACT-P7 §3.2: WHAT IS LEFT HERE IS A MULTISAMPLE DESTINATION. + // Every blit that resolves a multisample SOURCE into a single-sample destination + // returned above - the depth/stencil arm at the top of this lambda through + // ResolveWireDepthStencil, the colour arm below it through its resolve scratch - + // and both of those now carry their own scale and flip. So the samples of the two + // sides can only differ here by the DESTINATION being multisampled, which is the + // shape this arm declines rather than the one it performs. const Int32 width = region.srcOffsets[1].x - region.srcOffsets[0].x; const Int32 height = region.srcOffsets[1].y - region.srcOffsets[0].y; if (width <= 0 || height <= 0 || width != region.dstOffsets[1].x - region.dstOffsets[0].x || - height != region.dstOffsets[1].y - region.dstOffsets[0].y || source.format != destination.format) - MagmaWireFatal("multisample-blit-shape@P7"); - if (source.samples != VK_SAMPLE_COUNT_1_BIT && destination.samples == VK_SAMPLE_COUNT_1_BIT && - aspect == VK_IMAGE_ASPECT_COLOR_BIT) { - VkImageResolve resolve{region.srcSubresource, region.srcOffsets[0], - region.dstSubresource, region.dstOffsets[0], {static_cast(width), static_cast(height), 1}}; - vkCmdResolveImage(command, source.image, srcLayout, destination.image, dstLayout, 1, &resolve); - } else if (source.samples == destination.samples) { + height != region.dstOffsets[1].y - region.dstOffsets[0].y || source.format != destination.format) { + // `multisample-blit-shape@P7` RETIRES: the half that had a shape neither + // vkCmdResolveImage nor vkCmdBlitImage could express is done above, and this + // is the half that has none - vkCmdCopyImage is the only command that writes + // a multisampled image, it takes one extent, and GL 4.6 core 18.3.1 makes a + // multisample blit whose rectangles differ in size (or whose formats differ) + // INVALID_OPERATION rather than a picture. Rule I (a): say it once, record + // the error GL names, leave the destination alone, keep the session. + MGLOG_E_ONCE("DirectVulkan::BlitWireFramebuffers: a %dx%d source rectangle " + "(format %d, %u sample(s)) cannot reach a %dx%d destination " + "rectangle (format %d, %u sample(s)) - a blit onto a multisample " + "attachment carries no scale, no flip and no format conversion; " + "declining the blit", + width, height, static_cast(source.format), + static_cast(source.samples), + region.dstOffsets[1].x - region.dstOffsets[0].x, + region.dstOffsets[1].y - region.dstOffsets[0].y, + static_cast(destination.format), + static_cast(destination.samples)); + RecordTextureCopyError("BlitWireFramebuffers", ErrorCode::InvalidOperation, + "glBlitFramebuffer onto a multisampled draw framebuffer requires " + "identical source and destination rectangles and formats."); + return; + } + if (source.samples == destination.samples) { VkImageCopy copy{region.srcSubresource, region.srcOffsets[0], region.dstSubresource, region.dstOffsets[0], {static_cast(width), static_cast(height), 1}}; vkCmdCopyImage(command, source.image, srcLayout, destination.image, dstLayout, 1, ©); diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index a7aa5d96..ba3c3158 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -3080,6 +3080,26 @@ if (MOBILEGL_BUILD_DISAGGREGATED) ${mglItestArmProperties} ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MGITEST_MAGMA_FORCE_DEFAULT_BLIT_SCRATCH=${defaultBlitScratch}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-defaultblit${mglItestArmLogTag}-scratch${defaultBlitScratch}.log") endforeach() + # ---- P7 wave 2-B2: `multisample-blit-shape@P7`'s retirement and its decline ---------- + # + # SPLIT-ONLY, like the in-place copy pair below and for the same reason: the retired arm + # does something the monolith arm does not. A scaled multisample colour blit takes the + # monolith's `vkCmdResolveImage` at 1:1 into the destination's offset - the scale is + # dropped - so there is no `DirectVulkan.` reading to register these beside, and an + # equality between the two arms would be an assertion that the wire arm is as wrong. + # + # NO KNOB: every driver that can host a 4x multisample RGBA8 renderbuffer reaches both + # entries with the plain Fm environment, and one that cannot says so as a SKIP. + foreach(multisampleBlitCase MultisampleColorBlitScalesAndFlipsThroughTheResolveScratch + MultisampleBlitOntoAMultisampleDestinationDeclinesAndKeepsTheSession) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.MsBlit." + TEST_FILTER "F1WireScenario.${multisampleBlitCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${mglItestMagmaWireEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p7-msblit${mglItestArmLogTag}-${multisampleBlitCase}.log") + endforeach() foreach(inPlaceCase CopyImageInPlaceAcrossLevelsAndLayers CopyImageInPlaceOverlapDeclinesAndLeavesTheLevelAlone) gtest_discover_tests(MobileGLIntegrationTest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp index 9e0568ca..07c05fe9 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp @@ -1630,6 +1630,213 @@ TEST_F(F1WireScenario, ColorBlitToDefaultFromANonSampleableSourceDegrades) { Gl().EndFrame(); } +namespace { +// An 8x4 RGBA8 colour renderbuffer at `samples`, bottom half red and top half green in GL row +// order. TWO HALVES AND NOT ONE COLOUR, for the reason the shader-mip case gives: a pass that +// loses GL's row order averages a flat fill back to the same number everywhere and a +// single-colour assertion calls that correct. Returns false when this driver cannot host the +// attachment, which is a skip and not a failure. +bool MakeMultisampleHalvesFbo(int samples, GLuint& fbo, GLuint& renderbuffer) { + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, 8, 4); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GLenum(GL_FRAMEBUFFER_COMPLETE)) return false; + while (glGetError() != GLenum(GL_NO_ERROR)) {} + glDisable(GL_SCISSOR_TEST); + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, 8, 2); + glClearColor(1, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glScissor(0, 2, 8, 2); + glClearColor(0, 1, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + return true; +} + +// A single-sample RGBA8 destination of the given size, cleared to a colour that appears in no +// assertion below - so "the blit did nothing" and "the blit landed the wrong band" read +// differently from each other. +void MakeSingleSampleTarget(int width, int height, const GLfloat (&clear)[4], GLuint& fbo, GLuint& tex) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height); + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + glClearColor(clear[0], clear[1], clear[2], clear[3]); + glClear(GL_COLOR_BUFFER_BIT); +} +} // namespace + +// P7 wave 2-B2, CONTRACT-P7 §3.2: `multisample-blit-shape@P7` RETIRES. +// +// vkCmdResolveImage carries ONE extent and one offset per side. It can neither scale nor flip, +// so the wire arm refused every multisample colour blit whose destination rectangle was a +// different size from its source - which glBlitFramebuffer names, and which the MONOLITH arm +// already reaches by the route this package gives the wire arm (VulkanRenderer.cpp's +// `regionWasTransformed` branch: resolve into a scratch at raw offsets, then blit the scratch). +// The scratch was already here for the flip; the two size equalities were the only thing +// keeping the scale out. +// +// THE SCALE AND THE FLIP IN ONE CASE, over the same source, because they fail differently: a +// blit that dropped the scale lands a 8x4 band in a 4x2 attachment (and the driver refuses the +// command), while one that dropped the flip returns the right two colours in the wrong order. +// The second blit's expectation is the first's, rows swapped, so neither can pass the other's +// assertion. +// +// Red once (executed, reverted): restore the two `width != |dx1-dx0|` clauses in +// WireFramebuffer.inc's colour resolve arm and both blits die as +// Fatal{UnmigratedVerb, "Magma:multisample-resolve-region"}. +TEST_F(F1WireScenario, MultisampleColorBlitScalesAndFlipsThroughTheResolveScratch) { + if (!Ready()) return; + GLuint msFbo = 0, msRenderbuffer = 0; + if (!MakeMultisampleHalvesFbo(4, msFbo, msRenderbuffer)) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDeleteFramebuffers(1, &msFbo); + glDeleteRenderbuffers(1, &msRenderbuffer); + GTEST_SKIP() << "this driver cannot host a 4x multisample RGBA8 renderbuffer"; + } + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitShape.setup"; + + // Half the source in each axis: every destination texel is the resolve of a 2x2 source box + // that lies wholly inside ONE half, so the boundary never falls inside a destination texel + // and the two colours cannot mix. + constexpr GLfloat kBlue[4]{0, 0, 1, 1}; + GLuint dstFbo = 0, dstTexture = 0; + MakeSingleSampleTarget(4, 2, kBlue, dstFbo, dstTexture); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitShape.target"; + + constexpr std::array kRed{255, 0, 0, 255}; + constexpr std::array kGreen{0, 255, 0, 255}; + glBindFramebuffer(GL_READ_FRAMEBUFFER, msFbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dstFbo); + glDisable(GL_SCISSOR_TEST); + const auto before = PeekSplitRuntime().emitSeq; + glBlitFramebuffer(0, 0, 8, 4, 0, 0, 4, 2, GL_COLOR_BUFFER_BIT, GL_NEAREST); + EXPECT_GT(PeekSplitRuntime().emitSeq, before) << "F1.MsBlitShape.wire"; + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitShape.scaled.error"; + glBindFramebuffer(GL_READ_FRAMEBUFFER, dstFbo); + for (int x = 0; x < 4; ++x) { + EXPECT_EQ(ReadOnePixel(x, 0), kRed) << "F1.MsBlitShape scaled bottom row, x=" << x; + EXPECT_EQ(ReadOnePixel(x, 1), kGreen) << "F1.MsBlitShape scaled top row, x=" << x; + } + + // The same source, the same destination size, with the destination rectangle inverted on + // Y. The scratch holds the resolved band at its own origin either way; only the second leg + // sees the inversion, which is what vkCmdBlitImage's reversed offsets express. + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dstFbo); + glClearColor(kBlue[0], kBlue[1], kBlue[2], kBlue[3]); + glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_READ_FRAMEBUFFER, msFbo); + glBlitFramebuffer(0, 0, 8, 4, 0, 2, 4, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitShape.flipped.error"; + glBindFramebuffer(GL_READ_FRAMEBUFFER, dstFbo); + for (int x = 0; x < 4; ++x) { + EXPECT_EQ(ReadOnePixel(x, 0), kGreen) << "F1.MsBlitShape flipped bottom row, x=" << x; + EXPECT_EQ(ReadOnePixel(x, 1), kRed) << "F1.MsBlitShape flipped top row, x=" << x; + } + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDeleteFramebuffers(1, &dstFbo); + glDeleteTextures(1, &dstTexture); + glDeleteFramebuffers(1, &msFbo); + glDeleteRenderbuffers(1, &msRenderbuffer); + glBindTexture(GL_TEXTURE_2D, texture); + Gl().EndFrame(); +} + +// The surviving half of `multisample-blit-shape@P7`, and it is a DECLINE (rule I (a)). +// +// A MULTISAMPLE DESTINATION is the other side of the same guard and has no arm at all: +// vkCmdCopyImage is the only command that writes a multisampled image, it takes one extent, and +// GL 4.6 core 18.3.1 makes a blit whose rectangles differ in size INVALID_OPERATION when either +// framebuffer is multisampled - so there is no picture to get right, only a shape that used to +// end the session. What this case pins is that it no longer does, and that the destination is +// exactly as the clear left it. +// +// THE DESTINATION IS READ THROUGH A RESOLVE, because glReadPixels on a multisampled framebuffer +// is INVALID_OPERATION: the 1:1 resolve into a single-sample attachment is the retired arm +// above, so this case also proves the decline did not disturb the arm next to it. +// +// Red once (executed, reverted): restore MagmaWireFatal("multisample-blit-shape@P7") and this +// case dies as Fatal{UnmigratedVerb, "Magma:multisample-blit-shape@P7"}. +TEST_F(F1WireScenario, MultisampleBlitOntoAMultisampleDestinationDeclinesAndKeepsTheSession) { + if (!Ready()) return; + GLuint msFbo = 0, msRenderbuffer = 0; + glGenFramebuffers(1, &msFbo); + glBindFramebuffer(GL_FRAMEBUFFER, msFbo); + glGenRenderbuffers(1, &msRenderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, msRenderbuffer); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_RGBA8, 4, 2); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, msRenderbuffer); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GLenum(GL_FRAMEBUFFER_COMPLETE)) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDeleteFramebuffers(1, &msFbo); + glDeleteRenderbuffers(1, &msRenderbuffer); + GTEST_SKIP() << "this driver cannot host a 4x multisample RGBA8 renderbuffer"; + } + while (glGetError() != GLenum(GL_NO_ERROR)) {} + glDisable(GL_SCISSOR_TEST); + glClearColor(1, 0, 1, 1); // magenta: the colour the declined blit must leave behind + glClear(GL_COLOR_BUFFER_BIT); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitDecline.setup"; + + // A single-sample 8x4 source, flat yellow, so a blit that ran at all is visible. + constexpr GLfloat kYellow[4]{1, 1, 0, 1}; + GLuint srcFbo = 0, srcTexture = 0; + MakeSingleSampleTarget(8, 4, kYellow, srcFbo, srcTexture); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitDecline.source"; + + glBindFramebuffer(GL_READ_FRAMEBUFFER, srcFbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, msFbo); + const auto before = PeekSplitRuntime().emitSeq; + glBlitFramebuffer(0, 0, 8, 4, 0, 0, 4, 2, GL_COLOR_BUFFER_BIT, GL_NEAREST); + EXPECT_GT(PeekSplitRuntime().emitSeq, before) << "F1.MsBlitDecline.wire"; + // THE glFinish IS LOAD-BEARING, and it is what the first run of this case measured: the + // decline records its error where the blit is PERFORMED - the server's apply thread - and + // that error reaches this client's queue with a later reply, not with the blit call. Without + // the round trip the check below reads GL_NO_ERROR and the INVALID_OPERATION surfaces at + // whatever the next synchronising call happens to be, which is how it first showed up: + // attached to the resolve twenty lines down, a call that is not wrong about anything. + glFinish(); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_OPERATION)) + << "F1.MsBlitDecline: the decline must record the error GL names for the shape"; + + // Resolve the multisample destination 1:1 into a single-sample attachment and read THAT: + // the round trip is also the proof that the session is still answering. + constexpr GLfloat kBlue[4]{0, 0, 1, 1}; + GLuint readFbo = 0, readTexture = 0; + MakeSingleSampleTarget(4, 2, kBlue, readFbo, readTexture); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glBindFramebuffer(GL_READ_FRAMEBUFFER, msFbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, readFbo); + glBlitFramebuffer(0, 0, 4, 2, 0, 0, 4, 2, GL_COLOR_BUFFER_BIT, GL_NEAREST); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitDecline.resolve"; + glBindFramebuffer(GL_READ_FRAMEBUFFER, readFbo); + constexpr std::array kMagenta{255, 0, 255, 255}; + for (int y = 0; y < 2; ++y) + for (int x = 0; x < 4; ++x) + EXPECT_EQ(ReadOnePixel(x, y), kMagenta) + << "F1.MsBlitDecline: the declined blit wrote (" << x << "," << y << ")"; + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.MsBlitDecline: the session survived"; + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDeleteFramebuffers(1, &readFbo); + glDeleteTextures(1, &readTexture); + glDeleteFramebuffers(1, &srcFbo); + glDeleteTextures(1, &srcTexture); + glDeleteFramebuffers(1, &msFbo); + glDeleteRenderbuffers(1, &msRenderbuffer); + glBindTexture(GL_TEXTURE_2D, texture); + Gl().EndFrame(); +} + namespace { // One 8x8x2 RGBA8 array texture with two levels, filled so that EVERY subresource this pair // of cases touches is distinguishable from every other one. A flat fill would let a copy that