597d6dde2d
- Move _clock.set_time(0.) inside the 'if not server' block in NetworkTimeSynchronizer.start() so the server's SystemClock isn't reset to zero — fixes -1.78 billion second offset panic on client - Skip spawning peer 1's avatar on clients (the server replicates all avatars; spawning peer 1 locally creates a duplicate that the dedicated server doesn't have)
285 lines
9.1 KiB
GDScript
285 lines
9.1 KiB
GDScript
extends Node
|
|
class_name _NetworkTimeSynchronizer
|
|
|
|
## Continuously synchronizes time to the host's remote clock.
|
|
##
|
|
## Make sure to read the [i]NetworkTimeSynchronizer Guide[/i] to understand the
|
|
## different clocks that the class docs refer to.
|
|
##
|
|
## @tutorial(NetworkTimeSynchronizer Guide): https://foxssake.github.io/netfox/netfox/guides/network-time-synchronizer/
|
|
|
|
## The minimum time in seconds between two sync samples.
|
|
##
|
|
## See [member sync_interval]
|
|
const MIN_SYNC_INTERVAL := 0.1
|
|
|
|
## Time between sync samples, in seconds.
|
|
## Cannot be less than [member MIN_SYNC_INTERVAL]
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var sync_interval: float:
|
|
get:
|
|
return maxf(
|
|
_sync_interval,
|
|
MIN_SYNC_INTERVAL
|
|
)
|
|
set(v):
|
|
push_error("Trying to set read-only variable sync_interval")
|
|
|
|
## Number of measurements ( samples ) to use for time synchronization.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var sync_samples: int:
|
|
get:
|
|
return _sync_samples
|
|
set(v):
|
|
push_error("Trying to set read-only variable sync_samples")
|
|
|
|
## Number of iterations to nudge towards the host's remote clock.
|
|
##
|
|
## Lower values result in more aggressive changes in clock and may be more
|
|
## sensitive to jitter. Larger values may end up approaching the remote clock
|
|
## too slowly.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var adjust_steps: int:
|
|
get:
|
|
return _adjust_steps
|
|
set(v):
|
|
push_error("Trying to set read-only variable adjust_steps")
|
|
|
|
## Largest tolerated offset from the host's remote clock before panicking.
|
|
##
|
|
## Once this threshold is reached, the clock will be reset to the remote clock's
|
|
## value, and the nudge process will start from scratch.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var panic_threshold: float:
|
|
get:
|
|
return _panic_threshold
|
|
set(v):
|
|
push_error("Trying to set read-only variable panic_threshold")
|
|
|
|
## Measured roundtrip time measured to the host.
|
|
##
|
|
## This value is calculated from multiple samples. The actual roundtrip times
|
|
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var rtt: float:
|
|
get:
|
|
return _rtt
|
|
set(v):
|
|
push_error("Trying to set read-only variable rtt")
|
|
|
|
## Measured jitter in the roundtrip time to the host remote.
|
|
##
|
|
## This value is calculated from multiple samples. The actual roundtrip times
|
|
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var rtt_jitter: float:
|
|
get:
|
|
return _rtt_jitter
|
|
set(v):
|
|
push_error("Trying to set read-only variable rtt_jitter")
|
|
|
|
## Estimated offset from the host's remote clock.
|
|
##
|
|
## Positive values mean that the host's remote clock is ahead of ours, while
|
|
## negative values mean that our clock is behind the host's remote.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var remote_offset: float:
|
|
get:
|
|
return _offset
|
|
set(v):
|
|
push_error("Trying to set read-only variable remote_offset")
|
|
|
|
# Settings
|
|
var _sync_interval: float = ProjectSettings.get_setting(&"netfox/time/sync_interval", 0.25)
|
|
var _sync_samples: int = ProjectSettings.get_setting(&"netfox/time/sync_samples", 8)
|
|
var _adjust_steps: int =ProjectSettings.get_setting(&"netfox/time/sync_adjust_steps", 8)
|
|
var _panic_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 2.)
|
|
|
|
var _active: bool = false
|
|
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTimeSynchronizer")
|
|
|
|
# Samples are stored in a ring buffer
|
|
var _sample_buffer: _RingBuffer
|
|
var _sample_idx: int = 0
|
|
var _awaiting_samples: Dictionary = {}
|
|
|
|
var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new()
|
|
var _offset: float = 0.
|
|
var _rtt: float = 0.
|
|
var _rtt_jitter: float = 0.
|
|
|
|
@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
|
|
@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
|
|
@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
|
|
@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
|
|
|
|
## Emitted after the initial time sync.
|
|
##
|
|
## At the start of the game, clients request an initial timestamp to kickstart
|
|
## their time sync loop. This event is emitted once that initial timestamp is
|
|
## received.
|
|
signal on_initial_sync()
|
|
|
|
## Emitted when clocks get overly out of sync and a time sync panic occurs.
|
|
##
|
|
## Panic means that the difference between clocks is too large. The time sync
|
|
## will reset the clock to the remote clock's time and restart the time sync loop
|
|
## from there.
|
|
## [br][br]
|
|
## Use this event in case you need to react to clock changes in your game.
|
|
signal on_panic(offset: float)
|
|
|
|
## Start the time synchronization loop.
|
|
##
|
|
## Starting multiple times has no effect.
|
|
func start() -> void:
|
|
if _active:
|
|
return
|
|
|
|
if not multiplayer.is_server():
|
|
_clock.set_time(0.)
|
|
_active = true
|
|
_sample_idx = 0
|
|
_sample_buffer = _RingBuffer.new(sync_samples)
|
|
|
|
_cmd_req_time.send(PackedByteArray(), 1)
|
|
|
|
## Stop the time synchronization loop.
|
|
func stop() -> void:
|
|
_active = false
|
|
|
|
## Get the current time from the reference clock.
|
|
##
|
|
## Returns a timestamp in seconds, with a fractional part for extra precision.
|
|
func get_time() -> float:
|
|
return _clock.get_time()
|
|
|
|
func _loop() -> void:
|
|
_logger.info("Time sync loop started! Initial timestamp: %ss", [_clock.get_time()])
|
|
on_initial_sync.emit()
|
|
|
|
while _active:
|
|
if multiplayer.is_server():
|
|
return stop()
|
|
|
|
var sample := NetworkClockSample.new()
|
|
_awaiting_samples[_sample_idx] = sample
|
|
|
|
sample.ping_sent = _clock.get_time()
|
|
_cmd_ping.send(var_to_bytes(_sample_idx), 1)
|
|
|
|
_sample_idx += 1
|
|
|
|
await get_tree().create_timer(sync_interval).timeout
|
|
|
|
func _discipline_clock() -> void:
|
|
var sorted_samples := _sample_buffer.get_data()
|
|
if sorted_samples.is_empty():
|
|
# Should never happen
|
|
_logger.warning("Trying to discipline the clock with no samples available!")
|
|
return
|
|
|
|
# Sort samples by latency
|
|
sorted_samples.sort_custom(
|
|
func(a: NetworkClockSample, b: NetworkClockSample):
|
|
return a.get_rtt() < b.get_rtt()
|
|
)
|
|
|
|
_logger.trace("Using sorted samples: \n%s", [
|
|
"\n".join(sorted_samples.map(func(it: NetworkClockSample): return "\t" + it.to_string() + " (%.4fs)" % [get_time() - it.ping_sent]))
|
|
])
|
|
|
|
# Calculate rtt bounds
|
|
var rtt_min := sorted_samples.front().get_rtt() as float
|
|
var rtt_max := sorted_samples.back().get_rtt() as float
|
|
_rtt = (rtt_max + rtt_min) / 2.
|
|
_rtt_jitter = (rtt_max - rtt_min) / 2.
|
|
|
|
# Calculate offset
|
|
var offset := 0.
|
|
var offsets := sorted_samples.map(func(it): return it.get_offset())
|
|
var offset_weight := 0.
|
|
for i in range(offsets.size()):
|
|
var w = log(1 + sorted_samples[i].get_rtt())
|
|
offset += offsets[i] * w
|
|
offset_weight += w
|
|
|
|
if not is_zero_approx(offset_weight):
|
|
offset /= offset_weight
|
|
else:
|
|
# RTT is so good it's basically zero, which means offset_weight is zero
|
|
# Use a simple average instead
|
|
offset /= sorted_samples.size()
|
|
|
|
# Panic / Adjust
|
|
if abs(offset) > panic_threshold:
|
|
# Reset clock, throw away all samples
|
|
_clock.adjust(offset)
|
|
_sample_buffer.clear()
|
|
|
|
# Also drop in-flight samples
|
|
_awaiting_samples.clear()
|
|
|
|
_offset = 0.
|
|
|
|
_logger.warning("Offset %ss is above panic threshold %ss! Resetting clock", [offset, panic_threshold])
|
|
on_panic.emit(offset)
|
|
else:
|
|
# Nudge clock towards estimated time
|
|
var nudge := offset / adjust_steps
|
|
_clock.adjust(nudge)
|
|
_logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
|
|
|
|
_offset = offset - nudge
|
|
|
|
func _handle_ping(sender: int, data: PackedByteArray) -> void:
|
|
var idx := bytes_to_var(data) as int
|
|
var ping_received := _clock.get_time()
|
|
|
|
_cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender)
|
|
|
|
func _handle_pong(sender: int, data: PackedByteArray) -> void:
|
|
var args := bytes_to_var(data)
|
|
|
|
var idx := args[0] as int
|
|
var ping_received := args[1] as float
|
|
var pong_sent := args[2] as float
|
|
|
|
var pong_received := _clock.get_time()
|
|
|
|
if not _awaiting_samples.has(idx):
|
|
# Sample was dropped mid-flight during a panic episode
|
|
return
|
|
|
|
var sample := _awaiting_samples[idx] as NetworkClockSample
|
|
sample.ping_received = ping_received
|
|
sample.pong_sent = pong_sent
|
|
sample.pong_received = pong_received
|
|
|
|
_logger.trace("Received sample: %s", [sample])
|
|
|
|
# Once a sample is done, remove from in-flight samples and move to sample buffer
|
|
_awaiting_samples.erase(idx)
|
|
_sample_buffer.push(sample)
|
|
|
|
# Discipline clock based on new sample
|
|
_discipline_clock()
|
|
|
|
func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void:
|
|
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
|
_cmd_set_time.send(var_to_bytes(_clock.get_time()), sender)
|
|
|
|
func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void:
|
|
var timestamp := bytes_to_var(data) as float
|
|
|
|
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
|
_clock.set_time(timestamp)
|
|
_loop()
|