Skip to main content

alice_physics/
fracture.rs

1//! Fracture Simulation Modifier
2//!
3//! Stress accumulation from impacts and sustained loads drives crack
4//! propagation through the SDF. When accumulated stress exceeds the
5//! material's fracture toughness, cracks form along stress concentration
6//! lines and are subtracted from the SDF via CSG.
7//!
8//! # Model
9//!
10//! 1. Impacts and contacts accumulate stress in a 3D field
11//! 2. Stress above threshold triggers fracture seed points
12//! 3. Cracks propagate outward from seeds (thin box CSG subtraction)
13//! 4. Voronoi-like pattern from multiple seeds creates realistic fragments
14//!
15//! Author: Moroya Sakamoto
16
17use crate::math::Fix128;
18use crate::sim_field::ScalarField3D;
19use crate::sim_modifier::PhysicsModifier;
20use crate::sim_modifier::{observe_max, StateReader, StateWriter};
21use crate::world_participant::{
22    ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
23};
24
25#[cfg(not(feature = "std"))]
26use alloc::vec::Vec;
27
28// ============================================================================
29// Configuration
30// ============================================================================
31
32/// Fracture modifier configuration
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct FractureConfig {
35    /// Stress threshold for crack initiation
36    pub fracture_toughness: f32,
37    /// Crack width (SDF subtraction thickness)
38    pub crack_width: f32,
39    // LIMITATION(COV-FRACT-060): a finished crack stays carved into the SDF and keeps occupying its slot
40    /// Maximum number of cracks (growing and finished: a finished crack stays
41    /// carved into the SDF and keeps occupying its slot)
42    pub max_cracks: usize,
43    /// Stress diffusion rate
44    pub stress_diffusion: f32,
45    /// Stress decay rate
46    pub stress_decay: f32,
47    /// Crack propagation speed
48    pub propagation_speed: f32,
49    /// Maximum crack length
50    pub max_crack_length: f32,
51}
52
53impl Default for FractureConfig {
54    fn default() -> Self {
55        Self {
56            fracture_toughness: 50.0,
57            crack_width: 0.02,
58            max_cracks: 32,
59            stress_diffusion: 0.3,
60            stress_decay: 0.1,
61            propagation_speed: 5.0,
62            max_crack_length: 3.0,
63        }
64    }
65}
66
67// ============================================================================
68// Crack Representation
69// ============================================================================
70
71/// A single crack segment (line from start to end with width)
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct Crack {
74    /// Crack start point
75    pub start: (f32, f32, f32),
76    /// Crack end point (current tip)
77    pub end: (f32, f32, f32),
78    /// Crack direction (normalized)
79    pub direction: (f32, f32, f32),
80    /// Current crack length
81    pub length: f32,
82    /// Whether crack is still propagating
83    pub active: bool,
84}
85
86impl Crack {
87    /// SDF of a crack: capsule with given width
88    ///
89    /// Returns signed distance to the crack volume
90    fn distance(&self, x: f32, y: f32, z: f32, width: f32) -> f32 {
91        // Capsule SDF between start and end
92        let (ax, ay, az) = self.start;
93        let (bx, by, bz) = self.end;
94
95        let pax = x - ax;
96        let pay = y - ay;
97        let paz = z - az;
98        let b_ax = bx - ax;
99        let b_ay = by - ay;
100        let b_az = bz - az;
101
102        let ba_len_sq = b_az.mul_add(b_az, b_ax.mul_add(b_ax, b_ay * b_ay));
103        let h = if ba_len_sq > 1e-10 {
104            (paz.mul_add(b_az, pax.mul_add(b_ax, pay * b_ay)) / ba_len_sq).clamp(0.0, 1.0)
105        } else {
106            0.0
107        };
108
109        let dx = b_ax.mul_add(-h, pax);
110        let dy = b_ay.mul_add(-h, pay);
111        let dz = b_az.mul_add(-h, paz);
112
113        (dx * dx + dy * dy + dz * dz).sqrt() - width
114    }
115}
116
117// ============================================================================
118// Fracture Modifier
119// ============================================================================
120
121/// Fracture modifier: stress accumulation and crack propagation
122pub struct FractureModifier {
123    /// Configuration
124    pub config: FractureConfig,
125    /// Accumulated stress field
126    pub stress: ScalarField3D,
127    /// Active cracks
128    pub cracks: Vec<Crack>,
129    /// Whether modifier is enabled
130    pub enabled: bool,
131}
132
133impl FractureModifier {
134    /// Create a new fracture modifier
135    #[must_use]
136    pub fn new(
137        config: FractureConfig,
138        resolution: usize,
139        min: (f32, f32, f32),
140        max: (f32, f32, f32),
141    ) -> Self {
142        Self {
143            config,
144            stress: ScalarField3D::new(resolution, resolution, resolution, min, max),
145            cracks: Vec::new(),
146            enabled: true,
147        }
148    }
149
150    /// Apply stress at a point (from impact or load)
151    pub fn apply_stress_at(&mut self, x: f32, y: f32, z: f32, amount: f32, radius: f32) {
152        self.stress.splat(x, y, z, amount, radius);
153    }
154
155    /// Get stress at a point
156    #[must_use]
157    pub fn stress_at(&self, x: f32, y: f32, z: f32) -> f32 {
158        self.stress.sample(x, y, z)
159    }
160
161    /// Number of active cracks
162    #[must_use]
163    pub fn active_crack_count(&self) -> usize {
164        self.cracks.iter().filter(|c| c.active).count()
165    }
166
167    /// Check for new fracture seeds where stress exceeds threshold
168    fn check_fracture_seeds(&mut self) {
169        if self.cracks.len() >= self.config.max_cracks {
170            return;
171        }
172
173        let threshold = self.config.fracture_toughness;
174        let nx = self.stress.nx;
175        let ny = self.stress.ny;
176        let nz = self.stress.nz;
177
178        for iz in 0..nz {
179            for iy in 0..ny {
180                for ix in 0..nx {
181                    if self.cracks.len() >= self.config.max_cracks {
182                        return;
183                    }
184
185                    let s = self.stress.get(ix, iy, iz);
186                    if s > threshold {
187                        let wx = self.stress.min.0
188                            + ix as f32 * (self.stress.max.0 - self.stress.min.0)
189                                / (nx - 1).max(1) as f32;
190                        let wy = self.stress.min.1
191                            + iy as f32 * (self.stress.max.1 - self.stress.min.1)
192                                / (ny - 1).max(1) as f32;
193                        let wz = self.stress.min.2
194                            + iz as f32 * (self.stress.max.2 - self.stress.min.2)
195                                / (nz - 1).max(1) as f32;
196
197                        // Don't seed near existing cracks
198                        let too_close = self.cracks.iter().any(|c| {
199                            let dx = c.start.0 - wx;
200                            let dy = c.start.1 - wy;
201                            let dz = c.start.2 - wz;
202                            (dx * dx + dy * dy + dz * dz).sqrt() < self.config.crack_width * 10.0
203                        });
204
205                        if !too_close {
206                            // Crack direction: along stress gradient (perpendicular to max stress)
207                            let (gx, gy, gz) = self.stress.gradient(wx, wy, wz);
208                            let glen = gz.mul_add(gz, gx.mul_add(gx, gy * gy)).sqrt();
209
210                            // Perpendicular to gradient (crack runs along stress contour)
211                            let (dx, dy, dz) = if glen > 1e-5 {
212                                // LIMITATION(COV-FRACT-057): Cross with up vector for horizontal crack tendency
213                                // Cross with up vector for horizontal crack tendency
214                                let cx = gy.mul_add(0.0, -(gz * 1.0));
215                                let cy = gz.mul_add(0.0, -(gx * 0.0));
216                                let cz = gx.mul_add(1.0, -(gy * 0.0));
217                                let clen = (cx * cx + cy * cy + cz * cz).sqrt();
218                                if clen > 1e-5 {
219                                    (cx / clen, cy / clen, cz / clen)
220                                } else {
221                                    (1.0, 0.0, 0.0)
222                                }
223                            } else {
224                                // Random-ish direction based on position
225                                let hash =
226                                    ((ix * 73856093) ^ (iy * 19349663) ^ (iz * 83492791)) as f32;
227                                let angle = hash * 0.0001;
228                                (
229                                    crate::det_math::cos(angle),
230                                    0.0,
231                                    crate::det_math::sin(angle),
232                                )
233                            };
234
235                            self.cracks.push(Crack {
236                                start: (wx, wy, wz),
237                                end: (wx, wy, wz),
238                                direction: (dx, dy, dz),
239                                length: 0.0,
240                                active: true,
241                            });
242
243                            // Consume stress at seed point
244                            self.stress.set(ix, iy, iz, 0.0);
245                        }
246                    }
247                }
248            }
249        }
250    }
251
252    /// Propagate active cracks
253    fn propagate_cracks(&mut self, dt: f32) {
254        let speed = self.config.propagation_speed;
255        let max_len = self.config.max_crack_length;
256
257        for crack in &mut self.cracks {
258            if !crack.active {
259                continue;
260            }
261
262            let growth = speed * dt;
263            crack.length += growth;
264
265            if crack.length >= max_len {
266                crack.length = max_len;
267                crack.active = false;
268            }
269
270            // Extend tip
271            crack.end = (
272                crack.direction.0.mul_add(crack.length, crack.start.0),
273                crack.direction.1.mul_add(crack.length, crack.start.1),
274                crack.direction.2.mul_add(crack.length, crack.start.2),
275            );
276        }
277    }
278}
279
280impl PhysicsModifier for FractureModifier {
281    #[inline]
282    fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
283        if !self.enabled || self.cracks.is_empty() {
284            return original_dist;
285        }
286
287        let width = self.config.crack_width;
288        let mut d = original_dist;
289
290        for crack in &self.cracks {
291            if crack.length < 1e-5 {
292                continue;
293            }
294            let crack_dist = crack.distance(x, y, z, width);
295            // CSG subtraction: max(original, -crack)
296            // Only subtract where inside the original shape
297            if d < width * 2.0 {
298                d = crate::det_math::max(d, -crack_dist);
299            }
300        }
301
302        d
303    }
304
305    fn update(&mut self, dt: f32) {
306        if !self.enabled {
307            return;
308        }
309
310        // 1. Diffuse stress
311        self.stress.diffuse(dt, self.config.stress_diffusion);
312
313        // 2. Decay stress
314        self.stress.decay(self.config.stress_decay, dt);
315
316        // 3. Check for new fracture seeds
317        self.check_fracture_seeds();
318
319        // 4. Propagate existing cracks
320        self.propagate_cracks(dt);
321    }
322
323    fn name(&self) -> &'static str {
324        "fracture"
325    }
326
327    fn is_active(&self) -> bool {
328        self.enabled
329    }
330}
331
332// ============================================================================
333// World participant
334// ============================================================================
335
336impl FractureModifier {
337    /// Snapshot tag of this type as a world participant: `"FRAC"`, the four
338    /// ASCII bytes read big endian. Never changes.
339    pub const PARTICIPANT_KIND: ParticipantKind =
340        ParticipantKind::new(u32::from_be_bytes(*b"FRAC"));
341
342    fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
343        let mut r = StateReader::new(bytes)?;
344        let config = FractureConfig {
345            fracture_toughness: r.f32()?,
346            crack_width: r.f32()?,
347            max_cracks: r.usize()?,
348            stress_diffusion: r.f32()?,
349            stress_decay: r.f32()?,
350            propagation_speed: r.f32()?,
351            max_crack_length: r.f32()?,
352        };
353        let enabled = r.bool()?;
354        let stress = r.field()?;
355        let n = r.count(10 * 4 + 1)?;
356        let mut cracks = Vec::with_capacity(n);
357        for _ in 0..n {
358            cracks.push(Crack {
359                start: r.vec3()?,
360                end: r.vec3()?,
361                direction: r.vec3()?,
362                length: r.f32()?,
363                active: r.bool()?,
364            });
365        }
366        r.finish()?;
367        Ok(Self {
368            config,
369            stress,
370            cracks,
371            enabled,
372        })
373    }
374}
375
376/// One `update(h)` per world substep (`h` converted with
377/// [`Fix128::to_f32`]); see the module documentation of
378/// [`crate::sim_modifier`] for the payload and what is not coupled yet.
379///
380/// Observations: channel 0 the highest cell stress (absent for a grid
381/// without cells), channel 1 the number of cracks, channel 2 the number of
382/// cracks still growing.
383impl Participant for FractureModifier {
384    fn kind(&self) -> ParticipantKind {
385        Self::PARTICIPANT_KIND
386    }
387
388    fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
389        PhysicsModifier::update(self, h.to_f32());
390        Ok(())
391    }
392
393    fn observe(&self, out: &mut ObservationSink) {
394        observe_max(out, 0, &self.stress);
395        out.push(1, Fix128::from_int(self.cracks.len() as i64));
396        let growing = self.cracks.iter().filter(|c| c.active).count();
397        out.push(2, Fix128::from_int(growing as i64));
398    }
399
400    fn write_state(&self, out: &mut Vec<u8>) {
401        let mut w = StateWriter::new(out);
402        let c = &self.config;
403        w.f32(c.fracture_toughness);
404        w.f32(c.crack_width);
405        w.usize(c.max_cracks);
406        w.f32(c.stress_diffusion);
407        w.f32(c.stress_decay);
408        w.f32(c.propagation_speed);
409        w.f32(c.max_crack_length);
410        w.bool(self.enabled);
411        w.field(&self.stress);
412        w.usize(self.cracks.len());
413        for k in &self.cracks {
414            w.vec3(k.start);
415            w.vec3(k.end);
416            w.vec3(k.direction);
417            w.f32(k.length);
418            w.bool(k.active);
419        }
420    }
421
422    fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
423        Self::decode_state(bytes).map(drop)
424    }
425
426    fn read_state(&mut self, bytes: &[u8]) {
427        match Self::decode_state(bytes) {
428            Ok(state) => *self = state,
429            Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
430        }
431    }
432}
433
434// ============================================================================
435// Tests
436// ============================================================================
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_fracture_no_stress() {
444        let config = FractureConfig::default();
445        let modifier = FractureModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
446
447        let d = modifier.modify_distance(0.0, 0.0, 0.0, -0.5);
448        assert!((d - (-0.5)).abs() < 0.01, "No stress = no cracks");
449        assert_eq!(modifier.active_crack_count(), 0);
450    }
451
452    #[test]
453    fn test_fracture_crack_creation() {
454        let config = FractureConfig {
455            fracture_toughness: 10.0,
456            ..Default::default()
457        };
458        let mut modifier = FractureModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
459
460        // Apply massive stress (radius must cover grid cells)
461        modifier.apply_stress_at(0.0, 0.0, 0.0, 500.0, 1.5);
462
463        // Step to trigger crack
464        modifier.update(0.016);
465
466        assert!(
467            !modifier.cracks.is_empty(),
468            "High stress should create cracks"
469        );
470    }
471
472    #[test]
473    fn test_crack_propagation() {
474        let config = FractureConfig {
475            fracture_toughness: 5.0,
476            propagation_speed: 10.0,
477            max_crack_length: 1.0,
478            ..Default::default()
479        };
480        let mut modifier = FractureModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
481
482        modifier.apply_stress_at(0.0, 0.0, 0.0, 500.0, 1.5);
483        modifier.update(0.016); // Create crack
484
485        let initial_len = modifier.cracks.first().map_or(0.0, |c| c.length);
486
487        // Propagate
488        for _ in 0..10 {
489            modifier.update(0.016);
490        }
491
492        let final_len = modifier.cracks.first().map_or(0.0, |c| c.length);
493        assert!(
494            final_len > initial_len,
495            "Crack should grow: initial={initial_len}, final={final_len}"
496        );
497    }
498
499    #[test]
500    fn test_crack_sdf_subtraction() {
501        let config = FractureConfig {
502            crack_width: 0.1,
503            ..Default::default()
504        };
505        let mut modifier = FractureModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
506
507        // Manually add a crack
508        modifier.cracks.push(Crack {
509            start: (-1.0, 0.0, 0.0),
510            end: (1.0, 0.0, 0.0),
511            direction: (1.0, 0.0, 0.0),
512            length: 2.0,
513            active: false,
514        });
515
516        // Point near the crack should be affected
517        let d = modifier.modify_distance(0.0, 0.0, 0.0, -0.05);
518        // Original was inside (-0.05), crack distance at (0,0,0) should be near -0.1
519        // max(-0.05, -(-0.1)) = max(-0.05, 0.1) = 0.1
520        assert!(d > -0.05, "Crack should cut into the SDF, got {d}");
521    }
522
523    fn seeding_config(max_cracks: usize) -> FractureConfig {
524        FractureConfig {
525            fracture_toughness: 50.0,
526            crack_width: 0.01,
527            max_cracks,
528            stress_diffusion: 0.0,
529            stress_decay: 0.0,
530            propagation_speed: 4.0,
531            max_crack_length: 10.0,
532        }
533    }
534
535    /// oracle: on a one-cell grid at `min = (1, 2, 3)` the stress 100 is above
536    /// the toughness 50, so one update seeds a crack at the cell, consumes the
537    /// stress (0) and, with a flat stress (no gradient) at cell `(0, 0, 0)`,
538    /// runs it along `(cos 0, 0, sin 0) = (1, 0, 0)`; propagation `4·(1/4)`
539    /// gives length 1 and tip `(2, 2, 3)`.
540    #[test]
541    fn one_update_seeds_and_grows_a_crack() {
542        let mut m = FractureModifier::new(seeding_config(4), 1, (1.0, 2.0, 3.0), (2.0, 3.0, 4.0));
543        m.stress.data[0] = 100.0;
544        m.update(0.25);
545        assert_eq!(m.cracks.len(), 1);
546        let c = m.cracks[0];
547        assert_eq!(c.start, (1.0, 2.0, 3.0));
548        assert_eq!(c.direction, (1.0, 0.0, 0.0));
549        assert_eq!(c.length, 1.0);
550        assert_eq!(c.end, (2.0, 2.0, 3.0));
551        assert!(c.active);
552        assert_eq!(m.stress.data[0], 0.0);
553    }
554
555    /// oracle: with every one of the 8 cells over the toughness and the cells
556    /// 1 apart (well beyond `10 · width`), `max_cracks` caps the seeds: 1
557    /// crack for a cap of 1, none for a cap of 0 (and the stress is then
558    /// left untouched).
559    #[test]
560    fn max_cracks_caps_the_seeds() {
561        let mut m = FractureModifier::new(seeding_config(1), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
562        m.stress.data.fill(100.0);
563        m.update(0.25);
564        assert_eq!(m.cracks.len(), 1);
565
566        let mut none =
567            FractureModifier::new(seeding_config(0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
568        none.stress.data.fill(100.0);
569        none.update(0.25);
570        assert!(none.cracks.is_empty());
571        assert!(none.stress.data.iter().all(|&s| s == 100.0));
572    }
573
574    /// oracle: a crack growing `4·1 = 4` past the cap 3 stops at length 3,
575    /// becomes inactive and its tip sits 3 along its direction; an inactive
576    /// crack does not grow. A disabled modifier leaves the distance and the
577    /// cracks as they are.
578    #[test]
579    fn crack_stops_at_the_cap_and_disabled_is_inert() {
580        let mut m = FractureModifier::new(
581            FractureConfig {
582                max_crack_length: 3.0,
583                ..seeding_config(4)
584            },
585            1,
586            (0.0, 0.0, 0.0),
587            (1.0, 1.0, 1.0),
588        );
589        m.cracks.push(Crack {
590            start: (0.0, 0.0, 0.0),
591            end: (0.0, 0.0, 0.0),
592            direction: (0.0, 1.0, 0.0),
593            length: 0.0,
594            active: true,
595        });
596        m.update(1.0);
597        assert_eq!(m.cracks[0].length, 3.0);
598        assert!(!m.cracks[0].active);
599        assert_eq!(m.cracks[0].end, (0.0, 3.0, 0.0));
600        m.update(1.0);
601        assert_eq!(m.cracks[0].length, 3.0);
602
603        m.enabled = false;
604        assert!(!m.is_active());
605        assert_eq!(m.modify_distance(0.0, 1.0, 0.0, -0.5), -0.5);
606        m.stress.data[0] = 100.0;
607        m.update(1.0);
608        assert_eq!(m.cracks.len(), 1);
609        assert_eq!(m.stress.data[0], 100.0);
610    }
611
612    fn sample_modifier() -> FractureModifier {
613        let mut m = FractureModifier::new(
614            FractureConfig {
615                fracture_toughness: 12.5,
616                crack_width: 0.25,
617                max_cracks: 7,
618                stress_diffusion: 0.125,
619                stress_decay: 0.375,
620                propagation_speed: 1.5,
621                max_crack_length: 2.5,
622            },
623            2,
624            (-1.0, -2.0, -3.0),
625            (1.0, 2.0, 3.0),
626        );
627        for (i, v) in m.stress.data.iter_mut().enumerate() {
628            *v = i as f32 * 0.5;
629        }
630        m.cracks.push(Crack {
631            start: (0.5, 0.25, -0.5),
632            end: (1.0, 0.25, -0.5),
633            direction: (1.0, 0.0, 0.0),
634            length: 0.5,
635            active: true,
636        });
637        m.cracks.push(Crack {
638            start: (0.0, 0.0, 0.0),
639            end: (0.0, 0.0, 2.5),
640            direction: (0.0, 0.0, 1.0),
641            length: 2.5,
642            active: false,
643        });
644        m.enabled = false;
645        m
646    }
647
648    /// oracle: the participant kind is `"FRAC"` read big endian; a payload
649    /// written by `write_state` is accepted and read back into a different
650    /// modifier, which then has the original config, flag, stress and cracks
651    /// and writes the same bytes. Observations: channel 0 the largest stress
652    /// (cell 7: 3.5), channel 1 the cracks (2), channel 2 the growing ones (1).
653    #[test]
654    fn participant_state_round_trip_and_observations() {
655        assert_eq!(
656            FractureModifier::PARTICIPANT_KIND,
657            ParticipantKind::new(0x4652_4143)
658        );
659        let src = sample_modifier();
660        assert_eq!(Participant::kind(&src), FractureModifier::PARTICIPANT_KIND);
661        let mut bytes = Vec::new();
662        src.write_state(&mut bytes);
663        assert_eq!(src.check_state(&bytes), Ok(()));
664        let mut dst = FractureModifier::new(
665            FractureConfig::default(),
666            1,
667            (0.0, 0.0, 0.0),
668            (1.0, 1.0, 1.0),
669        );
670        dst.read_state(&bytes);
671        assert_eq!(dst.config, src.config);
672        assert!(!dst.enabled);
673        assert_eq!(dst.stress.data, src.stress.data);
674        assert_eq!(dst.cracks, src.cracks);
675        let mut again = Vec::new();
676        dst.write_state(&mut again);
677        assert_eq!(again, bytes);
678
679        let mut sink = ObservationSink::new();
680        dst.observe(&mut sink);
681        assert_eq!(
682            sink.values(),
683            &[
684                (0, Fix128::from_ratio(7, 2)),
685                (1, Fix128::from_int(2)),
686                (2, Fix128::ONE)
687            ]
688        );
689    }
690
691    /// oracle: a crack's `active` flag is the last byte of the payload; a
692    /// value other than 0/1 is refused, as are a payload one byte short or
693    /// long, and a crack count no payload of this length can hold.
694    #[test]
695    fn participant_state_refusals() {
696        let src = sample_modifier();
697        let mut bytes = Vec::new();
698        src.write_state(&mut bytes);
699        let last = bytes.len() - 1;
700        assert_eq!(bytes[last], 0);
701        let mut bad = bytes.clone();
702        bad[last] = 3;
703        assert_eq!(src.check_state(&bad), Err(StateError::InvalidValue));
704        assert!(matches!(
705            src.check_state(&bytes[..last]),
706            Err(StateError::Length { .. })
707        ));
708        let mut long = bytes.clone();
709        long.push(0);
710        assert!(matches!(
711            src.check_state(&long),
712            Err(StateError::Length { .. })
713        ));
714        // the crack count sits right before the two cracks of 41 bytes each
715        let at = bytes.len() - 2 * 41 - 8;
716        assert_eq!(bytes[at..at + 8], 2_u64.to_le_bytes());
717        let mut many = bytes;
718        many[at..at + 8].copy_from_slice(&1000_u64.to_le_bytes());
719        assert!(matches!(
720            src.check_state(&many),
721            Err(StateError::Length { .. })
722        ));
723    }
724
725    #[test]
726    #[should_panic(expected = "read_state called with a payload check_state refuses")]
727    fn participant_read_state_panics_on_a_refused_payload() {
728        let mut m = sample_modifier();
729        m.read_state(&[1, 0, 0, 0]);
730    }
731
732    /// oracle: in a world of one substep `dt = 1/4`, the participant runs the
733    /// same seeding update: one crack, stress consumed (channel 0 reads 0),
734    /// one crack still growing.
735    #[test]
736    fn participant_substep_in_a_world() {
737        let mut m = FractureModifier::new(seeding_config(4), 1, (1.0, 2.0, 3.0), (2.0, 3.0, 4.0));
738        m.stress.data[0] = 100.0;
739        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
740            substeps: 1,
741            ..Default::default()
742        });
743        world.add_participant(Box::new(m)).expect("register");
744        world.step(Fix128::from_ratio(1, 4));
745        let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
746        else {
747            panic!("observation");
748        };
749        assert_eq!(
750            sink.values(),
751            &[(0, Fix128::ZERO), (1, Fix128::ONE), (2, Fix128::ONE)]
752        );
753    }
754}