From 23f915c593a612fd19601d359a310a5f21ce49b9 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:22:00 +0100 Subject: [PATCH] display: damage-rect list + tile-grid trackers, vectorizable pixel loops, wide WC stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tearing mitigation for the GOP floor, attacking the copy window from three sides: - Damage is no longer one bounding box. Two trackers, A/B-switchable at compile time (display.zig damage_mode): DamageList (free-form dirty rects, overlap-merged) and TileGrid (fixed 64-px tiles, exact O(1) marking, runs coalesced back into rects). Far-apart changes — the cursor here, an animating layer there — no longer unite into one huge repaint. - fillRect/composite/blitTile now work in row spans (@memset/@memcpy), so the compiler vectorizes them and ReleaseSafe bounds checks drop to per-row. - The back->front present streams 8-byte volatile stores (presentSpan); Backend.present takes the rect list, so each present copies only what changed, faster. Host tests cover both trackers; the display QEMU cases all pass. --- system/services/display/backend.zig | 57 +++-- system/services/display/compositor.zig | 310 +++++++++++++++++++++++-- system/services/display/display.zig | 56 +++-- 3 files changed, 370 insertions(+), 53 deletions(-) diff --git a/system/services/display/backend.zig b/system/services/display/backend.zig index 52bd018..c4c426d 100644 --- a/system/services/display/backend.zig +++ b/system/services/display/backend.zig @@ -110,22 +110,48 @@ pub const Gop = struct { }; } - /// Stream the damaged rectangle from the back buffer to the write-combining LFB, row by - /// row (sequential writes — what WC memory wants; the LFB is never read). - pub fn present(self: *const Gop, damage: Rect) void { - const c = damage.intersect(.{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) }); - if (c.isEmpty()) return; - var y: i32 = c.y; - while (y < c.bottom()) : (y += 1) { - const off = @as(usize, @intCast(y)) * self.pitch; - const src: [*]const u32 = @ptrCast(@alignCast(self.back + off)); - const dst: [*]volatile u32 = @ptrCast(@alignCast(self.front + off)); - var x: i32 = c.x; - while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)]; + /// Stream each damaged rectangle from the back buffer to the write-combining LFB, row + /// by row (sequential writes — what WC memory wants; the LFB is never read). The rows + /// are copied by `presentSpan` below, which widens the stores by hand: `volatile` + /// keeps the compiler from eliding or reordering framebuffer writes, but it also + /// forbids it from merging them, so a naive per-pixel loop is stuck at one 4-byte + /// store per iteration. Keeping each copy small (the damage list) and each store wide + /// shrinks the window in which scanout can sample a half-written frame. + pub fn present(self: *const Gop, damage: []const Rect) void { + const bounds = Rect{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) }; + for (damage) |rect| { + const c = rect.intersect(bounds); + if (c.isEmpty()) continue; + const span: usize = @intCast(c.w); + var y: i32 = c.y; + while (y < c.bottom()) : (y += 1) { + const offset = @as(usize, @intCast(y)) * self.pitch + @as(usize, @intCast(c.x)) * 4; + const source: [*]const u32 = @ptrCast(@alignCast(self.back + offset)); + const front_row: [*]volatile u32 = @ptrCast(@alignCast(self.front + offset)); + presentSpan(front_row, source, span); + } } } }; +/// Copy `count` pixels into the write-combining front buffer with 8-byte volatile stores +/// (plus a 4-byte head/tail where the span isn't 8-aligned — pixel spans are always +/// 4-aligned). The loads come from the cacheable back buffer and are assembled into a +/// `u64` in registers, so nothing here reads the front buffer. +fn presentSpan(destination: [*]volatile u32, source: [*]const u32, count: usize) void { + var i: usize = 0; + if (i < count and (@intFromPtr(destination) & 7) != 0) { + destination[0] = source[0]; + i = 1; + } + while (i + 2 <= count) : (i += 2) { + const pair = @as(u64, source[i]) | (@as(u64, source[i + 1]) << 32); + const wide: *volatile u64 = @ptrCast(@alignCast(destination + i)); + wide.* = pair; + } + if (i < count) destination[i] = source[i]; +} + /// A display mode the native backend can switch to. pub const Mode = scanout_protocol.Mode; @@ -152,8 +178,9 @@ pub const VirtioGpu = struct { return .{ .pixels = self.pixels, .stride = self.stride, .width = self.width, .height = self.height }; } /// Ask the driver to present. The composited pixels are already in the shared surface, so - /// this is a single request over `.scanout`; the driver transfers + fenced-flushes. - pub fn present(self: *const VirtioGpu, damage: Rect) void { + /// this is a single request over `.scanout` regardless of how many damage rectangles + /// accumulated; the driver transfers + fenced-flushes the whole frame. + pub fn present(self: *const VirtioGpu, damage: []const Rect) void { _ = damage; var request = scanout_protocol.Request{ .operation = @intFromEnum(scanout_protocol.Operation.present), @@ -210,7 +237,7 @@ pub const Backend = union(enum) { inline else => |*b| b.surface(), }; } - pub fn present(self: *const Backend, damage: Rect) void { + pub fn present(self: *const Backend, damage: []const Rect) void { switch (self.*) { inline else => |*b| b.present(damage), } diff --git a/system/services/display/compositor.zig b/system/services/display/compositor.zig index 9e7cee6..97c3faa 100644 --- a/system/services/display/compositor.zig +++ b/system/services/display/compositor.zig @@ -57,6 +57,177 @@ pub const Rect = struct { } }; +/// The dirty screen regions accumulated between presents. Kept as a *list* of rectangles, +/// not one bounding box: when two small things move far apart — the cursor on one side of +/// the screen, an animating layer on the other — a single bounding box unites them into a +/// huge region, and presenting it streams megabytes to the framebuffer for a few thousand +/// changed pixels. The long copy widens the window in which scanout (or QEMU's display +/// refresh) samples a half-written frame — visible as tearing and cursor trails. Small +/// separate rectangles keep each copy, and that window, tight. +/// +/// A new rectangle that overlaps an existing entry is united into it (repainting a modest +/// superset is harmless — compositing is idempotent); the grown entry is *not* re-merged +/// against the rest, so entries may overlap, which costs only a duplicate repaint. When +/// the table is full the newcomer folds into the last entry — degrading toward the old +/// bounding-box behaviour instead of dropping damage. +pub const DamageList = struct { + pub const capacity = 16; + + rects: [capacity]Rect = [_]Rect{Rect.empty} ** capacity, + count: usize = 0, + + pub fn add(self: *DamageList, r: Rect) void { + if (r.isEmpty()) return; + for (self.rects[0..self.count]) |*existing| { + if (!existing.intersect(r).isEmpty()) { + existing.* = existing.unite(r); + return; + } + } + if (self.count < capacity) { + self.rects[self.count] = r; + self.count += 1; + return; + } + self.rects[capacity - 1] = self.rects[capacity - 1].unite(r); + } + + pub fn isEmpty(self: *const DamageList) bool { + return self.count == 0; + } + + pub fn slice(self: *const DamageList) []const Rect { + return self.rects[0..self.count]; + } + + pub fn clear(self: *DamageList) void { + self.count = 0; + } +}; + +/// The alternative damage tracker: a **fixed tile grid**, the scheme browser compositors +/// and tile-based GPUs use. The screen is divided into `tile_size`-pixel tiles up front; +/// `add` marks the tiles a rectangle touches (a bit per tile — merging is free and exact, +/// no heuristics), and `collect` walks the grid turning runs of adjacent dirty tiles into +/// repaint rectangles (horizontal runs, then equal-span rows merged vertically, so +/// full-screen damage collapses back to a single rectangle). +/// +/// Trade-off against `DamageList`: tracking is O(1) with a strictly bounded worst case +/// (never more than the dirty tiles), but repaints are quantized — a 1-pixel change +/// repaints a whole tile. Which wins depends on the workload; the display service has a +/// compile-time switch (`damage_mode`) to compare them. +pub const TileGrid = struct { + pub const tile_size = 64; + pub const maximum_columns = 128; // supports screens up to 8192 px wide… + pub const maximum_rows = 128; // …and 8192 px tall (beyond that, edge tiles stretch) + pub const maximum_tiles = maximum_columns * maximum_rows; + /// The most rectangles `collect` produces; extras fold into the last (never dropped). + pub const maximum_rects = 64; + + width: u32 = 0, + height: u32 = 0, + columns: u32 = 0, + rows: u32 = 0, + dirty_count: u32 = 0, + dirty: [maximum_tiles]bool = [_]bool{false} ** maximum_tiles, + + /// Size the grid for a screen. Also clears it — callers reset on a geometry change, + /// where the mode-set paths damage the whole new screen anyway. + pub fn reset(self: *TileGrid, width: u32, height: u32) void { + self.width = width; + self.height = height; + self.columns = @min((width + tile_size - 1) / tile_size, maximum_columns); + self.rows = @min((height + tile_size - 1) / tile_size, maximum_rows); + self.clear(); + } + + pub fn matches(self: *const TileGrid, width: u32, height: u32) bool { + return self.width == width and self.height == height; + } + + pub fn isEmpty(self: *const TileGrid) bool { + return self.dirty_count == 0; + } + + pub fn clear(self: *TileGrid) void { + @memset(&self.dirty, false); + self.dirty_count = 0; + } + + /// Mark every tile `r` touches. Clips to the screen first, so out-of-range + /// rectangles are harmless. + pub fn add(self: *TileGrid, r: Rect) void { + const screen = Rect{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) }; + const c = r.intersect(screen); + if (c.isEmpty()) return; + const column_first: u32 = @intCast(@divTrunc(c.x, tile_size)); + const row_first: u32 = @intCast(@divTrunc(c.y, tile_size)); + const column_last: u32 = @min(@as(u32, @intCast(@divTrunc(c.right() - 1, tile_size))), self.columns - 1); + const row_last: u32 = @min(@as(u32, @intCast(@divTrunc(c.bottom() - 1, tile_size))), self.rows - 1); + var row = row_first; + while (row <= row_last) : (row += 1) { + var column = column_first; + while (column <= column_last) : (column += 1) { + const index = row * self.columns + column; + if (!self.dirty[index]) { + self.dirty[index] = true; + self.dirty_count += 1; + } + } + } + } + + /// The screen rectangle covered by tiles [column_first, column_end) of `row`. Edge + /// tiles clamp to the true screen size (the last column/row may be partial — or, on a + /// screen wider than the grid supports, stretched to cover the remainder). + fn tileSpanRect(self: *const TileGrid, column_first: u32, column_end: u32, row: u32) Rect { + const x: i32 = @intCast(column_first * tile_size); + const y: i32 = @intCast(row * tile_size); + const right: i32 = if (column_end >= self.columns) @intCast(self.width) else @intCast(column_end * tile_size); + const bottom: i32 = if (row + 1 >= self.rows) @intCast(self.height) else @intCast((row + 1) * tile_size); + return .{ .x = x, .y = y, .w = right - x, .h = bottom - y }; + } + + /// Turn the dirty tiles into repaint rectangles in `out`: coalesce each row's runs of + /// adjacent dirty tiles, then merge a run into the rectangle directly above it when + /// the spans match — so a dirty block of tiles becomes one rectangle. Returns the + /// filled prefix of `out`. + pub fn collect(self: *const TileGrid, out: []Rect) []Rect { + var count: usize = 0; + var row: u32 = 0; + while (row < self.rows) : (row += 1) { + var column: u32 = 0; + while (column < self.columns) { + if (!self.dirty[row * self.columns + column]) { + column += 1; + continue; + } + var run_end = column + 1; + while (run_end < self.columns and self.dirty[row * self.columns + run_end]) run_end += 1; + const rect = self.tileSpanRect(column, run_end, row); + column = run_end; + + var merged = false; + for (out[0..count]) |*existing| { + if (existing.x == rect.x and existing.w == rect.w and existing.bottom() == rect.y) { + existing.h += rect.h; + merged = true; + break; + } + } + if (merged) continue; + if (count < out.len) { + out[count] = rect; + count += 1; + } else { + out[count - 1] = out[count - 1].unite(rect); + } + } + } + return out[0..count]; + } +}; + /// A block of 32-bit pixels: `pixels` addressed row-major with `stride` pixels between /// row starts (≥ width — the framebuffer's stride is pitch/4, a layer's is its width). pub const Surface = struct { @@ -74,15 +245,17 @@ pub const Surface = struct { } }; -/// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds. +/// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds. Each row is +/// one `@memset` over the clipped span, so the compiler vectorizes it and the bounds check +/// runs once per row, not once per pixel. pub fn fillRect(s: Surface, rect: Rect, colour: u32) void { const c = rect.intersect(s.bounds()); if (c.isEmpty()) return; + const x0: usize = @intCast(c.x); + const span: usize = @intCast(c.w); var y: i32 = c.y; while (y < c.bottom()) : (y += 1) { - const r = s.row(@intCast(y)); - var x: i32 = c.x; - while (x < c.right()) : (x += 1) r[@intCast(x)] = colour; + @memset((s.row(@intCast(y)) + x0)[0..span], colour); } } @@ -94,35 +267,36 @@ pub fn composite(dst: Surface, dx: i32, dy: i32, layer: Surface, clip: Rect) voi const on_screen = Rect{ .x = dx, .y = dy, .w = @intCast(layer.width), .h = @intCast(layer.height) }; const region = on_screen.intersect(clip).intersect(dst.bounds()); if (region.isEmpty()) return; + const span: usize = @intCast(region.w); + const dst_x: usize = @intCast(region.x); + const src_x: usize = @intCast(region.x - dx); var y: i32 = region.y; while (y < region.bottom()) : (y += 1) { - const src = layer.row(@intCast(y - dy)); - const d = dst.row(@intCast(y)); - var x: i32 = region.x; - while (x < region.right()) : (x += 1) { - d[@intCast(x)] = src[@intCast(x - dx)]; - } + const source_row = layer.row(@intCast(y - dy)) + src_x; + const destination_row = dst.row(@intCast(y)) + dst_x; + @memcpy(destination_row[0..span], source_row[0..span]); } } /// Copy a `w`×`h` tile of native pixels from `src` (raw little-endian bytes, row-major, -/// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` is read -/// with `readInt` because it comes straight out of an IPC message buffer and carries no -/// alignment guarantee. Returns without touching anything if `src` is short. +/// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` comes +/// straight out of an IPC message buffer and carries no alignment guarantee, so each +/// clipped row is a byte-wise `@memcpy` — which equals the old per-pixel little-endian +/// `readInt` on every danos target (all little-endian) without the alignment concern. +/// Returns without touching anything if `src` is short. pub fn blitTile(dst: Surface, dx: i32, dy: i32, src: []const u8, w: u32, h: u32) void { if (src.len < @as(usize, w) * h * 4) return; - var ty: u32 = 0; - while (ty < h) : (ty += 1) { - const yy = dy + @as(i32, @intCast(ty)); - if (yy < 0 or yy >= dst.height) continue; - const drow = dst.row(@intCast(yy)); - var tx: u32 = 0; - while (tx < w) : (tx += 1) { - const xx = dx + @as(i32, @intCast(tx)); - if (xx < 0 or xx >= dst.width) continue; - const off = (@as(usize, ty) * w + tx) * 4; - drow[@intCast(xx)] = std.mem.readInt(u32, src[off..][0..4], .little); - } + const region = Rect.init(dx, dy, @intCast(w), @intCast(h)).intersect(dst.bounds()); + if (region.isEmpty()) return; + const span: usize = @intCast(region.w); + const tile_x: usize = @intCast(region.x - dx); + const dst_x: usize = @intCast(region.x); + var y: i32 = region.y; + while (y < region.bottom()) : (y += 1) { + const tile_y: usize = @intCast(y - dy); + const offset = (tile_y * w + tile_x) * 4; + const destination_row = dst.row(@intCast(y)) + dst_x; + @memcpy(std.mem.sliceAsBytes(destination_row[0..span]), src[offset..][0 .. span * 4]); } } @@ -179,6 +353,92 @@ test "composite honours the damage rectangle" { try std.testing.expectEqual(@as(u32, 0), back[4 * 8 + 4]); // outside damage } +test "damage list keeps disjoint rectangles separate and merges overlap" { + var list = DamageList{}; + list.add(Rect.init(0, 0, 10, 10)); + list.add(Rect.init(100, 100, 10, 10)); // far away: its own entry + try std.testing.expectEqual(@as(usize, 2), list.slice().len); + list.add(Rect.init(5, 5, 10, 10)); // overlaps the first: united into it + try std.testing.expectEqual(@as(usize, 2), list.slice().len); + try std.testing.expectEqual(Rect.init(0, 0, 15, 15), list.slice()[0]); + try std.testing.expect(!list.isEmpty()); + list.clear(); + try std.testing.expect(list.isEmpty()); +} + +test "damage list folds overflow into the last entry instead of dropping it" { + var list = DamageList{}; + var i: i32 = 0; + while (i < DamageList.capacity) : (i += 1) { + list.add(Rect.init(i * 100, 0, 10, 10)); // disjoint: fills every slot + } + try std.testing.expectEqual(@as(usize, DamageList.capacity), list.slice().len); + const overflow = Rect.init(0, 5000, 10, 10); + list.add(overflow); + try std.testing.expectEqual(@as(usize, DamageList.capacity), list.slice().len); + const last = list.slice()[DamageList.capacity - 1]; + try std.testing.expect(!last.intersect(overflow).isEmpty()); // still covered +} + +test "damage list ignores empty rectangles" { + var list = DamageList{}; + list.add(Rect.empty); + try std.testing.expect(list.isEmpty()); +} + +test "tile grid coalesces a run of adjacent tiles into one rectangle" { + var grid = TileGrid{}; + grid.reset(256, 128); // 4×2 tiles of 64 px + grid.add(Rect.init(10, 10, 100, 10)); // spans tiles (0,0) and (1,0) + var scratch: [TileGrid.maximum_rects]Rect = undefined; + const rects = grid.collect(&scratch); + try std.testing.expectEqual(@as(usize, 1), rects.len); + try std.testing.expectEqual(Rect.init(0, 0, 128, 64), rects[0]); +} + +test "tile grid: full-screen damage collapses back to a single rectangle" { + var grid = TileGrid{}; + grid.reset(1280, 720); // 20×12 tiles; the bottom row is partial (720 = 11*64 + 16) + grid.add(Rect.init(0, 0, 1280, 720)); + var scratch: [TileGrid.maximum_rects]Rect = undefined; + const rects = grid.collect(&scratch); + try std.testing.expectEqual(@as(usize, 1), rects.len); + try std.testing.expectEqual(Rect.init(0, 0, 1280, 720), rects[0]); +} + +test "tile grid keeps far-apart damage as separate rectangles" { + var grid = TileGrid{}; + grid.reset(1280, 720); + grid.add(Rect.init(0, 0, 10, 10)); // top-left tile + grid.add(Rect.init(1000, 600, 10, 10)); // a far-away tile + var scratch: [TileGrid.maximum_rects]Rect = undefined; + const rects = grid.collect(&scratch); + try std.testing.expectEqual(@as(usize, 2), rects.len); +} + +test "tile grid clamps edge tiles to the true screen size" { + var grid = TileGrid{}; + grid.reset(100, 100); // 2×2 tiles, both partial in each axis + grid.add(Rect.init(0, 0, 100, 100)); + var scratch: [TileGrid.maximum_rects]Rect = undefined; + const rects = grid.collect(&scratch); + try std.testing.expectEqual(@as(usize, 1), rects.len); + try std.testing.expectEqual(Rect.init(0, 0, 100, 100), rects[0]); +} + +test "tile grid clear empties it and reset resizes it" { + var grid = TileGrid{}; + grid.reset(256, 256); + grid.add(Rect.init(0, 0, 256, 256)); + try std.testing.expect(!grid.isEmpty()); + grid.clear(); + try std.testing.expect(grid.isEmpty()); + try std.testing.expect(grid.matches(256, 256)); + grid.reset(512, 512); + try std.testing.expect(!grid.matches(256, 256)); + try std.testing.expect(grid.isEmpty()); +} + test "blitTile copies a packed tile, clipping and reading unaligned bytes" { var back = [_]u32{0} ** (4 * 4); const dst = Surface{ .pixels = &back, .stride = 4, .width = 4, .height = 4 }; diff --git a/system/services/display/display.zig b/system/services/display/display.zig index 88e4556..de463cb 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -50,8 +50,10 @@ var pending_modeset_check: bool = false; var background: u32 = 0; /// The layer stack. A fixed table (a compositor has few top-level surfaces during -/// bring-up); each used slot owns an mmap'd surface. `damage` accumulates the dirty -/// screen region since the last `present`, so a present touches only what changed. +/// bring-up); each used slot owns an mmap'd surface. `damage_list` accumulates the dirty +/// screen rectangles since the last `present`, so a present touches only what changed — +/// and keeps far-apart changes (the cursor here, an animating layer there) as *separate* +/// small copies rather than one huge bounding box (see compositor.DamageList). const maximum_layers = 16; const Layer = struct { @@ -65,7 +67,16 @@ const Layer = struct { }; var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers; -var damage: Rect = Rect.empty; + +/// Which damage tracker drives `present` — a compile-time A/B switch (both are in +/// compositor.zig with the trade-off discussion): +/// .list — free-form dirty rectangles (tight bounds, heuristic merging) +/// .grid — a fixed 64-px tile grid (exact O(1) merging, tile-quantized repaints) +const DamageMode = enum { list, grid }; +const damage_mode: DamageMode = .grid; + +var damage_list: compositor.DamageList = .{}; +var damage_grid: compositor.TileGrid = .{}; // --- geometry helpers ------------------------------------------------------- @@ -78,9 +89,20 @@ fn layerScreenRect(l: *const Layer) Rect { return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) }; } -/// Add `r` (screen coordinates) to the pending damage, clipped to the screen. +/// Add `r` (screen coordinates) to the pending damage, clipped to the screen. In grid +/// mode the grid re-sizes itself lazily when the screen geometry changes — every +/// geometry-changing path (`attach_scanout`, `set_mode`) damages the whole new screen +/// right after, so damage pending from the old geometry is safely superseded. fn addDamage(r: Rect) void { - damage = damage.unite(r.intersect(screenRect())); + const clipped = r.intersect(screenRect()); + switch (damage_mode) { + .list => damage_list.add(clipped), + .grid => { + const mode = backend.info(); + if (!damage_grid.matches(mode.width, mode.height)) damage_grid.reset(mode.width, mode.height); + damage_grid.add(clipped); + }, + } } // --- layer operations (called from onMessage and the self-check) ------------ @@ -183,21 +205,29 @@ fn compositeInto(clip: Rect) void { } } -/// Composite the accumulated damage into the backend's surface, hand it to the backend to -/// put on screen, then clear the damage. A no-op when nothing is dirty. The frame counter -/// advances regardless, so callers can name frames. +/// Composite each accumulated damage rectangle into the backend's surface, hand the list +/// to the backend to put on screen, then clear the damage. A no-op when nothing is dirty. +/// The frame counter advances regardless, so callers can name frames. fn present() void { - const dirty = damage.intersect(screenRect()); - if (!dirty.isEmpty()) { - compositeInto(dirty); + var scratch: [compositor.TileGrid.maximum_rects]Rect = undefined; + const dirty: []const Rect = switch (damage_mode) { + .list => damage_list.slice(), + .grid => damage_grid.collect(&scratch), + }; + const had_damage = dirty.len != 0; + if (had_damage) { + for (dirty) |region| compositeInto(region); backend.present(dirty); } - damage = Rect.empty; + switch (damage_mode) { + .list => damage_list.clear(), + .grid => damage_grid.clear(), + } frames += 1; // The first present after a native upgrade confirms the composited frame actually reached // the shared scanout surface (the automated stand-in for "it's on screen"). - if (pending_native_verify and !dirty.isEmpty()) { + if (pending_native_verify and had_damage) { pending_native_verify = false; verifyNativePresent(); }