Skip to main content

lightyear_link/
conditioner.rs

1//! Receive-side packet conditioning for simulated network latency, jitter, and loss.
2
3use bevy_reflect::Reflect;
4use core::time::Duration;
5use lightyear_core::time::Instant;
6use lightyear_utils::ready_buffer::ReadyBuffer;
7use rand::RngExt;
8
9/// Configuration for receive-side packet conditioning.
10///
11/// The values describe the local inbound path: a payload inserted into a
12/// [`LinkConditioner`] can be delayed by [`incoming_latency`](Self::incoming_latency),
13/// randomly shifted by [`incoming_jitter`](Self::incoming_jitter), or dropped according to
14/// [`incoming_loss`](Self::incoming_loss).
15///
16/// When modeling a full round trip, use one conditioner on
17/// each peer or call [`half`](Self::half) to derive an approximate one-way configuration from an
18/// end-to-end configuration.
19#[derive(Clone, Debug, Default, Reflect)]
20pub struct LinkConditionerConfig {
21    /// Base delay applied to incoming payloads.
22    ///
23    /// The conditioner currently schedules packets with millisecond precision by using
24    /// [`Duration::as_millis`]. For symmetric client/server simulations this is usually configured
25    /// as half of the desired round-trip time.
26    pub incoming_latency: Duration,
27    /// Maximum random delay variation applied around [`incoming_latency`](Self::incoming_latency).
28    ///
29    /// For each payload, a random millisecond offset in `[-incoming_jitter, incoming_jitter)` is
30    /// added to the base latency. Negative results are clamped by scheduling the packet at the
31    /// current instant rather than in the past.
32    pub incoming_jitter: Duration,
33    /// Probability that an incoming payload is dropped.
34    ///
35    /// This is expressed as a fraction in the inclusive range `0.0..=1.0`, where `0.0` keeps every
36    /// payload and `1.0` drops every payload. The value is not clamped by the constructor, so callers
37    /// should pass a normalized probability.
38    pub incoming_loss: f32,
39}
40
41/// Generic receive-side packet conditioner.
42///
43/// `LinkConditioner` delays and drops payloads according to a [`LinkConditionerConfig`].
44#[derive(Debug, Clone)]
45pub struct LinkConditioner<P: Eq> {
46    config: LinkConditionerConfig,
47    /// Payloads waiting for their simulated delivery time.
48    ///
49    /// The key is the delivery [`Instant`]. Once that instant is less than or equal to the instant
50    /// passed to the polling code, the payload is ready to be moved into the receive buffer.
51    ///
52    /// This field is public for tests and low-level integrations, but normal link users should
53    /// interact through [`crate::LinkReceiver`].
54    pub time_queue: ReadyBuffer<Instant, P>,
55}
56
57impl<P: Eq> LinkConditioner<P> {
58    /// Creates an empty conditioner using `config`.
59    pub fn new(config: LinkConditionerConfig) -> Self {
60        LinkConditioner {
61            config,
62            time_queue: ReadyBuffer::new(),
63        }
64    }
65
66    /// Applies latency, jitter, and loss to `packet` relative to `instant`.
67    ///
68    /// Dropped packets are discarded immediately. Delivered packets are queued by their simulated
69    /// delivery instant.
70    pub(crate) fn condition_packet(&mut self, packet: P, instant: Instant) {
71        let mut rng = rand::rng();
72        if rng.random_range(0.0..1.0) <= self.config.incoming_loss {
73            return;
74        }
75        let mut latency: i32 = self.config.incoming_latency.as_millis() as i32;
76        let mut packet_timestamp = instant;
77        if self.config.incoming_jitter > Duration::default() {
78            let jitter: i32 = self.config.incoming_jitter.as_millis() as i32;
79            latency += rng.random_range(-jitter..jitter);
80        }
81        if latency > 0 {
82            packet_timestamp += Duration::from_millis(latency as u64);
83        }
84        self.time_queue.push(packet_timestamp, packet);
85    }
86
87    /// Returns the next packet whose delivery instant has elapsed.
88    pub(crate) fn pop_packet(&mut self, instant: Instant) -> Option<P> {
89        self.time_queue.pop_item(&instant).map(|(_, packet)| packet)
90    }
91}
92
93impl LinkConditionerConfig {
94    /// Creates a configuration with explicit latency, jitter, and loss values.
95    ///
96    /// `incoming_latency` is the base receive delay, `incoming_jitter` is the maximum random
97    /// millisecond offset applied around that delay, and `incoming_loss` is the per-payload drop
98    /// probability.
99    pub fn new(incoming_latency: Duration, incoming_jitter: Duration, incoming_loss: f32) -> Self {
100        LinkConditionerConfig {
101            incoming_latency,
102            incoming_jitter,
103            incoming_loss,
104        }
105    }
106
107    /// Returns an approximate one-way half of this configuration.
108    ///
109    /// This divides latency, jitter, and packet-loss probability by two.
110    pub fn half(self) -> Self {
111        LinkConditionerConfig {
112            incoming_latency: self.incoming_latency / 2,
113            incoming_jitter: self.incoming_jitter / 2,
114            incoming_loss: self.incoming_loss / 2.0,
115        }
116    }
117
118    /// Returns a preset for a low-latency, low-loss connection.
119    pub fn good_condition() -> Self {
120        LinkConditionerConfig {
121            incoming_latency: Duration::from_millis(40),
122            incoming_jitter: Duration::from_millis(6),
123            incoming_loss: 0.002,
124        }
125    }
126
127    /// Returns a preset for a typical moderate-latency connection.
128    pub fn average_condition() -> Self {
129        LinkConditionerConfig {
130            incoming_latency: Duration::from_millis(100),
131            incoming_jitter: Duration::from_millis(15),
132            incoming_loss: 0.02,
133        }
134    }
135
136    /// Returns a preset for a high-latency, lossy connection.
137    pub fn poor_condition() -> Self {
138        LinkConditionerConfig {
139            incoming_latency: Duration::from_millis(200),
140            incoming_jitter: Duration::from_millis(30),
141            incoming_loss: 0.10,
142        }
143    }
144}