1use crate::geometry::{
23 CommandRecording, Point, RecordKind, Rect, SolidArcRecord, SolidRoundRectRecord, TapeRef,
24};
25use crate::{Color, CornerRadii};
26
27const REL_EPS: f32 = 2e-3;
29const ABS_EPS: f32 = 2e-2;
31const GROUP_SCALE_EPS: f32 = 1e-4;
36const GROUP_ANGLE_EPS: f32 = 2e-4;
37
38fn close_rel(a: f32, b: f32) -> bool {
39 (a - b).abs() <= ABS_EPS + REL_EPS * a.abs().max(b.abs())
40}
41
42fn close_angle(a: f32, b: f32) -> bool {
43 use std::f32::consts::TAU;
44 let mut d = (a - b) % TAU;
45 if d > TAU * 0.5 {
46 d -= TAU;
47 }
48 if d < -TAU * 0.5 {
49 d += TAU;
50 }
51 d.abs() <= ABS_EPS
52}
53
54fn close_point(a: Point, b: Point) -> bool {
55 close_rel(a.x, b.x) && close_rel(a.y, b.y)
56}
57
58#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct RecordTransform {
62 pub scale: f32,
63 pub angle: f32,
64}
65
66impl RecordTransform {
67 pub const IDENTITY: Self = Self {
68 scale: 1.0,
69 angle: 0.0,
70 };
71
72 pub fn apply(&self, center: Point, p: Point) -> Point {
73 let (sin, cos) = self.angle.sin_cos();
74 let dx = p.x - center.x;
75 let dy = p.y - center.y;
76 Point::new(
77 center.x + (dx * cos - dy * sin) * self.scale,
78 center.y + (dx * sin + dy * cos) * self.scale,
79 )
80 }
81
82 pub fn apply_to_bounds(&self, center: Point, bounds: Rect) -> Rect {
87 let corners = [
88 Point::new(bounds.x, bounds.y),
89 Point::new(bounds.x + bounds.width, bounds.y),
90 Point::new(bounds.x, bounds.y + bounds.height),
91 Point::new(bounds.x + bounds.width, bounds.y + bounds.height),
92 ];
93 let mut min_x = f32::INFINITY;
94 let mut min_y = f32::INFINITY;
95 let mut max_x = f32::NEG_INFINITY;
96 let mut max_y = f32::NEG_INFINITY;
97 for corner in corners {
98 let p = self.apply(center, corner);
99 min_x = min_x.min(p.x);
100 min_y = min_y.min(p.y);
101 max_x = max_x.max(p.x);
102 max_y = max_y.max(p.y);
103 }
104 Rect {
105 x: min_x,
106 y: min_y,
107 width: max_x - min_x,
108 height: max_y - min_y,
109 }
110 }
111}
112
113pub fn transforms_group(
117 entry: RecordTransform,
118 entry_pinned: bool,
119 anchor: RecordTransform,
120) -> bool {
121 use std::f32::consts::TAU;
122 if (entry.scale - anchor.scale).abs() > GROUP_SCALE_EPS * anchor.scale.abs().max(1.0) {
123 return false;
124 }
125 if !entry_pinned {
126 return true;
127 }
128 let mut d = (entry.angle - anchor.angle) % TAU;
129 if d > TAU * 0.5 {
130 d -= TAU;
131 }
132 if d < -TAU * 0.5 {
133 d += TAU;
134 }
135 d.abs() <= GROUP_ANGLE_EPS
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum RecordMatch {
142 Exact,
143 Recolor,
146 Mismatch,
147}
148
149pub fn circle_view(record: &SolidRoundRectRecord) -> Option<(Point, f32)> {
153 if !is_circle(record.rect, record.radii) {
154 return None;
155 }
156 Some((
157 Point::new(
158 record.rect.x + record.rect.width * 0.5,
159 record.rect.y + record.rect.height * 0.5,
160 ),
161 record.rect.width,
162 ))
163}
164
165pub fn is_circle(rect: Rect, radii: CornerRadii) -> bool {
167 let half = rect.width * 0.5;
168 close_rel(rect.width, rect.height)
169 && close_rel(radii.top_left, half)
170 && close_rel(radii.top_right, half)
171 && close_rel(radii.bottom_right, half)
172 && close_rel(radii.bottom_left, half)
173}
174
175fn stroke_width(record_stroke: Option<crate::Stroke>) -> Option<f32> {
176 record_stroke.map(|stroke| stroke.width)
177}
178
179pub fn arcs_anchor_compatible(current: &SolidArcRecord, anchor: &SolidArcRecord) -> bool {
184 close_rel(current.sweep_angle, anchor.sweep_angle)
185 && current.stroke.is_some() == anchor.stroke.is_some()
186}
187
188pub fn arc_anchor_transform(
191 current: &SolidArcRecord,
192 retained: &SolidArcRecord,
193) -> Option<RecordTransform> {
194 if retained.radius <= f32::EPSILON {
195 return None;
196 }
197 Some(RecordTransform {
198 scale: current.radius / retained.radius,
199 angle: current.start_angle - retained.start_angle,
200 })
201}
202
203pub fn circle_anchor_transform_pinned(
206 current: (Point, f32),
207 retained: (Point, f32),
208 center: Point,
209) -> Option<(RecordTransform, bool)> {
210 let (c_now, d_now) = current;
211 let (c_then, d_then) = retained;
212 if d_then <= f32::EPSILON {
213 return None;
214 }
215 let scale = d_now / d_then;
216 let dx_then = c_then.x - center.x;
217 let dy_then = c_then.y - center.y;
218 let pinned = dx_then * dx_then + dy_then * dy_then > 1.0;
219 let angle = if pinned {
220 let dx_now = c_now.x - center.x;
221 let dy_now = c_now.y - center.y;
222 dy_now.atan2(dx_now) - dy_then.atan2(dx_then)
223 } else {
224 0.0
225 };
226 Some((RecordTransform { scale, angle }, pinned))
227}
228
229pub fn match_arc(
233 current: &SolidArcRecord,
234 retained: &SolidArcRecord,
235 center: Point,
236 t: RecordTransform,
237) -> RecordMatch {
238 let geometry_ok = close_point(current.center, retained.center)
239 && close_point(current.center, center)
240 && close_rel(current.radius, retained.radius * t.scale)
241 && close_rel(current.inner_radius, retained.inner_radius * t.scale)
242 && close_angle(current.start_angle, retained.start_angle + t.angle)
243 && close_rel(current.sweep_angle, retained.sweep_angle)
244 && match (stroke_width(current.stroke), stroke_width(retained.stroke)) {
245 (None, None) => true,
246 (Some(now), Some(then)) => close_rel(now, then * t.scale),
247 _ => false,
248 };
249 if !geometry_ok {
250 return RecordMatch::Mismatch;
251 }
252 if current.color == retained.color {
253 RecordMatch::Exact
254 } else {
255 RecordMatch::Recolor
256 }
257}
258
259pub fn match_round_rect(
263 current: &SolidRoundRectRecord,
264 retained: &SolidRoundRectRecord,
265 center: Point,
266 t: RecordTransform,
267) -> RecordMatch {
268 let (Some((c_now, d_now)), Some((c_then, d_then))) =
269 (circle_view(current), circle_view(retained))
270 else {
271 return RecordMatch::Mismatch;
272 };
273 let geometry_ok = close_point(c_now, t.apply(center, c_then))
274 && close_rel(d_now, d_then * t.scale)
275 && match (stroke_width(current.stroke), stroke_width(retained.stroke)) {
276 (None, None) => true,
277 (Some(now), Some(then)) => close_rel(now, then * t.scale),
278 _ => false,
279 };
280 if !geometry_ok {
281 return RecordMatch::Mismatch;
282 }
283 if current.color == retained.color {
284 RecordMatch::Exact
285 } else {
286 RecordMatch::Recolor
287 }
288}
289
290pub const MIN_SEGMENT_RECORDS: usize = 128;
293pub const MAX_SEGMENT_RECORDS: usize = 2048;
296pub const MIN_REPLAY_COMMAND_RECORDS: usize = 512;
298const RESYNC_SPAN: usize = 48;
301const MAX_RESYNC_EVENTS: usize = 512;
302const RESYNC_WINDOW: usize = 1024;
305const ANCHOR_PROBE_RECORDS: usize = 4;
308const MAX_COMMIT_ATTEMPTS: usize = 4;
314const MIN_COVERAGE_FRACTION: f32 = 0.5;
317const RECAPTURE_EROSION: f32 = 0.05;
321const RECAPTURE_COOLDOWN_FRAMES: u32 = 180;
325
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
331enum ReplayView {
332 Arc(usize),
333 RoundRect(usize),
334}
335
336fn view_at_slices(
342 tape: &[TapeRef],
343 round_rects: &[SolidRoundRectRecord],
344 i: usize,
345) -> Option<ReplayView> {
346 let entry = tape[i];
347 match entry.kind() {
348 RecordKind::SolidArc => Some(ReplayView::Arc(entry.index())),
349 RecordKind::SolidRoundRect => circle_view(&round_rects[entry.index()])
352 .is_some()
353 .then_some(ReplayView::RoundRect(entry.index())),
354 RecordKind::SolidRect | RecordKind::Other => None,
355 }
356}
357
358fn view_at(recording: &CommandRecording, i: usize) -> Option<ReplayView> {
360 view_at_slices(&recording.tape, &recording.round_rects, i)
361}
362
363fn detect_center(recording: &CommandRecording) -> Option<Point> {
365 recording.arcs.first().map(|arc| arc.center)
366}
367
368fn views_compatible(
371 current: &CommandRecording,
372 current_view: Option<ReplayView>,
373 retained: &CommandRecording,
374 retained_view: Option<ReplayView>,
375) -> bool {
376 match (current_view, retained_view) {
377 (Some(ReplayView::Arc(i)), Some(ReplayView::Arc(j))) => {
378 arcs_anchor_compatible(¤t.arcs[i], &retained.arcs[j])
379 }
380 (Some(ReplayView::RoundRect(i)), Some(ReplayView::RoundRect(j))) => {
381 let now = current.round_rects[i].stroke.is_some();
382 let then = retained.round_rects[j].stroke.is_some();
383 now == then
384 }
385 (None, None) => true,
386 _ => false,
387 }
388}
389
390fn align_recordings(current: &CommandRecording, retained: &CommandRecording) -> Vec<Option<usize>> {
396 let pair = |i: usize, j: usize| -> bool {
397 views_compatible(current, view_at(current, i), retained, view_at(retained, j))
398 };
399 let current_len = current.tape.len();
400 let retained_len = retained.tape.len();
401 let mut aligned = vec![None; current_len];
402 let (mut i, mut j) = (0usize, 0usize);
403 let mut events = 0usize;
404 while i < current_len && j < retained_len {
405 if pair(i, j) {
406 aligned[i] = Some(j);
407 i += 1;
408 j += 1;
409 continue;
410 }
411 events += 1;
412 if events > MAX_RESYNC_EVENTS {
413 return vec![None; current_len];
416 }
417 let mut resynced = false;
418 'search: for total in 1..=RESYNC_SPAN {
419 for di in 0..=total {
420 let dj = total - di;
421 if i + di < current_len && j + dj < retained_len && pair(i + di, j + dj) {
422 i += di;
423 j += dj;
424 resynced = true;
425 break 'search;
426 }
427 }
428 }
429 if !resynced {
430 i += 1;
431 j += 1;
432 }
433 }
434 aligned
435}
436
437fn pair_transform(
439 current: &CommandRecording,
440 current_view: ReplayView,
441 retained: &CommandRecording,
442 retained_view: ReplayView,
443 center: Point,
444) -> Option<(RecordTransform, bool)> {
445 match (current_view, retained_view) {
446 (ReplayView::Arc(i), ReplayView::Arc(j)) => {
447 arc_anchor_transform(¤t.arcs[i], &retained.arcs[j]).map(|t| (t, true))
448 }
449 (ReplayView::RoundRect(i), ReplayView::RoundRect(j)) => {
450 let now = circle_view(¤t.round_rects[i])?;
451 let then = circle_view(&retained.round_rects[j])?;
452 circle_anchor_transform_pinned(now, then, center)
453 }
454 _ => None,
455 }
456}
457
458fn match_pair(
460 current: &CommandRecording,
461 current_view: ReplayView,
462 retained: &CommandRecording,
463 retained_view: ReplayView,
464 center: Point,
465 t: RecordTransform,
466) -> RecordMatch {
467 match (current_view, retained_view) {
468 (ReplayView::Arc(i), ReplayView::Arc(j)) => {
469 match_arc(¤t.arcs[i], &retained.arcs[j], center, t)
470 }
471 (ReplayView::RoundRect(i), ReplayView::RoundRect(j)) => {
472 match_round_rect(¤t.round_rects[i], &retained.round_rects[j], center, t)
473 }
474 _ => RecordMatch::Mismatch,
475 }
476}
477
478fn range_bounds(recording: &CommandRecording, range: (usize, usize)) -> Rect {
481 let mut min_x = f32::INFINITY;
482 let mut min_y = f32::INFINITY;
483 let mut max_x = f32::NEG_INFINITY;
484 let mut max_y = f32::NEG_INFINITY;
485 for view in (range.0..range.1).filter_map(|i| view_at(recording, i)) {
486 let (center, reach) = match view {
487 ReplayView::Arc(i) => {
488 let arc = &recording.arcs[i];
489 (
490 arc.center,
491 arc.radius + arc.stroke.map(|stroke| stroke.width).unwrap_or(0.0),
492 )
493 }
494 ReplayView::RoundRect(i) => {
495 let record = &recording.round_rects[i];
496 let Some((center, diameter)) = circle_view(record) else {
497 continue;
498 };
499 (
500 center,
501 diameter * 0.5 + record.stroke.map(|stroke| stroke.width).unwrap_or(0.0),
502 )
503 }
504 };
505 let reach = reach + 2.0;
506 min_x = min_x.min(center.x - reach);
507 min_y = min_y.min(center.y - reach);
508 max_x = max_x.max(center.x + reach);
509 max_y = max_y.max(center.y + reach);
510 }
511 if min_x > max_x {
512 return Rect {
513 x: 0.0,
514 y: 0.0,
515 width: 0.0,
516 height: 0.0,
517 };
518 }
519 Rect {
520 x: min_x,
521 y: min_y,
522 width: max_x - min_x,
523 height: max_y - min_y,
524 }
525}
526
527#[derive(Clone, Debug, PartialEq)]
532pub struct CommandSegment {
533 pub slot: u32,
539 pub slot_offset: usize,
541 pub tape_start: usize,
542 pub tape_end: usize,
543 pub bounds: Rect,
545}
546
547#[derive(Clone, Debug, PartialEq)]
549pub enum ReplaySpan {
550 Retained {
553 slot: u32,
555 capture: bool,
560 slot_offset: usize,
562 tape_start: usize,
564 tape_end: usize,
565 transform: RecordTransform,
566 recolors: Vec<(u32, Color)>,
567 bounds: Rect,
569 },
570 Dynamic { tape_start: usize, tape_end: usize },
572}
573
574#[derive(Debug, PartialEq)]
576pub enum ReplayOutcome {
577 AllDynamic,
579 Spans(Vec<ReplaySpan>),
582}
583
584pub trait VerifyExecutor: Sync {
590 fn for_each(&self, jobs: usize, run: &(dyn Fn(usize) + Sync));
591}
592
593#[derive(Clone, Debug)]
597pub struct CommandReplayFrame {
598 pub center: Point,
600 pub spans: Vec<FrameSpan>,
602 pub fallback: Option<std::rc::Rc<crate::geometry::CommandRecording>>,
613}
614
615impl PartialEq for CommandReplayFrame {
616 fn eq(&self, other: &Self) -> bool {
617 self.center == other.center
618 && self.spans == other.spans
619 && match (&self.fallback, &other.fallback) {
620 (None, None) => true,
621 (Some(a), Some(b)) => std::rc::Rc::ptr_eq(a, b),
622 _ => false,
623 }
624 }
625}
626
627#[derive(Clone, Debug, PartialEq)]
629pub enum FrameSpan {
630 Retained {
631 slot: u32,
634 capture: bool,
638 slot_offset: u32,
640 range: (u32, u32),
645 tape_range: (u32, u32),
648 transform: RecordTransform,
649 recolors: Vec<(u32, Color)>,
651 bounds: Rect,
653 },
654 Dynamic {
655 range: (u32, u32),
657 },
658}
659
660#[derive(Clone, Copy, Debug, PartialEq, Eq)]
661enum CommandReplayPhase {
662 Idle,
663 Snapshotted,
664 Captured,
665}
666
667#[derive(Debug, Default)]
671struct SpanResultSlot {
672 matched: usize,
673 recolors: Vec<(u32, Color)>,
674}
675
676#[derive(Debug)]
681pub struct CommandReplayState {
682 phase: CommandReplayPhase,
683 center: Point,
684 snapshot: CommandRecording,
685 segments: Vec<CommandSegment>,
686 next_slot_id: u32,
687 lifetime_deaths: u64,
688 lifetime_splits: u64,
689 capture_coverage: f32,
694 frames_since_capture: u32,
695 optimistic_commits: u64,
698 verify_results: Vec<std::sync::Mutex<SpanResultSlot>>,
708 recolor_scratch: Vec<(u32, Color)>,
713 best_recolor_scratch: Vec<(u32, Color)>,
716 verify_pending: std::collections::VecDeque<CommandSegment>,
719 verify_survivors: Vec<CommandSegment>,
720}
721
722impl Default for CommandReplayState {
723 fn default() -> Self {
724 Self {
725 phase: CommandReplayPhase::Idle,
726 center: Point::new(0.0, 0.0),
727 snapshot: CommandRecording::default(),
728 segments: Vec::new(),
729 next_slot_id: 0,
730 lifetime_deaths: 0,
731 lifetime_splits: 0,
732 capture_coverage: 0.0,
733 frames_since_capture: 0,
734 optimistic_commits: 0,
735 verify_results: Vec::new(),
736 recolor_scratch: Vec::new(),
737 best_recolor_scratch: Vec::new(),
738 verify_pending: std::collections::VecDeque::new(),
739 verify_survivors: Vec::new(),
740 }
741 }
742}
743
744impl CommandReplayState {
745 pub fn segments(&self) -> &[CommandSegment] {
746 &self.segments
747 }
748
749 pub fn stats(&self) -> (u64, u64) {
752 (self.lifetime_deaths, self.lifetime_splits)
753 }
754
755 pub fn optimistic_commits(&self) -> u64 {
757 self.optimistic_commits
758 }
759
760 pub fn center(&self) -> Point {
762 self.center
763 }
764
765 pub fn advance(&mut self, current: &CommandRecording) -> ReplayOutcome {
773 self.advance_pooled(current, None)
774 }
775
776 pub fn advance_pooled(
782 &mut self,
783 current: &CommandRecording,
784 pool: Option<&dyn VerifyExecutor>,
785 ) -> ReplayOutcome {
786 if current.tape.len() < MIN_REPLAY_COMMAND_RECORDS {
787 self.retire();
788 return ReplayOutcome::AllDynamic;
789 }
790 let Some(center) = detect_center(current) else {
791 self.retire();
792 return ReplayOutcome::AllDynamic;
793 };
794 match self.phase {
795 CommandReplayPhase::Idle => {
796 self.take_snapshot(current, center);
797 ReplayOutcome::AllDynamic
798 }
799 CommandReplayPhase::Snapshotted => self.partition(current, center),
800 CommandReplayPhase::Captured => self.verify(current, pool),
801 }
802 }
803
804 fn retire(&mut self) {
805 self.phase = CommandReplayPhase::Idle;
806 self.snapshot = CommandRecording::default();
807 self.segments.clear();
808 }
809
810 fn take_snapshot(&mut self, current: &CommandRecording, center: Point) {
811 self.snapshot = current.clone();
812 self.center = center;
813 self.segments.clear();
814 self.phase = CommandReplayPhase::Snapshotted;
815 }
816
817 fn partition(&mut self, current: &CommandRecording, center: Point) -> ReplayOutcome {
825 let aligned = align_recordings(current, &self.snapshot);
826 let mut chains: Vec<(usize, usize)> = Vec::new();
827 let mut i = 0;
828 while i < current.tape.len() {
829 let (Some(view), Some(snapshot_view)) = (
830 view_at(current, i),
831 aligned[i].and_then(|j| view_at(&self.snapshot, j)),
832 ) else {
833 i += 1;
834 continue;
835 };
836 let Some((t, true)) =
838 pair_transform(current, view, &self.snapshot, snapshot_view, self.center)
839 else {
840 i += 1;
841 continue;
842 };
843 if match_pair(current, view, &self.snapshot, snapshot_view, self.center, t)
844 == RecordMatch::Mismatch
845 {
846 i += 1;
847 continue;
848 }
849 let start = i;
850 let mut end = i + 1;
851 while end < current.tape.len() {
852 let (Some(view), Some(snapshot_view)) = (
853 view_at(current, end),
854 aligned[end].and_then(|j| view_at(&self.snapshot, j)),
855 ) else {
856 break;
857 };
858 let Some((entry_t, pinned)) =
859 pair_transform(current, view, &self.snapshot, snapshot_view, self.center)
860 else {
861 break;
862 };
863 if !transforms_group(entry_t, pinned, t) {
864 break;
865 }
866 if match_pair(current, view, &self.snapshot, snapshot_view, self.center, t)
867 == RecordMatch::Mismatch
868 {
869 break;
870 }
871 end += 1;
872 }
873 if end - start >= MIN_SEGMENT_RECORDS {
874 let mut piece_start = start;
875 while piece_start < end {
876 let piece_end = (piece_start + MAX_SEGMENT_RECORDS).min(end);
877 if piece_end - piece_start >= MIN_SEGMENT_RECORDS {
878 chains.push((piece_start, piece_end));
879 }
880 piece_start = piece_end;
881 }
882 }
883 i = end.max(i + 1);
884 }
885
886 if chains.is_empty() {
887 self.take_snapshot(current, center);
888 return ReplayOutcome::AllDynamic;
889 }
890 self.take_snapshot(current, center);
893 self.segments = chains
894 .into_iter()
895 .map(|range| {
896 let slot = self.next_slot_id;
897 self.next_slot_id += 1;
898 CommandSegment {
899 slot,
900 slot_offset: 0,
901 tape_start: range.0,
902 tape_end: range.1,
903 bounds: range_bounds(&self.snapshot, range),
904 }
905 })
906 .collect();
907 let covered: usize = self
908 .segments
909 .iter()
910 .map(|segment| segment.tape_end - segment.tape_start)
911 .sum();
912 self.capture_coverage = covered as f32 / current.tape.len().max(1) as f32;
913 self.frames_since_capture = 0;
914 self.phase = CommandReplayPhase::Captured;
915
916 let mut spans: Vec<ReplaySpan> = Vec::with_capacity(self.segments.len() * 2 + 1);
917 let mut cursor = 0usize;
918 for segment in &self.segments {
919 if segment.tape_start > cursor {
920 spans.push(ReplaySpan::Dynamic {
921 tape_start: cursor,
922 tape_end: segment.tape_start,
923 });
924 }
925 spans.push(ReplaySpan::Retained {
926 slot: segment.slot,
927 capture: true,
928 slot_offset: 0,
929 tape_start: segment.tape_start,
930 tape_end: segment.tape_end,
931 transform: RecordTransform::IDENTITY,
932 recolors: Vec::new(),
933 bounds: segment.bounds,
934 });
935 cursor = segment.tape_end;
936 }
937 if cursor < current.tape.len() {
938 spans.push(ReplaySpan::Dynamic {
939 tape_start: cursor,
940 tape_end: current.tape.len(),
941 });
942 }
943 ReplayOutcome::Spans(spans)
944 }
945
946 fn verify(
957 &mut self,
958 current: &CommandRecording,
959 pool: Option<&dyn VerifyExecutor>,
960 ) -> ReplayOutcome {
961 if let Some(pool) = pool {
962 if self.segments.len() >= 2 {
963 if let Some((spans, retained_records)) = self.verify_optimistic(current, pool) {
964 self.optimistic_commits += 1;
965 return self.finish_verify(current, spans, retained_records);
966 }
967 }
968 }
969 let mut spans: Vec<ReplaySpan> = Vec::new();
970 let mut retained_records = 0usize;
971 let mut cursor = 0usize;
973 self.verify_pending.clear();
978 self.verify_pending.extend(self.segments.drain(..));
979 self.verify_survivors.clear();
980 while let Some(segment) = self.verify_pending.pop_front() {
981 let len = segment.tape_end - segment.tape_start;
982 let search_end = (cursor + RESYNC_WINDOW)
983 .min(current.tape.len().saturating_sub(len - 1))
984 .max(cursor);
985 let candidates = cursor..search_end;
994 let mut located: Option<(usize, RecordTransform)> = None;
995 let mut best_prefix: Option<(usize, RecordTransform)> = None;
1001 let mut best_prefix_len = 0usize;
1002 let mut attempts = 0usize;
1003 'search: for start in candidates {
1004 let Some(t) = probe_anchor(
1005 current,
1006 &self.snapshot,
1007 self.center,
1008 segment.tape_start,
1009 len,
1010 start,
1011 ) else {
1012 continue;
1013 };
1014 let matched = match_span(
1018 TypedRecords::from(current),
1019 TypedRecords::from(&self.snapshot),
1020 self.center,
1021 start,
1022 segment.tape_start,
1023 len,
1024 t,
1025 &mut self.recolor_scratch,
1026 );
1027 if matched < len {
1028 if matched > best_prefix_len {
1029 best_prefix_len = matched;
1030 best_prefix = Some((start, t));
1031 std::mem::swap(&mut self.recolor_scratch, &mut self.best_recolor_scratch);
1034 }
1035 if matched >= MIN_SEGMENT_RECORDS {
1044 attempts += 1;
1045 if attempts >= MAX_COMMIT_ATTEMPTS {
1046 break 'search;
1047 }
1048 }
1049 continue;
1050 }
1051 located = Some((start, t));
1052 break;
1053 }
1054 let (span_start, t, recolors, span_len) = match located {
1065 Some((start, t)) => (start, t, std::mem::take(&mut self.recolor_scratch), len),
1066 None => {
1067 let split = best_prefix_len >= MIN_SEGMENT_RECORDS;
1068 let Some((start, t)) = best_prefix.filter(|_| split) else {
1069 self.lifetime_deaths += 1;
1070 continue;
1071 };
1072 let suffix_start = segment.tape_start + best_prefix_len + 1;
1073 if segment.tape_end > suffix_start
1074 && segment.tape_end - suffix_start >= MIN_SEGMENT_RECORDS
1075 {
1076 self.verify_pending.push_front(CommandSegment {
1079 slot: segment.slot,
1080 slot_offset: segment.slot_offset + (suffix_start - segment.tape_start),
1081 tape_start: suffix_start,
1082 tape_end: segment.tape_end,
1083 bounds: range_bounds(&self.snapshot, (suffix_start, segment.tape_end)),
1084 });
1085 }
1086 self.lifetime_splits += 1;
1087 (
1088 start,
1089 t,
1090 std::mem::take(&mut self.best_recolor_scratch),
1091 best_prefix_len,
1092 )
1093 }
1094 };
1095 let survivor = if span_len == len {
1096 segment
1097 } else {
1098 CommandSegment {
1101 slot: segment.slot,
1102 slot_offset: segment.slot_offset,
1103 tape_start: segment.tape_start,
1104 tape_end: segment.tape_start + span_len,
1105 bounds: range_bounds(
1106 &self.snapshot,
1107 (segment.tape_start, segment.tape_start + span_len),
1108 ),
1109 }
1110 };
1111 if span_start > cursor {
1112 spans.push(ReplaySpan::Dynamic {
1113 tape_start: cursor,
1114 tape_end: span_start,
1115 });
1116 }
1117 retained_records += span_len;
1118 spans.push(ReplaySpan::Retained {
1119 slot: survivor.slot,
1120 capture: false,
1121 slot_offset: survivor.slot_offset,
1122 tape_start: span_start,
1123 tape_end: span_start + span_len,
1124 transform: t,
1125 recolors,
1126 bounds: t.apply_to_bounds(self.center, survivor.bounds),
1127 });
1128 cursor = span_start + span_len;
1129 self.verify_survivors.push(survivor);
1130 }
1131 if cursor < current.tape.len() {
1132 spans.push(ReplaySpan::Dynamic {
1133 tape_start: cursor,
1134 tape_end: current.tape.len(),
1135 });
1136 }
1137
1138 std::mem::swap(&mut self.segments, &mut self.verify_survivors);
1141 self.finish_verify(current, spans, retained_records)
1142 }
1143
1144 fn finish_verify(
1147 &mut self,
1148 current: &CommandRecording,
1149 spans: Vec<ReplaySpan>,
1150 retained_records: usize,
1151 ) -> ReplayOutcome {
1152 self.frames_since_capture += 1;
1153 let retained_total: usize = self
1154 .segments
1155 .iter()
1156 .map(|segment| segment.tape_end - segment.tape_start)
1157 .sum();
1158 let coverage = retained_total as f32 / current.tape.len().max(1) as f32;
1159 let collapsed = retained_records == 0 || coverage < MIN_COVERAGE_FRACTION;
1160 let eroded = coverage + RECAPTURE_EROSION < self.capture_coverage
1161 && self.frames_since_capture >= RECAPTURE_COOLDOWN_FRAMES;
1162 if collapsed || eroded {
1163 let center = self.center;
1166 self.take_snapshot(current, center);
1167 if retained_records == 0 {
1168 return ReplayOutcome::AllDynamic;
1169 }
1170 }
1171 ReplayOutcome::Spans(spans)
1172 }
1173
1174 fn verify_optimistic(
1185 &mut self,
1186 current: &CommandRecording,
1187 pool: &dyn VerifyExecutor,
1188 ) -> Option<(Vec<ReplaySpan>, usize)> {
1189 struct SpanJob {
1190 start: usize,
1191 seg_start: usize,
1192 len: usize,
1193 t: RecordTransform,
1194 }
1195 let mut jobs: Vec<SpanJob> = Vec::with_capacity(self.segments.len());
1196 let mut cursor = 0usize;
1197 for segment in &self.segments {
1198 let len = segment.tape_end - segment.tape_start;
1199 let search_end = (cursor + RESYNC_WINDOW)
1200 .min(current.tape.len().saturating_sub(len - 1))
1201 .max(cursor);
1202 let mut found = None;
1203 for start in cursor..search_end {
1204 if let Some(t) = probe_anchor(
1205 current,
1206 &self.snapshot,
1207 self.center,
1208 segment.tape_start,
1209 len,
1210 start,
1211 ) {
1212 found = Some((start, t));
1213 break;
1214 }
1215 }
1216 let (start, t) = found?;
1217 jobs.push(SpanJob {
1218 start,
1219 seg_start: segment.tape_start,
1220 len,
1221 t,
1222 });
1223 cursor = start + len;
1224 }
1225 if self.verify_results.len() < jobs.len() {
1229 self.verify_results
1230 .resize_with(jobs.len(), Default::default);
1231 }
1232 {
1233 let current = TypedRecords::from(current);
1234 let snapshot = TypedRecords::from(&self.snapshot);
1235 let center = self.center;
1236 let jobs = &jobs;
1237 let results = &self.verify_results;
1238 pool.for_each(jobs.len(), &|i| {
1239 let job = &jobs[i];
1240 let mut guard = results[i].lock().expect("verify span job lock");
1241 let slot = &mut *guard;
1242 slot.matched = match_span(
1243 current,
1244 snapshot,
1245 center,
1246 job.start,
1247 job.seg_start,
1248 job.len,
1249 job.t,
1250 &mut slot.recolors,
1251 );
1252 });
1253 }
1254 for (job, result) in jobs.iter().zip(&self.verify_results) {
1257 if result.lock().expect("verify span job lock").matched < job.len {
1258 return None;
1259 }
1260 }
1261 let mut spans: Vec<ReplaySpan> = Vec::with_capacity(jobs.len() * 2 + 1);
1262 let mut retained_records = 0usize;
1263 let mut cursor = 0usize;
1264 for (segment, (job, result)) in self
1265 .segments
1266 .iter()
1267 .zip(jobs.iter().zip(&self.verify_results))
1268 {
1269 let recolors =
1273 std::mem::take(&mut result.lock().expect("verify span job lock").recolors);
1274 if job.start > cursor {
1275 spans.push(ReplaySpan::Dynamic {
1276 tape_start: cursor,
1277 tape_end: job.start,
1278 });
1279 }
1280 retained_records += job.len;
1281 spans.push(ReplaySpan::Retained {
1282 slot: segment.slot,
1283 capture: false,
1284 slot_offset: segment.slot_offset,
1285 tape_start: job.start,
1286 tape_end: job.start + job.len,
1287 transform: job.t,
1288 recolors,
1289 bounds: job.t.apply_to_bounds(self.center, segment.bounds),
1290 });
1291 cursor = job.start + job.len;
1292 }
1293 if cursor < current.tape.len() {
1294 spans.push(ReplaySpan::Dynamic {
1295 tape_start: cursor,
1296 tape_end: current.tape.len(),
1297 });
1298 }
1299 Some((spans, retained_records))
1300 }
1301}
1302
1303fn probe_anchor(
1308 current: &CommandRecording,
1309 snapshot: &CommandRecording,
1310 center: Point,
1311 seg_start: usize,
1312 len: usize,
1313 start: usize,
1314) -> Option<RecordTransform> {
1315 let (Some(view), Some(snapshot_view)) = (view_at(current, start), view_at(snapshot, seg_start))
1316 else {
1317 return None;
1318 };
1319 if !views_compatible(current, Some(view), snapshot, Some(snapshot_view)) {
1320 return None;
1321 }
1322 let (t, _) = pair_transform(current, view, snapshot, snapshot_view, center)?;
1323 for probe in 0..ANCHOR_PROBE_RECORDS.min(len) {
1324 let (Some(view), Some(snapshot_view)) = (
1325 view_at(current, start + probe),
1326 view_at(snapshot, seg_start + probe),
1327 ) else {
1328 return None;
1329 };
1330 if match_pair(current, view, snapshot, snapshot_view, center, t) == RecordMatch::Mismatch {
1331 return None;
1332 }
1333 }
1334 Some(t)
1335}
1336
1337#[derive(Clone, Copy)]
1342struct TypedRecords<'a> {
1343 tape: &'a [TapeRef],
1344 arcs: &'a [SolidArcRecord],
1345 round_rects: &'a [SolidRoundRectRecord],
1346}
1347
1348impl<'a> From<&'a CommandRecording> for TypedRecords<'a> {
1349 fn from(recording: &'a CommandRecording) -> Self {
1350 Self {
1351 tape: &recording.tape,
1352 arcs: &recording.arcs,
1353 round_rects: &recording.round_rects,
1354 }
1355 }
1356}
1357
1358impl TypedRecords<'_> {
1359 fn view_at(&self, i: usize) -> Option<ReplayView> {
1363 view_at_slices(self.tape, self.round_rects, i)
1364 }
1365}
1366
1367#[allow(clippy::too_many_arguments)]
1375fn match_span(
1376 current: TypedRecords<'_>,
1377 snapshot: TypedRecords<'_>,
1378 center: Point,
1379 start: usize,
1380 seg_start: usize,
1381 len: usize,
1382 t: RecordTransform,
1383 recolors: &mut Vec<(u32, Color)>,
1384) -> usize {
1385 recolors.clear();
1386 for offset in 0..len {
1387 let entry_match = match (
1388 current.view_at(start + offset),
1389 snapshot.view_at(seg_start + offset),
1390 ) {
1391 (Some(ReplayView::Arc(i)), Some(ReplayView::Arc(j))) => {
1392 match_arc(¤t.arcs[i], &snapshot.arcs[j], center, t)
1393 }
1394 (Some(ReplayView::RoundRect(i)), Some(ReplayView::RoundRect(j))) => {
1395 match_round_rect(¤t.round_rects[i], &snapshot.round_rects[j], center, t)
1396 }
1397 _ => RecordMatch::Mismatch,
1398 };
1399 match entry_match {
1400 RecordMatch::Exact => {}
1401 RecordMatch::Recolor => {
1402 let color = match current.view_at(start + offset) {
1403 Some(ReplayView::Arc(a)) => current.arcs[a].color,
1404 Some(ReplayView::RoundRect(r)) => current.round_rects[r].color,
1405 None => unreachable!("recolor requires a view"),
1406 };
1407 recolors.push((offset as u32, color));
1408 }
1409 RecordMatch::Mismatch => return offset,
1410 }
1411 }
1412 len
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418 use crate::{Color, Stroke};
1419
1420 const CENTER: Point = Point { x: 204.0, y: 204.0 };
1421
1422 fn arc(radius: f32, start: f32, color: Color) -> SolidArcRecord {
1423 SolidArcRecord {
1424 center: CENTER,
1425 radius,
1426 start_angle: start,
1427 sweep_angle: 0.4,
1428 inner_radius: radius * 0.8,
1429 color,
1430 stroke: None,
1431 }
1432 }
1433
1434 fn moved_arc(base: &SolidArcRecord, t: RecordTransform) -> SolidArcRecord {
1435 SolidArcRecord {
1436 center: base.center,
1437 radius: base.radius * t.scale,
1438 start_angle: base.start_angle + t.angle,
1439 sweep_angle: base.sweep_angle,
1440 inner_radius: base.inner_radius * t.scale,
1441 color: base.color,
1442 stroke: base.stroke.map(|stroke| Stroke {
1443 width: stroke.width * t.scale,
1444 ..stroke
1445 }),
1446 }
1447 }
1448
1449 fn circle(cx: f32, cy: f32, diameter: f32, color: Color) -> SolidRoundRectRecord {
1450 SolidRoundRectRecord {
1451 rect: Rect {
1452 x: cx - diameter * 0.5,
1453 y: cy - diameter * 0.5,
1454 width: diameter,
1455 height: diameter,
1456 },
1457 radii: CornerRadii::uniform(diameter * 0.5),
1458 color,
1459 stroke: None,
1460 }
1461 }
1462
1463 #[test]
1464 fn arc_anchor_recovers_the_baked_transform() {
1465 let t = RecordTransform {
1466 scale: 0.9994,
1467 angle: 0.0123,
1468 };
1469 let retained = arc(120.0, 1.0, Color::WHITE);
1470 let current = moved_arc(&retained, t);
1471 let derived = arc_anchor_transform(¤t, &retained).expect("derivable");
1472 assert!((derived.scale - t.scale).abs() < 1e-6);
1473 assert!((derived.angle - t.angle).abs() < 1e-6);
1474 assert_eq!(
1475 match_arc(¤t, &retained, CENTER, derived),
1476 RecordMatch::Exact
1477 );
1478 }
1479
1480 #[test]
1481 fn recolored_arc_matches_as_recolor() {
1482 let t = RecordTransform {
1483 scale: 1.0,
1484 angle: 0.05,
1485 };
1486 let retained = arc(80.0, 0.2, Color::WHITE);
1487 let mut current = moved_arc(&retained, t);
1488 current.color = Color::rgb(0.5, 0.1, 0.9);
1489 assert_eq!(
1490 match_arc(¤t, &retained, CENTER, t),
1491 RecordMatch::Recolor
1492 );
1493 }
1494
1495 #[test]
1496 fn changed_sweep_is_a_mismatch() {
1497 let t = RecordTransform::IDENTITY;
1498 let retained = arc(80.0, 0.2, Color::WHITE);
1499 let mut current = retained;
1500 current.sweep_angle += 0.1;
1501 assert_eq!(
1502 match_arc(¤t, &retained, CENTER, t),
1503 RecordMatch::Mismatch
1504 );
1505 }
1506
1507 #[test]
1508 fn stroked_arc_scales_its_width_with_the_segment() {
1509 let t = RecordTransform {
1510 scale: 0.98,
1511 angle: 0.0,
1512 };
1513 let mut retained = arc(60.0, 0.0, Color::WHITE);
1514 retained.stroke = Some(Stroke::new(5.0));
1515 let current = moved_arc(&retained, t);
1516 assert_eq!(
1517 match_arc(¤t, &retained, CENTER, t),
1518 RecordMatch::Exact
1519 );
1520
1521 let mut stale = current;
1524 stale.stroke = Some(Stroke::new(5.0));
1525 assert_eq!(
1526 match_arc(&stale, &retained, CENTER, t),
1527 RecordMatch::Mismatch
1528 );
1529 }
1530
1531 #[test]
1532 fn orbiting_circle_matches_under_rotation() {
1533 let t = RecordTransform {
1534 scale: 1.0,
1535 angle: 0.3,
1536 };
1537 let retained = circle(304.0, 204.0, 10.0, Color::WHITE);
1538 let (c_then, d_then) = circle_view(&retained).expect("circle");
1539 let c_now = t.apply(CENTER, c_then);
1540 let current = circle(c_now.x, c_now.y, d_then * t.scale, Color::WHITE);
1541 let (derived, pinned) = circle_anchor_transform_pinned(
1542 circle_view(¤t).unwrap(),
1543 (c_then, d_then),
1544 CENTER,
1545 )
1546 .expect("derivable");
1547 assert!(pinned, "an off-pivot circle pins rotation");
1548 assert!((derived.angle - t.angle).abs() < 1e-4);
1549 assert_eq!(
1550 match_round_rect(¤t, &retained, CENTER, derived),
1551 RecordMatch::Exact
1552 );
1553 }
1554
1555 #[test]
1556 fn non_circular_round_rect_never_matches() {
1557 let mut retained = circle(304.0, 204.0, 10.0, Color::WHITE);
1558 retained.rect.width = 14.0; assert_eq!(
1560 match_round_rect(&retained, &retained, CENTER, RecordTransform::IDENTITY),
1561 RecordMatch::Mismatch
1562 );
1563 }
1564
1565 #[test]
1566 fn grouping_is_tighter_than_verification() {
1567 let anchor = RecordTransform {
1568 scale: 1.0,
1569 angle: 0.010,
1570 };
1571 let same_ring = RecordTransform {
1572 scale: 1.0,
1573 angle: 0.0100001,
1574 };
1575 let next_ring = RecordTransform {
1576 scale: 1.0,
1577 angle: 0.011,
1578 };
1579 assert!(transforms_group(same_ring, true, anchor));
1580 assert!(
1581 !transforms_group(next_ring, true, anchor),
1582 "a 1e-3 rotation-step difference is another ring, not float noise"
1583 );
1584 let unpinned = RecordTransform {
1585 scale: 1.0,
1586 angle: 0.0,
1587 };
1588 assert!(transforms_group(unpinned, false, anchor));
1589 }
1590
1591 use crate::geometry::{DrawScopeDefault, Size};
1592 use crate::{Brush, DrawScope as _};
1593
1594 fn ring_frame(rings: usize, per_ring: usize, frame: usize, tail: usize) -> CommandRecording {
1598 let mut scope = DrawScopeDefault::new(Size::new(408.0, 408.0));
1599 let scale = 0.9994f32.powi(frame as i32);
1600 for ring in 0..rings {
1601 let step = 0.01 + ring as f32 * 0.005;
1602 let rotation = step * frame as f32;
1603 let radius = (60.0 + ring as f32 * 30.0) * scale;
1604 for slot in 0..per_ring {
1605 let start = slot as f32 * (std::f32::consts::TAU / per_ring as f32) + rotation;
1606 scope.draw_annular_sector(
1607 Brush::solid(Color::WHITE),
1608 CENTER,
1609 radius * 0.8,
1610 radius,
1611 start,
1612 0.02,
1613 );
1614 }
1615 }
1616 for i in 0..tail {
1617 let x = 40.0 + (frame * 17 + i * 31) as f32 % 300.0;
1619 scope.draw_circle(Brush::solid(Color::RED), Point::new(x, 50.0), 3.0);
1620 }
1621 scope.recorded().clone()
1622 }
1623
1624 #[test]
1625 fn ring_scene_reaches_retention_by_the_third_frame() {
1626 let mut state = CommandReplayState::default();
1627 assert!(matches!(
1628 state.advance(&ring_frame(3, 300, 0, 10)),
1629 ReplayOutcome::AllDynamic
1630 ));
1631 let ReplayOutcome::Spans(capture_spans) = state.advance(&ring_frame(3, 300, 1, 10)) else {
1634 panic!("partition frame should emit the capture");
1635 };
1636 assert!(capture_spans.iter().all(|span| match span {
1637 ReplaySpan::Retained {
1638 capture, transform, ..
1639 } => *capture && *transform == RecordTransform::IDENTITY,
1640 ReplaySpan::Dynamic { .. } => true,
1641 }));
1642 assert!(!state.segments().is_empty(), "partition found the rings");
1643
1644 let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(3, 300, 2, 10)) else {
1645 panic!("third frame should retain");
1646 };
1647 let retained: usize = spans
1648 .iter()
1649 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1650 .count();
1651 assert!(retained >= 3, "each ring retains, got {spans:?}");
1652 assert!(spans
1654 .iter()
1655 .any(|span| matches!(span, ReplaySpan::Dynamic { .. })));
1656 let transforms: Vec<RecordTransform> = spans
1658 .iter()
1659 .filter_map(|span| match span {
1660 ReplaySpan::Retained { transform, .. } => Some(*transform),
1661 _ => None,
1662 })
1663 .collect();
1664 assert!(transforms.windows(2).any(|w| w[0].angle != w[1].angle));
1665 }
1666
1667 #[test]
1668 fn entity_churn_between_frames_still_retains_rings() {
1669 let mut state = CommandReplayState::default();
1670 state.advance(&ring_frame(2, 400, 0, 8));
1671 state.advance(&ring_frame(2, 400, 1, 13)); let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(2, 400, 2, 5)) else {
1673 panic!("churned tail must not break ring retention");
1674 };
1675 let retained_records: usize = spans
1676 .iter()
1677 .filter_map(|span| match span {
1678 ReplaySpan::Retained { .. } => Some(1),
1679 _ => None,
1680 })
1681 .sum();
1682 assert!(retained_records >= 2);
1683 }
1684
1685 #[test]
1686 fn recolors_are_patches_not_mismatches() {
1687 let recolored_frame = |frame: usize| {
1688 let mut recording = ring_frame(1, 600, frame, 0);
1689 for i in (0..recording.arcs.len()).step_by(15) {
1691 recording.arcs[i].color = if frame.is_multiple_of(2) {
1692 Color::rgb(1.0, 0.5, 0.1)
1693 } else {
1694 Color::rgb(0.1, 0.5, 1.0)
1695 };
1696 }
1697 recording
1698 };
1699 let mut state = CommandReplayState::default();
1700 state.advance(&recolored_frame(0));
1701 state.advance(&recolored_frame(1));
1702 let ReplayOutcome::Spans(spans) = state.advance(&recolored_frame(2)) else {
1703 panic!("twinkles must not break retention");
1704 };
1705 let recolor_count: usize = spans
1706 .iter()
1707 .filter_map(|span| match span {
1708 ReplaySpan::Retained { recolors, .. } => Some(recolors.len()),
1709 _ => None,
1710 })
1711 .sum();
1712 assert!(recolor_count >= 30, "twinkles surface as patches");
1713 }
1714
1715 #[test]
1716 fn geometry_change_kills_only_its_segment() {
1717 let mut state = CommandReplayState::default();
1718 state.advance(&ring_frame(3, 300, 0, 0));
1719 state.advance(&ring_frame(3, 300, 1, 0));
1720 let mut broken = ring_frame(3, 300, 2, 0);
1721 broken.arcs[450].sweep_angle *= 3.0;
1723 let ReplayOutcome::Spans(spans) = state.advance(&broken) else {
1724 panic!("one changed entry must not drop the whole command");
1725 };
1726 let retained: usize = spans
1727 .iter()
1728 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1729 .count();
1730 assert!(
1731 retained >= 2,
1732 "the untouched rings keep retaining, got {spans:?}"
1733 );
1734 }
1735
1736 #[test]
1737 fn mid_segment_change_splits_and_retains_both_halves() {
1738 let mut state = CommandReplayState::default();
1739 state.advance(&ring_frame(1, 900, 0, 0));
1740 state.advance(&ring_frame(1, 900, 1, 0));
1741 assert_eq!(state.segments().len(), 1, "one ring is one segment");
1742 let mut broken = ring_frame(1, 900, 2, 0);
1743 broken.arcs[450].sweep_angle *= 3.0;
1744 let ReplayOutcome::Spans(spans) = state.advance(&broken) else {
1745 panic!("a single changed record must not drop retention");
1746 };
1747 let dynamic: usize = spans
1748 .iter()
1749 .filter_map(|span| match span {
1750 ReplaySpan::Dynamic {
1751 tape_start,
1752 tape_end,
1753 } => Some(tape_end - tape_start),
1754 _ => None,
1755 })
1756 .sum();
1757 let retained: Vec<(u32, usize, bool)> = spans
1758 .iter()
1759 .filter_map(|span| match span {
1760 ReplaySpan::Retained {
1761 slot,
1762 slot_offset,
1763 capture,
1764 ..
1765 } => Some((*slot, *slot_offset, *capture)),
1766 _ => None,
1767 })
1768 .collect();
1769 assert_eq!(
1770 retained.len(),
1771 2,
1772 "prefix and suffix both retain: {spans:?}"
1773 );
1774 assert_eq!(retained[0].0, retained[1].0);
1778 assert_eq!(retained[0].1, 0);
1779 assert_eq!(retained[1].1, 451);
1780 assert!(retained.iter().all(|(_, _, capture)| !capture));
1781 assert_eq!(dynamic, 1, "only the changed record goes dynamic");
1782 assert_eq!(state.stats(), (0, 1), "one split, no deaths");
1783
1784 let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(1, 900, 3, 0)) else {
1787 panic!("split pieces must keep retaining");
1788 };
1789 let retained = spans
1790 .iter()
1791 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1792 .count();
1793 assert_eq!(retained, 2, "both pieces relocate next frame: {spans:?}");
1794 }
1795
1796 #[test]
1797 fn erosion_recaptures_dead_ranges_after_the_cooldown() {
1798 let mut state = CommandReplayState::default();
1799 state.advance(&ring_frame(3, 300, 0, 0));
1800 state.advance(&ring_frame(3, 300, 1, 0));
1801 let mutated = |frame: usize| {
1804 let mut recording = ring_frame(3, 300, frame, 0);
1805 for arc in &mut recording.arcs[300..600] {
1806 arc.sweep_angle *= 3.0;
1807 }
1808 recording
1809 };
1810 let dynamic_records = |outcome: &ReplayOutcome| -> usize {
1811 match outcome {
1812 ReplayOutcome::AllDynamic => usize::MAX,
1813 ReplayOutcome::Spans(spans) => spans
1814 .iter()
1815 .filter_map(|span| match span {
1816 ReplaySpan::Dynamic {
1817 tape_start,
1818 tape_end,
1819 } => Some(tape_end - tape_start),
1820 _ => None,
1821 })
1822 .sum(),
1823 }
1824 };
1825 let after_death = state.advance(&mutated(2));
1826 let lost = dynamic_records(&after_death);
1827 assert!(
1828 (250..=400).contains(&lost),
1829 "the changed ring goes dynamic, got {lost}"
1830 );
1831 for frame in 3..(3 + RECAPTURE_COOLDOWN_FRAMES as usize + 4) {
1832 state.advance(&mutated(frame));
1833 }
1834 let recovered = state.advance(&mutated(200));
1835 let residue = dynamic_records(&recovered);
1836 assert!(
1837 residue < 50,
1838 "the recapture watches the ring's new shape, got {residue} dynamic"
1839 );
1840 }
1841
1842 #[test]
1843 fn small_commands_are_not_watched() {
1844 let mut state = CommandReplayState::default();
1845 for frame in 0..4 {
1846 assert!(matches!(
1847 state.advance(&ring_frame(1, 40, frame, 0)),
1848 ReplayOutcome::AllDynamic
1849 ));
1850 }
1851 assert!(state.segments().is_empty());
1852 }
1853
1854 struct ThreadedExec {
1858 lanes: usize,
1859 }
1860
1861 impl VerifyExecutor for ThreadedExec {
1862 fn for_each(&self, jobs: usize, run: &(dyn Fn(usize) + Sync)) {
1863 std::thread::scope(|s| {
1864 for lane in 1..self.lanes {
1865 s.spawn(move || {
1866 let mut i = lane;
1867 while i < jobs {
1868 run(i);
1869 i += self.lanes;
1870 }
1871 });
1872 }
1873 let mut i = 0;
1874 while i < jobs {
1875 run(i);
1876 i += self.lanes;
1877 }
1878 });
1879 }
1880 }
1881
1882 #[test]
1883 fn pooled_verification_matches_serial_exactly() {
1884 let exec = ThreadedExec { lanes: 3 };
1885 let frame = |f: usize| -> CommandRecording {
1890 let tail = [10usize, 13, 5, 8, 11, 6, 9, 12][f % 8];
1891 let mut recording = ring_frame(3, 300, f, tail);
1892 if f >= 3 {
1893 for i in (0..recording.arcs.len()).step_by(17) {
1894 recording.arcs[i].color = if f.is_multiple_of(2) {
1895 Color::rgb(1.0, 0.5, 0.1)
1896 } else {
1897 Color::rgb(0.1, 0.5, 1.0)
1898 };
1899 }
1900 }
1901 if f == 5 {
1902 recording.arcs[450].sweep_angle = 0.15;
1905 }
1906 recording
1907 };
1908 let mut serial = CommandReplayState::default();
1909 let mut pooled = CommandReplayState::default();
1910 for f in 0..10 {
1911 let recording = frame(f);
1912 let serial_outcome = serial.advance(&recording);
1913 let pooled_outcome = pooled.advance_pooled(&recording, Some(&exec));
1914 assert_eq!(
1915 serial_outcome, pooled_outcome,
1916 "outcome diverged at frame {f}"
1917 );
1918 assert_eq!(
1919 serial.segments(),
1920 pooled.segments(),
1921 "segments diverged at frame {f}"
1922 );
1923 assert_eq!(
1924 serial.stats(),
1925 pooled.stats(),
1926 "stats diverged at frame {f}"
1927 );
1928 }
1929 let (deaths, splits) = serial.stats();
1930 assert!(
1931 !serial.segments().is_empty() && deaths + splits > 0,
1932 "sequence must exercise both retention and the mismatch path, \
1933 got {deaths} deaths {splits} splits {} segments",
1934 serial.segments().len()
1935 );
1936 assert_eq!(serial.optimistic_commits(), 0);
1937 assert!(
1938 pooled.optimistic_commits() >= 3,
1939 "the pooled fast path must actually commit steady frames, got {}",
1940 pooled.optimistic_commits()
1941 );
1942 }
1943
1944 #[test]
1945 fn transformed_bounds_contain_the_moved_content() {
1946 let t = RecordTransform {
1947 scale: 1.1,
1948 angle: 0.5,
1949 };
1950 let bounds = Rect {
1951 x: 150.0,
1952 y: 150.0,
1953 width: 100.0,
1954 height: 30.0,
1955 };
1956 let moved = t.apply_to_bounds(CENTER, bounds);
1957 for corner in [
1958 Point::new(bounds.x, bounds.y),
1959 Point::new(bounds.x + bounds.width, bounds.y + bounds.height),
1960 ] {
1961 let p = t.apply(CENTER, corner);
1962 assert!(p.x >= moved.x - 1e-3 && p.x <= moved.x + moved.width + 1e-3);
1963 assert!(p.y >= moved.y - 1e-3 && p.y <= moved.y + moved.height + 1e-3);
1964 }
1965 }
1966}