453 lines
20 KiB
Zig
453 lines
20 KiB
Zig
//! The compositor's pure core: rectangle math and the three blitting primitives the
|
||
//! display service composes frames from — fill a rectangle of a surface, composite one
|
||
//! surface onto another clipped to a damage rectangle, and copy a client-supplied pixel
|
||
//! tile in. Deliberately free of any syscall or `runtime` dependency (it takes plain
|
||
//! pixel pointers), so it is host-tested under `zig build test`. The service
|
||
//! (system/services/display/display.zig) wires real mmap'd surfaces and the framebuffer
|
||
//! to it. Pixels are opaque native 32-bit values — v1 layers don't alpha-blend, and
|
||
//! channel order (rgbx/bgrx) is the caller's concern (see protocol.pack).
|
||
|
||
const std = @import("std");
|
||
|
||
/// An axis-aligned rectangle in pixels. Signed, so a surface partly off-screen (a layer
|
||
/// dragged past an edge) clips with plain arithmetic. Half-open: covers [x, x+w) × [y, y+h).
|
||
pub const Rect = struct {
|
||
x: i32,
|
||
y: i32,
|
||
w: i32,
|
||
h: i32,
|
||
|
||
pub const empty = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 };
|
||
|
||
pub fn init(x: i32, y: i32, w: i32, h: i32) Rect {
|
||
return .{ .x = x, .y = y, .w = w, .h = h };
|
||
}
|
||
|
||
pub fn isEmpty(r: Rect) bool {
|
||
return r.w <= 0 or r.h <= 0;
|
||
}
|
||
|
||
pub fn right(r: Rect) i32 {
|
||
return r.x + r.w;
|
||
}
|
||
pub fn bottom(r: Rect) i32 {
|
||
return r.y + r.h;
|
||
}
|
||
|
||
/// The overlap of two rectangles, or an empty rectangle if they don't touch.
|
||
pub fn intersect(a: Rect, b: Rect) Rect {
|
||
const x0 = @max(a.x, b.x);
|
||
const y0 = @max(a.y, b.y);
|
||
const x1 = @min(a.right(), b.right());
|
||
const y1 = @min(a.bottom(), b.bottom());
|
||
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
|
||
}
|
||
|
||
/// The bounding box of two rectangles. An empty operand contributes nothing (returns
|
||
/// the other), so folding damage rectangles with `unite` from `empty` yields their
|
||
/// bounding box.
|
||
pub fn unite(a: Rect, b: Rect) Rect {
|
||
if (a.isEmpty()) return b;
|
||
if (b.isEmpty()) return a;
|
||
const x0 = @min(a.x, b.x);
|
||
const y0 = @min(a.y, b.y);
|
||
const x1 = @max(a.right(), b.right());
|
||
const y1 = @max(a.bottom(), b.bottom());
|
||
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
|
||
}
|
||
};
|
||
|
||
/// 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 {
|
||
pixels: [*]u32,
|
||
stride: u32, // pixels per row
|
||
width: u32,
|
||
height: u32,
|
||
|
||
pub fn bounds(s: Surface) Rect {
|
||
return .{ .x = 0, .y = 0, .w = @intCast(s.width), .h = @intCast(s.height) };
|
||
}
|
||
|
||
inline fn row(s: Surface, y: u32) [*]u32 {
|
||
return s.pixels + @as(usize, y) * s.stride;
|
||
}
|
||
};
|
||
|
||
/// 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) {
|
||
@memset((s.row(@intCast(y)) + x0)[0..span], colour);
|
||
}
|
||
}
|
||
|
||
/// Composite the whole of `layer` onto `dst` with the layer's top-left at (`dx`, `dy`),
|
||
/// painting only the pixels that fall inside `clip` (a `dst`-space rectangle) and inside
|
||
/// `dst`. Opaque copy. This is the primitive `present` repeats over the visible layer
|
||
/// stack, bottom to top, for each damaged region.
|
||
pub fn composite(dst: Surface, dx: i32, dy: i32, layer: Surface, clip: Rect) void {
|
||
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 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` 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;
|
||
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]);
|
||
}
|
||
}
|
||
|
||
// --- tests ------------------------------------------------------------------
|
||
|
||
test "rect intersect: overlap and disjoint" {
|
||
try std.testing.expectEqual(Rect.init(5, 5, 5, 5), Rect.init(0, 0, 10, 10).intersect(Rect.init(5, 5, 10, 10)));
|
||
try std.testing.expect(Rect.init(0, 0, 10, 10).intersect(Rect.init(20, 20, 5, 5)).isEmpty());
|
||
}
|
||
|
||
test "rect unite: bounding box, empty is identity" {
|
||
const a = Rect.init(2, 2, 4, 4);
|
||
try std.testing.expectEqual(Rect.init(2, 1, 10, 5), a.unite(Rect.init(10, 1, 2, 2)));
|
||
try std.testing.expectEqual(a, a.unite(Rect.empty));
|
||
try std.testing.expectEqual(a, Rect.empty.unite(a));
|
||
}
|
||
|
||
test "fillRect clips to surface and honours stride padding" {
|
||
// A 4×3 surface inside a 6-wide allocation (stride 6 > width 4), like pitch padding.
|
||
var mem = [_]u32{0} ** (6 * 3);
|
||
const s = Surface{ .pixels = &mem, .stride = 6, .width = 4, .height = 3 };
|
||
fillRect(s, Rect.init(-1, -1, 3, 3), 0xAB); // straddles the top-left corner
|
||
try std.testing.expectEqual(@as(u32, 0xAB), mem[0 * 6 + 0]);
|
||
try std.testing.expectEqual(@as(u32, 0xAB), mem[1 * 6 + 1]);
|
||
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 2]); // beyond the 2-wide fill
|
||
try std.testing.expectEqual(@as(u32, 0), mem[2 * 6 + 0]); // row 2 untouched
|
||
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 4]); // stride padding untouched
|
||
}
|
||
|
||
test "composite: overlap shows the top layer, clipped to damage" {
|
||
var back = [_]u32{0} ** (8 * 8);
|
||
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
|
||
var lo = [_]u32{0x11} ** (4 * 4);
|
||
var hi = [_]u32{0x22} ** (4 * 4);
|
||
const low = Surface{ .pixels = &lo, .stride = 4, .width = 4, .height = 4 };
|
||
const high = Surface{ .pixels = &hi, .stride = 4, .width = 4, .height = 4 };
|
||
composite(dst, 0, 0, low, dst.bounds()); // bottom at (0,0)
|
||
composite(dst, 2, 2, high, dst.bounds()); // top overlaps at (2,2)
|
||
try std.testing.expectEqual(@as(u32, 0x11), back[0 * 8 + 0]); // bottom-only
|
||
try std.testing.expectEqual(@as(u32, 0x22), back[3 * 8 + 3]); // overlap → top wins
|
||
try std.testing.expectEqual(@as(u32, 0x22), back[5 * 8 + 5]); // top-only
|
||
try std.testing.expectEqual(@as(u32, 0), back[7 * 8 + 7]); // neither
|
||
}
|
||
|
||
test "composite honours the damage rectangle" {
|
||
var back = [_]u32{0} ** (8 * 8);
|
||
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
|
||
var fill = [_]u32{0x33} ** (8 * 8);
|
||
const layer = Surface{ .pixels = &fill, .stride = 8, .width = 8, .height = 8 };
|
||
composite(dst, 0, 0, layer, Rect.init(2, 2, 2, 2)); // only this damage region
|
||
try std.testing.expectEqual(@as(u32, 0x33), back[2 * 8 + 2]);
|
||
try std.testing.expectEqual(@as(u32, 0x33), back[3 * 8 + 3]);
|
||
try std.testing.expectEqual(@as(u32, 0), back[1 * 8 + 1]); // outside damage
|
||
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 };
|
||
// A 2×2 tile in a byte buffer offset by one byte, so reads are unaligned.
|
||
var raw = [_]u8{0} ** (1 + 2 * 2 * 4);
|
||
const tile = raw[1..];
|
||
for (0..4) |i| std.mem.writeInt(u32, tile[i * 4 ..][0..4], @intCast(0xA0 + i), .little);
|
||
blitTile(dst, 3, 3, tile, 2, 2); // bottom-right corner; only (3,3) lands on-surface
|
||
try std.testing.expectEqual(@as(u32, 0xA0), back[3 * 4 + 3]);
|
||
try std.testing.expectEqual(@as(u32, 0), back[0]); // nothing else touched
|
||
}
|