use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{NetcodeError, NetcodeResult};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct TickSyncConfig {
pub tick_rate: u32,
pub input_delay_ticks: u64,
pub smoothing: f64,
pub max_round_trip_time: Duration,
pub max_offset_correction_ticks: f64,
}
impl Default for TickSyncConfig {
fn default() -> Self {
Self {
tick_rate: 30,
input_delay_ticks: 2,
smoothing: 0.2,
max_round_trip_time: Duration::from_secs(2),
max_offset_correction_ticks: 4.0,
}
}
}
impl TickSyncConfig {
pub fn validate(&self) -> NetcodeResult<()> {
if !(1..=240).contains(&self.tick_rate) {
return Err(NetcodeError::InvalidConfig(
"Tick rate must be within 1..=240",
));
}
if !self.smoothing.is_finite() || self.smoothing <= 0.0 || self.smoothing > 1.0 {
return Err(NetcodeError::InvalidConfig(
"Tick smoothing must be within 0.0..=1.0",
));
}
if self.max_round_trip_time.is_zero() {
return Err(NetcodeError::InvalidConfig(
"maximum round-trip time must be positive",
));
}
if !self.max_offset_correction_ticks.is_finite() || self.max_offset_correction_ticks <= 0.0
{
return Err(NetcodeError::InvalidConfig(
"maximum Tick correction must be finite and positive",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TickSyncRequest {
pub sequence: u64,
pub client_sent_at: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TickSyncResponse {
pub sequence: u64,
pub client_sent_at: Duration,
pub server_received_at: Duration,
pub server_sent_at: Duration,
pub server_tick: u64,
}
impl TickSyncResponse {
pub fn sample(
self,
request: TickSyncRequest,
client_received_at: Duration,
local_tick: f64,
) -> NetcodeResult<TickSyncSample> {
if self.sequence != request.sequence || self.client_sent_at != request.client_sent_at {
return Err(NetcodeError::InvalidSample(
"Tick response does not match its request",
));
}
let server_processing_time = self
.server_sent_at
.checked_sub(self.server_received_at)
.ok_or(NetcodeError::InvalidSample(
"server send time precedes receive time",
))?;
Ok(TickSyncSample {
local_tick,
server_tick: self.server_tick,
client_sent_at: request.client_sent_at,
client_received_at,
server_processing_time,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TickSyncSample {
pub local_tick: f64,
pub server_tick: u64,
pub client_sent_at: Duration,
pub client_received_at: Duration,
pub server_processing_time: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TickSyncReport {
pub round_trip_time: Duration,
pub network_round_trip_time: Duration,
pub one_way_delay: Duration,
pub raw_offset_ticks: f64,
pub offset_ticks: f64,
pub estimated_server_tick: f64,
pub recommended_input_tick: u64,
}
#[derive(Debug, Clone)]
pub struct TickSynchronizer {
config: TickSyncConfig,
offset_ticks: f64,
smoothed_network_rtt_seconds: f64,
samples: u64,
}
impl TickSynchronizer {
pub fn new(config: TickSyncConfig) -> NetcodeResult<Self> {
config.validate()?;
Ok(Self {
config,
offset_ticks: 0.0,
smoothed_network_rtt_seconds: 0.0,
samples: 0,
})
}
pub fn observe(&mut self, sample: TickSyncSample) -> NetcodeResult<TickSyncReport> {
if !sample.local_tick.is_finite() || sample.local_tick < 0.0 {
return Err(NetcodeError::InvalidSample(
"local Tick must be finite and non-negative",
));
}
let round_trip_time = sample
.client_received_at
.checked_sub(sample.client_sent_at)
.ok_or(NetcodeError::InvalidSample(
"client receive time precedes send time",
))?;
let network_round_trip_time = round_trip_time
.checked_sub(sample.server_processing_time)
.ok_or(NetcodeError::InvalidSample(
"server processing exceeds total round-trip time",
))?;
if network_round_trip_time > self.config.max_round_trip_time {
return Err(NetcodeError::InvalidSample(
"network round-trip time exceeds the configured maximum",
));
}
let network_seconds = network_round_trip_time.as_secs_f64();
let one_way_seconds = network_seconds / 2.0;
let estimated_server_tick =
sample.server_tick as f64 + one_way_seconds * f64::from(self.config.tick_rate);
let raw_offset_ticks = estimated_server_tick - sample.local_tick;
if self.samples == 0 {
self.offset_ticks = raw_offset_ticks;
self.smoothed_network_rtt_seconds = network_seconds;
} else {
let correction = (raw_offset_ticks - self.offset_ticks).clamp(
-self.config.max_offset_correction_ticks,
self.config.max_offset_correction_ticks,
);
self.offset_ticks += correction * self.config.smoothing;
self.smoothed_network_rtt_seconds +=
(network_seconds - self.smoothed_network_rtt_seconds) * self.config.smoothing;
}
self.samples = self.samples.saturating_add(1);
Ok(TickSyncReport {
round_trip_time,
network_round_trip_time,
one_way_delay: Duration::from_secs_f64(one_way_seconds),
raw_offset_ticks,
offset_ticks: self.offset_ticks,
estimated_server_tick: self.estimate_server_tick(sample.local_tick),
recommended_input_tick: self.recommended_input_tick(sample.local_tick),
})
}
pub fn estimate_server_tick(&self, local_tick: f64) -> f64 {
if !local_tick.is_finite() {
return 0.0;
}
(local_tick + self.offset_ticks).max(0.0)
}
pub fn recommended_input_tick(&self, local_tick: f64) -> u64 {
let estimated = self.estimate_server_tick(local_tick).floor();
let current = if estimated >= u64::MAX as f64 {
u64::MAX
} else {
estimated as u64
};
current
.saturating_add(self.config.input_delay_ticks)
.saturating_add(1)
}
pub fn offset_ticks(&self) -> f64 {
self.offset_ticks
}
pub fn network_round_trip_time(&self) -> Option<Duration> {
(self.samples > 0)
.then(|| Duration::from_secs_f64(self.smoothed_network_rtt_seconds.max(0.0)))
}
pub fn samples(&self) -> u64 {
self.samples
}
}