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 prefix_commits: u64,
703 verify_results: Vec<std::sync::Mutex<SpanResultSlot>>,
713 recolor_scratch: Vec<(u32, Color)>,
718 best_recolor_scratch: Vec<(u32, Color)>,
721 verify_pending: std::collections::VecDeque<CommandSegment>,
724 verify_survivors: Vec<CommandSegment>,
725}
726
727impl Default for CommandReplayState {
728 fn default() -> Self {
729 Self {
730 phase: CommandReplayPhase::Idle,
731 center: Point::new(0.0, 0.0),
732 snapshot: CommandRecording::default(),
733 segments: Vec::new(),
734 next_slot_id: 0,
735 lifetime_deaths: 0,
736 lifetime_splits: 0,
737 capture_coverage: 0.0,
738 frames_since_capture: 0,
739 optimistic_commits: 0,
740 prefix_commits: 0,
741 verify_results: Vec::new(),
742 recolor_scratch: Vec::new(),
743 best_recolor_scratch: Vec::new(),
744 verify_pending: std::collections::VecDeque::new(),
745 verify_survivors: Vec::new(),
746 }
747 }
748}
749
750impl CommandReplayState {
751 pub fn segments(&self) -> &[CommandSegment] {
752 &self.segments
753 }
754
755 pub fn stats(&self) -> (u64, u64) {
758 (self.lifetime_deaths, self.lifetime_splits)
759 }
760
761 pub fn optimistic_commits(&self) -> u64 {
763 self.optimistic_commits
764 }
765
766 pub fn prefix_commits(&self) -> u64 {
770 self.prefix_commits
771 }
772
773 pub fn center(&self) -> Point {
775 self.center
776 }
777
778 pub fn advance(&mut self, current: &CommandRecording) -> ReplayOutcome {
786 self.advance_pooled(current, None)
787 }
788
789 pub fn advance_pooled(
799 &mut self,
800 current: &CommandRecording,
801 pool: Option<&dyn VerifyExecutor>,
802 ) -> ReplayOutcome {
803 if current.tape.len() < MIN_REPLAY_COMMAND_RECORDS {
804 self.retire();
805 return ReplayOutcome::AllDynamic;
806 }
807 let Some(center) = detect_center(current) else {
808 self.retire();
809 return ReplayOutcome::AllDynamic;
810 };
811 match self.phase {
812 CommandReplayPhase::Idle => {
813 self.take_snapshot(current, center);
814 ReplayOutcome::AllDynamic
815 }
816 CommandReplayPhase::Snapshotted => self.partition(current, center),
817 CommandReplayPhase::Captured => self.verify(current, pool),
818 }
819 }
820
821 fn retire(&mut self) {
822 self.phase = CommandReplayPhase::Idle;
823 self.snapshot = CommandRecording::default();
824 self.segments.clear();
825 }
826
827 fn take_snapshot(&mut self, current: &CommandRecording, center: Point) {
828 self.snapshot = current.clone();
829 self.center = center;
830 self.segments.clear();
831 self.phase = CommandReplayPhase::Snapshotted;
832 }
833
834 fn partition(&mut self, current: &CommandRecording, center: Point) -> ReplayOutcome {
842 let aligned = align_recordings(current, &self.snapshot);
843 let mut chains: Vec<(usize, usize)> = Vec::new();
844 let mut i = 0;
845 while i < current.tape.len() {
846 let (Some(view), Some(snapshot_view)) = (
847 view_at(current, i),
848 aligned[i].and_then(|j| view_at(&self.snapshot, j)),
849 ) else {
850 i += 1;
851 continue;
852 };
853 let Some((t, true)) =
855 pair_transform(current, view, &self.snapshot, snapshot_view, self.center)
856 else {
857 i += 1;
858 continue;
859 };
860 if match_pair(current, view, &self.snapshot, snapshot_view, self.center, t)
861 == RecordMatch::Mismatch
862 {
863 i += 1;
864 continue;
865 }
866 let start = i;
867 let mut end = i + 1;
868 while end < current.tape.len() {
869 let (Some(view), Some(snapshot_view)) = (
870 view_at(current, end),
871 aligned[end].and_then(|j| view_at(&self.snapshot, j)),
872 ) else {
873 break;
874 };
875 let Some((entry_t, pinned)) =
876 pair_transform(current, view, &self.snapshot, snapshot_view, self.center)
877 else {
878 break;
879 };
880 if !transforms_group(entry_t, pinned, t) {
881 break;
882 }
883 if match_pair(current, view, &self.snapshot, snapshot_view, self.center, t)
884 == RecordMatch::Mismatch
885 {
886 break;
887 }
888 end += 1;
889 }
890 if end - start >= MIN_SEGMENT_RECORDS {
891 let mut piece_start = start;
892 while piece_start < end {
893 let piece_end = (piece_start + MAX_SEGMENT_RECORDS).min(end);
894 if piece_end - piece_start >= MIN_SEGMENT_RECORDS {
895 chains.push((piece_start, piece_end));
896 }
897 piece_start = piece_end;
898 }
899 }
900 i = end.max(i + 1);
901 }
902
903 if chains.is_empty() {
904 self.take_snapshot(current, center);
905 return ReplayOutcome::AllDynamic;
906 }
907 self.take_snapshot(current, center);
910 self.segments = chains
911 .into_iter()
912 .map(|range| {
913 let slot = self.next_slot_id;
914 self.next_slot_id += 1;
915 CommandSegment {
916 slot,
917 slot_offset: 0,
918 tape_start: range.0,
919 tape_end: range.1,
920 bounds: range_bounds(&self.snapshot, range),
921 }
922 })
923 .collect();
924 let covered: usize = self
925 .segments
926 .iter()
927 .map(|segment| segment.tape_end - segment.tape_start)
928 .sum();
929 self.capture_coverage = covered as f32 / current.tape.len().max(1) as f32;
930 self.frames_since_capture = 0;
931 self.phase = CommandReplayPhase::Captured;
932
933 let mut spans: Vec<ReplaySpan> = Vec::with_capacity(self.segments.len() * 2 + 1);
934 let mut cursor = 0usize;
935 for segment in &self.segments {
936 if segment.tape_start > cursor {
937 spans.push(ReplaySpan::Dynamic {
938 tape_start: cursor,
939 tape_end: segment.tape_start,
940 });
941 }
942 spans.push(ReplaySpan::Retained {
943 slot: segment.slot,
944 capture: true,
945 slot_offset: 0,
946 tape_start: segment.tape_start,
947 tape_end: segment.tape_end,
948 transform: RecordTransform::IDENTITY,
949 recolors: Vec::new(),
950 bounds: segment.bounds,
951 });
952 cursor = segment.tape_end;
953 }
954 if cursor < current.tape.len() {
955 spans.push(ReplaySpan::Dynamic {
956 tape_start: cursor,
957 tape_end: current.tape.len(),
958 });
959 }
960 ReplayOutcome::Spans(spans)
961 }
962
963 fn verify(
974 &mut self,
975 current: &CommandRecording,
976 pool: Option<&dyn VerifyExecutor>,
977 ) -> ReplayOutcome {
978 let mut spans: Vec<ReplaySpan> = Vec::new();
979 let mut retained_records = 0usize;
980 let mut cursor = 0usize;
982 let mut committed = 0usize;
985 if let Some(pool) = pool {
986 if self.segments.len() >= 2 {
987 let commit = self.verify_optimistic(current, pool);
988 if commit.committed == self.segments.len() {
989 self.optimistic_commits += 1;
990 return self.finish_verify(current, commit.spans, commit.retained_records);
991 }
992 if commit.committed > 0 {
999 self.prefix_commits += 1;
1000 }
1001 spans = commit.spans;
1002 retained_records = commit.retained_records;
1003 cursor = commit.cursor;
1004 committed = commit.committed;
1005 }
1006 }
1007 self.verify_pending.clear();
1012 self.verify_pending.extend(self.segments.drain(committed..));
1013 self.verify_survivors.clear();
1014 self.verify_survivors.append(&mut self.segments);
1017 while let Some(segment) = self.verify_pending.pop_front() {
1018 let len = segment.tape_end - segment.tape_start;
1019 let search_end = (cursor + RESYNC_WINDOW)
1020 .min(current.tape.len().saturating_sub(len - 1))
1021 .max(cursor);
1022 let candidates = cursor..search_end;
1031 let mut located: Option<(usize, RecordTransform)> = None;
1032 let mut best_prefix: Option<(usize, RecordTransform)> = None;
1038 let mut best_prefix_len = 0usize;
1039 let mut attempts = 0usize;
1040 'search: for start in candidates {
1041 let Some(t) = probe_anchor(
1042 current,
1043 &self.snapshot,
1044 self.center,
1045 segment.tape_start,
1046 len,
1047 start,
1048 ) else {
1049 continue;
1050 };
1051 let matched = match_span(
1055 TypedRecords::from(current),
1056 TypedRecords::from(&self.snapshot),
1057 self.center,
1058 start,
1059 segment.tape_start,
1060 len,
1061 t,
1062 &mut self.recolor_scratch,
1063 );
1064 if matched < len {
1065 if matched > best_prefix_len {
1066 best_prefix_len = matched;
1067 best_prefix = Some((start, t));
1068 std::mem::swap(&mut self.recolor_scratch, &mut self.best_recolor_scratch);
1071 }
1072 if matched >= MIN_SEGMENT_RECORDS {
1081 attempts += 1;
1082 if attempts >= MAX_COMMIT_ATTEMPTS {
1083 break 'search;
1084 }
1085 }
1086 continue;
1087 }
1088 located = Some((start, t));
1089 break;
1090 }
1091 let (span_start, t, recolors, span_len) = match located {
1102 Some((start, t)) => (start, t, std::mem::take(&mut self.recolor_scratch), len),
1103 None => {
1104 let split = best_prefix_len >= MIN_SEGMENT_RECORDS;
1105 let Some((start, t)) = best_prefix.filter(|_| split) else {
1106 self.lifetime_deaths += 1;
1107 continue;
1108 };
1109 let suffix_start = segment.tape_start + best_prefix_len + 1;
1110 if segment.tape_end > suffix_start
1111 && segment.tape_end - suffix_start >= MIN_SEGMENT_RECORDS
1112 {
1113 self.verify_pending.push_front(CommandSegment {
1116 slot: segment.slot,
1117 slot_offset: segment.slot_offset + (suffix_start - segment.tape_start),
1118 tape_start: suffix_start,
1119 tape_end: segment.tape_end,
1120 bounds: range_bounds(&self.snapshot, (suffix_start, segment.tape_end)),
1121 });
1122 }
1123 self.lifetime_splits += 1;
1124 (
1125 start,
1126 t,
1127 std::mem::take(&mut self.best_recolor_scratch),
1128 best_prefix_len,
1129 )
1130 }
1131 };
1132 let survivor = if span_len == len {
1133 segment
1134 } else {
1135 CommandSegment {
1138 slot: segment.slot,
1139 slot_offset: segment.slot_offset,
1140 tape_start: segment.tape_start,
1141 tape_end: segment.tape_start + span_len,
1142 bounds: range_bounds(
1143 &self.snapshot,
1144 (segment.tape_start, segment.tape_start + span_len),
1145 ),
1146 }
1147 };
1148 if span_start > cursor {
1149 spans.push(ReplaySpan::Dynamic {
1150 tape_start: cursor,
1151 tape_end: span_start,
1152 });
1153 }
1154 retained_records += span_len;
1155 spans.push(ReplaySpan::Retained {
1156 slot: survivor.slot,
1157 capture: false,
1158 slot_offset: survivor.slot_offset,
1159 tape_start: span_start,
1160 tape_end: span_start + span_len,
1161 transform: t,
1162 recolors,
1163 bounds: t.apply_to_bounds(self.center, survivor.bounds),
1164 });
1165 cursor = span_start + span_len;
1166 self.verify_survivors.push(survivor);
1167 }
1168 if cursor < current.tape.len() {
1169 spans.push(ReplaySpan::Dynamic {
1170 tape_start: cursor,
1171 tape_end: current.tape.len(),
1172 });
1173 }
1174
1175 std::mem::swap(&mut self.segments, &mut self.verify_survivors);
1178 self.finish_verify(current, spans, retained_records)
1179 }
1180
1181 fn finish_verify(
1184 &mut self,
1185 current: &CommandRecording,
1186 spans: Vec<ReplaySpan>,
1187 retained_records: usize,
1188 ) -> ReplayOutcome {
1189 self.frames_since_capture += 1;
1190 let retained_total: usize = self
1191 .segments
1192 .iter()
1193 .map(|segment| segment.tape_end - segment.tape_start)
1194 .sum();
1195 let coverage = retained_total as f32 / current.tape.len().max(1) as f32;
1196 let collapsed = retained_records == 0 || coverage < MIN_COVERAGE_FRACTION;
1197 let eroded = coverage + RECAPTURE_EROSION < self.capture_coverage
1198 && self.frames_since_capture >= RECAPTURE_COOLDOWN_FRAMES;
1199 if collapsed || eroded {
1200 let center = self.center;
1203 self.take_snapshot(current, center);
1204 if retained_records == 0 {
1205 return ReplayOutcome::AllDynamic;
1206 }
1207 }
1208 ReplayOutcome::Spans(spans)
1209 }
1210
1211 fn verify_optimistic(
1234 &mut self,
1235 current: &CommandRecording,
1236 pool: &dyn VerifyExecutor,
1237 ) -> PooledCommit {
1238 struct SpanJob {
1239 start: usize,
1240 seg_start: usize,
1241 len: usize,
1242 t: RecordTransform,
1243 }
1244 let mut jobs: Vec<SpanJob> = Vec::with_capacity(self.segments.len());
1245 let mut cursor = 0usize;
1246 for segment in &self.segments {
1247 let len = segment.tape_end - segment.tape_start;
1248 let search_end = (cursor + RESYNC_WINDOW)
1249 .min(current.tape.len().saturating_sub(len - 1))
1250 .max(cursor);
1251 let mut found = None;
1252 for start in cursor..search_end {
1253 if let Some(t) = probe_anchor(
1254 current,
1255 &self.snapshot,
1256 self.center,
1257 segment.tape_start,
1258 len,
1259 start,
1260 ) {
1261 found = Some((start, t));
1262 break;
1263 }
1264 }
1265 let Some((start, t)) = found else {
1270 break;
1271 };
1272 jobs.push(SpanJob {
1273 start,
1274 seg_start: segment.tape_start,
1275 len,
1276 t,
1277 });
1278 cursor = start + len;
1279 }
1280 if jobs.is_empty() {
1281 return PooledCommit {
1282 spans: Vec::new(),
1283 retained_records: 0,
1284 committed: 0,
1285 cursor: 0,
1286 };
1287 }
1288 if self.verify_results.len() < jobs.len() {
1292 self.verify_results
1293 .resize_with(jobs.len(), Default::default);
1294 }
1295 {
1296 let current = TypedRecords::from(current);
1297 let snapshot = TypedRecords::from(&self.snapshot);
1298 let center = self.center;
1299 let jobs = &jobs;
1300 let results = &self.verify_results;
1301 pool.for_each(jobs.len(), &|i| {
1302 let job = &jobs[i];
1303 let mut guard = results[i].lock().expect("verify span job lock");
1304 let slot = &mut *guard;
1305 slot.matched = match_span(
1306 current,
1307 snapshot,
1308 center,
1309 job.start,
1310 job.seg_start,
1311 job.len,
1312 job.t,
1313 &mut slot.recolors,
1314 );
1315 });
1316 }
1317 let mut committed = jobs.len();
1321 for (i, (job, result)) in jobs.iter().zip(&self.verify_results).enumerate() {
1322 if result.lock().expect("verify span job lock").matched < job.len {
1323 committed = i;
1324 break;
1325 }
1326 }
1327 let mut spans: Vec<ReplaySpan> = Vec::with_capacity(committed * 2 + 1);
1328 let mut retained_records = 0usize;
1329 let mut cursor = 0usize;
1330 for (segment, (job, result)) in self
1331 .segments
1332 .iter()
1333 .zip(jobs.iter().zip(&self.verify_results))
1334 .take(committed)
1335 {
1336 let recolors =
1340 std::mem::take(&mut result.lock().expect("verify span job lock").recolors);
1341 if job.start > cursor {
1342 spans.push(ReplaySpan::Dynamic {
1343 tape_start: cursor,
1344 tape_end: job.start,
1345 });
1346 }
1347 retained_records += job.len;
1348 spans.push(ReplaySpan::Retained {
1349 slot: segment.slot,
1350 capture: false,
1351 slot_offset: segment.slot_offset,
1352 tape_start: job.start,
1353 tape_end: job.start + job.len,
1354 transform: job.t,
1355 recolors,
1356 bounds: job.t.apply_to_bounds(self.center, segment.bounds),
1357 });
1358 cursor = job.start + job.len;
1359 }
1360 if committed == self.segments.len() && cursor < current.tape.len() {
1363 spans.push(ReplaySpan::Dynamic {
1364 tape_start: cursor,
1365 tape_end: current.tape.len(),
1366 });
1367 }
1368 PooledCommit {
1369 spans,
1370 retained_records,
1371 committed,
1372 cursor,
1373 }
1374 }
1375}
1376
1377struct PooledCommit {
1384 spans: Vec<ReplaySpan>,
1385 retained_records: usize,
1386 committed: usize,
1388 cursor: usize,
1390}
1391
1392fn probe_anchor(
1397 current: &CommandRecording,
1398 snapshot: &CommandRecording,
1399 center: Point,
1400 seg_start: usize,
1401 len: usize,
1402 start: usize,
1403) -> Option<RecordTransform> {
1404 let (Some(view), Some(snapshot_view)) = (view_at(current, start), view_at(snapshot, seg_start))
1405 else {
1406 return None;
1407 };
1408 if !views_compatible(current, Some(view), snapshot, Some(snapshot_view)) {
1409 return None;
1410 }
1411 let (t, _) = pair_transform(current, view, snapshot, snapshot_view, center)?;
1412 for probe in 0..ANCHOR_PROBE_RECORDS.min(len) {
1413 let (Some(view), Some(snapshot_view)) = (
1414 view_at(current, start + probe),
1415 view_at(snapshot, seg_start + probe),
1416 ) else {
1417 return None;
1418 };
1419 if match_pair(current, view, snapshot, snapshot_view, center, t) == RecordMatch::Mismatch {
1420 return None;
1421 }
1422 }
1423 Some(t)
1424}
1425
1426#[derive(Clone, Copy)]
1431struct TypedRecords<'a> {
1432 tape: &'a [TapeRef],
1433 arcs: &'a [SolidArcRecord],
1434 round_rects: &'a [SolidRoundRectRecord],
1435}
1436
1437impl<'a> From<&'a CommandRecording> for TypedRecords<'a> {
1438 fn from(recording: &'a CommandRecording) -> Self {
1439 Self {
1440 tape: &recording.tape,
1441 arcs: &recording.arcs,
1442 round_rects: &recording.round_rects,
1443 }
1444 }
1445}
1446
1447impl TypedRecords<'_> {
1448 fn view_at(&self, i: usize) -> Option<ReplayView> {
1452 view_at_slices(self.tape, self.round_rects, i)
1453 }
1454}
1455
1456#[allow(clippy::too_many_arguments)]
1464fn match_span(
1465 current: TypedRecords<'_>,
1466 snapshot: TypedRecords<'_>,
1467 center: Point,
1468 start: usize,
1469 seg_start: usize,
1470 len: usize,
1471 t: RecordTransform,
1472 recolors: &mut Vec<(u32, Color)>,
1473) -> usize {
1474 recolors.clear();
1475 for offset in 0..len {
1476 let entry_match = match (
1477 current.view_at(start + offset),
1478 snapshot.view_at(seg_start + offset),
1479 ) {
1480 (Some(ReplayView::Arc(i)), Some(ReplayView::Arc(j))) => {
1481 match_arc(¤t.arcs[i], &snapshot.arcs[j], center, t)
1482 }
1483 (Some(ReplayView::RoundRect(i)), Some(ReplayView::RoundRect(j))) => {
1484 match_round_rect(¤t.round_rects[i], &snapshot.round_rects[j], center, t)
1485 }
1486 _ => RecordMatch::Mismatch,
1487 };
1488 match entry_match {
1489 RecordMatch::Exact => {}
1490 RecordMatch::Recolor => {
1491 let color = match current.view_at(start + offset) {
1492 Some(ReplayView::Arc(a)) => current.arcs[a].color,
1493 Some(ReplayView::RoundRect(r)) => current.round_rects[r].color,
1494 None => unreachable!("recolor requires a view"),
1495 };
1496 recolors.push((offset as u32, color));
1497 }
1498 RecordMatch::Mismatch => return offset,
1499 }
1500 }
1501 len
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506 use super::*;
1507 use crate::{Color, Stroke};
1508
1509 const CENTER: Point = Point { x: 204.0, y: 204.0 };
1510
1511 fn arc(radius: f32, start: f32, color: Color) -> SolidArcRecord {
1512 SolidArcRecord {
1513 center: CENTER,
1514 radius,
1515 start_angle: start,
1516 sweep_angle: 0.4,
1517 inner_radius: radius * 0.8,
1518 color,
1519 stroke: None,
1520 }
1521 }
1522
1523 fn moved_arc(base: &SolidArcRecord, t: RecordTransform) -> SolidArcRecord {
1524 SolidArcRecord {
1525 center: base.center,
1526 radius: base.radius * t.scale,
1527 start_angle: base.start_angle + t.angle,
1528 sweep_angle: base.sweep_angle,
1529 inner_radius: base.inner_radius * t.scale,
1530 color: base.color,
1531 stroke: base.stroke.map(|stroke| Stroke {
1532 width: stroke.width * t.scale,
1533 ..stroke
1534 }),
1535 }
1536 }
1537
1538 fn circle(cx: f32, cy: f32, diameter: f32, color: Color) -> SolidRoundRectRecord {
1539 SolidRoundRectRecord {
1540 rect: Rect {
1541 x: cx - diameter * 0.5,
1542 y: cy - diameter * 0.5,
1543 width: diameter,
1544 height: diameter,
1545 },
1546 radii: CornerRadii::uniform(diameter * 0.5),
1547 color,
1548 stroke: None,
1549 }
1550 }
1551
1552 #[test]
1553 fn arc_anchor_recovers_the_baked_transform() {
1554 let t = RecordTransform {
1555 scale: 0.9994,
1556 angle: 0.0123,
1557 };
1558 let retained = arc(120.0, 1.0, Color::WHITE);
1559 let current = moved_arc(&retained, t);
1560 let derived = arc_anchor_transform(¤t, &retained).expect("derivable");
1561 assert!((derived.scale - t.scale).abs() < 1e-6);
1562 assert!((derived.angle - t.angle).abs() < 1e-6);
1563 assert_eq!(
1564 match_arc(¤t, &retained, CENTER, derived),
1565 RecordMatch::Exact
1566 );
1567 }
1568
1569 #[test]
1570 fn recolored_arc_matches_as_recolor() {
1571 let t = RecordTransform {
1572 scale: 1.0,
1573 angle: 0.05,
1574 };
1575 let retained = arc(80.0, 0.2, Color::WHITE);
1576 let mut current = moved_arc(&retained, t);
1577 current.color = Color::rgb(0.5, 0.1, 0.9);
1578 assert_eq!(
1579 match_arc(¤t, &retained, CENTER, t),
1580 RecordMatch::Recolor
1581 );
1582 }
1583
1584 #[test]
1585 fn changed_sweep_is_a_mismatch() {
1586 let t = RecordTransform::IDENTITY;
1587 let retained = arc(80.0, 0.2, Color::WHITE);
1588 let mut current = retained;
1589 current.sweep_angle += 0.1;
1590 assert_eq!(
1591 match_arc(¤t, &retained, CENTER, t),
1592 RecordMatch::Mismatch
1593 );
1594 }
1595
1596 #[test]
1597 fn stroked_arc_scales_its_width_with_the_segment() {
1598 let t = RecordTransform {
1599 scale: 0.98,
1600 angle: 0.0,
1601 };
1602 let mut retained = arc(60.0, 0.0, Color::WHITE);
1603 retained.stroke = Some(Stroke::new(5.0));
1604 let current = moved_arc(&retained, t);
1605 assert_eq!(
1606 match_arc(¤t, &retained, CENTER, t),
1607 RecordMatch::Exact
1608 );
1609
1610 let mut stale = current;
1613 stale.stroke = Some(Stroke::new(5.0));
1614 assert_eq!(
1615 match_arc(&stale, &retained, CENTER, t),
1616 RecordMatch::Mismatch
1617 );
1618 }
1619
1620 #[test]
1621 fn orbiting_circle_matches_under_rotation() {
1622 let t = RecordTransform {
1623 scale: 1.0,
1624 angle: 0.3,
1625 };
1626 let retained = circle(304.0, 204.0, 10.0, Color::WHITE);
1627 let (c_then, d_then) = circle_view(&retained).expect("circle");
1628 let c_now = t.apply(CENTER, c_then);
1629 let current = circle(c_now.x, c_now.y, d_then * t.scale, Color::WHITE);
1630 let (derived, pinned) = circle_anchor_transform_pinned(
1631 circle_view(¤t).unwrap(),
1632 (c_then, d_then),
1633 CENTER,
1634 )
1635 .expect("derivable");
1636 assert!(pinned, "an off-pivot circle pins rotation");
1637 assert!((derived.angle - t.angle).abs() < 1e-4);
1638 assert_eq!(
1639 match_round_rect(¤t, &retained, CENTER, derived),
1640 RecordMatch::Exact
1641 );
1642 }
1643
1644 #[test]
1645 fn non_circular_round_rect_never_matches() {
1646 let mut retained = circle(304.0, 204.0, 10.0, Color::WHITE);
1647 retained.rect.width = 14.0; assert_eq!(
1649 match_round_rect(&retained, &retained, CENTER, RecordTransform::IDENTITY),
1650 RecordMatch::Mismatch
1651 );
1652 }
1653
1654 #[test]
1655 fn grouping_is_tighter_than_verification() {
1656 let anchor = RecordTransform {
1657 scale: 1.0,
1658 angle: 0.010,
1659 };
1660 let same_ring = RecordTransform {
1661 scale: 1.0,
1662 angle: 0.0100001,
1663 };
1664 let next_ring = RecordTransform {
1665 scale: 1.0,
1666 angle: 0.011,
1667 };
1668 assert!(transforms_group(same_ring, true, anchor));
1669 assert!(
1670 !transforms_group(next_ring, true, anchor),
1671 "a 1e-3 rotation-step difference is another ring, not float noise"
1672 );
1673 let unpinned = RecordTransform {
1674 scale: 1.0,
1675 angle: 0.0,
1676 };
1677 assert!(transforms_group(unpinned, false, anchor));
1678 }
1679
1680 use crate::geometry::{DrawScopeDefault, Size};
1681 use crate::{Brush, DrawScope as _};
1682
1683 fn ring_frame(rings: usize, per_ring: usize, frame: usize, tail: usize) -> CommandRecording {
1687 let mut scope = DrawScopeDefault::new(Size::new(408.0, 408.0));
1688 let scale = 0.9994f32.powi(frame as i32);
1689 for ring in 0..rings {
1690 let step = 0.01 + ring as f32 * 0.005;
1691 let rotation = step * frame as f32;
1692 let radius = (60.0 + ring as f32 * 30.0) * scale;
1693 for slot in 0..per_ring {
1694 let start = slot as f32 * (std::f32::consts::TAU / per_ring as f32) + rotation;
1695 scope.draw_annular_sector(
1696 Brush::solid(Color::WHITE),
1697 CENTER,
1698 radius * 0.8,
1699 radius,
1700 start,
1701 0.02,
1702 );
1703 }
1704 }
1705 for i in 0..tail {
1706 let x = 40.0 + (frame * 17 + i * 31) as f32 % 300.0;
1708 scope.draw_circle(Brush::solid(Color::RED), Point::new(x, 50.0), 3.0);
1709 }
1710 scope.recorded().clone()
1711 }
1712
1713 #[test]
1714 fn ring_scene_reaches_retention_by_the_third_frame() {
1715 let mut state = CommandReplayState::default();
1716 assert!(matches!(
1717 state.advance(&ring_frame(3, 300, 0, 10)),
1718 ReplayOutcome::AllDynamic
1719 ));
1720 let ReplayOutcome::Spans(capture_spans) = state.advance(&ring_frame(3, 300, 1, 10)) else {
1723 panic!("partition frame should emit the capture");
1724 };
1725 assert!(capture_spans.iter().all(|span| match span {
1726 ReplaySpan::Retained {
1727 capture, transform, ..
1728 } => *capture && *transform == RecordTransform::IDENTITY,
1729 ReplaySpan::Dynamic { .. } => true,
1730 }));
1731 assert!(!state.segments().is_empty(), "partition found the rings");
1732
1733 let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(3, 300, 2, 10)) else {
1734 panic!("third frame should retain");
1735 };
1736 let retained: usize = spans
1737 .iter()
1738 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1739 .count();
1740 assert!(retained >= 3, "each ring retains, got {spans:?}");
1741 assert!(spans
1743 .iter()
1744 .any(|span| matches!(span, ReplaySpan::Dynamic { .. })));
1745 let transforms: Vec<RecordTransform> = spans
1747 .iter()
1748 .filter_map(|span| match span {
1749 ReplaySpan::Retained { transform, .. } => Some(*transform),
1750 _ => None,
1751 })
1752 .collect();
1753 assert!(transforms.windows(2).any(|w| w[0].angle != w[1].angle));
1754 }
1755
1756 #[test]
1757 fn entity_churn_between_frames_still_retains_rings() {
1758 let mut state = CommandReplayState::default();
1759 state.advance(&ring_frame(2, 400, 0, 8));
1760 state.advance(&ring_frame(2, 400, 1, 13)); let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(2, 400, 2, 5)) else {
1762 panic!("churned tail must not break ring retention");
1763 };
1764 let retained_records: usize = spans
1765 .iter()
1766 .filter_map(|span| match span {
1767 ReplaySpan::Retained { .. } => Some(1),
1768 _ => None,
1769 })
1770 .sum();
1771 assert!(retained_records >= 2);
1772 }
1773
1774 #[test]
1775 fn recolors_are_patches_not_mismatches() {
1776 let recolored_frame = |frame: usize| {
1777 let mut recording = ring_frame(1, 600, frame, 0);
1778 for i in (0..recording.arcs.len()).step_by(15) {
1780 recording.arcs[i].color = if frame.is_multiple_of(2) {
1781 Color::rgb(1.0, 0.5, 0.1)
1782 } else {
1783 Color::rgb(0.1, 0.5, 1.0)
1784 };
1785 }
1786 recording
1787 };
1788 let mut state = CommandReplayState::default();
1789 state.advance(&recolored_frame(0));
1790 state.advance(&recolored_frame(1));
1791 let ReplayOutcome::Spans(spans) = state.advance(&recolored_frame(2)) else {
1792 panic!("twinkles must not break retention");
1793 };
1794 let recolor_count: usize = spans
1795 .iter()
1796 .filter_map(|span| match span {
1797 ReplaySpan::Retained { recolors, .. } => Some(recolors.len()),
1798 _ => None,
1799 })
1800 .sum();
1801 assert!(recolor_count >= 30, "twinkles surface as patches");
1802 }
1803
1804 #[test]
1805 fn geometry_change_kills_only_its_segment() {
1806 let mut state = CommandReplayState::default();
1807 state.advance(&ring_frame(3, 300, 0, 0));
1808 state.advance(&ring_frame(3, 300, 1, 0));
1809 let mut broken = ring_frame(3, 300, 2, 0);
1810 broken.arcs[450].sweep_angle *= 3.0;
1812 let ReplayOutcome::Spans(spans) = state.advance(&broken) else {
1813 panic!("one changed entry must not drop the whole command");
1814 };
1815 let retained: usize = spans
1816 .iter()
1817 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1818 .count();
1819 assert!(
1820 retained >= 2,
1821 "the untouched rings keep retaining, got {spans:?}"
1822 );
1823 }
1824
1825 #[test]
1826 fn mid_segment_change_splits_and_retains_both_halves() {
1827 let mut state = CommandReplayState::default();
1828 state.advance(&ring_frame(1, 900, 0, 0));
1829 state.advance(&ring_frame(1, 900, 1, 0));
1830 assert_eq!(state.segments().len(), 1, "one ring is one segment");
1831 let mut broken = ring_frame(1, 900, 2, 0);
1832 broken.arcs[450].sweep_angle *= 3.0;
1833 let ReplayOutcome::Spans(spans) = state.advance(&broken) else {
1834 panic!("a single changed record must not drop retention");
1835 };
1836 let dynamic: usize = spans
1837 .iter()
1838 .filter_map(|span| match span {
1839 ReplaySpan::Dynamic {
1840 tape_start,
1841 tape_end,
1842 } => Some(tape_end - tape_start),
1843 _ => None,
1844 })
1845 .sum();
1846 let retained: Vec<(u32, usize, bool)> = spans
1847 .iter()
1848 .filter_map(|span| match span {
1849 ReplaySpan::Retained {
1850 slot,
1851 slot_offset,
1852 capture,
1853 ..
1854 } => Some((*slot, *slot_offset, *capture)),
1855 _ => None,
1856 })
1857 .collect();
1858 assert_eq!(
1859 retained.len(),
1860 2,
1861 "prefix and suffix both retain: {spans:?}"
1862 );
1863 assert_eq!(retained[0].0, retained[1].0);
1867 assert_eq!(retained[0].1, 0);
1868 assert_eq!(retained[1].1, 451);
1869 assert!(retained.iter().all(|(_, _, capture)| !capture));
1870 assert_eq!(dynamic, 1, "only the changed record goes dynamic");
1871 assert_eq!(state.stats(), (0, 1), "one split, no deaths");
1872
1873 let ReplayOutcome::Spans(spans) = state.advance(&ring_frame(1, 900, 3, 0)) else {
1876 panic!("split pieces must keep retaining");
1877 };
1878 let retained = spans
1879 .iter()
1880 .filter(|span| matches!(span, ReplaySpan::Retained { .. }))
1881 .count();
1882 assert_eq!(retained, 2, "both pieces relocate next frame: {spans:?}");
1883 }
1884
1885 #[test]
1886 fn erosion_recaptures_dead_ranges_after_the_cooldown() {
1887 let mut state = CommandReplayState::default();
1888 state.advance(&ring_frame(3, 300, 0, 0));
1889 state.advance(&ring_frame(3, 300, 1, 0));
1890 let mutated = |frame: usize| {
1893 let mut recording = ring_frame(3, 300, frame, 0);
1894 for arc in &mut recording.arcs[300..600] {
1895 arc.sweep_angle *= 3.0;
1896 }
1897 recording
1898 };
1899 let dynamic_records = |outcome: &ReplayOutcome| -> usize {
1900 match outcome {
1901 ReplayOutcome::AllDynamic => usize::MAX,
1902 ReplayOutcome::Spans(spans) => spans
1903 .iter()
1904 .filter_map(|span| match span {
1905 ReplaySpan::Dynamic {
1906 tape_start,
1907 tape_end,
1908 } => Some(tape_end - tape_start),
1909 _ => None,
1910 })
1911 .sum(),
1912 }
1913 };
1914 let after_death = state.advance(&mutated(2));
1915 let lost = dynamic_records(&after_death);
1916 assert!(
1917 (250..=400).contains(&lost),
1918 "the changed ring goes dynamic, got {lost}"
1919 );
1920 for frame in 3..(3 + RECAPTURE_COOLDOWN_FRAMES as usize + 4) {
1921 state.advance(&mutated(frame));
1922 }
1923 let recovered = state.advance(&mutated(200));
1924 let residue = dynamic_records(&recovered);
1925 assert!(
1926 residue < 50,
1927 "the recapture watches the ring's new shape, got {residue} dynamic"
1928 );
1929 }
1930
1931 #[test]
1932 fn small_commands_are_not_watched() {
1933 let mut state = CommandReplayState::default();
1934 for frame in 0..4 {
1935 assert!(matches!(
1936 state.advance(&ring_frame(1, 40, frame, 0)),
1937 ReplayOutcome::AllDynamic
1938 ));
1939 }
1940 assert!(state.segments().is_empty());
1941 }
1942
1943 struct ThreadedExec {
1947 lanes: usize,
1948 }
1949
1950 impl VerifyExecutor for ThreadedExec {
1951 fn for_each(&self, jobs: usize, run: &(dyn Fn(usize) + Sync)) {
1952 std::thread::scope(|s| {
1953 for lane in 1..self.lanes {
1954 s.spawn(move || {
1955 let mut i = lane;
1956 while i < jobs {
1957 run(i);
1958 i += self.lanes;
1959 }
1960 });
1961 }
1962 let mut i = 0;
1963 while i < jobs {
1964 run(i);
1965 i += self.lanes;
1966 }
1967 });
1968 }
1969 }
1970
1971 #[test]
1972 fn pooled_verification_matches_serial_exactly() {
1973 let exec = ThreadedExec { lanes: 3 };
1974 let frame = |f: usize| -> CommandRecording {
1982 let tail = [10usize, 13, 5, 8, 11, 6, 9, 12][f % 8];
1983 let mut recording = ring_frame(3, 300, f, tail);
1984 if f >= 3 {
1985 for i in (0..recording.arcs.len()).step_by(17) {
1986 recording.arcs[i].color = if f.is_multiple_of(2) {
1987 Color::rgb(1.0, 0.5, 0.1)
1988 } else {
1989 Color::rgb(0.1, 0.5, 1.0)
1990 };
1991 }
1992 }
1993 match f {
1994 5 => {
1995 recording.arcs[450].sweep_angle = 0.15;
1999 }
2000 8 => {
2001 recording.arcs[100].sweep_angle = 0.15;
2005 recording.arcs[750].sweep_angle = 0.15;
2006 }
2007 12 => {
2008 recording.arcs[500].sweep_angle = 0.15;
2010 }
2011 16..=39 => {
2012 for arc in &mut recording.arcs[600..900] {
2018 arc.sweep_angle = 0.06;
2019 }
2020 }
2021 40..=45 => {
2022 for arc in &mut recording.arcs[150..900] {
2026 arc.sweep_angle = 0.08;
2027 }
2028 }
2029 52 => {
2030 recording.arcs[450].sweep_angle = 0.15;
2032 }
2033 _ => {}
2034 }
2035 recording
2036 };
2037 let mut serial = CommandReplayState::default();
2038 let mut pooled = CommandReplayState::default();
2039 for f in 0..60 {
2040 let recording = frame(f);
2041 let serial_outcome = serial.advance(&recording);
2042 let pooled_outcome = pooled.advance_pooled(&recording, Some(&exec));
2043 assert_eq!(
2044 serial_outcome, pooled_outcome,
2045 "outcome diverged at frame {f}"
2046 );
2047 assert_eq!(
2048 serial.segments(),
2049 pooled.segments(),
2050 "segments diverged at frame {f}"
2051 );
2052 assert_eq!(
2053 serial.stats(),
2054 pooled.stats(),
2055 "stats diverged at frame {f}"
2056 );
2057 }
2058 let (deaths, splits) = serial.stats();
2059 assert!(
2060 !serial.segments().is_empty() && deaths > 0 && splits > 0,
2061 "sequence must exercise retention, deaths, and splits, \
2062 got {deaths} deaths {splits} splits {} segments",
2063 serial.segments().len()
2064 );
2065 assert_eq!(serial.optimistic_commits(), 0);
2066 assert_eq!(serial.prefix_commits(), 0);
2067 assert!(
2068 pooled.optimistic_commits() >= 10,
2069 "the pooled fast path must actually commit steady frames, got {}",
2070 pooled.optimistic_commits()
2071 );
2072 assert!(
2073 pooled.prefix_commits() >= 3,
2074 "churn frames must commit their pooled prefix, got {}",
2075 pooled.prefix_commits()
2076 );
2077 }
2078
2079 #[test]
2080 fn transformed_bounds_contain_the_moved_content() {
2081 let t = RecordTransform {
2082 scale: 1.1,
2083 angle: 0.5,
2084 };
2085 let bounds = Rect {
2086 x: 150.0,
2087 y: 150.0,
2088 width: 100.0,
2089 height: 30.0,
2090 };
2091 let moved = t.apply_to_bounds(CENTER, bounds);
2092 for corner in [
2093 Point::new(bounds.x, bounds.y),
2094 Point::new(bounds.x + bounds.width, bounds.y + bounds.height),
2095 ] {
2096 let p = t.apply(CENTER, corner);
2097 assert!(p.x >= moved.x - 1e-3 && p.x <= moved.x + moved.width + 1e-3);
2098 assert!(p.y >= moved.y - 1e-3 && p.y <= moved.y + moved.height + 1e-3);
2099 }
2100 }
2101}