Merge pull request #78 from levineuwirth/session-gpu-golden-harness

GPU headless render harness + lavapipe CI gate (F-014)
This commit is contained in:
Levi Neuwirth 2026-07-03 14:11:40 -04:00 committed by GitHub
commit 6acbb98e38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 438 additions and 34 deletions

View File

@ -41,6 +41,38 @@ 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: |
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:
name: Test (${{ matrix.os }} / ${{ matrix.lua }})
runs-on: ${{ matrix.os }}

View File

@ -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.

View File

@ -319,10 +319,12 @@ type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
reason = "independent render/input state flags, not a config bitset"
)]
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,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
surface: Option<wgpu::Surface<'static>>,
config: wgpu::SurfaceConfiguration,
font_system: FontSystem,
swash_cache: SwashCache,
@ -1162,7 +1164,7 @@ impl ApplicationHandler<AppEvent> 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<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();
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<ViewportSend> {
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<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);
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<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)"
);
}
}