git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit3cdc9f6e3f641b07d491d12a142b9afdf4ac5c0c
parent40c66d4e5d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 13:16
test: render the 2D path offscreen and hold a grouped carve to its overlay

vk::plate_probe (cfg(test)) tessellates a DisplayList with the runner's
own tessellate_display_list / dl_batches_2d and draws it through the live
UI pipeline into an image it reads back. What it needs of the renderer is
shared rather than copied: create_ui_pipeline, batch_push_constants,
window_info_data and relief_px_at are pulled out of VkRenderer, which
calls them unchanged (the probe example renders pixel-identically before
and after). It returns None without a Vulkan device, and the test skips.

a_grouped_carve_is_drawn_as_its_overlay_is renders a plate whose carves
group beside one whose carves are forced to overlay, checks the grouping
happened, and asserts the two are the same pixels. Against the pre-fix
shader (6634ba3) it fails at 32504 px; clean under the Khronos
validation layer. Adds ~2 s to the suite for opening the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md             |  18 +++
 src/vk/mod.rs         |   2 +
 src/vk/plate_probe.rs | 408 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/vk/renderer.rs    | 407 ++++++++++++++++++++++++++-----------------------
 4 files changed, 650 insertions(+), 185 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 560cdf1..d636499 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -435,6 +435,24 @@ same recesses forced to overlay by a transparent quad): grouped and overlay colu
 5,462 px apart before, 0 after; in the designer with grouped text wells, 0 px from the
 `well_field` rendering.
 
