1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{NetcodeError, NetcodeResult};
6
7#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
9#[serde(default, deny_unknown_fields)]
10#[non_exhaustive]
11pub struct TickSyncConfig {
12 pub tick_rate: u32,
14 pub input_delay_ticks: u64,
16 pub smoothing: f64,
18 pub max_round_trip_time: Duration,
20 pub max_offset_correction_ticks: f64,
22}
23
24impl Default for TickSyncConfig {
25 fn default() -> Self {
26 Self {
27 tick_rate: 30,
28 input_delay_ticks: 2,
29 smoothing: 0.2,
30 max_round_trip_time: Duration::from_secs(2),
31 max_offset_correction_ticks: 4.0,
32 }
33 }
34}
35
36impl TickSyncConfig {
37 pub fn validate(&self) -> NetcodeResult<()> {
39 if !(1..=240).contains(&self.tick_rate) {
40 return Err(NetcodeError::InvalidConfig(
41 "Tick rate must be within 1..=240",
42 ));
43 }
44 if !self.smoothing.is_finite() || self.smoothing <= 0.0 || self.smoothing > 1.0 {
45 return Err(NetcodeError::InvalidConfig(
46 "Tick smoothing must be within 0.0..=1.0",
47 ));
48 }
49 if self.max_round_trip_time.is_zero() {
50 return Err(NetcodeError::InvalidConfig(
51 "maximum round-trip time must be positive",
52 ));
53 }
54 if !self.max_offset_correction_ticks.is_finite() || self.max_offset_correction_ticks <= 0.0
55 {
56 return Err(NetcodeError::InvalidConfig(
57 "maximum Tick correction must be finite and positive",
58 ));
59 }
60 Ok(())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub struct TickSyncRequest {
67 pub sequence: u64,
69 pub client_sent_at: Duration,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TickSyncResponse {
76 pub sequence: u64,
78 pub client_sent_at: Duration,
80 pub server_received_at: Duration,
82 pub server_sent_at: Duration,
84 pub server_tick: u64,
86}
87
88impl TickSyncResponse {
89 pub fn sample(
91 self,
92 request: TickSyncRequest,
93 client_received_at: Duration,
94 local_tick: f64,
95 ) -> NetcodeResult<TickSyncSample> {
96 if self.sequence != request.sequence || self.client_sent_at != request.client_sent_at {
97 return Err(NetcodeError::InvalidSample(
98 "Tick response does not match its request",
99 ));
100 }
101 let server_processing_time = self
102 .server_sent_at
103 .checked_sub(self.server_received_at)
104 .ok_or(NetcodeError::InvalidSample(
105 "server send time precedes receive time",
106 ))?;
107 Ok(TickSyncSample {
108 local_tick,
109 server_tick: self.server_tick,
110 client_sent_at: request.client_sent_at,
111 client_received_at,
112 server_processing_time,
113 })
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct TickSyncSample {
120 pub local_tick: f64,
122 pub server_tick: u64,
124 pub client_sent_at: Duration,
126 pub client_received_at: Duration,
128 pub server_processing_time: Duration,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct TickSyncReport {
135 pub round_trip_time: Duration,
137 pub network_round_trip_time: Duration,
139 pub one_way_delay: Duration,
141 pub raw_offset_ticks: f64,
143 pub offset_ticks: f64,
145 pub estimated_server_tick: f64,
147 pub recommended_input_tick: u64,
149}
150
151#[derive(Debug, Clone)]
153pub struct TickSynchronizer {
154 config: TickSyncConfig,
155 offset_ticks: f64,
156 smoothed_network_rtt_seconds: f64,
157 samples: u64,
158}
159
160impl TickSynchronizer {
161 pub fn new(config: TickSyncConfig) -> NetcodeResult<Self> {
163 config.validate()?;
164 Ok(Self {
165 config,
166 offset_ticks: 0.0,
167 smoothed_network_rtt_seconds: 0.0,
168 samples: 0,
169 })
170 }
171
172 pub fn observe(&mut self, sample: TickSyncSample) -> NetcodeResult<TickSyncReport> {
177 if !sample.local_tick.is_finite() || sample.local_tick < 0.0 {
178 return Err(NetcodeError::InvalidSample(
179 "local Tick must be finite and non-negative",
180 ));
181 }
182 let round_trip_time = sample
183 .client_received_at
184 .checked_sub(sample.client_sent_at)
185 .ok_or(NetcodeError::InvalidSample(
186 "client receive time precedes send time",
187 ))?;
188 let network_round_trip_time = round_trip_time
189 .checked_sub(sample.server_processing_time)
190 .ok_or(NetcodeError::InvalidSample(
191 "server processing exceeds total round-trip time",
192 ))?;
193 if network_round_trip_time > self.config.max_round_trip_time {
194 return Err(NetcodeError::InvalidSample(
195 "network round-trip time exceeds the configured maximum",
196 ));
197 }
198
199 let network_seconds = network_round_trip_time.as_secs_f64();
200 let one_way_seconds = network_seconds / 2.0;
201 let estimated_server_tick =
202 sample.server_tick as f64 + one_way_seconds * f64::from(self.config.tick_rate);
203 let raw_offset_ticks = estimated_server_tick - sample.local_tick;
204
205 if self.samples == 0 {
206 self.offset_ticks = raw_offset_ticks;
207 self.smoothed_network_rtt_seconds = network_seconds;
208 } else {
209 let correction = (raw_offset_ticks - self.offset_ticks).clamp(
210 -self.config.max_offset_correction_ticks,
211 self.config.max_offset_correction_ticks,
212 );
213 self.offset_ticks += correction * self.config.smoothing;
214 self.smoothed_network_rtt_seconds +=
215 (network_seconds - self.smoothed_network_rtt_seconds) * self.config.smoothing;
216 }
217 self.samples = self.samples.saturating_add(1);
218
219 Ok(TickSyncReport {
220 round_trip_time,
221 network_round_trip_time,
222 one_way_delay: Duration::from_secs_f64(one_way_seconds),
223 raw_offset_ticks,
224 offset_ticks: self.offset_ticks,
225 estimated_server_tick: self.estimate_server_tick(sample.local_tick),
226 recommended_input_tick: self.recommended_input_tick(sample.local_tick),
227 })
228 }
229
230 pub fn estimate_server_tick(&self, local_tick: f64) -> f64 {
232 if !local_tick.is_finite() {
233 return 0.0;
234 }
235 (local_tick + self.offset_ticks).max(0.0)
236 }
237
238 pub fn recommended_input_tick(&self, local_tick: f64) -> u64 {
240 let estimated = self.estimate_server_tick(local_tick).floor();
241 let current = if estimated >= u64::MAX as f64 {
242 u64::MAX
243 } else {
244 estimated as u64
245 };
246 current
247 .saturating_add(self.config.input_delay_ticks)
248 .saturating_add(1)
249 }
250
251 pub fn offset_ticks(&self) -> f64 {
253 self.offset_ticks
254 }
255
256 pub fn network_round_trip_time(&self) -> Option<Duration> {
258 (self.samples > 0)
259 .then(|| Duration::from_secs_f64(self.smoothed_network_rtt_seconds.max(0.0)))
260 }
261
262 pub fn samples(&self) -> u64 {
264 self.samples
265 }
266}