Merge pull request #78 from levineuwirth/session-gpu-golden-harness
GPU headless render harness + lavapipe CI gate (F-014)
This commit is contained in:
commit
6acbb98e38
|
|
@ -41,6 +41,38 @@ jobs:
|
||||||
# it explicitly or its warnings slip through CI (audit F-001).
|
# it explicitly or its warnings slip through CI (audit F-001).
|
||||||
- run: cargo clippy -p pmacs-gpu --all-targets -- -D warnings
|
- 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: |
|
||||||
|
ls -la /usr/share/vulkan/icd.d/ || true
|
||||||
|
vulkaninfo --summary || true
|
||||||
|
- name: pmacs-gpu tests (render harness included)
|
||||||
|
env:
|
||||||
|
# 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
|
||||||
|
LIBGL_ALWAYS_SOFTWARE: "1"
|
||||||
|
PMACS_REQUIRE_GPU: "1"
|
||||||
|
run: cargo test -p pmacs-gpu -- --test-threads=1
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test (${{ matrix.os }} / ${{ matrix.lua }})
|
name: Test (${{ matrix.os }} / ${{ matrix.lua }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
|
||||||
|
|
@ -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<Arc<Window>>` and `surface: Option<Surface>`; 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<u8>` 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.
|
||||||
|
|
@ -319,10 +319,12 @@ type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
|
||||||
reason = "independent render/input state flags, not a config bitset"
|
reason = "independent render/input state flags, not a config bitset"
|
||||||
)]
|
)]
|
||||||
struct State {
|
struct State {
|
||||||
window: Arc<Window>,
|
// `None` in the headless render-test path (F-014): a windowless State
|
||||||
|
// that renders to an offscreen texture instead of a surface.
|
||||||
|
window: Option<Arc<Window>>,
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
queue: wgpu::Queue,
|
queue: wgpu::Queue,
|
||||||
surface: wgpu::Surface<'static>,
|
surface: Option<wgpu::Surface<'static>>,
|
||||||
config: wgpu::SurfaceConfiguration,
|
config: wgpu::SurfaceConfiguration,
|
||||||
font_system: FontSystem,
|
font_system: FontSystem,
|
||||||
swash_cache: SwashCache,
|
swash_cache: SwashCache,
|
||||||
|
|
@ -1162,7 +1164,7 @@ impl ApplicationHandler<AppEvent> for App {
|
||||||
if let Some(deadline) = state.styled_redraw_deadline {
|
if let Some(deadline) = state.styled_redraw_deadline {
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
state.styled_redraw_deadline = None;
|
state.styled_redraw_deadline = None;
|
||||||
state.window.request_redraw();
|
state.request_redraw();
|
||||||
} else {
|
} else {
|
||||||
next_wake = Some(deadline);
|
next_wake = Some(deadline);
|
||||||
}
|
}
|
||||||
|
|
@ -1581,7 +1583,74 @@ impl State {
|
||||||
view_formats: vec![],
|
view_formats: vec![],
|
||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
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<Self> {
|
||||||
|
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<Arc<Window>>,
|
||||||
|
surface: Option<wgpu::Surface<'static>>,
|
||||||
|
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();
|
let mut font_system = FontSystem::new();
|
||||||
font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec());
|
font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec());
|
||||||
let swash_cache = SwashCache::new();
|
let swash_cache = SwashCache::new();
|
||||||
|
|
@ -1594,7 +1663,7 @@ impl State {
|
||||||
height: config.height,
|
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 =
|
let text_renderer =
|
||||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||||
// Q#CM1 — a second renderer so the menu draws as a top layer.
|
// 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.
|
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
|
||||||
let mb_text_renderer =
|
let mb_text_renderer =
|
||||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||||
let quad_renderer = QuadRenderer::new(&device, surface_format);
|
let quad_renderer = QuadRenderer::new(&device, format);
|
||||||
let squiggle_renderer = SquiggleRenderer::new(&device, surface_format);
|
let squiggle_renderer = SquiggleRenderer::new(&device, format);
|
||||||
|
|
||||||
// Smaller font in attach mode (file contents tend to be more
|
// Smaller font in attach mode (file contents tend to be more
|
||||||
// than one line); larger only fits "hello, pmacs"-shaped
|
// than one line); larger only fits "hello, pmacs"-shaped
|
||||||
|
|
@ -2342,7 +2411,7 @@ impl State {
|
||||||
// reshape, so none triggers one — diagnostic
|
// reshape, so none triggers one — diagnostic
|
||||||
// publishes no longer pay set_rich_text +
|
// publishes no longer pay set_rich_text +
|
||||||
// shape_until_scroll.
|
// shape_until_scroll.
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
InstanceMessage::InlineAdornments { buffer_id, items } => {
|
InstanceMessage::InlineAdornments { buffer_id, items } => {
|
||||||
|
|
@ -2378,7 +2447,7 @@ impl State {
|
||||||
diag_errors,
|
diag_errors,
|
||||||
diag_warnings,
|
diag_warnings,
|
||||||
});
|
});
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
|
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
|
||||||
|
|
@ -2403,7 +2472,7 @@ impl State {
|
||||||
regex,
|
regex,
|
||||||
invalid,
|
invalid,
|
||||||
});
|
});
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// Session 9.3 — peer presence. The editing frontend's
|
// Session 9.3 — peer presence. The editing frontend's
|
||||||
|
|
@ -2438,7 +2507,7 @@ impl State {
|
||||||
selection,
|
selection,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// Session B1 — our own cursor. The daemon emits this per
|
// Session B1 — our own cursor. The daemon emits this per
|
||||||
|
|
@ -2510,7 +2579,7 @@ impl State {
|
||||||
return Some(vp);
|
return Some(vp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
InstanceMessage::DispatchIdle { idle } => {
|
InstanceMessage::DispatchIdle { idle } => {
|
||||||
|
|
@ -2536,7 +2605,7 @@ impl State {
|
||||||
anchor_px: self.menu_anchor_px,
|
anchor_px: self.menu_anchor_px,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
|
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
|
||||||
|
|
@ -2557,7 +2626,7 @@ impl State {
|
||||||
selected,
|
selected,
|
||||||
total,
|
total,
|
||||||
});
|
});
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -2753,7 +2822,7 @@ impl State {
|
||||||
// content changes; offsets are clip-rebased per frame.
|
// content changes; offsets are clip-rebased per frame.
|
||||||
self.view_range = (vstart, vend);
|
self.view_range = (vstart, vend);
|
||||||
self.hit_map_dirty = true;
|
self.hit_map_dirty = true;
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let line_idx = self
|
let line_idx = self
|
||||||
|
|
@ -2783,7 +2852,7 @@ impl State {
|
||||||
self.buffer.shape_until_scroll(&mut self.font_system, false);
|
self.buffer.shape_until_scroll(&mut self.font_system, false);
|
||||||
self.view_range = (vstart, vend);
|
self.view_range = (vstart, vend);
|
||||||
self.hit_map_dirty = true;
|
self.hit_map_dirty = true;
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2877,7 +2946,7 @@ impl State {
|
||||||
self.hit_map_dirty = true;
|
self.hit_map_dirty = true;
|
||||||
if any_reused || self.line_chunk_cache.is_empty() {
|
if any_reused || self.line_chunk_cache.is_empty() {
|
||||||
self.styled_redraw_deadline = None;
|
self.styled_redraw_deadline = None;
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
} else {
|
} else {
|
||||||
// Far jump (Q#M6, bet #2): every line rebuilt, and the
|
// Far jump (Q#M6, bet #2): every line rebuilt, and the
|
||||||
// span set covers the *old* viewport — drawing now would
|
// span set covers the *old* viewport — drawing now would
|
||||||
|
|
@ -2920,7 +2989,7 @@ impl State {
|
||||||
// Fresh styling reached the slice — release any held
|
// Fresh styling reached the slice — release any held
|
||||||
// post-jump frame (Q#M6, bet #2).
|
// post-jump frame (Q#M6, bet #2).
|
||||||
self.styled_redraw_deadline = None;
|
self.styled_redraw_deadline = None;
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compose the status-band readout (Q#S1): diagnostic counts
|
/// 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_line_shapes = minimap_line_shapes(&self.current_text);
|
||||||
self.current_summary = Some(FileStyleSummaryState { generation, lines });
|
self.current_summary = Some(FileStyleSummaryState { generation, lines });
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `full = true` path: discard prior styling, take the segments'
|
/// `full = true` path: discard prior styling, take the segments'
|
||||||
|
|
@ -3490,13 +3559,23 @@ impl State {
|
||||||
self.hit_map_dirty = true;
|
self.hit_map_dirty = true;
|
||||||
// Full restyle: release any held post-jump frame (Q#M6).
|
// Full restyle: release any held post-jump frame (Q#M6).
|
||||||
self.styled_redraw_deadline = None;
|
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<ViewportSend> {
|
fn resize(&mut self, width: u32, height: u32) -> Option<ViewportSend> {
|
||||||
self.config.width = width;
|
self.config.width = width;
|
||||||
self.config.height = height;
|
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
|
self.viewport
|
||||||
.update(&self.queue, Resolution { width, height });
|
.update(&self.queue, Resolution { width, height });
|
||||||
self.buffer.set_size(
|
self.buffer.set_size(
|
||||||
|
|
@ -3517,29 +3596,131 @@ impl State {
|
||||||
// A taller/shorter window changes the visible line count, so the
|
// A taller/shorter window changes the visible line count, so the
|
||||||
// slice + scoped viewport change (session S1).
|
// slice + scoped viewport change (session S1).
|
||||||
self.reshape();
|
self.reshape();
|
||||||
self.window.request_redraw();
|
self.request_redraw();
|
||||||
self.current_buffer_id
|
self.current_buffer_id
|
||||||
.and_then(|bid| self.viewport_send_if_changed(bid))
|
.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) {
|
fn render(&mut self) {
|
||||||
let frame = match self.surface.get_current_texture() {
|
let frame = {
|
||||||
wgpu::CurrentSurfaceTexture::Success(frame)
|
let Some(surface) = self.surface.as_ref() else {
|
||||||
| 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");
|
|
||||||
return;
|
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
|
let view = frame
|
||||||
.texture
|
.texture
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
.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<u8> {
|
||||||
|
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);
|
let frame_start = debug_frame().then(std::time::Instant::now);
|
||||||
self.refresh_status_line();
|
self.refresh_status_line();
|
||||||
self.refresh_menu_buffer();
|
self.refresh_menu_buffer();
|
||||||
|
|
@ -3789,7 +3970,7 @@ impl State {
|
||||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some("pmacs-gpu pass"),
|
label: Some("pmacs-gpu pass"),
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: &view,
|
view,
|
||||||
depth_slice: None,
|
depth_slice: None,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
|
|
@ -3849,7 +4030,6 @@ impl State {
|
||||||
.expect("menu text_renderer render");
|
.expect("menu text_renderer render");
|
||||||
}
|
}
|
||||||
self.queue.submit(std::iter::once(encoder.finish()));
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
frame.present();
|
|
||||||
self.atlas.trim();
|
self.atlas.trim();
|
||||||
|
|
||||||
if let (Some(start), Some(after_bg), Some(after_minimap)) =
|
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], 100, 100).is_empty());
|
||||||
assert!(squiggles_to_vertex_bytes(&[zero_w], 0, 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<State> {
|
||||||
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue