git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit4312fcd70795200865dc183502eb829917ed60bd
parent1bf3b47b84
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:04
perf(wpe): read back only what each frame changed

Every webview now runs with WebKit's PropagateDamagingInformation, so
each frame reports what it repainted. The frame sink keeps that per
view -- including frames handed back unread, whose changes the next
read must still carry -- and pump copies only those rectangles out of
the SHM buffer and writes them into the tab's image with cce-ui's new
update_pixel_regions. A whole frame is still read when the tab has no
image at that size, when a frame reports nothing (unknown), or when
the damage covers half the frame. Logic and tests in src/wpe/damage.rs.

Measured in a shadow at scale 2: an overlay scrollbar fade costs
0.27 MB a frame instead of 15 MB; on Muji, with its sliding banner,
the browser's own CPU went from 15% to 10% of a core and the copying
from ~107 MB/s to ~1 MB/s.

A frame now goes to the tab that drew it. Before, the newest frame
from any view was uploaded as the active tab's picture; a view no tab
owns (the spare) is released unread.

CCE_BROWSER_DAMAGE_CHECK=1 keeps a CPU copy per tab, patched region by
region, and compares it with the whole frame on every read; it was
exact on all ~1000 reads through load, idle, scrolling, a resize and
Muji. CCE_BROWSER_FULL_FRAMES=1 restores whole-frame reads.

Also on: HiddenPageCSSAnimationSuspension, so a background tab's CSS
animations stop as its requestAnimationFrame already does.

Pins cce-ui to ab44fd2 for update_pixel_regions. The CLAUDE.md section
describing this landed early, in 1bf3b47.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md           |   1 +
 Cargo.toml          |   2 +-
 src/wpe/damage.rs   | 210 +++++++++++++++++++++++++++++++++++++++++
 src/wpe/host.rs     | 264 +++++++++++++++++++++++++++++++++++++++++++---------
 src/wpe/mod.rs      |   2 +
 src/wpe/subclass.rs |  24 ++++-
 6 files changed, 453 insertions(+), 50 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index aed01ac..c1e2430 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,6 +30,7 @@ Nineteen files, ~14.8k lines. The twelve that carry the design:
 | `src/vi.rs` | vi mode, after qutebrowser: the modes, the bindings and their parser, hint labels, `:` commands, and the page scripts |
 | `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
 | `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
+| `src/wpe/damage.rs` | what a frame changed: damage accumulated per view across skipped frames, turned into the regions `pump` reads back |
 | `src/raindrop/` | bookmark sync with Raindrop.io's Unsorted: the three-way merge (`mod.rs`), the REST client (`api.rs`), the worker and status line (`sync.rs`) — design in RAINDROP-SYNC.md |
 
 ## Build
diff --git a/Cargo.toml b/Cargo.toml
index 07baa97..8beba3b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,7 @@ wpe = ["dep:rustix"]
 servo = ["dep:servo", "dep:dpi", "dep:euclid", "dep:rustls", "dep:http"]
 
 [dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "b91b95e0b6d9922ac51538ede03023070e43a446" }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "ab44fd20f362113854038b8eeb75fa3a45173901" }
 calloop = "0.13.0"
 wayland-client = { version = "0.31", features = ["system"] }
 servo = { version = "0.4", optional = true }
