lib/gui/src/paint/retained.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const gui = @import("../root.zig");
  4 const paint = @import("root.zig");
  5 
  6 const Allocator = std.mem.Allocator;
  7 const Command = paint.command.Command;
  8 const CommandBuffer = paint.command.CommandBuffer;
  9 const Fragment = paint.command.Fragment;
 10 const FragmentId = paint.command.FragmentId;
 11 const Rect = gui.layout.Rect;
 12 const Region = paint.cpu.Region;
 13 const RegionSet = paint.regions.Set;
 14 
 15 pub const Damage = union(enum) {
 16     semantic,
 17     narrowed: RegionSet,
 18 };
 19 
 20 pub const Commands = struct {
 21     pub const Error = Allocator.Error || error{
 22         DuplicateFragmentId,
 23         TooManyFragments,
 24         UnfragmentedCommands,
 25     };
 26 
 27     allocator: Allocator,
 28     previous: CommandBuffer,
 29     previous_index: std.AutoHashMapUnmanaged(FragmentId, u32) = .empty,
 30     current_index: std.AutoHashMapUnmanaged(FragmentId, u32) = .empty,
 31     seen_previous: std.ArrayListUnmanaged(bool) = .empty,
 32     indexed_current: ?*const CommandBuffer = null,
 33     indexed_command_count: usize = 0,
 34     indexed_fragment_count: usize = 0,
 35     valid: bool = false,
 36 
 37     pub fn init(allocator: Allocator) Commands {
 38         return .{
 39             .allocator = allocator,
 40             .previous = CommandBuffer.init(allocator),
 41         };
 42     }
 43 
 44     pub fn deinit(self: *Commands) void {
 45         self.previous.deinit();
 46         self.previous_index.deinit(self.allocator);
 47         self.current_index.deinit(self.allocator);
 48         self.seen_previous.deinit(self.allocator);
 49         self.* = undefined;
 50     }
 51 
 52     pub fn invalidate(self: *Commands) void {
 53         self.cancelPrepared();
 54         self.valid = false;
 55     }
 56 
 57     pub fn hasBaseline(self: *const Commands) bool {
 58         return self.valid;
 59     }
 60 
 61     pub fn diff(
 62         self: *Commands,
 63         current: *const CommandBuffer,
 64         limits: []const Region,
 65         width: u32,
 66         height: u32,
 67     ) Error!Damage {
 68         if (!self.valid) return .semantic;
 69         try requireComplete(&self.previous);
 70         try requireComplete(current);
 71         try self.indexCurrentFragments(current);
 72         try self.seen_previous.resize(
 73             self.allocator,
 74             self.previous.fragmentItems().len,
 75         );
 76         @memset(self.seen_previous.items, false);
 77         if (!orderedLikePrevious(
 78             self.previous_index,
 79             current.fragmentItems(),
 80         )) return .semantic;
 81         const fragment_damage = try self.fragmentDamage(
 82             current,
 83             limits,
 84             width,
 85             height,
 86         );
 87         const command_damage = commandDamage(
 88             &self.previous,
 89             current,
 90             limits,
 91             width,
 92             height,
 93         );
 94         return narrowerDamage(fragment_damage, command_damage);
 95     }
 96 
 97     pub fn retain(
 98         self: *Commands,
 99         current: *CommandBuffer,
100     ) (CommandBuffer.Error || Error)!void {
101         try self.prepare(current);
102         self.commit(current);
103     }
104 
105     pub fn prepare(
106         self: *Commands,
107         current: *const CommandBuffer,
108     ) Error!void {
109         try requireComplete(current);
110         if (!self.currentIsIndexed(current)) {
111             try self.indexCurrentFragments(current);
112         }
113         try self.previous.ensureCapacity(current.items().len);
114         try self.previous.ensureFragmentCapacity(current.fragmentItems().len);
115     }
116 
117     pub fn commit(
118         self: *Commands,
119         current: *CommandBuffer,
120     ) void {
121         std.debug.assert(current.fragmentsComplete());
122         std.debug.assert(self.previous.commands.capacity >= current.items().len);
123         std.debug.assert(
124             self.previous.fragments.capacity >= current.fragmentItems().len,
125         );
126         std.debug.assert(self.currentIsIndexed(current));
127         std.mem.swap(
128             std.AutoHashMapUnmanaged(FragmentId, u32),
129             &self.previous_index,
130             &self.current_index,
131         );
132         self.cancelPrepared();
133         std.mem.swap(CommandBuffer, &self.previous, current);
134         current.reset();
135         self.valid = true;
136     }
137 
138     pub fn cancelPrepared(self: *Commands) void {
139         self.indexed_current = null;
140         self.indexed_command_count = 0;
141         self.indexed_fragment_count = 0;
142     }
143 
144     fn indexCurrentFragments(
145         self: *Commands,
146         current: *const CommandBuffer,
147     ) Error!void {
148         self.cancelPrepared();
149         try reserveMap(
150             &self.current_index,
151             self.allocator,
152             current.fragmentItems().len,
153         );
154         self.current_index.clearRetainingCapacity();
155         for (current.fragmentItems(), 0..) |fragment, index| {
156             const value = std.math.cast(u32, index) orelse
157                 return error.TooManyFragments;
158             try insertUnique(
159                 &self.current_index,
160                 self.allocator,
161                 fragment.id,
162                 value,
163             );
164         }
165         self.indexed_current = current;
166         self.indexed_command_count = current.items().len;
167         self.indexed_fragment_count = current.fragmentItems().len;
168     }
169 
170     fn currentIsIndexed(
171         self: *const Commands,
172         current: *const CommandBuffer,
173     ) bool {
174         return self.indexed_current == current and
175             self.indexed_command_count == current.items().len and
176             self.indexed_fragment_count == current.fragmentItems().len;
177     }
178 
179     fn fragmentDamage(
180         self: *Commands,
181         current: *const CommandBuffer,
182         limits: []const Region,
183         width: u32,
184         height: u32,
185     ) Error!Damage {
186         var regions = RegionSet{};
187         var changed = false;
188         for (current.fragmentItems()) |fragment| {
189             const previous_index = self.previous_index.get(fragment.id);
190             if (previous_index == null) {
191                 changed = true;
192                 includeFragment(&regions, fragment, limits, width, height);
193                 continue;
194             }
195             self.seen_previous.items[previous_index.?] = true;
196             const old = self.previous.fragmentItems()[previous_index.?];
197             if (fragmentsEqual(&self.previous, old, current, fragment)) {
198                 continue;
199             }
200             changed = true;
201             includeChangedCommands(
202                 &regions,
203                 &self.previous,
204                 old,
205                 current,
206                 fragment,
207                 limits,
208                 width,
209                 height,
210             );
211         }
212         changed = includeRemoved(self, &regions, limits, width, height) or changed;
213         if (!changed) return .semantic;
214         return .{ .narrowed = regions };
215     }
216 };
217 
218 fn reserveMap(
219     map: anytype,
220     allocator: Allocator,
221     count: usize,
222 ) Allocator.Error!void {
223     const capacity = std.math.cast(u32, count) orelse
224         return error.OutOfMemory;
225     try map.ensureTotalCapacity(allocator, capacity);
226 }
227 
228 fn insertUnique(
229     map: anytype,
230     allocator: Allocator,
231     id: FragmentId,
232     value: anytype,
233 ) Commands.Error!void {
234     const entry = try map.getOrPut(allocator, id);
235     if (entry.found_existing) return error.DuplicateFragmentId;
236     entry.value_ptr.* = value;
237 }
238 
239 fn orderedLikePrevious(
240     previous: std.AutoHashMapUnmanaged(FragmentId, u32),
241     current: []const Fragment,
242 ) bool {
243     var last: ?u32 = null;
244     for (current) |fragment| {
245         const index = previous.get(fragment.id) orelse continue;
246         if (last) |prior| {
247             if (index <= prior) return false;
248         }
249         last = index;
250     }
251     return true;
252 }
253 
254 fn includeRemoved(
255     self: *Commands,
256     regions: *RegionSet,
257     limits: []const Region,
258     width: u32,
259     height: u32,
260 ) bool {
261     var changed = false;
262     for (self.previous.fragmentItems(), 0..) |fragment, index| {
263         if (self.seen_previous.items[index]) continue;
264         includeFragment(regions, fragment, limits, width, height);
265         changed = true;
266     }
267     return changed;
268 }
269 
270 fn includeFragment(
271     regions: *RegionSet,
272     fragment: Fragment,
273     limits: []const Region,
274     width: u32,
275     height: u32,
276 ) void {
277     includeRect(regions, fragment.bounds, limits, width, height);
278 }
279 
280 fn includeCommand(
281     regions: *RegionSet,
282     value: Command,
283     limits: []const Region,
284     width: u32,
285     height: u32,
286 ) void {
287     includeRect(
288         regions,
289         paint.command.visibleBounds(value),
290         limits,
291         width,
292         height,
293     );
294 }
295 
296 fn includeRect(
297     regions: *RegionSet,
298     rect: Rect,
299     limits: []const Region,
300     width: u32,
301     height: u32,
302 ) void {
303     const raw = paint.regions.fromRect(rect, 1, width, height) orelse return;
304     for (limits) |limit| {
305         const clipped = paint.regions.intersect(raw, limit);
306         if (clipped.pixelCount() != 0) regions.append(clipped);
307     }
308 }
309 
310 fn includeChangedCommands(
311     regions: *RegionSet,
312     previous: *const CommandBuffer,
313     old: Fragment,
314     current: *const CommandBuffer,
315     new: Fragment,
316     limits: []const Region,
317     width: u32,
318     height: u32,
319 ) void {
320     const old_commands = fragmentCommands(previous, old);
321     const new_commands = fragmentCommands(current, new);
322     const count = @max(old_commands.len, new_commands.len);
323     for (0..count) |index| {
324         const before = if (index < old_commands.len)
325             old_commands[index]
326         else
327             null;
328         const after = if (index < new_commands.len)
329             new_commands[index]
330         else
331             null;
332         if (before != null and
333             after != null and
334             paint.command.visuallyEqual(before.?, after.?))
335         {
336             continue;
337         }
338         if (before) |value| {
339             includeCommand(regions, value, limits, width, height);
340         }
341         if (after) |value| {
342             includeCommand(regions, value, limits, width, height);
343         }
344     }
345 }
346 
347 fn commandDamage(
348     previous: *const CommandBuffer,
349     current: *const CommandBuffer,
350     limits: []const Region,
351     width: u32,
352     height: u32,
353 ) Damage {
354     var regions = RegionSet{};
355     var changed = false;
356     const count = @max(previous.items().len, current.items().len);
357     for (0..count) |index| {
358         const before = if (index < previous.items().len)
359             previous.items()[index]
360         else
361             null;
362         const after = if (index < current.items().len)
363             current.items()[index]
364         else
365             null;
366         if (before != null and
367             after != null and
368             paint.command.visuallyEqual(before.?, after.?))
369         {
370             continue;
371         }
372         changed = true;
373         if (before) |value| {
374             includeCommand(&regions, value, limits, width, height);
375         }
376         if (after) |value| {
377             includeCommand(&regions, value, limits, width, height);
378         }
379     }
380     if (!changed) return .semantic;
381     return .{ .narrowed = regions };
382 }
383 
384 fn narrowerDamage(fragment: Damage, command: Damage) Damage {
385     if (command == .semantic) return .semantic;
386     if (fragment == .semantic) return command;
387     const fragment_area = damageArea(fragment.narrowed);
388     const command_area = damageArea(command.narrowed);
389     return if (fragment_area <= command_area) fragment else command;
390 }
391 
392 fn damageArea(regions: RegionSet) usize {
393     return if (regions.bounding()) |region| region.pixelCount() else 0;
394 }
395 
396 fn fragmentsEqual(
397     previous: *const CommandBuffer,
398     old: Fragment,
399     current: *const CommandBuffer,
400     new: Fragment,
401 ) bool {
402     if (old.command_count != new.command_count) return false;
403     const old_commands = fragmentCommands(previous, old);
404     const new_commands = fragmentCommands(current, new);
405     for (old_commands, new_commands) |left, right| {
406         if (!paint.command.visuallyEqual(left, right)) return false;
407     }
408     return true;
409 }
410 
411 fn fragmentCommands(
412     buffer: *const CommandBuffer,
413     fragment: Fragment,
414 ) []const Command {
415     const start: usize = fragment.command_start;
416     const end = start + fragment.command_count;
417     std.debug.assert(end <= buffer.items().len);
418     return buffer.items()[start..end];
419 }
420 
421 fn requireComplete(
422     buffer: *const CommandBuffer,
423 ) error{UnfragmentedCommands}!void {
424     if (!buffer.fragmentsComplete()) return error.UnfragmentedCommands;
425 }
426 
427 fn testId(value: u64) FragmentId {
428     return .{
429         .root_id = 1,
430         .element_id = value,
431         .namespace = 99,
432         .part = 1,
433     };
434 }
435 
436 fn appendTestFragment(
437     buffer: *CommandBuffer,
438     id: FragmentId,
439     x: f32,
440     color: u8,
441 ) !void {
442     const start = buffer.items().len;
443     try buffer.append(.{
444         .kind = .fill,
445         .rect = .{ .x = x, .width = 8, .height = 8 },
446         .clip = .{ .width = 128, .height = 32 },
447         .color = .{ .r = color, .a = 255 },
448     });
449     try buffer.commitFragment(id, start);
450 }
451 
452 fn appendTestCommandFragment(
453     buffer: *CommandBuffer,
454     id: FragmentId,
455     value: Command,
456 ) !void {
457     const start = buffer.items().len;
458     try buffer.append(value);
459     try buffer.commitFragment(id, start);
460 }
461 
462 fn appendTestPairFragment(
463     buffer: *CommandBuffer,
464     id: FragmentId,
465     second_color: u8,
466 ) !void {
467     const start = buffer.items().len;
468     try buffer.append(.{
469         .kind = .fill,
470         .rect = .{ .x = 4, .width = 8, .height = 8 },
471         .clip = .{ .width = 128, .height = 32 },
472         .color = .{ .r = 10, .a = 255 },
473     });
474     try buffer.append(.{
475         .kind = .fill,
476         .rect = .{ .x = 80, .width = 8, .height = 8 },
477         .clip = .{ .width = 128, .height = 32 },
478         .color = .{ .r = second_color, .a = 255 },
479     });
480     try buffer.commitFragment(id, start);
481 }
482 
483 test "retained insertion damages only the inserted fragment" {
484     var retained = Commands.init(std.testing.allocator);
485     defer retained.deinit();
486     var current = CommandBuffer.init(std.testing.allocator);
487     defer current.deinit();
488     try appendTestFragment(&current, testId(1), 20, 10);
489     try appendTestFragment(&current, testId(2), 40, 20);
490     try retained.retain(&current);
491     try appendTestFragment(&current, testId(9), 2, 90);
492     try appendTestFragment(&current, testId(1), 20, 10);
493     try appendTestFragment(&current, testId(2), 40, 20);
494 
495     const damage = try retained.diff(
496         &current,
497         &.{Region.full(128, 32)},
498         128,
499         32,
500     );
501 
502     try std.testing.expectEqual(
503         Region{ .x = 1, .width = 10, .height = 9 },
504         damage.narrowed.bounding().?,
505     );
506 }
507 
508 test "retained matched fragment damages only changed commands" {
509     var retained = Commands.init(std.testing.allocator);
510     defer retained.deinit();
511     var current = CommandBuffer.init(std.testing.allocator);
512     defer current.deinit();
513     try appendTestPairFragment(&current, testId(1), 20);
514     try retained.retain(&current);
515     try appendTestPairFragment(&current, testId(1), 30);
516 
517     const damage = try retained.diff(
518         &current,
519         &.{Region.full(128, 32)},
520         128,
521         32,
522     );
523 
524     try std.testing.expectEqual(
525         Region{ .x = 79, .width = 10, .height = 9 },
526         damage.narrowed.bounding().?,
527     );
528 }
529 
530 test "retained identity churn keeps narrower positional command damage" {
531     var retained = Commands.init(std.testing.allocator);
532     defer retained.deinit();
533     var current = CommandBuffer.init(std.testing.allocator);
534     defer current.deinit();
535     try appendTestPairFragment(&current, testId(1), 20);
536     try retained.retain(&current);
537     try appendTestPairFragment(&current, testId(2), 30);
538 
539     const damage = try retained.diff(
540         &current,
541         &.{Region.full(128, 32)},
542         128,
543         32,
544     );
545 
546     try std.testing.expectEqual(
547         Region{ .x = 79, .width = 10, .height = 9 },
548         damage.narrowed.bounding().?,
549     );
550 }
551 
552 test "retained reorder preserves semantic damage" {
553     var retained = Commands.init(std.testing.allocator);
554     defer retained.deinit();
555     var current = CommandBuffer.init(std.testing.allocator);
556     defer current.deinit();
557     try appendTestFragment(&current, testId(1), 0, 10);
558     try appendTestFragment(&current, testId(2), 4, 20);
559     try retained.retain(&current);
560     try appendTestFragment(&current, testId(2), 4, 20);
561     try appendTestFragment(&current, testId(1), 0, 10);
562 
563     const damage = try retained.diff(
564         &current,
565         &.{Region.full(32, 16)},
566         32,
567         16,
568     );
569 
570     try std.testing.expect(damage == .semantic);
571 }
572 
573 test "retained duplicate fragment identities reject" {
574     var retained = Commands.init(std.testing.allocator);
575     defer retained.deinit();
576     var current = CommandBuffer.init(std.testing.allocator);
577     defer current.deinit();
578     try appendTestFragment(&current, testId(1), 0, 10);
579     try retained.retain(&current);
580     try appendTestFragment(&current, testId(2), 0, 10);
581     try appendTestFragment(&current, testId(2), 16, 20);
582 
583     try std.testing.expectError(
584         error.DuplicateFragmentId,
585         retained.diff(&current, &.{Region.full(32, 16)}, 32, 16),
586     );
587     current.reset();
588     try appendTestFragment(&current, testId(3), 8, 30);
589     try retained.retain(&current);
590     try std.testing.expect(retained.hasBaseline());
591 }
592 
593 test "retained baseline rejects duplicate fragment identities" {
594     var retained = Commands.init(std.testing.allocator);
595     defer retained.deinit();
596     var current = CommandBuffer.init(std.testing.allocator);
597     defer current.deinit();
598     try appendTestFragment(&current, testId(2), 0, 10);
599     try appendTestFragment(&current, testId(2), 16, 20);
600 
601     try std.testing.expectError(
602         error.DuplicateFragmentId,
603         retained.retain(&current),
604     );
605     try std.testing.expect(!retained.hasBaseline());
606 }
607 
608 test "retained damage includes removed effects and current geometry" {
609     var retained = Commands.init(std.testing.allocator);
610     defer retained.deinit();
611     var current = CommandBuffer.init(std.testing.allocator);
612     defer current.deinit();
613     try appendTestCommandFragment(&current, testId(1), .{
614         .kind = .shadow,
615         .rect = .{ .x = 10, .y = 10, .width = 10, .height = 10 },
616         .clip = .{ .width = 64, .height = 64 },
617         .color = .{ .a = 255 },
618         .width = 4,
619     });
620     try retained.retain(&current);
621     try appendTestCommandFragment(&current, testId(1), .{
622         .kind = .fill,
623         .rect = .{ .x = 20, .y = 20, .width = 10, .height = 10 },
624         .clip = .{ .width = 64, .height = 64 },
625         .color = .{ .a = 255 },
626     });
627 
628     const damage = try retained.diff(
629         &current,
630         &.{Region.full(64, 64)},
631         64,
632         64,
633     );
634 
635     try std.testing.expectEqual(
636         Region{ .x = 5, .y = 5, .width = 26, .height = 26 },
637         damage.narrowed.bounding().?,
638     );
639 }
640 
641 test "retained clip shrink damages pixels uncovered by the old clip" {
642     var retained = Commands.init(std.testing.allocator);
643     defer retained.deinit();
644     var current = CommandBuffer.init(std.testing.allocator);
645     defer current.deinit();
646     try appendTestCommandFragment(&current, testId(1), .{
647         .kind = .fill,
648         .rect = .{ .width = 16, .height = 8 },
649         .clip = .{ .width = 16, .height = 8 },
650         .color = .{ .a = 255 },
651     });
652     try retained.retain(&current);
653     try appendTestCommandFragment(&current, testId(1), .{
654         .kind = .fill,
655         .rect = .{ .width = 16, .height = 8 },
656         .clip = .{ .x = 4, .width = 12, .height = 8 },
657         .color = .{ .a = 255 },
658     });
659 
660     const damage = try retained.diff(
661         &current,
662         &.{Region.full(32, 16)},
663         32,
664         16,
665     );
666 
667     try std.testing.expect(damage == .narrowed);
668     try std.testing.expectEqual(
669         Region{ .width = 17, .height = 9 },
670         damage.narrowed.bounding().?,
671     );
672 }
673 
674 test "retained linear gradient changes damage its widget bounds" {
675     const base = Command{
676         .kind = .linear_gradient,
677         .rect = .{ .x = 8, .y = 4, .width = 24, .height = 12 },
678         .clip = .{ .width = 64, .height = 32 },
679         .color = .{ .r = 20, .g = 40, .b = 80, .a = 255 },
680         .color_end = .{ .r = 80, .g = 140, .b = 220, .a = 255 },
681         .gradient_start = .{ .x = 8, .y = 4 },
682         .gradient_end = .{ .x = 32, .y = 16 },
683         .radius = 4,
684     };
685     var retained = Commands.init(std.testing.allocator);
686     defer retained.deinit();
687     var current = CommandBuffer.init(std.testing.allocator);
688     defer current.deinit();
689     try appendTestCommandFragment(&current, testId(1), base);
690     try retained.retain(&current);
691     var changed = base;
692     changed.color_end.g = 180;
693     changed.gradient_end.y = 4;
694     try appendTestCommandFragment(&current, testId(1), changed);
695 
696     const damage = try retained.diff(
697         &current,
698         &.{Region.full(64, 32)},
699         64,
700         32,
701     );
702 
703     try std.testing.expect(damage == .narrowed);
704     try std.testing.expectEqual(
705         Region{ .x = 7, .y = 3, .width = 26, .height = 14 },
706         damage.narrowed.bounding().?,
707     );
708 }
709 
710 test "retained equal commands preserve caller semantic damage" {
711     var retained = Commands.init(std.testing.allocator);
712     defer retained.deinit();
713     var current = CommandBuffer.init(std.testing.allocator);
714     defer current.deinit();
715     try appendTestFragment(&current, testId(1), 4, 10);
716     try retained.retain(&current);
717     try appendTestFragment(&current, testId(1), 4, 10);
718 
719     const damage = try retained.diff(
720         &current,
721         &.{Region{ .x = 2, .width = 12, .height = 8 }},
722         32,
723         16,
724     );
725 
726     try std.testing.expect(damage == .semantic);
727 }
728 
729 test "retained semantic damage repaints changed image content" {
730     const red = [_]u32{paint.packRgba(.{ .r = 255, .a = 255 })};
731     const blue = [_]u32{paint.packRgba(.{ .b = 255, .a = 255 })};
732     const old_images = paint.ImageSet{
733         .images = &.{.{ .width = 1, .height = 1, .pixels = &red }},
734     };
735     const new_images = paint.ImageSet{
736         .images = &.{.{ .width = 1, .height = 1, .pixels = &blue }},
737     };
738     const image_command = Command{
739         .kind = .image,
740         .rect = .{ .width = 1, .height = 1 },
741         .clip = .{ .width = 1, .height = 1 },
742         .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
743     };
744     var retained = Commands.init(std.testing.allocator);
745     defer retained.deinit();
746     var current = CommandBuffer.init(std.testing.allocator);
747     defer current.deinit();
748     try appendTestCommandFragment(&current, testId(1), image_command);
749     var actual = [_]u32{0};
750     try paint.renderCommandsPackedWithImages(
751         current.items(),
752         .{ .width = 1, .height = 1, .pixels = &actual },
753         .{ .a = 255 },
754         old_images,
755     );
756     try retained.retain(&current);
757     try appendTestCommandFragment(&current, testId(1), image_command);
758 
759     const semantic = Region.full(1, 1);
760     const damage = try retained.diff(
761         &current,
762         &.{semantic},
763         1,
764         1,
765     );
766     try std.testing.expect(damage == .semantic);
767     try paint.renderCommandsPackedRegionWithImages(
768         current.items(),
769         .{ .width = 1, .height = 1, .pixels = &actual },
770         .{ .a = 255 },
771         new_images,
772         semantic,
773     );
774     var expected = [_]u32{0};
775     try paint.renderCommandsPackedWithImages(
776         current.items(),
777         .{ .width = 1, .height = 1, .pixels = &expected },
778         .{ .a = 255 },
779         new_images,
780     );
781     try std.testing.expectEqualSlices(u32, &expected, &actual);
782 }
783 
784 test "retained preflight failure preserves a reusable baseline" {
785     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
786     const allocator = failing.allocator();
787     var retained = Commands.init(allocator);
788     defer retained.deinit();
789     var current = CommandBuffer.init(allocator);
790     defer current.deinit();
791     try appendTestFragment(&current, testId(1), 0, 10);
792     try retained.retain(&current);
793     for (0..16) |index| {
794         try appendTestFragment(
795             &current,
796             testId(@intCast(index + 2)),
797             @floatFromInt(index * 4),
798             @intCast(index + 20),
799         );
800     }
801 
802     failing.fail_index = failing.alloc_index;
803     failing.resize_fail_index = failing.resize_index;
804     try std.testing.expectError(error.OutOfMemory, retained.prepare(&current));
805     try std.testing.expect(failing.has_induced_failure);
806     try std.testing.expect(retained.hasBaseline());
807 
808     failing.fail_index = std.math.maxInt(usize);
809     failing.resize_fail_index = std.math.maxInt(usize);
810     const damage = try retained.diff(
811         &current,
812         &.{Region.full(128, 32)},
813         128,
814         32,
815     );
816     try std.testing.expect(damage == .narrowed);
817     try retained.retain(&current);
818     try std.testing.expect(retained.hasBaseline());
819 }
820 
821 test "retained command bookkeeping needs no allocation after warmup" {
822     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
823     var retained = Commands.init(failing.allocator());
824     defer retained.deinit();
825     var current = CommandBuffer.init(failing.allocator());
826     defer current.deinit();
827 
828     try appendTestFragment(&current, testId(1), 4, 10);
829     try appendTestFragment(&current, testId(2), 20, 20);
830     try retained.retain(&current);
831     try appendTestFragment(&current, testId(1), 4, 10);
832     try appendTestFragment(&current, testId(2), 20, 20);
833     _ = try retained.diff(&current, &.{Region.full(64, 16)}, 64, 16);
834     try retained.retain(&current);
835 
836     failing.fail_index = failing.alloc_index;
837     failing.resize_fail_index = failing.resize_index;
838     for (0..8) |_| {
839         try appendTestFragment(&current, testId(1), 4, 10);
840         try appendTestFragment(&current, testId(2), 20, 20);
841         _ = try retained.diff(&current, &.{Region.full(64, 16)}, 64, 16);
842         try retained.retain(&current);
843     }
844     try std.testing.expect(!failing.has_induced_failure);
845 }