diff --git a/addons/netfox.extras/plugin.cfg b/addons/netfox.extras/plugin.cfg index f1cb8f27..e603fdc7 100644 --- a/addons/netfox.extras/plugin.cfg +++ b/addons/netfox.extras/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.extras" description="Game-specific utilities for Netfox" author="Tamas Galffy" -version="1.9.2" +version="1.10.0" script="netfox-extras.gd" diff --git a/addons/netfox.internals/logger.gd b/addons/netfox.internals/logger.gd index 1ad00dcc..6865567f 100644 --- a/addons/netfox.internals/logger.gd +++ b/addons/netfox.internals/logger.gd @@ -47,7 +47,7 @@ static func for_extras(p_name: String) -> _NetfoxLogger: static func make_setting(name: String) -> Dictionary: return { "name": name, - "value": LOG_MIN, + "value": LOG_DEBUG, "type": TYPE_INT, "hint": PROPERTY_HINT_ENUM, "hint_string": "All,Trace,Debug,Info,Warning,Error,None" diff --git a/addons/netfox.internals/plugin.cfg b/addons/netfox.internals/plugin.cfg index 485b8ce4..9d936a22 100644 --- a/addons/netfox.internals/plugin.cfg +++ b/addons/netfox.internals/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.internals" description="Shared internals for netfox addons" author="Tamas Galffy" -version="1.9.2" +version="1.10.0" script="plugin.gd" diff --git a/addons/netfox.internals/ring-buffer.gd b/addons/netfox.internals/ring-buffer.gd new file mode 100644 index 00000000..e8d2a6e4 --- /dev/null +++ b/addons/netfox.internals/ring-buffer.gd @@ -0,0 +1,34 @@ +extends RefCounted +class_name _RingBuffer + +var _data: Array +var _capacity: int +var _size: int = 0 +var _head: int = 0 + +func _init(p_capacity: int): + _capacity = p_capacity + _data = [] + _data.resize(p_capacity) + +func push(item): + _data[_head] = item + + _size += 1 + _head = (_head + 1) % _capacity + +func get_data() -> Array: + if _size < _capacity: + return _data.slice(0, _size) + else: + return _data + +func size() -> int: + return _size + +func is_empty() -> bool: + return _size == 0 + +func clear(): + _size = 0 + _head = 0 diff --git a/addons/netfox.noray/plugin.cfg b/addons/netfox.noray/plugin.cfg index 6edfda96..ce6ac964 100644 --- a/addons/netfox.noray/plugin.cfg +++ b/addons/netfox.noray/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.noray" description="Bulletproof your connectivity with noray integration for netfox" author="Tamas Galffy" -version="1.9.2" +version="1.10.0" script="netfox-noray.gd" diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index b4b28d95..6e25e1d2 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -31,8 +31,10 @@ var SETTINGS = [ { # Time to wait between time syncs "name": "netfox/time/sync_interval", - "value": 1.0, - "type": TYPE_FLOAT + "value": 0.25, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "%s,2,or_greater" % [_NetworkTimeSynchronizer.MIN_SYNC_INTERVAL] }, { "name": "netfox/time/sync_samples", @@ -40,6 +42,12 @@ var SETTINGS = [ "type": TYPE_INT }, { + "name": "netfox/time/sync_adjust_steps", + "value": 8, + "type": TYPE_INT + }, + { + # !! Deprecated # Time to wait between time sync samples "name": "netfox/time/sync_sample_interval", "value": 0.1, @@ -50,6 +58,13 @@ var SETTINGS = [ "value": false, "type": TYPE_BOOL }, + { + "name": "netfox/time/max_time_stretch", + "value": 1.25, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "1,2,0.05,or_greater" + }, # Rollback settings { "name": "netfox/rollback/enabled", diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 91706e59..aaebcfed 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -1,16 +1,33 @@ extends Node +class_name _NetworkTimeSynchronizer -## Time between syncs, in seconds. +## 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 ProjectSettings.get_setting("netfox/time/sync_interval", 1.0) + return maxf( + ProjectSettings.get_setting("netfox/time/sync_interval", 0.25), + MIN_SYNC_INTERVAL + ) set(v): push_error("Trying to set read-only variable sync_interval") -## Number of measurements ( samples ) to take to guess latency. -## +## 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: @@ -18,25 +35,95 @@ var sync_samples: int: set(v): push_error("Trying to set read-only variable sync_samples") -## Time between samples in a single sync process. +## 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 ProjectSettings.get_setting("netfox/time/sync_adjust_steps", 8) + 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 sync_sample_interval: float: +var panic_threshold: float: + get: + return ProjectSettings.get_setting("netfox/time/recalibrate_threshold", 2.) + 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 ProjectSettings.get_setting("netfox/time/sync_sample_interval", 0.1) + return _offset set(v): - push_error("Trying to set read-only variable sync_sample_interval") + push_error("Trying to set read-only variable remote_offset") -var _remote_rtt: Dictionary = {} -var _remote_time: Dictionary = {} -var _remote_tick: Dictionary = {} 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 = {} -## Event emitted when a time sync process completes -signal on_sync(server_time: float, server_tick: int, rtt: float) +var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new() +var _offset: float = 0. +var _rtt: float = 0. +var _rtt_jitter: float = 0. -## Event emitted when a response to a ping request arrives. -signal on_ping(peer_id: int, peer_time: float, peer_tick: int) +## 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. ## @@ -44,103 +131,128 @@ signal on_ping(peer_id: int, peer_time: float, peer_tick: int) func start(): if _active: return + + _clock.set_time(0.) - _active = true - _sync_time_loop(sync_interval) + if not multiplayer.is_server(): + _active = true + _sample_idx = 0 + _sample_buffer = _RingBuffer.new(sync_samples) + + _request_timestamp.rpc_id(1) ## Stop the time synchronization loop. func stop(): _active = false -## Get the amount of time passed since Godot has started, in seconds. -func get_real_time(): - return Time.get_ticks_msec() / 1000.0 - -## Estimate the time at the given peer, in seconds. -## -## While this is a coroutine, so it won't block your game, this can take multiple -## seconds, depending on latency, number of samples and sample interval. +## Get the current time from the reference clock. ## -## Returns a triplet of the following: -## [ol] -## last_remote_time - Latest timestamp received from target -## rtt - Estimated roundtrip time to target -## synced_time - Estimated time at target -## [/ol] -func sync_time(id: int) -> Array[float]: - _remote_rtt.clear() - _remote_time.clear() - _remote_tick.clear() +## Returns a timestamp in seconds, with a fractional part for extra precision. +func get_time() -> float: + return _clock.get_time() + +func _loop(): + _logger.info("Time sync loop started! Initial timestamp: %ss" % [_clock.get_time()]) + on_initial_sync.emit() + + while _active: + var sample = NetworkClockSample.new() + _awaiting_samples[_sample_idx] = sample + + sample.ping_sent = _clock.get_time() + _send_ping.rpc_id(1, _sample_idx) + + _sample_idx += 1 + + await get_tree().create_timer(sync_interval).timeout + +func _discipline_clock(): + var sorted_samples := _sample_buffer.get_data() - for i in range(sync_samples): - get_rtt(id, i) - await get_tree().create_timer(sync_sample_interval).timeout + # Sort samples by latency + sorted_samples.sort_custom( + func(a: NetworkClockSample, b: NetworkClockSample): + return a.get_rtt() < b.get_rtt() + ) - # Wait for all samples to run through - while _remote_rtt.size() != sync_samples: - await get_tree().process_frame + _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])) + ]) - var samples = _remote_rtt.values().duplicate() - var last_remote_time = _remote_time.values().max() - samples.sort() - var average = samples.reduce(func(a, b): return a + b) / samples.size() + # Calculate rtt bounds + var rtt_min = sorted_samples.front().get_rtt() + var rtt_max = sorted_samples.back().get_rtt() + _rtt = (rtt_max + rtt_min) / 2. + _rtt_jitter = (rtt_max - rtt_min) / 2. - # Reject samples that are too far away from average - var deviation_threshold = 1 - samples = samples.filter(func(s): return (s - average) / average < deviation_threshold) + # 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 - # Return NAN if none of the samples fit within threshold - # Should be rare, but technically possible - if samples.is_empty(): - return [NAN, NAN, NAN] + offset /= offset_weight - average = samples.reduce(func(a, b): return a + b) / samples.size() - var rtt = average - var latency = rtt / 2.0 + # 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 - return [last_remote_time, rtt, last_remote_time + latency] +@rpc("any_peer", "call_remote", "unreliable") +func _send_ping(idx: int): + var ping_received = _clock.get_time() + var sender = multiplayer.get_remote_sender_id() + + _send_pong.rpc_id(sender, idx, ping_received, _clock.get_time()) -## Get roundtrip time to a given peer, in seconds. -func get_rtt(id: int, sample_id: int = -1) -> float: - if id == multiplayer.get_unique_id(): - return 0 +@rpc("any_peer", "call_remote", "unreliable") +func _send_pong(idx: int, ping_received: float, pong_sent: float): + var pong_received = _clock.get_time() - var trip_start = get_real_time() - _request_ping.rpc_id(id) - var response = await on_ping - var trip_end = get_real_time() - var rtt = trip_end - trip_start + if not _awaiting_samples.has(idx): + # Sample was dropped mid-flight during a panic episode + return - _remote_rtt[sample_id] = rtt - _remote_time[sample_id] = response[1] - _remote_tick[sample_id] = response[2] - return rtt - -func _sync_time_loop(interval: float): - while true: - var sync_result = await sync_time(1) - var rtt = sync_result[1] - var new_time = sync_result[2] - - if not _active: - # Make sure we don't emit any events if we've been stopped since - break - if new_time == NAN: - # Skip if sync has failed - continue - - var new_tick = floor(new_time * NetworkTime.tickrate) - new_time = NetworkTime.ticks_to_seconds(new_tick) # Sync to tick - - on_sync.emit(new_time, new_tick, rtt) - await get_tree().create_timer(interval).timeout - -@rpc("any_peer", "reliable", "call_remote") -func _request_ping(): - var sender = multiplayer.get_remote_sender_id() - _respond_ping.rpc_id(sender, NetworkTime.time, NetworkTime.tick) + 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() -@rpc("any_peer", "reliable", "call_remote") -func _respond_ping(peer_time: float, peer_tick: int): - var sender = multiplayer.get_remote_sender_id() - on_ping.emit(sender, peer_time, peer_tick) +@rpc("any_peer", "call_remote", "reliable") +func _request_timestamp(): + _logger.debug("Requested initial timestamp @ %.4fs raw time" % [_clock.get_raw_time()]) + _set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time()) + +@rpc("any_peer", "call_remote", "reliable") +func _set_timestamp(timestamp: float): + _logger.debug("Received initial timestamp @ %.4fs raw time" % [_clock.get_raw_time()]) + _clock.set_time(timestamp) + _loop() diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index aa1756f0..e7b22ad2 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -1,4 +1,6 @@ extends Node +class_name _NetworkTime + ## This class handles timing. ## ## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/netfox/guides/network-time/ @@ -17,8 +19,8 @@ var tickrate: int: ## Whether to sync the network ticks to physics updates. ## -## When set to true, tickrate and the custom timer is ignored, and a network -## tick will be done on every physics frame. +## When set to true, tickrate will be the same as the physics ticks per second, +## and the network tick loop will be run inside the physics update process. ## ## [i]read-only[/i], you can change this in the project settings var sync_to_physics: bool: @@ -84,16 +86,18 @@ var tick: int: set(v): push_error("Trying to set read-only variable tick") -## Threshold before recalibrating [code]tick[/code] and [code]time[/code]. +## Threshold before recalibrating [member tick] and [member time]. ## ## Time is continuously synced to the server. In case the time difference is ## excessive between local and the server, both [code]tick[/code] and ## [code]time[/code] will be reset to the estimated server values. -## +## [br][br] ## This property determines the difference threshold in seconds for ## recalibration. -## +## [br][br] ## [i]read-only[/i], you can change this in the project settings +## [br][br] +## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead. var recalibrate_threshold: float: get: return ProjectSettings.get_setting("netfox/time/recalibrate_threshold", 8.0) @@ -104,11 +108,13 @@ var recalibrate_threshold: float: ## ## This is value is only an estimate, and is regularly updated. This means that ## this value can and probably will change depending on network conditions. -## +## [br][br] ## [i]read-only[/i] +## [br][br] +## @deprecated: Will return the same as [member tick]. var remote_tick: int: get: - return _remote_tick + return tick set(v): push_error("Trying to set read-only variable remote_tick") @@ -116,25 +122,28 @@ var remote_tick: int: ## ## This is value is only an estimate, and is regularly updated. This means that ## this value can and probably will change depending on network conditions. -## +## [br][br] ## [i]read-only[/i] +## [br][br] +## @deprecated: Will return the same as [member time]. var remote_time: float: get: - return float(_remote_tick) / tickrate + return time set(v): push_error("Trying to set read-only variable remote_time") ## Estimated roundtrip time to server. ## ## This value is updated regularly, during server time sync. Latency can be -## estimated as half of the roundtrip time. -## +## estimated as half of the roundtrip time. Returns the same as [member +## _NetworkTimeSynchronizer.rtt]. +## [br][br] ## Will always be 0 on servers. -## +## [br][br] ## [i]read-only[/i] var remote_rtt: float: get: - return _remote_rtt + return NetworkTimeSynchronizer.rtt set(v): push_error("Trying to set read-only variable remote_rtt") @@ -143,16 +152,18 @@ var remote_rtt: float: ## On clients, this value is synced to the server [i]only once[/i] when joining ## the game. After that, it will increase monotonically, incrementing every ## single tick. -## +## [br][br] ## When hosting, this value is simply the number of ticks since game start. -## +## [br][br] ## This property can be used for things that require a timer that is guaranteed ## to be linear, i.e. no jumps in time. -## +## [br][br] ## [i]read-only[/i] +## [br][br] +## @deprecated: Will return the same as [member tick]. var local_tick: int: get: - return _local_tick + return tick set(v): push_error("Trying to set read-only variable local_tick") @@ -161,16 +172,18 @@ var local_tick: int: ## On clients, this value is synced to the server [i]only once[/i] when joining ## the game. After that, it will increase monotonically, incrementing every ## single tick. -## +## [br][br] ## When hosting, this value is simply the seconds elapsed since game start. -## +## [br][br] ## This property can be used for things that require a timer that is guaranteed ## to be linear, i.e. no jumps in time. -## +## [br][br] ## [i]read-only[/i] +## [br][br] +## @deprecated: Will return the same as [member time]. var local_time: float: get: - return float(_local_tick) / tickrate + return time set(v): push_error("Trying to set read-only variable local_time") @@ -228,6 +241,70 @@ var physics_factor: float: set(v): push_error("Trying to set read-only variable physics_factor") +## The maximum clock stretch factor allowed. +## +## For more context on clock stretch, see [member clock_stretch_factor]. The +## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max. +## Setting this to larger values will allow for quicker clock adjustment at the +## cost of bigger deviations in game speed. +## [br][br] +## Make sure to adjust this value based on the game's needs. +## [br][br] +## [i]read-only[/i], you can change this in the project settings +var clock_stretch_max: float: + get: + return ProjectSettings.get_setting("netfox/time/max_time_stretch", 1.25) + set(v): + push_error("Trying to set read-only variable stretch_max") + +## The currently used clock stretch factor. +## +## As the game progresses, the simulation clock may be ahead of, or behind the +## host's remote clock. To compensate, whenever the simulation clock is ahead of +## the remote clock, the game will slightly slow down, to allow the remote clock +## to catch up. When the remote clock is ahead of the simulation clock, the game +## will run slightly faster to catch up with the remote clock. +## [br][br] +## This value indicates the current clock speed multiplier. Values over 1.0 +## indicate speeding up, under 1.0 indicate slowing down. +## [br][br] +## See [member clock_stretch_max] for clock stretch bounds.[br] +## See [_NetworkTimeSynchronizer] for more on the reference- and simulation +## clock. +## [br][br] +## [i]read-only[/i] +var clock_stretch_factor: float: + get: + return _clock_stretch_factor + +## The current estimated offset between the reference clock and the simulation +## clock. +## +## Positive values mean the simulation clock is behind, and needs to run +## slightly faster to catch up. Negative values mean the simulation clock is +## ahead, and needs to slow down slightly. +## [br][br] +## See [member clock_stretch] for more clock speed adjustment. +## [br][br] +## [i]read-only[/i] +var clock_offset: float: + get: + # Offset is synced time - local time + return NetworkTimeSynchronizer.get_time() - _clock.get_time() + +## The current estimated offset between the reference clock and the remote +## clock. +## +## Positive values mean the reference clock is behind the remote clock. +## Negative values mean the reference clock is ahead of the remote clock. +## [br][br] +## Returns the same as [member _NetworkTimeSynchronizer.remote_offset]. +## [br][br] +## [i]read-only[/i] +var remote_clock_offset: float: + get: + return NetworkTimeSynchronizer.remote_offset + ## Emitted before a tick loop is run. signal before_tick_loop() @@ -258,15 +335,15 @@ signal after_client_sync(peer_id: int) var _tick: int = 0 var _active: bool = false +var _was_paused: bool = false var _initial_sync_done = false var _process_delta: float = 0 var _next_tick_time: float = 0 var _last_process_time: float = 0. -var _remote_rtt: float = 0 -var _remote_tick: int = 0 -var _local_tick: int = 0 +var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new() +var _clock_stretch_factor: float = 1. # Cache the synced clients, as the rpc call itself may arrive multiple times # ( for some reason? ) @@ -278,9 +355,9 @@ static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NetworkTime") ## ## Once this is called, time will be synchronized and ticks will be consistently ## emitted. -## +## [br][br] ## On clients, the initial time sync must complete before any ticks are emitted. -## +## [br][br] ## To check if this initial sync is done, see [method is_initial_sync_done]. If ## you need a signal, see [signal after_sync]. func start(): @@ -288,35 +365,34 @@ func start(): return _tick = 0 - _remote_tick = 0 - _local_tick = 0 - _remote_rtt = 0 _initial_sync_done = false after_client_sync.connect(func(pid): _logger.debug("Client #%s is now on time!" % [pid]) ) + NetworkTimeSynchronizer.start() + if not multiplayer.is_server(): - NetworkTimeSynchronizer.start() - await NetworkTimeSynchronizer.on_sync - _tick = _remote_tick - _local_tick = _remote_tick + await NetworkTimeSynchronizer.on_initial_sync + + _tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) _initial_sync_done = true _active = true - _next_tick_time = _get_os_time() - after_sync.emit() _submit_sync_success.rpc_id(1) else: _active = true _initial_sync_done = true - _next_tick_time = _get_os_time() - after_sync.emit() # Remove clients from the synced cache when disconnected multiplayer.peer_disconnected.connect(func(peer): _synced_clients.erase(peer)) + _clock.set_time(NetworkTimeSynchronizer.get_time()) + _last_process_time = _clock.get_time() + _next_tick_time = _clock.get_time() + after_sync.emit() + ## Stop NetworkTime. ## ## This will stop the time sync in the background, and no more ticks will be @@ -359,64 +435,67 @@ func seconds_between(tick_from: int, tick_to: int) -> float: func ticks_between(seconds_from: float, seconds_to: float) -> int: return seconds_to_ticks(seconds_to - seconds_from) -func _ready(): - NetworkTimeSynchronizer.on_sync.connect(_handle_sync) - -func _process(delta): - # Use OS delta to determine if the game's paused from editor, or through the SceneTree - var os_delta = _get_os_time() - _last_process_time - var is_delta_mismatch = os_delta / delta > 4. and os_delta > .5 +func _loop(): + # Adjust local clock + _clock.step(_clock_stretch_factor) + var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() - # Adjust next tick time if the game is paused, so we don't try to "catch up" after unpausing - if (is_delta_mismatch and Engine.is_editor_hint()) or get_tree().paused: - _next_tick_time += os_delta - - _process_delta = delta - _last_process_time += os_delta + # Ignore diffs under 1ms + clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) + + var clock_stretch_min = 1. / clock_stretch_max + # var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2. + var clock_stretch_f = inverse_lerp(-ticktime, +ticktime, clock_diff) + clock_stretch_f = clampf(clock_stretch_f, 0., 1.) + var previous_stretch_factor = _clock_stretch_factor + _clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f) + + # Detect editor pause + var clock_step = _clock.get_time() - _last_process_time + var clock_step_raw = clock_step / previous_stretch_factor + if OS.has_feature("editor") and clock_step_raw > 1.: + # Game stalled for a while, probably paused, don't run extra ticks + # to catch up + _was_paused = true + _logger.debug("Game stalled for %.4fs, assuming it was a pause" % [clock_step_raw]) + + # Handle pause + if _was_paused: + _was_paused = false + _next_tick_time += clock_step + # Run tick loop if needed - if _active and not sync_to_physics: - var ticks_in_loop = 0 - while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame: - if ticks_in_loop == 0: - before_tick_loop.emit() - - _run_tick() - - ticks_in_loop += 1 - _next_tick_time += ticktime + var ticks_in_loop = 0 + _last_process_time = _clock.get_time() + while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame: + if ticks_in_loop == 0: + before_tick_loop.emit() + + before_tick.emit(ticktime, tick) + on_tick.emit(ticktime, tick) + after_tick.emit(ticktime, tick) - if ticks_in_loop > 0: - after_tick_loop.emit() - -func _physics_process(delta): - if _active and sync_to_physics and not get_tree().paused: - # Run a single tick every physics frame - before_tick_loop.emit() - _run_tick() + _tick += 1 + ticks_in_loop += 1 + _next_tick_time += ticktime + + if ticks_in_loop > 0: after_tick_loop.emit() -func _run_tick(): - before_tick.emit(ticktime, tick) - on_tick.emit(ticktime, tick) - after_tick.emit(ticktime, tick) +func _process(delta): + _process_delta = delta - _tick += 1 - _remote_tick +=1 - _local_tick += 1 + if _active and not sync_to_physics: + _loop() -func _get_os_time() -> float: - return Time.get_ticks_msec() / 1000. +func _physics_process(delta): + if _active and sync_to_physics: + _loop() -func _handle_sync(server_time: float, server_tick: int, rtt: float): - _remote_tick = server_tick - _remote_rtt = rtt - - # Adjust tick if it's too far away from remote - if absf(seconds_between(tick, remote_tick)) > recalibrate_threshold and _initial_sync_done: - _logger.error("Large difference between estimated remote time and local time!") - _logger.error("Local time: %s; Remote time: %s" % [time, remote_time]) - _tick = _remote_tick +func _notification(what): + if what == NOTIFICATION_UNPAUSED: + _was_paused = true @rpc("any_peer", "reliable", "call_remote") func _submit_sync_success(): diff --git a/addons/netfox/plugin.cfg b/addons/netfox/plugin.cfg index 5c0c19e3..5d321746 100644 --- a/addons/netfox/plugin.cfg +++ b/addons/netfox/plugin.cfg @@ -3,5 +3,5 @@ name="netfox" description="Shared internals for netfox addons" author="Tamas Galffy" -version="1.9.2" +version="1.10.0" script="netfox.gd" diff --git a/addons/netfox/time/network-clock-sample.gd b/addons/netfox/time/network-clock-sample.gd new file mode 100644 index 00000000..6f4d5793 --- /dev/null +++ b/addons/netfox/time/network-clock-sample.gd @@ -0,0 +1,21 @@ +extends RefCounted +class_name NetworkClockSample + +var ping_sent: float +var ping_received: float +var pong_sent: float +var pong_received: float + +func get_rtt() -> float: + return pong_received - ping_sent + +func get_offset() -> float: + # See: https://datatracker.ietf.org/doc/html/rfc5905#section-8 + # See: https://support.huawei.com/enterprise/en/doc/EDOC1100278232/729da750/ntp-fundamentals + return ((ping_received - ping_sent) + (pong_sent - pong_received)) / 2. + +func _to_string(): + return "(theta=%.2fms; delta=%.2fms; t1=%.4fs; t2=%.4fs; t3=%.4fs; t4=%.4fs)" % [ + get_offset() * 1000., get_rtt() * 1000., + ping_sent, ping_received, pong_sent, pong_received + ] diff --git a/addons/netfox/time/network-clocks.gd b/addons/netfox/time/network-clocks.gd new file mode 100644 index 00000000..dc489ec0 --- /dev/null +++ b/addons/netfox/time/network-clocks.gd @@ -0,0 +1,41 @@ +extends Object +class_name NetworkClocks + +class SystemClock: + var offset: float = 0. + + func get_raw_time() -> float: + return Time.get_unix_time_from_system() + + func get_time() -> float: + return get_raw_time() + offset + + func adjust(p_offset: float): + offset += p_offset + + func set_time(p_time: float): + offset = p_time - get_raw_time() + +class SteppingClock: + var time: float = 0. + var last_step: float = get_raw_time() + + func get_raw_time() -> float: + return Time.get_unix_time_from_system() + + func get_time() -> float: + return time + + func adjust(p_offset: float): + time += p_offset + + func set_time(p_time: float): + last_step = get_raw_time() + time = p_time + + func step(p_multiplier: float = 1.): + var current_step = get_raw_time() + var step_duration = current_step - last_step + last_step = current_step + + adjust(step_duration * p_multiplier) diff --git a/docs/netfox/assets/network-time-settings.png b/docs/netfox/assets/network-time-settings.png index 0fb7815c..92cc8f85 100644 Binary files a/docs/netfox/assets/network-time-settings.png and b/docs/netfox/assets/network-time-settings.png differ diff --git a/docs/netfox/guides/network-time-synchronizer.md b/docs/netfox/guides/network-time-synchronizer.md index ac6dfd93..c237a741 100644 --- a/docs/netfox/guides/network-time-synchronizer.md +++ b/docs/netfox/guides/network-time-synchronizer.md @@ -1,20 +1,91 @@ # NetworkTimeSynchronizer -Synchronizes time towards a target peer. Provided as an autoload. +Synchronizes time to the host remote. Provided as an autoload. -Synchronization is run periodically in a loop. During synchronization, the -*NetworkTimeSynchronizer* measures the roundtrip to the target peer, assumes -latency is half of the roundtrip, and adds the latency to the latest time -received from the target peer. +Synchronization is done by continuously pinging the host remote, and using +these samples to figure out clock difference and network latency. These are +then used to gradually adjust the local clock to keep in sync. -To estimate the roundtrip time, it sends multiple ping messages to the target -peer, measuring how much time it takes to get a response. Measurements that are -too far from the average are rejected to filter out latency spikes. +## The three clocks -Further reading: [Time, Tick, Clock Synchronisation] +The process distinguishes three different clock concepts: + +The *Remote clock* is the clock being synchronized to, running on the host peer. + +The *Reference clock* is a local clock, running on the client, that is getting +adjusted to match the Remote clock as closely as possible. This clock is +unsuitable to use for gameplay, as it being regularly adjusted can lead to +glitchy movement. + +The *Simulation clock* is also a local clock, and is being synchronized to the +Reference clock. The Simulation clock is guaranteed to only move forwards in +time. It drives the [Network tick loop]. Most of the time you shouldn't need to interface with this class directly, instead you can use [NetworkTime]. -[Time, Tick, Clock Synchronisation]: https://daposto.medium.com/game-networking-2-time-tick-clock-synchronisation-9a0e76101fe5 +## Synchronizing the Reference clock + +Synchronization is done by regularly taking samples of the remote clock, and +deriving roundtrip time and clock offset from each sample. These samples are +then combined into a single set of stats - offset, roundtrip time and jitter. + +*Offset* is the difference to the remote clock. Positive values mean the remote +clock is ahead of the reference clock. Negative values mean that the remote +clock is behind the reference clock. May also be called theta. + +*Roundtrip time* is the time it takes for data to travel to the remote and then +back over the network. Smaller roundtrip times usually mean faster network +connections. May also be called delay or delta. + +*Jitter* is the amount of variation in measured roundtrip times. The less +jitter, the more stable the network connection usually. + +These stats are then used to get a good estimate of the current time on the +remote clock. The remote clock estimate is then used to slowly adjust ( nudge ) +the reference clock towards the remote clock's value. + +This is done iteratively, to avoid large jumps in time, and to - when possible +- only go forward in time, not backwards. + +When the offset gets too significant, it means that the clocks are excessively +out of sync. In these cases, a panic occurs and the reference clock is reset. + +This process is inspired by the [NTPv4] RFC. + +## Synchronizing the Simulation clock + +While the Reference clock is in sync with the Remote clock, its time is not +linear - it is not guaranteed to advance monotonously, and technically it's +also possible for it to move backwards. This would lead to uneven tick loops ( +e.g. sometimes 3 ticks in a single loop, sometimes 1, sometimes 5), and by +extension, uneven and jerky movement. + +To counteract the above, the Simulation clock is introduced. It is synced to +the Reference clock, but instead of adjusting it by adding small offsets to it, +it is *stretched*. + +Whenever the Simulation clock is ahead of the Reference clock, the it will +slightly slow down, to allow the Reference clock to catch up. When the +Reference clock is ahead of the Simulation clock, it will run slightly faster +to catch up with the Reference clock. + +These stretches are subtle enough to not disturb gameplay, but effective enough +to keep the two clocks in sync. + +The Simulation clock is handled by [NetworkTime]. + +## Characteristics + +The above process works well regardless of latency - very similar results can +be achieved with 50ms latency as with 250ms. + +Synchronization is more sensitive to jitter. Less stable network connections +produce more varied latencies, which makes it difficult to distinguish clock +offsets from latency variations. This in turn leads to the estimated clock +offset changing more often, which results in more clock adjustments. + +[Network tick loop]: ./network-time.md#network-tick-loop [NetworkTime]: ./network-time.md +[NTPv4]: https://datatracker.ietf.org/doc/html/rfc5905 + diff --git a/docs/netfox/guides/network-time.md b/docs/netfox/guides/network-time.md index 6285f2c1..96a22844 100644 --- a/docs/netfox/guides/network-time.md +++ b/docs/netfox/guides/network-time.md @@ -98,8 +98,20 @@ The synchronization itself is handled by [NetworkTimeSynchronizer]. Each time can be accessed as ticks or seconds. Both advance after every network tick. +### Synchronized time + +* `NetworkTime.time` +* `NetworkTime.ticks` + +Marks the current network game time. This is continuously synchronized, making +sure that these values are as close to eachother on all peers as possible. + +Use this whenever a notion of game time is needed. + ### Local time +*Deprecated since netfox v1.9.0.* Use synchronized time instead. + * `NetworkTime.local_time` * `NetworkTime.local_ticks` @@ -114,6 +126,8 @@ player. ### Remote time +*Deprecated since netfox v1.9.0.* Use synchronized time instead. + * `NetworkTime.remote_ticks` * `NetwokrTime.remote_time` * `NetworkTime.remote_rtt` @@ -132,20 +146,6 @@ for tying game logic to it. To get notified when a time synchronization happens and the remote time is updated, use the `NetworkTime.after_sync` signal. -### Time - -* `NetworkTime.time` -* `NetworkTime.ticks` - -Marks the current network game time. On start, this time is set to the -estimated remote time. - -The game time is only adjusted if it is too far off from the remote time, -making it a good, consistent source of time. - -Can be used when timing data needs to be shared between players, and for game -logic that is synchronized over the network. - ## Settings Settings are found in the Project Settings, under Netfox > Time: @@ -157,19 +157,25 @@ Settings are found in the Project Settings, under Netfox > Time: *Max Ticks Per Frame* sets the maximum number of frames to simulate per tick loop. Used to avoid freezing the game under load. *Recalibrate Threshold* is the largest allowed time discrepancy in seconds. If -the difference between the remote time and game time is larger than this -setting, the game time will be reset to the remote time. +the difference between the remote clock and reference clock is larger than this +setting, the reference clock will be reset to the remote clock. See +[NetworkTimeSynchronizer] for more details. + +*Sync Interval* is the resting time in seconds between sampling the remote +clock. -*Sync Interval* is the resting time between time synchronizations. Note that -the synchronization itself may take multiple seconds, so overall there will be -more time between two synchronization runs than just the interval. +*Sync Samples* is the number of measurements to use for time synchronization. +This includes measuring roundtrip time and estimating clock offsets. -*Sync Samples* is the number of measurements to take for estimating roundtrip -time. +*Sync Adjust Steps* is the number of iterations to use when adjusting the +reference clock. Larger values result in more stable clocks but slower +convergence, while smaller values synchronize more aggressively. -*Sync Sample Interval* is the resting time between roundtrip measurements. +*Sync Sample Interval* *deprecated in netfox v1.9.0*. Originally used as the +resting time between roundtrip measurements. -*Sync to Physics* ties the network tick loop to the physics process when -enabled. +*Sync to Physics* ensures that the network tick loop runs in Godot's physics +process when enabled. This can be useful in cases where a lot of physics +operations need to be done as part of the tick- or the rollback loop. [NetworkTimeSynchronizer]: ./network-time-synchronizer.md diff --git a/examples/shared/scripts/time-display.gd b/examples/shared/scripts/time-display.gd index 3f9730f2..69a4e492 100644 --- a/examples/shared/scripts/time-display.gd +++ b/examples/shared/scripts/time-display.gd @@ -7,8 +7,9 @@ func _tick(_delta: float, _t: int): pass func _process(_delta): - text = "Time: %.2f at tick #%d" % [NetworkTime.time, NetworkTime.tick] - text += "\nRemote time: %.2f at tick#%d with %.2fms RTT" % [NetworkTime.remote_time, NetworkTime.remote_tick, NetworkTime.remote_rtt * 1000.0] + text = "Time: %.2f at tick #%d, clock at %.2f%%" % [NetworkTime.time, NetworkTime.tick, NetworkTime.clock_stretch_factor * 100.] + text += "\nClock offset: %.2fms, Remote offset: %.2fms" % [NetworkTime.clock_offset * 1000., NetworkTime.remote_clock_offset * 1000.] + text += "\nRemote RTT: %.2fms +/- %.2fms" % [NetworkTimeSynchronizer.rtt * 1000., NetworkTimeSynchronizer.rtt_jitter * 1000.] text += "\nFactor: %.2f" % [NetworkTime.tick_factor] text += "\nFPS: %s" % [Engine.get_frames_per_second()] diff --git a/project.godot b/project.godot index 388f7deb..d892db06 100644 --- a/project.godot +++ b/project.godot @@ -21,12 +21,12 @@ config/icon="res://icon.png" Async="*res://examples/shared/scripts/async.gd" GameEvents="*res://examples/forest-brawl/scripts/game-events.gd" +Noray="*res://addons/netfox.noray/noray.gd" +PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd" NetworkTime="*res://addons/netfox/network-time.gd" NetworkTimeSynchronizer="*res://addons/netfox/network-time-synchronizer.gd" NetworkRollback="*res://addons/netfox/rollback/network-rollback.gd" NetworkEvents="*res://addons/netfox/network-events.gd" -Noray="*res://addons/netfox.noray/noray.gd" -PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd" NetworkPerformance="*res://addons/netfox/network-performance.gd" [display]