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}