diff --git a/src/wpe/damage.rs b/src/wpe/damage.rs
new file mode 100644
index 0000000..3685355
--- /dev/null
+++ b/src/wpe/damage.rs
@@ -0,0 +1,210 @@
+//! What changed between the frame a tab's image holds and the one about to be
+//! read, so that only that much is copied out of the engine's buffer and
+//! uploaded.
+//!
+//! WebKit reports each frame's damage — what it repainted since the frame
+//! before — once `PropagateDamagingInformation` is on (`host.rs` turns it on
+//! for every webview). A frame that is handed back unread still changed the
+//! picture, so its damage is kept and folded into the next frame of the same
+//! view; that is what lets the readback skip frames *and* copy only regions.
+//!
+//! The whole point is the idle cost. A page that can scroll has WebKit
+//! repainting its overlay scrollbar about sixty times a second, for good
+//! (WebKit 2.52; measured in a shadow, and MiniBrowser does it too), and a
+//! page with a sliding banner repaints that band. Reading the whole window
+//! for each — 35 MB a frame at 3840x2400 — was most of the browser's own CPU
+//! on such pages.
+
+/// A rectangle of the buffer in pixels: `(x, y, width, height)`.
+pub type Rect = (u32, u32, u32, u32);
+
+/// More rectangles than this and they are merged into their bounding box:
+/// each one is its own row loop and its own copy region, and a frame that
+/// changed in that many places is better read as one.
+const MAX_RECTS: usize = 16;
+
+/// Damage that covers this share of the frame or more is read whole: one
+/// contiguous copy beats many row copies adding up to nearly the same bytes.
+const FULL_SHARE: f64 = 0.5;
+
+/// What changed in one view since its frame was last read.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Damage {
+    /// Unknown or everything: read the whole frame.
+    Full,
+    /// Only these, in buffer pixels, not yet clamped to the buffer.
+    Rects(Vec<(i32, i32, i32, i32)>),
+}
+
+impl Damage {
+    /// Fold one frame's report in. A frame that reports no rectangles did
+    /// not say what it changed — that is what an engine without damage
+    /// propagation sends — so it counts as everything.
+    pub fn add(&mut self, frame: &[(i32, i32, i32, i32)]) {
+        if frame.is_empty() {
+            *self = Damage::Full;
+            return;
+        }
+        if let Damage::Rects(rects) = self {
+            for r in frame {
+                if !rects.contains(r) {
+                    rects.push(*r);
+                }
+            }
+        }
+    }
+
+    /// The regions to copy from a `width` x `height` buffer, or `None` when
+    /// the whole frame should be read instead.
+    pub fn regions(&self, width: u32, height: u32) -> Option<Vec<Rect>> {
+        let Damage::Rects(raw) = self else { return None };
+        let mut rects: Vec<Rect> = Vec::new();
+        for r in raw.iter().filter_map(|&r| clamp(r, width, height)) {
+            // Two reports can clamp to the same rectangle.
+            if !rects.contains(&r) {
+                rects.push(r);
+            }
+        }
+        // A rectangle inside another adds nothing but a second copy of it.
+        // (No two are equal by now, so containment is strict.)
+        let mut i = 0;
+        while i < rects.len() {
+            let inner = rects[i];
+            if rects.iter().enumerate().any(|(j, &outer)| j != i && contains(outer, inner)) {
+                rects.remove(i);
+            } else {
+                i += 1;
+            }
+        }
+        if rects.len() > MAX_RECTS {
+            rects = vec![bounding(&rects)];
+        }
+        let area: u64 = rects.iter().map(|r| r.2 as u64 * r.3 as u64).sum();
+        if area as f64 >= FULL_SHARE * width as f64 * height as f64 {
+            return None;
+        }
+        Some(rects)
+    }
+}
+
+fn clamp((x, y, w, h): (i32, i32, i32, i32), width: u32, height: u32) -> Option<Rect> {
+    let x0 = x.max(0) as i64;
+    let y0 = y.max(0) as i64;
+    let x1 = (x as i64 + w as i64).min(width as i64);
+    let y1 = (y as i64 + h as i64).min(height as i64);
+    (x1 > x0 && y1 > y0).then(|| (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32))
+}
+
+fn contains(outer: Rect, inner: Rect) -> bool {
+    inner.0 >= outer.0
+        && inner.1 >= outer.1
+        && inner.0 + inner.2 <= outer.0 + outer.2
+        && inner.1 + inner.3 <= outer.1 + outer.3
+}
+
+fn bounding(rects: &[Rect]) -> Rect {
+    let x0 = rects.iter().map(|r| r.0).min().unwrap_or(0);
+    let y0 = rects.iter().map(|r| r.1).min().unwrap_or(0);
+    let x1 = rects.iter().map(|r| r.0 + r.2).max().unwrap_or(0);
+    let y1 = rects.iter().map(|r| r.1 + r.3).max().unwrap_or(0);
+    (x0, y0, x1 - x0, y1 - y0)
+}
+
+/// Copy `regions` of a 4-byte-per-pixel image whose rows are `stride` bytes
+/// apart into `out`, each region tightly packed, one after another — the
+/// layout `cce_ui::vk::update_pixel_regions` takes.
+///
+/// # Safety
+/// `src` must be readable for every byte of every region at `stride`.
+pub unsafe fn pack(src: *const u8, stride: usize, regions: &[Rect], out: &mut [u8]) {
+    let mut at = 0usize;
+    for &(x, y, w, h) in regions {
+        let row = w as usize * 4;
+        for r in 0..h as usize {
+            let from = src.add((y as usize + r) * stride + x as usize * 4);
+            std::ptr::copy_nonoverlapping(from, out.as_mut_ptr().add(at), row);
+            at += row;
+        }
+    }
+}
+
+/// Bytes [`pack`] writes for `regions`.
+pub fn packed_len(regions: &[Rect]) -> usize {
+    regions.iter().map(|r| r.2 as usize * r.3 as usize * 4).sum()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_frame_without_rects_is_everything() {
+        let mut d = Damage::Rects(vec![]);
+        d.add(&[(0, 0, 10, 10)]);
+        d.add(&[]);
+        assert_eq!(d, Damage::Full);
+        // And stays so: later reports cannot narrow an unknown change.
+        d.add(&[(0, 0, 1, 1)]);
+        assert_eq!(d.regions(100, 100), None);
+    }
+
+    #[test]
+    fn skipped_frames_accumulate_without_repeats() {
+        let mut d = Damage::Rects(vec![]);
+        d.add(&[(1238, 0, 42, 720)]);
+        d.add(&[(1238, 0, 42, 720)]);
+        d.add(&[(0, 0, 10, 10)]);
+        assert_eq!(d.regions(1280, 720), Some(vec![(1238, 0, 42, 720), (0, 0, 10, 10)]));
+    }
+
+    #[test]
+    fn rects_are_clamped_to_the_buffer() {
+        // The engine's first frame reports its pre-resize size.
+        let mut d = Damage::Rects(vec![]);
+        d.add(&[(-5, -5, 20, 20), (1270, 710, 50, 50)]);
+        assert_eq!(d.regions(1280, 720), Some(vec![(0, 0, 15, 15), (1270, 710, 10, 10)]));
+        let mut off = Damage::Rects(vec![]);
+        off.add(&[(2000, 0, 10, 10)]);
+        assert_eq!(off.regions(1280, 720), Some(vec![]));
+    }
+
+    #[test]
+    fn contained_rects_are_dropped() {
+        let mut d = Damage::Rects(vec![]);
+        d.add(&[(0, 0, 100, 100), (10, 10, 5, 5)]);
+        assert_eq!(d.regions(1000, 1000), Some(vec![(0, 0, 100, 100)]));
+    }
+
+    #[test]
+    fn large_damage_reads_the_whole_frame() {
+        let mut d = Damage::Rects(vec![]);
+        d.add(&[(0, 0, 1280, 400)]);
+        assert_eq!(d.regions(1280, 720), None);
+    }
+
+    #[test]
+    fn many_rects_merge_into_their_bounds() {
+        let mut d = Damage::Rects(vec![]);
+        let many: Vec<_> = (0..20).map(|i| (i * 10, 0, 5, 5)).collect();
+        d.add(&many);
+        assert_eq!(d.regions(1280, 720), Some(vec![(0, 0, 195, 5)]));
+    }
+
+    #[test]
+    fn pack_copies_each_region_tightly() {
+        // A 4x3 image, stride padded to 5 pixels; each pixel's bytes are its
+        // index, so what lands where is readable.
+        let (w, h, stride) = (4usize, 3usize, 20usize);
+        let mut src = vec![0xEEu8; stride * h];
+        for y in 0..h {
+            for x in 0..w {
+                src[y * stride + x * 4..][..4].fill((y * w + x) as u8);
+            }
+        }
+        let regions = [(1, 0, 2, 2), (3, 2, 1, 1)];
+        let mut out = vec![0u8; packed_len(&regions)];
+        unsafe { pack(src.as_ptr(), stride, &regions, &mut out) };
+        let px: Vec<u8> = out.chunks(4).map(|p| p[0]).collect();
+        assert_eq!(px, vec![1, 2, 5, 6, 11]);
+    }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index cf616e1..dbc0a26 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -145,6 +145,9 @@ pub struct Tab {
     pub url: Option<Url>,
     pub loading: bool,
     image: Option<(u32, u32, u32)>,
+    /// Under `CCE_BROWSER_DAMAGE_CHECK` only: the picture `image` should
+    /// hold, patched region by region alongside it.
+    mirror: Option<Vec<u8>>,
 }
 
 impl Drop for Tab {
@@ -288,6 +291,26 @@ fn terminated_page(url: Option<&Url>, reason: WebKitWebProcessTerminationReason:
 struct FrameCounts {
     produced: u64,
     read: u64,
+    /// Of `read`, how many copied only their damage.
+    partial: u64,
+    /// Bytes copied out of the engine's buffers.
+    bytes: u64,
+}
+
+/// Read whole frames only, ignoring damage. The escape hatch if a page is
+/// ever drawn stale; `CCE_BROWSER_DAMAGE_CHECK` is how to find out.
+fn full_frames() -> bool {
+    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_FULL_FRAMES").is_some())
+}
+
+/// Check every region read against the whole frame: each tab keeps a CPU
+/// copy of its picture, patched exactly as its image is, and any pixel that
+/// disagrees with the engine's buffer is logged. Costs a full copy and a
+/// compare per frame, so it is a test switch, not a mode.
+fn damage_check() -> bool {
+    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_DAMAGE_CHECK").is_some())
 }
 
 fn frame_debug() -> bool {
@@ -302,6 +325,10 @@ struct Pending {
     /// hands this one back **unread** — that skipped copy is the whole point
     /// of holding it rather than copying in the callback.
     held: Option<(*mut WPEView, *mut WPEBuffer)>,
+    /// Per view, what its frames changed since the last one that was read —
+    /// including every frame handed back unread in between, whose changes
+    /// the next readback still has to carry. No entry: nothing changed.
+    damage: std::collections::HashMap<usize, super::damage::Damage>,
     counts: FrameCounts,
     /// When the counters were last reported.
     reported: Option<std::time::Instant>,
@@ -477,8 +504,12 @@ impl WebKitHost {
             let prompts = Rc::new(RefCell::new(Prompts::default()));
             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
             let sink = pending.clone();
-            FRAME_SINK = Some(Box::new(move |view: *mut WPEView, buffer: *mut WPEBuffer| {
+            FRAME_SINK = Some(Box::new(move |view: *mut WPEView, buffer: *mut WPEBuffer, damage: &[(i32, i32, i32, i32)]| {
                 let mut slot = sink.borrow_mut();
+                slot.damage
+                    .entry(view as usize)
+                    .or_insert_with(|| super::damage::Damage::Rects(Vec::new()))
+                    .add(damage);
                 // Replace, never accumulate: the newest frame wins. The one it
                 // supersedes goes back to the engine **without being read** —
                 // several frames can be dispatched inside a single pump's
@@ -840,6 +871,7 @@ impl WebKitHost {
                 self.session,
                 std::ptr::null::<c_char>(),
             ) as *mut WebKitWebView;
+            set_features(wv);
             let view = webkit_web_view_get_wpe_view(wv);
             wpe_view_set_toplevel(view, self.toplevel);
             // Signals, not polling: a background tab has to be able to report
@@ -981,6 +1013,7 @@ impl WebKitHost {
             url: Some(url),
             loading: true,
             image: None,
+            mirror: None,
         });
         if show {
             self.activate(self.tabs.len() - 1);
@@ -1150,36 +1183,91 @@ impl WebKitHost {
         let Some((view, buffer)) = held else {
             return (false, dirty);
         };
-        let frame = unsafe {
-            let f = read_shm(buffer);
+        let damage = self.pending.borrow_mut().damage.remove(&(view as usize));
+        // The frame belongs to the tab that drew it, which is not always the
+        // one on screen. A view no tab owns is the prewarmed spare, or a tab
+        // closed since: nothing shows it.
+        let Some(index) = self.tabs.iter().position(|t| t.view == view) else {
+            unsafe { wpe_view_buffer_released(view, buffer) };
+            return (false, dirty);
+        };
+        let read = unsafe {
+            let read = self.read_frame(index, buffer, damage);
             // The pixels are ours now; the memory can go back.
             wpe_view_buffer_released(view, buffer);
-            f
+            read
         };
         if frame_debug() {
             let mut p = self.pending.borrow_mut();
             p.counts.read += 1;
+            if let Some((bytes, partial)) = read {
+                p.counts.bytes += bytes as u64;
+                p.counts.partial += partial as u64;
+            }
             let now = std::time::Instant::now();
             let due = p.reported.is_none_or(|t| now.duration_since(t).as_secs_f32() >= 1.0);
             if due {
                 p.reported = Some(now);
-                let (produced, read) = (p.counts.produced, p.counts.read);
-                p.counts = FrameCounts::default();
+                let c = std::mem::take(&mut p.counts);
                 log::info!(
-                    "frames: engine produced {produced}, read back {read} \
-                     ({} handed back unread)",
-                    produced.saturating_sub(read)
+                    "frames: engine produced {}, read back {} ({} handed back unread), \
+                     {} of them only their damage; {:.1} MB copied",
+                    c.produced,
+                    c.read,
+                    c.produced.saturating_sub(c.read),
+                    c.partial,
+                    c.bytes as f64 / 1e6
                 );
             }
         }
-        let Some((px, w, h)) = frame else {
-            return (false, dirty);
-        };
+        if read.is_none() || index != self.active {
+            return (false, true);
+        }
+        self.pending_draw.set(true);
+        (true, true)
+    }
+
+    /// Copy a finished frame into tab `index`'s image: only the damaged
+    /// regions when the image already holds the frame before them, the
+    /// whole frame otherwise. Returns the bytes copied and whether it was
+    /// regions, or `None` for a buffer that could not be read.
+    unsafe fn read_frame(
+        &mut self,
+        index: usize,
+        buffer: *mut WPEBuffer,
+        damage: Option<super::damage::Damage>,
+    ) -> Option<(usize, bool)> {
+        let shm = ShmFrame::of(buffer)?;
+        let (w, h) = (shm.width, shm.height);
         // The one pixel anything actually reads back (see `sample_pixel`),
         // kept instead of a copy of the whole frame. Cloning 35 MB per frame
         // to serve a three-byte question cost 7 ms of every frame.
-        self.last_pixel = (px.len() >= 4).then(|| (px[2], px[1], px[0]));
-        let tab = &mut self.tabs[self.active];
+        let p0 = std::slice::from_raw_parts(shm.data, 4);
+        self.last_pixel = Some((p0[2], p0[1], p0[0]));
+        let tab = &mut self.tabs[index];
+        // Regions only make sense against the picture they change: this
+        // tab's image, at this size. No damage at all means nothing changed.
+        let current = tab.image.is_some_and(|(_, iw, ih)| (iw, ih) == (w, h));
+        let regions = match damage {
+            _ if full_frames() || !current => None,
+            None => Some(Vec::new()),
+            Some(d) => d.regions(w, h),
+        };
+        if let (Some(regions), Some((id, ..))) = (regions, tab.image) {
+            let len = super::damage::packed_len(&regions);
+            let mut px = cce_ui::vk::recycle_buffer(len);
+            super::damage::pack(shm.data, shm.stride, &regions, &mut px);
+            if let Some(mirror) = tab.mirror.as_mut() {
+                check_regions(mirror, &shm, &px, &regions, index);
+            }
+            cce_ui::vk::update_pixel_regions(id, px, w, h, cce_ui::vk::PixelFormat::Bgra, regions);
+            return Some((len, true));
+        }
+        let px = shm.copy_all();
+        let len = px.len();
+        if damage_check() {
+            tab.mirror = Some(px.clone());
+        }
         match tab.image {
             // Same tab, same size: replace the contents of the image that is
             // already there. No allocation, no descriptor, and above all no
@@ -1193,11 +1281,9 @@ impl WebKitHost {
                 if let Some((old, ..)) = tab.image.replace((id, w, h)) {
                     cce_ui::vk::free_image(old);
                 }
-                tab.image = Some((id, w, h));
             }
         }
-        self.pending_draw.set(true);
-        (true, true)
+        Some((len, false))
     }
 
     /// Re-paint the page into a renderer that has just replaced the one the
@@ -1966,35 +2052,125 @@ impl WebKitHost {
 /// before the next pump is never read at all.
 ///
 /// The stride is not assumed to equal `width * 4`.
-unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
-    if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
-        return None;
-    }
-    let shm = buffer as *mut WPEBufferSHM;
-    let (w, h) = (
-        wpe_buffer_get_width(buffer) as u32,
-        wpe_buffer_get_height(buffer) as u32,
-    );
-    let mut len: u64 = 0;
-    let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
-    if src.is_null() || w == 0 || h == 0 {
-        return None;
-    }
-    let stride = wpe_buffer_shm_get_stride(shm) as usize;
-    let row = w as usize * 4;
-    let need = row * h as usize;
-    if (len as usize) < stride * (h as usize - 1) + row {
-        return None;
-    }
-    let mut out = cce_ui::vk::recycle_buffer(need);
-    if stride == row {
-        std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), need);
-    } else {
-        for y in 0..h as usize {
-            std::ptr::copy_nonoverlapping(src.add(y * stride), out.as_mut_ptr().add(y * row), row);
+/// WebKit features every webview runs with, off by default in this build.
+///
+/// * `PropagateDamagingInformation` — each frame reports what it repainted,
+///   so `pump` copies only that (see `damage.rs`). Without it a page that
+///   can scroll costs a whole-window copy sixty times a second while it sits
+///   still, for an overlay scrollbar WebKit never stops repainting.
+/// * `HiddenPageCSSAnimationSuspension` — a background tab's CSS animations
+///   stop. WebKit already stops its `requestAnimationFrame` (checked: 0 a
+///   second hidden), but not this, and not its timers. A hidden Muji page
+///   measured 67% of a core with it off and 43% on.
+const FEATURES: &[(&str, bool)] =
+    &[("PropagateDamagingInformation", true), ("HiddenPageCSSAnimationSuspension", true)];
+
+unsafe fn set_features(wv: *mut WebKitWebView) {
+    let settings = webkit_web_view_get_settings(wv);
+    let list = webkit_settings_get_all_features();
+    for &(name, on) in FEATURES {
+        let found = (0..webkit_feature_list_get_length(list))
+            .map(|i| webkit_feature_list_get(list, i))
+            .find(|&f| from_cstr(webkit_feature_get_identifier(f)).as_deref() == Some(name));
+        match found {
+            Some(f) => webkit_settings_set_feature_enabled(settings, f, on as gboolean),
+            // A WebKit upgrade renamed or dropped it: the browser still
+            // works, it just loses what the feature bought.
+            None => log::warn!("WebKit has no feature {name}; leaving it as it is"),
+        }
+    }
+    webkit_feature_list_unref(list);
+}
+
+/// A mapped SHM frame: where its pixels are, and how they are laid out.
+/// Borrowed from the buffer, so it must not outlive the buffer's release.
+struct ShmFrame {
+    data: *const u8,
+    stride: usize,
+    width: u32,
+    height: u32,
+}
+
+impl ShmFrame {
+    /// The buffer's pixels, if it is an SHM buffer with all its rows there.
+    unsafe fn of(buffer: *mut WPEBuffer) -> Option<Self> {
+        if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
+            return None;
         }
+        let shm = buffer as *mut WPEBufferSHM;
+        let (width, height) = (
+            wpe_buffer_get_width(buffer) as u32,
+            wpe_buffer_get_height(buffer) as u32,
+        );
+        let mut len: u64 = 0;
+        let data = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
+        if data.is_null() || width == 0 || height == 0 {
+            return None;
+        }
+        let stride = wpe_buffer_shm_get_stride(shm) as usize;
+        if (len as usize) < stride * (height as usize - 1) + width as usize * 4 {
+            return None;
+        }
+        Some(Self { data, stride, width, height })
+    }
+
+    /// The whole frame, tightly packed, in a recycled buffer.
+    unsafe fn copy_all(&self) -> Vec<u8> {
+        let row = self.width as usize * 4;
+        let need = row * self.height as usize;
+        let mut out = cce_ui::vk::recycle_buffer(need);
+        if self.stride == row {
+            std::ptr::copy_nonoverlapping(self.data, out.as_mut_ptr(), need);
+        } else {
+            for y in 0..self.height as usize {
+                std::ptr::copy_nonoverlapping(
+                    self.data.add(y * self.stride),
+                    out.as_mut_ptr().add(y * row),
+                    row,
+                );
+            }
+        }
+        out
+    }
+}
+
+/// `CCE_BROWSER_DAMAGE_CHECK`: patch the tab's CPU copy with the regions just
+/// read, as its image is being patched, and compare the result with the
+/// engine's whole frame. A mismatch means the damage left something out and
+/// the page on screen is stale there; the copy is then resynced so one miss
+/// is not reported on every frame after it.
+unsafe fn check_regions(
+    mirror: &mut [u8],
+    shm: &ShmFrame,
+    packed: &[u8],
+    regions: &[super::damage::Rect],
+    tab: usize,
+) {
+    let row = shm.width as usize * 4;
+    let mut at = 0usize;
+    for &(x, y, w, h) in regions {
+        let len = w as usize * 4;
+        for r in 0..h as usize {
+            let dst = (y as usize + r) * row + x as usize * 4;
+            mirror[dst..dst + len].copy_from_slice(&packed[at..at + len]);
+            at += len;
+        }
+    }
+    let truth = shm.copy_all();
+    let wrong = mirror
+        .chunks_exact(4)
+        .zip(truth.chunks_exact(4))
+        .filter(|(a, b)| a != b)
+        .count();
+    if wrong > 0 {
+        log::warn!(
+            "damage check: tab {tab} is stale in {wrong} pixels after reading {} region(s) {regions:?}",
+            regions.len()
+        );
+        mirror.copy_from_slice(&truth);
+    } else {
+        log::info!("damage check: tab {tab} exact after {} region(s)", regions.len());
     }
-    Some((out, w, h))
 }
 
 unsafe fn from_cstr(p: *const c_char) -> Option<String> {
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 591d83f..d259308 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -10,6 +10,8 @@ pub mod ffi {
 }
 
 mod subclass;
+/// What a frame changed, so only that much is read back and uploaded.
+mod damage;
 mod input;
 mod glib_source;
 mod host;
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
index 5af3f9f..37c1443 100644
--- a/src/wpe/subclass.rs
+++ b/src/wpe/subclass.rs
@@ -86,17 +86,23 @@ pub(super) unsafe fn types() -> &'static Types {
 /// Set by the host before it creates a webview; `render_buffer` hands frames
 /// here. One host per process for now (see `WebKitHost::new`).
 ///
+/// The third argument is the frame's damage — what it repainted since the
+/// frame before, in buffer pixels as `(x, y, width, height)` — and is empty
+/// when the engine did not say.
+///
 /// Returns whether the sink is **keeping** the buffer. If it is, releasing it
 /// is the sink's job — it reads the pixels out at the next pump and hands the
 /// memory back then.
-pub(super) static mut FRAME_SINK: Option<Box<dyn FnMut(*mut WPEView, *mut WPEBuffer) -> bool>> =
-    None;
+#[allow(clippy::type_complexity)]
+pub(super) static mut FRAME_SINK: Option<
+    Box<dyn FnMut(*mut WPEView, *mut WPEBuffer, &[(i32, i32, i32, i32)]) -> bool>,
+> = None;
 
 unsafe extern "C" fn view_render_buffer(
     view: *mut WPEView,
     buffer: *mut WPEBuffer,
-    _damage: *const WPERectangle,
-    _n_damage: u32,
+    damage: *const WPERectangle,
+    n_damage: u32,
     _error: *mut *mut GError,
 ) -> gboolean {
     // The two halves mean different things and are no longer said together.
@@ -111,8 +117,16 @@ unsafe extern "C" fn view_render_buffer(
     // and a frame superseded before anyone read it is handed back unread
     // rather than copied.
     wpe_view_buffer_rendered(view, buffer);
+    let damage: Vec<(i32, i32, i32, i32)> = if damage.is_null() {
+        Vec::new()
+    } else {
+        std::slice::from_raw_parts(damage, n_damage as usize)
+            .iter()
+            .map(|r| (r.x, r.y, r.width, r.height))
+            .collect()
+    };
     #[allow(static_mut_refs)]
-    let held = FRAME_SINK.as_mut().is_some_and(|sink| sink(view, buffer));
+    let held = FRAME_SINK.as_mut().is_some_and(|sink| sink(view, buffer, &damage));
     if !held {
         wpe_view_buffer_released(view, buffer);
     }