+**And it is tested by rendering, not by reading** (`vk::plate_probe`, `cfg(test)`): an
+offscreen 2D render — a `DisplayList` through the runner's own `tessellate_display_list`
+and `dl_batches_2d`, drawn with the LIVE pipeline into an image and read back. The
+renderer's pieces it needs are shared functions, not copies, so the two cannot draw
+differently: `create_ui_pipeline` (descriptor layout, push range, vertex layout, blend),
+`batch_push_constants` (a batch's 32-float block, feature rebase included),
+`window_info_data` and `relief_px_at`. It draws plates, carves and flat geometry; it
+refuses blur-behind (the snapshot is the swapchain path's) and draws no text or images.
+`render` returns `None` with no Vulkan device and the test skips with a note; it is not
+`#[ignore]`d, since a device is the normal case here and a regression nobody runs is
+not caught. `a_grouped_carve_is_drawn_as_its_overlay_is` renders two plates, one grouped
+and one forced to overlay, asserts the grouping really happened (five features), and
+holds them equal to the pixel: against the pre-fix shader it fails at 32,504 px, worst
+channel 54. Clean under `VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`. Opening the
+device adds about 2 s to the suite. A test of any other 2D look can use the same harness.
+(Across devices the live path agrees to within 5/255 on about 125 edge pixels of the
+probe — float rounding at antialiased edges, not a shading difference.)
+
 **It was never the GPU.** The report was "doubled on the NVIDIA card, single on the
 Iris Xe"; the 2D path is identical to the pixel on both, before the fix and after. Two
 things made it look GPU-specific. Grouping is decided per frame by what is painted
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 3766eba..1d23520 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -43,6 +43,8 @@ mod core;
 pub mod image;
 mod renderer;
 mod rt;
+#[cfg(test)]
+mod plate_probe;
 mod scene;
 mod text;
 
diff --git a/src/vk/plate_probe.rs b/src/vk/plate_probe.rs
new file mode 100644
index 0000000..41d62df
--- /dev/null
+++ b/src/vk/plate_probe.rs
@@ -0,0 +1,408 @@
+//! An offscreen 2D render for tests: a `DisplayList` tessellated by the
+//! runner's own `tessellate_display_list`, drawn through the live UI pipeline
+//! (`renderer::create_ui_pipeline`, the same push constants and `WindowInfo`)
+//! into an image, and read back. What it does not do is what a plate test does
+//! not need: no text, no images, no 3D backdrop (a 1x1 clear one is bound),
+//! and no blur-behind — a frosted plate is refused, since it samples a
+//! snapshot of the frame-so-far that only the swapchain path takes.
+//!
+//! It exists so the 2D shader can be TESTED rather than read: the first test
+//! holds a carve grouped into its plate to the same carve drawn as an overlay,
+//! pixel for pixel (see cce-ui/CLAUDE.md, "A grouped carve shades as its
+//! overlay does"). `render` returns `None` where there is no Vulkan device,
+//! and the tests skip with a note, as the GPU cross-checks elsewhere do.
+
+use ash::vk;
+use gpu_allocator::vulkan::{AllocationCreateDesc, AllocationScheme};
+use gpu_allocator::MemoryLocation;
+
+use super::core::VkCore;
+use super::renderer::{
+    batch_push_constants, clear_image_to_shader_read, create_cpu_buffer, create_ui_pipeline,
+    destroy_cpu_buffer, flipped_viewport, relief_px_at, window_info_data, FRAMES_IN_FLIGHT,
+    MAX_PLATE_FEATURES, PLATE_FEATURE_BYTES, WINDOW_INFO_BYTES,
+};
+use crate::scene::paint::DisplayList;
+
+/// The format the renderer prefers for its swapchain, so blending happens in
+/// the same (linear) space.
+const FORMAT: vk::Format = vk::Format::R8G8B8A8_SRGB;
+
+/// A rendered frame: `width * height` RGBA8 pixels (sRGB-encoded), rows top
+/// to bottom.
+pub(crate) struct Rendered {
+    pub width: u32,
+    pub height: u32,
+    pub pixels: Vec<u8>,
+}
+
+impl Rendered {
+    pub fn rgba(&self, x: u32, y: u32) -> [u8; 4] {
+        let i = ((y * self.width + x) * 4) as usize;
+        [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2], self.pixels[i + 3]]
+    }
+}
+
+/// Render `dl` at `logical_w` x `logical_h` logical px and `scale`, over an
+/// opaque black clear. `None` when no Vulkan device can be opened.
+pub(crate) fn render(dl: &DisplayList, logical_w: f32, logical_h: f32, scale: f32) -> Option<Rendered> {
+    let mut core = std::panic::catch_unwind(VkCore::new_headless).ok()?;
+    let (verts, dl_batches, _images, features) =
+        crate::backend::window_runner::tessellate_display_list(dl, logical_w, logical_h, scale);
+    let batches = crate::backend::window_runner::dl_batches_2d(&dl_batches, scale);
+    assert!(
+        batches.iter().all(|b| !b.blur_behind),
+        "plate_probe draws no blur-behind batch: give the plates an unfrosted material"
+    );
+    assert!(features.len() <= MAX_PLATE_FEATURES, "more carves than one frame's feature slot");
+    let width = (logical_w * scale).round() as u32;
+    let height = (logical_h * scale).round() as u32;
+    let extent = vk::Extent2D { width, height };
+    let clip_shape = crate::layout::corner_shape();
+
+    unsafe {
+        let device = core.device.clone();
+        let queue = core.queue;
+        let pool = core.command_pool;
+        let allocator = core.allocator.as_mut().expect("allocator");
+
+        // Target: the colour attachment, then the copy source.
+        let mut image_of = |w: u32, h: u32, usage: vk::ImageUsageFlags, name: &str| {
+            let image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(FORMAT)
+                        .extent(vk::Extent3D { width: w, height: h, depth: 1 })
+                        .mip_levels(1)
+                        .array_layers(1)
+                        .samples(vk::SampleCountFlags::TYPE_1)
+                        .tiling(vk::ImageTiling::OPTIMAL)
+                        .usage(usage)
+                        .initial_layout(vk::ImageLayout::UNDEFINED),
+                    None,
+                )
+                .expect("plate_probe image");
+            let allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name,
+                    requirements: device.get_image_memory_requirements(image),
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("plate_probe image memory");
+            device
+                .bind_image_memory(image, allocation.memory(), allocation.offset())
+                .expect("plate_probe bind image");
+            let view = device
+                .create_image_view(
+                    &vk::ImageViewCreateInfo::default()
+                        .image(image)
+                        .view_type(vk::ImageViewType::TYPE_2D)
+                        .format(FORMAT)
+                        .subresource_range(color_range()),
+                    None,
+                )
+                .expect("plate_probe view");
+            (image, allocation, view)
+        };
+        let (target, target_mem, target_view) = image_of(
+            width,
+            height,
+            vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC,
+            "plate-probe-target",
+        );
+        // The backdrop binding: nothing samples it without blur-behind.
+        let (backdrop, backdrop_mem, backdrop_view) = image_of(
+            1,
+            1,
+            vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
+            "plate-probe-backdrop",
+        );
+        clear_image_to_shader_read(&device, queue, pool, backdrop);
+
+        let attachments = [vk::AttachmentDescription::default()
+            .format(FORMAT)
+            .samples(vk::SampleCountFlags::TYPE_1)
+            .load_op(vk::AttachmentLoadOp::CLEAR)
+            .store_op(vk::AttachmentStoreOp::STORE)
+            .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+            .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+            .initial_layout(vk::ImageLayout::UNDEFINED)
+            .final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)];
+        let color_refs = [vk::AttachmentReference::default()
+            .attachment(0)
+            .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
+        let subpasses = [vk::SubpassDescription::default()
+            .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
+            .color_attachments(&color_refs)];
+        let render_pass = device
+            .create_render_pass(
+                &vk::RenderPassCreateInfo::default().attachments(&attachments).subpasses(&subpasses),
+                None,
+            )
+            .expect("plate_probe render pass");
+        let (set_layout, pipeline_layout, shader_module, pipeline) = create_ui_pipeline(&device, render_pass);
+        let framebuffer_views = [target_view];
+        let framebuffer = device
+            .create_framebuffer(
+                &vk::FramebufferCreateInfo::default()
+                    .render_pass(render_pass)
+                    .attachments(&framebuffer_views)
+                    .width(width)
+                    .height(height)
+                    .layers(1),
+                None,
+            )
+            .expect("plate_probe framebuffer");
+
+        // Buffers: vertices, WindowInfo, the feature UBO (slot 0 of the
+        // renderer's two), the readback.
+        // All the renderer's frame slots, as the shader declares them; the
+        // features go in slot 0, so their offsets need no rebase.
+        let feature_bytes = (FRAMES_IN_FLIGHT * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES) as vk::DeviceSize;
+        let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
+        let mut vbuf = create_cpu_buffer(
+            &device,
+            allocator,
+            (vert_bytes.len() as vk::DeviceSize).max(64),
+            vk::BufferUsageFlags::VERTEX_BUFFER,
+            "plate-probe-verts",
+        );
+        let mut info = create_cpu_buffer(&device, allocator, WINDOW_INFO_BYTES, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-info");
+        let mut feat = create_cpu_buffer(&device, allocator, feature_bytes, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-features");
+        let readback_bytes = (width as vk::DeviceSize) * (height as vk::DeviceSize) * 4;
+        let mut readback = create_cpu_buffer(&device, allocator, readback_bytes, vk::BufferUsageFlags::TRANSFER_DST, "plate-probe-readback");
+        vbuf.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..vert_bytes.len()].copy_from_slice(vert_bytes);
+        let info_data = window_info_data(extent, 0.0, relief_px_at(scale));
+        info.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
+            .copy_from_slice(bytemuck::cast_slice(&info_data));
+        let feat_bytes: &[u8] = bytemuck::cast_slice(&features);
+        feat.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..feat_bytes.len()].copy_from_slice(feat_bytes);
+
+        let sampler = device
+            .create_sampler(&vk::SamplerCreateInfo::default(), None)
+            .expect("plate_probe sampler");
+        let pool_sizes = [
+            vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLED_IMAGE).descriptor_count(1),
+            vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLER).descriptor_count(1),
+            vk::DescriptorPoolSize::default().ty(vk::DescriptorType::UNIFORM_BUFFER).descriptor_count(2),
+        ];
+        let descriptor_pool = device
+            .create_descriptor_pool(&vk::DescriptorPoolCreateInfo::default().max_sets(1).pool_sizes(&pool_sizes), None)
+            .expect("plate_probe descriptor pool");
+        let layouts = [set_layout];
+        let set = device
+            .allocate_descriptor_sets(
+                &vk::DescriptorSetAllocateInfo::default().descriptor_pool(descriptor_pool).set_layouts(&layouts),
+            )
+            .expect("plate_probe descriptor set")[0];
+        let image_infos = [vk::DescriptorImageInfo::default()
+            .image_view(backdrop_view)
+            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+        let sampler_infos = [vk::DescriptorImageInfo::default().sampler(sampler)];
+        let info_infos = [vk::DescriptorBufferInfo::default().buffer(info.buffer).offset(0).range(WINDOW_INFO_BYTES)];
+        let feat_infos = [vk::DescriptorBufferInfo::default().buffer(feat.buffer).offset(0).range(feature_bytes)];
+        device.update_descriptor_sets(
+            &[
+                vk::WriteDescriptorSet::default()
+                    .dst_set(set)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .image_info(&image_infos),
+                vk::WriteDescriptorSet::default()
+                    .dst_set(set)
+                    .dst_binding(1)
+                    .descriptor_type(vk::DescriptorType::SAMPLER)
+                    .image_info(&sampler_infos),
+                vk::WriteDescriptorSet::default()
+                    .dst_set(set)
+                    .dst_binding(2)
+                    .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+                    .buffer_info(&info_infos),
+                vk::WriteDescriptorSet::default()
+                    .dst_set(set)
+                    .dst_binding(3)
+                    .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+                    .buffer_info(&feat_infos),
+            ],
+            &[],
+        );
+
+        let cmd = device
+            .allocate_command_buffers(
+                &vk::CommandBufferAllocateInfo::default()
+                    .command_pool(pool)
+                    .level(vk::CommandBufferLevel::PRIMARY)
+                    .command_buffer_count(1),
+            )
+            .expect("plate_probe command buffer")[0];
+        device
+            .begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT))
+            .unwrap();
+        let clear = [vk::ClearValue { color: vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 1.0] } }];
+        let full = vk::Rect2D { offset: vk::Offset2D { x: 0, y: 0 }, extent };
+        device.cmd_begin_render_pass(
+            cmd,
+            &vk::RenderPassBeginInfo::default()
+                .render_pass(render_pass)
+                .framebuffer(framebuffer)
+                .render_area(full)
+                .clear_values(&clear),
+            vk::SubpassContents::INLINE,
+        );
+        device.cmd_set_viewport(cmd, 0, &[flipped_viewport(extent)]);
+        device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline);
+        device.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline_layout, 0, &[set], &[]);
+        device.cmd_bind_vertex_buffers(cmd, 0, &[vbuf.buffer], &[0]);
+        for batch in &batches {
+            if batch.start >= batch.end {
+                continue;
+            }
+            let scissor = match batch.scissor {
+                Some((x, y, w, h)) => {
+                    if x >= width || y >= height {
+                        continue;
+                    }
+                    let (w, h) = (w.min(width - x), h.min(height - y));
+                    if w == 0 || h == 0 {
+                        continue;
+                    }
+                    vk::Rect2D { offset: vk::Offset2D { x: x as i32, y: y as i32 }, extent: vk::Extent2D { width: w, height: h } }
+                }
+                None => full,
+            };
+            device.cmd_set_scissor(cmd, 0, &[scissor]);
+            let pc = batch_push_constants(batch, clip_shape, 0);
+            device.cmd_push_constants(cmd, pipeline_layout, vk::ShaderStageFlags::FRAGMENT, 0, bytemuck::cast_slice(&pc));
+            device.cmd_draw(cmd, batch.end - batch.start, 1, batch.start, 0);
+        }
+        device.cmd_end_render_pass(cmd);
+        device.cmd_copy_image_to_buffer(
+            cmd,
+            target,
+            vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+            readback.buffer,
+            &[vk::BufferImageCopy::default()
+                .image_subresource(
+                    vk::ImageSubresourceLayers::default()
+                        .aspect_mask(vk::ImageAspectFlags::COLOR)
+                        .layer_count(1),
+                )
+                .image_extent(vk::Extent3D { width, height, depth: 1 })],
+        );
+        device.end_command_buffer(cmd).unwrap();
+        let fence = device.create_fence(&vk::FenceCreateInfo::default(), None).expect("plate_probe fence");
+        let cmds = [cmd];
+        device
+            .queue_submit(queue, &[vk::SubmitInfo::default().command_buffers(&cmds)], fence)
+            .expect("plate_probe submit");
+        device.wait_for_fences(&[fence], true, u64::MAX).expect("plate_probe wait");
+        let pixels = readback.allocation.as_ref().unwrap().mapped_slice().unwrap()[..readback_bytes as usize].to_vec();
+
+        device.destroy_fence(fence, None);
+        device.free_command_buffers(pool, &cmds);
+        device.destroy_descriptor_pool(descriptor_pool, None);
+        device.destroy_sampler(sampler, None);
+        for b in [&mut vbuf, &mut info, &mut feat, &mut readback] {
+            destroy_cpu_buffer(&device, allocator, b);
+        }
+        device.destroy_framebuffer(framebuffer, None);
+        device.destroy_pipeline(pipeline, None);
+        device.destroy_pipeline_layout(pipeline_layout, None);
+        device.destroy_shader_module(shader_module, None);
+        device.destroy_descriptor_set_layout(set_layout, None);
+        device.destroy_render_pass(render_pass, None);
+        for (image, mem, view) in [(target, target_mem, target_view), (backdrop, backdrop_mem, backdrop_view)] {
+            device.destroy_image_view(view, None);
+            device.destroy_image(image, None);
+            allocator.free(mem).expect("plate_probe free");
+        }
+        Some(Rendered { width, height, pixels })
+    }
+}
+
+fn color_range() -> vk::ImageSubresourceRange {
+    vk::ImageSubresourceRange::default()
+        .aspect_mask(vk::ImageAspectFlags::COLOR)
+        .level_count(1)
+        .layer_count(1)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::scene::layout::Rect;
+    use crate::scene::paint::PaintCtx;
+    use crate::scene::Material;
+
+    /// Two identical plates of recesses (at the parameter pane's sizes, a
+    /// boss among them): the left one's carves group into it as CSG
+    /// features, the right one's are each their own overlay, because a
+    /// transparent quad painted first closes the plate's grouping window.
+    fn two_plates(w: f32, h: f32) -> DisplayList {
+        let mut pc = PaintCtx::new();
+        let half = (w - 30.0) * 0.5;
+        let bevel = crate::layout::bevel_width();
+        for (i, x) in [10.0, 20.0 + half].into_iter().enumerate() {
+            let plate = Rect { x, y: 10.0, width: half, height: h - 20.0 };
+            pc.plate(plate, (16.0, 16.0, 16.0, 16.0), &Material::opaque([0.13, 0.13, 0.15, 1.0]), bevel);
+            if i == 1 {
+                pc.quad(Rect { x: x + 1.0, y: 11.0, width: 1.0, height: 1.0 }, [0.0; 4]);
+            }
+            let mut y = 40.0;
+            for (rh, rw) in [(20.0, 200.0), (20.0, 90.0), (28.0, 200.0), (40.0, 200.0)] {
+                let depth = bevel.min(rh * 0.2);
+                let r = (rh * 0.5f32).min(8.0);
+                pc.recess(Rect { x: x + 30.0, y, width: rw, height: rh }, (r, r, r, r), depth);
+                y += rh + 20.0;
+            }
+            pc.boss(Rect { x: x + 30.0, y, width: 120.0, height: 30.0 }, (8.0, 8.0, 8.0, 8.0), 6.0);
+        }
+        pc.finish()
+    }
+
+    /// A carve grouped into its plate shades exactly as the same carve drawn
+    /// as an overlay: the two plates are the same pixels. Until 2026-10-02 the
+    /// grouped one drew a doubled outline — the shade line taken from the
+    /// carves' slope fired twice down every wall — on every device, which
+    /// was first taken for an NVIDIA quirk.
+    #[test]
+    fn a_grouped_carve_is_drawn_as_its_overlay_is() {
+        // The style registry loads the config lazily; load it before the
+        // tessellation, so both plates are shaded under one configuration.
+        let _ = crate::layout::corner_shape();
+        let (w, h, scale) = (540.0f32, 280.0f32, 2.0f32);
+        let dl = two_plates(w, h);
+        // Not vacuous: the left plate's five carves really are grouped.
+        let (_, _, _, features) = crate::backend::window_runner::tessellate_display_list(&dl, w, h, scale);
+        assert_eq!(features.len(), 5, "the left plate's carves should group");
+        let Some(img) = render(&dl, w, h, scale) else {
+            eprintln!("skipping: no Vulkan device");
+            return;
+        };
+        let half = (w - 30.0) * 0.5;
+        let shift = ((10.0 + half) * scale) as u32;
+        let (x0, x1) = ((10.0 * scale) as u32, ((10.0 + half) * scale) as u32);
+        let face = img.rgba((60.0 * scale) as u32, (25.0 * scale) as u32);
+        let (mut differ, mut worst, mut carved) = (0usize, 0u8, 0usize);
+        for y in 0..img.height {
+            for x in x0..x1 {
+                let (a, b) = (img.rgba(x, y), img.rgba(x + shift, y));
+                let d = (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap();
+                if d > 1 {
+                    differ += 1;
+                }
+                worst = worst.max(d);
+                if (0..3).any(|c| a[c].abs_diff(face[c]) > 8) {
+                    carved += 1;
+                }
+            }
+        }
+        // The plates' own silhouettes and rolls are in the count too; what
+        // matters is that the carves made marks at all.
+        assert!(carved > 2000, "the carves left too little on the plate to compare ({carved} px)");
+        assert_eq!(differ, 0, "grouped and overlay plates differ in {differ} px (worst channel {worst})");
+    }
+}
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 6b0dbbe..84d7bff 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -71,6 +71,36 @@ const _: () = assert!(
      before raising PUSH_CONSTANT_FLOATS"
 );
 
+/// The fragment push-constant block for one batch: its rounded-rect clip,
+/// and its plate block when it is a plate cover quad. `feature_base` is the
+/// frame slot's first entry in the feature UBO, added to a plate's (or a
+/// union carve's) feature offset. Shared with the offscreen test harness
+/// (`vk::plate_probe`), so the two cannot push different blocks.
+pub(crate) fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
+    let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+    let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+    let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
+    pc[..5].copy_from_slice(&rr);
+    pc[5] = enabled;
+    pc[7] = clip_shape;
+    if let Some(p) = &batch.plate {
+        pc[6] = p.mode;
+        pc[7] = p.shape;
+        pc[8..12].copy_from_slice(&p.rect);
+        pc[12..16].copy_from_slice(&p.radii);
+        pc[16..20].copy_from_slice(&p.light);
+        pc[20..24].copy_from_slice(&p.material);
+        pc[24..28].copy_from_slice(&p.host);
+        pc[28..32].copy_from_slice(&p.specular_tint);
+        if p.mode == 1.0 || p.mode == 14.0 {
+            // Rebase the feature offset onto this frame's UBO slot (a plate's
+            // CSG carves, or a union carve's boxes).
+            pc[24] += feature_base as f32;
+        }
+    }
+    pc
+}
+
 /// Push-constant block for one SDF-lit plate batch (physical px throughout).
 /// Mirrors the `p_*` fields of shader2d's `RRectClip`.
 #[derive(Clone, Copy, PartialEq, Debug)]
