Skip to main content

netget/client/spice/
display.rs

1//! The display channel as the model sees it: the primary surface, rebuilt from the draw
2//! commands this client can apply exactly, plus a count of every command and image the server
3//! sent — so a reading is honest about what it could not show.
4//!
5//! Applied exactly: SURFACE_CREATE/DESTROY of the primary surface, DRAW_FILL with a solid
6//! brush, DRAW_COPY of an uncompressed bitmap (32-bit, RGBA, 24-bit; scaled nearest-neighbour
7//! when the source and destination differ) or of an image from the pixmap cache, BLACKNESS,
8//! WHITENESS, INVERS and COPY_BITS, each through its clip rectangles. Everything else —
9//! compressed images (QUIC, LZ, GLZ, JPEG, LZ4), patterns, masks, raster operations other than
10//! PUT, streams, text and strokes — is counted, and a reading that includes any of it says
11//! `exact: false`. The client asks the server for uncompressed images
12//! (`SPICE_MSGC_DISPLAY_PREFERRED_COMPRESSION` OFF) where it offers the choice, which spice-server
13//! honours.
14//!
15//! Every size the server declares is checked before anything is allocated for it: a surface or
16//! image above [`MAX_SIDE`] a side or [`MAX_PIXELS`] in all is refused, a bitmap must fit in the
17//! message that carries it, and the pixmap cache holds at most [`MAX_CACHE_BYTES`].
18use crate::server::spice::wire::{self, image_type, msg, Cursor};
19use anyhow::{ensure, Result};
20use serde_json::{json, Value};
21use std::collections::{BTreeMap, HashMap, VecDeque};
22use tokio::time::Instant;
23
24/// The largest surface or image side accepted.
25pub const MAX_SIDE: u32 = 4096;
26/// The largest surface or image accepted, in pixels (4 bytes each).
27pub const MAX_PIXELS: u64 = 4096 * 2160;
28/// The pixmap cache's ceiling, and (in pixels) what DISPLAY_INIT announces to the server.
29pub const MAX_CACHE_BYTES: usize = 64 << 20;
30/// Clip rectangles accepted on one draw.
31pub const MAX_CLIP_RECTS: u32 = 1024;
32/// Monitors accepted in one MONITORS_CONFIG.
33pub const MAX_MONITORS: u16 = 16;
34
35/// What a display message changed that the model should hear about.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Change {
38    SurfaceCreated {
39        width: u32,
40        height: u32,
41        format: u32,
42    },
43}
44
45/// An image decoded to BGRX.
46#[derive(Clone)]
47struct Image {
48    width: u32,
49    height: u32,
50    bgrx: Vec<u8>,
51}
52
53#[derive(Clone, Copy, Debug)]
54struct Rect {
55    top: i64,
56    left: i64,
57    bottom: i64,
58    right: i64,
59}
60
61impl Rect {
62    fn read(c: &mut Cursor) -> Result<Self> {
63        Ok(Rect {
64            top: c.i32("rect top")? as i64,
65            left: c.i32("rect left")? as i64,
66            bottom: c.i32("rect bottom")? as i64,
67            right: c.i32("rect right")? as i64,
68        })
69    }
70    fn width(&self) -> i64 {
71        self.right - self.left
72    }
73    fn height(&self) -> i64 {
74        self.bottom - self.top
75    }
76    fn intersect(&self, o: &Rect) -> Rect {
77        Rect {
78            top: self.top.max(o.top),
79            left: self.left.max(o.left),
80            bottom: self.bottom.min(o.bottom),
81            right: self.right.min(o.right),
82        }
83    }
84    fn is_empty(&self) -> bool {
85        self.width() <= 0 || self.height() <= 0
86    }
87}
88
89struct Surface {
90    id: u32,
91    width: u32,
92    height: u32,
93    format: u32,
94    bgrx: Vec<u8>,
95}
96
97/// The pixmap cache: images the server flagged CACHE_ME, by id, oldest evicted first.
98#[derive(Default)]
99struct Cache {
100    images: HashMap<u64, Image>,
101    order: VecDeque<u64>,
102    bytes: usize,
103}
104
105impl Cache {
106    fn insert(&mut self, id: u64, image: Image) {
107        let size = image.bgrx.len();
108        if size > MAX_CACHE_BYTES {
109            return;
110        }
111        if let Some(old) = self.images.remove(&id) {
112            self.bytes -= old.bgrx.len();
113            self.order.retain(|i| *i != id);
114        }
115        while self.bytes + size > MAX_CACHE_BYTES {
116            let Some(oldest) = self.order.pop_front() else {
117                break;
118            };
119            if let Some(old) = self.images.remove(&oldest) {
120                self.bytes -= old.bgrx.len();
121            }
122        }
123        self.bytes += size;
124        self.order.push_back(id);
125        self.images.insert(id, image);
126    }
127    fn remove(&mut self, id: u64) {
128        if let Some(old) = self.images.remove(&id) {
129            self.bytes -= old.bgrx.len();
130            self.order.retain(|i| *i != id);
131        }
132    }
133    fn clear(&mut self) {
134        *self = Cache::default();
135    }
136}
137
138pub struct DisplayModel {
139    primary: Option<Surface>,
140    other_surfaces: u32,
141    ops: BTreeMap<&'static str, u64>,
142    images: BTreeMap<&'static str, u64>,
143    undecoded: u64,
144    updates: u64,
145    marked: bool,
146    monitors: Vec<Value>,
147    cache: Cache,
148    pub last_update: Instant,
149}
150
151impl Default for DisplayModel {
152    fn default() -> Self {
153        Self {
154            primary: None,
155            other_surfaces: 0,
156            ops: BTreeMap::new(),
157            images: BTreeMap::new(),
158            undecoded: 0,
159            updates: 0,
160            marked: false,
161            monitors: Vec::new(),
162            cache: Cache::default(),
163            last_update: Instant::now(),
164        }
165    }
166}
167
168fn op_name(t: u16) -> &'static str {
169    match t {
170        msg::DISPLAY_MODE => "mode",
171        msg::DISPLAY_MARK => "mark",
172        msg::DISPLAY_RESET => "reset",
173        msg::DISPLAY_COPY_BITS => "copy_bits",
174        msg::DISPLAY_INVAL_LIST => "inval_list",
175        msg::DISPLAY_INVAL_ALL_PIXMAPS => "inval_all_pixmaps",
176        msg::DISPLAY_INVAL_PALETTE => "inval_palette",
177        msg::DISPLAY_INVAL_ALL_PALETTES => "inval_all_palettes",
178        msg::DISPLAY_STREAM_CREATE => "stream_create",
179        msg::DISPLAY_STREAM_DATA => "stream_data",
180        msg::DISPLAY_STREAM_CLIP => "stream_clip",
181        msg::DISPLAY_STREAM_DESTROY => "stream_destroy",
182        msg::DISPLAY_STREAM_DESTROY_ALL => "stream_destroy_all",
183        msg::DISPLAY_DRAW_FILL => "fill",
184        msg::DISPLAY_DRAW_OPAQUE => "opaque",
185        msg::DISPLAY_DRAW_COPY => "copy",
186        msg::DISPLAY_DRAW_BLEND => "blend",
187        msg::DISPLAY_DRAW_BLACKNESS => "blackness",
188        msg::DISPLAY_DRAW_WHITENESS => "whiteness",
189        msg::DISPLAY_DRAW_INVERS => "invers",
190        msg::DISPLAY_DRAW_ROP3 => "rop3",
191        msg::DISPLAY_DRAW_STROKE => "stroke",
192        msg::DISPLAY_DRAW_TEXT => "text",
193        msg::DISPLAY_DRAW_TRANSPARENT => "transparent",
194        msg::DISPLAY_DRAW_ALPHA_BLEND => "alpha_blend",
195        msg::DISPLAY_SURFACE_CREATE => "surface_create",
196        msg::DISPLAY_SURFACE_DESTROY => "surface_destroy",
197        msg::DISPLAY_STREAM_DATA_SIZED => "stream_data_sized",
198        msg::DISPLAY_MONITORS_CONFIG => "monitors_config",
199        msg::DISPLAY_DRAW_COMPOSITE => "composite",
200        _ => "other",
201    }
202}
203
204fn check_size(what: &str, width: u32, height: u32) -> Result<()> {
205    ensure!(
206        (1..=MAX_SIDE).contains(&width) && (1..=MAX_SIDE).contains(&height),
207        "{what} of {width}x{height}: this client accepts 1 to {MAX_SIDE} pixels a side"
208    );
209    ensure!(
210        u64::from(width) * u64::from(height) <= MAX_PIXELS,
211        "{what} of {width}x{height} is above the {MAX_PIXELS} pixel limit"
212    );
213    Ok(())
214}
215
216impl DisplayModel {
217    /// Apply one display-channel message. An error means the server declared something this
218    /// client refuses to allocate, and ends the channel.
219    pub fn handle(&mut self, msg_type: u16, body: &[u8]) -> Result<Option<Change>> {
220        let name = op_name(msg_type);
221        if name == "other" {
222            return Ok(None);
223        }
224        *self.ops.entry(name).or_default() += 1;
225        let mut c = Cursor::new(body);
226        match msg_type {
227            msg::DISPLAY_SURFACE_CREATE => {
228                let id = c.u32("surface id")?;
229                let width = c.u32("surface width")?;
230                let height = c.u32("surface height")?;
231                let format = c.u32("surface format")?;
232                let flags = c.u32("surface flags")?;
233                if flags & wire::SURFACE_FLAGS_PRIMARY == 0 {
234                    self.other_surfaces += 1;
235                    return Ok(None);
236                }
237                check_size("a primary surface", width, height)?;
238                self.primary = Some(Surface {
239                    id,
240                    width,
241                    height,
242                    format,
243                    bgrx: vec![0; width as usize * height as usize * 4],
244                });
245                self.touch();
246                return Ok(Some(Change::SurfaceCreated {
247                    width,
248                    height,
249                    format,
250                }));
251            }
252            msg::DISPLAY_SURFACE_DESTROY => {
253                let id = c.u32("surface id")?;
254                if self.primary.as_ref().is_some_and(|s| s.id == id) {
255                    self.primary = None;
256                    self.marked = false;
257                } else {
258                    self.other_surfaces = self.other_surfaces.saturating_sub(1);
259                }
260            }
261            msg::DISPLAY_MARK => self.marked = true,
262            msg::DISPLAY_RESET => {
263                self.primary = None;
264                self.marked = false;
265            }
266            msg::DISPLAY_INVAL_ALL_PIXMAPS => self.cache.clear(),
267            msg::DISPLAY_INVAL_LIST => {
268                let n = c.u16("resource count")?;
269                for _ in 0..n {
270                    let _kind = c.u8("resource type")?;
271                    let id = c.u64("resource id")?;
272                    self.cache.remove(id);
273                }
274            }
275            msg::DISPLAY_MONITORS_CONFIG => {
276                let n = c.u16("monitor count")?;
277                let _max = c.u16("max monitors")?;
278                ensure!(n <= MAX_MONITORS, "{n} monitors; at most {MAX_MONITORS}");
279                self.monitors.clear();
280                for _ in 0..n {
281                    let head = json!({
282                        "id": c.u32("head id")?,
283                        "surface_id": c.u32("head surface")?,
284                        "width": c.u32("head width")?,
285                        "height": c.u32("head height")?,
286                        "x": c.u32("head x")?,
287                        "y": c.u32("head y")?,
288                    });
289                    let _flags = c.u32("head flags")?;
290                    self.monitors.push(head);
291                }
292            }
293            msg::DISPLAY_DRAW_FILL
294            | msg::DISPLAY_DRAW_COPY
295            | msg::DISPLAY_DRAW_BLACKNESS
296            | msg::DISPLAY_DRAW_WHITENESS
297            | msg::DISPLAY_DRAW_INVERS
298            | msg::DISPLAY_COPY_BITS
299            | msg::DISPLAY_DRAW_OPAQUE
300            | msg::DISPLAY_DRAW_BLEND
301            | msg::DISPLAY_DRAW_ROP3
302            | msg::DISPLAY_DRAW_STROKE
303            | msg::DISPLAY_DRAW_TEXT
304            | msg::DISPLAY_DRAW_TRANSPARENT
305            | msg::DISPLAY_DRAW_ALPHA_BLEND
306            | msg::DISPLAY_DRAW_COMPOSITE => {
307                self.touch();
308                let applied = self.draw(msg_type, body)?;
309                if !applied {
310                    self.undecoded += 1;
311                }
312            }
313            msg::DISPLAY_STREAM_DATA | msg::DISPLAY_STREAM_DATA_SIZED => {
314                self.touch();
315                self.undecoded += 1;
316            }
317            _ => {}
318        }
319        Ok(None)
320    }
321
322    fn touch(&mut self) {
323        self.updates += 1;
324        self.last_update = Instant::now();
325    }
326
327    /// Apply a draw command to the primary surface. Ok(false) when it cannot be applied
328    /// exactly; Ok(true) also for a draw on a surface this client does not keep.
329    fn draw(&mut self, msg_type: u16, body: &[u8]) -> Result<bool> {
330        let mut c = Cursor::new(body);
331        let surface_id = c.u32("surface id")?;
332        let bbox = Rect::read(&mut c)?;
333        let clip_type = c.u8("clip type")?;
334        let clips = if clip_type == 1 {
335            let n = c.u32("clip rect count")?;
336            ensure!(
337                n <= MAX_CLIP_RECTS,
338                "{n} clip rects; at most {MAX_CLIP_RECTS}"
339            );
340            ensure!(
341                (n as usize) * 16 <= c.remaining(),
342                "{n} clip rects do not fit in the message"
343            );
344            (0..n)
345                .map(|_| Rect::read(&mut c))
346                .collect::<Result<Vec<_>>>()?
347        } else {
348            vec![bbox]
349        };
350        let Some(primary) = self.primary.as_ref() else {
351            return Ok(false);
352        };
353        if primary.id != surface_id {
354            // Off-screen surfaces are not kept; what is copied from them is what counts.
355            return Ok(true);
356        }
357        if primary.format != wire::SURFACE_FMT_32_XRGB && primary.format != 96 {
358            return Ok(false);
359        }
360        let bounds = Rect {
361            top: 0,
362            left: 0,
363            bottom: primary.height as i64,
364            right: primary.width as i64,
365        };
366        let regions: Vec<Rect> = clips
367            .iter()
368            .map(|r| r.intersect(&bbox).intersect(&bounds))
369            .filter(|r| !r.is_empty())
370            .collect();
371        match msg_type {
372            msg::DISPLAY_DRAW_FILL => {
373                let brush = c.u8("brush type")?;
374                if brush != 1 {
375                    return Ok(false);
376                }
377                let color = c.u32("brush color")?;
378                let rop = c.u16("rop")?;
379                let mask = Self::read_mask(&mut c)?;
380                if rop != wire::ROPD_OP_PUT || mask {
381                    return Ok(false);
382                }
383                let px = [color as u8, (color >> 8) as u8, (color >> 16) as u8, 0];
384                self.fill(&regions, |_| px);
385                Ok(true)
386            }
387            msg::DISPLAY_DRAW_BLACKNESS | msg::DISPLAY_DRAW_WHITENESS => {
388                if Self::read_mask(&mut c)? {
389                    return Ok(false);
390                }
391                let v = if msg_type == msg::DISPLAY_DRAW_BLACKNESS {
392                    0
393                } else {
394                    255
395                };
396                self.fill(&regions, |_| [v, v, v, 0]);
397                Ok(true)
398            }
399            msg::DISPLAY_DRAW_INVERS => {
400                if Self::read_mask(&mut c)? {
401                    return Ok(false);
402                }
403                self.fill(&regions, |p| [!p[0], !p[1], !p[2], 0]);
404                Ok(true)
405            }
406            msg::DISPLAY_COPY_BITS => {
407                let sx = c.i32("source x")? as i64;
408                let sy = c.i32("source y")? as i64;
409                let snapshot = self.primary.as_ref().map(|s| s.bgrx.clone());
410                let Some(src) = snapshot else {
411                    return Ok(false);
412                };
413                let (w, h) = (bounds.right, bounds.bottom);
414                self.copy(&regions, |x, y| {
415                    let (u, v) = (sx + (x - bbox.left), sy + (y - bbox.top));
416                    if u < 0 || v < 0 || u >= w || v >= h {
417                        return None;
418                    }
419                    let i = ((v * w + u) * 4) as usize;
420                    Some([src[i], src[i + 1], src[i + 2], 0])
421                });
422                Ok(true)
423            }
424            msg::DISPLAY_DRAW_COPY => {
425                let image_at = c.u32("image offset")?;
426                let src = Rect::read(&mut c)?;
427                let rop = c.u16("rop")?;
428                let _scale = c.u8("scale mode")?;
429                let mask = Self::read_mask(&mut c)?;
430                let image = self.image(body, image_at)?;
431                let Some(image) = image else {
432                    return Ok(false);
433                };
434                if rop != wire::ROPD_OP_PUT || mask || src.is_empty() || bbox.is_empty() {
435                    return Ok(false);
436                }
437                let (sw, sh) = (src.width(), src.height());
438                let (bw, bh) = (bbox.width(), bbox.height());
439                let (iw, ih) = (image.width as i64, image.height as i64);
440                self.copy(&regions, |x, y| {
441                    let u = src.left + (x - bbox.left) * sw / bw;
442                    let v = src.top + (y - bbox.top) * sh / bh;
443                    if u < 0 || v < 0 || u >= iw || v >= ih {
444                        return None;
445                    }
446                    let i = ((v * iw + u) * 4) as usize;
447                    Some([image.bgrx[i], image.bgrx[i + 1], image.bgrx[i + 2], 0])
448                });
449                Ok(true)
450            }
451            _ => {
452                // Opaque, blend, rop3, transparent, alpha blend and composite carry an image
453                // as their first field; it is counted (and cached) even though the draw is not
454                // applied.
455                if matches!(
456                    msg_type,
457                    msg::DISPLAY_DRAW_OPAQUE
458                        | msg::DISPLAY_DRAW_BLEND
459                        | msg::DISPLAY_DRAW_ROP3
460                        | msg::DISPLAY_DRAW_TRANSPARENT
461                ) {
462                    let at = c.u32("image offset")?;
463                    let _ = self.image(body, at)?;
464                }
465                Ok(false)
466            }
467        }
468    }
469
470    /// A QMask: flags, position, image offset. True when a mask image is present.
471    fn read_mask(c: &mut Cursor) -> Result<bool> {
472        let _flags = c.u8("mask flags")?;
473        let _x = c.i32("mask x")?;
474        let _y = c.i32("mask y")?;
475        Ok(c.u32("mask image")? != 0)
476    }
477
478    fn fill(&mut self, regions: &[Rect], f: impl Fn([u8; 4]) -> [u8; 4]) {
479        let Some(s) = self.primary.as_mut() else {
480            return;
481        };
482        let w = s.width as i64;
483        for r in regions {
484            for y in r.top..r.bottom {
485                for x in r.left..r.right {
486                    let i = ((y * w + x) * 4) as usize;
487                    let old = [s.bgrx[i], s.bgrx[i + 1], s.bgrx[i + 2], s.bgrx[i + 3]];
488                    s.bgrx[i..i + 4].copy_from_slice(&f(old));
489                }
490            }
491        }
492    }
493
494    fn copy(&mut self, regions: &[Rect], src: impl Fn(i64, i64) -> Option<[u8; 4]>) {
495        let Some(s) = self.primary.as_mut() else {
496            return;
497        };
498        let w = s.width as i64;
499        for r in regions {
500            for y in r.top..r.bottom {
501                for x in r.left..r.right {
502                    if let Some(px) = src(x, y) {
503                        let i = ((y * w + x) * 4) as usize;
504                        s.bgrx[i..i + 4].copy_from_slice(&px);
505                    }
506                }
507            }
508        }
509    }
510
511    /// Decode the image at `offset` within `body`, counting its type; `None` when it is a
512    /// kind this client does not decode, or a cache miss.
513    fn image(&mut self, body: &[u8], offset: u32) -> Result<Option<Image>> {
514        if offset == 0 {
515            return Ok(None);
516        }
517        let mut c = Cursor::at(body, offset as usize);
518        let id = c.u64("image id")?;
519        let kind = c.u8("image type")?;
520        let flags = c.u8("image flags")?;
521        let width = c.u32("image width")?;
522        let height = c.u32("image height")?;
523        *self.images.entry(image_type::name(kind)).or_default() += 1;
524        let image = match kind {
525            image_type::BITMAP => {
526                check_size("an image", width, height)?;
527                let format = c.u8("bitmap format")?;
528                let bflags = c.u8("bitmap flags")?;
529                let x = c.u32("bitmap width")?;
530                let y = c.u32("bitmap height")?;
531                let stride = c.u32("bitmap stride")?;
532                if bflags & wire::BITMAP_FLAGS_PAL_FROM_CACHE != 0 {
533                    c.u64("palette id")?;
534                } else {
535                    c.u32("palette offset")?;
536                }
537                let bpp = match format {
538                    wire::BITMAP_FMT_32BIT | wire::BITMAP_FMT_RGBA => 4,
539                    wire::BITMAP_FMT_24BIT => 3,
540                    _ => return Ok(None),
541                };
542                ensure!(
543                    x == width && y == height,
544                    "a {x}x{y} bitmap in a {width}x{height} image"
545                );
546                ensure!(
547                    u64::from(stride) >= u64::from(x) * bpp as u64,
548                    "a bitmap stride of {stride} for {x} pixels of {bpp} bytes"
549                );
550                let size = u64::from(stride) * u64::from(y);
551                ensure!(
552                    size <= c.remaining() as u64,
553                    "a bitmap of {size} bytes in a message with {} left",
554                    c.remaining()
555                );
556                let data = c.bytes(size as usize, "bitmap data")?;
557                let top_down = bflags & wire::BITMAP_FLAGS_TOP_DOWN != 0;
558                let mut bgrx = Vec::with_capacity(x as usize * y as usize * 4);
559                for row in 0..y as usize {
560                    let r = if top_down { row } else { y as usize - 1 - row };
561                    let line = &data[r * stride as usize..];
562                    for px in 0..x as usize {
563                        let p = &line[px * bpp..px * bpp + bpp];
564                        bgrx.extend_from_slice(&[p[0], p[1], p[2], 0]);
565                    }
566                }
567                Some(Image {
568                    width,
569                    height,
570                    bgrx,
571                })
572            }
573            image_type::FROM_CACHE | image_type::FROM_CACHE_LOSSLESS => {
574                self.cache.images.get(&id).cloned()
575            }
576            _ => None,
577        };
578        if let Some(img) = &image {
579            if flags & wire::IMAGE_FLAGS_CACHE_ME != 0 {
580                self.cache.insert(id, img.clone());
581            }
582        }
583        Ok(image)
584    }
585
586    pub fn primary_size(&self) -> Option<(u32, u32)> {
587        self.primary.as_ref().map(|s| (s.width, s.height))
588    }
589
590    /// The reading the model gets: size, colours, a checksum, and what was and was not drawn.
591    pub fn describe(&self, grid: usize) -> Value {
592        let mut out = match &self.primary {
593            Some(s) => describe_bgrx(&s.bgrx, s.width as usize, s.height as usize, grid),
594            None => json!({"width": 0, "height": 0}),
595        };
596        out["surface"] = json!(self.primary.is_some());
597        out["marked"] = json!(self.marked);
598        out["updates"] = json!(self.updates);
599        out["draw_ops"] = json!(self.ops);
600        out["images"] = json!(self.images);
601        out["undecoded"] = json!(self.undecoded);
602        out["exact"] = json!(self.undecoded == 0);
603        out["offscreen_surfaces"] = json!(self.other_surfaces);
604        out["monitors"] = json!(self.monitors);
605        out
606    }
607}
608
609fn hex([r, g, b]: [u8; 3]) -> String {
610    format!("#{r:02x}{g:02x}{b:02x}")
611}
612
613/// Size, most common colour and its share, a grid of each cell's most common colour, and an
614/// FNV-1a checksum of the RGB bytes. A cell's most common colour, not its average, so a
615/// background reads exactly and an edge does not blend two colours into a third.
616pub fn describe_bgrx(bgrx: &[u8], width: usize, height: usize, grid: usize) -> Value {
617    let grid = grid.clamp(1, width.max(1)).clamp(1, height.max(1));
618    let mut counts: HashMap<[u8; 3], u64> = HashMap::new();
619    let mut cells: Vec<HashMap<[u8; 3], u64>> = vec![HashMap::new(); grid * grid];
620    let mut checksum: u64 = 0xcbf2_9ce4_8422_2325;
621    for y in 0..height {
622        let gy = y * grid / height;
623        for x in 0..width {
624            let i = (y * width + x) * 4;
625            let px = [bgrx[i + 2], bgrx[i + 1], bgrx[i]];
626            for b in px {
627                checksum ^= u64::from(b);
628                checksum = checksum.wrapping_mul(0x0100_0000_01b3);
629            }
630            *counts.entry(px).or_default() += 1;
631            *cells[gy * grid + x * grid / width].entry(px).or_default() += 1;
632        }
633    }
634    let (dominant, n) = most_common(&counts);
635    let rows: Vec<Vec<String>> = cells
636        .chunks(grid)
637        .map(|row| row.iter().map(|c| hex(most_common(c).0)).collect())
638        .collect();
639    json!({
640        "width": width,
641        "height": height,
642        "dominant_color": hex(dominant),
643        "dominant_share": ((n as f64 / (width * height).max(1) as f64) * 1000.0).round() / 1000.0,
644        "distinct_colors": counts.len(),
645        "grid": rows,
646        "checksum": format!("{checksum:016x}"),
647    })
648}
649
650fn most_common(counts: &HashMap<[u8; 3], u64>) -> ([u8; 3], u64) {
651    counts
652        .iter()
653        .max_by_key(|&(px, n)| (*n, std::cmp::Reverse(*px)))
654        .map(|(px, n)| (*px, *n))
655        .unwrap_or(([0, 0, 0], 0))
656}
657
658/// The pixel at (x, y) as #rrggbb, for tests and the model's spot checks.
659pub fn pixel_hex(model: &DisplayModel, x: u32, y: u32) -> Option<String> {
660    let s = model.primary.as_ref()?;
661    if x >= s.width || y >= s.height {
662        return None;
663    }
664    let i = ((y * s.width + x) * 4) as usize;
665    Some(hex([s.bgrx[i + 2], s.bgrx[i + 1], s.bgrx[i]]))
666}