Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 275 additions & 27 deletions crates/cli/lib/commands/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod macos {
use std::os::unix::net::UnixStream;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use anyhow::{anyhow, Context};
use memmap2::Mmap;
Expand All @@ -59,6 +60,9 @@ mod macos {
/// Only the first scanout is shown.
const SCANOUT: u32 = 0;

/// Set `MSB_DISPLAY_STATS=1` for a one-line frame/copy summary per second.
const STATS_ENV: &str = "MSB_DISPLAY_STATS";

enum UserEvent {
Server(ServerMsg),
Disconnected,
Expand All @@ -70,6 +74,137 @@ mod macos {
frame_size: usize,
mmap: Mmap,
slot: usize,
scaler: Scaler,
}

/// Nearest-neighbour scaler for windows that are not the scanout size, with
/// the x-index table cached across redraws so a resize costs one table
/// build rather than a division per pixel.
#[derive(Default)]
struct Scaler {
xmap: Vec<u32>,
/// `(source width, destination width)` `xmap` was built for.
built_for: (usize, usize),
/// The source row currently expanded to `0RGB`.
row: Vec<u32>,
}

impl Scaler {
/// Scale a `sw` x `sh` BGRX frame into a `dw` x `dh` `0RGB` buffer.
/// Integer factors duplicate pixels with slice fills, other sizes gather
/// through the x-index table; either way each source row is expanded
/// once and destination rows sampling the same source row are copied
/// rather than rebuilt.
fn scale_into(
&mut self,
src: &[u8],
(sw, sh): (usize, usize),
dst: &mut [u32],
(dw, dh): (usize, usize),
) {
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
return;
}
if src.len() < sw * sh * 4 || dst.len() < dw * dh {
return;
}
let factor = (dw % sw == 0 && dh % sh == 0 && dw / sw == dh / sh).then(|| dw / sw);
if factor.is_none() && self.built_for != (sw, dw) {
self.xmap = (0..dw).map(|x| (x * sw / dw) as u32).collect();
self.built_for = (sw, dw);
}
// The source row currently in `self.row`, and the destination row
// holding it — consecutive destination rows usually share one.
let mut built: Option<usize> = None;
let mut prev = 0usize;
for y in 0..dh {
let sy = y * sh / dh;
let (done, rest) = dst.split_at_mut(y * dw);
let out = &mut rest[..dw];
if built == Some(sy) {
out.copy_from_slice(&done[prev * dw..prev * dw + dw]);
continue;
}
self.row.clear();
self.row.extend(
src[sy * sw * 4..(sy + 1) * sw * 4]
.chunks_exact(4)
.map(|px| u32::from_le_bytes([px[0], px[1], px[2], 0])),
);
match factor {
Some(k) => {
for (x, &px) in self.row.iter().enumerate() {
out[x * k..(x + 1) * k].fill(px);
}
}
None => {
for (o, &sx) in out.iter_mut().zip(&self.xmap) {
*o = self.row[sx as usize];
}
}
}
built = Some(sy);
prev = y;
}
}
}

/// Byte view of a pixel slice. softbuffer's macOS backend takes `0RGB`
/// `u32`s and renders them with `NoneSkipFirst` + little-endian byte order
/// (`softbuffer-0.4.8/src/backends/cg.rs:326`), so a pixel is `[b, g, r, x]`
/// in memory — exactly the guest's BGRX layout, with the top byte ignored.
/// A same-size frame therefore reaches the window as one `memcpy`, with no
/// per-pixel conversion at all.
fn pixels_as_bytes_mut(pixels: &mut [u32]) -> &mut [u8] {
const _: () = assert!(cfg!(target_endian = "little"), "0RGB pixels assume LE");
// SAFETY: `u32` has no padding and no invalid bit patterns, and its
// alignment is stricter than `u8`'s, so the same memory is a valid
// `[u8]` four times as long, for the same lifetime.
unsafe {
std::slice::from_raw_parts_mut(pixels.as_mut_ptr().cast::<u8>(), pixels.len() * 4)
}
}

/// Per-second counters printed when `MSB_DISPLAY_STATS=1`.
struct Stats {
since: Instant,
frames: u64,
redraws: u64,
bytes: u64,
draw: Duration,
}

impl Stats {
fn new() -> Stats {
Stats {
since: Instant::now(),
frames: 0,
redraws: 0,
bytes: 0,
draw: Duration::ZERO,
}
}

/// Print and reset once a second has passed.
fn tick(&mut self) {
let elapsed = self.since.elapsed();
if elapsed < Duration::from_secs(1) {
return;
}
let secs = elapsed.as_secs_f64();
let per_redraw = if self.redraws == 0 {
0.0
} else {
self.draw.as_secs_f64() * 1e3 / self.redraws as f64
};
eprintln!(
"stats: {:.1} frames/s, {:.1} redraws/s, {:.1} MB/s copied, {per_redraw:.2} ms/redraw",
self.frames as f64 / secs,
self.redraws as f64 / secs,
self.bytes as f64 / secs / 1e6,
);
*self = Stats::new();
}
}

struct Sender(Mutex<UnixStream>);
Expand All @@ -90,6 +225,7 @@ mod macos {
surface: Option<softbuffer::Surface<Rc<Window>, Rc<Window>>>,
scanout: Option<Scanout>,
wheel_carry: (f32, f32),
stats: Option<Stats>,
}

impl App {
Expand All @@ -111,13 +247,23 @@ mod macos {
};
let context = softbuffer::Context::new(window.clone()).expect("softbuffer context");
let surface = softbuffer::Surface::new(&context, window.clone()).expect("surface");
let size = window.inner_size();
eprintln!(
"window: {}x{} physical, scale {}",
size.width,
size.height,
window.scale_factor()
);
self.window = Some(window);
self.surface = Some(surface);
}

fn redraw(&mut self) {
let (Some(window), Some(surface), Some(scanout)) =
(&self.window, &mut self.surface, &self.scanout)
let started = Instant::now();
let Some(window) = self.window.as_ref() else {
return;
};
let (Some(surface), Some(scanout)) = (self.surface.as_mut(), self.scanout.as_mut())
else {
return;
};
Expand All @@ -129,30 +275,41 @@ mod macos {
if surface.resize(w, h).is_err() {
return;
}
let Ok(mut buffer) = surface.buffer_mut() else { return };
let start = scanout.slot * scanout.frame_size;
let src = &scanout.mmap[start..start + scanout.frame_size];
let (sw, sh) = (scanout.width as usize, scanout.height as usize);
let Scanout {
width,
height,
frame_size,
mmap,
slot,
scaler,
} = scanout;
let Some(src) = mmap.get(*slot * *frame_size..(*slot + 1) * *frame_size) else {
return;
};
let bytes = src.len();
// The slot always holds a complete frame, and softbuffer's macOS
// surface buffer never persists — `buffer_mut` hands out a fresh
// zeroed `Vec` and `present` moves it into a `CGDataProvider`
// (cg.rs:261, 298), while `present_with_damage` ignores its damage
// (cg.rs:364). Every redraw therefore writes the whole window out
// of the whole slot; a viewer-side copy of the frame saves nothing.
let Ok(mut buffer) = surface.buffer_mut() else {
return;
};
let (sw, sh) = (*width as usize, *height as usize);
let (dw, dh) = (size.width as usize, size.height as usize);
if (sw, sh) == (dw, dh) {
for (dst, px) in buffer.iter_mut().zip(src.chunks_exact(4)) {
*dst = u32::from_le_bytes([px[0], px[1], px[2], 0]);
}
if (sw, sh) == (dw, dh) && buffer.len() * 4 == bytes {
pixels_as_bytes_mut(&mut buffer).copy_from_slice(src);
} else {
// Nearest-neighbour scale; the window is usually the scanout
// size or its HiDPI multiple.
for y in 0..dh {
let sy = y * sh / dh;
let row = &src[sy * sw * 4..(sy + 1) * sw * 4];
let out = &mut buffer[y * dw..(y + 1) * dw];
for (x, dst) in out.iter_mut().enumerate() {
let sx = x * sw / dw;
let px = &row[sx * 4..sx * 4 + 4];
*dst = u32::from_le_bytes([px[0], px[1], px[2], 0]);
}
}
scaler.scale_into(src, (sw, sh), &mut buffer, (dw, dh));
}
let _ = buffer.present();
if let Some(stats) = self.stats.as_mut() {
stats.redraws += 1;
stats.bytes += bytes as u64;
stats.draw += started.elapsed();
stats.tick();
}
}

fn pointer_abs(&self, x: f64, y: f64) -> Option<(u32, u32)> {
Expand Down Expand Up @@ -202,18 +359,29 @@ mod macos {
frame_size,
mmap,
slot: 0,
scaler: Scaler::default(),
});
self.ensure_window(event_loop, width, height);
if let Some(window) = &self.window {
window.request_redraw();
}
}
// `rect` is deliberately ignored. Linux's virtio-gpu driver
// sets `ignore_damage_clips` whenever the plane's framebuffer
// object changes, because uploads are done per buffer (v6.12
// `drivers/gpu/drm/virtio/virtgpu_plane.c:91-97`), and a
// compositor page-flips between buffers on every frame — so the
// guest's FLUSH always covers the whole scanout. It stays in the
// protocol for logging and for guests that do send real damage.
UserEvent::Server(ServerMsg::Frame { scanout, slot, .. }) => {
if scanout != SCANOUT {
return;
}
if let Some(s) = &mut self.scanout {
s.slot = slot as usize;
let Some(s) = &mut self.scanout else { return };
s.slot = slot as usize;
if let Some(stats) = self.stats.as_mut() {
stats.frames += 1;
stats.tick();
}
if let Some(window) = &self.window {
window.request_redraw();
Expand All @@ -231,7 +399,12 @@ mod macos {
}
}

fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => self.redraw(),
Expand All @@ -244,8 +417,12 @@ mod macos {
if event.repeat {
return;
}
let PhysicalKey::Code(code) = event.physical_key else { return };
let Some(code) = keycode_to_evdev(code) else { return };
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(code) = keycode_to_evdev(code) else {
return;
};
self.sender.send(&ViewerMsg::Key {
code,
down: event.state == ElementState::Pressed,
Expand Down Expand Up @@ -338,6 +515,9 @@ mod macos {
surface: None,
scanout: None,
wheel_carry: (0.0, 0.0),
stats: std::env::var_os(STATS_ENV)
.is_some_and(|v| v == "1")
.then(Stats::new),
};
event_loop
.run_app(&mut app)
Expand Down Expand Up @@ -460,4 +640,72 @@ mod macos {
_ => return None,
})
}

//--------------------------------------------------------------------------------------------------
// Tests
//--------------------------------------------------------------------------------------------------

#[cfg(test)]
mod tests {
use super::*;

/// A `w` x `h` BGRX frame whose pixels encode their own index.
fn frame(w: usize, h: usize) -> Vec<u8> {
(0..w * h)
.flat_map(|i| [i as u8, (i >> 8) as u8, 0, 0xff])
.collect()
}

/// The `0RGB` pixel [`frame`] puts at index `i` — note the BGRX `x`
/// byte is dropped, which is what softbuffer's `NoneSkipFirst` ignores.
fn pixel(i: usize) -> u32 {
u32::from_le_bytes([i as u8, (i >> 8) as u8, 0, 0])
}

#[test]
fn integer_scale_duplicates_pixels_and_rows() {
let (sw, sh) = (3usize, 2usize);
let (dw, dh) = (sw * 2, sh * 2);
let mut dst = vec![u32::MAX; dw * dh];
Scaler::default().scale_into(&frame(sw, sh), (sw, sh), &mut dst, (dw, dh));
let want: Vec<u32> = [
0, 0, 1, 1, 2, 2, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 3, 3, 4, 4, 5, 5,
]
.iter()
.map(|&i| pixel(i))
.collect();
assert_eq!(dst, want);
}

#[test]
fn non_integer_scale_matches_nearest_neighbour() {
let (sw, sh) = (4usize, 3usize);
let (dw, dh) = (7usize, 5usize);
let src = frame(sw, sh);
let mut dst = vec![u32::MAX; dw * dh];
let mut scaler = Scaler::default();
scaler.scale_into(&src, (sw, sh), &mut dst, (dw, dh));
for y in 0..dh {
for x in 0..dw {
let want = pixel((y * sh / dh) * sw + x * sw / dw);
assert_eq!(dst[y * dw + x], want, "at {x},{y}");
}
}
// The table is cached and the second pass reuses it unchanged.
assert_eq!(scaler.built_for, (sw, dw));
let mut again = vec![u32::MAX; dw * dh];
scaler.scale_into(&src, (sw, sh), &mut again, (dw, dh));
assert_eq!(again, dst);
}

#[test]
fn scale_into_ignores_buffers_that_are_too_small() {
let mut dst = vec![0u32; 3];
Scaler::default().scale_into(&frame(2, 2), (2, 2), &mut dst, (2, 2));
assert_eq!(dst, vec![0, 0, 0]);
let mut dst = vec![0u32; 4];
Scaler::default().scale_into(&frame(2, 1), (2, 2), &mut dst, (2, 2));
assert_eq!(dst, vec![0, 0, 0, 0]);
}
}
}