From 84095b5df82e88e6054c651b14db6c7374e282db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 18 Oct 2024 13:49:00 +0200 Subject: [PATCH 01/31] gather samples From c368cf5f8b67a203debe49132cebc7cf962ada97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 19 Oct 2024 21:09:54 +0200 Subject: [PATCH 02/31] discipline clock --- .../nudge-timer-process/nudge-time-process.gd | 113 ++++++++++++++++++ .../nudge-timer-process.tscn | 73 +++++++++++ 2 files changed, 186 insertions(+) create mode 100644 examples/nudge-timer-process/nudge-time-process.gd create mode 100644 examples/nudge-timer-process/nudge-timer-process.tscn diff --git a/examples/nudge-timer-process/nudge-time-process.gd b/examples/nudge-timer-process/nudge-time-process.gd new file mode 100644 index 00000000..4cb61513 --- /dev/null +++ b/examples/nudge-timer-process/nudge-time-process.gd @@ -0,0 +1,113 @@ +extends Node + +const CLOCK_SAMPLES = 8 +const APPROACH_STEPS = 8 +const PANIC_THRESHOLD_SECONDS = 2. + +class TimeSample: + 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. + +@export var sample_interval_seconds = .2 + +var logger = _NetfoxLogger.new("ntp", "ntp") +var samples = {} +var sample_buffer = [] # TODO: Ring buffer +var clock_offset = 0. +var first_approach = true + +func get_real_time() -> float: + return Time.get_unix_time_from_system() - clock_offset + +func _ready(): + clock_offset += get_real_time() + + NetworkEvents.on_peer_join.connect(func(id): + if multiplayer.is_server(): + _send_initial_timestamp.rpc_id(id, get_real_time()) + ) + +func _loop(): + logger.info("NTP loop started! Initial timestamp: %s" % [get_real_time()]) + logger.warning("Local clock offset: %ss" % [clock_offset]) + + var sample_idx = 0 + + while true: + var sample = TimeSample.new() + samples[sample_idx] = sample + + sample.ping_sent = get_real_time() + _send_ping.rpc_id(1, sample_idx) + + sample_idx += 1 + + await get_tree().create_timer(sample_interval_seconds).timeout + +func _discipline_clock(): + # https://datatracker.ietf.org/doc/html/rfc5905#section-10 + var clock_samples = sample_buffer.duplicate() + clock_samples.sort_custom(func(a: TimeSample, b: TimeSample): return a.get_rtt() < b.get_rtt()) + + var offsets = clock_samples.map(func(it: TimeSample): return it.get_offset()) + + var offset = 0. + var offset_weight = 0. + for i in range(offsets.size()): + var w = pow(2, -i) + offset += offsets[i] * w + offset_weight += w + offset /= offset_weight + + if abs(offset) > PANIC_THRESHOLD_SECONDS: + logger.error("Offset %ss is above panic threshold %s!" % [offset, PANIC_THRESHOLD_SECONDS]) + clock_offset -= offset + sample_buffer.clear() + else: + clock_offset -= offset / APPROACH_STEPS + logger.info("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., get_real_time()]) + +@rpc("any_peer", "call_remote", "reliable") +func _send_initial_timestamp(timestamp: float): + clock_offset += get_real_time() - timestamp + logger.info("Received initial timestamp: %s" % [timestamp]) + _loop() + +@rpc("any_peer", "call_remote", "unreliable") +func _send_ping(idx: int): + var ping_received = get_real_time() + var sender = multiplayer.get_remote_sender_id() + + _send_pong.rpc_id(sender, idx, ping_received, get_real_time()) + +@rpc("any_peer", "call_remote", "unreliable") +func _send_pong(idx: int, ping_received: float, pong_sent: float): + var pong_received = get_real_time() + + var sample = samples[idx] as TimeSample + sample.ping_received = ping_received + sample.pong_sent = pong_sent + sample.pong_received = pong_received + + logger.info("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ + sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, + sample.get_offset() * 1000., sample.get_rtt() * 1000. + ]) + + # Once a sample is done, remove from in-flight samples and move to sample buffer + samples.erase(idx) + sample_buffer.append(sample) + if sample_buffer.size() > CLOCK_SAMPLES: + sample_buffer.pop_front() + + _discipline_clock() diff --git a/examples/nudge-timer-process/nudge-timer-process.tscn b/examples/nudge-timer-process/nudge-timer-process.tscn new file mode 100644 index 00000000..7db0fd32 --- /dev/null +++ b/examples/nudge-timer-process/nudge-timer-process.tscn @@ -0,0 +1,73 @@ +[gd_scene load_steps=3 format=3 uid="uid://bfx6bweohondo"] + +[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="1_p0jfi"] +[ext_resource type="Script" path="res://examples/nudge-timer-process/nudge-time-process.gd" id="2_1yb6j"] + +[node name="nudge-timer-process" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Network Popup" parent="." instance=ExtResource("1_p0jfi")] +layout_mode = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Time: " + +[node name="Label2" type="Label" parent="VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tick: " + +[node name="Title row" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "t1" + +[node name="Label2" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "t2" + +[node name="Label3" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "t3" + +[node name="Label4" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "t4" + +[node name="Label5" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Offset" + +[node name="Label6" type="Label" parent="VBoxContainer/Title row"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Roundtrip" + +[node name="NudgeTimeProcess" type="Node" parent="."] +script = ExtResource("2_1yb6j") From cabb22d3ff7cc05e63faafa514ed0c885214ef27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 19 Oct 2024 21:18:18 +0200 Subject: [PATCH 03/31] refactor clock --- .../nudge-timer-process/nudge-time-process.gd | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/examples/nudge-timer-process/nudge-time-process.gd b/examples/nudge-timer-process/nudge-time-process.gd index 4cb61513..bbd264cf 100644 --- a/examples/nudge-timer-process/nudge-time-process.gd +++ b/examples/nudge-timer-process/nudge-time-process.gd @@ -18,28 +18,54 @@ class TimeSample: # See: https://support.huawei.com/enterprise/en/doc/EDOC1100278232/729da750/ntp-fundamentals return ((ping_received - ping_sent) + (pong_sent - pong_received)) / 2. +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. + + func get_raw_time() -> float: + return time + + func get_time() -> float: + return time + + func adjust(p_offset: float): + time += p_offset + + func set_time(p_time: float): + time = p_time + @export var sample_interval_seconds = .2 var logger = _NetfoxLogger.new("ntp", "ntp") var samples = {} var sample_buffer = [] # TODO: Ring buffer -var clock_offset = 0. +var clock = SystemClock.new() var first_approach = true -func get_real_time() -> float: - return Time.get_unix_time_from_system() - clock_offset - func _ready(): - clock_offset += get_real_time() + clock.set_time(0.) NetworkEvents.on_peer_join.connect(func(id): if multiplayer.is_server(): - _send_initial_timestamp.rpc_id(id, get_real_time()) + _send_initial_timestamp.rpc_id(id, clock.get_time()) ) func _loop(): - logger.info("NTP loop started! Initial timestamp: %s" % [get_real_time()]) - logger.warning("Local clock offset: %ss" % [clock_offset]) + logger.info("NTP loop started! Initial timestamp: %s" % [clock.get_time()]) var sample_idx = 0 @@ -47,7 +73,7 @@ func _loop(): var sample = TimeSample.new() samples[sample_idx] = sample - sample.ping_sent = get_real_time() + sample.ping_sent = clock.get_time() _send_ping.rpc_id(1, sample_idx) sample_idx += 1 @@ -71,28 +97,28 @@ func _discipline_clock(): if abs(offset) > PANIC_THRESHOLD_SECONDS: logger.error("Offset %ss is above panic threshold %s!" % [offset, PANIC_THRESHOLD_SECONDS]) - clock_offset -= offset + clock.adjust(offset) sample_buffer.clear() else: - clock_offset -= offset / APPROACH_STEPS - logger.info("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., get_real_time()]) + clock.adjust(offset / APPROACH_STEPS) + logger.info("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., clock.get_time()]) @rpc("any_peer", "call_remote", "reliable") func _send_initial_timestamp(timestamp: float): - clock_offset += get_real_time() - timestamp + clock.set_time(timestamp) logger.info("Received initial timestamp: %s" % [timestamp]) _loop() @rpc("any_peer", "call_remote", "unreliable") func _send_ping(idx: int): - var ping_received = get_real_time() + var ping_received = clock.get_time() var sender = multiplayer.get_remote_sender_id() - _send_pong.rpc_id(sender, idx, ping_received, get_real_time()) + _send_pong.rpc_id(sender, idx, ping_received, clock.get_time()) @rpc("any_peer", "call_remote", "unreliable") func _send_pong(idx: int, ping_received: float, pong_sent: float): - var pong_received = get_real_time() + var pong_received = clock.get_time() var sample = samples[idx] as TimeSample sample.ping_received = ping_received From 58ab728e4cfe32183437388fe93d517e34c8a664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 19 Oct 2024 23:26:43 +0200 Subject: [PATCH 04/31] move to network time sync --- addons/netfox/network-time-synchronizer.gd | 212 +++++++++--------- addons/netfox/network-time.gd | 59 +++-- addons/netfox/time/network-clock-sample.gd | 15 ++ addons/netfox/time/network-clocks.gd | 41 ++++ .../nudge-timer-process/nudge-time-process.gd | 139 ------------ .../nudge-timer-process.tscn | 61 +---- 6 files changed, 197 insertions(+), 330 deletions(-) create mode 100644 addons/netfox/time/network-clock-sample.gd create mode 100644 addons/netfox/time/network-clocks.gd delete mode 100644 examples/nudge-timer-process/nudge-time-process.gd diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 91706e59..35fe2256 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -5,7 +5,8 @@ extends Node ## [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 ProjectSettings.get_setting("netfox/time/sync_interval", 0.25) + return 0.25 set(v): push_error("Trying to set read-only variable sync_interval") @@ -14,29 +15,33 @@ var sync_interval: float: ## [i]read-only[/i], you can change this in the Netfox project settings var sync_samples: int: get: - return ProjectSettings.get_setting("netfox/time/sync_samples", 8) + # return ProjectSettings.get_setting("netfox/time/sync_samples", 8) + return 8 set(v): push_error("Trying to set read-only variable sync_samples") -## Time between samples in a single sync process. -## -## [i]read-only[/i], you can change this in the Netfox project settings -var sync_sample_interval: float: +# TODO: Doc +var adjust_steps: int: get: - return ProjectSettings.get_setting("netfox/time/sync_sample_interval", 0.1) - set(v): - push_error("Trying to set read-only variable sync_sample_interval") + return 8 + +# TODO: Doc +var panic_threshold: float: + get: + return 2. -var _remote_rtt: Dictionary = {} -var _remote_time: Dictionary = {} -var _remote_tick: Dictionary = {} var _active: bool = false +static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NTP") + +var _sample_buffer: Array[NetworkClockSample] = [] +var _sample_buf_size: int = 0 +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.new() -## Event emitted when a response to a ping request arrives. -signal on_ping(peer_id: int, peer_time: float, peer_tick: int) +# TODO: Doc +signal on_initial_sync() ## Start the time synchronization loop. ## @@ -45,102 +50,107 @@ func start(): if _active: return - _active = true - _sync_time_loop(sync_interval) + if multiplayer.is_server(): + _clock.set_time(0.) + else: + _active = true + + _sample_buffer.clear() + _sample_buffer.resize(sync_samples) + _sample_buf_size = 0 + _sample_idx = 0 + + _clock.set_time(0.) + + _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. -## -## 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() +# TODO: Doc +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(): + # https://datatracker.ietf.org/doc/html/rfc5905#section-10 + # Sort samples by latency + var sorted_samples := _sample_buffer.slice(0, _sample_buf_size) as Array[NetworkClockSample] + sorted_samples.sort_custom( + func(a: NetworkClockSample, b: NetworkClockSample): + return a.get_rtt() < b.get_rtt() + ) - for i in range(sync_samples): - get_rtt(id, i) - await get_tree().create_timer(sync_sample_interval).timeout + var offset = 0. + var offset_weight = 0. + for i in range(sorted_samples.size()): + var w = pow(2, -i) + offset += sorted_samples[i].get_offset() * w + offset_weight += w + offset /= offset_weight - # Wait for all samples to run through - while _remote_rtt.size() != sync_samples: - await get_tree().process_frame + if abs(offset) > panic_threshold: + # Reset clock, throw away all samples + _clock.adjust(offset) + _sample_buffer.fill(null) + _sample_buf_size = 0 + + _logger.warning("Offset %ss is above panic threshold %ss! Resetting clock" % [offset, panic_threshold]) + else: + # Nudge clock towards estimated time + _clock.adjust(offset / adjust_steps) + _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) + +@rpc("any_peer", "call_remote", "unreliable") +func _send_ping(idx: int): + var ping_received = _clock.get_time() + var sender = multiplayer.get_remote_sender_id() - 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() + _send_pong.rpc_id(sender, idx, ping_received, _clock.get_time()) + +@rpc("any_peer", "call_remote", "unreliable") +func _send_pong(idx: int, ping_received: float, pong_sent: float): + var pong_received = _clock.get_time() - # Reject samples that are too far away from average - var deviation_threshold = 1 - samples = samples.filter(func(s): return (s - average) / average < deviation_threshold) + var sample = _awaiting_samples[idx] as NetworkClockSample + sample.ping_received = ping_received + sample.pong_sent = pong_sent + sample.pong_received = pong_received - # Return NAN if none of the samples fit within threshold - # Should be rare, but technically possible - if samples.is_empty(): - return [NAN, NAN, NAN] + _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ + sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, + sample.get_offset() * 1000., sample.get_rtt() * 1000. + ]) - average = samples.reduce(func(a, b): return a + b) / samples.size() - var rtt = average - var latency = rtt / 2.0 - - return [last_remote_time, rtt, last_remote_time + latency] - -## 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 + # Once a sample is done, remove from in-flight samples and move to sample buffer + _awaiting_samples.erase(idx) - 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 + _sample_buffer[_sample_buf_size % _sample_buffer.size()] = sample + _sample_buf_size += 1 - _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) + # 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(): + _set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time()) + +@rpc("any_peer", "call_remote", "reliable") +func _set_timestamp(timestamp: float): + _clock.set_time(timestamp) + _loop() diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index aa1756f0..93c233b9 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -268,6 +268,9 @@ var _remote_rtt: float = 0 var _remote_tick: int = 0 var _local_tick: int = 0 +var _clock := NetworkClocks.SteppingClock.new() +var _clock_multiplier := 1. + # Cache the synced clients, as the rpc call itself may arrive multiple times # ( for some reason? ) var _synced_clients: Dictionary = {} @@ -297,26 +300,29 @@ func start(): _logger.debug("Client #%s is now on time!" % [pid]) ) + NetworkTimeSynchronizer.start() + if not multiplayer.is_server(): - NetworkTimeSynchronizer.start() - await NetworkTimeSynchronizer.on_sync + await NetworkTimeSynchronizer.on_initial_sync + + _remote_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) _tick = _remote_tick _local_tick = _remote_tick _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()) + _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,20 +365,26 @@ 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 - - # 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 + if _active: + _clock.step(_clock_multiplier) + var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() + + # Ignore diffs under 1ms + clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) + + var multiplier_min = .75 + var multiplier_max = 1. / multiplier_min + var multiplier_f = (1. + clock_diff / ticktime) / 2. + multiplier_f = clampf(multiplier_f, 0., 1.) + _clock_multiplier = lerpf(multiplier_min, multiplier_max, multiplier_f) + + if not multiplayer.is_server(): + _logger.trace("Clock difference: %sms, multiplier: %s%%" % [clock_diff * 1000., _clock_multiplier * 100.]) + _process_delta = delta - _last_process_time += os_delta + _last_process_time = _clock.get_time() # Run tick loop if needed if _active and not sync_to_physics: @@ -405,19 +417,6 @@ func _run_tick(): _remote_tick +=1 _local_tick += 1 -func _get_os_time() -> float: - return Time.get_ticks_msec() / 1000. - -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 - @rpc("any_peer", "reliable", "call_remote") func _submit_sync_success(): var peer_id = multiplayer.get_remote_sender_id() diff --git a/addons/netfox/time/network-clock-sample.gd b/addons/netfox/time/network-clock-sample.gd new file mode 100644 index 00000000..a8e5d551 --- /dev/null +++ b/addons/netfox/time/network-clock-sample.gd @@ -0,0 +1,15 @@ +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. 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/examples/nudge-timer-process/nudge-time-process.gd b/examples/nudge-timer-process/nudge-time-process.gd deleted file mode 100644 index bbd264cf..00000000 --- a/examples/nudge-timer-process/nudge-time-process.gd +++ /dev/null @@ -1,139 +0,0 @@ -extends Node - -const CLOCK_SAMPLES = 8 -const APPROACH_STEPS = 8 -const PANIC_THRESHOLD_SECONDS = 2. - -class TimeSample: - 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. - -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. - - func get_raw_time() -> float: - return time - - func get_time() -> float: - return time - - func adjust(p_offset: float): - time += p_offset - - func set_time(p_time: float): - time = p_time - -@export var sample_interval_seconds = .2 - -var logger = _NetfoxLogger.new("ntp", "ntp") -var samples = {} -var sample_buffer = [] # TODO: Ring buffer -var clock = SystemClock.new() -var first_approach = true - -func _ready(): - clock.set_time(0.) - - NetworkEvents.on_peer_join.connect(func(id): - if multiplayer.is_server(): - _send_initial_timestamp.rpc_id(id, clock.get_time()) - ) - -func _loop(): - logger.info("NTP loop started! Initial timestamp: %s" % [clock.get_time()]) - - var sample_idx = 0 - - while true: - var sample = TimeSample.new() - 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(sample_interval_seconds).timeout - -func _discipline_clock(): - # https://datatracker.ietf.org/doc/html/rfc5905#section-10 - var clock_samples = sample_buffer.duplicate() - clock_samples.sort_custom(func(a: TimeSample, b: TimeSample): return a.get_rtt() < b.get_rtt()) - - var offsets = clock_samples.map(func(it: TimeSample): return it.get_offset()) - - var offset = 0. - var offset_weight = 0. - for i in range(offsets.size()): - var w = pow(2, -i) - offset += offsets[i] * w - offset_weight += w - offset /= offset_weight - - if abs(offset) > PANIC_THRESHOLD_SECONDS: - logger.error("Offset %ss is above panic threshold %s!" % [offset, PANIC_THRESHOLD_SECONDS]) - clock.adjust(offset) - sample_buffer.clear() - else: - clock.adjust(offset / APPROACH_STEPS) - logger.info("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., clock.get_time()]) - -@rpc("any_peer", "call_remote", "reliable") -func _send_initial_timestamp(timestamp: float): - clock.set_time(timestamp) - logger.info("Received initial timestamp: %s" % [timestamp]) - _loop() - -@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()) - -@rpc("any_peer", "call_remote", "unreliable") -func _send_pong(idx: int, ping_received: float, pong_sent: float): - var pong_received = clock.get_time() - - var sample = samples[idx] as TimeSample - sample.ping_received = ping_received - sample.pong_sent = pong_sent - sample.pong_received = pong_received - - logger.info("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ - sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, - sample.get_offset() * 1000., sample.get_rtt() * 1000. - ]) - - # Once a sample is done, remove from in-flight samples and move to sample buffer - samples.erase(idx) - sample_buffer.append(sample) - if sample_buffer.size() > CLOCK_SAMPLES: - sample_buffer.pop_front() - - _discipline_clock() diff --git a/examples/nudge-timer-process/nudge-timer-process.tscn b/examples/nudge-timer-process/nudge-timer-process.tscn index 7db0fd32..4d594a12 100644 --- a/examples/nudge-timer-process/nudge-timer-process.tscn +++ b/examples/nudge-timer-process/nudge-timer-process.tscn @@ -1,7 +1,6 @@ -[gd_scene load_steps=3 format=3 uid="uid://bfx6bweohondo"] +[gd_scene load_steps=2 format=3 uid="uid://bfx6bweohondo"] [ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="1_p0jfi"] -[ext_resource type="Script" path="res://examples/nudge-timer-process/nudge-time-process.gd" id="2_1yb6j"] [node name="nudge-timer-process" type="Control"] layout_mode = 3 @@ -13,61 +12,3 @@ grow_vertical = 2 [node name="Network Popup" parent="." instance=ExtResource("1_p0jfi")] layout_mode = 0 - -[node name="VBoxContainer" type="VBoxContainer" parent="."] -visible = false -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 - -[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] -layout_mode = 2 - -[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "Time: " - -[node name="Label2" type="Label" parent="VBoxContainer/HBoxContainer"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "Tick: " - -[node name="Title row" type="HBoxContainer" parent="VBoxContainer"] -layout_mode = 2 - -[node name="Label" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "t1" - -[node name="Label2" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "t2" - -[node name="Label3" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "t3" - -[node name="Label4" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "t4" - -[node name="Label5" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "Offset" - -[node name="Label6" type="Label" parent="VBoxContainer/Title row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "Roundtrip" - -[node name="NudgeTimeProcess" type="Node" parent="."] -script = ExtResource("2_1yb6j") From cc07c4adc7ea4aa5dce9422ca26f254c526b6ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 20 Oct 2024 00:10:11 +0200 Subject: [PATCH 05/31] track rtt and jitter --- addons/netfox/network-time-synchronizer.gd | 17 +++++++++++ addons/netfox/network-time.gd | 33 +++++++++++----------- examples/shared/scripts/time-display.gd | 4 +-- project.godot | 1 + 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 35fe2256..2c2dd8e6 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -30,6 +30,16 @@ var panic_threshold: float: get: return 2. +# TODO: Doc +var rtt: float: + get: + return _rtt + +# TODO: Doc +var rtt_jitter: float: + get: + return _rtt_jitter + var _active: bool = false static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NTP") @@ -39,6 +49,8 @@ var _sample_idx: int = 0 var _awaiting_samples: Dictionary = {} var _clock := NetworkClocks.SystemClock.new() +var _rtt := 0. +var _rtt_jitter := 0. # TODO: Doc signal on_initial_sync() @@ -96,6 +108,11 @@ func _discipline_clock(): return a.get_rtt() < b.get_rtt() ) + 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. + var offset = 0. var offset_weight = 0. for i in range(sorted_samples.size()): diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 93c233b9..3791c3ae 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -106,9 +106,10 @@ var recalibrate_threshold: float: ## this value can and probably will change depending on network conditions. ## ## [i]read-only[/i] +# TODO: Deprecate var remote_tick: int: get: - return _remote_tick + return tick set(v): push_error("Trying to set read-only variable remote_tick") @@ -118,9 +119,10 @@ var remote_tick: int: ## this value can and probably will change depending on network conditions. ## ## [i]read-only[/i] +# TODO: Deprecate var remote_time: float: get: - return float(_remote_tick) / tickrate + return time set(v): push_error("Trying to set read-only variable remote_time") @@ -132,9 +134,10 @@ var remote_time: float: ## Will always be 0 on servers. ## ## [i]read-only[/i] +# TODO: Deprecate? var remote_rtt: float: get: - return _remote_rtt + return NetworkTimeSynchronizer.rtt set(v): push_error("Trying to set read-only variable remote_rtt") @@ -150,9 +153,10 @@ var remote_rtt: float: ## to be linear, i.e. no jumps in time. ## ## [i]read-only[/i] +# TODO: Deprecate var local_tick: int: get: - return _local_tick + return tick set(v): push_error("Trying to set read-only variable local_tick") @@ -168,9 +172,10 @@ var local_tick: int: ## to be linear, i.e. no jumps in time. ## ## [i]read-only[/i] +# TODO: Deprecate 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 +233,10 @@ var physics_factor: float: set(v): push_error("Trying to set read-only variable physics_factor") +var clock_multiplier: float: + get: + return _clock_multiplier + ## Emitted before a tick loop is run. signal before_tick_loop() @@ -264,10 +273,6 @@ 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.new() var _clock_multiplier := 1. @@ -291,9 +296,6 @@ func start(): return _tick = 0 - _remote_tick = 0 - _local_tick = 0 - _remote_rtt = 0 _initial_sync_done = false after_client_sync.connect(func(pid): @@ -305,9 +307,7 @@ func start(): if not multiplayer.is_server(): await NetworkTimeSynchronizer.on_initial_sync - _remote_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) - _tick = _remote_tick - _local_tick = _remote_tick + _tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) _initial_sync_done = true _active = true @@ -402,6 +402,7 @@ func _process(delta): after_tick_loop.emit() func _physics_process(delta): + # TODO: Use time-stretch if _active and sync_to_physics and not get_tree().paused: # Run a single tick every physics frame before_tick_loop.emit() @@ -414,8 +415,6 @@ func _run_tick(): after_tick.emit(ticktime, tick) _tick += 1 - _remote_tick +=1 - _local_tick += 1 @rpc("any_peer", "reliable", "call_remote") func _submit_sync_success(): diff --git a/examples/shared/scripts/time-display.gd b/examples/shared/scripts/time-display.gd index 3f9730f2..f99132e3 100644 --- a/examples/shared/scripts/time-display.gd +++ b/examples/shared/scripts/time-display.gd @@ -7,8 +7,8 @@ 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_multiplier * 100] + 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..16006e27 100644 --- a/project.godot +++ b/project.godot @@ -112,6 +112,7 @@ aim_south={ general/clear_settings=false time/tickrate=24 +logging/log_level=3 [rendering] From 58b6e2c44f6e88767a89cf597b448460123d25dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 20 Oct 2024 23:10:05 +0200 Subject: [PATCH 06/31] signals and variables --- addons/netfox/network-time-synchronizer.gd | 23 +++++++++++++++++++++ addons/netfox/network-time.gd | 24 +++++++++++++++++++++- examples/shared/scripts/time-display.gd | 3 ++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 2c2dd8e6..cf7efd0d 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -24,21 +24,36 @@ var sync_samples: int: var adjust_steps: int: get: return 8 + set(v): + push_error("Trying to set read-only variable adjust_steps") # TODO: Doc var panic_threshold: float: get: return 2. + set(v): + push_error("Trying to set read-only variable panic_threshold") # TODO: Doc var rtt: float: get: return _rtt + set(v): + push_error("Trying to set read-only variable rtt") # TODO: Doc var rtt_jitter: float: get: return _rtt_jitter + set(v): + push_error("Trying to set read-only variable rtt_jitter") + +# TODO: Doc +var remote_offset: float: + get: + return _offset + set(v): + push_error("Trying to set read-only variable remote_offset") var _active: bool = false static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NTP") @@ -49,12 +64,16 @@ var _sample_idx: int = 0 var _awaiting_samples: Dictionary = {} var _clock := NetworkClocks.SystemClock.new() +var _offset := 0. var _rtt := 0. var _rtt_jitter := 0. # TODO: Doc signal on_initial_sync() +# TODO: Doc +signal on_panic(offset: float) + ## Start the time synchronization loop. ## ## Starting multiple times has no effect. @@ -126,12 +145,16 @@ func _discipline_clock(): _clock.adjust(offset) _sample_buffer.fill(null) _sample_buf_size = 0 + _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 _clock.adjust(offset / adjust_steps) _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) + + _offset = offset * (1. - 1. / adjust_steps) @rpc("any_peer", "call_remote", "unreliable") func _send_ping(idx: int): diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 3791c3ae..6fa60874 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -233,10 +233,23 @@ var physics_factor: float: set(v): push_error("Trying to set read-only variable physics_factor") +# TODO: Doc var clock_multiplier: float: get: return _clock_multiplier +# TODO: Doc +var clock_offset: float: + get: + var sync_time = NetworkTimeSynchronizer.get_time() + var game_time = _clock.get_time() + return sync_time - game_time + +# TODO: Doc +var remote_clock_offset: float: + get: + return NetworkTimeSynchronizer.remote_offset + ## Emitted before a tick loop is run. signal before_tick_loop() @@ -366,6 +379,15 @@ func ticks_between(seconds_from: float, seconds_to: float) -> int: return seconds_to_ticks(seconds_to - seconds_from) func _process(delta): + # TODO: Remove + if Input.is_key_pressed(KEY_F5): + if Input.is_key_pressed(KEY_SHIFT): + _clock.adjust(delta * 8.) + NetworkTimeSynchronizer._clock.adjust(delta * 8.) + else: + _clock.adjust(-delta * 8.) + NetworkTimeSynchronizer._clock.adjust(-delta * 8.) + if _active: _clock.step(_clock_multiplier) var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() @@ -375,7 +397,7 @@ func _process(delta): var multiplier_min = .75 var multiplier_max = 1. / multiplier_min - var multiplier_f = (1. + clock_diff / ticktime) / 2. + var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. multiplier_f = clampf(multiplier_f, 0., 1.) _clock_multiplier = lerpf(multiplier_min, multiplier_max, multiplier_f) diff --git a/examples/shared/scripts/time-display.gd b/examples/shared/scripts/time-display.gd index f99132e3..723c8663 100644 --- a/examples/shared/scripts/time-display.gd +++ b/examples/shared/scripts/time-display.gd @@ -7,7 +7,8 @@ func _tick(_delta: float, _t: int): pass func _process(_delta): - text = "Time: %.2f at tick #%d, clock at %.2f%%" % [NetworkTime.time, NetworkTime.tick, NetworkTime.clock_multiplier * 100] + text = "Time: %.2f at tick #%d, clock at %.2f%%" % [NetworkTime.time, NetworkTime.tick, NetworkTime.clock_multiplier * 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()] From 8d7ca4ee5f1d79900cd536b4220cad20956eba5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 11:01:18 +0200 Subject: [PATCH 07/31] project settings --- addons/netfox/netfox.gd | 8 +++++++- addons/netfox/network-time-synchronizer.gd | 10 ++++------ addons/netfox/network-time.gd | 1 + project.godot | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index b4b28d95..34e2818b 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -31,7 +31,7 @@ var SETTINGS = [ { # Time to wait between time syncs "name": "netfox/time/sync_interval", - "value": 1.0, + "value": 0.25, "type": TYPE_FLOAT }, { @@ -40,6 +40,12 @@ var SETTINGS = [ "type": TYPE_INT }, { + "name": "netfox/time/sync_adjust_steps", + "value": 8, + "type": TYPE_INT + }, + { + # TODO: Deprecate # Time to wait between time sync samples "name": "netfox/time/sync_sample_interval", "value": 0.1, diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index cf7efd0d..ba9dc752 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -5,8 +5,7 @@ extends Node ## [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", 0.25) - return 0.25 + return ProjectSettings.get_setting("netfox/time/sync_interval", 0.25) set(v): push_error("Trying to set read-only variable sync_interval") @@ -15,22 +14,21 @@ var sync_interval: float: ## [i]read-only[/i], you can change this in the Netfox project settings var sync_samples: int: get: - # return ProjectSettings.get_setting("netfox/time/sync_samples", 8) - return 8 + return ProjectSettings.get_setting("netfox/time/sync_samples", 8) set(v): push_error("Trying to set read-only variable sync_samples") # TODO: Doc var adjust_steps: int: get: - return 8 + return ProjectSettings.get_setting("netfox/time/sync_adjust_steps", 8) set(v): push_error("Trying to set read-only variable adjust_steps") # TODO: Doc var panic_threshold: float: get: - return 2. + return ProjectSettings.get_setting("netfox/time/recalibrate_threshold", 2.) set(v): push_error("Trying to set read-only variable panic_threshold") diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 6fa60874..8a0806a3 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -94,6 +94,7 @@ var tick: int: ## recalibration. ## ## [i]read-only[/i], you can change this in the project settings +# TODO: Deprecate var recalibrate_threshold: float: get: return ProjectSettings.get_setting("netfox/time/recalibrate_threshold", 8.0) diff --git a/project.godot b/project.godot index 16006e27..5846c3d6 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] From 05839074a5532b3655d45358a0b59140ca7f775e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 11:14:49 +0200 Subject: [PATCH 08/31] fxs --- addons/netfox/network-time-synchronizer.gd | 26 +++++++++++-------- addons/netfox/network-time.gd | 11 ++++---- .../nudge-timer-process.tscn | 14 ---------- 3 files changed, 21 insertions(+), 30 deletions(-) delete mode 100644 examples/nudge-timer-process/nudge-timer-process.tscn diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index ba9dc752..677be842 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -1,5 +1,7 @@ extends Node +# TODO: Doc algo + ## Time between syncs, in seconds. ## ## [i]read-only[/i], you can change this in the Netfox project settings @@ -56,15 +58,17 @@ var remote_offset: float: var _active: bool = false static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NTP") +# Samples are stored in a ring buffer var _sample_buffer: Array[NetworkClockSample] = [] var _sample_buf_size: int = 0 var _sample_idx: int = 0 + var _awaiting_samples: Dictionary = {} -var _clock := NetworkClocks.SystemClock.new() -var _offset := 0. -var _rtt := 0. -var _rtt_jitter := 0. +var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new() +var _offset: float = 0. +var _rtt: float = 0. +var _rtt_jitter: float = 0. # TODO: Doc signal on_initial_sync() @@ -78,10 +82,10 @@ signal on_panic(offset: float) func start(): if _active: return + + _clock.set_time(0.) - if multiplayer.is_server(): - _clock.set_time(0.) - else: + if not multiplayer.is_server(): _active = true _sample_buffer.clear() @@ -89,8 +93,6 @@ func start(): _sample_buf_size = 0 _sample_idx = 0 - _clock.set_time(0.) - _request_timestamp.rpc_id(1) ## Stop the time synchronization loop. @@ -117,7 +119,6 @@ func _loop(): await get_tree().create_timer(sync_interval).timeout func _discipline_clock(): - # https://datatracker.ietf.org/doc/html/rfc5905#section-10 # Sort samples by latency var sorted_samples := _sample_buffer.slice(0, _sample_buf_size) as Array[NetworkClockSample] sorted_samples.sort_custom( @@ -125,11 +126,13 @@ func _discipline_clock(): return a.get_rtt() < b.get_rtt() ) + # 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. + # Calculate offset var offset = 0. var offset_weight = 0. for i in range(sorted_samples.size()): @@ -138,6 +141,7 @@ func _discipline_clock(): offset_weight += w offset /= offset_weight + # Panic / Adjust if abs(offset) > panic_threshold: # Reset clock, throw away all samples _clock.adjust(offset) @@ -179,7 +183,7 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): _awaiting_samples.erase(idx) _sample_buffer[_sample_buf_size % _sample_buffer.size()] = sample - _sample_buf_size += 1 + _sample_buf_size = mini(_sample_buf_size + 1, _sample_buffer.size()) # Discipline clock based on new sample _discipline_clock() diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 8a0806a3..91a1624c 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -242,9 +242,8 @@ var clock_multiplier: float: # TODO: Doc var clock_offset: float: get: - var sync_time = NetworkTimeSynchronizer.get_time() - var game_time = _clock.get_time() - return sync_time - game_time + # Offset is synced time - local time + return NetworkTimeSynchronizer.get_time() - _clock.get_time() # TODO: Doc var remote_clock_offset: float: @@ -287,8 +286,8 @@ var _process_delta: float = 0 var _next_tick_time: float = 0 var _last_process_time: float = 0. -var _clock := NetworkClocks.SteppingClock.new() -var _clock_multiplier := 1. +var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new() +var _clock_multiplier: float = 1. # Cache the synced clients, as the rpc call itself may arrive multiple times # ( for some reason? ) @@ -396,6 +395,7 @@ func _process(delta): # Ignore diffs under 1ms clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) + # TODO: Configurable bounds var multiplier_min = .75 var multiplier_max = 1. / multiplier_min var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. @@ -406,6 +406,7 @@ func _process(delta): if not multiplayer.is_server(): _logger.trace("Clock difference: %sms, multiplier: %s%%" % [clock_diff * 1000., _clock_multiplier * 100.]) + # TODO: Handle editor pauses _process_delta = delta _last_process_time = _clock.get_time() diff --git a/examples/nudge-timer-process/nudge-timer-process.tscn b/examples/nudge-timer-process/nudge-timer-process.tscn deleted file mode 100644 index 4d594a12..00000000 --- a/examples/nudge-timer-process/nudge-timer-process.tscn +++ /dev/null @@ -1,14 +0,0 @@ -[gd_scene load_steps=2 format=3 uid="uid://bfx6bweohondo"] - -[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="1_p0jfi"] - -[node name="nudge-timer-process" type="Control"] -layout_mode = 3 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 - -[node name="Network Popup" parent="." instance=ExtResource("1_p0jfi")] -layout_mode = 0 From 8b52646dca3b3fd7d12e5f36f312f95e893d8ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 11:47:13 +0200 Subject: [PATCH 09/31] update sync_to_physics --- addons/netfox/network-time.gd | 92 +++++++++++++++-------------------- 1 file changed, 38 insertions(+), 54 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 91a1624c..12851d97 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -17,8 +17,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: @@ -378,67 +378,51 @@ 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 _process(delta): - # TODO: Remove - if Input.is_key_pressed(KEY_F5): - if Input.is_key_pressed(KEY_SHIFT): - _clock.adjust(delta * 8.) - NetworkTimeSynchronizer._clock.adjust(delta * 8.) - else: - _clock.adjust(-delta * 8.) - NetworkTimeSynchronizer._clock.adjust(-delta * 8.) +func _loop(): + # Adjust local clock + _clock.step(_clock_multiplier) + var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() - if _active: - _clock.step(_clock_multiplier) - var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() - - # Ignore diffs under 1ms - clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) - - # TODO: Configurable bounds - var multiplier_min = .75 - var multiplier_max = 1. / multiplier_min - var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. - multiplier_f = clampf(multiplier_f, 0., 1.) + # Ignore diffs under 1ms + clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) + + # TODO: Configurable bounds + var multiplier_min = .75 + var multiplier_max = 1. / multiplier_min + var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. + multiplier_f = clampf(multiplier_f, 0., 1.) - _clock_multiplier = lerpf(multiplier_min, multiplier_max, multiplier_f) - - if not multiplayer.is_server(): - _logger.trace("Clock difference: %sms, multiplier: %s%%" % [clock_diff * 1000., _clock_multiplier * 100.]) + _clock_multiplier = lerpf(multiplier_min, multiplier_max, multiplier_f) + # Run tick loop if needed # TODO: Handle editor pauses - _process_delta = delta _last_process_time = _clock.get_time() - - # 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 + 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): - # TODO: Use time-stretch - 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 + if _active and not sync_to_physics: + _loop() + +func _physics_process(delta): + if _active and sync_to_physics: + _loop() @rpc("any_peer", "reliable", "call_remote") func _submit_sync_success(): From 30509cad293907037a045bae4ada02bf00d0996f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 12:13:29 +0200 Subject: [PATCH 10/31] configurable clock stretch --- addons/netfox/netfox.gd | 7 +++++++ addons/netfox/network-time.gd | 22 ++++++++++++++-------- examples/shared/scripts/time-display.gd | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 34e2818b..3c12f749 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -56,6 +56,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.gd b/addons/netfox/network-time.gd index 12851d97..d460b751 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -235,9 +235,16 @@ var physics_factor: float: push_error("Trying to set read-only variable physics_factor") # TODO: Doc -var clock_multiplier: float: +var clock_stretch_max: float: get: - return _clock_multiplier + return ProjectSettings.get_setting("netfox/time/max_time_stretch", 1.25) + set(v): + push_error("Trying to set read-only variable stretch_max") + +# TODO: Doc +var clock_stretch_factor: float: + get: + return _clock_stretch_factor # TODO: Doc var clock_offset: float: @@ -287,7 +294,7 @@ var _next_tick_time: float = 0 var _last_process_time: float = 0. var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new() -var _clock_multiplier: float = 1. +var _clock_stretch_factor: float = 1. # Cache the synced clients, as the rpc call itself may arrive multiple times # ( for some reason? ) @@ -380,19 +387,18 @@ func ticks_between(seconds_from: float, seconds_to: float) -> int: func _loop(): # Adjust local clock - _clock.step(_clock_multiplier) + _clock.step(_clock_stretch_factor) var clock_diff = NetworkTimeSynchronizer.get_time() - _clock.get_time() # Ignore diffs under 1ms clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) - # TODO: Configurable bounds - var multiplier_min = .75 - var multiplier_max = 1. / multiplier_min + var multiplier_max = clock_stretch_max + var multiplier_min = 1. / clock_stretch_max var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. multiplier_f = clampf(multiplier_f, 0., 1.) - _clock_multiplier = lerpf(multiplier_min, multiplier_max, multiplier_f) + _clock_stretch_factor = lerpf(multiplier_min, multiplier_max, multiplier_f) # Run tick loop if needed # TODO: Handle editor pauses diff --git a/examples/shared/scripts/time-display.gd b/examples/shared/scripts/time-display.gd index 723c8663..69a4e492 100644 --- a/examples/shared/scripts/time-display.gd +++ b/examples/shared/scripts/time-display.gd @@ -7,7 +7,7 @@ func _tick(_delta: float, _t: int): pass func _process(_delta): - text = "Time: %.2f at tick #%d, clock at %.2f%%" % [NetworkTime.time, NetworkTime.tick, NetworkTime.clock_multiplier * 100.] + 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] From dd2532c063a37f1d903811fabe44f3d21471bcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 14:37:00 +0200 Subject: [PATCH 11/31] detect pauses in editor --- addons/netfox/network-time.gd | 27 +++++++++++++++++++-------- project.godot | 1 - 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index d460b751..4fb434d9 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -393,17 +393,28 @@ func _loop(): # Ignore diffs under 1ms clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.) - var multiplier_max = clock_stretch_max - var multiplier_min = 1. / clock_stretch_max - var multiplier_f = (1. + clock_diff / (1. * ticktime)) / 2. - multiplier_f = clampf(multiplier_f, 0., 1.) + var clock_stretch_min = 1. / clock_stretch_max + var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2. + clock_stretch_f = clampf(clock_stretch_f, 0., 1.) - _clock_stretch_factor = lerpf(multiplier_min, multiplier_max, multiplier_f) + var previous_stretch_factor = _clock_stretch_factor + _clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f) - # Run tick loop if needed - # TODO: Handle editor pauses - _last_process_time = _clock.get_time() + # Detect game pause ( editor only ) + if OS.has_feature("editor"): + var clock_step = _clock.get_time() - _last_process_time + var clock_step_raw = clock_step / previous_stretch_factor + _last_process_time += clock_step + + if clock_step_raw > 1.: + # Game stalled for a while, probably paused, don't run extra ticks + # to catch up + _next_tick_time += clock_step + _logger.debug("Game stalled for %.2fs, assuming it was a pause" % [clock_step_raw]) + else: + _last_process_time = _clock.get_time() + # Run tick loop if needed 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: diff --git a/project.godot b/project.godot index 5846c3d6..d892db06 100644 --- a/project.godot +++ b/project.godot @@ -112,7 +112,6 @@ aim_south={ general/clear_settings=false time/tickrate=24 -logging/log_level=3 [rendering] From 8f9406e7e0c79836673be6bbec81dec8eaaa7515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 15:20:38 +0200 Subject: [PATCH 12/31] add docs --- addons/netfox/network-time-synchronizer.gd | 37 +++++++-- addons/netfox/network-time.gd | 97 ++++++++++++++++------ 2 files changed, 103 insertions(+), 31 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 677be842..4bc3592f 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -1,4 +1,5 @@ extends Node +class_name _NetworkTimeSynchronizer # TODO: Doc algo @@ -20,35 +21,61 @@ var sync_samples: int: set(v): push_error("Trying to set read-only variable sync_samples") -# TODO: Doc +## Number of iterations to nudge towards the host clock. +## +## Lower values result in more aggressive changes in clock and may be more +## sensitive to jitter. Larger values may end up approaching the host clock too +## slowly. +## +## [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") -# TODO: Doc +## Largest tolerated offset from host clock before panicking. +## +## Once this threshold is reached, the clock will be reset to the host clock's +## value, and the nudge process will start from scratch. +## +## [i]read-only[/i], you can change this in the Netfox project settings 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") -# TODO: Doc +## Measured roundtrip time measured to the host. +## +## This value is calculated from multiple samples. The actual roundtrip times +## can be anywhere in the rtt +/- rtt_jitter range. +## +## [i]read-only[/i] var rtt: float: get: return _rtt set(v): push_error("Trying to set read-only variable rtt") -# TODO: Doc +## Measured jitter in the roundtrip time to the host. +## +## This value is calculated from multiple samples. The actual roundtrip times +## can be anywhere in the rtt +/- rtt_jitter range. +## +## [i]read-only[/i] var rtt_jitter: float: get: return _rtt_jitter set(v): push_error("Trying to set read-only variable rtt_jitter") -# TODO: Doc +## Estimated offset from the host's clock. +## +## Positive values mean that the host's clock is ahead of ours, while negative +## values mean that our clock is behind the host's. +## +## [i]read-only[/i] var remote_offset: float: get: return _offset diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 4fb434d9..925228dc 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/ @@ -84,17 +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] ## This property determines the difference threshold in seconds for ## recalibration. -## +## [br] ## [i]read-only[/i], you can change this in the project settings -# TODO: Deprecate +## [br] +## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead. var recalibrate_threshold: float: get: return ProjectSettings.get_setting("netfox/time/recalibrate_threshold", 8.0) @@ -105,9 +108,10 @@ 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] ## [i]read-only[/i] -# TODO: Deprecate +## [br] +## @deprecated: Will return the same as [member tick]. var remote_tick: int: get: return tick @@ -118,9 +122,10 @@ 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] ## [i]read-only[/i] -# TODO: Deprecate +## [br] +## @deprecated: Will return the same as [member time]. var remote_time: float: get: return time @@ -130,12 +135,12 @@ var remote_time: float: ## 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] ## Will always be 0 on servers. -## +## [br] ## [i]read-only[/i] -# TODO: Deprecate? var remote_rtt: float: get: return NetworkTimeSynchronizer.rtt @@ -147,14 +152,15 @@ 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] ## When hosting, this value is simply the number of ticks since game start. -## +## [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] ## [i]read-only[/i] -# TODO: Deprecate +## [br] +## @deprecated: Will return the same as [member tick]. var local_tick: int: get: return tick @@ -166,14 +172,15 @@ 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] ## When hosting, this value is simply the seconds elapsed since game start. -## +## [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] ## [i]read-only[/i] -# TODO: Deprecate +## [br] +## @deprecated: Will return the same as [member time]. var local_time: float: get: return time @@ -234,25 +241,63 @@ var physics_factor: float: set(v): push_error("Trying to set read-only variable physics_factor") -# TODO: Doc +## 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] +## Make sure to adjust this value based on the game's needs. +## [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") -# TODO: Doc +## The currently used clock stretch factor. +## +## As the game progresses, the game clock may be ahead of, or behind the host's +## clock. To compensate, whenever the game clock is ahead of the host's clock, +## the game will slightly slow down, to allow the host's clock to catch up. When +## the host's clock is ahead of the game clock, the game will run slightly +## faster to catch up with the host's clock. +## [br] +## This value indicates the current clock speed multiplier. Values over 1.0 +## indicate speeding up, under 1.0 indicate slowing down. +## [br] +## See [member clock_stretch_max] for clock stretch bounds. +## [br] +## [i]read-only[/i] var clock_stretch_factor: float: get: return _clock_stretch_factor -# TODO: Doc +## The current estimated offset between the reference clock and the game clock. +## +## Positive values mean the game clock is behind, and needs to run slightly +## faster to catch up. Negative values mean the game clock is ahead, and needs +## to slow down slightly. +## [br] +## See [member clock_stretch] for more clock speed adjustment. +## [br] +## [i]read-only[/i] var clock_offset: float: get: # Offset is synced time - local time return NetworkTimeSynchronizer.get_time() - _clock.get_time() -# TODO: Doc +## 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] +## Returns the same as [member _NetworkTimeSynchronizer.remote_offset]. +## [br] +## [i]read-only[/i] var remote_clock_offset: float: get: return NetworkTimeSynchronizer.remote_offset @@ -306,9 +351,9 @@ static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NetworkTime") ## ## Once this is called, time will be synchronized and ticks will be consistently ## emitted. -## +## [br] ## On clients, the initial time sync must complete before any ticks are emitted. -## +## [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(): From e72a92635f11ba8646bac4b1ad3ba66c65fbb641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 15:38:23 +0200 Subject: [PATCH 13/31] improve docs --- addons/netfox/network-time-synchronizer.gd | 76 ++++++++++++++++------ 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 4bc3592f..8ad86c53 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -1,10 +1,36 @@ extends Node class_name _NetworkTimeSynchronizer -# TODO: Doc algo +## Continuously synchronizes time to the host remote's 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. +## [br][br] +## [i]Offset[/i] 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. +## [br][br] +## [i]Roundtrip time[/i] 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. +## [br][br] +## [i]Jitter[/i] is the amount of variation in measured roundtrip times. The +## less jitter, the more stable the network connection usually. +## [br][br] +## 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. +## [br][br] +## This is done iteratively, to avoid large jumps in time, and to - when +## possible - only go forward in time, not backwards. +## [br][br] +## 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. ## Time between syncs, in seconds. -## +## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var sync_interval: float: get: @@ -13,7 +39,7 @@ var sync_interval: float: push_error("Trying to set read-only variable sync_interval") ## Number of measurements ( samples ) to take to guess latency. -## +## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var sync_samples: int: get: @@ -21,12 +47,12 @@ var sync_samples: int: set(v): push_error("Trying to set read-only variable sync_samples") -## Number of iterations to nudge towards the host clock. +## Number of iterations to nudge towards the host remote's clock. ## ## Lower values result in more aggressive changes in clock and may be more ## sensitive to jitter. Larger values may end up approaching the host clock too ## slowly. -## +## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var adjust_steps: int: get: @@ -34,11 +60,11 @@ var adjust_steps: int: set(v): push_error("Trying to set read-only variable adjust_steps") -## Largest tolerated offset from host clock before panicking. +## Largest tolerated offset from the host remote's clock before panicking. ## ## Once this threshold is reached, the clock will be reset to the host clock's ## value, and the nudge process will start from scratch. -## +## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var panic_threshold: float: get: @@ -49,8 +75,8 @@ var panic_threshold: float: ## Measured roundtrip time measured to the host. ## ## This value is calculated from multiple samples. The actual roundtrip times -## can be anywhere in the rtt +/- rtt_jitter range. -## +## can be anywhere in the [member rtt] +/- [member rtt_jitter] range. +## [br] ## [i]read-only[/i] var rtt: float: get: @@ -58,11 +84,11 @@ var rtt: float: set(v): push_error("Trying to set read-only variable rtt") -## Measured jitter in the roundtrip time to the host. +## 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 rtt +/- rtt_jitter range. -## +## can be anywhere in the [member rtt] +/- [member rtt_jitter] range. +## [br] ## [i]read-only[/i] var rtt_jitter: float: get: @@ -70,11 +96,11 @@ var rtt_jitter: float: set(v): push_error("Trying to set read-only variable rtt_jitter") -## Estimated offset from the host's clock. -## -## Positive values mean that the host's clock is ahead of ours, while negative -## values mean that our clock is behind the host's. +## Estimated offset from the host remote's clock. ## +## Positive values mean that the host remote's clock is ahead of ours, while +## negative values mean that our clock is behind the host remote's. +## [br] ## [i]read-only[/i] var remote_offset: float: get: @@ -97,10 +123,20 @@ var _offset: float = 0. var _rtt: float = 0. var _rtt_jitter: float = 0. -# TODO: Doc +## 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() -# TODO: Doc +## 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's time and restart the time sync loop +## from there. +## [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. @@ -126,7 +162,9 @@ func start(): func stop(): _active = false -# TODO: Doc +## 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() From 4f0fcd0004555b998d459208317d0e682086ef5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 15:39:16 +0200 Subject: [PATCH 14/31] fxs --- addons/netfox/netfox.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 3c12f749..d9833c1c 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -45,7 +45,7 @@ var SETTINGS = [ "type": TYPE_INT }, { - # TODO: Deprecate + # !! Deprecated # Time to wait between time sync samples "name": "netfox/time/sync_sample_interval", "value": 0.1, From a5d94c0805d90084ebdf05f8f3c44fc1ec5d274f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 22 Oct 2024 15:49:48 +0200 Subject: [PATCH 15/31] bv --- addons/netfox.extras/plugin.cfg | 2 +- addons/netfox.internals/plugin.cfg | 2 +- addons/netfox.noray/plugin.cfg | 2 +- addons/netfox/plugin.cfg | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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.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/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" From b7dcb1d72a4c0ebe3c27ea43a519b369b00c7294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 13:28:03 +0200 Subject: [PATCH 16/31] fix initial adjust wrongly treated as stall --- addons/netfox/network-time.gd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 925228dc..d105dd41 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -385,6 +385,7 @@ func start(): 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() @@ -455,7 +456,7 @@ func _loop(): # Game stalled for a while, probably paused, don't run extra ticks # to catch up _next_tick_time += clock_step - _logger.debug("Game stalled for %.2fs, assuming it was a pause" % [clock_step_raw]) + _logger.debug("Game stalled for %.4fs, assuming it was a pause" % [clock_step_raw]) else: _last_process_time = _clock.get_time() From 8e2bf247e5150b313473fc77f8acdb0b6d581307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 13:35:52 +0200 Subject: [PATCH 17/31] adjust logs --- addons/netfox/network-time-synchronizer.gd | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 8ad86c53..4a03d09d 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -109,7 +109,7 @@ var remote_offset: float: push_error("Trying to set read-only variable remote_offset") var _active: bool = false -static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NTP") +static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NetworkTimeSynchronizer") # Samples are stored in a ring buffer var _sample_buffer: Array[NetworkClockSample] = [] @@ -219,7 +219,7 @@ func _discipline_clock(): else: # Nudge clock towards estimated time _clock.adjust(offset / adjust_steps) - _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) +# _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) _offset = offset * (1. - 1. / adjust_steps) @@ -239,10 +239,10 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): sample.pong_sent = pong_sent sample.pong_received = pong_received - _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ - sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, - sample.get_offset() * 1000., sample.get_rtt() * 1000. - ]) +# _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ +# sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, +# sample.get_offset() * 1000., sample.get_rtt() * 1000. +# ]) # Once a sample is done, remove from in-flight samples and move to sample buffer _awaiting_samples.erase(idx) From 4f29d07c77a6c1def047ec04563eb106689a820a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 14:10:55 +0200 Subject: [PATCH 18/31] update nts docs --- addons/netfox/network-time-synchronizer.gd | 28 +----- .../guides/network-time-synchronizer.md | 91 +++++++++++++++++-- 2 files changed, 83 insertions(+), 36 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 4a03d09d..81e10fbf 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -3,33 +3,9 @@ class_name _NetworkTimeSynchronizer ## Continuously synchronizes time to the host remote's 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. -## [br][br] -## [i]Offset[/i] 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. -## [br][br] -## [i]Roundtrip time[/i] 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. -## [br][br] -## [i]Jitter[/i] is the amount of variation in measured roundtrip times. The -## less jitter, the more stable the network connection usually. -## [br][br] -## 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. -## [br][br] -## This is done iteratively, to avoid large jumps in time, and to - when -## possible - only go forward in time, not backwards. -## [br][br] -## 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. +## @tutorial(NetworkTimeSynchronizer Guide): https://foxssake.github.io/netfox/netfox/guides/network-time-synchronizer/ -## Time between syncs, in seconds. +## Time between sync samples, in seconds. ## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var sync_interval: float: 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 + From 47636a04d70e60d39ddeadffb73a640011599516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 14:21:31 +0200 Subject: [PATCH 19/31] adjust doc wording --- addons/netfox/network-time-synchronizer.gd | 23 ++++++++++++---------- addons/netfox/network-time.gd | 20 ++++++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 81e10fbf..654cb239 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -1,7 +1,10 @@ extends Node class_name _NetworkTimeSynchronizer -## Continuously synchronizes time to the host remote's clock. +## 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/ @@ -23,11 +26,11 @@ var sync_samples: int: set(v): push_error("Trying to set read-only variable sync_samples") -## Number of iterations to nudge towards the host remote's clock. +## 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 host clock too -## slowly. +## sensitive to jitter. Larger values may end up approaching the remote clock +## too slowly. ## [br] ## [i]read-only[/i], you can change this in the Netfox project settings var adjust_steps: int: @@ -36,9 +39,9 @@ var adjust_steps: int: set(v): push_error("Trying to set read-only variable adjust_steps") -## Largest tolerated offset from the host remote's clock before panicking. +## Largest tolerated offset from the host's remote clock before panicking. ## -## Once this threshold is reached, the clock will be reset to the host clock's +## 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] ## [i]read-only[/i], you can change this in the Netfox project settings @@ -72,10 +75,10 @@ var rtt_jitter: float: set(v): push_error("Trying to set read-only variable rtt_jitter") -## Estimated offset from the host remote's clock. +## Estimated offset from the host's remote clock. ## -## Positive values mean that the host remote's clock is ahead of ours, while -## negative values mean that our clock is behind the host remote's. +## 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] ## [i]read-only[/i] var remote_offset: float: @@ -109,7 +112,7 @@ 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's time and restart the time sync loop +## will reset the clock to the remote clock's time and restart the time sync loop ## from there. ## [br] ## Use this event in case you need to react to clock changes in your game. diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index d105dd41..824754c6 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -259,17 +259,19 @@ var clock_stretch_max: float: ## The currently used clock stretch factor. ## -## As the game progresses, the game clock may be ahead of, or behind the host's -## clock. To compensate, whenever the game clock is ahead of the host's clock, -## the game will slightly slow down, to allow the host's clock to catch up. When -## the host's clock is ahead of the game clock, the game will run slightly -## faster to catch up with the host's clock. -## [br] +## 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] -## See [member clock_stretch_max] for clock stretch bounds. -## [br] +## [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: From 17e46d4247afdb18202f9aaa33e4ca7790358113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 14:33:49 +0200 Subject: [PATCH 20/31] update network time docs --- addons/netfox/network-time-synchronizer.gd | 2 +- addons/netfox/network-time.gd | 13 +++++----- docs/netfox/assets/network-time-settings.png | Bin 71341 -> 76669 bytes docs/netfox/guides/network-time.md | 26 ++++++++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 654cb239..6ca350d1 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -17,7 +17,7 @@ var sync_interval: float: 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] ## [i]read-only[/i], you can change this in the Netfox project settings var sync_samples: int: diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 824754c6..b1c098a5 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -277,14 +277,15 @@ var clock_stretch_factor: float: get: return _clock_stretch_factor -## The current estimated offset between the reference clock and the game clock. +## The current estimated offset between the reference clock and the simulation +## clock. ## -## Positive values mean the game clock is behind, and needs to run slightly -## faster to catch up. Negative values mean the game clock is ahead, and needs -## to slow down slightly. -## [br] +## 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][br] ## [i]read-only[/i] var clock_offset: float: get: diff --git a/docs/netfox/assets/network-time-settings.png b/docs/netfox/assets/network-time-settings.png index 0fb7815c3874d62cf9e43f39fe4e3ddc9e821102..92cc8f8538a731fd310e7103c2dbd8460ab06dd0 100644 GIT binary patch literal 76669 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 literal 71341 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 B+Gzj) diff --git a/docs/netfox/guides/network-time.md b/docs/netfox/guides/network-time.md index 6285f2c1..ab3f0ca6 100644 --- a/docs/netfox/guides/network-time.md +++ b/docs/netfox/guides/network-time.md @@ -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 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 Interval* is the resting time in seconds between sampling the remote +clock. -*Sync Samples* is the number of measurements to take for estimating roundtrip -time. +*Sync Samples* is the number of measurements to use for time synchronization. +This includes measuring roundtrip time and estimating clock offsets. -*Sync Sample Interval* is the resting time between roundtrip measurements. +*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 to Physics* ties the network tick loop to the physics process when -enabled. +*Sync Sample Interval* *deprecated in netfox v1.9.0*. Originally used as the +resting time between roundtrip measurements. + +*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 From 5eed981403bf5e86eb9cf0da95406c45754736da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 14:41:37 +0200 Subject: [PATCH 21/31] add deprecate docs --- docs/netfox/guides/network-time.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/netfox/guides/network-time.md b/docs/netfox/guides/network-time.md index ab3f0ca6..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: From 98c41193cbfc531512dbf1feda6d1804797c3260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 14:59:07 +0200 Subject: [PATCH 22/31] restore scene tree pause detection --- addons/netfox/network-time.gd | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index b1c098a5..a0847326 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -335,6 +335,7 @@ 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 @@ -449,22 +450,23 @@ func _loop(): var previous_stretch_factor = _clock_stretch_factor _clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f) - # Detect game pause ( editor only ) - if OS.has_feature("editor"): - var clock_step = _clock.get_time() - _last_process_time - var clock_step_raw = clock_step / previous_stretch_factor - _last_process_time += clock_step - - if clock_step_raw > 1.: + # 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 - _next_tick_time += clock_step + _was_paused = true _logger.debug("Game stalled for %.4fs, assuming it was a pause" % [clock_step_raw]) - else: - _last_process_time = _clock.get_time() + + # Handle pause + if _was_paused: + _was_paused = false + _next_tick_time += clock_step # Run tick loop if needed 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() @@ -490,6 +492,10 @@ func _physics_process(delta): if _active and sync_to_physics: _loop() +func _notification(what): + if what == NOTIFICATION_UNPAUSED: + _was_paused = true + @rpc("any_peer", "reliable", "call_remote") func _submit_sync_success(): var peer_id = multiplayer.get_remote_sender_id() From 1ed15287f161c1270475731eed0663fb0a058f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 15:26:12 +0200 Subject: [PATCH 23/31] restore trace logs --- addons/netfox.internals/logger.gd | 2 +- addons/netfox/network-time-synchronizer.gd | 10 +++++----- project.godot | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) 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/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 6ca350d1..503f9c5e 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -198,7 +198,7 @@ func _discipline_clock(): else: # Nudge clock towards estimated time _clock.adjust(offset / adjust_steps) -# _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) + _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_time()]) _offset = offset * (1. - 1. / adjust_steps) @@ -218,10 +218,10 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): sample.pong_sent = pong_sent sample.pong_received = pong_received -# _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ -# sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, -# sample.get_offset() * 1000., sample.get_rtt() * 1000. -# ]) + _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ + sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, + sample.get_offset() * 1000., sample.get_rtt() * 1000. + ]) # Once a sample is done, remove from in-flight samples and move to sample buffer _awaiting_samples.erase(idx) diff --git a/project.godot b/project.godot index d892db06..18fcf23f 100644 --- a/project.godot +++ b/project.godot @@ -111,6 +111,7 @@ aim_south={ [netfox] general/clear_settings=false +logging/log_level=2 time/tickrate=24 [rendering] From 86881c41e814ecdd50ee7cf5fa0ef77569e825a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 23 Oct 2024 22:45:04 +0200 Subject: [PATCH 24/31] log initial timestamp times --- addons/netfox/network-time-synchronizer.gd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 503f9c5e..cbc361b8 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -206,7 +206,7 @@ func _discipline_clock(): 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()) @rpc("any_peer", "call_remote", "unreliable") @@ -234,9 +234,11 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): @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() From 29116150c8fd8173ae27b25182537697e8ed4d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 30 Oct 2024 17:20:45 +0100 Subject: [PATCH 25/31] more logging --- addons/netfox/network-time-synchronizer.gd | 23 ++++++++++++---------- addons/netfox/time/network-clock-sample.gd | 6 ++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index cbc361b8..faa537d9 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -170,6 +170,10 @@ func _discipline_clock(): return a.get_rtt() < b.get_rtt() ) + _logger.trace("Using sorted samples: \n%s" % [ + "\n".join(sorted_samples.map(func(it): return "\t" + it.to_string())) + ]) + # Calculate rtt bounds var rtt_min = sorted_samples.front().get_rtt() var rtt_max = sorted_samples.back().get_rtt() @@ -177,11 +181,12 @@ func _discipline_clock(): _rtt_jitter = (rtt_max - rtt_min) / 2. # Calculate offset - var offset = 0. + var offset := 0. + var offsets = sorted_samples.map(func(it): return it.get_offset()) var offset_weight = 0. - for i in range(sorted_samples.size()): + for i in range(offsets.size()): var w = pow(2, -i) - offset += sorted_samples[i].get_offset() * w + offset += offsets[i] * w offset_weight += w offset /= offset_weight @@ -197,10 +202,11 @@ func _discipline_clock(): on_panic.emit(offset) else: # Nudge clock towards estimated time - _clock.adjust(offset / adjust_steps) - _logger.trace("Adjusted clock, offset: %sms, new time: %ss" % [offset * 1000., _clock.get_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 * (1. - 1. / adjust_steps) + _offset = offset - nudge @rpc("any_peer", "call_remote", "unreliable") func _send_ping(idx: int): @@ -218,10 +224,7 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): sample.pong_sent = pong_sent sample.pong_received = pong_received - _logger.trace("Received sample: t1=%s; t2=%s; t3=%s; t4=%s; theta=%sms; delta=%sms" % [ - sample.ping_sent, sample.ping_received, sample.pong_sent, sample.pong_received, - sample.get_offset() * 1000., sample.get_rtt() * 1000. - ]) + _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) diff --git a/addons/netfox/time/network-clock-sample.gd b/addons/netfox/time/network-clock-sample.gd index a8e5d551..6f4d5793 100644 --- a/addons/netfox/time/network-clock-sample.gd +++ b/addons/netfox/time/network-clock-sample.gd @@ -13,3 +13,9 @@ 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 + ] From f7a0954d2c9d91705848f3173c8bdce7106c064b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 30 Oct 2024 17:35:05 +0100 Subject: [PATCH 26/31] clamp sync interval --- addons/netfox/netfox.gd | 4 +++- addons/netfox/network-time-synchronizer.gd | 27 ++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index d9833c1c..6e25e1d2 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -32,7 +32,9 @@ var SETTINGS = [ # Time to wait between time syncs "name": "netfox/time/sync_interval", "value": 0.25, - "type": TYPE_FLOAT + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "%s,2,or_greater" % [_NetworkTimeSynchronizer.MIN_SYNC_INTERVAL] }, { "name": "netfox/time/sync_samples", diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index faa537d9..8677c557 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -5,20 +5,29 @@ class_name _NetworkTimeSynchronizer ## ## 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. -## [br] +## 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", 0.25) + 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 use for time synchronization. -## [br] +## [br][br] ## [i]read-only[/i], you can change this in the Netfox project settings var sync_samples: int: get: @@ -31,7 +40,7 @@ var sync_samples: int: ## 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][br] ## [i]read-only[/i], you can change this in the Netfox project settings var adjust_steps: int: get: @@ -43,7 +52,7 @@ var adjust_steps: int: ## ## 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][br] ## [i]read-only[/i], you can change this in the Netfox project settings var panic_threshold: float: get: @@ -55,7 +64,7 @@ var panic_threshold: float: ## ## This value is calculated from multiple samples. The actual roundtrip times ## can be anywhere in the [member rtt] +/- [member rtt_jitter] range. -## [br] +## [br][br] ## [i]read-only[/i] var rtt: float: get: @@ -67,7 +76,7 @@ var rtt: float: ## ## This value is calculated from multiple samples. The actual roundtrip times ## can be anywhere in the [member rtt] +/- [member rtt_jitter] range. -## [br] +## [br][br] ## [i]read-only[/i] var rtt_jitter: float: get: @@ -79,7 +88,7 @@ var rtt_jitter: float: ## ## 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][br] ## [i]read-only[/i] var remote_offset: float: get: From e895c8e25d831e6fe73d123245e31964c9792e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 31 Oct 2024 15:36:03 +0100 Subject: [PATCH 27/31] adjust offset weight function --- addons/netfox/network-time-synchronizer.gd | 5 +++-- project.godot | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 8677c557..3d667724 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -172,8 +172,9 @@ func _loop(): await get_tree().create_timer(sync_interval).timeout func _discipline_clock(): - # Sort samples by latency var sorted_samples := _sample_buffer.slice(0, _sample_buf_size) as Array[NetworkClockSample] + + # Sort samples by latency sorted_samples.sort_custom( func(a: NetworkClockSample, b: NetworkClockSample): return a.get_rtt() < b.get_rtt() @@ -194,7 +195,7 @@ func _discipline_clock(): var offsets = sorted_samples.map(func(it): return it.get_offset()) var offset_weight = 0. for i in range(offsets.size()): - var w = pow(2, -i) + var w = 1. / (1. + sorted_samples[i].get_rtt()) offset += offsets[i] * w offset_weight += w offset /= offset_weight diff --git a/project.godot b/project.godot index 18fcf23f..d892db06 100644 --- a/project.godot +++ b/project.godot @@ -111,7 +111,6 @@ aim_south={ [netfox] general/clear_settings=false -logging/log_level=2 time/tickrate=24 [rendering] From 39fb7e5583c6246e701ba5f32002528f7f980ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 2 Nov 2024 23:38:56 +0100 Subject: [PATCH 28/31] move to ring buffer and add logs --- addons/netfox.internals/ring-buffer.gd | 34 ++++++++++++++++++++++ addons/netfox/network-time-synchronizer.gd | 24 +++++++-------- addons/netfox/network-time.gd | 3 +- 3 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 addons/netfox.internals/ring-buffer.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/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 3d667724..f75c14f9 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -100,10 +100,8 @@ var _active: bool = false static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NetworkTimeSynchronizer") # Samples are stored in a ring buffer -var _sample_buffer: Array[NetworkClockSample] = [] -var _sample_buf_size: int = 0 +var _sample_buffer: _RingBuffer var _sample_idx: int = 0 - var _awaiting_samples: Dictionary = {} var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new() @@ -138,11 +136,8 @@ func start(): if not multiplayer.is_server(): _active = true - - _sample_buffer.clear() - _sample_buffer.resize(sync_samples) - _sample_buf_size = 0 _sample_idx = 0 + _sample_buffer = _RingBuffer.new(sync_samples) _request_timestamp.rpc_id(1) @@ -172,7 +167,7 @@ func _loop(): await get_tree().create_timer(sync_interval).timeout func _discipline_clock(): - var sorted_samples := _sample_buffer.slice(0, _sample_buf_size) as Array[NetworkClockSample] + var sorted_samples := _sample_buffer.get_data() # Sort samples by latency sorted_samples.sort_custom( @@ -181,7 +176,7 @@ func _discipline_clock(): ) _logger.trace("Using sorted samples: \n%s" % [ - "\n".join(sorted_samples.map(func(it): return "\t" + it.to_string())) + "\n".join(sorted_samples.map(func(it: NetworkClockSample): return "\t" + it.to_string() + " (%.4fs)" % [get_time() - it.ping_sent])) ]) # Calculate rtt bounds @@ -198,14 +193,17 @@ func _discipline_clock(): var w = 1. / (1. + sorted_samples[i].get_rtt()) offset += offsets[i] * w offset_weight += w + + _logger.trace("Adding offset %.2fms * %.2f = %.2fms" % [offsets[i] * 1000., w, offsets[i] * 1000. * w]) + + _logger.trace("Normalizing weight %.2fms / %.4f => %.2fms" % [offset * 1000., offset_weight, offset / offset_weight * 1000.]) offset /= offset_weight # Panic / Adjust if abs(offset) > panic_threshold: # Reset clock, throw away all samples _clock.adjust(offset) - _sample_buffer.fill(null) - _sample_buf_size = 0 + _sample_buffer.clear() _offset = 0. _logger.warning("Offset %ss is above panic threshold %ss! Resetting clock" % [offset, panic_threshold]) @@ -238,9 +236,7 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float): # Once a sample is done, remove from in-flight samples and move to sample buffer _awaiting_samples.erase(idx) - - _sample_buffer[_sample_buf_size % _sample_buffer.size()] = sample - _sample_buf_size = mini(_sample_buf_size + 1, _sample_buffer.size()) + _sample_buffer.push(sample) # Discipline clock based on new sample _discipline_clock() diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index a0847326..07079cd3 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -444,7 +444,8 @@ func _loop(): 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 = (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 From 6426fd045b63dd73a1755c543c9f55b5694c4b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 2 Nov 2024 23:47:54 +0100 Subject: [PATCH 29/31] adjust weight function --- addons/netfox/network-time-synchronizer.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index f75c14f9..d2c56aa0 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -190,7 +190,7 @@ func _discipline_clock(): var offsets = sorted_samples.map(func(it): return it.get_offset()) var offset_weight = 0. for i in range(offsets.size()): - var w = 1. / (1. + sorted_samples[i].get_rtt()) + var w = log(1 + sorted_samples[i].get_rtt()) offset += offsets[i] * w offset_weight += w From 243a2ced30c27bf34c303bd4725d50509fb7b298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 3 Nov 2024 00:12:25 +0100 Subject: [PATCH 30/31] drop in-flight samples on panic --- addons/netfox/network-time-synchronizer.gd | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index d2c56aa0..cffbffd1 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -204,6 +204,10 @@ func _discipline_clock(): # 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]) @@ -227,6 +231,10 @@ func _send_ping(idx: int): func _send_pong(idx: int, ping_received: float, pong_sent: 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 From 4cb5cc6885dda42b1d76852f5a2fa4b12fc3518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 8 Nov 2024 13:14:24 +0100 Subject: [PATCH 31/31] fxs --- addons/netfox/network-time-synchronizer.gd | 7 +--- addons/netfox/network-time.gd | 46 +++++++++++----------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index cffbffd1..aaebcfed 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -121,7 +121,7 @@ signal on_initial_sync() ## 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][br] ## Use this event in case you need to react to clock changes in your game. signal on_panic(offset: float) @@ -193,10 +193,7 @@ func _discipline_clock(): var w = log(1 + sorted_samples[i].get_rtt()) offset += offsets[i] * w offset_weight += w - - _logger.trace("Adding offset %.2fms * %.2f = %.2fms" % [offsets[i] * 1000., w, offsets[i] * 1000. * w]) - - _logger.trace("Normalizing weight %.2fms / %.4f => %.2fms" % [offset * 1000., offset_weight, offset / offset_weight * 1000.]) + offset /= offset_weight # Panic / Adjust diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 07079cd3..e7b22ad2 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -91,12 +91,12 @@ var tick: int: ## 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][br] ## This property determines the difference threshold in seconds for ## recalibration. -## [br] +## [br][br] ## [i]read-only[/i], you can change this in the project settings -## [br] +## [br][br] ## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead. var recalibrate_threshold: float: get: @@ -108,9 +108,9 @@ 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][br] ## [i]read-only[/i] -## [br] +## [br][br] ## @deprecated: Will return the same as [member tick]. var remote_tick: int: get: @@ -122,9 +122,9 @@ 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][br] ## [i]read-only[/i] -## [br] +## [br][br] ## @deprecated: Will return the same as [member time]. var remote_time: float: get: @@ -137,9 +137,9 @@ var remote_time: float: ## This value is updated regularly, during server time sync. Latency can be ## estimated as half of the roundtrip time. Returns the same as [member ## _NetworkTimeSynchronizer.rtt]. -## [br] +## [br][br] ## Will always be 0 on servers. -## [br] +## [br][br] ## [i]read-only[/i] var remote_rtt: float: get: @@ -152,14 +152,14 @@ 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][br] ## When hosting, this value is simply the number of ticks since game start. -## [br] +## [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][br] ## [i]read-only[/i] -## [br] +## [br][br] ## @deprecated: Will return the same as [member tick]. var local_tick: int: get: @@ -172,14 +172,14 @@ 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][br] ## When hosting, this value is simply the seconds elapsed since game start. -## [br] +## [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][br] ## [i]read-only[/i] -## [br] +## [br][br] ## @deprecated: Will return the same as [member time]. var local_time: float: get: @@ -247,9 +247,9 @@ var physics_factor: float: ## 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][br] ## Make sure to adjust this value based on the game's needs. -## [br] +## [br][br] ## [i]read-only[/i], you can change this in the project settings var clock_stretch_max: float: get: @@ -297,9 +297,9 @@ var clock_offset: float: ## ## 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][br] ## Returns the same as [member _NetworkTimeSynchronizer.remote_offset]. -## [br] +## [br][br] ## [i]read-only[/i] var remote_clock_offset: float: get: @@ -355,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][br] ## On clients, the initial time sync must complete before any ticks are emitted. -## [br] +## [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():