Skip to main content

alice_physics/
erosion.rs

1//! Erosion Simulation Modifier
2//!
3//! Surface material removal from sustained forces:
4//! - **Wind Erosion**: Airflow strips exposed surfaces
5//! - **Water Erosion**: Flowing water carves channels
6//! - **Chemical Corrosion**: Reactive agents eat material
7//! - **Ablation**: High-speed flow strips surface layer
8//!
9//! # Physics Model
10//!
11//! Empirical rate law, `depth += rate · (1 − hardness) · vⁿ · exposure · dt`,
12//! capped at `max_depth`; accumulated erosion increases the SDF distance
13//! (surface recedes). The per-type constants are fixed in this version:
14//!
15//! | type | `n` | prefactor | note |
16//! |---|---|---|---|
17//! | `Wind` | 1 | 1 | linear in flow speed |
18//! | `Water` | 1 | 1.5 | water carries more momentum per unit speed |
19//! | `Chemical` | 0 | 1 | speed-independent attack |
20//! | `Ablation` | 2 | 1 | kinetic-energy flux |
21//!
22//! Solid-particle erosion measurements give `n ≈ 2–3` (Finnie 1960,
23// LIMITATION(COV-FRACT-085): so `Wind` / `Water` are game-tuned rather than validated
24//! Bitter 1963), so `Wind` / `Water` are game-tuned rather than validated;
25//! `exposure` is re-read every frame and decays with a fixed `5 /s` when the
26//! caller stops supplying it. Making `n`, the prefactor and the exposure
27//! decay configurable adds fields to [`ErosionConfig`] and is scheduled for
28//! 2.0 (see `docs/ROADMAP.md`).
29//!
30//! Author: Moroya Sakamoto
31
32use crate::math::Fix128;
33use crate::sim_field::ScalarField3D;
34use crate::sim_modifier::PhysicsModifier;
35use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
36use crate::world_participant::{
37    ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
38};
39
40// ============================================================================
41// Configuration
42// ============================================================================
43
44/// Erosion type
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ErosionType {
47    /// Wind-driven erosion (velocity-dependent)
48    Wind,
49    /// Water-driven erosion (velocity + gravity channeling)
50    Water,
51    /// Chemical corrosion (uniform or spatially varying)
52    Chemical,
53    /// High-speed ablation (velocity-squared dependent)
54    Ablation,
55}
56
57/// Erosion modifier configuration
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct ErosionConfig {
60    /// Type of erosion
61    pub erosion_type: ErosionType,
62    /// Base erosion rate (distance/second per unit velocity)
63    pub rate: f32,
64    /// Material hardness (0 = soft, 1 = hard). Harder = less erosion.
65    pub hardness: f32,
66    /// Maximum erosion depth
67    pub max_depth: f32,
68    /// Smoothing rate for erosion field (prevents sharp edges)
69    pub smoothing: f32,
70    /// Flow direction (for wind/water erosion)
71    pub flow_direction: (f32, f32, f32),
72    /// Flow speed
73    pub flow_speed: f32,
74}
75
76impl Default for ErosionConfig {
77    fn default() -> Self {
78        Self {
79            erosion_type: ErosionType::Wind,
80            rate: 0.01,
81            hardness: 0.5,
82            max_depth: 2.0,
83            smoothing: 0.1,
84            flow_direction: (1.0, 0.0, 0.0),
85            flow_speed: 1.0,
86        }
87    }
88}
89
90// ============================================================================
91// Erosion Modifier
92// ============================================================================
93
94/// Erosion modifier that removes surface material over time
95pub struct ErosionModifier {
96    /// Configuration
97    pub config: ErosionConfig,
98    /// Accumulated erosion depth (positive = material removed)
99    pub erosion_depth: ScalarField3D,
100    /// Exposure field: how exposed each point is to the erosion agent
101    /// (computed from SDF surface proximity and flow alignment)
102    pub exposure: ScalarField3D,
103    /// Whether modifier is enabled
104    pub enabled: bool,
105}
106
107impl ErosionModifier {
108    /// Create a new erosion modifier
109    #[must_use]
110    pub fn new(
111        config: ErosionConfig,
112        resolution: usize,
113        min: (f32, f32, f32),
114        max: (f32, f32, f32),
115    ) -> Self {
116        Self {
117            config,
118            erosion_depth: ScalarField3D::new(resolution, resolution, resolution, min, max),
119            exposure: ScalarField3D::new(resolution, resolution, resolution, min, max),
120            enabled: true,
121        }
122    }
123
124    /// Set exposure at a point (call from physics contact detection)
125    ///
126    /// `exposure` = how exposed this point is (0..1)
127    pub fn set_exposure_at(&mut self, x: f32, y: f32, z: f32, exposure_value: f32, radius: f32) {
128        self.exposure.splat(x, y, z, exposure_value, radius);
129    }
130
131    /// Mark surface points as exposed based on flow direction alignment
132    ///
133    /// Points where the SDF normal faces the flow receive higher exposure.
134    pub fn compute_exposure_from_normals(
135        &mut self,
136        sdf: &dyn crate::sdf_collider::SdfField,
137        surface_threshold: f32,
138    ) {
139        let nx = self.exposure.nx;
140        let ny = self.exposure.ny;
141        let nz = self.exposure.nz;
142
143        let (fx, fy, fz) = self.config.flow_direction;
144        let flow_len = fz.mul_add(fz, fx.mul_add(fx, fy * fy)).sqrt();
145        let (fx, fy, fz) = if flow_len > 1e-10 {
146            (fx / flow_len, fy / flow_len, fz / flow_len)
147        } else {
148            (1.0, 0.0, 0.0)
149        };
150
151        for iz in 0..nz {
152            for iy in 0..ny {
153                for ix in 0..nx {
154                    let wx = self.exposure.min.0
155                        + ix as f32 * (self.exposure.max.0 - self.exposure.min.0)
156                            / (nx - 1).max(1) as f32;
157                    let wy = self.exposure.min.1
158                        + iy as f32 * (self.exposure.max.1 - self.exposure.min.1)
159                            / (ny - 1).max(1) as f32;
160                    let wz = self.exposure.min.2
161                        + iz as f32 * (self.exposure.max.2 - self.exposure.min.2)
162                            / (nz - 1).max(1) as f32;
163
164                    let dist = sdf.distance(wx, wy, wz).abs();
165
166                    if dist < surface_threshold {
167                        let (snx, sny, snz) = sdf.normal(wx, wy, wz);
168                        // Dot product: how much the surface faces the flow
169                        let alignment = windward_alignment((snx, sny, snz), (fx, fy, fz));
170                        let exp = crate::det_math::max(alignment, 0.0); // Only windward faces
171                        let idx = self.exposure.index(ix, iy, iz);
172                        self.exposure.data[idx] = exp;
173                    }
174                }
175            }
176        }
177    }
178
179    /// Get current erosion depth at a point
180    #[must_use]
181    pub fn erosion_at(&self, x: f32, y: f32, z: f32) -> f32 {
182        self.erosion_depth.sample(x, y, z)
183    }
184
185    /// Compute erosion rate based on type
186    fn compute_rate(&self, exposure: f32) -> f32 {
187        let speed = self.config.flow_speed;
188        let base = self.config.rate * (1.0 - self.config.hardness);
189
190        match self.config.erosion_type {
191            ErosionType::Wind => base * speed * exposure,
192            ErosionType::Water => base * speed * exposure * WATER_PREFACTOR,
193            ErosionType::Chemical => base * exposure, // speed-independent
194            ErosionType::Ablation => base * speed * speed * exposure, // v² (kinetic flux)
195        }
196    }
197}
198
199/// Water erosion prefactor relative to wind at the same speed (module doc
200/// table).
201pub const WATER_PREFACTOR: f32 = 1.5;
202
203/// Exposure decay rate (1/s) applied every `update` when the caller does not
204/// re-supply exposure.
205pub const EXPOSURE_DECAY_PER_S: f32 = 5.0;
206
207impl PhysicsModifier for ErosionModifier {
208    #[inline]
209    fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
210        if !self.enabled {
211            return original_dist;
212        }
213
214        let erosion = self.erosion_depth.sample(x, y, z);
215        original_dist + erosion // Positive erosion = surface recedes
216    }
217
218    fn update(&mut self, dt: f32) {
219        if !self.enabled {
220            return;
221        }
222
223        // Accumulate erosion based on exposure
224        let max_depth = self.config.max_depth;
225        let n = self.exposure.cell_count();
226        for i in 0..n {
227            let exp = self.exposure.data[i];
228            if exp > 0.0 {
229                let rate = self.compute_rate(exp);
230                self.erosion_depth.data[i] =
231                    crate::det_math::min(rate.mul_add(dt, self.erosion_depth.data[i]), max_depth);
232            }
233        }
234
235        // Smooth erosion field (prevents jagged edges)
236        if self.config.smoothing > 0.0 {
237            self.erosion_depth.diffuse(dt, self.config.smoothing);
238        }
239
240        // Decay exposure (needs to be re-applied each frame); the rate is
241        // fixed until `ErosionConfig` gains an `exposure_decay` field (2.0)
242        self.exposure.decay(EXPOSURE_DECAY_PER_S, dt);
243    }
244
245    fn name(&self) -> &'static str {
246        "erosion"
247    }
248
249    fn is_active(&self) -> bool {
250        self.enabled
251    }
252}
253
254// ============================================================================
255// World participant
256// ============================================================================
257
258impl ErosionType {
259    /// Payload tag: `Wind` 0, `Water` 1, `Chemical` 2, `Ablation` 3.
260    const fn state_tag(self) -> u8 {
261        match self {
262            Self::Wind => 0,
263            Self::Water => 1,
264            Self::Chemical => 2,
265            Self::Ablation => 3,
266        }
267    }
268
269    const fn from_state_tag(tag: u8) -> Option<Self> {
270        match tag {
271            0 => Some(Self::Wind),
272            1 => Some(Self::Water),
273            2 => Some(Self::Chemical),
274            3 => Some(Self::Ablation),
275            _ => None,
276        }
277    }
278}
279
280impl ErosionModifier {
281    /// Snapshot tag of this type as a world participant: `"EROS"`, the four
282    /// ASCII bytes read big endian. Never changes.
283    pub const PARTICIPANT_KIND: ParticipantKind =
284        ParticipantKind::new(u32::from_be_bytes(*b"EROS"));
285
286    fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
287        let mut r = StateReader::new(bytes)?;
288        let config = ErosionConfig {
289            erosion_type: ErosionType::from_state_tag(r.u8()?).ok_or(StateError::InvalidValue)?,
290            rate: r.f32()?,
291            hardness: r.f32()?,
292            max_depth: r.f32()?,
293            smoothing: r.f32()?,
294            flow_direction: r.vec3()?,
295            flow_speed: r.f32()?,
296        };
297        let enabled = r.bool()?;
298        let erosion_depth = r.field()?;
299        let exposure = r.field()?;
300        r.finish()?;
301        Ok(Self {
302            config,
303            erosion_depth,
304            exposure,
305            enabled,
306        })
307    }
308}
309
310/// One `update(h)` per world substep (`h` converted with
311/// [`Fix128::to_f32`]); see the module documentation of
312/// [`crate::sim_modifier`] for the payload and what is not coupled yet.
313///
314/// Observations: channel 0 the deepest erosion of a cell (absent for a grid
315/// without cells), channel 1 the total erosion depth.
316impl Participant for ErosionModifier {
317    fn kind(&self) -> ParticipantKind {
318        Self::PARTICIPANT_KIND
319    }
320
321    fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
322        PhysicsModifier::update(self, h.to_f32());
323        Ok(())
324    }
325
326    fn observe(&self, out: &mut ObservationSink) {
327        observe_max(out, 0, &self.erosion_depth);
328        observe_sum(out, 1, &self.erosion_depth);
329    }
330
331    fn write_state(&self, out: &mut Vec<u8>) {
332        let mut w = StateWriter::new(out);
333        let c = &self.config;
334        w.u8(c.erosion_type.state_tag());
335        w.f32(c.rate);
336        w.f32(c.hardness);
337        w.f32(c.max_depth);
338        w.f32(c.smoothing);
339        w.vec3(c.flow_direction);
340        w.f32(c.flow_speed);
341        w.bool(self.enabled);
342        w.field(&self.erosion_depth);
343        w.field(&self.exposure);
344    }
345
346    fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
347        Self::decode_state(bytes).map(drop)
348    }
349
350    fn read_state(&mut self, bytes: &[u8]) {
351        match Self::decode_state(bytes) {
352            Ok(state) => *self = state,
353            Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
354        }
355    }
356}
357
358// ============================================================================
359// Tests
360// ============================================================================
361
362/// `-(n · f)`, how much a surface with normal `n` faces a flow `f`
363///
364/// Written with the negation on the operands, `(-n.z)·f.z + ((-n.x)·f.x +
365/// (-n.y)·f.y)` (two fused multiply-adds), not as the negated result
366/// `-(n.z·f.z + (n.x·f.x + n.y·f.y))`: the optimiser may rewrite a negated
367/// fused multiply-add into one with negated operands, and the two differ in the
368/// sign of a zero result, so the negated-result form gave -0 unoptimized and
369/// +0 optimized on targets with a fused multiply-add instruction (aarch64) when
370/// nonzero terms cancel exactly The operand form has one meaning on every
371/// target and profile: the same bits as the negated result wherever the result
372/// is not zero, and at a zero result the IEEE sum of the negated terms, which
373/// is -0 only when every term `n_i·f_i` is +0 and +0 otherwise (what the
374/// optimizer produced for the negated form) The exposure stored from it is
375/// `max(alignment, 0)`, which is +0 for either zero, so stored values do not
376/// change
377#[inline]
378fn windward_alignment(n: (f32, f32, f32), f: (f32, f32, f32)) -> f32 {
379    (-n.2).mul_add(f.2, (-n.0).mul_add(f.0, (-n.1) * f.1))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    // the negated-result form, with the negation kept out of the optimiser's
387    // reach (black_box), as the reference value
388    fn negated_result(n: (f32, f32, f32), f: (f32, f32, f32)) -> f32 {
389        -core::hint::black_box(n.2.mul_add(f.2, n.0.mul_add(f.0, n.1 * f.1)))
390    }
391
392    #[test]
393    fn windward_alignment_equals_the_negated_dot_product_and_defines_the_sign_of_a_zero_result() {
394        // 0.5 · 0.70710677 is exact, so (0.5, 0, -0.5) against (s, 0, s) cancels
395        // to an exact zero from nonzero terms
396        let s = core::hint::black_box(core::f32::consts::FRAC_1_SQRT_2);
397        let vals = [-1.0, -0.5, -0.0, 0.0, 0.5, 1.0, s, -s];
398        let (mut compared, mut sign_differs, mut cancellations) = (0, 0, 0);
399        for &nx in &vals {
400            for &ny in &vals {
401                for &nz in &vals {
402                    for &fx in &vals {
403                        for &fy in &vals {
404                            for &fz in &vals {
405                                let (n, f) = (core::hint::black_box((nx, ny, nz)), (fx, fy, fz));
406                                let got = windward_alignment(n, f);
407                                let reference = negated_result(n, f);
408                                compared += 1;
409                                if reference != 0.0 {
410                                    assert_eq!(got.to_bits(), reference.to_bits(), "{n:?} {f:?}");
411                                    continue;
412                                }
413                                let terms = [nx * fx, ny * fy, nz * fz];
414                                let all_positive_zero = terms.iter().all(|t| t.to_bits() == 0);
415                                let want = if all_positive_zero { 0x8000_0000 } else { 0 };
416                                assert_eq!(got.to_bits(), want, "{n:?} {f:?}: zero result");
417                                if got.to_bits() != reference.to_bits() {
418                                    sign_differs += 1;
419                                    if terms.iter().any(|t| *t != 0.0) {
420                                        cancellations += 1;
421                                    }
422                                }
423                            }
424                        }
425                    }
426                }
427            }
428        }
429        assert_eq!(compared, 8_usize.pow(6));
430        // the lattice reaches both kinds of zero result where the two forms differ
431        assert!(cancellations > 0, "no exact cancellation of nonzero terms");
432        assert!(sign_differs > cancellations, "no mixed-sign zero terms");
433    }
434    use crate::sdf_collider::ClosureSdf;
435
436    #[test]
437    fn test_erosion_no_exposure() {
438        let config = ErosionConfig::default();
439        let modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
440
441        // No exposure: distance unchanged
442        let d = modifier.modify_distance(1.0, 0.0, 0.0, 0.5);
443        assert!((d - 0.5).abs() < 0.01, "No erosion without exposure");
444    }
445
446    #[test]
447    fn test_erosion_with_exposure() {
448        let config = ErosionConfig {
449            rate: 1.0,
450            hardness: 0.0, // Very soft
451            flow_speed: 1.0,
452            ..Default::default()
453        };
454        let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
455
456        // Set exposure at a point
457        modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
458
459        // Simulate erosion
460        for _ in 0..30 {
461            modifier.update(0.016);
462        }
463
464        let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
465        assert!(erosion > 0.0, "Exposed area should erode, got {erosion}");
466    }
467
468    #[test]
469    fn test_erosion_max_depth() {
470        let config = ErosionConfig {
471            rate: 100.0, // Very fast
472            hardness: 0.0,
473            max_depth: 0.5,
474            flow_speed: 1.0,
475            ..Default::default()
476        };
477        let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
478
479        modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
480
481        for _ in 0..100 {
482            modifier.update(0.016);
483        }
484
485        let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
486        assert!(
487            erosion <= 0.5 + 0.01,
488            "Erosion should be capped at max_depth, got {erosion}"
489        );
490    }
491
492    #[test]
493    fn test_compute_exposure_from_normals() {
494        let sphere = ClosureSdf::new(
495            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
496            |x, y, z| {
497                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
498                if len < 1e-10 {
499                    (0.0, 1.0, 0.0)
500                } else {
501                    (x / len, y / len, z / len)
502                }
503            },
504        );
505
506        let config = ErosionConfig {
507            flow_direction: (-1.0, 0.0, 0.0), // Wind from +X
508            ..Default::default()
509        };
510        let mut modifier = ErosionModifier::new(config, 16, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
511        modifier.compute_exposure_from_normals(&sphere, 0.3);
512
513        // +X face should be exposed (faces into wind)
514        let exp_windward = modifier.exposure.sample(1.0, 0.0, 0.0);
515        // -X face should be sheltered
516        let exp_leeward = modifier.exposure.sample(-1.0, 0.0, 0.0);
517
518        assert!(
519            exp_windward > exp_leeward,
520            "Windward face should be more exposed: windward={exp_windward}, leeward={exp_leeward}"
521        );
522    }
523
524    fn chemical(rate: f32) -> ErosionConfig {
525        ErosionConfig {
526            erosion_type: ErosionType::Chemical,
527            rate,
528            hardness: 0.0,
529            max_depth: 10.0,
530            smoothing: 0.0,
531            ..Default::default()
532        }
533    }
534
535    /// oracle (module doc table): with `base = rate·(1 − hardness)`, wind is
536    /// `base·v·e`, water `1.5·base·v·e`, chemical `base·e` (no speed),
537    /// ablation `base·v²·e`. `rate 2, hardness 1/2, v 3, e 1/2`: base 1, so
538    /// 1.5 / 2.25 / 0.5 / 4.5.
539    #[test]
540    fn compute_rate_per_erosion_type() {
541        for (ty, want) in [
542            (ErosionType::Wind, 1.5_f32),
543            (ErosionType::Water, 2.25),
544            (ErosionType::Chemical, 0.5),
545            (ErosionType::Ablation, 4.5),
546        ] {
547            let m = ErosionModifier::new(
548                ErosionConfig {
549                    erosion_type: ty,
550                    rate: 2.0,
551                    hardness: 0.5,
552                    flow_speed: 3.0,
553                    ..Default::default()
554                },
555                2,
556                (0.0, 0.0, 0.0),
557                (1.0, 1.0, 1.0),
558            );
559            assert!((m.compute_rate(0.5) - want).abs() < 1e-6, "{ty:?}");
560        }
561    }
562
563    /// oracle: a disabled modifier leaves the distance as it is and its
564    /// `update` changes nothing, even with full exposure.
565    #[test]
566    fn disabled_erosion_is_inert() {
567        let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
568        m.erosion_depth.data[0] = 0.25;
569        m.exposure.data[0] = 1.0;
570        m.enabled = false;
571        assert!(!m.is_active());
572        assert_eq!(m.modify_distance(0.0, 0.0, 0.0, 0.5), 0.5);
573        m.update(1.0);
574        assert_eq!(m.erosion_depth.data[0], 0.25);
575        assert_eq!(m.exposure.data[0], 1.0);
576    }
577
578    /// oracle: a zero flow direction falls back to `+x`: on the ground plane
579    /// (`distance = y`, normal `+y`) the windward alignment `−n·f = 0`, so
580    /// every near-surface cell gets exposure 0, the same as for flow `+x`.
581    #[test]
582    fn zero_flow_direction_falls_back_to_plus_x() {
583        let ground = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
584        let mut m = ErosionModifier::new(
585            ErosionConfig {
586                flow_direction: (0.0, 0.0, 0.0),
587                ..Default::default()
588            },
589            3,
590            (-1.0, -1.0, -1.0),
591            (1.0, 1.0, 1.0),
592        );
593        m.exposure.data.fill(7.0);
594        m.compute_exposure_from_normals(&ground, 0.5);
595        // the middle layer (y = 0) is on the surface: exposure 0 there
596        for iz in 0..3 {
597            for ix in 0..3 {
598                let i = m.exposure.index(ix, 1, iz);
599                assert_eq!(m.exposure.data[i], 0.0, "({ix}, 1, {iz})");
600            }
601        }
602        // the layers at y = ±1 are farther than 0.5: untouched
603        assert_eq!(m.exposure.data[m.exposure.index(0, 0, 0)], 7.0);
604    }
605
606    fn sample_modifier(ty: ErosionType) -> ErosionModifier {
607        let mut m = ErosionModifier::new(
608            ErosionConfig {
609                erosion_type: ty,
610                rate: 0.75,
611                hardness: 0.25,
612                max_depth: 3.5,
613                smoothing: 0.125,
614                flow_direction: (0.0, 1.0, -1.0),
615                flow_speed: 2.5,
616            },
617            2,
618            (-1.0, -2.0, -3.0),
619            (1.0, 2.0, 3.0),
620        );
621        for (i, v) in m.erosion_depth.data.iter_mut().enumerate() {
622            *v = i as f32 * 0.5;
623        }
624        m.exposure.data[3] = 0.875;
625        m.enabled = false;
626        m
627    }
628
629    /// oracle: the participant kind is `"EROS"` read big endian; a payload
630    /// written by `write_state` is accepted and read back into a different
631    /// modifier, which then writes the same bytes and has the original
632    /// config, flag and fields, for every erosion type.
633    #[test]
634    fn participant_state_round_trip() {
635        assert_eq!(
636            ErosionModifier::PARTICIPANT_KIND,
637            ParticipantKind::new(0x4552_4f53)
638        );
639        for ty in [
640            ErosionType::Wind,
641            ErosionType::Water,
642            ErosionType::Chemical,
643            ErosionType::Ablation,
644        ] {
645            let src = sample_modifier(ty);
646            assert_eq!(Participant::kind(&src), ErosionModifier::PARTICIPANT_KIND);
647            let mut bytes = Vec::new();
648            src.write_state(&mut bytes);
649            assert_eq!(src.check_state(&bytes), Ok(()));
650            let mut dst = ErosionModifier::new(
651                ErosionConfig::default(),
652                1,
653                (0.0, 0.0, 0.0),
654                (1.0, 1.0, 1.0),
655            );
656            dst.read_state(&bytes);
657            assert_eq!(dst.config, src.config);
658            assert!(!dst.enabled);
659            assert_eq!(dst.erosion_depth.data, src.erosion_depth.data);
660            assert_eq!(dst.exposure.data, src.exposure.data);
661            assert_eq!(
662                (dst.exposure.min, dst.exposure.max),
663                ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
664            );
665            let mut again = Vec::new();
666            dst.write_state(&mut again);
667            assert_eq!(again, bytes);
668        }
669    }
670
671    /// oracle: the erosion type is the byte after the 4-byte version; tag 4
672    /// names no type, a missing last byte or an extra byte is a length error.
673    #[test]
674    fn participant_state_refusals() {
675        let src = sample_modifier(ErosionType::Wind);
676        let mut bytes = Vec::new();
677        src.write_state(&mut bytes);
678        let mut bad_tag = bytes.clone();
679        bad_tag[4] = 4;
680        assert_eq!(src.check_state(&bad_tag), Err(StateError::InvalidValue));
681        assert!(matches!(
682            src.check_state(&bytes[..bytes.len() - 1]),
683            Err(StateError::Length { .. })
684        ));
685        let mut long = bytes.clone();
686        long.push(0);
687        assert!(matches!(
688            src.check_state(&long),
689            Err(StateError::Length { .. })
690        ));
691    }
692
693    #[test]
694    #[should_panic(expected = "read_state called with a payload check_state refuses")]
695    fn participant_read_state_panics_on_a_refused_payload() {
696        let mut m = sample_modifier(ErosionType::Wind);
697        m.read_state(&[1, 0, 0, 0, 9]);
698    }
699
700    /// oracle: in a world of one substep, the participant runs `update(dt)`
701    /// once: chemical erosion with rate 1, hardness 0, exposure 1 on one cell
702    /// and `dt = 1/4` deepens that cell by `1·1·(1/4) = 1/4`, so channel 0
703    /// (deepest cell) and channel 1 (sum of cells) both read 1/4.
704    #[test]
705    fn participant_substep_in_a_world() {
706        let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
707        m.exposure.data[0] = 1.0;
708        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
709            substeps: 1,
710            ..Default::default()
711        });
712        world.add_participant(Box::new(m)).expect("register");
713        world.step(Fix128::from_ratio(1, 4));
714        let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
715        else {
716            panic!("observation");
717        };
718        let quarter = Fix128::from_ratio(1, 4);
719        assert_eq!(sink.values(), &[(0, quarter), (1, quarter)]);
720    }
721}