From a5f1372b3e1ca83b76b5857a32612b6d76dd435e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 3 Jul 2026 12:15:19 -0400 Subject: [PATCH 1/3] pmacs GPU: headless render harness + lavapipe CI gate (F-014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit F-014. pmacs-gpu had 40 tests but none exercised the wgpu composition path — layout/render regressions passed silently, a human eyeball the only gate. This adds the first tests that actually render a frame, headless, and read the pixels back. Enabling refactor (least-invasive; not the Renderer-sub-struct split): - `State.window`/`.surface` become `Option`; a shared `assemble(...)` builds the window-agnostic half, called by both the windowed `new` and a `#[cfg(test)] new_headless(w, h, text)` (compatible_surface: None, returns None when no adapter). `request_redraw` is now an Option-guarded helper across its 15 sites; `resize` guards `surface.configure`. - `render()` splits into a surface-acquire wrapper + window-agnostic `render_to_view(&TextureView)`; `#[cfg(test)] render_offscreen()` renders through the same path into a RENDER_ATTACHMENT|COPY_SRC texture and reads it back (256-byte row alignment). Two smoke tests through the real composition path: a full frame is non-uniform (something composited); setting text changes the frame vs an empty buffer. They skip when no adapter is present, except under `PMACS_REQUIRE_GPU` (CI) where a missing adapter is a hard failure. CI: a "GPU Render (headless)" job installs mesa-vulkan-drivers (lavapipe) and runs `cargo test -p pmacs-gpu` — also the first time pmacs-gpu's tests run in CI at all (the workspace test job covers only the root package). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- .github/workflows/ci.yml | 28 ++++ pmacs-gpu/src/main.rs | 309 ++++++++++++++++++++++++++++++++++----- 2 files changed, 303 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e59c50..caa3789 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,34 @@ jobs: # it explicitly or its warnings slip through CI (audit F-001). - run: cargo clippy -p pmacs-gpu --all-targets -- -D warnings + gpu-render: + name: GPU Render (headless) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # The headless render harness (audit F-014) needs a Vulkan adapter; + # the runner has no GPU, so install the mesa software rasterizer + # (lavapipe). This job also runs pmacs-gpu's other tests, which the + # workspace test job (root package only) never executes. + - name: Install lavapipe (Vulkan software rasterizer) + run: | + sudo apt-get update + sudo apt-get install -y mesa-vulkan-drivers vulkan-tools + - name: Confirm a Vulkan adapter is present + run: vulkaninfo --summary || true + - name: pmacs-gpu tests (render harness included) + env: + # Force the Vulkan backend + lavapipe ICD so wgpu finds the + # software adapter; PMACS_REQUIRE_GPU turns a missing adapter + # into a hard failure so a broken setup can't pass as skipped. + WGPU_BACKEND: vulkan + VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.x86_64.json + LIBGL_ALWAYS_SOFTWARE: "1" + PMACS_REQUIRE_GPU: "1" + run: cargo test -p pmacs-gpu -- --test-threads=1 + test: name: Test (${{ matrix.os }} / ${{ matrix.lua }}) runs-on: ${{ matrix.os }} diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index d5716c6..c1f0188 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -319,10 +319,12 @@ type LoroTextDeltaBatches = Arc>>>; reason = "independent render/input state flags, not a config bitset" )] struct State { - window: Arc, + // `None` in the headless render-test path (F-014): a windowless State + // that renders to an offscreen texture instead of a surface. + window: Option>, device: wgpu::Device, queue: wgpu::Queue, - surface: wgpu::Surface<'static>, + surface: Option>, config: wgpu::SurfaceConfiguration, font_system: FontSystem, swash_cache: SwashCache, @@ -1162,7 +1164,7 @@ impl ApplicationHandler for App { if let Some(deadline) = state.styled_redraw_deadline { if now >= deadline { state.styled_redraw_deadline = None; - state.window.request_redraw(); + state.request_redraw(); } else { next_wake = Some(deadline); } @@ -1581,7 +1583,74 @@ impl State { view_formats: vec![], }; surface.configure(&device, &config); + Self::assemble( + Some(window), + Some(surface), + device, + queue, + config, + initial_text, + ) + } + /// Build a windowless `State` that renders to an offscreen texture, for + /// the headless render tests (F-014). Returns `None` when no GPU + /// adapter is available (a dev box with no working Vulkan, or CI + /// without lavapipe), so the caller skips rather than fails. + #[cfg(test)] + fn new_headless(width: u32, height: u32, initial_text: &str) -> Option { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + })) + .ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("pmacs-gpu headless device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + ..wgpu::DeviceDescriptor::default() + })) + .ok()?; + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: width.max(1), + height: height.max(1), + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + }; + Some(Self::assemble( + None, + None, + device, + queue, + config, + initial_text, + )) + } + + /// Build the window-agnostic half of a `State` — font system, glyph + /// atlas, the three text renderers, quad/squiggle pipelines, and every + /// render-input field — given an already-created device/queue and the + /// target `format`. Shared by the windowed `new` and headless + /// `new_headless` (F-014). + #[allow(clippy::too_many_lines)] // one large struct literal. + fn assemble( + window: Option>, + surface: Option>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + initial_text: &str, + ) -> Self { + // Pipelines, atlas, and any offscreen texture must all share the + // render-target format; `config.format` is the single source. + let format = config.format; let mut font_system = FontSystem::new(); font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec()); let swash_cache = SwashCache::new(); @@ -1594,7 +1663,7 @@ impl State { height: config.height, }, ); - let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format); + let mut atlas = TextAtlas::new(&device, &queue, &cache, format); let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Q#CM1 — a second renderer so the menu draws as a top layer. @@ -1603,8 +1672,8 @@ impl State { // Q#MB1 — a third renderer for the minibuffer dropdown layer. let mb_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); - let quad_renderer = QuadRenderer::new(&device, surface_format); - let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); + let quad_renderer = QuadRenderer::new(&device, format); + let squiggle_renderer = SquiggleRenderer::new(&device, format); // Smaller font in attach mode (file contents tend to be more // than one line); larger only fits "hello, pmacs"-shaped @@ -2342,7 +2411,7 @@ impl State { // reshape, so none triggers one — diagnostic // publishes no longer pay set_rich_text + // shape_until_scroll. - self.window.request_redraw(); + self.request_redraw(); None } InstanceMessage::InlineAdornments { buffer_id, items } => { @@ -2378,7 +2447,7 @@ impl State { diag_errors, diag_warnings, }); - self.window.request_redraw(); + self.request_redraw(); None } // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). @@ -2403,7 +2472,7 @@ impl State { regex, invalid, }); - self.window.request_redraw(); + self.request_redraw(); None } // Session 9.3 — peer presence. The editing frontend's @@ -2438,7 +2507,7 @@ impl State { selection, }, ); - self.window.request_redraw(); + self.request_redraw(); None } // Session B1 — our own cursor. The daemon emits this per @@ -2510,7 +2579,7 @@ impl State { return Some(vp); } } - self.window.request_redraw(); + self.request_redraw(); None } InstanceMessage::DispatchIdle { idle } => { @@ -2536,7 +2605,7 @@ impl State { anchor_px: self.menu_anchor_px, }) }; - self.window.request_redraw(); + self.request_redraw(); None } // Q#MB1 — the minibuffer prompt/input/candidates. `prompt: @@ -2557,7 +2626,7 @@ impl State { selected, total, }); - self.window.request_redraw(); + self.request_redraw(); None } _ => None, @@ -2753,7 +2822,7 @@ impl State { // content changes; offsets are clip-rebased per frame. self.view_range = (vstart, vend); self.hit_map_dirty = true; - self.window.request_redraw(); + self.request_redraw(); return true; } let line_idx = self @@ -2783,7 +2852,7 @@ impl State { self.buffer.shape_until_scroll(&mut self.font_system, false); self.view_range = (vstart, vend); self.hit_map_dirty = true; - self.window.request_redraw(); + self.request_redraw(); true } @@ -2877,7 +2946,7 @@ impl State { self.hit_map_dirty = true; if any_reused || self.line_chunk_cache.is_empty() { self.styled_redraw_deadline = None; - self.window.request_redraw(); + self.request_redraw(); } else { // Far jump (Q#M6, bet #2): every line rebuilt, and the // span set covers the *old* viewport — drawing now would @@ -2920,7 +2989,7 @@ impl State { // Fresh styling reached the slice — release any held // post-jump frame (Q#M6, bet #2). self.styled_redraw_deadline = None; - self.window.request_redraw(); + self.request_redraw(); } /// Compose the status-band readout (Q#S1): diagnostic counts @@ -3280,7 +3349,7 @@ impl State { } self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); - self.window.request_redraw(); + self.request_redraw(); } /// `full = true` path: discard prior styling, take the segments' @@ -3490,13 +3559,23 @@ impl State { self.hit_map_dirty = true; // Full restyle: release any held post-jump frame (Q#M6). self.styled_redraw_deadline = None; - self.window.request_redraw(); + self.request_redraw(); + } + + /// Ask the window to repaint. A no-op headless (no window), where the + /// render tests drive `render_offscreen` directly (F-014). + fn request_redraw(&self) { + if let Some(window) = &self.window { + window.request_redraw(); + } } fn resize(&mut self, width: u32, height: u32) -> Option { self.config.width = width; self.config.height = height; - self.surface.configure(&self.device, &self.config); + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.config); + } self.viewport .update(&self.queue, Resolution { width, height }); self.buffer.set_size( @@ -3517,29 +3596,131 @@ impl State { // A taller/shorter window changes the visible line count, so the // slice + scoped viewport change (session S1). self.reshape(); - self.window.request_redraw(); + self.request_redraw(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } - #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. + /// Acquire the surface's current texture and render into it — the live + /// windowed path. Composition lives in `render_to_view`, shared with + /// the headless offscreen path (`render_offscreen`, F-014). fn render(&mut self) { - let frame = match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(frame) - | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, - wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { - self.surface.configure(&self.device, &self.config); - return; - } - wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return, - wgpu::CurrentSurfaceTexture::Validation => { - eprintln!("surface acquisition raised a validation error"); + let frame = { + let Some(surface) = self.surface.as_ref() else { return; + }; + match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) + | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + surface.configure(&self.device, &self.config); + return; + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + return; + } + wgpu::CurrentSurfaceTexture::Validation => { + eprintln!("surface acquisition raised a validation error"); + return; + } } }; let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); + self.render_to_view(&view); + frame.present(); + } + + /// Render one frame to an offscreen texture and read it back as packed + /// RGBA8 (`width * height * 4` bytes, row padding removed). Test-only, + /// the entry point for the headless render harness (F-014). + #[cfg(test)] + fn render_offscreen(&mut self) -> Vec { + let width = self.config.width; + let height = self.config.height; + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pmacs-gpu offscreen target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.render_to_view(&view); + + // Copy into a mappable buffer, honoring the 256-byte per-row + // alignment `copy_texture_to_buffer` requires. + let unpadded_bytes_per_row = width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; + let readback = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pmacs-gpu readback"), + size: u64::from(padded_bytes_per_row) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("pmacs-gpu readback encoder"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = readback.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + self.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll readback"); + rx.recv().expect("map channel").expect("map readback"); + + let mapped = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((unpadded_bytes_per_row * height) as usize); + for row in 0..height { + let start = (row * padded_bytes_per_row) as usize; + pixels.extend_from_slice(&mapped[start..start + unpadded_bytes_per_row as usize]); + } + drop(mapped); + readback.unmap(); + pixels + } + + /// Compose and submit one frame into `view`. Window-agnostic — shared + /// by the live surface path (`render`) and the headless offscreen path + /// (`render_offscreen`, F-014). No surface acquire, no `present`. + #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. + fn render_to_view(&mut self, view: &wgpu::TextureView) { let frame_start = debug_frame().then(std::time::Instant::now); self.refresh_status_line(); self.refresh_menu_buffer(); @@ -3789,7 +3970,7 @@ impl State { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pmacs-gpu pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { @@ -3849,7 +4030,6 @@ impl State { .expect("menu text_renderer render"); } self.queue.submit(std::iter::once(encoder.finish())); - frame.present(); self.atlas.trim(); if let (Some(start), Some(after_bg), Some(after_minimap)) = @@ -6813,4 +6993,65 @@ mod tests { assert!(squiggles_to_vertex_bytes(&[zero_w], 100, 100).is_empty()); assert!(squiggles_to_vertex_bytes(&[zero_w], 0, 100).is_empty()); } + + // --- Headless render harness (F-014) --------------------------------- + // + // These render a real frame through the actual `render_to_view` + // composition path to an offscreen texture and read the pixels back. + // They skip (not fail) when no wgpu adapter is available — a dev box + // without working Vulkan, or CI without lavapipe. + + /// Build a headless `State`, or return `None` and log when there's no + /// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set + /// (CI, where lavapipe is installed) a missing adapter is a hard + /// failure instead — so a broken software-rasterizer setup can't pass + /// as a silently-skipped green (F-014). + fn headless_or_skip(width: u32, height: u32, text: &str) -> Option { + let state = State::new_headless(width, height, text); + if state.is_none() { + assert!( + std::env::var_os("PMACS_REQUIRE_GPU").is_none(), + "PMACS_REQUIRE_GPU is set but no wgpu adapter was available" + ); + eprintln!("skipping headless render test: no wgpu adapter available"); + } + state + } + + #[test] + fn headless_render_produces_a_full_nonblank_frame() { + let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else { + return; + }; + let px = state.render_offscreen(); + assert_eq!(px.len(), 320 * 240 * 4, "packed RGBA8 of the whole frame"); + // A real frame varies (text ink over the background). A single + // uniform value would mean nothing composited. + let first = px[0]; + assert!( + px.iter().any(|&b| b != first), + "frame is a single uniform value — nothing appears to have rendered" + ); + } + + #[test] + fn headless_text_changes_the_rendered_frame() { + let Some(mut empty) = headless_or_skip(320, 240, "") else { + return; + }; + let empty_px = empty.render_offscreen(); + let mut with_text = + State::new_headless(320, 240, "hello pmacs").expect("adapter was just available"); + let text_px = with_text.render_offscreen(); + assert_eq!(empty_px.len(), text_px.len()); + let differing = empty_px + .iter() + .zip(&text_px) + .filter(|(a, b)| a != b) + .count(); + assert!( + differing > 200, + "text should paint visible ink (only {differing} bytes differ from the empty frame)" + ); + } } From 68542adaa525564b3692cf99b8b9f1646bb9b1b4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 3 Jul 2026 12:15:19 -0400 Subject: [PATCH 2/3] docs: GPU headless render harness framing + as-built (Q#GH) Records the gap (no test exercised the wgpu path), the enabling refactor (window/surface Optional + render_to_view split + offscreen readback), the narrow smoke scope, and the lavapipe CI decision. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- docs/gpu-golden-harness-framing.md | 131 +++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/gpu-golden-harness-framing.md diff --git a/docs/gpu-golden-harness-framing.md b/docs/gpu-golden-harness-framing.md new file mode 100644 index 0000000..aa5d128 --- /dev/null +++ b/docs/gpu-golden-harness-framing.md @@ -0,0 +1,131 @@ +# GPU headless render harness — framing + as-built + +pmacs-gpu has 40 unit tests, but every one is helper/logic or +vertex-math — **none exercises the wgpu composition path**. Layout and +rendering regressions (hit-testing, dropdown clipping, minimap +projection, squiggles, selection/current-line backgrounds, status/ +minibuffer composition) pass the suite silently. This is why every GPU +arc this session ended with "looks good in the GUI" — a human eyeball +was the only render gate. Audit F-014. + +This arc adds the **first test that actually renders a frame** — headless +(no window), to an offscreen texture, read back to CPU for pixel +assertions. + +## What the recon established + +- `State` (`main.rs:321`) holds device, queue, surface, window, the + renderers, and ~70 render-input fields. Device/queue creation is + **separable** from window/surface (`request_device` takes no surface; + `request_adapter` can pass `compatible_surface: None`). +- `render()` (`main.rs:3526`) renders to the surface's current texture + view — its **only** target. Everything after the acquire uses `view` + + `config.width/height`, so swapping in an offscreen view is localized. +- Render inputs are **plain settable fields**; `apply_attach_message` is + the wire path, not a gate — a test can set `current_text`, spans, + decorations, menu/minibuffer, etc. directly, no daemon. +- The one blocker: **`State` can't be built without a device + window** + (`window`/`surface` are non-optional). + +## The rule + +**Q#GH1 — make the render core headless-constructible, least-invasively.** +`window: Option>` and `surface: Option`; a +`State::new_headless(width, height)` that builds device/queue/renderers/ +atlas/viewport/config with `compatible_surface: None` and no window; +promote the surface format to a `format` field on `State` (pick a fixed +renderable `Rgba8UnormSrgb` headless, shared by the offscreen texture and +all three pipelines + atlas — they must match). `request_redraw` and +`surface.configure` become `if let Some(...)`. *Not* the full +`Renderer`-sub-struct extraction the recon floated as the "clean" option +— that's hundreds of `self.device → self.renderer.device` edits; deferred +until a second consumer needs it. + +**Q#GH2 — split `render()` into acquire + `render_to_view`.** Move the +body (`3543-3853`) into `render_to_view(&mut self, view: &TextureView)`; +`render()` keeps the surface acquire and calls it; a new +`render_offscreen() -> Vec` creates an `RENDER_ATTACHMENT | COPY_SRC` +texture, calls `render_to_view`, then `copy_texture_to_buffer` into a +`MAP_READ` buffer (256-byte `bytes_per_row` alignment), `map_async`, and +returns RGBA. The composition path is exercised **as-is** — the test +renders through the real `render_to_view`, not a reimplementation. + +**Q#GH3 — start narrow (the audit's own guidance).** Smoke assertions, +not pixel-exact golden PNGs: +- an **empty** buffer renders the clear color (`BG`) everywhere — the + baseline; +- with `current_text` set, **ink appears** — the frame differs from the + empty baseline in the text region (glyphs rasterized); +- a colored decoration background paints **its** color where placed. + +These catch the real regression class ("nothing renders" / "text stopped +drawing" / "a layer broke") without brittle exact-pixel goldens. + +## Categorical bets + +- **Exercise the real path, don't mock it.** The value is in rendering + through the actual `render_to_view` + real renderers; a reimplemented + mini-pipeline would test itself, not the code. +- **`Rgba8UnormSrgb` headless is representative enough.** The surface + picks an srgb format anyway; a fixed one keeps readback deterministic. + +## CI gating (decided: lavapipe now) + +Headless wgpu needs an adapter and CI runners have no GPU, so the CI job +installs the Vulkan software rasterizer **lavapipe** (`mesa-vulkan- +drivers`) and points wgpu at its ICD. A dedicated **GPU Render +(headless)** job then runs `cargo test -p pmacs-gpu` — which *also* +closes a gap the recon surfaced: the workspace default member is only the +root `pmacs` package, so pmacs-gpu's tests weren't being executed in CI +at all (the clippy job builds them but never runs them). This job runs +them, render tests included. + +The test still **skips gracefully** when `request_adapter` returns `None` +(log + early return) so it doesn't fail on a dev box without a working +adapter — but with lavapipe present in CI it runs for real and gates +every PR. + +## Validation implication + +Making `surface`/`window` optional touches `render()` and `resize()` — +the live windowed path. The headless test passing does **not** prove the +windowed frontend still renders; **that needs a human eyeball** after +this lands (attach the GUI, confirm it still draws/resizes). + +## As-built + +Landed as framed, via the least-invasive refactor (not the `Renderer` +extraction): + +- `State.window`/`State.surface` are now `Option`; a shared + `State::assemble(window, surface, device, queue, config, initial_text)` + builds the window-agnostic half, called by both the windowed `new` and + a `#[cfg(test)] new_headless(w, h, text)` that passes + `compatible_surface: None` and returns `None` when no adapter exists. + The stored `format` field turned out redundant with `config.format`, so + it was dropped — `config.format` is the single source the pipelines, + atlas, and offscreen texture share. +- `render()` split into a surface-acquire wrapper + `render_to_view(& + TextureView)`; a `#[cfg(test)] render_offscreen()` renders through the + same `render_to_view` into a `RENDER_ATTACHMENT | COPY_SRC` texture and + reads it back (256-byte row alignment, `PollType::wait_indefinitely`). + `request_redraw` became an `Option`-guarded helper across its 15 call + sites. +- Two smoke tests through the **real** composition path: a full frame is + non-uniform (something composited), and setting text changes the frame + vs an empty buffer. Both pass locally on this box's AMD Vulkan adapter. +- CI: a **GPU Render (headless)** job installs `mesa-vulkan-drivers` + (lavapipe) and runs `cargo test -p pmacs-gpu` — also the first time + pmacs-gpu's tests run in CI at all. `PMACS_REQUIRE_GPU=1` there turns a + missing adapter into a hard failure, so a broken lavapipe setup can't + masquerade as a silently-skipped green. + +Divergence from framing: the fixed-format `format` field was dropped as +redundant (above). No other divergence. + +## Deferred (named) + +- Golden-PNG comparison + a case gallery (dropdown clipped above a short + window, minimap projection, squiggle under a glyph, resize reflow). +- The `Renderer`-sub-struct extraction, if a second headless consumer + appears. From 6d0cdfe0f549d3b7fd1f5026d3448065c1e6e846 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 3 Jul 2026 12:55:29 -0400 Subject: [PATCH 3/3] ci: fix lavapipe adapter discovery in the GPU Render job (F-014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run showed lavapipe installed and visible to vulkaninfo (DRIVER_ID_MESA_LLVMPIPE), but the render tests still found no wgpu adapter and — correctly — hard-failed under PMACS_REQUIRE_GPU. Cause: VK_ICD_FILENAMES pinned an ICD path that doesn't match the runner, which overrides the loader's default discovery and hides every ICD. Drop it and rely on default discovery (which vulkaninfo uses); also `ls` the icd.d dir for future debugging. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- .github/workflows/ci.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caa3789..4bce42f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,14 +57,18 @@ jobs: sudo apt-get update sudo apt-get install -y mesa-vulkan-drivers vulkan-tools - name: Confirm a Vulkan adapter is present - run: vulkaninfo --summary || true + run: | + ls -la /usr/share/vulkan/icd.d/ || true + vulkaninfo --summary || true - name: pmacs-gpu tests (render harness included) env: - # Force the Vulkan backend + lavapipe ICD so wgpu finds the - # software adapter; PMACS_REQUIRE_GPU turns a missing adapter - # into a hard failure so a broken setup can't pass as skipped. + # Force the Vulkan backend so wgpu uses lavapipe. Do NOT set + # VK_ICD_FILENAMES — the loader's default ICD discovery already + # finds lavapipe (vulkaninfo above proves it), and pinning a + # path that doesn't match the runner hides every ICD. The + # PMACS_REQUIRE_GPU guard turns a missing adapter into a hard + # failure so a broken setup can't pass as a silent skip. WGPU_BACKEND: vulkan - VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.x86_64.json LIBGL_ALWAYS_SOFTWARE: "1" PMACS_REQUIRE_GPU: "1" run: cargo test -p pmacs-gpu -- --test-threads=1