@@ -180,18 +210,53 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
     }
 }
 
-const FRAMES_IN_FLIGHT: usize = 2;
+pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
 /// Max plate-carve features per frame; the shader's UBO holds one slot of this
 /// size per frame in flight.
 pub const MAX_PLATE_FEATURES: usize = 64;
-const PLATE_FEATURE_BYTES: usize = 48;
+pub(crate) const PLATE_FEATURE_BYTES: usize = 48;
 /// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
 /// [8 vec4 of profile slope samples].
 // [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
 // [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
 // [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
 // END — every offset above is addressed by index from both sides.
-const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
+pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
+
+/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
+/// follow the width.
+pub(crate) fn relief_px_at(scale: f32) -> (f32, f32) {
+    let s = scale.max(0.001);
+    (
+        crate::layout::bevel_height().map_or(0.0, |h| h * s),
+        crate::layout::roll_height().map_or(0.0, |h| h * s),
+    )
+}
+
+/// shader2d's `WindowInfo` block for a target of `extent` whose corners clip
+/// at `clip_corner_radius` (physical px), with the pinned relief heights
+/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
+/// corner shape from the live style. Shared with the offscreen test harness.
+pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES as usize / 4] {
+    let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
+    data[0] = extent.width as f32;
+    data[1] = extent.height as f32;
+    data[2] = clip_corner_radius;
+    data[3] = crate::layout::corner_shape();
+    if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+        data[4] = 1.0;
+        data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        data[8..8 + slopes.len()].copy_from_slice(&slopes);
+    }
+    if let Some(slopes) = crate::layout::roll_profile_slopes() {
+        data[40] = 1.0;
+        data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        data[44..44 + slopes.len()].copy_from_slice(&slopes);
+    }
+    data[76] = relief.0;
+    data[77] = relief.1;
+    data
+}
 
 pub(crate) struct AllocatedBuffer {
     pub(crate) buffer: vk::Buffer,
@@ -353,6 +418,155 @@ pub struct VkRenderer {
 /// (Y-down) is handled with a negative-height viewport (like wgpu-hal), NOT in
 /// the shader — flipping in the shader would reverse screen-space winding and
 /// break the 3D pipeline's back-face culling.
+/// The 2D UI pipeline over `render_pass`: shader2d's descriptor set layout,
+/// its push-constant range, the vertex layout and the blend — what the
+/// renderer draws every frame with. Shared with the offscreen test harness
+/// (`vk::plate_probe`), so it draws with exactly the live pipeline.
+pub(crate) unsafe fn create_ui_pipeline(
+    device: &ash::Device,
+    render_pass: vk::RenderPass,
+) -> (vk::DescriptorSetLayout, vk::PipelineLayout, vk::ShaderModule, vk::Pipeline) {
+    // Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
+    // texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
+    let bindings = [
+        vk::DescriptorSetLayoutBinding::default()
+            .binding(0)
+            .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+            .descriptor_count(1)
+            .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+        vk::DescriptorSetLayoutBinding::default()
+            .binding(1)
+            .descriptor_type(vk::DescriptorType::SAMPLER)
+            .descriptor_count(1)
+            .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+        vk::DescriptorSetLayoutBinding::default()
+            .binding(2)
+            .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+            .descriptor_count(1)
+            .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+        vk::DescriptorSetLayoutBinding::default()
+            .binding(3)
+            .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+            .descriptor_count(1)
+            .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+    ];
+    let descriptor_set_layout = device
+        .create_descriptor_set_layout(
+            &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+            None,
+        )
+        .expect("Failed to create descriptor set layout");
+
+    let set_layouts = [descriptor_set_layout];
+    // Push constants: the per-batch rounded-rect clip plus the SDF-lit
+    // plate block (eight vec4s, matching shader2d's `RRectClip`), read by
+    // shader2d's fragment stage. See `PUSH_CONSTANT_BYTES` — the block is
+    // exactly the Vulkan-guaranteed minimum and completely full.
+    let push_ranges = [vk::PushConstantRange::default()
+        .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+        .offset(0)
+        .size(PUSH_CONSTANT_BYTES)];
+    let pipeline_layout = device
+        .create_pipeline_layout(
+            &vk::PipelineLayoutCreateInfo::default()
+                .set_layouts(&set_layouts)
+                .push_constant_ranges(&push_ranges),
+            None,
+        )
+        .expect("Failed to create pipeline layout");
+
+    // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
+    let spirv = shader2d_spirv();
+    let shader_module = device
+        .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
+        .expect("Failed to create shader module");
+
+    let stages = [
+        vk::PipelineShaderStageCreateInfo::default()
+            .stage(vk::ShaderStageFlags::VERTEX)
+            .module(shader_module)
+            .name(c"vs_main"),
+        vk::PipelineShaderStageCreateInfo::default()
+            .stage(vk::ShaderStageFlags::FRAGMENT)
+            .module(shader_module)
+            .name(c"fs_main"),
+    ];
+
+    // Vertex layout = cce_ui::engine::Vertex: pos vec2f, color vec4f, clip vec3f.
+    let vertex_bindings = [vk::VertexInputBindingDescription::default()
+        .binding(0)
+        .stride(std::mem::size_of::<Vertex>() as u32)
+        .input_rate(vk::VertexInputRate::VERTEX)];
+    let vertex_attributes = [
+        vk::VertexInputAttributeDescription::default()
+            .location(0)
+            .binding(0)
+            .format(vk::Format::R32G32_SFLOAT)
+            .offset(0),
+        vk::VertexInputAttributeDescription::default()
+            .location(1)
+            .binding(0)
+            .format(vk::Format::R32G32B32A32_SFLOAT)
+            .offset(8),
+        vk::VertexInputAttributeDescription::default()
+            .location(2)
+            .binding(0)
+            .format(vk::Format::R32G32B32_SFLOAT)
+            .offset(24),
+    ];
+    let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+        .vertex_binding_descriptions(&vertex_bindings)
+        .vertex_attribute_descriptions(&vertex_attributes);
+
+    let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
+        .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
+    let viewport_state = vk::PipelineViewportStateCreateInfo::default()
+        .viewport_count(1)
+        .scissor_count(1);
+    let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+        .polygon_mode(vk::PolygonMode::FILL)
+        .cull_mode(vk::CullModeFlags::NONE)
+        .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+        .line_width(1.0);
+    let multisample = vk::PipelineMultisampleStateCreateInfo::default()
+        .rasterization_samples(vk::SampleCountFlags::TYPE_1);
+    // wgpu::BlendState::ALPHA_BLENDING.
+    let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
+        .blend_enable(true)
+        .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
+        .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+        .color_blend_op(vk::BlendOp::ADD)
+        .src_alpha_blend_factor(vk::BlendFactor::ONE)
+        .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+        .alpha_blend_op(vk::BlendOp::ADD)
+        .color_write_mask(vk::ColorComponentFlags::RGBA)];
+    let color_blend =
+        vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
+    let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
+    let dynamic_state =
+        vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
+
+    let pipeline = device
+        .create_graphics_pipelines(
+            vk::PipelineCache::null(),
+            &[vk::GraphicsPipelineCreateInfo::default()
+                .stages(&stages)
+                .vertex_input_state(&vertex_input)
+                .input_assembly_state(&input_assembly)
+                .viewport_state(&viewport_state)
+                .rasterization_state(&rasterization)
+                .multisample_state(&multisample)
+                .color_blend_state(&color_blend)
+                .dynamic_state(&dynamic_state)
+                .layout(pipeline_layout)
+                .render_pass(render_pass)
+                .subpass(0)],
+            None,
+        )
+        .expect("Failed to create graphics pipeline")[0];
+    (descriptor_set_layout, pipeline_layout, shader_module, pipeline)
+}
+
 pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     let module = naga::front::wgsl::parse_str(source).expect("WGSL parse failed");
     let info = naga::valid::Validator::new(
@@ -645,144 +859,8 @@ impl VkRenderer {
             )
             .expect("Failed to create partial render pass");
 
-        // Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
-        // texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
-        let bindings = [
-            vk::DescriptorSetLayoutBinding::default()
-                .binding(0)
-                .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
-                .descriptor_count(1)
-                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
-            vk::DescriptorSetLayoutBinding::default()
-                .binding(1)
-                .descriptor_type(vk::DescriptorType::SAMPLER)
-                .descriptor_count(1)
-                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
-            vk::DescriptorSetLayoutBinding::default()
-                .binding(2)
-                .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
-                .descriptor_count(1)
-                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
-            vk::DescriptorSetLayoutBinding::default()
-                .binding(3)
-                .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
-                .descriptor_count(1)
-                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
-        ];
-        let descriptor_set_layout = device
-            .create_descriptor_set_layout(
-                &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
-                None,
-            )
-            .expect("Failed to create descriptor set layout");
-
-        let set_layouts = [descriptor_set_layout];
-        // Push constants: the per-batch rounded-rect clip plus the SDF-lit
-        // plate block (eight vec4s, matching shader2d's `RRectClip`), read by
-        // shader2d's fragment stage. See `PUSH_CONSTANT_BYTES` — the block is
-        // exactly the Vulkan-guaranteed minimum and completely full.
-        let push_ranges = [vk::PushConstantRange::default()
-            .stage_flags(vk::ShaderStageFlags::FRAGMENT)
-            .offset(0)
-            .size(PUSH_CONSTANT_BYTES)];
-        let pipeline_layout = device
-            .create_pipeline_layout(
-                &vk::PipelineLayoutCreateInfo::default()
-                    .set_layouts(&set_layouts)
-                    .push_constant_ranges(&push_ranges),
-                None,
-            )
-            .expect("Failed to create pipeline layout");
-
-        // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
-        let spirv = shader2d_spirv();
-        let shader_module = device
-            .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
-            .expect("Failed to create shader module");
-
-        let stages = [
-            vk::PipelineShaderStageCreateInfo::default()
-                .stage(vk::ShaderStageFlags::VERTEX)
-                .module(shader_module)
-                .name(c"vs_main"),
-            vk::PipelineShaderStageCreateInfo::default()
-                .stage(vk::ShaderStageFlags::FRAGMENT)
-                .module(shader_module)
-                .name(c"fs_main"),
-        ];
-
-        // Vertex layout = cce_ui::engine::Vertex: pos vec2f, color vec4f, clip vec3f.
-        let vertex_bindings = [vk::VertexInputBindingDescription::default()
-            .binding(0)
-            .stride(std::mem::size_of::<Vertex>() as u32)
-            .input_rate(vk::VertexInputRate::VERTEX)];
-        let vertex_attributes = [
-            vk::VertexInputAttributeDescription::default()
-                .location(0)
-                .binding(0)
-                .format(vk::Format::R32G32_SFLOAT)
-                .offset(0),
-            vk::VertexInputAttributeDescription::default()
-                .location(1)
-                .binding(0)
-                .format(vk::Format::R32G32B32A32_SFLOAT)
-                .offset(8),
-            vk::VertexInputAttributeDescription::default()
-                .location(2)
-                .binding(0)
-                .format(vk::Format::R32G32B32_SFLOAT)
-                .offset(24),
-        ];
-        let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
-            .vertex_binding_descriptions(&vertex_bindings)
-            .vertex_attribute_descriptions(&vertex_attributes);
-
-        let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
-            .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
-        let viewport_state = vk::PipelineViewportStateCreateInfo::default()
-            .viewport_count(1)
-            .scissor_count(1);
-        let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
-            .polygon_mode(vk::PolygonMode::FILL)
-            .cull_mode(vk::CullModeFlags::NONE)
-            .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
-            .line_width(1.0);
-        let multisample = vk::PipelineMultisampleStateCreateInfo::default()
-            .rasterization_samples(vk::SampleCountFlags::TYPE_1);
-        // wgpu::BlendState::ALPHA_BLENDING.
-        let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
-            .blend_enable(true)
-            .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
-            .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
-            .color_blend_op(vk::BlendOp::ADD)
-            .src_alpha_blend_factor(vk::BlendFactor::ONE)
-            .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
-            .alpha_blend_op(vk::BlendOp::ADD)
-            .color_write_mask(vk::ColorComponentFlags::RGBA)];
-        let color_blend =
-            vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
-        let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
-        let dynamic_state =
-            vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
-
-        let pipeline = device
-            .create_graphics_pipelines(
-                vk::PipelineCache::null(),
-                &[vk::GraphicsPipelineCreateInfo::default()
-                    .stages(&stages)
-                    .vertex_input_state(&vertex_input)
-                    .input_assembly_state(&input_assembly)
-                    .viewport_state(&viewport_state)
-                    .rasterization_state(&rasterization)
-                    .multisample_state(&multisample)
-                    .color_blend_state(&color_blend)
-                    .dynamic_state(&dynamic_state)
-                    .layout(pipeline_layout)
-                    .render_pass(render_pass)
-                    .subpass(0)],
-                None,
-            )
-            .expect("Failed to create graphics pipeline")[0];
+        let (descriptor_set_layout, pipeline_layout, shader_module, pipeline) =
+            create_ui_pipeline(&device, render_pass);
 
         // Full-size backdrop + depth live in the scene stage: the 3D pass renders
         // into the backdrop, and the UI pass samples it for blur-behind plates.
@@ -1047,11 +1125,7 @@ impl VkRenderer {
 
     /// The pinned relief heights in physical px, 0 = follow the width.
     fn relief_px(&self) -> (f32, f32) {
-        let s = crate::scale::scale_factor().max(0.001);
-        (
-            crate::layout::bevel_height().map_or(0.0, |h| h * s),
-            crate::layout::roll_height().map_or(0.0, |h| h * s),
-        )
+        relief_px_at(crate::scale::scale_factor())
     }
 
     fn write_window_info(&mut self) {
@@ -1059,24 +1133,8 @@ impl VkRenderer {
         // [roll profile meta vec4][8 vec4 roll slopes][relief heights vec4]
         // — must stay in lockstep with shader2d's WindowInfo. (The frost
         // recipe is per plate, in its push block, since RFC material step 3.)
-        let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
-        data[0] = self.extent.width as f32;
-        data[1] = self.extent.height as f32;
-        data[2] = self.clip_corner_radius();
-        data[3] = crate::layout::corner_shape();
-        if let Some(slopes) = crate::layout::bevel_profile_slopes() {
-            data[4] = 1.0;
-            data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
-            data[8..8 + slopes.len()].copy_from_slice(&slopes);
-        }
-        if let Some(slopes) = crate::layout::roll_profile_slopes() {
-            data[40] = 1.0;
-            data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
-            data[44..44 + slopes.len()].copy_from_slice(&slopes);
-        }
         let relief = self.relief_px();
-        data[76] = relief.0;
-        data[77] = relief.1;
+        let data = window_info_data(self.extent, self.clip_corner_radius(), relief);
         self.relief_uploaded = relief;
         self.profile_gen = crate::layout::bevel_profile_generation();
         self.roll_profile_gen = crate::layout::roll_profile_generation();
@@ -2231,28 +2289,7 @@ impl VkRenderer {
                             // Per-batch rounded-rect clip (fragments outside
                             // discard) + the SDF-lit plate block when this
                             // batch is a plate cover quad.
-                            let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
-                            let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
-                            let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
-                            pc[..5].copy_from_slice(&rr);
-                            pc[5] = enabled;
-                            pc[7] = clip_shape;
-                            if let Some(p) = &batch.plate {
-                                pc[6] = p.mode;
-                                pc[7] = p.shape;
-                                pc[8..12].copy_from_slice(&p.rect);
-                                pc[12..16].copy_from_slice(&p.radii);
-                                pc[16..20].copy_from_slice(&p.light);
-                                pc[20..24].copy_from_slice(&p.material);
-                                pc[24..28].copy_from_slice(&p.host);
-                                pc[28..32].copy_from_slice(&p.specular_tint);
-                                if p.mode == 1.0 || p.mode == 14.0 {
-                                    // Rebase the feature offset onto this
-                                    // frame's UBO slot (a plate's CSG carves,
-                                    // or a union carve's boxes).
-                                    pc[24] += (frame_index * MAX_PLATE_FEATURES) as f32;
-                                }
-                            }
+                            let pc = batch_push_constants(batch, clip_shape, frame_index * MAX_PLATE_FEATURES);
                             self.core.device.cmd_push_constants(
                                 cmd,
                                 self.pipeline_layout,